Compare commits

..
Author SHA1 Message Date
92f86cb231 chore(deps): update dependency gradle to v9
Some checks failed
renovate/stability-days Updates have met minimum release age requirement
Dependency builds / Build web (pull_request) Successful in 4m49s
Dependency builds / Build mobile (pull_request) Failing after 7m37s
Dependency builds / Build desktop (pull_request) Successful in 8m32s
2026-08-20 18:00:23 +03:00
33 changed files with 785 additions and 1825 deletions

View file

@ -37,18 +37,6 @@ jobs:
- run: nix develop .#electron --command pnpm run build:packages
- run: nix develop .#electron --command pnpm run build:desktop
native-mtp:
if: ${{ github.actor == 'rasensprenger' }}
name: Test native MTP
runs-on: nixos
steps:
- run: nix profile add nixpkgs#nodejs_24
- uses: https://data.forgejo.org/actions/checkout@v4
with:
persist-credentials: false
- run: git submodule update --init --recursive
- run: nix develop .#electron --command bash -lc 'cd apps/tauri/src-tauri && cargo test'
mobile:
if: ${{ github.actor == 'rasensprenger' }}
name: Build mobile

View file

@ -674,6 +674,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
@ -883,6 +889,7 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
@ -967,14 +974,25 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"pem-rfc7468 0.7.0",
"zeroize",
]
[[package]]
name = "der"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
dependencies = [
"const-oid",
"pem-rfc7468",
"const-oid 0.10.2",
"pem-rfc7468 1.0.0",
"zeroize",
]
@ -1039,7 +1057,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"const-oid 0.10.2",
"crypto-common 0.2.2",
"ctutils",
]
@ -1186,14 +1204,38 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8 0.10.2",
"signature 2.2.0",
]
[[package]]
name = "ed25519"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
"pkcs8",
"signature",
"pkcs8 0.11.0",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek 4.1.3",
"ed25519 2.2.3",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]]
@ -1203,10 +1245,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek 5.0.0",
"ed25519",
"ed25519 3.0.0",
"serde",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"subtle",
"zeroize",
]
@ -2695,14 +2737,14 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
dependencies = [
"const-oid",
"const-oid 0.10.2",
"crypto-common 0.2.2",
"ctutils",
"hybrid-array",
"module-lattice",
"pkcs8",
"pkcs8 0.11.0",
"shake",
"signature",
"signature 3.0.0",
]
[[package]]
@ -2744,50 +2786,72 @@ dependencies = [
[[package]]
name = "mtp"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"mtp-client",
"mtp-codec",
"mtp-common",
"mtp-crypto",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-host",
"mtp-transport",
"mtp-type-map",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-webserver",
]
[[package]]
name = "mtp-client"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"mtp-codec",
"mtp-common",
"mtp-crypto",
"mtp-transport",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"rand 0.10.2",
"tokio",
]
[[package]]
name = "mtp-codec"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"base64 0.23.1",
"byteorder",
"mtp-common",
"mtp-crypto",
"mtp-type-map",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"rand 0.10.2",
]
[[package]]
name = "mtp-codec"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [
"base64 0.23.1",
"byteorder",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"rand 0.10.2",
"thiserror 2.0.19",
]
[[package]]
name = "mtp-common"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"quinn",
"rustls",
"thiserror 2.0.19",
"wtransport",
]
[[package]]
name = "mtp-common"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [
"quinn",
"rustls",
@ -2797,18 +2861,18 @@ dependencies = [
[[package]]
name = "mtp-crypto"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"chacha20poly1305",
"ed25519-dalek",
"ed25519-dalek 2.2.0",
"getrandom 0.4.3",
"hkdf",
"ml-dsa",
"mlkem-tls",
"rand 0.10.2",
"rand_core 0.6.4",
"rand_core 0.10.1",
"rustls",
"serde",
"sha2 0.11.0",
@ -2818,31 +2882,67 @@ dependencies = [
]
[[package]]
name = "mtp-host"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
name = "mtp-crypto"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [
"mtp-codec",
"mtp-common",
"mtp-crypto",
"mtp-transport",
"base64 0.23.1",
"chacha20poly1305",
"ed25519-dalek 3.0.0",
"getrandom 0.4.3",
"hkdf",
"ml-dsa",
"rand 0.10.2",
"rand_core 0.10.1",
"rustls",
"sha2 0.11.0",
"thiserror 2.0.19",
"tokio",
"zeroize",
]
[[package]]
name = "mtp-host"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"rand 0.10.2",
"tokio",
"tracing",
"wtransport",
]
[[package]]
name = "mtp-transport"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"async-trait",
"mtp-codec",
"mtp-common",
"mtp-crypto",
"rand 0.10.2",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"rcgen",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"tokio",
"tracing",
"wtransport",
]
[[package]]
name = "mtp-transport"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [
"async-trait",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"rcgen",
"rustls",
"rustls-native-certs",
@ -2850,13 +2950,21 @@ dependencies = [
"tokio",
"tracing",
"wtransport",
"zeroize",
]
[[package]]
name = "mtp-type-map"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"serde",
"serde_yaml",
]
[[package]]
name = "mtp-type-map"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [
"serde",
"serde_yaml",
@ -2864,8 +2972,8 @@ dependencies = [
[[package]]
name = "mtp-webserver"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"async-trait",
"bytes",
@ -2876,12 +2984,13 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-util",
"mtp-codec",
"mtp-common",
"mtp-crypto",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-host",
"mtp-transport",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"quinn",
"rand 0.10.2",
"rustls",
"thiserror 2.0.19",
"tokio",
@ -3383,6 +3492,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "pem-rfc7468"
version = "1.0.0"
@ -3468,14 +3586,24 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der 0.7.10",
"spki 0.7.3",
]
[[package]]
name = "pkcs8"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
dependencies = [
"der",
"spki",
"der 0.8.1",
"spki 0.8.0",
]
[[package]]
@ -4464,6 +4592,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "signature"
version = "3.0.0"
@ -4572,6 +4709,16 @@ dependencies = [
"system-deps",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der 0.7.10",
]
[[package]]
name = "spki"
version = "0.8.0"
@ -4579,7 +4726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
dependencies = [
"base64ct",
"der",
"der 0.8.1",
]
[[package]]
@ -5142,6 +5289,7 @@ dependencies = [
"base64 0.22.1",
"jni 0.22.4",
"mtp",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"reqwest",
"serde",
"serde_json",

View file

@ -24,7 +24,8 @@ serde_json = "1"
base64 = "0.22"
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a5c8d4f0c898c78351e9d54124886c86e789a22a", features = ["client", "crypto"] }
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "7182272e3edb0079d7b74b45b2f62a298d2fee22", features = ["client", "crypto"] }
mtp-transport = { git = "https://git.methanium.net/methanium/mtp.git", rev = "b067614a684eb1856bc5db7b3fd82148c036ce6b" }
webpki-root-certs = "1"
tauri-plugin-deep-link = "2"
tauri-plugin-notification = "2"
@ -35,11 +36,6 @@ version = "2"
features = []
default-features = true
[target.'cfg(not(target_os = "android"))'.dependencies.tauri]
version = "2"
features = []
default-features = true
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.22"

View file

@ -1,6 +1,9 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use lib::log;
fn main() {
mobile_lib::run();
log("Test test 123")
}

View file

@ -2,7 +2,7 @@ use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex, OnceLock, RwLock,
};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::Duration;
use base64::{
engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
@ -16,7 +16,6 @@ use mtp::crypto::{
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc;
const EVENT_NAME: &str = "mtp://event";
const DISCONNECTED: u8 = 0;
@ -25,9 +24,6 @@ const CONNECTED: u8 = 2;
const CHAT_SECRET_SALT: &[u8] = b"tensamin-chat-secret-v1";
const CHAT_MESSAGE_SALT: &[u8] = b"tensamin-chat-message-v1";
const CHAT_SECRET_SCHEME: &str = "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1";
const INITIAL_SYNC_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_BUFFERED_INITIAL_FRAMES: usize = 1_000;
const NOTIFICATION_QUEUE_CAPACITY: usize = 32;
#[cfg(target_os = "android")]
const ROOT_YE_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\
MIIB2TCCAWCgAwIBAgIRAKQCa6LvbHwg1AR+XmWmk4AwCgYIKoZIzj0EAwMwLjEL\n\
@ -99,7 +95,7 @@ enum MtpEvent {
pub struct MtpManager {
runtime: tokio::runtime::Runtime,
config: RwLock<Option<MtpConfig>>,
connection: RwLock<Option<Arc<ManagedConnection>>>,
connection: RwLock<Option<Arc<MTPConnection>>>,
snapshot: RwLock<MtpSnapshot>,
generation: AtomicU64,
enabled: AtomicBool,
@ -108,46 +104,6 @@ pub struct MtpManager {
start_lock: Mutex<()>,
}
struct RequestIdAllocator {
next: AtomicU64,
}
impl RequestIdAllocator {
fn new() -> Self {
Self {
next: AtomicU64::new(1),
}
}
fn next(&self) -> Result<u32, String> {
let value = self.next.fetch_add(1, Ordering::Relaxed);
u32::try_from(value)
.map_err(|_| "MTP request ID space exhausted for this connection".to_string())
}
}
struct ManagedConnection {
mtp: Arc<MTPConnection>,
request_ids: RequestIdAllocator,
}
impl ManagedConnection {
async fn next_request_id(&self) -> Result<u32, String> {
let id = self.request_ids.next();
if id.is_err() {
self.mtp.sender.close().await;
}
id
}
}
struct PreparedConnection {
connection: MTPConnection,
request_ids: RequestIdAllocator,
initial_state: Value,
buffered_frames: Vec<CommunicationValue>,
}
static MANAGER: OnceLock<MtpManager> = OnceLock::new();
pub fn manager() -> &'static MtpManager {
@ -193,7 +149,7 @@ impl MtpManager {
.take()
{
self.runtime
.spawn(async move { connection.mtp.sender.close().await });
.spawn(async move { connection.sender.close().await });
}
self.set_snapshot(MtpSnapshot {
generation,
@ -215,7 +171,7 @@ impl MtpManager {
.take()
{
self.runtime
.spawn(async move { connection.mtp.sender.close().await });
.spawn(async move { connection.sender.close().await });
}
self.set_snapshot(MtpSnapshot {
generation: self.generation.load(Ordering::SeqCst),
@ -275,16 +231,20 @@ impl MtpManager {
self.enabled.load(Ordering::SeqCst) && self.generation.load(Ordering::SeqCst) == generation
}
async fn request(&self, type_name: &str, data: Value) -> Result<Value, String> {
async fn request(
&self,
type_name: &str,
data: Value,
id: Option<u32>,
) -> Result<Value, String> {
let connection = self
.connection
.read()
.map_err(|_| "MTP connection lock is unavailable")?
.clone()
.ok_or_else(|| "MTP is not connected".to_string())?;
let request = json_to_frame(type_name, data, connection.next_request_id().await?)?;
let request = json_to_frame(type_name, data, id)?;
let response = connection
.mtp
.request(&request, None)
.await
.map_err(|error| error.to_string())?;
@ -301,12 +261,9 @@ async fn supervise(generation: u64) {
manager.log(2, "Starting native MTP connection", None);
android_status("Connecting");
match connect(&config).await {
Ok(prepared) => {
Ok((connection, state)) => {
delay = Duration::from_secs(1);
let connection = Arc::new(ManagedConnection {
mtp: Arc::new(prepared.connection),
request_ids: prepared.request_ids,
});
let connection = Arc::new(connection);
let stale = {
let _guard = manager.start_lock.lock().expect("start lock poisoned");
let mut current = manager
@ -322,47 +279,22 @@ async fn supervise(generation: u64) {
generation,
ready_state: CONNECTED,
identified: true,
state: Some(prepared.initial_state),
state: Some(state),
error: None,
});
false
}
};
if stale {
connection.mtp.sender.close().await;
connection.sender.close().await;
break;
}
android_status("Connected");
manager.log(2, "Native MTP connection established", None);
let (notification_tx, mut notification_rx) =
mpsc::channel(NOTIFICATION_QUEUE_CAPACITY);
let notification_connection = connection.clone();
let notification_config = config.clone();
let notification_worker = tokio::spawn(async move {
while let Some(frame) = notification_rx.recv().await {
if !manager().is_current(generation) {
break;
}
if let Err(error) = notify_message(
&notification_config,
notification_connection.clone(),
&frame,
)
.await
{
eprintln!("failed to create background message notification: {error}");
}
}
});
for frame in prepared.buffered_frames {
handle_push(generation, &notification_tx, frame).await;
}
while manager.is_current(generation) {
match connection.mtp.receive().await {
Ok(frame) => handle_push(generation, &notification_tx, frame).await,
match connection.receive().await {
Ok(frame) => handle_push(generation, connection.clone(), frame).await,
Err(error) => {
let _guard = manager.start_lock.lock().expect("start lock poisoned");
if manager.is_current(generation) {
@ -378,13 +310,6 @@ async fn supervise(generation: u64) {
}
}
}
drop(notification_tx);
notification_worker.abort();
if let Err(error) = notification_worker.await {
if !error.is_cancelled() {
eprintln!("background notification worker failed: {error}");
}
}
let mut current = manager
.connection
.write()
@ -414,21 +339,12 @@ async fn supervise(generation: u64) {
break;
}
android_status("Reconnecting");
tokio::time::sleep(jittered_retry_delay(delay)).await;
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(60));
}
}
fn jittered_retry_delay(delay: Duration) -> Duration {
let entropy = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.subsec_nanos())
.unwrap_or_default();
let percent = 80 + entropy % 41;
delay.mul_f64(percent as f64 / 100.0)
}
async fn connect(config: &MtpConfig) -> Result<PreparedConnection, String> {
async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> {
let (url, public_key) = resolve_endpoint(config)
.await
.map_err(|error| format!("endpoint discovery failed: {error}"))?;
@ -465,10 +381,39 @@ async fn connect(config: &MtpConfig) -> Result<PreparedConnection, String> {
.map_err(|error| format!("transport authentication failed: {error}"))?;
manager().log(2, "Native MTP authentication completed", None);
let (state, buffered_frames) = await_initial_state(&connection).await?;
let request_ids = RequestIdAllocator::new();
let (initial_state, ack) = prepare_initial_state_ack(&state, &request_ids)?;
let response = tokio::time::timeout(INITIAL_SYNC_TIMEOUT, connection.request(&ack, None))
let connected = CommunicationValue::new(CommunicationType::ClientConnected)
.add_typed_default(
DataType::SessionId,
DataValue::UnsignedNumber(current_millis() as u128),
)
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::CacheValid, DataValue::BoolFalse)
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0));
let state = tokio::time::timeout(
Duration::from_secs(30),
connection.request(&connected, None),
)
.await
.map_err(|_| "initial state synchronization timed out".to_string())?
.map_err(|error| format!("initial state synchronization failed: {error}"))?;
if !state.is_type(CommunicationType::ClientStateSync) {
return Err(format!(
"expected ClientStateSync, received {}",
state.get_type_name().unwrap_or("unknown")
));
}
let session_id = state
.get_data(DataType::SessionId)
.as_number()
.ok_or("ClientStateSync omitted SessionId")?;
let version = state
.get_data(DataType::VersionNumber)
.as_number()
.ok_or("ClientStateSync omitted VersionNumber")?;
let ack = CommunicationValue::new(CommunicationType::ClientStateAck)
.add_typed_default(DataType::SessionId, number_to_data(session_id))
.add_typed_default(DataType::VersionNumber, number_to_data(version));
let response = tokio::time::timeout(Duration::from_secs(30), connection.request(&ack, None))
.await
.map_err(|_| "state acknowledgement timed out".to_string())?
.map_err(|error| format!("state acknowledgement failed: {error}"))?;
@ -478,182 +423,7 @@ async fn connect(config: &MtpConfig) -> Result<PreparedConnection, String> {
{
return Err(format!("ClientStateAck failed: {response}"));
}
Ok(PreparedConnection {
connection,
request_ids,
initial_state,
buffered_frames,
})
}
fn prepare_initial_state_ack(
state: &CommunicationValue,
request_ids: &RequestIdAllocator,
) -> Result<(Value, CommunicationValue), String> {
let initial_state = frame_data_to_json(state)?;
validate_client_state_sync(&initial_state)?;
let data = initial_state
.as_object()
.ok_or("ClientStateSync payload is not an object")?;
let session_id = required_integer(data, "SessionId")?;
let version = required_integer(data, "VersionNumber")?;
let ack = CommunicationValue::new(CommunicationType::ClientStateAck)
.with_id(request_ids.next()?)
.add_typed_default(DataType::SessionId, number_to_data(session_id))
.add_typed_default(DataType::VersionNumber, number_to_data(version));
Ok((initial_state, ack))
}
fn validate_client_state_sync(state: &Value) -> Result<(), String> {
let data = state
.as_object()
.ok_or("ClientStateSync payload is not an object")?;
if required_integer(data, "SessionId")? <= 0 {
return Err("ClientStateSync SessionId must be a positive integer".into());
}
for field in ["VersionNumber", "CacheSchemaVersion"] {
if required_integer(data, field)? < 0 {
return Err(format!("ClientStateSync {field} must be nonnegative"));
}
}
match data.get("SyncMode").and_then(Value::as_str) {
Some("full" | "delta") => {}
_ => return Err("ClientStateSync SyncMode must be 'full' or 'delta'".into()),
}
for field in ["Contacts", "Communities", "Calls", "Messages"] {
if !data.get(field).is_some_and(Value::is_array) {
return Err(format!("ClientStateSync {field} must be an array"));
}
}
for field in ["DeletedMessageIds", "DeletedContactIds"] {
if let Some(value) = data.get(field) {
let values = value
.as_array()
.ok_or_else(|| format!("ClientStateSync {field} must be an array"))?;
if values.iter().any(|value| !value.is_number()) {
return Err(format!("ClientStateSync {field} must contain numbers"));
}
}
}
validate_object_array(data, "Communities", |_| Ok(()))?;
validate_object_array(data, "Contacts", validate_contact)?;
validate_object_array(data, "Calls", validate_call)?;
validate_object_array(data, "Messages", validate_message)?;
Ok(())
}
fn validate_object_array(
data: &Map<String, Value>,
field: &str,
validate: impl Fn(&Map<String, Value>) -> Result<(), String>,
) -> Result<(), String> {
let values = data
.get(field)
.and_then(Value::as_array)
.ok_or_else(|| format!("ClientStateSync {field} must be an array"))?;
for value in values {
let object = value
.as_object()
.ok_or_else(|| format!("ClientStateSync {field} entries must be objects"))?;
validate(object)?;
}
Ok(())
}
fn validate_contact(contact: &Map<String, Value>) -> Result<(), String> {
if !contact.get("UserId").is_some_and(Value::is_number) {
return Err("ClientStateSync contact omitted numeric UserId".into());
}
if let Some(messages) = contact.get("Messages") {
let messages = messages
.as_array()
.ok_or("ClientStateSync contact Messages must be an array")?;
for message in messages {
validate_message(
message
.as_object()
.ok_or("ClientStateSync contact message must be an object")?,
)?;
}
}
Ok(())
}
fn validate_call(call: &Map<String, Value>) -> Result<(), String> {
if !call.get("CallId").is_some_and(Value::is_string) {
return Err("ClientStateSync call omitted string CallId".into());
}
let members = call
.get("CallMembers")
.and_then(Value::as_array)
.ok_or("ClientStateSync call omitted CallMembers array")?;
if members.iter().any(|member| !member.is_number()) {
return Err("ClientStateSync CallMembers must contain numbers".into());
}
Ok(())
}
fn validate_message(message: &Map<String, Value>) -> Result<(), String> {
for field in ["SenderId", "SendTime"] {
if !message.get(field).is_some_and(Value::is_number) {
return Err(format!("ClientStateSync message omitted numeric {field}"));
}
}
let content = message
.get("Content")
.and_then(Value::as_str)
.ok_or("ClientStateSync message omitted string Content")?;
STANDARD
.decode(content)
.or_else(|_| STANDARD_NO_PAD.decode(content))
.map_err(|_| "ClientStateSync message Content must be base64".to_string())?;
if let Some(state) = message.get("MessageState") {
match state.as_str() {
Some("read" | "received" | "sent" | "sending" | "awaiting") => {}
_ => return Err("ClientStateSync message has invalid MessageState".into()),
}
}
Ok(())
}
fn required_integer(data: &Map<String, Value>, field: &str) -> Result<i128, String> {
let value = data
.get(field)
.ok_or_else(|| format!("ClientStateSync omitted {field}"))?;
if let Some(value) = value.as_i64() {
return Ok(value as i128);
}
value
.as_u64()
.map(|value| value as i128)
.ok_or_else(|| format!("ClientStateSync {field} must be an integer"))
}
async fn await_initial_state(
connection: &MTPConnection,
) -> Result<(CommunicationValue, Vec<CommunicationValue>), String> {
let mut buffered = Vec::new();
let deadline = tokio::time::Instant::now() + INITIAL_SYNC_TIMEOUT;
loop {
let frame = tokio::time::timeout_at(deadline, connection.receive())
.await
.map_err(|_| "initial state synchronization timed out".to_string())?
.map_err(|error| format!("initial state synchronization failed: {error}"))?;
if frame.is_type(CommunicationType::ErrorNoIota) {
return Err("No Iota is currently connected".into());
}
if frame.is_type(CommunicationType::ClientStateSync) {
return Ok((frame, buffered));
}
if buffered.len() == MAX_BUFFERED_INITIAL_FRAMES {
return Err("initial state synchronization buffered too many frames".into());
}
buffered.push(frame);
}
Ok((connection, frame_data_to_json(&state)?))
}
async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String> {
@ -695,11 +465,7 @@ async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String
))
}
async fn handle_push(
generation: u64,
notification_tx: &mpsc::Sender<CommunicationValue>,
frame: CommunicationValue,
) {
async fn handle_push(generation: u64, connection: Arc<MTPConnection>, frame: CommunicationValue) {
let manager = manager();
if let Ok(message) = frame_to_json(&frame) {
manager.emit(MtpEvent::Message {
@ -712,7 +478,7 @@ async fn handle_push(
{
if let Some(partner_id) = frame
.get_data(DataType::ChatPartnerId)
.and_then(DataValue::as_number)
.as_number()
.and_then(|value| u64::try_from(value).ok())
{
if let Err(error) = android_cancel_notification(partner_id) {
@ -721,39 +487,40 @@ async fn handle_push(
}
}
if frame.is_type(CommunicationType::MessageLive) && !manager.ui_visible.load(Ordering::SeqCst) {
if notification_tx.try_send(frame).is_err() {
eprintln!("background message notification queue is full");
if let Err(error) = notify_message(connection, &frame).await {
eprintln!("failed to create background message notification: {error}");
}
}
}
async fn notify_message(
config: &MtpConfig,
connection: Arc<ManagedConnection>,
connection: Arc<MTPConnection>,
frame: &CommunicationValue,
) -> Result<(), String> {
let sender_id = frame
.get_data(DataType::SenderId)
.and_then(DataValue::as_number)
.as_number()
.and_then(|value| u64::try_from(value).ok())
.ok_or("MessageLive omitted SenderId")?;
let message = frame
.get_data(DataType::Message)
.ok_or("MessageLive omitted Message")?;
let content = container_value(message, DataType::AppContent)
let message = frame.get_data(DataType::Message);
let content = container_value(message, DataType::Content)
.and_then(DataValue::as_str)
.ok_or("MessageLive omitted Content")?;
let config = manager()
.config
.read()
.expect("config lock poisoned")
.clone()
.ok_or("missing config")?;
let keyring_bytes = decode_browser_base64(&config.keyring)?;
let keyring = Keyring::from_bytes(&keyring_bytes).map_err(|error| error.to_string())?;
let chat_id = derive_chat_id(config.user_id, sender_id);
let secret_id = format!("chat:{chat_id}:main");
let secret_request = CommunicationValue::new(CommunicationType::GetChatSecret)
.with_id(connection.next_request_id().await?)
.add_typed_default(DataType::UserId, DataValue::Str(config.user_id.to_string()))
.add_typed_default(DataType::ChatId, DataValue::Str(chat_id.clone()))
.add_typed_default(DataType::SecretId, DataValue::Str(secret_id.clone()));
let secret = connection
.mtp
.request(&secret_request, None)
.await
.map_err(|error| error.to_string())?;
@ -765,7 +532,7 @@ async fn notify_message(
}
let version = secret
.get_data(DataType::VersionNumber)
.and_then(DataValue::as_number)
.as_number()
.ok_or("missing secret version")?;
let encrypted_secret = secret
.get_bytes(DataType::EncryptedSecret)
@ -793,14 +560,11 @@ async fn notify_message(
.decrypt(&ciphertext, b"")
.map_err(|error| error.to_string())?;
let user_request = CommunicationValue::new(CommunicationType::GetUserData)
.with_id(connection.next_request_id().await?)
.add_typed_default(
DataType::UserId,
DataValue::UnsignedNumber(sender_id as u128),
);
let user_request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default(
DataType::UserId,
DataValue::UnsignedNumber(sender_id as u128),
);
let user = connection
.mtp
.request(&user_request, None)
.await
.map_err(|error| error.to_string())?;
@ -888,42 +652,24 @@ fn container_value(value: &DataValue, field: DataType) -> Option<&DataValue> {
value.get_field(id)
}
fn wire_field_name(name: &str) -> &str {
match name {
"Content" => "AppContent",
"CreatedAt" => "AppCreatedAt",
"MessageId" => "AppMessageId",
_ => name,
}
}
fn application_field_name(name: &str) -> &str {
match name {
"AppContent" => "Content",
"AppCreatedAt" => "CreatedAt",
"AppMessageId" => "MessageId",
_ => name,
}
}
fn json_to_frame(type_name: &str, data: Value, id: u32) -> Result<CommunicationValue, String> {
fn json_to_frame(
type_name: &str,
data: Value,
id: Option<u32>,
) -> Result<CommunicationValue, String> {
let comm_type = CommunicationType::from_name(type_name)
.ok_or_else(|| format!("unknown communication type: {type_name}"))?;
let mut frame = CommunicationValue::new(comm_type).with_id(id);
let mut frame = CommunicationValue::new(comm_type);
if let Some(id) = id {
frame = frame.with_id(id);
}
let Value::Object(fields) = data else {
return Err("MTP request data must be an object".into());
};
let mut translated_fields = std::collections::HashSet::<String>::with_capacity(fields.len());
for (name, value) in fields {
let wire_name = wire_field_name(&name).to_owned();
if !translated_fields.insert(wire_name.clone()) {
return Err(format!(
"duplicate MTP field after translation: {wire_name}"
));
}
let data_type =
DataType::from_name(&wire_name).ok_or_else(|| format!("unknown data type: {name}"))?;
frame = frame.add_typed_default(data_type, json_to_data(&wire_name, value)?);
DataType::from_name(&name).ok_or_else(|| format!("unknown data type: {name}"))?;
frame = frame.add_typed_default(data_type, json_to_data(&name, value)?);
}
Ok(frame)
}
@ -961,21 +707,13 @@ fn json_to_data(field: &str, value: Value) -> Result<DataValue, String> {
),
Value::Object(fields) => {
let mut entries = Vec::with_capacity(fields.len());
let mut translated_fields =
std::collections::HashSet::<String>::with_capacity(fields.len());
for (name, value) in fields {
let wire_name = wire_field_name(&name).to_owned();
if !translated_fields.insert(wire_name.clone()) {
return Err(format!(
"duplicate MTP field after translation: {wire_name}"
));
}
let data_type = DataType::from_name(&wire_name)
let data_type = DataType::from_name(&name)
.ok_or_else(|| format!("unknown nested data type: {name}"))?;
let id = data_type
.try_to_id(&TypeMap::latest())
.ok_or_else(|| format!("unmapped data type: {name}"))?;
entries.push((id, json_to_data(&wire_name, value)?));
entries.push((id, json_to_data(&name, value)?));
}
DataValue::Container(entries)
}
@ -999,8 +737,8 @@ fn is_bytes_field(field: &str) -> bool {
fn frame_to_json(frame: &CommunicationValue) -> Result<Value, String> {
let mut result = Map::new();
if let Some(id) = frame.id() {
result.insert("id".into(), Value::from(id));
if frame.get_id() != 0 {
result.insert("id".into(), Value::from(frame.get_id()));
}
result.insert(
"type".into(),
@ -1013,20 +751,11 @@ fn frame_to_json(frame: &CommunicationValue) -> Result<Value, String> {
fn frame_data_to_json(frame: &CommunicationValue) -> Result<Value, String> {
let map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest);
let mut result = Map::new();
let entries = frame
.data()
.ok_or("MTP frame payload is not a data container")?;
for (id, value) in entries {
for (id, value) in frame.data() {
let name = map
.data_type_name(id.0)
.ok_or_else(|| format!("unknown data type id: {}", id.0))?;
let application_name = application_field_name(name);
if result.contains_key(application_name) {
return Err(format!(
"duplicate MTP field after translation: {application_name}"
));
}
result.insert(application_name.to_owned(), data_to_json(value, &map)?);
result.insert(name.to_owned(), data_to_json(value, &map)?);
}
Ok(Value::Object(result))
}
@ -1057,13 +786,7 @@ fn data_to_json(value: &DataValue, map: &TypeMap) -> Result<Value, String> {
let name = map
.data_type_name(id.0)
.ok_or_else(|| format!("unknown nested data type id: {}", id.0))?;
let application_name = application_field_name(name);
if object.contains_key(application_name) {
return Err(format!(
"duplicate MTP field after translation: {application_name}"
));
}
object.insert(application_name.to_owned(), data_to_json(value, map)?);
object.insert(name.to_owned(), data_to_json(value, map)?);
}
Value::Object(object)
}
@ -1078,15 +801,16 @@ fn number_to_json(value: i128) -> Result<Value, String> {
.map_err(|_| "number exceeds JSON range".into())
}
fn current_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[cfg(test)]
mod tests {
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use serde_json::json;
use super::{
container_value, decode_browser_base64, decode_sdk_bytes, frame_to_json,
jittered_retry_delay, json_to_frame, prepare_initial_state_ack, RequestIdAllocator,
};
use super::{decode_browser_base64, decode_sdk_bytes};
#[test]
fn browser_base64_accepts_file_whitespace_and_missing_padding() {
@ -1099,148 +823,11 @@ mod tests {
assert_eq!(decode_sdk_bytes("0x01:02-ff").unwrap(), [1, 2, 255]);
assert_eq!(decode_sdk_bytes("AQI=").unwrap(), [1, 2]);
}
#[test]
fn request_ids_are_nonzero_and_monotonic() {
let ids = RequestIdAllocator::new();
assert_eq!(ids.next().unwrap(), 1);
assert_eq!(ids.next().unwrap(), 2);
}
#[test]
fn retry_jitter_stays_within_policy_bounds() {
let delay = jittered_retry_delay(std::time::Duration::from_secs(10));
assert!(delay >= std::time::Duration::from_secs(8));
assert!(delay <= std::time::Duration::from_secs(12));
}
#[test]
fn json_content_uses_app_content_wire_type() {
let frame = json_to_frame(
"MessageEdit",
json!({
"Content": "ciphertext",
"ChatPartnerId": 42,
"SendTime": 10,
}),
1,
)
.unwrap();
assert_eq!(
frame
.get_data(DataType::AppContent)
.and_then(DataValue::as_str),
Some("ciphertext")
);
}
#[test]
fn nested_json_content_uses_app_content_wire_type() {
let frame = json_to_frame(
"MessageEdit",
json!({
"Message": { "Content": "ciphertext" },
}),
1,
)
.unwrap();
let message = frame.get_data(DataType::Message).unwrap();
assert_eq!(
container_value(message, DataType::AppContent).and_then(DataValue::as_str),
Some("ciphertext")
);
}
#[test]
fn app_content_is_exposed_as_content_to_frontend() {
let frame = CommunicationValue::new(CommunicationType::MessageEditLive)
.with_id(1)
.add_typed_default(DataType::AppContent, DataValue::Str("ciphertext".into()));
let json = frame_to_json(&frame).unwrap();
assert_eq!(json["data"]["Content"], "ciphertext");
assert!(json["data"].get("AppContent").is_none());
}
#[test]
fn translated_field_collisions_are_rejected() {
assert!(json_to_frame(
"MessageEdit",
json!({ "Content": "a", "AppContent": "b" }),
1,
)
.is_err());
}
fn valid_initial_state() -> CommunicationValue {
CommunicationValue::new(CommunicationType::ClientStateSync)
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::SyncMode, DataValue::Str("full".into()))
.add_typed_default(DataType::Contacts, DataValue::Array(vec![]))
.add_typed_default(DataType::Communities, DataValue::Array(vec![]))
.add_typed_default(DataType::Calls, DataValue::Array(vec![]))
.add_typed_default(DataType::Messages, DataValue::Array(vec![]))
}
#[test]
fn valid_initial_state_is_prepared_before_ack() {
let ids = RequestIdAllocator::new();
let (state, ack) = prepare_initial_state_ack(&valid_initial_state(), &ids).unwrap();
assert_eq!(state["SyncMode"], "full");
assert!(ack.is_type(CommunicationType::ClientStateAck));
assert_eq!(ack.id(), Some(1));
}
#[test]
fn malformed_initial_state_does_not_prepare_ack() {
let ids = RequestIdAllocator::new();
let malformed = valid_initial_state()
.add_typed_default(DataType::SyncMode, DataValue::Str("invalid".into()));
assert!(prepare_initial_state_ack(&malformed, &ids).is_err());
assert_eq!(
ids.next().unwrap(),
1,
"no acknowledgement ID was allocated"
);
}
#[test]
fn malformed_nested_initial_state_does_not_prepare_ack() {
let ids = RequestIdAllocator::new();
let malformed = CommunicationValue::new(CommunicationType::ClientStateSync)
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::SyncMode, DataValue::Str("full".into()))
.add_typed_default(
DataType::Contacts,
DataValue::Array(vec![DataValue::Container(vec![])]),
)
.add_typed_default(DataType::Communities, DataValue::Array(vec![]))
.add_typed_default(DataType::Calls, DataValue::Array(vec![]))
.add_typed_default(DataType::Messages, DataValue::Array(vec![]));
assert!(prepare_initial_state_ack(&malformed, &ids).is_err());
assert_eq!(
ids.next().unwrap(),
1,
"no acknowledgement ID was allocated"
);
}
}
#[tauri::command]
pub async fn mtp_request(type_name: String, data: Value) -> Result<Value, String> {
manager().request(&type_name, data).await
pub async fn mtp_request(type_name: String, data: Value, id: Option<u32>) -> Result<Value, String> {
manager().request(&type_name, data, id).await
}
#[tauri::command]

View file

@ -55,7 +55,6 @@
"esbuild": "^0.28.1",
"eslint": "^10.8.0",
"globals": "^17.9.0",
"mtp": "*",
"typescript": "~6.0.3",
"typescript-eslint": "^8.66.0",
"vite": "^8.2.1"

View file

@ -63,7 +63,6 @@
pipewire
systemd
wayland
webkitgtk_4_1
libX11
libXScrnSaver
libXcomposite
@ -185,7 +184,6 @@
pipewire
systemd
wayland
webkitgtk_4_1
libX11
libXScrnSaver
libXcomposite

View file

@ -29,22 +29,22 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^26.2.0",
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.8.1",
"@typescript-eslint/parser": "^8.66.0",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"fallow": "^3.17.0",
"globals": "^17.11.0",
"fallow": "^3.14.0",
"globals": "^17.9.0",
"prettier": "^3.9.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
"vitest": "^4.1.11"
"typescript-eslint": "^8.66.0",
"vitest": "^4.1.10"
},
"dependencies": {
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz",
"mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz",
"sonner": "^2.0.8"
"@methanium/ui": "*",
"mtp": "*",
"sonner": "^2.0.7"
}
}

View file

@ -28,7 +28,6 @@
"deepfilternet3-noise-filter": "1.3.0",
"livekit-client": "^2.21.0",
"lucide-react": "^1.29.0",
"mtp": "*",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.1",

View file

@ -19,6 +19,7 @@ export function getMediaShareAdapter(): MediaShareAdapter {
export type {
MediaShareAdapter,
MediaShareCapabilities,
MediaShareKind,
MediaShareRequest,
MediaShareSession,

View file

@ -15,7 +15,6 @@
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"mtp": "*",
"react": "^19.2.8",
"react-dom": "^19.2.8"
}

View file

@ -13,13 +13,11 @@ export function deriveCallSecretId(callId: string): string {
}
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
return crypto.keyringToKeys({ value: keyring, encoding: "base64" })
.kemPublicKey;
return crypto.keyringToKeys(keyring).kemPublicKey;
}
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" })
.kemPublicKey;
return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey;
}
export async function wrapCallSecret(args: {
@ -74,10 +72,7 @@ export async function unwrapCallSecret(args: {
);
}
const ownKeys = crypto.keyringToKeys({
value: args.keyring,
encoding: "base64",
});
const ownKeys = crypto.keyringToKeys(args.keyring);
const sharedSecret = crypto.decapsulate(
ownKeys.kemSecretKey,
args.kemCiphertext,

View file

@ -23,13 +23,11 @@ export function randomChatSecret(): Uint8Array {
}
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
return crypto.keyringToKeys({ value: keyring, encoding: "base64" })
.kemPublicKey;
return crypto.keyringToKeys(keyring).kemPublicKey;
}
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" })
.kemPublicKey;
return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey;
}
export async function wrapChatSecret(args: {
@ -81,10 +79,7 @@ export async function unwrapChatSecret(args: {
);
}
const ownKeys = crypto.keyringToKeys({
value: args.keyring,
encoding: "base64",
});
const ownKeys = crypto.keyringToKeys(args.keyring);
const sharedSecret = crypto.decapsulate(
ownKeys.kemSecretKey,
args.kemCiphertext,

View file

@ -9,8 +9,7 @@
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src --ext .ts,.tsx",
"test": "vitest run",
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@methanium/ui": "*",

View file

@ -1,62 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { RequestIdAllocator } from "./requestIds";
vi.mock("@methanium/ui", () => ({
toast: {
dismiss: vi.fn(),
error: vi.fn(),
loading: vi.fn(),
},
}));
vi.mock("@tensamin/shared/log", () => ({ log: vi.fn() }));
vi.mock("@tensamin/storage/context", () => ({
useStorage: () => ({ load: vi.fn() }),
}));
const { completeInitialSynchronization, isPushType, validateResponse } =
await import("./context");
const validState = {
SessionId: 7,
VersionNumber: 2,
CacheSchemaVersion: 0,
SyncMode: "full",
Contacts: [],
Communities: [],
Calls: [],
Messages: [],
DeletedMessageIds: [],
DeletedContactIds: [],
};
function mockInitialSyncClient(
state: { type: string; data: unknown } = {
type: "ClientStateSync",
data: validState,
},
acknowledgement: unknown = { type: "ClientStateAck", data: {} },
) {
const handlers = new Map<string, (message: never) => void>();
const request = vi.fn().mockResolvedValue(acknowledgement);
const client = {
auth: vi.fn(async () => {
handlers.get(state.type)?.(state as never);
}),
subscribe: vi.fn((type: string, handler: (message: never) => void) => {
handlers.set(type, handler);
return () => handlers.delete(type);
}),
request,
disconnect: vi.fn(),
} as unknown as Parameters<typeof completeInitialSynchronization>[0];
return { client, handlers, request };
}
afterEach(() => {
vi.useRealTimers();
});
import { isPushType, validateResponse } from "./context";
describe("MTP protocol dispatch", () => {
it("preserves protocol errors for the request layer", () => {
@ -75,146 +19,3 @@ describe("MTP protocol dispatch", () => {
expect(isPushType("UnknownMessage")).toBe(false);
});
});
describe("browser initial synchronization", () => {
it("validates state, sends a nonzero acknowledgement, then resolves", async () => {
const { client, request } = mockInitialSyncClient();
const state = await completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
);
expect(state.data).toEqual(validState);
expect(request).toHaveBeenCalledWith(
"ClientStateAck",
{ SessionId: 7, VersionNumber: 2 },
{ id: 1 },
);
});
it("does not acknowledge malformed state", async () => {
const { client, request } = mockInitialSyncClient({
type: "ClientStateSync",
data: { ...validState, SyncMode: "invalid" },
});
await expect(
completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
),
).rejects.toThrow("Response validation failed");
expect(request).not.toHaveBeenCalled();
});
it("rejects ErrorNoIota during initial synchronization", async () => {
const { client, request } = mockInitialSyncClient({
type: "ErrorNoIota",
data: {},
});
await expect(
completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
),
).rejects.toThrow("No Iota is currently connected");
expect(request).not.toHaveBeenCalled();
});
it("rejects an acknowledgement protocol error", async () => {
const { client } = mockInitialSyncClient(undefined, {
type: "ErrorInvalidData",
data: {},
});
await expect(
completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
),
).rejects.toThrow("State acknowledgement failed: ErrorInvalidData");
});
it("times out a missing acknowledgement", async () => {
vi.useFakeTimers();
const { client } = mockInitialSyncClient();
vi.mocked(client.request).mockReturnValue(new Promise(() => {}));
const result = completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
100,
10,
);
const assertion = expect(result).rejects.toThrow(
"State acknowledgement timed out",
);
await vi.advanceTimersByTimeAsync(10);
await assertion;
});
it("stops immediately when the connection attempt is cancelled", async () => {
const { client } = mockInitialSyncClient();
vi.mocked(client.auth).mockImplementation(() => new Promise(() => {}));
const controller = new AbortController();
const result = completeInitialSynchronization(
client,
new RequestIdAllocator(),
controller.signal,
);
controller.abort(new Error("MTP connection lost"));
await expect(result).rejects.toThrow("MTP connection lost");
});
it("times out authentication while waiting for initial state", async () => {
vi.useFakeTimers();
const { client, handlers } = mockInitialSyncClient();
vi.mocked(client.auth).mockImplementation(() => {
handlers.get("ClientStateSync")?.({
type: "ClientStateSync",
data: validState,
} as never);
return new Promise(() => {});
});
const result = completeInitialSynchronization(
client,
new RequestIdAllocator(),
new AbortController().signal,
100,
10,
);
const assertion = expect(result).rejects.toThrow(
"MTP authentication timed out",
);
await vi.advanceTimersByTimeAsync(100);
await assertion;
});
it("uses a fresh request ID namespace for each connection", async () => {
const first = mockInitialSyncClient();
const second = mockInitialSyncClient();
await completeInitialSynchronization(
first.client,
new RequestIdAllocator(),
new AbortController().signal,
);
await completeInitialSynchronization(
second.client,
new RequestIdAllocator(),
new AbortController().signal,
);
expect(first.request.mock.calls[0]?.[2]).toEqual({ id: 1 });
expect(second.request.mock.calls[0]?.[2]).toEqual({ id: 1 });
});
});

View file

@ -27,20 +27,7 @@ import { log } from "@tensamin/shared/log";
import { ProtocolError } from "@tensamin/shared/errors";
import { useStorage } from "@tensamin/storage/context";
import { fromWireMessage, toWireData } from "./protocolFields";
import { RequestIdAllocator } from "./requestIds";
import {
DISCOVERY_TIMEOUT,
INITIAL_SYNC_TIMEOUT,
RECONNECT_JITTER,
RECONNECT_LONG_INTERVAL,
RECONNECT_RESET,
RECONNECT_TRIES,
RETRY_INTERVAL,
STATE_ACK_TIMEOUT,
} from "./values";
type BrowserMtpClient = Awaited<ReturnType<typeof MTPClient.create>>;
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values";
function base64ToUint8Array(b64: string) {
const bin = atob(b64);
@ -64,6 +51,7 @@ export type ProtocolMessage<
export type BoundSendFn = <T extends keyof Schemas & string>(
type: T,
data?: z.infer<Schemas[T]["request"]>,
options?: { id?: number },
) => Promise<ProtocolMessage<T>>;
export type PushHandler = (message: ProtocolMessage) => void | Promise<void>;
@ -84,29 +72,6 @@ export function isPushType(type: string): boolean {
return (PUSH_TYPES as readonly string[]).includes(type);
}
function normalizeMtpMessage<T extends { data: unknown }>(message: T): T {
return fromWireMessage(message);
}
async function requestWithId(
client: BrowserMtpClient,
ids: RequestIdAllocator,
type: string,
data: Record<string, unknown>,
) {
let id: number;
try {
id = ids.allocate();
} catch (error) {
client.disconnect();
throw error;
}
return client.request(type, toWireData(data) as Record<string, unknown>, {
id,
});
}
function removeMissingContacts(
contacts: Contacts,
message: ProtocolMessage,
@ -196,120 +161,6 @@ export function validateResponse<T extends keyof Schemas & string>(
} as ProtocolMessage<T>;
}
function abortError(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error("Initial state synchronization was cancelled");
}
function withDeadline<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutMessage: string,
signal: AbortSignal,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
if (signal.aborted) {
reject(abortError(signal));
return;
}
let settled = false;
const finish = (complete: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
signal.removeEventListener("abort", onAbort);
complete();
};
const timeout = setTimeout(
() => finish(() => reject(new Error(timeoutMessage))),
timeoutMs,
);
const onAbort = () => finish(() => reject(abortError(signal)));
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => finish(() => resolve(value)),
(error: unknown) => finish(() => reject(error)),
);
});
}
export async function completeInitialSynchronization(
client: BrowserMtpClient,
ids: RequestIdAllocator,
signal: AbortSignal,
syncTimeoutMs = INITIAL_SYNC_TIMEOUT,
ackTimeoutMs = STATE_ACK_TIMEOUT,
): Promise<ProtocolMessage<"ClientStateSync">> {
const stateSync = new Promise<ProtocolMessage<"ClientStateSync">>(
(resolve, reject) => {
let unsubscribeStateSync = () => {};
let unsubscribeNoIota = () => {};
const cleanup = () => {
clearTimeout(timeout);
unsubscribeStateSync();
unsubscribeNoIota();
signal.removeEventListener("abort", onAbort);
};
const onAbort = () => {
cleanup();
reject(abortError(signal));
};
const timeout = setTimeout(() => {
cleanup();
reject(new Error("Initial state synchronization timed out"));
}, syncTimeoutMs);
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) {
onAbort();
return;
}
unsubscribeStateSync = client.subscribe("ClientStateSync", (message) => {
cleanup();
try {
resolve(
validateResponse("ClientStateSync", normalizeMtpMessage(message)),
);
} catch (error) {
reject(error);
}
});
unsubscribeNoIota = client.subscribe("ErrorNoIota", () => {
cleanup();
reject(new Error("No Iota is currently connected"));
});
},
);
const [, state] = await Promise.all([
withDeadline(
client.auth(),
syncTimeoutMs,
"MTP authentication timed out",
signal,
),
stateSync,
]);
if (state.type.startsWith("Error")) {
throw new Error(`State synchronization failed: ${state.type}`);
}
const acknowledgement = normalizeMtpMessage(
await withDeadline(
requestWithId(client, ids, "ClientStateAck", {
SessionId: state.data.SessionId,
VersionNumber: state.data.VersionNumber,
}),
ackTimeoutMs,
"State acknowledgement timed out",
signal,
),
);
if (acknowledgement.type.startsWith("Error")) {
throw new Error(`State acknowledgement failed: ${acknowledgement.type}`);
}
if (signal.aborted) throw abortError(signal);
return state;
}
function useMessageHandlers() {
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const pushHandlersRef = useRef(new Set<PushHandler>());
@ -354,7 +205,6 @@ function BrowserProvider(props: {
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
null,
);
const requestIdsRef = useRef<RequestIdAllocator | null>(null);
const {
addInterceptor,
interceptorsRef,
@ -373,24 +223,19 @@ function BrowserProvider(props: {
// Validation override functions
const send: BoundSendFn = useMemo(
() => async (type, data) => {
() => async (type, data, options) => {
const client = clientRef.current;
const ids = requestIdsRef.current;
if (!client) {
throw new Error("mtp is not connected");
}
if (!ids) {
throw new Error("MTP request allocator is unavailable");
}
const rawMessage = await requestWithId(
client,
ids,
const message = await client.request(
type,
(data ?? {}) as Record<string, unknown>,
options,
);
const response = validateResponse(type, normalizeMtpMessage(rawMessage));
const response = validateResponse(type, message);
setFreshContacts((contacts) => removeMissingContacts(contacts, response));
if (response.type.startsWith("Error")) {
const errorData = response.data as Record<string, unknown>;
@ -415,7 +260,7 @@ function BrowserProvider(props: {
}
return client.subscribe(type, (message) => {
handler(validateResponse(type, normalizeMtpMessage(message)));
handler(validateResponse(type, message));
});
}, []);
@ -446,39 +291,33 @@ function BrowserProvider(props: {
const scheduleReconnect = (error: unknown) => {
if (disposed || reconnectScheduled) return;
attempts += 1;
const shortRetry = attempts <= RECONNECT_TRIES;
if (!shortRetry) {
if (attempts >= RECONNECT_TRIES) {
log(0, "mtp", "red", "Reconnection attempts exhausted", error);
sonnerToast.error("Connection failed", {
id: "mtp-connection-toast",
description:
error instanceof Error
? `${error.message.split(":")[0]}. Retrying in the background.`
: "Connection lost. Retrying in the background.",
? error.message.split(":")[0]
: "Connection lost",
icon: null,
duration: Infinity,
closeButton: true,
promise: null,
} as unknown as Parameters<typeof sonnerToast.error>[1]);
} else {
sonnerToast.loading(
`Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`,
{ id: "mtp-connection-toast" },
);
return;
}
const baseDelay = shortRetry ? RETRY_INTERVAL : RECONNECT_LONG_INTERVAL;
const jitter = 1 + (Math.random() * 2 - 1) * RECONNECT_JITTER;
reconnectScheduled = true;
reconnectTimer = setTimeout(
() => {
reconnectScheduled = false;
reconnectTimer = null;
void connect();
},
Math.round(baseDelay * jitter),
attempts += 1;
sonnerToast.loading(
`Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`,
{ id: "mtp-connection-toast" },
);
reconnectScheduled = true;
reconnectTimer = setTimeout(() => {
reconnectScheduled = false;
reconnectTimer = null;
void connect();
}, RETRY_INTERVAL);
};
async function connect() {
@ -487,16 +326,10 @@ function BrowserProvider(props: {
const generation = ++connectionGeneration;
let client: Awaited<ReturnType<typeof MTPClient.create>> | null = null;
let failed = false;
let connectionReady = false;
const attemptAbort = new AbortController();
const cleanup = () => {
attemptAbort.abort(
new Error("Initial state synchronization was cancelled"),
);
client?.disconnect();
if (clientRef.current === client) {
clientRef.current = null;
requestIdsRef.current = null;
}
clearReconnectResetTimer();
if (generation === connectionGeneration) {
@ -526,18 +359,19 @@ function BrowserProvider(props: {
omikronPublicKey = forcedOmikronPublicKey;
} else {
log(2, "mtp", "purple", "Fetching Omikron data.");
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`, {
signal: AbortSignal.any([
attemptAbort.signal,
AbortSignal.timeout(DISCOVERY_TIMEOUT),
]),
});
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`);
if (data.status === 404) {
throw new Error("No Omikron assignment is currently available");
}
if (!data.ok) {
throw new Error(`Omikron discovery failed: HTTP ${data.status}`);
sonnerToast.error("We couldn't reach your Iota", {
description:
"Check your network connection and try restarting your Iota",
icon: null,
duration: Infinity,
closeButton: true,
});
resolveConnectionRef.current?.();
cleanup();
return;
}
const omikronData = (await data.json()) as {
id: number;
@ -570,10 +404,7 @@ function BrowserProvider(props: {
clientId: userId,
keyring: base64ToUint8Array(keyring),
},
hostPublicKey: {
value: omikronPublicKey,
encoding: "base64",
},
hostPublicKey: omikronPublicKey,
descriptor: "client",
pings: true,
logger: (event) => {
@ -587,12 +418,10 @@ function BrowserProvider(props: {
!failed
) {
failed = true;
const error = new Error("MTP connection lost");
attemptAbort.abort(error);
if (connectionReady) {
cleanup();
scheduleReconnect(error);
}
clientRef.current = null;
setIdentified(false);
setIdentifying(false);
scheduleReconnect(new Error("MTP connection lost"));
}
}
@ -627,12 +456,11 @@ function BrowserProvider(props: {
const activeClient = client;
clientRef.current = activeClient;
requestIdsRef.current = new RequestIdAllocator();
for (const type of PUSH_TYPES) {
activeClient.subscribe(type, (message) => {
let validated: ProtocolMessage;
try {
validated = validateResponse(type, normalizeMtpMessage(message));
validated = validateResponse(type, message);
} catch (error) {
log(1, "mtp", "red", "Failed to validate push message", error, {
type,
@ -654,45 +482,80 @@ function BrowserProvider(props: {
if (validated.type === "GetStates") {
lastInitialStateRef.current = validated;
}
if (
validated.type === "ErrorNoIota" &&
clientRef.current === activeClient &&
!failed
) {
failed = true;
const error = new Error("No Iota is currently connected");
attemptAbort.abort(error);
cleanup();
scheduleReconnect(error);
}
});
}
setReadyState(activeClient.state);
clearReconnectTimer();
// Schedule reconnect reset
clearReconnectResetTimer();
reconnectResetTimer = setTimeout(() => {
attempts = 0;
reconnectResetTimer = null;
}, RECONNECT_RESET * 1_000);
setReadyState(activeClient.state);
setIdentifying(true);
const ids = requestIdsRef.current;
if (!ids) throw new Error("MTP request allocator is unavailable");
const finalResponse = await completeInitialSynchronization(
activeClient,
ids,
attemptAbort.signal,
const stateSync = new Promise<ProtocolMessage<"ClientStateSync">>(
(resolve, reject) => {
let unsubscribeStateSync = () => {};
let unsubscribeNoIota = () => {};
const cleanupStateSync = () => {
clearTimeout(timeout);
unsubscribeStateSync();
unsubscribeNoIota();
};
const timeout = setTimeout(() => {
cleanupStateSync();
reject(new Error("Initial state synchronization timed out"));
}, 120_000);
unsubscribeStateSync = activeClient.subscribe(
"ClientStateSync",
(message) => {
cleanupStateSync();
try {
resolve(validateResponse("ClientStateSync", message));
} catch (error) {
reject(error);
}
},
);
unsubscribeNoIota = activeClient.subscribe("ErrorNoIota", () => {
cleanupStateSync();
reject(new Error("No Iota is currently connected"));
});
},
);
const [, finalResponse] = await Promise.all([
activeClient.auth(),
stateSync,
]);
if (finalResponse.type.startsWith("Error")) {
throw new Error(
`State synchronization failed: ${finalResponse.type}`,
);
}
const acknowledgement = await activeClient.request("ClientStateAck", {
SessionId: finalResponse.data.SessionId,
VersionNumber: finalResponse.data.VersionNumber,
});
if (acknowledgement.type.startsWith("Error")) {
throw new Error(
`State acknowledgement failed: ${acknowledgement.type}`,
);
}
if (disposed || clientRef.current !== activeClient) return;
setFreshContacts(finalResponse.data.Contacts);
setFreshCommunities(finalResponse.data.Communities);
setFreshCalls(finalResponse.data.Calls);
connectionReady = true;
setIdentifying(false);
setIdentified(true);
clearReconnectTimer();
clearReconnectResetTimer();
reconnectResetTimer = setTimeout(() => {
attempts = 0;
reconnectResetTimer = null;
}, RECONNECT_RESET * 1_000);
resolveConnectionRef.current?.();
} catch (connectError) {
if (disposed || generation !== connectionGeneration) {
@ -726,7 +589,6 @@ function BrowserProvider(props: {
clientRef.current?.disconnect();
clientRef.current = null;
requestIdsRef.current = null;
setReadyState(ConnectionState.Disconnected);
setIdentified(false);
setIdentifying(false);
@ -788,9 +650,9 @@ function BrowserProvider(props: {
}, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]);
const sendQueued: BoundSendFn = useMemo(
() => async (type, data) => {
() => async (type, data, options) => {
const mtp = await mtpRef.get();
const response = await mtp.send(type, data);
const response = await mtp.send(type, data, options);
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(
interceptor({ type, data, response: response as ProtocolMessage }),
@ -862,32 +724,16 @@ function TauriProvider(props: {
if (next.error) {
log(0, "android", "orange", "MTP connection failed", next.error);
}
if (!next.identified) {
setSnapshot(next);
return;
}
if (next.state === undefined) {
setSnapshot({
...next,
identified: false,
error: "Native MTP connection omitted initial state",
});
return;
}
setSnapshot(next);
if (!next.identified || next.state === undefined) return;
const parsed = schemas.ClientStateSync.response.safeParse(next.state);
if (!parsed.success) {
log(0, "mtp", "red", "Invalid native MTP state", parsed.error);
setSnapshot({
...next,
identified: false,
error: "Invalid ClientStateSync payload",
});
return;
}
setFreshContacts(parsed.data.Contacts);
setFreshCommunities(parsed.data.Communities);
setFreshCalls(parsed.data.Calls);
setSnapshot(next);
}, []);
const dispatchMessage = useCallback(
@ -898,7 +744,7 @@ function TauriProvider(props: {
try {
validated = validateResponse(
message.type as keyof Schemas & string,
normalizeMtpMessage(message),
message,
);
} catch (error) {
log(1, "mtp", "red", "Failed to validate native MTP message", error);
@ -1000,12 +846,13 @@ function TauriProvider(props: {
}, [props.blockConnection]);
const send = useCallback<BoundSendFn>(
async (type, data) => {
async (type, data, options) => {
const response = await invoke<ProtocolMessage>("mtp_request", {
typeName: type,
data: data ?? {},
id: options?.id,
});
const validated = validateResponse(type, normalizeMtpMessage(response));
const validated = validateResponse(type, response);
setFreshContacts((contacts) =>
removeMissingContacts(contacts, validated),
);

View file

@ -1,5 +1,4 @@
export { Provider, useMTP } from "./context";
export { RequestIdAllocator } from "./requestIds";
export type {
BoundSendFn,
MTPExchange,

View file

@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import { fromWireData, toWireData } from "./protocolFields";
describe("MTP protocol field translation", () => {
it("maps application message fields to MTP wire fields", () => {
expect(
toWireData({
Messages: [{ Content: "abc", MessageId: 4 }],
}),
).toEqual({
Messages: [{ AppContent: "abc", AppMessageId: 4 }],
});
});
it("maps MTP wire fields back to application fields", () => {
expect(
fromWireData({
AppCreatedAt: 123,
Message: { AppContent: "abc", AppMessageId: 4 },
}),
).toEqual({
CreatedAt: 123,
Message: { Content: "abc", MessageId: 4 },
});
});
it("preserves byte arrays and unrelated fields", () => {
const bytes = new Uint8Array([1, 2, 3]);
const translated = toWireData({ Payload: bytes, Other: "value" }) as {
Payload: Uint8Array;
Other: string;
};
expect(translated.Payload).toBe(bytes);
expect(translated.Other).toBe("value");
});
it("rejects field mapping collisions", () => {
expect(() => toWireData({ Content: "a", AppContent: "b" })).toThrow(
"MTP field translation collision",
);
});
});

View file

@ -1,58 +0,0 @@
const APPLICATION_TO_WIRE_FIELDS = {
Content: "AppContent",
CreatedAt: "AppCreatedAt",
MessageId: "AppMessageId",
} as const;
const WIRE_TO_APPLICATION_FIELDS = {
AppContent: "Content",
AppCreatedAt: "CreatedAt",
AppMessageId: "MessageId",
} as const;
function mapProtocolFields(
value: unknown,
fieldMap: Readonly<Record<string, string>>,
): unknown {
if (
value === null ||
typeof value !== "object" ||
value instanceof Uint8Array
) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => mapProtocolFields(item, fieldMap));
}
const source = value as Record<string, unknown>;
const target: Record<string, unknown> = {};
for (const [key, child] of Object.entries(source)) {
const mappedKey = fieldMap[key] ?? key;
if (mappedKey in target) {
throw new Error(`MTP field translation collision for ${mappedKey}`);
}
target[mappedKey] = mapProtocolFields(child, fieldMap);
}
return target;
}
export function toWireData(value: unknown): unknown {
return mapProtocolFields(value, APPLICATION_TO_WIRE_FIELDS);
}
export function fromWireData(value: unknown): unknown {
return mapProtocolFields(value, WIRE_TO_APPLICATION_FIELDS);
}
export function fromWireMessage<T extends { data: unknown }>(message: T): T {
return {
...message,
data: fromWireData(message.data),
};
}

View file

@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { RequestIdAllocator } from "./requestIds";
describe("MTP request ID allocation", () => {
it("allocates nonzero request IDs monotonically", () => {
const ids = new RequestIdAllocator();
expect(ids.allocate()).toBe(1);
expect(ids.allocate()).toBe(2);
expect(new RequestIdAllocator().allocate()).toBe(1);
});
it("does not wrap exhausted request IDs", () => {
const ids = new RequestIdAllocator(0x1_0000_0000);
expect(() => ids.allocate()).toThrow("MTP request ID space exhausted");
});
it("rejects invalid allocator states", () => {
expect(() => new RequestIdAllocator(0)).toThrow(
"invalid MTP request ID allocator state",
);
});
});

View file

@ -1,24 +0,0 @@
const MAX_MTP_REQUEST_ID = 0xffff_ffff;
export class RequestIdAllocator {
#next: number;
constructor(next = 1) {
if (
!Number.isSafeInteger(next) ||
next <= 0 ||
next > MAX_MTP_REQUEST_ID + 1
) {
throw new RangeError("invalid MTP request ID allocator state");
}
this.#next = next;
}
allocate(): number {
if (this.#next > MAX_MTP_REQUEST_ID) {
throw new Error("MTP request ID space exhausted for this connection");
}
return this.#next++;
}
}

View file

@ -1,8 +1,3 @@
export const RETRY_INTERVAL = 3_000;
export const RECONNECT_TRIES = 3;
export const RECONNECT_RESET = 6;
export const RECONNECT_LONG_INTERVAL = 60_000;
export const RECONNECT_JITTER = 0.2;
export const DISCOVERY_TIMEOUT = 20_000;
export const INITIAL_SYNC_TIMEOUT = 120_000;
export const STATE_ACK_TIMEOUT = 30_000;

View file

@ -87,9 +87,15 @@ export default function Provider(props: { children: React.ReactNode }) {
moveUserIdToTop(data.SenderId);
if (await load("settings.receive_confirmations")) {
void send("MessageState", {
MessageState: "received",
});
void send(
"MessageState",
{
MessageState: "received",
},
{
id: data.Message.SendTime,
},
);
}
}

View file

@ -1,8 +1,8 @@
import { Outlet, useLocation } from "@tanstack/react-router";
import { Button, useIsMobile } from "@methanium/ui";
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router";
import { Button, ClearStorageButton, cn, useIsMobile } from "@methanium/ui";
import { ArrowLeft } from "lucide-react";
import { SettingsSidebar } from "./sidebar";
import { settingsNavigation } from "./manifest";
export default function SettingsLayout() {
const isMobile = useIsMobile();
@ -36,3 +36,47 @@ export default function SettingsLayout() {
</div>
);
}
export function SettingsSidebar({
mobile = false,
className,
}: {
mobile?: boolean;
className?: string;
}) {
const navigate = useNavigate();
const categories = [
...new Set(settingsNavigation.map((page) => page.category)),
];
return (
<div
className={cn(
mobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
"flex flex-col gap-6",
className,
)}
>
{categories.map((category) => (
<div key={category} className="flex flex-col gap-2">
<h2 className="font-bold text-xs uppercase">{category}</h2>
{settingsNavigation
.filter((page) => page.category === category)
.map((page) => (
<Button
key={page.path}
className="w-full"
variant="outline"
onClick={() => navigate({ to: `/settings/${page.path}` })}
>
{page.label}
</Button>
))}
</div>
))}
<div className="mt-auto">
<ClearStorageButton className="w-full" />
</div>
</div>
);
}

View file

@ -8,24 +8,46 @@ import Profile from "./pages/profile";
import Security from "./pages/security";
import Theme from "./pages/theme";
import Hotkeys from "./pages/hotkeys";
import { settingsNavigation } from "./navigation";
const pageComponents = {
profile: Profile,
security: Security,
chat: Chat,
call: Call,
cache: Cache,
theme: Theme,
accessibility: Accessibility,
hotkeys: Hotkeys,
licenses: Licenses,
} as const;
export const settingsPages = [
{ path: "/", component: Index },
...settingsNavigation.map((page) => ({
...page,
component: pageComponents[page.path],
})),
{
category: "account",
path: "profile",
label: "Profile",
component: Profile,
},
{
category: "account",
path: "security",
label: "Security",
component: Security,
},
{ category: "general", path: "chat", label: "Chat", component: Chat },
{ category: "general", path: "call", label: "Call", component: Call },
{ category: "application", path: "cache", label: "Cache", component: Cache },
{ category: "application", path: "theme", label: "Theme", component: Theme },
{
category: "application",
path: "accessibility",
label: "Accessibility",
component: Accessibility,
},
{
category: "application",
path: "hotkeys",
label: "Hotkeys",
component: Hotkeys,
},
{
category: "application",
path: "licenses",
label: "Licenses",
component: Licenses,
},
] as const;
export const settingsNavigation = settingsPages.filter(
(page): page is Exclude<(typeof settingsPages)[number], { path: "/" }> =>
page.path !== "/",
);

View file

@ -1,31 +0,0 @@
export const settingsNavigation = [
{
category: "account",
path: "profile",
label: "Profile",
},
{
category: "account",
path: "security",
label: "Security",
},
{ category: "general", path: "chat", label: "Chat" },
{ category: "general", path: "call", label: "Call" },
{ category: "application", path: "cache", label: "Cache" },
{ category: "application", path: "theme", label: "Theme" },
{
category: "application",
path: "accessibility",
label: "Accessibility",
},
{
category: "application",
path: "hotkeys",
label: "Hotkeys",
},
{
category: "application",
path: "licenses",
label: "Licenses",
},
] as const;

View file

@ -1,4 +1,4 @@
import { SettingsSidebar } from "../sidebar";
import { SettingsSidebar } from "../layout";
export default function Page() {
return (

View file

@ -1,48 +0,0 @@
import { Button, ClearStorageButton, cn } from "@methanium/ui";
import { useNavigate } from "@tanstack/react-router";
import { settingsNavigation } from "./navigation";
export function SettingsSidebar({
mobile = false,
className,
}: {
mobile?: boolean;
className?: string;
}) {
const navigate = useNavigate();
const categories = [
...new Set(settingsNavigation.map((page) => page.category)),
];
return (
<div
className={cn(
mobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
"flex flex-col gap-6",
className,
)}
>
{categories.map((category) => (
<div key={category} className="flex flex-col gap-2">
<h2 className="font-bold text-xs uppercase">{category}</h2>
{settingsNavigation
.filter((page) => page.category === category)
.map((page) => (
<Button
key={page.path}
className="w-full"
variant="outline"
onClick={() => navigate({ to: `/settings/${page.path}` })}
>
{page.label}
</Button>
))}
</div>
))}
<div className="mt-auto">
<ClearStorageButton className="w-full" />
</div>
</div>
);
}

702
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -7,4 +7,4 @@ allowBuilds:
esbuild: true
overrides:
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz"

View file

@ -15,14 +15,7 @@ function getPackageDirs(dir: string): string[] {
for (const entry of entries) {
const fullPath = join(dir, entry);
if (entry === "node_modules" || entry.startsWith(".")) continue;
let stats;
try {
stats = statSync(fullPath);
} catch {
continue;
}
if (!stats.isDirectory()) continue;
if (!statSync(fullPath).isDirectory()) continue;
if (existsSync(join(fullPath, "package.json"))) {
dirs.push(fullPath);

View file

@ -16,14 +16,7 @@ function getPackageDirs(dir: string): string[] {
for (const entry of entries) {
const fullPath = join(dir, entry);
if (entry === "node_modules" || entry.startsWith(".")) continue;
let stats;
try {
stats = statSync(fullPath);
} catch {
continue;
}
if (!stats.isDirectory()) continue;
if (!statSync(fullPath).isDirectory()) continue;
if (existsSync(join(fullPath, "package.json"))) {
dirs.push(fullPath);

View file

@ -15,14 +15,7 @@ function getPackageDirs(dir: string): string[] {
for (const entry of entries) {
const fullPath = join(dir, entry);
if (entry === "node_modules" || entry.startsWith(".")) continue;
let stats;
try {
stats = statSync(fullPath);
} catch {
continue;
}
if (!stats.isDirectory()) continue;
if (!statSync(fullPath).isDirectory()) continue;
if (existsSync(join(fullPath, "package.json"))) {
dirs.push(fullPath);