Compare commits
36 Commits
wip/attach
...
1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| a850c9d612 | |||
| a51ff2a7c2 | |||
| 7cf2724a75 | |||
| f0ec6b8cb4 | |||
| f38702bc95 | |||
| a52c2e0909 | |||
| 9765994f14 | |||
| 5fd94489af | |||
| e807528466 | |||
| 7c117eb52e | |||
| 1febd91c2c | |||
| 9a3c808095 | |||
| 6ccef24512 | |||
| 61c1b690ba | |||
| 2f58283e26 | |||
| be2e3ea9d9 | |||
| fc69c387c5 | |||
| 68bb94aa0b | |||
| f4402292a1 | |||
| e650cffde7 | |||
| cbd9dccf1a | |||
| 1a5f13f2b8 | |||
| 87e986707d | |||
| b5ba0b1f7a | |||
| bc51bf03a1 | |||
| 8304b68a64 | |||
| 6261351598 | |||
| 955ff95520 | |||
| 754ad3282d | |||
| f901077067 | |||
| 74d1a7f54b | |||
| 4b497aaabc | |||
| 6caf008a39 | |||
| d20afef370 | |||
| 357be5cdf4 | |||
| 4db28222a6 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
13
README.md
13
README.md
@@ -64,16 +64,3 @@ Below are brief notes. Each subproject’s README has more detail.
|
||||
- Android: open `android/` in Android Studio and build. See `android/README.md` for configuration.
|
||||
- Mock server (Go): `cd mock && go run ./...` or `make`.
|
||||
|
||||
## Security and Entitlements
|
||||
|
||||
The macOS server uses private APIs and restricted entitlements. On production macOS builds, processes with restricted entitlements can be killed by the kernel; development requires workarounds (e.g., swizzling, hooking `imagent`) and careful code signing. See `server/README.md` for instructions and caveats.
|
||||
|
||||
## Status
|
||||
|
||||
- Android client: ships its own API client (not yet using Rust `core`).
|
||||
- GTK + macOS clients: use the Rust `core` library and integrate with the `kordophoned` client daemon for caching/IPC.
|
||||
- Mock server: useful for development; implements common endpoints and WebSocket updates.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and PRs are welcome. If you add a new client or endpoint, please update relevant READMEs and link it from this root README. Prefer small, focused changes and keep style consistent with the existing code.
|
||||
|
||||
18
core/.cargo/config.toml
Normal file
18
core/.cargo/config.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[target.arm-unknown-linux-gnueabihf]
|
||||
# Match Raspberry Pi Zero CPU (ARM1176JZF-S).
|
||||
rustflags = [
|
||||
"-C", "target-cpu=arm1176jzf-s",
|
||||
"-L", "native=/usr/lib/arm-linux-gnueabihf",
|
||||
"-L", "native=/lib/arm-linux-gnueabihf",
|
||||
"-C", "link-arg=-Wl,-rpath-link,/usr/lib/arm-linux-gnueabihf",
|
||||
"-C", "link-arg=-Wl,-rpath-link,/lib/arm-linux-gnueabihf",
|
||||
]
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
rustflags = [
|
||||
"-L", "native=/usr/lib/aarch64-linux-gnu",
|
||||
"-L", "native=/lib/aarch64-linux-gnu",
|
||||
"-C", "link-arg=-Wl,-rpath-link,/usr/lib/aarch64-linux-gnu",
|
||||
"-C", "link-arg=-Wl,-rpath-link,/lib/aarch64-linux-gnu",
|
||||
]
|
||||
426
core/Cargo.lock
generated
426
core/Cargo.lock
generated
@@ -26,6 +26,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
@@ -232,10 +238,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.0.95"
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d32a725bc159af97c3e629873bb9f88fb8cf8a4867175f76dc987815ea07c83b"
|
||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
@@ -289,7 +314,7 @@ dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"strsim 0.8.0",
|
||||
"textwrap",
|
||||
"unicode-width",
|
||||
"unicode-width 0.1.14",
|
||||
"vec_map",
|
||||
]
|
||||
|
||||
@@ -339,6 +364,20 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -374,6 +413,31 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.3",
|
||||
"crossterm_winapi",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
@@ -476,7 +540,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a76dc35ce83e4e9fa089b4fabe66c757b27bd504dc2179c97a01b36d3e874fb0"
|
||||
dependencies = [
|
||||
"clap 2.34.0",
|
||||
"dbus",
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
@@ -714,10 +777,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.0.2"
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984"
|
||||
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
@@ -726,19 +789,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
@@ -898,6 +952,11 @@ name = "hashbrown"
|
||||
version = "0.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -1002,16 +1061,18 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.5.0"
|
||||
name = "hyper-rustls"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
||||
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"hyper",
|
||||
"native-tls",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls 0.24.1",
|
||||
"webpki-roots 0.25.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1053,6 +1114,28 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.16"
|
||||
@@ -1070,6 +1153,15 @@ version = "1.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.11"
|
||||
@@ -1134,9 +1226,9 @@ dependencies = [
|
||||
"env_logger 0.11.8",
|
||||
"futures-util",
|
||||
"hyper",
|
||||
"hyper-tls",
|
||||
"hyper-rustls",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls 0.23.29",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_plain",
|
||||
@@ -1213,6 +1305,19 @@ dependencies = [
|
||||
"xpc-connection-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kordophoned-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"block",
|
||||
"dbus",
|
||||
"dbus-codegen",
|
||||
"log",
|
||||
"xpc-connection",
|
||||
"xpc-connection-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kpcli"
|
||||
version = "0.1.0"
|
||||
@@ -1240,6 +1345,18 @@ dependencies = [
|
||||
"xpc-connection-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kptui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossterm",
|
||||
"kordophoned-client",
|
||||
"ratatui",
|
||||
"time",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.4.0"
|
||||
@@ -1319,6 +1436,15 @@ version = "0.4.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.2"
|
||||
@@ -1379,28 +1505,11 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
|
||||
dependencies = [
|
||||
"hermit-abi 0.3.9",
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework 2.10.0",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "5.1.3"
|
||||
@@ -1505,50 +1614,6 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f"
|
||||
dependencies = [
|
||||
"bitflags 2.9.3",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -1578,6 +1643,12 @@ dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "peeking_take_while"
|
||||
version = "0.1.2"
|
||||
@@ -1638,7 +1709,7 @@ dependencies = [
|
||||
"arrayvec",
|
||||
"termcolor",
|
||||
"typed-arena",
|
||||
"unicode-width",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1652,7 +1723,7 @@ dependencies = [
|
||||
"is-terminal",
|
||||
"lazy_static",
|
||||
"term",
|
||||
"unicode-width",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1738,6 +1809,27 @@ dependencies = [
|
||||
"getrandom 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||
dependencies = [
|
||||
"bitflags 2.9.3",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"crossterm",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools",
|
||||
"lru",
|
||||
"paste",
|
||||
"strum",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
@@ -1838,6 +1930,17 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.21.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-webpki 0.101.7",
|
||||
"sct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.29"
|
||||
@@ -1847,7 +1950,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"rustls-webpki 0.103.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -1861,6 +1964,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.101.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.4"
|
||||
@@ -1884,21 +1997,22 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sct"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.10.0"
|
||||
@@ -2001,6 +2115,27 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.1"
|
||||
@@ -2041,6 +2176,12 @@ version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.8.0"
|
||||
@@ -2053,6 +2194,28 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -2070,18 +2233,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "term"
|
||||
version = "0.7.0"
|
||||
@@ -2108,7 +2259,7 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2221,12 +2372,12 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
name = "tokio-rustls"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -2236,7 +2387,7 @@ version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"rustls 0.23.29",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -2248,10 +2399,10 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls 0.23.29",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.26.2",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
@@ -2347,7 +2498,7 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.1",
|
||||
"rustls",
|
||||
"rustls 0.23.29",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.12",
|
||||
@@ -2379,12 +2530,35 @@ version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
dependencies = [
|
||||
"itertools",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -2528,6 +2702,12 @@ version = "0.2.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d"
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.25.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
|
||||
@@ -3,7 +3,9 @@ members = [
|
||||
"kordophone",
|
||||
"kordophone-db",
|
||||
"kordophoned",
|
||||
"kordophoned-client",
|
||||
"kpcli",
|
||||
"kptui",
|
||||
"utilities",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
37
core/Cross.toml
Normal file
37
core/Cross.toml
Normal 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
25
core/Dockerfile.deb
Normal 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"]
|
||||
@@ -12,5 +12,23 @@ rpm:
|
||||
cargo build --release --workspace
|
||||
strip -s target/release/kordophoned
|
||||
strip -s target/release/kpcli
|
||||
cargo generate-rpm -p kordophoned
|
||||
strip -s target/release/kptui
|
||||
cargo generate-rpm -p kordophoned --auto-req builtin
|
||||
|
||||
.PHONY: deb
|
||||
deb:
|
||||
cargo build --release --workspace
|
||||
strip -s target/release/kordophoned
|
||||
strip -s target/release/kpcli
|
||||
strip -s target/release/kptui
|
||||
cargo deb -p kordophoned --no-build
|
||||
|
||||
.PHONY: pi-zero
|
||||
pi-zero:
|
||||
CARGO_TARGET_DIR=target/cross/arm-unknown-linux-gnueabihf \
|
||||
cross build --release --target arm-unknown-linux-gnueabihf -p kordophoned -p kpcli -p kptui
|
||||
|
||||
.PHONY: pi-aarch64
|
||||
pi-aarch64:
|
||||
CARGO_TARGET_DIR=target/cross/aarch64-unknown-linux-gnu \
|
||||
cross build --release --target aarch64-unknown-linux-gnu -p kordophoned -p kpcli -p kptui
|
||||
|
||||
@@ -9,7 +9,9 @@ Workspace members:
|
||||
- `kordophoned/` — Client daemon providing local caching and IPC
|
||||
- Linux: D‑Bus
|
||||
- macOS: XPC (see notes below)
|
||||
- `kordophoned-client/` — Cross-platform client library for talking to `kordophoned` (D-Bus/XPC).
|
||||
- `kpcli/` — Command‑line interface for interacting with the API, DB, and daemon.
|
||||
- `kptui/` — Terminal UI client (Ratatui) for reading and replying to chats via the daemon.
|
||||
- `utilities/` — Small helper tools (e.g., testing utilities).
|
||||
|
||||
## Build
|
||||
@@ -27,6 +29,42 @@ cargo build -p kordophone
|
||||
cargo build -p kordophoned --release
|
||||
```
|
||||
|
||||
## Raspberry Pi Zero (cross build)
|
||||
|
||||
Recommended approach is `cross` (https://github.com/cross-rs/cross), which uses a containerized toolchain.
|
||||
|
||||
Prereqs:
|
||||
|
||||
- Install Docker or Podman
|
||||
- Install `cross`: `cargo install cross`
|
||||
|
||||
Build ARMv6 (Pi Zero / Zero W):
|
||||
|
||||
```bash
|
||||
cd core
|
||||
make pi-zero
|
||||
```
|
||||
|
||||
Build aarch64 (Pi OS 64-bit / Pi 3+):
|
||||
|
||||
```bash
|
||||
cd core
|
||||
make pi-aarch64
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The aarch64 cross build uses a Debian 11 (bullseye) container base image to keep glibc compatible with bullseye.
|
||||
|
||||
Artifacts:
|
||||
|
||||
- `target/cross/arm-unknown-linux-gnueabihf/arm-unknown-linux-gnueabihf/release/kordophoned`
|
||||
- `target/cross/arm-unknown-linux-gnueabihf/arm-unknown-linux-gnueabihf/release/kpcli`
|
||||
- `target/cross/arm-unknown-linux-gnueabihf/arm-unknown-linux-gnueabihf/release/kptui`
|
||||
- `target/cross/aarch64-unknown-linux-gnu/aarch64-unknown-linux-gnu/release/kordophoned`
|
||||
- `target/cross/aarch64-unknown-linux-gnu/aarch64-unknown-linux-gnu/release/kpcli`
|
||||
- `target/cross/aarch64-unknown-linux-gnu/aarch64-unknown-linux-gnu/release/kptui`
|
||||
|
||||
## `kordophoned` (Client Daemon)
|
||||
|
||||
The daemon maintains a local cache, handles update cycles, and exposes IPC for GUI apps.
|
||||
@@ -53,6 +91,16 @@ strip -s target/release/kordophoned
|
||||
cargo generate-rpm
|
||||
```
|
||||
|
||||
### Packaging (DEB example)
|
||||
|
||||
`kordophoned` is configured for Debian packaging via `cargo-deb`.
|
||||
|
||||
```bash
|
||||
cargo install cargo-deb
|
||||
cd core
|
||||
cargo deb -p kordophoned
|
||||
```
|
||||
|
||||
## `kpcli` (CLI)
|
||||
|
||||
Useful for quick testing and interacting with the daemon/cache.
|
||||
@@ -65,4 +113,3 @@ cargo run -p kpcli -- --help
|
||||
|
||||
- TLS/WebSocket: the `kordophone` crate includes `rustls` and installs a crypto provider at process start.
|
||||
- DB: `kordophone-db` includes Diesel migrations under `kordophone-db/migrations/`.
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Drop the alias mapping table
|
||||
DROP TABLE IF EXISTS `message_aliases`;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Add table to map local (client) IDs to server message GUIDs
|
||||
CREATE TABLE IF NOT EXISTS `message_aliases` (
|
||||
`local_id` TEXT NOT NULL PRIMARY KEY,
|
||||
`server_id` TEXT NOT NULL UNIQUE,
|
||||
`conversation_id` TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -264,16 +264,34 @@ impl<'a> Repository<'a> {
|
||||
.order_by(schema::messages::date.asc())
|
||||
.load::<MessageRecord>(self.connection)?;
|
||||
|
||||
let sender_handles: Vec<String> = message_records
|
||||
.iter()
|
||||
.filter_map(|record| record.sender_participant_handle.clone())
|
||||
.collect();
|
||||
|
||||
let participant_map: HashMap<String, Participant> = if sender_handles.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
participants
|
||||
.filter(handle.eq_any(&sender_handles))
|
||||
.load::<ParticipantRecord>(self.connection)?
|
||||
.into_iter()
|
||||
.map(|participant| {
|
||||
let key = participant.handle.clone();
|
||||
(key, participant.into())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
for message_record in message_records {
|
||||
let mut message: Message = message_record.clone().into();
|
||||
|
||||
// If the message references a sender participant, load the participant info
|
||||
if let Some(sender_handle) = message_record.sender_participant_handle {
|
||||
let participant = participants
|
||||
.find(sender_handle)
|
||||
.first::<ParticipantRecord>(self.connection)?;
|
||||
message.sender = participant.into();
|
||||
if let Some(participant) = participant_map.get(&sender_handle) {
|
||||
message.sender = participant.clone();
|
||||
}
|
||||
}
|
||||
|
||||
result.push(message);
|
||||
@@ -307,8 +325,11 @@ impl<'a> Repository<'a> {
|
||||
}
|
||||
|
||||
pub fn delete_all_messages(&mut self) -> Result<()> {
|
||||
use crate::schema::messages::dsl::*;
|
||||
diesel::delete(messages).execute(self.connection)?;
|
||||
use crate::schema::message_aliases::dsl as aliases_dsl;
|
||||
use crate::schema::messages::dsl as messages_dsl;
|
||||
|
||||
diesel::delete(messages_dsl::messages).execute(self.connection)?;
|
||||
diesel::delete(aliases_dsl::message_aliases).execute(self.connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -359,6 +380,57 @@ impl<'a> Repository<'a> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Create or update an alias mapping between a local (client) message id and a server message id.
|
||||
pub fn set_message_alias(
|
||||
&mut self,
|
||||
local_id_in: &str,
|
||||
server_id_in: &str,
|
||||
conversation_id_in: &str,
|
||||
) -> Result<()> {
|
||||
use crate::schema::message_aliases::dsl::*;
|
||||
diesel::replace_into(message_aliases)
|
||||
.values((
|
||||
local_id.eq(local_id_in),
|
||||
server_id.eq(server_id_in),
|
||||
conversation_id.eq(conversation_id_in),
|
||||
))
|
||||
.execute(self.connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the local id for a given server id, if any.
|
||||
pub fn get_local_id_for(&mut self, server_id_in: &str) -> Result<Option<String>> {
|
||||
use crate::schema::message_aliases::dsl::*;
|
||||
let result = message_aliases
|
||||
.filter(server_id.eq(server_id_in))
|
||||
.select(local_id)
|
||||
.first::<String>(self.connection)
|
||||
.optional()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Batch lookup: returns a map server_id -> local_id for the provided server ids.
|
||||
pub fn get_local_ids_for(
|
||||
&mut self,
|
||||
server_ids_in: Vec<String>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
use crate::schema::message_aliases::dsl::*;
|
||||
if server_ids_in.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let rows: Vec<(String, String)> = message_aliases
|
||||
.filter(server_id.eq_any(&server_ids_in))
|
||||
.select((server_id, local_id))
|
||||
.load::<(String, String)>(self.connection)?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for (sid, lid) in rows {
|
||||
map.insert(sid, lid);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Update the contact_id for an existing participant record.
|
||||
pub fn update_participant_contact(
|
||||
&mut self,
|
||||
|
||||
@@ -44,6 +44,14 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
message_aliases (local_id) {
|
||||
local_id -> Text,
|
||||
server_id -> Text,
|
||||
conversation_id -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
settings (key) {
|
||||
key -> Text,
|
||||
@@ -62,5 +70,6 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
conversation_participants,
|
||||
messages,
|
||||
conversation_messages,
|
||||
message_aliases,
|
||||
settings,
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ ctor = "0.2.8"
|
||||
env_logger = "0.11.5"
|
||||
futures-util = "0.3.31"
|
||||
hyper = { version = "0.14", features = ["full"] }
|
||||
hyper-tls = "0.5.0"
|
||||
hyper-rustls = { version = "0.24", default-features = false, features = ["http1", "webpki-tokio"] }
|
||||
log = { version = "0.4.21", features = [] }
|
||||
serde = { version = "1.0.152", features = ["derive"] }
|
||||
serde_json = "1.0.91"
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::api::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpda
|
||||
use crate::api::AuthenticationStore;
|
||||
use bytes::Bytes;
|
||||
use hyper::{Body, Client, Method, Request, Uri};
|
||||
use hyper_tls::HttpsConnector;
|
||||
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
@@ -397,6 +397,7 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
|
||||
let uri = self
|
||||
.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
|
||||
|
||||
loop {
|
||||
log::debug!("Connecting to websocket: {:?}", uri);
|
||||
|
||||
let auth = self.auth_store.get_token().await;
|
||||
@@ -425,10 +426,11 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
|
||||
|
||||
log::debug!("Websocket request: {:?}", request);
|
||||
|
||||
let mut should_retry = true; // retry once after authenticating.
|
||||
match connect_async(request).await.map_err(Error::from) {
|
||||
Ok((socket, response)) => {
|
||||
log::debug!("Websocket connected: {:?}", response.status());
|
||||
Ok(WebsocketEventSocket::new(socket))
|
||||
break Ok(WebsocketEventSocket::new(socket))
|
||||
}
|
||||
Err(e) => match &e {
|
||||
Error::ClientError(ce) => match ce.as_str() {
|
||||
@@ -439,27 +441,36 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
|
||||
let new_token = self.authenticate(credentials.clone()).await?;
|
||||
self.auth_store.set_token(new_token.to_string()).await;
|
||||
|
||||
if should_retry {
|
||||
// try again on the next attempt.
|
||||
return Err(Error::Unauthorized);
|
||||
continue;
|
||||
} else {
|
||||
break Err(e);
|
||||
}
|
||||
} else {
|
||||
log::error!("Websocket unauthorized, no credentials provided");
|
||||
return Err(Error::ClientError(
|
||||
break Err(Error::ClientError(
|
||||
"Unauthorized, no credentials provided".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => Err(e),
|
||||
_ => break Err(e),
|
||||
},
|
||||
|
||||
_ => Err(e),
|
||||
_ => break Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
|
||||
pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient<K> {
|
||||
let https = HttpsConnector::new();
|
||||
let https = HttpsConnectorBuilder::new()
|
||||
.with_webpki_roots()
|
||||
.https_or_http()
|
||||
.enable_http1()
|
||||
.build();
|
||||
let client = Client::builder().build::<_, Body>(https);
|
||||
|
||||
HTTPAPIClient { base_url, auth_store, client }
|
||||
|
||||
22
core/kordophoned-client/Cargo.toml
Normal file
22
core/kordophoned-client/Cargo.toml
Normal 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" }
|
||||
26
core/kordophoned-client/build.rs
Normal file
26
core/kordophoned-client/build.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
const KORDOPHONE_XML: &str = "../kordophoned/include/net.buzzert.kordophonecd.Server.xml";
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {
|
||||
// No D-Bus codegen on non-Linux platforms.
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() {
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let out_path = std::path::Path::new(&out_dir).join("kordophone-client.rs");
|
||||
|
||||
let opts = dbus_codegen::GenOpts {
|
||||
connectiontype: dbus_codegen::ConnectionType::Blocking,
|
||||
methodtype: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let xml = std::fs::read_to_string(KORDOPHONE_XML).expect("Error reading server dbus interface");
|
||||
let output =
|
||||
dbus_codegen::generate(&xml, &opts).expect("Error generating client dbus interface");
|
||||
std::fs::write(out_path, output).expect("Error writing client dbus code");
|
||||
|
||||
println!("cargo:rerun-if-changed={}", KORDOPHONE_XML);
|
||||
}
|
||||
|
||||
5
core/kordophoned-client/src/lib.rs
Normal file
5
core/kordophoned-client/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod platform;
|
||||
mod worker;
|
||||
|
||||
pub use worker::{spawn_worker, ChatMessage, ConversationSummary, Event, Request};
|
||||
|
||||
189
core/kordophoned-client/src/platform/linux.rs
Normal file
189
core/kordophoned-client/src/platform/linux.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use crate::worker::{ChatMessage, ConversationSummary, DaemonClient, Event};
|
||||
use anyhow::Result;
|
||||
use dbus::arg::{PropMap, RefArg};
|
||||
use dbus::blocking::{Connection, Proxy};
|
||||
use dbus::channel::Token;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::time::Duration;
|
||||
|
||||
const DBUS_NAME: &str = "net.buzzert.kordophonecd";
|
||||
const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon";
|
||||
|
||||
#[allow(unused)]
|
||||
mod dbus_interface {
|
||||
#![allow(unused)]
|
||||
include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs"));
|
||||
}
|
||||
use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository;
|
||||
|
||||
pub(crate) struct DBusClient {
|
||||
conn: Connection,
|
||||
signal_tokens: Vec<Token>,
|
||||
}
|
||||
|
||||
impl DBusClient {
|
||||
pub(crate) fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
conn: Connection::new_session()?,
|
||||
signal_tokens: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn proxy(&self) -> Proxy<&Connection> {
|
||||
self.conn
|
||||
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_string(map: &PropMap, key: &str) -> String {
|
||||
map.get(key)
|
||||
.and_then(|v| v.0.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn get_i64(map: &PropMap, key: &str) -> i64 {
|
||||
map.get(key).and_then(|v| v.0.as_i64()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn get_u32(map: &PropMap, key: &str) -> u32 {
|
||||
get_i64(map, key).try_into().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
|
||||
map.get(key)
|
||||
.and_then(|v| v.0.as_iter())
|
||||
.map(|iter| {
|
||||
iter.filter_map(|item| item.as_str().map(|s| s.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl DaemonClient for DBusClient {
|
||||
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
|
||||
let mut items = KordophoneRepository::get_conversations(&self.proxy(), limit, offset)?;
|
||||
let mut conversations = items
|
||||
.drain(..)
|
||||
.map(|conv| {
|
||||
let id = get_string(&conv, "guid");
|
||||
let display_name = get_string(&conv, "display_name");
|
||||
let participants = get_vec_string(&conv, "participants");
|
||||
let title = if !display_name.trim().is_empty() {
|
||||
display_name
|
||||
} else if participants.is_empty() {
|
||||
"<unknown>".to_string()
|
||||
} else {
|
||||
participants.join(", ")
|
||||
};
|
||||
|
||||
ConversationSummary {
|
||||
id,
|
||||
title,
|
||||
preview: get_string(&conv, "last_message_preview").replace('\n', " "),
|
||||
unread_count: get_u32(&conv, "unread_count"),
|
||||
date_unix: get_i64(&conv, "date"),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
conversations.sort_by_key(|c| std::cmp::Reverse(c.date_unix));
|
||||
Ok(conversations)
|
||||
}
|
||||
|
||||
fn get_messages(
|
||||
&mut self,
|
||||
conversation_id: String,
|
||||
last_message_id: Option<String>,
|
||||
) -> Result<Vec<ChatMessage>> {
|
||||
let messages = KordophoneRepository::get_messages(
|
||||
&self.proxy(),
|
||||
&conversation_id,
|
||||
&last_message_id.unwrap_or_default(),
|
||||
)?;
|
||||
|
||||
Ok(messages
|
||||
.into_iter()
|
||||
.map(|msg| ChatMessage {
|
||||
sender: get_string(&msg, "sender"),
|
||||
text: get_string(&msg, "text"),
|
||||
date_unix: get_i64(&msg, "date"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
|
||||
let attachment_guids: Vec<&str> = vec![];
|
||||
let outgoing_id = KordophoneRepository::send_message(
|
||||
&self.proxy(),
|
||||
&conversation_id,
|
||||
&text,
|
||||
attachment_guids,
|
||||
)?;
|
||||
Ok(Some(outgoing_id))
|
||||
}
|
||||
|
||||
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
|
||||
KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {e}"))
|
||||
}
|
||||
|
||||
fn sync_conversation(&mut self, conversation_id: String) -> Result<()> {
|
||||
KordophoneRepository::sync_conversation(&self.proxy(), &conversation_id)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to sync conversation: {e}"))
|
||||
}
|
||||
|
||||
fn install_signal_handlers(&mut self, event_tx: Sender<Event>) -> Result<()> {
|
||||
let conversations_tx = event_tx.clone();
|
||||
let t1 = self
|
||||
.proxy()
|
||||
.match_signal(
|
||||
move |_: dbus_interface::NetBuzzertKordophoneRepositoryConversationsUpdated,
|
||||
_: &Connection,
|
||||
_: &dbus::message::Message| {
|
||||
let _ = conversations_tx.send(Event::ConversationsUpdated);
|
||||
true
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to match ConversationsUpdated: {e}"))?;
|
||||
|
||||
let messages_tx = event_tx.clone();
|
||||
let t2 = self
|
||||
.proxy()
|
||||
.match_signal(
|
||||
move |s: dbus_interface::NetBuzzertKordophoneRepositoryMessagesUpdated,
|
||||
_: &Connection,
|
||||
_: &dbus::message::Message| {
|
||||
let _ = messages_tx.send(Event::MessagesUpdated {
|
||||
conversation_id: s.conversation_id,
|
||||
});
|
||||
true
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to match MessagesUpdated: {e}"))?;
|
||||
|
||||
let reconnected_tx = event_tx;
|
||||
let t3 = self
|
||||
.proxy()
|
||||
.match_signal(
|
||||
move |_: dbus_interface::NetBuzzertKordophoneRepositoryUpdateStreamReconnected,
|
||||
_: &Connection,
|
||||
_: &dbus::message::Message| {
|
||||
let _ = reconnected_tx.send(Event::UpdateStreamReconnected);
|
||||
true
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to match UpdateStreamReconnected: {e}"))?;
|
||||
|
||||
self.signal_tokens.extend([t1, t2, t3]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll(&mut self, timeout: Duration) -> Result<()> {
|
||||
self.conn.process(timeout)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
233
core/kordophoned-client/src/platform/macos.rs
Normal file
233
core/kordophoned-client/src/platform/macos.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use crate::worker::{ChatMessage, ConversationSummary, DaemonClient};
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{CStr, CString};
|
||||
|
||||
use xpc_connection::Message;
|
||||
|
||||
const SERVICE_NAME: &str = "net.buzzert.kordophonecd\0";
|
||||
|
||||
struct XpcTransport {
|
||||
connection: xpc_connection_sys::xpc_connection_t,
|
||||
}
|
||||
|
||||
impl XpcTransport {
|
||||
fn connect(name: impl AsRef<CStr>) -> Self {
|
||||
use xpc_connection_sys::xpc_connection_create_mach_service;
|
||||
use xpc_connection_sys::xpc_connection_resume;
|
||||
|
||||
let name = name.as_ref();
|
||||
let connection =
|
||||
unsafe { xpc_connection_create_mach_service(name.as_ptr(), std::ptr::null_mut(), 0) };
|
||||
|
||||
unsafe { xpc_connection_resume(connection) };
|
||||
|
||||
Self { connection }
|
||||
}
|
||||
|
||||
fn send_with_reply(&self, message: Message) -> Message {
|
||||
use xpc_connection::message_to_xpc_object;
|
||||
use xpc_connection::xpc_object_to_message;
|
||||
use xpc_connection_sys::{xpc_connection_send_message_with_reply_sync, xpc_release};
|
||||
|
||||
unsafe {
|
||||
let xobj = message_to_xpc_object(message);
|
||||
let reply = xpc_connection_send_message_with_reply_sync(self.connection, xobj);
|
||||
xpc_release(xobj);
|
||||
let msg = xpc_object_to_message(reply);
|
||||
if !reply.is_null() {
|
||||
xpc_release(reply);
|
||||
}
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for XpcTransport {
|
||||
fn drop(&mut self) {
|
||||
use xpc_connection_sys::xpc_object_t;
|
||||
use xpc_connection_sys::xpc_release;
|
||||
unsafe { xpc_release(self.connection as xpc_object_t) };
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct XpcClient {
|
||||
transport: XpcTransport,
|
||||
}
|
||||
|
||||
impl XpcClient {
|
||||
pub(crate) fn new() -> Result<Self> {
|
||||
let mach_port_name = CString::new(SERVICE_NAME).unwrap();
|
||||
Ok(Self {
|
||||
transport: XpcTransport::connect(&mach_port_name),
|
||||
})
|
||||
}
|
||||
|
||||
fn key(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
fn request(name: &str, arguments: Option<HashMap<CString, Message>>) -> Message {
|
||||
let mut root: HashMap<CString, Message> = HashMap::new();
|
||||
root.insert(Self::key("name"), Message::String(Self::key(name)));
|
||||
if let Some(args) = arguments {
|
||||
root.insert(Self::key("arguments"), Message::Dictionary(args));
|
||||
}
|
||||
Message::Dictionary(root)
|
||||
}
|
||||
|
||||
fn get_string(map: &HashMap<CString, Message>, key: &str) -> Option<String> {
|
||||
match map.get(&Self::key(key)) {
|
||||
Some(Message::String(s)) => Some(s.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_i64_from_str(map: &HashMap<CString, Message>, key: &str) -> i64 {
|
||||
Self::get_string(map, key)
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl DaemonClient for XpcClient {
|
||||
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(Self::key("limit"), Message::String(Self::key(&limit.to_string())));
|
||||
args.insert(Self::key("offset"), Message::String(Self::key(&offset.to_string())));
|
||||
|
||||
let reply = self
|
||||
.transport
|
||||
.send_with_reply(Self::request("GetConversations", Some(args)));
|
||||
|
||||
let Message::Dictionary(map) = reply else {
|
||||
anyhow::bail!("Unexpected conversations response");
|
||||
};
|
||||
|
||||
let Some(Message::Array(items)) = map.get(&Self::key("conversations")) else {
|
||||
anyhow::bail!("Missing conversations in response");
|
||||
};
|
||||
|
||||
let mut conversations = Vec::new();
|
||||
for item in items {
|
||||
let Message::Dictionary(conv) = item else { continue };
|
||||
let id = Self::get_string(conv, "guid").unwrap_or_default();
|
||||
let display_name = Self::get_string(conv, "display_name").unwrap_or_default();
|
||||
let preview = Self::get_string(conv, "last_message_preview").unwrap_or_default();
|
||||
let unread_count = Self::get_i64_from_str(conv, "unread_count") as u32;
|
||||
let date_unix = Self::get_i64_from_str(conv, "date");
|
||||
|
||||
let participants = match conv.get(&Self::key("participants")) {
|
||||
Some(Message::Array(arr)) => arr
|
||||
.iter()
|
||||
.filter_map(|m| match m {
|
||||
Message::String(s) => Some(s.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
let title = if !display_name.trim().is_empty() {
|
||||
display_name
|
||||
} else if participants.is_empty() {
|
||||
"<unknown>".to_string()
|
||||
} else {
|
||||
participants.join(", ")
|
||||
};
|
||||
|
||||
conversations.push(ConversationSummary {
|
||||
id,
|
||||
title,
|
||||
preview: preview.replace('\n', " "),
|
||||
unread_count,
|
||||
date_unix,
|
||||
});
|
||||
}
|
||||
|
||||
conversations.sort_by_key(|c| std::cmp::Reverse(c.date_unix));
|
||||
Ok(conversations)
|
||||
}
|
||||
|
||||
fn get_messages(
|
||||
&mut self,
|
||||
conversation_id: String,
|
||||
last_message_id: Option<String>,
|
||||
) -> Result<Vec<ChatMessage>> {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("conversation_id"),
|
||||
Message::String(Self::key(&conversation_id)),
|
||||
);
|
||||
if let Some(last) = last_message_id {
|
||||
args.insert(Self::key("last_message_id"), Message::String(Self::key(&last)));
|
||||
}
|
||||
|
||||
let reply = self
|
||||
.transport
|
||||
.send_with_reply(Self::request("GetMessages", Some(args)));
|
||||
let Message::Dictionary(map) = reply else {
|
||||
anyhow::bail!("Unexpected messages response");
|
||||
};
|
||||
|
||||
let Some(Message::Array(items)) = map.get(&Self::key("messages")) else {
|
||||
anyhow::bail!("Missing messages in response");
|
||||
};
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for item in items {
|
||||
let Message::Dictionary(msg) = item else { continue };
|
||||
messages.push(ChatMessage {
|
||||
sender: Self::get_string(msg, "sender").unwrap_or_default(),
|
||||
text: Self::get_string(msg, "text").unwrap_or_default(),
|
||||
date_unix: Self::get_i64_from_str(msg, "date"),
|
||||
});
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("conversation_id"),
|
||||
Message::String(Self::key(&conversation_id)),
|
||||
);
|
||||
args.insert(Self::key("text"), Message::String(Self::key(&text)));
|
||||
|
||||
let reply = self
|
||||
.transport
|
||||
.send_with_reply(Self::request("SendMessage", Some(args)));
|
||||
let Message::Dictionary(map) = reply else {
|
||||
anyhow::bail!("Unexpected send response");
|
||||
};
|
||||
|
||||
Ok(Self::get_string(&map, "uuid"))
|
||||
}
|
||||
|
||||
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("conversation_id"),
|
||||
Message::String(Self::key(&conversation_id)),
|
||||
);
|
||||
let _ = self
|
||||
.transport
|
||||
.send_with_reply(Self::request("MarkConversationAsRead", Some(args)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_conversation(&mut self, conversation_id: String) -> Result<()> {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
Self::key("conversation_id"),
|
||||
Message::String(Self::key(&conversation_id)),
|
||||
);
|
||||
let _ = self
|
||||
.transport
|
||||
.send_with_reply(Self::request("SyncConversation", Some(args)));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
24
core/kordophoned-client/src/platform/mod.rs
Normal file
24
core/kordophoned-client/src/platform/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::worker::DaemonClient;
|
||||
use anyhow::Result;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
pub(crate) fn new_daemon_client() -> Result<Box<dyn DaemonClient>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Ok(Box::new(linux::DBusClient::new()?))
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Ok(Box::new(macos::XpcClient::new()?))
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
anyhow::bail!("Unsupported platform")
|
||||
}
|
||||
}
|
||||
|
||||
133
core/kordophoned-client/src/worker.rs
Normal file
133
core/kordophoned-client/src/worker.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use crate::platform;
|
||||
use anyhow::Result;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConversationSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub preview: String,
|
||||
pub unread_count: u32,
|
||||
pub date_unix: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChatMessage {
|
||||
pub sender: String,
|
||||
pub text: String,
|
||||
pub date_unix: i64,
|
||||
}
|
||||
|
||||
pub enum Request {
|
||||
RefreshConversations,
|
||||
RefreshMessages { conversation_id: String },
|
||||
SendMessage { conversation_id: String, text: String },
|
||||
MarkRead { conversation_id: String },
|
||||
SyncConversation { conversation_id: String },
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Conversations(Vec<ConversationSummary>),
|
||||
Messages {
|
||||
conversation_id: String,
|
||||
messages: Vec<ChatMessage>,
|
||||
},
|
||||
MessageSent {
|
||||
conversation_id: String,
|
||||
outgoing_id: Option<String>,
|
||||
},
|
||||
MarkedRead,
|
||||
ConversationSyncTriggered { conversation_id: String },
|
||||
ConversationsUpdated,
|
||||
MessagesUpdated { conversation_id: String },
|
||||
UpdateStreamReconnected,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub fn spawn_worker(
|
||||
request_rx: std::sync::mpsc::Receiver<Request>,
|
||||
event_tx: std::sync::mpsc::Sender<Event>,
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
std::thread::spawn(move || {
|
||||
let mut client = match platform::new_daemon_client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(Event::Error(format!("Failed to connect to daemon: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = client.install_signal_handlers(event_tx.clone()) {
|
||||
let _ = event_tx.send(Event::Error(format!("Failed to install daemon signals: {e}")));
|
||||
}
|
||||
|
||||
loop {
|
||||
match request_rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(req) => {
|
||||
let res = match req {
|
||||
Request::RefreshConversations => client
|
||||
.get_conversations(200, 0)
|
||||
.map(Event::Conversations),
|
||||
Request::RefreshMessages { conversation_id } => client
|
||||
.get_messages(conversation_id.clone(), None)
|
||||
.map(|messages| Event::Messages {
|
||||
conversation_id,
|
||||
messages,
|
||||
}),
|
||||
Request::SendMessage {
|
||||
conversation_id,
|
||||
text,
|
||||
} => client
|
||||
.send_message(conversation_id.clone(), text)
|
||||
.map(|outgoing_id| Event::MessageSent {
|
||||
conversation_id,
|
||||
outgoing_id,
|
||||
}),
|
||||
Request::MarkRead { conversation_id } => client
|
||||
.mark_conversation_as_read(conversation_id.clone())
|
||||
.map(|_| Event::MarkedRead),
|
||||
Request::SyncConversation { conversation_id } => client
|
||||
.sync_conversation(conversation_id.clone())
|
||||
.map(|_| Event::ConversationSyncTriggered { conversation_id }),
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(evt) => {
|
||||
let _ = event_tx.send(evt);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(Event::Error(format!("{e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
|
||||
if let Err(e) = client.poll(Duration::from_millis(0)) {
|
||||
let _ = event_tx.send(Event::Error(format!("Daemon polling error: {e}")));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) trait DaemonClient {
|
||||
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>>;
|
||||
fn get_messages(
|
||||
&mut self,
|
||||
conversation_id: String,
|
||||
last_message_id: Option<String>,
|
||||
) -> Result<Vec<ChatMessage>>;
|
||||
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>>;
|
||||
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
|
||||
fn sync_conversation(&mut self, conversation_id: String) -> Result<()>;
|
||||
fn install_signal_handlers(&mut self, _event_tx: mpsc::Sender<Event>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn poll(&mut self, timeout: Duration) -> Result<()> {
|
||||
std::thread::sleep(timeout);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,7 @@ dbus-tree = "0.9.2"
|
||||
|
||||
# D-Bus codegen only on Linux
|
||||
[target.'cfg(target_os = "linux")'.build-dependencies]
|
||||
dbus-codegen = "0.10.0"
|
||||
dbus-crossroads = "0.5.1"
|
||||
dbus-codegen = { version = "0.10.0", default-features = false }
|
||||
|
||||
# XPC (libxpc) interface for macOS IPC
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
@@ -48,5 +47,18 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
assets = [
|
||||
{ source = "../target/release/kordophoned", dest = "/usr/libexec/kordophoned", mode = "755" },
|
||||
{ source = "../target/release/kpcli", dest = "/usr/bin/kpcli", mode = "755" },
|
||||
{ source = "../target/release/kptui", dest = "/usr/bin/kptui", mode = "755" },
|
||||
{ source = "include/net.buzzert.kordophonecd.service", dest = "/usr/share/dbus-1/services/net.buzzert.kordophonecd.service", mode = "644" },
|
||||
]
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "James Magahern <james@magahern.com>"
|
||||
copyright = "2026, James Magahern <james@magahern.com>"
|
||||
section = "net"
|
||||
priority = "optional"
|
||||
assets = [
|
||||
["../target/release/kordophoned", "usr/libexec/kordophoned", "755"],
|
||||
["../target/release/kpcli", "usr/bin/kpcli", "755"],
|
||||
["../target/release/kptui", "usr/bin/kptui", "755"],
|
||||
["include/net.buzzert.kordophonecd.service", "usr/share/dbus-1/services/net.buzzert.kordophonecd.service", "644"],
|
||||
]
|
||||
|
||||
@@ -14,6 +14,17 @@ strip -s target/release/kordophoned
|
||||
cargo generate-rpm
|
||||
```
|
||||
|
||||
# Building DEB
|
||||
|
||||
Make sure cargo-deb is installed, `cargo install cargo-deb`.
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
cd core
|
||||
cargo deb -p kordophoned
|
||||
```
|
||||
|
||||
## Running on macOS
|
||||
|
||||
Before any client can talk to the kordophone daemon on macOS, the XPC service needs to be manually registered with launchd.
|
||||
@@ -34,4 +45,3 @@ and the following in Info.plist:
|
||||
<key>MachServices</key><dict><key>net.buzzert.kordophonecd</key><true/></dict>
|
||||
<key>KeepAlive</key><true/>
|
||||
```
|
||||
|
||||
|
||||
@@ -27,3 +27,4 @@ fn main() {
|
||||
|
||||
println!("cargo:rerun-if-changed={}", KORDOPHONE_XML);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,14 +73,13 @@
|
||||
'sender' (string): Sender display name
|
||||
'attachments' (array of dictionaries): List of attachments
|
||||
'guid' (string): Attachment GUID
|
||||
'path' (string): Attachment path
|
||||
'preview_path' (string): Preview attachment path
|
||||
'downloaded' (boolean): Whether the attachment is downloaded
|
||||
'preview_downloaded' (boolean): Whether the preview is downloaded
|
||||
'metadata' (dictionary, optional): Attachment metadata
|
||||
'attribution_info' (dictionary, optional): Attribution info
|
||||
'width' (int32, optional): Width
|
||||
'height' (int32, optional): Height"/>
|
||||
'height' (int32, optional): Height
|
||||
Use GetAttachmentInfo for full/preview paths."/>
|
||||
</arg>
|
||||
</method>
|
||||
|
||||
@@ -129,6 +128,20 @@
|
||||
"/>
|
||||
</method>
|
||||
|
||||
<method name="OpenAttachmentFd">
|
||||
<arg type="s" name="attachment_id" direction="in"/>
|
||||
<arg type="b" name="preview" direction="in"/>
|
||||
<arg type="h" name="fd" direction="out"/>
|
||||
|
||||
<annotation name="org.freedesktop.DBus.DocString"
|
||||
value="Opens a read-only file descriptor for an attachment path.
|
||||
Arguments:
|
||||
attachment_id: the attachment GUID
|
||||
preview: whether to open the preview path (true) or full path (false)
|
||||
Returns:
|
||||
fd: a Unix file descriptor to read attachment bytes"/>
|
||||
</method>
|
||||
|
||||
<method name="DownloadAttachment">
|
||||
<arg type="s" name="attachment_id" direction="in"/>
|
||||
<arg type="b" name="preview" direction="in"/>
|
||||
|
||||
@@ -115,39 +115,57 @@ impl AttachmentStore {
|
||||
base_path: base_path,
|
||||
metadata: None,
|
||||
mime_type: None,
|
||||
cached_full_path: None,
|
||||
cached_preview_path: None,
|
||||
};
|
||||
|
||||
// Best-effort: if a file already exists, try to infer MIME type from extension
|
||||
let kind = "full";
|
||||
// Best-effort: if files already exist, cache their exact paths and infer MIME type.
|
||||
let stem = attachment
|
||||
.base_path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let legacy = attachment.base_path.with_extension(kind);
|
||||
let existing_path = if legacy.exists() {
|
||||
Some(legacy)
|
||||
} else {
|
||||
let prefix = format!("{}.{}.", stem, kind);
|
||||
|
||||
let legacy_full = attachment.base_path.with_extension("full");
|
||||
if legacy_full.exists() {
|
||||
attachment.cached_full_path = Some(legacy_full);
|
||||
}
|
||||
|
||||
let legacy_preview = attachment.base_path.with_extension("preview");
|
||||
if legacy_preview.exists() {
|
||||
attachment.cached_preview_path = Some(legacy_preview);
|
||||
}
|
||||
|
||||
if attachment.cached_full_path.is_none() || attachment.cached_preview_path.is_none() {
|
||||
let full_prefix = format!("{}.full.", stem);
|
||||
let preview_prefix = format!("{}.preview.", stem);
|
||||
let parent = attachment
|
||||
.base_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."));
|
||||
let mut found: Option<PathBuf> = None;
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(parent) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with(&prefix) && !name.ends_with(".download") {
|
||||
found = Some(entry.path());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
};
|
||||
|
||||
if let Some(existing) = existing_path {
|
||||
if let Some(m) = mime_guess::from_path(&existing).first_raw() {
|
||||
if !name.ends_with(".download") {
|
||||
if attachment.cached_full_path.is_none() && name.starts_with(&full_prefix) {
|
||||
attachment.cached_full_path = Some(entry.path());
|
||||
continue;
|
||||
}
|
||||
|
||||
if attachment.cached_preview_path.is_none()
|
||||
&& name.starts_with(&preview_prefix)
|
||||
{
|
||||
attachment.cached_preview_path = Some(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(existing_full) = &attachment.cached_full_path {
|
||||
if let Some(m) = mime_guess::from_path(existing_full).first_raw() {
|
||||
attachment.mime_type = Some(m.to_string());
|
||||
}
|
||||
}
|
||||
@@ -342,6 +360,9 @@ impl AttachmentStore {
|
||||
match kind {
|
||||
AttachmentStoreError::AttachmentAlreadyDownloaded => {
|
||||
log::debug!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", &_guid);
|
||||
let _ = daemon_event_sink
|
||||
.send(DaemonEvent::AttachmentDownloaded(_guid.clone()))
|
||||
.await;
|
||||
}
|
||||
AttachmentStoreError::DownloadAlreadyInProgress => {
|
||||
// Already logged a warning where detected
|
||||
@@ -360,6 +381,10 @@ impl AttachmentStore {
|
||||
log::debug!(target: target::ATTACHMENTS, "Queued download for attachment: {}", &guid);
|
||||
} else {
|
||||
log::debug!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", guid);
|
||||
let _ = self
|
||||
.daemon_event_sink
|
||||
.send(DaemonEvent::AttachmentDownloaded(guid))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EDSContactResolverBackend;
|
||||
|
||||
@@ -189,11 +191,10 @@ impl ContactResolverBackend for EDSContactResolverBackend {
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let address_book_proxy = handle.connection.with_proxy(
|
||||
&handle.bus_name,
|
||||
&handle.object_path,
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
let address_book_proxy =
|
||||
handle
|
||||
.connection
|
||||
.with_proxy(&handle.bus_name, &handle.object_path, LOOKUP_TIMEOUT);
|
||||
|
||||
ensure_address_book_open(&address_book_proxy);
|
||||
|
||||
@@ -255,11 +256,10 @@ impl ContactResolverBackend for EDSContactResolverBackend {
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let address_book_proxy = handle.connection.with_proxy(
|
||||
&handle.bus_name,
|
||||
&handle.object_path,
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
let address_book_proxy =
|
||||
handle
|
||||
.connection
|
||||
.with_proxy(&handle.bus_name, &handle.object_path, LOOKUP_TIMEOUT);
|
||||
|
||||
ensure_address_book_open(&address_book_proxy);
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ pub type AnyContactID = String;
|
||||
#[derive(Clone)]
|
||||
pub struct ContactResolver<T: ContactResolverBackend> {
|
||||
backend: T,
|
||||
display_name_cache: HashMap<AnyContactID, String>,
|
||||
contact_id_cache: HashMap<String, AnyContactID>,
|
||||
display_name_cache: HashMap<AnyContactID, Option<String>>,
|
||||
contact_id_cache: HashMap<String, Option<AnyContactID>>,
|
||||
}
|
||||
|
||||
impl<T: ContactResolverBackend> ContactResolver<T>
|
||||
@@ -67,29 +67,25 @@ where
|
||||
|
||||
pub fn resolve_contact_id(&mut self, address: &str) -> Option<AnyContactID> {
|
||||
if let Some(id) = self.contact_id_cache.get(address) {
|
||||
return Some(id.clone());
|
||||
return id.clone();
|
||||
}
|
||||
|
||||
let id = self.backend.resolve_contact_id(address).map(|id| id.into());
|
||||
if let Some(ref id) = id {
|
||||
self.contact_id_cache
|
||||
.insert(address.to_string(), id.clone());
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
pub fn get_contact_display_name(&mut self, contact_id: &AnyContactID) -> Option<String> {
|
||||
if let Some(display_name) = self.display_name_cache.get(contact_id) {
|
||||
return Some(display_name.clone());
|
||||
return display_name.clone();
|
||||
}
|
||||
|
||||
let backend_contact_id: T::ContactID = T::ContactID::from((*contact_id).clone());
|
||||
let display_name = self.backend.get_contact_display_name(&backend_contact_id);
|
||||
if let Some(ref display_name) = display_name {
|
||||
self.display_name_cache
|
||||
.insert(contact_id.to_string(), display_name.clone());
|
||||
}
|
||||
|
||||
display_name
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
@@ -72,6 +73,8 @@ pub mod target {
|
||||
pub static DAEMON: &str = "daemon";
|
||||
}
|
||||
|
||||
const GET_MESSAGES_INITIAL_WINDOW: usize = 300;
|
||||
|
||||
pub struct Daemon {
|
||||
pub event_sender: Sender<Event>,
|
||||
event_receiver: Receiver<Event>,
|
||||
@@ -185,14 +188,14 @@ impl Daemon {
|
||||
async fn handle_event(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::GetVersion(reply) => {
|
||||
reply.send(self.version.clone()).unwrap();
|
||||
let _ = reply.send(self.version.clone());
|
||||
}
|
||||
|
||||
Event::SyncConversationList(reply) => {
|
||||
self.spawn_conversation_list_sync();
|
||||
|
||||
// This is a background operation, so return right away.
|
||||
reply.send(()).unwrap();
|
||||
let _ = reply.send(());
|
||||
}
|
||||
|
||||
Event::SyncAllConversations(reply) => {
|
||||
@@ -207,7 +210,7 @@ impl Daemon {
|
||||
});
|
||||
|
||||
// This is a background operation, so return right away.
|
||||
reply.send(()).unwrap();
|
||||
let _ = reply.send(());
|
||||
}
|
||||
|
||||
Event::SyncConversation(conversation_id, reply) => {
|
||||
@@ -225,7 +228,7 @@ impl Daemon {
|
||||
}
|
||||
});
|
||||
|
||||
reply.send(()).unwrap();
|
||||
let _ = reply.send(());
|
||||
}
|
||||
|
||||
Event::MarkConversationAsRead(conversation_id, reply) => {
|
||||
@@ -237,7 +240,7 @@ impl Daemon {
|
||||
}
|
||||
});
|
||||
|
||||
reply.send(()).unwrap();
|
||||
let _ = reply.send(());
|
||||
}
|
||||
|
||||
Event::UpdateConversationMetadata(conversation, reply) => {
|
||||
@@ -250,7 +253,7 @@ impl Daemon {
|
||||
}
|
||||
});
|
||||
|
||||
reply.send(()).unwrap();
|
||||
let _ = reply.send(());
|
||||
}
|
||||
|
||||
Event::UpdateStreamReconnected => {
|
||||
@@ -268,7 +271,7 @@ impl Daemon {
|
||||
|
||||
Event::GetAllConversations(limit, offset, reply) => {
|
||||
let conversations = self.get_conversations_limit_offset(limit, offset).await;
|
||||
reply.send(conversations).unwrap();
|
||||
let _ = reply.send(conversations);
|
||||
}
|
||||
|
||||
Event::GetAllSettings(reply) => {
|
||||
@@ -277,7 +280,7 @@ impl Daemon {
|
||||
Settings::default()
|
||||
});
|
||||
|
||||
reply.send(settings).unwrap();
|
||||
let _ = reply.send(settings);
|
||||
}
|
||||
|
||||
Event::UpdateSettings(settings, reply) => {
|
||||
@@ -309,12 +312,14 @@ impl Daemon {
|
||||
}
|
||||
}
|
||||
|
||||
reply.send(()).unwrap();
|
||||
let _ = reply.send(());
|
||||
}
|
||||
|
||||
Event::GetMessages(conversation_id, last_message_id, reply) => {
|
||||
let messages = self.get_messages(conversation_id, last_message_id).await;
|
||||
reply.send(messages).unwrap();
|
||||
if reply.send(messages).is_err() {
|
||||
log::warn!(target: target::EVENT, "GetMessages reply receiver dropped before send");
|
||||
}
|
||||
}
|
||||
|
||||
Event::DeleteAllConversations(reply) => {
|
||||
@@ -322,7 +327,7 @@ impl Daemon {
|
||||
log::error!(target: target::SYNC, "Failed to delete all conversations: {}", e);
|
||||
});
|
||||
|
||||
reply.send(()).unwrap();
|
||||
let _ = reply.send(());
|
||||
}
|
||||
|
||||
Event::SendMessage(conversation_id, text, attachment_guids, reply) => {
|
||||
@@ -330,7 +335,7 @@ impl Daemon {
|
||||
let uuid = self
|
||||
.enqueue_outgoing_message(text, conversation_id.clone(), attachment_guids)
|
||||
.await;
|
||||
reply.send(uuid).unwrap();
|
||||
let _ = reply.send(uuid);
|
||||
|
||||
// Send message updated signal, we have a placeholder message we will return.
|
||||
self.signal_sender
|
||||
@@ -347,7 +352,16 @@ impl Daemon {
|
||||
self.database
|
||||
.lock()
|
||||
.await
|
||||
.with_repository(|r| r.insert_message(&conversation_id, message.into()))
|
||||
.with_repository(|r| {
|
||||
// 1) Insert the server message
|
||||
r.insert_message(&conversation_id, message.clone().into())?;
|
||||
// 2) Persist alias local -> server for stable UI ids
|
||||
r.set_message_alias(
|
||||
&outgoing_message.guid.to_string(),
|
||||
&message.id,
|
||||
&conversation_id,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -386,7 +400,7 @@ impl Daemon {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
reply.send(()).unwrap();
|
||||
let _ = reply.send(());
|
||||
}
|
||||
|
||||
Event::AttachmentDownloaded(attachment_id) => {
|
||||
@@ -439,8 +453,9 @@ impl Daemon {
|
||||
async fn get_messages(
|
||||
&mut self,
|
||||
conversation_id: String,
|
||||
_last_message_id: Option<MessageID>,
|
||||
last_message_id: Option<MessageID>,
|
||||
) -> Vec<Message> {
|
||||
let started = Instant::now();
|
||||
// Get outgoing messages for this conversation.
|
||||
let empty_vec: Vec<OutgoingMessage> = vec![];
|
||||
let outgoing_messages: &Vec<OutgoingMessage> = self
|
||||
@@ -448,18 +463,59 @@ impl Daemon {
|
||||
.get(&conversation_id)
|
||||
.unwrap_or(&empty_vec);
|
||||
|
||||
self.database
|
||||
// Fetch DB messages and an alias map (server_id -> local_id) in one DB access.
|
||||
let (db_messages, alias_map) = self
|
||||
.database
|
||||
.lock()
|
||||
.await
|
||||
.with_repository(|r| {
|
||||
r.get_messages_for_conversation(&conversation_id)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|m| m.into()) // Convert db::Message to daemon::Message
|
||||
.chain(outgoing_messages.into_iter().map(|m| m.into()))
|
||||
.collect()
|
||||
let msgs = r.get_messages_for_conversation(&conversation_id).unwrap();
|
||||
let ids: Vec<String> = msgs.iter().map(|m| m.id.clone()).collect();
|
||||
let map = r.get_local_ids_for(ids).unwrap_or_default();
|
||||
(msgs, map)
|
||||
})
|
||||
.await
|
||||
.await;
|
||||
|
||||
// Convert DB messages to daemon model, substituting local_id when an alias exists.
|
||||
let mut result: Vec<Message> = Vec::with_capacity(
|
||||
db_messages.len() + outgoing_messages.len(),
|
||||
);
|
||||
for m in db_messages.into_iter() {
|
||||
let server_id = m.id.clone();
|
||||
let mut dm: Message = m.into();
|
||||
if let Some(local_id) = alias_map.get(&server_id) {
|
||||
dm.id = local_id.clone();
|
||||
}
|
||||
result.push(dm);
|
||||
}
|
||||
|
||||
// Append pending outgoing messages (these already use local_id)
|
||||
for om in outgoing_messages.iter() {
|
||||
result.push(om.into());
|
||||
}
|
||||
|
||||
if let Some(last_id) = last_message_id {
|
||||
if let Some(last_index) = result.iter().position(|message| message.id == last_id) {
|
||||
result = result.split_off(last_index + 1);
|
||||
}
|
||||
} else if result.len() > GET_MESSAGES_INITIAL_WINDOW {
|
||||
let dropped = result.len() - GET_MESSAGES_INITIAL_WINDOW;
|
||||
result = result.split_off(dropped);
|
||||
log::debug!(
|
||||
target: target::EVENT,
|
||||
"GetMessages initial window applied: dropped {} older messages",
|
||||
dropped
|
||||
);
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
target: target::EVENT,
|
||||
"GetMessages completed in {}ms: {} messages",
|
||||
started.elapsed().as_millis(),
|
||||
result.len()
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn enqueue_outgoing_message(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AttachmentMetadata {
|
||||
@@ -17,6 +17,8 @@ pub struct Attachment {
|
||||
pub base_path: PathBuf,
|
||||
pub metadata: Option<AttachmentMetadata>,
|
||||
pub mime_type: Option<String>,
|
||||
pub cached_full_path: Option<PathBuf>,
|
||||
pub cached_preview_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Attachment {
|
||||
@@ -25,8 +27,7 @@ impl Attachment {
|
||||
// Prefer common, user-friendly extensions over obscure ones
|
||||
match normalized {
|
||||
"image/jpeg" | "image/pjpeg" => Some("jpg"),
|
||||
_ => mime_guess::get_mime_extensions_str(normalized)
|
||||
.and_then(|list| {
|
||||
_ => mime_guess::get_mime_extensions_str(normalized).and_then(|list| {
|
||||
// If jpg is one of the candidates, prefer it
|
||||
if list.iter().any(|e| *e == "jpg") {
|
||||
Some("jpg")
|
||||
@@ -45,17 +46,21 @@ impl Attachment {
|
||||
}
|
||||
|
||||
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.
|
||||
let kind = if preview { "preview" } else { "full" };
|
||||
|
||||
// If not a scratch path, and a file already exists on disk with a concrete
|
||||
// file extension (e.g., guid.full.jpg), return that existing path.
|
||||
if !scratch {
|
||||
if let Some(existing) = self.find_existing_path(preview) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to constructing a path using known info. If we know the MIME type,
|
||||
// prefer an extension guessed from it; otherwise keep legacy naming.
|
||||
let ext_from_mime = self
|
||||
@@ -77,44 +82,15 @@ impl Attachment {
|
||||
}
|
||||
|
||||
pub fn is_downloaded(&self, preview: bool) -> bool {
|
||||
std::fs::exists(&self.get_path_for_preview(preview)).expect(
|
||||
let path = self.get_path_for_preview(preview);
|
||||
std::fs::exists(&path).expect(
|
||||
format!(
|
||||
"Wasn't able to check for the existence of an attachment file path at {}",
|
||||
&self.get_path_for_preview(preview).display()
|
||||
path.display()
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
}
|
||||
|
||||
fn find_existing_path(&self, preview: bool) -> Option<PathBuf> {
|
||||
let kind = if preview { "preview" } else { "full" };
|
||||
|
||||
// First, check legacy path without a concrete file extension.
|
||||
let legacy = self.base_path.with_extension(kind);
|
||||
if legacy.exists() {
|
||||
return Some(legacy);
|
||||
}
|
||||
|
||||
// Next, search for a filename like: <guid>.<kind>.<ext>
|
||||
let file_stem = self
|
||||
.base_path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())?;
|
||||
let prefix = format!("{}.{}.", file_stem, kind);
|
||||
|
||||
let parent = self.base_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
if let Ok(dir) = std::fs::read_dir(parent) {
|
||||
for entry in dir.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
if name.starts_with(&prefix) && !name.ends_with(".download") {
|
||||
return Some(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl From<kordophone::model::message::AttachmentMetadata> for AttachmentMetadata {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use dbus::arg;
|
||||
use dbus_tree::MethodErr;
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::fd::{FromRawFd, IntoRawFd};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::{future::Future, thread};
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
|
||||
@@ -176,9 +179,8 @@ impl DBusAgent {
|
||||
&self,
|
||||
make_event: impl FnOnce(Reply<T>) -> Event + Send,
|
||||
) -> Result<T, MethodErr> {
|
||||
run_sync_future(self.send_event(make_event))
|
||||
.unwrap()
|
||||
.map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e)))
|
||||
let daemon_result = run_sync_future(self.send_event(make_event))?;
|
||||
daemon_result.map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e)))
|
||||
}
|
||||
|
||||
fn resolve_participant_display_name(&mut self, participant: &Participant) -> String {
|
||||
@@ -278,15 +280,20 @@ impl DbusRepository for DBusAgent {
|
||||
conversation_id: String,
|
||||
last_message_id: String,
|
||||
) -> Result<Vec<arg::PropMap>, MethodErr> {
|
||||
let started = Instant::now();
|
||||
let last_message_id_opt = if last_message_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(last_message_id)
|
||||
};
|
||||
|
||||
self.send_event_sync(|r| Event::GetMessages(conversation_id, last_message_id_opt, r))
|
||||
.map(|messages| {
|
||||
messages
|
||||
let messages =
|
||||
self.send_event_sync(|r| Event::GetMessages(conversation_id, last_message_id_opt, r))?;
|
||||
|
||||
let mut attachment_count: usize = 0;
|
||||
let mut text_bytes: usize = 0;
|
||||
|
||||
let mapped: Vec<arg::PropMap> = messages
|
||||
.into_iter()
|
||||
.map(|msg| {
|
||||
let mut map = arg::PropMap::new();
|
||||
@@ -294,6 +301,7 @@ impl DbusRepository for DBusAgent {
|
||||
|
||||
// Remove the attachment placeholder here.
|
||||
let text = msg.text.replace("\u{FFFC}", "");
|
||||
text_bytes += text.len();
|
||||
|
||||
map.insert("text".into(), arg::Variant(Box::new(text)));
|
||||
map.insert(
|
||||
@@ -302,48 +310,29 @@ impl DbusRepository for DBusAgent {
|
||||
);
|
||||
map.insert(
|
||||
"sender".into(),
|
||||
arg::Variant(Box::new(
|
||||
self.resolve_participant_display_name(&msg.sender.into()),
|
||||
)),
|
||||
arg::Variant(Box::new(msg.sender.display_name())),
|
||||
);
|
||||
|
||||
// Attachments array
|
||||
if !msg.attachments.is_empty() {
|
||||
let attachments: Vec<arg::PropMap> = msg
|
||||
.attachments
|
||||
.into_iter()
|
||||
.map(|attachment| {
|
||||
attachment_count += 1;
|
||||
let mut attachment_map = arg::PropMap::new();
|
||||
attachment_map.insert(
|
||||
"guid".into(),
|
||||
arg::Variant(Box::new(attachment.guid.clone())),
|
||||
);
|
||||
|
||||
// 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(
|
||||
"downloaded".into(),
|
||||
arg::Variant(Box::new(downloaded)),
|
||||
arg::Variant(Box::new(attachment.is_downloaded(false))),
|
||||
);
|
||||
attachment_map.insert(
|
||||
"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 {
|
||||
let mut metadata_map = arg::PropMap::new();
|
||||
|
||||
@@ -372,16 +361,27 @@ impl DbusRepository for DBusAgent {
|
||||
arg::Variant(Box::new(metadata_map)),
|
||||
);
|
||||
}
|
||||
|
||||
attachment_map
|
||||
})
|
||||
.collect();
|
||||
|
||||
map.insert("attachments".into(), arg::Variant(Box::new(attachments)));
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -425,6 +425,23 @@ impl DbusRepository for DBusAgent {
|
||||
self.send_event_sync(|r| Event::DownloadAttachment(attachment_id, preview, r))
|
||||
}
|
||||
|
||||
fn open_attachment_fd(
|
||||
&mut self,
|
||||
attachment_id: String,
|
||||
preview: bool,
|
||||
) -> Result<arg::OwnedFd, MethodErr> {
|
||||
let attachment = self.send_event_sync(|r| Event::GetAttachment(attachment_id, r))?;
|
||||
let path = attachment.get_path_for_preview(preview);
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(&path)
|
||||
.map_err(|e| MethodErr::failed(&format!("Failed to open attachment: {}", e)))?;
|
||||
|
||||
let fd = file.into_raw_fd();
|
||||
Ok(unsafe { arg::OwnedFd::from_raw_fd(fd) })
|
||||
}
|
||||
|
||||
fn upload_attachment(&mut self, path: String) -> Result<String, MethodErr> {
|
||||
use std::path::PathBuf;
|
||||
let path = PathBuf::from(path);
|
||||
@@ -496,7 +513,7 @@ where
|
||||
T: Send,
|
||||
F: Future<Output = T> + Send,
|
||||
{
|
||||
thread::scope(move |s| {
|
||||
let joined = thread::scope(move |s| {
|
||||
s.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -507,6 +524,10 @@ where
|
||||
Ok(result)
|
||||
})
|
||||
.join()
|
||||
})
|
||||
.expect("Error joining runtime thread")
|
||||
});
|
||||
|
||||
match joined {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(MethodErr::failed("Error joining runtime thread")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ dbus-tree = "0.9.2"
|
||||
|
||||
# D-Bus codegen only on Linux
|
||||
[target.'cfg(target_os = "linux")'.build-dependencies]
|
||||
dbus-codegen = "0.10.0"
|
||||
dbus-codegen = { version = "0.10.0", default-features = false }
|
||||
|
||||
# XPC (libxpc) interface only on macOS
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -143,7 +143,10 @@ impl ClientCli {
|
||||
|
||||
println!("Listening for raw updates...");
|
||||
let mut stream = socket.raw_updates().await;
|
||||
while let Some(Ok(update)) = stream.next().await {
|
||||
|
||||
loop {
|
||||
match stream.next().await.unwrap() {
|
||||
Ok(update) => {
|
||||
match update {
|
||||
SocketUpdate::Update(updates) => {
|
||||
for update in updates {
|
||||
@@ -154,6 +157,13 @@ impl ClientCli {
|
||||
println!("Pong");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
println!("Update error: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -33,7 +33,7 @@ impl DBusDaemonInterface {
|
||||
|
||||
fn proxy(&self) -> Proxy<&Connection> {
|
||||
self.conn
|
||||
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000))
|
||||
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_secs(30))
|
||||
}
|
||||
|
||||
async fn print_settings(&mut self) -> Result<()> {
|
||||
|
||||
12
core/kptui/Cargo.toml
Normal file
12
core/kptui/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "kptui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.93"
|
||||
crossterm = "0.28.1"
|
||||
kordophoned-client = { path = "../kordophoned-client" }
|
||||
ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
|
||||
time = { version = "0.3.37", features = ["formatting"] }
|
||||
unicode-width = "0.2.0"
|
||||
789
core/kptui/src/main.rs
Normal file
789
core/kptui/src/main.rs
Normal file
@@ -0,0 +1,789 @@
|
||||
use kordophoned_client as daemon;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event as CEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::*;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ViewMode {
|
||||
List,
|
||||
Chat,
|
||||
Split,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Navigation,
|
||||
Input,
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
conversations: Vec<daemon::ConversationSummary>,
|
||||
selected_idx: usize,
|
||||
selected_conversation_id: Option<String>,
|
||||
messages: Vec<daemon::ChatMessage>,
|
||||
active_conversation_id: Option<String>,
|
||||
active_conversation_title: String,
|
||||
status: String,
|
||||
input: String,
|
||||
focus: Focus,
|
||||
transcript_scroll: u16,
|
||||
pinned_to_bottom: bool,
|
||||
refresh_conversations_in_flight: bool,
|
||||
refresh_messages_in_flight: bool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
conversations: Vec::new(),
|
||||
selected_idx: 0,
|
||||
selected_conversation_id: None,
|
||||
messages: Vec::new(),
|
||||
active_conversation_id: None,
|
||||
active_conversation_title: String::new(),
|
||||
status: String::new(),
|
||||
input: String::new(),
|
||||
focus: Focus::Navigation,
|
||||
transcript_scroll: 0,
|
||||
pinned_to_bottom: true,
|
||||
refresh_conversations_in_flight: false,
|
||||
refresh_messages_in_flight: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn select_next(&mut self) {
|
||||
if self.conversations.is_empty() {
|
||||
self.selected_idx = 0;
|
||||
self.selected_conversation_id = None;
|
||||
return;
|
||||
}
|
||||
self.selected_idx = (self.selected_idx + 1).min(self.conversations.len() - 1);
|
||||
self.selected_conversation_id = self
|
||||
.conversations
|
||||
.get(self.selected_idx)
|
||||
.map(|c| c.id.clone());
|
||||
}
|
||||
|
||||
fn select_prev(&mut self) {
|
||||
if self.conversations.is_empty() {
|
||||
self.selected_idx = 0;
|
||||
self.selected_conversation_id = None;
|
||||
return;
|
||||
}
|
||||
self.selected_idx = self.selected_idx.saturating_sub(1);
|
||||
self.selected_conversation_id = self
|
||||
.conversations
|
||||
.get(self.selected_idx)
|
||||
.map(|c| c.id.clone());
|
||||
}
|
||||
|
||||
fn open_selected_conversation(&mut self) {
|
||||
if let Some(conv) = self.conversations.get(self.selected_idx) {
|
||||
self.active_conversation_id = Some(conv.id.clone());
|
||||
self.active_conversation_title = conv.title.clone();
|
||||
self.selected_conversation_id = Some(conv.id.clone());
|
||||
self.messages.clear();
|
||||
self.transcript_scroll = 0;
|
||||
self.pinned_to_bottom = true;
|
||||
self.focus = Focus::Input;
|
||||
self.status = "Loading…".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_mode(width: u16, has_active_conversation: bool, requested: ViewMode) -> ViewMode {
|
||||
let min_conversations = 24u16;
|
||||
let min_chat = 44u16;
|
||||
let min_total = min_conversations + 1 + min_chat;
|
||||
if width >= min_total {
|
||||
return ViewMode::Split;
|
||||
}
|
||||
if has_active_conversation {
|
||||
requested
|
||||
} else {
|
||||
ViewMode::List
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(frame: &mut Frame, app: &AppState, requested_view: ViewMode) {
|
||||
let area = frame.area();
|
||||
let mode = view_mode(
|
||||
area.width,
|
||||
app.active_conversation_id.is_some(),
|
||||
requested_view,
|
||||
);
|
||||
|
||||
let show_input =
|
||||
matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();
|
||||
let chunks = if show_input {
|
||||
Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area)
|
||||
} else {
|
||||
Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(1), Constraint::Length(1)])
|
||||
.split(area)
|
||||
};
|
||||
|
||||
let (main_area, input_area, status_area) = if show_input {
|
||||
(chunks[0], Some(chunks[1]), chunks[2])
|
||||
} else {
|
||||
(chunks[0], None, chunks[1])
|
||||
};
|
||||
|
||||
match mode {
|
||||
ViewMode::Split => {
|
||||
let left_width = (main_area.width / 3).clamp(24, 40);
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(left_width), Constraint::Min(1)])
|
||||
.split(main_area);
|
||||
render_conversations(frame, app, cols[0], true);
|
||||
render_transcript(frame, app, cols[1], true);
|
||||
}
|
||||
ViewMode::List => render_conversations(frame, app, main_area, false),
|
||||
ViewMode::Chat => render_transcript(frame, app, main_area, false),
|
||||
}
|
||||
|
||||
if let Some(input_area) = input_area {
|
||||
let input_scroll_x = render_input(frame, app, input_area);
|
||||
if app.focus == Focus::Input {
|
||||
let cursor_col = visual_width_u16(app.input.as_str());
|
||||
let mut x = input_area
|
||||
.x
|
||||
.saturating_add(1)
|
||||
.saturating_add(cursor_col.saturating_sub(input_scroll_x));
|
||||
let max_x = input_area
|
||||
.x
|
||||
.saturating_add(input_area.width.saturating_sub(2));
|
||||
x = x.min(max_x);
|
||||
let y = input_area.y + 1;
|
||||
frame.set_cursor_position(Position { x, y });
|
||||
}
|
||||
}
|
||||
|
||||
render_status(frame, app, status_area, mode);
|
||||
}
|
||||
|
||||
fn render_conversations(frame: &mut Frame, app: &AppState, area: Rect, _in_split: bool) {
|
||||
let title = "Kordophone";
|
||||
let items = app
|
||||
.conversations
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let is_active = app.active_conversation_id.as_deref() == Some(c.id.as_str());
|
||||
let header = Line::from(vec![
|
||||
Span::styled(
|
||||
if c.unread_count > 0 { "● " } else { " " },
|
||||
Style::default()
|
||||
.fg(if c.unread_count > 0 {
|
||||
Color::LightYellow
|
||||
} else {
|
||||
Color::DarkGray
|
||||
})
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
c.title.clone(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
let preview = Line::from(Span::styled(
|
||||
c.preview.clone(),
|
||||
Style::default().fg(if is_active {
|
||||
Color::Gray
|
||||
} else {
|
||||
Color::DarkGray
|
||||
}),
|
||||
));
|
||||
ListItem::new(vec![header, preview]).style(if is_active {
|
||||
Style::default().bg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default()
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut state = ListState::default();
|
||||
state.select(if app.conversations.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(app.selected_idx)
|
||||
});
|
||||
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(Color::Blue)
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("▸ ");
|
||||
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
fn render_transcript(frame: &mut Frame, app: &AppState, area: Rect, in_split: bool) {
|
||||
let title = if let Some(_) = app.active_conversation_id {
|
||||
if in_split {
|
||||
format!("{} (Esc: nav, Tab: focus)", app.active_conversation_title)
|
||||
} else {
|
||||
format!("{} (Esc: back)", app.active_conversation_title)
|
||||
}
|
||||
} else {
|
||||
"Chat".to_string()
|
||||
};
|
||||
|
||||
let lines = transcript_lines(&app.messages);
|
||||
|
||||
let paragraph = Paragraph::new(Text::from(lines))
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((app.transcript_scroll, 0));
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn render_input(frame: &mut Frame, app: &AppState, area: Rect) -> u16 {
|
||||
let title = if app.focus == Focus::Input {
|
||||
"Reply (Enter to send)"
|
||||
} else {
|
||||
"Reply (press i to type)"
|
||||
};
|
||||
|
||||
let inner_width = area.width.saturating_sub(2).max(1);
|
||||
let cursor_col = visual_width_u16(app.input.as_str());
|
||||
let input_scroll_x = cursor_col.saturating_sub(inner_width.saturating_sub(1));
|
||||
|
||||
let input = Paragraph::new(app.input.as_str())
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.scroll((0, input_scroll_x));
|
||||
frame.render_widget(input, area);
|
||||
input_scroll_x
|
||||
}
|
||||
|
||||
fn render_status(frame: &mut Frame, app: &AppState, area: Rect, mode: ViewMode) {
|
||||
let mut parts = vec![
|
||||
format!("{} convs", app.conversations.len()),
|
||||
match mode {
|
||||
ViewMode::Split => "split".to_string(),
|
||||
ViewMode::List => "list".to_string(),
|
||||
ViewMode::Chat => "chat".to_string(),
|
||||
},
|
||||
];
|
||||
if !app.status.trim().is_empty() {
|
||||
parts.push(app.status.clone());
|
||||
}
|
||||
let line = parts.join(" | ");
|
||||
frame.render_widget(
|
||||
Paragraph::new(line).block(Block::default().borders(Borders::TOP)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = std::io::stdout();
|
||||
crossterm::execute!(
|
||||
stdout,
|
||||
crossterm::terminal::EnterAlternateScreen,
|
||||
crossterm::event::EnableMouseCapture
|
||||
)?;
|
||||
let backend = ratatui::backend::CrosstermBackend::new(stdout);
|
||||
let mut terminal = ratatui::Terminal::new(backend)?;
|
||||
|
||||
let res = run_app(&mut terminal);
|
||||
|
||||
disable_raw_mode()?;
|
||||
crossterm::execute!(
|
||||
terminal.backend_mut(),
|
||||
crossterm::event::DisableMouseCapture,
|
||||
crossterm::terminal::LeaveAlternateScreen
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
fn run_app(
|
||||
terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
|
||||
) -> Result<()> {
|
||||
let (request_tx, request_rx) = mpsc::channel::<daemon::Request>();
|
||||
let (event_tx, event_rx) = mpsc::channel::<daemon::Event>();
|
||||
let _worker = daemon::spawn_worker(request_rx, event_tx);
|
||||
|
||||
let tick_rate = Duration::from_millis(150);
|
||||
let refresh_rate = Duration::from_secs(2);
|
||||
let mut last_tick = Instant::now();
|
||||
let mut last_refresh = Instant::now() - refresh_rate;
|
||||
|
||||
let mut requested_view = ViewMode::List;
|
||||
let mut app = AppState::new();
|
||||
app.status = "Connecting…".to_string();
|
||||
request_tx.send(daemon::Request::RefreshConversations).ok();
|
||||
app.refresh_conversations_in_flight = true;
|
||||
|
||||
loop {
|
||||
let size = terminal.size()?;
|
||||
|
||||
while let Ok(evt) = event_rx.try_recv() {
|
||||
match evt {
|
||||
daemon::Event::Conversations(convs) => {
|
||||
let keep_selected_id = app
|
||||
.selected_conversation_id
|
||||
.clone()
|
||||
.or_else(|| app.active_conversation_id.clone());
|
||||
|
||||
app.refresh_conversations_in_flight = false;
|
||||
app.status.clear();
|
||||
app.conversations = convs;
|
||||
if app.conversations.is_empty() {
|
||||
app.selected_idx = 0;
|
||||
app.selected_conversation_id = None;
|
||||
} else if let Some(id) = keep_selected_id {
|
||||
if let Some(idx) = app.conversations.iter().position(|c| c.id == id) {
|
||||
app.selected_idx = idx;
|
||||
app.selected_conversation_id = Some(id);
|
||||
} else {
|
||||
app.selected_idx = 0;
|
||||
app.selected_conversation_id = Some(app.conversations[0].id.clone());
|
||||
}
|
||||
} else {
|
||||
app.selected_idx = app.selected_idx.min(app.conversations.len() - 1);
|
||||
app.selected_conversation_id =
|
||||
Some(app.conversations[app.selected_idx].id.clone());
|
||||
}
|
||||
}
|
||||
daemon::Event::Messages {
|
||||
conversation_id,
|
||||
messages,
|
||||
} => {
|
||||
app.refresh_messages_in_flight = false;
|
||||
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
||||
let was_pinned = app.pinned_to_bottom;
|
||||
app.messages = messages;
|
||||
app.pinned_to_bottom = was_pinned;
|
||||
}
|
||||
}
|
||||
daemon::Event::MessageSent {
|
||||
conversation_id,
|
||||
outgoing_id,
|
||||
} => {
|
||||
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
||||
app.status = outgoing_id
|
||||
.as_deref()
|
||||
.map(|id| format!("Sent ({id})"))
|
||||
.unwrap_or_else(|| "Sent".to_string());
|
||||
app.refresh_messages_in_flight = false;
|
||||
request_tx
|
||||
.send(daemon::Request::RefreshMessages { conversation_id })
|
||||
.ok();
|
||||
app.refresh_messages_in_flight = true;
|
||||
}
|
||||
}
|
||||
daemon::Event::MarkedRead => {}
|
||||
daemon::Event::ConversationSyncTriggered { conversation_id } => {
|
||||
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
||||
app.status = "Syncing…".to_string();
|
||||
}
|
||||
}
|
||||
daemon::Event::ConversationsUpdated => {
|
||||
if !app.refresh_conversations_in_flight {
|
||||
request_tx.send(daemon::Request::RefreshConversations).ok();
|
||||
app.refresh_conversations_in_flight = true;
|
||||
}
|
||||
if let Some(cid) = app.active_conversation_id.clone() {
|
||||
if !app.refresh_messages_in_flight {
|
||||
request_tx
|
||||
.send(daemon::Request::RefreshMessages {
|
||||
conversation_id: cid,
|
||||
})
|
||||
.ok();
|
||||
app.refresh_messages_in_flight = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
daemon::Event::MessagesUpdated { conversation_id } => {
|
||||
if !app.refresh_conversations_in_flight {
|
||||
request_tx.send(daemon::Request::RefreshConversations).ok();
|
||||
app.refresh_conversations_in_flight = true;
|
||||
}
|
||||
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
||||
if !app.refresh_messages_in_flight {
|
||||
request_tx
|
||||
.send(daemon::Request::RefreshMessages { conversation_id })
|
||||
.ok();
|
||||
app.refresh_messages_in_flight = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
daemon::Event::UpdateStreamReconnected => {
|
||||
if !app.refresh_conversations_in_flight {
|
||||
request_tx.send(daemon::Request::RefreshConversations).ok();
|
||||
app.refresh_conversations_in_flight = true;
|
||||
}
|
||||
if let Some(cid) = app.active_conversation_id.clone() {
|
||||
if !app.refresh_messages_in_flight {
|
||||
request_tx
|
||||
.send(daemon::Request::RefreshMessages {
|
||||
conversation_id: cid,
|
||||
})
|
||||
.ok();
|
||||
app.refresh_messages_in_flight = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
daemon::Event::Error(e) => {
|
||||
app.refresh_conversations_in_flight = false;
|
||||
app.refresh_messages_in_flight = false;
|
||||
app.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_transcript_scroll_policy(&mut app, size, requested_view);
|
||||
terminal.draw(|f| ui(f, &app, requested_view))?;
|
||||
|
||||
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
|
||||
if crossterm::event::poll(timeout)? {
|
||||
if let CEvent::Key(key) = crossterm::event::read()? {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match (key.code, ctrl) {
|
||||
(KeyCode::Char('c'), true) => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let screen_mode = view_mode(
|
||||
size.width,
|
||||
app.active_conversation_id.is_some(),
|
||||
requested_view,
|
||||
);
|
||||
|
||||
let max_scroll = max_transcript_scroll(&app, size, requested_view);
|
||||
|
||||
match screen_mode {
|
||||
ViewMode::List => match key.code {
|
||||
KeyCode::Up => app.select_prev(),
|
||||
KeyCode::Down => app.select_next(),
|
||||
KeyCode::Enter => {
|
||||
app.open_selected_conversation();
|
||||
if app.active_conversation_id.is_some() {
|
||||
requested_view = ViewMode::Chat;
|
||||
if let Some(cid) = app.active_conversation_id.clone() {
|
||||
request_tx
|
||||
.send(daemon::Request::MarkRead {
|
||||
conversation_id: cid.clone(),
|
||||
})
|
||||
.ok();
|
||||
request_tx
|
||||
.send(daemon::Request::SyncConversation {
|
||||
conversation_id: cid.clone(),
|
||||
})
|
||||
.ok();
|
||||
request_tx
|
||||
.send(daemon::Request::RefreshMessages {
|
||||
conversation_id: cid,
|
||||
})
|
||||
.ok();
|
||||
app.refresh_messages_in_flight = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ViewMode::Chat => match key.code {
|
||||
KeyCode::Esc => {
|
||||
requested_view = ViewMode::List;
|
||||
app.focus = Focus::Navigation;
|
||||
}
|
||||
KeyCode::Char('i') if app.focus != Focus::Input => app.focus = Focus::Input,
|
||||
_ => {
|
||||
handle_chat_keys(&mut app, &request_tx, key, max_scroll);
|
||||
}
|
||||
},
|
||||
ViewMode::Split => match key.code {
|
||||
KeyCode::Tab => {
|
||||
app.focus = match app.focus {
|
||||
Focus::Navigation => Focus::Input,
|
||||
Focus::Input => Focus::Navigation,
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => app.focus = Focus::Navigation,
|
||||
KeyCode::Char('i') if app.focus != Focus::Input => app.focus = Focus::Input,
|
||||
KeyCode::Up => {
|
||||
if app.focus == Focus::Navigation {
|
||||
app.select_prev()
|
||||
} else {
|
||||
scroll_up(&mut app, 1);
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if app.focus == Focus::Navigation {
|
||||
app.select_next()
|
||||
} else {
|
||||
scroll_down(&mut app, 1, max_scroll);
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if app.focus == Focus::Navigation {
|
||||
app.open_selected_conversation();
|
||||
requested_view = ViewMode::Chat;
|
||||
if let Some(cid) = app.active_conversation_id.clone() {
|
||||
request_tx
|
||||
.send(daemon::Request::MarkRead {
|
||||
conversation_id: cid.clone(),
|
||||
})
|
||||
.ok();
|
||||
request_tx
|
||||
.send(daemon::Request::SyncConversation {
|
||||
conversation_id: cid.clone(),
|
||||
})
|
||||
.ok();
|
||||
request_tx
|
||||
.send(daemon::Request::RefreshMessages {
|
||||
conversation_id: cid,
|
||||
})
|
||||
.ok();
|
||||
app.refresh_messages_in_flight = true;
|
||||
}
|
||||
} else {
|
||||
handle_chat_keys(&mut app, &request_tx, key, max_scroll);
|
||||
}
|
||||
}
|
||||
_ => handle_chat_keys(&mut app, &request_tx, key, max_scroll),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if last_refresh.elapsed() >= refresh_rate {
|
||||
if !app.refresh_conversations_in_flight {
|
||||
request_tx.send(daemon::Request::RefreshConversations).ok();
|
||||
app.refresh_conversations_in_flight = true;
|
||||
}
|
||||
|
||||
if let Some(cid) = app.active_conversation_id.clone() {
|
||||
if !app.refresh_messages_in_flight {
|
||||
request_tx
|
||||
.send(daemon::Request::RefreshMessages {
|
||||
conversation_id: cid,
|
||||
})
|
||||
.ok();
|
||||
app.refresh_messages_in_flight = true;
|
||||
}
|
||||
}
|
||||
|
||||
last_refresh = Instant::now();
|
||||
}
|
||||
|
||||
if last_tick.elapsed() >= tick_rate {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_chat_keys(
|
||||
app: &mut AppState,
|
||||
request_tx: &mpsc::Sender<daemon::Request>,
|
||||
key: KeyEvent,
|
||||
max_scroll: u16,
|
||||
) {
|
||||
let code = key.code;
|
||||
let modifiers = key.modifiers;
|
||||
|
||||
match code {
|
||||
KeyCode::PageUp => scroll_up(app, 10),
|
||||
KeyCode::PageDown => scroll_down(app, 10, max_scroll),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if app.focus != Focus::Input {
|
||||
return;
|
||||
}
|
||||
|
||||
match code {
|
||||
KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
app.input.clear();
|
||||
}
|
||||
KeyCode::Backspace if modifiers.contains(KeyModifiers::ALT) => {
|
||||
delete_prev_word(&mut app.input);
|
||||
}
|
||||
KeyCode::Char('w') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
delete_prev_word(&mut app.input);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let text = app.input.trim().to_string();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(conversation_id) = app.active_conversation_id.clone() else {
|
||||
app.status = "No conversation selected".to_string();
|
||||
return;
|
||||
};
|
||||
request_tx
|
||||
.send(daemon::Request::SendMessage {
|
||||
conversation_id,
|
||||
text,
|
||||
})
|
||||
.ok();
|
||||
app.refresh_messages_in_flight = true;
|
||||
app.input.clear();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.input.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if !c.is_control() {
|
||||
app.input.push(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_prev_word(input: &mut String) {
|
||||
while input.chars().last().is_some_and(|c| c.is_whitespace()) {
|
||||
input.pop();
|
||||
}
|
||||
while input.chars().last().is_some_and(|c| !c.is_whitespace()) {
|
||||
input.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_up(app: &mut AppState, amount: u16) {
|
||||
if amount > 0 {
|
||||
app.pinned_to_bottom = false;
|
||||
}
|
||||
app.transcript_scroll = app.transcript_scroll.saturating_sub(amount);
|
||||
}
|
||||
|
||||
fn scroll_down(app: &mut AppState, amount: u16, max_scroll: u16) {
|
||||
app.transcript_scroll = app.transcript_scroll.saturating_add(amount);
|
||||
if app.transcript_scroll >= max_scroll {
|
||||
app.transcript_scroll = max_scroll;
|
||||
app.pinned_to_bottom = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn transcript_inner_width(size: Size, app: &AppState, requested_view: ViewMode) -> u16 {
|
||||
let mode = view_mode(
|
||||
size.width,
|
||||
app.active_conversation_id.is_some(),
|
||||
requested_view,
|
||||
);
|
||||
let outer_width = match mode {
|
||||
ViewMode::Split => {
|
||||
let left_width = (size.width / 3).clamp(24, 40);
|
||||
size.width.saturating_sub(left_width)
|
||||
}
|
||||
ViewMode::Chat => size.width,
|
||||
ViewMode::List => 0,
|
||||
};
|
||||
|
||||
outer_width.saturating_sub(2).max(1)
|
||||
}
|
||||
|
||||
fn visual_width_u16(s: &str) -> u16 {
|
||||
s.width().min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
||||
fn transcript_lines(messages: &[daemon::ChatMessage]) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
for message in messages {
|
||||
let ts = time::OffsetDateTime::from_unix_timestamp(message.date_unix)
|
||||
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
message.sender.clone(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(ts, Style::default().fg(Color::DarkGray)),
|
||||
]));
|
||||
|
||||
let mut rendered_any_text = false;
|
||||
for text_line in message.text.lines() {
|
||||
rendered_any_text = true;
|
||||
lines.push(Line::from(Span::raw(text_line.to_string())));
|
||||
}
|
||||
if !rendered_any_text {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"<non-text message>",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
lines.push(Line::from(Span::raw("")));
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"No messages.",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn transcript_content_visual_lines(messages: &[daemon::ChatMessage], inner_width: u16) -> u16 {
|
||||
let paragraph =
|
||||
Paragraph::new(Text::from(transcript_lines(messages))).wrap(Wrap { trim: false });
|
||||
paragraph.line_count(inner_width).min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
||||
fn transcript_viewport_height(size: Size, app: &AppState, requested_view: ViewMode) -> u16 {
|
||||
let mode = view_mode(
|
||||
size.width,
|
||||
app.active_conversation_id.is_some(),
|
||||
requested_view,
|
||||
);
|
||||
let show_input =
|
||||
matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();
|
||||
|
||||
let transcript_height = if show_input {
|
||||
size.height.saturating_sub(4) // input (3) + status (1)
|
||||
} else {
|
||||
size.height.saturating_sub(1) // status
|
||||
};
|
||||
|
||||
match mode {
|
||||
ViewMode::Chat | ViewMode::Split => transcript_height.saturating_sub(2), // borders
|
||||
ViewMode::List => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn max_transcript_scroll(app: &AppState, size: Size, requested_view: ViewMode) -> u16 {
|
||||
let viewport_height = transcript_viewport_height(size, app, requested_view);
|
||||
let inner_width = transcript_inner_width(size, app, requested_view);
|
||||
let content = transcript_content_visual_lines(&app.messages, inner_width);
|
||||
content.saturating_sub(viewport_height)
|
||||
}
|
||||
|
||||
fn apply_transcript_scroll_policy(app: &mut AppState, size: Size, requested_view: ViewMode) {
|
||||
let max_scroll = max_transcript_scroll(app, size, requested_view);
|
||||
if app.pinned_to_bottom {
|
||||
app.transcript_scroll = max_scroll;
|
||||
} else {
|
||||
app.transcript_scroll = app.transcript_scroll.min(max_scroll);
|
||||
}
|
||||
}
|
||||
2
gtk/.gitignore
vendored
2
gtk/.gitignore
vendored
@@ -1 +1,3 @@
|
||||
build/
|
||||
flatpak-build/
|
||||
.flatpak-builder/
|
||||
|
||||
25
gtk/Dockerfile.deb
Normal file
25
gtk/Dockerfile.deb
Normal 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"]
|
||||
@@ -11,4 +11,13 @@ rpm:
|
||||
git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(TMP)/v$(VER).tar.gz HEAD
|
||||
rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)"
|
||||
|
||||
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
|
||||
|
||||
@@ -5,3 +5,5 @@ Libadwaita/GTK4 client for the Kordophone client daemon.
|
||||
# Building
|
||||
|
||||
Build an RPM using `rpmbuild -ba dist/rpm/kordophone.spec`
|
||||
|
||||
Build a DEB using `make deb`
|
||||
|
||||
6
gtk/dist/deb/.gitignore
vendored
Normal file
6
gtk/dist/deb/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
*.deb
|
||||
*.buildinfo
|
||||
*.changes
|
||||
*.dsc
|
||||
*.tar.*
|
||||
*.build
|
||||
49
gtk/dist/deb/build-deb.sh
vendored
Executable file
49
gtk/dist/deb/build-deb.sh
vendored
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
VERSION="${1:-}"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
VERSION="$(sed -n "s/.*version[[:space:]]*:[[:space:]]*'\\([^']*\\)'.*/\\1/p" meson.build | head -n1)"
|
||||
fi
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "Could not determine version (pass as first arg)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
PKG="kordophone"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
|
||||
PKGROOT="$STAGE/pkgroot"
|
||||
mkdir -p "$PKGROOT/DEBIAN"
|
||||
|
||||
BUILD_DIR="$STAGE/build"
|
||||
meson setup "$BUILD_DIR" --prefix=/usr
|
||||
ninja -C "$BUILD_DIR"
|
||||
DESTDIR="$PKGROOT" ninja -C "$BUILD_DIR" install
|
||||
|
||||
INSTALLED_SIZE_KB="$(du -sk "$PKGROOT/usr" | awk '{print $1}')"
|
||||
|
||||
cat >"$PKGROOT/DEBIAN/control" <<EOF
|
||||
Package: ${PKG}
|
||||
Version: ${VERSION}
|
||||
Section: net
|
||||
Priority: optional
|
||||
Architecture: ${ARCH}
|
||||
Maintainer: James Magahern <james@magahern.com>
|
||||
Installed-Size: ${INSTALLED_SIZE_KB}
|
||||
Depends: libgtk-4-1, libadwaita-1-0, libglib2.0-0, libgee-0.8-2, libsecret-1-0, kordophoned (>= 1.0.0)
|
||||
Description: GTK4/Libadwaita client for Kordophone
|
||||
A GTK4/Libadwaita Linux client for the Kordophone client daemon.
|
||||
EOF
|
||||
|
||||
OUT_DIR="$ROOT_DIR/dist/deb"
|
||||
mkdir -p "$OUT_DIR"
|
||||
OUT_DEB="${OUT_DIR}/${PKG}_${VERSION}_${ARCH}.deb"
|
||||
dpkg-deb --root-owner-group --build "$PKGROOT" "$OUT_DEB"
|
||||
echo "$OUT_DEB"
|
||||
18
gtk/flatpak/README.md
Normal file
18
gtk/flatpak/README.md
Normal 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
|
||||
```
|
||||
25
gtk/flatpak/net.buzzert.kordophone.yml
Normal file
25
gtk/flatpak/net.buzzert.kordophone.yml
Normal 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: ..
|
||||
@@ -1,5 +1,5 @@
|
||||
project('kordophone', 'vala',
|
||||
version : '1.0.1',
|
||||
version : '1.0.2',
|
||||
meson_version : '>=0.56.0',
|
||||
default_options : ['warning_level=2']
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ public class MainWindow : Adw.ApplicationWindow
|
||||
{
|
||||
private ConversationListView conversation_list_view;
|
||||
private TranscriptContainerView transcript_container_view;
|
||||
private NavigationSplitView split_view;
|
||||
|
||||
private EventControllerMotion _motion_controller = new EventControllerMotion();
|
||||
private bool _motion_queued = false;
|
||||
@@ -12,10 +13,15 @@ public class MainWindow : Adw.ApplicationWindow
|
||||
public MainWindow () {
|
||||
Object (title: "Kordophone");
|
||||
|
||||
var split_view = new NavigationSplitView ();
|
||||
split_view = new NavigationSplitView ();
|
||||
split_view.set_min_sidebar_width (400);
|
||||
split_view.show_content = false;
|
||||
set_content (split_view);
|
||||
|
||||
var breakpoint = new Breakpoint (BreakpointCondition.parse ("max-width: 750sp"));
|
||||
breakpoint.add_setter (split_view, "collapsed", true);
|
||||
add_breakpoint (breakpoint);
|
||||
|
||||
conversation_list_view = new ConversationListView ();
|
||||
conversation_list_view.conversation_selected.connect (conversation_selected);
|
||||
conversation_list_view.conversation_activated.connect (open_conversation_in_new_window);
|
||||
@@ -100,6 +106,10 @@ public class MainWindow : Adw.ApplicationWindow
|
||||
GLib.warning("Failed to sync conversation: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (split_view.collapsed) {
|
||||
split_view.show_content = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ public class ConversationListView : Adw.Bin
|
||||
|
||||
private string? selected_conversation_guid = null;
|
||||
private bool selection_update_queued = false;
|
||||
private bool suppress_row_selected = false;
|
||||
|
||||
public ConversationListView () {
|
||||
container = new Adw.ToolbarView ();
|
||||
@@ -29,6 +30,10 @@ public class ConversationListView : Adw.Bin
|
||||
scrolled_window.set_child (list_box);
|
||||
|
||||
list_box.row_selected.connect ((row) => {
|
||||
if (suppress_row_selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
var conversation_row = (ConversationRow?) row;
|
||||
if (conversation_row != null) {
|
||||
selected_conversation_guid = conversation_row.conversation.guid;
|
||||
@@ -112,7 +117,9 @@ public class ConversationListView : Adw.Bin
|
||||
if (conversation.guid == selected_conversation_guid) {
|
||||
var row = list_box.get_row_at_index((int)i);
|
||||
if (row != null) {
|
||||
suppress_row_selected = true;
|
||||
list_box.select_row(row);
|
||||
suppress_row_selected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ public class AttachmentInfo : Object {
|
||||
}
|
||||
|
||||
public class Attachment : Object {
|
||||
public string guid;
|
||||
public string path;
|
||||
public string preview_path;
|
||||
public bool downloaded;
|
||||
public bool preview_downloaded;
|
||||
public string guid = "";
|
||||
public string path = "";
|
||||
public string preview_path = "";
|
||||
public bool downloaded = false;
|
||||
public bool preview_downloaded = false;
|
||||
public AttachmentMetadata? metadata;
|
||||
|
||||
public Attachment(string guid, AttachmentMetadata? metadata) {
|
||||
|
||||
@@ -73,14 +73,13 @@
|
||||
'sender' (string): Sender display name
|
||||
'attachments' (array of dictionaries): List of attachments
|
||||
'guid' (string): Attachment GUID
|
||||
'path' (string): Attachment path
|
||||
'preview_path' (string): Preview attachment path
|
||||
'downloaded' (boolean): Whether the attachment is downloaded
|
||||
'preview_downloaded' (boolean): Whether the preview is downloaded
|
||||
'metadata' (dictionary, optional): Attachment metadata
|
||||
'attribution_info' (dictionary, optional): Attribution info
|
||||
'width' (int32, optional): Width
|
||||
'height' (int32, optional): Height"/>
|
||||
'height' (int32, optional): Height
|
||||
Use GetAttachmentInfo for full/preview paths."/>
|
||||
</arg>
|
||||
</method>
|
||||
|
||||
|
||||
@@ -144,4 +144,35 @@ public class Repository : DBusServiceProxy {
|
||||
var info = dbus_repository.get_attachment_info(attachment_guid);
|
||||
return new AttachmentInfo(info.attr1, info.attr2, info.attr3, info.attr4);
|
||||
}
|
||||
|
||||
public int open_attachment_fd(string attachment_guid, bool preview) throws DBusServiceProxyError, GLib.Error {
|
||||
if (dbus_repository == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected");
|
||||
}
|
||||
|
||||
var connection = Bus.get_sync(BusType.SESSION);
|
||||
UnixFDList? out_fd_list = null;
|
||||
var result = connection.call_with_unix_fd_list_sync(
|
||||
DBUS_NAME,
|
||||
DBUS_PATH,
|
||||
"net.buzzert.kordophone.Repository",
|
||||
"OpenAttachmentFd",
|
||||
new Variant("(sb)", attachment_guid, preview),
|
||||
new VariantType("(h)"),
|
||||
DBusCallFlags.NONE,
|
||||
120000,
|
||||
null,
|
||||
out out_fd_list,
|
||||
null
|
||||
);
|
||||
|
||||
int fd_handle = -1;
|
||||
result.get("(h)", out fd_handle);
|
||||
|
||||
if (out_fd_list == null) {
|
||||
throw new DBusServiceProxyError.NOT_CONNECTED("Missing UnixFDList from OpenAttachmentFd");
|
||||
}
|
||||
|
||||
return out_fd_list.get(fd_handle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,30 +13,52 @@ private class SizeCache
|
||||
return instance;
|
||||
}
|
||||
|
||||
public Graphene.Size? get_size(string image_path) {
|
||||
return size_cache.get(image_path);
|
||||
public Graphene.Size? get_size(string attachment_guid) {
|
||||
return size_cache.get(attachment_guid);
|
||||
}
|
||||
|
||||
public void set_size(string image_path, Graphene.Size size) {
|
||||
size_cache.set(image_path, size);
|
||||
public void set_size(string attachment_guid, Graphene.Size size) {
|
||||
size_cache.set(attachment_guid, size);
|
||||
}
|
||||
}
|
||||
|
||||
private class TextureCache
|
||||
{
|
||||
private static TextureCache instance = null;
|
||||
private HashMap<string, Gdk.Texture> texture_cache = new HashMap<string, Gdk.Texture>();
|
||||
|
||||
public static TextureCache get_instance() {
|
||||
if (instance == null) {
|
||||
instance = new TextureCache();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public Gdk.Texture? get_texture(string attachment_guid) {
|
||||
return texture_cache.get(attachment_guid);
|
||||
}
|
||||
|
||||
public void set_texture(string attachment_guid, Gdk.Texture texture) {
|
||||
texture_cache.set(attachment_guid, texture);
|
||||
}
|
||||
}
|
||||
|
||||
private class ImageBubbleLayout : BubbleLayout
|
||||
{
|
||||
public string image_path;
|
||||
public string attachment_guid;
|
||||
public bool is_downloaded;
|
||||
public string? attachment_guid;
|
||||
|
||||
private Graphene.Size image_size;
|
||||
private Gdk.Texture? cached_texture = null;
|
||||
private bool preview_download_queued = false;
|
||||
|
||||
public ImageBubbleLayout(string image_path, bool from_me, Widget parent, float max_width, Graphene.Size? image_size = null) {
|
||||
public ImageBubbleLayout(string attachment_guid, bool from_me, Widget parent, float max_width, Graphene.Size? image_size = null) {
|
||||
base(parent, max_width);
|
||||
|
||||
this.from_me = from_me;
|
||||
this.image_path = image_path;
|
||||
this.attachment_guid = attachment_guid;
|
||||
this.is_downloaded = false;
|
||||
this.cached_texture = TextureCache.get_instance().get_texture(attachment_guid);
|
||||
|
||||
// Calculate image dimensions for layout
|
||||
calculate_image_dimensions(image_size);
|
||||
@@ -48,27 +70,26 @@ private class ImageBubbleLayout : BubbleLayout
|
||||
return;
|
||||
}
|
||||
|
||||
var cached_size = SizeCache.get_instance().get_size(image_path);
|
||||
var cached_size = SizeCache.get_instance().get_size(attachment_guid);
|
||||
if (cached_size != null) {
|
||||
this.image_size = cached_size;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to load the image to get its dimensions
|
||||
try {
|
||||
warning("No image size provided, loading image to get dimensions");
|
||||
|
||||
var texture = Gdk.Texture.from_filename(image_path);
|
||||
var original_width = (float)texture.get_width();
|
||||
var original_height = (float)texture.get_height();
|
||||
|
||||
this.image_size = Graphene.Size() { width = original_width, height = original_height };
|
||||
SizeCache.get_instance().set_size(image_path, this.image_size);
|
||||
} catch (Error e) {
|
||||
// Fallback dimensions if image can't be loaded
|
||||
warning("Failed to load image %s: %s", image_path, e.message);
|
||||
this.image_size = Graphene.Size() { width = 200.0f, height = 150.0f };
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -81,9 +102,22 @@ private class ImageBubbleLayout : BubbleLayout
|
||||
}
|
||||
|
||||
try {
|
||||
cached_texture = Gdk.Texture.from_filename(image_path);
|
||||
int fd = Repository.get_instance().open_attachment_fd(attachment_guid, true);
|
||||
var stream = new UnixInputStream(fd, true);
|
||||
var pixbuf = new Gdk.Pixbuf.from_stream(stream, null);
|
||||
cached_texture = Gdk.Texture.for_pixbuf(pixbuf);
|
||||
|
||||
if (cached_texture != null) {
|
||||
TextureCache.get_instance().set_texture(attachment_guid, cached_texture);
|
||||
this.image_size = Graphene.Size() {
|
||||
width = (float)cached_texture.get_width(),
|
||||
height = (float)cached_texture.get_height()
|
||||
};
|
||||
SizeCache.get_instance().set_size(attachment_guid, this.image_size);
|
||||
parent.queue_allocate();
|
||||
}
|
||||
} catch (Error e) {
|
||||
warning("Failed to load image %s: %s", image_path, e.message);
|
||||
warning("Failed to load preview image for %s: %s", attachment_guid, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +144,7 @@ private class ImageBubbleLayout : BubbleLayout
|
||||
}
|
||||
|
||||
public override void draw_content(Snapshot snapshot) {
|
||||
queue_preview_download_if_needed();
|
||||
load_image_if_needed();
|
||||
|
||||
snapshot.save();
|
||||
|
||||
@@ -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>();
|
||||
previous_messages.add_all(_messages);
|
||||
|
||||
try {
|
||||
bool first_load = _messages.size == 0;
|
||||
string last_message_id = (first_load || force_full_reload) ? "" : _messages.get(_messages.size - 1).guid;
|
||||
|
||||
Message[] messages = Repository.get_instance().get_messages(conversation.guid);
|
||||
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;
|
||||
_messages.clear();
|
||||
participants.clear();
|
||||
|
||||
// Notify of removal
|
||||
if (old_count > 0) {
|
||||
items_changed(0, old_count, 0);
|
||||
}
|
||||
|
||||
// Process each conversation
|
||||
uint position = 0;
|
||||
|
||||
for (int i = 0; i < messages.length; i++) {
|
||||
var message = messages[i];
|
||||
participants.add(message.sender);
|
||||
|
||||
if (!first_load && !previous_messages.contains(message)) {
|
||||
// This is a new message according to the UI, schedule an animation for it.
|
||||
message.should_animate = true;
|
||||
}
|
||||
|
||||
@@ -97,10 +99,29 @@ public class MessageListModel : Object, ListModel
|
||||
position++;
|
||||
}
|
||||
|
||||
// Notify of additions
|
||||
if (position > 0) {
|
||||
items_changed(0, 0, position);
|
||||
}
|
||||
} else {
|
||||
uint old_count = _messages.size;
|
||||
uint appended = 0;
|
||||
|
||||
for (int i = 0; i < messages.length; i++) {
|
||||
var message = messages[i];
|
||||
if (previous_messages.contains(message)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
participants.add(message.sender);
|
||||
message.should_animate = true;
|
||||
_messages.add(message);
|
||||
appended++;
|
||||
}
|
||||
|
||||
if (appended > 0) {
|
||||
items_changed(old_count, 0, appended);
|
||||
}
|
||||
}
|
||||
} catch (Error e) {
|
||||
warning("Failed to load messages: %s", e.message);
|
||||
}
|
||||
|
||||
@@ -364,16 +364,15 @@ private class TranscriptDrawingArea : Widget
|
||||
// Check for attachments. For each one, add an image layout bubble
|
||||
foreach (var attachment in message.attachments) {
|
||||
Graphene.Size? image_size = null;
|
||||
if (attachment.metadata != null) {
|
||||
if (attachment.metadata != null && attachment.metadata.attribution_info != null) {
|
||||
image_size = Graphene.Size() {
|
||||
width = attachment.metadata.attribution_info.width,
|
||||
height = attachment.metadata.attribution_info.height
|
||||
};
|
||||
}
|
||||
|
||||
var image_layout = new ImageBubbleLayout(attachment.preview_path, message.from_me, this, max_width, image_size);
|
||||
var image_layout = new ImageBubbleLayout(attachment.guid, message.from_me, this, max_width, image_size);
|
||||
image_layout.id = @"image-$(attachment.guid)";
|
||||
image_layout.attachment_guid = attachment.guid;
|
||||
|
||||
if (animate) {
|
||||
start_animation(image_layout.id);
|
||||
@@ -381,16 +380,6 @@ private class TranscriptDrawingArea : Widget
|
||||
|
||||
image_layout.is_downloaded = attachment.preview_downloaded;
|
||||
items.add(image_layout);
|
||||
|
||||
// If the attachment isn't downloaded, queue a download since we are going to be showing it here.
|
||||
// TODO: Probably would be better if we only did this for stuff in the viewport.
|
||||
if (!attachment.preview_downloaded) {
|
||||
try {
|
||||
Repository.get_instance().download_attachment(attachment.guid, true);
|
||||
} catch (GLib.Error e) {
|
||||
warning("Wasn't able to message daemon about queuing attachment download: %s", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_sender = message.sender;
|
||||
|
||||
@@ -148,7 +148,7 @@ public class TranscriptView : Adw.Bin
|
||||
GLib.Idle.add(() => {
|
||||
if (needs_reload) {
|
||||
debug("Reloading messages for attachment download");
|
||||
model.load_messages();
|
||||
model.load_messages(true);
|
||||
needs_reload = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,29 +32,29 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
CD41F5972E5B8E7300E0027B /* kordophone2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = kordophone2.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CD41F5972E5B8E7300E0027B /* Kordophone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Kordophone.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */ = {
|
||||
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Daemon/kordophoned,
|
||||
Daemon/net.buzzert.kordophonecd.plist,
|
||||
);
|
||||
target = CD41F5962E5B8E7300E0027B /* kordophone2 */;
|
||||
target = CD41F5962E5B8E7300E0027B /* Kordophone */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
|
||||
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */ = {
|
||||
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */ = {
|
||||
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
|
||||
buildPhase = CD41F5D92E6284FD00E0027B /* CopyFiles */;
|
||||
membershipExceptions = (
|
||||
Daemon/net.buzzert.kordophonecd.plist,
|
||||
);
|
||||
};
|
||||
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */ = {
|
||||
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */ = {
|
||||
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
|
||||
attributesByRelativePath = {
|
||||
Daemon/kordophoned = (CodeSignOnCopy, );
|
||||
@@ -70,9 +70,9 @@
|
||||
CD41F5992E5B8E7300E0027B /* kordophone2 */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */,
|
||||
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */,
|
||||
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */,
|
||||
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */,
|
||||
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
|
||||
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
|
||||
);
|
||||
path = kordophone2;
|
||||
sourceTree = "<group>";
|
||||
@@ -102,7 +102,7 @@
|
||||
CD41F5982E5B8E7300E0027B /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CD41F5972E5B8E7300E0027B /* kordophone2.app */,
|
||||
CD41F5972E5B8E7300E0027B /* Kordophone.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -110,9 +110,9 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
CD41F5962E5B8E7300E0027B /* kordophone2 */ = {
|
||||
CD41F5962E5B8E7300E0027B /* Kordophone */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */;
|
||||
buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */;
|
||||
buildPhases = (
|
||||
CD41F5932E5B8E7300E0027B /* Sources */,
|
||||
CD41F5942E5B8E7300E0027B /* Frameworks */,
|
||||
@@ -127,12 +127,12 @@
|
||||
fileSystemSynchronizedGroups = (
|
||||
CD41F5992E5B8E7300E0027B /* kordophone2 */,
|
||||
);
|
||||
name = kordophone2;
|
||||
name = Kordophone;
|
||||
packageProductDependencies = (
|
||||
CD41F5D22E62431D00E0027B /* KeychainAccess */,
|
||||
);
|
||||
productName = kordophone2;
|
||||
productReference = CD41F5972E5B8E7300E0027B /* kordophone2.app */;
|
||||
productReference = CD41F5972E5B8E7300E0027B /* Kordophone.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -167,7 +167,7 @@
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
CD41F5962E5B8E7300E0027B /* kordophone2 */,
|
||||
CD41F5962E5B8E7300E0027B /* Kordophone */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -322,7 +322,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
DEVELOPMENT_TEAM = 3SJALV9BQ7;
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -349,7 +349,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
DEVELOPMENT_TEAM = 3SJALV9BQ7;
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -379,7 +379,7 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */ = {
|
||||
CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CD41F5A42E5B8E7400E0027B /* Debug */,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
|
||||
BuildableName = "Kordophone.app"
|
||||
BlueprintName = "Kordophone"
|
||||
ReferencedContainer = "container:kordophone2.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
|
||||
BuildableName = "Kordophone.app"
|
||||
BlueprintName = "Kordophone"
|
||||
ReferencedContainer = "container:kordophone2.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
|
||||
BuildableName = "Kordophone.app"
|
||||
BlueprintName = "Kordophone"
|
||||
ReferencedContainer = "container:kordophone2.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -14,6 +14,13 @@ struct KordophoneApp: App
|
||||
WindowGroup {
|
||||
SplitView()
|
||||
}
|
||||
.commands {
|
||||
TextEditingCommands()
|
||||
}
|
||||
|
||||
WindowGroup(id: .transcriptWindow, for: Display.Conversation.self) { selectedConversation in
|
||||
TranscriptWindowView(conversation: selectedConversation)
|
||||
}
|
||||
|
||||
Settings {
|
||||
PreferencesView()
|
||||
@@ -25,3 +32,42 @@ struct KordophoneApp: App
|
||||
print("Error: \(e.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
struct TranscriptWindowView: View
|
||||
{
|
||||
@State private var transcriptViewModel = TranscriptView.ViewModel()
|
||||
@State private var entryViewModel = MessageEntryView.ViewModel()
|
||||
private let displayedConversation: Binding<Display.Conversation?>
|
||||
|
||||
public init(conversation: Binding<Display.Conversation?>) {
|
||||
self.displayedConversation = conversation
|
||||
transcriptViewModel.displayedConversation = conversation.wrappedValue
|
||||
observeDisplayedConversationChanges()
|
||||
}
|
||||
|
||||
private func observeDisplayedConversationChanges() {
|
||||
withObservationTracking {
|
||||
_ = displayedConversation.wrappedValue
|
||||
} onChange: {
|
||||
Task { @MainActor in
|
||||
guard let displayedConversation = self.displayedConversation.wrappedValue else { return }
|
||||
transcriptViewModel.displayedConversation = displayedConversation
|
||||
|
||||
observeDisplayedConversationChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
|
||||
.navigationTitle(displayedConversation.wrappedValue?.displayName ?? "Kordophone")
|
||||
.selectedConversation(displayedConversation.wrappedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String
|
||||
{
|
||||
static let transcriptWindow = "TranscriptWindow"
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ struct ConversationListView: View
|
||||
{
|
||||
@Binding var model: ViewModel
|
||||
@Environment(\.xpcClient) private var xpcClient
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
List($model.conversations, selection: $model.selectedConversations) { conv in
|
||||
List($model.conversations, selection: $model.selectedConversation) { conv in
|
||||
let isUnread = conv.wrappedValue.unreadCount > 0
|
||||
|
||||
HStack(spacing: 0.0) {
|
||||
@@ -64,14 +65,14 @@ struct ConversationListView: View
|
||||
class ViewModel
|
||||
{
|
||||
var conversations: [Display.Conversation]
|
||||
var selectedConversations: Set<Display.Conversation.ID>
|
||||
var selectedConversation: Display.Conversation.ID?
|
||||
|
||||
private var needsReload: Bool = true
|
||||
private let client = XPCClient()
|
||||
|
||||
public init(conversations: [Display.Conversation] = []) {
|
||||
self.conversations = conversations
|
||||
self.selectedConversations = Set()
|
||||
self.selectedConversation = nil
|
||||
setNeedsReload()
|
||||
}
|
||||
|
||||
@@ -101,6 +102,11 @@ struct ConversationListView: View
|
||||
.map { Display.Conversation(from: $0) }
|
||||
|
||||
self.conversations = clientConversations
|
||||
|
||||
let unreadConversations = clientConversations.filter(\.isUnread)
|
||||
await MainActor.run {
|
||||
NSApplication.shared.dockTile.badgeLabel = unreadConversations.isEmpty ? nil : "\(unreadConversations.count)"
|
||||
}
|
||||
} catch {
|
||||
print("Error reloading conversations: \(error)")
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ struct ConversationView: View
|
||||
@Binding var entryModel: MessageEntryView.ViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
VStack(spacing: 0.0) {
|
||||
TranscriptView(model: $transcriptModel)
|
||||
MessageEntryView(viewModel: $entryModel)
|
||||
}
|
||||
@@ -23,5 +23,10 @@ struct ConversationView: View
|
||||
entryModel.handleDroppedProviders(providers)
|
||||
return true
|
||||
}
|
||||
|
||||
.onHover { isHovering in
|
||||
guard isHovering else { return }
|
||||
transcriptModel.setNeedsMarkAsRead()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -36,6 +36,7 @@ struct MessageEntryView: View
|
||||
.font(.body)
|
||||
.scrollDisabled(true)
|
||||
.disabled(selectedConversation == nil)
|
||||
.id("messageEntry")
|
||||
}
|
||||
.padding(8.0)
|
||||
.background {
|
||||
|
||||
@@ -10,7 +10,7 @@ import XPC
|
||||
|
||||
enum Display
|
||||
{
|
||||
struct Conversation: Identifiable, Hashable
|
||||
struct Conversation: Identifiable, Hashable, Codable
|
||||
{
|
||||
let id: String
|
||||
let name: String?
|
||||
@@ -27,6 +27,10 @@ enum Display
|
||||
participants.count > 1
|
||||
}
|
||||
|
||||
var isUnread: Bool {
|
||||
unreadCount > 0
|
||||
}
|
||||
|
||||
init(from c: Serialized.Conversation) {
|
||||
self.id = c.guid
|
||||
self.name = c.displayName
|
||||
@@ -112,6 +116,14 @@ enum Display
|
||||
data.previewPath
|
||||
}
|
||||
|
||||
var isFullsizeDownloaded: Bool {
|
||||
data.isDownloaded
|
||||
}
|
||||
|
||||
var fullsizePath: String {
|
||||
data.path
|
||||
}
|
||||
|
||||
init(from serialized: Serialized.Attachment, dateSent: Date, sender: Sender) {
|
||||
self.id = serialized.guid
|
||||
self.sender = sender
|
||||
|
||||
@@ -15,7 +15,7 @@ struct SplitView: View
|
||||
|
||||
private let xpcClient = XPCClient()
|
||||
private var selectedConversation: Display.Conversation? {
|
||||
guard let id = conversationListModel.selectedConversations.first else { return nil }
|
||||
guard let id = conversationListModel.selectedConversation else { return nil }
|
||||
return conversationListModel.conversations.first { $0.id == id }
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ struct SplitView: View
|
||||
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
|
||||
.xpcClient(xpcClient)
|
||||
.selectedConversation(selectedConversation)
|
||||
.navigationTitle("Kordophone")
|
||||
.navigationSubtitle(selectedConversation?.displayName ?? "")
|
||||
.onChange(of: conversationListModel.selectedConversations) { oldValue, newValue in
|
||||
transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue.first }
|
||||
.navigationTitle(selectedConversation?.displayName ?? "Kordophone")
|
||||
.navigationSubtitle(selectedConversation?.participants.joined(separator: ", ") ?? "")
|
||||
.onChange(of: conversationListModel.selectedConversation) { oldValue, newValue in
|
||||
transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
osx/kordophone2/Transcript/PreviewPanel.swift
Normal file
39
osx/kordophone2/Transcript/PreviewPanel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ struct TextBubbleItemView: View
|
||||
|
||||
BubbleView(sender: sender, date: date) {
|
||||
HStack {
|
||||
Text(text)
|
||||
Text(text.linkifiedAttributedString())
|
||||
.foregroundStyle(textColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
@@ -75,6 +75,7 @@ struct TextBubbleItemView: View
|
||||
.padding(.horizontal, 16.0)
|
||||
.padding(.vertical, 10.0)
|
||||
.background(bubbleColor)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +90,7 @@ struct ImageItemView: View
|
||||
@Environment(\.xpcClient) var xpcClient
|
||||
|
||||
@State private var containerWidth: CGFloat? = nil
|
||||
@State private var isDownloadingFullAttachment: Bool = false
|
||||
|
||||
private var aspectRatio: CGFloat {
|
||||
attachment.size?.aspectRatio ?? 1.0
|
||||
@@ -101,6 +103,8 @@ struct ImageItemView: View
|
||||
var body: some View {
|
||||
BubbleView(sender: sender, date: date) {
|
||||
let maxWidth = CGFloat.minimum(.imageMaxWidth, containerWidth ?? .imageMaxWidth)
|
||||
|
||||
Group {
|
||||
if let img {
|
||||
Image(nsImage: img)
|
||||
.resizable()
|
||||
@@ -113,6 +117,23 @@ struct ImageItemView: View
|
||||
.frame(maxWidth: maxWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// Download indicator
|
||||
.overlay {
|
||||
if isDownloadingFullAttachment {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.black.opacity(0.2))
|
||||
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onTapGesture(count: 2) {
|
||||
openAttachment()
|
||||
}
|
||||
.onGeometryChange(for: CGFloat.self,
|
||||
of: { $0.size.width },
|
||||
action: { containerWidth = $0 })
|
||||
@@ -135,6 +156,24 @@ struct ImageItemView: View
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openAttachment() {
|
||||
Task {
|
||||
var path = attachment.fullsizePath
|
||||
if !attachment.isFullsizeDownloaded {
|
||||
isDownloadingFullAttachment = true
|
||||
try await xpcClient.downloadAttachment(attachmentId: attachment.id, preview: false, awaitCompletion: true)
|
||||
|
||||
// Need to re-fetch this -- the extension may have changed.
|
||||
let info = try await xpcClient.getAttachmentInfo(attachmentId: attachment.id)
|
||||
path = info.path
|
||||
|
||||
isDownloadingFullAttachment = false
|
||||
}
|
||||
|
||||
PreviewPanel.shared.show(url: URL(filePath: path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaceholderImageItemView: View
|
||||
@@ -219,14 +258,16 @@ struct SenderAttributionView: View
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension CGFloat {
|
||||
fileprivate extension CGFloat
|
||||
{
|
||||
static let dominantCornerRadius = 16.0
|
||||
static let minorCornerRadius = 4.0
|
||||
static let minimumBubbleHorizontalPadding = 80.0
|
||||
static let imageMaxWidth = 380.0
|
||||
}
|
||||
|
||||
fileprivate extension CGSize {
|
||||
fileprivate extension CGSize
|
||||
{
|
||||
var aspectRatio: CGFloat { width / height }
|
||||
}
|
||||
|
||||
@@ -239,3 +280,28 @@ fileprivate func preferredBubbleWidth(forAttachmentSize attachmentSize: CGSize?,
|
||||
return 200.0 // fallback
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension String
|
||||
{
|
||||
func linkifiedAttributedString() -> AttributedString {
|
||||
var attributed = AttributedString(self)
|
||||
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
|
||||
return attributed
|
||||
}
|
||||
|
||||
let nsText = self as NSString
|
||||
let fullRange = NSRange(location: 0, length: nsText.length)
|
||||
detector.enumerateMatches(in: self, options: [], range: fullRange) { result, _, _ in
|
||||
guard let result, let url = result.url,
|
||||
let swiftRange = Range(result.range, in: self),
|
||||
let start = AttributedString.Index(swiftRange.lowerBound, within: attributed),
|
||||
let end = AttributedString.Index(swiftRange.upperBound, within: attributed) else { return }
|
||||
|
||||
attributed[start..<end].link = url
|
||||
attributed[start..<end].foregroundColor = NSColor.textColor
|
||||
attributed[start..<end].underlineStyle = .single
|
||||
}
|
||||
|
||||
return attributed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
|
||||
extension TranscriptView.ViewModel
|
||||
{
|
||||
internal func rebuildDisplayItems(animated: Bool = false) {
|
||||
internal func rebuildDisplayItems(animated: Bool = false, completion: () -> Void = {}) {
|
||||
var displayItems: [DisplayItem] = []
|
||||
var lastDate: Date = .distantPast
|
||||
var lastSender: Display.Sender? = nil
|
||||
@@ -53,6 +53,7 @@ extension TranscriptView.ViewModel
|
||||
let animation: Animation? = animated ? .default : nil
|
||||
withAnimation(animation) {
|
||||
self.displayItems = displayItems
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,17 @@ struct TranscriptView: View
|
||||
|
||||
@Environment(\.xpcClient) private var xpcClient
|
||||
|
||||
init(model: Binding<ViewModel>) {
|
||||
self._model = model
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
// For resetting scroll position to the "bottom"
|
||||
EmptyView()
|
||||
.id(ViewID.bottomAnchor)
|
||||
|
||||
LazyVStack(spacing: 6.0) {
|
||||
ForEach($model.displayItems.reversed()) { item in
|
||||
displayItemView(item.wrappedValue)
|
||||
@@ -28,9 +37,32 @@ struct TranscriptView: View
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// Flip vertically so newest messages are at the bottom.
|
||||
.scaleEffect(CGSize(width: 1.0, height: -1.0))
|
||||
.id(model.displayedConversation?.id)
|
||||
|
||||
// Watch for xpc events
|
||||
.task { await watchForMessageListChanges() }
|
||||
|
||||
// On conversation change, reload displayed messages and mark as read.
|
||||
.onChange(of: model.displayedConversation) { oldValue, newValue in
|
||||
Task {
|
||||
guard oldValue != newValue else { return }
|
||||
|
||||
// Reload NOW
|
||||
await model.reloadMessages(animated: false) {
|
||||
// Once that's done, scroll to the "bottom" (actually top)
|
||||
proxy.scrollTo(ViewID.bottomAnchor, anchor: .top)
|
||||
}
|
||||
}
|
||||
|
||||
Task.detached {
|
||||
// Mark as read on server, and trigger a sync.
|
||||
await model.markAsRead()
|
||||
await model.triggerSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func watchForMessageListChanges() async {
|
||||
@@ -82,6 +114,13 @@ struct TranscriptView: View
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum ViewID: String
|
||||
{
|
||||
case bottomAnchor
|
||||
}
|
||||
|
||||
// MARK: - View Model
|
||||
|
||||
@Observable
|
||||
class ViewModel
|
||||
{
|
||||
@@ -92,9 +131,11 @@ struct TranscriptView: View
|
||||
internal var messages: [Display.Message]
|
||||
internal let client = XPCClient()
|
||||
|
||||
private var needsMarkAsRead: Bool = false
|
||||
private var lastMarkAsRead: Date = .now
|
||||
|
||||
init(messages: [Display.Message] = []) {
|
||||
self.messages = messages
|
||||
observeDisplayedConversation()
|
||||
rebuildDisplayItems()
|
||||
}
|
||||
|
||||
@@ -106,7 +147,20 @@ struct TranscriptView: View
|
||||
needsReload = .yes(animated)
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await reloadMessages()
|
||||
await reloadIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func setNeedsMarkAsRead() {
|
||||
guard needsMarkAsRead == false else { return }
|
||||
guard Date.now.timeIntervalSince(lastMarkAsRead) > 5.0 else { return }
|
||||
|
||||
needsMarkAsRead = true
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await markAsRead()
|
||||
needsMarkAsRead = false
|
||||
lastMarkAsRead = .now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,22 +169,6 @@ struct TranscriptView: View
|
||||
setNeedsReload(animated: false)
|
||||
}
|
||||
|
||||
private func observeDisplayedConversation() {
|
||||
withObservationTracking {
|
||||
_ = displayedConversation
|
||||
} onChange: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
await markAsRead()
|
||||
await triggerSync()
|
||||
|
||||
setNeedsReload(animated: false)
|
||||
observeDisplayedConversation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markAsRead() async {
|
||||
guard let displayedConversation else { return }
|
||||
|
||||
@@ -151,10 +189,14 @@ struct TranscriptView: View
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadMessages() async {
|
||||
func reloadIfNeeded(completion: () -> Void = {}) async {
|
||||
guard case .yes(let animated) = needsReload else { return }
|
||||
needsReload = .no
|
||||
|
||||
await reloadMessages(animated: animated, completion: completion)
|
||||
}
|
||||
|
||||
func reloadMessages(animated: Bool, completion: () -> Void) async {
|
||||
guard let displayedConversation else { return }
|
||||
|
||||
do {
|
||||
@@ -167,8 +209,10 @@ struct TranscriptView: View
|
||||
// Only animate for incoming messages.
|
||||
let shouldAnimate = (newIds.count == 1)
|
||||
|
||||
await MainActor.run {
|
||||
self.messages = clientMessages
|
||||
self.rebuildDisplayItems(animated: animated && shouldAnimate)
|
||||
self.rebuildDisplayItems(animated: animated && shouldAnimate, completion: completion)
|
||||
}
|
||||
} catch {
|
||||
print("Message fetch error: \(error)")
|
||||
}
|
||||
|
||||
@@ -146,13 +146,33 @@ final class XPCClient
|
||||
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
|
||||
}
|
||||
|
||||
public func downloadAttachment(attachmentId: String, preview: Bool) async throws {
|
||||
public func getAttachmentInfo(attachmentId: String) async throws -> AttachmentInfo {
|
||||
var args: [String: xpc_object_t] = [:]
|
||||
args["attachment_id"] = xpcString(attachmentId)
|
||||
|
||||
let req = makeRequest(method: "GetAttachmentInfo", arguments: args)
|
||||
guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError }
|
||||
|
||||
return AttachmentInfo(
|
||||
path: reply["path"] ?? "",
|
||||
previewPath: reply["preview_path"] ?? "",
|
||||
isDownloaded: reply["is_downloaded"] ?? false,
|
||||
isPreviewDownloaded: reply["is_preview_downloaded"] ?? false
|
||||
)
|
||||
}
|
||||
|
||||
public func downloadAttachment(attachmentId: String, preview: Bool, awaitCompletion: Bool = false) async throws {
|
||||
var args: [String: xpc_object_t] = [:]
|
||||
args["attachment_id"] = xpcString(attachmentId)
|
||||
args["preview"] = xpcString(preview ? "true" : "false")
|
||||
|
||||
let req = makeRequest(method: "DownloadAttachment", arguments: args)
|
||||
_ = try await sendSync(req)
|
||||
|
||||
if awaitCompletion {
|
||||
// Wait for downloaded event
|
||||
let _ = await eventStream().first { $0 == .attachmentDownloaded(attachmentId: attachmentId) }
|
||||
}
|
||||
}
|
||||
|
||||
public func uploadAttachment(path: String) async throws -> String {
|
||||
@@ -201,6 +221,14 @@ final class XPCClient
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
struct AttachmentInfo: Decodable
|
||||
{
|
||||
let path: String
|
||||
let previewPath: String
|
||||
let isDownloaded: Bool
|
||||
let isPreviewDownloaded: Bool
|
||||
}
|
||||
|
||||
enum Error: Swift.Error
|
||||
{
|
||||
case typeError
|
||||
@@ -209,7 +237,7 @@ final class XPCClient
|
||||
case connectionError
|
||||
}
|
||||
|
||||
enum Signal
|
||||
enum Signal: Equatable
|
||||
{
|
||||
case conversationsUpdated
|
||||
case messagesUpdated(conversationId: String)
|
||||
|
||||
@@ -76,6 +76,22 @@ extension Array: XPCConvertible where Element: XPCConvertible
|
||||
}
|
||||
}
|
||||
|
||||
extension Bool: XPCConvertible
|
||||
{
|
||||
static func fromXPC(_ value: xpc_object_t) -> Bool? {
|
||||
if xpc_get_type(value) == XPC_TYPE_BOOL {
|
||||
return xpc_bool_get_value(value)
|
||||
}
|
||||
|
||||
if xpc_get_type(value) == XPC_TYPE_STRING {
|
||||
guard let cstr = xpc_string_get_string_ptr(value) else { return nil }
|
||||
return strcmp(cstr, "true") == 0
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension xpc_object_t
|
||||
{
|
||||
func getObject(_ key: String) -> xpc_object_t? {
|
||||
|
||||
Reference in New Issue
Block a user