Compare commits
15 Commits
1.0.2
...
wip/local_
| Author | SHA1 | Date | |
|---|---|---|---|
| 8304b68a64 | |||
| 6261351598 | |||
| 955ff95520 | |||
| 754ad3282d | |||
| f901077067 | |||
| 778d4b6650 | |||
| e8256a9e57 | |||
| 4e8b161d26 | |||
| 74d1a7f54b | |||
| 4b497aaabc | |||
| 6caf008a39 | |||
| d20afef370 | |||
| 357be5cdf4 | |||
| 4db28222a6 | |||
| 469fd8fa13 |
23
core/Cargo.lock
generated
23
core/Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Drop the alias mapping table
|
||||||
|
DROP TABLE IF EXISTS `message_aliases`;
|
||||||
|
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -397,62 +397,69 @@ 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()))?;
|
||||||
|
|
||||||
log::debug!("Connecting to websocket: {:?}", uri);
|
loop {
|
||||||
|
log::debug!("Connecting to websocket: {:?}", uri);
|
||||||
|
|
||||||
let auth = self.auth_store.get_token().await;
|
let auth = self.auth_store.get_token().await;
|
||||||
let host = uri.authority().unwrap().host();
|
let host = uri.authority().unwrap().host();
|
||||||
let mut request = TungsteniteRequest::builder()
|
let mut request = TungsteniteRequest::builder()
|
||||||
.header("Host", host)
|
.header("Host", host)
|
||||||
.header("Connection", "Upgrade")
|
.header("Connection", "Upgrade")
|
||||||
.header("Upgrade", "websocket")
|
.header("Upgrade", "websocket")
|
||||||
.header("Sec-WebSocket-Version", "13")
|
.header("Sec-WebSocket-Version", "13")
|
||||||
.header("Sec-WebSocket-Key", generate_key())
|
.header("Sec-WebSocket-Key", generate_key())
|
||||||
.uri(uri.to_string())
|
.uri(uri.to_string())
|
||||||
.body(())
|
.body(())
|
||||||
.expect("Unable to build websocket request");
|
.expect("Unable to build websocket request");
|
||||||
|
|
||||||
match &auth {
|
match &auth {
|
||||||
Some(token) => {
|
Some(token) => {
|
||||||
request.headers_mut().insert(
|
request.headers_mut().insert(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
format!("Bearer: {}", token).parse().unwrap(),
|
format!("Bearer: {}", token).parse().unwrap(),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::warn!(target: "websocket", "Proceeding without auth token.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => {
|
|
||||||
log::warn!(target: "websocket", "Proceeding without auth token.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!("Websocket request: {:?}", request);
|
log::debug!("Websocket request: {:?}", request);
|
||||||
|
|
||||||
match connect_async(request).await.map_err(Error::from) {
|
let mut should_retry = true; // retry once after authenticating.
|
||||||
Ok((socket, response)) => {
|
match connect_async(request).await.map_err(Error::from) {
|
||||||
log::debug!("Websocket connected: {:?}", response.status());
|
Ok((socket, response)) => {
|
||||||
Ok(WebsocketEventSocket::new(socket))
|
log::debug!("Websocket connected: {:?}", response.status());
|
||||||
}
|
break Ok(WebsocketEventSocket::new(socket))
|
||||||
Err(e) => match &e {
|
}
|
||||||
Error::ClientError(ce) => match ce.as_str() {
|
Err(e) => match &e {
|
||||||
"HTTP error: 401 Unauthorized" | "Unauthorized" => {
|
Error::ClientError(ce) => match ce.as_str() {
|
||||||
// Try to authenticate
|
"HTTP error: 401 Unauthorized" | "Unauthorized" => {
|
||||||
if let Some(credentials) = &self.auth_store.get_credentials().await {
|
// Try to authenticate
|
||||||
log::warn!("Websocket connection failed, attempting to authenticate");
|
if let Some(credentials) = &self.auth_store.get_credentials().await {
|
||||||
let new_token = self.authenticate(credentials.clone()).await?;
|
log::warn!("Websocket connection failed, attempting to authenticate");
|
||||||
self.auth_store.set_token(new_token.to_string()).await;
|
let new_token = self.authenticate(credentials.clone()).await?;
|
||||||
|
self.auth_store.set_token(new_token.to_string()).await;
|
||||||
|
|
||||||
// try again on the next attempt.
|
if should_retry {
|
||||||
return Err(Error::Unauthorized);
|
// try again on the next attempt.
|
||||||
} else {
|
continue;
|
||||||
log::error!("Websocket unauthorized, no credentials provided");
|
} else {
|
||||||
return Err(Error::ClientError(
|
break Err(e);
|
||||||
"Unauthorized, no credentials provided".into(),
|
}
|
||||||
));
|
} else {
|
||||||
|
log::error!("Websocket unauthorized, no credentials provided");
|
||||||
|
break Err(Error::ClientError(
|
||||||
|
"Unauthorized, no credentials provided".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
_ => break Err(e),
|
||||||
_ => Err(e),
|
},
|
||||||
},
|
|
||||||
|
|
||||||
_ => Err(e),
|
_ => break Err(e),
|
||||||
},
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +110,49 @@ 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(
|
||||||
@@ -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,7 +337,23 @@ impl AttachmentStore {
|
|||||||
).await;
|
).await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
log::error!(target: target::ATTACHMENTS, "Error downloading attachment {}: {}", &_guid, e);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -143,15 +143,25 @@ 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 {
|
||||||
SocketUpdate::Update(updates) => {
|
match stream.next().await.unwrap() {
|
||||||
for update in updates {
|
Ok(update) => {
|
||||||
println!("Got update: {:?}", update);
|
match update {
|
||||||
|
SocketUpdate::Update(updates) => {
|
||||||
|
for update in updates {
|
||||||
|
println!("Got update: {:?}", update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SocketUpdate::Pong => {
|
||||||
|
println!("Pong");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
SocketUpdate::Pong => {
|
|
||||||
println!("Pong");
|
Err(e) => {
|
||||||
|
println!("Update error: {:?}", e);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
gtk/Makefile
Normal file
14
gtk/Makefile
Normal 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)"
|
||||||
|
|
||||||
|
|
||||||
4
gtk/dist/rpm/kordophone.spec
vendored
4
gtk/dist/rpm/kordophone.spec
vendored
@@ -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
|
||||||
|
|||||||
@@ -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 */,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -14,7 +14,14 @@ 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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
@@ -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 {
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -26,7 +26,11 @@ enum Display
|
|||||||
var isGroupChat: Bool {
|
var isGroupChat: Bool {
|
||||||
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
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ struct TextBubbleItemView: View
|
|||||||
let date: Date
|
let date: Date
|
||||||
|
|
||||||
private var isFromMe: Bool { sender.isMe }
|
private var isFromMe: Bool { sender.isMe }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let bubbleColor: Color = isFromMe ? .blue : Color(NSColor(name: "grayish", dynamicProvider: { appearance in
|
let bubbleColor: Color = isFromMe ? .blue : Color(NSColor(name: "grayish", dynamicProvider: { appearance in
|
||||||
appearance.name == .darkAqua ? .darkGray : NSColor(white: 0.78, alpha: 1.0)
|
appearance.name == .darkAqua ? .darkGray : NSColor(white: 0.78, alpha: 1.0)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user