Private
Public Access
1
0

15 Commits

Author SHA1 Message Date
8304b68a64 first attempt at trying to keep track of locally send id 2025-09-12 12:04:31 -07:00
6261351598 osx: wiring for opening a new window, but not connected to gesture yet
when I add `.tapGesture(count: 2)` to list items, this seems to block
single clicks because SwiftUI sucks. Need to find a better way to invoke
this.
2025-09-11 15:33:56 -07:00
955ff95520 osx: name app "Kordophone" instead of kordophone2 2025-09-11 15:33:31 -07:00
754ad3282d Merge branch 'wip/attachment_mime'
* wip/attachment_mime:
  core: attachment store: limit concurrent downloads
  core: attachment mime: prefer jpg instead of jfif
  wip: attachment MIME
2025-09-10 14:41:36 -07:00
f901077067 osx: some minor fixes 2025-09-10 14:41:24 -07:00
778d4b6650 core: attachment store: limit concurrent downloads 2025-09-10 14:23:02 -07:00
e8256a9e57 core: attachment mime: prefer jpg instead of jfif 2025-09-10 14:06:54 -07:00
4e8b161d26 wip: attachment MIME 2025-09-10 13:48:27 -07:00
74d1a7f54b osx: try badging icon for unread 2025-09-09 18:54:14 -07:00
4b497aaabc osx: linkify text, enable selection 2025-09-09 15:45:50 -07:00
6caf008a39 osx: update kordophoned binary 2025-09-09 13:40:43 -07:00
d20afef370 kpcli: updates: print error on error 2025-09-09 13:36:35 -07:00
357be5cdf4 core: HTTPClient: update socket should just automatically retry on subsqeuent auth success 2025-09-09 13:33:13 -07:00
4db28222a6 core: HTTPClient: event stream should just automatically retry after auth token 2025-09-09 13:30:53 -07:00
469fd8fa13 gtk: add Makefile for making rpm 2025-09-07 18:32:57 -07:00
22 changed files with 650 additions and 122 deletions

23
core/Cargo.lock generated
View File

@@ -1201,6 +1201,7 @@ dependencies = [
"kordophone", "kordophone",
"kordophone-db", "kordophone-db",
"log", "log",
"mime_guess",
"once_cell", "once_cell",
"serde", "serde",
"serde_json", "serde_json",
@@ -1345,6 +1346,22 @@ dependencies = [
"quote", "quote",
] ]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.7.2" version = "0.7.2"
@@ -2350,6 +2367,12 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.12" version = "1.0.12"

View File

@@ -0,0 +1,3 @@
-- Drop the alias mapping table
DROP TABLE IF EXISTS `message_aliases`;

View File

@@ -0,0 +1,7 @@
-- Add table to map local (client) IDs to server message GUIDs
CREATE TABLE IF NOT EXISTS `message_aliases` (
`local_id` TEXT NOT NULL PRIMARY KEY,
`server_id` TEXT NOT NULL UNIQUE,
`conversation_id` TEXT NOT NULL
);

View File

@@ -307,8 +307,11 @@ impl<'a> Repository<'a> {
} }
pub fn delete_all_messages(&mut self) -> Result<()> { pub fn delete_all_messages(&mut self) -> Result<()> {
use crate::schema::messages::dsl::*; use crate::schema::messages::dsl as messages_dsl;
diesel::delete(messages).execute(self.connection)?; use crate::schema::message_aliases::dsl as aliases_dsl;
diesel::delete(messages_dsl::messages).execute(self.connection)?;
diesel::delete(aliases_dsl::message_aliases).execute(self.connection)?;
Ok(()) Ok(())
} }
@@ -359,6 +362,57 @@ impl<'a> Repository<'a> {
) )
} }
/// Create or update an alias mapping between a local (client) message id and a server message id.
pub fn set_message_alias(
&mut self,
local_id_in: &str,
server_id_in: &str,
conversation_id_in: &str,
) -> Result<()> {
use crate::schema::message_aliases::dsl::*;
diesel::replace_into(message_aliases)
.values((
local_id.eq(local_id_in),
server_id.eq(server_id_in),
conversation_id.eq(conversation_id_in),
))
.execute(self.connection)?;
Ok(())
}
/// Returns the local id for a given server id, if any.
pub fn get_local_id_for(&mut self, server_id_in: &str) -> Result<Option<String>> {
use crate::schema::message_aliases::dsl::*;
let result = message_aliases
.filter(server_id.eq(server_id_in))
.select(local_id)
.first::<String>(self.connection)
.optional()?;
Ok(result)
}
/// Batch lookup: returns a map server_id -> local_id for the provided server ids.
pub fn get_local_ids_for(
&mut self,
server_ids_in: Vec<String>,
) -> Result<HashMap<String, String>> {
use crate::schema::message_aliases::dsl::*;
if server_ids_in.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(String, String)> = message_aliases
.filter(server_id.eq_any(&server_ids_in))
.select((server_id, local_id))
.load::<(String, String)>(self.connection)?;
let mut map = HashMap::new();
for (sid, lid) in rows {
map.insert(sid, lid);
}
Ok(map)
}
/// Update the contact_id for an existing participant record. /// Update the contact_id for an existing participant record.
pub fn update_participant_contact( pub fn update_participant_contact(
&mut self, &mut self,

View File

@@ -44,6 +44,14 @@ diesel::table! {
} }
} }
diesel::table! {
message_aliases (local_id) {
local_id -> Text,
server_id -> Text,
conversation_id -> Text,
}
}
diesel::table! { diesel::table! {
settings (key) { settings (key) {
key -> Text, key -> Text,
@@ -62,5 +70,6 @@ diesel::allow_tables_to_appear_in_same_query!(
conversation_participants, conversation_participants,
messages, messages,
conversation_messages, conversation_messages,
message_aliases,
settings, settings,
); );

View File

@@ -397,6 +397,7 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
let uri = self let uri = self
.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?; .uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
loop {
log::debug!("Connecting to websocket: {:?}", uri); log::debug!("Connecting to websocket: {:?}", uri);
let auth = self.auth_store.get_token().await; let auth = self.auth_store.get_token().await;
@@ -425,10 +426,11 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
log::debug!("Websocket request: {:?}", request); log::debug!("Websocket request: {:?}", request);
let mut should_retry = true; // retry once after authenticating.
match connect_async(request).await.map_err(Error::from) { match connect_async(request).await.map_err(Error::from) {
Ok((socket, response)) => { Ok((socket, response)) => {
log::debug!("Websocket connected: {:?}", response.status()); log::debug!("Websocket connected: {:?}", response.status());
Ok(WebsocketEventSocket::new(socket)) break Ok(WebsocketEventSocket::new(socket))
} }
Err(e) => match &e { Err(e) => match &e {
Error::ClientError(ce) => match ce.as_str() { Error::ClientError(ce) => match ce.as_str() {
@@ -439,23 +441,28 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
let new_token = self.authenticate(credentials.clone()).await?; let new_token = self.authenticate(credentials.clone()).await?;
self.auth_store.set_token(new_token.to_string()).await; self.auth_store.set_token(new_token.to_string()).await;
if should_retry {
// try again on the next attempt. // try again on the next attempt.
return Err(Error::Unauthorized); continue;
} else {
break Err(e);
}
} else { } else {
log::error!("Websocket unauthorized, no credentials provided"); log::error!("Websocket unauthorized, no credentials provided");
return Err(Error::ClientError( break Err(Error::ClientError(
"Unauthorized, no credentials provided".into(), "Unauthorized, no credentials provided".into(),
)); ));
} }
} }
_ => Err(e), _ => break Err(e),
}, },
_ => Err(e), _ => break Err(e),
}, },
} }
} }
} }
}
impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> { impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient<K> { pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient<K> {
@@ -618,6 +625,27 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
Ok(response) Ok(response)
} }
// Fetch an attachment while preserving response headers (e.g., Content-Type).
// Returns the streaming body and the Content-Type header if present.
pub async fn fetch_attachment_with_metadata(
&mut self,
guid: &str,
preview: bool,
) -> Result<(ResponseStream, Option<String>), Error> {
let endpoint = format!("attachment?guid={}&preview={}", guid, preview);
let response = self
.response_with_body_retry(&endpoint, Method::GET, Body::empty, true)
.await?;
let content_type = response
.headers()
.get(hyper::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
Ok((ResponseStream::from(response.into_body()), content_type))
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -22,6 +22,7 @@ tokio = { version = "1", features = ["full"] }
tokio-condvar = "0.3.0" tokio-condvar = "0.3.0"
uuid = "1.16.0" uuid = "1.16.0"
once_cell = "1.19.0" once_cell = "1.19.0"
mime_guess = "2.0"
# D-Bus dependencies only on Linux # D-Bus dependencies only on Linux
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
@@ -49,4 +50,3 @@ assets = [
{ source = "../target/release/kpcli", dest = "/usr/bin/kpcli", mode = "755" }, { source = "../target/release/kpcli", dest = "/usr/bin/kpcli", mode = "755" },
{ source = "include/net.buzzert.kordophonecd.service", dest = "/usr/share/dbus-1/services/net.buzzert.kordophonecd.service", mode = "644" }, { source = "include/net.buzzert.kordophonecd.service", dest = "/usr/share/dbus-1/services/net.buzzert.kordophonecd.service", mode = "644" },
] ]

View File

@@ -17,7 +17,7 @@ use crate::daemon::Daemon;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::Mutex; use tokio::sync::{Mutex, Semaphore};
use uuid::Uuid; use uuid::Uuid;
@@ -63,6 +63,9 @@ pub struct AttachmentStore {
event_source: Receiver<AttachmentStoreEvent>, event_source: Receiver<AttachmentStoreEvent>,
event_sink: Option<Sender<AttachmentStoreEvent>>, event_sink: Option<Sender<AttachmentStoreEvent>>,
// Limits concurrent downloads to avoid overloading server and local I/O
download_limit: Arc<Semaphore>,
} }
impl AttachmentStore { impl AttachmentStore {
@@ -84,12 +87,16 @@ impl AttachmentStore {
let (event_sink, event_source) = tokio::sync::mpsc::channel(100); let (event_sink, event_source) = tokio::sync::mpsc::channel(100);
// Limit to at most 5 concurrent downloads by default
let download_limit = Arc::new(Semaphore::new(5));
AttachmentStore { AttachmentStore {
store_path: store_path, store_path: store_path,
database: database, database: database,
daemon_event_sink: daemon_event_sink, daemon_event_sink: daemon_event_sink,
event_source: event_source, event_source: event_source,
event_sink: Some(event_sink), event_sink: Some(event_sink),
download_limit,
} }
} }
@@ -103,12 +110,50 @@ impl AttachmentStore {
pub fn get_attachment_impl(store_path: &PathBuf, guid: &String) -> Attachment { pub fn get_attachment_impl(store_path: &PathBuf, guid: &String) -> Attachment {
let base_path = store_path.join(guid); let base_path = store_path.join(guid);
Attachment { let mut attachment = Attachment {
guid: guid.to_owned(), guid: guid.to_owned(),
base_path: base_path, base_path: base_path,
metadata: None, metadata: None,
mime_type: None,
};
// Best-effort: if a file already exists, try to infer MIME type from extension
let kind = "full";
let stem = attachment
.base_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let legacy = attachment.base_path.with_extension(kind);
let existing_path = if legacy.exists() {
Some(legacy)
} else {
let prefix = format!("{}.{}.", stem, kind);
let parent = attachment
.base_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let mut found: Option<PathBuf> = None;
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(&prefix) && !name.ends_with(".download") {
found = Some(entry.path());
break;
} }
} }
}
found
};
if let Some(existing) = existing_path {
if let Some(m) = mime_guess::from_path(&existing).first_raw() {
attachment.mime_type = Some(m.to_string());
}
}
attachment
}
async fn download_attachment_impl( async fn download_attachment_impl(
store_path: &PathBuf, store_path: &PathBuf,
@@ -124,22 +169,45 @@ impl AttachmentStore {
return Err(AttachmentStoreError::AttachmentAlreadyDownloaded.into()); return Err(AttachmentStoreError::AttachmentAlreadyDownloaded.into());
} }
let temporary_path = attachment.get_path_for_preview_scratch(preview, true); // Check if any in-progress temporary file exists already for this attachment
if std::fs::exists(&temporary_path).unwrap_or(false) { if let Some(in_progress) = Self::find_in_progress_download(&attachment, preview) {
log::warn!(target: target::ATTACHMENTS, "Temporary file already exists: {}, assuming download is in progress", temporary_path.display()); log::warn!(target: target::ATTACHMENTS, "Temporary file already exists: {}, assuming download is in progress", in_progress.display());
// Treat as a non-fatal condition so we don't spam errors.
return Err(AttachmentStoreError::DownloadAlreadyInProgress.into()); return Err(AttachmentStoreError::DownloadAlreadyInProgress.into());
} }
log::debug!(target: target::ATTACHMENTS, "Starting download for attachment: {}", attachment.guid); log::debug!(target: target::ATTACHMENTS, "Starting download for attachment: {}", attachment.guid);
let file = std::fs::File::create(&temporary_path)?;
let mut writer = BufWriter::new(&file);
let mut client = Daemon::get_client_impl(database).await?; let mut client = Daemon::get_client_impl(database).await?;
let mut stream = client let (mut stream, content_type) = client
.fetch_attachment_data(&attachment.guid, preview) .fetch_attachment_with_metadata(&attachment.guid, preview)
.await .await
.map_err(|e| AttachmentStoreError::APIClientError(format!("{:?}", e)))?; .map_err(|e| AttachmentStoreError::APIClientError(format!("{:?}", e)))?;
let kind = if preview { "preview" } else { "full" };
let normalized_mime = content_type
.as_deref()
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string());
let guessed_ext = normalized_mime
.as_deref()
.and_then(|m| Attachment::preferred_extension_for_mime(m))
.unwrap_or("bin");
let final_path = attachment
.base_path
.with_extension(format!("{}.{}", kind, guessed_ext));
let temporary_path = final_path.with_extension(format!(
"{}.download",
final_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
));
let file = std::fs::File::create(&temporary_path)?;
let mut writer = BufWriter::new(&file);
log::trace!(target: target::ATTACHMENTS, "Writing attachment {:?} data to temporary file {:?}", &attachment.guid, &temporary_path); log::trace!(target: target::ATTACHMENTS, "Writing attachment {:?} data to temporary file {:?}", &attachment.guid, &temporary_path);
while let Some(Ok(data)) = stream.next().await { while let Some(Ok(data)) = stream.next().await {
writer.write(data.as_ref())?; writer.write(data.as_ref())?;
@@ -150,10 +218,7 @@ impl AttachmentStore {
file.sync_all()?; file.sync_all()?;
// Atomically move the temporary file to the final location // Atomically move the temporary file to the final location
std::fs::rename( std::fs::rename(&temporary_path, &final_path)?;
&temporary_path,
&attachment.get_path_for_preview_scratch(preview, false),
)?;
log::debug!(target: target::ATTACHMENTS, "Completed download for attachment: {}", attachment.guid); log::debug!(target: target::ATTACHMENTS, "Completed download for attachment: {}", attachment.guid);
@@ -164,6 +229,41 @@ impl AttachmentStore {
Ok(()) Ok(())
} }
fn find_in_progress_download(attachment: &Attachment, preview: bool) -> Option<PathBuf> {
let kind = if preview { "preview" } else { "full" };
// Legacy temp path: guid.<kind>.download
let legacy = attachment
.base_path
.with_extension(format!("{}.download", kind));
if legacy.exists() {
return Some(legacy);
}
// Scan for any guid.<kind>.<ext>.download
let parent = attachment
.base_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let stem = attachment
.base_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let prefix = format!("{}.{}.", stem, kind);
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(&prefix) && name.ends_with(".download") {
return Some(entry.path());
}
}
}
None
}
async fn upload_attachment_impl( async fn upload_attachment_impl(
store_path: &PathBuf, store_path: &PathBuf,
incoming_path: &PathBuf, incoming_path: &PathBuf,
@@ -222,9 +322,12 @@ impl AttachmentStore {
let mut database = self.database.clone(); let mut database = self.database.clone();
let daemon_event_sink = self.daemon_event_sink.clone(); let daemon_event_sink = self.daemon_event_sink.clone();
let _guid = guid.clone(); let _guid = guid.clone();
let limiter = self.download_limit.clone();
// Spawn a new task here so we don't block incoming queue events. // Spawn a new task here so we don't block incoming queue events.
tokio::spawn(async move { tokio::spawn(async move {
// Acquire a slot in the concurrent download limiter.
let _permit = limiter.acquire_owned().await.expect("Semaphore closed");
let result = Self::download_attachment_impl( let result = Self::download_attachment_impl(
&store_path, &store_path,
&mut database, &mut database,
@@ -234,8 +337,24 @@ impl AttachmentStore {
).await; ).await;
if let Err(e) = result { if let Err(e) = result {
// Downgrade noise for expected cases
if let Some(kind) = e.downcast_ref::<AttachmentStoreError>() {
match kind {
AttachmentStoreError::AttachmentAlreadyDownloaded => {
log::debug!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", &_guid);
}
AttachmentStoreError::DownloadAlreadyInProgress => {
// Already logged a warning where detected
log::debug!(target: target::ATTACHMENTS, "Download already in progress: {}", &_guid);
}
_ => {
log::error!(target: target::ATTACHMENTS, "Error downloading attachment {}: {}", &_guid, kind);
}
}
} else {
log::error!(target: target::ATTACHMENTS, "Error downloading attachment {}: {}", &_guid, e); log::error!(target: target::ATTACHMENTS, "Error downloading attachment {}: {}", &_guid, e);
} }
}
}); });
log::debug!(target: target::ATTACHMENTS, "Queued download for attachment: {}", &guid); log::debug!(target: target::ATTACHMENTS, "Queued download for attachment: {}", &guid);

View File

@@ -347,7 +347,16 @@ impl Daemon {
self.database self.database
.lock() .lock()
.await .await
.with_repository(|r| r.insert_message(&conversation_id, message.into())) .with_repository(|r| {
// 1) Insert the server message
r.insert_message(&conversation_id, message.clone().into())?;
// 2) Persist alias local -> server for stable UI ids
r.set_message_alias(
&outgoing_message.guid.to_string(),
&message.id,
&conversation_id,
)
})
.await .await
.unwrap(); .unwrap();
@@ -448,18 +457,38 @@ impl Daemon {
.get(&conversation_id) .get(&conversation_id)
.unwrap_or(&empty_vec); .unwrap_or(&empty_vec);
self.database // Fetch DB messages and an alias map (server_id -> local_id) in one DB access.
let (db_messages, alias_map) = self
.database
.lock() .lock()
.await .await
.with_repository(|r| { .with_repository(|r| {
r.get_messages_for_conversation(&conversation_id) let msgs = r.get_messages_for_conversation(&conversation_id).unwrap();
.unwrap() let ids: Vec<String> = msgs.iter().map(|m| m.id.clone()).collect();
.into_iter() let map = r.get_local_ids_for(ids).unwrap_or_default();
.map(|m| m.into()) // Convert db::Message to daemon::Message (msgs, map)
.chain(outgoing_messages.into_iter().map(|m| m.into()))
.collect()
}) })
.await .await;
// Convert DB messages to daemon model, substituting local_id when an alias exists.
let mut result: Vec<Message> = Vec::with_capacity(
db_messages.len() + outgoing_messages.len(),
);
for m in db_messages.into_iter() {
let server_id = m.id.clone();
let mut dm: Message = m.into();
if let Some(local_id) = alias_map.get(&server_id) {
dm.id = local_id.clone();
}
result.push(dm);
}
// Append pending outgoing messages (these already use local_id)
for om in outgoing_messages.iter() {
result.push(om.into());
}
result
} }
async fn enqueue_outgoing_message( async fn enqueue_outgoing_message(

View File

@@ -1,4 +1,4 @@
use std::path::PathBuf; use std::path::{Path, PathBuf};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AttachmentMetadata { pub struct AttachmentMetadata {
@@ -16,9 +16,26 @@ pub struct Attachment {
pub guid: String, pub guid: String,
pub base_path: PathBuf, pub base_path: PathBuf,
pub metadata: Option<AttachmentMetadata>, pub metadata: Option<AttachmentMetadata>,
pub mime_type: Option<String>,
} }
impl Attachment { impl Attachment {
pub(crate) fn preferred_extension_for_mime(mime: &str) -> Option<&'static str> {
let normalized = mime.split(';').next().unwrap_or(mime).trim();
// Prefer common, user-friendly extensions over obscure ones
match normalized {
"image/jpeg" | "image/pjpeg" => Some("jpg"),
_ => mime_guess::get_mime_extensions_str(normalized)
.and_then(|list| {
// If jpg is one of the candidates, prefer it
if list.iter().any(|e| *e == "jpg") {
Some("jpg")
} else {
list.first().copied()
}
}),
}
}
pub fn get_path(&self) -> PathBuf { pub fn get_path(&self) -> PathBuf {
self.get_path_for_preview_scratch(false, false) self.get_path_for_preview_scratch(false, false)
} }
@@ -28,12 +45,34 @@ impl Attachment {
} }
pub fn get_path_for_preview_scratch(&self, preview: bool, scratch: bool) -> PathBuf { pub fn get_path_for_preview_scratch(&self, preview: bool, scratch: bool) -> PathBuf {
let extension = if preview { "preview" } else { "full" }; // Determine whether this is a preview or full attachment.
let kind = if preview { "preview" } else { "full" };
// If not a scratch path, and a file already exists on disk with a concrete
// file extension (e.g., guid.full.jpg), return that existing path.
if !scratch {
if let Some(existing) = self.find_existing_path(preview) {
return existing;
}
}
// Fall back to constructing a path using known info. If we know the MIME type,
// prefer an extension guessed from it; otherwise keep legacy naming.
let ext_from_mime = self
.mime_type
.as_ref()
.and_then(|m| Self::preferred_extension_for_mime(m));
let base_ext = match ext_from_mime {
Some(ext) => format!("{}.{}", kind, ext),
None => kind.to_string(),
};
if scratch { if scratch {
self.base_path self.base_path
.with_extension(format!("{}.download", extension)) .with_extension(format!("{}.download", base_ext))
} else { } else {
self.base_path.with_extension(extension) self.base_path.with_extension(base_ext)
} }
} }
@@ -46,6 +85,36 @@ impl Attachment {
.as_str(), .as_str(),
) )
} }
fn find_existing_path(&self, preview: bool) -> Option<PathBuf> {
let kind = if preview { "preview" } else { "full" };
// First, check legacy path without a concrete file extension.
let legacy = self.base_path.with_extension(kind);
if legacy.exists() {
return Some(legacy);
}
// Next, search for a filename like: <guid>.<kind>.<ext>
let file_stem = self
.base_path
.file_name()
.map(|s| s.to_string_lossy().to_string())?;
let prefix = format!("{}.{}.", file_stem, kind);
let parent = self.base_path.parent().unwrap_or_else(|| Path::new("."));
if let Ok(dir) = std::fs::read_dir(parent) {
for entry in dir.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name.starts_with(&prefix) && !name.ends_with(".download") {
return Some(entry.path());
}
}
}
None
}
} }
impl From<kordophone::model::message::AttachmentMetadata> for AttachmentMetadata { impl From<kordophone::model::message::AttachmentMetadata> for AttachmentMetadata {

View File

@@ -143,7 +143,10 @@ impl ClientCli {
println!("Listening for raw updates..."); println!("Listening for raw updates...");
let mut stream = socket.raw_updates().await; let mut stream = socket.raw_updates().await;
while let Some(Ok(update)) = stream.next().await {
loop {
match stream.next().await.unwrap() {
Ok(update) => {
match update { match update {
SocketUpdate::Update(updates) => { SocketUpdate::Update(updates) => {
for update in updates { for update in updates {
@@ -154,6 +157,13 @@ impl ClientCli {
println!("Pong"); println!("Pong");
} }
} }
},
Err(e) => {
println!("Update error: {:?}", e);
break;
}
}
} }
Ok(()) Ok(())

14
gtk/Makefile Normal file
View File

@@ -0,0 +1,14 @@
all: setup
ninja -C build
setup: build/
meson build
VER := 1.0.2
TMP := $(shell mktemp -d)
rpm:
git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(TMP)/v$(VER).tar.gz HEAD
rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)"

View File

@@ -1,11 +1,11 @@
Name: kordophone Name: kordophone
Version: 1.0.1 Version: 1.0.2
Release: 1%{?dist} Release: 1%{?dist}
Summary: GTK4/Libadwaita client for Kordophone Summary: GTK4/Libadwaita client for Kordophone
License: GPL License: GPL
URL: https://code.buzzert.dev/buzzert/Kordophone URL: https://code.buzzert.dev/buzzert/Kordophone
Source0: https://code.buzzert.dev/buzzert/Kordophone/archive/master.tar.gz Source0: %{url}/archive/v%{version}.tar.gz
BuildRequires: meson >= 0.56.0 BuildRequires: meson >= 0.56.0
BuildRequires: vala BuildRequires: vala

View File

@@ -32,29 +32,29 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
CD41F5972E5B8E7300E0027B /* kordophone2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = kordophone2.app; sourceTree = BUILT_PRODUCTS_DIR; }; CD41F5972E5B8E7300E0027B /* Kordophone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Kordophone.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */ = { CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Daemon/kordophoned, Daemon/kordophoned,
Daemon/net.buzzert.kordophonecd.plist, Daemon/net.buzzert.kordophonecd.plist,
); );
target = CD41F5962E5B8E7300E0027B /* kordophone2 */; target = CD41F5962E5B8E7300E0027B /* Kordophone */;
}; };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */ = { CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */ = {
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
buildPhase = CD41F5D92E6284FD00E0027B /* CopyFiles */; buildPhase = CD41F5D92E6284FD00E0027B /* CopyFiles */;
membershipExceptions = ( membershipExceptions = (
Daemon/net.buzzert.kordophonecd.plist, Daemon/net.buzzert.kordophonecd.plist,
); );
}; };
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */ = { CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */ = {
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
attributesByRelativePath = { attributesByRelativePath = {
Daemon/kordophoned = (CodeSignOnCopy, ); Daemon/kordophoned = (CodeSignOnCopy, );
@@ -70,9 +70,9 @@
CD41F5992E5B8E7300E0027B /* kordophone2 */ = { CD41F5992E5B8E7300E0027B /* kordophone2 */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */, CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */,
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */, CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */, CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
); );
path = kordophone2; path = kordophone2;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -102,7 +102,7 @@
CD41F5982E5B8E7300E0027B /* Products */ = { CD41F5982E5B8E7300E0027B /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CD41F5972E5B8E7300E0027B /* kordophone2.app */, CD41F5972E5B8E7300E0027B /* Kordophone.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -110,9 +110,9 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
CD41F5962E5B8E7300E0027B /* kordophone2 */ = { CD41F5962E5B8E7300E0027B /* Kordophone */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */; buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */;
buildPhases = ( buildPhases = (
CD41F5932E5B8E7300E0027B /* Sources */, CD41F5932E5B8E7300E0027B /* Sources */,
CD41F5942E5B8E7300E0027B /* Frameworks */, CD41F5942E5B8E7300E0027B /* Frameworks */,
@@ -127,12 +127,12 @@
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
CD41F5992E5B8E7300E0027B /* kordophone2 */, CD41F5992E5B8E7300E0027B /* kordophone2 */,
); );
name = kordophone2; name = Kordophone;
packageProductDependencies = ( packageProductDependencies = (
CD41F5D22E62431D00E0027B /* KeychainAccess */, CD41F5D22E62431D00E0027B /* KeychainAccess */,
); );
productName = kordophone2; productName = kordophone2;
productReference = CD41F5972E5B8E7300E0027B /* kordophone2.app */; productReference = CD41F5972E5B8E7300E0027B /* Kordophone.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
@@ -167,7 +167,7 @@
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
CD41F5962E5B8E7300E0027B /* kordophone2 */, CD41F5962E5B8E7300E0027B /* Kordophone */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -322,7 +322,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = DQQH5H6GBD; DEVELOPMENT_TEAM = 3SJALV9BQ7;
ENABLE_HARDENED_RUNTIME = NO; ENABLE_HARDENED_RUNTIME = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -349,7 +349,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = DQQH5H6GBD; DEVELOPMENT_TEAM = 3SJALV9BQ7;
ENABLE_HARDENED_RUNTIME = NO; ENABLE_HARDENED_RUNTIME = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -379,7 +379,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */ = { CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
CD41F5A42E5B8E7400E0027B /* Debug */, CD41F5A42E5B8E7400E0027B /* Debug */,

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
BuildableName = "Kordophone.app"
BlueprintName = "Kordophone"
ReferencedContainer = "container:kordophone2.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
BuildableName = "Kordophone.app"
BlueprintName = "Kordophone"
ReferencedContainer = "container:kordophone2.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
BuildableName = "Kordophone.app"
BlueprintName = "Kordophone"
ReferencedContainer = "container:kordophone2.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -14,6 +14,13 @@ struct KordophoneApp: App
WindowGroup { WindowGroup {
SplitView() SplitView()
} }
.commands {
TextEditingCommands()
}
WindowGroup(id: .transcriptWindow, for: Display.Conversation.self) { selectedConversation in
TranscriptWindowView(conversation: selectedConversation)
}
Settings { Settings {
PreferencesView() PreferencesView()
@@ -25,3 +32,42 @@ struct KordophoneApp: App
print("Error: \(e.localizedDescription)") print("Error: \(e.localizedDescription)")
} }
} }
struct TranscriptWindowView: View
{
@State private var transcriptViewModel = TranscriptView.ViewModel()
@State private var entryViewModel = MessageEntryView.ViewModel()
private let displayedConversation: Binding<Display.Conversation?>
public init(conversation: Binding<Display.Conversation?>) {
self.displayedConversation = conversation
transcriptViewModel.displayedConversation = conversation.wrappedValue
observeDisplayedConversationChanges()
}
private func observeDisplayedConversationChanges() {
withObservationTracking {
_ = displayedConversation.wrappedValue
} onChange: {
Task { @MainActor in
guard let displayedConversation = self.displayedConversation.wrappedValue else { return }
transcriptViewModel.displayedConversation = displayedConversation
observeDisplayedConversationChanges()
}
}
}
var body: some View {
VStack {
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
.navigationTitle(displayedConversation.wrappedValue?.displayName ?? "Kordophone")
.selectedConversation(displayedConversation.wrappedValue)
}
}
}
extension String
{
static let transcriptWindow = "TranscriptWindow"
}

View File

@@ -11,9 +11,10 @@ struct ConversationListView: View
{ {
@Binding var model: ViewModel @Binding var model: ViewModel
@Environment(\.xpcClient) private var xpcClient @Environment(\.xpcClient) private var xpcClient
@Environment(\.openWindow) private var openWindow
var body: some View { var body: some View {
List($model.conversations, selection: $model.selectedConversations) { conv in List($model.conversations, selection: $model.selectedConversation) { conv in
let isUnread = conv.wrappedValue.unreadCount > 0 let isUnread = conv.wrappedValue.unreadCount > 0
HStack(spacing: 0.0) { HStack(spacing: 0.0) {
@@ -64,14 +65,14 @@ struct ConversationListView: View
class ViewModel class ViewModel
{ {
var conversations: [Display.Conversation] var conversations: [Display.Conversation]
var selectedConversations: Set<Display.Conversation.ID> var selectedConversation: Display.Conversation.ID?
private var needsReload: Bool = true private var needsReload: Bool = true
private let client = XPCClient() private let client = XPCClient()
public init(conversations: [Display.Conversation] = []) { public init(conversations: [Display.Conversation] = []) {
self.conversations = conversations self.conversations = conversations
self.selectedConversations = Set() self.selectedConversation = nil
setNeedsReload() setNeedsReload()
} }
@@ -101,6 +102,11 @@ struct ConversationListView: View
.map { Display.Conversation(from: $0) } .map { Display.Conversation(from: $0) }
self.conversations = clientConversations self.conversations = clientConversations
let unreadConversations = clientConversations.filter(\.isUnread)
await MainActor.run {
NSApplication.shared.dockTile.badgeLabel = unreadConversations.isEmpty ? nil : "\(unreadConversations.count)"
}
} catch { } catch {
print("Error reloading conversations: \(error)") print("Error reloading conversations: \(error)")
} }

Binary file not shown.

View File

@@ -36,6 +36,7 @@ struct MessageEntryView: View
.font(.body) .font(.body)
.scrollDisabled(true) .scrollDisabled(true)
.disabled(selectedConversation == nil) .disabled(selectedConversation == nil)
.id("messageEntry")
} }
.padding(8.0) .padding(8.0)
.background { .background {

View File

@@ -10,7 +10,7 @@ import XPC
enum Display enum Display
{ {
struct Conversation: Identifiable, Hashable struct Conversation: Identifiable, Hashable, Codable
{ {
let id: String let id: String
let name: String? let name: String?
@@ -27,6 +27,10 @@ enum Display
participants.count > 1 participants.count > 1
} }
var isUnread: Bool {
unreadCount > 0
}
init(from c: Serialized.Conversation) { init(from c: Serialized.Conversation) {
self.id = c.guid self.id = c.guid
self.name = c.displayName self.name = c.displayName

View File

@@ -15,7 +15,7 @@ struct SplitView: View
private let xpcClient = XPCClient() private let xpcClient = XPCClient()
private var selectedConversation: Display.Conversation? { private var selectedConversation: Display.Conversation? {
guard let id = conversationListModel.selectedConversations.first else { return nil } guard let id = conversationListModel.selectedConversation else { return nil }
return conversationListModel.conversations.first { $0.id == id } return conversationListModel.conversations.first { $0.id == id }
} }
@@ -28,10 +28,10 @@ struct SplitView: View
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel) ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
.xpcClient(xpcClient) .xpcClient(xpcClient)
.selectedConversation(selectedConversation) .selectedConversation(selectedConversation)
.navigationTitle("Kordophone") .navigationTitle(selectedConversation?.displayName ?? "Kordophone")
.navigationSubtitle(selectedConversation?.displayName ?? "") .navigationSubtitle(selectedConversation?.participants.joined(separator: ", ") ?? "")
.onChange(of: conversationListModel.selectedConversations) { oldValue, newValue in .onChange(of: conversationListModel.selectedConversation) { oldValue, newValue in
transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue.first } transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue }
} }
} }
} }

View File

@@ -67,7 +67,7 @@ struct TextBubbleItemView: View
BubbleView(sender: sender, date: date) { BubbleView(sender: sender, date: date) {
HStack { HStack {
Text(text) Text(text.linkifiedAttributedString())
.foregroundStyle(textColor) .foregroundStyle(textColor)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
} }
@@ -75,6 +75,7 @@ struct TextBubbleItemView: View
.padding(.horizontal, 16.0) .padding(.horizontal, 16.0)
.padding(.vertical, 10.0) .padding(.vertical, 10.0)
.background(bubbleColor) .background(bubbleColor)
.textSelection(.enabled)
} }
} }
} }
@@ -219,14 +220,16 @@ struct SenderAttributionView: View
} }
} }
fileprivate extension CGFloat { fileprivate extension CGFloat
{
static let dominantCornerRadius = 16.0 static let dominantCornerRadius = 16.0
static let minorCornerRadius = 4.0 static let minorCornerRadius = 4.0
static let minimumBubbleHorizontalPadding = 80.0 static let minimumBubbleHorizontalPadding = 80.0
static let imageMaxWidth = 380.0 static let imageMaxWidth = 380.0
} }
fileprivate extension CGSize { fileprivate extension CGSize
{
var aspectRatio: CGFloat { width / height } var aspectRatio: CGFloat { width / height }
} }
@@ -239,3 +242,28 @@ fileprivate func preferredBubbleWidth(forAttachmentSize attachmentSize: CGSize?,
return 200.0 // fallback return 200.0 // fallback
} }
} }
fileprivate extension String
{
func linkifiedAttributedString() -> AttributedString {
var attributed = AttributedString(self)
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
return attributed
}
let nsText = self as NSString
let fullRange = NSRange(location: 0, length: nsText.length)
detector.enumerateMatches(in: self, options: [], range: fullRange) { result, _, _ in
guard let result, let url = result.url,
let swiftRange = Range(result.range, in: self),
let start = AttributedString.Index(swiftRange.lowerBound, within: attributed),
let end = AttributedString.Index(swiftRange.upperBound, within: attributed) else { return }
attributed[start..<end].link = url
attributed[start..<end].foregroundColor = NSColor.textColor
attributed[start..<end].underlineStyle = .single
}
return attributed
}
}