Private
Public Access
1
0

Compare commits

...

37 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
84 changed files with 4444 additions and 769 deletions

2
.gitignore vendored Normal file
View File

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

View File

@@ -1,3 +1,3 @@
[submodule "CocoaHTTPServer"] [submodule "CocoaHTTPServer"]
path = CocoaHTTPServer path = server/CocoaHTTPServer
url = https://github.com/robbiehanson/CocoaHTTPServer.git 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. - Android: open `android/` in Android Studio and build. See `android/README.md` for configuration.
- Mock server (Go): `cd mock && go run ./...` or `make`. - Mock server (Go): `cd mock && go run ./...` or `make`.
## Security and Entitlements
The macOS server uses private APIs and restricted entitlements. On production macOS builds, processes with restricted entitlements can be killed by the kernel; development requires workarounds (e.g., swizzling, hooking `imagent`) and careful code signing. See `server/README.md` for instructions and caveats.
## Status
- Android client: ships its own API client (not yet using Rust `core`).
- GTK + macOS clients: use the Rust `core` library and integrate with the `kordophoned` client daemon for caching/IPC.
- Mock server: useful for development; implements common endpoints and WebSocket updates.
## Contributing
Issues and PRs are welcome. If you add a new client or endpoint, please update relevant READMEs and link it from this root README. Prefer small, focused changes and keep style consistent with the existing code.

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

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

428
core/Cargo.lock generated
View File

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

View File

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

37
core/Cross.toml Normal file
View File

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

25
core/Dockerfile.deb Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ use crate::api::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpda
use crate::api::AuthenticationStore; use crate::api::AuthenticationStore;
use bytes::Bytes; use bytes::Bytes;
use hyper::{Body, Client, Method, Request, Uri}; use hyper::{Body, Client, Method, Request, Uri};
use hyper_tls::HttpsConnector; use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use async_trait::async_trait; use async_trait::async_trait;
use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
@@ -24,7 +24,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use crate::{ use crate::{
model::{ model::{
Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage, Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage,
UpdateItem, OutgoingMessageTarget, ResolveHandleResponse, SendMessageResponse, UpdateItem,
}, },
APIInterface, APIInterface,
}; };
@@ -65,7 +65,15 @@ impl std::error::Error for Error {
impl std::fmt::Display for Error { impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 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(()) 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( async fn get_messages(
&mut self, &mut self,
conversation_id: &ConversationID, conversation_id: &ConversationID,
@@ -312,16 +331,46 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
async fn send_message( async fn send_message(
&mut self, &mut self,
outgoing_message: &OutgoingMessage, outgoing_message: &OutgoingMessage,
) -> Result<Message, Self::Error> { ) -> Result<SendMessageResponse, Self::Error> {
let message: Message = self 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, || { .deserialized_response_with_body("sendMessage", Method::POST, || {
serde_json::to_string(&outgoing_message).unwrap().into() Self::send_message_request_body(outgoing_message)
}) })
.await?; .await?;
Ok(message) 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( async fn fetch_attachment_data(
&mut self, &mut self,
guid: &str, guid: &str,
@@ -394,8 +443,7 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
None => "updates".to_string(), None => "updates".to_string(),
}; };
let uri = self let uri = self.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
loop { loop {
log::debug!("Connecting to websocket: {:?}", uri); log::debug!("Connecting to websocket: {:?}", uri);
@@ -426,18 +474,20 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
log::debug!("Websocket request: {:?}", request); log::debug!("Websocket request: {:?}", request);
let mut should_retry = true; // retry once after authenticating. let should_retry = true; // retry once after authenticating.
match connect_async(request).await.map_err(Error::from) { match connect_async(request).await.map_err(Error::from) {
Ok((socket, response)) => { Ok((socket, response)) => {
log::debug!("Websocket connected: {:?}", response.status()); log::debug!("Websocket connected: {:?}", response.status());
break Ok(WebsocketEventSocket::new(socket)) break Ok(WebsocketEventSocket::new(socket));
} }
Err(e) => match &e { Err(e) => match &e {
Error::ClientError(ce) => match ce.as_str() { Error::ClientError(ce) => match ce.as_str() {
"HTTP error: 401 Unauthorized" | "Unauthorized" => { "HTTP error: 401 Unauthorized" | "Unauthorized" => {
// Try to authenticate // Try to authenticate
if let Some(credentials) = &self.auth_store.get_credentials().await { if let Some(credentials) = &self.auth_store.get_credentials().await {
log::warn!("Websocket connection failed, attempting to authenticate"); log::warn!(
"Websocket connection failed, attempting to authenticate"
);
let new_token = self.authenticate(credentials.clone()).await?; let new_token = self.authenticate(credentials.clone()).await?;
self.auth_store.set_token(new_token.to_string()).await; self.auth_store.set_token(new_token.to_string()).await;
@@ -466,19 +516,51 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> { impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient<K> { pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient<K> {
let https = HttpsConnector::new(); let https = HttpsConnectorBuilder::new()
.with_webpki_roots()
.https_or_http()
.enable_http1()
.build();
let client = Client::builder().build::<_, Body>(https); let client = Client::builder().build::<_, Body>(https);
HTTPAPIClient { base_url, auth_store, client } HTTPAPIClient {
base_url,
auth_store,
client,
}
}
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> { fn uri_for_endpoint(&self, endpoint: &str, scheme: Option<&str>) -> Result<Uri, Error> {
let mut parts = self.base_url.clone().into_parts(); let mut parts = self.base_url.clone().into_parts();
let root_path: PathBuf = parts let root_path: PathBuf = parts.path_and_query.ok_or(Error::URLError)?.path().into();
.path_and_query
.ok_or(Error::URLError)?
.path()
.into();
let path = root_path.join(endpoint); let path = root_path.join(endpoint);
let path_str = path.to_str().ok_or(Error::URLError)?; let path_str = path.to_str().ok_or(Error::URLError)?;
@@ -499,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>( async fn deserialized_response<T: DeserializeOwned>(
&mut self, &mut self,
endpoint: &str, endpoint: &str,
@@ -532,15 +626,26 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
T: DeserializeOwned, T: DeserializeOwned,
{ {
let response = self 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?; .await?;
// Read and parse response body // 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) { let parsed: T = match serde_json::from_slice(&body) {
Ok(result) => Ok(result), Ok(result) => Ok(result),
Err(json_err) => { 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)); log::error!("Body: {:?}", String::from_utf8_lossy(&body));
// If JSON deserialization fails, try to interpret it as plain text // If JSON deserialization fails, try to interpret it as plain text
@@ -563,7 +668,8 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
use hyper::StatusCode; use hyper::StatusCode;
let uri = self.uri_for_endpoint(endpoint, None)?; 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 mut build_request = |auth: &Option<String>| {
let body = body_fn(); let body = body_fn();
@@ -577,13 +683,24 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
log::trace!("Obtaining token from auth store"); log::trace!("Obtaining token from auth store");
let token = self.auth_store.get_token().await; let token = self.auth_store.get_token().await;
log::trace!("Token: {:?}", token); log::trace!("Token present: {}", token.is_some());
let request = build_request(&token); 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?; let mut response = match self.client.request(request).await {
log::debug!("-> Response: {:}", response.status()); 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() { match response.status() {
StatusCode::OK => { /* cool */ } StatusCode::OK => { /* cool */ }
@@ -602,7 +719,19 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
let new_token = self.authenticate(credentials.clone()).await?; let new_token = self.authenticate(credentials.clone()).await?;
let request = build_request(&Some(new_token.to_string())); 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 { } else {
return Err(Error::ClientError( return Err(Error::ClientError(
"Unauthorized, no credentials provided".into(), "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 async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
@@ -42,7 +45,13 @@ pub trait APIInterface {
async fn send_message( async fn send_message(
&mut self, &mut self,
outgoing_message: &OutgoingMessage, 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 // (GET) /attachment
async fn fetch_attachment_data( async fn fetch_attachment_data(
@@ -70,6 +79,12 @@ pub trait APIInterface {
conversation_id: &ConversationID, conversation_id: &ConversationID,
) -> Result<(), Self::Error>; ) -> Result<(), Self::Error>;
// (GET) /delete
async fn delete_conversation(
&mut self,
conversation_id: &ConversationID,
) -> Result<(), Self::Error>;
// (WS) /updates // (WS) /updates
async fn open_event_socket( async fn open_event_socket(
&mut self, &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 conversation;
pub mod event; pub mod event;
pub mod handle;
pub mod message; pub mod message;
pub mod outgoing_message; pub mod outgoing_message;
pub mod send_message_response;
pub mod update; pub mod update;
pub use conversation::Conversation; pub use conversation::Conversation;
@@ -10,8 +12,15 @@ pub use conversation::ConversationID;
pub use message::Message; pub use message::Message;
pub use message::MessageID; 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::OutgoingMessage;
pub use outgoing_message::OutgoingMessageBuilder; pub use outgoing_message::OutgoingMessageBuilder;
pub use outgoing_message::OutgoingMessageTarget;
pub use send_message_response::SendMessageResponse;
pub use update::UpdateItem; pub use update::UpdateItem;

View File

@@ -1,23 +1,23 @@
use super::conversation::ConversationID; use super::conversation::ConversationID;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use serde::Serialize;
use uuid::Uuid; 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 { pub struct OutgoingMessage {
#[serde(skip)]
pub guid: Uuid, pub guid: Uuid,
#[serde(skip)]
pub date: NaiveDateTime, pub date: NaiveDateTime,
#[serde(rename = "body")]
pub text: String, pub text: String,
#[serde(rename = "guid")] pub target: OutgoingMessageTarget,
pub conversation_id: ConversationID,
#[serde(rename = "fileTransferGUIDs")]
pub file_transfer_guids: Vec<String>, pub file_transfer_guids: Vec<String>,
} }
@@ -25,13 +25,27 @@ impl OutgoingMessage {
pub fn builder() -> OutgoingMessageBuilder { pub fn builder() -> OutgoingMessageBuilder {
OutgoingMessageBuilder::new() 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)] #[derive(Default)]
pub struct OutgoingMessageBuilder { pub struct OutgoingMessageBuilder {
guid: Option<Uuid>, guid: Option<Uuid>,
text: Option<String>, text: Option<String>,
conversation_id: Option<ConversationID>, target: Option<OutgoingMessageTarget>,
file_transfer_guids: Option<Vec<String>>, file_transfer_guids: Option<Vec<String>>,
} }
@@ -50,8 +64,18 @@ impl OutgoingMessageBuilder {
self self
} }
pub fn target(mut self, target: OutgoingMessageTarget) -> Self {
self.target = Some(target);
self
}
pub fn conversation_id(mut self, conversation_id: ConversationID) -> 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 self
} }
@@ -64,7 +88,7 @@ impl OutgoingMessageBuilder {
OutgoingMessage { OutgoingMessage {
guid: self.guid.unwrap_or_else(Uuid::new_v4), guid: self.guid.unwrap_or_else(Uuid::new_v4),
text: self.text.unwrap(), text: self.text.unwrap(),
conversation_id: self.conversation_id.unwrap(), target: self.target.unwrap(),
file_transfer_guids: self.file_transfer_guids.unwrap_or_default(), file_transfer_guids: self.file_transfer_guids.unwrap_or_default(),
date: chrono::Utc::now().naive_utc(), 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; use crate::APIInterface;
pub mod api_interface { pub mod api_interface {
use crate::model::Conversation; use crate::model::{Conversation, HandleResolutionStatus, OutgoingMessage};
use super::*; use super::*;
@@ -28,4 +28,42 @@ pub mod api_interface {
assert_eq!(conversations.len(), 1); assert_eq!(conversations.len(), 1);
assert_eq!(conversations[0].display_name, test_convo.display_name); 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::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpdate},
api::http_client::Credentials, api::http_client::Credentials,
model::{ model::{
Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage, Conversation, ConversationID, Event, HandleResolutionStatus, JwtToken, Message, MessageID,
UpdateItem, OutgoingMessage, OutgoingMessageTarget, ResolveHandleResponse, ResolvedHandle,
SendMessageResponse,
}, },
}; };
use bytes::Bytes; use bytes::Bytes;
use futures_util::sink::drain;
use futures_util::stream::BoxStream; use futures_util::stream::BoxStream;
use futures_util::Sink; use futures_util::Sink;
use futures_util::SinkExt;
use futures_util::StreamExt; use futures_util::StreamExt;
pub struct TestClient { pub struct TestClient {
@@ -63,13 +66,18 @@ impl EventSocket for TestEventSocket {
impl Sink<SinkMessage, Error = Self::Error>, impl Sink<SinkMessage, Error = Self::Error>,
) { ) {
( (
futures_util::stream::iter(self.events.into_iter().map(Ok)).boxed(), futures_util::stream::iter(
futures_util::sink::sink(), 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 { 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() futures_util::stream::iter(results.into_iter()).boxed()
} }
} }
@@ -94,9 +102,9 @@ impl APIInterface for TestClient {
async fn get_messages( async fn get_messages(
&mut self, &mut self,
conversation_id: &ConversationID, conversation_id: &ConversationID,
limit: Option<u32>, _limit: Option<u32>,
before: Option<MessageID>, _before: Option<MessageID>,
after: Option<MessageID>, _after: Option<MessageID>,
) -> Result<Vec<Message>, Self::Error> { ) -> Result<Vec<Message>, Self::Error> {
if let Some(messages) = self.messages.get(conversation_id) { if let Some(messages) = self.messages.get(conversation_id) {
return Ok(messages.clone()); return Ok(messages.clone());
@@ -108,18 +116,42 @@ impl APIInterface for TestClient {
async fn send_message( async fn send_message(
&mut self, &mut self,
outgoing_message: &OutgoingMessage, outgoing_message: &OutgoingMessage,
) -> Result<Message, Self::Error> { ) -> Result<SendMessageResponse, Self::Error> {
let message = Message::builder() let message = Message::builder()
.guid(Uuid::new_v4().to_string()) .guid(Uuid::new_v4().to_string())
.text(outgoing_message.text.clone()) .text(outgoing_message.text.clone())
.date(OffsetDateTime::now_utc()) .date(OffsetDateTime::now_utc())
.build(); .build();
let conversation_id = match &outgoing_message.target {
OutgoingMessageTarget::Conversation(conversation_id) => {
self.messages self.messages
.entry(outgoing_message.conversation_id.clone()) .entry(conversation_id.clone())
.or_insert(vec![]) .or_insert(vec![])
.push(message.clone()); .push(message.clone());
Ok(message) 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( async fn open_event_socket(
@@ -131,17 +163,17 @@ impl APIInterface for TestClient {
async fn fetch_attachment_data( async fn fetch_attachment_data(
&mut self, &mut self,
guid: &str, _guid: &str,
preview: bool, _preview: bool,
) -> Result<Self::ResponseStream, Self::Error> { ) -> Result<Self::ResponseStream, Self::Error> {
Ok(futures_util::stream::iter(vec![Ok(Bytes::from_static(b"test"))]).boxed()) Ok(futures_util::stream::iter(vec![Ok(Bytes::from_static(b"test"))]).boxed())
} }
async fn upload_attachment<R>( async fn upload_attachment<R>(
&mut self, &mut self,
data: tokio::io::BufReader<R>, _data: tokio::io::BufReader<R>,
filename: &str, _filename: &str,
size: u64, _size: u64,
) -> Result<String, Self::Error> ) -> Result<String, Self::Error>
where where
R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static, R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static,
@@ -151,8 +183,23 @@ impl APIInterface for TestClient {
async fn mark_conversation_as_read( async fn mark_conversation_as_read(
&mut self, &mut self,
conversation_id: &ConversationID, _conversation_id: &ConversationID,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
Ok(()) 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] [package]
name = "kordophoned" name = "kordophoned"
version = "1.0.1" version = "1.3.0"
edition = "2021" edition = "2021"
license = "GPL-3.0" license = "GPL-3.0"
description = "Client daemon for the Kordophone chat protocol" description = "Client daemon for the Kordophone chat protocol"
@@ -33,8 +33,7 @@ dbus-tree = "0.9.2"
# D-Bus codegen only on Linux # D-Bus codegen only on Linux
[target.'cfg(target_os = "linux")'.build-dependencies] [target.'cfg(target_os = "linux")'.build-dependencies]
dbus-codegen = "0.10.0" dbus-codegen = { version = "0.10.0", default-features = false }
dbus-crossroads = "0.5.1"
# XPC (libxpc) interface for macOS IPC # XPC (libxpc) interface for macOS IPC
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
@@ -48,5 +47,18 @@ serde = { version = "1.0", features = ["derive"] }
assets = [ assets = [
{ source = "../target/release/kordophoned", dest = "/usr/libexec/kordophoned", mode = "755" }, { source = "../target/release/kordophoned", dest = "/usr/libexec/kordophoned", mode = "755" },
{ source = "../target/release/kpcli", dest = "/usr/bin/kpcli", mode = "755" }, { source = "../target/release/kpcli", dest = "/usr/bin/kpcli", mode = "755" },
{ source = "../target/release/kptui", dest = "/usr/bin/kptui", mode = "755" },
{ source = "include/net.buzzert.kordophonecd.service", dest = "/usr/share/dbus-1/services/net.buzzert.kordophonecd.service", mode = "644" }, { source = "include/net.buzzert.kordophonecd.service", dest = "/usr/share/dbus-1/services/net.buzzert.kordophonecd.service", mode = "644" },
] ]
[package.metadata.deb]
maintainer = "James Magahern <james@magahern.com>"
copyright = "2026, James Magahern <james@magahern.com>"
section = "net"
priority = "optional"
assets = [
["../target/release/kordophoned", "usr/libexec/kordophoned", "755"],
["../target/release/kpcli", "usr/bin/kpcli", "755"],
["../target/release/kptui", "usr/bin/kptui", "755"],
["include/net.buzzert.kordophonecd.service", "usr/share/dbus-1/services/net.buzzert.kordophonecd.service", "644"],
]

View File

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

View File

@@ -73,18 +73,17 @@
'sender' (string): Sender display name 'sender' (string): Sender display name
'attachments' (array of dictionaries): List of attachments 'attachments' (array of dictionaries): List of attachments
'guid' (string): Attachment GUID 'guid' (string): Attachment GUID
'path' (string): Attachment path
'preview_path' (string): Preview attachment path
'downloaded' (boolean): Whether the attachment is downloaded 'downloaded' (boolean): Whether the attachment is downloaded
'preview_downloaded' (boolean): Whether the preview is downloaded 'preview_downloaded' (boolean): Whether the preview is downloaded
'metadata' (dictionary, optional): Attachment metadata 'metadata' (dictionary, optional): Attachment metadata
'attribution_info' (dictionary, optional): Attribution info 'attribution_info' (dictionary, optional): Attribution info
'width' (int32, optional): Width 'width' (int32, optional): Width
'height' (int32, optional): Height"/> 'height' (int32, optional): Height
Use GetAttachmentInfo for full/preview paths."/>
</arg> </arg>
</method> </method>
<method name="SendMessage"> <method name="Reply">
<arg type="s" name="conversation_id" direction="in"/> <arg type="s" name="conversation_id" direction="in"/>
<arg type="s" name="text" direction="in"/> <arg type="s" name="text" direction="in"/>
<arg type="as" name="attachment_guids" direction="in"/> <arg type="as" name="attachment_guids" direction="in"/>
@@ -92,9 +91,28 @@
<arg type="s" name="outgoing_message_id" direction="out"/> <arg type="s" name="outgoing_message_id" direction="out"/>
<annotation name="org.freedesktop.DBus.DocString" <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: 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. - text: The text of the message to send.
- attachment_guids: The GUIDs of the attachments to send. - attachment_guids: The GUIDs of the attachments to send.
@@ -129,6 +147,20 @@
"/> "/>
</method> </method>
<method name="OpenAttachmentFd">
<arg type="s" name="attachment_id" direction="in"/>
<arg type="b" name="preview" direction="in"/>
<arg type="h" name="fd" direction="out"/>
<annotation name="org.freedesktop.DBus.DocString"
value="Opens a read-only file descriptor for an attachment path.
Arguments:
attachment_id: the attachment GUID
preview: whether to open the preview path (true) or full path (false)
Returns:
fd: a Unix file descriptor to read attachment bytes"/>
</method>
<method name="DownloadAttachment"> <method name="DownloadAttachment">
<arg type="s" name="attachment_id" direction="in"/> <arg type="s" name="attachment_id" direction="in"/>
<arg type="b" name="preview" direction="in"/> <arg type="b" name="preview" direction="in"/>

View File

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

View File

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

View File

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

View File

@@ -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. /// - 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>>), GetMessages(String, Option<String>, Reply<Vec<Message>>),
/// Enqueues a message to be sent to the server. /// Enqueues a reply to an existing conversation.
/// Parameters: /// Parameters:
/// - conversation_id: The ID of the conversation to send the message to. /// - conversation_id: The ID of the conversation to send the message to.
/// - text: The text of the message to send. /// - text: The text of the message to send.
/// - attachment_guids: The GUIDs of the attachments to send. /// - attachment_guids: The GUIDs of the attachments to send.
/// - reply: The outgoing message ID (not the server-assigned message ID). /// - reply: The outgoing message ID (not the server-assigned message ID).
SendMessage(String, String, Vec<String>, Reply<Uuid>), 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. /// Notifies the daemon that a message has been sent.
/// Parameters: /// Parameters:

View File

@@ -15,6 +15,7 @@ use std::collections::HashMap;
use std::error::Error; use std::error::Error;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant;
use thiserror::Error; use thiserror::Error;
use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::mpsc::{Receiver, Sender};
@@ -28,7 +29,7 @@ use kordophone_db::{
use kordophone::api::http_client::HTTPAPIClient; use kordophone::api::http_client::HTTPAPIClient;
use kordophone::api::APIInterface; use kordophone::api::APIInterface;
use kordophone::model::outgoing_message::OutgoingMessage; use kordophone::model::outgoing_message::{OutgoingMessage, OutgoingMessageTarget};
use kordophone::model::{ConversationID, MessageID}; use kordophone::model::{ConversationID, MessageID};
mod update_monitor; mod update_monitor;
@@ -72,6 +73,8 @@ pub mod target {
pub static DAEMON: &str = "daemon"; pub static DAEMON: &str = "daemon";
} }
const GET_MESSAGES_INITIAL_WINDOW: usize = 300;
pub struct Daemon { pub struct Daemon {
pub event_sender: Sender<Event>, pub event_sender: Sender<Event>,
event_receiver: Receiver<Event>, event_receiver: Receiver<Event>,
@@ -185,14 +188,14 @@ impl Daemon {
async fn handle_event(&mut self, event: Event) { async fn handle_event(&mut self, event: Event) {
match event { match event {
Event::GetVersion(reply) => { Event::GetVersion(reply) => {
reply.send(self.version.clone()).unwrap(); let _ = reply.send(self.version.clone());
} }
Event::SyncConversationList(reply) => { Event::SyncConversationList(reply) => {
self.spawn_conversation_list_sync(); self.spawn_conversation_list_sync();
// This is a background operation, so return right away. // This is a background operation, so return right away.
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::SyncAllConversations(reply) => { Event::SyncAllConversations(reply) => {
@@ -207,7 +210,7 @@ impl Daemon {
}); });
// This is a background operation, so return right away. // This is a background operation, so return right away.
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::SyncConversation(conversation_id, reply) => { Event::SyncConversation(conversation_id, reply) => {
@@ -225,7 +228,7 @@ impl Daemon {
} }
}); });
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::MarkConversationAsRead(conversation_id, reply) => { Event::MarkConversationAsRead(conversation_id, reply) => {
@@ -237,7 +240,7 @@ impl Daemon {
} }
}); });
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::UpdateConversationMetadata(conversation, reply) => { Event::UpdateConversationMetadata(conversation, reply) => {
@@ -250,7 +253,7 @@ impl Daemon {
} }
}); });
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::UpdateStreamReconnected => { Event::UpdateStreamReconnected => {
@@ -268,7 +271,7 @@ impl Daemon {
Event::GetAllConversations(limit, offset, reply) => { Event::GetAllConversations(limit, offset, reply) => {
let conversations = self.get_conversations_limit_offset(limit, offset).await; let conversations = self.get_conversations_limit_offset(limit, offset).await;
reply.send(conversations).unwrap(); let _ = reply.send(conversations);
} }
Event::GetAllSettings(reply) => { Event::GetAllSettings(reply) => {
@@ -277,7 +280,7 @@ impl Daemon {
Settings::default() Settings::default()
}); });
reply.send(settings).unwrap(); let _ = reply.send(settings);
} }
Event::UpdateSettings(settings, reply) => { Event::UpdateSettings(settings, reply) => {
@@ -309,12 +312,14 @@ impl Daemon {
} }
} }
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::GetMessages(conversation_id, last_message_id, reply) => { Event::GetMessages(conversation_id, last_message_id, reply) => {
let messages = self.get_messages(conversation_id, last_message_id).await; let messages = self.get_messages(conversation_id, last_message_id).await;
reply.send(messages).unwrap(); if reply.send(messages).is_err() {
log::warn!(target: target::EVENT, "GetMessages reply receiver dropped before send");
}
} }
Event::DeleteAllConversations(reply) => { Event::DeleteAllConversations(reply) => {
@@ -322,15 +327,19 @@ impl Daemon {
log::error!(target: target::SYNC, "Failed to delete all conversations: {}", e); log::error!(target: target::SYNC, "Failed to delete all conversations: {}", e);
}); });
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::SendMessage(conversation_id, text, attachment_guids, reply) => { Event::Reply(conversation_id, text, attachment_guids, reply) => {
let conversation_id = conversation_id.clone(); let conversation_id = conversation_id.clone();
let uuid = self let uuid = self
.enqueue_outgoing_message(text, conversation_id.clone(), attachment_guids) .enqueue_outgoing_message(
text,
OutgoingMessageTarget::Conversation(conversation_id.clone()),
attachment_guids,
)
.await; .await;
reply.send(uuid).unwrap(); let _ = reply.send(uuid);
// Send message updated signal, we have a placeholder message we will return. // Send message updated signal, we have a placeholder message we will return.
self.signal_sender self.signal_sender
@@ -339,12 +348,52 @@ impl Daemon {
.unwrap(); .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) => { Event::MessageSent(message, outgoing_message, conversation_id) => {
log::info!(target: target::EVENT, "Daemon: message sent: {}", message.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. // Insert the message into the database.
log::debug!(target: target::EVENT, "inserting sent message into database: {}", message.id); log::debug!(target: target::EVENT, "inserting sent message into database: {}", message.id);
self.database if let Err(e) = self
.database
.lock() .lock()
.await .await
.with_repository(|r| { .with_repository(|r| {
@@ -358,13 +407,24 @@ impl Daemon {
) )
}) })
.await .await
.unwrap(); {
log::error!(
target: target::EVENT,
"Failed to persist sent message {} for conversation {}: {}",
message.id,
conversation_id,
e
);
return;
}
// Remove from outgoing messages. // Remove from outgoing messages.
log::debug!(target: target::EVENT, "Removing message from outgoing messages: {}", outgoing_message.guid); 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 self.outgoing_messages
.get_mut(&conversation_id) .retain(|_, messages| !messages.is_empty());
.map(|messages| messages.retain(|m| m.guid != outgoing_message.guid));
// Send message updated signal. // Send message updated signal.
self.signal_sender self.signal_sender
@@ -395,7 +455,7 @@ impl Daemon {
.await .await
.unwrap(); .unwrap();
reply.send(()).unwrap(); let _ = reply.send(());
} }
Event::AttachmentDownloaded(attachment_id) => { Event::AttachmentDownloaded(attachment_id) => {
@@ -448,8 +508,9 @@ impl Daemon {
async fn get_messages( async fn get_messages(
&mut self, &mut self,
conversation_id: String, conversation_id: String,
_last_message_id: Option<MessageID>, last_message_id: Option<MessageID>,
) -> Vec<Message> { ) -> Vec<Message> {
let started = Instant::now();
// Get outgoing messages for this conversation. // Get outgoing messages for this conversation.
let empty_vec: Vec<OutgoingMessage> = vec![]; let empty_vec: Vec<OutgoingMessage> = vec![];
let outgoing_messages: &Vec<OutgoingMessage> = self let outgoing_messages: &Vec<OutgoingMessage> = self
@@ -471,9 +532,8 @@ impl Daemon {
.await; .await;
// Convert DB messages to daemon model, substituting local_id when an alias exists. // Convert DB messages to daemon model, substituting local_id when an alias exists.
let mut result: Vec<Message> = Vec::with_capacity( let mut result: Vec<Message> =
db_messages.len() + outgoing_messages.len(), Vec::with_capacity(db_messages.len() + outgoing_messages.len());
);
for m in db_messages.into_iter() { for m in db_messages.into_iter() {
let server_id = m.id.clone(); let server_id = m.id.clone();
let mut dm: Message = m.into(); let mut dm: Message = m.into();
@@ -488,27 +548,111 @@ impl Daemon {
result.push(om.into()); 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 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( async fn enqueue_outgoing_message(
&mut self, &mut self,
text: String, text: String,
conversation_id: String, target: OutgoingMessageTarget,
attachment_guids: Vec<String>, attachment_guids: Vec<String>,
) -> Uuid { ) -> Uuid {
let conversation_id = conversation_id.clone();
let outgoing_message = OutgoingMessage::builder() let outgoing_message = OutgoingMessage::builder()
.text(text) .text(text)
.conversation_id(conversation_id.clone()) .target(target)
.file_transfer_guids(attachment_guids) .file_transfer_guids(attachment_guids)
.build(); .build();
// Keep a record of this so we can provide a consistent model to the client. 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 self.outgoing_messages
.entry(conversation_id) .entry(conversation_id)
.or_insert(vec![]) .or_insert(vec![])
.push(outgoing_message.clone()); .push(outgoing_message.clone());
}
let guid = outgoing_message.guid.clone(); let guid = outgoing_message.guid.clone();
self.post_office_sink self.post_office_sink

View File

@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf}; use std::path::PathBuf;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AttachmentMetadata { pub struct AttachmentMetadata {
@@ -17,6 +17,8 @@ pub struct Attachment {
pub base_path: PathBuf, pub base_path: PathBuf,
pub metadata: Option<AttachmentMetadata>, pub metadata: Option<AttachmentMetadata>,
pub mime_type: Option<String>, pub mime_type: Option<String>,
pub cached_full_path: Option<PathBuf>,
pub cached_preview_path: Option<PathBuf>,
} }
impl Attachment { impl Attachment {
@@ -25,8 +27,7 @@ impl Attachment {
// Prefer common, user-friendly extensions over obscure ones // Prefer common, user-friendly extensions over obscure ones
match normalized { match normalized {
"image/jpeg" | "image/pjpeg" => Some("jpg"), "image/jpeg" | "image/pjpeg" => Some("jpg"),
_ => mime_guess::get_mime_extensions_str(normalized) _ => mime_guess::get_mime_extensions_str(normalized).and_then(|list| {
.and_then(|list| {
// If jpg is one of the candidates, prefer it // If jpg is one of the candidates, prefer it
if list.iter().any(|e| *e == "jpg") { if list.iter().any(|e| *e == "jpg") {
Some("jpg") Some("jpg")
@@ -45,17 +46,21 @@ impl Attachment {
} }
pub fn get_path_for_preview_scratch(&self, preview: bool, scratch: bool) -> PathBuf { pub fn get_path_for_preview_scratch(&self, preview: bool, scratch: bool) -> PathBuf {
if !scratch {
let cached = if preview {
self.cached_preview_path.as_ref()
} else {
self.cached_full_path.as_ref()
};
if let Some(path) = cached {
return path.clone();
}
}
// Determine whether this is a preview or full attachment. // Determine whether this is a preview or full attachment.
let kind = if preview { "preview" } else { "full" }; let kind = if preview { "preview" } else { "full" };
// If not a scratch path, and a file already exists on disk with a concrete
// file extension (e.g., guid.full.jpg), return that existing path.
if !scratch {
if let Some(existing) = self.find_existing_path(preview) {
return existing;
}
}
// Fall back to constructing a path using known info. If we know the MIME type, // Fall back to constructing a path using known info. If we know the MIME type,
// prefer an extension guessed from it; otherwise keep legacy naming. // prefer an extension guessed from it; otherwise keep legacy naming.
let ext_from_mime = self let ext_from_mime = self
@@ -77,44 +82,15 @@ impl Attachment {
} }
pub fn is_downloaded(&self, preview: bool) -> bool { pub fn is_downloaded(&self, preview: bool) -> bool {
std::fs::exists(&self.get_path_for_preview(preview)).expect( let path = self.get_path_for_preview(preview);
std::fs::exists(&path).expect(
format!( format!(
"Wasn't able to check for the existence of an attachment file path at {}", "Wasn't able to check for the existence of an attachment file path at {}",
&self.get_path_for_preview(preview).display() path.display()
) )
.as_str(), .as_str(),
) )
} }
fn find_existing_path(&self, preview: bool) -> Option<PathBuf> {
let kind = if preview { "preview" } else { "full" };
// First, check legacy path without a concrete file extension.
let legacy = self.base_path.with_extension(kind);
if legacy.exists() {
return Some(legacy);
}
// Next, search for a filename like: <guid>.<kind>.<ext>
let file_stem = self
.base_path
.file_name()
.map(|s| s.to_string_lossy().to_string())?;
let prefix = format!("{}.{}.", file_stem, kind);
let parent = self.base_path.parent().unwrap_or_else(|| Path::new("."));
if let Ok(dir) = std::fs::read_dir(parent) {
for entry in dir.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name.starts_with(&prefix) && !name.ends_with(".download") {
return Some(entry.path());
}
}
}
None
}
} }
impl From<kordophone::model::message::AttachmentMetadata> for AttachmentMetadata { impl From<kordophone::model::message::AttachmentMetadata> for AttachmentMetadata {

View File

@@ -8,6 +8,7 @@ use tokio_condvar::Condvar;
use crate::daemon::events::Event as DaemonEvent; use crate::daemon::events::Event as DaemonEvent;
use kordophone::api::APIInterface; use kordophone::api::APIInterface;
use kordophone::model::outgoing_message::OutgoingMessage; use kordophone::model::outgoing_message::OutgoingMessage;
use kordophone::model::OutgoingMessageTarget;
use anyhow::Result; use anyhow::Result;
@@ -102,10 +103,29 @@ impl<C: APIInterface, F: AsyncFnMut() -> Result<C>> PostOffice<C, F> {
Ok(sent_message) => { Ok(sent_message) => {
log::info!(target: target::POST_OFFICE, "Message sent successfully: {}", message.guid); log::info!(target: target::POST_OFFICE, "Message sent successfully: {}", message.guid);
let conversation_id = message.conversation_id.clone(); let conversation_id = sent_message.conversation_id.clone().or_else(|| {
let event = match &message.target {
DaemonEvent::MessageSent(sent_message.into(), message, conversation_id); 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(); 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) => { Err(e) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,10 @@ use kordophone::api::InMemoryAuthenticationStore;
use kordophone::APIInterface; use kordophone::APIInterface;
use crate::printers::{ConversationPrinter, MessagePrinter}; use crate::printers::{ConversationPrinter, MessagePrinter};
use anyhow::Result; use anyhow::{bail, Result};
use clap::Subcommand; use clap::Subcommand;
use kordophone::model::event::EventData; use kordophone::model::event::EventData;
use kordophone::model::outgoing_message::OutgoingMessage; use kordophone::model::{HandleResolutionStatus, OutgoingMessage, OutgoingMessageTarget};
use futures_util::StreamExt; use futures_util::StreamExt;
@@ -47,14 +47,29 @@ pub enum Commands {
/// Prints all raw updates from the server. /// Prints all raw updates from the server.
RawUpdates, RawUpdates,
/// Sends a message to the server. /// Resolves an address to a canonical handle.
SendMessage { #[command(alias = "resolve")]
ResolveHandle { address: String },
/// Replies to an existing conversation.
#[command(alias = "send-message")]
Reply {
conversation_id: String, conversation_id: String,
message: 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. /// Marks a conversation as read.
Mark { conversation_id: String }, Mark { conversation_id: String },
/// Deletes a conversation from the server.
Delete { conversation_id: String },
} }
impl Commands { impl Commands {
@@ -66,13 +81,21 @@ impl Commands {
Commands::Messages { conversation_id } => client.print_messages(conversation_id).await, Commands::Messages { conversation_id } => client.print_messages(conversation_id).await,
Commands::RawUpdates => client.print_raw_updates().await, Commands::RawUpdates => client.print_raw_updates().await,
Commands::Events => client.print_events().await, Commands::Events => client.print_events().await,
Commands::SendMessage { Commands::ResolveHandle { address } => client.resolve_handle(address).await,
Commands::Reply {
conversation_id, conversation_id,
message, 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 } => { Commands::Mark { conversation_id } => {
client.mark_conversation_as_read(conversation_id).await client.mark_conversation_as_read(conversation_id).await
} }
Commands::Delete { conversation_id } => {
client.delete_conversation(conversation_id).await
}
} }
} }
} }
@@ -146,8 +169,7 @@ impl ClientCli {
loop { loop {
match stream.next().await.unwrap() { match stream.next().await.unwrap() {
Ok(update) => { Ok(update) => match update {
match update {
SocketUpdate::Update(updates) => { SocketUpdate::Update(updates) => {
for update in updates { for update in updates {
println!("Got update: {:?}", update); println!("Got update: {:?}", update);
@@ -156,7 +178,6 @@ impl ClientCli {
SocketUpdate::Pong => { SocketUpdate::Pong => {
println!("Pong"); println!("Pong");
} }
}
}, },
Err(e) => { Err(e) => {
@@ -169,20 +190,91 @@ impl ClientCli {
Ok(()) 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() let outgoing_message = OutgoingMessage::builder()
.conversation_id(conversation_id) .target(target)
.text(message) .text(message)
.build(); .build();
let message = self.api.send_message(&outgoing_message).await?; let response = self.api.send_message(&outgoing_message).await?;
println!("Message sent: {}", message.guid); 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(()) 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<()> { pub async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
self.api.mark_conversation_as_read(&conversation_id).await?; self.api.mark_conversation_as_read(&conversation_id).await?;
println!("Conversation marked as read: {}", conversation_id); println!("Conversation marked as read: {}", conversation_id);
Ok(()) 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> { fn proxy(&self) -> Proxy<&Connection> {
self.conn self.conn
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000)) .with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_secs(30))
} }
async fn print_settings(&mut self) -> Result<()> { async fn print_settings(&mut self) -> Result<()> {
@@ -109,15 +109,20 @@ impl DaemonInterface for DBusDaemonInterface {
Ok(()) Ok(())
} }
async fn enqueue_outgoing_message( async fn reply(&mut self, conversation_id: String, text: String) -> Result<()> {
&mut self,
conversation_id: String,
text: String,
) -> Result<()> {
let attachment_guids: Vec<&str> = vec![]; 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(), &self.proxy(),
&conversation_id, handle_ids,
&text, &text,
attachment_guids, attachment_guids,
)?; )?;

View File

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

View File

@@ -371,11 +371,7 @@ impl DaemonInterface for XpcDaemonInterface {
_ => Err(anyhow::anyhow!("Unexpected messages payload")), _ => Err(anyhow::anyhow!("Unexpected messages payload")),
} }
} }
async fn enqueue_outgoing_message( async fn reply(&mut self, _conversation_id: String, _text: String) -> Result<()> {
&mut self,
_conversation_id: String,
_text: String,
) -> Result<()> {
let mach_port_name = Self::build_service_name()?; let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name); let mut client = XPCClient::connect(&mach_port_name);
let mut args = HashMap::new(); let mut args = HashMap::new();
@@ -387,10 +383,34 @@ impl DaemonInterface for XpcDaemonInterface {
Self::key("text"), Self::key("text"),
Message::String(CString::new(_text).unwrap()), Message::String(CString::new(_text).unwrap()),
); );
let reply = self let response = self.call_method(&mut client, "Reply", Some(args)).await?;
.call_method(&mut client, "SendMessage", Some(args)) 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?; .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()); println!("Outgoing message ID: {}", uuid.to_string_lossy());
} }
Ok(()) 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::env;
use std::process; use std::process;
use kordophone::{
api::{HTTPAPIClient, InMemoryAuthenticationStore, EventSocket},
model::{ConversationID, event::EventData},
APIInterface,
};
use kordophone::api::http_client::Credentials;
use kordophone::api::AuthenticationStore; use kordophone::api::AuthenticationStore;
use kordophone::api::http_client::Credentials;
use kordophone::{
APIInterface,
api::{EventSocket, HTTPAPIClient, InMemoryAuthenticationStore},
model::{ConversationID, event::EventData},
};
use futures_util::StreamExt; use futures_util::StreamExt;
use hyper::Uri; use hyper::Uri;
@@ -18,7 +18,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
if args.len() < 2 { if args.len() < 2 {
eprintln!("Usage: {} <conversation_id1> [conversation_id2] [conversation_id3] ...", args[0]); eprintln!(
"Usage: {} <conversation_id1> [conversation_id2] [conversation_id3] ...",
args[0]
);
eprintln!("Environment variables required:"); eprintln!("Environment variables required:");
eprintln!(" KORDOPHONE_API_URL - Server URL"); eprintln!(" KORDOPHONE_API_URL - Server URL");
eprintln!(" KORDOPHONE_USERNAME - Username for authentication"); eprintln!(" KORDOPHONE_USERNAME - Username for authentication");
@@ -40,12 +43,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let credentials = Credentials { username, password }; let credentials = Credentials { username, password };
// Collect all conversation IDs from command line arguments // Collect all conversation IDs from command line arguments
let target_conversation_ids: Vec<ConversationID> = args[1..].iter() let target_conversation_ids: Vec<ConversationID> =
.map(|id| id.clone()) args[1..].iter().map(|id| id.clone()).collect();
.collect();
println!("Monitoring {} conversation(s) for updates: {:?}", println!(
target_conversation_ids.len(), target_conversation_ids); "Monitoring {} conversation(s) for updates: {:?}",
target_conversation_ids.len(),
target_conversation_ids
);
let auth_store = InMemoryAuthenticationStore::new(Some(credentials.clone())); let auth_store = InMemoryAuthenticationStore::new(Some(credentials.clone()));
let mut client = HTTPAPIClient::new(server_url, auth_store); let mut client = HTTPAPIClient::new(server_url, auth_store);
@@ -62,26 +67,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
match event_result { match event_result {
Ok(socket_event) => { Ok(socket_event) => {
match socket_event { match socket_event {
kordophone::api::event_socket::SocketEvent::Update(event) => { kordophone::api::event_socket::SocketEvent::Update(event) => match event.data {
match event.data {
EventData::MessageReceived(conversation, _message) => { EventData::MessageReceived(conversation, _message) => {
if target_conversation_ids.contains(&conversation.guid) { if target_conversation_ids.contains(&conversation.guid) {
println!("Message update detected for conversation {}, marking as read...", conversation.guid); println!(
"Message update detected for conversation {}, marking as read...",
conversation.guid
);
match client.mark_conversation_as_read(&conversation.guid).await { match client.mark_conversation_as_read(&conversation.guid).await {
Ok(_) => println!("Successfully marked conversation {} as read", conversation.guid), Ok(_) => println!(
Err(e) => eprintln!("Failed to mark conversation {} as read: {:?}", conversation.guid, e), "Successfully marked conversation {} as read",
conversation.guid
),
Err(e) => eprintln!(
"Failed to mark conversation {} as read: {:?}",
conversation.guid, e
),
}
} }
} }
},
_ => {} _ => {}
}
}, },
kordophone::api::event_socket::SocketEvent::Pong => { kordophone::api::event_socket::SocketEvent::Pong => {
// Ignore pong messages // Ignore pong messages
} }
} }
}, }
Err(e) => { Err(e) => {
eprintln!("Error receiving event: {:?}", e); eprintln!("Error receiving event: {:?}", e);
break; break;

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/ 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/ setup: build/
meson 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) TMP := $(shell mktemp -d)
rpm: rpm:
git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(TMP)/v$(VER).tar.gz HEAD git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(TMP)/v$(VER).tar.gz HEAD
rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)" rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)" --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 # Building
Build an RPM using `rpmbuild -ba dist/rpm/kordophone.spec` Build an RPM using `rpmbuild -ba dist/rpm/kordophone.spec`
Build a DEB using `make deb`

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

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

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

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
VERSION="${1:-}"
if [[ -z "$VERSION" ]]; then
VERSION="$(sed -n "s/.*version[[:space:]]*:[[:space:]]*'\\([^']*\\)'.*/\\1/p" meson.build | head -n1)"
fi
if [[ -z "$VERSION" ]]; then
echo "Could not determine version (pass as first arg)" >&2
exit 1
fi
ARCH="$(dpkg --print-architecture)"
PKG="kordophone"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
PKGROOT="$STAGE/pkgroot"
mkdir -p "$PKGROOT/DEBIAN"
BUILD_DIR="$STAGE/build"
meson setup "$BUILD_DIR" --prefix=/usr
ninja -C "$BUILD_DIR"
DESTDIR="$PKGROOT" ninja -C "$BUILD_DIR" install
INSTALLED_SIZE_KB="$(du -sk "$PKGROOT/usr" | awk '{print $1}')"
cat >"$PKGROOT/DEBIAN/control" <<EOF
Package: ${PKG}
Version: ${VERSION}
Section: net
Priority: optional
Architecture: ${ARCH}
Maintainer: James Magahern <james@magahern.com>
Installed-Size: ${INSTALLED_SIZE_KB}
Depends: libgtk-4-1, libadwaita-1-0, libglib2.0-0, libgee-0.8-2, libsecret-1-0, kordophoned (>= 1.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 Name: kordophone
Version: 1.0.2 Version: %{?app_version}%{!?app_version:1.3.0}
Release: 1%{?dist} Release: 1%{?dist}
Summary: GTK4/Libadwaita client for Kordophone Summary: GTK4/Libadwaita client for Kordophone
@@ -22,7 +22,7 @@ Requires: libadwaita
Requires: glib2 Requires: glib2
Requires: libgee Requires: libgee
Requires: libsecret Requires: libsecret
Requires: kordophoned >= 1.0.0 Requires: kordophoned >= 1.3.0
%description %description
A GTK4/Libadwaita Linux Client for the Kordophone client daemon. A GTK4/Libadwaita Linux Client for the Kordophone client daemon.
@@ -49,4 +49,3 @@ popd
%changelog %changelog
* Fri Aug 8 2025 James Magahern <james@magahern.com> * Fri Aug 8 2025 James Magahern <james@magahern.com>
- Updated rpmspec - 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', project('kordophone', 'vala',
version : '1.0.1', version : '1.0.2',
meson_version : '>=0.56.0', meson_version : '>=0.56.0',
default_options : ['warning_level=2'] default_options : ['warning_level=2']
) )

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,18 +73,17 @@
'sender' (string): Sender display name 'sender' (string): Sender display name
'attachments' (array of dictionaries): List of attachments 'attachments' (array of dictionaries): List of attachments
'guid' (string): Attachment GUID 'guid' (string): Attachment GUID
'path' (string): Attachment path
'preview_path' (string): Preview attachment path
'downloaded' (boolean): Whether the attachment is downloaded 'downloaded' (boolean): Whether the attachment is downloaded
'preview_downloaded' (boolean): Whether the preview is downloaded 'preview_downloaded' (boolean): Whether the preview is downloaded
'metadata' (dictionary, optional): Attachment metadata 'metadata' (dictionary, optional): Attachment metadata
'attribution_info' (dictionary, optional): Attribution info 'attribution_info' (dictionary, optional): Attribution info
'width' (int32, optional): Width 'width' (int32, optional): Width
'height' (int32, optional): Height"/> 'height' (int32, optional): Height
Use GetAttachmentInfo for full/preview paths."/>
</arg> </arg>
</method> </method>
<method name="SendMessage"> <method name="Reply">
<arg type="s" name="conversation_id" direction="in"/> <arg type="s" name="conversation_id" direction="in"/>
<arg type="s" name="text" direction="in"/> <arg type="s" name="text" direction="in"/>
<arg type="as" name="attachment_guids" direction="in"/> <arg type="as" name="attachment_guids" direction="in"/>
@@ -92,9 +91,28 @@
<arg type="s" name="outgoing_message_id" direction="out"/> <arg type="s" name="outgoing_message_id" direction="out"/>
<annotation name="org.freedesktop.DBus.DocString" <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: 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. - text: The text of the message to send.
- attachment_guids: The GUIDs of the attachments to send. - attachment_guids: The GUIDs of the attachments to send.

View File

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

View File

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

View File

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

View File

@@ -257,7 +257,7 @@ class TranscriptContainerView : Adw.Bin
} }
try { 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) { } catch (Error e) {
GLib.warning("Failed to send message: %s", e.message); GLib.warning("Failed to send message: %s", e.message);
} }

View File

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

View File

@@ -148,7 +148,7 @@ public class TranscriptView : Adw.Bin
GLib.Idle.add(() => { GLib.Idle.add(() => {
if (needs_reload) { if (needs_reload) {
debug("Reloading messages for attachment download"); debug("Reloading messages for attachment download");
model.load_messages(); model.load_messages(true);
needs_reload = false; needs_reload = false;
} }
@@ -159,7 +159,6 @@ public class TranscriptView : Adw.Bin
} }
delegate void OpenPath(string path); delegate void OpenPath(string path);
private ulong attachment_downloaded_handler_id = 0;
private void open_attachment(string attachment_guid) { private void open_attachment(string attachment_guid) {
OpenPath open_path = (path) => { OpenPath open_path = (path) => {
try { try {
@@ -180,10 +179,17 @@ public class TranscriptView : Adw.Bin
// TODO: Should probably indicate progress here. // 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) { if (guid == attachment_guid) {
open_path(attachment_info.path); try {
Repository.get_instance().disconnect(attachment_downloaded_handler_id); 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

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

Binary file not shown.

View File

@@ -119,7 +119,7 @@ struct MessageEntryView: View
Task { Task {
do { do {
try await client.sendMessage( try await client.reply(
conversationId: convo.id, conversationId: convo.id,
message: messageText, message: messageText,
transferGuids: transferGuids transferGuids: transferGuids

View File

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

View File

@@ -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

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

View File

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

View File

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

View File

@@ -133,7 +133,7 @@ final class XPCClient
return results 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] = [:] var args: [String: xpc_object_t] = [:]
args["conversation_id"] = xpcString(conversationId) args["conversation_id"] = xpcString(conversationId)
args["text"] = xpcString(message) args["text"] = xpcString(message)
@@ -142,17 +142,50 @@ final class XPCClient
args["attachment_guids"] = xpcStringArray(transferGuids) 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 } 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] = [:] var args: [String: xpc_object_t] = [:]
args["attachment_id"] = xpcString(attachmentId) args["attachment_id"] = xpcString(attachmentId)
args["preview"] = xpcString(preview ? "true" : "false") args["preview"] = xpcString(preview ? "true" : "false")
let req = makeRequest(method: "DownloadAttachment", arguments: args) let req = makeRequest(method: "DownloadAttachment", arguments: args)
_ = try await sendSync(req) _ = try await sendSync(req)
if awaitCompletion {
// Wait for downloaded event
let _ = await eventStream().first { $0 == .attachmentDownloaded(attachmentId: attachmentId) }
}
} }
public func uploadAttachment(path: String) async throws -> String { public func uploadAttachment(path: String) async throws -> String {
@@ -201,6 +234,14 @@ final class XPCClient
// MARK: - Types // MARK: - Types
struct AttachmentInfo: Decodable
{
let path: String
let previewPath: String
let isDownloaded: Bool
let isPreviewDownloaded: Bool
}
enum Error: Swift.Error enum Error: Swift.Error
{ {
case typeError case typeError
@@ -209,7 +250,7 @@ final class XPCClient
case connectionError case connectionError
} }
enum Signal enum Signal: Equatable
{ {
case conversationsUpdated case conversationsUpdated
case messagesUpdated(conversationId: String) case messagesUpdated(conversationId: String)
@@ -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 extension xpc_object_t
{ {
func getObject(_ key: String) -> xpc_object_t? { func getObject(_ key: String) -> xpc_object_t? {

View File

@@ -14,6 +14,7 @@
#import "MBIMAuthToken.h" #import "MBIMAuthToken.h"
#import "MBIMUpdateQueue.h" #import "MBIMUpdateQueue.h"
#import "MBIMURLUtilities.h" #import "MBIMURLUtilities.h"
#import "MBIMLogging.h"
#import <Security/Security.h> #import <Security/Security.h>
#import "HTTPMessage.h" #import "HTTPMessage.h"
@@ -98,6 +99,10 @@
__block NSObject<HTTPResponse> *response = nil; __block NSObject<HTTPResponse> *response = nil;
dispatch_semaphore_t sema = dispatch_semaphore_create(0); dispatch_semaphore_t sema = dispatch_semaphore_create(0);
MBIMBridgeOperationCompletionBlock completion = ^(NSObject<HTTPResponse> *incomingResponse) { MBIMBridgeOperationCompletionBlock completion = ^(NSObject<HTTPResponse> *incomingResponse) {
if (incomingResponse == nil) {
MBIMLogError(@"Operation for %@ %@ completed with a nil response.", method, path);
}
response = incomingResponse; response = incomingResponse;
dispatch_semaphore_signal(sema); dispatch_semaphore_signal(sema);
}; };
@@ -125,6 +130,11 @@
response = [_currentOperation cancelAndReturnTimeoutResponse]; 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; return response;
} }

View File

@@ -10,6 +10,7 @@
#import "IMCore_ClassDump.h" #import "IMCore_ClassDump.h"
#import "IMMessageItem+Encoded.h" #import "IMMessageItem+Encoded.h"
#import "MBIMErrorResponse.h"
@implementation MBIMSendMessageOperation @implementation MBIMSendMessageOperation
@@ -20,16 +21,192 @@
return @"sendMessage"; 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; if ([registry respondsToSelector:@selector(existingChatWithHandle:allowAlternativeService:)]) {
MBIMLogInfo(@"Using IMChatRegistry existingChatWithHandle:allowAlternativeService:");
return [registry existingChatWithHandle:handle allowAlternativeService:NO];
}
dispatch_sync([[self class] sharedIMAccessQueue], ^{ if ([registry respondsToSelector:@selector(existingChatWithHandle:)]) {
IMChat *chat = [[IMChatRegistry sharedInstance] existingChatWithGUID:chatGUID]; 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);
// TODO: chat might not be an iMessage chat!
IMAccount *iMessageAccount = [[IMAccountController sharedInstance] bestAccountForService:[IMServiceImpl iMessageService]]; IMAccount *iMessageAccount = [[IMAccountController sharedInstance] bestAccountForService:[IMServiceImpl iMessageService]];
IMHandle *senderHandle = [iMessageAccount loginIMHandle]; 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) {
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 *replyAttrString = [[NSAttributedString alloc] initWithString:messageBody];
NSAttributedString *attrStringWithFileTransfers = IMCreateSuperFormatStringWithAppendedFileTransfers(replyAttrString, guids); NSAttributedString *attrStringWithFileTransfers = IMCreateSuperFormatStringWithAppendedFileTransfers(replyAttrString, guids);
@@ -40,19 +217,31 @@
flags:(kIMMessageFinished | kIMMessageIsFromMe)]; flags:(kIMMessageFinished | kIMMessageIsFromMe)];
for (NSString *guid in [reply fileTransferGUIDs]) { for (NSString *guid in [reply fileTransferGUIDs]) {
[[IMFileTransferCenter sharedInstance] assignTransfer:guid toHandle:chat.recipient]; [[IMFileTransferCenter sharedInstance] assignTransfer:guid toMessage:reply account:sendingAccount];
} }
if (!chat) { NSDictionary *replyRepresentation = [reply mbim_dictionaryRepresentation];
MBIMLogInfo(@"Chat does not exist: %@", chatGUID); if (![replyRepresentation isKindOfClass:[NSDictionary class]]) {
} else { MBIMLogError(@"Unable to encode sent message for chat %@", chatGUID ?: @"<unknown>");
result = reply; 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(), ^{ dispatch_async(dispatch_get_main_queue(), ^{
[chat sendMessage:reply]; [chat sendMessage:reply];
}); });
}
});
return result; return result;
} }
@@ -79,41 +268,112 @@
- (void)main - (void)main
{ {
NSObject<HTTPResponse> *response = [[HTTPErrorResponse alloc] initWithErrorCode:500]; __block NSObject<HTTPResponse> *response = [[HTTPErrorResponse alloc] initWithErrorCode:500];
NSError *error = nil; NSError *error = nil;
NSDictionary *args = [NSJSONSerialization JSONObjectWithData:self.requestBodyData options:0 error:&error]; NSDictionary *args = [NSJSONSerialization JSONObjectWithData:self.requestBodyData options:0 error:&error];
if (error || args.count == 0) { if (error || args.count == 0) {
MBIMLogError(@"Unable to parse sendMessage request body: %@", error);
self.serverCompletionBlock(response); self.serverCompletionBlock(response);
return; return;
} }
NSString *guid = [args objectForKey:@"guid"]; NSString *guid = [args objectForKey:@"guid"];
NSString *messageBody = [args objectForKey:@"body"]; 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); self.serverCompletionBlock(response);
return; return;
} }
// tapbacks NSMutableArray<NSString *> *handleIDs = [NSMutableArray array];
#if 0 if (hasHandleIDs) {
IMMessage *acknowledgment = [IMMessage instantMessageWithAssociatedMessageContent: /* [NSString stringWithFormat:@"%@ \"%%@\"", tapbackAction] */ for (id handleID in rawHandleIDs) {
flags:0 if ([handleID isKindOfClass:[NSString class]] && [handleID length] > 0) {
associatedMessageGUID:guid [handleIDs addObject:handleID];
associatedMessageType:IMAssociatedMessageTypeAcknowledgmentHeart }
associatedMessageRange:[imMessage messagePartRange]
messageSummaryInfo:[self adjustMessageSummaryInfoForSending:message]
threadIdentifier:[imMessage threadIdentifier]];
#endif
NSArray *transferGUIDs = [args objectForKey:@"fileTransferGUIDs"];
if (!transferGUIDs) {
transferGUIDs = @[];
} }
IMMessage *result = [self _sendMessage:messageBody toChatWithGUID:guid attachmentGUIDs:transferGUIDs]; handleIDs = [[[NSOrderedSet orderedSetWithArray:handleIDs] array] mutableCopy];
if (result) { if ([handleIDs count] == 0) {
response = [MBIMJSONDataResponse responseWithJSONObject:[result mbim_dictionaryRepresentation]]; response = [[MBIMErrorResponse alloc] initWithErrorCode:500 message:@"No valid handle IDs provided."];
self.serverCompletionBlock(response);
return;
}
}
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); self.serverCompletionBlock(response);