Private
Public Access
1
0

36 Commits

Author SHA1 Message Date
a850c9d612 make rpm: bandaid for old cargo-generate-rpm 2026-02-21 23:52:34 -08:00
a51ff2a7c2 daemon: Cargo: add deb copyright 2026-02-21 23:45:37 -08:00
7cf2724a75 gtk: implement get_attachment_fd and texture/attachment cache, viewport loading 2026-02-21 23:28:21 -08:00
f0ec6b8cb4 core: implement get_attachment_fd event for dbus, message limit for get_messages 2026-02-21 23:26:00 -08:00
f38702bc95 dbus: Smaller GetMessages buffers, avoid encoding attachment paths. 2026-02-21 22:22:09 -08:00
a52c2e0909 eds contact resolver: cache when no contact found 2026-02-21 21:40:12 -08:00
9765994f14 fix for transcript being cut off 2026-02-19 18:11:56 -08:00
5fd94489af kptui: simplify title 2026-02-13 16:58:35 -08:00
e807528466 kptui: get rid of unread count 2026-02-12 13:08:58 -08:00
7c117eb52e kptui: Unread indicator, highlight vs selected state 2026-02-12 13:06:01 -08:00
1febd91c2c kptui: handle some emacs like keyboard shortcuts 2026-02-11 14:48:00 -08:00
9a3c808095 kptui: message entry should scroll horizontally 2026-02-11 14:43:04 -08:00
6ccef24512 gtk: flatpak manifest 2025-12-15 01:02:17 -08:00
61c1b690ba Adds deb building scripts 2025-12-15 00:56:24 -08:00
2f58283e26 Adds cross-compiling support for arm/raspi 2025-12-15 00:32:46 -08:00
be2e3ea9d9 gtk: add split view navigation stack support 2025-12-14 18:49:38 -08:00
fc69c387c5 kptui: organize client code into kordophoned-client 2025-12-14 18:06:14 -08:00
68bb94aa0b kptui: initial commit 2025-12-14 17:50:37 -08:00
f4402292a1 gitignore: add target 2025-12-14 17:10:25 -08: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
70 changed files with 3125 additions and 585 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target/

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.

18
core/.cargo/config.toml Normal file
View File

@@ -0,0 +1,18 @@
[target.arm-unknown-linux-gnueabihf]
# Match Raspberry Pi Zero CPU (ARM1176JZF-S).
rustflags = [
"-C", "target-cpu=arm1176jzf-s",
"-L", "native=/usr/lib/arm-linux-gnueabihf",
"-L", "native=/lib/arm-linux-gnueabihf",
"-C", "link-arg=-Wl,-rpath-link,/usr/lib/arm-linux-gnueabihf",
"-C", "link-arg=-Wl,-rpath-link,/lib/arm-linux-gnueabihf",
]
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
rustflags = [
"-L", "native=/usr/lib/aarch64-linux-gnu",
"-L", "native=/lib/aarch64-linux-gnu",
"-C", "link-arg=-Wl,-rpath-link,/usr/lib/aarch64-linux-gnu",
"-C", "link-arg=-Wl,-rpath-link,/lib/aarch64-linux-gnu",
]

426
core/Cargo.lock generated
View File

@@ -26,6 +26,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "android-tzdata" name = "android-tzdata"
version = "0.1.1" version = "0.1.1"
@@ -232,10 +238,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]] [[package]]
name = "cc" name = "cassowary"
version = "1.0.95" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d32a725bc159af97c3e629873bb9f88fb8cf8a4867175f76dc987815ea07c83b" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]] [[package]]
name = "cexpr" name = "cexpr"
@@ -289,7 +314,7 @@ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"strsim 0.8.0", "strsim 0.8.0",
"textwrap", "textwrap",
"unicode-width", "unicode-width 0.1.14",
"vec_map", "vec_map",
] ]
@@ -339,6 +364,20 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -374,6 +413,31 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.9.3",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.6" version = "0.1.6"
@@ -476,7 +540,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a76dc35ce83e4e9fa089b4fabe66c757b27bd504dc2179c97a01b36d3e874fb0" checksum = "a76dc35ce83e4e9fa089b4fabe66c757b27bd504dc2179c97a01b36d3e874fb0"
dependencies = [ dependencies = [
"clap 2.34.0", "clap 2.34.0",
"dbus",
"xml-rs", "xml-rs",
] ]
@@ -714,10 +777,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "fastrand" name = "find-msvc-tools"
version = "2.0.2" version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]] [[package]]
name = "fnv" name = "fnv"
@@ -726,19 +789,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foreign-types" name = "foldhash"
version = "0.3.2" version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "futures" name = "futures"
@@ -898,6 +952,11 @@ name = "hashbrown"
version = "0.15.2" version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]] [[package]]
name = "heck" name = "heck"
@@ -1002,16 +1061,18 @@ dependencies = [
] ]
[[package]] [[package]]
name = "hyper-tls" name = "hyper-rustls"
version = "0.5.0" version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [ dependencies = [
"bytes", "futures-util",
"http 0.2.12",
"hyper", "hyper",
"native-tls", "rustls 0.21.12",
"tokio", "tokio",
"tokio-native-tls", "tokio-rustls 0.24.1",
"webpki-roots 0.25.4",
] ]
[[package]] [[package]]
@@ -1053,6 +1114,28 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "is-terminal" name = "is-terminal"
version = "0.4.16" version = "0.4.16"
@@ -1070,6 +1153,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.11" version = "1.0.11"
@@ -1134,9 +1226,9 @@ dependencies = [
"env_logger 0.11.8", "env_logger 0.11.8",
"futures-util", "futures-util",
"hyper", "hyper",
"hyper-tls", "hyper-rustls",
"log", "log",
"rustls", "rustls 0.23.29",
"serde", "serde",
"serde_json", "serde_json",
"serde_plain", "serde_plain",
@@ -1213,6 +1305,19 @@ dependencies = [
"xpc-connection-sys", "xpc-connection-sys",
] ]
[[package]]
name = "kordophoned-client"
version = "0.1.0"
dependencies = [
"anyhow",
"block",
"dbus",
"dbus-codegen",
"log",
"xpc-connection",
"xpc-connection-sys",
]
[[package]] [[package]]
name = "kpcli" name = "kpcli"
version = "0.1.0" version = "0.1.0"
@@ -1240,6 +1345,18 @@ dependencies = [
"xpc-connection-sys", "xpc-connection-sys",
] ]
[[package]]
name = "kptui"
version = "0.1.0"
dependencies = [
"anyhow",
"crossterm",
"kordophoned-client",
"ratatui",
"time",
"unicode-width 0.2.0",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.4.0" version = "1.4.0"
@@ -1319,6 +1436,15 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.2" version = "2.7.2"
@@ -1379,28 +1505,11 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
dependencies = [ dependencies = [
"hermit-abi 0.3.9", "hermit-abi 0.3.9",
"libc", "libc",
"log",
"wasi 0.11.0+wasi-snapshot-preview1", "wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "native-tls"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework 2.10.0",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "5.1.3" version = "5.1.3"
@@ -1505,50 +1614,6 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "openssl"
version = "0.10.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f"
dependencies = [
"bitflags 2.9.3",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -1578,6 +1643,12 @@ dependencies = [
"windows-targets 0.48.5", "windows-targets 0.48.5",
] ]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]] [[package]]
name = "peeking_take_while" name = "peeking_take_while"
version = "0.1.2" version = "0.1.2"
@@ -1638,7 +1709,7 @@ dependencies = [
"arrayvec", "arrayvec",
"termcolor", "termcolor",
"typed-arena", "typed-arena",
"unicode-width", "unicode-width 0.1.14",
] ]
[[package]] [[package]]
@@ -1652,7 +1723,7 @@ dependencies = [
"is-terminal", "is-terminal",
"lazy_static", "lazy_static",
"term", "term",
"unicode-width", "unicode-width 0.1.14",
] ]
[[package]] [[package]]
@@ -1738,6 +1809,27 @@ dependencies = [
"getrandom 0.3.2", "getrandom 0.3.2",
] ]
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags 2.9.3",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.4.1" version = "0.4.1"
@@ -1838,6 +1930,17 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rustls"
version = "0.21.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"ring",
"rustls-webpki 0.101.7",
"sct",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.29" version = "0.23.29"
@@ -1847,7 +1950,7 @@ dependencies = [
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki 0.103.4",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@@ -1861,6 +1964,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-webpki"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
"ring",
"untrusted",
]
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.4" version = "0.103.4"
@@ -1884,21 +1997,22 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
[[package]]
name = "schannel"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534"
dependencies = [
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sct"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
dependencies = [
"ring",
"untrusted",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.10.0" version = "2.10.0"
@@ -2001,6 +2115,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.1" version = "1.4.1"
@@ -2041,6 +2176,12 @@ version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.8.0" version = "0.8.0"
@@ -2053,6 +2194,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -2070,18 +2233,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "tempfile"
version = "3.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
dependencies = [
"cfg-if",
"fastrand",
"rustix",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "term" name = "term"
version = "0.7.0" version = "0.7.0"
@@ -2108,7 +2259,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [ dependencies = [
"unicode-width", "unicode-width 0.1.14",
] ]
[[package]] [[package]]
@@ -2221,12 +2372,12 @@ dependencies = [
] ]
[[package]] [[package]]
name = "tokio-native-tls" name = "tokio-rustls"
version = "0.3.1" version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [ dependencies = [
"native-tls", "rustls 0.21.12",
"tokio", "tokio",
] ]
@@ -2236,7 +2387,7 @@ version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
dependencies = [ dependencies = [
"rustls", "rustls 0.23.29",
"tokio", "tokio",
] ]
@@ -2248,10 +2399,10 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"log", "log",
"rustls", "rustls 0.23.29",
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls 0.26.2",
"tungstenite", "tungstenite",
"webpki-roots 0.26.11", "webpki-roots 0.26.11",
] ]
@@ -2347,7 +2498,7 @@ dependencies = [
"httparse", "httparse",
"log", "log",
"rand 0.9.1", "rand 0.9.1",
"rustls", "rustls 0.23.29",
"rustls-pki-types", "rustls-pki-types",
"sha1", "sha1",
"thiserror 2.0.12", "thiserror 2.0.12",
@@ -2379,12 +2530,35 @@ version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.1.14" version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -2528,6 +2702,12 @@ version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d"
[[package]]
name = "webpki-roots"
version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"

View File

@@ -3,7 +3,9 @@ members = [
"kordophone", "kordophone",
"kordophone-db", "kordophone-db",
"kordophoned", "kordophoned",
"kordophoned-client",
"kpcli", "kpcli",
"kptui",
"utilities", "utilities",
] ]
resolver = "2" resolver = "2"

37
core/Cross.toml Normal file
View File

@@ -0,0 +1,37 @@
[target.arm-unknown-linux-gnueabihf]
# Raspberry Pi Zero / Zero W (ARM1176JZF-S, ARMv6 + hard-float).
#
# Several workspace crates use native libs via pkg-config (dbus, sqlite, libsecret).
# Install the ARMv6/armhf -dev packages inside the cross image so they are available
# to the target linker.
pre-build = [
"dpkg --add-architecture armhf",
"apt-get update",
"apt-get install -y --no-install-recommends bash pkg-config libc6-dev:armhf libdbus-1-dev:armhf libsystemd-dev:armhf libsqlite3-dev:armhf libsecret-1-dev:armhf",
# `cross` doesn't reliably forward PKG_CONFIG_* env vars into the container, so install a tiny
# wrapper that selects the correct multiarch pkgconfig dir based on `$TARGET`.
"bash -lc 'if [ -x /usr/bin/pkg-config ] && [ ! -x /usr/bin/pkg-config.real ]; then mv /usr/bin/pkg-config /usr/bin/pkg-config.real; fi'",
"bash -lc 'printf \"%b\" \"#!/usr/bin/env bash\\nset -euo pipefail\\nREAL=/usr/bin/pkg-config.real\\ncase \\\"\\${TARGET:-}\\\" in\\n arm-unknown-linux-gnueabihf)\\n export PKG_CONFIG_ALLOW_CROSS=1\\n export PKG_CONFIG_SYSROOT_DIR=/\\n export PKG_CONFIG_LIBDIR=/usr/lib/arm-linux-gnueabihf/pkgconfig\\n export PKG_CONFIG_PATH=\\\"\\$PKG_CONFIG_LIBDIR\\\"\\n ;;\\n aarch64-unknown-linux-gnu)\\n export PKG_CONFIG_ALLOW_CROSS=1\\n export PKG_CONFIG_SYSROOT_DIR=/\\n export PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig\\n export PKG_CONFIG_PATH=\\\"\\$PKG_CONFIG_LIBDIR\\\"\\n ;;\\n *)\\n ;;\\nesac\\nexec \\\"\\$REAL\\\" \\\"\\$@\\\"\\n\" > /usr/bin/pkg-config && chmod +x /usr/bin/pkg-config'",
# Sanity checks (use wrapper + armhf search path).
"bash -lc 'TARGET=arm-unknown-linux-gnueabihf pkg-config --modversion dbus-1'",
"bash -lc 'TARGET=arm-unknown-linux-gnueabihf pkg-config --modversion sqlite3'",
"bash -lc 'TARGET=arm-unknown-linux-gnueabihf pkg-config --modversion libsecret-1'",
]
[target.aarch64-unknown-linux-gnu]
# Raspberry Pi OS (64-bit) / other aarch64 Linux.
#
# Use a Debian 11 (bullseye) base so the resulting binaries are compatible with
# bullseye's glibc, and to get a system `libsqlite3` new enough for Diesel.
image = "debian:bullseye-slim"
pre-build = [
"dpkg --add-architecture arm64",
"apt-get update",
"apt-get install -y --no-install-recommends ca-certificates bash pkg-config build-essential gcc-aarch64-linux-gnu libc6-dev:arm64 libdbus-1-dev:arm64 libsystemd-dev:arm64 libsqlite3-dev:arm64 libsecret-1-dev:arm64",
# Same wrapper as above (installed once, safe to re-run).
"bash -lc 'if [ -x /usr/bin/pkg-config ] && [ ! -x /usr/bin/pkg-config.real ]; then mv /usr/bin/pkg-config /usr/bin/pkg-config.real; fi'",
"bash -lc 'printf \"%b\" \"#!/usr/bin/env bash\\nset -euo pipefail\\nREAL=/usr/bin/pkg-config.real\\ncase \\\"\\${TARGET:-}\\\" in\\n arm-unknown-linux-gnueabihf)\\n export PKG_CONFIG_ALLOW_CROSS=1\\n export PKG_CONFIG_SYSROOT_DIR=/\\n export PKG_CONFIG_LIBDIR=/usr/lib/arm-linux-gnueabihf/pkgconfig\\n export PKG_CONFIG_PATH=\\\"\\$PKG_CONFIG_LIBDIR\\\"\\n ;;\\n aarch64-unknown-linux-gnu)\\n export PKG_CONFIG_ALLOW_CROSS=1\\n export PKG_CONFIG_SYSROOT_DIR=/\\n export PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig\\n export PKG_CONFIG_PATH=\\\"\\$PKG_CONFIG_LIBDIR\\\"\\n ;;\\n *)\\n ;;\\nesac\\nexec \\\"\\$REAL\\\" \\\"\\$@\\\"\\n\" > /usr/bin/pkg-config && chmod +x /usr/bin/pkg-config'",
"bash -lc 'TARGET=aarch64-unknown-linux-gnu pkg-config --modversion dbus-1'",
"bash -lc 'TARGET=aarch64-unknown-linux-gnu pkg-config --modversion sqlite3'",
"bash -lc 'TARGET=aarch64-unknown-linux-gnu pkg-config --modversion libsecret-1'",
]

25
core/Dockerfile.deb Normal file
View File

@@ -0,0 +1,25 @@
FROM debian:bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
build-essential \
make \
pkg-config \
libssl-dev \
libsqlite3-dev \
libdbus-1-dev \
libsystemd-dev \
dpkg \
&& rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install cargo-deb
WORKDIR /workspace
COPY . .
CMD ["make", "deb"]

View File

@@ -12,5 +12,23 @@ rpm:
cargo build --release --workspace cargo build --release --workspace
strip -s target/release/kordophoned strip -s target/release/kordophoned
strip -s target/release/kpcli strip -s target/release/kpcli
cargo generate-rpm -p kordophoned strip -s target/release/kptui
cargo generate-rpm -p kordophoned --auto-req builtin
.PHONY: deb
deb:
cargo build --release --workspace
strip -s target/release/kordophoned
strip -s target/release/kpcli
strip -s target/release/kptui
cargo deb -p kordophoned --no-build
.PHONY: pi-zero
pi-zero:
CARGO_TARGET_DIR=target/cross/arm-unknown-linux-gnueabihf \
cross build --release --target arm-unknown-linux-gnueabihf -p kordophoned -p kpcli -p kptui
.PHONY: pi-aarch64
pi-aarch64:
CARGO_TARGET_DIR=target/cross/aarch64-unknown-linux-gnu \
cross build --release --target aarch64-unknown-linux-gnu -p kordophoned -p kpcli -p kptui

View File

@@ -9,7 +9,9 @@ Workspace members:
- `kordophoned/` — Client daemon providing local caching and IPC - `kordophoned/` — Client daemon providing local caching and IPC
- Linux: DBus - Linux: DBus
- macOS: XPC (see notes below) - macOS: XPC (see notes below)
- `kordophoned-client/` — Cross-platform client library for talking to `kordophoned` (D-Bus/XPC).
- `kpcli/` — Commandline interface for interacting with the API, DB, and daemon. - `kpcli/` — Commandline interface for interacting with the API, DB, and daemon.
- `kptui/` — Terminal UI client (Ratatui) for reading and replying to chats via the daemon.
- `utilities/` — Small helper tools (e.g., testing utilities). - `utilities/` — Small helper tools (e.g., testing utilities).
## Build ## Build
@@ -27,6 +29,42 @@ cargo build -p kordophone
cargo build -p kordophoned --release cargo build -p kordophoned --release
``` ```
## Raspberry Pi Zero (cross build)
Recommended approach is `cross` (https://github.com/cross-rs/cross), which uses a containerized toolchain.
Prereqs:
- Install Docker or Podman
- Install `cross`: `cargo install cross`
Build ARMv6 (Pi Zero / Zero W):
```bash
cd core
make pi-zero
```
Build aarch64 (Pi OS 64-bit / Pi 3+):
```bash
cd core
make pi-aarch64
```
Notes:
- The aarch64 cross build uses a Debian 11 (bullseye) container base image to keep glibc compatible with bullseye.
Artifacts:
- `target/cross/arm-unknown-linux-gnueabihf/arm-unknown-linux-gnueabihf/release/kordophoned`
- `target/cross/arm-unknown-linux-gnueabihf/arm-unknown-linux-gnueabihf/release/kpcli`
- `target/cross/arm-unknown-linux-gnueabihf/arm-unknown-linux-gnueabihf/release/kptui`
- `target/cross/aarch64-unknown-linux-gnu/aarch64-unknown-linux-gnu/release/kordophoned`
- `target/cross/aarch64-unknown-linux-gnu/aarch64-unknown-linux-gnu/release/kpcli`
- `target/cross/aarch64-unknown-linux-gnu/aarch64-unknown-linux-gnu/release/kptui`
## `kordophoned` (Client Daemon) ## `kordophoned` (Client Daemon)
The daemon maintains a local cache, handles update cycles, and exposes IPC for GUI apps. The daemon maintains a local cache, handles update cycles, and exposes IPC for GUI apps.
@@ -53,6 +91,16 @@ strip -s target/release/kordophoned
cargo generate-rpm cargo generate-rpm
``` ```
### Packaging (DEB example)
`kordophoned` is configured for Debian packaging via `cargo-deb`.
```bash
cargo install cargo-deb
cd core
cargo deb -p kordophoned
```
## `kpcli` (CLI) ## `kpcli` (CLI)
Useful for quick testing and interacting with the daemon/cache. Useful for quick testing and interacting with the daemon/cache.
@@ -65,4 +113,3 @@ cargo run -p kpcli -- --help
- TLS/WebSocket: the `kordophone` crate includes `rustls` and installs a crypto provider at process start. - TLS/WebSocket: the `kordophone` crate includes `rustls` and installs a crypto provider at process start.
- DB: `kordophone-db` includes Diesel migrations under `kordophone-db/migrations/`. - DB: `kordophone-db` includes Diesel migrations under `kordophone-db/migrations/`.

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

@@ -264,16 +264,34 @@ impl<'a> Repository<'a> {
.order_by(schema::messages::date.asc()) .order_by(schema::messages::date.asc())
.load::<MessageRecord>(self.connection)?; .load::<MessageRecord>(self.connection)?;
let sender_handles: Vec<String> = message_records
.iter()
.filter_map(|record| record.sender_participant_handle.clone())
.collect();
let participant_map: HashMap<String, Participant> = if sender_handles.is_empty() {
HashMap::new()
} else {
participants
.filter(handle.eq_any(&sender_handles))
.load::<ParticipantRecord>(self.connection)?
.into_iter()
.map(|participant| {
let key = participant.handle.clone();
(key, participant.into())
})
.collect()
};
let mut result = Vec::new(); let mut result = Vec::new();
for message_record in message_records { for message_record in message_records {
let mut message: Message = message_record.clone().into(); let mut message: Message = message_record.clone().into();
// If the message references a sender participant, load the participant info // If the message references a sender participant, load the participant info
if let Some(sender_handle) = message_record.sender_participant_handle { if let Some(sender_handle) = message_record.sender_participant_handle {
let participant = participants if let Some(participant) = participant_map.get(&sender_handle) {
.find(sender_handle) message.sender = participant.clone();
.first::<ParticipantRecord>(self.connection)?; }
message.sender = participant.into();
} }
result.push(message); result.push(message);
@@ -307,8 +325,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 +380,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

@@ -14,7 +14,7 @@ ctor = "0.2.8"
env_logger = "0.11.5" env_logger = "0.11.5"
futures-util = "0.3.31" futures-util = "0.3.31"
hyper = { version = "0.14", features = ["full"] } hyper = { version = "0.14", features = ["full"] }
hyper-tls = "0.5.0" hyper-rustls = { version = "0.24", default-features = false, features = ["http1", "webpki-tokio"] }
log = { version = "0.4.21", features = [] } log = { version = "0.4.21", features = [] }
serde = { version = "1.0.152", features = ["derive"] } serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.91" serde_json = "1.0.91"

View File

@@ -7,7 +7,7 @@ use crate::api::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpda
use crate::api::AuthenticationStore; use crate::api::AuthenticationStore;
use bytes::Bytes; use bytes::Bytes;
use hyper::{Body, Client, Method, Request, Uri}; use hyper::{Body, Client, Method, Request, Uri};
use hyper_tls::HttpsConnector; use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use async_trait::async_trait; use async_trait::async_trait;
use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
@@ -397,6 +397,7 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
let uri = self let uri = self
.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?; .uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
loop {
log::debug!("Connecting to websocket: {:?}", uri); log::debug!("Connecting to websocket: {:?}", uri);
let auth = self.auth_store.get_token().await; let auth = self.auth_store.get_token().await;
@@ -425,10 +426,11 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
log::debug!("Websocket request: {:?}", request); log::debug!("Websocket request: {:?}", request);
let mut should_retry = true; // retry once after authenticating.
match connect_async(request).await.map_err(Error::from) { match connect_async(request).await.map_err(Error::from) {
Ok((socket, response)) => { Ok((socket, response)) => {
log::debug!("Websocket connected: {:?}", response.status()); log::debug!("Websocket connected: {:?}", response.status());
Ok(WebsocketEventSocket::new(socket)) break Ok(WebsocketEventSocket::new(socket))
} }
Err(e) => match &e { Err(e) => match &e {
Error::ClientError(ce) => match ce.as_str() { Error::ClientError(ce) => match ce.as_str() {
@@ -439,27 +441,36 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
let new_token = self.authenticate(credentials.clone()).await?; let new_token = self.authenticate(credentials.clone()).await?;
self.auth_store.set_token(new_token.to_string()).await; self.auth_store.set_token(new_token.to_string()).await;
if should_retry {
// try again on the next attempt. // try again on the next attempt.
return Err(Error::Unauthorized); continue;
} else {
break Err(e);
}
} else { } else {
log::error!("Websocket unauthorized, no credentials provided"); log::error!("Websocket unauthorized, no credentials provided");
return Err(Error::ClientError( break Err(Error::ClientError(
"Unauthorized, no credentials provided".into(), "Unauthorized, no credentials provided".into(),
)); ));
} }
} }
_ => Err(e), _ => break Err(e),
}, },
_ => Err(e), _ => break Err(e),
}, },
} }
} }
}
} }
impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> { impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient<K> { pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient<K> {
let https = HttpsConnector::new(); let https = HttpsConnectorBuilder::new()
.with_webpki_roots()
.https_or_http()
.enable_http1()
.build();
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 }

View File

@@ -0,0 +1,22 @@
[package]
name = "kordophoned-client"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.93"
log = "0.4.22"
# D-Bus dependencies only on Linux
[target.'cfg(target_os = "linux")'.dependencies]
dbus = "0.9.7"
# D-Bus codegen only on Linux
[target.'cfg(target_os = "linux")'.build-dependencies]
dbus-codegen = { version = "0.10.0", default-features = false }
# XPC (libxpc) interface only on macOS
[target.'cfg(target_os = "macos")'.dependencies]
block = "0.1.6"
xpc-connection = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection" }
xpc-connection-sys = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection-sys" }

View File

@@ -0,0 +1,26 @@
const KORDOPHONE_XML: &str = "../kordophoned/include/net.buzzert.kordophonecd.Server.xml";
#[cfg(not(target_os = "linux"))]
fn main() {
// No D-Bus codegen on non-Linux platforms.
}
#[cfg(target_os = "linux")]
fn main() {
let out_dir = std::env::var("OUT_DIR").unwrap();
let out_path = std::path::Path::new(&out_dir).join("kordophone-client.rs");
let opts = dbus_codegen::GenOpts {
connectiontype: dbus_codegen::ConnectionType::Blocking,
methodtype: None,
..Default::default()
};
let xml = std::fs::read_to_string(KORDOPHONE_XML).expect("Error reading server dbus interface");
let output =
dbus_codegen::generate(&xml, &opts).expect("Error generating client dbus interface");
std::fs::write(out_path, output).expect("Error writing client dbus code");
println!("cargo:rerun-if-changed={}", KORDOPHONE_XML);
}

View File

@@ -0,0 +1,5 @@
mod platform;
mod worker;
pub use worker::{spawn_worker, ChatMessage, ConversationSummary, Event, Request};

View File

@@ -0,0 +1,189 @@
#![cfg(target_os = "linux")]
use crate::worker::{ChatMessage, ConversationSummary, DaemonClient, Event};
use anyhow::Result;
use dbus::arg::{PropMap, RefArg};
use dbus::blocking::{Connection, Proxy};
use dbus::channel::Token;
use std::sync::mpsc::Sender;
use std::time::Duration;
const DBUS_NAME: &str = "net.buzzert.kordophonecd";
const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon";
#[allow(unused)]
mod dbus_interface {
#![allow(unused)]
include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs"));
}
use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository;
pub(crate) struct DBusClient {
conn: Connection,
signal_tokens: Vec<Token>,
}
impl DBusClient {
pub(crate) fn new() -> Result<Self> {
Ok(Self {
conn: Connection::new_session()?,
signal_tokens: Vec::new(),
})
}
fn proxy(&self) -> Proxy<&Connection> {
self.conn
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000))
}
}
fn get_string(map: &PropMap, key: &str) -> String {
map.get(key)
.and_then(|v| v.0.as_str())
.unwrap_or_default()
.to_string()
}
fn get_i64(map: &PropMap, key: &str) -> i64 {
map.get(key).and_then(|v| v.0.as_i64()).unwrap_or(0)
}
fn get_u32(map: &PropMap, key: &str) -> u32 {
get_i64(map, key).try_into().unwrap_or(0)
}
fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
map.get(key)
.and_then(|v| v.0.as_iter())
.map(|iter| {
iter.filter_map(|item| item.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
impl DaemonClient for DBusClient {
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
let mut items = KordophoneRepository::get_conversations(&self.proxy(), limit, offset)?;
let mut conversations = items
.drain(..)
.map(|conv| {
let id = get_string(&conv, "guid");
let display_name = get_string(&conv, "display_name");
let participants = get_vec_string(&conv, "participants");
let title = if !display_name.trim().is_empty() {
display_name
} else if participants.is_empty() {
"<unknown>".to_string()
} else {
participants.join(", ")
};
ConversationSummary {
id,
title,
preview: get_string(&conv, "last_message_preview").replace('\n', " "),
unread_count: get_u32(&conv, "unread_count"),
date_unix: get_i64(&conv, "date"),
}
})
.collect::<Vec<_>>();
conversations.sort_by_key(|c| std::cmp::Reverse(c.date_unix));
Ok(conversations)
}
fn get_messages(
&mut self,
conversation_id: String,
last_message_id: Option<String>,
) -> Result<Vec<ChatMessage>> {
let messages = KordophoneRepository::get_messages(
&self.proxy(),
&conversation_id,
&last_message_id.unwrap_or_default(),
)?;
Ok(messages
.into_iter()
.map(|msg| ChatMessage {
sender: get_string(&msg, "sender"),
text: get_string(&msg, "text"),
date_unix: get_i64(&msg, "date"),
})
.collect())
}
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
let attachment_guids: Vec<&str> = vec![];
let outgoing_id = KordophoneRepository::send_message(
&self.proxy(),
&conversation_id,
&text,
attachment_guids,
)?;
Ok(Some(outgoing_id))
}
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id)
.map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {e}"))
}
fn sync_conversation(&mut self, conversation_id: String) -> Result<()> {
KordophoneRepository::sync_conversation(&self.proxy(), &conversation_id)
.map_err(|e| anyhow::anyhow!("Failed to sync conversation: {e}"))
}
fn install_signal_handlers(&mut self, event_tx: Sender<Event>) -> Result<()> {
let conversations_tx = event_tx.clone();
let t1 = self
.proxy()
.match_signal(
move |_: dbus_interface::NetBuzzertKordophoneRepositoryConversationsUpdated,
_: &Connection,
_: &dbus::message::Message| {
let _ = conversations_tx.send(Event::ConversationsUpdated);
true
},
)
.map_err(|e| anyhow::anyhow!("Failed to match ConversationsUpdated: {e}"))?;
let messages_tx = event_tx.clone();
let t2 = self
.proxy()
.match_signal(
move |s: dbus_interface::NetBuzzertKordophoneRepositoryMessagesUpdated,
_: &Connection,
_: &dbus::message::Message| {
let _ = messages_tx.send(Event::MessagesUpdated {
conversation_id: s.conversation_id,
});
true
},
)
.map_err(|e| anyhow::anyhow!("Failed to match MessagesUpdated: {e}"))?;
let reconnected_tx = event_tx;
let t3 = self
.proxy()
.match_signal(
move |_: dbus_interface::NetBuzzertKordophoneRepositoryUpdateStreamReconnected,
_: &Connection,
_: &dbus::message::Message| {
let _ = reconnected_tx.send(Event::UpdateStreamReconnected);
true
},
)
.map_err(|e| anyhow::anyhow!("Failed to match UpdateStreamReconnected: {e}"))?;
self.signal_tokens.extend([t1, t2, t3]);
Ok(())
}
fn poll(&mut self, timeout: Duration) -> Result<()> {
self.conn.process(timeout)?;
Ok(())
}
}

View File

@@ -0,0 +1,233 @@
#![cfg(target_os = "macos")]
use crate::worker::{ChatMessage, ConversationSummary, DaemonClient};
use anyhow::Result;
use std::collections::HashMap;
use std::ffi::{CStr, CString};
use xpc_connection::Message;
const SERVICE_NAME: &str = "net.buzzert.kordophonecd\0";
struct XpcTransport {
connection: xpc_connection_sys::xpc_connection_t,
}
impl XpcTransport {
fn connect(name: impl AsRef<CStr>) -> Self {
use xpc_connection_sys::xpc_connection_create_mach_service;
use xpc_connection_sys::xpc_connection_resume;
let name = name.as_ref();
let connection =
unsafe { xpc_connection_create_mach_service(name.as_ptr(), std::ptr::null_mut(), 0) };
unsafe { xpc_connection_resume(connection) };
Self { connection }
}
fn send_with_reply(&self, message: Message) -> Message {
use xpc_connection::message_to_xpc_object;
use xpc_connection::xpc_object_to_message;
use xpc_connection_sys::{xpc_connection_send_message_with_reply_sync, xpc_release};
unsafe {
let xobj = message_to_xpc_object(message);
let reply = xpc_connection_send_message_with_reply_sync(self.connection, xobj);
xpc_release(xobj);
let msg = xpc_object_to_message(reply);
if !reply.is_null() {
xpc_release(reply);
}
msg
}
}
}
impl Drop for XpcTransport {
fn drop(&mut self) {
use xpc_connection_sys::xpc_object_t;
use xpc_connection_sys::xpc_release;
unsafe { xpc_release(self.connection as xpc_object_t) };
}
}
pub(crate) struct XpcClient {
transport: XpcTransport,
}
impl XpcClient {
pub(crate) fn new() -> Result<Self> {
let mach_port_name = CString::new(SERVICE_NAME).unwrap();
Ok(Self {
transport: XpcTransport::connect(&mach_port_name),
})
}
fn key(s: &str) -> CString {
CString::new(s).unwrap()
}
fn request(name: &str, arguments: Option<HashMap<CString, Message>>) -> Message {
let mut root: HashMap<CString, Message> = HashMap::new();
root.insert(Self::key("name"), Message::String(Self::key(name)));
if let Some(args) = arguments {
root.insert(Self::key("arguments"), Message::Dictionary(args));
}
Message::Dictionary(root)
}
fn get_string(map: &HashMap<CString, Message>, key: &str) -> Option<String> {
match map.get(&Self::key(key)) {
Some(Message::String(s)) => Some(s.to_string_lossy().into_owned()),
_ => None,
}
}
fn get_i64_from_str(map: &HashMap<CString, Message>, key: &str) -> i64 {
Self::get_string(map, key)
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0)
}
}
impl DaemonClient for XpcClient {
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
let mut args = HashMap::new();
args.insert(Self::key("limit"), Message::String(Self::key(&limit.to_string())));
args.insert(Self::key("offset"), Message::String(Self::key(&offset.to_string())));
let reply = self
.transport
.send_with_reply(Self::request("GetConversations", Some(args)));
let Message::Dictionary(map) = reply else {
anyhow::bail!("Unexpected conversations response");
};
let Some(Message::Array(items)) = map.get(&Self::key("conversations")) else {
anyhow::bail!("Missing conversations in response");
};
let mut conversations = Vec::new();
for item in items {
let Message::Dictionary(conv) = item else { continue };
let id = Self::get_string(conv, "guid").unwrap_or_default();
let display_name = Self::get_string(conv, "display_name").unwrap_or_default();
let preview = Self::get_string(conv, "last_message_preview").unwrap_or_default();
let unread_count = Self::get_i64_from_str(conv, "unread_count") as u32;
let date_unix = Self::get_i64_from_str(conv, "date");
let participants = match conv.get(&Self::key("participants")) {
Some(Message::Array(arr)) => arr
.iter()
.filter_map(|m| match m {
Message::String(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>(),
_ => Vec::new(),
};
let title = if !display_name.trim().is_empty() {
display_name
} else if participants.is_empty() {
"<unknown>".to_string()
} else {
participants.join(", ")
};
conversations.push(ConversationSummary {
id,
title,
preview: preview.replace('\n', " "),
unread_count,
date_unix,
});
}
conversations.sort_by_key(|c| std::cmp::Reverse(c.date_unix));
Ok(conversations)
}
fn get_messages(
&mut self,
conversation_id: String,
last_message_id: Option<String>,
) -> Result<Vec<ChatMessage>> {
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(Self::key(&conversation_id)),
);
if let Some(last) = last_message_id {
args.insert(Self::key("last_message_id"), Message::String(Self::key(&last)));
}
let reply = self
.transport
.send_with_reply(Self::request("GetMessages", Some(args)));
let Message::Dictionary(map) = reply else {
anyhow::bail!("Unexpected messages response");
};
let Some(Message::Array(items)) = map.get(&Self::key("messages")) else {
anyhow::bail!("Missing messages in response");
};
let mut messages = Vec::new();
for item in items {
let Message::Dictionary(msg) = item else { continue };
messages.push(ChatMessage {
sender: Self::get_string(msg, "sender").unwrap_or_default(),
text: Self::get_string(msg, "text").unwrap_or_default(),
date_unix: Self::get_i64_from_str(msg, "date"),
});
}
Ok(messages)
}
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(Self::key(&conversation_id)),
);
args.insert(Self::key("text"), Message::String(Self::key(&text)));
let reply = self
.transport
.send_with_reply(Self::request("SendMessage", Some(args)));
let Message::Dictionary(map) = reply else {
anyhow::bail!("Unexpected send response");
};
Ok(Self::get_string(&map, "uuid"))
}
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(Self::key(&conversation_id)),
);
let _ = self
.transport
.send_with_reply(Self::request("MarkConversationAsRead", Some(args)));
Ok(())
}
fn sync_conversation(&mut self, conversation_id: String) -> Result<()> {
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(Self::key(&conversation_id)),
);
let _ = self
.transport
.send_with_reply(Self::request("SyncConversation", Some(args)));
Ok(())
}
}

View File

@@ -0,0 +1,24 @@
use crate::worker::DaemonClient;
use anyhow::Result;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
pub(crate) fn new_daemon_client() -> Result<Box<dyn DaemonClient>> {
#[cfg(target_os = "linux")]
{
Ok(Box::new(linux::DBusClient::new()?))
}
#[cfg(target_os = "macos")]
{
Ok(Box::new(macos::XpcClient::new()?))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
anyhow::bail!("Unsupported platform")
}
}

View File

@@ -0,0 +1,133 @@
use crate::platform;
use anyhow::Result;
use std::sync::mpsc;
use std::time::Duration;
#[derive(Clone, Debug)]
pub struct ConversationSummary {
pub id: String,
pub title: String,
pub preview: String,
pub unread_count: u32,
pub date_unix: i64,
}
#[derive(Clone, Debug)]
pub struct ChatMessage {
pub sender: String,
pub text: String,
pub date_unix: i64,
}
pub enum Request {
RefreshConversations,
RefreshMessages { conversation_id: String },
SendMessage { conversation_id: String, text: String },
MarkRead { conversation_id: String },
SyncConversation { conversation_id: String },
}
pub enum Event {
Conversations(Vec<ConversationSummary>),
Messages {
conversation_id: String,
messages: Vec<ChatMessage>,
},
MessageSent {
conversation_id: String,
outgoing_id: Option<String>,
},
MarkedRead,
ConversationSyncTriggered { conversation_id: String },
ConversationsUpdated,
MessagesUpdated { conversation_id: String },
UpdateStreamReconnected,
Error(String),
}
pub fn spawn_worker(
request_rx: std::sync::mpsc::Receiver<Request>,
event_tx: std::sync::mpsc::Sender<Event>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let mut client = match platform::new_daemon_client() {
Ok(c) => c,
Err(e) => {
let _ = event_tx.send(Event::Error(format!("Failed to connect to daemon: {e}")));
return;
}
};
if let Err(e) = client.install_signal_handlers(event_tx.clone()) {
let _ = event_tx.send(Event::Error(format!("Failed to install daemon signals: {e}")));
}
loop {
match request_rx.recv_timeout(Duration::from_millis(100)) {
Ok(req) => {
let res = match req {
Request::RefreshConversations => client
.get_conversations(200, 0)
.map(Event::Conversations),
Request::RefreshMessages { conversation_id } => client
.get_messages(conversation_id.clone(), None)
.map(|messages| Event::Messages {
conversation_id,
messages,
}),
Request::SendMessage {
conversation_id,
text,
} => client
.send_message(conversation_id.clone(), text)
.map(|outgoing_id| Event::MessageSent {
conversation_id,
outgoing_id,
}),
Request::MarkRead { conversation_id } => client
.mark_conversation_as_read(conversation_id.clone())
.map(|_| Event::MarkedRead),
Request::SyncConversation { conversation_id } => client
.sync_conversation(conversation_id.clone())
.map(|_| Event::ConversationSyncTriggered { conversation_id }),
};
match res {
Ok(evt) => {
let _ = event_tx.send(evt);
}
Err(e) => {
let _ = event_tx.send(Event::Error(format!("{e}")));
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
if let Err(e) = client.poll(Duration::from_millis(0)) {
let _ = event_tx.send(Event::Error(format!("Daemon polling error: {e}")));
}
}
})
}
pub(crate) trait DaemonClient {
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>>;
fn get_messages(
&mut self,
conversation_id: String,
last_message_id: Option<String>,
) -> Result<Vec<ChatMessage>>;
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>>;
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
fn sync_conversation(&mut self, conversation_id: String) -> Result<()>;
fn install_signal_handlers(&mut self, _event_tx: mpsc::Sender<Event>) -> Result<()> {
Ok(())
}
fn poll(&mut self, timeout: Duration) -> Result<()> {
std::thread::sleep(timeout);
Ok(())
}
}

View File

@@ -33,8 +33,7 @@ dbus-tree = "0.9.2"
# D-Bus codegen only on Linux # D-Bus codegen only on Linux
[target.'cfg(target_os = "linux")'.build-dependencies] [target.'cfg(target_os = "linux")'.build-dependencies]
dbus-codegen = "0.10.0" dbus-codegen = { version = "0.10.0", default-features = false }
dbus-crossroads = "0.5.1"
# XPC (libxpc) interface for macOS IPC # XPC (libxpc) interface for macOS IPC
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
@@ -48,5 +47,18 @@ serde = { version = "1.0", features = ["derive"] }
assets = [ assets = [
{ source = "../target/release/kordophoned", dest = "/usr/libexec/kordophoned", mode = "755" }, { source = "../target/release/kordophoned", dest = "/usr/libexec/kordophoned", mode = "755" },
{ source = "../target/release/kpcli", dest = "/usr/bin/kpcli", mode = "755" }, { source = "../target/release/kpcli", dest = "/usr/bin/kpcli", mode = "755" },
{ source = "../target/release/kptui", dest = "/usr/bin/kptui", 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" },
] ]
[package.metadata.deb]
maintainer = "James Magahern <james@magahern.com>"
copyright = "2026, James Magahern <james@magahern.com>"
section = "net"
priority = "optional"
assets = [
["../target/release/kordophoned", "usr/libexec/kordophoned", "755"],
["../target/release/kpcli", "usr/bin/kpcli", "755"],
["../target/release/kptui", "usr/bin/kptui", "755"],
["include/net.buzzert.kordophonecd.service", "usr/share/dbus-1/services/net.buzzert.kordophonecd.service", "644"],
]

View File

@@ -14,6 +14,17 @@ strip -s target/release/kordophoned
cargo generate-rpm cargo generate-rpm
``` ```
# Building DEB
Make sure cargo-deb is installed, `cargo install cargo-deb`.
Then:
```bash
cd core
cargo deb -p kordophoned
```
## Running on macOS ## Running on macOS
Before any client can talk to the kordophone daemon on macOS, the XPC service needs to be manually registered with launchd. Before any client can talk to the kordophone daemon on macOS, the XPC service needs to be manually registered with launchd.
@@ -34,4 +45,3 @@ and the following in Info.plist:
<key>MachServices</key><dict><key>net.buzzert.kordophonecd</key><true/></dict> <key>MachServices</key><dict><key>net.buzzert.kordophonecd</key><true/></dict>
<key>KeepAlive</key><true/> <key>KeepAlive</key><true/>
``` ```

View File

@@ -27,3 +27,4 @@ fn main() {
println!("cargo:rerun-if-changed={}", KORDOPHONE_XML); println!("cargo:rerun-if-changed={}", KORDOPHONE_XML);
} }

View File

@@ -73,14 +73,13 @@
'sender' (string): Sender display name 'sender' (string): Sender display name
'attachments' (array of dictionaries): List of attachments 'attachments' (array of dictionaries): List of attachments
'guid' (string): Attachment GUID 'guid' (string): Attachment GUID
'path' (string): Attachment path
'preview_path' (string): Preview attachment path
'downloaded' (boolean): Whether the attachment is downloaded 'downloaded' (boolean): Whether the attachment is downloaded
'preview_downloaded' (boolean): Whether the preview is downloaded 'preview_downloaded' (boolean): Whether the preview is downloaded
'metadata' (dictionary, optional): Attachment metadata 'metadata' (dictionary, optional): Attachment metadata
'attribution_info' (dictionary, optional): Attribution info 'attribution_info' (dictionary, optional): Attribution info
'width' (int32, optional): Width 'width' (int32, optional): Width
'height' (int32, optional): Height"/> 'height' (int32, optional): Height
Use GetAttachmentInfo for full/preview paths."/>
</arg> </arg>
</method> </method>
@@ -129,6 +128,20 @@
"/> "/>
</method> </method>
<method name="OpenAttachmentFd">
<arg type="s" name="attachment_id" direction="in"/>
<arg type="b" name="preview" direction="in"/>
<arg type="h" name="fd" direction="out"/>
<annotation name="org.freedesktop.DBus.DocString"
value="Opens a read-only file descriptor for an attachment path.
Arguments:
attachment_id: the attachment GUID
preview: whether to open the preview path (true) or full path (false)
Returns:
fd: a Unix file descriptor to read attachment bytes"/>
</method>
<method name="DownloadAttachment"> <method name="DownloadAttachment">
<arg type="s" name="attachment_id" direction="in"/> <arg type="s" name="attachment_id" direction="in"/>
<arg type="b" name="preview" direction="in"/> <arg type="b" name="preview" direction="in"/>

View File

@@ -115,39 +115,57 @@ impl AttachmentStore {
base_path: base_path, base_path: base_path,
metadata: None, metadata: None,
mime_type: None, mime_type: None,
cached_full_path: None,
cached_preview_path: None,
}; };
// Best-effort: if a file already exists, try to infer MIME type from extension // Best-effort: if files already exist, cache their exact paths and infer MIME type.
let kind = "full";
let stem = attachment let stem = attachment
.base_path .base_path
.file_name() .file_name()
.map(|s| s.to_string_lossy().to_string()) .map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(); .unwrap_or_default();
let legacy = attachment.base_path.with_extension(kind);
let existing_path = if legacy.exists() { let legacy_full = attachment.base_path.with_extension("full");
Some(legacy) if legacy_full.exists() {
} else { attachment.cached_full_path = Some(legacy_full);
let prefix = format!("{}.{}.", stem, kind); }
let legacy_preview = attachment.base_path.with_extension("preview");
if legacy_preview.exists() {
attachment.cached_preview_path = Some(legacy_preview);
}
if attachment.cached_full_path.is_none() || attachment.cached_preview_path.is_none() {
let full_prefix = format!("{}.full.", stem);
let preview_prefix = format!("{}.preview.", stem);
let parent = attachment let parent = attachment
.base_path .base_path
.parent() .parent()
.unwrap_or_else(|| std::path::Path::new(".")); .unwrap_or_else(|| std::path::Path::new("."));
let mut found: Option<PathBuf> = None;
if let Ok(entries) = std::fs::read_dir(parent) { if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() { for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string(); 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 !name.ends_with(".download") {
if let Some(m) = mime_guess::from_path(&existing).first_raw() { if attachment.cached_full_path.is_none() && name.starts_with(&full_prefix) {
attachment.cached_full_path = Some(entry.path());
continue;
}
if attachment.cached_preview_path.is_none()
&& name.starts_with(&preview_prefix)
{
attachment.cached_preview_path = Some(entry.path());
}
}
}
}
}
if let Some(existing_full) = &attachment.cached_full_path {
if let Some(m) = mime_guess::from_path(existing_full).first_raw() {
attachment.mime_type = Some(m.to_string()); attachment.mime_type = Some(m.to_string());
} }
} }
@@ -342,6 +360,9 @@ impl AttachmentStore {
match kind { match kind {
AttachmentStoreError::AttachmentAlreadyDownloaded => { AttachmentStoreError::AttachmentAlreadyDownloaded => {
log::debug!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", &_guid); log::debug!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", &_guid);
let _ = daemon_event_sink
.send(DaemonEvent::AttachmentDownloaded(_guid.clone()))
.await;
} }
AttachmentStoreError::DownloadAlreadyInProgress => { AttachmentStoreError::DownloadAlreadyInProgress => {
// Already logged a warning where detected // Already logged a warning where detected
@@ -360,6 +381,10 @@ impl AttachmentStore {
log::debug!(target: target::ATTACHMENTS, "Queued download for attachment: {}", &guid); log::debug!(target: target::ATTACHMENTS, "Queued download for attachment: {}", &guid);
} else { } else {
log::debug!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", guid); log::debug!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", guid);
let _ = self
.daemon_event_sink
.send(DaemonEvent::AttachmentDownloaded(guid))
.await;
} }
} }

View File

@@ -6,6 +6,8 @@ use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Clone)] #[derive(Clone)]
pub struct EDSContactResolverBackend; pub struct EDSContactResolverBackend;
@@ -189,11 +191,10 @@ impl ContactResolverBackend for EDSContactResolverBackend {
None => return None, None => return None,
}; };
let address_book_proxy = handle.connection.with_proxy( let address_book_proxy =
&handle.bus_name, handle
&handle.object_path, .connection
Duration::from_secs(60), .with_proxy(&handle.bus_name, &handle.object_path, LOOKUP_TIMEOUT);
);
ensure_address_book_open(&address_book_proxy); ensure_address_book_open(&address_book_proxy);
@@ -255,11 +256,10 @@ impl ContactResolverBackend for EDSContactResolverBackend {
None => return None, None => return None,
}; };
let address_book_proxy = handle.connection.with_proxy( let address_book_proxy =
&handle.bus_name, handle
&handle.object_path, .connection
Duration::from_secs(60), .with_proxy(&handle.bus_name, &handle.object_path, LOOKUP_TIMEOUT);
);
ensure_address_book_open(&address_book_proxy); ensure_address_book_open(&address_book_proxy);

View File

@@ -47,8 +47,8 @@ pub type AnyContactID = String;
#[derive(Clone)] #[derive(Clone)]
pub struct ContactResolver<T: ContactResolverBackend> { pub struct ContactResolver<T: ContactResolverBackend> {
backend: T, backend: T,
display_name_cache: HashMap<AnyContactID, String>, display_name_cache: HashMap<AnyContactID, Option<String>>,
contact_id_cache: HashMap<String, AnyContactID>, contact_id_cache: HashMap<String, Option<AnyContactID>>,
} }
impl<T: ContactResolverBackend> ContactResolver<T> impl<T: ContactResolverBackend> ContactResolver<T>
@@ -67,29 +67,25 @@ where
pub fn resolve_contact_id(&mut self, address: &str) -> Option<AnyContactID> { pub fn resolve_contact_id(&mut self, address: &str) -> Option<AnyContactID> {
if let Some(id) = self.contact_id_cache.get(address) { if let Some(id) = self.contact_id_cache.get(address) {
return Some(id.clone()); return id.clone();
} }
let id = self.backend.resolve_contact_id(address).map(|id| id.into()); let id = self.backend.resolve_contact_id(address).map(|id| id.into());
if let Some(ref id) = id {
self.contact_id_cache self.contact_id_cache
.insert(address.to_string(), id.clone()); .insert(address.to_string(), id.clone());
}
id id
} }
pub fn get_contact_display_name(&mut self, contact_id: &AnyContactID) -> Option<String> { pub fn get_contact_display_name(&mut self, contact_id: &AnyContactID) -> Option<String> {
if let Some(display_name) = self.display_name_cache.get(contact_id) { if let Some(display_name) = self.display_name_cache.get(contact_id) {
return Some(display_name.clone()); return display_name.clone();
} }
let backend_contact_id: T::ContactID = T::ContactID::from((*contact_id).clone()); let backend_contact_id: T::ContactID = T::ContactID::from((*contact_id).clone());
let display_name = self.backend.get_contact_display_name(&backend_contact_id); let display_name = self.backend.get_contact_display_name(&backend_contact_id);
if let Some(ref display_name) = display_name {
self.display_name_cache self.display_name_cache
.insert(contact_id.to_string(), display_name.clone()); .insert(contact_id.to_string(), display_name.clone());
}
display_name display_name
} }

View File

@@ -15,6 +15,7 @@ use std::collections::HashMap;
use std::error::Error; use std::error::Error;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant;
use thiserror::Error; use thiserror::Error;
use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::mpsc::{Receiver, Sender};
@@ -72,6 +73,8 @@ pub mod target {
pub static DAEMON: &str = "daemon"; pub static DAEMON: &str = "daemon";
} }
const GET_MESSAGES_INITIAL_WINDOW: usize = 300;
pub struct Daemon { pub struct Daemon {
pub event_sender: Sender<Event>, pub event_sender: Sender<Event>,
event_receiver: Receiver<Event>, event_receiver: Receiver<Event>,
@@ -185,14 +188,14 @@ impl Daemon {
async fn handle_event(&mut self, event: Event) { async fn handle_event(&mut self, event: Event) {
match event { match event {
Event::GetVersion(reply) => { Event::GetVersion(reply) => {
reply.send(self.version.clone()).unwrap(); let _ = reply.send(self.version.clone());
} }
Event::SyncConversationList(reply) => { Event::SyncConversationList(reply) => {
self.spawn_conversation_list_sync(); self.spawn_conversation_list_sync();
// This is a background operation, so return right away. // This is a background operation, so return right away.
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::SyncAllConversations(reply) => { Event::SyncAllConversations(reply) => {
@@ -207,7 +210,7 @@ impl Daemon {
}); });
// This is a background operation, so return right away. // This is a background operation, so return right away.
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::SyncConversation(conversation_id, reply) => { Event::SyncConversation(conversation_id, reply) => {
@@ -225,7 +228,7 @@ impl Daemon {
} }
}); });
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::MarkConversationAsRead(conversation_id, reply) => { Event::MarkConversationAsRead(conversation_id, reply) => {
@@ -237,7 +240,7 @@ impl Daemon {
} }
}); });
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::UpdateConversationMetadata(conversation, reply) => { Event::UpdateConversationMetadata(conversation, reply) => {
@@ -250,7 +253,7 @@ impl Daemon {
} }
}); });
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::UpdateStreamReconnected => { Event::UpdateStreamReconnected => {
@@ -268,7 +271,7 @@ impl Daemon {
Event::GetAllConversations(limit, offset, reply) => { Event::GetAllConversations(limit, offset, reply) => {
let conversations = self.get_conversations_limit_offset(limit, offset).await; let conversations = self.get_conversations_limit_offset(limit, offset).await;
reply.send(conversations).unwrap(); let _ = reply.send(conversations);
} }
Event::GetAllSettings(reply) => { Event::GetAllSettings(reply) => {
@@ -277,7 +280,7 @@ impl Daemon {
Settings::default() Settings::default()
}); });
reply.send(settings).unwrap(); let _ = reply.send(settings);
} }
Event::UpdateSettings(settings, reply) => { Event::UpdateSettings(settings, reply) => {
@@ -309,12 +312,14 @@ impl Daemon {
} }
} }
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::GetMessages(conversation_id, last_message_id, reply) => { Event::GetMessages(conversation_id, last_message_id, reply) => {
let messages = self.get_messages(conversation_id, last_message_id).await; let messages = self.get_messages(conversation_id, last_message_id).await;
reply.send(messages).unwrap(); if reply.send(messages).is_err() {
log::warn!(target: target::EVENT, "GetMessages reply receiver dropped before send");
}
} }
Event::DeleteAllConversations(reply) => { Event::DeleteAllConversations(reply) => {
@@ -322,7 +327,7 @@ impl Daemon {
log::error!(target: target::SYNC, "Failed to delete all conversations: {}", e); log::error!(target: target::SYNC, "Failed to delete all conversations: {}", e);
}); });
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::SendMessage(conversation_id, text, attachment_guids, reply) => { Event::SendMessage(conversation_id, text, attachment_guids, reply) => {
@@ -330,7 +335,7 @@ impl Daemon {
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(); let _ = reply.send(uuid);
// Send message updated signal, we have a placeholder message we will return. // Send message updated signal, we have a placeholder message we will return.
self.signal_sender self.signal_sender
@@ -347,7 +352,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();
@@ -386,7 +400,7 @@ impl Daemon {
.await .await
.unwrap(); .unwrap();
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::AttachmentDownloaded(attachment_id) => { Event::AttachmentDownloaded(attachment_id) => {
@@ -439,8 +453,9 @@ impl Daemon {
async fn get_messages( async fn get_messages(
&mut self, &mut self,
conversation_id: String, conversation_id: String,
_last_message_id: Option<MessageID>, last_message_id: Option<MessageID>,
) -> Vec<Message> { ) -> Vec<Message> {
let started = Instant::now();
// Get outgoing messages for this conversation. // Get outgoing messages for this conversation.
let empty_vec: Vec<OutgoingMessage> = vec![]; let empty_vec: Vec<OutgoingMessage> = vec![];
let outgoing_messages: &Vec<OutgoingMessage> = self let outgoing_messages: &Vec<OutgoingMessage> = self
@@ -448,18 +463,59 @@ 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());
}
if let Some(last_id) = last_message_id {
if let Some(last_index) = result.iter().position(|message| message.id == last_id) {
result = result.split_off(last_index + 1);
}
} else if result.len() > GET_MESSAGES_INITIAL_WINDOW {
let dropped = result.len() - GET_MESSAGES_INITIAL_WINDOW;
result = result.split_off(dropped);
log::debug!(
target: target::EVENT,
"GetMessages initial window applied: dropped {} older messages",
dropped
);
}
log::debug!(
target: target::EVENT,
"GetMessages completed in {}ms: {} messages",
started.elapsed().as_millis(),
result.len()
);
result
} }
async fn enqueue_outgoing_message( async fn enqueue_outgoing_message(

View File

@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf}; use std::path::PathBuf;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AttachmentMetadata { pub struct AttachmentMetadata {
@@ -17,6 +17,8 @@ pub struct Attachment {
pub base_path: PathBuf, pub base_path: PathBuf,
pub metadata: Option<AttachmentMetadata>, pub metadata: Option<AttachmentMetadata>,
pub mime_type: Option<String>, pub mime_type: Option<String>,
pub cached_full_path: Option<PathBuf>,
pub cached_preview_path: Option<PathBuf>,
} }
impl Attachment { impl Attachment {
@@ -25,8 +27,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")
@@ -45,17 +46,21 @@ 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 {
if !scratch {
let cached = if preview {
self.cached_preview_path.as_ref()
} else {
self.cached_full_path.as_ref()
};
if let Some(path) = cached {
return path.clone();
}
}
// Determine whether this is a preview or full attachment. // Determine whether this is a preview or full attachment.
let kind = if preview { "preview" } else { "full" }; 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, // 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. // prefer an extension guessed from it; otherwise keep legacy naming.
let ext_from_mime = self let ext_from_mime = self
@@ -77,44 +82,15 @@ impl Attachment {
} }
pub fn is_downloaded(&self, preview: bool) -> bool { pub fn is_downloaded(&self, preview: bool) -> bool {
std::fs::exists(&self.get_path_for_preview(preview)).expect( let path = self.get_path_for_preview(preview);
std::fs::exists(&path).expect(
format!( format!(
"Wasn't able to check for the existence of an attachment file path at {}", "Wasn't able to check for the existence of an attachment file path at {}",
&self.get_path_for_preview(preview).display() path.display()
) )
.as_str(), .as_str(),
) )
} }
fn find_existing_path(&self, preview: bool) -> Option<PathBuf> {
let kind = if preview { "preview" } else { "full" };
// First, check legacy path without a concrete file extension.
let legacy = self.base_path.with_extension(kind);
if legacy.exists() {
return Some(legacy);
}
// Next, search for a filename like: <guid>.<kind>.<ext>
let file_stem = self
.base_path
.file_name()
.map(|s| s.to_string_lossy().to_string())?;
let prefix = format!("{}.{}.", file_stem, kind);
let parent = self.base_path.parent().unwrap_or_else(|| Path::new("."));
if let Ok(dir) = std::fs::read_dir(parent) {
for entry in dir.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name.starts_with(&prefix) && !name.ends_with(".download") {
return Some(entry.path());
}
}
}
None
}
} }
impl From<kordophone::model::message::AttachmentMetadata> for AttachmentMetadata { impl From<kordophone::model::message::AttachmentMetadata> for AttachmentMetadata {

View File

@@ -1,6 +1,9 @@
use dbus::arg; use dbus::arg;
use dbus_tree::MethodErr; use dbus_tree::MethodErr;
use std::fs::OpenOptions;
use std::os::fd::{FromRawFd, IntoRawFd};
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant;
use std::{future::Future, thread}; use std::{future::Future, thread};
use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::sync::{mpsc, oneshot, Mutex};
@@ -176,9 +179,8 @@ impl DBusAgent {
&self, &self,
make_event: impl FnOnce(Reply<T>) -> Event + Send, make_event: impl FnOnce(Reply<T>) -> Event + Send,
) -> Result<T, MethodErr> { ) -> Result<T, MethodErr> {
run_sync_future(self.send_event(make_event)) let daemon_result = run_sync_future(self.send_event(make_event))?;
.unwrap() daemon_result.map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e)))
.map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e)))
} }
fn resolve_participant_display_name(&mut self, participant: &Participant) -> String { fn resolve_participant_display_name(&mut self, participant: &Participant) -> String {
@@ -278,15 +280,20 @@ impl DbusRepository for DBusAgent {
conversation_id: String, conversation_id: String,
last_message_id: String, last_message_id: String,
) -> Result<Vec<arg::PropMap>, MethodErr> { ) -> Result<Vec<arg::PropMap>, MethodErr> {
let started = Instant::now();
let last_message_id_opt = if last_message_id.is_empty() { let last_message_id_opt = if last_message_id.is_empty() {
None None
} else { } else {
Some(last_message_id) Some(last_message_id)
}; };
self.send_event_sync(|r| Event::GetMessages(conversation_id, last_message_id_opt, r)) let messages =
.map(|messages| { self.send_event_sync(|r| Event::GetMessages(conversation_id, last_message_id_opt, r))?;
messages
let mut attachment_count: usize = 0;
let mut text_bytes: usize = 0;
let mapped: Vec<arg::PropMap> = messages
.into_iter() .into_iter()
.map(|msg| { .map(|msg| {
let mut map = arg::PropMap::new(); let mut map = arg::PropMap::new();
@@ -294,6 +301,7 @@ impl DbusRepository for DBusAgent {
// Remove the attachment placeholder here. // Remove the attachment placeholder here.
let text = msg.text.replace("\u{FFFC}", ""); let text = msg.text.replace("\u{FFFC}", "");
text_bytes += text.len();
map.insert("text".into(), arg::Variant(Box::new(text))); map.insert("text".into(), arg::Variant(Box::new(text)));
map.insert( map.insert(
@@ -302,48 +310,29 @@ impl DbusRepository for DBusAgent {
); );
map.insert( map.insert(
"sender".into(), "sender".into(),
arg::Variant(Box::new( arg::Variant(Box::new(msg.sender.display_name())),
self.resolve_participant_display_name(&msg.sender.into()),
)),
); );
// Attachments array if !msg.attachments.is_empty() {
let attachments: Vec<arg::PropMap> = msg let attachments: Vec<arg::PropMap> = msg
.attachments .attachments
.into_iter() .into_iter()
.map(|attachment| { .map(|attachment| {
attachment_count += 1;
let mut attachment_map = arg::PropMap::new(); let mut attachment_map = arg::PropMap::new();
attachment_map.insert( attachment_map.insert(
"guid".into(), "guid".into(),
arg::Variant(Box::new(attachment.guid.clone())), arg::Variant(Box::new(attachment.guid.clone())),
); );
// Paths and download status
let path = attachment.get_path_for_preview(false);
let preview_path = attachment.get_path_for_preview(true);
let downloaded = attachment.is_downloaded(false);
let preview_downloaded = attachment.is_downloaded(true);
attachment_map.insert(
"path".into(),
arg::Variant(Box::new(path.to_string_lossy().to_string())),
);
attachment_map.insert(
"preview_path".into(),
arg::Variant(Box::new(
preview_path.to_string_lossy().to_string(),
)),
);
attachment_map.insert( attachment_map.insert(
"downloaded".into(), "downloaded".into(),
arg::Variant(Box::new(downloaded)), arg::Variant(Box::new(attachment.is_downloaded(false))),
); );
attachment_map.insert( attachment_map.insert(
"preview_downloaded".into(), "preview_downloaded".into(),
arg::Variant(Box::new(preview_downloaded)), arg::Variant(Box::new(attachment.is_downloaded(true))),
); );
// Metadata
if let Some(ref metadata) = attachment.metadata { if let Some(ref metadata) = attachment.metadata {
let mut metadata_map = arg::PropMap::new(); let mut metadata_map = arg::PropMap::new();
@@ -372,16 +361,27 @@ impl DbusRepository for DBusAgent {
arg::Variant(Box::new(metadata_map)), arg::Variant(Box::new(metadata_map)),
); );
} }
attachment_map attachment_map
}) })
.collect(); .collect();
map.insert("attachments".into(), arg::Variant(Box::new(attachments))); map.insert("attachments".into(), arg::Variant(Box::new(attachments)));
}
map map
}) })
.collect() .collect();
})
log::debug!(
target: "dbus",
"GetMessages mapped in {}ms: {} messages, {} attachments, {} text-bytes",
started.elapsed().as_millis(),
mapped.len(),
attachment_count,
text_bytes
);
Ok(mapped)
} }
fn delete_all_conversations(&mut self) -> Result<(), MethodErr> { fn delete_all_conversations(&mut self) -> Result<(), MethodErr> {
@@ -425,6 +425,23 @@ impl DbusRepository for DBusAgent {
self.send_event_sync(|r| Event::DownloadAttachment(attachment_id, preview, r)) self.send_event_sync(|r| Event::DownloadAttachment(attachment_id, preview, r))
} }
fn open_attachment_fd(
&mut self,
attachment_id: String,
preview: bool,
) -> Result<arg::OwnedFd, MethodErr> {
let attachment = self.send_event_sync(|r| Event::GetAttachment(attachment_id, r))?;
let path = attachment.get_path_for_preview(preview);
let file = OpenOptions::new()
.read(true)
.open(&path)
.map_err(|e| MethodErr::failed(&format!("Failed to open attachment: {}", e)))?;
let fd = file.into_raw_fd();
Ok(unsafe { arg::OwnedFd::from_raw_fd(fd) })
}
fn upload_attachment(&mut self, path: String) -> Result<String, MethodErr> { fn upload_attachment(&mut self, path: String) -> Result<String, MethodErr> {
use std::path::PathBuf; use std::path::PathBuf;
let path = PathBuf::from(path); let path = PathBuf::from(path);
@@ -496,7 +513,7 @@ where
T: Send, T: Send,
F: Future<Output = T> + Send, F: Future<Output = T> + Send,
{ {
thread::scope(move |s| { let joined = thread::scope(move |s| {
s.spawn(move || { s.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
@@ -507,6 +524,10 @@ where
Ok(result) Ok(result)
}) })
.join() .join()
}) });
.expect("Error joining runtime thread")
match joined {
Ok(result) => result,
Err(_) => Err(MethodErr::failed("Error joining runtime thread")),
}
} }

View File

@@ -28,7 +28,7 @@ dbus-tree = "0.9.2"
# D-Bus codegen only on Linux # D-Bus codegen only on Linux
[target.'cfg(target_os = "linux")'.build-dependencies] [target.'cfg(target_os = "linux")'.build-dependencies]
dbus-codegen = "0.10.0" dbus-codegen = { version = "0.10.0", default-features = false }
# XPC (libxpc) interface only on macOS # XPC (libxpc) interface only on macOS
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]

View File

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

View File

@@ -33,7 +33,7 @@ impl DBusDaemonInterface {
fn proxy(&self) -> Proxy<&Connection> { fn proxy(&self) -> Proxy<&Connection> {
self.conn self.conn
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000)) .with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_secs(30))
} }
async fn print_settings(&mut self) -> Result<()> { async fn print_settings(&mut self) -> Result<()> {

12
core/kptui/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "kptui"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.93"
crossterm = "0.28.1"
kordophoned-client = { path = "../kordophoned-client" }
ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
time = { version = "0.3.37", features = ["formatting"] }
unicode-width = "0.2.0"

789
core/kptui/src/main.rs Normal file
View File

@@ -0,0 +1,789 @@
use kordophoned_client as daemon;
use anyhow::Result;
use crossterm::event::{Event as CEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::prelude::*;
use ratatui::widgets::*;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use unicode_width::UnicodeWidthStr;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ViewMode {
List,
Chat,
Split,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus {
Navigation,
Input,
}
struct AppState {
conversations: Vec<daemon::ConversationSummary>,
selected_idx: usize,
selected_conversation_id: Option<String>,
messages: Vec<daemon::ChatMessage>,
active_conversation_id: Option<String>,
active_conversation_title: String,
status: String,
input: String,
focus: Focus,
transcript_scroll: u16,
pinned_to_bottom: bool,
refresh_conversations_in_flight: bool,
refresh_messages_in_flight: bool,
}
impl AppState {
fn new() -> Self {
Self {
conversations: Vec::new(),
selected_idx: 0,
selected_conversation_id: None,
messages: Vec::new(),
active_conversation_id: None,
active_conversation_title: String::new(),
status: String::new(),
input: String::new(),
focus: Focus::Navigation,
transcript_scroll: 0,
pinned_to_bottom: true,
refresh_conversations_in_flight: false,
refresh_messages_in_flight: false,
}
}
fn select_next(&mut self) {
if self.conversations.is_empty() {
self.selected_idx = 0;
self.selected_conversation_id = None;
return;
}
self.selected_idx = (self.selected_idx + 1).min(self.conversations.len() - 1);
self.selected_conversation_id = self
.conversations
.get(self.selected_idx)
.map(|c| c.id.clone());
}
fn select_prev(&mut self) {
if self.conversations.is_empty() {
self.selected_idx = 0;
self.selected_conversation_id = None;
return;
}
self.selected_idx = self.selected_idx.saturating_sub(1);
self.selected_conversation_id = self
.conversations
.get(self.selected_idx)
.map(|c| c.id.clone());
}
fn open_selected_conversation(&mut self) {
if let Some(conv) = self.conversations.get(self.selected_idx) {
self.active_conversation_id = Some(conv.id.clone());
self.active_conversation_title = conv.title.clone();
self.selected_conversation_id = Some(conv.id.clone());
self.messages.clear();
self.transcript_scroll = 0;
self.pinned_to_bottom = true;
self.focus = Focus::Input;
self.status = "Loading…".to_string();
}
}
}
fn view_mode(width: u16, has_active_conversation: bool, requested: ViewMode) -> ViewMode {
let min_conversations = 24u16;
let min_chat = 44u16;
let min_total = min_conversations + 1 + min_chat;
if width >= min_total {
return ViewMode::Split;
}
if has_active_conversation {
requested
} else {
ViewMode::List
}
}
fn ui(frame: &mut Frame, app: &AppState, requested_view: ViewMode) {
let area = frame.area();
let mode = view_mode(
area.width,
app.active_conversation_id.is_some(),
requested_view,
);
let show_input =
matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();
let chunks = if show_input {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(1),
])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(area)
};
let (main_area, input_area, status_area) = if show_input {
(chunks[0], Some(chunks[1]), chunks[2])
} else {
(chunks[0], None, chunks[1])
};
match mode {
ViewMode::Split => {
let left_width = (main_area.width / 3).clamp(24, 40);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(left_width), Constraint::Min(1)])
.split(main_area);
render_conversations(frame, app, cols[0], true);
render_transcript(frame, app, cols[1], true);
}
ViewMode::List => render_conversations(frame, app, main_area, false),
ViewMode::Chat => render_transcript(frame, app, main_area, false),
}
if let Some(input_area) = input_area {
let input_scroll_x = render_input(frame, app, input_area);
if app.focus == Focus::Input {
let cursor_col = visual_width_u16(app.input.as_str());
let mut x = input_area
.x
.saturating_add(1)
.saturating_add(cursor_col.saturating_sub(input_scroll_x));
let max_x = input_area
.x
.saturating_add(input_area.width.saturating_sub(2));
x = x.min(max_x);
let y = input_area.y + 1;
frame.set_cursor_position(Position { x, y });
}
}
render_status(frame, app, status_area, mode);
}
fn render_conversations(frame: &mut Frame, app: &AppState, area: Rect, _in_split: bool) {
let title = "Kordophone";
let items = app
.conversations
.iter()
.map(|c| {
let is_active = app.active_conversation_id.as_deref() == Some(c.id.as_str());
let header = Line::from(vec![
Span::styled(
if c.unread_count > 0 { "" } else { " " },
Style::default()
.fg(if c.unread_count > 0 {
Color::LightYellow
} else {
Color::DarkGray
})
.add_modifier(Modifier::BOLD),
),
Span::styled(
c.title.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
]);
let preview = Line::from(Span::styled(
c.preview.clone(),
Style::default().fg(if is_active {
Color::Gray
} else {
Color::DarkGray
}),
));
ListItem::new(vec![header, preview]).style(if is_active {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
})
})
.collect::<Vec<_>>();
let mut state = ListState::default();
state.select(if app.conversations.is_empty() {
None
} else {
Some(app.selected_idx)
});
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("");
frame.render_stateful_widget(list, area, &mut state);
}
fn render_transcript(frame: &mut Frame, app: &AppState, area: Rect, in_split: bool) {
let title = if let Some(_) = app.active_conversation_id {
if in_split {
format!("{} (Esc: nav, Tab: focus)", app.active_conversation_title)
} else {
format!("{} (Esc: back)", app.active_conversation_title)
}
} else {
"Chat".to_string()
};
let lines = transcript_lines(&app.messages);
let paragraph = Paragraph::new(Text::from(lines))
.block(Block::default().borders(Borders::ALL).title(title))
.wrap(Wrap { trim: false })
.scroll((app.transcript_scroll, 0));
frame.render_widget(paragraph, area);
}
fn render_input(frame: &mut Frame, app: &AppState, area: Rect) -> u16 {
let title = if app.focus == Focus::Input {
"Reply (Enter to send)"
} else {
"Reply (press i to type)"
};
let inner_width = area.width.saturating_sub(2).max(1);
let cursor_col = visual_width_u16(app.input.as_str());
let input_scroll_x = cursor_col.saturating_sub(inner_width.saturating_sub(1));
let input = Paragraph::new(app.input.as_str())
.block(Block::default().borders(Borders::ALL).title(title))
.scroll((0, input_scroll_x));
frame.render_widget(input, area);
input_scroll_x
}
fn render_status(frame: &mut Frame, app: &AppState, area: Rect, mode: ViewMode) {
let mut parts = vec![
format!("{} convs", app.conversations.len()),
match mode {
ViewMode::Split => "split".to_string(),
ViewMode::List => "list".to_string(),
ViewMode::Chat => "chat".to_string(),
},
];
if !app.status.trim().is_empty() {
parts.push(app.status.clone());
}
let line = parts.join(" | ");
frame.render_widget(
Paragraph::new(line).block(Block::default().borders(Borders::TOP)),
area,
);
}
fn main() -> Result<()> {
enable_raw_mode()?;
let mut stdout = std::io::stdout();
crossterm::execute!(
stdout,
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableMouseCapture
)?;
let backend = ratatui::backend::CrosstermBackend::new(stdout);
let mut terminal = ratatui::Terminal::new(backend)?;
let res = run_app(&mut terminal);
disable_raw_mode()?;
crossterm::execute!(
terminal.backend_mut(),
crossterm::event::DisableMouseCapture,
crossterm::terminal::LeaveAlternateScreen
)?;
terminal.show_cursor()?;
res
}
fn run_app(
terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
) -> Result<()> {
let (request_tx, request_rx) = mpsc::channel::<daemon::Request>();
let (event_tx, event_rx) = mpsc::channel::<daemon::Event>();
let _worker = daemon::spawn_worker(request_rx, event_tx);
let tick_rate = Duration::from_millis(150);
let refresh_rate = Duration::from_secs(2);
let mut last_tick = Instant::now();
let mut last_refresh = Instant::now() - refresh_rate;
let mut requested_view = ViewMode::List;
let mut app = AppState::new();
app.status = "Connecting…".to_string();
request_tx.send(daemon::Request::RefreshConversations).ok();
app.refresh_conversations_in_flight = true;
loop {
let size = terminal.size()?;
while let Ok(evt) = event_rx.try_recv() {
match evt {
daemon::Event::Conversations(convs) => {
let keep_selected_id = app
.selected_conversation_id
.clone()
.or_else(|| app.active_conversation_id.clone());
app.refresh_conversations_in_flight = false;
app.status.clear();
app.conversations = convs;
if app.conversations.is_empty() {
app.selected_idx = 0;
app.selected_conversation_id = None;
} else if let Some(id) = keep_selected_id {
if let Some(idx) = app.conversations.iter().position(|c| c.id == id) {
app.selected_idx = idx;
app.selected_conversation_id = Some(id);
} else {
app.selected_idx = 0;
app.selected_conversation_id = Some(app.conversations[0].id.clone());
}
} else {
app.selected_idx = app.selected_idx.min(app.conversations.len() - 1);
app.selected_conversation_id =
Some(app.conversations[app.selected_idx].id.clone());
}
}
daemon::Event::Messages {
conversation_id,
messages,
} => {
app.refresh_messages_in_flight = false;
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
let was_pinned = app.pinned_to_bottom;
app.messages = messages;
app.pinned_to_bottom = was_pinned;
}
}
daemon::Event::MessageSent {
conversation_id,
outgoing_id,
} => {
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
app.status = outgoing_id
.as_deref()
.map(|id| format!("Sent ({id})"))
.unwrap_or_else(|| "Sent".to_string());
app.refresh_messages_in_flight = false;
request_tx
.send(daemon::Request::RefreshMessages { conversation_id })
.ok();
app.refresh_messages_in_flight = true;
}
}
daemon::Event::MarkedRead => {}
daemon::Event::ConversationSyncTriggered { conversation_id } => {
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
app.status = "Syncing…".to_string();
}
}
daemon::Event::ConversationsUpdated => {
if !app.refresh_conversations_in_flight {
request_tx.send(daemon::Request::RefreshConversations).ok();
app.refresh_conversations_in_flight = true;
}
if let Some(cid) = app.active_conversation_id.clone() {
if !app.refresh_messages_in_flight {
request_tx
.send(daemon::Request::RefreshMessages {
conversation_id: cid,
})
.ok();
app.refresh_messages_in_flight = true;
}
}
}
daemon::Event::MessagesUpdated { conversation_id } => {
if !app.refresh_conversations_in_flight {
request_tx.send(daemon::Request::RefreshConversations).ok();
app.refresh_conversations_in_flight = true;
}
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
if !app.refresh_messages_in_flight {
request_tx
.send(daemon::Request::RefreshMessages { conversation_id })
.ok();
app.refresh_messages_in_flight = true;
}
}
}
daemon::Event::UpdateStreamReconnected => {
if !app.refresh_conversations_in_flight {
request_tx.send(daemon::Request::RefreshConversations).ok();
app.refresh_conversations_in_flight = true;
}
if let Some(cid) = app.active_conversation_id.clone() {
if !app.refresh_messages_in_flight {
request_tx
.send(daemon::Request::RefreshMessages {
conversation_id: cid,
})
.ok();
app.refresh_messages_in_flight = true;
}
}
}
daemon::Event::Error(e) => {
app.refresh_conversations_in_flight = false;
app.refresh_messages_in_flight = false;
app.status = e;
}
}
}
apply_transcript_scroll_policy(&mut app, size, requested_view);
terminal.draw(|f| ui(f, &app, requested_view))?;
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if crossterm::event::poll(timeout)? {
if let CEvent::Key(key) = crossterm::event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match (key.code, ctrl) {
(KeyCode::Char('c'), true) => return Ok(()),
_ => {}
}
let screen_mode = view_mode(
size.width,
app.active_conversation_id.is_some(),
requested_view,
);
let max_scroll = max_transcript_scroll(&app, size, requested_view);
match screen_mode {
ViewMode::List => match key.code {
KeyCode::Up => app.select_prev(),
KeyCode::Down => app.select_next(),
KeyCode::Enter => {
app.open_selected_conversation();
if app.active_conversation_id.is_some() {
requested_view = ViewMode::Chat;
if let Some(cid) = app.active_conversation_id.clone() {
request_tx
.send(daemon::Request::MarkRead {
conversation_id: cid.clone(),
})
.ok();
request_tx
.send(daemon::Request::SyncConversation {
conversation_id: cid.clone(),
})
.ok();
request_tx
.send(daemon::Request::RefreshMessages {
conversation_id: cid,
})
.ok();
app.refresh_messages_in_flight = true;
}
}
}
_ => {}
},
ViewMode::Chat => match key.code {
KeyCode::Esc => {
requested_view = ViewMode::List;
app.focus = Focus::Navigation;
}
KeyCode::Char('i') if app.focus != Focus::Input => app.focus = Focus::Input,
_ => {
handle_chat_keys(&mut app, &request_tx, key, max_scroll);
}
},
ViewMode::Split => match key.code {
KeyCode::Tab => {
app.focus = match app.focus {
Focus::Navigation => Focus::Input,
Focus::Input => Focus::Navigation,
}
}
KeyCode::Esc => app.focus = Focus::Navigation,
KeyCode::Char('i') if app.focus != Focus::Input => app.focus = Focus::Input,
KeyCode::Up => {
if app.focus == Focus::Navigation {
app.select_prev()
} else {
scroll_up(&mut app, 1);
}
}
KeyCode::Down => {
if app.focus == Focus::Navigation {
app.select_next()
} else {
scroll_down(&mut app, 1, max_scroll);
}
}
KeyCode::Enter => {
if app.focus == Focus::Navigation {
app.open_selected_conversation();
requested_view = ViewMode::Chat;
if let Some(cid) = app.active_conversation_id.clone() {
request_tx
.send(daemon::Request::MarkRead {
conversation_id: cid.clone(),
})
.ok();
request_tx
.send(daemon::Request::SyncConversation {
conversation_id: cid.clone(),
})
.ok();
request_tx
.send(daemon::Request::RefreshMessages {
conversation_id: cid,
})
.ok();
app.refresh_messages_in_flight = true;
}
} else {
handle_chat_keys(&mut app, &request_tx, key, max_scroll);
}
}
_ => handle_chat_keys(&mut app, &request_tx, key, max_scroll),
},
}
}
}
if last_refresh.elapsed() >= refresh_rate {
if !app.refresh_conversations_in_flight {
request_tx.send(daemon::Request::RefreshConversations).ok();
app.refresh_conversations_in_flight = true;
}
if let Some(cid) = app.active_conversation_id.clone() {
if !app.refresh_messages_in_flight {
request_tx
.send(daemon::Request::RefreshMessages {
conversation_id: cid,
})
.ok();
app.refresh_messages_in_flight = true;
}
}
last_refresh = Instant::now();
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
}
}
fn handle_chat_keys(
app: &mut AppState,
request_tx: &mpsc::Sender<daemon::Request>,
key: KeyEvent,
max_scroll: u16,
) {
let code = key.code;
let modifiers = key.modifiers;
match code {
KeyCode::PageUp => scroll_up(app, 10),
KeyCode::PageDown => scroll_down(app, 10, max_scroll),
_ => {}
}
if app.focus != Focus::Input {
return;
}
match code {
KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => {
app.input.clear();
}
KeyCode::Backspace if modifiers.contains(KeyModifiers::ALT) => {
delete_prev_word(&mut app.input);
}
KeyCode::Char('w') if modifiers.contains(KeyModifiers::CONTROL) => {
delete_prev_word(&mut app.input);
}
KeyCode::Enter => {
let text = app.input.trim().to_string();
if text.is_empty() {
return;
}
let Some(conversation_id) = app.active_conversation_id.clone() else {
app.status = "No conversation selected".to_string();
return;
};
request_tx
.send(daemon::Request::SendMessage {
conversation_id,
text,
})
.ok();
app.refresh_messages_in_flight = true;
app.input.clear();
}
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Char(c) => {
if !c.is_control() {
app.input.push(c);
}
}
_ => {}
}
}
fn delete_prev_word(input: &mut String) {
while input.chars().last().is_some_and(|c| c.is_whitespace()) {
input.pop();
}
while input.chars().last().is_some_and(|c| !c.is_whitespace()) {
input.pop();
}
}
fn scroll_up(app: &mut AppState, amount: u16) {
if amount > 0 {
app.pinned_to_bottom = false;
}
app.transcript_scroll = app.transcript_scroll.saturating_sub(amount);
}
fn scroll_down(app: &mut AppState, amount: u16, max_scroll: u16) {
app.transcript_scroll = app.transcript_scroll.saturating_add(amount);
if app.transcript_scroll >= max_scroll {
app.transcript_scroll = max_scroll;
app.pinned_to_bottom = true;
}
}
fn transcript_inner_width(size: Size, app: &AppState, requested_view: ViewMode) -> u16 {
let mode = view_mode(
size.width,
app.active_conversation_id.is_some(),
requested_view,
);
let outer_width = match mode {
ViewMode::Split => {
let left_width = (size.width / 3).clamp(24, 40);
size.width.saturating_sub(left_width)
}
ViewMode::Chat => size.width,
ViewMode::List => 0,
};
outer_width.saturating_sub(2).max(1)
}
fn visual_width_u16(s: &str) -> u16 {
s.width().min(u16::MAX as usize) as u16
}
fn transcript_lines(messages: &[daemon::ChatMessage]) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
for message in messages {
let ts = time::OffsetDateTime::from_unix_timestamp(message.date_unix)
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
lines.push(Line::from(vec![
Span::styled(
message.sender.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(ts, Style::default().fg(Color::DarkGray)),
]));
let mut rendered_any_text = false;
for text_line in message.text.lines() {
rendered_any_text = true;
lines.push(Line::from(Span::raw(text_line.to_string())));
}
if !rendered_any_text {
lines.push(Line::from(Span::styled(
"<non-text message>",
Style::default().fg(Color::DarkGray),
)));
}
lines.push(Line::from(Span::raw("")));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"No messages.",
Style::default().fg(Color::DarkGray),
)));
}
lines
}
fn transcript_content_visual_lines(messages: &[daemon::ChatMessage], inner_width: u16) -> u16 {
let paragraph =
Paragraph::new(Text::from(transcript_lines(messages))).wrap(Wrap { trim: false });
paragraph.line_count(inner_width).min(u16::MAX as usize) as u16
}
fn transcript_viewport_height(size: Size, app: &AppState, requested_view: ViewMode) -> u16 {
let mode = view_mode(
size.width,
app.active_conversation_id.is_some(),
requested_view,
);
let show_input =
matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();
let transcript_height = if show_input {
size.height.saturating_sub(4) // input (3) + status (1)
} else {
size.height.saturating_sub(1) // status
};
match mode {
ViewMode::Chat | ViewMode::Split => transcript_height.saturating_sub(2), // borders
ViewMode::List => 0,
}
}
fn max_transcript_scroll(app: &AppState, size: Size, requested_view: ViewMode) -> u16 {
let viewport_height = transcript_viewport_height(size, app, requested_view);
let inner_width = transcript_inner_width(size, app, requested_view);
let content = transcript_content_visual_lines(&app.messages, inner_width);
content.saturating_sub(viewport_height)
}
fn apply_transcript_scroll_policy(app: &mut AppState, size: Size, requested_view: ViewMode) {
let max_scroll = max_transcript_scroll(app, size, requested_view);
if app.pinned_to_bottom {
app.transcript_scroll = max_scroll;
} else {
app.transcript_scroll = app.transcript_scroll.min(max_scroll);
}
}

2
gtk/.gitignore vendored
View File

@@ -1 +1,3 @@
build/ build/
flatpak-build/
.flatpak-builder/

25
gtk/Dockerfile.deb Normal file
View File

@@ -0,0 +1,25 @@
FROM debian:trixie
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
dpkg \
make \
build-essential \
python3 \
imagemagick \
meson \
ninja-build \
valac \
pkg-config \
libgtk-4-dev \
libadwaita-1-dev \
libglib2.0-dev \
libgee-0.8-dev \
libsecret-1-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
COPY . .
CMD ["make", "deb"]

View File

@@ -11,4 +11,13 @@ rpm:
git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(TMP)/v$(VER).tar.gz HEAD git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(TMP)/v$(VER).tar.gz HEAD
rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)" rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)"
deb:
./dist/deb/build-deb.sh $(VER)
.PHONY: flatpak
flatpak:
flatpak-builder --force-clean flatpak-build flatpak/net.buzzert.kordophone.yml
.PHONY: flatpak-install
flatpak-install:
flatpak-builder --force-clean --user --install flatpak-build flatpak/net.buzzert.kordophone.yml

View File

@@ -5,3 +5,5 @@ Libadwaita/GTK4 client for the Kordophone client daemon.
# Building # Building
Build an RPM using `rpmbuild -ba dist/rpm/kordophone.spec` Build an RPM using `rpmbuild -ba dist/rpm/kordophone.spec`
Build a DEB using `make deb`

6
gtk/dist/deb/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
*.deb
*.buildinfo
*.changes
*.dsc
*.tar.*
*.build

49
gtk/dist/deb/build-deb.sh vendored Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
VERSION="${1:-}"
if [[ -z "$VERSION" ]]; then
VERSION="$(sed -n "s/.*version[[:space:]]*:[[:space:]]*'\\([^']*\\)'.*/\\1/p" meson.build | head -n1)"
fi
if [[ -z "$VERSION" ]]; then
echo "Could not determine version (pass as first arg)" >&2
exit 1
fi
ARCH="$(dpkg --print-architecture)"
PKG="kordophone"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
PKGROOT="$STAGE/pkgroot"
mkdir -p "$PKGROOT/DEBIAN"
BUILD_DIR="$STAGE/build"
meson setup "$BUILD_DIR" --prefix=/usr
ninja -C "$BUILD_DIR"
DESTDIR="$PKGROOT" ninja -C "$BUILD_DIR" install
INSTALLED_SIZE_KB="$(du -sk "$PKGROOT/usr" | awk '{print $1}')"
cat >"$PKGROOT/DEBIAN/control" <<EOF
Package: ${PKG}
Version: ${VERSION}
Section: net
Priority: optional
Architecture: ${ARCH}
Maintainer: James Magahern <james@magahern.com>
Installed-Size: ${INSTALLED_SIZE_KB}
Depends: libgtk-4-1, libadwaita-1-0, libglib2.0-0, libgee-0.8-2, libsecret-1-0, kordophoned (>= 1.0.0)
Description: GTK4/Libadwaita client for Kordophone
A GTK4/Libadwaita Linux client for the Kordophone client daemon.
EOF
OUT_DIR="$ROOT_DIR/dist/deb"
mkdir -p "$OUT_DIR"
OUT_DEB="${OUT_DIR}/${PKG}_${VERSION}_${ARCH}.deb"
dpkg-deb --root-owner-group --build "$PKGROOT" "$OUT_DEB"
echo "$OUT_DEB"

18
gtk/flatpak/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Flatpak (GTK client)
This builds the GTK client as a Flatpak **assuming `kordophoned` is installed on the host**
and reachable on the **session bus** as `net.buzzert.kordophonecd`.
## Build
```bash
cd gtk
make flatpak
```
## Install (user)
```bash
cd gtk
make flatpak-install
```

View File

@@ -0,0 +1,25 @@
app-id: net.buzzert.kordophone
runtime: org.gnome.Platform
runtime-version: "48"
sdk: org.gnome.Sdk
command: kordophone
finish-args:
- --share=ipc
- --socket=wayland
- --socket=fallback-x11
- --device=dri
# Talk to the host-installed daemon (option A).
- --socket=session-bus
- --talk-name=net.buzzert.kordophonecd
# libsecret (Secret Service) access for stored credentials.
- --talk-name=org.freedesktop.secrets
modules:
- name: kordophone
buildsystem: meson
config-opts:
- --prefix=/app
sources:
- type: dir
path: ..

View File

@@ -1,5 +1,5 @@
project('kordophone', 'vala', project('kordophone', 'vala',
version : '1.0.1', version : '1.0.2',
meson_version : '>=0.56.0', meson_version : '>=0.56.0',
default_options : ['warning_level=2'] default_options : ['warning_level=2']
) )

View File

@@ -5,6 +5,7 @@ public class MainWindow : Adw.ApplicationWindow
{ {
private ConversationListView conversation_list_view; private ConversationListView conversation_list_view;
private TranscriptContainerView transcript_container_view; private TranscriptContainerView transcript_container_view;
private NavigationSplitView split_view;
private EventControllerMotion _motion_controller = new EventControllerMotion(); private EventControllerMotion _motion_controller = new EventControllerMotion();
private bool _motion_queued = false; private bool _motion_queued = false;
@@ -12,10 +13,15 @@ public class MainWindow : Adw.ApplicationWindow
public MainWindow () { public MainWindow () {
Object (title: "Kordophone"); Object (title: "Kordophone");
var split_view = new NavigationSplitView (); split_view = new NavigationSplitView ();
split_view.set_min_sidebar_width (400); split_view.set_min_sidebar_width (400);
split_view.show_content = false;
set_content (split_view); set_content (split_view);
var breakpoint = new Breakpoint (BreakpointCondition.parse ("max-width: 750sp"));
breakpoint.add_setter (split_view, "collapsed", true);
add_breakpoint (breakpoint);
conversation_list_view = new ConversationListView (); conversation_list_view = new ConversationListView ();
conversation_list_view.conversation_selected.connect (conversation_selected); conversation_list_view.conversation_selected.connect (conversation_selected);
conversation_list_view.conversation_activated.connect (open_conversation_in_new_window); conversation_list_view.conversation_activated.connect (open_conversation_in_new_window);
@@ -100,6 +106,10 @@ public class MainWindow : Adw.ApplicationWindow
GLib.warning("Failed to sync conversation: %s", e.message); GLib.warning("Failed to sync conversation: %s", e.message);
} }
} }
if (split_view.collapsed) {
split_view.show_content = true;
}
} }
} }

View File

@@ -14,6 +14,7 @@ public class ConversationListView : Adw.Bin
private string? selected_conversation_guid = null; private string? selected_conversation_guid = null;
private bool selection_update_queued = false; private bool selection_update_queued = false;
private bool suppress_row_selected = false;
public ConversationListView () { public ConversationListView () {
container = new Adw.ToolbarView (); container = new Adw.ToolbarView ();
@@ -29,6 +30,10 @@ public class ConversationListView : Adw.Bin
scrolled_window.set_child (list_box); scrolled_window.set_child (list_box);
list_box.row_selected.connect ((row) => { list_box.row_selected.connect ((row) => {
if (suppress_row_selected) {
return;
}
var conversation_row = (ConversationRow?) row; var conversation_row = (ConversationRow?) row;
if (conversation_row != null) { if (conversation_row != null) {
selected_conversation_guid = conversation_row.conversation.guid; selected_conversation_guid = conversation_row.conversation.guid;
@@ -112,7 +117,9 @@ public class ConversationListView : Adw.Bin
if (conversation.guid == selected_conversation_guid) { if (conversation.guid == selected_conversation_guid) {
var row = list_box.get_row_at_index((int)i); var row = list_box.get_row_at_index((int)i);
if (row != null) { if (row != null) {
suppress_row_selected = true;
list_box.select_row(row); list_box.select_row(row);
suppress_row_selected = false;
} }
} }
} }

View File

@@ -44,11 +44,11 @@ public class AttachmentInfo : Object {
} }
public class Attachment : Object { public class Attachment : Object {
public string guid; public string guid = "";
public string path; public string path = "";
public string preview_path; public string preview_path = "";
public bool downloaded; public bool downloaded = false;
public bool preview_downloaded; public bool preview_downloaded = false;
public AttachmentMetadata? metadata; public AttachmentMetadata? metadata;
public Attachment(string guid, AttachmentMetadata? metadata) { public Attachment(string guid, AttachmentMetadata? metadata) {

View File

@@ -73,14 +73,13 @@
'sender' (string): Sender display name 'sender' (string): Sender display name
'attachments' (array of dictionaries): List of attachments 'attachments' (array of dictionaries): List of attachments
'guid' (string): Attachment GUID 'guid' (string): Attachment GUID
'path' (string): Attachment path
'preview_path' (string): Preview attachment path
'downloaded' (boolean): Whether the attachment is downloaded 'downloaded' (boolean): Whether the attachment is downloaded
'preview_downloaded' (boolean): Whether the preview is downloaded 'preview_downloaded' (boolean): Whether the preview is downloaded
'metadata' (dictionary, optional): Attachment metadata 'metadata' (dictionary, optional): Attachment metadata
'attribution_info' (dictionary, optional): Attribution info 'attribution_info' (dictionary, optional): Attribution info
'width' (int32, optional): Width 'width' (int32, optional): Width
'height' (int32, optional): Height"/> 'height' (int32, optional): Height
Use GetAttachmentInfo for full/preview paths."/>
</arg> </arg>
</method> </method>

View File

@@ -144,4 +144,35 @@ public class Repository : DBusServiceProxy {
var info = dbus_repository.get_attachment_info(attachment_guid); var info = dbus_repository.get_attachment_info(attachment_guid);
return new AttachmentInfo(info.attr1, info.attr2, info.attr3, info.attr4); return new AttachmentInfo(info.attr1, info.attr2, info.attr3, info.attr4);
} }
public int open_attachment_fd(string attachment_guid, bool preview) throws DBusServiceProxyError, GLib.Error {
if (dbus_repository == null) {
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
}
var connection = Bus.get_sync(BusType.SESSION);
UnixFDList? out_fd_list = null;
var result = connection.call_with_unix_fd_list_sync(
DBUS_NAME,
DBUS_PATH,
"net.buzzert.kordophone.Repository",
"OpenAttachmentFd",
new Variant("(sb)", attachment_guid, preview),
new VariantType("(h)"),
DBusCallFlags.NONE,
120000,
null,
out out_fd_list,
null
);
int fd_handle = -1;
result.get("(h)", out fd_handle);
if (out_fd_list == null) {
throw new DBusServiceProxyError.NOT_CONNECTED("Missing UnixFDList from OpenAttachmentFd");
}
return out_fd_list.get(fd_handle);
}
} }

View File

@@ -13,30 +13,52 @@ private class SizeCache
return instance; return instance;
} }
public Graphene.Size? get_size(string image_path) { public Graphene.Size? get_size(string attachment_guid) {
return size_cache.get(image_path); return size_cache.get(attachment_guid);
} }
public void set_size(string image_path, Graphene.Size size) { public void set_size(string attachment_guid, Graphene.Size size) {
size_cache.set(image_path, size); size_cache.set(attachment_guid, size);
}
}
private class TextureCache
{
private static TextureCache instance = null;
private HashMap<string, Gdk.Texture> texture_cache = new HashMap<string, Gdk.Texture>();
public static TextureCache get_instance() {
if (instance == null) {
instance = new TextureCache();
}
return instance;
}
public Gdk.Texture? get_texture(string attachment_guid) {
return texture_cache.get(attachment_guid);
}
public void set_texture(string attachment_guid, Gdk.Texture texture) {
texture_cache.set(attachment_guid, texture);
} }
} }
private class ImageBubbleLayout : BubbleLayout private class ImageBubbleLayout : BubbleLayout
{ {
public string image_path; public string attachment_guid;
public bool is_downloaded; public bool is_downloaded;
public string? attachment_guid;
private Graphene.Size image_size; private Graphene.Size image_size;
private Gdk.Texture? cached_texture = null; private Gdk.Texture? cached_texture = null;
private bool preview_download_queued = false;
public ImageBubbleLayout(string image_path, bool from_me, Widget parent, float max_width, Graphene.Size? image_size = null) { public ImageBubbleLayout(string attachment_guid, bool from_me, Widget parent, float max_width, Graphene.Size? image_size = null) {
base(parent, max_width); base(parent, max_width);
this.from_me = from_me; this.from_me = from_me;
this.image_path = image_path; this.attachment_guid = attachment_guid;
this.is_downloaded = false; this.is_downloaded = false;
this.cached_texture = TextureCache.get_instance().get_texture(attachment_guid);
// Calculate image dimensions for layout // Calculate image dimensions for layout
calculate_image_dimensions(image_size); calculate_image_dimensions(image_size);
@@ -48,27 +70,26 @@ private class ImageBubbleLayout : BubbleLayout
return; return;
} }
var cached_size = SizeCache.get_instance().get_size(image_path); var cached_size = SizeCache.get_instance().get_size(attachment_guid);
if (cached_size != null) { if (cached_size != null) {
this.image_size = cached_size; this.image_size = cached_size;
return; return;
} }
// Try to load the image to get its dimensions
try {
warning("No image size provided, loading image to get dimensions");
var texture = Gdk.Texture.from_filename(image_path);
var original_width = (float)texture.get_width();
var original_height = (float)texture.get_height();
this.image_size = Graphene.Size() { width = original_width, height = original_height };
SizeCache.get_instance().set_size(image_path, this.image_size);
} catch (Error e) {
// Fallback dimensions if image can't be loaded
warning("Failed to load image %s: %s", image_path, e.message);
this.image_size = Graphene.Size() { width = 200.0f, height = 150.0f }; this.image_size = Graphene.Size() { width = 200.0f, height = 150.0f };
} }
private void queue_preview_download_if_needed() {
if (is_downloaded || preview_download_queued || attachment_guid == "") {
return;
}
try {
Repository.get_instance().download_attachment(attachment_guid, true);
preview_download_queued = true;
} catch (GLib.Error e) {
warning("Failed to queue preview download for %s: %s", attachment_guid, e.message);
}
} }
private void load_image_if_needed() { private void load_image_if_needed() {
@@ -81,9 +102,22 @@ private class ImageBubbleLayout : BubbleLayout
} }
try { try {
cached_texture = Gdk.Texture.from_filename(image_path); int fd = Repository.get_instance().open_attachment_fd(attachment_guid, true);
var stream = new UnixInputStream(fd, true);
var pixbuf = new Gdk.Pixbuf.from_stream(stream, null);
cached_texture = Gdk.Texture.for_pixbuf(pixbuf);
if (cached_texture != null) {
TextureCache.get_instance().set_texture(attachment_guid, cached_texture);
this.image_size = Graphene.Size() {
width = (float)cached_texture.get_width(),
height = (float)cached_texture.get_height()
};
SizeCache.get_instance().set_size(attachment_guid, this.image_size);
parent.queue_allocate();
}
} catch (Error e) { } catch (Error e) {
warning("Failed to load image %s: %s", image_path, e.message); warning("Failed to load preview image for %s: %s", attachment_guid, e.message);
} }
} }
@@ -110,6 +144,7 @@ private class ImageBubbleLayout : BubbleLayout
} }
public override void draw_content(Snapshot snapshot) { public override void draw_content(Snapshot snapshot) {
queue_preview_download_if_needed();
load_image_if_needed(); load_image_if_needed();
snapshot.save(); snapshot.save();

View File

@@ -62,34 +62,36 @@ public class MessageListModel : Object, ListModel
} }
} }
public void load_messages() { public void load_messages(bool force_full_reload = false) {
var previous_messages = new HashSet<Message>(); var previous_messages = new HashSet<Message>();
previous_messages.add_all(_messages); previous_messages.add_all(_messages);
try { try {
bool first_load = _messages.size == 0; bool first_load = _messages.size == 0;
string last_message_id = (first_load || force_full_reload) ? "" : _messages.get(_messages.size - 1).guid;
Message[] messages = Repository.get_instance().get_messages(conversation.guid); Message[] messages = Repository.get_instance().get_messages(conversation.guid, last_message_id);
// Clear existing set bool fallback_full_reload = first_load || force_full_reload;
if (!first_load && messages.length > 0 && previous_messages.contains(messages[0])) {
fallback_full_reload = true;
}
if (fallback_full_reload) {
uint old_count = _messages.size; uint old_count = _messages.size;
_messages.clear(); _messages.clear();
participants.clear(); participants.clear();
// Notify of removal
if (old_count > 0) { if (old_count > 0) {
items_changed(0, old_count, 0); items_changed(0, old_count, 0);
} }
// Process each conversation
uint position = 0; uint position = 0;
for (int i = 0; i < messages.length; i++) { for (int i = 0; i < messages.length; i++) {
var message = messages[i]; var message = messages[i];
participants.add(message.sender); participants.add(message.sender);
if (!first_load && !previous_messages.contains(message)) { if (!first_load && !previous_messages.contains(message)) {
// This is a new message according to the UI, schedule an animation for it.
message.should_animate = true; message.should_animate = true;
} }
@@ -97,10 +99,29 @@ public class MessageListModel : Object, ListModel
position++; position++;
} }
// Notify of additions
if (position > 0) { if (position > 0) {
items_changed(0, 0, position); items_changed(0, 0, position);
} }
} else {
uint old_count = _messages.size;
uint appended = 0;
for (int i = 0; i < messages.length; i++) {
var message = messages[i];
if (previous_messages.contains(message)) {
continue;
}
participants.add(message.sender);
message.should_animate = true;
_messages.add(message);
appended++;
}
if (appended > 0) {
items_changed(old_count, 0, appended);
}
}
} catch (Error e) { } catch (Error e) {
warning("Failed to load messages: %s", e.message); warning("Failed to load messages: %s", e.message);
} }

View File

@@ -364,16 +364,15 @@ private class TranscriptDrawingArea : Widget
// Check for attachments. For each one, add an image layout bubble // Check for attachments. For each one, add an image layout bubble
foreach (var attachment in message.attachments) { foreach (var attachment in message.attachments) {
Graphene.Size? image_size = null; Graphene.Size? image_size = null;
if (attachment.metadata != null) { if (attachment.metadata != null && attachment.metadata.attribution_info != null) {
image_size = Graphene.Size() { image_size = Graphene.Size() {
width = attachment.metadata.attribution_info.width, width = attachment.metadata.attribution_info.width,
height = attachment.metadata.attribution_info.height height = attachment.metadata.attribution_info.height
}; };
} }
var image_layout = new ImageBubbleLayout(attachment.preview_path, message.from_me, this, max_width, image_size); var image_layout = new ImageBubbleLayout(attachment.guid, message.from_me, this, max_width, image_size);
image_layout.id = @"image-$(attachment.guid)"; image_layout.id = @"image-$(attachment.guid)";
image_layout.attachment_guid = attachment.guid;
if (animate) { if (animate) {
start_animation(image_layout.id); start_animation(image_layout.id);
@@ -381,16 +380,6 @@ private class TranscriptDrawingArea : Widget
image_layout.is_downloaded = attachment.preview_downloaded; image_layout.is_downloaded = attachment.preview_downloaded;
items.add(image_layout); items.add(image_layout);
// If the attachment isn't downloaded, queue a download since we are going to be showing it here.
// TODO: Probably would be better if we only did this for stuff in the viewport.
if (!attachment.preview_downloaded) {
try {
Repository.get_instance().download_attachment(attachment.guid, true);
} catch (GLib.Error e) {
warning("Wasn't able to message daemon about queuing attachment download: %s", e.message);
}
}
} }
last_sender = message.sender; last_sender = message.sender;

View File

@@ -148,7 +148,7 @@ public class TranscriptView : Adw.Bin
GLib.Idle.add(() => { GLib.Idle.add(() => {
if (needs_reload) { if (needs_reload) {
debug("Reloading messages for attachment download"); debug("Reloading messages for attachment download");
model.load_messages(); model.load_messages(true);
needs_reload = false; needs_reload = false;
} }

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? {