Commit 29f8ccacfdc7
tarball42 files changed+50-13722
@@ -1,2 +1,5 @@
11
[target.wasm32-unknown-unknown]22
rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]3
+4
+[net]5
+git-fetch-with-cli = true@@ -1871,7 +1871,6 @@ version = "0.35.0"
18711871
source = "registry+https://github.com/rust-lang/crates.io-index"18721872
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"18731873
dependencies = [1874
- "cc",18751874
"pkg-config",18761875
"vcpkg",18771876
]@@ -1936,6 +1935,7 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
19361935
[[package]]19371936
name = "macltok"19381937
version = "0.1.0"1938
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=7515819c2f3ed19dc742baec9701ccaa6bba83c5#7515819c2f3ed19dc742baec9701ccaa6bba83c5"19391939
dependencies = [19401940
"futures-util",19411941
"getrandom 0.2.17",@@ -1943,29 +1943,21 @@ dependencies = [
19431943
"getrandom 0.4.3",19441944
"http",19451945
"imbl",1946
- "js-sys",19471946
"matrix-sdk",19481947
"matrix-sdk-base",19491948
"matrix-sdk-crypto",19501949
"matrix-sdk-store-encryption",1951
- "matrix-sdk-test",19521950
"matrix-sdk-ui",1953
- "matrix_indexed_db_futures",19541951
"mime",19551952
"reqwest",19561953
"ruma",19571954
"serde",1958
- "serde_json",19591955
"sha2",19601956
"thiserror 2.0.21",19611957
"tokio",19621958
"tracing",19631959
"url",19641960
"uuid",1965
- "wasm-bindgen",1966
- "wasm-bindgen-futures",1967
- "web-sys",1968
- "wiremock",19691961
]19701962
19711963
[[package]]@@ -2172,6 +2164,7 @@ dependencies = [
21722164
[[package]]21732165
name = "matrix-sdk"21742166
version = "0.18.0"2167
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"21752168
dependencies = [21762169
"anymap2",21772170
"aquamarine",@@ -2234,6 +2227,7 @@ dependencies = [
22342227
[[package]]22352228
name = "matrix-sdk-base"22362229
version = "0.18.0"2230
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"22372231
dependencies = [22382232
"as_variant",22392233
"assert_matches",@@ -2263,6 +2257,7 @@ dependencies = [
22632257
[[package]]22642258
name = "matrix-sdk-common"22652259
version = "0.18.0"2260
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"22662261
dependencies = [22672262
"eyeball-im",22682263
"futures-core",@@ -2285,6 +2280,7 @@ dependencies = [
22852280
[[package]]22862281
name = "matrix-sdk-crypto"22872282
version = "0.18.0"2283
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"22882284
dependencies = [22892285
"aes",22902286
"aquamarine",@@ -2325,13 +2321,13 @@ dependencies = [
23252321
[[package]]23262322
name = "matrix-sdk-indexeddb"23272323
version = "0.18.0"2324
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"23282325
dependencies = [23292326
"async-trait",23302327
"base64 0.22.1",23312328
"futures-util",23322329
"getrandom 0.4.3",23332330
"gloo-utils",2334
- "growable-bloom-filter",23352331
"hkdf",23362332
"js-sys",23372333
"matrix-sdk-base",@@ -2356,6 +2352,7 @@ dependencies = [
23562352
[[package]]23572353
name = "matrix-sdk-sqlite"23582354
version = "0.18.0"2355
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"23592356
dependencies = [23602357
"as_variant",23612358
"async-trait",@@ -2382,6 +2379,7 @@ dependencies = [
23822379
[[package]]23832380
name = "matrix-sdk-store-encryption"23842381
version = "0.18.0"2382
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"23852383
dependencies = [23862384
"base64 0.22.1",23872385
"blake3",@@ -2402,6 +2400,7 @@ dependencies = [
24022400
[[package]]24032401
name = "matrix-sdk-test"24042402
version = "0.18.0"2403
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"24052404
dependencies = [24062405
"as_variant",24072406
"ctor",@@ -2424,6 +2423,7 @@ dependencies = [
24242423
[[package]]24252424
name = "matrix-sdk-test-macros"24262425
version = "0.18.0"2426
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"24272427
dependencies = [24282428
"quote",24292429
"syn 2.0.119",@@ -2432,6 +2432,7 @@ dependencies = [
24322432
[[package]]24332433
name = "matrix-sdk-ui"24342434
version = "0.18.0"2435
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"24352436
dependencies = [24362437
"as_variant",24372438
"async-rx",@@ -3,7 +3,7 @@ resolver = "3"
33
members = ["crates/malice-core", "crates/malice-server", "crates/malice-static"]44
55
[workspace.dependencies]6
-macltok = { path = "vendor/macltok" }6
+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "7515819c2f3ed19dc742baec9701ccaa6bba83c5" }77
ruma = { version = "0.16", default-features = false }88
serde = { version = "1", features = ["derive"] }99
serde_json = "1"@@ -13,15 +13,3 @@ http = "1"
1313
1414
[workspace.lints.rust]1515
unsafe_code = "forbid"16
-17
-[patch.crates-io]18
-matrix-sdk = { path = "../matrix-rust-sdk/crates/matrix-sdk" }19
-matrix-sdk-base = { path = "../matrix-rust-sdk/crates/matrix-sdk-base" }20
-matrix-sdk-common = { path = "../matrix-rust-sdk/crates/matrix-sdk-common" }21
-matrix-sdk-crypto = { path = "../matrix-rust-sdk/crates/matrix-sdk-crypto" }22
-matrix-sdk-indexeddb = { path = "../matrix-rust-sdk/crates/matrix-sdk-indexeddb" }23
-matrix-sdk-sqlite = { path = "../matrix-rust-sdk/crates/matrix-sdk-sqlite" }24
-matrix-sdk-store-encryption = { path = "../matrix-rust-sdk/crates/matrix-sdk-store-encryption" }25
-matrix-sdk-test = { path = "../matrix-rust-sdk/testing/matrix-sdk-test" }26
-matrix-sdk-test-macros = { path = "../matrix-rust-sdk/testing/matrix-sdk-test-macros" }27
-matrix-sdk-ui = { path = "../matrix-rust-sdk/crates/matrix-sdk-ui" }@@ -31,7 +31,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
3131
[dev-dependencies]3232
http-body-util = "0.1"3333
tower = { version = "0.5", features = ["util"] }34
-matrix-sdk = { version = "0.18", default-features = false, features = ["testing"] }34
+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "cabc67b5cc0529bcf548c004f2e7c889aed6c4e5", default-features = false, features = ["testing"] }3535
reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls"] }3636
tempfile = "3"3737
wiremock = "0.6"@@ -1,3 +1,6 @@
11
[target.wasm32-unknown-unknown]22
runner = "wasm-bindgen-test-runner"33
rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]4
+5
+[net]6
+git-fetch-with-cli = true@@ -1722,6 +1722,7 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
17221722
[[package]]17231723
name = "macltok"17241724
version = "0.1.0"1725
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=7515819c2f3ed19dc742baec9701ccaa6bba83c5#7515819c2f3ed19dc742baec9701ccaa6bba83c5"17251726
dependencies = [17261727
"futures-util",17271728
"getrandom 0.2.17",@@ -1926,6 +1927,7 @@ dependencies = [
19261927
[[package]]19271928
name = "matrix-sdk"19281929
version = "0.18.0"1930
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"19291931
dependencies = [19301932
"anymap2",19311933
"aquamarine",@@ -1985,6 +1987,7 @@ dependencies = [
19851987
[[package]]19861988
name = "matrix-sdk-base"19871989
version = "0.18.0"1990
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"19881991
dependencies = [19891992
"as_variant",19901993
"async-trait",@@ -2010,6 +2013,7 @@ dependencies = [
20102013
[[package]]20112014
name = "matrix-sdk-common"20122015
version = "0.18.0"2016
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"20132017
dependencies = [20142018
"eyeball-im",20152019
"futures-core",@@ -2032,6 +2036,7 @@ dependencies = [
20322036
[[package]]20332037
name = "matrix-sdk-crypto"20342038
version = "0.18.0"2039
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"20352040
dependencies = [20362041
"aes",20372042
"aquamarine",@@ -2071,6 +2076,7 @@ dependencies = [
20712076
[[package]]20722077
name = "matrix-sdk-indexeddb"20732078
version = "0.18.0"2079
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"20742080
dependencies = [20752081
"async-trait",20762082
"base64 0.22.1",@@ -2102,6 +2108,7 @@ dependencies = [
21022108
[[package]]21032109
name = "matrix-sdk-sqlite"21042110
version = "0.18.0"2111
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"21052112
dependencies = [21062113
"as_variant",21072114
"async-trait",@@ -2128,6 +2135,7 @@ dependencies = [
21282135
[[package]]21292136
name = "matrix-sdk-store-encryption"21302137
version = "0.18.0"2138
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"21312139
dependencies = [21322140
"base64 0.22.1",21332141
"blake3",@@ -2148,6 +2156,7 @@ dependencies = [
21482156
[[package]]21492157
name = "matrix-sdk-ui"21502158
version = "0.18.0"2159
+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=cabc67b5cc0529bcf548c004f2e7c889aed6c4e5#cabc67b5cc0529bcf548c004f2e7c889aed6c4e5"21512160
dependencies = [21522161
"as_variant",21532162
"async-rx",@@ -4513,11 +4522,3 @@ name = "zmij"
45134522
version = "1.0.23"45144523
source = "registry+https://github.com/rust-lang/crates.io-index"45154524
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"4516
-4517
-[[patch.unused]]4518
-name = "matrix-sdk-test"4519
-version = "0.18.0"4520
-4521
-[[patch.unused]]4522
-name = "matrix-sdk-test-macros"4523
-version = "0.18.0"@@ -14,7 +14,7 @@ publish = false
1414
crate-type = ["cdylib", "rlib"]1515
1616
[target.'cfg(target_family = "wasm")'.dependencies]17
-macltok = { path = "../../vendor/macltok", features = ["indexeddb", "serde", "mirror"] }17
+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "7515819c2f3ed19dc742baec9701ccaa6bba83c5", features = ["indexeddb", "serde", "mirror"] }1818
malice-core = { path = "../malice-core" }1919
ruma = { version = "0.16", default-features = false }2020
futures-util = { version = "0.3", default-features = false, features = ["std"] }@@ -45,15 +45,3 @@ lto = true
4545
codegen-units = 14646
panic = "abort"4747
strip = true48
-49
-[patch.crates-io]50
-matrix-sdk = { path = "../../../matrix-rust-sdk/crates/matrix-sdk" }51
-matrix-sdk-base = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-base" }52
-matrix-sdk-common = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-common" }53
-matrix-sdk-crypto = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-crypto" }54
-matrix-sdk-indexeddb = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-indexeddb" }55
-matrix-sdk-sqlite = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-sqlite" }56
-matrix-sdk-store-encryption = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-store-encryption" }57
-matrix-sdk-test = { path = "../../../matrix-rust-sdk/testing/matrix-sdk-test" }58
-matrix-sdk-test-macros = { path = "../../../matrix-rust-sdk/testing/matrix-sdk-test-macros" }59
-matrix-sdk-ui = { path = "../../../matrix-rust-sdk/crates/matrix-sdk-ui" }@@ -87,6 +87,10 @@
8787
version = "0.1.0";8888
src = cleanSrc ./web;8989
npmDeps = webNodeModules pkgs;90
+ disallowedReferences = [91
+ (webNodeModules pkgs)92
+ pkgs.nodejs_2293
+ ];9094
nativeBuildInputs = [9195
pkgs.nodejs_229296
pkgs.importNpmLock.hooks.linkNodeModulesHook@@ -123,7 +127,10 @@
123127
pname = "malice-server";124128
version = "0.1.0";125129
src = cleanSrc ./.;126
- cargoLock.lockFile = ./Cargo.lock;130
+ cargoLock = {131
+ lockFile = ./Cargo.lock;132
+ allowBuiltinFetchGit = true;133
+ };127134
nativeBuildInputs = [ pkgs.pkg-config ];128135
buildInputs = [ pkgs.sqlite ];129136
cargoBuildFlags = [@@ -144,7 +151,10 @@
144151
pname = "malice-static";145152
version = "0.1.0";146153
src = cleanSrc ./.;147
- cargoLock.lockFile = ./Cargo.lock;154
+ cargoLock = {155
+ lockFile = ./Cargo.lock;156
+ allowBuiltinFetchGit = true;157
+ };148158
cargoBuildFlags = [149159
"-p"150160
"malice-static"@@ -173,7 +183,10 @@
173183
version = "0.1.0";174184
src = cleanSrc ./.;175185
cargoRoot = "crates/malice-worker";176
- cargoLock.lockFile = ./crates/malice-worker/Cargo.lock;186
+ cargoLock = {187
+ lockFile = ./crates/malice-worker/Cargo.lock;188
+ allowBuiltinFetchGit = true;189
+ };177190
nativeBuildInputs = [178191
(wasmBindgenCli pkgs)179192
pkgs.removeReferencesTo@@ -290,7 +303,10 @@
290303
version = "0.1.0";291304
src = cleanSrc ./.;292305
cargoRoot = "crates/malice-worker";293
- cargoLock.lockFile = ./crates/malice-worker/Cargo.lock;306
+ cargoLock = {307
+ lockFile = ./crates/malice-worker/Cargo.lock;308
+ allowBuiltinFetchGit = true;309
+ };294310
nativeBuildInputs = [295311
(wasmBindgenCli pkgs)296312
pkgs.firefox@@ -1,79 +0,0 @@
1
-[package]2
-name = "macltok"3
-version = "0.1.0"4
-edition = "2024"5
-description = "A compact application backend on top of the Matrix Rust SDK"6
-7
-[features]8
-default = []9
-mirror = []10
-serde = ["dep:serde", "imbl/serde"]11
-sqlite = ["matrix-sdk/sqlite", "serde", "dep:url", "dep:matrix-sdk-store-encryption"]12
-bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"]13
-indexeddb = [14
- "matrix-sdk/indexeddb",15
- "serde",16
- "dep:url",17
- "dep:matrix-sdk-store-encryption",18
- "dep:indexed_db_futures",19
- "dep:js-sys",20
- "dep:wasm-bindgen",21
- "dep:wasm-bindgen-futures",22
- "dep:web-sys",23
-]24
-25
-[dependencies]26
-futures-util = { version = "0.3.31", default-features = false, features = ["std"] }27
-http = "1"28
-imbl = { version = "6.1.0", default-features = false }29
-matrix-sdk = { version = "0.18.0", default-features = false, features = ["e2e-encryption"] }30
-matrix-sdk-base = { version = "0.18.0", default-features = false }31
-matrix-sdk-crypto = { version = "0.18.0", default-features = false }32
-matrix-sdk-store-encryption = { version = "0.18.0", optional = true }33
-matrix-sdk-ui = { version = "0.18.0", default-features = false }34
-ruma = { version = "0.16.0", default-features = false, features = ["unstable-msc2545", "unstable-msc4095"] }35
-serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }36
-thiserror = "2.0.16"37
-mime = "0.3.17"38
-sha2 = "0.10.9"39
-tokio = { version = "1.47.1", features = ["macros", "sync"] }40
-tracing = "0.1.41"41
-url = { version = "2.5.7", optional = true }42
-43
-[target.'cfg(target_family = "wasm")'.dependencies]44
-getrandom = { version = "0.2", features = ["js"] }45
-getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }46
-getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }47
-indexed_db_futures = { package = "matrix_indexed_db_futures", version = "0.7.0", optional = true, features = ["typed-arrays"] }48
-js-sys = { version = "0.3.105", optional = true }49
-matrix-sdk = { version = "0.18.0", default-features = false, features = ["js"] }50
-matrix-sdk-ui = { version = "0.18.0", default-features = false, features = ["js"] }51
-# the sdk's own wasm reqwest leaves out stream, which the bounded media download needs52
-reqwest = { version = "0.13.1", default-features = false, features = ["stream"] }53
-uuid = { version = "1", features = ["js"] }54
-wasm-bindgen = { version = "0.2.128", optional = true }55
-wasm-bindgen-futures = { version = "0.4.78", optional = true }56
-web-sys = { version = "0.3.105", optional = true, features = [57
- "IdbFactory",58
- "IdbOpenDbRequest",59
- "IdbRequest",60
- "Window",61
- "WorkerGlobalScope",62
-] }63
-64
-[dev-dependencies]65
-matrix-sdk = { version = "0.18.0", default-features = false, features = ["testing"] }66
-matrix-sdk-test = "0.18.0"67
-serde_json = "1.0.143"68
-tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal", "time"] }69
-wiremock = "0.6.5"70
-71
-[[example]]72
-name = "headless"73
-required-features = ["sqlite"]74
-75
-[lints.rust]76
-unsafe_code = "forbid"77
-78
-[lints.clippy]79
-all = "warn"@@ -1,119 +0,0 @@
1
-use std::sync::Arc;2
-3
-use macltok::{4
- EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Retain, RoomMembership,5
- Session, SessionStore, StoreConfig, TimelineContent, TimelineDiff, TimelineId, TimelineItem,6
- imbl::Vector,7
-};8
-9
-#[tokio::main]10
-async fn main() -> Result<(), Box<dyn std::error::Error>> {11
- let store_path = std::path::PathBuf::from(required("MATRIX_STORE_PATH")?);12
- let passphrase = required("MATRIX_STORE_PASSPHRASE")?;13
- let sessions = Arc::new(EncryptedSessionStore::file(14
- store_path.with_extension("session"),15
- StoreConfig::sqlite(&store_path, &passphrase),16
- ));17
- let session = match Session::restore(sessions.clone()).await? {18
- Some(Restore::Resumed(session)) => session,19
- Some(Restore::SoftLoggedOut(soft)) => {20
- Session::login_password(21
- soft.password_login(required("MATRIX_PASSWORD")?),22
- sessions.clone(),23
- )24
- .await?25
- }26
- None | Some(Restore::OAuthPending) => {27
- Session::login_password(28
- PasswordLogin::new(29
- required("MATRIX_HOMESERVER")?,30
- required("MATRIX_USERNAME")?,31
- required("MATRIX_PASSWORD")?,32
- )33
- .with_device_display_name("macltok headless example"),34
- sessions.clone(),35
- )36
- .await?37
- }38
- };39
- let (engine, handle, mut events) = session.build(EngineConfig::default()).await?;40
- let running = tokio::spawn(engine.run());41
- let mut opened: Option<TimelineId> = None;42
- let mut items = Vector::new();43
- let mut stale = false;44
-45
- loop {46
- tokio::select! {47
- event = events.recv() => {48
- let Some(event) = event else { break };49
- match event {50
- Event::RoomsChanged if opened.is_none() => {51
- let Some(room) = handle52
- .rooms()53
- .await54
- .into_iter()55
- .find(|room| room.membership == RoomMembership::Joined)56
- else {57
- continue;58
- };59
- opened = Some(room.room_id.clone().into());60
- // answers arrive on their own, the event loop keeps draining meanwhile61
- let handle = handle.clone();62
- tokio::spawn(async move {63
- if let Err(error) = handle.open_room(room.room_id).await {64
- eprintln!("could not open {error}");65
- }66
- });67
- }68
- Event::TimelineReset { timeline_id, items: snapshot }69
- if opened.as_ref() == Some(&timeline_id) =>70
- {71
- items = snapshot;72
- stale = false;73
- print_messages(&items);74
- }75
- Event::TimelineChanged { timeline_id, diffs }76
- if !stale && opened.as_ref() == Some(&timeline_id) =>77
- {78
- if TimelineDiff::try_apply_all(diffs, &mut items).is_err() {79
- stale = true;80
- let handle = handle.clone();81
- tokio::spawn(async move { handle.resync(timeline_id).await });82
- continue;83
- }84
- print_messages(&items);85
- }86
- Event::Stopped => break,87
- _ => {}88
- }89
- }90
- result = tokio::signal::ctrl_c() => {91
- result?;92
- let handle = handle.clone();93
- tokio::spawn(async move { handle.shutdown().await });94
- }95
- }96
- }97
- let termination = running.await??;98
- drop((handle, events));99
- match termination.retain() {100
- Retain::RecordAndStore => {}101
- Retain::Store => sessions.soft_log_out().await?,102
- Retain::Nothing => sessions.discard().await?,103
- }104
- Ok(())105
-}106
-107
-fn print_messages(items: &Vector<TimelineItem>) {108
- for item in items {109
- if let TimelineItem::Event(event) = item110
- && let TimelineContent::Message(message) = &event.content111
- {112
- println!("{} {}", event.sender.name(), message.body);113
- }114
- }115
-}116
-117
-fn required(name: &str) -> Result<String, Box<dyn std::error::Error>> {118
- std::env::var(name).map_err(|_| format!("{name} is required").into())119
-}@@ -1,1021 +0,0 @@
1
-#[cfg(feature = "sqlite")]2
-use std::path::PathBuf;3
-#[cfg(not(target_family = "wasm"))]4
-use std::sync::atomic::{AtomicBool, Ordering};5
-use std::{collections::HashMap, sync::Arc};6
-7
-use matrix_sdk::{8
- Client, ClientBuilder, SendOutsideWasm, SessionChange, SessionMeta, SyncOutsideWasm,9
- ThreadingSupport,10
- authentication::{11
- AuthSession,12
- matrix::MatrixSession,13
- oauth::{14
- ClientId, ClientRegistrationData, CsrfToken, OAuthSession, PendingAuthorization,15
- UserSession,16
- registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},17
- },18
- },19
- cross_process_lock::CrossProcessLockConfig,20
- encryption::{BackupDownloadStrategy, EncryptionSettings},21
- ruma::{MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedUserId, serde::Raw},22
-};23
-use serde::{Deserialize, Serialize};24
-use thiserror::Error;25
-use tokio::sync::{broadcast, mpsc};26
-use url::Url;27
-28
-use crate::{29
- ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, EventStream, ServerError,30
- SessionStore,31
- engine::{Persistence, StoreLock},32
- error::SourceError,33
-};34
-35
-/// a logged in client and the `SessionStore` its record lives in. the engine it builds36
-/// saves every token rotation there, unless told `without_persistence`37
-pub struct Session {38
- client: Client,39
- tracking: Tracking,40
- store_lock: StoreLock,41
-}42
-43
-/// taken as soon as the client exists, since the sdk can refresh before there's an engine44
-struct Tracking {45
- session_changes: broadcast::Receiver<SessionChange>,46
- persistence: Option<Persistence>,47
- #[cfg(not(target_family = "wasm"))]48
- inline: Arc<AtomicBool>,49
-}50
-51
-#[non_exhaustive]52
-pub struct PasswordLogin {53
- pub homeserver: String,54
- pub username: String,55
- pub password: String,56
- pub device_display_name: Option<String>,57
- /// set after a soft logout so the store's keys still belong to us58
- pub device_id: Option<OwnedDeviceId>,59
- /// let plain http through, for loopback hosts only60
- pub allow_insecure_loopback: bool,61
-}62
-63
-impl PasswordLogin {64
- #[must_use]65
- pub fn new(66
- homeserver: impl Into<String>,67
- username: impl Into<String>,68
- password: impl Into<String>,69
- ) -> Self {70
- Self {71
- homeserver: homeserver.into(),72
- username: username.into(),73
- password: password.into(),74
- device_display_name: None,75
- device_id: None,76
- allow_insecure_loopback: false,77
- }78
- }79
-80
- #[must_use]81
- pub fn with_device_display_name(mut self, name: impl Into<String>) -> Self {82
- self.device_display_name = Some(name.into());83
- self84
- }85
-86
- #[must_use]87
- pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {88
- self.device_id = Some(device_id);89
- self90
- }91
-92
- #[must_use]93
- pub fn allow_insecure_loopback(mut self, allow: bool) -> Self {94
- self.allow_insecure_loopback = allow;95
- self96
- }97
-}98
-99
-#[non_exhaustive]100
-pub struct OAuthLogin {101
- pub homeserver: String,102
- pub redirect_uri: String,103
- pub client_uri: String,104
- pub client_name: Option<String>,105
- pub client_id: Option<String>,106
- pub device_id: Option<OwnedDeviceId>,107
- pub application_type: OAuthApplicationType,108
- /// let plain http through, for loopback hosts only109
- pub allow_insecure_loopback: bool,110
-}111
-112
-impl OAuthLogin {113
- #[must_use]114
- pub fn new(115
- homeserver: impl Into<String>,116
- redirect_uri: impl Into<String>,117
- client_uri: impl Into<String>,118
- application_type: OAuthApplicationType,119
- ) -> Self {120
- Self {121
- homeserver: homeserver.into(),122
- redirect_uri: redirect_uri.into(),123
- client_uri: client_uri.into(),124
- client_name: None,125
- client_id: None,126
- device_id: None,127
- application_type,128
- allow_insecure_loopback: false,129
- }130
- }131
-132
- #[must_use]133
- pub fn with_client_name(mut self, name: impl Into<String>) -> Self {134
- self.client_name = Some(name.into());135
- self136
- }137
-138
- /// a statically registered client id, skipping dynamic registration139
- #[must_use]140
- pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {141
- self.client_id = Some(client_id.into());142
- self143
- }144
-145
- #[must_use]146
- pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {147
- self.device_id = Some(device_id);148
- self149
- }150
-151
- #[must_use]152
- pub fn allow_insecure_loopback(mut self, allow: bool) -> Self {153
- self.allow_insecure_loopback = allow;154
- self155
- }156
-}157
-158
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]159
-#[non_exhaustive]160
-pub enum OAuthApplicationType {161
- Native,162
- Web,163
-}164
-165
-pub struct PendingOAuthLogin {166
- client: Client,167
- sessions: Arc<dyn SessionStore>,168
- tracking: Option<(Tracking, StoreLock)>,169
- authorization_url: String,170
- state: Option<CsrfToken>,171
- previous: Option<SessionRecord>,172
-}173
-174
-impl PendingOAuthLogin {175
- #[must_use]176
- pub fn authorization_url(&self) -> &str {177
- &self.authorization_url178
- }179
-180
- #[must_use]181
- pub fn registered_client_id(&self) -> String {182
- self.client183
- .oauth()184
- .client_id()185
- .expect("OAuth client was registered before authorization")186
- .as_str()187
- .to_owned()188
- }189
-190
- #[must_use]191
- pub const fn is_pending(&self) -> bool {192
- self.state.is_some()193
- }194
-195
- pub async fn finish(&mut self, callback_url: &str) -> Result<Session, AccountError> {196
- let callback_url = Url::parse(callback_url)197
- .map_err(|error| account_error("parsing the OAuth callback URL", error))?;198
- let state = self199
- .state200
- .take()201
- .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;202
- let oauth = self.client.oauth();203
- if let Err(error) = oauth.finish_login(callback_url.into()).await {204
- oauth.abort_login(&state).await;205
- if let Some(previous) = self.previous.take() {206
- put_back(self.sessions.as_ref(), previous).await;207
- }208
- return Err(account_error("OAuth login", error));209
- }210
- let (tracking, store_lock) = self211
- .tracking212
- .take()213
- .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;214
- Session::saved(215
- self.client.clone(),216
- self.sessions.clone(),217
- tracking,218
- store_lock,219
- )220
- .await221
- }222
-223
- /// puts back what the store held before, or deletes the store if it held nothing224
- pub async fn abort(self) {225
- let Self {226
- client,227
- sessions,228
- tracking,229
- state,230
- previous,231
- ..232
- } = self;233
- let Some(state) = state else {234
- return;235
- };236
- client.oauth().abort_login(&state).await;237
- drop(client);238
- match previous {239
- Some(previous) => put_back(sessions.as_ref(), previous).await,240
- None => {241
- if let Err(error) = sessions.discard().await {242
- tracing::warn!(%error, "deleting an abandoned OAuth login's store failed");243
- }244
- }245
- }246
- drop(tracking);247
- }248
-}249
-250
-async fn put_back(sessions: &dyn SessionStore, previous: SessionRecord) {251
- if let Err(error) = sessions.save(&previous).await {252
- tracing::warn!(%error, "putting back the record an OAuth login replaced failed");253
- }254
-}255
-256
-pub struct StoreConfig {257
- location: StoreLocation,258
- passphrase: String,259
-}260
-261
-enum StoreLocation {262
- #[cfg(feature = "sqlite")]263
- Sqlite(PathBuf),264
- #[cfg(feature = "indexeddb")]265
- IndexedDb(String),266
-}267
-268
-impl StoreConfig {269
- #[cfg(feature = "sqlite")]270
- #[must_use]271
- pub fn sqlite(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {272
- Self {273
- location: StoreLocation::Sqlite(path.into()),274
- passphrase: passphrase.into(),275
- }276
- }277
-278
- #[cfg(feature = "indexeddb")]279
- #[must_use]280
- pub fn indexeddb(name: impl Into<String>, passphrase: impl Into<String>) -> Self {281
- Self {282
- location: StoreLocation::IndexedDb(name.into()),283
- passphrase: passphrase.into(),284
- }285
- }286
-287
- /// delete the store's databases, once nothing has them open288
- pub async fn delete(&self) -> Result<(), AccountError> {289
- match &self.location {290
- #[cfg(feature = "sqlite")]291
- StoreLocation::Sqlite(path) => crate::session_store::delete_sqlite_store(path)292
- .map_err(|error| account_error("deleting the Matrix store", error)),293
- #[cfg(feature = "indexeddb")]294
- StoreLocation::IndexedDb(name) => crate::session_store::delete_indexeddb_store(name)295
- .await296
- .map_err(|error| error.into_account_error("deleting the Matrix store")),297
- }298
- }299
-300
- /// taken before the client opens the store, and held until the engine and every301
- /// `ClientHandle` are gone302
- pub(crate) async fn lock(&self) -> Result<StoreLock, AccountError> {303
- match &self.location {304
- #[cfg(feature = "sqlite")]305
- StoreLocation::Sqlite(path) => StoreLock::file(path),306
- #[cfg(feature = "indexeddb")]307
- StoreLocation::IndexedDb(name) => StoreLock::web(name).await,308
- }309
- }310
-311
- pub(crate) fn passphrase(&self) -> &str {312
- &self.passphrase313
- }314
-315
- #[cfg(feature = "indexeddb")]316
- pub(crate) fn indexeddb_name(&self) -> &str {317
- let StoreLocation::IndexedDb(name) = &self.location;318
- name319
- }320
-}321
-322
-/// Serializable authentication secrets. A `SessionStore` keeps it, and the engine323
-/// replaces it there on every token rotation.324
-#[derive(Clone, Deserialize, Serialize)]325
-pub struct SessionRecord {326
- homeserver: String,327
- authentication: AuthenticationRecord,328
-}329
-330
-#[derive(Clone, Deserialize, Serialize)]331
-#[serde(tag = "type", content = "session", rename_all = "snake_case")]332
-enum AuthenticationRecord {333
- Matrix(MatrixSession),334
- OAuth {335
- client_id: ClientId,336
- user: UserSession,337
- },338
- SoftLoggedOut {339
- meta: SessionMeta,340
- client_id: Option<ClientId>,341
- },342
- /// an oauth login waiting on its redirect. `authorization` holds the pkce verifier343
- PendingOAuth {344
- client_id: ClientId,345
- state: CsrfToken,346
- authorization: Box<PendingAuthorization>,347
- started_ms: u64,348
- /// what the store held before, put back if the login never finishes349
- previous: Option<Box<SessionRecord>>,350
- },351
-}352
-353
-/// authorization codes live for minutes, so a login this old was abandoned354
-const OAUTH_PENDING_FOR_MS: u64 = 60 * 60 * 1000;355
-356
-/// the authorization server answers in the query, or the fragment when asked to357
-pub(crate) fn callback_state(callback: &Url) -> Option<String> {358
- let query = callback.query_pairs();359
- let fragment = url::form_urlencoded::parse(callback.fragment().unwrap_or_default().as_bytes());360
- query361
- .chain(fragment)362
- .find(|(key, _)| key == "state")363
- .map(|(_, state)| state.into_owned())364
-}365
-366
-fn now_ms() -> u64 {367
- MilliSecondsSinceUnixEpoch::now().get().into()368
-}369
-370
-impl SessionRecord {371
- #[must_use]372
- pub fn homeserver(&self) -> &str {373
- &self.homeserver374
- }375
-376
- /// a pending oauth login is nobody yet, unless it resumes someone377
- pub(crate) fn user_id(&self) -> Option<&OwnedUserId> {378
- match &self.authentication {379
- AuthenticationRecord::Matrix(session) => Some(&session.meta.user_id),380
- AuthenticationRecord::OAuth { user, .. } => Some(&user.meta.user_id),381
- AuthenticationRecord::SoftLoggedOut { meta, .. } => Some(&meta.user_id),382
- AuthenticationRecord::PendingOAuth { previous, .. } => previous.as_ref()?.user_id(),383
- }384
- }385
-386
- /// what a login should resume. a pending oauth login stands in for the record before it387
- pub(crate) fn resumable(self) -> Option<Self> {388
- match self.authentication {389
- AuthenticationRecord::PendingOAuth { previous, .. } => {390
- previous.and_then(|previous| previous.resumable())391
- }392
- _ => Some(self),393
- }394
- }395
-396
- pub(crate) fn pending_oauth_state(&self) -> Option<&str> {397
- match &self.authentication {398
- AuthenticationRecord::PendingOAuth { state, .. } => Some(state.secret()),399
- _ => None,400
- }401
- }402
-403
- pub(crate) fn oauth_expired(&self) -> bool {404
- let AuthenticationRecord::PendingOAuth { started_ms, .. } = &self.authentication else {405
- return false;406
- };407
- now_ms().saturating_sub(*started_ms) > OAUTH_PENDING_FOR_MS408
- }409
-410
- pub(crate) fn soft_logout(&self) -> Option<SoftLogout> {411
- let (meta, client_id) = match &self.authentication {412
- AuthenticationRecord::SoftLoggedOut { meta, client_id } => (meta, client_id),413
- AuthenticationRecord::PendingOAuth { previous, .. } => {414
- return previous.as_ref()?.soft_logout();415
- }416
- _ => return None,417
- };418
- Some(SoftLogout {419
- homeserver: self.homeserver.clone(),420
- user_id: meta.user_id.clone(),421
- device_id: meta.device_id.clone(),422
- client_id: client_id423
- .as_ref()424
- .map(|client_id| client_id.as_str().to_owned()),425
- })426
- }427
-428
- pub(crate) fn soft_logged_out(self) -> Self {429
- let authentication = match self.authentication {430
- AuthenticationRecord::Matrix(session) => AuthenticationRecord::SoftLoggedOut {431
- meta: session.meta,432
- client_id: None,433
- },434
- AuthenticationRecord::OAuth { client_id, user } => {435
- AuthenticationRecord::SoftLoggedOut {436
- meta: user.meta,437
- client_id: Some(client_id),438
- }439
- }440
- soft @ AuthenticationRecord::SoftLoggedOut { .. } => soft,441
- pending @ AuthenticationRecord::PendingOAuth { .. } => pending,442
- };443
- Self {444
- homeserver: self.homeserver,445
- authentication,446
- }447
- }448
-}449
-450
-pub enum Restore {451
- Resumed(Session),452
- SoftLoggedOut(SoftLogout),453
- /// an oauth login is waiting on its redirect. `Session::finish_oauth` takes the callback454
- OAuthPending,455
-}456
-457
-/// who to log in again as. the store keeps this device's e2ee keys, so a login458
-/// as any other device would orphan them459
-#[derive(Clone, Debug)]460
-#[non_exhaustive]461
-pub struct SoftLogout {462
- pub homeserver: String,463
- pub user_id: OwnedUserId,464
- pub device_id: OwnedDeviceId,465
- /// set when the login was OAuth, for `OAuthLogin::with_client_id`466
- pub client_id: Option<String>,467
-}468
-469
-impl SoftLogout {470
- #[must_use]471
- pub fn password_login(&self, password: impl Into<String>) -> PasswordLogin {472
- PasswordLogin::new(self.homeserver.clone(), self.user_id.as_str(), password)473
- .with_device_id(self.device_id.clone())474
- .allow_insecure_loopback(true)475
- }476
-477
- #[must_use]478
- pub fn oauth_login(479
- &self,480
- redirect_uri: impl Into<String>,481
- client_uri: impl Into<String>,482
- application_type: OAuthApplicationType,483
- ) -> OAuthLogin {484
- let login = OAuthLogin::new(485
- self.homeserver.clone(),486
- redirect_uri,487
- client_uri,488
- application_type,489
- )490
- .with_device_id(self.device_id.clone())491
- .allow_insecure_loopback(true);492
- match &self.client_id {493
- Some(client_id) => login.with_client_id(client_id.clone()),494
- None => login,495
- }496
- }497
-}498
-499
-#[derive(Debug, Error)]500
-pub enum AccountError {501
- #[error("{context} failed")]502
- Operation {503
- context: &'static str,504
- kind: ErrorKind,505
- server: Option<ServerError>,506
- #[source]507
- source: Box<SourceError>,508
- },509
- #[error("the homeserver returned no authenticated session")]510
- MissingSession,511
- #[error("the SDK returned an unsupported authentication session")]512
- UnsupportedAuthentication,513
- #[error("the OAuth callback was already submitted")]514
- OAuthCallbackAlreadySubmitted,515
- /// nothing is waiting for this callback. it was finished, abandoned, expired, or begun516
- /// somewhere else517
- #[error("no OAuth login is waiting for this callback")]518
- NoPendingOAuth,519
- /// what was typed can't name a homeserver, so nothing was sent anywhere520
- #[error("that isn't a usable homeserver, {0}")]521
- InvalidHomeserver(&'static str),522
- /// `Accounts` already has a live login for them. a second would be another device523
- #[error("{0} is already logged in")]524
- AlreadyLoggedIn(OwnedUserId),525
- /// an IndexedDB delete is waiting on the store's other connections. it goes through526
- /// once they close, and opening the store again waits behind it527
- #[error("the store is still open elsewhere, it will be deleted once that closes")]528
- StoreDeletePending,529
- /// another engine, in this process or another, has the store. only one runs per store530
- #[error("another engine is using this store")]531
- StoreInUse,532
- /// the login worked but its first record didn't save. the server already has the533
- /// device, so retry the save rather than log in again534
- #[error("saving the new login's record failed")]535
- Unsaved {536
- session: Box<UnsavedSession>,537
- #[source]538
- source: Box<AccountError>,539
- },540
- #[error(transparent)]541
- Engine(#[from] EngineError),542
-}543
-544
-impl AccountError {545
- /// what the homeserver said, when it refused546
- #[must_use]547
- pub fn server(&self) -> Option<&ServerError> {548
- match self {549
- Self::Operation { server, .. } => server.as_ref(),550
- Self::Unsaved { source, .. } => source.server(),551
- _ => None,552
- }553
- }554
-}555
-556
-/// a live login whose record never reached its `SessionStore`557
-pub struct UnsavedSession {558
- session: Session,559
- sessions: Arc<dyn SessionStore>,560
-}561
-562
-impl UnsavedSession {563
- pub async fn save(self) -> Result<Session, AccountError> {564
- let saved = match session_record(&self.session.client) {565
- Ok(record) => self.sessions.save(&record).await,566
- Err(error) => Err(error),567
- };568
- match saved {569
- Ok(()) => Ok(self.session),570
- Err(source) => Err(AccountError::Unsaved {571
- session: Box::new(self),572
- source: Box::new(source),573
- }),574
- }575
- }576
-577
- /// the record to keep somewhere else, if the store won't take it578
- pub fn record(&self) -> Result<SessionRecord, AccountError> {579
- session_record(&self.session.client)580
- }581
-}582
-583
-impl std::fmt::Debug for UnsavedSession {584
- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {585
- formatter586
- .debug_struct("UnsavedSession")587
- .finish_non_exhaustive()588
- }589
-}590
-591
-impl Session {592
- pub async fn login_password(593
- login: PasswordLogin,594
- sessions: Arc<dyn SessionStore>,595
- ) -> Result<Self, AccountError> {596
- homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;597
- let store_lock = sessions.store().lock().await?;598
- Self::login_password_locked(login, sessions, store_lock).await599
- }600
-601
- pub(crate) async fn login_password_locked(602
- login: PasswordLogin,603
- sessions: Arc<dyn SessionStore>,604
- store_lock: StoreLock,605
- ) -> Result<Self, AccountError> {606
- let homeserver = homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;607
- let client = matrix_client_builder(sessions.store())608
- .server_name_or_homeserver_url(homeserver)609
- .build()610
- .await611
- .map_err(|error| account_error("building the Matrix client", error))?;612
- let tracking = Tracking::start(&client, sessions.clone())?;613
- let mut request = client614
- .matrix_auth()615
- .login_username(login.username.trim(), &login.password)616
- .request_refresh_token();617
- if let Some(name) = login.device_display_name.as_deref() {618
- request = request.initial_device_display_name(name);619
- }620
- if let Some(device_id) = login.device_id.as_deref() {621
- request = request.device_id(device_id.as_str());622
- }623
- request624
- .send()625
- .await626
- .map_err(|error| account_error("password login", error))?;627
- Self::saved(client, sessions, tracking, store_lock).await628
- }629
-630
- /// the pending login lives only in this process, so the one that begins it has to finish it631
- pub async fn begin_oauth(632
- login: OAuthLogin,633
- sessions: Arc<dyn SessionStore>,634
- ) -> Result<PendingOAuthLogin, AccountError> {635
- homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;636
- let store_lock = sessions.store().lock().await?;637
- Self::begin_oauth_locked(login, sessions, store_lock).await638
- }639
-640
- pub(crate) async fn begin_oauth_locked(641
- login: OAuthLogin,642
- sessions: Arc<dyn SessionStore>,643
- store_lock: StoreLock,644
- ) -> Result<PendingOAuthLogin, AccountError> {645
- let redirect_uri = Url::parse(login.redirect_uri.trim())646
- .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;647
- let client_uri = Url::parse(login.client_uri.trim())648
- .map_err(|error| account_error("parsing the OAuth client URI", error))?;649
- let homeserver = homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;650
- let client = matrix_client_builder(sessions.store())651
- .server_name_or_homeserver_url(homeserver)652
- .build()653
- .await654
- .map_err(|error| account_error("building the Matrix client", error))?;655
- let tracking = Tracking::start(&client, sessions.clone())?;656
- let application_type = match login.application_type {657
- OAuthApplicationType::Native => ApplicationType::Native,658
- OAuthApplicationType::Web => ApplicationType::Web,659
- };660
- let mut metadata = ClientMetadata::new(661
- application_type,662
- vec![OAuthGrantType::AuthorizationCode {663
- redirect_uris: vec![redirect_uri.clone()],664
- }],665
- Localized::new(client_uri, []),666
- );667
- metadata.client_name = login.client_name.map(|name| Localized::new(name, []));668
- let metadata = Raw::new(&metadata)669
- .map_err(|error| account_error("serializing OAuth client metadata", error))?;670
- let mut registration = ClientRegistrationData::new(metadata);671
- if let Some(client_id) = login.client_id {672
- registration.static_registrations = Some(HashMap::from([(673
- client.homeserver(),674
- ClientId::new(client_id),675
- )]));676
- }677
- let authorization = client678
- .oauth()679
- .login(redirect_uri, login.device_id, Some(registration), None)680
- .build()681
- .await682
- .map_err(|error| account_error("starting OAuth login", error))?;683
- let previous = sessions.load().await?.and_then(SessionRecord::resumable);684
- let pending = SessionRecord {685
- homeserver: client.homeserver().to_string(),686
- authentication: AuthenticationRecord::PendingOAuth {687
- client_id: client688
- .oauth()689
- .client_id()690
- .ok_or(AccountError::MissingSession)?691
- .clone(),692
- state: authorization.state.clone(),693
- authorization: Box::new(694
- client695
- .oauth()696
- .pending_authorization(&authorization.state)697
- .await698
- .ok_or(AccountError::MissingSession)?,699
- ),700
- started_ms: now_ms(),701
- previous: previous.clone().map(Box::new),702
- },703
- };704
- sessions.save(&pending).await?;705
- Ok(PendingOAuthLogin {706
- client,707
- sessions,708
- tracking: Some((tracking, store_lock)),709
- authorization_url: authorization.url.to_string(),710
- state: Some(authorization.state),711
- previous,712
- })713
- }714
-715
- /// finishes a login `begin_oauth` started in another process, from the callback the716
- /// redirect brought back. the pending login is in `sessions`, so nothing else is needed717
- pub async fn finish_oauth(718
- sessions: Arc<dyn SessionStore>,719
- callback_url: &str,720
- ) -> Result<Self, AccountError> {721
- let store_lock = sessions.store().lock().await?;722
- Self::finish_oauth_locked(sessions, store_lock, callback_url).await723
- }724
-725
- pub(crate) async fn finish_oauth_locked(726
- sessions: Arc<dyn SessionStore>,727
- store_lock: StoreLock,728
- callback_url: &str,729
- ) -> Result<Self, AccountError> {730
- let callback = Url::parse(callback_url)731
- .map_err(|error| account_error("parsing the OAuth callback URL", error))?;732
- let record = sessions.load().await?.ok_or(AccountError::NoPendingOAuth)?;733
- if record.pending_oauth_state() != callback_state(&callback).as_deref() {734
- return Err(AccountError::NoPendingOAuth);735
- }736
- let AuthenticationRecord::PendingOAuth {737
- client_id,738
- state,739
- authorization,740
- previous,741
- ..742
- } = record.authentication743
- else {744
- return Err(AccountError::NoPendingOAuth);745
- };746
- let client = matrix_client_builder(sessions.store())747
- .homeserver_url(&record.homeserver)748
- .build()749
- .await750
- .map_err(|error| account_error("reopening the Matrix store", error))?;751
- let tracking = Tracking::start(&client, sessions.clone())?;752
- let oauth = client.oauth();753
- oauth.restore_registered_client(client_id);754
- let finished = match oauth755
- .restore_pending_authorization(state, *authorization)756
- .await757
- {758
- Ok(()) => oauth.finish_login(callback.into()).await,759
- Err(error) => Err(error.into()),760
- };761
- if let Err(error) = finished {762
- match previous {763
- Some(previous) => put_back(sessions.as_ref(), *previous).await,764
- None => {765
- drop((tracking, client));766
- if let Err(error) = sessions.discard().await {767
- tracing::warn!(%error, "deleting a failed OAuth login's store failed");768
- }769
- }770
- }771
- return Err(account_error("OAuth login", error));772
- }773
- Self::saved(client, sessions, tracking, store_lock).await774
- }775
-776
- /// Restore the record `sessions` holds, or `None` when it holds none.777
- /// The caller must also handle rejection and explicit logout.778
- pub async fn restore(sessions: Arc<dyn SessionStore>) -> Result<Option<Restore>, AccountError> {779
- let Some(record) = sessions.load().await? else {780
- return Ok(None);781
- };782
- let authentication = match record.authentication {783
- AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),784
- AuthenticationRecord::OAuth { client_id, user } => {785
- OAuthSession { client_id, user }.into()786
- }787
- AuthenticationRecord::PendingOAuth { .. } if record.oauth_expired() => {788
- let previous = record.resumable();789
- match previous {790
- Some(previous) => put_back(sessions.as_ref(), previous).await,791
- None => sessions.discard().await?,792
- }793
- return Box::pin(Self::restore(sessions)).await;794
- }795
- AuthenticationRecord::PendingOAuth { .. } => return Ok(Some(Restore::OAuthPending)),796
- AuthenticationRecord::SoftLoggedOut { meta, client_id } => {797
- return Ok(Some(Restore::SoftLoggedOut(SoftLogout {798
- homeserver: record.homeserver,799
- user_id: meta.user_id,800
- device_id: meta.device_id,801
- client_id: client_id.map(|client_id| client_id.as_str().to_owned()),802
- })));803
- }804
- };805
- let store_lock = sessions.store().lock().await?;806
- let client = matrix_client_builder(sessions.store())807
- .homeserver_url(&record.homeserver)808
- .build()809
- .await810
- .map_err(|error| account_error("reopening the Matrix store", error))?;811
- let tracking = Tracking::start(&client, sessions)?;812
- client813
- .restore_session(authentication)814
- .await815
- .map_err(|error| account_error("restoring the authenticated session", error))?;816
- Ok(Some(Restore::Resumed(Self {817
- client,818
- tracking,819
- store_lock,820
- })))821
- }822
-823
- /// leave the record to the host, which then saves `ClientHandle::session_record()`824
- /// on every `SessionTokensChanged`825
- #[must_use]826
- pub fn without_persistence(mut self) -> Self {827
- self.tracking.persistence = None;828
- #[cfg(not(target_family = "wasm"))]829
- self.tracking.inline.store(false, Ordering::Release);830
- self831
- }832
-833
- pub async fn build(834
- self,835
- config: EngineConfig,836
- ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {837
- Engine::with_persistence(838
- self.client,839
- config,840
- self.tracking.session_changes,841
- self.tracking.persistence,842
- Some(self.store_lock),843
- )844
- .await845
- .map_err(AccountError::from)846
- }847
-848
- async fn saved(849
- client: Client,850
- sessions: Arc<dyn SessionStore>,851
- tracking: Tracking,852
- store_lock: StoreLock,853
- ) -> Result<Self, AccountError> {854
- UnsavedSession {855
- session: Self {856
- client,857
- tracking,858
- store_lock,859
- },860
- sessions,861
- }862
- .save()863
- .await864
- }865
-}866
-867
-impl Tracking {868
- fn start(client: &Client, sessions: Arc<dyn SessionStore>) -> Result<Self, AccountError> {869
- let session_changes = client.subscribe_to_session_changes();870
- #[cfg_attr(target_family = "wasm", allow(unused_variables))]871
- let (results, inline_results) = mpsc::unbounded_channel();872
- #[cfg(not(target_family = "wasm"))]873
- let inline = save_inline(client, sessions.clone(), results)?;874
- Ok(Self {875
- session_changes,876
- persistence: Some(Persistence {877
- save: Box::new(move |client| {878
- let sessions = sessions.clone();879
- Box::pin(async move {880
- let record = session_record(&client).map_err(save_failed)?;881
- sessions.save(&record).await.map_err(save_failed)882
- })883
- }),884
- inline: inline_results,885
- }),886
- #[cfg(not(target_family = "wasm"))]887
- inline,888
- })889
- }890
-}891
-892
-/// the sdk calls this inside its refresh, after it has the new tokens and before it uses893
-/// them, so a store that writes there and then can't lose a refresh token to a crash894
-#[cfg(not(target_family = "wasm"))]895
-fn save_inline(896
- client: &Client,897
- sessions: Arc<dyn SessionStore>,898
- results: mpsc::UnboundedSender<Result<(), crate::Error>>,899
-) -> Result<Arc<AtomicBool>, AccountError> {900
- let enabled = Arc::new(AtomicBool::new(true));901
- let saving = enabled.clone();902
- client903
- .set_session_callbacks(904
- // only asked for with the cross process refresh lock, which we never enable905
- Box::new(|client| {906
- client907
- .session_tokens()908
- .ok_or_else(|| AccountError::MissingSession.into())909
- }),910
- Box::new(move |client| {911
- if !saving.load(Ordering::Acquire) {912
- return Ok(());913
- }914
- let record = session_record(&client)?;915
- if let Some(result) = sessions.save_blocking(&record) {916
- let _ = results.send(result.map_err(save_failed));917
- }918
- Ok(())919
- }),920
- )921
- .map_err(|error| account_error("registering the session callbacks", error))?;922
- Ok(enabled)923
-}924
-925
-fn save_failed(error: AccountError) -> crate::Error {926
- let message = format!("saving the session record failed, {error}");927
- crate::Error::with_source(ErrorKind::Store, message, error)928
-}929
-930
-pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {931
- let authentication = match client.session().ok_or(AccountError::MissingSession)? {932
- AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),933
- AuthSession::OAuth(session) => {934
- let OAuthSession { client_id, user } = *session;935
- AuthenticationRecord::OAuth { client_id, user }936
- }937
- _ => return Err(AccountError::UnsupportedAuthentication),938
- };939
- Ok(SessionRecord {940
- homeserver: client.homeserver().to_string(),941
- authentication,942
- })943
-}944
-945
-fn encryption_settings() -> EncryptionSettings {946
- EncryptionSettings {947
- auto_enable_cross_signing: true,948
- backup_download_strategy: BackupDownloadStrategy::OneShot,949
- auto_enable_backups: true,950
- }951
-}952
-953
-/// `StoreLock` already keeps every other client off the store, and the sdk's lease would cost954
-/// a store write per locked section and a task renewing it every 50ms955
-fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {956
- let builder = Client::builder()957
- .with_encryption_settings(encryption_settings())958
- .with_threading_support(ThreadingSupport::Enabled {959
- with_subscriptions: false,960
- })961
- .handle_refresh_tokens()962
- .cross_process_store_config(CrossProcessLockConfig::SingleProcess);963
- match &store.location {964
- #[cfg(feature = "sqlite")]965
- StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),966
- #[cfg(feature = "indexeddb")]967
- StoreLocation::IndexedDb(name) => builder.indexeddb_store(name, Some(&store.passphrase)),968
- }969
-}970
-971
-/// a bare server name goes through untouched, since that's what makes the sdk try `.well-known`972
-pub(crate) fn homeserver_input(973
- input: &str,974
- allow_insecure_loopback: bool,975
-) -> Result<&str, AccountError> {976
- let input = input.trim();977
- let invalid = |reason| Err(AccountError::InvalidHomeserver(reason));978
- if input.is_empty() {979
- return invalid("it's empty");980
- }981
- if input.contains(char::is_whitespace) {982
- return invalid("it has spaces in it");983
- }984
- if !input.contains("://") {985
- return match <&ruma::ServerName>::try_from(input) {986
- Ok(_) => Ok(input),987
- Err(_) => invalid("it's neither a server name nor a url"),988
- };989
- }990
- let Ok(url) = Url::parse(input) else {991
- return invalid("it isn't a valid url");992
- };993
- if !url.username().is_empty() || url.password().is_some() {994
- return invalid("it has a username in it");995
- }996
- let loopback = match url.host() {997
- None => return invalid("it has no host"),998
- Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),999
- Some(url::Host::Ipv4(address)) => address.is_loopback(),1000
- Some(url::Host::Ipv6(address)) => address.is_loopback(),1001
- };1002
- match url.scheme() {1003
- "https" => Ok(input),1004
- "http" if allow_insecure_loopback && loopback => Ok(input),1005
- "http" if allow_insecure_loopback => invalid("plain http only reaches loopback hosts"),1006
- "http" => invalid("it isn't https"),1007
- _ => invalid("only http and https are spoken"),1008
- }1009
-}1010
-1011
-pub(crate) fn account_error(1012
- context: &'static str,1013
- source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,1014
-) -> AccountError {1015
- AccountError::Operation {1016
- context,1017
- kind: crate::error::classify(&source),1018
- server: ServerError::find(&source),1019
- source: Box::new(source),1020
- }1021
-}@@ -1,470 +0,0 @@
1
-use std::sync::Arc;2
-#[cfg(feature = "sqlite")]3
-use std::{collections::BTreeSet, path::PathBuf};4
-5
-use ruma::TransactionId;6
-7
-use crate::{8
- AccountError, EncryptedSessionStore, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore,9
- Session, SessionRecord, SessionStore, SoftLogout, StoreConfig,10
- account::{callback_state, homeserver_input},11
- engine::StoreLock,12
-};13
-14
-/// every account one owner has, a store each under one root and one passphrase. whoever holds15
-/// the set can resume any soft-logged-out device in it with just its password, and the device's16
-/// store has its e2ee keys, so a set belongs to one person and never to a whole machine17
-pub struct Accounts {18
- root: Root,19
- passphrase: String,20
-}21
-22
-enum Root {23
- #[cfg(feature = "sqlite")]24
- Directory(PathBuf),25
- #[cfg(feature = "indexeddb")]26
- Prefix(String),27
-}28
-29
-/// one account's record and store30
-#[derive(Clone)]31
-pub struct Account {32
- id: String,33
- sessions: Arc<EncryptedSessionStore>,34
-}35
-36
-impl Account {37
- /// stable for the account's life, and safe in a cookie or a url38
- #[must_use]39
- pub fn id(&self) -> &str {40
- &self.id41
- }42
-43
- /// for `Termination::retain`. a discarded account drops out of `Accounts::list`44
- #[must_use]45
- pub fn sessions(&self) -> Arc<dyn SessionStore> {46
- self.sessions.clone()47
- }48
-49
- pub async fn restore(&self) -> Result<Option<Restore>, AccountError> {50
- Session::restore(self.sessions()).await51
- }52
-}53
-54
-impl std::fmt::Debug for Account {55
- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {56
- formatter57
- .debug_struct("Account")58
- .field("id", &self.id)59
- .finish_non_exhaustive()60
- }61
-}62
-63
-enum Found {64
- Nobody,65
- SoftLoggedOut(Account, SoftLogout),66
-}67
-68
-impl Accounts {69
- /// each account is a store directory under `root` with its record beside it70
- #[cfg(feature = "sqlite")]71
- #[must_use]72
- pub fn sqlite(root: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {73
- Self {74
- root: Root::Directory(root.into()),75
- passphrase: passphrase.into(),76
- }77
- }78
-79
- /// each account is a store named `<prefix>::<id>`, listed in `<prefix>::macltok-accounts`80
- #[cfg(feature = "indexeddb")]81
- #[must_use]82
- pub fn indexeddb(prefix: impl Into<String>, passphrase: impl Into<String>) -> Self {83
- Self {84
- root: Root::Prefix(prefix.into()),85
- passphrase: passphrase.into(),86
- }87
- }88
-89
- /// `None` for anything `Account::id` couldn't have been90
- #[must_use]91
- pub fn account(&self, id: &str) -> Option<Account> {92
- let valid =93
- !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric());94
- valid.then(|| self.open(id.to_owned()))95
- }96
-97
- /// every account with a record. a store left without one, by a login that died halfway or a98
- /// discard that couldn't finish, is deleted on the way past, and so is an oauth login whose99
- /// redirect never came back. one that resumed an account puts that account back100
- pub async fn list(&self) -> Result<Vec<Account>, AccountError> {101
- let mut accounts = Vec::new();102
- for account in self.slots().await? {103
- match account.sessions.load().await {104
- Ok(None) => self.sweep(&account).await,105
- Ok(Some(record)) if record.oauth_expired() => {106
- if self.expire(&account).await {107
- accounts.push(account);108
- }109
- }110
- Ok(Some(_)) | Err(_) => accounts.push(account),111
- }112
- }113
- Ok(accounts)114
- }115
-116
- /// finishes an oauth login begun before a redirect, finding its account by the callback's117
- /// state, so the host needs to have kept nothing across the redirect118
- pub async fn finish_oauth(119
- &self,120
- callback_url: &str,121
- ) -> Result<(Account, Session), AccountError> {122
- let state = url::Url::parse(callback_url)123
- .ok()124
- .as_ref()125
- .and_then(callback_state)126
- .ok_or(AccountError::NoPendingOAuth)?;127
- for account in self.slots().await? {128
- let Ok(Some(record)) = account.sessions.load().await else {129
- continue;130
- };131
- if record.pending_oauth_state() != Some(state.as_str()) {132
- continue;133
- }134
- let store_lock = account.sessions.store().lock().await?;135
- let session =136
- Session::finish_oauth_locked(account.sessions(), store_lock, callback_url).await?;137
- return Ok((account, session));138
- }139
- Err(AccountError::NoPendingOAuth)140
- }141
-142
- /// a soft-logged-out account the login names is resumed as its own device, keeping its keys.143
- /// anyone else gets a new account144
- pub async fn login_password(145
- &self,146
- login: PasswordLogin,147
- ) -> Result<(Account, Session), AccountError> {148
- homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;149
- let found = self150
- .find(|record| record_names(record, &login.homeserver, &login.username))151
- .await?;152
- match found {153
- Found::SoftLoggedOut(account, soft) => {154
- let store_lock = account.sessions.store().lock().await?;155
- let login = PasswordLogin {156
- device_display_name: login.device_display_name,157
- ..soft.password_login(login.password)158
- };159
- let session =160
- Session::login_password_locked(login, account.sessions(), store_lock).await?;161
- Ok((account, session))162
- }163
- Found::Nobody => {164
- let (account, store_lock) = self.fresh().await?;165
- let login = PasswordLogin {166
- device_id: None,167
- ..login168
- };169
- match Session::login_password_locked(login, account.sessions(), store_lock).await {170
- Ok(session) => Ok((account, session)),171
- Err(error) => Err(self.abandon(&account, error).await),172
- }173
- }174
- }175
- }176
-177
- /// resumes the soft-logged-out account whose device `login` names, as178
- /// `SoftLogout::oauth_login` sets it. without one it's a new account, since which account an179
- /// OAuth login is for isn't known until it finishes180
- pub async fn begin_oauth(181
- &self,182
- login: OAuthLogin,183
- ) -> Result<(Account, PendingOAuthLogin), AccountError> {184
- homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;185
- let device_id = login.device_id.clone();186
- let found = self187
- .find(|record| {188
- record189
- .soft_logout()190
- .is_some_and(|soft| device_id.as_ref() == Some(&soft.device_id))191
- })192
- .await?;193
- match found {194
- Found::SoftLoggedOut(account, soft) => {195
- let store_lock = account.sessions.store().lock().await?;196
- let login = OAuthLogin {197
- homeserver: soft.homeserver,198
- client_id: login.client_id.or(soft.client_id),199
- allow_insecure_loopback: true,200
- ..login201
- };202
- let pending =203
- Session::begin_oauth_locked(login, account.sessions(), store_lock).await?;204
- Ok((account, pending))205
- }206
- Found::Nobody => {207
- let (account, store_lock) = self.fresh().await?;208
- let login = OAuthLogin {209
- device_id: None,210
- ..login211
- };212
- match Session::begin_oauth_locked(login, account.sessions(), store_lock).await {213
- Ok(pending) => Ok((account, pending)),214
- Err(error) => Err(self.abandon(&account, error).await),215
- }216
- }217
- }218
- }219
-220
- /// a live login for the same person is refused rather than given a second device221
- async fn find(&self, resumes: impl Fn(&SessionRecord) -> bool) -> Result<Found, AccountError> {222
- for account in self.slots().await? {223
- let Ok(Some(record)) = account.sessions.load().await else {224
- continue;225
- };226
- if !resumes(&record) {227
- continue;228
- }229
- return match (record.soft_logout(), record.user_id()) {230
- (Some(soft), _) => Ok(Found::SoftLoggedOut(account, soft)),231
- (None, Some(user_id)) => Err(AccountError::AlreadyLoggedIn(user_id.clone())),232
- (None, None) => continue,233
- };234
- }235
- Ok(Found::Nobody)236
- }237
-238
- /// locked before anything else can see it, so `list` never sweeps a login still under way239
- async fn fresh(&self) -> Result<(Account, StoreLock), AccountError> {240
- let account = self.open(TransactionId::new().to_string());241
- let store_lock = account.sessions.store().lock().await?;242
- #[cfg(feature = "indexeddb")]243
- {244
- let Root::Prefix(prefix) = &self.root;245
- index::add(prefix, &account.id).await?;246
- }247
- Ok((account, store_lock))248
- }249
-250
- /// a failed first login leaves nothing behind. one whose record didn't save has a live device251
- /// on the server, so that stays for `UnsavedSession::save`252
- async fn abandon(&self, account: &Account, error: AccountError) -> AccountError {253
- if !matches!(error, AccountError::Unsaved { .. }) {254
- self.sweep(account).await;255
- }256
- error257
- }258
-259
- /// best effort, since whatever it leaves is found and swept again by the next `list`260
- async fn sweep(&self, account: &Account) {261
- let Ok(_store_lock) = account.sessions.store().lock().await else {262
- return;263
- };264
- if matches!(account.sessions.load().await, Ok(None)) {265
- self.remove(account).await;266
- }267
- }268
-269
- /// whether the account is still there. a login running in another tab holds the lock and270
- /// is left alone271
- async fn expire(&self, account: &Account) -> bool {272
- let Ok(_store_lock) = account.sessions.store().lock().await else {273
- return true;274
- };275
- let Ok(Some(record)) = account.sessions.load().await else {276
- return true;277
- };278
- if !record.oauth_expired() {279
- return true;280
- }281
- match record.resumable() {282
- Some(previous) => {283
- let _ = account.sessions.save(&previous).await;284
- true285
- }286
- None => {287
- self.remove(account).await;288
- false289
- }290
- }291
- }292
-293
- async fn remove(&self, account: &Account) {294
- if account.sessions.discard().await.is_err() {295
- return;296
- }297
- #[cfg(feature = "indexeddb")]298
- {299
- let Root::Prefix(prefix) = &self.root;300
- let _ = index::remove(prefix, &account.id).await;301
- }302
- }303
-304
- fn open(&self, id: String) -> Account {305
- let sessions = match &self.root {306
- #[cfg(feature = "sqlite")]307
- Root::Directory(root) => EncryptedSessionStore::file(308
- root.join(format!("{id}.session")),309
- StoreConfig::sqlite(root.join(&id), self.passphrase.clone()),310
- ),311
- #[cfg(feature = "indexeddb")]312
- Root::Prefix(prefix) => EncryptedSessionStore::indexeddb(StoreConfig::indexeddb(313
- format!("{prefix}::{id}"),314
- self.passphrase.clone(),315
- )),316
- };317
- Account {318
- id,319
- sessions: Arc::new(sessions),320
- }321
- }322
-323
- async fn slots(&self) -> Result<Vec<Account>, AccountError> {324
- let ids = match &self.root {325
- #[cfg(feature = "sqlite")]326
- Root::Directory(root) => directory_slots(root)?,327
- #[cfg(feature = "indexeddb")]328
- Root::Prefix(prefix) => index::list(prefix).await?,329
- };330
- Ok(ids.iter().filter_map(|id| self.account(id)).collect())331
- }332
-}333
-334
-/// a localpart or a full user id names the same account, but only on the same server. the server335
-/// is compared by name as well as by url, so a delegated `example.org` still finds the account336
-/// discovery took to `matrix.example.org`337
-fn record_names(record: &SessionRecord, homeserver: &str, username: &str) -> bool {338
- let Some(user_id) = record.user_id() else {339
- return false;340
- };341
- let username = username.trim();342
- let user = if username.starts_with('@') {343
- username.eq_ignore_ascii_case(user_id.as_str())344
- } else {345
- username.eq_ignore_ascii_case(user_id.localpart())346
- };347
- let typed = authority(homeserver);348
- user && (typed.eq_ignore_ascii_case(user_id.server_name().as_str())349
- || typed.eq_ignore_ascii_case(authority(record.homeserver())))350
-}351
-352
-fn authority(homeserver: &str) -> &str {353
- let homeserver = homeserver.trim();354
- let rest = homeserver355
- .split_once("://")356
- .map_or(homeserver, |(_, rest)| rest);357
- rest.split('/').next().unwrap_or(rest)358
-}359
-360
-#[cfg(feature = "sqlite")]361
-fn directory_slots(root: &std::path::Path) -> Result<BTreeSet<String>, AccountError> {362
- use std::io::ErrorKind;363
-364
- let entries = match std::fs::read_dir(root) {365
- Ok(entries) => entries,366
- Err(error) if error.kind() == ErrorKind::NotFound => return Ok(BTreeSet::new()),367
- Err(error) => return Err(crate::account::account_error("listing the accounts", error)),368
- };369
- let mut ids = BTreeSet::new();370
- for entry in entries {371
- let entry =372
- entry.map_err(|error| crate::account::account_error("listing the accounts", error))?;373
- let name = entry.file_name();374
- let Some(name) = name.to_str() else {375
- continue;376
- };377
- if entry.path().is_dir() {378
- ids.insert(name.to_owned());379
- } else if let Some(id) = name.strip_suffix(".session") {380
- ids.insert(id.to_owned());381
- }382
- }383
- Ok(ids)384
-}385
-386
-/// a key per account, so two tabs adding at once can't lose one to the other387
-#[cfg(feature = "indexeddb")]388
-mod index {389
- use std::collections::BTreeSet;390
-391
- use indexed_db_futures::{392
- Build,393
- database::Database,394
- error::{Error, OpenDbError},395
- prelude::*,396
- transaction::{Transaction, TransactionMode},397
- };398
-399
- use crate::{AccountError, account::account_error};400
-401
- const ACCOUNTS: &str = "accounts";402
-403
- fn name(prefix: &str) -> String {404
- format!("{prefix}::macltok-accounts")405
- }406
-407
- async fn open(prefix: &str) -> Result<Database, OpenDbError> {408
- Database::open(&name(prefix))409
- .with_version(1u32)410
- .with_on_upgrade_needed(|_, transaction: &Transaction<'_>| -> Result<(), Error> {411
- transaction.db().create_object_store(ACCOUNTS).build()?;412
- Ok(())413
- })414
- .build()?415
- .await416
- }417
-418
- pub(super) async fn list(prefix: &str) -> Result<BTreeSet<String>, AccountError> {419
- let failed = |error| account_error("listing the accounts", error);420
- let database = open(prefix).await.map_err(failed)?;421
- let ids = keys(&database).await;422
- database.close();423
- ids.map_err(|error| account_error("listing the accounts", error))424
- }425
-426
- pub(super) async fn add(prefix: &str, id: &str) -> Result<(), AccountError> {427
- let failed = |error| account_error("adding the account", error);428
- let database = open(prefix).await.map_err(failed)?;429
- let added = write(&database, |store| {430
- store.put(true).with_key(id).build()?;431
- Ok(())432
- })433
- .await;434
- database.close();435
- added.map_err(|error| account_error("adding the account", error))436
- }437
-438
- pub(super) async fn remove(prefix: &str, id: &str) -> Result<(), AccountError> {439
- let failed = |error| account_error("removing the account", error);440
- let database = open(prefix).await.map_err(failed)?;441
- let removed = write(&database, |store| {442
- store.delete(id).build()?;443
- Ok(())444
- })445
- .await;446
- database.close();447
- removed.map_err(|error| account_error("removing the account", error))448
- }449
-450
- async fn keys(database: &Database) -> Result<BTreeSet<String>, Error> {451
- let transaction = database452
- .transaction(ACCOUNTS)453
- .with_mode(TransactionMode::Readonly)454
- .build()?;455
- let store = transaction.object_store(ACCOUNTS)?;456
- store.get_all_keys::<String>().await?.collect()457
- }458
-459
- async fn write(460
- database: &Database,461
- change: impl FnOnce(&indexed_db_futures::object_store::ObjectStore<'_>) -> Result<(), Error>,462
- ) -> Result<(), Error> {463
- let transaction = database464
- .transaction(ACCOUNTS)465
- .with_mode(TransactionMode::Readwrite)466
- .build()?;467
- change(&transaction.object_store(ACCOUNTS)?)?;468
- transaction.commit().await469
- }470
-}@@ -1,903 +0,0 @@
1
-use std::sync::Arc;2
-3
-use futures_util::StreamExt;4
-use matrix_sdk::executor::{JoinHandle, spawn};5
-use matrix_sdk::{6
- Client,7
- encryption::{8
- VerificationState,9
- backups::BackupState,10
- recovery::RecoveryState as SdkRecoveryState,11
- verification::{12
- SasState, SasVerification, Verification, VerificationRequest, VerificationRequestState,13
- },14
- },15
- ruma::events::key::verification::VerificationMethod,16
-};17
-use ruma::{OwnedDeviceId, UserId};18
-use tokio::sync::Mutex;19
-20
-use crate::{21
- EncryptionState, Error, ErrorKind, Event, KeyBackupState, RecoveryState, VerificationAction,22
- VerificationState as UiState, VerificationTarget, model::VerificationFailure,23
- transport::EventSender,24
-};25
-26
-pub(crate) fn encryption_state(27
- device_id: OwnedDeviceId,28
- recovery: SdkRecoveryState,29
- verification: VerificationState,30
- backup: BackupState,31
-) -> EncryptionState {32
- EncryptionState {33
- device_id,34
- device_verified: matches!(verification, VerificationState::Verified),35
- recovery: match recovery {36
- SdkRecoveryState::Unknown => RecoveryState::Unknown,37
- SdkRecoveryState::Disabled => RecoveryState::Disabled,38
- SdkRecoveryState::Incomplete => RecoveryState::Incomplete,39
- SdkRecoveryState::Enabled => RecoveryState::Enabled,40
- },41
- key_backup: match backup {42
- BackupState::Unknown => KeyBackupState::Unknown,43
- BackupState::Creating => KeyBackupState::Creating,44
- BackupState::Enabling => KeyBackupState::Enabling,45
- BackupState::Resuming => KeyBackupState::Resuming,46
- BackupState::Enabled => KeyBackupState::Enabled,47
- BackupState::Downloading => KeyBackupState::Downloading,48
- BackupState::Disabling => KeyBackupState::Disabling,49
- },50
- }51
-}52
-53
-#[derive(Default)]54
-pub(crate) struct VerificationController {55
- active: Arc<Mutex<VerificationSession>>,56
- task: Option<JoinHandle<()>>,57
-}58
-59
-#[derive(Default)]60
-struct VerificationSession {61
- request: Option<VerificationRequest>,62
- sas: Option<SasVerification>,63
-}64
-65
-impl VerificationController {66
- pub(crate) async fn apply(67
- &mut self,68
- sender: EventSender,69
- action: VerificationAction,70
- ) -> Result<(), Error> {71
- match action {72
- VerificationAction::Accept => self.accept().await,73
- VerificationAction::Confirm => {74
- // Retain a handle across the operation: the watcher clears75
- // `active` when it publishes a terminal event, but this clone76
- // still observes the shared SDK state afterwards.77
- let sas = self.active.lock().await.sas.clone();78
- match self.confirm().await {79
- Ok(()) => Ok(()),80
- Err(error) if error.kind == ErrorKind::VerificationInactive => Err(error),81
- // The confirm advanced local SDK state before its send failed,82
- // so the retained session could report success on retry without83
- // network traffic. Best-effort cancel (no lock held across the84
- // network), clear for restart, then report the terminal state.85
- Err(error) => {86
- // Stop the watcher first so it cannot emit a stale87
- // Confirmed from the local transition we are unwinding.88
- self.stop_task().await;89
- // The peer can complete or cancel the verification90
- // while our own Done send fails. The stopped watcher may91
- // already have published that terminal outcome (losing the92
- // claim below); otherwise this path owns it and must emit93
- // it instead of regressing to Cancelled.94
- if sas.as_ref().is_some_and(|sas| sas.is_done()) {95
- if take_verification(&self.active).await {96
- let _ =97
- sender.send(Event::VerificationChanged(UiState::Done)).await;98
- }99
- return Err(error);100
- }101
- if let Some(reason) = sas102
- .as_ref()103
- .and_then(|sas| sas.cancel_info())104
- .map(|info| info.reason().to_owned())105
- {106
- if take_verification(&self.active).await {107
- let _ = sender108
- .send(Event::VerificationChanged(UiState::Cancelled(reason)))109
- .await;110
- }111
- return Err(error);112
- }113
- if let Some(sas) = sas {114
- if sas.cancel().await.is_err() {115
- // The best-effort unwind already advanced116
- // local state; clearing here keeps the failed117
- // cancellation from looking retryable. The118
- // send is owned only if the watcher has not119
- // already claimed the session for its own120
- // terminal outcome.121
- if take_verification(&self.active).await {122
- let _ = sender123
- .send(Event::VerificationChanged(UiState::Cancelled(124
- error.to_string(),125
- )))126
- .await;127
- }128
- return Err(error);129
- }130
- // The peer can complete while the unwind cancel is in131
- // flight and the SDK call no-ops; the Done then owns the132
- // terminal outcome, so report it instead of attributing133
- // it to the send failure.134
- if sas.is_done() {135
- if take_verification(&self.active).await {136
- let _ = sender137
- .send(Event::VerificationChanged(UiState::Done))138
- .await;139
- }140
- return Err(error);141
- }142
- if let Some(info) = sas.cancel_info() {143
- // Our own unwind cancel just marked the flow; it does144
- // not own the outcome yet, so fall through and report145
- // the send failure as Cancelled below. Only a peer146
- // cancellation racing the unwind owns it instead.147
- if !info.cancelled_by_us() {148
- if take_verification(&self.active).await {149
- let _ = sender150
- .send(Event::VerificationChanged(UiState::Cancelled(151
- info.reason().to_owned(),152
- )))153
- .await;154
- }155
- return Err(Error::new(156
- ErrorKind::VerificationInactive,157
- info.reason().to_owned(),158
- ));159
- }160
- }161
- }162
- if take_verification(&self.active).await {163
- let _ = sender164
- .send(Event::VerificationChanged(UiState::Cancelled(165
- error.to_string(),166
- )))167
- .await;168
- }169
- Err(error)170
- }171
- }172
- }173
- VerificationAction::Reject => self.reject().await,174
- VerificationAction::Cancel => {175
- // The SDK no-ops a cancel of an already-settled flow, so a176
- // failed claim inside `cancel` already reported the true177
- // outcome as an error and owns no event here. Only the claim178
- // winner emits the local Cancelled.179
- let claimed = self.cancel_claimed(Some(&sender)).await?;180
- if claimed {181
- sender182
- .send(Event::VerificationChanged(UiState::Cancelled(183
- "cancelled by this client".to_owned(),184
- )))185
- .await186
- .map_err(|source| {187
- Error::with_source(188
- ErrorKind::Interrupted,189
- "event receiver has closed",190
- source,191
- )192
- })?;193
- }194
- Ok(())195
- }196
- }197
- }198
-199
- pub(crate) async fn start(200
- &mut self,201
- client: &Client,202
- sender: EventSender,203
- target: VerificationTarget,204
- ) -> Result<(), Error> {205
- client206
- .encryption()207
- .wait_for_e2ee_initialization_tasks()208
- .await;209
- let own_user_id = client.user_id().ok_or_else(|| {210
- Error::new(211
- ErrorKind::Unauthenticated,212
- "the Matrix client is not authenticated",213
- )214
- })?;215
- if self.has_active_verification().await {216
- self.cancel().await?;217
- } else {218
- self.stop_task().await;219
- }220
- let request = match &target {221
- VerificationTarget::OwnIdentity => {222
- request_identity_verification(client, own_user_id).await?223
- }224
- VerificationTarget::User(user_id) => {225
- request_identity_verification(client, user_id).await?226
- }227
- VerificationTarget::Device { user_id, device_id } => {228
- let device = client229
- .encryption()230
- .get_device(user_id, device_id)231
- .await232
- .map_err(|source| {233
- Error::with_source(234
- ErrorKind::Encryption,235
- "reading the verification device failed",236
- source,237
- )238
- })?239
- .ok_or_else(|| {240
- Error::new(241
- ErrorKind::VerificationUnsupported,242
- "the verification device is unavailable",243
- )244
- })?;245
- device246
- .request_verification_with_methods(vec![VerificationMethod::SasV1])247
- .await248
- .map_err(|source| {249
- Error::from_sdk("starting device verification failed", source)250
- })?251
- }252
- };253
- // subscribed before anything can wait on the event channel, or a quick peer's254
- // answer lands while we're blocked and nobody ever hears it255
- let changes = request.changes();256
- self.active.lock().await.request = Some(request.clone());257
- sender258
- .send(Event::VerificationTargetChanged(target))259
- .await260
- .map_err(|source| {261
- Error::with_source(ErrorKind::Interrupted, "event receiver has closed", source)262
- })?;263
- sender264
- .send(Event::VerificationChanged(UiState::Waiting))265
- .await266
- .map_err(|source| {267
- Error::with_source(ErrorKind::Interrupted, "event receiver has closed", source)268
- })?;269
- let active = self.active.clone();270
- self.task = Some(spawn(async move {271
- follow_request(request, changes, active, sender).await;272
- }));273
- Ok(())274
- }275
-276
- /// another device asked us. only taken when nothing else is in flight, and not accepted277
- /// until the user says so with `VerificationAction::Accept`278
- pub(crate) async fn adopt_incoming(279
- &mut self,280
- sender: EventSender,281
- request: VerificationRequest,282
- ) {283
- if request.we_started() || request.is_done() || request.cancel_info().is_some() {284
- return;285
- }286
- if self.has_active_verification().await {287
- tracing::debug!("ignoring a verification request while another is in flight");288
- return;289
- }290
- self.stop_task().await;291
- let changes = request.changes();292
- self.active.lock().await.request = Some(request.clone());293
- let incoming = UiState::Incoming {294
- user_id: request.other_user_id().to_owned(),295
- device_id: match request.state() {296
- VerificationRequestState::Requested {297
- other_device_data, ..298
- }299
- | VerificationRequestState::Ready {300
- other_device_data, ..301
- } => Some(other_device_data.device_id().to_owned()),302
- _ => None,303
- },304
- };305
- let target = VerificationTarget::User(request.other_user_id().to_owned());306
- for event in [307
- Event::VerificationTargetChanged(target),308
- Event::VerificationChanged(incoming),309
- ] {310
- if sender.send(event).await.is_err() {311
- return;312
- }313
- }314
- let active = self.active.clone();315
- self.task = Some(spawn(async move {316
- follow_request(request, changes, active, sender).await;317
- }));318
- }319
-320
- async fn accept(&self) -> Result<(), Error> {321
- let inactive = |message: &str| Error::new(ErrorKind::VerificationInactive, message);322
- let (request, sas) = {323
- let active = self.active.lock().await;324
- (active.request.clone(), active.sas.is_some())325
- };326
- let request = request.ok_or_else(|| inactive("no verification request is pending"))?;327
- if request.we_started() {328
- return Err(inactive("nobody asked us, we asked them"));329
- }330
- if request.is_done() {331
- return Err(inactive("verification is already complete"));332
- }333
- if let Some(info) = request.cancel_info() {334
- return Err(inactive(info.reason()));335
- }336
- if sas {337
- return Err(inactive("verification has already started"));338
- }339
- request340
- .accept_with_methods(vec![VerificationMethod::SasV1])341
- .await342
- .map_err(|source| Error::from_sdk("accepting verification request failed", source))343
- }344
-345
- async fn confirm(&self) -> Result<(), Error> {346
- let sas = self.active.lock().await.sas.clone().ok_or_else(|| {347
- Error::new(348
- ErrorKind::VerificationInactive,349
- "verification has not reached comparison",350
- )351
- })?;352
- // Report a settled flow truthfully instead of as "not reached353
- // comparison": the SDK would no-op the confirm with success.354
- if sas.is_done() {355
- return Err(Error::new(356
- ErrorKind::VerificationInactive,357
- "verification is already complete",358
- ));359
- }360
- if let Some(info) = sas.cancel_info() {361
- return Err(Error::new(362
- ErrorKind::VerificationInactive,363
- info.reason().to_owned(),364
- ));365
- }366
- // Only the short-auth-string stage is confirmable. The SDK reports the367
- // remote-MAC-arrived state through the same presentable flag, while an368
- // early confirm would locally no-op and falsely report success.369
- if !sas.can_be_presented() {370
- return Err(Error::new(371
- ErrorKind::VerificationInactive,372
- "verification has not reached comparison",373
- ));374
- }375
- // Our own confirm can move the flow to Done, so only Done that predates376
- // the send means the SDK no-op'd on a stale session; a cancellation377
- // racing the send must not report success.378
- let was_done = sas.is_done();379
- sas.confirm()380
- .await381
- .map_err(|source| Error::from_sdk("confirming verification failed", source))?;382
- if sas.is_done() {383
- if was_done {384
- return Err(Error::new(385
- ErrorKind::VerificationInactive,386
- "verification is already complete",387
- ));388
- }389
- } else if sas.is_cancelled() {390
- let reason = sas391
- .cancel_info()392
- .map(|info| info.reason().to_owned())393
- .unwrap_or_else(|| "verification was cancelled".to_owned());394
- return Err(Error::new(ErrorKind::VerificationInactive, reason));395
- }396
- Ok(())397
- }398
-399
- async fn reject(&self) -> Result<(), Error> {400
- let sas = self.active.lock().await.sas.clone().ok_or_else(|| {401
- Error::new(402
- ErrorKind::VerificationInactive,403
- "verification has not reached comparison",404
- )405
- })?;406
- // Report a settled flow truthfully instead of as "not reached407
- // comparison": the SDK would no-op the mismatch with success.408
- if sas.is_done() {409
- return Err(Error::new(410
- ErrorKind::VerificationInactive,411
- "verification is already complete",412
- ));413
- }414
- if let Some(info) = sas.cancel_info() {415
- return Err(Error::new(416
- ErrorKind::VerificationInactive,417
- info.reason().to_owned(),418
- ));419
- }420
- // Rejecting before the comparison stage would cancel a flow the user421
- // never compared, so it requires the same presentable state.422
- if !sas.can_be_presented() {423
- return Err(Error::new(424
- ErrorKind::VerificationInactive,425
- "verification has not reached comparison",426
- ));427
- }428
- sas.mismatch()429
- .await430
- .map_err(|source| Error::from_sdk("rejecting verification failed", source))?;431
- // A peer completing the flow concurrently must not be reported as432
- // rejected. A remote cancellation racing the send turns the SDK call433
- // into a no-op success, so report it instead of a false success.434
- if sas.is_done() {435
- return Err(Error::new(436
- ErrorKind::VerificationInactive,437
- "verification is already complete",438
- ));439
- }440
- if let Some(info) = sas.cancel_info() {441
- // Our own mismatch just marked the flow cancelled-by-us; that is the442
- // expected reject path and stays Ok. Only a peer cancel reports an443
- // error.444
- if !info.cancelled_by_us() {445
- return Err(Error::new(446
- ErrorKind::VerificationInactive,447
- info.reason().to_owned(),448
- ));449
- }450
- }451
- Ok(())452
- }453
- pub(crate) async fn cancel(&mut self) -> Result<(), Error> {454
- self.cancel_claimed(None).await.map(|_| ())455
- }456
-457
- /// Return whether this action won the claim for a local cancellation.458
- /// The optional sender reports a peer terminal outcome when it races after459
- /// the claim; starting a replacement verification does not publish it.460
- async fn cancel_claimed(&mut self, sender: Option<&EventSender>) -> Result<bool, Error> {461
- let (sas, request) = {462
- let active = self.active.lock().await;463
- (active.sas.clone(), active.request.clone())464
- };465
- if sas.is_none() && request.is_none() {466
- return Err(Error::new(467
- ErrorKind::VerificationInactive,468
- "no verification is active",469
- ));470
- }471
- // The watcher owns terminal emission and claims the session when it472
- // publishes. If the SDK is already terminal, the watcher either already473
- // reported it or is about to: leave session and watcher alone so the474
- // outcome is emitted exactly once, and report it instead of letting the475
- // SDK no-op success regress Done to "cancelled by this client".476
- if let Some(sas) = sas.as_ref() {477
- if sas.is_done() {478
- return Err(Error::new(479
- ErrorKind::VerificationInactive,480
- "verification is already complete",481
- ));482
- }483
- if let Some(info) = sas.cancel_info() {484
- return Err(Error::new(485
- ErrorKind::VerificationInactive,486
- info.reason().to_owned(),487
- ));488
- }489
- } else if let Some(request) = request.as_ref() {490
- if request.is_done() {491
- return Err(Error::new(492
- ErrorKind::VerificationInactive,493
- "verification is already complete",494
- ));495
- }496
- if let Some(info) = request.cancel_info() {497
- return Err(Error::new(498
- ErrorKind::VerificationInactive,499
- info.reason().to_owned(),500
- ));501
- }502
- }503
- // Own the terminal outcome from here: claim the session before504
- // stopping the watcher so a watcher that already claimed (and will505
- // emit its own terminal event) turns this into a truthful error506
- // instead of a duplicate local cancel; no lock is held across sends.507
- if !take_verification(&self.active).await {508
- // Re-check the SDK: the claim loss may be a stale session after a509
- // terminal outcome, or a concurrent start of a new flow. Only the510
- // terminal case reports here; a live session belongs to the new511
- // flow and must not be disturbed.512
- if let Some(sas) = sas.as_ref() {513
- if sas.is_done() {514
- return Err(Error::new(515
- ErrorKind::VerificationInactive,516
- "verification is already complete",517
- ));518
- }519
- if let Some(info) = sas.cancel_info() {520
- return Err(Error::new(521
- ErrorKind::VerificationInactive,522
- info.reason().to_owned(),523
- ));524
- }525
- } else if let Some(request) = request.as_ref() {526
- if request.is_done() {527
- return Err(Error::new(528
- ErrorKind::VerificationInactive,529
- "verification is already complete",530
- ));531
- }532
- if let Some(info) = request.cancel_info() {533
- return Err(Error::new(534
- ErrorKind::VerificationInactive,535
- info.reason().to_owned(),536
- ));537
- }538
- }539
- return Err(Error::new(540
- ErrorKind::VerificationInactive,541
- "no verification is active",542
- ));543
- }544
- self.stop_task().await;545
- if let Some(sas) = sas {546
- let result = sas.cancel().await;547
- if sas.is_done() {548
- report_claimed_terminal(sender, UiState::Done).await;549
- return Err(Error::new(550
- ErrorKind::VerificationInactive,551
- "verification is already complete",552
- ));553
- }554
- if let Some(info) = sas.cancel_info()555
- && !info.cancelled_by_us()556
- {557
- let reason = info.reason().to_owned();558
- report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;559
- return Err(Error::new(ErrorKind::VerificationInactive, reason));560
- }561
- if let Err(source) = result {562
- let error = Error::from_sdk("cancelling verification failed", source);563
- report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;564
- return Err(error);565
- }566
- Ok(true)567
- } else if let Some(request) = request {568
- let result = request.cancel().await;569
- if request.is_done() {570
- report_claimed_terminal(sender, UiState::Done).await;571
- return Err(Error::new(572
- ErrorKind::VerificationInactive,573
- "verification is already complete",574
- ));575
- }576
- if let Some(info) = request.cancel_info()577
- && !info.cancelled_by_us()578
- {579
- let reason = info.reason().to_owned();580
- report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;581
- return Err(Error::new(ErrorKind::VerificationInactive, reason));582
- }583
- if let Err(source) = result {584
- let error = Error::from_sdk("cancelling verification failed", source);585
- report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;586
- return Err(error);587
- }588
- Ok(true)589
- } else {590
- Ok(false)591
- }592
- }593
-594
- async fn has_active_verification(&self) -> bool {595
- let active = self.active.lock().await;596
- active.request.is_some() || active.sas.is_some()597
- }598
-599
- async fn stop_task(&mut self) {600
- if let Some(task) = self.task.take() {601
- task.abort();602
- let _ = task.await;603
- }604
- }605
-}606
-607
-/// shutdown forgets the flow without telling the sdk, a network send could hang it608
-impl Drop for VerificationController {609
- fn drop(&mut self) {610
- if let Some(task) = self.task.take() {611
- task.abort();612
- }613
- }614
-}615
-616
-async fn request_identity_verification(617
- client: &Client,618
- user_id: &UserId,619
-) -> Result<VerificationRequest, Error> {620
- let identity = client621
- .encryption()622
- .request_user_identity(user_id)623
- .await624
- .map_err(|source| Error::from_sdk("requesting the user identity failed", source))?625
- .ok_or_else(|| {626
- Error::new(627
- ErrorKind::NoCrossSigningIdentity,628
- "the user has no cross-signing identity",629
- )630
- })?;631
- identity632
- .request_verification_with_methods(vec![VerificationMethod::SasV1])633
- .await634
- .map_err(|source| Error::from_verification_request("starting verification failed", source))635
-}636
-637
-/// `changes` is subscribed by the caller before the request could move, and the current state638
-/// is handled first, since the stream only reports what happens after it was taken639
-async fn follow_request(640
- request: VerificationRequest,641
- changes: impl futures_util::Stream<Item = VerificationRequestState> + Unpin,642
- active: Arc<Mutex<VerificationSession>>,643
- sender: EventSender,644
-) {645
- let mut states = futures_util::stream::iter([request.state()]).chain(changes);646
- while let Some(state) = states.next().await {647
- match state {648
- // created is ours waiting on them, requested is theirs waiting on `Accept`649
- VerificationRequestState::Created { .. }650
- | VerificationRequestState::Requested { .. } => {}651
- VerificationRequestState::Ready { .. } => match request.start_sas().await {652
- Ok(Some(sas)) => {653
- follow_sas(sas, active.clone(), sender.clone()).await;654
- return;655
- }656
- Ok(None) => {}657
- Err(error) => {658
- tracing::warn!(error = %error, "starting SAS verification failed");659
- // Claim the session so this terminal outcome is emitted exactly660
- // once; a lost claim means an action path already owns it.661
- report_watcher_terminal(662
- &active,663
- &sender,664
- UiState::Failed(VerificationFailure::SasStartup(error.to_string())),665
- "SAS startup failure",666
- )667
- .await;668
- return;669
- }670
- },671
- VerificationRequestState::Transitioned { verification } => {672
- if let Verification::SasV1(sas) = verification {673
- follow_sas(sas, active.clone(), sender.clone()).await;674
- } else {675
- report_watcher_terminal(676
- &active,677
- &sender,678
- UiState::Cancelled(679
- "the other device selected an unsupported method".into(),680
- ),681
- "unsupported verification method",682
- )683
- .await;684
- }685
- return;686
- }687
- VerificationRequestState::Done => {688
- report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;689
- return;690
- }691
- VerificationRequestState::Cancelled(info) => {692
- report_watcher_terminal(693
- &active,694
- &sender,695
- UiState::Cancelled(info.reason().to_owned()),696
- "cancellation",697
- )698
- .await;699
- return;700
- }701
- }702
- }703
- tracing::debug!("verification request watcher ended unexpectedly");704
- // The stream may end after the SDK already settled without delivering the705
- // terminal update; report the settled outcome instead of StreamEnded, and706
- // stay silent if an action path already claimed the session.707
- if request.is_done() {708
- report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;709
- } else if let Some(info) = request.cancel_info() {710
- let reason = info.reason().to_owned();711
- report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;712
- } else {713
- report_watcher_terminal(714
- &active,715
- &sender,716
- UiState::Failed(VerificationFailure::RequestStreamEnded),717
- "request stream termination",718
- )719
- .await;720
- }721
-}722
-723
-async fn follow_sas(724
- sas: SasVerification,725
- active: Arc<Mutex<VerificationSession>>,726
- sender: EventSender,727
-) {728
- // the sdk may already have moved the flow along before we got here, a peer starting729
- // sas is the usual case, so take the stream first and the current state after730
- let changes = sas.changes();731
- active.lock().await.sas = Some(sas.clone());732
- let mut states = futures_util::stream::iter([sas.state()]).chain(changes);733
- while let Some(state) = states.next().await {734
- let update = match state {735
- SasState::Created { .. } | SasState::Accepted { .. } => continue,736
- SasState::Started { .. } => {737
- if let Err(error) = sas.accept().await {738
- tracing::warn!(error = %error, "accepting SAS verification failed");739
- // Claim the session so this terminal outcome is emitted exactly740
- // once; a lost claim means an action path already owns it.741
- report_watcher_terminal(742
- &active,743
- &sender,744
- UiState::Failed(VerificationFailure::SasAcceptance(error.to_string())),745
- "SAS acceptance failure",746
- )747
- .await;748
- return;749
- }750
- continue;751
- }752
- SasState::KeysExchanged { emojis, decimals } => UiState::Comparison {753
- emojis: emojis754
- .map(|short_auth| {755
- short_auth756
- .emojis757
- .into_iter()758
- .map(|emoji| (emoji.symbol.to_owned(), emoji.description.to_owned()))759
- .collect()760
- })761
- .unwrap_or_default(),762
- decimals,763
- },764
- SasState::Confirmed => UiState::Confirmed,765
- SasState::Done { .. } => UiState::Done,766
- SasState::Cancelled(info) => UiState::Cancelled(info.reason().to_owned()),767
- };768
- let terminal = matches!(update, UiState::Done | UiState::Cancelled(_));769
- // Claim the session before the terminal send so a racing Cancel observes770
- // no active session and the SDK no-op cannot regress Done back to771
- // "cancelled by this client". A lost claim means an action path already772
- // owns the terminal outcome, so stay silent instead of duplicating it.773
- if terminal && !take_verification(&active).await {774
- return;775
- }776
- if sender777
- .send(Event::VerificationChanged(update))778
- .await779
- .is_err()780
- {781
- tracing::debug!("SAS watcher stopped because the event receiver closed");782
- return;783
- }784
- if terminal {785
- return;786
- }787
- }788
- tracing::debug!("SAS watcher ended unexpectedly");789
- // The stream may end after the SDK already settled without delivering the790
- // terminal update; report the settled outcome instead of StreamEnded, and791
- // stay silent if an action path already claimed the session.792
- if sas.is_done() {793
- report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;794
- } else if let Some(info) = sas.cancel_info() {795
- let reason = info.reason().to_owned();796
- report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;797
- } else {798
- report_watcher_terminal(799
- &active,800
- &sender,801
- UiState::Failed(VerificationFailure::SasStreamEnded),802
- "SAS stream termination",803
- )804
- .await;805
- }806
-}807
-808
-async fn report_watcher_terminal(809
- active: &Arc<Mutex<VerificationSession>>,810
- sender: &EventSender,811
- state: UiState,812
- context: &'static str,813
-) {814
- if take_verification(active).await815
- && sender816
- .send(Event::VerificationChanged(state))817
- .await818
- .is_err()819
- {820
- tracing::debug!(821
- context,822
- "verification watcher could not report terminal outcome"823
- );824
- }825
-}826
-827
-async fn report_claimed_terminal(sender: Option<&EventSender>, state: UiState) {828
- if let Some(sender) = sender {829
- let _ = sender.send(Event::VerificationChanged(state)).await;830
- }831
-}832
-833
-/// Claim the active session for terminal emission, if one is still present.834
-///835
-/// Exactly one party (watcher or action path) wins the claim and may emit the836
-/// terminal event; the loser stays silent so Done is never overwritten by a837
-/// later local Cancelled/Failed.838
-async fn take_verification(active: &Arc<Mutex<VerificationSession>>) -> bool {839
- let mut active = active.lock().await;840
- let had = active.sas.is_some() || active.request.is_some();841
- active.sas.take();842
- active.request.take();843
- had844
-}845
-846
-#[cfg(test)]847
-mod tests {848
- use matrix_sdk::encryption::VerificationState;849
- use matrix_sdk::encryption::backups::BackupState;850
- use ruma::owned_device_id;851
-852
- use super::*;853
-854
- #[test]855
- fn sdk_security_state_is_normalized() {856
- assert_eq!(857
- encryption_state(858
- owned_device_id!("DEVICE"),859
- SdkRecoveryState::Incomplete,860
- VerificationState::Unverified,861
- BackupState::Unknown,862
- ),863
- EncryptionState {864
- device_id: owned_device_id!("DEVICE"),865
- device_verified: false,866
- recovery: RecoveryState::Incomplete,867
- key_backup: KeyBackupState::Unknown,868
- }869
- );870
- assert_eq!(871
- encryption_state(872
- owned_device_id!("DEVICE"),873
- SdkRecoveryState::Enabled,874
- VerificationState::Verified,875
- BackupState::Enabled,876
- ),877
- EncryptionState {878
- device_id: owned_device_id!("DEVICE"),879
- device_verified: true,880
- recovery: RecoveryState::Enabled,881
- key_backup: KeyBackupState::Enabled,882
- }883
- );884
- }885
-886
- #[tokio::test]887
- async fn comparison_actions_require_an_active_sas() {888
- let mut controller = VerificationController::default();889
-890
- assert_eq!(891
- controller.confirm().await.unwrap_err().to_string(),892
- "verification has not reached comparison"893
- );894
- assert_eq!(895
- controller.reject().await.unwrap_err().to_string(),896
- "verification has not reached comparison"897
- );898
- assert_eq!(899
- controller.cancel().await.unwrap_err().to_string(),900
- "no verification is active"901
- );902
- }903
-}@@ -1,329 +0,0 @@
1
-use std::{future::Future, panic::AssertUnwindSafe};2
-3
-use futures_util::FutureExt;4
-use matrix_sdk::encryption::verification::VerificationRequest;5
-use matrix_sdk::{6
- Client,7
- notification_settings::{IsEncrypted, IsOneToOne, RoomNotificationMode as SdkMode},8
- ruma::{9
- OwnedDeviceId, OwnedEventId, RoomId, TransactionId,10
- api::client::receipt::create_receipt::v3::ReceiptType, events::receipt::ReceiptThread,11
- },12
- send_queue::{LocalEchoContent, SendHandle},13
-};14
-use tokio::sync::mpsc;15
-16
-use crate::{17
- EncryptionCommand, Error, ErrorKind, InteractiveAuth, InteractiveAuthChallenge,18
- NotificationCommand, RoomNotificationMode, TimelineId,19
- encryption::VerificationController,20
- transport::{EventSender, Responder},21
-};22
-23
-pub(super) struct Job<T> {24
- pub(super) command: T,25
- pub(super) reply: Responder,26
-}27
-28
-/// a panicking command answers with an error instead of taking its worker down with it29
-pub(super) async fn guarded<T>(30
- label: &'static str,31
- work: impl Future<Output = Result<T, Error>>,32
-) -> Result<T, Error> {33
- AssertUnwindSafe(work)34
- .catch_unwind()35
- .await36
- .unwrap_or_else(|_| Err(Error::new(ErrorKind::Other, format!("{label} panicked"))))37
-}38
-39
-pub(super) async fn run_notifications(40
- client: Client,41
- mut jobs: mpsc::Receiver<Job<NotificationCommand>>,42
-) {43
- while let Some(Job { command, reply }) = jobs.recv().await {44
- reply.done(guarded("notification command", notification(&client, command)).await);45
- }46
-}47
-48
-pub(super) async fn run_encryption(49
- client: Client,50
- events: EventSender,51
- mut jobs: mpsc::Receiver<Job<EncryptionCommand>>,52
- mut incoming: mpsc::UnboundedReceiver<VerificationRequest>,53
-) {54
- let mut verification = VerificationController::default();55
- loop {56
- tokio::select! {57
- job = jobs.recv() => {58
- let Some(Job { command, reply }) = job else { return };59
- let work = encryption(&client, &events, &mut verification, command);60
- reply.done(guarded("encryption command", work).await);61
- }62
- Some(request) = incoming.recv() => {63
- verification.adopt_incoming(events.clone(), request).await;64
- }65
- }66
- }67
-}68
-69
-async fn encryption(70
- client: &Client,71
- events: &EventSender,72
- verification: &mut VerificationController,73
- command: EncryptionCommand,74
-) -> Result<(), Error> {75
- let encryption = client.encryption();76
- match command {77
- EncryptionCommand::Recover(key) => encryption78
- .recovery()79
- .recover(&key)80
- .await81
- .map_err(|source| Error::from_recovery("account recovery failed", source)),82
- EncryptionCommand::RecoverKeyBackup(key) => encryption83
- .recovery()84
- .recover_and_fix_backup(&key)85
- .await86
- .map_err(|source| Error::from_recovery("recovering key backup failed", source)),87
- EncryptionCommand::CreateKeyBackup => encryption88
- .backups()89
- .create()90
- .await91
- .map_err(|source| Error::from_sdk("creating key backup failed", source)),92
- EncryptionCommand::EnableKeyBackup => encryption93
- .recovery()94
- .enable_backup()95
- .await96
- .map_err(|source| Error::from_recovery("enabling key backup failed", source)),97
- EncryptionCommand::DisableKeyBackup { delete_from_server } => {98
- let backups = encryption.backups();99
- if delete_from_server {100
- backups.disable_and_delete().await101
- } else {102
- backups.disable().await103
- }104
- .map_err(|source| Error::from_sdk("disabling key backup failed", source))105
- }106
- EncryptionCommand::StartVerification(target) => {107
- verification.start(client, events.clone(), target).await108
- }109
- EncryptionCommand::Verify(action) => verification.apply(events.clone(), action).await,110
- }111
-}112
-113
-async fn notification(client: &Client, command: NotificationCommand) -> Result<(), Error> {114
- let settings = client.notification_settings().await;115
- match command {116
- NotificationCommand::SetRoomMode { room_id, mode } => {117
- settings118
- .set_room_notification_mode(&room_id, sdk_mode(mode))119
- .await120
- }121
- NotificationCommand::ResetRoomMode(room_id) => {122
- settings.delete_user_defined_room_rules(&room_id).await123
- }124
- NotificationCommand::SetDefaultMode {125
- encrypted,126
- one_to_one,127
- mode,128
- } => {129
- settings130
- .set_default_room_notification_mode(131
- IsEncrypted::from(encrypted),132
- IsOneToOne::from(one_to_one),133
- sdk_mode(mode),134
- )135
- .await136
- }137
- NotificationCommand::AddKeyword(keyword) => settings.add_keyword(keyword).await,138
- NotificationCommand::RemoveKeyword(keyword) => settings.remove_keyword(&keyword).await,139
- }140
- .map_err(|source| {141
- Error::with_source(142
- ErrorKind::Server,143
- "updating notification settings failed",144
- source,145
- )146
- })147
-}148
-149
-const fn sdk_mode(mode: RoomNotificationMode) -> SdkMode {150
- match mode {151
- RoomNotificationMode::AllMessages => SdkMode::AllMessages,152
- RoomNotificationMode::MentionsAndKeywordsOnly => SdkMode::MentionsAndKeywordsOnly,153
- RoomNotificationMode::Mute => SdkMode::Mute,154
- }155
-}156
-157
-fn room(client: &Client, room_id: &RoomId) -> Result<matrix_sdk::Room, Error> {158
- client.get_room(room_id).ok_or_else(Error::room_unknown)159
-}160
-161
-/// the closed-room path. without a timeline nothing stops the marker going backwards162
-pub(super) async fn mark_read(163
- client: &Client,164
- timeline_id: &TimelineId,165
- event_id: OwnedEventId,166
-) -> Result<(), Error> {167
- let room = room(client, timeline_id.room_id())?;168
- let receipt_failed = |source| Error::from_sdk("sending read receipt failed", source);169
- let thread = match timeline_id.thread_root() {170
- None => ReceiptThread::Main,171
- Some(root) => ReceiptThread::Thread(root.clone()),172
- };173
- room.send_single_receipt(ReceiptType::Read, thread, event_id.clone())174
- .await175
- .map_err(receipt_failed)?;176
- if timeline_id.thread_root().is_none() {177
- room.send_single_receipt(ReceiptType::FullyRead, ReceiptThread::Unthreaded, event_id)178
- .await179
- .map_err(receipt_failed)?;180
- }181
- Ok(())182
-}183
-184
-pub(super) async fn set_typing(185
- client: &Client,186
- room_id: &RoomId,187
- typing: bool,188
-) -> Result<(), Error> {189
- room(client, room_id)?190
- .typing_notice(typing)191
- .await192
- .map_err(|source| Error::from_sdk("typing update failed", source))193
-}194
-195
-async fn send_handle(196
- client: &Client,197
- room_id: &RoomId,198
- transaction_id: &TransactionId,199
-) -> Result<SendHandle, Error> {200
- let (echoes, _) = room(client, room_id)?201
- .send_queue()202
- .subscribe()203
- .await204
- .map_err(|source| {205
- Error::with_source(ErrorKind::Store, "reading send queue failed", source)206
- })?;207
- echoes208
- .into_iter()209
- .find_map(|echo| match echo.content {210
- LocalEchoContent::Event { send_handle, .. }211
- if echo.transaction_id.as_str() == transaction_id.as_str() =>212
- {213
- Some(send_handle)214
- }215
- _ => None,216
- })217
- .ok_or_else(|| {218
- Error::new(219
- ErrorKind::Invalid,220
- "send is no longer pending or is not a message",221
- )222
- })223
-}224
-225
-pub(super) async fn cancel_send(226
- client: &Client,227
- room_id: &RoomId,228
- transaction_id: &TransactionId,229
-) -> Result<(), Error> {230
- match send_handle(client, room_id, transaction_id)231
- .await?232
- .abort()233
- .await234
- {235
- Ok(true) => Ok(()),236
- Ok(false) => Err(Error::new(237
- ErrorKind::Invalid,238
- "send could not be cancelled because it has already been sent",239
- )),240
- Err(source) => Err(Error::with_source(241
- ErrorKind::Store,242
- "cancelling send failed",243
- source,244
- )),245
- }246
-}247
-248
-pub(super) async fn retry_send(249
- client: &Client,250
- room_id: &RoomId,251
- transaction_id: &TransactionId,252
-) -> Result<(), Error> {253
- send_handle(client, room_id, transaction_id)254
- .await?255
- .unwedge()256
- .await257
- .map_err(|source| Error::with_source(ErrorKind::Store, "retrying send failed", source))?;258
- room(client, room_id)?.send_queue().set_enabled(true);259
- Ok(())260
-}261
-262
-pub(super) async fn upload_avatar(263
- client: &Client,264
- mimetype: &str,265
- bytes: Vec<u8>,266
- max_bytes: usize,267
-) -> Result<(), Error> {268
- if bytes.len() > max_bytes {269
- return Err(Error::new(270
- ErrorKind::MediaTooLarge,271
- "avatar exceeds the configured byte limit",272
- ));273
- }274
- let mimetype: mime::Mime = mimetype275
- .parse()276
- .map_err(|_| Error::new(ErrorKind::Invalid, "avatar MIME type is invalid"))?;277
- if mimetype.type_() != mime::IMAGE {278
- return Err(Error::new(279
- ErrorKind::Invalid,280
- "an avatar has to be an image",281
- ));282
- }283
- client284
- .account()285
- .upload_avatar(&mimetype, bytes)286
- .await287
- .map(drop)288
- .map_err(|source| Error::from_sdk("uploading avatar failed", source))289
-}290
-291
-pub(super) async fn rename_device(292
- client: &Client,293
- device_id: &OwnedDeviceId,294
- display_name: &str,295
-) -> Result<(), Error> {296
- client297
- .rename_device(device_id, display_name)298
- .await299
- .map(drop)300
- .map_err(|source| Error::from_http("renaming device failed", source))301
-}302
-303
-pub(super) async fn delete_devices(304
- client: &Client,305
- device_ids: &[OwnedDeviceId],306
- auth: Option<InteractiveAuth>,307
-) -> Result<(), Error> {308
- let user_id = client309
- .user_id()310
- .ok_or_else(|| Error::new(ErrorKind::Unauthenticated, "the client is not logged in"))?;311
- let auth = auth312
- .map(|auth| auth.into_sdk(user_id))313
- .transpose()314
- .map_err(|message| {315
- Error::new(316
- ErrorKind::Invalid,317
- format!("interactive authentication data is invalid, {message}"),318
- )319
- })?;320
- match client.delete_devices(device_ids, auth).await {321
- Ok(_) => Ok(()),322
- Err(source) => match source.as_uiaa_response() {323
- Some(info) => Err(Error::interactive_auth(InteractiveAuthChallenge::from(324
- info,325
- ))),326
- None => Err(Error::from_http("deleting devices failed", source)),327
- },328
- }329
-}@@ -1,82 +0,0 @@
1
-use std::future::Future;2
-3
-use futures_util::{StreamExt, stream::FuturesUnordered};4
-use matrix_sdk::SendOutsideWasm;5
-pub(super) use matrix_sdk::executor::{AbortHandle, JoinError, JoinHandle, spawn};6
-7
-#[cfg(not(target_family = "wasm"))]8
-pub(crate) type EngineBoxFuture<'a, T> = futures_util::future::BoxFuture<'a, T>;9
-#[cfg(target_family = "wasm")]10
-pub(crate) type EngineBoxFuture<'a, T> = futures_util::future::LocalBoxFuture<'a, T>;11
-12
-#[cfg(not(target_family = "wasm"))]13
-pub(super) fn is_panic(error: &JoinError) -> bool {14
- error.is_panic()15
-}16
-17
-/// the sdk's wasm join error has no payload, and a real panic traps before it gets here18
-#[cfg(target_family = "wasm")]19
-pub(super) fn is_panic(error: &JoinError) -> bool {20
- !error.is_cancelled()21
-}22
-23
-#[cfg(not(target_family = "wasm"))]24
-pub(super) fn resume_unwind(error: JoinError) -> ! {25
- std::panic::resume_unwind(error.into_panic())26
-}27
-28
-#[cfg(target_family = "wasm")]29
-pub(super) fn resume_unwind(error: JoinError) -> ! {30
- panic!("{error}")31
-}32
-33
-/// a `JoinSet` that runs on wasm too, and like it aborts whatever's left when dropped34
-pub(super) struct TaskSet<T: 'static> {35
- handles: FuturesUnordered<JoinHandle<T>>,36
-}37
-38
-impl<T: 'static> TaskSet<T> {39
- pub(super) fn new() -> Self {40
- Self {41
- handles: FuturesUnordered::new(),42
- }43
- }44
-45
- pub(super) fn spawn(46
- &mut self,47
- task: impl Future<Output = T> + SendOutsideWasm + 'static,48
- ) -> AbortHandle49
- where50
- T: SendOutsideWasm,51
- {52
- let handle = spawn(task);53
- let abort = handle.abort_handle();54
- self.handles.push(handle);55
- abort56
- }57
-58
- pub(super) fn is_empty(&self) -> bool {59
- self.handles.is_empty()60
- }61
-62
- pub(super) async fn join_next(&mut self) -> Option<Result<T, JoinError>> {63
- self.handles.next().await64
- }65
-66
- fn abort_all(&self) {67
- for handle in &self.handles {68
- handle.abort();69
- }70
- }71
-72
- pub(super) async fn shutdown(&mut self) {73
- self.abort_all();74
- while self.handles.next().await.is_some() {}75
- }76
-}77
-78
-impl<T: 'static> Drop for TaskSet<T> {79
- fn drop(&mut self) {80
- self.abort_all();81
- }82
-}@@ -1,743 +0,0 @@
1
-use std::{2
- borrow::Cow,3
- collections::{HashMap, VecDeque},4
- io::Read as _,5
- panic::AssertUnwindSafe,6
- sync::Arc,7
- time::Duration,8
-};9
-10
-use futures_util::{11
- FutureExt, StreamExt,12
- future::{Shared, WeakShared},13
-};14
-use matrix_sdk::{15
- Client, Room, SendOutsideWasm, SyncOutsideWasm,16
- media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings},17
- room::reply::{EnforceThread, Reply as SdkReply},18
- ruma::{19
- api::{20
- Metadata, OutgoingRequest,21
- auth_scheme::SendAccessToken,22
- client::{authenticated_media, media},23
- },24
- events::room::{25
- EncryptedFileHash, EncryptedFileHashAlgorithm, EncryptedFileInfo, MediaSource,26
- message::{AddMentions, ReplyWithinThread},27
- },28
- },29
-};30
-use matrix_sdk_crypto::{AttachmentDecryptor, MediaEncryptionInfo};31
-use ruma::MxcUri;32
-use tokio::sync::Mutex;33
-34
-use super::{executor::EngineBoxFuture, url_preview::UrlPreviews};35
-use crate::{36
- AttachmentEncryption, AttachmentUpload, Error, ErrorKind, LinkPreview, Media, MediaFile,37
- MediaId, MediaKind, MediaLocation, MediaSize, TimelineId,38
- normalise::{MediaRegistration, MediaScope, MediaSourceInfo},39
- upload::prepare_upload,40
-};41
-42
-/// a stalled download gives up after this long without a byte43
-const DOWNLOAD_IDLE: Duration = Duration::from_secs(60);44
-/// the sdk client gives up on any request after thirty seconds, far too soon for a video45
-const DOWNLOAD_TOTAL: Duration = Duration::from_secs(60 * 60);46
-47
-type FlightFuture = EngineBoxFuture<'static, Result<Media, Error>>;48
-type Flight = Shared<FlightFuture>;49
-50
-/// owns everything needed to turn a media id or an mxc uri into bytes, so the51
-/// handle can answer media queries without a trip through the engine52
-pub(crate) struct MediaService {53
- client: Client,54
- registry: Mutex<MediaRegistry>,55
- flights: Mutex<HashMap<String, WeakShared<FlightFuture>>>,56
- max_bytes: usize,57
- url_previews: UrlPreviews,58
-}59
-60
-impl MediaService {61
- pub(crate) fn new(client: Client, max_bytes: usize, max_sources: usize) -> Self {62
- Self {63
- url_previews: UrlPreviews::new(client.clone()),64
- client,65
- registry: Mutex::new(MediaRegistry::new(max_sources)),66
- flights: Mutex::new(HashMap::new()),67
- max_bytes,68
- }69
- }70
-71
- pub(crate) async fn url_preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {72
- self.url_previews.preview(url).await73
- }74
-75
- pub(crate) async fn register(&self, media: Vec<MediaRegistration>) {76
- if !media.is_empty() {77
- self.registry.lock().await.insert_all(media);78
- }79
- }80
-81
- pub(crate) async fn release(&self, scope: &MediaScope) {82
- self.registry.lock().await.release(scope);83
- }84
-85
- pub(crate) async fn clear(&self) {86
- self.registry.lock().await.clear();87
- }88
-89
- pub(crate) async fn timeline_media(90
- &self,91
- media_id: &MediaId,92
- size: MediaSize,93
- ) -> Result<Media, Error> {94
- let chosen = choose_source(self.registered(media_id).await?, size)?;95
- if chosen96
- .declared_size97
- .is_some_and(|size| size > self.max_bytes as u64)98
- {99
- return Err(too_large("media exceeds the configured byte limit"));100
- }101
- self.fetch_or_file(chosen.request, chosen.mimetype).await102
- }103
-104
- pub(crate) async fn media_location(105
- &self,106
- media_id: &MediaId,107
- size: MediaSize,108
- ) -> Result<MediaLocation, Error> {109
- let media = self.registered(media_id).await?;110
- let chosen = choose_source(media.clone(), size)?;111
- if is_local(&chosen.request.source) {112
- return Ok(MediaLocation::Local);113
- }114
- if !matches!(chosen.request.format, MediaFormat::Thumbnail(_)) {115
- return Ok(MediaLocation::File(116
- media_file(117
- &self.client,118
- chosen.request,119
- chosen.mimetype,120
- chosen.declared_size,121
- )122
- .await?,123
- ));124
- }125
- let url = media_http_request(&self.client, &chosen.request)126
- .await?127
- .uri()128
- .to_string();129
- let fallback = MediaRequestParameters {130
- source: media.full.source,131
- format: MediaFormat::File,132
- };133
- Ok(MediaLocation::ServerThumbnail {134
- url,135
- fallback: media_file(136
- &self.client,137
- fallback,138
- media.full.mimetype,139
- media.full.declared_size,140
- )141
- .await?,142
- })143
- }144
-145
- async fn registered(&self, media_id: &MediaId) -> Result<MediaRegistration, Error> {146
- if let Some(uri) = media_id.as_plain() {147
- return plain_registration(media_id, uri);148
- }149
- self.registry150
- .lock()151
- .await152
- .get(media_id)153
- .cloned()154
- .ok_or_else(|| {155
- Error::new(156
- ErrorKind::MediaUnknown,157
- "media is not registered in a retained timeline",158
- )159
- })160
- }161
-162
- /// plenty of servers won't thumbnail everything, and the file itself is the next best163
- /// thing. the byte cap still holds either way164
- async fn fetch_or_file(165
- &self,166
- request: MediaRequestParameters,167
- mimetype: Option<String>,168
- ) -> Result<Media, Error> {169
- if !matches!(request.format, MediaFormat::Thumbnail(_)) {170
- return self.fetch(request, mimetype).await;171
- }172
- match self.fetch(request.clone(), mimetype.clone()).await {173
- Err(error) if error.kind == ErrorKind::MediaUnavailable => {174
- let file = MediaRequestParameters {175
- source: request.source,176
- format: MediaFormat::File,177
- };178
- self.fetch(file, mimetype).await179
- }180
- result => result,181
- }182
- }183
-184
- async fn fetch(185
- &self,186
- request: MediaRequestParameters,187
- mimetype: Option<String>,188
- ) -> Result<Media, Error> {189
- let key = flight_key(&request);190
- let flight = {191
- let mut flights = self.flights.lock().await;192
- flights.retain(|_, flight| flight.upgrade().is_some());193
- match flights.get(&key).and_then(WeakShared::upgrade) {194
- Some(flight) => flight,195
- None => {196
- let prepared = AssertUnwindSafe(fetch(197
- self.client.clone(),198
- request,199
- mimetype,200
- self.max_bytes,201
- ))202
- .catch_unwind()203
- .map(|result| {204
- result.unwrap_or_else(|_| {205
- Err(Error::new(ErrorKind::Other, "media fetch panicked"))206
- })207
- });208
- let boxed: FlightFuture = Box::pin(prepared);209
- let flight: Flight = boxed.shared();210
- if let Some(weak) = flight.downgrade() {211
- flights.insert(key, weak);212
- }213
- flight214
- }215
- }216
- };217
- flight.await218
- }219
-}220
-221
-struct ChosenSource {222
- request: MediaRequestParameters,223
- mimetype: Option<String>,224
- declared_size: Option<u64>,225
-}226
-227
-fn plain_registration(media_id: &MediaId, uri: &MxcUri) -> Result<MediaRegistration, Error> {228
- if !uri.is_valid() {229
- return Err(Error::new(ErrorKind::Invalid, "mxc uri is malformed"));230
- }231
- Ok(MediaRegistration {232
- id: media_id.clone(),233
- kind: MediaKind::Image,234
- full: MediaSourceInfo {235
- source: MediaSource::Plain(uri.to_owned()),236
- mimetype: None,237
- declared_size: None,238
- },239
- thumbnail: None,240
- })241
-}242
-243
-fn choose_source(media: MediaRegistration, size: MediaSize) -> Result<ChosenSource, Error> {244
- let (source, format, mimetype, declared_size) = match size {245
- MediaSize::Full => (246
- media.full.source,247
- MediaFormat::File,248
- media.full.mimetype,249
- media.full.declared_size,250
- ),251
- MediaSize::Thumbnail { width, height } => match media.thumbnail {252
- Some(thumbnail) => (253
- thumbnail.source,254
- MediaFormat::File,255
- thumbnail.mimetype,256
- thumbnail.declared_size,257
- ),258
- // the server can only resize what it can read259
- None if matches!(media.kind, MediaKind::Image | MediaKind::Video)260
- && matches!(media.full.source, MediaSource::Plain(_)) =>261
- {262
- // the server picks the thumbnail's type, so the original's would be a lie263
- let format = if is_local(&media.full.source) {264
- MediaFormat::File265
- } else {266
- thumbnail_format(width, height)267
- };268
- (media.full.source, format, None, None)269
- }270
- None => {271
- return Err(Error::new(272
- ErrorKind::MediaUnavailable,273
- "the requested media size is unavailable",274
- ));275
- }276
- },277
- };278
- Ok(ChosenSource {279
- request: MediaRequestParameters { source, format },280
- mimetype,281
- declared_size,282
- })283
-}284
-285
-async fn media_file(286
- client: &Client,287
- request: MediaRequestParameters,288
- mimetype: Option<String>,289
- declared_size: Option<u64>,290
-) -> Result<MediaFile, Error> {291
- let url = media_http_request(client, &request)292
- .await?293
- .uri()294
- .to_string();295
- let encryption = match &request.source {296
- MediaSource::Plain(_) => None,297
- MediaSource::Encrypted(file) => {298
- let (EncryptedFileInfo::V2(info), Some(EncryptedFileHash::Sha256(sha256))) = (299
- &file.info,300
- file.hashes.get(&EncryptedFileHashAlgorithm::Sha256),301
- ) else {302
- return Err(Error::new(303
- ErrorKind::MediaUnavailable,304
- "encrypted media is not v2 with a sha256 hash",305
- ));306
- };307
- Some(AttachmentEncryption {308
- key: info.k.encode(),309
- iv: info.iv.encode(),310
- sha256: sha256.encode(),311
- })312
- }313
- };314
- Ok(MediaFile {315
- url,316
- mimetype,317
- declared_size,318
- encryption,319
- })320
-}321
-322
-fn thumbnail_format(width: u32, height: u32) -> MediaFormat {323
- MediaFormat::Thumbnail(MediaThumbnailSettings::new(width.into(), height.into()))324
-}325
-326
-fn is_local(source: &MediaSource) -> bool {327
- let uri = match source {328
- MediaSource::Plain(uri) => uri,329
- MediaSource::Encrypted(file) => &file.url,330
- };331
- uri.server_name()332
- .is_ok_and(|server| server.as_str() == "send-queue.localhost")333
-}334
-335
-/// plain and encrypted sources share a cache key in the sdk, but must never share a flight336
-fn flight_key(request: &MediaRequestParameters) -> String {337
- let uri = match &request.source {338
- MediaSource::Plain(uri) => format!("plain|{uri}"),339
- MediaSource::Encrypted(file) => format!("encrypted|{}", file.url),340
- };341
- match &request.format {342
- MediaFormat::File => uri.to_owned(),343
- MediaFormat::Thumbnail(settings) => format!(344
- "{uri}|{}|{}x{}|{}",345
- settings.method, settings.width, settings.height, settings.animated346
- ),347
- }348
-}349
-350
-fn too_large(message: &'static str) -> Error {351
- Error::new(ErrorKind::MediaTooLarge, message)352
-}353
-354
-async fn fetch(355
- client: Client,356
- request: MediaRequestParameters,357
- mimetype: Option<String>,358
- max_bytes: usize,359
-) -> Result<Media, Error> {360
- if let Some(cached) = cached_media(&client, &request).await? {361
- if cached.len() > max_bytes {362
- if let Ok(store) = client.media_store().lock().await {363
- let _ = store.remove_media_content(&request).await;364
- }365
- return Err(too_large("cached media exceeds the configured byte limit"));366
- }367
- // plain and encrypted share a cache key, so a plain fetch of an encrypted file's uri368
- // leaves ciphertext here. its hash gives it away369
- if is_ciphertext(&cached, &request.source) {370
- let bytes = maybe_decrypt(cached, &request, max_bytes)?;371
- cache(&client, &request, &bytes).await;372
- return Ok(media(bytes, mimetype));373
- }374
- return Ok(media(cached, mimetype));375
- }376
- let (bytes, served_mimetype) = download_bounded(&client, &request, max_bytes).await?;377
- // the sdk caches plaintext under the encrypted key, so we do too or nobody can read anything378
- let bytes = maybe_decrypt(bytes, &request, max_bytes)?;379
- cache(&client, &request, &bytes).await;380
- Ok(media(bytes, mimetype.or(served_mimetype)))381
-}382
-383
-async fn cache(client: &Client, request: &MediaRequestParameters, bytes: &[u8]) {384
- if let Ok(store) = client.media_store().lock().await {385
- let _ = store386
- .add_media_content(387
- request,388
- bytes.to_vec(),389
- matrix_sdk::media::store::IgnoreMediaRetentionPolicy::No,390
- )391
- .await;392
- }393
-}394
-395
-fn is_ciphertext(bytes: &[u8], source: &MediaSource) -> bool {396
- use sha2::{Digest, Sha256};397
- let MediaSource::Encrypted(file) = source else {398
- return false;399
- };400
- file.hashes401
- .get(&ruma::events::room::EncryptedFileHashAlgorithm::Sha256)402
- .is_some_and(|hash| hash.as_bytes() == Sha256::digest(bytes).as_slice())403
-}404
-405
-fn media(bytes: Vec<u8>, mimetype: Option<String>) -> Media {406
- let mimetype = mimetype407
- .or_else(|| sniff_image(&bytes).map(str::to_owned))408
- .unwrap_or_else(|| "application/octet-stream".to_owned());409
- Media {410
- bytes: Arc::from(bytes),411
- mimetype,412
- }413
-}414
-415
-/// the cache forgets content types, and avatars never declare one. images are the common case416
-fn sniff_image(bytes: &[u8]) -> Option<&'static str> {417
- match bytes {418
- [0x89, b'P', b'N', b'G', ..] => Some("image/png"),419
- [0xff, 0xd8, 0xff, ..] => Some("image/jpeg"),420
- [b'G', b'I', b'F', b'8', ..] => Some("image/gif"),421
- [422
- b'R',423
- b'I',424
- b'F',425
- b'F',426
- _,427
- _,428
- _,429
- _,430
- b'W',431
- b'E',432
- b'B',433
- b'P',434
- ..,435
- ] => Some("image/webp"),436
- _ => None,437
- }438
-}439
-440
-async fn cached_media(441
- client: &Client,442
- request: &MediaRequestParameters,443
-) -> Result<Option<Vec<u8>>, Error> {444
- fn cache_failed(445
- source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,446
- ) -> Error {447
- Error::with_source(ErrorKind::Store, "reading media cache failed", source)448
- }449
- client450
- .media_store()451
- .lock()452
- .await453
- .map_err(cache_failed)?454
- .get_media_content(request)455
- .await456
- .map_err(cache_failed)457
-}458
-459
-fn maybe_decrypt(460
- bytes: Vec<u8>,461
- request: &MediaRequestParameters,462
- max_bytes: usize,463
-) -> Result<Vec<u8>, Error> {464
- let MediaSource::Encrypted(file) = &request.source else {465
- return Ok(bytes);466
- };467
- fn decrypt_failed(468
- source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,469
- ) -> Error {470
- Error::with_source(471
- ErrorKind::MediaUnavailable,472
- "media decryption failed",473
- source,474
- )475
- }476
- let info: MediaEncryptionInfo = (**file).clone().into();477
- let mut cursor = std::io::Cursor::new(bytes);478
- let mut decryptor = AttachmentDecryptor::new(&mut cursor, info).map_err(decrypt_failed)?;479
- let mut decrypted = Vec::new();480
- let mut chunk = [0_u8; 8192];481
- loop {482
- let read = decryptor.read(&mut chunk).map_err(decrypt_failed)?;483
- if read == 0 {484
- return Ok(decrypted);485
- }486
- if read > max_bytes.saturating_sub(decrypted.len()) {487
- return Err(too_large(488
- "decrypted media exceeds the configured byte limit",489
- ));490
- }491
- decrypted.extend_from_slice(&chunk[..read]);492
- }493
-}494
-495
-/// the sdk's own download has no size cap, so we do it by hand496
-async fn download_bounded(497
- client: &Client,498
- request: &MediaRequestParameters,499
- max_bytes: usize,500
-) -> Result<(Vec<u8>, Option<String>), Error> {501
- if is_local(&request.source) {502
- return Err(Error::new(503
- ErrorKind::MediaUnavailable,504
- "local media is not in the media cache",505
- ));506
- }507
- let network_failed =508
- |source| Error::with_source(ErrorKind::Network, "media download failed", source);509
- let mut refreshed = false;510
- let response = loop {511
- let http_request = media_http_request(client, request).await?;512
- let (parts, _) = http_request.into_parts();513
- let mut builder = client514
- .http_client()515
- .get(parts.uri.to_string())516
- .timeout(DOWNLOAD_TOTAL);517
- for (name, value) in &parts.headers {518
- builder = builder.header(name.as_str(), value.as_bytes());519
- }520
- let response = builder.send().await.map_err(network_failed)?;521
- if response.status() != matrix_sdk::reqwest::StatusCode::UNAUTHORIZED {522
- break response;523
- }524
- if refreshed525
- || client526
- .session_tokens()527
- .is_none_or(|tokens| tokens.refresh_token.is_none())528
- {529
- return Err(Error::new(530
- ErrorKind::Unauthenticated,531
- "media download was not authorized",532
- ));533
- }534
- client.refresh_access_token().await.map_err(|source| {535
- Error::from_refresh_token("refreshing media access token failed", source)536
- })?;537
- refreshed = true;538
- };539
- let status = response.status();540
- if !status.is_success() {541
- return Err(Error::new(542
- ErrorKind::MediaUnavailable,543
- format!("media download failed with status {status}"),544
- ));545
- }546
- if response547
- .content_length()548
- .is_some_and(|length| length > max_bytes as u64)549
- {550
- return Err(too_large("media exceeds the configured byte limit"));551
- }552
- let mimetype = response553
- .headers()554
- .get(http::header::CONTENT_TYPE)555
- .and_then(|value| value.to_str().ok())556
- .map(str::to_owned);557
- let mut stream = response.bytes_stream();558
- let mut bytes = Vec::new();559
- loop {560
- let chunk = matrix_sdk::timeout::timeout(stream.next(), DOWNLOAD_IDLE)561
- .await562
- .map_err(|_| Error::new(ErrorKind::Network, "media download stalled"))?;563
- let Some(chunk) = chunk else { break };564
- let chunk = chunk.map_err(network_failed)?;565
- if chunk.len() > max_bytes.saturating_sub(bytes.len()) {566
- return Err(too_large(567
- "downloaded media exceeds the configured byte limit",568
- ));569
- }570
- bytes.extend_from_slice(&chunk);571
- }572
- Ok((bytes, mimetype))573
-}574
-575
-async fn media_http_request(576
- client: &Client,577
- request: &MediaRequestParameters,578
-) -> Result<http::Request<Vec<u8>>, Error> {579
- let versions = client.supported_versions().await.map_err(|source| {580
- Error::with_source(581
- ErrorKind::Network,582
- "reading supported server versions failed",583
- source,584
- )585
- })?;586
- let authenticated =587
- authenticated_media::get_content::v1::Request::PATH_BUILDER.is_supported(&versions);588
- let base = client.homeserver().to_string();589
- let token = client.access_token();590
- let invalid_uri =591
- |source| Error::with_source(ErrorKind::Invalid, "media URI is invalid", source);592
- let (uri, thumbnail) = match (&request.source, &request.format) {593
- (MediaSource::Encrypted(file), _) => (&file.url, None),594
- (MediaSource::Plain(uri), MediaFormat::File) => (uri, None),595
- (MediaSource::Plain(uri), MediaFormat::Thumbnail(settings)) => (uri, Some(settings)),596
- };597
- // each endpoint has its own auth input type, so no generic helper can take them all598
- macro_rules! into_http {599
- ($endpoint:expr, $token:expr) => {600
- $endpoint601
- .try_into_http_request::<Vec<u8>>(&base, $token, Cow::Borrowed(&versions))602
- .map_err(|source| {603
- Error::with_source(ErrorKind::Other, "media request is invalid", source)604
- })?605
- };606
- }607
- let authed = SendAccessToken::IfRequired(token.as_deref().unwrap_or(""));608
- #[allow(deprecated)]609
- let mut http_request = match (thumbnail, authenticated) {610
- (None, true) => into_http!(611
- authenticated_media::get_content::v1::Request::from_uri(uri).map_err(invalid_uri)?,612
- authed613
- ),614
- (None, false) => into_http!(615
- media::get_content::v3::Request::from_url(uri).map_err(invalid_uri)?,616
- SendAccessToken::None617
- ),618
- (Some(settings), true) => {619
- let mut endpoint = authenticated_media::get_content_thumbnail::v1::Request::from_uri(620
- uri,621
- settings.width,622
- settings.height,623
- )624
- .map_err(invalid_uri)?;625
- endpoint.method = Some(settings.method.clone());626
- endpoint.animated = Some(settings.animated);627
- into_http!(endpoint, authed)628
- }629
- (Some(settings), false) => {630
- let mut endpoint = media::get_content_thumbnail::v3::Request::from_url(631
- uri,632
- settings.width,633
- settings.height,634
- )635
- .map_err(invalid_uri)?;636
- endpoint.method = Some(settings.method.clone());637
- endpoint.animated = Some(settings.animated);638
- into_http!(endpoint, SendAccessToken::None)639
- }640
- };641
- // legacy endpoints don't ask for a token but plenty of servers want one anyway642
- if let Some(token) = token.as_deref() {643
- let value = format!("Bearer {token}").parse().map_err(|_| {644
- Error::new(ErrorKind::Unauthenticated, "stored access token is invalid")645
- })?;646
- http_request647
- .headers_mut()648
- .insert(http::header::AUTHORIZATION, value);649
- }650
- Ok(http_request)651
-}652
-653
-pub(crate) async fn queue_upload(654
- room: Room,655
- upload: AttachmentUpload,656
- max_bytes: usize,657
-) -> Result<(), Error> {658
- let (upload, mime, mut config) = prepare_upload(upload, max_bytes)?;659
- if let TimelineId::Thread { root_event_id, .. } = &upload.timeline_id {660
- config.reply = Some(SdkReply {661
- event_id: root_event_id.clone(),662
- enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),663
- add_mentions: AddMentions::No,664
- });665
- }666
- room.send_queue()667
- .send_attachment(upload.filename, mime, upload.bytes, config)668
- .await669
- .map(drop)670
- .map_err(|source| {671
- use matrix_sdk::send_queue::RoomSendQueueError;672
- let kind = match &source {673
- RoomSendQueueError::RoomNotJoined | RoomSendQueueError::RoomDisappeared => {674
- ErrorKind::RoomUnknown675
- }676
- RoomSendQueueError::StorageError(_) => ErrorKind::Store,677
- _ => ErrorKind::MediaUnavailable,678
- };679
- Error::with_source(kind, "media upload could not be queued", source)680
- })681
-}682
-683
-/// least recently seen goes first. stale order slots are skipped by stamp rather than hunted down684
-struct MediaRegistry {685
- entries: HashMap<MediaId, (MediaRegistration, u64)>,686
- order: VecDeque<(MediaId, u64)>,687
- next_stamp: u64,688
- limit: usize,689
-}690
-691
-impl MediaRegistry {692
- fn new(limit: usize) -> Self {693
- Self {694
- entries: HashMap::new(),695
- order: VecDeque::new(),696
- next_stamp: 0,697
- limit,698
- }699
- }700
-701
- fn get(&self, id: &MediaId) -> Option<&MediaRegistration> {702
- self.entries.get(id).map(|(media, _)| media)703
- }704
-705
- fn insert_all(&mut self, media: Vec<MediaRegistration>) {706
- for media in media {707
- let stamp = self.next_stamp;708
- self.next_stamp += 1;709
- self.order.push_back((media.id.clone(), stamp));710
- self.entries.insert(media.id.clone(), (media, stamp));711
- }712
- while self.entries.len() > self.limit {713
- let Some((id, stamp)) = self.order.pop_front() else {714
- break;715
- };716
- if self.is_current(&id, stamp) {717
- self.entries.remove(&id);718
- }719
- }720
- if self.order.len() > self.limit.saturating_mul(4) {721
- let entries = &self.entries;722
- self.order723
- .retain(|(id, stamp)| entries.get(id).is_some_and(|(_, current)| current == stamp));724
- }725
- }726
-727
- fn is_current(&self, id: &MediaId, stamp: u64) -> bool {728
- self.entries729
- .get(id)730
- .is_some_and(|(_, current)| *current == stamp)731
- }732
-733
- fn release(&mut self, scope: &MediaScope) {734
- self.entries.retain(|id, _| !scope.owns(id));735
- let entries = &self.entries;736
- self.order.retain(|(id, _)| entries.contains_key(id));737
- }738
-739
- fn clear(&mut self) {740
- self.entries.clear();741
- self.order.clear();742
- }743
-}@@ -1,968 +0,0 @@
1
-mod commands;2
-pub(crate) mod executor;3
-mod media;4
-mod mutations;5
-mod prepare;6
-mod room;7
-mod session;8
-mod timeline;9
-mod url_preview;10
-mod watchers;11
-12
-use std::{13
- collections::{HashMap, VecDeque},14
- future::Future,15
- panic::AssertUnwindSafe,16
- sync::Arc,17
-};18
-19
-use futures_util::FutureExt;20
-use matrix_sdk::{Client, SendOutsideWasm, SessionChange};21
-use matrix_sdk_ui::sync_service::SyncService;22
-use ruma::{OwnedDeviceId, OwnedRoomId};23
-use thiserror::Error as ThisError;24
-use tokio::sync::{broadcast, mpsc, watch};25
-26
-pub(crate) use media::MediaService;27
-pub(crate) use session::Persistence;28
-29
-#[cfg(any(feature = "sqlite", feature = "indexeddb"))]30
-pub(crate) use crate::store_lock::StoreLock;31
-#[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]32
-pub(crate) enum StoreLock {}33
-34
-use self::{35
- commands::Job,36
- executor::{EngineBoxFuture, TaskSet},37
- mutations::{Mutation, MutationJob},38
- room::{ActorNote, ActorRequest, RoomActor},39
- timeline::{SessionContext, Supervisor},40
-};41
-use crate::{42
- AccountCommand, ClientHandle, Command, ConnectionState, EncryptionCommand, EngineErrorKind,43
- EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand, MediaCommand,44
- NotificationCommand, RoomCommand, Termination, TimelineCommand,45
- normalise::{ROOM_PACKS, ROOM_PREVIEW_URLS},46
- transport::{DriverPort, Request, Responder, transport_pair},47
-};48
-49
-#[derive(Clone, Debug, Eq, PartialEq)]50
-#[non_exhaustive]51
-pub struct EngineConfig {52
- pub command_capacity: usize,53
- pub event_capacity: usize,54
- pub initial_history_limit: u16,55
- pub max_media_bytes: usize,56
- pub max_media_sources: usize,57
- /// warm a room up for sending when it's opened or someone starts typing in it58
- pub prepare_to_send: bool,59
-}60
-61
-impl EngineConfig {62
- #[must_use]63
- pub const fn with_command_capacity(mut self, capacity: usize) -> Self {64
- self.command_capacity = capacity;65
- self66
- }67
-68
- #[must_use]69
- pub const fn with_event_capacity(mut self, capacity: usize) -> Self {70
- self.event_capacity = capacity;71
- self72
- }73
-74
- #[must_use]75
- pub const fn with_initial_history_limit(mut self, limit: u16) -> Self {76
- self.initial_history_limit = limit;77
- self78
- }79
-80
- #[must_use]81
- pub const fn with_max_media_bytes(mut self, bytes: usize) -> Self {82
- self.max_media_bytes = bytes;83
- self84
- }85
-86
- #[must_use]87
- pub const fn with_max_media_sources(mut self, sources: usize) -> Self {88
- self.max_media_sources = sources;89
- self90
- }91
-92
- #[must_use]93
- pub const fn with_prepare_to_send(mut self, prepare: bool) -> Self {94
- self.prepare_to_send = prepare;95
- self96
- }97
-}98
-99
-impl Default for EngineConfig {100
- fn default() -> Self {101
- Self {102
- command_capacity: 64,103
- event_capacity: 128,104
- initial_history_limit: 30,105
- max_media_bytes: 20 * 1024 * 1024,106
- max_media_sources: 2_048,107
- prepare_to_send: true,108
- }109
- }110
-}111
-112
-#[derive(Debug, ThisError)]113
-pub enum EngineError {114
- #[error("the Matrix client is not authenticated")]115
- Unauthenticated,116
- #[error("command, event, and media source capacities must be nonzero")]117
- ZeroCapacity,118
- #[error("could not build the sync service")]119
- SyncService(#[source] matrix_sdk_ui::sync_service::Error),120
- #[error("the event receiver has closed")]121
- EventReceiverClosed,122
- #[error("engine task {task} panicked")]123
- TaskPanicked { task: String },124
-}125
-126
-impl EngineError {127
- fn failure(&self) -> EngineFailure {128
- EngineFailure {129
- kind: match self {130
- Self::EventReceiverClosed => EngineErrorKind::EventReceiverClosed,131
- Self::TaskPanicked { .. } => EngineErrorKind::Watcher,132
- Self::Unauthenticated | Self::ZeroCapacity | Self::SyncService(_) => {133
- EngineErrorKind::Other134
- }135
- },136
- message: self.to_string(),137
- }138
- }139
-}140
-141
-#[derive(Clone)]142
-enum TaskContext {143
- /// a panic here costs the caller an `Interrupted` answer and nothing else144
- Command(&'static str),145
- /// a panic here takes the engine with it146
- Watcher(&'static str),147
- RoomActor {148
- room_id: OwnedRoomId,149
- generation: u64,150
- },151
-}152
-153
-impl TaskContext {154
- fn label(&self) -> String {155
- match self {156
- Self::Command(name) | Self::Watcher(name) => (*name).to_owned(),157
- Self::RoomActor {158
- room_id,159
- generation,160
- } => format!("room actor {room_id} generation {generation}"),161
- }162
- }163
-}164
-165
-/// tasks catch their own panics so each comes back with its context, since joining166
-/// through the sdk's executor gives no task id to look one up by. wasm has nothing to catch167
-fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {168
- payload169
- .downcast_ref::<&str>()170
- .map(ToString::to_string)171
- .or_else(|| payload.downcast_ref::<String>().cloned())172
- .unwrap_or_else(|| "task panicked with a non-string payload".to_owned())173
-}174
-175
-struct TaskRegistry {176
- tasks: TaskSet<(TaskContext, Option<String>)>,177
-}178
-179
-impl TaskRegistry {180
- fn spawn(181
- &mut self,182
- context: TaskContext,183
- task: impl Future<Output = ()> + SendOutsideWasm + 'static,184
- ) {185
- self.tasks.spawn(async move {186
- let panic = AssertUnwindSafe(task)187
- .catch_unwind()188
- .await189
- .err()190
- .map(panic_message);191
- (context, panic)192
- });193
- }194
-195
- fn is_empty(&self) -> bool {196
- self.tasks.is_empty()197
- }198
-199
- async fn join_next(&mut self) -> Option<(TaskContext, Option<String>)> {200
- loop {201
- if let Ok(done) = self.tasks.join_next().await? {202
- return Some(done);203
- }204
- }205
- }206
-207
- async fn shutdown(&mut self) {208
- self.tasks.shutdown().await;209
- }210
-}211
-212
-enum RoomSlot {213
- Active {214
- generation: u64,215
- sender: mpsc::Sender<ActorRequest>,216
- },217
- /// opens and closes that arrive while the actor winds down, replayed in order once it's gone218
- Closing {219
- generation: u64,220
- pending: VecDeque<Deferred>,221
- },222
-}223
-224
-impl RoomSlot {225
- const fn generation(&self) -> u64 {226
- match self {227
- Self::Active { generation, .. } | Self::Closing { generation, .. } => *generation,228
- }229
- }230
-}231
-232
-enum Deferred {233
- Open(Responder),234
- Close(Responder),235
-}236
-237
-/// logouts in flight, and a token rejection held back because one of them caused it238
-#[derive(Default)]239
-pub(super) struct Logouts {240
- pub(super) in_flight: usize,241
- pub(super) rejected: Option<Option<OwnedDeviceId>>,242
-}243
-244
-struct Queues {245
- mutations: mpsc::Receiver<MutationJob>,246
- notifications: mpsc::Receiver<Job<NotificationCommand>>,247
- encryption: mpsc::Receiver<Job<EncryptionCommand>>,248
- session_changes: broadcast::Receiver<SessionChange>,249
- persistence: Option<Persistence>,250
-}251
-252
-/// requests other devices send us, handed from the sdk's event handler to the encryption worker253
-type IncomingVerifications =254
- mpsc::UnboundedSender<matrix_sdk::encryption::verification::VerificationRequest>;255
-256
-pub struct Engine {257
- client: Client,258
- sync: Arc<SyncService>,259
- port: DriverPort,260
- config: EngineConfig,261
- media: Arc<MediaService>,262
- tasks: TaskRegistry,263
- rooms: HashMap<OwnedRoomId, RoomSlot>,264
- next_generation: u64,265
- notes: mpsc::UnboundedReceiver<ActorNote>,266
- note_sender: mpsc::UnboundedSender<ActorNote>,267
- /// the rooms sliding sync should follow, applied off the run loop since it touches the store268
- subscriptions: watch::Sender<Vec<OwnedRoomId>>,269
- mutations: mpsc::Sender<MutationJob>,270
- notifications: mpsc::Sender<Job<NotificationCommand>>,271
- encryption: mpsc::Sender<Job<EncryptionCommand>>,272
- queues: Option<Queues>,273
- /// the client can outlive us, so the handler has to come off it when we stop274
- verification_handler: Option<matrix_sdk::event_handler::EventHandlerHandle>,275
- stop: mpsc::UnboundedSender<Termination>,276
- stops: mpsc::UnboundedReceiver<Termination>,277
- logouts: Arc<watch::Sender<Logouts>>,278
- preparations: prepare::Preparations,279
- /// shared with every `ClientHandle`, so the store stays locked while the client is reachable280
- _store_lock: Option<Arc<StoreLock>>,281
-}282
-283
-impl Engine {284
- /// the escape hatch for a client you built yourself. `Session` is the usual way in285
- pub async fn from_client(286
- client: Client,287
- config: EngineConfig,288
- ) -> Result<(Self, ClientHandle, EventStream), EngineError> {289
- let session_changes = client.subscribe_to_session_changes();290
- Self::with_persistence(client, config, session_changes, None, None).await291
- }292
-293
- /// `session_changes` is taken when the client is built, since the sdk can refresh294
- /// before an engine exists295
- pub(crate) async fn with_persistence(296
- client: Client,297
- config: EngineConfig,298
- session_changes: broadcast::Receiver<SessionChange>,299
- persistence: Option<Persistence>,300
- store_lock: Option<StoreLock>,301
- ) -> Result<(Self, ClientHandle, EventStream), EngineError> {302
- if client.user_id().is_none() {303
- return Err(EngineError::Unauthenticated);304
- }305
- if config.command_capacity == 0306
- || config.event_capacity == 0307
- || config.max_media_sources == 0308
- {309
- return Err(EngineError::ZeroCapacity);310
- }311
- let sync = Arc::new(312
- SyncService::builder(client.clone())313
- .with_offline_mode()314
- .with_room_list_extra_required_state(vec![315
- (ROOM_PACKS.into(), "*".to_owned()),316
- (ROOM_PREVIEW_URLS.into(), String::new()),317
- ])318
- .build()319
- .await320
- .map_err(EngineError::SyncService)?,321
- );322
- let store_lock = store_lock.map(Arc::new);323
- let media = Arc::new(MediaService::new(324
- client.clone(),325
- config.max_media_bytes,326
- config.max_media_sources,327
- ));328
- let (handle, port, events) = transport_pair(329
- client.clone(),330
- sync.clone(),331
- media.clone(),332
- store_lock.clone(),333
- config.command_capacity,334
- config.event_capacity,335
- );336
- client.send_queue().enable_upload_progress(true);337
- client.send_queue().enable_global_upload_progress(true);338
- let (note_sender, notes) = mpsc::unbounded_channel();339
- let (stop, stops) = mpsc::unbounded_channel();340
- let (mutations, mutation_queue) = mpsc::channel(config.command_capacity);341
- let (notifications, notification_queue) = mpsc::channel(config.command_capacity);342
- let (encryption, encryption_queue) = mpsc::channel(config.command_capacity);343
- let engine = Self {344
- client,345
- sync,346
- port,347
- config,348
- media,349
- tasks: TaskRegistry {350
- tasks: TaskSet::new(),351
- },352
- rooms: HashMap::new(),353
- next_generation: 0,354
- notes,355
- note_sender,356
- subscriptions: watch::Sender::new(Vec::new()),357
- mutations,358
- notifications,359
- encryption,360
- queues: Some(Queues {361
- mutations: mutation_queue,362
- notifications: notification_queue,363
- encryption: encryption_queue,364
- session_changes,365
- persistence,366
- }),367
- verification_handler: None,368
- preparations: prepare::Preparations::default(),369
- stop,370
- stops,371
- logouts: Arc::new(watch::Sender::new(Logouts::default())),372
- _store_lock: store_lock,373
- };374
- Ok((engine, handle, events))375
- }376
-377
- /// boxed because the engine's future is deep enough to blow the compiler's query depth378
- /// in every crate that spawns it, which isn't something a client should have to fix379
- pub fn run(self) -> EngineBoxFuture<'static, Result<Termination, EngineError>> {380
- Box::pin(self.run_inner())381
- }382
-383
- async fn run_inner(mut self) -> Result<Termination, EngineError> {384
- let events = self.port.event_sender();385
- let user_id = self386
- .client387
- .user_id()388
- .expect("authentication was checked during construction")389
- .to_owned();390
- self.port391
- .connection_sender()392
- .send_replace(ConnectionState::Connecting);393
- for event in [394
- Event::Authenticated { user_id },395
- Event::ConnectionChanged(ConnectionState::Connecting),396
- ] {397
- if events.send(event).await.is_err() {398
- self.shutdown().await;399
- return Err(EngineError::EventReceiverClosed);400
- }401
- }402
- self.start_tasks();403
- self.sync.start().await;404
-405
- let mut shutdown_reply = None;406
- let mut handles_dropped = false;407
- let mut logouts = self.logouts.subscribe();408
- let ending = loop {409
- tokio::select! {410
- biased;411
- Some(termination) = self.stops.recv() => break Ok(termination),412
- () = events.closed() => break Err(EngineError::EventReceiverClosed),413
- () = settled(&mut logouts), if handles_dropped => {414
- break Ok(Termination::HandlesDropped);415
- }416
- Some((context, panic)) = self.tasks.join_next(), if !self.tasks.is_empty() => {417
- let Some(message) = panic else { continue };418
- if let TaskContext::Command(label) = context {419
- tracing::error!(label, panic = %message, "command task panicked");420
- continue;421
- }422
- break Err(EngineError::TaskPanicked { task: context.label() });423
- }424
- Some(note) = self.notes.recv() => self.actor_note(note).await,425
- request = self.port.recv(), if !handles_dropped => {426
- // nobody's left to ask, but a logout they asked for still deserves to land427
- let Some(request) = request else {428
- handles_dropped = true;429
- continue;430
- };431
- if let Some(reply) = self.dispatch(request).await {432
- shutdown_reply = Some(reply);433
- break Ok(Termination::ShutDown);434
- }435
- }436
- }437
- };438
-439
- if let Err(error) = &ending {440
- let _ = events.send(Event::Failed(error.failure())).await;441
- }442
- self.shutdown().await;443
- let _ = events.send(Event::Stopped).await;444
- if let Some(reply) = shutdown_reply {445
- reply.done(Ok(()));446
- }447
- // everything else still queued answers `Interrupted` as it drops, but a shutdown did happen448
- while let Some(Request { command, reply }) = self.port.try_recv() {449
- if matches!(command, Command::Lifecycle(LifecycleCommand::Shutdown)) {450
- reply.done(Ok(()));451
- }452
- }453
- ending454
- }455
-456
- fn start_tasks(&mut self) {457
- let queues = self.queues.take().expect("engine tasks start once");458
- let (incoming, incoming_verifications) = mpsc::unbounded_channel();459
- self.verification_handler = Some(self.verification_handler(incoming));460
- let client = &self.client;461
- let events = || self.port.event_sender();462
- let (notices, notice_queue) = mpsc::unbounded_channel();463
- let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 13] = [464
- (465
- "upload tracker",466
- Box::pin(watchers::uploads(467
- client.clone(),468
- client.send_queue().subscribe(),469
- events(),470
- )),471
- ),472
- (473
- "room subscription watcher",474
- Box::pin(watchers::room_subscriptions(475
- self.sync.clone(),476
- self.subscriptions.subscribe(),477
- )),478
- ),479
- (480
- "session change watcher",481
- Box::pin(session::session(482
- client.clone(),483
- queues.session_changes,484
- queues.persistence,485
- notices,486
- client487
- .device_id()488
- .expect("authentication was checked during construction")489
- .to_owned(),490
- self.logouts.clone(),491
- )),492
- ),493
- (494
- "session notice watcher",495
- Box::pin(session::session_notices(496
- notice_queue,497
- events(),498
- self.stop.clone(),499
- )),500
- ),501
- (502
- "connection state watcher",503
- Box::pin(watchers::connection(504
- self.sync.state(),505
- self.sync.clone(),506
- client.clone(),507
- events(),508
- self.port.connection_sender(),509
- )),510
- ),511
- (512
- "send queue watcher",513
- Box::pin(watchers::send_queue_errors(514
- client.clone(),515
- client.send_queue().subscribe_errors(),516
- self.port.connection_sender().subscribe(),517
- )),518
- ),519
- (520
- "encryption state watcher",521
- Box::pin(watchers::encryption(client.clone(), events())),522
- ),523
- (524
- "room list watcher",525
- Box::pin(watchers::rooms(client.clone(), events())),526
- ),527
- (528
- "image pack watcher",529
- Box::pin(watchers::image_packs(client.clone(), events())),530
- ),531
- (532
- "notification settings watcher",533
- Box::pin(watchers::notification_settings(client.clone(), events())),534
- ),535
- (536
- "room command worker",537
- Box::pin(mutations::run_scheduler(538
- client.clone(),539
- queues.mutations,540
- self.config.command_capacity,541
- )),542
- ),543
- (544
- "notification command worker",545
- Box::pin(commands::run_notifications(546
- client.clone(),547
- queues.notifications,548
- )),549
- ),550
- (551
- "encryption command worker",552
- Box::pin(commands::run_encryption(553
- client.clone(),554
- events(),555
- queues.encryption,556
- incoming_verifications,557
- )),558
- ),559
- ];560
- for (label, task) in watchers {561
- self.tasks.spawn(TaskContext::Watcher(label), task);562
- }563
- }564
-565
- fn verification_handler(566
- &self,567
- incoming: IncomingVerifications,568
- ) -> matrix_sdk::event_handler::EventHandlerHandle {569
- use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent;570
- self.client.add_event_handler(571
- move |event: ToDeviceKeyVerificationRequestEvent, client: Client| {572
- let incoming = incoming.clone();573
- async move {574
- let request = client575
- .encryption()576
- .get_verification_request(577
- &event.sender,578
- event.content.transaction_id.as_str(),579
- )580
- .await;581
- if let Some(request) = request {582
- let _ = incoming.send(request);583
- }584
- }585
- },586
- )587
- }588
-589
- /// hands back the shutdown request's responder, which is answered once we've stopped590
- async fn dispatch(&mut self, Request { command, reply }: Request) -> Option<Responder> {591
- let client = self.client.clone();592
- match command {593
- Command::Lifecycle(LifecycleCommand::Shutdown) => return Some(reply),594
- Command::Account(AccountCommand::Logout) => {595
- let events = self.port.event_sender();596
- let stop = self.stop.clone();597
- let logouts = self.logouts.clone();598
- logouts.send_modify(|logouts| logouts.in_flight += 1);599
- self.spawn_command("logout", reply, async move {600
- let result = client.logout().await;601
- if result.is_ok() {602
- let _ = events.send(Event::LoggedOut).await;603
- let _ = stop.send(Termination::LoggedOut);604
- }605
- let mut rejected = None;606
- logouts.send_if_modified(|logouts| {607
- if result.is_err() && logouts.in_flight == 1 {608
- rejected = logouts.rejected.take();609
- }610
- false611
- });612
- if let Some(soft_logout) = rejected {613
- session::reject_session(&events, &stop, soft_logout).await;614
- }615
- logouts.send_modify(|logouts| logouts.in_flight -= 1);616
- result.map_err(|source| Error::from_sdk("logout failed", source))617
- });618
- }619
- Command::Account(AccountCommand::SetAccountData {620
- event_type,621
- content,622
- }) => enqueue(623
- &self.mutations,624
- Job {625
- command: Mutation::GlobalAccountData {626
- event_type,627
- content,628
- },629
- reply,630
- },631
- ),632
- Command::Account(AccountCommand::SetDisplayName(name)) => {633
- self.spawn_command("set display name", reply, async move {634
- client635
- .account()636
- .set_display_name(name.as_deref())637
- .await638
- .map_err(|source| Error::from_sdk("setting display name failed", source))639
- });640
- }641
- Command::Account(AccountCommand::SetAvatar(avatar)) => {642
- self.spawn_command("set avatar", reply, async move {643
- client644
- .account()645
- .set_avatar_url(avatar.as_deref())646
- .await647
- .map_err(|source| Error::from_sdk("setting avatar failed", source))648
- });649
- }650
- Command::Account(AccountCommand::UploadAvatar { mimetype, bytes }) => {651
- let max_bytes = self.config.max_media_bytes;652
- self.spawn_command("upload avatar", reply, async move {653
- commands::upload_avatar(&client, &mimetype, bytes, max_bytes).await654
- });655
- }656
- Command::Account(AccountCommand::RenameDevice {657
- device_id,658
- display_name,659
- }) => self.spawn_command("rename device", reply, async move {660
- commands::rename_device(&client, &device_id, &display_name).await661
- }),662
- Command::Account(AccountCommand::DeleteDevices { device_ids, auth }) => {663
- self.spawn_command("delete devices", reply, async move {664
- commands::delete_devices(&client, &device_ids, auth).await665
- });666
- }667
- Command::Room(RoomCommand::Open(room_id)) => {668
- if self.config.prepare_to_send {669
- self.prepare_quietly(room_id.clone());670
- }671
- self.open_room(room_id, reply).await;672
- }673
- Command::Room(RoomCommand::Close(room_id)) => self.close_room(room_id, reply).await,674
- Command::Room(RoomCommand::PrepareToSend(room_id)) => {675
- let flight = self.preparations.join(&client, room_id);676
- self.spawn_command("prepare to send", reply, flight);677
- }678
- Command::Room(RoomCommand::SetTyping { room_id, typing }) => {679
- if typing && self.config.prepare_to_send {680
- self.prepare_quietly(room_id.clone());681
- }682
- self.spawn_command("typing", reply, async move {683
- commands::set_typing(&client, &room_id, typing).await684
- });685
- }686
- Command::Room(RoomCommand::CancelSend {687
- room_id,688
- transaction_id,689
- }) => self.spawn_command("cancel send", reply, async move {690
- commands::cancel_send(&client, &room_id, &transaction_id).await691
- }),692
- Command::Room(RoomCommand::RetrySend {693
- room_id,694
- transaction_id,695
- }) => self.spawn_command("retry send", reply, async move {696
- commands::retry_send(&client, &room_id, &transaction_id).await697
- }),698
- Command::Room(command) => enqueue(699
- &self.mutations,700
- Job {701
- command: Mutation::Room(command),702
- reply,703
- },704
- ),705
- Command::Timeline(command) => self.route_timeline(command, reply),706
- Command::Media(MediaCommand::Upload(upload)) => self.upload(upload, reply),707
- Command::Encryption(command) => enqueue(&self.encryption, Job { command, reply }),708
- Command::Notification(command) => {709
- enqueue(&self.notifications, Job { command, reply });710
- }711
- }712
- None713
- }714
-715
- fn spawn_command(716
- &mut self,717
- label: &'static str,718
- reply: Responder,719
- work: impl Future<Output = Result<(), Error>> + SendOutsideWasm + 'static,720
- ) {721
- self.tasks.spawn(TaskContext::Command(label), async move {722
- reply.done(work.await);723
- });724
- }725
-726
- fn prepare_quietly(&mut self, room_id: OwnedRoomId) {727
- let flight = self.preparations.join(&self.client, room_id);728
- self.tasks729
- .spawn(TaskContext::Command("prepare to send"), async move {730
- let _ = flight.await;731
- });732
- }733
-734
- fn upload(&mut self, upload: crate::AttachmentUpload, reply: Responder) {735
- let Some(room) = self.client.get_room(upload.timeline_id.room_id()) else {736
- return reply.done(Err(Error::room_unknown()));737
- };738
- let max_bytes = self.config.max_media_bytes;739
- self.spawn_command("upload", reply, async move {740
- media::queue_upload(room, upload, max_bytes).await741
- });742
- }743
-744
- fn route_timeline(&mut self, command: TimelineCommand, reply: Responder) {745
- let sender = match self.rooms.get(command.room_id()) {746
- Some(RoomSlot::Active { sender, .. }) => Some(sender.clone()),747
- _ => None,748
- };749
- match (sender, command) {750
- (Some(sender), command) => send_actor(&sender, ActorRequest::Execute(command, reply)),751
- (752
- None,753
- TimelineCommand::MarkRead {754
- timeline_id,755
- event_id,756
- },757
- ) => {758
- let client = self.client.clone();759
- self.spawn_command("mark read", reply, async move {760
- commands::mark_read(&client, &timeline_id, event_id).await761
- });762
- }763
- (None, _) => reply.done(Err(Error::new(ErrorKind::RoomNotOpen, "room is not open"))),764
- }765
- }766
-767
- async fn open_room(&mut self, room_id: OwnedRoomId, reply: Responder) {768
- match self.rooms.get_mut(&room_id) {769
- Some(RoomSlot::Active { sender, .. }) => send_actor(sender, ActorRequest::Open(reply)),770
- Some(RoomSlot::Closing { pending, .. }) => {771
- defer(pending, Deferred::Open(reply), self.config.command_capacity);772
- }773
- None => self.start_actor(room_id, reply).await,774
- }775
- }776
-777
- async fn close_room(&mut self, room_id: OwnedRoomId, reply: Responder) {778
- let (generation, sender) = match self.rooms.get_mut(&room_id) {779
- None => return reply.done(Ok(())),780
- Some(RoomSlot::Closing { pending, .. }) => {781
- return defer(782
- pending,783
- Deferred::Close(reply),784
- self.config.command_capacity,785
- );786
- }787
- Some(RoomSlot::Active { generation, sender }) => (*generation, sender.clone()),788
- };789
- match sender.try_send(ActorRequest::Close(reply)) {790
- Ok(()) => {}791
- // the actor already left on its own, which is closed enough792
- Err(mpsc::error::TrySendError::Closed(request)) => request.into_reply().done(Ok(())),793
- Err(mpsc::error::TrySendError::Full(request)) => {794
- return request.into_reply().done(Err(Error::new(795
- ErrorKind::RoomBusy,796
- "room command queue is full",797
- )));798
- }799
- }800
- self.rooms.insert(801
- room_id,802
- RoomSlot::Closing {803
- generation,804
- pending: VecDeque::new(),805
- },806
- );807
- self.update_room_subscriptions();808
- }809
-810
- async fn start_actor(&mut self, room_id: OwnedRoomId, reply: Responder) {811
- let Some(room) = self.client.get_room(&room_id) else {812
- return reply.done(Err(Error::room_unknown()));813
- };814
- self.next_generation = self.next_generation.wrapping_add(1);815
- let generation = self.next_generation;816
- let (sender, requests) = mpsc::channel(self.config.command_capacity);817
- let (supervisor, panics) = Supervisor::new();818
- let actor = RoomActor {819
- room_id: room_id.clone(),820
- generation,821
- room,822
- requests,823
- notes: self.note_sender.clone(),824
- context: SessionContext {825
- events: self.port.event_sender(),826
- media: self.media.clone(),827
- current_user_id: self.client.user_id().map(ToOwned::to_owned),828
- initial_history_limit: self.config.initial_history_limit,829
- supervisor,830
- },831
- panics,832
- opening: vec![reply],833
- };834
- self.rooms835
- .insert(room_id.clone(), RoomSlot::Active { generation, sender });836
- self.tasks.spawn(837
- TaskContext::RoomActor {838
- room_id,839
- generation,840
- },841
- actor.run(),842
- );843
- self.update_room_subscriptions();844
- }845
-846
- async fn actor_note(&mut self, note: ActorNote) {847
- let (room_id, generation) = match ¬e {848
- ActorNote::Leaving {849
- room_id,850
- generation,851
- }852
- | ActorNote::Exited {853
- room_id,854
- generation,855
- ..856
- } => (room_id.clone(), *generation),857
- };858
- if self859
- .rooms860
- .get(&room_id)861
- .is_none_or(|slot| slot.generation() != generation)862
- {863
- return;864
- }865
- let ActorNote::Exited { leftovers, .. } = note else {866
- if let Some(slot @ RoomSlot::Active { .. }) = self.rooms.get_mut(&room_id) {867
- *slot = RoomSlot::Closing {868
- generation,869
- pending: VecDeque::new(),870
- };871
- self.update_room_subscriptions();872
- }873
- return;874
- };875
- let pending = match self.rooms.remove(&room_id) {876
- Some(RoomSlot::Closing { pending, .. }) => pending,877
- _ => VecDeque::new(),878
- };879
- // leftovers were sent before anything deferred, so they go first880
- for request in leftovers {881
- match request {882
- ActorRequest::Open(reply) => self.open_room(room_id.clone(), reply).await,883
- ActorRequest::Close(reply) => reply.done(Ok(())),884
- ActorRequest::Execute(command, reply) => self.route_timeline(command, reply),885
- }886
- }887
- for deferred in pending {888
- match deferred {889
- Deferred::Open(reply) => self.open_room(room_id.clone(), reply).await,890
- Deferred::Close(reply) => self.close_room(room_id.clone(), reply).await,891
- }892
- }893
- self.update_room_subscriptions();894
- }895
-896
- fn update_room_subscriptions(&self) {897
- let room_ids = self898
- .rooms899
- .iter()900
- .filter(|(_, slot)| matches!(slot, RoomSlot::Active { .. }))901
- .map(|(room_id, _)| room_id.clone())902
- .collect();903
- self.subscriptions.send_replace(room_ids);904
- }905
-906
- async fn shutdown(&mut self) {907
- if let Some(handler) = self.verification_handler.take() {908
- self.client.remove_event_handler(handler);909
- }910
- self.port.close();911
- self.tasks.shutdown().await;912
- self.rooms.clear();913
- self.media.clear().await;914
- self.sync.stop().await;915
- self.port916
- .connection_sender()917
- .send_replace(ConnectionState::Stopped);918
- let _ = self919
- .port920
- .event_sender()921
- .send(Event::ConnectionChanged(ConnectionState::Stopped))922
- .await;923
- }924
-}925
-926
-fn send_actor(sender: &mpsc::Sender<ActorRequest>, request: ActorRequest) {927
- if let Err(error) = sender.try_send(request) {928
- let (request, error) = match error {929
- mpsc::error::TrySendError::Full(request) => (930
- request,931
- Error::new(ErrorKind::RoomBusy, "room command queue is full"),932
- ),933
- mpsc::error::TrySendError::Closed(request) => (934
- request,935
- Error::new(ErrorKind::RoomNotOpen, "room is not open"),936
- ),937
- };938
- request.into_reply().done(Err(error));939
- }940
-}941
-942
-fn defer(pending: &mut VecDeque<Deferred>, deferred: Deferred, capacity: usize) {943
- if pending.len() < capacity {944
- return pending.push_back(deferred);945
- }946
- let (Deferred::Open(reply) | Deferred::Close(reply)) = deferred;947
- reply.done(Err(Error::new(948
- ErrorKind::RoomBusy,949
- "room lifecycle queue is full",950
- )));951
-}952
-953
-async fn settled(logouts: &mut watch::Receiver<Logouts>) {954
- let _ = logouts.wait_for(|logouts| logouts.in_flight == 0).await;955
-}956
-957
-fn enqueue<T>(sender: &mpsc::Sender<Job<T>>, job: Job<T>) {958
- if let Err(error) = sender.try_send(job) {959
- let (job, error) = match error {960
- mpsc::error::TrySendError::Full(job) => (961
- job,962
- Error::new(ErrorKind::QueueFull, "command queue is full"),963
- ),964
- mpsc::error::TrySendError::Closed(job) => (job, Error::interrupted()),965
- };966
- job.reply.done(Err(error));967
- }968
-}@@ -1,379 +0,0 @@
1
-use std::{2
- collections::{HashMap, HashSet, VecDeque},3
- sync::Arc,4
-};5
-6
-use matrix_sdk::{7
- Client, Room,8
- ruma::{9
- Int,10
- api::client::room::{11
- Visibility as SdkVisibility,12
- create_room::v3::{Request as CreateRoomRequest, RoomPreset as SdkPreset},13
- },14
- events::{15
- AnyGlobalAccountDataEventContent, GlobalAccountDataEventType, MessageLikeEventType,16
- StateEventType, room::power_levels::RoomPowerLevelsEventContent,17
- },18
- serde::Raw,19
- },20
-};21
-use ruma::{OwnedRoomId, OwnedUserId};22
-use tokio::sync::mpsc;23
-24
-use super::commands::{Job, guarded};25
-use super::executor::TaskSet;26
-use crate::{27
- CommandOutput, CreateRoomOptions, Error, ErrorKind, PowerLevelChanges, RoomCommand, RoomPreset,28
- RoomVisibility,29
-};30
-31
-/// state changes that must not race each other. same room, same alias, same dm32
-/// partner or same account data type run in order, everything else runs at once33
-pub(super) enum Mutation {34
- Room(RoomCommand),35
- GlobalAccountData {36
- event_type: GlobalAccountDataEventType,37
- content: Raw<AnyGlobalAccountDataEventContent>,38
- },39
-}40
-41
-pub(super) type MutationJob = Job<Mutation>;42
-43
-#[derive(Clone, Debug, Eq, Hash, PartialEq)]44
-enum Key {45
- Room(OwnedRoomId),46
- Alias(String),47
- Direct(OwnedUserId),48
- AccountData(String),49
- Independent(u64),50
-}51
-52
-pub(super) async fn run_scheduler(53
- client: Client,54
- mut jobs: mpsc::Receiver<MutationJob>,55
- queue_limit: usize,56
-) {57
- let fresh_dms = FreshDms::default();58
- let mut waiting = HashMap::<Key, VecDeque<MutationJob>>::new();59
- let mut running = HashSet::<Key>::new();60
- let mut tasks = TaskSet::<Key>::new();61
- let mut queued = 0_usize;62
- let mut independent = 0_u64;63
- let mut receiving = true;64
- while receiving || !tasks.is_empty() {65
- tokio::select! {66
- biased;67
- Some(finished) = tasks.join_next(), if !tasks.is_empty() => {68
- // a job's own panic is caught inside it, so this is only ever a key69
- let Ok(key) = finished else { continue };70
- let next = waiting.get_mut(&key).and_then(VecDeque::pop_front);71
- if waiting.get(&key).is_some_and(VecDeque::is_empty) {72
- waiting.remove(&key);73
- }74
- match next {75
- Some(job) => {76
- queued -= 1;77
- spawn(&mut tasks, &client, &fresh_dms, key, job);78
- }79
- None => {80
- running.remove(&key);81
- }82
- }83
- }84
- job = jobs.recv(), if receiving => {85
- let Some(job) = job else {86
- receiving = false;87
- continue;88
- };89
- let key = key(&job.command, client.user_id().map(ruma::UserId::server_name)).unwrap_or_else(|| {90
- independent += 1;91
- Key::Independent(independent)92
- });93
- if running.insert(key.clone()) {94
- spawn(&mut tasks, &client, &fresh_dms, key, job);95
- } else if queued < queue_limit {96
- waiting.entry(key).or_default().push_back(job);97
- queued += 1;98
- } else {99
- job.reply.done(Err(Error::new(100
- ErrorKind::QueueFull,101
- "room command queue is full",102
- )));103
- }104
- }105
- }106
- }107
-}108
-109
-/// `server` is ours, since a new alias lives there110
-fn key(mutation: &Mutation, server: Option<&ruma::ServerName>) -> Option<Key> {111
- let room_id = match mutation {112
- Mutation::GlobalAccountData { event_type, .. } => {113
- return Some(Key::AccountData(event_type.to_string()));114
- }115
- Mutation::Room(RoomCommand::Join { room, .. }) => {116
- return Some(match OwnedRoomId::try_from(room.clone()) {117
- Ok(room_id) => Key::Room(room_id),118
- Err(alias) => Key::Alias(alias.to_string()),119
- });120
- }121
- Mutation::Room(RoomCommand::CreateDirect(user_id)) => {122
- return Some(Key::Direct(user_id.clone()));123
- }124
- Mutation::Room(RoomCommand::Create(options)) => {125
- return match (&options.alias_localpart, options.invite.as_slice()) {126
- (Some(localpart), _) => Some(Key::Alias(format!("#{localpart}:{}", server?))),127
- (None, [user_id]) if options.direct => Some(Key::Direct(user_id.clone())),128
- _ => None,129
- };130
- }131
- Mutation::Room(132
- RoomCommand::Leave(room_id)133
- | RoomCommand::Forget(room_id)134
- | RoomCommand::AcceptInvite(room_id)135
- | RoomCommand::RejectInvite(room_id)136
- | RoomCommand::InviteUser { room_id, .. }137
- | RoomCommand::KickUser { room_id, .. }138
- | RoomCommand::BanUser { room_id, .. }139
- | RoomCommand::UnbanUser { room_id, .. }140
- | RoomCommand::SetPowerLevels { room_id, .. }141
- | RoomCommand::SetUserPowerLevel { room_id, .. }142
- | RoomCommand::SetAccountData { room_id, .. },143
- ) => room_id,144
- Mutation::Room(_) => return None,145
- };146
- Some(Key::Room(room_id.clone()))147
-}148
-149
-/// dms made here that the next sync hasn't told the sdk about yet, since `get_dm_room`150
-/// only knows `m.direct` once it comes back round151
-type FreshDms = Arc<std::sync::Mutex<HashMap<OwnedUserId, OwnedRoomId>>>;152
-153
-fn spawn(154
- tasks: &mut TaskSet<Key>,155
- client: &Client,156
- fresh_dms: &FreshDms,157
- key: Key,158
- job: MutationJob,159
-) {160
- let client = client.clone();161
- let fresh_dms = fresh_dms.clone();162
- tasks.spawn(async move {163
- let Job { command, reply } = job;164
- reply.send(guarded("room command", execute(&client, &fresh_dms, command)).await);165
- key166
- });167
-}168
-169
-async fn execute(170
- client: &Client,171
- fresh_dms: &FreshDms,172
- mutation: Mutation,173
-) -> Result<CommandOutput, Error> {174
- let command = match mutation {175
- Mutation::GlobalAccountData {176
- event_type,177
- content,178
- } => {179
- client180
- .account()181
- .set_account_data_raw(event_type, content)182
- .await183
- .map_err(|source| Error::from_sdk("writing account data failed", source))?;184
- return Ok(CommandOutput::Done);185
- }186
- Mutation::Room(command) => command,187
- };188
- let room = |room_id: &OwnedRoomId| client.get_room(room_id).ok_or_else(Error::room_unknown);189
- let joined = |room: Room| CommandOutput::Room(room.room_id().to_owned());190
- let sdk = |message: &'static str| move |source| Error::from_sdk(message, source);191
- match command {192
- RoomCommand::Join { room, via } => client193
- .join_room_by_id_or_alias(&room, &via)194
- .await195
- .map(joined)196
- .map_err(sdk("joining room failed")),197
- RoomCommand::Leave(room_id) => room(&room_id)?198
- .leave()199
- .await200
- .map(done)201
- .map_err(sdk("leaving room failed")),202
- RoomCommand::Forget(room_id) => room(&room_id)?203
- .forget()204
- .await205
- .map(done)206
- .map_err(sdk("forgetting room failed")),207
- RoomCommand::AcceptInvite(room_id) => room(&room_id)?208
- .join()209
- .await210
- .map(done)211
- .map_err(sdk("accepting invite failed")),212
- RoomCommand::RejectInvite(room_id) => room(&room_id)?213
- .leave()214
- .await215
- .map(done)216
- .map_err(sdk("rejecting invite failed")),217
- RoomCommand::InviteUser { room_id, user_id } => room(&room_id)?218
- .invite_user_by_id(&user_id)219
- .await220
- .map(done)221
- .map_err(sdk("inviting user failed")),222
- RoomCommand::KickUser {223
- room_id,224
- user_id,225
- reason,226
- } => room(&room_id)?227
- .kick_user(&user_id, reason.as_deref())228
- .await229
- .map(done)230
- .map_err(sdk("kicking user failed")),231
- RoomCommand::BanUser {232
- room_id,233
- user_id,234
- reason,235
- } => room(&room_id)?236
- .ban_user(&user_id, reason.as_deref())237
- .await238
- .map(done)239
- .map_err(sdk("banning user failed")),240
- RoomCommand::UnbanUser {241
- room_id,242
- user_id,243
- reason,244
- } => room(&room_id)?245
- .unban_user(&user_id, reason.as_deref())246
- .await247
- .map(done)248
- .map_err(sdk("unbanning user failed")),249
- RoomCommand::SetPowerLevels { room_id, changes } => {250
- apply_power_level_changes(&room(&room_id)?, changes)251
- .await252
- .map(done)253
- }254
- RoomCommand::SetUserPowerLevel {255
- room_id,256
- user_id,257
- level,258
- } => room(&room_id)?259
- .update_power_levels(vec![(&user_id, power_level(level)?)])260
- .await261
- .map(done)262
- .map_err(sdk("updating user power level failed")),263
- RoomCommand::SetAccountData {264
- room_id,265
- event_type,266
- content,267
- } => room(&room_id)?268
- .set_account_data_raw(event_type, content)269
- .await270
- .map(done)271
- .map_err(sdk("writing room account data failed")),272
- RoomCommand::Create(options) => client273
- .create_room(create_room_request(options))274
- .await275
- .map(joined)276
- .map_err(sdk("creating room failed")),277
- RoomCommand::CreateDirect(user_id) => {278
- if let Some(existing) = client.get_dm_room(&user_id) {279
- return Ok(joined(existing));280
- }281
- let fresh = fresh_dms282
- .lock()283
- .expect("nothing panics holding this")284
- .get(&user_id)285
- .cloned();286
- if let Some(room_id) = fresh.filter(|room_id| client.get_room(room_id).is_some()) {287
- return Ok(CommandOutput::Room(room_id));288
- }289
- let room = client290
- .create_dm(&user_id)291
- .await292
- .map_err(sdk("creating direct room failed"))?;293
- fresh_dms294
- .lock()295
- .expect("nothing panics holding this")296
- .insert(user_id, room.room_id().to_owned());297
- Ok(joined(room))298
- }299
- _ => Err(Error::new(ErrorKind::Other, "room command was misrouted")),300
- }301
-}302
-303
-/// one state event carrying every requested change, since the sdk's own patcher is private304
-async fn apply_power_level_changes(room: &Room, changes: PowerLevelChanges) -> Result<(), Error> {305
- let mut levels = room306
- .power_levels()307
- .await308
- .map_err(|source| Error::from_sdk("reading power levels failed", source.into()))?;309
- for (target, value) in [310
- (&mut levels.ban, changes.ban),311
- (&mut levels.invite, changes.invite),312
- (&mut levels.kick, changes.kick),313
- (&mut levels.redact, changes.redact),314
- (&mut levels.events_default, changes.events_default),315
- (&mut levels.state_default, changes.state_default),316
- (&mut levels.users_default, changes.users_default),317
- (&mut levels.notifications.room, changes.room_notification),318
- ] {319
- if let Some(value) = value {320
- *target = power_level(value)?;321
- }322
- }323
- for (event_type, value) in [324
- (StateEventType::RoomName.into(), changes.room_name),325
- (StateEventType::RoomAvatar.into(), changes.room_avatar),326
- (StateEventType::RoomTopic.into(), changes.room_topic),327
- (StateEventType::SpaceChild.into(), changes.space_child),328
- (MessageLikeEventType::Beacon.into(), changes.beacon),329
- (StateEventType::BeaconInfo.into(), changes.beacon_info),330
- ] {331
- if let Some(value) = value {332
- levels.events.insert(event_type, power_level(value)?);333
- }334
- }335
- let content = RoomPowerLevelsEventContent::try_from(levels).map_err(|source| {336
- Error::with_source(337
- ErrorKind::Invalid,338
- "normalising power levels failed",339
- source,340
- )341
- })?;342
- room.send_state_event(content)343
- .await344
- .map(drop)345
- .map_err(|source| Error::from_sdk("updating power levels failed", source))346
-}347
-348
-fn done<T>(_: T) -> CommandOutput {349
- CommandOutput::Done350
-}351
-352
-fn power_level(value: i64) -> Result<Int, Error> {353
- value.try_into().map_err(|source| {354
- Error::with_source(355
- ErrorKind::Invalid,356
- "power level is outside the Matrix integer range",357
- source,358
- )359
- })360
-}361
-362
-fn create_room_request(options: CreateRoomOptions) -> CreateRoomRequest {363
- let mut request = CreateRoomRequest::new();364
- request.name = options.name;365
- request.topic = options.topic;366
- request.room_alias_name = options.alias_localpart;367
- request.invite = options.invite;368
- request.is_direct = options.direct;369
- request.visibility = match options.visibility {370
- RoomVisibility::Private => SdkVisibility::Private,371
- RoomVisibility::Public => SdkVisibility::Public,372
- };373
- request.preset = options.preset.map(|preset| match preset {374
- RoomPreset::PrivateChat => SdkPreset::PrivateChat,375
- RoomPreset::TrustedPrivateChat => SdkPreset::TrustedPrivateChat,376
- RoomPreset::PublicChat => SdkPreset::PublicChat,377
- });378
- request379
-}@@ -1,43 +0,0 @@
1
-use std::collections::HashMap;2
-3
-use futures_util::{4
- FutureExt,5
- future::{Shared, WeakShared},6
-};7
-use matrix_sdk::Client;8
-use ruma::OwnedRoomId;9
-10
-use super::executor::EngineBoxFuture;11
-use crate::Error;12
-13
-type Flight = Shared<EngineBoxFuture<'static, Result<(), Error>>>;14
-15
-/// one warm-up per room at a time, later asks join the one in flight16
-#[derive(Default)]17
-pub(super) struct Preparations(18
- HashMap<OwnedRoomId, WeakShared<EngineBoxFuture<'static, Result<(), Error>>>>,19
-);20
-21
-impl Preparations {22
- pub(super) fn join(&mut self, client: &Client, room_id: OwnedRoomId) -> Flight {23
- self.0.retain(|_, flight| flight.upgrade().is_some());24
- if let Some(flight) = self.0.get(&room_id).and_then(WeakShared::upgrade) {25
- return flight;26
- }27
- let work: EngineBoxFuture<'static, _> = Box::pin(prepare(client.clone(), room_id.clone()));28
- let flight = work.shared();29
- if let Some(weak) = flight.downgrade() {30
- self.0.insert(room_id, weak);31
- }32
- flight33
- }34
-}35
-36
-/// the sdk's own pre-send work, run early. a send landing mid-share waits on the37
-/// sdk's per-room share lock instead of starting another (forked sdk, see design-decisions)38
-async fn prepare(client: Client, room_id: OwnedRoomId) -> Result<(), Error> {39
- let room = client.get_room(&room_id).ok_or_else(Error::room_unknown)?;40
- room.prepare_encryption()41
- .await42
- .map_err(|source| Error::from_sdk("preparing room encryption failed", source))43
-}@@ -1,459 +0,0 @@
1
-use std::collections::{HashMap, VecDeque};2
-3
-use matrix_sdk::{Room, RoomState};4
-use matrix_sdk_ui::timeline::{RoomExt, Timeline, TimelineFocus, TimelineReadReceiptTracking};5
-use ruma::{OwnedEventId, OwnedRoomId};6
-use tokio::sync::mpsc;7
-8
-use super::executor::{AbortHandle, JoinError, TaskSet, is_panic, resume_unwind};9
-use super::timeline::{SessionContext, TaskGuard, TimelineSession};10
-use crate::{11
- Error, ErrorKind, Event, TimelineCommand, TimelineId, TypingState, transport::Responder,12
-};13
-14
-pub(super) enum ActorRequest {15
- /// opening an open room answers with a fresh reset16
- Open(Responder),17
- Execute(TimelineCommand, Responder),18
- Close(Responder),19
-}20
-21
-impl ActorRequest {22
- pub(super) fn into_reply(self) -> Responder {23
- match self {24
- Self::Open(reply) | Self::Execute(_, reply) | Self::Close(reply) => reply,25
- }26
- }27
-}28
-29
-pub(super) enum ActorNote {30
- /// stopping on its own, after a leave or a kick. anything sent its way now comes back in `Exited`31
- Leaving {32
- room_id: OwnedRoomId,33
- generation: u64,34
- },35
- Exited {36
- room_id: OwnedRoomId,37
- generation: u64,38
- /// requests that arrived too late to be served, for the engine to answer or replay39
- leftovers: Vec<ActorRequest>,40
- },41
-}42
-43
-pub(super) struct RoomActor {44
- pub(super) room_id: OwnedRoomId,45
- pub(super) generation: u64,46
- pub(super) room: Room,47
- pub(super) requests: mpsc::Receiver<ActorRequest>,48
- pub(super) notes: mpsc::UnboundedSender<ActorNote>,49
- pub(super) context: SessionContext,50
- pub(super) panics: mpsc::UnboundedReceiver<&'static str>,51
- pub(super) opening: Vec<Responder>,52
-}53
-54
-impl RoomActor {55
- pub(super) async fn run(mut self) {56
- self.serve().await;57
- self.requests.close();58
- let mut leftovers = Vec::new();59
- while let Ok(request) = self.requests.try_recv() {60
- leftovers.push(request);61
- }62
- let _ = self.notes.send(ActorNote::Exited {63
- room_id: self.room_id.clone(),64
- generation: self.generation,65
- leftovers,66
- });67
- }68
-69
- async fn serve(&mut self) {70
- let timeline = match build_timeline(71
- &self.room,72
- TimelineFocus::Live {73
- hide_threaded_events: true,74
- },75
- )76
- .await77
- {78
- Ok(timeline) => timeline,79
- Err(error) => return self.refuse_everything(error).await,80
- };81
- let Ok(room_timeline) = TimelineSession::start(82
- TimelineId::Room(self.room_id.clone()),83
- timeline,84
- self.context.clone(),85
- )86
- .await87
- else {88
- return;89
- };90
- for reply in self.opening.drain(..) {91
- reply.done(Ok(()));92
- }93
- let (typing_guard, typing) = self.room.subscribe_to_typing_notifications();94
- let mut typing_watcher = TaskGuard(self.context.supervisor.spawn(95
- "typing watcher",96
- watch_typing(self.room_id.clone(), typing, self.context.events.clone()),97
- ));98
- let mut timelines = Timelines {99
- room: room_timeline,100
- threads: Threads::new(self.requests.max_capacity()),101
- work: TaskSet::new(),102
- };103
- let close = self.serve_open(&mut timelines).await;104
- if close.is_none() {105
- self.requests.close();106
- let _ = self.notes.send(ActorNote::Leaving {107
- room_id: self.room_id.clone(),108
- generation: self.generation,109
- });110
- }111
-112
- // let redactions and receipts that already left finish rather than lie about them113
- while timelines.work.join_next().await.is_some() {}114
- drop(typing_guard);115
- typing_watcher.0.abort();116
- let _ = (&mut typing_watcher.0).await;117
- let _ = self118
- .context119
- .events120
- .send(Event::TypingChanged(TypingState {121
- room_id: self.room_id.clone(),122
- users: Vec::new(),123
- }))124
- .await;125
- timelines.threads.close().await;126
- timelines.room.close().await;127
- if let Some(reply) = close {128
- reply.done(Ok(()));129
- }130
- }131
-132
- /// returns the close request's responder, if a close is why we stopped133
- async fn serve_open(&mut self, timelines: &mut Timelines) -> Option<Responder> {134
- let mut room_info = self.room.subscribe_info();135
- // a leave that landed while the timeline was building won't come round again136
- if matches!(self.room.state(), RoomState::Left | RoomState::Banned) {137
- return None;138
- }139
- loop {140
- tokio::select! {141
- Some(info) = room_info.next() => {142
- if matches!(info.state(), RoomState::Left | RoomState::Banned) {143
- tracing::debug!(room_id = %self.room_id, "room actor stopped because we left the room");144
- return None;145
- }146
- }147
- request = self.requests.recv() => match request {148
- None => return None,149
- Some(ActorRequest::Close(reply)) => return Some(reply),150
- Some(ActorRequest::Open(reply)) => reply.done(timelines.room.resync().await),151
- Some(ActorRequest::Execute(command, reply)) => {152
- timelines.execute(command, reply, &self.room).await;153
- }154
- },155
- Some(built) = timelines.threads.builds.join_next(), if !timelines.threads.builds.is_empty() => {156
- timelines.thread_built(built, &self.room_id, &self.context).await;157
- }158
- Some(label) = self.panics.recv() => panic!("{label} panicked in room {}", self.room_id),159
- Some(done) = timelines.work.join_next(), if !timelines.work.is_empty() => {160
- if let Err(error) = done && is_panic(&error) {161
- tracing::warn!(room_id = %self.room_id, %error, "timeline operation panicked");162
- }163
- }164
- }165
- }166
- }167
-168
- async fn refuse_everything(&mut self, error: matrix_sdk_ui::timeline::Error) {169
- tracing::warn!(%error, room_id = %self.room_id, "room actor could not build its timeline");170
- let error = Error::with_source(ErrorKind::Timeline, "room timeline build failed", error);171
- for reply in self.opening.drain(..) {172
- reply.done(Err(error.clone()));173
- }174
- self.requests.close();175
- while let Some(request) = self.requests.recv().await {176
- request.into_reply().done(Err(error.clone()));177
- }178
- }179
-}180
-181
-async fn build_timeline(182
- room: &Room,183
- focus: TimelineFocus,184
-) -> Result<Timeline, matrix_sdk_ui::timeline::Error> {185
- room.timeline_builder()186
- .with_focus(focus)187
- .track_read_marker_and_receipts(TimelineReadReceiptTracking::AllEvents)188
- .build()189
- .await190
-}191
-192
-async fn watch_typing(193
- room_id: OwnedRoomId,194
- mut typing: tokio::sync::broadcast::Receiver<Vec<ruma::OwnedUserId>>,195
- events: crate::transport::EventSender,196
-) {197
- loop {198
- match typing.recv().await {199
- Ok(users) => {200
- let state = TypingState {201
- room_id: room_id.clone(),202
- users,203
- };204
- if events.send(Event::TypingChanged(state)).await.is_err() {205
- return;206
- }207
- }208
- Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {209
- tracing::warn!(%room_id, skipped, "typing watcher lagged");210
- }211
- Err(tokio::sync::broadcast::error::RecvError::Closed) => return,212
- }213
- }214
-}215
-216
-struct Timelines {217
- room: TimelineSession,218
- threads: Threads,219
- work: TaskSet<()>,220
-}221
-222
-impl Timelines {223
- async fn execute(&mut self, command: TimelineCommand, reply: Responder, room: &Room) {224
- match command {225
- TimelineCommand::OpenThread { root_event_id, .. } => {226
- self.threads.open(root_event_id, reply, room);227
- }228
- TimelineCommand::CloseThread { root_event_id, .. } => {229
- reply.done(self.threads.close_one(&root_event_id).await);230
- }231
- TimelineCommand::Resync {232
- timeline_id: TimelineId::Room(_),233
- } => reply.done(self.room.resync().await),234
- command => {235
- let root = command236
- .timeline_id()237
- .and_then(TimelineId::thread_root)238
- .cloned();239
- let Some(root) = root else {240
- return self.room.execute(command, reply, &mut self.work).await;241
- };242
- if let TimelineCommand::MarkRead {243
- timeline_id,244
- event_id,245
- } = &command246
- && !self.threads.open.contains_key(&root)247
- {248
- let client = room.client();249
- let (timeline_id, event_id) = (timeline_id.clone(), event_id.clone());250
- self.work.spawn(async move {251
- reply.done(252
- super::commands::mark_read(&client, &timeline_id, event_id).await,253
- );254
- });255
- return;256
- }257
- match self.threads.route(root, command, reply) {258
- Routed::Open(session, TimelineCommand::Resync { .. }, reply) => {259
- reply.done(session.resync().await);260
- }261
- Routed::Open(session, command, reply) => {262
- session.execute(command, reply, &mut self.work).await;263
- }264
- Routed::Queued => {}265
- }266
- }267
- }268
- }269
-270
- async fn thread_built(271
- &mut self,272
- built: Result<Built, JoinError>,273
- room_id: &OwnedRoomId,274
- context: &SessionContext,275
- ) {276
- let Some((root, queued, timeline)) = self.threads.built(built) else {277
- return;278
- };279
- let timeline = match timeline {280
- Ok(timeline) => timeline,281
- Err(error) => {282
- tracing::warn!(%error, %room_id, %root, "thread timeline build failed");283
- let error =284
- Error::with_source(ErrorKind::Timeline, "thread timeline build failed", error);285
- for (_, reply) in queued {286
- reply.done(Err(error.clone()));287
- }288
- return;289
- }290
- };291
- let id = TimelineId::thread(room_id.clone(), root.clone());292
- let Ok(session) = TimelineSession::start(id, timeline, context.clone()).await else {293
- return;294
- };295
- let session = self296
- .threads297
- .open298
- .entry(root)299
- .insert_entry(session)300
- .into_mut();301
- for (command, reply) in queued {302
- match command {303
- None => reply.done(Ok(())),304
- Some(TimelineCommand::Resync { .. }) => reply.done(session.resync().await),305
- Some(command) => session.execute(command, reply, &mut self.work).await,306
- }307
- }308
- }309
-}310
-311
-type Built = (312
- OwnedEventId,313
- u64,314
- Result<Timeline, matrix_sdk_ui::timeline::Error>,315
-);316
-317
-/// `None` is the open itself, everything else waits behind it318
-type Queued = VecDeque<(Option<TimelineCommand>, Responder)>;319
-320
-enum Routed<'a> {321
- Open(&'a mut TimelineSession, TimelineCommand, Responder),322
- Queued,323
-}324
-325
-struct OpeningThread {326
- generation: u64,327
- build: AbortHandle,328
- queued: Queued,329
-}330
-331
-struct Threads {332
- open: HashMap<OwnedEventId, TimelineSession>,333
- opening: HashMap<OwnedEventId, OpeningThread>,334
- builds: TaskSet<Built>,335
- next_generation: u64,336
- queued: usize,337
- capacity: usize,338
-}339
-340
-impl Threads {341
- fn new(capacity: usize) -> Self {342
- Self {343
- open: HashMap::new(),344
- opening: HashMap::new(),345
- builds: TaskSet::new(),346
- next_generation: 0,347
- queued: 0,348
- capacity,349
- }350
- }351
-352
- fn enqueue(&mut self, root: &OwnedEventId, command: Option<TimelineCommand>, reply: Responder) {353
- if self.queued >= self.capacity {354
- return reply.done(Err(Error::new(355
- ErrorKind::QueueFull,356
- "thread command queue is full",357
- )));358
- }359
- let Some(opening) = self.opening.get_mut(root) else {360
- return reply.done(Err(Error::new(361
- ErrorKind::TimelineNotOpen,362
- "thread timeline is not open",363
- )));364
- };365
- opening.queued.push_back((command, reply));366
- self.queued += 1;367
- }368
-369
- fn open(&mut self, root: OwnedEventId, reply: Responder, room: &Room) {370
- if self.open.contains_key(&root) {371
- return reply.done(Ok(()));372
- }373
- if !self.opening.contains_key(&root) {374
- let generation = self.next_generation;375
- self.next_generation = self.next_generation.wrapping_add(1);376
- let room = room.clone();377
- let build_root = root.clone();378
- let build = self.builds.spawn(async move {379
- let focus = TimelineFocus::Thread {380
- root_event_id: build_root.clone(),381
- };382
- (build_root, generation, build_timeline(&room, focus).await)383
- });384
- self.opening.insert(385
- root.clone(),386
- OpeningThread {387
- generation,388
- build,389
- queued: VecDeque::new(),390
- },391
- );392
- }393
- self.enqueue(&root, None, reply);394
- }395
-396
- fn route(397
- &mut self,398
- root: OwnedEventId,399
- command: TimelineCommand,400
- reply: Responder,401
- ) -> Routed<'_> {402
- if !self.open.contains_key(&root) {403
- self.enqueue(&root, Some(command), reply);404
- return Routed::Queued;405
- }406
- let session = self.open.get_mut(&root).expect("thread was just checked");407
- Routed::Open(session, command, reply)408
- }409
-410
- /// `None` when the build belongs to a thread that was closed while it ran411
- fn built(412
- &mut self,413
- built: Result<Built, JoinError>,414
- ) -> Option<(415
- OwnedEventId,416
- Queued,417
- Result<Timeline, matrix_sdk_ui::timeline::Error>,418
- )> {419
- let (root, generation, timeline) = match built {420
- Ok(built) => built,421
- Err(error) if error.is_cancelled() => return None,422
- Err(error) => resume_unwind(error),423
- };424
- if self425
- .opening426
- .get(&root)427
- .is_none_or(|opening| opening.generation != generation)428
- {429
- return None;430
- }431
- let opening = self.opening.remove(&root)?;432
- self.queued -= opening.queued.len();433
- Some((root, opening.queued, timeline))434
- }435
-436
- async fn close_one(&mut self, root: &OwnedEventId) -> Result<(), Error> {437
- if let Some(session) = self.open.remove(root) {438
- session.close().await;439
- return Ok(());440
- }441
- let Some(opening) = self.opening.remove(root) else {442
- return Err(Error::new(443
- ErrorKind::TimelineNotOpen,444
- "thread timeline is not open",445
- ));446
- };447
- opening.build.abort();448
- self.queued -= opening.queued.len();449
- Ok(())450
- }451
-452
- async fn close(&mut self) {453
- self.builds.shutdown().await;454
- self.opening.clear();455
- for (_, session) in self.open.drain() {456
- session.close().await;457
- }458
- }459
-}@@ -1,132 +0,0 @@
1
-use std::sync::Arc;2
-3
-use matrix_sdk::{BoxFuture, Client, SessionChange};4
-use ruma::OwnedDeviceId;5
-use tokio::sync::{6
- broadcast::{self, error::RecvError},7
- mpsc, watch,8
-};9
-10
-use super::Logouts;11
-use crate::{Error, Event, Termination, transport::EventSender};12
-13
-#[cfg(not(target_family = "wasm"))]14
-pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>> + Send + Sync;15
-#[cfg(target_family = "wasm")]16
-pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>>;17
-18
-/// how the engine keeps the saved record current, handed over by `Session::build`19
-pub(crate) struct Persistence {20
- pub(crate) save: Box<SaveRecord>,21
- /// what the sdk's own save callback made of each refresh, when the store could write22
- /// there and then23
- pub(crate) inline: mpsc::UnboundedReceiver<Result<(), Error>>,24
-}25
-26
-impl Persistence {27
- async fn persist(&mut self, client: &Client) -> Result<(), Error> {28
- let mut inline = None;29
- while let Ok(result) = self.inline.try_recv() {30
- inline = Some(result);31
- }32
- if let Some(Ok(())) = inline {33
- return Ok(());34
- }35
- // an inline save for a newer refresh can land while this one writes, so go again36
- // until what's on disk is what the client holds37
- loop {38
- let tokens = client.session_tokens();39
- (self.save)(client.clone()).await?;40
- if client.session_tokens() == tokens {41
- return Ok(());42
- }43
- }44
- }45
-}46
-47
-pub(super) enum Notice {48
- Event(Event),49
- Rejected(Option<OwnedDeviceId>),50
-}51
-52
-/// saves and notices are split, so a full event stream never holds a save back53
-pub(super) async fn session(54
- client: Client,55
- mut changes: broadcast::Receiver<SessionChange>,56
- mut persistence: Option<Persistence>,57
- notices: mpsc::UnboundedSender<Notice>,58
- device_id: OwnedDeviceId,59
- logouts: Arc<watch::Sender<Logouts>>,60
-) {61
- loop {62
- let event = match changes.recv().await {63
- // a missed refresh costs one needless save, a missed rejection comes round again64
- Ok(SessionChange::TokensRefreshed) | Err(RecvError::Lagged(_)) => {65
- match persistence.as_mut() {66
- None => Event::SessionTokensChanged,67
- Some(persistence) => match persistence.persist(&client).await {68
- Ok(()) => Event::SessionTokensChanged,69
- Err(error) => Event::SessionSaveFailed {70
- message: error.message,71
- },72
- },73
- }74
- }75
- Ok(SessionChange::UnknownToken(data)) => {76
- let soft_logout = data.soft_logout.then_some(device_id);77
- let mut held = false;78
- // our own logout revokes the token, so a rejection meanwhile is its doing79
- logouts.send_if_modified(|logouts| {80
- held = logouts.in_flight > 0;81
- if held {82
- logouts.rejected = Some(soft_logout.clone());83
- }84
- held85
- });86
- if !held {87
- let _ = notices.send(Notice::Rejected(soft_logout));88
- }89
- return;90
- }91
- Err(RecvError::Closed) => return,92
- };93
- if notices.send(Notice::Event(event)).is_err() {94
- return;95
- }96
- }97
-}98
-99
-pub(super) async fn session_notices(100
- mut notices: mpsc::UnboundedReceiver<Notice>,101
- events: EventSender,102
- stop: mpsc::UnboundedSender<Termination>,103
-) {104
- while let Some(notice) = notices.recv().await {105
- match notice {106
- Notice::Event(event) => {107
- if events.send(event).await.is_err() {108
- return;109
- }110
- }111
- Notice::Rejected(soft_logout) => {112
- return reject_session(&events, &stop, soft_logout).await;113
- }114
- }115
- }116
-}117
-118
-pub(super) async fn reject_session(119
- events: &EventSender,120
- stop: &mpsc::UnboundedSender<Termination>,121
- soft_logout: Option<OwnedDeviceId>,122
-) {123
- let _ = events124
- .send(Event::SessionAccessRejected {125
- soft_logout: soft_logout.clone(),126
- })127
- .await;128
- let _ = stop.send(match soft_logout {129
- Some(device_id) => Termination::SoftLoggedOut { device_id },130
- None => Termination::AccessRevoked,131
- });132
-}@@ -1,468 +0,0 @@
1
-use std::{collections::HashSet, future::Future, panic::AssertUnwindSafe, sync::Arc};2
-3
-use futures_util::{FutureExt, StreamExt};4
-use matrix_sdk::SendOutsideWasm;5
-use matrix_sdk::{6
- room::edit::EditedContent,7
- room::reply::{EnforceThread, Reply as SdkReply},8
- ruma::{9
- api::client::receipt::create_receipt::v3::ReceiptType,10
- events::{11
- Mentions,12
- room::message::{13
- AddMentions, Relation, ReplyWithinThread, RoomMessageEventContentWithoutRelation,14
- },15
- },16
- },17
-};18
-use matrix_sdk_ui::timeline::{Timeline, TimelineEventItemId};19
-use ruma::{OwnedEventId, OwnedUserId};20
-use tokio::sync::mpsc;21
-22
-use super::executor::{EngineBoxFuture, JoinHandle, TaskSet, spawn};23
-use crate::{24
- Error, ErrorKind, Event, EventRef, HistoryState, MessageContent, MessageEditContent,25
- MessageMentions, RichText, TimelineCommand, TimelineId,26
- engine::MediaService,27
- normalise::{MediaScope, normalise_timeline, normalise_timeline_diffs},28
- transport::{EventSender, Responder, SendEventError},29
-};30
-31
-const PAGINATION_QUEUE: usize = 32;32
-33
-/// long-lived tasks inside a room actor report panics here so the actor can die loudly34
-#[derive(Clone)]35
-pub(super) struct Supervisor(mpsc::UnboundedSender<&'static str>);36
-37
-impl Supervisor {38
- pub(super) fn new() -> (Self, mpsc::UnboundedReceiver<&'static str>) {39
- let (sender, receiver) = mpsc::unbounded_channel();40
- (Self(sender), receiver)41
- }42
-43
- pub(super) fn spawn(44
- &self,45
- label: &'static str,46
- task: impl Future<Output = ()> + SendOutsideWasm + 'static,47
- ) -> JoinHandle<()> {48
- let panics = self.0.clone();49
- spawn(async move {50
- if AssertUnwindSafe(task).catch_unwind().await.is_err() {51
- let _ = panics.send(label);52
- }53
- })54
- }55
-}56
-57
-/// aborts its task when dropped, so an actor that dies mid-flight takes its helpers with it58
-pub(super) struct TaskGuard(pub(super) JoinHandle<()>);59
-60
-impl Drop for TaskGuard {61
- fn drop(&mut self) {62
- self.0.abort();63
- }64
-}65
-66
-/// what a timeline session needs from the room that owns it67
-#[derive(Clone)]68
-pub(super) struct SessionContext {69
- pub(super) events: EventSender,70
- pub(super) media: Arc<MediaService>,71
- pub(super) current_user_id: Option<OwnedUserId>,72
- pub(super) initial_history_limit: u16,73
- pub(super) supervisor: Supervisor,74
-}75
-76
-pub(super) struct TimelineSession {77
- pub(super) id: TimelineId,78
- timeline: Arc<Timeline>,79
- context: SessionContext,80
- scope: MediaScope,81
- watcher: JoinHandle<()>,82
- pagination: mpsc::Sender<PaginationRequest>,83
- pagination_worker: JoinHandle<()>,84
-}85
-86
-struct PaginationRequest {87
- limit: u16,88
- reply: Option<Responder>,89
-}90
-91
-impl TimelineSession {92
- pub(super) async fn start(93
- id: TimelineId,94
- timeline: Timeline,95
- context: SessionContext,96
- ) -> Result<Self, SendEventError> {97
- let timeline = Arc::new(timeline);98
- let scope = MediaScope::new();99
- let watcher = start_watcher(&id, &timeline, &context, &scope).await?;100
- let (pagination, requests) = mpsc::channel(PAGINATION_QUEUE);101
- let pagination_worker = context.supervisor.spawn(102
- "pagination worker",103
- run_pagination(104
- requests,105
- timeline.clone(),106
- context.events.clone(),107
- id.clone(),108
- ),109
- );110
- let session = Self {111
- id,112
- timeline,113
- scope,114
- watcher,115
- pagination,116
- pagination_worker,117
- context,118
- };119
- if session.context.initial_history_limit > 0 {120
- session.paginate(session.context.initial_history_limit, None);121
- }122
- Ok(session)123
- }124
-125
- /// swaps in a fresh watcher. the old one is dead before the reset goes out,126
- /// so no stale diff can land after it127
- pub(super) async fn resync(&mut self) -> Result<(), Error> {128
- self.watcher.abort();129
- let _ = (&mut self.watcher).await;130
- self.watcher = start_watcher(&self.id, &self.timeline, &self.context, &self.scope)131
- .await132
- .map_err(|_| Error::new(ErrorKind::Timeline, "timeline reset could not be sent"))?;133
- Ok(())134
- }135
-136
- pub(super) fn paginate(&self, limit: u16, reply: Option<Responder>) {137
- if let Err(error) = self.pagination.try_send(PaginationRequest { limit, reply }) {138
- let (request, error) = match error {139
- mpsc::error::TrySendError::Full(request) => (140
- request,141
- Error::new(ErrorKind::QueueFull, "pagination queue is full"),142
- ),143
- mpsc::error::TrySendError::Closed(request) => (144
- request,145
- Error::new(ErrorKind::TimelineNotOpen, "timeline is closing"),146
- ),147
- };148
- if let Some(reply) = request.reply {149
- reply.done(Err(error));150
- }151
- }152
- }153
-154
- /// pending paginations answer `Interrupted` as their responders drop155
- pub(super) async fn close(mut self) {156
- self.pagination_worker.abort();157
- let _ = (&mut self.pagination_worker).await;158
- self.watcher.abort();159
- let _ = (&mut self.watcher).await;160
- // after the watcher, or a last diff could register media for a timeline that's gone161
- self.context.media.release(&self.scope).await;162
- let _ = self163
- .context164
- .events165
- .send(Event::TimelineClosed {166
- timeline_id: self.id.clone(),167
- })168
- .await;169
- }170
-171
- pub(super) async fn execute(172
- &self,173
- command: TimelineCommand,174
- reply: Responder,175
- work: &mut TaskSet<()>,176
- ) {177
- let timeline = self.timeline.clone();178
- let thread_root = self.id.thread_root().cloned();179
- let background: EngineBoxFuture<'static, Result<(), Error>> = match command {180
- TimelineCommand::Paginate { limit, .. } => return self.paginate(limit, Some(reply)),181
- // network-bound, so it runs beside the actor instead of queueing the room behind it182
- TimelineCommand::Redact { target, reason, .. } => Box::pin(async move {183
- timeline184
- .redact(&item_id(target), reason.as_deref())185
- .await186
- .map_err(timeline_error)187
- }),188
- TimelineCommand::ToggleReaction {189
- target,190
- key,191
- shortcode,192
- ..193
- } => Box::pin(async move {194
- timeline195
- .toggle_reaction_with_shortcode(&item_id(target), &key, shortcode.as_deref())196
- .await197
- .map(drop)198
- .map_err(timeline_error)199
- }),200
- TimelineCommand::MarkRead { event_id, .. } => Box::pin(async move {201
- let receipt_failed =202
- |source| Error::from_sdk("sending read receipt failed", source);203
- timeline204
- .send_single_receipt(ReceiptType::Read, event_id.clone())205
- .await206
- .map_err(receipt_failed)?;207
- if thread_root.is_none() {208
- timeline209
- .send_single_receipt(ReceiptType::FullyRead, event_id)210
- .await211
- .map_err(receipt_failed)?;212
- }213
- Ok(())214
- }),215
- command => return reply.done(self.execute_serial(command).await),216
- };217
- work.spawn(async move { reply.done(background.await) });218
- }219
-220
- /// sends stay on the actor so they hit the send queue in the order they were asked for221
- async fn execute_serial(&self, command: TimelineCommand) -> Result<(), Error> {222
- let timeline = &self.timeline;223
- match command {224
- TimelineCommand::Send { content, .. } => {225
- let body = match &content {226
- MessageContent::Text { content, .. }227
- | MessageContent::Emote { content, .. }228
- | MessageContent::Reply { content, .. } => &content.body,229
- };230
- if body.trim().is_empty() {231
- return Err(Error::new(ErrorKind::EmptyBody, "message body is empty"));232
- }233
- let content = match content {234
- MessageContent::Text { content, mentions } => {235
- message_content(content, mentions, false).with_relation(None)236
- }237
- MessageContent::Emote { content, mentions } => {238
- message_content(content, mentions, true).with_relation(None)239
- }240
- MessageContent::Reply {241
- event_id,242
- content,243
- mentions,244
- notify_replied_to,245
- } => {246
- self.reply_content(event_id, content, mentions, notify_replied_to)247
- .await?248
- }249
- };250
- timeline251
- .send(content.into())252
- .await253
- .map(drop)254
- .map_err(timeline_error)255
- }256
- TimelineCommand::Edit {257
- target, content, ..258
- } => {259
- let (content, mentions, emote) = match content {260
- MessageEditContent::Text { content, mentions } => (content, mentions, false),261
- MessageEditContent::Emote { content, mentions } => (content, mentions, true),262
- };263
- if content.body.trim().is_empty() {264
- return Err(Error::new(ErrorKind::EmptyBody, "message body is empty"));265
- }266
- timeline267
- .edit(268
- &item_id(target),269
- EditedContent::RoomMessage(message_content(content, mentions, emote)),270
- )271
- .await272
- .map_err(timeline_error)273
- }274
- _ => Err(Error::new(275
- ErrorKind::Other,276
- "timeline command was misrouted",277
- )),278
- }279
- }280
-281
- async fn reply_content(282
- &self,283
- event_id: OwnedEventId,284
- content: RichText,285
- mentions: MessageMentions,286
- notify_replied_to: bool,287
- ) -> Result<matrix_sdk::ruma::events::room::message::RoomMessageEventContent, Error> {288
- let thread_root = self.id.thread_root();289
- let content = self290
- .timeline291
- .room()292
- .make_reply_event(293
- message_content(content, mentions, false),294
- SdkReply {295
- event_id,296
- enforce_thread: if thread_root.is_some() {297
- EnforceThread::Threaded(ReplyWithinThread::Yes)298
- } else {299
- EnforceThread::MaybeThreaded300
- },301
- add_mentions: if notify_replied_to {302
- AddMentions::Yes303
- } else {304
- AddMentions::No305
- },306
- },307
- )308
- .await309
- .map_err(|source| {310
- Error::with_source(ErrorKind::Timeline, "reply operation failed", source)311
- })?;312
- if let Some(thread_root) = thread_root313
- && !matches!(314
- content.relates_to.as_ref(),315
- Some(Relation::Thread(thread)) if &thread.event_id == thread_root316
- )317
- {318
- return Err(Error::new(319
- ErrorKind::Invalid,320
- "reply target does not belong to the selected thread",321
- ));322
- }323
- Ok(content)324
- }325
-}326
-327
-impl Drop for TimelineSession {328
- fn drop(&mut self) {329
- self.watcher.abort();330
- self.pagination_worker.abort();331
- }332
-}333
-334
-fn item_id(target: EventRef) -> TimelineEventItemId {335
- match target {336
- EventRef::Remote(event_id) => TimelineEventItemId::EventId(event_id),337
- EventRef::Local(transaction_id) => TimelineEventItemId::TransactionId(transaction_id),338
- }339
-}340
-341
-/// the sdk leaves a reply's target unloaded until someone asks, so the watcher asks, once per event342
-struct ReplyFetcher {343
- timeline: Arc<Timeline>,344
- asked: HashSet<OwnedEventId>,345
- fetches: TaskSet<()>,346
-}347
-348
-impl ReplyFetcher {349
- fn fetch(&mut self, missing: Vec<OwnedEventId>) {350
- for event_id in missing {351
- if self.asked.insert(event_id.clone()) {352
- let timeline = self.timeline.clone();353
- self.fetches.spawn(async move {354
- if let Err(error) = timeline.fetch_details_for_event(&event_id).await {355
- tracing::debug!(%event_id, %error, "fetching reply details failed");356
- }357
- });358
- }359
- }360
- }361
-}362
-363
-async fn start_watcher(364
- id: &TimelineId,365
- timeline: &Arc<Timeline>,366
- context: &SessionContext,367
- scope: &MediaScope,368
-) -> Result<JoinHandle<()>, SendEventError> {369
- let (items, mut stream) = timeline.subscribe().await;370
- let current_user_id = context.current_user_id.clone();371
- let normalised = normalise_timeline(items.iter(), current_user_id.as_deref(), scope);372
- context.media.register(normalised.media).await;373
- context374
- .events375
- .send(Event::TimelineReset {376
- timeline_id: id.clone(),377
- items: normalised.items,378
- })379
- .await?;380
- let mut replies = ReplyFetcher {381
- timeline: timeline.clone(),382
- asked: HashSet::new(),383
- fetches: TaskSet::new(),384
- };385
- replies.fetch(normalised.missing_replies);386
- let id = id.clone();387
- let events = context.events.clone();388
- let media = context.media.clone();389
- let scope = scope.clone();390
- Ok(context.supervisor.spawn("timeline watcher", async move {391
- loop {392
- let diffs = tokio::select! {393
- diffs = stream.next() => match diffs {394
- Some(diffs) => diffs,395
- None => return,396
- },397
- Some(_) = replies.fetches.join_next(), if !replies.fetches.is_empty() => continue,398
- };399
- let normalised = normalise_timeline_diffs(diffs, current_user_id.as_deref(), &scope);400
- media.register(normalised.media).await;401
- replies.fetch(normalised.missing_replies);402
- let event = Event::TimelineChanged {403
- timeline_id: id.clone(),404
- diffs: normalised.diffs,405
- };406
- if events.send(event).await.is_err() {407
- return;408
- }409
- }410
- }))411
-}412
-413
-async fn run_pagination(414
- mut requests: mpsc::Receiver<PaginationRequest>,415
- timeline: Arc<Timeline>,416
- events: EventSender,417
- timeline_id: TimelineId,418
-) {419
- while let Some(PaginationRequest { limit, reply }) = requests.recv().await {420
- let history = |state| Event::HistoryChanged {421
- timeline_id: timeline_id.clone(),422
- state,423
- };424
- if events.send(history(HistoryState::Loading)).await.is_err() {425
- return;426
- }427
- let result = timeline428
- .paginate_backwards(limit.max(1))429
- .await430
- .map_err(timeline_error);431
- let state = match &result {432
- Ok(true) => HistoryState::StartReached,433
- Ok(false) => HistoryState::Idle,434
- Err(error) => HistoryState::Failed(error.to_string()),435
- };436
- let _ = events.send(history(state)).await;437
- if let Some(reply) = reply {438
- reply.done(result.map(drop));439
- }440
- }441
-}442
-443
-fn message_content(444
- content: RichText,445
- mentions: MessageMentions,446
- emote: bool,447
-) -> RoomMessageEventContentWithoutRelation {448
- let content = match content.formatted_body {449
- Some(formatted_body) if emote => {450
- RoomMessageEventContentWithoutRelation::emote_html(content.body, formatted_body)451
- }452
- Some(formatted_body) => {453
- RoomMessageEventContentWithoutRelation::text_html(content.body, formatted_body)454
- }455
- None if emote => RoomMessageEventContentWithoutRelation::emote_plain(content.body),456
- None => RoomMessageEventContentWithoutRelation::text_plain(content.body),457
- };458
- let content = content.add_mentions(Mentions::with_user_ids(mentions.user_ids));459
- if mentions.room {460
- content.add_mentions(Mentions::with_room_mention())461
- } else {462
- content463
- }464
-}465
-466
-pub(super) fn timeline_error(source: matrix_sdk_ui::timeline::Error) -> Error {467
- Error::with_source(ErrorKind::Timeline, "timeline operation failed", source)468
-}@@ -1,166 +0,0 @@
1
-use std::{2
- collections::{HashMap, VecDeque},3
- sync::{4
- Arc,5
- atomic::{AtomicBool, Ordering},6
- },7
- time::Duration,8
-};9
-10
-use futures_util::{FutureExt, future::WeakShared};11
-use matrix_sdk::{12
- Client, HttpError,13
- ruma::api::{14
- Metadata,15
- client::{authenticated_media, media},16
- },17
-};18
-use ruma::{19
- api::error::ErrorKind as ApiErrorKind,20
- serde::{JsonObject, Raw},21
- time::Instant,22
-};23
-use tokio::sync::Mutex;24
-25
-use super::executor::EngineBoxFuture;26
-use crate::{Error, LinkPreview, normalise::server_preview};27
-28
-const HIT_FOR: Duration = Duration::from_secs(60 * 60);29
-const MISS_FOR: Duration = Duration::from_secs(10 * 60);30
-const REMEMBERED: usize = 512;31
-32
-type Lookup = EngineBoxFuture<'static, Result<Option<LinkPreview>, Error>>;33
-34
-/// the homeserver rate limits `preview_url` and a busy timeline repeats links, so answers are35
-/// kept a while, misses too, and asks for one url at once share one request36
-pub(crate) struct UrlPreviews(Arc<Inner>);37
-38
-struct Inner {39
- client: Client,40
- /// a server that doesn't preview at all says so once and isn't asked again41
- unsupported: AtomicBool,42
- cache: Mutex<Cache>,43
- lookups: Mutex<HashMap<String, WeakShared<Lookup>>>,44
-}45
-46
-#[derive(Default)]47
-struct Cache {48
- entries: HashMap<String, (Instant, Option<LinkPreview>)>,49
- order: VecDeque<String>,50
-}51
-52
-impl UrlPreviews {53
- pub(crate) fn new(client: Client) -> Self {54
- Self(Arc::new(Inner {55
- client,56
- unsupported: AtomicBool::new(false),57
- cache: Mutex::new(Cache::default()),58
- lookups: Mutex::new(HashMap::new()),59
- }))60
- }61
-62
- pub(crate) async fn preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {63
- if self.0.unsupported.load(Ordering::Relaxed) {64
- return Ok(None);65
- }66
- if let Some(preview) = self.0.cache.lock().await.get(url) {67
- return Ok(preview);68
- }69
- let lookup = {70
- let mut lookups = self.0.lookups.lock().await;71
- lookups.retain(|_, lookup| lookup.upgrade().is_some());72
- match lookups.get(url).and_then(WeakShared::upgrade) {73
- Some(lookup) => lookup,74
- None => {75
- let inner = self.0.clone();76
- let owned = url.to_owned();77
- let lookup: Lookup = Box::pin(async move { inner.look_up(owned).await });78
- let lookup = lookup.shared();79
- if let Some(weak) = lookup.downgrade() {80
- lookups.insert(url.to_owned(), weak);81
- }82
- lookup83
- }84
- }85
- };86
- lookup.await87
- }88
-}89
-90
-impl Inner {91
- async fn look_up(&self, url: String) -> Result<Option<LinkPreview>, Error> {92
- let data = match self.fetch(&url).await {93
- Ok(data) => data,94
- Err(error) => {95
- let Some(api) = error.as_client_api_error() else {96
- return Err(Error::from_sdk(97
- "asking for a url preview failed",98
- error.into(),99
- ));100
- };101
- let unrecognised = matches!(api.error_kind(), Some(ApiErrorKind::Unrecognized))102
- || api.status_code.as_u16() == 405;103
- if unrecognised {104
- self.unsupported.store(true, Ordering::Relaxed);105
- return Ok(None);106
- }107
- // synapse answers 500 for a page it can't download, 502 when dns fails and 403 for108
- // its blocklist (synapse/media/url_previewer.py). a real fault hides in the 500s too,109
- // but a missing card is cheap and only cached ten minutes110
- if !matches!(api.status_code.as_u16(), 400 | 403 | 404 | 500 | 502) {111
- return Err(Error::from_sdk(112
- "asking for a url preview failed",113
- error.into(),114
- ));115
- }116
- None117
- }118
- };119
- let preview = data.and_then(|data| server_preview(&url, &data));120
- self.cache.lock().await.insert(url, preview.clone());121
- Ok(preview)122
- }123
-124
- async fn fetch(&self, url: &str) -> Result<Option<Raw<JsonObject>>, HttpError> {125
- let versions = self.client.supported_versions().await?;126
- let data = if authenticated_media::get_media_preview::v1::Request::PATH_BUILDER127
- .is_supported(&versions)128
- {129
- self.client130
- .send(authenticated_media::get_media_preview::v1::Request::new(131
- url.to_owned(),132
- ))133
- .await?134
- .data135
- } else {136
- #[allow(deprecated)]137
- let request = media::get_media_preview::v3::Request::new(url.to_owned());138
- self.client.send(request).await?.data139
- };140
- Ok(data.map(Raw::from_json))141
- }142
-}143
-144
-impl Cache {145
- fn get(&mut self, url: &str) -> Option<Option<LinkPreview>> {146
- let (at, preview) = self.entries.get(url)?;147
- let lasts = if preview.is_some() { HIT_FOR } else { MISS_FOR };148
- (at.elapsed() < lasts).then(|| preview.clone())149
- }150
-151
- fn insert(&mut self, url: String, preview: Option<LinkPreview>) {152
- if self153
- .entries154
- .insert(url.clone(), (Instant::now(), preview))155
- .is_none()156
- {157
- self.order.push_back(url);158
- }159
- while self.entries.len() > REMEMBERED {160
- let Some(oldest) = self.order.pop_front() else {161
- break;162
- };163
- self.entries.remove(&oldest);164
- }165
- }166
-}@@ -1,565 +0,0 @@
1
-use std::{2
- collections::{HashSet, VecDeque},3
- sync::Arc,4
- time::Duration,5
-};6
-7
-use futures_util::StreamExt;8
-use matrix_sdk::{9
- Client,10
- ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},11
- send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},12
-};13
-use matrix_sdk_base::RoomInfoNotableUpdateReasons;14
-use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};15
-use ruma::{16
- OwnedRoomId, OwnedTransactionId,17
- events::{AnyGlobalAccountDataEvent, AnySyncStateEvent},18
- serde::Raw,19
- time::Instant,20
-};21
-use tokio::sync::{Notify, broadcast::error::RecvError, watch};22
-23
-use super::executor::TaskSet;24
-use crate::{25
- ConnectionState, Event, UploadOutcome,26
- encryption::encryption_state,27
- error::classify_sdk_error,28
- normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK},29
- transport::EventSender,30
-};31
-32
-const SYNC_RESTART_MIN: Duration = Duration::from_secs(1);33
-const SYNC_RESTART_MAX: Duration = Duration::from_secs(60);34
-const SEND_QUEUE_RETRY: Duration = Duration::from_secs(5);35
-const SYNC_STABLE: Duration = Duration::from_secs(30);36
-37
-pub(super) async fn room_subscriptions(38
- sync: Arc<SyncService>,39
- mut rooms: watch::Receiver<Vec<ruma::OwnedRoomId>>,40
-) {41
- while rooms.changed().await.is_ok() {42
- let room_ids = rooms.borrow_and_update().clone();43
- let room_ids = room_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>();44
- sync.room_list_service().subscribe_to_rooms(&room_ids).await;45
- }46
-}47
-48
-/// `states` must be taken before sync starts, a late subscriber never hears the transition it missed49
-pub(super) async fn connection(50
- mut states: impl futures_util::Stream<Item = SyncState> + Unpin,51
- sync: Arc<SyncService>,52
- client: Client,53
- events: EventSender,54
- connection: watch::Sender<ConnectionState>,55
-) {56
- let mut backoff = SYNC_RESTART_MIN;57
- let mut online_since: Option<Instant> = None;58
- while let Some(state) = states.next().await {59
- // only a run that lasted counts as recovered, or a server that answers /versions60
- // but refuses sync would have us bounce between offline and online forever61
- let stable = online_since62
- .take()63
- .map(|since| since.elapsed() >= SYNC_STABLE);64
- if stable == Some(true) {65
- backoff = SYNC_RESTART_MIN;66
- }67
- let (state, restart) = match state {68
- // only ever us stopping it for a restart, nothing a client needs to hear69
- SyncState::Idle => continue,70
- SyncState::Running => {71
- online_since = Some(Instant::now());72
- // the sdk parks a room queue on any send error and never unparks it73
- client.send_queue().set_enabled(true).await;74
- (ConnectionState::Online, false)75
- }76
- SyncState::Offline => (ConnectionState::Offline, stable == Some(false)),77
- SyncState::Terminated => (ConnectionState::Offline, true),78
- SyncState::Error(error) => {79
- tracing::warn!(%error, "sync service reported an error");80
- (ConnectionState::Offline, true)81
- }82
- };83
- if *connection.borrow() != state {84
- connection.send_replace(state);85
- if events.send(Event::ConnectionChanged(state)).await.is_err() {86
- return;87
- }88
- }89
- if restart {90
- sync.stop().await;91
- matrix_sdk::sleep::sleep(backoff).await;92
- backoff = (backoff * 2).min(SYNC_RESTART_MAX);93
- sync.start().await;94
- }95
- }96
-}97
-98
-/// a recoverable failure while we're online is a hiccup, so give the room another go99
-pub(super) async fn send_queue_errors(100
- client: Client,101
- mut errors: tokio::sync::broadcast::Receiver<matrix_sdk::send_queue::SendQueueRoomError>,102
- online: watch::Receiver<ConnectionState>,103
-) {104
- let mut retries = TaskSet::new();105
- loop {106
- let error = tokio::select! {107
- error = errors.recv() => error,108
- Some(_) = retries.join_next(), if !retries.is_empty() => continue,109
- };110
- match error {111
- Ok(error) if error.is_recoverable => {112
- let client = client.clone();113
- let online = online.clone();114
- retries.spawn(async move {115
- matrix_sdk::sleep::sleep(SEND_QUEUE_RETRY).await;116
- if *online.borrow() == ConnectionState::Online117
- && let Some(room) = client.get_room(&error.room_id)118
- {119
- room.send_queue().set_enabled(true);120
- }121
- });122
- }123
- Ok(_) | Err(RecvError::Lagged(_)) => {}124
- Err(RecvError::Closed) => return,125
- }126
- }127
-}128
-129
-/// the one place upload events leave from, so an upload is never reported after it130
-/// ended. the client-wide channel exists before any upload does. it holds 32 updates and131
-/// progress ticks share it, so this keeps draining while the event stream is full132
-pub(super) async fn uploads(133
- client: Client,134
- mut updates: tokio::sync::broadcast::Receiver<SendQueueUpdate>,135
- events: EventSender,136
-) {137
- let (_, mut rooms) = client.rooms_stream();138
- let mut tracker = UploadTracker::new(attachment_echoes(&client).await);139
- let mut pending = VecDeque::new();140
- loop {141
- let reports: Vec<Event> = tokio::select! {142
- biased;143
- update = updates.recv() => match update {144
- Ok(update) => tracker.update(update).into_iter().collect(),145
- Err(RecvError::Lagged(skipped)) => {146
- tracing::warn!(skipped, "upload tracker lagged");147
- tracker.resnapshot(attachment_echoes(&client).await)148
- }149
- Err(RecvError::Closed) => return,150
- },151
- update = rooms.next() => {152
- if update.is_none() {153
- return;154
- }155
- tracker.finish_where(156
- |(room_id, _)| client.get_room(room_id).is_none(),157
- &UploadOutcome::RoomForgotten,158
- )159
- }160
- permit = events.reserve(), if !pending.is_empty() => {161
- let Ok(permit) = permit else { return };162
- permit.send(pending.pop_front().expect("pending was checked"));163
- continue;164
- }165
- };166
- for event in reports {167
- queue_upload_event(&mut pending, event);168
- }169
- }170
-}171
-172
-/// a progress tick replaces a waiting one for the same upload, unless something else about173
-/// that upload is queued after it174
-fn queue_upload_event(pending: &mut VecDeque<Event>, event: Event) {175
- if matches!(event, Event::MediaUploadProgress { .. })176
- && let Some(waiting) = pending177
- .iter_mut()178
- .rev()179
- .find(|waiting| upload_of(waiting) == upload_of(&event))180
- && matches!(waiting, Event::MediaUploadProgress { .. })181
- {182
- *waiting = event;183
- return;184
- }185
- pending.push_back(event);186
-}187
-188
-fn upload_of(event: &Event) -> Option<(&OwnedRoomId, &OwnedTransactionId)> {189
- match event {190
- Event::MediaUploadProgress {191
- room_id,192
- transaction_id,193
- ..194
- }195
- | Event::MediaUploadFailed {196
- room_id,197
- transaction_id,198
- ..199
- }200
- | Event::MediaUploadFinished {201
- room_id,202
- transaction_id,203
- ..204
- } => Some((room_id, transaction_id)),205
- _ => None,206
- }207
-}208
-209
-type Upload = (OwnedRoomId, OwnedTransactionId);210
-211
-/// the sdk sends progress ticks from a detached task, so a tick can land after its212
-/// upload's end. it trails by one task hop, so a short memory of ends covers it213
-const REMEMBERED_ENDS: usize = 256;214
-215
-/// everything reported gets exactly one `MediaUploadFinished` and nothing after it216
-struct UploadTracker {217
- live: HashSet<Upload>,218
- ended: HashSet<Upload>,219
- ended_order: VecDeque<Upload>,220
-}221
-222
-impl UploadTracker {223
- fn new(live: HashSet<Upload>) -> Self {224
- Self {225
- live,226
- ended: HashSet::new(),227
- ended_order: VecDeque::new(),228
- }229
- }230
-231
- fn update(&mut self, update: SendQueueUpdate) -> Option<Event> {232
- let room_id = update.room_id;233
- let (transaction_id, outcome) = match update.update {234
- RoomSendQueueUpdate::NewLocalEvent(echo) => {235
- let upload = (room_id, echo.transaction_ishow full diff (262144 bytes truncated)