Private
Public Access
1
0

Compare commits

..

48 Commits

Author SHA1 Message Date
78004fdef5 fixup gitmodules 2026-04-01 18:19:25 -07:00
dce71228aa docs: GLib bindings plan 2026-04-01 18:15:19 -07:00
99f695d6f2 Add plumbing for new message/reply through core, gtk, and osx 2026-04-01 18:03:15 -07:00
a61127622c SendMessage: some backwards compatibility 2026-04-01 17:22:28 -07:00
28679a1dd1 More logging, delete, new convo 2026-04-01 17:16:58 -07:00
c2a697f2c1 new message: initial commit 2026-04-01 15:29:37 -07:00
45285892de gitignore: ext/ 2026-04-01 11:24:14 -07:00
64d7394ffa gtk: fix attachment download race condition 2026-02-22 00:36:15 -08:00
69892a4d08 fix rpmspec 2026-02-22 00:24:36 -08:00
a4bd28b22c gtk: image sizing fix 2026-02-22 00:19:43 -08:00
335ded4752 gtk: auto versioning from git tags 2026-02-22 00:02:33 -08:00
d40eb1886e cargo spec fix for rpm 2026-02-21 23:55:50 -08:00
de06e449be make rpm: bandaid for old cargo-generate-rpm 2026-02-21 23:54:27 -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
92 changed files with 4810 additions and 853 deletions

2
.gitignore vendored Normal file
View File

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

View File

@@ -1,3 +1,3 @@
[submodule "CocoaHTTPServer"]
path = CocoaHTTPServer
path = server/CocoaHTTPServer
url = https://github.com/robbiehanson/CocoaHTTPServer.git

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.
- 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",
]

428
core/Cargo.lock generated
View File

@@ -26,6 +26,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android-tzdata"
version = "0.1.1"
@@ -232,10 +238,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cc"
version = "1.0.95"
name = "cassowary"
version = "0.3.0"
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]]
name = "cexpr"
@@ -289,7 +314,7 @@ dependencies = [
"bitflags 1.3.2",
"strsim 0.8.0",
"textwrap",
"unicode-width",
"unicode-width 0.1.14",
"vec_map",
]
@@ -339,6 +364,20 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "core-foundation"
version = "0.9.4"
@@ -374,6 +413,31 @@ dependencies = [
"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]]
name = "crypto-common"
version = "0.1.6"
@@ -476,7 +540,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a76dc35ce83e4e9fa089b4fabe66c757b27bd504dc2179c97a01b36d3e874fb0"
dependencies = [
"clap 2.34.0",
"dbus",
"xml-rs",
]
@@ -714,10 +777,10 @@ dependencies = [
]
[[package]]
name = "fastrand"
version = "2.0.2"
name = "find-msvc-tools"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]]
name = "fnv"
@@ -726,19 +789,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
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"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "futures"
@@ -898,6 +952,11 @@ name = "hashbrown"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
@@ -1002,16 +1061,18 @@ dependencies = [
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
name = "hyper-rustls"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"bytes",
"futures-util",
"http 0.2.12",
"hyper",
"native-tls",
"rustls 0.21.12",
"tokio",
"tokio-native-tls",
"tokio-rustls 0.24.1",
"webpki-roots 0.25.4",
]
[[package]]
@@ -1053,6 +1114,28 @@ dependencies = [
"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]]
name = "is-terminal"
version = "0.4.16"
@@ -1070,6 +1153,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.11"
@@ -1134,9 +1226,9 @@ dependencies = [
"env_logger 0.11.8",
"futures-util",
"hyper",
"hyper-tls",
"hyper-rustls",
"log",
"rustls",
"rustls 0.23.29",
"serde",
"serde_json",
"serde_plain",
@@ -1182,7 +1274,7 @@ dependencies = [
[[package]]
name = "kordophoned"
version = "1.0.1"
version = "1.3.0"
dependencies = [
"anyhow",
"async-trait",
@@ -1213,6 +1305,19 @@ dependencies = [
"xpc-connection-sys",
]
[[package]]
name = "kordophoned-client"
version = "0.1.0"
dependencies = [
"anyhow",
"block",
"dbus",
"dbus-codegen",
"log",
"xpc-connection",
"xpc-connection-sys",
]
[[package]]
name = "kpcli"
version = "0.1.0"
@@ -1240,6 +1345,18 @@ dependencies = [
"xpc-connection-sys",
]
[[package]]
name = "kptui"
version = "0.1.0"
dependencies = [
"anyhow",
"crossterm",
"kordophoned-client",
"ratatui",
"time",
"unicode-width 0.2.0",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
@@ -1319,6 +1436,15 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
]
[[package]]
name = "memchr"
version = "2.7.2"
@@ -1379,28 +1505,11 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
dependencies = [
"hermit-abi 0.3.9",
"libc",
"log",
"wasi 0.11.0+wasi-snapshot-preview1",
"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]]
name = "nom"
version = "5.1.3"
@@ -1505,50 +1614,6 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "option-ext"
version = "0.2.0"
@@ -1578,6 +1643,12 @@ dependencies = [
"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]]
name = "peeking_take_while"
version = "0.1.2"
@@ -1638,7 +1709,7 @@ dependencies = [
"arrayvec",
"termcolor",
"typed-arena",
"unicode-width",
"unicode-width 0.1.14",
]
[[package]]
@@ -1652,7 +1723,7 @@ dependencies = [
"is-terminal",
"lazy_static",
"term",
"unicode-width",
"unicode-width 0.1.14",
]
[[package]]
@@ -1738,6 +1809,27 @@ dependencies = [
"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]]
name = "redox_syscall"
version = "0.4.1"
@@ -1838,6 +1930,17 @@ dependencies = [
"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]]
name = "rustls"
version = "0.23.29"
@@ -1847,7 +1950,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"rustls-webpki 0.103.4",
"subtle",
"zeroize",
]
@@ -1861,6 +1964,16 @@ dependencies = [
"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]]
name = "rustls-webpki"
version = "0.103.4"
@@ -1884,21 +1997,22 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "security-framework"
version = "2.10.0"
@@ -2001,6 +2115,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "signal-hook-registry"
version = "1.4.1"
@@ -2041,6 +2176,12 @@ version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.8.0"
@@ -2053,6 +2194,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "subtle"
version = "2.6.1"
@@ -2070,18 +2233,6 @@ dependencies = [
"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]]
name = "term"
version = "0.7.0"
@@ -2108,7 +2259,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
"unicode-width 0.1.14",
]
[[package]]
@@ -2221,12 +2372,12 @@ dependencies = [
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"native-tls",
"rustls 0.21.12",
"tokio",
]
@@ -2236,7 +2387,7 @@ version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
dependencies = [
"rustls",
"rustls 0.23.29",
"tokio",
]
@@ -2248,10 +2399,10 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls 0.23.29",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tokio-rustls 0.26.2",
"tungstenite",
"webpki-roots 0.26.11",
]
@@ -2347,7 +2498,7 @@ dependencies = [
"httparse",
"log",
"rand 0.9.1",
"rustls",
"rustls 0.23.29",
"rustls-pki-types",
"sha1",
"thiserror 2.0.12",
@@ -2379,12 +2530,35 @@ version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -2528,6 +2702,12 @@ version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d"
[[package]]
name = "webpki-roots"
version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "webpki-roots"
version = "0.26.11"

View File

@@ -3,7 +3,9 @@ members = [
"kordophone",
"kordophone-db",
"kordophoned",
"kordophoned-client",
"kpcli",
"kptui",
"utilities",
]
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
strip -s target/release/kordophoned
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
- Linux: DBus
- 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.
- `kptui/` — Terminal UI client (Ratatui) for reading and replying to chats via the daemon.
- `utilities/` — Small helper tools (e.g., testing utilities).
## Build
@@ -27,6 +29,42 @@ cargo build -p kordophone
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)
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
```
### 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)
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.
- 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())
.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();
for message_record in message_records {
let mut message: Message = message_record.clone().into();
// If the message references a sender participant, load the participant info
if let Some(sender_handle) = message_record.sender_participant_handle {
let participant = participants
.find(sender_handle)
.first::<ParticipantRecord>(self.connection)?;
message.sender = participant.into();
if let Some(participant) = participant_map.get(&sender_handle) {
message.sender = participant.clone();
}
}
result.push(message);
@@ -307,8 +325,11 @@ impl<'a> Repository<'a> {
}
pub fn delete_all_messages(&mut self) -> Result<()> {
use crate::schema::messages::dsl::*;
diesel::delete(messages).execute(self.connection)?;
use crate::schema::message_aliases::dsl as aliases_dsl;
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(())
}
@@ -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.
pub fn update_participant_contact(
&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! {
settings (key) {
key -> Text,
@@ -62,5 +70,6 @@ diesel::allow_tables_to_appear_in_same_query!(
conversation_participants,
messages,
conversation_messages,
message_aliases,
settings,
);

View File

@@ -26,7 +26,7 @@ fn participants_vec_equal_ignoring_id(a: &[Participant], b: &[Participant]) -> b
// For each participant in a, check if there is a matching participant in b
a.iter().all(|a_participant| {
b.iter().any(|b_participant| participants_equal_ignoring_id(a_participant, b_participant))
}) &&
}) &&
// Also check the reverse to ensure no duplicates
b.iter().all(|b_participant| {
a.iter().any(|a_participant| participants_equal_ignoring_id(b_participant, a_participant))

View File

@@ -14,7 +14,7 @@ ctor = "0.2.8"
env_logger = "0.11.5"
futures-util = "0.3.31"
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 = [] }
serde = { version = "1.0.152", features = ["derive"] }
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 bytes::Bytes;
use hyper::{Body, Client, Method, Request, Uri};
use hyper_tls::HttpsConnector;
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use async_trait::async_trait;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
@@ -24,7 +24,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use crate::{
model::{
Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage,
UpdateItem,
OutgoingMessageTarget, ResolveHandleResponse, SendMessageResponse, UpdateItem,
},
APIInterface,
};
@@ -65,7 +65,15 @@ impl std::error::Error for Error {
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
match self {
Error::ClientError(message) => write!(f, "{}", message),
Error::HTTPError(err) => write!(f, "HTTP transport error: {}", err),
Error::SerdeError(err) => write!(f, "JSON error: {}", err),
Error::DecodeError(message) => write!(f, "Decode error: {}", message),
Error::PongError(err) => write!(f, "WebSocket error: {}", err),
Error::URLError => write!(f, "Invalid URL"),
Error::Unauthorized => write!(f, "Unauthorized"),
}
}
}
@@ -284,6 +292,17 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
Ok(())
}
async fn delete_conversation(
&mut self,
conversation_id: &ConversationID,
) -> Result<(), Self::Error> {
// SERVER JANK: This should be DELETE or POST, but it's GET for some reason.
let endpoint = format!("delete?guid={}", conversation_id);
self.response_with_body_retry(&endpoint, Method::GET, Body::empty, true)
.await?;
Ok(())
}
async fn get_messages(
&mut self,
conversation_id: &ConversationID,
@@ -312,16 +331,46 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
async fn send_message(
&mut self,
outgoing_message: &OutgoingMessage,
) -> Result<Message, Self::Error> {
let message: Message = self
) -> Result<SendMessageResponse, Self::Error> {
match &outgoing_message.target {
OutgoingMessageTarget::Conversation(conversation_id) => {
log::debug!(
"Sending message to conversation {} (body_length={}, attachment_count={})",
conversation_id,
outgoing_message.text.len(),
outgoing_message.file_transfer_guids.len()
);
}
OutgoingMessageTarget::Handles(handle_ids) => {
log::debug!(
"Sending message to resolved handles {:?} (body_length={}, attachment_count={})",
handle_ids,
outgoing_message.text.len(),
outgoing_message.file_transfer_guids.len()
);
}
}
let message: SendMessageResponse = self
.deserialized_response_with_body("sendMessage", Method::POST, || {
serde_json::to_string(&outgoing_message).unwrap().into()
Self::send_message_request_body(outgoing_message)
})
.await?;
Ok(message)
}
async fn resolve_handle(
&mut self,
handle_id: &str,
) -> Result<ResolveHandleResponse, Self::Error> {
log::debug!("Resolving handle {}", handle_id);
let endpoint = format!("resolveHandle?id={}", urlencoding::encode(handle_id));
let response: ResolveHandleResponse =
self.deserialized_response(&endpoint, Method::GET).await?;
Ok(response)
}
async fn fetch_attachment_data(
&mut self,
guid: &str,
@@ -394,84 +443,124 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
None => "updates".to_string(),
};
let uri = self
.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
let uri = self.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
log::debug!("Connecting to websocket: {:?}", uri);
loop {
log::debug!("Connecting to websocket: {:?}", uri);
let auth = self.auth_store.get_token().await;
let host = uri.authority().unwrap().host();
let mut request = TungsteniteRequest::builder()
.header("Host", host)
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.uri(uri.to_string())
.body(())
.expect("Unable to build websocket request");
let auth = self.auth_store.get_token().await;
let host = uri.authority().unwrap().host();
let mut request = TungsteniteRequest::builder()
.header("Host", host)
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.uri(uri.to_string())
.body(())
.expect("Unable to build websocket request");
match &auth {
Some(token) => {
request.headers_mut().insert(
"Authorization",
format!("Bearer: {}", token).parse().unwrap(),
);
match &auth {
Some(token) => {
request.headers_mut().insert(
"Authorization",
format!("Bearer: {}", token).parse().unwrap(),
);
}
None => {
log::warn!(target: "websocket", "Proceeding without auth token.");
}
}
None => {
log::warn!(target: "websocket", "Proceeding without auth token.");
}
}
log::debug!("Websocket request: {:?}", request);
log::debug!("Websocket request: {:?}", request);
match connect_async(request).await.map_err(Error::from) {
Ok((socket, response)) => {
log::debug!("Websocket connected: {:?}", response.status());
Ok(WebsocketEventSocket::new(socket))
}
Err(e) => match &e {
Error::ClientError(ce) => match ce.as_str() {
"HTTP error: 401 Unauthorized" | "Unauthorized" => {
// Try to authenticate
if let Some(credentials) = &self.auth_store.get_credentials().await {
log::warn!("Websocket connection failed, attempting to authenticate");
let new_token = self.authenticate(credentials.clone()).await?;
self.auth_store.set_token(new_token.to_string()).await;
let should_retry = true; // retry once after authenticating.
match connect_async(request).await.map_err(Error::from) {
Ok((socket, response)) => {
log::debug!("Websocket connected: {:?}", response.status());
break Ok(WebsocketEventSocket::new(socket));
}
Err(e) => match &e {
Error::ClientError(ce) => match ce.as_str() {
"HTTP error: 401 Unauthorized" | "Unauthorized" => {
// Try to authenticate
if let Some(credentials) = &self.auth_store.get_credentials().await {
log::warn!(
"Websocket connection failed, attempting to authenticate"
);
let new_token = self.authenticate(credentials.clone()).await?;
self.auth_store.set_token(new_token.to_string()).await;
// try again on the next attempt.
return Err(Error::Unauthorized);
} else {
log::error!("Websocket unauthorized, no credentials provided");
return Err(Error::ClientError(
"Unauthorized, no credentials provided".into(),
));
if should_retry {
// try again on the next attempt.
continue;
} else {
break Err(e);
}
} else {
log::error!("Websocket unauthorized, no credentials provided");
break Err(Error::ClientError(
"Unauthorized, no credentials provided".into(),
));
}
}
}
_ => Err(e),
},
_ => break Err(e),
},
_ => Err(e),
},
_ => break Err(e),
},
}
}
}
}
impl<K: AuthenticationStore + Send + Sync> 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);
HTTPAPIClient { base_url, auth_store, client }
HTTPAPIClient {
base_url,
auth_store,
client,
}
}
fn send_message_request_body(outgoing_message: &OutgoingMessage) -> Body {
#[derive(Serialize)]
struct SendMessageRequest<'a> {
#[serde(rename = "body")]
text: &'a str,
#[serde(rename = "guid", skip_serializing_if = "Option::is_none")]
conversation_id: Option<&'a ConversationID>,
#[serde(rename = "handleIDs", skip_serializing_if = "Option::is_none")]
handle_ids: Option<&'a [String]>,
#[serde(rename = "fileTransferGUIDs")]
file_transfer_guids: &'a Vec<String>,
}
let (conversation_id, handle_ids) = match &outgoing_message.target {
OutgoingMessageTarget::Conversation(conversation_id) => (Some(conversation_id), None),
OutgoingMessageTarget::Handles(handle_ids) => (None, Some(handle_ids.as_slice())),
};
serde_json::to_string(&SendMessageRequest {
text: &outgoing_message.text,
conversation_id,
handle_ids,
file_transfer_guids: &outgoing_message.file_transfer_guids,
})
.unwrap()
.into()
}
fn uri_for_endpoint(&self, endpoint: &str, scheme: Option<&str>) -> Result<Uri, Error> {
let mut parts = self.base_url.clone().into_parts();
let root_path: PathBuf = parts
.path_and_query
.ok_or(Error::URLError)?
.path()
.into();
let root_path: PathBuf = parts.path_and_query.ok_or(Error::URLError)?.path().into();
let path = root_path.join(endpoint);
let path_str = path.to_str().ok_or(Error::URLError)?;
@@ -492,6 +581,18 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
}
}
fn log_transport_error(method: &Method, target: &str, err: &hyper::Error) {
log::error!("HTTP transport error for {} {}: {}", method, target, err);
if format!("{:?}", err).contains("IncompleteMessage") {
log::error!(
"The server closed the connection before a complete response was received for {} {}.",
method,
target
);
}
}
async fn deserialized_response<T: DeserializeOwned>(
&mut self,
endpoint: &str,
@@ -525,15 +626,26 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
T: DeserializeOwned,
{
let response = self
.response_with_body_retry(endpoint, method, body_fn, retry_auth)
.response_with_body_retry(endpoint, method.clone(), body_fn, retry_auth)
.await?;
// Read and parse response body
let body = hyper::body::to_bytes(response.into_body()).await?;
let body = match hyper::body::to_bytes(response.into_body()).await {
Ok(body) => body,
Err(err) => {
Self::log_transport_error(&method, endpoint, &err);
return Err(Error::HTTPError(err));
}
};
let parsed: T = match serde_json::from_slice(&body) {
Ok(result) => Ok(result),
Err(json_err) => {
log::error!("Error deserializing JSON: {:?}", json_err);
log::error!(
"Error deserializing JSON for {} {}: {:?}",
method,
endpoint,
json_err
);
log::error!("Body: {:?}", String::from_utf8_lossy(&body));
// If JSON deserialization fails, try to interpret it as plain text
@@ -556,7 +668,8 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
use hyper::StatusCode;
let uri = self.uri_for_endpoint(endpoint, None)?;
log::debug!("Requesting {:?} {:?}", method, uri);
let uri_string = uri.to_string();
log::debug!("Requesting {} {}", method, uri_string);
let mut build_request = |auth: &Option<String>| {
let body = body_fn();
@@ -570,13 +683,24 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
log::trace!("Obtaining token from auth store");
let token = self.auth_store.get_token().await;
log::trace!("Token: {:?}", token);
log::trace!("Token present: {}", token.is_some());
let request = build_request(&token);
log::trace!("Request: {:?}. Sending request...", request);
log::trace!(
"Sending request: method={} uri={} authenticated={}",
method,
uri_string,
token.is_some()
);
let mut response = self.client.request(request).await?;
log::debug!("-> Response: {:}", response.status());
let mut response = match self.client.request(request).await {
Ok(response) => response,
Err(err) => {
Self::log_transport_error(&method, &uri_string, &err);
return Err(Error::HTTPError(err));
}
};
log::debug!("-> Response: {}", response.status());
match response.status() {
StatusCode::OK => { /* cool */ }
@@ -595,7 +719,19 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
let new_token = self.authenticate(credentials.clone()).await?;
let request = build_request(&Some(new_token.to_string()));
response = self.client.request(request).await?;
log::trace!(
"Retrying request after authentication: method={} uri={} authenticated=true",
method,
uri_string
);
response = match self.client.request(request).await {
Ok(response) => response,
Err(err) => {
Self::log_transport_error(&method, &uri_string, &err);
return Err(Error::HTTPError(err));
}
};
log::debug!("-> Retry response: {}", response.status());
} else {
return Err(Error::ClientError(
"Unauthorized, no credentials provided".into(),

View File

@@ -1,4 +1,7 @@
pub use crate::model::{Conversation, ConversationID, Message, MessageID, OutgoingMessage};
pub use crate::model::{
Conversation, ConversationID, Message, MessageID, OutgoingMessage, ResolveHandleResponse,
SendMessageResponse,
};
use async_trait::async_trait;
use bytes::Bytes;
@@ -42,7 +45,13 @@ pub trait APIInterface {
async fn send_message(
&mut self,
outgoing_message: &OutgoingMessage,
) -> Result<Message, Self::Error>;
) -> Result<SendMessageResponse, Self::Error>;
// (GET) /resolveHandle
async fn resolve_handle(
&mut self,
handle_id: &str,
) -> Result<ResolveHandleResponse, Self::Error>;
// (GET) /attachment
async fn fetch_attachment_data(
@@ -70,6 +79,12 @@ pub trait APIInterface {
conversation_id: &ConversationID,
) -> Result<(), Self::Error>;
// (GET) /delete
async fn delete_conversation(
&mut self,
conversation_id: &ConversationID,
) -> Result<(), Self::Error>;
// (WS) /updates
async fn open_event_socket(
&mut self,

View File

@@ -0,0 +1,28 @@
use serde::{Deserialize, Serialize};
use super::conversation::ConversationID;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResolvedHandle {
pub id: String,
pub name: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum HandleResolutionStatus {
Valid,
Invalid,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResolveHandleResponse {
#[serde(rename = "resolvedHandle")]
pub resolved_handle: ResolvedHandle,
pub status: HandleResolutionStatus,
#[serde(rename = "existingChat")]
pub existing_chat: Option<ConversationID>,
}

View File

@@ -1,7 +1,9 @@
pub mod conversation;
pub mod event;
pub mod handle;
pub mod message;
pub mod outgoing_message;
pub mod send_message_response;
pub mod update;
pub use conversation::Conversation;
@@ -10,8 +12,15 @@ pub use conversation::ConversationID;
pub use message::Message;
pub use message::MessageID;
pub use handle::HandleResolutionStatus;
pub use handle::ResolveHandleResponse;
pub use handle::ResolvedHandle;
pub use outgoing_message::OutgoingMessage;
pub use outgoing_message::OutgoingMessageBuilder;
pub use outgoing_message::OutgoingMessageTarget;
pub use send_message_response::SendMessageResponse;
pub use update::UpdateItem;

View File

@@ -1,23 +1,23 @@
use super::conversation::ConversationID;
use chrono::NaiveDateTime;
use serde::Serialize;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutgoingMessageTarget {
Conversation(ConversationID),
Handles(Vec<String>),
}
#[derive(Debug, Clone)]
pub struct OutgoingMessage {
#[serde(skip)]
pub guid: Uuid,
#[serde(skip)]
pub date: NaiveDateTime,
#[serde(rename = "body")]
pub text: String,
#[serde(rename = "guid")]
pub conversation_id: ConversationID,
pub target: OutgoingMessageTarget,
#[serde(rename = "fileTransferGUIDs")]
pub file_transfer_guids: Vec<String>,
}
@@ -25,13 +25,27 @@ impl OutgoingMessage {
pub fn builder() -> OutgoingMessageBuilder {
OutgoingMessageBuilder::new()
}
pub fn conversation_id(&self) -> Option<&ConversationID> {
match &self.target {
OutgoingMessageTarget::Conversation(conversation_id) => Some(conversation_id),
OutgoingMessageTarget::Handles(_) => None,
}
}
pub fn handle_ids(&self) -> Option<&[String]> {
match &self.target {
OutgoingMessageTarget::Conversation(_) => None,
OutgoingMessageTarget::Handles(handle_ids) => Some(handle_ids.as_slice()),
}
}
}
#[derive(Default)]
pub struct OutgoingMessageBuilder {
guid: Option<Uuid>,
text: Option<String>,
conversation_id: Option<ConversationID>,
target: Option<OutgoingMessageTarget>,
file_transfer_guids: Option<Vec<String>>,
}
@@ -50,8 +64,18 @@ impl OutgoingMessageBuilder {
self
}
pub fn target(mut self, target: OutgoingMessageTarget) -> Self {
self.target = Some(target);
self
}
pub fn conversation_id(mut self, conversation_id: ConversationID) -> Self {
self.conversation_id = Some(conversation_id);
self.target = Some(OutgoingMessageTarget::Conversation(conversation_id));
self
}
pub fn handle_ids(mut self, handle_ids: Vec<String>) -> Self {
self.target = Some(OutgoingMessageTarget::Handles(handle_ids));
self
}
@@ -64,7 +88,7 @@ impl OutgoingMessageBuilder {
OutgoingMessage {
guid: self.guid.unwrap_or_else(Uuid::new_v4),
text: self.text.unwrap(),
conversation_id: self.conversation_id.unwrap(),
target: self.target.unwrap(),
file_transfer_guids: self.file_transfer_guids.unwrap_or_default(),
date: chrono::Utc::now().naive_utc(),
}

View File

@@ -0,0 +1,12 @@
use serde::Deserialize;
use super::{conversation::ConversationID, message::Message};
#[derive(Debug, Clone, Deserialize)]
pub struct SendMessageResponse {
#[serde(flatten)]
pub message: Message,
#[serde(rename = "conversationGUID")]
pub conversation_id: Option<ConversationID>,
}

View File

@@ -3,7 +3,7 @@ use self::test_client::TestClient;
use crate::APIInterface;
pub mod api_interface {
use crate::model::Conversation;
use crate::model::{Conversation, HandleResolutionStatus, OutgoingMessage};
use super::*;
@@ -28,4 +28,42 @@ pub mod api_interface {
assert_eq!(conversations.len(), 1);
assert_eq!(conversations[0].display_name, test_convo.display_name);
}
#[tokio::test]
async fn test_resolve_handle() {
let mut client = TestClient::new();
let resolved = client.resolve_handle("user@example.com").await.unwrap();
assert_eq!(resolved.resolved_handle.id, "user@example.com");
assert_eq!(resolved.status, HandleResolutionStatus::Valid);
assert_eq!(resolved.existing_chat, None);
}
#[tokio::test]
async fn test_send_message_with_handles() {
let mut client = TestClient::new();
let outgoing_message = OutgoingMessage::builder()
.text("hello".to_string())
.handle_ids(vec!["user@example.com".to_string()])
.build();
let sent = client.send_message(&outgoing_message).await.unwrap();
assert_eq!(sent.message.text, "hello");
assert_eq!(sent.conversation_id, None);
}
#[tokio::test]
async fn test_delete_conversation() {
let mut client = TestClient::new();
let test_convo = Conversation::builder().display_name("Delete Me").build();
client.conversations.push(test_convo.clone());
client.delete_conversation(&test_convo.guid).await.unwrap();
let conversations = client.get_conversations().await.unwrap();
assert!(conversations.is_empty());
}
}

View File

@@ -9,14 +9,17 @@ use crate::{
api::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpdate},
api::http_client::Credentials,
model::{
Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage,
UpdateItem,
Conversation, ConversationID, Event, HandleResolutionStatus, JwtToken, Message, MessageID,
OutgoingMessage, OutgoingMessageTarget, ResolveHandleResponse, ResolvedHandle,
SendMessageResponse,
},
};
use bytes::Bytes;
use futures_util::sink::drain;
use futures_util::stream::BoxStream;
use futures_util::Sink;
use futures_util::SinkExt;
use futures_util::StreamExt;
pub struct TestClient {
@@ -63,13 +66,18 @@ impl EventSocket for TestEventSocket {
impl Sink<SinkMessage, Error = Self::Error>,
) {
(
futures_util::stream::iter(self.events.into_iter().map(Ok)).boxed(),
futures_util::sink::sink(),
futures_util::stream::iter(
self.events
.into_iter()
.map(|event| Ok(SocketEvent::Update(event))),
)
.boxed(),
drain().sink_map_err(|err| match err {}),
)
}
async fn raw_updates(self) -> Self::UpdateStream {
let results: Vec<Result<Vec<UpdateItem>, TestError>> = vec![];
let results: Vec<Result<SocketUpdate, TestError>> = vec![];
futures_util::stream::iter(results.into_iter()).boxed()
}
}
@@ -94,9 +102,9 @@ impl APIInterface for TestClient {
async fn get_messages(
&mut self,
conversation_id: &ConversationID,
limit: Option<u32>,
before: Option<MessageID>,
after: Option<MessageID>,
_limit: Option<u32>,
_before: Option<MessageID>,
_after: Option<MessageID>,
) -> Result<Vec<Message>, Self::Error> {
if let Some(messages) = self.messages.get(conversation_id) {
return Ok(messages.clone());
@@ -108,18 +116,42 @@ impl APIInterface for TestClient {
async fn send_message(
&mut self,
outgoing_message: &OutgoingMessage,
) -> Result<Message, Self::Error> {
) -> Result<SendMessageResponse, Self::Error> {
let message = Message::builder()
.guid(Uuid::new_v4().to_string())
.text(outgoing_message.text.clone())
.date(OffsetDateTime::now_utc())
.build();
self.messages
.entry(outgoing_message.conversation_id.clone())
.or_insert(vec![])
.push(message.clone());
Ok(message)
let conversation_id = match &outgoing_message.target {
OutgoingMessageTarget::Conversation(conversation_id) => {
self.messages
.entry(conversation_id.clone())
.or_insert(vec![])
.push(message.clone());
None
}
OutgoingMessageTarget::Handles(_) => None,
};
Ok(SendMessageResponse {
message,
conversation_id,
})
}
async fn resolve_handle(
&mut self,
handle_id: &str,
) -> Result<ResolveHandleResponse, Self::Error> {
Ok(ResolveHandleResponse {
resolved_handle: ResolvedHandle {
id: handle_id.to_string(),
name: None,
},
status: HandleResolutionStatus::Valid,
existing_chat: None,
})
}
async fn open_event_socket(
@@ -131,17 +163,17 @@ impl APIInterface for TestClient {
async fn fetch_attachment_data(
&mut self,
guid: &str,
preview: bool,
_guid: &str,
_preview: bool,
) -> Result<Self::ResponseStream, Self::Error> {
Ok(futures_util::stream::iter(vec![Ok(Bytes::from_static(b"test"))]).boxed())
}
async fn upload_attachment<R>(
&mut self,
data: tokio::io::BufReader<R>,
filename: &str,
size: u64,
_data: tokio::io::BufReader<R>,
_filename: &str,
_size: u64,
) -> Result<String, Self::Error>
where
R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static,
@@ -151,8 +183,23 @@ impl APIInterface for TestClient {
async fn mark_conversation_as_read(
&mut self,
conversation_id: &ConversationID,
_conversation_id: &ConversationID,
) -> Result<(), Self::Error> {
Ok(())
}
async fn delete_conversation(
&mut self,
conversation_id: &ConversationID,
) -> Result<(), Self::Error> {
let previous_len = self.conversations.len();
self.conversations.retain(|c| &c.guid != conversation_id);
self.messages.remove(conversation_id);
if self.conversations.len() == previous_len {
return Err(TestError::ConversationNotFound);
}
Ok(())
}
}

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,25 @@
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,4 @@
mod platform;
mod worker;
pub use worker::{spawn_worker, ChatMessage, ConversationSummary, Event, Request};

View File

@@ -0,0 +1,200 @@
#![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 reply(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
let attachment_guids: Vec<&str> = vec![];
let outgoing_id =
KordophoneRepository::reply(&self.proxy(), &conversation_id, &text, attachment_guids)?;
Ok(Some(outgoing_id))
}
fn new_conversation(
&mut self,
handle_ids: Vec<String>,
text: String,
) -> Result<Option<String>> {
let attachment_guids: Vec<&str> = vec![];
let handle_ids: Vec<&str> = handle_ids.iter().map(String::as_str).collect();
let outgoing_id = KordophoneRepository::new_conversation(
&self.proxy(),
handle_ids,
&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,272 @@
#![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 reply(&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("Reply", Some(args)));
let Message::Dictionary(map) = reply else {
anyhow::bail!("Unexpected send response");
};
Ok(Self::get_string(&map, "uuid"))
}
fn new_conversation(
&mut self,
handle_ids: Vec<String>,
text: String,
) -> Result<Option<String>> {
let mut args = HashMap::new();
args.insert(
Self::key("handle_ids"),
Message::Array(
handle_ids
.into_iter()
.map(|handle_id| Message::String(Self::key(&handle_id)))
.collect(),
),
);
args.insert(Self::key("text"), Message::String(Self::key(&text)));
let reply = self
.transport
.send_with_reply(Self::request("NewConversation", 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,23 @@
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,159 @@
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,
},
Reply {
conversation_id: String,
text: String,
},
NewConversation {
handle_ids: Vec<String>,
text: String,
},
MarkRead {
conversation_id: String,
},
SyncConversation {
conversation_id: String,
},
}
pub enum Event {
Conversations(Vec<ConversationSummary>),
Messages {
conversation_id: String,
messages: Vec<ChatMessage>,
},
MessageQueued {
conversation_id: Option<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::Reply {
conversation_id,
text,
} => client
.reply(conversation_id.clone(), text)
.map(|outgoing_id| Event::MessageQueued {
conversation_id: Some(conversation_id),
outgoing_id,
}),
Request::NewConversation { handle_ids, text } => client
.new_conversation(handle_ids, text)
.map(|outgoing_id| Event::MessageQueued {
conversation_id: None,
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 reply(&mut self, conversation_id: String, text: String) -> Result<Option<String>>;
fn new_conversation(&mut self, handle_ids: Vec<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

@@ -1,6 +1,6 @@
[package]
name = "kordophoned"
version = "1.0.1"
version = "1.3.0"
edition = "2021"
license = "GPL-3.0"
description = "Client daemon for the Kordophone chat protocol"
@@ -33,8 +33,7 @@ dbus-tree = "0.9.2"
# D-Bus codegen only on Linux
[target.'cfg(target_os = "linux")'.build-dependencies]
dbus-codegen = "0.10.0"
dbus-crossroads = "0.5.1"
dbus-codegen = { version = "0.10.0", default-features = false }
# XPC (libxpc) interface for macOS IPC
[target.'cfg(target_os = "macos")'.dependencies]
@@ -48,5 +47,18 @@ serde = { version = "1.0", features = ["derive"] }
assets = [
{ source = "../target/release/kordophoned", dest = "/usr/libexec/kordophoned", 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" },
]
[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
```
# Building DEB
Make sure cargo-deb is installed, `cargo install cargo-deb`.
Then:
```bash
cd core
cargo deb -p kordophoned
```
## Running on macOS
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>KeepAlive</key><true/>
```

View File

@@ -73,18 +73,17 @@
'sender' (string): Sender display name
'attachments' (array of dictionaries): List of attachments
'guid' (string): Attachment GUID
'path' (string): Attachment path
'preview_path' (string): Preview attachment path
'downloaded' (boolean): Whether the attachment is downloaded
'preview_downloaded' (boolean): Whether the preview is downloaded
'metadata' (dictionary, optional): Attachment metadata
'attribution_info' (dictionary, optional): Attribution info
'width' (int32, optional): Width
'height' (int32, optional): Height"/>
'height' (int32, optional): Height
Use GetAttachmentInfo for full/preview paths."/>
</arg>
</method>
<method name="SendMessage">
<method name="Reply">
<arg type="s" name="conversation_id" direction="in"/>
<arg type="s" name="text" direction="in"/>
<arg type="as" name="attachment_guids" direction="in"/>
@@ -92,9 +91,28 @@
<arg type="s" name="outgoing_message_id" direction="out"/>
<annotation name="org.freedesktop.DBus.DocString"
value="Sends a message to the server. Returns the outgoing message ID.
value="Replies to an existing conversation. Returns the outgoing message ID.
Arguments:
- conversation_id: The ID of the conversation to send the message to.
- conversation_id: The ID of the conversation to reply to.
- text: The text of the message to send.
- attachment_guids: The GUIDs of the attachments to send.
Returns:
- outgoing_message_id: The ID of the outgoing message.
"/>
</method>
<method name="NewConversation">
<arg type="as" name="handle_ids" direction="in"/>
<arg type="s" name="text" direction="in"/>
<arg type="as" name="attachment_guids" direction="in"/>
<arg type="s" name="outgoing_message_id" direction="out"/>
<annotation name="org.freedesktop.DBus.DocString"
value="Sends a message to a new conversation identified by resolved handles.
Arguments:
- handle_ids: The resolved handles for the new conversation.
- text: The text of the message to send.
- attachment_guids: The GUIDs of the attachments to send.
@@ -129,6 +147,20 @@
"/>
</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">
<arg type="s" name="attachment_id" direction="in"/>
<arg type="b" name="preview" direction="in"/>

View File

@@ -115,39 +115,57 @@ impl AttachmentStore {
base_path: base_path,
metadata: 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
let kind = "full";
// Best-effort: if files already exist, cache their exact paths and infer MIME type.
let stem = attachment
.base_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let legacy = attachment.base_path.with_extension(kind);
let existing_path = if legacy.exists() {
Some(legacy)
} else {
let prefix = format!("{}.{}.", stem, kind);
let legacy_full = attachment.base_path.with_extension("full");
if legacy_full.exists() {
attachment.cached_full_path = Some(legacy_full);
}
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
.base_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let mut found: Option<PathBuf> = None;
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(&prefix) && !name.ends_with(".download") {
found = Some(entry.path());
break;
if !name.ends_with(".download") {
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());
}
}
}
}
found
};
}
if let Some(existing) = existing_path {
if let Some(m) = mime_guess::from_path(&existing).first_raw() {
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());
}
}
@@ -342,6 +360,9 @@ impl AttachmentStore {
match kind {
AttachmentStoreError::AttachmentAlreadyDownloaded => {
log::debug!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", &_guid);
let _ = daemon_event_sink
.send(DaemonEvent::AttachmentDownloaded(_guid.clone()))
.await;
}
AttachmentStoreError::DownloadAlreadyInProgress => {
// Already logged a warning where detected
@@ -360,6 +381,10 @@ impl AttachmentStore {
log::debug!(target: target::ATTACHMENTS, "Queued download for attachment: {}", &guid);
} else {
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::time::Duration;
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Clone)]
pub struct EDSContactResolverBackend;
@@ -189,11 +191,10 @@ impl ContactResolverBackend for EDSContactResolverBackend {
None => return None,
};
let address_book_proxy = handle.connection.with_proxy(
&handle.bus_name,
&handle.object_path,
Duration::from_secs(60),
);
let address_book_proxy =
handle
.connection
.with_proxy(&handle.bus_name, &handle.object_path, LOOKUP_TIMEOUT);
ensure_address_book_open(&address_book_proxy);
@@ -255,11 +256,10 @@ impl ContactResolverBackend for EDSContactResolverBackend {
None => return None,
};
let address_book_proxy = handle.connection.with_proxy(
&handle.bus_name,
&handle.object_path,
Duration::from_secs(60),
);
let address_book_proxy =
handle
.connection
.with_proxy(&handle.bus_name, &handle.object_path, LOOKUP_TIMEOUT);
ensure_address_book_open(&address_book_proxy);

View File

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

View File

@@ -53,13 +53,21 @@ pub enum Event {
/// - last_message_id: (optional) The ID of the last message to get. If None, all messages are returned.
GetMessages(String, Option<String>, Reply<Vec<Message>>),
/// Enqueues a message to be sent to the server.
/// Enqueues a reply to an existing conversation.
/// Parameters:
/// - conversation_id: The ID of the conversation to send the message to.
/// - text: The text of the message to send.
/// - attachment_guids: The GUIDs of the attachments to send.
/// - reply: The outgoing message ID (not the server-assigned message ID).
SendMessage(String, String, Vec<String>, Reply<Uuid>),
Reply(String, String, Vec<String>, Reply<Uuid>),
/// Enqueues a message to one or more resolved handles.
/// Parameters:
/// - handle_ids: The resolved handle IDs for the new conversation.
/// - text: The text of the message to send.
/// - attachment_guids: The GUIDs of the attachments to send.
/// - reply: The outgoing message ID (not the server-assigned message ID).
NewConversation(Vec<String>, String, Vec<String>, Reply<Uuid>),
/// Notifies the daemon that a message has been sent.
/// Parameters:

View File

@@ -15,6 +15,7 @@ use std::collections::HashMap;
use std::error::Error;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use thiserror::Error;
use tokio::sync::mpsc::{Receiver, Sender};
@@ -28,7 +29,7 @@ use kordophone_db::{
use kordophone::api::http_client::HTTPAPIClient;
use kordophone::api::APIInterface;
use kordophone::model::outgoing_message::OutgoingMessage;
use kordophone::model::outgoing_message::{OutgoingMessage, OutgoingMessageTarget};
use kordophone::model::{ConversationID, MessageID};
mod update_monitor;
@@ -72,6 +73,8 @@ pub mod target {
pub static DAEMON: &str = "daemon";
}
const GET_MESSAGES_INITIAL_WINDOW: usize = 300;
pub struct Daemon {
pub event_sender: Sender<Event>,
event_receiver: Receiver<Event>,
@@ -185,14 +188,14 @@ impl Daemon {
async fn handle_event(&mut self, event: Event) {
match event {
Event::GetVersion(reply) => {
reply.send(self.version.clone()).unwrap();
let _ = reply.send(self.version.clone());
}
Event::SyncConversationList(reply) => {
self.spawn_conversation_list_sync();
// This is a background operation, so return right away.
reply.send(()).unwrap();
let _ = reply.send(());
}
Event::SyncAllConversations(reply) => {
@@ -207,7 +210,7 @@ impl Daemon {
});
// This is a background operation, so return right away.
reply.send(()).unwrap();
let _ = reply.send(());
}
Event::SyncConversation(conversation_id, reply) => {
@@ -225,7 +228,7 @@ impl Daemon {
}
});
reply.send(()).unwrap();
let _ = reply.send(());
}
Event::MarkConversationAsRead(conversation_id, reply) => {
@@ -237,7 +240,7 @@ impl Daemon {
}
});
reply.send(()).unwrap();
let _ = reply.send(());
}
Event::UpdateConversationMetadata(conversation, reply) => {
@@ -250,7 +253,7 @@ impl Daemon {
}
});
reply.send(()).unwrap();
let _ = reply.send(());
}
Event::UpdateStreamReconnected => {
@@ -268,7 +271,7 @@ impl Daemon {
Event::GetAllConversations(limit, offset, reply) => {
let conversations = self.get_conversations_limit_offset(limit, offset).await;
reply.send(conversations).unwrap();
let _ = reply.send(conversations);
}
Event::GetAllSettings(reply) => {
@@ -277,7 +280,7 @@ impl Daemon {
Settings::default()
});
reply.send(settings).unwrap();
let _ = reply.send(settings);
}
Event::UpdateSettings(settings, reply) => {
@@ -309,12 +312,14 @@ impl Daemon {
}
}
reply.send(()).unwrap();
let _ = reply.send(());
}
Event::GetMessages(conversation_id, last_message_id, reply) => {
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) => {
@@ -322,15 +327,19 @@ impl Daemon {
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::Reply(conversation_id, text, attachment_guids, reply) => {
let conversation_id = conversation_id.clone();
let uuid = self
.enqueue_outgoing_message(text, conversation_id.clone(), attachment_guids)
.enqueue_outgoing_message(
text,
OutgoingMessageTarget::Conversation(conversation_id.clone()),
attachment_guids,
)
.await;
reply.send(uuid).unwrap();
let _ = reply.send(uuid);
// Send message updated signal, we have a placeholder message we will return.
self.signal_sender
@@ -339,23 +348,83 @@ impl Daemon {
.unwrap();
}
Event::NewConversation(handle_ids, text, attachment_guids, reply) => {
let uuid = self
.enqueue_outgoing_message(
text,
OutgoingMessageTarget::Handles(handle_ids),
attachment_guids,
)
.await;
let _ = reply.send(uuid);
}
Event::MessageSent(message, outgoing_message, conversation_id) => {
log::info!(target: target::EVENT, "Daemon: message sent: {}", message.id);
let conversation_created = match self
.ensure_conversation_exists_for_sent_message(
&conversation_id,
&outgoing_message,
&message,
)
.await
{
Ok(created) => created,
Err(e) => {
log::error!(
target: target::EVENT,
"Failed to ensure conversation {} exists for sent message {}: {}",
conversation_id,
message.id,
e
);
return;
}
};
if conversation_created {
self.signal_sender
.send(Signal::ConversationsUpdated)
.await
.unwrap();
}
// Insert the message into the database.
log::debug!(target: target::EVENT, "inserting sent message into database: {}", message.id);
self.database
if let Err(e) = self
.database
.lock()
.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
.unwrap();
{
log::error!(
target: target::EVENT,
"Failed to persist sent message {} for conversation {}: {}",
message.id,
conversation_id,
e
);
return;
}
// Remove from outgoing messages.
log::debug!(target: target::EVENT, "Removing message from outgoing messages: {}", outgoing_message.guid);
for messages in self.outgoing_messages.values_mut() {
messages.retain(|m| m.guid != outgoing_message.guid);
}
self.outgoing_messages
.get_mut(&conversation_id)
.map(|messages| messages.retain(|m| m.guid != outgoing_message.guid));
.retain(|_, messages| !messages.is_empty());
// Send message updated signal.
self.signal_sender
@@ -386,7 +455,7 @@ impl Daemon {
.await
.unwrap();
reply.send(()).unwrap();
let _ = reply.send(());
}
Event::AttachmentDownloaded(attachment_id) => {
@@ -439,8 +508,9 @@ impl Daemon {
async fn get_messages(
&mut self,
conversation_id: String,
_last_message_id: Option<MessageID>,
last_message_id: Option<MessageID>,
) -> Vec<Message> {
let started = Instant::now();
// Get outgoing messages for this conversation.
let empty_vec: Vec<OutgoingMessage> = vec![];
let outgoing_messages: &Vec<OutgoingMessage> = self
@@ -448,38 +518,141 @@ impl Daemon {
.get(&conversation_id)
.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()
.await
.with_repository(|r| {
r.get_messages_for_conversation(&conversation_id)
.unwrap()
.into_iter()
.map(|m| m.into()) // Convert db::Message to daemon::Message
.chain(outgoing_messages.into_iter().map(|m| m.into()))
.collect()
let msgs = r.get_messages_for_conversation(&conversation_id).unwrap();
let ids: Vec<String> = msgs.iter().map(|m| m.id.clone()).collect();
let map = r.get_local_ids_for(ids).unwrap_or_default();
(msgs, map)
})
.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 ensure_conversation_exists_for_sent_message(
&mut self,
conversation_id: &ConversationID,
outgoing_message: &OutgoingMessage,
message: &Message,
) -> Result<bool> {
let conversation_exists = self
.database
.lock()
.await
.with_repository(|r| r.get_conversation_by_guid(conversation_id))
.await?
.is_some();
if conversation_exists {
return Ok(false);
}
let participants = Self::participants_for_outgoing_message(outgoing_message);
let mut builder = Conversation::builder()
.guid(conversation_id)
.date(message.date)
.unread_count(0)
.participants(participants);
if !message.text.trim().is_empty() {
builder = builder.last_message_preview(&message.text);
}
let conversation = builder.build();
log::info!(
target: target::EVENT,
"Creating local conversation {} from sent message {}",
conversation_id,
message.id
);
self.database
.lock()
.await
.with_repository(|r| r.insert_conversation(conversation))
.await?;
Ok(true)
}
fn participants_for_outgoing_message(outgoing_message: &OutgoingMessage) -> Vec<DbParticipant> {
let handle_ids = match &outgoing_message.target {
OutgoingMessageTarget::Conversation(_) => return Vec::new(),
OutgoingMessageTarget::Handles(handle_ids) => handle_ids,
};
let mut contact_resolver = ContactResolver::new(DefaultContactResolverBackend::default());
handle_ids
.iter()
.map(|handle| DbParticipant::Remote {
handle: handle.clone(),
contact_id: contact_resolver.resolve_contact_id(handle),
})
.collect()
}
async fn enqueue_outgoing_message(
&mut self,
text: String,
conversation_id: String,
target: OutgoingMessageTarget,
attachment_guids: Vec<String>,
) -> Uuid {
let conversation_id = conversation_id.clone();
let outgoing_message = OutgoingMessage::builder()
.text(text)
.conversation_id(conversation_id.clone())
.target(target)
.file_transfer_guids(attachment_guids)
.build();
// Keep a record of this so we can provide a consistent model to the client.
self.outgoing_messages
.entry(conversation_id)
.or_insert(vec![])
.push(outgoing_message.clone());
if let Some(conversation_id) = outgoing_message.conversation_id().cloned() {
// Keep a record of replies so we can provide a consistent model to the client.
self.outgoing_messages
.entry(conversation_id)
.or_insert(vec![])
.push(outgoing_message.clone());
}
let guid = outgoing_message.guid.clone();
self.post_office_sink

View File

@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf};
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct AttachmentMetadata {
@@ -17,6 +17,8 @@ pub struct Attachment {
pub base_path: PathBuf,
pub metadata: Option<AttachmentMetadata>,
pub mime_type: Option<String>,
pub cached_full_path: Option<PathBuf>,
pub cached_preview_path: Option<PathBuf>,
}
impl Attachment {
@@ -25,15 +27,14 @@ impl Attachment {
// Prefer common, user-friendly extensions over obscure ones
match normalized {
"image/jpeg" | "image/pjpeg" => Some("jpg"),
_ => mime_guess::get_mime_extensions_str(normalized)
.and_then(|list| {
// If jpg is one of the candidates, prefer it
if list.iter().any(|e| *e == "jpg") {
Some("jpg")
} else {
list.first().copied()
}
}),
_ => mime_guess::get_mime_extensions_str(normalized).and_then(|list| {
// If jpg is one of the candidates, prefer it
if list.iter().any(|e| *e == "jpg") {
Some("jpg")
} else {
list.first().copied()
}
}),
}
}
pub fn get_path(&self) -> PathBuf {
@@ -45,17 +46,21 @@ impl Attachment {
}
pub fn get_path_for_preview_scratch(&self, preview: bool, scratch: bool) -> PathBuf {
// Determine whether this is a preview or full attachment.
let kind = if preview { "preview" } else { "full" };
// If not a scratch path, and a file already exists on disk with a concrete
// file extension (e.g., guid.full.jpg), return that existing path.
if !scratch {
if let Some(existing) = self.find_existing_path(preview) {
return existing;
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.
let kind = if preview { "preview" } else { "full" };
// Fall back to constructing a path using known info. If we know the MIME type,
// prefer an extension guessed from it; otherwise keep legacy naming.
let ext_from_mime = self
@@ -77,44 +82,15 @@ impl Attachment {
}
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!(
"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(),
)
}
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 {

View File

@@ -8,6 +8,7 @@ use tokio_condvar::Condvar;
use crate::daemon::events::Event as DaemonEvent;
use kordophone::api::APIInterface;
use kordophone::model::outgoing_message::OutgoingMessage;
use kordophone::model::OutgoingMessageTarget;
use anyhow::Result;
@@ -102,10 +103,29 @@ impl<C: APIInterface, F: AsyncFnMut() -> Result<C>> PostOffice<C, F> {
Ok(sent_message) => {
log::info!(target: target::POST_OFFICE, "Message sent successfully: {}", message.guid);
let conversation_id = message.conversation_id.clone();
let event =
DaemonEvent::MessageSent(sent_message.into(), message, conversation_id);
event_sink.send(event).await.unwrap();
let conversation_id = sent_message.conversation_id.clone().or_else(|| {
match &message.target {
OutgoingMessageTarget::Conversation(conversation_id) => {
Some(conversation_id.clone())
}
OutgoingMessageTarget::Handles(_) => None,
}
});
if let Some(conversation_id) = conversation_id {
let event = DaemonEvent::MessageSent(
sent_message.message.into(),
message,
conversation_id,
);
event_sink.send(event).await.unwrap();
} else {
log::error!(
target: target::POST_OFFICE,
"Message sent but no conversation id was available for {}",
message.guid
);
}
}
Err(e) => {

View File

@@ -1,6 +1,9 @@
use dbus::arg;
use dbus_tree::MethodErr;
use std::fs::OpenOptions;
use std::os::fd::{FromRawFd, IntoRawFd};
use std::sync::Arc;
use std::time::Instant;
use std::{future::Future, thread};
use tokio::sync::{mpsc, oneshot, Mutex};
@@ -176,9 +179,8 @@ impl DBusAgent {
&self,
make_event: impl FnOnce(Reply<T>) -> Event + Send,
) -> Result<T, MethodErr> {
run_sync_future(self.send_event(make_event))
.unwrap()
.map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e)))
let daemon_result = run_sync_future(self.send_event(make_event))?;
daemon_result.map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e)))
}
fn resolve_participant_display_name(&mut self, participant: &Participant) -> String {
@@ -278,123 +280,131 @@ impl DbusRepository for DBusAgent {
conversation_id: String,
last_message_id: String,
) -> Result<Vec<arg::PropMap>, MethodErr> {
let started = Instant::now();
let last_message_id_opt = if last_message_id.is_empty() {
None
} else {
Some(last_message_id)
};
self.send_event_sync(|r| Event::GetMessages(conversation_id, last_message_id_opt, r))
.map(|messages| {
messages
.into_iter()
.map(|msg| {
let mut map = arg::PropMap::new();
map.insert("id".into(), arg::Variant(Box::new(msg.id)));
let messages =
self.send_event_sync(|r| Event::GetMessages(conversation_id, last_message_id_opt, r))?;
// Remove the attachment placeholder here.
let text = msg.text.replace("\u{FFFC}", "");
let mut attachment_count: usize = 0;
let mut text_bytes: usize = 0;
map.insert("text".into(), arg::Variant(Box::new(text)));
map.insert(
"date".into(),
arg::Variant(Box::new(msg.date.and_utc().timestamp())),
);
map.insert(
"sender".into(),
arg::Variant(Box::new(
self.resolve_participant_display_name(&msg.sender.into()),
)),
);
let mapped: Vec<arg::PropMap> = messages
.into_iter()
.map(|msg| {
let mut map = arg::PropMap::new();
map.insert("id".into(), arg::Variant(Box::new(msg.id)));
// Attachments array
let attachments: Vec<arg::PropMap> = msg
.attachments
.into_iter()
.map(|attachment| {
let mut attachment_map = arg::PropMap::new();
attachment_map.insert(
"guid".into(),
arg::Variant(Box::new(attachment.guid.clone())),
);
// Remove the attachment placeholder here.
let text = msg.text.replace("\u{FFFC}", "");
text_bytes += text.len();
// 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);
map.insert("text".into(), arg::Variant(Box::new(text)));
map.insert(
"date".into(),
arg::Variant(Box::new(msg.date.and_utc().timestamp())),
);
map.insert(
"sender".into(),
arg::Variant(Box::new(msg.sender.display_name())),
);
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(
"downloaded".into(),
arg::Variant(Box::new(downloaded)),
);
attachment_map.insert(
"preview_downloaded".into(),
arg::Variant(Box::new(preview_downloaded)),
);
if !msg.attachments.is_empty() {
let attachments: Vec<arg::PropMap> = msg
.attachments
.into_iter()
.map(|attachment| {
attachment_count += 1;
let mut attachment_map = arg::PropMap::new();
attachment_map.insert(
"guid".into(),
arg::Variant(Box::new(attachment.guid.clone())),
);
attachment_map.insert(
"downloaded".into(),
arg::Variant(Box::new(attachment.is_downloaded(false))),
);
attachment_map.insert(
"preview_downloaded".into(),
arg::Variant(Box::new(attachment.is_downloaded(true))),
);
// Metadata
if let Some(ref metadata) = attachment.metadata {
let mut metadata_map = arg::PropMap::new();
if let Some(ref metadata) = attachment.metadata {
let mut metadata_map = arg::PropMap::new();
if let Some(ref attribution_info) = metadata.attribution_info {
let mut attribution_map = arg::PropMap::new();
if let Some(width) = attribution_info.width {
attribution_map.insert(
"width".into(),
arg::Variant(Box::new(width as i32)),
);
}
if let Some(height) = attribution_info.height {
attribution_map.insert(
"height".into(),
arg::Variant(Box::new(height as i32)),
);
}
metadata_map.insert(
"attribution_info".into(),
arg::Variant(Box::new(attribution_map)),
if let Some(ref attribution_info) = metadata.attribution_info {
let mut attribution_map = arg::PropMap::new();
if let Some(width) = attribution_info.width {
attribution_map.insert(
"width".into(),
arg::Variant(Box::new(width as i32)),
);
}
attachment_map.insert(
"metadata".into(),
arg::Variant(Box::new(metadata_map)),
if let Some(height) = attribution_info.height {
attribution_map.insert(
"height".into(),
arg::Variant(Box::new(height as i32)),
);
}
metadata_map.insert(
"attribution_info".into(),
arg::Variant(Box::new(attribution_map)),
);
}
attachment_map
})
.collect();
attachment_map.insert(
"metadata".into(),
arg::Variant(Box::new(metadata_map)),
);
}
attachment_map
})
.collect();
map.insert("attachments".into(), arg::Variant(Box::new(attachments)));
map
})
.collect()
map.insert("attachments".into(), arg::Variant(Box::new(attachments)));
}
map
})
.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> {
self.send_event_sync(Event::DeleteAllConversations)
}
fn send_message(
fn reply(
&mut self,
conversation_id: String,
text: String,
attachment_guids: Vec<String>,
) -> Result<String, MethodErr> {
self.send_event_sync(|r| Event::SendMessage(conversation_id, text, attachment_guids, r))
self.send_event_sync(|r| Event::Reply(conversation_id, text, attachment_guids, r))
.map(|uuid| uuid.to_string())
}
fn new_conversation(
&mut self,
handle_ids: Vec<String>,
text: String,
attachment_guids: Vec<String>,
) -> Result<String, MethodErr> {
self.send_event_sync(|r| Event::NewConversation(handle_ids, text, attachment_guids, r))
.map(|uuid| uuid.to_string())
}
@@ -425,6 +435,23 @@ impl DbusRepository for DBusAgent {
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> {
use std::path::PathBuf;
let path = PathBuf::from(path);
@@ -496,7 +523,7 @@ where
T: Send,
F: Future<Output = T> + Send,
{
thread::scope(move |s| {
let joined = thread::scope(move |s| {
s.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -507,6 +534,10 @@ where
Ok(result)
})
.join()
})
.expect("Error joining runtime thread")
});
match joined {
Ok(result) => result,
Err(_) => Err(MethodErr::failed("Error joining runtime thread")),
}
}

View File

@@ -127,7 +127,7 @@ impl XpcAgent {
// Drop any cleanup resource now that payload is constructed and sent.
drop(result.cleanup);
log::trace!(target: LOG_TARGET, "XPC reply sent for method: {}", method);
} else {
log::warn!(target: LOG_TARGET, "No reply port for method: {}", method);

View File

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

View File

@@ -105,7 +105,12 @@ pub async fn dispatch(
.and_then(|m| dict_get_str(m, "conversation_id"))
{
Some(id) => id,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
};
match agent
.send_event(|r| Event::SyncConversation(conversation_id, r))
@@ -122,7 +127,12 @@ pub async fn dispatch(
.and_then(|m| dict_get_str(m, "conversation_id"))
{
Some(id) => id,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
};
match agent
.send_event(|r| Event::MarkConversationAsRead(conversation_id, r))
@@ -137,11 +147,21 @@ pub async fn dispatch(
"GetMessages" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let conversation_id = match dict_get_str(args, "conversation_id") {
Some(id) => id,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
};
let last_message_id = dict_get_str(args, "last_message_id");
match agent
@@ -158,13 +178,10 @@ pub async fn dispatch(
dict_put_str(&mut m, "sender", &msg.sender.display_name());
// Include attachment GUIDs for the client to resolve/download
let attachment_guids: Vec<String> = msg
.attachments
.iter()
.map(|a| a.guid.clone())
.collect();
let attachment_guids: Vec<String> =
msg.attachments.iter().map(|a| a.guid.clone()).collect();
m.insert(cstr("attachment_guids"), array_from_strs(attachment_guids));
// Full attachments array with metadata (mirrors DBus fields)
let mut attachments_items: Vec<Message> = Vec::new();
for attachment in msg.attachments.iter() {
@@ -193,12 +210,23 @@ pub async fn dispatch(
if let Some(attribution_info) = &metadata.attribution_info {
let mut attribution_map: XpcMap = HashMap::new();
if let Some(width) = attribution_info.width {
dict_put_i64_as_str(&mut attribution_map, "width", width as i64);
dict_put_i64_as_str(
&mut attribution_map,
"width",
width as i64,
);
}
if let Some(height) = attribution_info.height {
dict_put_i64_as_str(&mut attribution_map, "height", height as i64);
dict_put_i64_as_str(
&mut attribution_map,
"height",
height as i64,
);
}
metadata_map.insert(cstr("attribution_info"), Message::Dictionary(attribution_map));
metadata_map.insert(
cstr("attribution_info"),
Message::Dictionary(attribution_map),
);
}
if !metadata_map.is_empty() {
a.insert(cstr("metadata"), Message::Dictionary(metadata_map));
@@ -208,7 +236,7 @@ pub async fn dispatch(
attachments_items.push(Message::Dictionary(a));
}
m.insert(cstr("attachments"), Message::Array(attachments_items));
items.push(Message::Dictionary(m));
}
let mut reply: XpcMap = HashMap::new();
@@ -226,15 +254,25 @@ pub async fn dispatch(
Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))),
},
// SendMessage
"SendMessage" => {
// Reply
"Reply" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let conversation_id = match dict_get_str(args, "conversation_id") {
Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
};
let text = dict_get_str(args, "text").unwrap_or_default();
let attachment_guids: Vec<String> = match args.get(&cstr("attachment_guids")) {
@@ -248,12 +286,64 @@ pub async fn dispatch(
_ => Vec::new(),
};
match agent
.send_event(|r| Event::SendMessage(conversation_id, text, attachment_guids, r))
.send_event(|r| Event::Reply(conversation_id, text, attachment_guids, r))
.await
{
Ok(uuid) => {
let mut reply: XpcMap = HashMap::new();
dict_put_str(&mut reply, "type", "SendMessageResponse");
dict_put_str(&mut reply, "type", "ReplyResponse");
dict_put_str(&mut reply, "uuid", &uuid.to_string());
DispatchResult::new(Message::Dictionary(reply))
}
Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))),
}
}
// NewConversation
"NewConversation" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let handle_ids: Vec<String> = match args.get(&cstr("handle_ids")) {
Some(Message::Array(arr)) => arr
.iter()
.filter_map(|m| match m {
Message::String(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect(),
_ => Vec::new(),
};
if handle_ids.is_empty() {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing handle_ids",
));
}
let text = dict_get_str(args, "text").unwrap_or_default();
let attachment_guids: Vec<String> = match args.get(&cstr("attachment_guids")) {
Some(Message::Array(arr)) => arr
.iter()
.filter_map(|m| match m {
Message::String(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect(),
_ => Vec::new(),
};
match agent
.send_event(|r| Event::NewConversation(handle_ids, text, attachment_guids, r))
.await
{
Ok(uuid) => {
let mut reply: XpcMap = HashMap::new();
dict_put_str(&mut reply, "type", "NewConversationResponse");
dict_put_str(&mut reply, "uuid", &uuid.to_string());
DispatchResult::new(Message::Dictionary(reply))
}
@@ -265,11 +355,21 @@ pub async fn dispatch(
"GetAttachmentInfo" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let attachment_id = match dict_get_str(args, "attachment_id") {
Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing attachment_id",
))
}
};
match agent
.send_event(|r| Event::GetAttachment(attachment_id, r))
@@ -308,11 +408,21 @@ pub async fn dispatch(
"OpenAttachmentFd" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let attachment_id = match dict_get_str(args, "attachment_id") {
Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing attachment_id",
))
}
};
let preview = dict_get_str(args, "preview")
.map(|s| s == "true")
@@ -324,7 +434,7 @@ pub async fn dispatch(
{
Ok(attachment) => {
use std::os::fd::AsRawFd;
let path = attachment.get_path_for_preview(preview);
match std::fs::OpenOptions::new().read(true).open(&path) {
Ok(file) => {
@@ -335,9 +445,14 @@ pub async fn dispatch(
dict_put_str(&mut reply, "type", "OpenAttachmentFdResponse");
reply.insert(cstr("fd"), Message::Fd(fd));
DispatchResult { message: Message::Dictionary(reply), cleanup: Some(Box::new(file)) }
DispatchResult {
message: Message::Dictionary(reply),
cleanup: Some(Box::new(file)),
}
}
Err(e) => {
DispatchResult::new(make_error_reply("OpenFailed", &format!("{}", e)))
}
Err(e) => DispatchResult::new(make_error_reply("OpenFailed", &format!("{}", e))),
}
}
Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))),
@@ -348,11 +463,21 @@ pub async fn dispatch(
"DownloadAttachment" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let attachment_id = match dict_get_str(args, "attachment_id") {
Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing attachment_id",
))
}
};
let preview = dict_get_str(args, "preview")
.map(|s| s == "true")
@@ -371,11 +496,18 @@ pub async fn dispatch(
use std::path::PathBuf;
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let path = match dict_get_str(args, "path") {
Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing path")),
None => {
return DispatchResult::new(make_error_reply("InvalidRequest", "Missing path"))
}
};
match agent
.send_event(|r| Event::UploadAttachment(PathBuf::from(path), r))
@@ -413,7 +545,12 @@ pub async fn dispatch(
"UpdateSettings" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let server_url = dict_get_str(args, "server_url");
let username = dict_get_str(args, "username");

View File

@@ -28,7 +28,7 @@ dbus-tree = "0.9.2"
# D-Bus codegen only on Linux
[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
[target.'cfg(target_os = "macos")'.dependencies]

View File

@@ -5,10 +5,10 @@ use kordophone::api::InMemoryAuthenticationStore;
use kordophone::APIInterface;
use crate::printers::{ConversationPrinter, MessagePrinter};
use anyhow::Result;
use anyhow::{bail, Result};
use clap::Subcommand;
use kordophone::model::event::EventData;
use kordophone::model::outgoing_message::OutgoingMessage;
use kordophone::model::{HandleResolutionStatus, OutgoingMessage, OutgoingMessageTarget};
use futures_util::StreamExt;
@@ -47,14 +47,29 @@ pub enum Commands {
/// Prints all raw updates from the server.
RawUpdates,
/// Sends a message to the server.
SendMessage {
/// Resolves an address to a canonical handle.
#[command(alias = "resolve")]
ResolveHandle { address: String },
/// Replies to an existing conversation.
#[command(alias = "send-message")]
Reply {
conversation_id: String,
message: String,
},
/// Starts a new message to one or more resolved handles.
New {
#[arg(long = "handle", required = true)]
handle_ids: Vec<String>,
message: String,
},
/// Marks a conversation as read.
Mark { conversation_id: String },
/// Deletes a conversation from the server.
Delete { conversation_id: String },
}
impl Commands {
@@ -66,13 +81,21 @@ impl Commands {
Commands::Messages { conversation_id } => client.print_messages(conversation_id).await,
Commands::RawUpdates => client.print_raw_updates().await,
Commands::Events => client.print_events().await,
Commands::SendMessage {
Commands::ResolveHandle { address } => client.resolve_handle(address).await,
Commands::Reply {
conversation_id,
message,
} => client.send_message(conversation_id, message).await,
} => client.reply(conversation_id, message).await,
Commands::New {
handle_ids,
message,
} => client.new_message(handle_ids, message).await,
Commands::Mark { conversation_id } => {
client.mark_conversation_as_read(conversation_id).await
}
Commands::Delete { conversation_id } => {
client.delete_conversation(conversation_id).await
}
}
}
}
@@ -143,15 +166,23 @@ impl ClientCli {
println!("Listening for raw updates...");
let mut stream = socket.raw_updates().await;
while let Some(Ok(update)) = stream.next().await {
match update {
SocketUpdate::Update(updates) => {
for update in updates {
println!("Got update: {:?}", update);
loop {
match stream.next().await.unwrap() {
Ok(update) => match update {
SocketUpdate::Update(updates) => {
for update in updates {
println!("Got update: {:?}", update);
}
}
}
SocketUpdate::Pong => {
println!("Pong");
SocketUpdate::Pong => {
println!("Pong");
}
},
Err(e) => {
println!("Update error: {:?}", e);
break;
}
}
}
@@ -159,20 +190,91 @@ impl ClientCli {
Ok(())
}
pub async fn send_message(&mut self, conversation_id: String, message: String) -> Result<()> {
pub async fn resolve_handle(&mut self, address: String) -> Result<()> {
let response = self.api.resolve_handle(&address).await?;
let status = match response.status {
HandleResolutionStatus::Valid => "valid",
HandleResolutionStatus::Invalid => "invalid",
HandleResolutionStatus::Unknown => "unknown",
};
println!("Resolved handle: {}", response.resolved_handle.id);
if let Some(name) = response.resolved_handle.name {
println!("Name: {}", name);
}
println!("Status: {}", status);
if let Some(conversation_id) = response.existing_chat {
println!("Existing conversation: {}", conversation_id);
}
Ok(())
}
async fn send_message(&mut self, target: OutgoingMessageTarget, message: String) -> Result<()> {
let outgoing_message = OutgoingMessage::builder()
.conversation_id(conversation_id)
.target(target)
.text(message)
.build();
let message = self.api.send_message(&outgoing_message).await?;
println!("Message sent: {}", message.guid);
let response = self.api.send_message(&outgoing_message).await?;
if let Some(conversation_id) = response.conversation_id {
println!(
"Message sent: {} conversation: {}",
response.message.guid, conversation_id
);
} else {
println!("Message sent: {}", response.message.guid);
}
Ok(())
}
async fn resolve_handle_ids(&mut self, handle_ids: Vec<String>) -> Result<Vec<String>> {
let mut resolved_handle_ids = Vec::with_capacity(handle_ids.len());
for handle_id in handle_ids {
let response = self.api.resolve_handle(&handle_id).await?;
match response.status {
HandleResolutionStatus::Valid => {
resolved_handle_ids.push(response.resolved_handle.id);
}
HandleResolutionStatus::Invalid => {
bail!("Handle '{}' is not iMessage-capable.", handle_id);
}
HandleResolutionStatus::Unknown => {
bail!("Handle '{}' could not be resolved.", handle_id);
}
}
}
Ok(resolved_handle_ids)
}
pub async fn reply(&mut self, conversation_id: String, message: String) -> Result<()> {
self.send_message(
OutgoingMessageTarget::Conversation(conversation_id),
message,
)
.await
}
pub async fn new_message(&mut self, handle_ids: Vec<String>, message: String) -> Result<()> {
let resolved_handle_ids = self.resolve_handle_ids(handle_ids).await?;
self.send_message(OutgoingMessageTarget::Handles(resolved_handle_ids), message)
.await
}
pub async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
self.api.mark_conversation_as_read(&conversation_id).await?;
println!("Conversation marked as read: {}", conversation_id);
Ok(())
}
pub async fn delete_conversation(&mut self, conversation_id: String) -> Result<()> {
self.api.delete_conversation(&conversation_id).await?;
println!("Conversation deleted: {}", conversation_id);
Ok(())
}
}

View File

@@ -33,7 +33,7 @@ impl DBusDaemonInterface {
fn proxy(&self) -> Proxy<&Connection> {
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<()> {
@@ -109,15 +109,20 @@ impl DaemonInterface for DBusDaemonInterface {
Ok(())
}
async fn enqueue_outgoing_message(
&mut self,
conversation_id: String,
text: String,
) -> Result<()> {
async fn reply(&mut self, conversation_id: String, text: String) -> Result<()> {
let attachment_guids: Vec<&str> = vec![];
let outgoing_message_id = KordophoneRepository::send_message(
let outgoing_message_id =
KordophoneRepository::reply(&self.proxy(), &conversation_id, &text, attachment_guids)?;
println!("Outgoing message ID: {}", outgoing_message_id);
Ok(())
}
async fn new_conversation(&mut self, handle_ids: Vec<String>, text: String) -> Result<()> {
let attachment_guids: Vec<&str> = vec![];
let handle_ids: Vec<&str> = handle_ids.iter().map(String::as_str).collect();
let outgoing_message_id = KordophoneRepository::new_conversation(
&self.proxy(),
&conversation_id,
handle_ids,
&text,
attachment_guids,
)?;

View File

@@ -21,11 +21,8 @@ pub trait DaemonInterface {
conversation_id: String,
last_message_id: Option<String>,
) -> Result<()>;
async fn enqueue_outgoing_message(
&mut self,
conversation_id: String,
text: String,
) -> Result<()>;
async fn reply(&mut self, conversation_id: String, text: String) -> Result<()>;
async fn new_conversation(&mut self, handle_ids: Vec<String>, text: String) -> Result<()>;
async fn wait_for_signals(&mut self) -> Result<()>;
async fn config(&mut self, cmd: ConfigCommands) -> Result<()>;
async fn delete_all_conversations(&mut self) -> Result<()>;
@@ -73,11 +70,12 @@ impl DaemonInterface for StubDaemonInterface {
"Daemon interface not implemented on this platform"
))
}
async fn enqueue_outgoing_message(
&mut self,
_conversation_id: String,
_text: String,
) -> Result<()> {
async fn reply(&mut self, _conversation_id: String, _text: String) -> Result<()> {
Err(anyhow::anyhow!(
"Daemon interface not implemented on this platform"
))
}
async fn new_conversation(&mut self, _handle_ids: Vec<String>, _text: String) -> Result<()> {
Err(anyhow::anyhow!(
"Daemon interface not implemented on this platform"
))
@@ -161,12 +159,20 @@ pub enum Commands {
/// Deletes all conversations.
DeleteAllConversations,
/// Enqueues an outgoing message to be sent to a conversation.
SendMessage {
/// Replies to an existing conversation.
#[command(alias = "send-message")]
Reply {
conversation_id: String,
text: String,
},
/// Starts a new conversation with one or more resolved handles.
New {
#[arg(long = "handle", required = true)]
handle_ids: Vec<String>,
text: String,
},
/// Downloads an attachment from the server to the attachment store. Returns the path to the attachment.
DownloadAttachment { attachment_id: String },
@@ -208,10 +214,11 @@ impl Commands {
.await
}
Commands::DeleteAllConversations => client.delete_all_conversations().await,
Commands::SendMessage {
Commands::Reply {
conversation_id,
text,
} => client.enqueue_outgoing_message(conversation_id, text).await,
} => client.reply(conversation_id, text).await,
Commands::New { handle_ids, text } => client.new_conversation(handle_ids, text).await,
Commands::UploadAttachment { path } => client.upload_attachment(path).await,
Commands::DownloadAttachment { attachment_id } => {
client.download_attachment(attachment_id).await

View File

@@ -371,11 +371,7 @@ impl DaemonInterface for XpcDaemonInterface {
_ => Err(anyhow::anyhow!("Unexpected messages payload")),
}
}
async fn enqueue_outgoing_message(
&mut self,
_conversation_id: String,
_text: String,
) -> Result<()> {
async fn reply(&mut self, _conversation_id: String, _text: String) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
let mut args = HashMap::new();
@@ -387,10 +383,34 @@ impl DaemonInterface for XpcDaemonInterface {
Self::key("text"),
Message::String(CString::new(_text).unwrap()),
);
let reply = self
.call_method(&mut client, "SendMessage", Some(args))
let response = self.call_method(&mut client, "Reply", Some(args)).await?;
if let Some(uuid) = Self::get_string(&response, "uuid") {
println!("Outgoing message ID: {}", uuid.to_string_lossy());
}
Ok(())
}
async fn new_conversation(&mut self, handle_ids: Vec<String>, text: String) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
let mut args = HashMap::new();
args.insert(
Self::key("handle_ids"),
Message::Array(
handle_ids
.into_iter()
.map(|handle_id| Message::String(CString::new(handle_id).unwrap()))
.collect(),
),
);
args.insert(
Self::key("text"),
Message::String(CString::new(text).unwrap()),
);
let response = self
.call_method(&mut client, "NewConversation", Some(args))
.await?;
if let Some(uuid) = Self::get_string(&reply, "uuid") {
if let Some(uuid) = Self::get_string(&response, "uuid") {
println!("Outgoing message ID: {}", uuid.to_string_lossy());
}
Ok(())

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"

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

@@ -0,0 +1,791 @@
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::MessageQueued {
conversation_id,
outgoing_id,
} => {
if let Some(conversation_id) = conversation_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::Reply {
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);
}
}

View File

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

263
docs/plans/GLIB_BINDINGS.md Normal file
View File

@@ -0,0 +1,263 @@
# GLib Bindings Plan
## Status
Proposed. Not started.
## Context
Today the GTK app talks to `kordophoned` directly over D-Bus in
[`gtk/src/service/repository.vala`](/home/buzzert/src/Kordophone/gtk/src/service/repository.vala)
and the generated interface in
[`gtk/src/service/interface/dbusservice.vala`](/home/buzzert/src/Kordophone/gtk/src/service/interface/dbusservice.vala).
At the same time, the Rust-side daemon client logic already exists in
[`core/kordophoned-client/src/worker.rs`](/home/buzzert/src/Kordophone/core/kordophoned-client/src/worker.rs)
with platform backends for D-Bus and XPC. That means protocol changes currently
have to be reflected in multiple places:
- `kordophoned` D-Bus/XPC server shims
- `kordophoned-client` Rust transport layer
- GTK/Vala D-Bus interface and proxy code
- Swift XPC client code
For GTK/Vala specifically, the goal is to stop binding the application directly
to the daemon protocol surface.
## Recommendation
Add a GTK-facing GLib/GObject wrapper on top of a small C ABI exported from the
Rust daemon client stack.
Do not expose the current `kordophoned-client` Rust API directly as raw C.
The current surface uses Rust enums, `Vec<String>`, `Option`, and a threaded
worker model, which is fine internally but not a good stable FFI boundary.
The recommended layering is:
1. Keep `core/kordophoned-client` as the Rust-native transport/domain layer.
2. Add a new FFI crate with a narrow, C-safe API.
3. Add a small GLib/GObject wrapper for GTK/Vala consumption.
4. Migrate the GTK app to that wrapper and remove its direct D-Bus binding code.
This keeps one transport implementation in Rust while giving Vala a natural
GObject-style API with methods, async operations, and signals.
## Why Not Direct Rust GObject Export?
Exporting a GObject API directly from Rust is possible in principle, but the
tooling for generating the introspection artifacts that Vala wants is still much
less straightforward than plain C/GObject.
For this repo, the lower-risk path is:
- Rust for the daemon client implementation
- C ABI as the stable binary boundary
- a thin C/GObject wrapper for GI/Vala
That gives us standard GLib ownership rules, normal `.gir` / `.typelib` /
`.vapi` generation, and a cleaner Meson integration story for the GTK app.
## Proposed Layout
Add a new crate:
- `core/kordophoned-client-c`
This crate should export a small `extern "C"` interface around the existing
daemon client logic.
Add a new Linux-focused wrapper library:
- `gtk/libkordophone-client-glib` or `gtk/src/service/glib/`
This wrapper should be written in C and expose a GObject API that Vala can use.
It should depend on the Rust C ABI library, not on D-Bus directly.
## Proposed Responsibilities
### `core/kordophoned-client`
- Own request/response/signal semantics.
- Own platform transport handling:
- D-Bus on Linux
- XPC on macOS
- Stay Rust-native.
### `core/kordophoned-client-c`
- Define opaque client handles.
- Define FFI-safe request/response structs.
- Define callback registration for async completions and daemon signals.
- Marshal Rust events onto C callbacks.
- Hide Rust enums and collections from C consumers.
### GLib Wrapper
- Expose a `KpDaemonClient` GObject.
- Convert C callbacks into `GTask` completions and GObject signals.
- Marshal all callbacks onto the GLib main context.
- Expose Vala-friendly model objects or boxed structs.
## Draft Public Surface
The GTK-facing API should look like a normal GLib client, not like a transport
binding.
Suggested primary type:
- `KpDaemonClient`
Suggested async methods:
- `get_conversations_async(limit, offset, cancellable, callback)`
- `get_messages_async(conversation_id, last_message_id, cancellable, callback)`
- `reply_async(conversation_id, text, attachment_guids, cancellable, callback)`
- `new_conversation_async(handle_ids, text, attachment_guids, cancellable, callback)`
- `mark_conversation_as_read_async(conversation_id, cancellable, callback)`
- `sync_conversation_async(conversation_id, cancellable, callback)`
- `sync_conversation_list_async(cancellable, callback)`
- `upload_attachment_async(path, cancellable, callback)`
- `download_attachment_async(attachment_id, preview, cancellable, callback)`
- `get_attachment_info_async(attachment_id, cancellable, callback)`
Suggested synchronous or utility methods:
- `open_attachment_fd(attachment_id, preview, error)`
- `start()`
- `stop()`
Suggested signals:
- `conversations-updated`
- `messages-updated(conversation-id)`
- `attachment-downloaded(attachment-id)`
- `attachment-uploaded(upload-guid, attachment-guid)`
- `reconnected`
- `error(message)`
The first pass does not need to expose every daemon event. It only needs enough
surface to replace the current GTK repository layer.
## Suggested Model Types
Avoid returning raw hash tables to Vala.
Add small typed model objects or boxed structs for:
- `KpConversationSummary`
- `KpChatMessage`
- `KpAttachmentInfo`
If send acknowledgements matter to the UI, add:
- `KpQueuedMessage`
The GTK app can keep its own higher-level `Repository` wrapper initially, but it
should be wrapping typed client results instead of raw D-Bus maps.
## Signal Handling
Signals are the main reason this should be a GLib wrapper instead of plain C
calls from Vala.
Required behavior:
- daemon signal subscriptions must stay alive for the lifetime of the client
- transport callbacks must never call into GTK from a non-main thread
- all emitted GObject signals must be marshalled onto the GLib main context
The C ABI should therefore support registration of signal callbacks plus a user
data pointer, while the GLib wrapper owns the main-context handoff.
## Migration Plan
### Phase 1: Stabilize Rust FFI Boundary
- Add FFI-safe request/response types instead of exposing the current worker
enums directly.
- Keep the Rust worker and transport code internal.
- Decide which operations are callback-based and which can be blocking.
### Phase 2: Add `kordophoned-client-c`
- Expose opaque client construction/destruction.
- Expose request entry points for the operations GTK already uses.
- Expose signal subscription hooks.
- Add explicit allocation/free helpers for returned strings and arrays.
### Phase 3: Add GLib Wrapper
- Implement `KpDaemonClient` as a GObject in C.
- Convert C callbacks into `GTask`-based async completion methods.
- Emit GObject signals for daemon events.
- Generate introspection artifacts for Vala.
### Phase 4: Migrate GTK
- Replace direct use of `DBusService.Repository` in
[`gtk/src/service/repository.vala`](/home/buzzert/src/Kordophone/gtk/src/service/repository.vala).
- Remove the generated D-Bus binding dependency from the GTK app.
- Keep the existing GTK-side repository shape initially to minimize churn.
### Phase 5: Revisit Swift
Optional.
If this turns out cleaner than the current Swift XPC wrapper, add a Swift-facing
wrapper around the same C ABI later. This is not required for the GTK migration.
## Build System Notes
This plan introduces a Cargo + Meson integration boundary.
Expected follow-up work:
- decide whether the Rust C ABI library is built via `cargo build`, `cargo-c`,
or a Meson custom target
- decide where generated headers live
- decide where `.gir`, `.typelib`, and `.vapi` artifacts are produced and
installed
The cleanest packaging story is likely:
- Cargo builds the Rust library
- Meson builds the GLib wrapper and generates introspection data
- GTK links to the GLib wrapper
## Non-Goals
- replacing D-Bus and XPC with a custom socket transport
- unifying the macOS app onto GLib
- exposing the entire daemon protocol on day one
- redesigning GTK application architecture beyond the service boundary
## Risks
- FFI ownership mistakes across Rust, C, and GLib
- callback threading bugs if signal delivery is not marshalled correctly
- build complexity from mixed Cargo and Meson workflows
- over-exposing the current daemon protocol instead of defining a cleaner client
API
## Open Questions
- Should the C ABI be Linux-only at first, or cross-platform from day one?
- Should the first GTK-facing layer expose send acknowledgements, or just fire
and rely on message update signals?
- Should handle resolution be part of the GLib client API immediately, or added
only when GTK gains compose-new-conversation UI?
- Is it worth creating a higher-level shared protocol schema before building the
C ABI, or should that wait until after the GTK migration?
## Short Version
If we do this later, the best path is probably:
- Rust daemon client stays as the implementation core
- add a small C ABI on top of it
- add a tiny C/GObject wrapper for Vala
- move GTK off direct D-Bus bindings
That removes one of the protocol surfaces we currently maintain without forcing
the GTK app to consume a Rust-native API directly.

2
gtk/.gitignore vendored
View File

@@ -1 +1,3 @@
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

@@ -5,10 +5,20 @@ all: setup
setup: build/
meson build
VER := 1.0.2
VER_RAW := $(shell git -C .. describe --tags --abbrev=0 2>/dev/null || git -C .. describe --tags 2>/dev/null || printf '0.0.0')
VER := $(patsubst v%,%,$(VER_RAW))
TMP := $(shell mktemp -d)
rpm:
git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(TMP)/v$(VER).tar.gz HEAD
rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)"
rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)" --define "app_version $(VER)"
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
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.3.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"

View File

@@ -1,5 +1,5 @@
Name: kordophone
Version: 1.0.2
Version: %{?app_version}%{!?app_version:1.3.0}
Release: 1%{?dist}
Summary: GTK4/Libadwaita client for Kordophone
@@ -22,7 +22,7 @@ Requires: libadwaita
Requires: glib2
Requires: libgee
Requires: libsecret
Requires: kordophoned >= 1.0.0
Requires: kordophoned >= 1.3.0
%description
A GTK4/Libadwaita Linux Client for the Kordophone client daemon.
@@ -49,4 +49,3 @@ popd
%changelog
* Fri Aug 8 2025 James Magahern <james@magahern.com>
- Updated rpmspec

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',
version : '1.0.1',
version : '1.0.2',
meson_version : '>=0.56.0',
default_options : ['warning_level=2']
)

View File

@@ -5,6 +5,7 @@ public class MainWindow : Adw.ApplicationWindow
{
private ConversationListView conversation_list_view;
private TranscriptContainerView transcript_container_view;
private NavigationSplitView split_view;
private EventControllerMotion _motion_controller = new EventControllerMotion();
private bool _motion_queued = false;
@@ -12,10 +13,15 @@ public class MainWindow : Adw.ApplicationWindow
public MainWindow () {
Object (title: "Kordophone");
var split_view = new NavigationSplitView ();
split_view = new NavigationSplitView ();
split_view.set_min_sidebar_width (400);
split_view.show_content = false;
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.conversation_selected.connect (conversation_selected);
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);
}
}
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 bool selection_update_queued = false;
private bool suppress_row_selected = false;
public ConversationListView () {
container = new Adw.ToolbarView ();
@@ -29,6 +30,10 @@ public class ConversationListView : Adw.Bin
scrolled_window.set_child (list_box);
list_box.row_selected.connect ((row) => {
if (suppress_row_selected) {
return;
}
var conversation_row = (ConversationRow?) row;
if (conversation_row != null) {
selected_conversation_guid = conversation_row.conversation.guid;
@@ -112,7 +117,9 @@ public class ConversationListView : Adw.Bin
if (conversation.guid == selected_conversation_guid) {
var row = list_box.get_row_at_index((int)i);
if (row != null) {
suppress_row_selected = true;
list_box.select_row(row);
suppress_row_selected = false;
}
}
}
@@ -123,4 +130,4 @@ public class ConversationListView : Adw.Bin
Conversation conversation = (Conversation) item;
return new ConversationRow (conversation);
}
}
}

View File

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

View File

@@ -50,8 +50,11 @@ namespace DBusService {
[DBus (name = "GetMessages")]
public abstract GLib.HashTable<string, GLib.Variant>[] get_messages(string conversation_id, string last_message_id) throws DBusError, IOError;
[DBus (name = "SendMessage")]
public abstract string send_message(string conversation_id, string text, string[] attachment_guids) throws DBusError, IOError;
[DBus (name = "Reply")]
public abstract string reply(string conversation_id, string text, string[] attachment_guids) throws DBusError, IOError;
[DBus (name = "NewConversation")]
public abstract string new_conversation(string[] handle_ids, string text, string[] attachment_guids) throws DBusError, IOError;
[DBus (name = "MessagesUpdated")]
public signal void messages_updated(string conversation_id);

View File

@@ -73,18 +73,17 @@
'sender' (string): Sender display name
'attachments' (array of dictionaries): List of attachments
'guid' (string): Attachment GUID
'path' (string): Attachment path
'preview_path' (string): Preview attachment path
'downloaded' (boolean): Whether the attachment is downloaded
'preview_downloaded' (boolean): Whether the preview is downloaded
'metadata' (dictionary, optional): Attachment metadata
'attribution_info' (dictionary, optional): Attribution info
'width' (int32, optional): Width
'height' (int32, optional): Height"/>
'height' (int32, optional): Height
Use GetAttachmentInfo for full/preview paths."/>
</arg>
</method>
<method name="SendMessage">
<method name="Reply">
<arg type="s" name="conversation_id" direction="in"/>
<arg type="s" name="text" direction="in"/>
<arg type="as" name="attachment_guids" direction="in"/>
@@ -92,9 +91,28 @@
<arg type="s" name="outgoing_message_id" direction="out"/>
<annotation name="org.freedesktop.DBus.DocString"
value="Sends a message to the server. Returns the outgoing message ID.
value="Replies to an existing conversation. Returns the outgoing message ID.
Arguments:
- conversation_id: The ID of the conversation to send the message to.
- conversation_id: The ID of the conversation to reply to.
- text: The text of the message to send.
- attachment_guids: The GUIDs of the attachments to send.
Returns:
- outgoing_message_id: The ID of the outgoing message.
"/>
</method>
<method name="NewConversation">
<arg type="as" name="handle_ids" direction="in"/>
<arg type="s" name="text" direction="in"/>
<arg type="as" name="attachment_guids" direction="in"/>
<arg type="s" name="outgoing_message_id" direction="out"/>
<annotation name="org.freedesktop.DBus.DocString"
value="Sends a message to a new conversation identified by resolved handles.
Arguments:
- handle_ids: The resolved handles for the new conversation.
- text: The text of the message to send.
- attachment_guids: The GUIDs of the attachments to send.

View File

@@ -96,12 +96,20 @@ public class Repository : DBusServiceProxy {
return returned_messages;
}
public string send_message(string conversation_guid, string message, string[] attachment_guids) throws DBusServiceProxyError, GLib.Error {
public string reply(string conversation_guid, string message, string[] attachment_guids) throws DBusServiceProxyError, GLib.Error {
if (dbus_repository == null) {
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
}
return dbus_repository.send_message(conversation_guid, message, attachment_guids);
return dbus_repository.reply(conversation_guid, message, attachment_guids);
}
public string new_conversation(string[] handle_ids, string message, string[] attachment_guids) throws DBusServiceProxyError, GLib.Error {
if (dbus_repository == null) {
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
}
return dbus_repository.new_conversation(handle_ids, message, attachment_guids);
}
public void sync_conversation(string conversation_guid) throws DBusServiceProxyError, GLib.Error {
@@ -144,4 +152,35 @@ public class Repository : DBusServiceProxy {
var info = dbus_repository.get_attachment_info(attachment_guid);
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,61 +13,91 @@ private class SizeCache
return instance;
}
public Graphene.Size? get_size(string image_path) {
return size_cache.get(image_path);
public Graphene.Size? get_size(string attachment_guid) {
return size_cache.get(attachment_guid);
}
public void set_size(string image_path, Graphene.Size size) {
size_cache.set(image_path, size);
public void set_size(string attachment_guid, Graphene.Size 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
{
public string image_path;
public string attachment_guid;
public bool is_downloaded;
public string? attachment_guid;
private Graphene.Size image_size;
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);
this.from_me = from_me;
this.image_path = image_path;
this.attachment_guid = attachment_guid;
this.is_downloaded = false;
this.cached_texture = TextureCache.get_instance().get_texture(attachment_guid);
if (this.cached_texture != null) {
this.image_size = Graphene.Size() {
width = (float)this.cached_texture.get_width(),
height = (float)this.cached_texture.get_height()
};
SizeCache.get_instance().set_size(attachment_guid, this.image_size);
return;
}
// Calculate image dimensions for layout
calculate_image_dimensions(image_size);
}
private void calculate_image_dimensions(Graphene.Size? image_size) {
if (image_size != null) {
this.image_size = image_size;
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) {
this.image_size = cached_size;
return;
}
// Try to load the image to get its dimensions
if (image_size != null) {
this.image_size = image_size;
return;
}
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 {
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 };
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);
}
}
@@ -81,9 +111,22 @@ private class ImageBubbleLayout : BubbleLayout
}
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) {
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 +153,7 @@ private class ImageBubbleLayout : BubbleLayout
}
public override void draw_content(Snapshot snapshot) {
queue_preview_download_if_needed();
load_image_if_needed();
snapshot.save();
@@ -137,4 +181,4 @@ private class ImageBubbleLayout : BubbleLayout
public override void copy(Gdk.Clipboard clipboard) {
clipboard.set_texture(cached_texture);
}
}
}

View File

@@ -62,44 +62,65 @@ public class MessageListModel : Object, ListModel
}
}
public void load_messages() {
public void load_messages(bool force_full_reload = false) {
var previous_messages = new HashSet<Message>();
previous_messages.add_all(_messages);
try {
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);
// Clear existing set
uint old_count = _messages.size;
_messages.clear();
participants.clear();
// Notify of removal
if (old_count > 0) {
items_changed(0, old_count, 0);
Message[] messages = Repository.get_instance().get_messages(conversation.guid, last_message_id);
bool fallback_full_reload = first_load || force_full_reload;
if (!first_load && messages.length > 0 && previous_messages.contains(messages[0])) {
fallback_full_reload = true;
}
// Process each conversation
uint position = 0;
for (int i = 0; i < messages.length; i++) {
var message = messages[i];
participants.add(message.sender);
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;
if (fallback_full_reload) {
uint old_count = _messages.size;
_messages.clear();
participants.clear();
if (old_count > 0) {
items_changed(0, old_count, 0);
}
_messages.add(message);
position++;
}
// Notify of additions
if (position > 0) {
items_changed(0, 0, position);
uint position = 0;
for (int i = 0; i < messages.length; i++) {
var message = messages[i];
participants.add(message.sender);
if (!first_load && !previous_messages.contains(message)) {
message.should_animate = true;
}
_messages.add(message);
position++;
}
if (position > 0) {
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) {
warning("Failed to load messages: %s", e.message);
@@ -134,4 +155,4 @@ public class MessageListModel : Object, ListModel
public Object? get_item(uint position) {
return _messages.get((int)position);
}
}
}

View File

@@ -257,7 +257,7 @@ class TranscriptContainerView : Adw.Bin
}
try {
Repository.get_instance().send_message(selected_conversation.guid, body, attachment_guids.to_array());
Repository.get_instance().reply(selected_conversation.guid, body, attachment_guids.to_array());
} catch (Error e) {
GLib.warning("Failed to send message: %s", e.message);
}
@@ -333,4 +333,4 @@ class UploadedAttachment
this.upload_guid = upload_guid;
this.attachment_guid = attachment_guid;
}
}
}

View File

@@ -327,7 +327,8 @@ private class TranscriptDrawingArea : Widget
private void recompute_message_layouts() {
var container_width = get_width();
float max_width = container_width * 0.90f;
float max_width = container_width * 0.80f;
float image_max_width = max_width * 0.70f;
DateTime? last_date = null;
string? last_sender = null;
@@ -364,16 +365,15 @@ private class TranscriptDrawingArea : Widget
// Check for attachments. For each one, add an image layout bubble
foreach (var attachment in message.attachments) {
Graphene.Size? image_size = null;
if (attachment.metadata != null) {
if (attachment.metadata != null && attachment.metadata.attribution_info != null) {
image_size = Graphene.Size() {
width = attachment.metadata.attribution_info.width,
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, image_max_width, image_size);
image_layout.id = @"image-$(attachment.guid)";
image_layout.attachment_guid = attachment.guid;
if (animate) {
start_animation(image_layout.id);
@@ -381,16 +381,6 @@ private class TranscriptDrawingArea : Widget
image_layout.is_downloaded = attachment.preview_downloaded;
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;
@@ -447,4 +437,4 @@ public struct VisibleLayout {
this.bubble = bubble;
this.rect = rect;
}
}
}

View File

@@ -148,7 +148,7 @@ public class TranscriptView : Adw.Bin
GLib.Idle.add(() => {
if (needs_reload) {
debug("Reloading messages for attachment download");
model.load_messages();
model.load_messages(true);
needs_reload = false;
}
@@ -159,7 +159,6 @@ public class TranscriptView : Adw.Bin
}
delegate void OpenPath(string path);
private ulong attachment_downloaded_handler_id = 0;
private void open_attachment(string attachment_guid) {
OpenPath open_path = (path) => {
try {
@@ -180,10 +179,17 @@ public class TranscriptView : Adw.Bin
// TODO: Should probably indicate progress here.
attachment_downloaded_handler_id = Repository.get_instance().attachment_downloaded.connect((guid) => {
ulong handler_id = 0;
handler_id = Repository.get_instance().attachment_downloaded.connect((guid) => {
if (guid == attachment_guid) {
open_path(attachment_info.path);
Repository.get_instance().disconnect(attachment_downloaded_handler_id);
try {
var updated_attachment_info = Repository.get_instance().get_attachment_info(attachment_guid);
open_path(updated_attachment_info.path);
} catch (GLib.Error e) {
warning("Failed to get attachment info after download: %s", e.message);
}
Repository.get_instance().disconnect(handler_id);
}
});
}

View File

@@ -32,29 +32,29 @@
/* End PBXCopyFilesBuildPhase 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 */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */ = {
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Daemon/kordophoned,
Daemon/net.buzzert.kordophonecd.plist,
);
target = CD41F5962E5B8E7300E0027B /* kordophone2 */;
target = CD41F5962E5B8E7300E0027B /* Kordophone */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet 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;
buildPhase = CD41F5D92E6284FD00E0027B /* CopyFiles */;
membershipExceptions = (
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;
attributesByRelativePath = {
Daemon/kordophoned = (CodeSignOnCopy, );
@@ -70,9 +70,9 @@
CD41F5992E5B8E7300E0027B /* kordophone2 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */,
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */,
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */,
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */,
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
);
path = kordophone2;
sourceTree = "<group>";
@@ -102,7 +102,7 @@
CD41F5982E5B8E7300E0027B /* Products */ = {
isa = PBXGroup;
children = (
CD41F5972E5B8E7300E0027B /* kordophone2.app */,
CD41F5972E5B8E7300E0027B /* Kordophone.app */,
);
name = Products;
sourceTree = "<group>";
@@ -110,9 +110,9 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
CD41F5962E5B8E7300E0027B /* kordophone2 */ = {
CD41F5962E5B8E7300E0027B /* Kordophone */ = {
isa = PBXNativeTarget;
buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */;
buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */;
buildPhases = (
CD41F5932E5B8E7300E0027B /* Sources */,
CD41F5942E5B8E7300E0027B /* Frameworks */,
@@ -127,12 +127,12 @@
fileSystemSynchronizedGroups = (
CD41F5992E5B8E7300E0027B /* kordophone2 */,
);
name = kordophone2;
name = Kordophone;
packageProductDependencies = (
CD41F5D22E62431D00E0027B /* KeychainAccess */,
);
productName = kordophone2;
productReference = CD41F5972E5B8E7300E0027B /* kordophone2.app */;
productReference = CD41F5972E5B8E7300E0027B /* Kordophone.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -167,7 +167,7 @@
projectDirPath = "";
projectRoot = "";
targets = (
CD41F5962E5B8E7300E0027B /* kordophone2 */,
CD41F5962E5B8E7300E0027B /* Kordophone */,
);
};
/* End PBXProject section */
@@ -322,7 +322,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = DQQH5H6GBD;
DEVELOPMENT_TEAM = 3SJALV9BQ7;
ENABLE_HARDENED_RUNTIME = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -349,7 +349,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = DQQH5H6GBD;
DEVELOPMENT_TEAM = 3SJALV9BQ7;
ENABLE_HARDENED_RUNTIME = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -379,7 +379,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */ = {
CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */ = {
isa = XCConfigurationList;
buildConfigurations = (
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,7 +14,14 @@ struct KordophoneApp: App
WindowGroup {
SplitView()
}
.commands {
TextEditingCommands()
}
WindowGroup(id: .transcriptWindow, for: Display.Conversation.self) { selectedConversation in
TranscriptWindowView(conversation: selectedConversation)
}
Settings {
PreferencesView()
}
@@ -25,3 +32,42 @@ struct KordophoneApp: App
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
@Environment(\.xpcClient) private var xpcClient
@Environment(\.openWindow) private var openWindow
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
HStack(spacing: 0.0) {
@@ -64,14 +65,14 @@ struct ConversationListView: View
class ViewModel
{
var conversations: [Display.Conversation]
var selectedConversations: Set<Display.Conversation.ID>
var selectedConversation: Display.Conversation.ID?
private var needsReload: Bool = true
private let client = XPCClient()
public init(conversations: [Display.Conversation] = []) {
self.conversations = conversations
self.selectedConversations = Set()
self.selectedConversation = nil
setNeedsReload()
}
@@ -101,6 +102,11 @@ struct ConversationListView: View
.map { Display.Conversation(from: $0) }
self.conversations = clientConversations
let unreadConversations = clientConversations.filter(\.isUnread)
await MainActor.run {
NSApplication.shared.dockTile.badgeLabel = unreadConversations.isEmpty ? nil : "\(unreadConversations.count)"
}
} catch {
print("Error reloading conversations: \(error)")
}

View File

@@ -14,7 +14,7 @@ struct ConversationView: View
@Binding var entryModel: MessageEntryView.ViewModel
var body: some View {
VStack {
VStack(spacing: 0.0) {
TranscriptView(model: $transcriptModel)
MessageEntryView(viewModel: $entryModel)
}
@@ -23,5 +23,10 @@ struct ConversationView: View
entryModel.handleDroppedProviders(providers)
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)
.scrollDisabled(true)
.disabled(selectedConversation == nil)
.id("messageEntry")
}
.padding(8.0)
.background {
@@ -118,7 +119,7 @@ struct MessageEntryView: View
Task {
do {
try await client.sendMessage(
try await client.reply(
conversationId: convo.id,
message: messageText,
transferGuids: transferGuids

View File

@@ -10,7 +10,7 @@ import XPC
enum Display
{
struct Conversation: Identifiable, Hashable
struct Conversation: Identifiable, Hashable, Codable
{
let id: String
let name: String?
@@ -26,7 +26,11 @@ enum Display
var isGroupChat: Bool {
participants.count > 1
}
var isUnread: Bool {
unreadCount > 0
}
init(from c: Serialized.Conversation) {
self.id = c.guid
self.name = c.displayName
@@ -111,7 +115,15 @@ enum Display
var previewPath: String {
data.previewPath
}
var isFullsizeDownloaded: Bool {
data.isDownloaded
}
var fullsizePath: String {
data.path
}
init(from serialized: Serialized.Attachment, dateSent: Date, sender: Sender) {
self.id = serialized.guid
self.sender = sender

View File

@@ -15,7 +15,7 @@ struct SplitView: View
private let xpcClient = XPCClient()
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 }
}
@@ -28,10 +28,10 @@ struct SplitView: View
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
.xpcClient(xpcClient)
.selectedConversation(selectedConversation)
.navigationTitle("Kordophone")
.navigationSubtitle(selectedConversation?.displayName ?? "")
.onChange(of: conversationListModel.selectedConversations) { oldValue, newValue in
transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue.first }
.navigationTitle(selectedConversation?.displayName ?? "Kordophone")
.navigationSubtitle(selectedConversation?.participants.joined(separator: ", ") ?? "")
.onChange(of: conversationListModel.selectedConversation) { oldValue, newValue in
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

@@ -58,7 +58,7 @@ struct TextBubbleItemView: View
let date: Date
private var isFromMe: Bool { sender.isMe }
var body: some View {
let bubbleColor: Color = isFromMe ? .blue : Color(NSColor(name: "grayish", dynamicProvider: { appearance in
appearance.name == .darkAqua ? .darkGray : NSColor(white: 0.78, alpha: 1.0)
@@ -67,7 +67,7 @@ struct TextBubbleItemView: View
BubbleView(sender: sender, date: date) {
HStack {
Text(text)
Text(text.linkifiedAttributedString())
.foregroundStyle(textColor)
.multilineTextAlignment(.leading)
}
@@ -75,6 +75,7 @@ struct TextBubbleItemView: View
.padding(.horizontal, 16.0)
.padding(.vertical, 10.0)
.background(bubbleColor)
.textSelection(.enabled)
}
}
}
@@ -89,7 +90,8 @@ struct ImageItemView: View
@Environment(\.xpcClient) var xpcClient
@State private var containerWidth: CGFloat? = nil
@State private var isDownloadingFullAttachment: Bool = false
private var aspectRatio: CGFloat {
attachment.size?.aspectRatio ?? 1.0
}
@@ -101,17 +103,36 @@ struct ImageItemView: View
var body: some View {
BubbleView(sender: sender, date: date) {
let maxWidth = CGFloat.minimum(.imageMaxWidth, containerWidth ?? .imageMaxWidth)
if let img {
Image(nsImage: img)
.resizable()
.scaledToFit()
.frame(maxWidth: maxWidth)
} else {
Rectangle()
.fill(.gray.opacity(0.4))
.frame(width: preferredWidth, height: preferredWidth / aspectRatio)
.frame(maxWidth: maxWidth)
Group {
if let img {
Image(nsImage: img)
.resizable()
.scaledToFit()
.frame(maxWidth: maxWidth)
} else {
Rectangle()
.fill(.gray.opacity(0.4))
.frame(width: preferredWidth, height: preferredWidth / aspectRatio)
.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,
of: { $0.size.width },
@@ -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
@@ -219,14 +258,16 @@ struct SenderAttributionView: View
}
}
fileprivate extension CGFloat {
fileprivate extension CGFloat
{
static let dominantCornerRadius = 16.0
static let minorCornerRadius = 4.0
static let minimumBubbleHorizontalPadding = 80.0
static let imageMaxWidth = 380.0
}
fileprivate extension CGSize {
fileprivate extension CGSize
{
var aspectRatio: CGFloat { width / height }
}
@@ -239,3 +280,28 @@ fileprivate func preferredBubbleWidth(forAttachmentSize attachmentSize: CGSize?,
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
{
internal func rebuildDisplayItems(animated: Bool = false) {
internal func rebuildDisplayItems(animated: Bool = false, completion: () -> Void = {}) {
var displayItems: [DisplayItem] = []
var lastDate: Date = .distantPast
var lastSender: Display.Sender? = nil
@@ -53,6 +53,7 @@ extension TranscriptView.ViewModel
let animation: Animation? = animated ? .default : nil
withAnimation(animation) {
self.displayItems = displayItems
completion()
}
}
}

View File

@@ -12,25 +12,57 @@ struct TranscriptView: View
@Binding var model: ViewModel
@Environment(\.xpcClient) private var xpcClient
init(model: Binding<ViewModel>) {
self._model = model
}
var body: some View {
ScrollView {
LazyVStack(spacing: 6.0) {
ForEach($model.displayItems.reversed()) { item in
displayItemView(item.wrappedValue)
.id(item.id)
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.transition(
.push(from: .top)
.combined(with: .opacity)
)
ScrollViewReader { proxy in
ScrollView {
// For resetting scroll position to the "bottom"
EmptyView()
.id(ViewID.bottomAnchor)
LazyVStack(spacing: 6.0) {
ForEach($model.displayItems.reversed()) { item in
displayItemView(item.wrappedValue)
.id(item.id)
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.transition(
.push(from: .top)
.combined(with: .opacity)
)
}
}
.padding()
}
// Flip vertically so newest messages are at the bottom.
.scaleEffect(CGSize(width: 1.0, height: -1.0))
// Watch for xpc events
.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()
}
}
.padding()
}
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.id(model.displayedConversation?.id)
.task { await watchForMessageListChanges() }
}
private func watchForMessageListChanges() async {
@@ -79,22 +111,31 @@ struct TranscriptView: View
}
}
}
// MARK: - Types
enum ViewID: String
{
case bottomAnchor
}
// MARK: - View Model
@Observable
class ViewModel
{
var displayItems: [DisplayItem] = []
var displayedConversation: Display.Conversation? = nil
internal var needsReload: NeedsReload = .no
internal var messages: [Display.Message]
internal let client = XPCClient()
private var needsMarkAsRead: Bool = false
private var lastMarkAsRead: Date = .now
init(messages: [Display.Message] = []) {
self.messages = messages
observeDisplayedConversation()
rebuildDisplayItems()
}
@@ -106,31 +147,28 @@ struct TranscriptView: View
needsReload = .yes(animated)
Task { @MainActor [weak self] in
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
}
}
func attachmentDownloaded(id: String) {
// TODO: should be smarter here
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 {
guard let displayedConversation else { return }
@@ -150,11 +188,15 @@ struct TranscriptView: View
print("Error triggering sync: \(error)")
}
}
private func reloadMessages() async {
func reloadIfNeeded(completion: () -> Void = {}) async {
guard case .yes(let animated) = needsReload else { return }
needsReload = .no
await reloadMessages(animated: animated, completion: completion)
}
func reloadMessages(animated: Bool, completion: () -> Void) async {
guard let displayedConversation else { return }
do {
@@ -167,8 +209,10 @@ struct TranscriptView: View
// Only animate for incoming messages.
let shouldAnimate = (newIds.count == 1)
self.messages = clientMessages
self.rebuildDisplayItems(animated: animated && shouldAnimate)
await MainActor.run {
self.messages = clientMessages
self.rebuildDisplayItems(animated: animated && shouldAnimate, completion: completion)
}
} catch {
print("Message fetch error: \(error)")
}

View File

@@ -133,7 +133,7 @@ final class XPCClient
return results
}
public func sendMessage(conversationId: String, message: String, transferGuids: Set<String>) async throws {
public func reply(conversationId: String, message: String, transferGuids: Set<String>) async throws {
var args: [String: xpc_object_t] = [:]
args["conversation_id"] = xpcString(conversationId)
args["text"] = xpcString(message)
@@ -142,17 +142,50 @@ final class XPCClient
args["attachment_guids"] = xpcStringArray(transferGuids)
}
let req = makeRequest(method: "SendMessage", arguments: args)
let req = makeRequest(method: "Reply", arguments: args)
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 newConversation(handleIds: Set<String>, message: String, transferGuids: Set<String>) async throws {
var args: [String: xpc_object_t] = [:]
args["handle_ids"] = xpcStringArray(handleIds)
args["text"] = xpcString(message)
if !transferGuids.isEmpty {
args["attachment_guids"] = xpcStringArray(transferGuids)
}
let req = makeRequest(method: "NewConversation", arguments: args)
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
}
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] = [:]
args["attachment_id"] = xpcString(attachmentId)
args["preview"] = xpcString(preview ? "true" : "false")
let req = makeRequest(method: "DownloadAttachment", arguments: args)
_ = 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 {
@@ -200,6 +233,14 @@ final class XPCClient
}
// MARK: - Types
struct AttachmentInfo: Decodable
{
let path: String
let previewPath: String
let isDownloaded: Bool
let isPreviewDownloaded: Bool
}
enum Error: Swift.Error
{
@@ -209,7 +250,7 @@ final class XPCClient
case connectionError
}
enum Signal
enum Signal: Equatable
{
case conversationsUpdated
case messagesUpdated(conversationId: String)
@@ -383,4 +424,3 @@ extension xpc_object_t
)
}
}

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
{
func getObject(_ key: String) -> xpc_object_t? {

View File

@@ -14,6 +14,7 @@
#import "MBIMAuthToken.h"
#import "MBIMUpdateQueue.h"
#import "MBIMURLUtilities.h"
#import "MBIMLogging.h"
#import <Security/Security.h>
#import "HTTPMessage.h"
@@ -98,6 +99,10 @@
__block NSObject<HTTPResponse> *response = nil;
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
MBIMBridgeOperationCompletionBlock completion = ^(NSObject<HTTPResponse> *incomingResponse) {
if (incomingResponse == nil) {
MBIMLogError(@"Operation for %@ %@ completed with a nil response.", method, path);
}
response = incomingResponse;
dispatch_semaphore_signal(sema);
};
@@ -124,6 +129,11 @@
if (requestTimedOut) {
response = [_currentOperation cancelAndReturnTimeoutResponse];
}
if (response == nil) {
MBIMLogError(@"Returning fallback 500 for %@ %@ because the operation produced no response.", method, path);
response = [[HTTPErrorResponse alloc] initWithErrorCode:500];
}
return response;
}

View File

@@ -10,6 +10,7 @@
#import "IMCore_ClassDump.h"
#import "IMMessageItem+Encoded.h"
#import "MBIMErrorResponse.h"
@implementation MBIMSendMessageOperation
@@ -20,40 +21,228 @@
return @"sendMessage";
}
- (IMMessage *)_sendMessage:(NSString *)messageBody toChatWithGUID:(NSString *)chatGUID attachmentGUIDs:(NSArray<NSString *> *)guids
- (nullable IMChat *)_existingSingleChatForHandle:(IMHandle *)handle registry:(IMChatRegistry *)registry
{
__block IMMessage *result = nil;
dispatch_sync([[self class] sharedIMAccessQueue], ^{
IMChat *chat = [[IMChatRegistry sharedInstance] existingChatWithGUID:chatGUID];
// TODO: chat might not be an iMessage chat!
IMAccount *iMessageAccount = [[IMAccountController sharedInstance] bestAccountForService:[IMServiceImpl iMessageService]];
IMHandle *senderHandle = [iMessageAccount loginIMHandle];
NSAttributedString *replyAttrString = [[NSAttributedString alloc] initWithString:messageBody];
NSAttributedString *attrStringWithFileTransfers = IMCreateSuperFormatStringWithAppendedFileTransfers(replyAttrString, guids);
IMMessage *reply = [IMMessage fromMeIMHandle:senderHandle
withText:attrStringWithFileTransfers
fileTransferGUIDs:guids
flags:(kIMMessageFinished | kIMMessageIsFromMe)];
for (NSString *guid in [reply fileTransferGUIDs]) {
[[IMFileTransferCenter sharedInstance] assignTransfer:guid toHandle:chat.recipient];
if ([registry respondsToSelector:@selector(existingChatWithHandle:allowAlternativeService:)]) {
MBIMLogInfo(@"Using IMChatRegistry existingChatWithHandle:allowAlternativeService:");
return [registry existingChatWithHandle:handle allowAlternativeService:NO];
}
if ([registry respondsToSelector:@selector(existingChatWithHandle:)]) {
MBIMLogInfo(@"Using IMChatRegistry existingChatWithHandle:");
return [registry existingChatWithHandle:handle];
}
if ([registry respondsToSelector:@selector(existingChatForIMHandle:allowRetargeting:)]) {
MBIMLogInfo(@"Using IMChatRegistry existingChatForIMHandle:allowRetargeting:");
return [registry existingChatForIMHandle:handle allowRetargeting:NO];
}
if ([registry respondsToSelector:@selector(existingChatForIMHandle:)]) {
MBIMLogInfo(@"Using IMChatRegistry existingChatForIMHandle:");
return [registry existingChatForIMHandle:handle];
}
MBIMLogError(@"IMChatRegistry does not support any known single-handle existing chat lookup selector.");
return nil;
}
- (nullable IMChat *)_createSingleChatForHandle:(IMHandle *)handle registry:(IMChatRegistry *)registry
{
if ([registry respondsToSelector:@selector(chatWithHandle:)]) {
MBIMLogInfo(@"Using IMChatRegistry chatWithHandle:");
return [registry chatWithHandle:handle];
}
if ([registry respondsToSelector:@selector(chatForIMHandle:)]) {
MBIMLogInfo(@"Using IMChatRegistry chatForIMHandle:");
return [registry chatForIMHandle:handle];
}
MBIMLogError(@"IMChatRegistry does not support any known single-handle chat creation selector.");
return nil;
}
- (nullable IMChat *)_existingGroupChatForHandles:(NSArray<IMHandle *> *)handles registry:(IMChatRegistry *)registry
{
if ([registry respondsToSelector:@selector(existingChatWithHandles:allowAlternativeService:groupID:displayName:joinedChatsOnly:)]) {
MBIMLogInfo(@"Using IMChatRegistry existingChatWithHandles:allowAlternativeService:groupID:displayName:joinedChatsOnly:");
return [registry existingChatWithHandles:handles
allowAlternativeService:NO
groupID:nil
displayName:nil
joinedChatsOnly:YES];
}
if ([registry respondsToSelector:@selector(existingChatWithHandles:allowAlternativeService:)]) {
MBIMLogInfo(@"Using IMChatRegistry existingChatWithHandles:allowAlternativeService:");
return [registry existingChatWithHandles:handles allowAlternativeService:NO];
}
if ([registry respondsToSelector:@selector(existingChatWithHandles:)]) {
MBIMLogInfo(@"Using IMChatRegistry existingChatWithHandles:");
return [registry existingChatWithHandles:handles];
}
if ([registry respondsToSelector:@selector(existingChatForIMHandles:allowRetargeting:groupID:displayName:joinedChatsOnly:)]) {
MBIMLogInfo(@"Using IMChatRegistry existingChatForIMHandles:allowRetargeting:groupID:displayName:joinedChatsOnly:");
return [registry existingChatForIMHandles:handles
allowRetargeting:NO
groupID:nil
displayName:nil
joinedChatsOnly:YES];
}
if ([registry respondsToSelector:@selector(existingChatForIMHandles:allowRetargeting:)]) {
MBIMLogInfo(@"Using IMChatRegistry existingChatForIMHandles:allowRetargeting:");
return [registry existingChatForIMHandles:handles allowRetargeting:NO];
}
if ([registry respondsToSelector:@selector(existingChatForIMHandles:)]) {
MBIMLogInfo(@"Using IMChatRegistry existingChatForIMHandles:");
return [registry existingChatForIMHandles:handles];
}
MBIMLogError(@"IMChatRegistry does not support any known multi-handle existing chat lookup selector.");
return nil;
}
- (nullable IMChat *)_createGroupChatForHandles:(NSArray<IMHandle *> *)handles registry:(IMChatRegistry *)registry
{
if ([registry respondsToSelector:@selector(chatWithHandles:displayName:joinedChatsOnly:)]) {
MBIMLogInfo(@"Using IMChatRegistry chatWithHandles:displayName:joinedChatsOnly:");
return [registry chatWithHandles:handles displayName:nil joinedChatsOnly:YES];
}
if ([registry respondsToSelector:@selector(chatWithHandles:)]) {
MBIMLogInfo(@"Using IMChatRegistry chatWithHandles:");
return [registry chatWithHandles:handles];
}
if ([registry respondsToSelector:@selector(chatForIMHandles:displayName:joinedChatsOnly:)]) {
MBIMLogInfo(@"Using IMChatRegistry chatForIMHandles:displayName:joinedChatsOnly:");
return [registry chatForIMHandles:handles displayName:nil joinedChatsOnly:YES];
}
if ([registry respondsToSelector:@selector(chatForIMHandles:)]) {
MBIMLogInfo(@"Using IMChatRegistry chatForIMHandles:");
return [registry chatForIMHandles:handles];
}
MBIMLogError(@"IMChatRegistry does not support any known multi-handle chat creation selector.");
return nil;
}
- (nullable IMChat *)_chatForHandleIDs:(NSArray<NSString *> *)handleIDs registry:(IMChatRegistry *)registry
{
MBIMLogInfo(@"Resolving send target for handles: %@", handleIDs);
IMAccount *iMessageAccount = [[IMAccountController sharedInstance] bestAccountForService:[IMServiceImpl iMessageService]];
if (!iMessageAccount) {
MBIMLogError(@"Unable to find an iMessage account for message send.");
return nil;
}
NSMutableArray<IMHandle *> *handles = [NSMutableArray arrayWithCapacity:[handleIDs count]];
for (NSString *handleID in handleIDs) {
IMHandle *handle = [iMessageAccount imHandleWithID:handleID];
if (!handle) {
MBIMLogError(@"Couldn't resolve IMHandle for id %@", handleID);
return nil;
}
[handles addObject:handle];
}
if ([handles count] == 1) {
IMHandle *handle = [handles firstObject];
IMChat *chat = [self _existingSingleChatForHandle:handle registry:registry];
if (!chat) {
MBIMLogInfo(@"Chat does not exist: %@", chatGUID);
} else {
result = reply;
dispatch_async(dispatch_get_main_queue(), ^{
[chat sendMessage:reply];
});
chat = [self _createSingleChatForHandle:handle registry:registry];
}
if (chat) {
MBIMLogInfo(@"Resolved send target %@ to chat %@", [handle ID], [chat guid] ?: @"<unknown>");
} else {
MBIMLogError(@"Unable to locate or create chat for handle %@", [handle ID]);
}
return chat;
}
IMChat *chat = [self _existingGroupChatForHandles:handles registry:registry];
if (!chat) {
chat = [self _createGroupChatForHandles:handles registry:registry];
}
if (chat) {
MBIMLogInfo(@"Resolved handles %@ to chat %@", handleIDs, [chat guid] ?: @"<unknown>");
} else {
MBIMLogError(@"Unable to locate or create chat for handles %@", handleIDs);
}
return chat;
}
- (nullable NSDictionary *)_sendMessage:(NSString *)messageBody toChat:(IMChat *)chat attachmentGUIDs:(NSArray<NSString *> *)guids includeConversationGUID:(BOOL)includeConversationGUID
{
if (!chat) {
return nil;
}
NSString *chatGUID = [chat guid];
if (!chatGUID) {
chatGUID = [[[IMChatRegistry sharedInstance] allGUIDsForChat:chat] firstObject];
}
MBIMLogInfo(@"Preparing sendMessage for chat %@ (bodyLength=%lu attachmentCount=%lu)", chatGUID ?: @"<unknown>", (unsigned long)[messageBody length], (unsigned long)[guids count]);
IMAccount *sendingAccount = [chat account];
if (!sendingAccount) {
sendingAccount = [[IMAccountController sharedInstance] bestAccountForService:[IMServiceImpl iMessageService]];
}
IMHandle *senderHandle = [sendingAccount loginIMHandle];
if (!senderHandle) {
MBIMLogError(@"Unable to determine sender handle for message send.");
return nil;
}
NSAttributedString *replyAttrString = [[NSAttributedString alloc] initWithString:messageBody];
NSAttributedString *attrStringWithFileTransfers = IMCreateSuperFormatStringWithAppendedFileTransfers(replyAttrString, guids);
IMMessage *reply = [IMMessage fromMeIMHandle:senderHandle
withText:attrStringWithFileTransfers
fileTransferGUIDs:guids
flags:(kIMMessageFinished | kIMMessageIsFromMe)];
for (NSString *guid in [reply fileTransferGUIDs]) {
[[IMFileTransferCenter sharedInstance] assignTransfer:guid toMessage:reply account:sendingAccount];
}
NSDictionary *replyRepresentation = [reply mbim_dictionaryRepresentation];
if (![replyRepresentation isKindOfClass:[NSDictionary class]]) {
MBIMLogError(@"Unable to encode sent message for chat %@", chatGUID ?: @"<unknown>");
return nil;
}
NSMutableDictionary *result = [replyRepresentation mutableCopy];
if (includeConversationGUID) {
NSString *conversationGUID = chatGUID;
if (!conversationGUID) {
conversationGUID = [[[IMChatRegistry sharedInstance] allGUIDsForChat:chat] firstObject];
}
if (conversationGUID) {
result[@"conversationGUID"] = conversationGUID;
}
}
MBIMLogInfo(@"Dispatching IMCore send for chat %@", chatGUID ?: @"<unknown>");
dispatch_async(dispatch_get_main_queue(), ^{
[chat sendMessage:reply];
});
return result;
}
@@ -79,41 +268,112 @@
- (void)main
{
NSObject<HTTPResponse> *response = [[HTTPErrorResponse alloc] initWithErrorCode:500];
__block NSObject<HTTPResponse> *response = [[HTTPErrorResponse alloc] initWithErrorCode:500];
NSError *error = nil;
NSDictionary *args = [NSJSONSerialization JSONObjectWithData:self.requestBodyData options:0 error:&error];
if (error || args.count == 0) {
MBIMLogError(@"Unable to parse sendMessage request body: %@", error);
self.serverCompletionBlock(response);
return;
}
NSString *guid = [args objectForKey:@"guid"];
NSString *messageBody = [args objectForKey:@"body"];
if (!guid || !messageBody) {
NSArray *rawHandleIDs = [args objectForKey:@"handleIDs"];
BOOL hasGUID = [guid isKindOfClass:[NSString class]] && [guid length] > 0;
BOOL hasHandleIDs = [rawHandleIDs isKindOfClass:[NSArray class]] && [rawHandleIDs count] > 0;
if (![messageBody isKindOfClass:[NSString class]] || (!hasGUID && !hasHandleIDs) || (hasGUID && hasHandleIDs)) {
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"sendMessage requires body and exactly one of guid or handleIDs."];
self.serverCompletionBlock(response);
return;
}
// tapbacks
#if 0
IMMessage *acknowledgment = [IMMessage instantMessageWithAssociatedMessageContent: /* [NSString stringWithFormat:@"%@ \"%%@\"", tapbackAction] */
flags:0
associatedMessageGUID:guid
associatedMessageType:IMAssociatedMessageTypeAcknowledgmentHeart
associatedMessageRange:[imMessage messagePartRange]
messageSummaryInfo:[self adjustMessageSummaryInfoForSending:message]
threadIdentifier:[imMessage threadIdentifier]];
#endif
NSArray *transferGUIDs = [args objectForKey:@"fileTransferGUIDs"];
if (!transferGUIDs) {
transferGUIDs = @[];
NSMutableArray<NSString *> *handleIDs = [NSMutableArray array];
if (hasHandleIDs) {
for (id handleID in rawHandleIDs) {
if ([handleID isKindOfClass:[NSString class]] && [handleID length] > 0) {
[handleIDs addObject:handleID];
}
}
handleIDs = [[[NSOrderedSet orderedSetWithArray:handleIDs] array] mutableCopy];
if ([handleIDs count] == 0) {
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"No valid handle IDs provided."];
self.serverCompletionBlock(response);
return;
}
}
IMMessage *result = [self _sendMessage:messageBody toChatWithGUID:guid attachmentGUIDs:transferGUIDs];
if (result) {
response = [MBIMJSONDataResponse responseWithJSONObject:[result mbim_dictionaryRepresentation]];
NSArray *rawTransferGUIDs = [args objectForKey:@"fileTransferGUIDs"];
NSMutableArray<NSString *> *transferGUIDs = [NSMutableArray array];
if ([rawTransferGUIDs isKindOfClass:[NSArray class]]) {
for (id transferGUID in rawTransferGUIDs) {
if ([transferGUID isKindOfClass:[NSString class]] && [transferGUID length] > 0) {
[transferGUIDs addObject:transferGUID];
}
}
}
MBIMLogInfo(@"sendMessage request received. guid=%@ handleIDs=%@ bodyLength=%lu attachmentGUIDs=%@", hasGUID ? guid : @"<none>", handleIDs, (unsigned long)[messageBody length], transferGUIDs);
@try {
dispatch_sync([[self class] sharedIMAccessQueue], ^{
IMChatRegistry *registry = [IMChatRegistry sharedInstance];
IMChat *chat = nil;
BOOL includeConversationGUID = NO;
if (hasGUID) {
MBIMLogInfo(@"sendMessage targeting existing conversation %@", guid);
chat = [registry existingChatWithGUID:guid];
if (!chat) {
MBIMLogError(@"Chat does not exist for guid %@", guid);
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"Chat does not exist for the provided guid."];
return;
}
} else {
MBIMLogInfo(@"sendMessage targeting handles %@", handleIDs);
chat = [self _chatForHandleIDs:handleIDs registry:registry];
includeConversationGUID = YES;
if (!chat) {
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"Unable to create or locate a chat for the provided handles."];
return;
}
}
NSString *resolvedChatGUID = [chat guid];
if (!resolvedChatGUID) {
resolvedChatGUID = [[[IMChatRegistry sharedInstance] allGUIDsForChat:chat] firstObject];
}
MBIMLogInfo(@"sendMessage resolved target chat %@", resolvedChatGUID ?: @"<unknown>");
NSDictionary *result = [self _sendMessage:messageBody
toChat:chat
attachmentGUIDs:transferGUIDs
includeConversationGUID:includeConversationGUID];
if (!result) {
MBIMLogError(@"sendMessage failed before a response payload could be encoded for chat %@", resolvedChatGUID ?: @"<unknown>");
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"Unable to construct sent message response."];
return;
}
NSObject<HTTPResponse> *jsonResponse = [MBIMJSONDataResponse responseWithJSONObject:result];
if (jsonResponse) {
response = jsonResponse;
} else {
MBIMLogError(@"Unable to encode sendMessage JSON response for chat %@", resolvedChatGUID ?: @"<unknown>");
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"Unable to encode sendMessage response."];
}
});
} @catch (NSException *exception) {
MBIMLogError(@"Unhandled exception during sendMessage. name=%@ reason=%@ userInfo=%@", exception.name, exception.reason, exception.userInfo);
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"Unhandled exception while sending message. Check server logs."];
}
if (response == nil) {
MBIMLogError(@"sendMessage completed without producing a response. guid=%@ handleIDs=%@", hasGUID ? guid : @"<none>", handleIDs);
response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"sendMessage did not produce a response. Check server logs."];
}
self.serverCompletionBlock(response);