Private
Public Access
1
0

20 Commits

Author SHA1 Message Date
8d9251bfe2 first pass at platform agnostic notifications 2025-12-14 17:09:41 -08:00
0cfa5e05d4 Move notificationservice to separate component 2025-11-01 23:41:47 -07:00
717138b371 first attempt: notification code is in dbus::agent 2025-11-01 21:39:53 -07:00
e650cffde7 osx: mark as read when hovering over window 2025-09-25 00:20:19 -07:00
cbd9dccf1a README: trim 2025-09-12 18:26:57 -07:00
1a5f13f2b8 osx: implements quicklook 2025-09-12 18:17:58 -07:00
87e986707d osx: update kpd 2025-09-12 16:07:31 -07:00
b5ba0b1f7a Merge branch 'wip/local_ids'
* wip/local_ids:
  first attempt at trying to keep track of locally send id
2025-09-12 15:58:50 -07:00
bc51bf03a1 osx: better scroll view management 2025-09-12 15:58:34 -07:00
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
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
38 changed files with 2198 additions and 395 deletions

View File

@@ -64,16 +64,3 @@ Below are brief notes. Each subprojects README has more detail.
- Android: open `android/` in Android Studio and build. See `android/README.md` for configuration. - Android: open `android/` in Android Studio and build. See `android/README.md` for configuration.
- Mock server (Go): `cd mock && go run ./...` or `make`. - Mock server (Go): `cd mock && go run ./...` or `make`.
## Security and Entitlements
The macOS server uses private APIs and restricted entitlements. On production macOS builds, processes with restricted entitlements can be killed by the kernel; development requires workarounds (e.g., swizzling, hooking `imagent`) and careful code signing. See `server/README.md` for instructions and caveats.
## Status
- Android client: ships its own API client (not yet using Rust `core`).
- GTK + macOS clients: use the Rust `core` library and integrate with the `kordophoned` client daemon for caching/IPC.
- Mock server: useful for development; implements common endpoints and WebSocket updates.
## Contributing
Issues and PRs are welcome. If you add a new client or endpoint, please update relevant READMEs and link it from this root README. Prefer small, focused changes and keep style consistent with the existing code.

840
core/Cargo.lock generated

File diff suppressed because it is too large Load Diff

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::message_aliases::dsl as aliases_dsl;
diesel::delete(messages).execute(self.connection)?; use crate::schema::messages::dsl as messages_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

@@ -394,9 +394,9 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
None => "updates".to_string(), None => "updates".to_string(),
}; };
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,53 +425,61 @@ 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() {
"HTTP error: 401 Unauthorized" | "Unauthorized" => { "HTTP error: 401 Unauthorized" | "Unauthorized" => {
// Try to authenticate // Try to authenticate
if let Some(credentials) = &self.auth_store.get_credentials().await { if let Some(credentials) = &self.auth_store.get_credentials().await {
log::warn!("Websocket connection failed, attempting to authenticate"); log::warn!(
"Websocket connection failed, attempting to authenticate"
);
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> {
let https = HttpsConnector::new(); let https = HttpsConnector::new();
let client = Client::builder().build::<_, Body>(https); let client = Client::builder().build::<_, Body>(https);
HTTPAPIClient { base_url, auth_store, client } HTTPAPIClient {
base_url,
auth_store,
client,
}
} }
fn uri_for_endpoint(&self, endpoint: &str, scheme: Option<&str>) -> Result<Uri, Error> { fn uri_for_endpoint(&self, endpoint: &str, scheme: Option<&str>) -> Result<Uri, Error> {
let mut parts = self.base_url.clone().into_parts(); let mut parts = self.base_url.clone().into_parts();
let root_path: PathBuf = parts let root_path: PathBuf = parts.path_and_query.ok_or(Error::URLError)?.path().into();
.path_and_query
.ok_or(Error::URLError)?
.path()
.into();
let path = root_path.join(endpoint); let path = root_path.join(endpoint);
let path_str = path.to_str().ok_or(Error::URLError)?; let path_str = path.to_str().ok_or(Error::URLError)?;

View File

@@ -23,6 +23,7 @@ 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" mime_guess = "2.0"
notify = { package = "notify-rust", version = "4.10.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]

View File

@@ -103,6 +103,13 @@
"/> "/>
</method> </method>
<method name="TestNotification">
<arg type="s" name="summary" direction="in"/>
<arg type="s" name="body" direction="in"/>
<annotation name="org.freedesktop.DBus.DocString"
value="Displays a test desktop notification with the provided summary and body."/>
</method>
<signal name="MessagesUpdated"> <signal name="MessagesUpdated">
<arg type="s" name="conversation_id" direction="in"/> <arg type="s" name="conversation_id" direction="in"/>
<annotation name="org.freedesktop.DBus.DocString" <annotation name="org.freedesktop.DBus.DocString"

View File

@@ -61,6 +61,9 @@ pub enum Event {
/// - reply: The outgoing message ID (not the server-assigned message ID). /// - reply: The outgoing message ID (not the server-assigned message ID).
SendMessage(String, String, Vec<String>, Reply<Uuid>), SendMessage(String, String, Vec<String>, Reply<Uuid>),
/// Triggers a manual test notification.
TestNotification(String, String, Reply<Result<(), String>>),
/// Notifies the daemon that a message has been sent. /// Notifies the daemon that a message has been sent.
/// Parameters: /// Parameters:
/// - message: The message that was sent. /// - message: The message that was sent.

View File

@@ -17,8 +17,11 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use thiserror::Error; use thiserror::Error;
use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::{
use tokio::sync::Mutex; broadcast,
mpsc::{Receiver, Sender},
Mutex,
};
use uuid::Uuid; use uuid::Uuid;
use kordophone_db::{ use kordophone_db::{
@@ -41,6 +44,9 @@ mod post_office;
use post_office::Event as PostOfficeEvent; use post_office::Event as PostOfficeEvent;
use post_office::PostOffice; use post_office::PostOffice;
mod notifier;
use notifier::NotificationService;
mod models; mod models;
pub use models::Attachment; pub use models::Attachment;
pub use models::Message; pub use models::Message;
@@ -76,8 +82,7 @@ pub struct Daemon {
pub event_sender: Sender<Event>, pub event_sender: Sender<Event>,
event_receiver: Receiver<Event>, event_receiver: Receiver<Event>,
signal_receiver: Option<Receiver<Signal>>, signal_sender: broadcast::Sender<Signal>,
signal_sender: Sender<Signal>,
post_office_sink: Sender<PostOfficeEvent>, post_office_sink: Sender<PostOfficeEvent>,
post_office_source: Option<Receiver<PostOfficeEvent>>, post_office_source: Option<Receiver<PostOfficeEvent>>,
@@ -87,6 +92,7 @@ pub struct Daemon {
attachment_store_sink: Option<Sender<AttachmentStoreEvent>>, attachment_store_sink: Option<Sender<AttachmentStoreEvent>>,
update_monitor_command_tx: Option<Sender<UpdateMonitorCommand>>, update_monitor_command_tx: Option<Sender<UpdateMonitorCommand>>,
notifier: Arc<NotificationService>,
version: String, version: String,
database: Arc<Mutex<Database>>, database: Arc<Mutex<Database>>,
runtime: tokio::runtime::Runtime, runtime: tokio::runtime::Runtime,
@@ -103,7 +109,7 @@ impl Daemon {
// Create event channels // Create event channels
let (event_sender, event_receiver) = tokio::sync::mpsc::channel(100); let (event_sender, event_receiver) = tokio::sync::mpsc::channel(100);
let (signal_sender, signal_receiver) = tokio::sync::mpsc::channel(100); let (signal_sender, _) = tokio::sync::broadcast::channel(100);
let (post_office_sink, post_office_source) = tokio::sync::mpsc::channel(100); let (post_office_sink, post_office_source) = tokio::sync::mpsc::channel(100);
// Create background task runtime // Create background task runtime
@@ -114,13 +120,14 @@ impl Daemon {
let database_impl = Database::new(&database_path.to_string_lossy())?; let database_impl = Database::new(&database_path.to_string_lossy())?;
let database = Arc::new(Mutex::new(database_impl)); let database = Arc::new(Mutex::new(database_impl));
let notifier = Arc::new(NotificationService::new());
Ok(Self { Ok(Self {
version: env!("CARGO_PKG_VERSION").to_string(), version: env!("CARGO_PKG_VERSION").to_string(),
notifier,
database, database,
event_receiver, event_receiver,
event_sender, event_sender,
signal_receiver: Some(signal_receiver),
signal_sender, signal_sender,
post_office_sink, post_office_sink,
post_office_source: Some(post_office_source), post_office_source: Some(post_office_source),
@@ -165,6 +172,16 @@ impl Daemon {
attachment_store.run().await; attachment_store.run().await;
}); });
// Notification listener
{
let notifier = self.notifier.clone();
let mut signal_rx = self.signal_sender.subscribe();
let database = self.database.clone();
tokio::spawn(async move {
notifier.listen(signal_rx, database).await;
});
}
while let Some(event) = self.event_receiver.recv().await { while let Some(event) = self.event_receiver.recv().await {
log::debug!(target: target::EVENT, "Received event: {:?}", event); log::debug!(target: target::EVENT, "Received event: {:?}", event);
self.handle_event(event).await; self.handle_event(event).await;
@@ -260,10 +277,11 @@ impl Daemon {
self.spawn_conversation_list_sync(); self.spawn_conversation_list_sync();
// Send signal to the client that the update stream has been reconnected. // Send signal to the client that the update stream has been reconnected.
self.signal_sender Self::send_signal(
.send(Signal::UpdateStreamReconnected) &self.signal_sender,
.await Signal::UpdateStreamReconnected,
.unwrap(); target::UPDATES,
);
} }
Event::GetAllConversations(limit, offset, reply) => { Event::GetAllConversations(limit, offset, reply) => {
@@ -326,17 +344,13 @@ impl Daemon {
} }
Event::SendMessage(conversation_id, text, attachment_guids, reply) => { Event::SendMessage(conversation_id, text, attachment_guids, reply) => {
let conversation_id = conversation_id.clone();
let uuid = self let uuid = self
.enqueue_outgoing_message(text, conversation_id.clone(), attachment_guids) .enqueue_outgoing_message(text, conversation_id.clone(), attachment_guids)
.await; .await;
reply.send(uuid).unwrap(); reply.send(uuid).unwrap();
// Send message updated signal, we have a placeholder message we will return. // Notify clients that messages have changed (e.g., to refresh placeholders).
self.signal_sender self.emit_messages_updated(conversation_id);
.send(Signal::MessagesUpdated(conversation_id.clone()))
.await
.unwrap();
} }
Event::MessageSent(message, outgoing_message, conversation_id) => { Event::MessageSent(message, outgoing_message, conversation_id) => {
@@ -347,7 +361,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();
@@ -357,11 +380,20 @@ impl Daemon {
.get_mut(&conversation_id) .get_mut(&conversation_id)
.map(|messages| messages.retain(|m| m.guid != outgoing_message.guid)); .map(|messages| messages.retain(|m| m.guid != outgoing_message.guid));
// Send message updated signal. // Notify clients to refresh the conversation after the final message arrives.
self.signal_sender self.emit_messages_updated(conversation_id);
.send(Signal::MessagesUpdated(conversation_id)) }
.await
.unwrap(); Event::TestNotification(summary, body, reply) => {
let result = self
.signal_sender
.send(Signal::Internal(InternalSignal::TestNotification {
summary,
body,
}))
.map(|_| ())
.map_err(|e| e.to_string());
reply.send(result).unwrap();
} }
Event::GetAttachment(guid, reply) => { Event::GetAttachment(guid, reply) => {
@@ -393,10 +425,11 @@ impl Daemon {
log::debug!(target: target::ATTACHMENTS, "Daemon: attachment downloaded: {}, sending signal", attachment_id); log::debug!(target: target::ATTACHMENTS, "Daemon: attachment downloaded: {}, sending signal", attachment_id);
// Send signal to the client that the attachment has been downloaded. // Send signal to the client that the attachment has been downloaded.
self.signal_sender Self::send_signal(
.send(Signal::AttachmentDownloaded(attachment_id)) &self.signal_sender,
.await Signal::AttachmentDownloaded(attachment_id),
.unwrap(); target::ATTACHMENTS,
);
} }
Event::UploadAttachment(path, reply) => { Event::UploadAttachment(path, reply) => {
@@ -411,17 +444,17 @@ impl Daemon {
Event::AttachmentUploaded(upload_guid, attachment_guid) => { Event::AttachmentUploaded(upload_guid, attachment_guid) => {
log::info!(target: target::ATTACHMENTS, "Daemon: attachment uploaded: {}, {}", upload_guid, attachment_guid); log::info!(target: target::ATTACHMENTS, "Daemon: attachment uploaded: {}, {}", upload_guid, attachment_guid);
self.signal_sender Self::send_signal(
.send(Signal::AttachmentUploaded(upload_guid, attachment_guid)) &self.signal_sender,
.await Signal::AttachmentUploaded(upload_guid, attachment_guid),
.unwrap(); target::ATTACHMENTS,
);
} }
} }
} }
/// Panics if the signal receiver has already been taken. pub fn subscribe_signals(&self) -> broadcast::Receiver<Signal> {
pub fn obtain_signal_receiver(&mut self) -> Receiver<Signal> { self.signal_sender.subscribe()
self.signal_receiver.take().unwrap()
} }
async fn get_conversations_limit_offset( async fn get_conversations_limit_offset(
@@ -436,6 +469,10 @@ impl Daemon {
.await .await
} }
fn emit_messages_updated(&self, conversation_id: String) {
Self::send_messages_updated(&self.signal_sender, conversation_id);
}
async fn get_messages( async fn get_messages(
&mut self, &mut self,
conversation_id: String, conversation_id: String,
@@ -448,18 +485,37 @@ 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(
@@ -492,7 +548,7 @@ impl Daemon {
async fn sync_conversation_list( async fn sync_conversation_list(
database: &mut Arc<Mutex<Database>>, database: &mut Arc<Mutex<Database>>,
signal_sender: &Sender<Signal>, signal_sender: &broadcast::Sender<Signal>,
) -> Result<()> { ) -> Result<()> {
log::info!(target: target::SYNC, "Starting list conversation sync"); log::info!(target: target::SYNC, "Starting list conversation sync");
@@ -544,7 +600,7 @@ impl Daemon {
} }
// Send conversations updated signal // Send conversations updated signal
signal_sender.send(Signal::ConversationsUpdated).await?; Self::send_signal(signal_sender, Signal::ConversationsUpdated, target::SYNC);
log::info!(target: target::SYNC, "List synchronized: {} conversations", num_conversations); log::info!(target: target::SYNC, "List synchronized: {} conversations", num_conversations);
Ok(()) Ok(())
@@ -552,7 +608,7 @@ impl Daemon {
async fn sync_all_conversations_impl( async fn sync_all_conversations_impl(
database: &mut Arc<Mutex<Database>>, database: &mut Arc<Mutex<Database>>,
signal_sender: &Sender<Signal>, signal_sender: &broadcast::Sender<Signal>,
) -> Result<()> { ) -> Result<()> {
log::info!(target: target::SYNC, "Starting full conversation sync"); log::info!(target: target::SYNC, "Starting full conversation sync");
@@ -580,7 +636,7 @@ impl Daemon {
} }
// Send conversations updated signal. // Send conversations updated signal.
signal_sender.send(Signal::ConversationsUpdated).await?; Self::send_signal(signal_sender, Signal::ConversationsUpdated, target::SYNC);
log::info!(target: target::SYNC, "Full sync complete, {} conversations processed", num_conversations); log::info!(target: target::SYNC, "Full sync complete, {} conversations processed", num_conversations);
Ok(()) Ok(())
@@ -588,7 +644,7 @@ impl Daemon {
async fn sync_conversation_impl( async fn sync_conversation_impl(
database: &mut Arc<Mutex<Database>>, database: &mut Arc<Mutex<Database>>,
signal_sender: &Sender<Signal>, signal_sender: &broadcast::Sender<Signal>,
conversation_id: String, conversation_id: String,
) -> Result<()> { ) -> Result<()> {
log::debug!(target: target::SYNC, "Starting conversation sync for {}", conversation_id); log::debug!(target: target::SYNC, "Starting conversation sync for {}", conversation_id);
@@ -646,9 +702,7 @@ impl Daemon {
// Send messages updated signal, if we actually inserted any messages. // Send messages updated signal, if we actually inserted any messages.
if num_messages > 0 { if num_messages > 0 {
signal_sender Self::send_messages_updated(signal_sender, conversation_id.clone());
.send(Signal::MessagesUpdated(conversation_id.clone()))
.await?;
} }
log::debug!(target: target::SYNC, "Synchronized {} messages for conversation {}", num_messages, &conversation_id); log::debug!(target: target::SYNC, "Synchronized {} messages for conversation {}", num_messages, &conversation_id);
@@ -669,14 +723,14 @@ impl Daemon {
async fn update_conversation_metadata_impl( async fn update_conversation_metadata_impl(
database: &mut Arc<Mutex<Database>>, database: &mut Arc<Mutex<Database>>,
conversation: Conversation, conversation: Conversation,
signal_sender: &Sender<Signal>, signal_sender: &broadcast::Sender<Signal>,
) -> Result<()> { ) -> Result<()> {
log::debug!(target: target::DAEMON, "Updating conversation metadata: {}", conversation.guid); log::debug!(target: target::DAEMON, "Updating conversation metadata: {}", conversation.guid);
let updated = database let updated = database
.with_repository(|r| r.merge_conversation_metadata(conversation)) .with_repository(|r| r.merge_conversation_metadata(conversation))
.await?; .await?;
if updated { if updated {
signal_sender.send(Signal::ConversationsUpdated).await?; Self::send_signal(signal_sender, Signal::ConversationsUpdated, target::DAEMON);
} }
Ok(()) Ok(())
@@ -691,6 +745,40 @@ impl Daemon {
self.database.with_settings(|s| settings.save(s)).await self.database.with_settings(|s| settings.save(s)).await
} }
fn send_signal(sender: &broadcast::Sender<Signal>, signal: Signal, log_target: &str) {
if let Err(error) = sender.send(signal) {
log::trace!(
target: log_target,
"Signal delivery skipped (no listeners?): {}",
error
);
}
}
fn send_internal(sender: &broadcast::Sender<Signal>, signal: InternalSignal) {
if let Err(error) = sender.send(Signal::Internal(signal)) {
log::trace!(
target: target::DAEMON,
"Internal signal delivery skipped: {}",
error
);
}
}
fn send_messages_updated(sender: &broadcast::Sender<Signal>, conversation_id: String) {
Self::send_internal(
sender,
InternalSignal::MessagesUpdated(conversation_id.clone()),
);
if let Err(error) = sender.send(Signal::MessagesUpdated(conversation_id)) {
log::warn!(
target: target::DAEMON,
"Failed to send MessagesUpdated signal: {}",
error
);
}
}
async fn get_client_impl( async fn get_client_impl(
database: &mut Arc<Mutex<Database>>, database: &mut Arc<Mutex<Database>>,
) -> Result<HTTPAPIClient<DatabaseAuthenticationStore>> { ) -> Result<HTTPAPIClient<DatabaseAuthenticationStore>> {
@@ -723,9 +811,11 @@ impl Daemon {
}) })
.await?; .await?;
self.signal_sender Self::send_signal(
.send(Signal::ConversationsUpdated) &self.signal_sender,
.await?; Signal::ConversationsUpdated,
target::SYNC,
);
Ok(()) Ok(())
} }

View File

@@ -25,8 +25,7 @@ impl Attachment {
// Prefer common, user-friendly extensions over obscure ones // Prefer common, user-friendly extensions over obscure ones
match normalized { match normalized {
"image/jpeg" | "image/pjpeg" => Some("jpg"), "image/jpeg" | "image/pjpeg" => Some("jpg"),
_ => mime_guess::get_mime_extensions_str(normalized) _ => mime_guess::get_mime_extensions_str(normalized).and_then(|list| {
.and_then(|list| {
// If jpg is one of the candidates, prefer it // If jpg is one of the candidates, prefer it
if list.iter().any(|e| *e == "jpg") { if list.iter().any(|e| *e == "jpg") {
Some("jpg") Some("jpg")

View File

@@ -0,0 +1,288 @@
use super::contact_resolver::{ContactResolver, DefaultContactResolverBackend};
use super::models::message::Participant;
use super::signals::{InternalSignal, Signal};
use super::{target, Message};
use kordophone_db::{
database::{Database, DatabaseAccess},
models::Conversation,
models::Participant as DbParticipant,
};
use notify::Notification;
use std::sync::Arc;
use tokio::sync::{broadcast, Mutex};
/// Centralised notification helper used by platform transports (D-Bus, XPC, …).
pub struct NotificationService {
resolver: Mutex<ContactResolver<DefaultContactResolverBackend>>,
}
impl NotificationService {
pub fn new() -> Self {
Self {
resolver: Mutex::new(ContactResolver::new(
DefaultContactResolverBackend::default(),
)),
}
}
pub async fn listen(
self: Arc<Self>,
mut signal_rx: broadcast::Receiver<Signal>,
database: Arc<Mutex<Database>>,
) {
log::trace!(target: target::DAEMON, "NotificationService listener started");
loop {
match signal_rx.recv().await {
Ok(Signal::Internal(InternalSignal::MessagesUpdated(conversation_id))) => {
log::trace!(
target: target::DAEMON,
"NotificationService received MessagesUpdated for {}",
conversation_id
);
self.notify_new_messages(&database, &conversation_id).await;
}
Ok(Signal::Internal(InternalSignal::TestNotification { summary, body })) => {
log::trace!(
target: target::DAEMON,
"NotificationService received TestNotification"
);
if let Err(error) = self.send_manual(&summary, &body) {
log::warn!(
target: target::DAEMON,
"Failed to display test notification: {}",
error
);
}
}
Ok(other) => {
log::trace!(
target: target::DAEMON,
"NotificationService ignoring signal: {:?}",
other
);
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
log::warn!(
target: target::DAEMON,
"NotificationService lagged; skipped {} signals",
skipped
);
}
Err(broadcast::error::RecvError::Closed) => {
log::trace!(target: target::DAEMON, "NotificationService listener exiting");
break;
}
}
}
}
/// Checks whether a new user-visible notification should be shown for the
/// given conversation and displays it if appropriate.
pub async fn notify_new_messages(
&self,
database: &Arc<Mutex<Database>>,
conversation_id: &str,
) {
log::trace!(
target: target::DAEMON,
"NotificationService preparing payload for {}",
conversation_id
);
if let Some((summary, body)) = self.prepare_payload(database, conversation_id).await {
log::trace!(
target: target::DAEMON,
"NotificationService displaying notification for {}",
conversation_id
);
if let Err(error) = self.show_notification(&summary, &body) {
log::warn!(
target: target::DAEMON,
"Failed to display notification for conversation {}: {}",
conversation_id,
error
);
}
} else {
log::trace!(
target: target::DAEMON,
"NotificationService skipping notification for {}",
conversation_id
);
}
}
/// Displays a manual test notification.
pub fn send_manual(&self, summary: &str, body: &str) -> Result<(), notify::error::Error> {
log::trace!(
target: target::DAEMON,
"NotificationService sending manual notification"
);
self.show_notification(summary, body)
}
async fn prepare_payload(
&self,
database: &Arc<Mutex<Database>>,
conversation_id: &str,
) -> Option<(String, String)> {
let conversation_opt = database
.lock()
.await
.with_repository(|r| r.get_conversation_by_guid(conversation_id))
.await;
let conversation = match conversation_opt {
Ok(Some(conv)) => conv,
Ok(None) => {
log::trace!(
target: target::DAEMON,
"NotificationService: conversation {} not found",
conversation_id
);
return None;
}
Err(err) => {
log::warn!(
target: target::DAEMON,
"Notification lookup failed for conversation {}: {}",
conversation_id,
err
);
return None;
}
};
if conversation.unread_count == 0 {
log::trace!(
target: target::DAEMON,
"NotificationService: conversation {} has no unread messages",
conversation_id
);
return None;
}
let last_message_opt = database
.lock()
.await
.with_repository(|r| r.get_last_message_for_conversation(conversation_id))
.await;
let last_message: Message = match last_message_opt {
Ok(Some(message)) => message.into(),
Ok(None) => {
log::trace!(
target: target::DAEMON,
"NotificationService: conversation {} has no messages",
conversation_id
);
return None;
}
Err(err) => {
log::warn!(
target: target::DAEMON,
"Notification lookup failed for conversation {}: {}",
conversation_id,
err
);
return None;
}
};
if matches!(last_message.sender, Participant::Me) {
log::trace!(
target: target::DAEMON,
"NotificationService: last message in {} was sent by self",
conversation_id
);
return None;
}
let mut resolver = self.resolver.lock().await;
let summary = self.conversation_display_name(&conversation, &mut resolver);
let sender_display_name =
self.resolve_participant_display_name(&last_message.sender, &mut resolver);
let mut message_text = last_message.text.replace('\u{FFFC}', "");
if message_text.trim().is_empty() {
if !last_message.attachments.is_empty() {
message_text = "Sent an attachment".to_string();
} else {
message_text = "Sent a message".to_string();
}
}
let body = if sender_display_name.is_empty() {
message_text
} else {
format!("{}: {}", sender_display_name, message_text)
};
Some((summary, body))
}
fn conversation_display_name(
&self,
conversation: &Conversation,
resolver: &mut ContactResolver<DefaultContactResolverBackend>,
) -> String {
if let Some(display_name) = &conversation.display_name {
if !display_name.trim().is_empty() {
return display_name.clone();
}
}
let names: Vec<String> = conversation
.participants
.iter()
.filter_map(|participant| match participant {
DbParticipant::Me => None,
DbParticipant::Remote { handle, contact_id } => {
if let Some(contact_id) = contact_id {
Some(
resolver
.get_contact_display_name(contact_id)
.unwrap_or_else(|| handle.clone()),
)
} else {
Some(handle.clone())
}
}
})
.collect();
if names.is_empty() {
"Kordophone".to_string()
} else {
names.join(", ")
}
}
fn resolve_participant_display_name(
&self,
participant: &Participant,
resolver: &mut ContactResolver<DefaultContactResolverBackend>,
) -> String {
match participant {
Participant::Me => "".to_string(),
Participant::Remote { handle, contact_id } => {
if let Some(contact_id) = contact_id {
resolver
.get_contact_display_name(contact_id)
.unwrap_or_else(|| handle.clone())
} else {
handle.clone()
}
}
}
}
fn show_notification(&self, summary: &str, body: &str) -> Result<(), notify::error::Error> {
Notification::new()
.appname("Kordophone")
.summary(summary)
.body(body)
.show()
.map(|_| ())
}
}

View File

@@ -1,3 +1,12 @@
#[derive(Debug, Clone)]
pub enum InternalSignal {
/// Notification that new messages are available for a conversation.
MessagesUpdated(String),
/// Manual test notification request.
TestNotification { summary: String, body: String },
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Signal { pub enum Signal {
/// Emitted when the list of conversations is updated. /// Emitted when the list of conversations is updated.
@@ -21,4 +30,7 @@ pub enum Signal {
/// Emitted when the update stream is reconnected after a timeout or configuration change. /// Emitted when the update stream is reconnected after a timeout or configuration change.
UpdateStreamReconnected, UpdateStreamReconnected,
/// Internal-only signals consumed by daemon components.
Internal(InternalSignal),
} }

View File

@@ -2,7 +2,7 @@ use dbus::arg;
use dbus_tree::MethodErr; use dbus_tree::MethodErr;
use std::sync::Arc; use std::sync::Arc;
use std::{future::Future, thread}; use std::{future::Future, thread};
use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::sync::{broadcast, mpsc, oneshot, Mutex};
use kordophoned::daemon::{ use kordophoned::daemon::{
contact_resolver::{ContactResolver, DefaultContactResolverBackend}, contact_resolver::{ContactResolver, DefaultContactResolverBackend},
@@ -22,12 +22,15 @@ use dbus_tokio::connection;
#[derive(Clone)] #[derive(Clone)]
pub struct DBusAgent { pub struct DBusAgent {
event_sink: mpsc::Sender<Event>, event_sink: mpsc::Sender<Event>,
signal_receiver: Arc<Mutex<Option<mpsc::Receiver<Signal>>>>, signal_receiver: Arc<Mutex<Option<broadcast::Receiver<Signal>>>>,
contact_resolver: ContactResolver<DefaultContactResolverBackend>, contact_resolver: ContactResolver<DefaultContactResolverBackend>,
} }
impl DBusAgent { impl DBusAgent {
pub fn new(event_sink: mpsc::Sender<Event>, signal_receiver: mpsc::Receiver<Signal>) -> Self { pub fn new(
event_sink: mpsc::Sender<Event>,
signal_receiver: broadcast::Receiver<Signal>,
) -> Self {
Self { Self {
event_sink, event_sink,
signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))), signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))),
@@ -75,8 +78,9 @@ impl DBusAgent {
.take() .take()
.expect("Signal receiver already taken"); .expect("Signal receiver already taken");
while let Some(signal) = receiver.recv().await { loop {
match signal { match receiver.recv().await {
Ok(signal) => match signal {
Signal::ConversationsUpdated => { Signal::ConversationsUpdated => {
log::debug!("Sending signal: ConversationsUpdated"); log::debug!("Sending signal: ConversationsUpdated");
registry registry
@@ -150,6 +154,20 @@ impl DBusAgent {
0 0
}); });
} }
Signal::Internal(_) => {
log::trace!("Ignoring internal signal for D-Bus transport");
}
},
Err(broadcast::error::RecvError::Lagged(skipped)) => {
log::warn!(
"Signal receiver lagged; skipped {} daemon signals",
skipped
);
}
Err(broadcast::error::RecvError::Closed) => {
log::warn!("Signal channel closed; stopping D-Bus forwarding");
break;
}
} }
} }
}); });
@@ -398,6 +416,13 @@ impl DbusRepository for DBusAgent {
.map(|uuid| uuid.to_string()) .map(|uuid| uuid.to_string())
} }
fn test_notification(&mut self, summary: String, body: String) -> Result<(), MethodErr> {
match self.send_event_sync(|r| Event::TestNotification(summary, body, r))? {
Ok(()) => Ok(()),
Err(message) => Err(MethodErr::failed(&message)),
}
}
fn get_attachment_info( fn get_attachment_info(
&mut self, &mut self,
attachment_id: String, attachment_id: String,

View File

@@ -26,7 +26,7 @@ async fn start_ipc_agent(daemon: &mut Daemon) {
use dbus::agent::DBusAgent; use dbus::agent::DBusAgent;
// Start the D-Bus agent (events in, signals out). // Start the D-Bus agent (events in, signals out).
let agent = DBusAgent::new(daemon.event_sender.clone(), daemon.obtain_signal_receiver()); let agent = DBusAgent::new(daemon.event_sender.clone(), daemon.subscribe_signals());
tokio::spawn(async move { tokio::spawn(async move {
agent.run().await; agent.run().await;
}); });
@@ -35,8 +35,7 @@ async fn start_ipc_agent(daemon: &mut Daemon) {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
async fn start_ipc_agent(daemon: &mut Daemon) { async fn start_ipc_agent(daemon: &mut Daemon) {
// Start the macOS XPC agent (events in, signals out) on a dedicated thread. // Start the macOS XPC agent (events in, signals out) on a dedicated thread.
let agent = let agent = xpc::agent::XpcAgent::new(daemon.event_sender.clone(), daemon.subscribe_signals());
xpc::agent::XpcAgent::new(daemon.event_sender.clone(), daemon.obtain_signal_receiver());
std::thread::spawn(move || { std::thread::spawn(move || {
// Use a single-threaded Tokio runtime for the XPC agent. // Use a single-threaded Tokio runtime for the XPC agent.
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()

View File

@@ -4,7 +4,7 @@ use std::ffi::CString;
use std::os::raw::c_char; use std::os::raw::c_char;
use std::ptr; use std::ptr;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::sync::{broadcast, mpsc, oneshot, Mutex};
use xpc_connection::{message_to_xpc_object, xpc_object_to_message, Message, MessageError}; use xpc_connection::{message_to_xpc_object, xpc_object_to_message, Message, MessageError};
use xpc_connection_sys as xpc_sys; use xpc_connection_sys as xpc_sys;
@@ -22,11 +22,14 @@ type Subscribers = Arc<std::sync::Mutex<Vec<XpcConn>>>;
#[derive(Clone)] #[derive(Clone)]
pub struct XpcAgent { pub struct XpcAgent {
event_sink: mpsc::Sender<Event>, event_sink: mpsc::Sender<Event>,
signal_receiver: Arc<Mutex<Option<mpsc::Receiver<Signal>>>>, signal_receiver: Arc<Mutex<Option<broadcast::Receiver<Signal>>>>,
} }
impl XpcAgent { impl XpcAgent {
pub fn new(event_sink: mpsc::Sender<Event>, signal_receiver: mpsc::Receiver<Signal>) -> Self { pub fn new(
event_sink: mpsc::Sender<Event>,
signal_receiver: broadcast::Receiver<Signal>,
) -> Self {
Self { Self {
event_sink, event_sink,
signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))), signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))),
@@ -71,7 +74,31 @@ impl XpcAgent {
.await .await
.take() .take()
.expect("Signal receiver already taken"); .expect("Signal receiver already taken");
while let Some(signal) = receiver.recv().await { loop {
let signal = match receiver.recv().await {
Ok(signal) => signal,
Err(broadcast::error::RecvError::Lagged(skipped)) => {
log::warn!(
target: LOG_TARGET,
"XPC agent lagged; skipped {} signals",
skipped
);
continue;
}
Err(broadcast::error::RecvError::Closed) => {
log::warn!(
target: LOG_TARGET,
"Signal channel closed; stopping XPC forwarding"
);
break;
}
};
if matches!(signal, Signal::Internal(_)) {
log::trace!(target: LOG_TARGET, "Skipping internal signal for XPC");
continue;
}
log::trace!(target: LOG_TARGET, "Broadcasting signal: {:?}", signal); log::trace!(target: LOG_TARGET, "Broadcasting signal: {:?}", signal);
let msg = super::util::signal_to_message(signal); let msg = super::util::signal_to_message(signal);
let xobj = message_to_xpc_object(msg); let xobj = message_to_xpc_object(msg);

View File

@@ -15,10 +15,16 @@ pub struct DispatchResult {
impl DispatchResult { impl DispatchResult {
pub fn new(message: Message) -> Self { pub fn new(message: Message) -> Self {
Self { message, cleanup: None } Self {
message,
cleanup: None,
}
} }
pub fn with_cleanup<T: Any + Send + 'static>(message: Message, cleanup: T) -> Self { pub fn with_cleanup<T: Any + Send + 'static>(message: Message, cleanup: T) -> Self {
Self { message, cleanup: Some(Box::new(cleanup)) } Self {
message,
cleanup: Some(Box::new(cleanup)),
}
} }
} }

View File

@@ -105,7 +105,12 @@ pub async fn dispatch(
.and_then(|m| dict_get_str(m, "conversation_id")) .and_then(|m| dict_get_str(m, "conversation_id"))
{ {
Some(id) => id, Some(id) => id,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
}; };
match agent match agent
.send_event(|r| Event::SyncConversation(conversation_id, r)) .send_event(|r| Event::SyncConversation(conversation_id, r))
@@ -122,7 +127,12 @@ pub async fn dispatch(
.and_then(|m| dict_get_str(m, "conversation_id")) .and_then(|m| dict_get_str(m, "conversation_id"))
{ {
Some(id) => id, Some(id) => id,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
}; };
match agent match agent
.send_event(|r| Event::MarkConversationAsRead(conversation_id, r)) .send_event(|r| Event::MarkConversationAsRead(conversation_id, r))
@@ -137,11 +147,21 @@ pub async fn dispatch(
"GetMessages" => { "GetMessages" => {
let args = match get_dictionary_field(root, "arguments") { let args = match get_dictionary_field(root, "arguments") {
Some(a) => a, Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
}; };
let conversation_id = match dict_get_str(args, "conversation_id") { let conversation_id = match dict_get_str(args, "conversation_id") {
Some(id) => id, Some(id) => id,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
}; };
let last_message_id = dict_get_str(args, "last_message_id"); let last_message_id = dict_get_str(args, "last_message_id");
match agent match agent
@@ -158,11 +178,8 @@ pub async fn dispatch(
dict_put_str(&mut m, "sender", &msg.sender.display_name()); dict_put_str(&mut m, "sender", &msg.sender.display_name());
// Include attachment GUIDs for the client to resolve/download // Include attachment GUIDs for the client to resolve/download
let attachment_guids: Vec<String> = msg let attachment_guids: Vec<String> =
.attachments msg.attachments.iter().map(|a| a.guid.clone()).collect();
.iter()
.map(|a| a.guid.clone())
.collect();
m.insert(cstr("attachment_guids"), array_from_strs(attachment_guids)); m.insert(cstr("attachment_guids"), array_from_strs(attachment_guids));
// Full attachments array with metadata (mirrors DBus fields) // Full attachments array with metadata (mirrors DBus fields)
@@ -193,12 +210,23 @@ pub async fn dispatch(
if let Some(attribution_info) = &metadata.attribution_info { if let Some(attribution_info) = &metadata.attribution_info {
let mut attribution_map: XpcMap = HashMap::new(); let mut attribution_map: XpcMap = HashMap::new();
if let Some(width) = attribution_info.width { if let Some(width) = attribution_info.width {
dict_put_i64_as_str(&mut attribution_map, "width", width as i64); dict_put_i64_as_str(
&mut attribution_map,
"width",
width as i64,
);
} }
if let Some(height) = attribution_info.height { if let Some(height) = attribution_info.height {
dict_put_i64_as_str(&mut attribution_map, "height", height as i64); dict_put_i64_as_str(
&mut attribution_map,
"height",
height as i64,
);
} }
metadata_map.insert(cstr("attribution_info"), Message::Dictionary(attribution_map)); metadata_map.insert(
cstr("attribution_info"),
Message::Dictionary(attribution_map),
);
} }
if !metadata_map.is_empty() { if !metadata_map.is_empty() {
a.insert(cstr("metadata"), Message::Dictionary(metadata_map)); a.insert(cstr("metadata"), Message::Dictionary(metadata_map));
@@ -230,11 +258,21 @@ pub async fn dispatch(
"SendMessage" => { "SendMessage" => {
let args = match get_dictionary_field(root, "arguments") { let args = match get_dictionary_field(root, "arguments") {
Some(a) => a, Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
}; };
let conversation_id = match dict_get_str(args, "conversation_id") { let conversation_id = match dict_get_str(args, "conversation_id") {
Some(v) => v, Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
}; };
let text = dict_get_str(args, "text").unwrap_or_default(); let text = dict_get_str(args, "text").unwrap_or_default();
let attachment_guids: Vec<String> = match args.get(&cstr("attachment_guids")) { let attachment_guids: Vec<String> = match args.get(&cstr("attachment_guids")) {
@@ -265,11 +303,21 @@ pub async fn dispatch(
"GetAttachmentInfo" => { "GetAttachmentInfo" => {
let args = match get_dictionary_field(root, "arguments") { let args = match get_dictionary_field(root, "arguments") {
Some(a) => a, Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
}; };
let attachment_id = match dict_get_str(args, "attachment_id") { let attachment_id = match dict_get_str(args, "attachment_id") {
Some(v) => v, Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing attachment_id",
))
}
}; };
match agent match agent
.send_event(|r| Event::GetAttachment(attachment_id, r)) .send_event(|r| Event::GetAttachment(attachment_id, r))
@@ -308,11 +356,21 @@ pub async fn dispatch(
"OpenAttachmentFd" => { "OpenAttachmentFd" => {
let args = match get_dictionary_field(root, "arguments") { let args = match get_dictionary_field(root, "arguments") {
Some(a) => a, Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
}; };
let attachment_id = match dict_get_str(args, "attachment_id") { let attachment_id = match dict_get_str(args, "attachment_id") {
Some(v) => v, Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing attachment_id",
))
}
}; };
let preview = dict_get_str(args, "preview") let preview = dict_get_str(args, "preview")
.map(|s| s == "true") .map(|s| s == "true")
@@ -335,9 +393,14 @@ pub async fn dispatch(
dict_put_str(&mut reply, "type", "OpenAttachmentFdResponse"); dict_put_str(&mut reply, "type", "OpenAttachmentFdResponse");
reply.insert(cstr("fd"), Message::Fd(fd)); reply.insert(cstr("fd"), Message::Fd(fd));
DispatchResult { message: Message::Dictionary(reply), cleanup: Some(Box::new(file)) } DispatchResult {
message: Message::Dictionary(reply),
cleanup: Some(Box::new(file)),
}
}
Err(e) => {
DispatchResult::new(make_error_reply("OpenFailed", &format!("{}", e)))
} }
Err(e) => DispatchResult::new(make_error_reply("OpenFailed", &format!("{}", e))),
} }
} }
Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))),
@@ -348,11 +411,21 @@ pub async fn dispatch(
"DownloadAttachment" => { "DownloadAttachment" => {
let args = match get_dictionary_field(root, "arguments") { let args = match get_dictionary_field(root, "arguments") {
Some(a) => a, Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
}; };
let attachment_id = match dict_get_str(args, "attachment_id") { let attachment_id = match dict_get_str(args, "attachment_id") {
Some(v) => v, Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing attachment_id",
))
}
}; };
let preview = dict_get_str(args, "preview") let preview = dict_get_str(args, "preview")
.map(|s| s == "true") .map(|s| s == "true")
@@ -371,11 +444,18 @@ pub async fn dispatch(
use std::path::PathBuf; use std::path::PathBuf;
let args = match get_dictionary_field(root, "arguments") { let args = match get_dictionary_field(root, "arguments") {
Some(a) => a, Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
}; };
let path = match dict_get_str(args, "path") { let path = match dict_get_str(args, "path") {
Some(v) => v, Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing path")), None => {
return DispatchResult::new(make_error_reply("InvalidRequest", "Missing path"))
}
}; };
match agent match agent
.send_event(|r| Event::UploadAttachment(PathBuf::from(path), r)) .send_event(|r| Event::UploadAttachment(PathBuf::from(path), r))
@@ -413,7 +493,12 @@ pub async fn dispatch(
"UpdateSettings" => { "UpdateSettings" => {
let args = match get_dictionary_field(root, "arguments") { let args = match get_dictionary_field(root, "arguments") {
Some(a) => a, Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
}; };
let server_url = dict_get_str(args, "server_url"); let server_url = dict_get_str(args, "server_url");
let username = dict_get_str(args, "username"); let username = dict_get_str(args, "username");

View File

@@ -143,8 +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 {
match update { loop {
match stream.next().await.unwrap() {
Ok(update) => match update {
SocketUpdate::Update(updates) => { SocketUpdate::Update(updates) => {
for update in updates { for update in updates {
println!("Got update: {:?}", update); println!("Got update: {:?}", update);
@@ -153,6 +155,12 @@ impl ClientCli {
SocketUpdate::Pong => { SocketUpdate::Pong => {
println!("Pong"); println!("Pong");
} }
},
Err(e) => {
println!("Update error: {:?}", e);
break;
}
} }
} }

View File

@@ -209,4 +209,9 @@ impl DaemonInterface for DBusDaemonInterface {
KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id) KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id)
.map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {}", e)) .map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {}", e))
} }
async fn test_notification(&mut self, summary: String, body: String) -> Result<()> {
KordophoneRepository::test_notification(&self.proxy(), &summary, &body)
.map_err(|e| anyhow::anyhow!("Failed to trigger test notification: {}", e))
}
} }

View File

@@ -32,6 +32,7 @@ pub trait DaemonInterface {
async fn download_attachment(&mut self, attachment_id: String) -> Result<()>; async fn download_attachment(&mut self, attachment_id: String) -> Result<()>;
async fn upload_attachment(&mut self, path: String) -> Result<()>; async fn upload_attachment(&mut self, path: String) -> Result<()>;
async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>; async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
async fn test_notification(&mut self, summary: String, body: String) -> Result<()>;
} }
struct StubDaemonInterface; struct StubDaemonInterface;
@@ -112,6 +113,11 @@ impl DaemonInterface for StubDaemonInterface {
"Daemon interface not implemented on this platform" "Daemon interface not implemented on this platform"
)) ))
} }
async fn test_notification(&mut self, _summary: String, _body: String) -> Result<()> {
Err(anyhow::anyhow!(
"Daemon interface not implemented on this platform"
))
}
} }
pub fn new_daemon_interface() -> Result<Box<dyn DaemonInterface>> { pub fn new_daemon_interface() -> Result<Box<dyn DaemonInterface>> {
@@ -175,6 +181,9 @@ pub enum Commands {
/// Marks a conversation as read. /// Marks a conversation as read.
MarkConversationAsRead { conversation_id: String }, MarkConversationAsRead { conversation_id: String },
/// Displays a test notification using the daemon.
TestNotification { summary: String, body: String },
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -219,6 +228,9 @@ impl Commands {
Commands::MarkConversationAsRead { conversation_id } => { Commands::MarkConversationAsRead { conversation_id } => {
client.mark_conversation_as_read(conversation_id).await client.mark_conversation_as_read(conversation_id).await
} }
Commands::TestNotification { summary, body } => {
client.test_notification(summary, body).await
}
} }
} }
} }

View File

@@ -1,13 +1,13 @@
use std::env; use std::env;
use std::process; use std::process;
use kordophone::{
api::{HTTPAPIClient, InMemoryAuthenticationStore, EventSocket},
model::{ConversationID, event::EventData},
APIInterface,
};
use kordophone::api::http_client::Credentials;
use kordophone::api::AuthenticationStore; use kordophone::api::AuthenticationStore;
use kordophone::api::http_client::Credentials;
use kordophone::{
APIInterface,
api::{EventSocket, HTTPAPIClient, InMemoryAuthenticationStore},
model::{ConversationID, event::EventData},
};
use futures_util::StreamExt; use futures_util::StreamExt;
use hyper::Uri; use hyper::Uri;
@@ -18,7 +18,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
if args.len() < 2 { if args.len() < 2 {
eprintln!("Usage: {} <conversation_id1> [conversation_id2] [conversation_id3] ...", args[0]); eprintln!(
"Usage: {} <conversation_id1> [conversation_id2] [conversation_id3] ...",
args[0]
);
eprintln!("Environment variables required:"); eprintln!("Environment variables required:");
eprintln!(" KORDOPHONE_API_URL - Server URL"); eprintln!(" KORDOPHONE_API_URL - Server URL");
eprintln!(" KORDOPHONE_USERNAME - Username for authentication"); eprintln!(" KORDOPHONE_USERNAME - Username for authentication");
@@ -40,12 +43,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let credentials = Credentials { username, password }; let credentials = Credentials { username, password };
// Collect all conversation IDs from command line arguments // Collect all conversation IDs from command line arguments
let target_conversation_ids: Vec<ConversationID> = args[1..].iter() let target_conversation_ids: Vec<ConversationID> =
.map(|id| id.clone()) args[1..].iter().map(|id| id.clone()).collect();
.collect();
println!("Monitoring {} conversation(s) for updates: {:?}", println!(
target_conversation_ids.len(), target_conversation_ids); "Monitoring {} conversation(s) for updates: {:?}",
target_conversation_ids.len(),
target_conversation_ids
);
let auth_store = InMemoryAuthenticationStore::new(Some(credentials.clone())); let auth_store = InMemoryAuthenticationStore::new(Some(credentials.clone()));
let mut client = HTTPAPIClient::new(server_url, auth_store); let mut client = HTTPAPIClient::new(server_url, auth_store);
@@ -62,26 +67,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
match event_result { match event_result {
Ok(socket_event) => { Ok(socket_event) => {
match socket_event { match socket_event {
kordophone::api::event_socket::SocketEvent::Update(event) => { kordophone::api::event_socket::SocketEvent::Update(event) => match event.data {
match event.data {
EventData::MessageReceived(conversation, _message) => { EventData::MessageReceived(conversation, _message) => {
if target_conversation_ids.contains(&conversation.guid) { if target_conversation_ids.contains(&conversation.guid) {
println!("Message update detected for conversation {}, marking as read...", conversation.guid); println!(
"Message update detected for conversation {}, marking as read...",
conversation.guid
);
match client.mark_conversation_as_read(&conversation.guid).await { match client.mark_conversation_as_read(&conversation.guid).await {
Ok(_) => println!("Successfully marked conversation {} as read", conversation.guid), Ok(_) => println!(
Err(e) => eprintln!("Failed to mark conversation {} as read: {:?}", conversation.guid, e), "Successfully marked conversation {} as read",
conversation.guid
),
Err(e) => eprintln!(
"Failed to mark conversation {} as read: {:?}",
conversation.guid, e
),
}
} }
} }
},
_ => {} _ => {}
}
}, },
kordophone::api::event_socket::SocketEvent::Pong => { kordophone::api::event_socket::SocketEvent::Pong => {
// Ignore pong messages // Ignore pong messages
} }
} }
}, }
Err(e) => { Err(e) => {
eprintln!("Error receiving event: {:?}", e); eprintln!("Error receiving event: {:?}", e);
break; break;

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)")
} }

View File

@@ -14,7 +14,7 @@ struct ConversationView: View
@Binding var entryModel: MessageEntryView.ViewModel @Binding var entryModel: MessageEntryView.ViewModel
var body: some View { var body: some View {
VStack { VStack(spacing: 0.0) {
TranscriptView(model: $transcriptModel) TranscriptView(model: $transcriptModel)
MessageEntryView(viewModel: $entryModel) MessageEntryView(viewModel: $entryModel)
} }
@@ -23,5 +23,10 @@ struct ConversationView: View
entryModel.handleDroppedProviders(providers) entryModel.handleDroppedProviders(providers)
return true return true
} }
.onHover { isHovering in
guard isHovering else { return }
transcriptModel.setNeedsMarkAsRead()
}
} }
} }

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
@@ -112,6 +116,14 @@ enum Display
data.previewPath data.previewPath
} }
var isFullsizeDownloaded: Bool {
data.isDownloaded
}
var fullsizePath: String {
data.path
}
init(from serialized: Serialized.Attachment, dateSent: Date, sender: Sender) { init(from serialized: Serialized.Attachment, dateSent: Date, sender: Sender) {
self.id = serialized.guid self.id = serialized.guid
self.sender = sender self.sender = sender

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

@@ -0,0 +1,39 @@
//
// PreviewPanel.swift
// Kordophone
//
// Created by James Magahern on 9/12/25.
//
import AppKit
import QuickLook
import QuickLookUI
internal class PreviewPanel
{
static let shared = PreviewPanel()
private var displayedURL: URL? = nil
private var impl: QLPreviewPanel { QLPreviewPanel.shared() }
private init() {
impl.dataSource = self
}
public func show(url: URL) {
self.displayedURL = url
impl.makeKeyAndOrderFront(self)
}
}
extension PreviewPanel: QLPreviewPanelDataSource
{
func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
1
}
func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! {
return displayedURL! as NSURL
}
}

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)
} }
} }
} }
@@ -89,6 +90,7 @@ struct ImageItemView: View
@Environment(\.xpcClient) var xpcClient @Environment(\.xpcClient) var xpcClient
@State private var containerWidth: CGFloat? = nil @State private var containerWidth: CGFloat? = nil
@State private var isDownloadingFullAttachment: Bool = false
private var aspectRatio: CGFloat { private var aspectRatio: CGFloat {
attachment.size?.aspectRatio ?? 1.0 attachment.size?.aspectRatio ?? 1.0
@@ -101,6 +103,8 @@ struct ImageItemView: View
var body: some View { var body: some View {
BubbleView(sender: sender, date: date) { BubbleView(sender: sender, date: date) {
let maxWidth = CGFloat.minimum(.imageMaxWidth, containerWidth ?? .imageMaxWidth) let maxWidth = CGFloat.minimum(.imageMaxWidth, containerWidth ?? .imageMaxWidth)
Group {
if let img { if let img {
Image(nsImage: img) Image(nsImage: img)
.resizable() .resizable()
@@ -113,6 +117,23 @@ struct ImageItemView: View
.frame(maxWidth: maxWidth) .frame(maxWidth: maxWidth)
} }
} }
// Download indicator
.overlay {
if isDownloadingFullAttachment {
ZStack {
Rectangle()
.fill(.black.opacity(0.2))
ProgressView()
.progressViewStyle(.circular)
}
}
}
}
.onTapGesture(count: 2) {
openAttachment()
}
.onGeometryChange(for: CGFloat.self, .onGeometryChange(for: CGFloat.self,
of: { $0.size.width }, of: { $0.size.width },
action: { containerWidth = $0 }) action: { containerWidth = $0 })
@@ -135,6 +156,24 @@ struct ImageItemView: View
} }
} }
} }
private func openAttachment() {
Task {
var path = attachment.fullsizePath
if !attachment.isFullsizeDownloaded {
isDownloadingFullAttachment = true
try await xpcClient.downloadAttachment(attachmentId: attachment.id, preview: false, awaitCompletion: true)
// Need to re-fetch this -- the extension may have changed.
let info = try await xpcClient.getAttachmentInfo(attachmentId: attachment.id)
path = info.path
isDownloadingFullAttachment = false
}
PreviewPanel.shared.show(url: URL(filePath: path))
}
}
} }
struct PlaceholderImageItemView: View struct PlaceholderImageItemView: View
@@ -219,14 +258,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 +280,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
}
}

View File

@@ -10,7 +10,7 @@ import SwiftUI
extension TranscriptView.ViewModel extension TranscriptView.ViewModel
{ {
internal func rebuildDisplayItems(animated: Bool = false) { internal func rebuildDisplayItems(animated: Bool = false, completion: () -> Void = {}) {
var displayItems: [DisplayItem] = [] var displayItems: [DisplayItem] = []
var lastDate: Date = .distantPast var lastDate: Date = .distantPast
var lastSender: Display.Sender? = nil var lastSender: Display.Sender? = nil
@@ -53,6 +53,7 @@ extension TranscriptView.ViewModel
let animation: Animation? = animated ? .default : nil let animation: Animation? = animated ? .default : nil
withAnimation(animation) { withAnimation(animation) {
self.displayItems = displayItems self.displayItems = displayItems
completion()
} }
} }
} }

View File

@@ -13,8 +13,17 @@ struct TranscriptView: View
@Environment(\.xpcClient) private var xpcClient @Environment(\.xpcClient) private var xpcClient
init(model: Binding<ViewModel>) {
self._model = model
}
var body: some View { var body: some View {
ScrollViewReader { proxy in
ScrollView { ScrollView {
// For resetting scroll position to the "bottom"
EmptyView()
.id(ViewID.bottomAnchor)
LazyVStack(spacing: 6.0) { LazyVStack(spacing: 6.0) {
ForEach($model.displayItems.reversed()) { item in ForEach($model.displayItems.reversed()) { item in
displayItemView(item.wrappedValue) displayItemView(item.wrappedValue)
@@ -28,9 +37,32 @@ struct TranscriptView: View
} }
.padding() .padding()
} }
// Flip vertically so newest messages are at the bottom.
.scaleEffect(CGSize(width: 1.0, height: -1.0)) .scaleEffect(CGSize(width: 1.0, height: -1.0))
.id(model.displayedConversation?.id)
// Watch for xpc events
.task { await watchForMessageListChanges() } .task { await watchForMessageListChanges() }
// On conversation change, reload displayed messages and mark as read.
.onChange(of: model.displayedConversation) { oldValue, newValue in
Task {
guard oldValue != newValue else { return }
// Reload NOW
await model.reloadMessages(animated: false) {
// Once that's done, scroll to the "bottom" (actually top)
proxy.scrollTo(ViewID.bottomAnchor, anchor: .top)
}
}
Task.detached {
// Mark as read on server, and trigger a sync.
await model.markAsRead()
await model.triggerSync()
}
}
}
} }
private func watchForMessageListChanges() async { private func watchForMessageListChanges() async {
@@ -82,6 +114,13 @@ struct TranscriptView: View
// MARK: - Types // MARK: - Types
enum ViewID: String
{
case bottomAnchor
}
// MARK: - View Model
@Observable @Observable
class ViewModel class ViewModel
{ {
@@ -92,9 +131,11 @@ struct TranscriptView: View
internal var messages: [Display.Message] internal var messages: [Display.Message]
internal let client = XPCClient() internal let client = XPCClient()
private var needsMarkAsRead: Bool = false
private var lastMarkAsRead: Date = .now
init(messages: [Display.Message] = []) { init(messages: [Display.Message] = []) {
self.messages = messages self.messages = messages
observeDisplayedConversation()
rebuildDisplayItems() rebuildDisplayItems()
} }
@@ -106,7 +147,20 @@ struct TranscriptView: View
needsReload = .yes(animated) needsReload = .yes(animated)
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
await reloadMessages() await reloadIfNeeded()
}
}
func setNeedsMarkAsRead() {
guard needsMarkAsRead == false else { return }
guard Date.now.timeIntervalSince(lastMarkAsRead) > 5.0 else { return }
needsMarkAsRead = true
Task { @MainActor [weak self] in
guard let self else { return }
await markAsRead()
needsMarkAsRead = false
lastMarkAsRead = .now
} }
} }
@@ -115,22 +169,6 @@ struct TranscriptView: View
setNeedsReload(animated: false) setNeedsReload(animated: false)
} }
private func observeDisplayedConversation() {
withObservationTracking {
_ = displayedConversation
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
await markAsRead()
await triggerSync()
setNeedsReload(animated: false)
observeDisplayedConversation()
}
}
}
func markAsRead() async { func markAsRead() async {
guard let displayedConversation else { return } guard let displayedConversation else { return }
@@ -151,10 +189,14 @@ struct TranscriptView: View
} }
} }
private func reloadMessages() async { func reloadIfNeeded(completion: () -> Void = {}) async {
guard case .yes(let animated) = needsReload else { return } guard case .yes(let animated) = needsReload else { return }
needsReload = .no needsReload = .no
await reloadMessages(animated: animated, completion: completion)
}
func reloadMessages(animated: Bool, completion: () -> Void) async {
guard let displayedConversation else { return } guard let displayedConversation else { return }
do { do {
@@ -167,8 +209,10 @@ struct TranscriptView: View
// Only animate for incoming messages. // Only animate for incoming messages.
let shouldAnimate = (newIds.count == 1) let shouldAnimate = (newIds.count == 1)
await MainActor.run {
self.messages = clientMessages self.messages = clientMessages
self.rebuildDisplayItems(animated: animated && shouldAnimate) self.rebuildDisplayItems(animated: animated && shouldAnimate, completion: completion)
}
} catch { } catch {
print("Message fetch error: \(error)") print("Message fetch error: \(error)")
} }

View File

@@ -146,13 +146,33 @@ final class XPCClient
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError } guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
} }
public func downloadAttachment(attachmentId: String, preview: Bool) async throws { public func getAttachmentInfo(attachmentId: String) async throws -> AttachmentInfo {
var args: [String: xpc_object_t] = [:]
args["attachment_id"] = xpcString(attachmentId)
let req = makeRequest(method: "GetAttachmentInfo", arguments: args)
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
return AttachmentInfo(
path: reply["path"] ?? "",
previewPath: reply["preview_path"] ?? "",
isDownloaded: reply["is_downloaded"] ?? false,
isPreviewDownloaded: reply["is_preview_downloaded"] ?? false
)
}
public func downloadAttachment(attachmentId: String, preview: Bool, awaitCompletion: Bool = false) async throws {
var args: [String: xpc_object_t] = [:] var args: [String: xpc_object_t] = [:]
args["attachment_id"] = xpcString(attachmentId) args["attachment_id"] = xpcString(attachmentId)
args["preview"] = xpcString(preview ? "true" : "false") args["preview"] = xpcString(preview ? "true" : "false")
let req = makeRequest(method: "DownloadAttachment", arguments: args) let req = makeRequest(method: "DownloadAttachment", arguments: args)
_ = try await sendSync(req) _ = try await sendSync(req)
if awaitCompletion {
// Wait for downloaded event
let _ = await eventStream().first { $0 == .attachmentDownloaded(attachmentId: attachmentId) }
}
} }
public func uploadAttachment(path: String) async throws -> String { public func uploadAttachment(path: String) async throws -> String {
@@ -201,6 +221,14 @@ final class XPCClient
// MARK: - Types // MARK: - Types
struct AttachmentInfo: Decodable
{
let path: String
let previewPath: String
let isDownloaded: Bool
let isPreviewDownloaded: Bool
}
enum Error: Swift.Error enum Error: Swift.Error
{ {
case typeError case typeError
@@ -209,7 +237,7 @@ final class XPCClient
case connectionError case connectionError
} }
enum Signal enum Signal: Equatable
{ {
case conversationsUpdated case conversationsUpdated
case messagesUpdated(conversationId: String) case messagesUpdated(conversationId: String)

View File

@@ -76,6 +76,22 @@ extension Array: XPCConvertible where Element: XPCConvertible
} }
} }
extension Bool: XPCConvertible
{
static func fromXPC(_ value: xpc_object_t) -> Bool? {
if xpc_get_type(value) == XPC_TYPE_BOOL {
return xpc_bool_get_value(value)
}
if xpc_get_type(value) == XPC_TYPE_STRING {
guard let cstr = xpc_string_get_string_ptr(value) else { return nil }
return strcmp(cstr, "true") == 0
}
return nil
}
}
extension xpc_object_t extension xpc_object_t
{ {
func getObject(_ key: String) -> xpc_object_t? { func getObject(_ key: String) -> xpc_object_t? {