From 44b744609d67da12a3ecb0046d60904a7a46abff Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 27 Jul 2026 22:08:16 +0200 Subject: [PATCH 1/9] feat(call): vendor native lifecycle plugin --- package.json | 1 + pnpm-lock.yaml | 10 + src-tauri/Cargo.lock | 669 +++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/desktop.json | 3 +- .../tauri-plugin-call-lifecycle/.gitignore | 20 + .../tauri-plugin-call-lifecycle/Cargo.toml | 23 + .../tauri-plugin-call-lifecycle/build.rs | 8 + .../dist-js/index.cjs | 17 + .../dist-js/index.d.ts | 23 + .../dist-js/index.js | 13 + .../guest-js/index.ts | 43 ++ .../tauri-plugin-call-lifecycle/package.json | 30 + .../autogenerated/commands/connect.toml | 13 + .../autogenerated/commands/disconnect.toml | 13 + .../autogenerated/commands/get_state.toml | 13 + .../permissions/autogenerated/reference.md | 97 +++ .../permissions/default.toml | 3 + .../permissions/schemas/schema.json | 342 +++++++++ .../rollup.config.js | 32 + .../tauri-plugin-call-lifecycle/src/actor.rs | 634 +++++++++++++++++ .../src/commands.rs | 26 + .../tauri-plugin-call-lifecycle/src/error.rs | 51 ++ .../tauri-plugin-call-lifecycle/src/lib.rs | 42 ++ .../tauri-plugin-call-lifecycle/src/models.rs | 43 ++ .../tauri-plugin-call-lifecycle/tsconfig.json | 14 + src-tauri/src/lib.rs | 3 +- src/app/cs-api.ts | 23 +- .../features/call/livekitProvisioning.test.ts | 142 ++++ src/app/features/call/livekitProvisioning.ts | 135 ++++ src/app/plugins/call/callLifecycle.test.ts | 100 +++ src/app/plugins/call/callLifecycle.ts | 28 + src/app/plugins/call/index.ts | 1 + src/types/matrix-sdk.ts | 6 + 34 files changed, 2588 insertions(+), 34 deletions(-) create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/.gitignore create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/Cargo.toml create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/build.rs create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.cjs create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.d.ts create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.js create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/guest-js/index.ts create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/package.json create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/connect.toml create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/disconnect.toml create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/get_state.toml create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/reference.md create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/default.toml create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/schemas/schema.json create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/rollup.config.js create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/src/actor.rs create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/src/commands.rs create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/src/error.rs create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/src/lib.rs create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/src/models.rs create mode 100644 src-tauri/plugins/tauri-plugin-call-lifecycle/tsconfig.json create mode 100644 src/app/features/call/livekitProvisioning.test.ts create mode 100644 src/app/features/call/livekitProvisioning.ts create mode 100644 src/app/plugins/call/callLifecycle.test.ts create mode 100644 src/app/plugins/call/callLifecycle.ts diff --git a/package.json b/package.json index e40a6aefea..1a5b730044 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "slate-history": "^0.113.1", "slate-react": "^0.125.1", "tauri-plugin-android-fs-api": "28.4.0", + "tauri-plugin-call-lifecycle-api": "file:src-tauri/plugins/tauri-plugin-call-lifecycle", "ua-parser-js": "^2.0.10", "virtua": "^0.49.2", "workbox-precaching": "^7.4.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73d8ac5e0a..2183f02c6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,6 +253,9 @@ importers: tauri-plugin-android-fs-api: specifier: 28.4.0 version: 28.4.0 + tauri-plugin-call-lifecycle-api: + specifier: file:src-tauri/plugins/tauri-plugin-call-lifecycle + version: file:src-tauri/plugins/tauri-plugin-call-lifecycle ua-parser-js: specifier: ^2.0.10 version: 2.0.10 @@ -5338,6 +5341,9 @@ packages: tauri-plugin-android-fs-api@28.4.0: resolution: {integrity: sha512-YOo1P+cRjuaoPbTNZprMhr56ruKO4hIDq7kJOum91NqkN+VWY9F+UPAdjZokhzJSsbkeY0nsb1VDLXb2/h3f3w==} + tauri-plugin-call-lifecycle-api@file:src-tauri/plugins/tauri-plugin-call-lifecycle: + resolution: {directory: src-tauri/plugins/tauri-plugin-call-lifecycle, type: directory} + teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -10770,6 +10776,10 @@ snapshots: '@tauri-apps/api': 2.11.1 create-web-stream: 1.1.3 + tauri-plugin-call-lifecycle-api@file:src-tauri/plugins/tauri-plugin-call-lifecycle: + dependencies: + '@tauri-apps/api': 2.11.1 + teex@1.0.1: dependencies: streamx: 2.28.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d2ac364e14..f1d1bd48a5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -168,7 +168,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -179,7 +179,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -563,6 +563,16 @@ dependencies = [ "piper", ] +[[package]] +name = "bmrng" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54df9073108f1558f90ae6c5bf5ab9c917c4185f5527b280c87a993cbead0ac" +dependencies = [ + "futures-core", + "tokio", +] + [[package]] name = "borsh" version = "1.8.0" @@ -930,7 +940,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -998,6 +1008,17 @@ dependencies = [ "objc", ] +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1011,7 +1032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1325,14 +1346,100 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "cxx" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash 0.2.0", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2" +dependencies = [ + "cc", + "codespan-reporting", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "scratch", + "syn 3.0.3", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d" +dependencies = [ + "clap", + "codespan-reporting", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c" +dependencies = [ + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", ] [[package]] @@ -1344,21 +1451,38 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "data-url" version = "0.3.2" @@ -1477,6 +1601,21 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +[[package]] +name = "device-info" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ca8e71544c1b67dcdbc2699ab258828aff985e5bc8d5f6b486d90d7df2f848" +dependencies = [ + "core-foundation 0.10.1", + "jni 0.21.1", + "libc", + "thiserror 2.0.19", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "devtools-core" version = "0.3.6" @@ -1527,6 +1666,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1553,7 +1693,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1855,7 +1995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1953,6 +2093,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2024,6 +2170,27 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "from_variants" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e859c8f2057687618905dbe99fc76e836e0a69738865ef90e46fc214a41bbf2" +dependencies = [ + "from_variants_impl", +] + +[[package]] +name = "from_variants_impl" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a5e644a80e6d96b2b4910fa7993301d7b7926c045b475b62202b20a36ce69e" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "fs-err" version = "3.3.1" @@ -2033,6 +2200,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futf" version = "0.1.5" @@ -2618,6 +2795,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "hostname" version = "0.4.2" @@ -2788,6 +2974,7 @@ dependencies = [ "hyper 1.11.0", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -3108,6 +3295,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -3299,6 +3495,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature", + "zeroize", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -3431,6 +3642,39 @@ dependencies = [ "redox_syscall 0.9.0", ] +[[package]] +name = "libwebrtc" +version = "0.3.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f5497ff0694ddcee8f88129c78139defa5ec3fc434c3e182906ed0a72372bd9" +dependencies = [ + "cxx", + "jni 0.21.1", + "js-sys", + "lazy_static", + "livekit-runtime", + "log", + "parking_lot", + "rtrb", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webrtc-sys", +] + +[[package]] +name = "link-cplusplus" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +dependencies = [ + "cc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3449,6 +3693,145 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "livekit" +version = "0.7.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8ed793e154d63b588397ea7a84cfb3606b6d049f4d24137d9d9e0384617059" +dependencies = [ + "base64 0.22.1", + "bmrng", + "bytes", + "chrono", + "futures-util", + "lazy_static", + "libloading 0.8.9", + "libwebrtc", + "livekit-api", + "livekit-common", + "livekit-data-stream", + "livekit-datatrack", + "livekit-protocol", + "livekit-runtime", + "log", + "parking_lot", + "prost", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", +] + +[[package]] +name = "livekit-api" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e827d3444235ddccf360fc1d4737034e86ee2ea2c0a696f148ecc08e7249bfd3" +dependencies = [ + "base64 0.21.7", + "bytes", + "device-info", + "flate2", + "futures-util", + "hmac", + "http 1.4.2", + "jsonwebtoken", + "livekit-common", + "livekit-protocol", + "livekit-runtime", + "log", + "os_info", + "parking_lot", + "pbjson-types", + "prost", + "rand 0.9.5", + "reqwest 0.12.28", + "scopeguard", + "serde", + "serde_json", + "sha2", + "signature", + "thiserror 2.0.19", + "tokio", + "tokio-tungstenite", + "url", +] + +[[package]] +name = "livekit-common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a76cd816c062654d105b697ebab136da87d14126ee77b2f13280cb895bcb58" +dependencies = [ + "livekit-protocol", +] + +[[package]] +name = "livekit-data-stream" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ad1cca8f05fbe29026d99931884641199df544c33777d1f1b321df6f1031dd" +dependencies = [ + "bmrng", + "bytes", + "chrono", + "flate2", + "futures-util", + "livekit-common", + "livekit-protocol", + "log", + "parking_lot", + "prost", + "thiserror 2.0.19", + "tokio", + "uuid", +] + +[[package]] +name = "livekit-datatrack" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201bec4becf4db2b0af616a73ff3b2421f6af3693c287b82f5182028ee36e5f5" +dependencies = [ + "anyhow", + "bytes", + "from_variants", + "futures-core", + "futures-util", + "indexmap 2.14.0", + "livekit-protocol", + "livekit-runtime", + "log", + "rand 0.9.5", + "thiserror 2.0.19", + "tokio", + "tokio-stream", +] + +[[package]] +name = "livekit-protocol" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d26880e94e2f9bab298445e7d86a3794453d211a12ddbd051bd9991a343f9ff" +dependencies = [ + "pbjson", + "pbjson-types", + "prost", + "serde", +] + +[[package]] +name = "livekit-runtime" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "532e84c6cdc5fe774f2b5d9912597b5f3bea561927a48296d03e24549d21c3f6" +dependencies = [ + "tokio", + "tokio-stream", +] + [[package]] name = "local-ip-address" version = "0.5.7" @@ -3653,9 +4036,15 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.19", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "ndk" version = "0.9.0" @@ -3777,7 +4166,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4181,7 +4570,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4270,6 +4659,43 @@ dependencies = [ "regex", ] +[[package]] +name = "pbjson" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1030c719b0ec2a2d25a5df729d6cff1acf3cc230bf766f4f97833591f7577b90" +dependencies = [ + "base64 0.21.7", + "serde", +] + +[[package]] +name = "pbjson-build" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2580e33f2292d34be285c5bc3dba5259542b083cfad6037b6d70345f24dcb735" +dependencies = [ + "heck 0.4.1", + "itertools 0.11.0", + "prost", + "prost-types", +] + +[[package]] +name = "pbjson-types" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f596653ba4ac51bdecbb4ef6773bc7f56042dc13927910de1684ad3d32aa12" +dependencies = [ + "bytes", + "chrono", + "pbjson", + "pbjson-build", + "prost", + "prost-build", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4318,13 +4744,23 @@ dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset 0.4.2", + "indexmap 2.14.0", +] + [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.15.5", "indexmap 2.14.0", ] @@ -4649,6 +5085,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4727,6 +5173,27 @@ dependencies = [ "prost-derive", ] +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck 0.5.0", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph 0.6.5", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", +] + [[package]] name = "prost-derive" version = "0.12.6" @@ -4734,7 +5201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.119", @@ -4839,7 +5306,7 @@ dependencies = [ "once_cell", "socket2 0.6.5", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5094,6 +5561,7 @@ dependencies = [ "cookie", "cookie_store", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.15", @@ -5110,6 +5578,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -5217,6 +5686,12 @@ dependencies = [ "portable-atomic-util", ] +[[package]] +name = "rtrb" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" + [[package]] name = "rust-ini" version = "0.21.3" @@ -5258,7 +5733,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5316,7 +5791,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5376,6 +5851,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-android-fs", + "tauri-plugin-call-lifecycle", "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", "tauri-plugin-devtools", @@ -5485,6 +5961,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + [[package]] name = "sctk-adwaita" version = "0.11.1" @@ -5498,6 +5980,16 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5812,7 +6304,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.119", @@ -5859,6 +6351,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -5901,6 +6404,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6011,7 +6523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6134,6 +6646,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -6535,6 +7053,19 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "tauri-plugin-call-lifecycle" +version = "0.1.0" +dependencies = [ + "livekit", + "secrecy", + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "tokio", +] + [[package]] name = "tauri-plugin-clipboard-manager" version = "2.3.2" @@ -6871,7 +7402,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -7069,7 +7600,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7334,6 +7865,19 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", ] [[package]] @@ -7694,7 +8238,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.19", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7705,7 +8249,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", "nom", - "petgraph", + "petgraph 0.8.3", ] [[package]] @@ -7742,6 +8286,23 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.2", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.19", + "url", +] + [[package]] name = "typeid" version = "1.0.3" @@ -7768,7 +8329,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8334,6 +8895,36 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webrtc-sys" +version = "0.3.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a555de886b8d4961e6c2878ac3ca1f716b75c77f60311c5cf6ed136d0df14e1" +dependencies = [ + "cc", + "cxx", + "cxx-build", + "glob", + "log", + "pkg-config", + "webrtc-sys-build", +] + +[[package]] +name = "webrtc-sys-build" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d46da6b5a5cbd091fae0400f77189f4ca4807c0d9442b85838a584f28720570" +dependencies = [ + "anyhow", + "fs2", + "regex", + "reqwest 0.12.28", + "scratch", + "semver", + "zip 0.6.6", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -8398,7 +8989,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -9441,6 +10032,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -9475,6 +10080,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zip" version = "4.6.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f7868c7103..2f7c179362 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -103,6 +103,7 @@ tauri-plugin-window-state = "2.4.1" tauri-plugin-global-shortcut = "2" regex = "1" zip = { version = "=4.6.1", default-features = false, features = ["deflate-flate2-zlib-rs"] } +tauri-plugin-call-lifecycle = { path = "plugins/tauri-plugin-call-lifecycle" } [target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies] gtk = "0.18" diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index f42f6888ea..f1f0662a5f 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -9,6 +9,7 @@ "store:default", "notifications:default", "process:default", - "dialog:default" + "dialog:default", + "call-lifecycle:default" ] } diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/.gitignore b/src-tauri/plugins/tauri-plugin-call-lifecycle/.gitignore new file mode 100644 index 0000000000..f23de25d29 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/.gitignore @@ -0,0 +1,20 @@ +/.vs +.DS_Store +.Thumbs.db +*.sublime* +.idea/ +debug.log +package-lock.json +.vscode/settings.json +yarn.lock + +/.tauri +/target +Cargo.lock +node_modules/ + +dist + +# The guest package is consumed directly by Sable's local file dependency. +!dist-js/ +!dist-js/** diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/Cargo.toml b/src-tauri/plugins/tauri-plugin-call-lifecycle/Cargo.toml new file mode 100644 index 0000000000..fe3358b727 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "tauri-plugin-call-lifecycle" +version = "0.1.0" +authors = [ "Sable Contributors" ] +description = "" +edition = "2021" +rust-version = "1.77.2" +exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"] +links = "tauri-plugin-call-lifecycle" + +[dependencies] +tauri = { version = "2.11.3" } +livekit = "0.7" +secrecy = { version = "0.10", features = ["serde"] } +serde = "1.0" +thiserror = "2" +tokio = { version = "1", features = ["macros", "rt", "sync"] } + +[build-dependencies] +tauri-plugin = { version = "2.6.3", features = ["build"] } + +[dev-dependencies] +serde_json = "1.0" diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/build.rs b/src-tauri/plugins/tauri-plugin-call-lifecycle/build.rs new file mode 100644 index 0000000000..2b5e2b3805 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/build.rs @@ -0,0 +1,8 @@ +const COMMANDS: &[&str] = &["connect", "disconnect", "get_state"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .android_path("android") + .ios_path("ios") + .build(); +} diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.cjs b/src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.cjs new file mode 100644 index 0000000000..6828d9846b --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.cjs @@ -0,0 +1,17 @@ +'use strict'; + +var core = require('@tauri-apps/api/core'); + +async function connect(request) { + return await core.invoke('plugin:call-lifecycle|connect', { payload: request }); +} +async function disconnect(request) { + return await core.invoke('plugin:call-lifecycle|disconnect', { payload: request }); +} +async function getState() { + return await core.invoke('plugin:call-lifecycle|get_state'); +} + +exports.connect = connect; +exports.disconnect = disconnect; +exports.getState = getState; diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.d.ts b/src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.d.ts new file mode 100644 index 0000000000..c2db410702 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.d.ts @@ -0,0 +1,23 @@ +export interface ConnectRequest { + connectionId: string; + serverUrl: string; + participantToken: string; +} +export interface DisconnectRequest { + connectionId: string; +} +export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnecting'; +export interface CallState { + revision: number; + state: ConnectionState; + connectionId: string | null; +} +export interface CallLifecycleError { + revision: number; + code: string; + message: string; + connectionId: string | null; +} +export declare function connect(request: ConnectRequest): Promise; +export declare function disconnect(request: DisconnectRequest): Promise; +export declare function getState(): Promise; diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.js b/src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.js new file mode 100644 index 0000000000..109d467e1d --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/dist-js/index.js @@ -0,0 +1,13 @@ +import { invoke } from '@tauri-apps/api/core'; + +async function connect(request) { + return await invoke('plugin:call-lifecycle|connect', { payload: request }); +} +async function disconnect(request) { + return await invoke('plugin:call-lifecycle|disconnect', { payload: request }); +} +async function getState() { + return await invoke('plugin:call-lifecycle|get_state'); +} + +export { connect, disconnect, getState }; diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/guest-js/index.ts b/src-tauri/plugins/tauri-plugin-call-lifecycle/guest-js/index.ts new file mode 100644 index 0000000000..f8dd0fdbd5 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/guest-js/index.ts @@ -0,0 +1,43 @@ +import { invoke } from '@tauri-apps/api/core' + +export interface ConnectRequest { + connectionId: string + serverUrl: string + participantToken: string +} + +export interface DisconnectRequest { + connectionId: string +} + +export type ConnectionState = + | 'idle' + | 'connecting' + | 'connected' + | 'reconnecting' + | 'disconnecting' + +export interface CallState { + revision: number + state: ConnectionState + connectionId: string | null +} + +export interface CallLifecycleError { + revision: number + code: string + message: string + connectionId: string | null +} + +export async function connect(request: ConnectRequest): Promise { + return await invoke('plugin:call-lifecycle|connect', { payload: request }) +} + +export async function disconnect(request: DisconnectRequest): Promise { + return await invoke('plugin:call-lifecycle|disconnect', { payload: request }) +} + +export async function getState(): Promise { + return await invoke('plugin:call-lifecycle|get_state') +} diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/package.json b/src-tauri/plugins/tauri-plugin-call-lifecycle/package.json new file mode 100644 index 0000000000..050b253ded --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/package.json @@ -0,0 +1,30 @@ +{ + "name": "tauri-plugin-call-lifecycle-api", + "version": "0.1.0", + "author": "Sable Contributors", + "description": "", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "files": ["dist-js"], + "scripts": { + "build": "rollup -c", + "prepublishOnly": "pnpm build", + "pretest": "pnpm build" + }, + "dependencies": { + "@tauri-apps/api": "^2.0.0" + }, + "devDependencies": { + "@rollup/plugin-typescript": "^12.0.0", + "rollup": "^4.9.6", + "typescript": "^6.0.0", + "tslib": "^2.6.2" + } +} diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/connect.toml b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/connect.toml new file mode 100644 index 0000000000..49ce9ad3f5 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/connect.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-connect" +description = "Enables the connect command without any pre-configured scope." +commands.allow = ["connect"] + +[[permission]] +identifier = "deny-connect" +description = "Denies the connect command without any pre-configured scope." +commands.deny = ["connect"] diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/disconnect.toml b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/disconnect.toml new file mode 100644 index 0000000000..8f5a99868d --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/disconnect.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-disconnect" +description = "Enables the disconnect command without any pre-configured scope." +commands.allow = ["disconnect"] + +[[permission]] +identifier = "deny-disconnect" +description = "Denies the disconnect command without any pre-configured scope." +commands.deny = ["disconnect"] diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/get_state.toml b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/get_state.toml new file mode 100644 index 0000000000..344da274a1 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/commands/get_state.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-state" +description = "Enables the get_state command without any pre-configured scope." +commands.allow = ["get_state"] + +[[permission]] +identifier = "deny-get-state" +description = "Denies the get_state command without any pre-configured scope." +commands.deny = ["get_state"] diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/reference.md b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/reference.md new file mode 100644 index 0000000000..1ea007202e --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/autogenerated/reference.md @@ -0,0 +1,97 @@ +## Default Permission + +Default permissions for the plugin + +#### This default permission set includes the following: + +- `allow-connect` +- `allow-disconnect` +- `allow-get-state` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`call-lifecycle:allow-connect` + + + +Enables the connect command without any pre-configured scope. + +
+ +`call-lifecycle:deny-connect` + + + +Denies the connect command without any pre-configured scope. + +
+ +`call-lifecycle:allow-disconnect` + + + +Enables the disconnect command without any pre-configured scope. + +
+ +`call-lifecycle:deny-disconnect` + + + +Denies the disconnect command without any pre-configured scope. + +
+ +`call-lifecycle:allow-get-state` + + + +Enables the get_state command without any pre-configured scope. + +
+ +`call-lifecycle:deny-get-state` + + + +Denies the get_state command without any pre-configured scope. + +
diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/default.toml b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/default.toml new file mode 100644 index 0000000000..ada76b3651 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/default.toml @@ -0,0 +1,3 @@ +[default] +description = "Default permissions for the plugin" +permissions = ["allow-connect", "allow-disconnect", "allow-get-state"] diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/schemas/schema.json b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/schemas/schema.json new file mode 100644 index 0000000000..3561320b3a --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/permissions/schemas/schema.json @@ -0,0 +1,342 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the connect command without any pre-configured scope.", + "type": "string", + "const": "allow-connect", + "markdownDescription": "Enables the connect command without any pre-configured scope." + }, + { + "description": "Denies the connect command without any pre-configured scope.", + "type": "string", + "const": "deny-connect", + "markdownDescription": "Denies the connect command without any pre-configured scope." + }, + { + "description": "Enables the disconnect command without any pre-configured scope.", + "type": "string", + "const": "allow-disconnect", + "markdownDescription": "Enables the disconnect command without any pre-configured scope." + }, + { + "description": "Denies the disconnect command without any pre-configured scope.", + "type": "string", + "const": "deny-disconnect", + "markdownDescription": "Denies the disconnect command without any pre-configured scope." + }, + { + "description": "Enables the get_state command without any pre-configured scope.", + "type": "string", + "const": "allow-get-state", + "markdownDescription": "Enables the get_state command without any pre-configured scope." + }, + { + "description": "Denies the get_state command without any pre-configured scope.", + "type": "string", + "const": "deny-get-state", + "markdownDescription": "Denies the get_state command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-connect`\n- `allow-disconnect`\n- `allow-get-state`", + "type": "string", + "const": "default", + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-connect`\n- `allow-disconnect`\n- `allow-get-state`" + } + ] + } + } +} \ No newline at end of file diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/rollup.config.js b/src-tauri/plugins/tauri-plugin-call-lifecycle/rollup.config.js new file mode 100644 index 0000000000..118be066c3 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/rollup.config.js @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { cwd } from 'node:process' +import typescript from '@rollup/plugin-typescript' + +const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8')) + +export default { + input: 'guest-js/index.ts', + output: [ + { + file: pkg.exports.import, + format: 'esm' + }, + { + file: pkg.exports.require, + format: 'cjs' + } + ], + plugins: [ + typescript({ + declaration: true, + declarationDir: dirname(pkg.exports.import), + rootDir: 'guest-js' + }) + ], + external: [ + /^@tauri-apps\/api/, + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}) + ] +} diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/src/actor.rs b/src-tauri/plugins/tauri-plugin-call-lifecycle/src/actor.rs new file mode 100644 index 0000000000..51309351f4 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/src/actor.rs @@ -0,0 +1,634 @@ +use std::marker::PhantomData; + +use livekit::{Room, RoomEvent, RoomOptions}; +use secrecy::ExposeSecret; +use tauri::{async_runtime, AppHandle, Emitter, Runtime}; +use tokio::sync::{mpsc, oneshot}; + +#[cfg(test)] +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +use crate::error::{Error, Result}; +use crate::models::{ + CallLifecycleError, CallState, ConnectRequest, ConnectionState, DisconnectRequest, +}; + +pub(crate) const STATE_EVENT: &str = "plugin:call-lifecycle://state"; +pub(crate) const ERROR_EVENT: &str = "plugin:call-lifecycle://error"; + +type RoomEvents = mpsc::UnboundedReceiver; + +struct ConnectedRoom { + room: OwnedRoom, + events: RoomEvents, +} + +enum OwnedRoom { + LiveKit(Room), + #[cfg(test)] + Fake(Arc), +} + +impl OwnedRoom { + async fn close(self) -> std::result::Result<(), ()> { + match self { + Self::LiveKit(room) => room.close().await.map_err(|_| ()), + #[cfg(test)] + Self::Fake(closes) => { + closes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + } +} + +pub(crate) enum Command { + Connect(ConnectRequest, oneshot::Sender>), + Disconnect(DisconnectRequest, oneshot::Sender>), + GetState(oneshot::Sender), +} + +enum InternalMessage { + ConnectFinished { + connection_id: String, + room: Option, + }, +} + +struct PendingConnection { + connection_id: String, + task: async_runtime::JoinHandle<()>, + response: oneshot::Sender>, +} + +struct ActiveConnection { + connection_id: String, + room: OwnedRoom, + events: RoomEvents, +} + +pub struct CallLifecycle { + commands: mpsc::Sender, + _runtime: PhantomData R>, +} + +impl CallLifecycle { + pub(crate) fn new(app: AppHandle) -> Self { + let (commands, command_rx) = mpsc::channel(32); + let (internal_tx, internal_rx) = mpsc::unbounded_channel(); + async_runtime::spawn(run_actor(app, command_rx, internal_tx, internal_rx)); + Self { + commands, + _runtime: PhantomData, + } + } + + pub async fn connect(&self, request: ConnectRequest) -> Result { + let (response, result) = oneshot::channel(); + self.commands + .send(Command::Connect(request, response)) + .await + .map_err(|_| Error::ActorUnavailable)?; + result.await.map_err(|_| Error::ActorUnavailable)? + } + + pub async fn disconnect(&self, request: DisconnectRequest) -> Result { + let (response, result) = oneshot::channel(); + self.commands + .send(Command::Disconnect(request, response)) + .await + .map_err(|_| Error::ActorUnavailable)?; + result.await.map_err(|_| Error::ActorUnavailable)? + } + + pub async fn get_state(&self) -> Result { + let (response, result) = oneshot::channel(); + self.commands + .send(Command::GetState(response)) + .await + .map_err(|_| Error::ActorUnavailable)?; + result.await.map_err(|_| Error::ActorUnavailable) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectDecision { + Start, + Same, + Busy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DisconnectDecision { + None, + CancelPending, + CloseActive, + Stale, +} + +#[derive(Debug, Clone)] +struct StateMachine { + state: ConnectionState, + revision: u64, + connection_id: Option, +} + +impl Default for StateMachine { + fn default() -> Self { + Self { + state: ConnectionState::Idle, + revision: 0, + connection_id: None, + } + } +} + +impl StateMachine { + fn snapshot(&self) -> CallState { + CallState { + revision: self.revision, + state: self.state, + connection_id: self.connection_id.clone(), + } + } + + fn connect_decision(&self, connection_id: &str) -> ConnectDecision { + match self.connection_id.as_deref() { + None => ConnectDecision::Start, + Some(current) if current == connection_id => ConnectDecision::Same, + Some(_) => ConnectDecision::Busy, + } + } + + fn disconnect_decision(&self, connection_id: &str) -> DisconnectDecision { + if self.connection_id.as_deref() != Some(connection_id) { + return if self.connection_id.is_some() { + DisconnectDecision::Stale + } else { + DisconnectDecision::None + }; + } + match self.state { + ConnectionState::Connecting => DisconnectDecision::CancelPending, + ConnectionState::Idle | ConnectionState::Disconnecting => DisconnectDecision::None, + ConnectionState::Connected | ConnectionState::Reconnecting => { + DisconnectDecision::CloseActive + } + } + } + + fn transition(&mut self, state: ConnectionState, connection_id: Option) { + self.revision += 1; + self.state = state; + self.connection_id = connection_id; + } +} + +struct Actor { + app: AppHandle, + commands: mpsc::Receiver, + internal_tx: mpsc::UnboundedSender, + internal_rx: mpsc::UnboundedReceiver, + machine: StateMachine, + pending: Option, + active: Option, +} + +async fn run_actor( + app: AppHandle, + commands: mpsc::Receiver, + internal_tx: mpsc::UnboundedSender, + internal_rx: mpsc::UnboundedReceiver, +) { + let mut actor = Actor { + app, + commands, + internal_tx, + internal_rx, + machine: StateMachine::default(), + pending: None, + active: None, + }; + actor.emit_state(); + + loop { + if actor.active.is_some() { + tokio::select! { + command = actor.commands.recv() => { + let Some(command) = command else { break }; + actor.handle_command(command).await; + } + internal = actor.internal_rx.recv() => { + let Some(internal) = internal else { break }; + actor.handle_internal(internal).await; + } + event = receive_room_event(&mut actor.active) => { + match event { + Some(event) => actor.handle_room_event(event).await, + None => { + close_active_connection(actor.active.take()).await; + actor.transition(ConnectionState::Idle, None); + } + } + } + } + } else { + tokio::select! { + command = actor.commands.recv() => { + let Some(command) = command else { break }; + actor.handle_command(command).await; + } + internal = actor.internal_rx.recv() => { + let Some(internal) = internal else { break }; + actor.handle_internal(internal).await; + } + } + } + } + + actor.cleanup().await; +} + +async fn receive_room_event(active: &mut Option) -> Option { + match active.as_mut() { + Some(active) => active.events.recv().await, + None => std::future::pending().await, + } +} + +impl Actor { + fn emit_state(&self) { + let _ = self.app.emit(STATE_EVENT, self.machine.snapshot()); + } + + fn emit_error(&self, error: &Error, connection_id: Option) { + let payload = CallLifecycleError { + revision: self.machine.revision, + code: error.code(), + message: error.message(), + connection_id, + }; + let _ = self.app.emit(ERROR_EVENT, payload); + } + + fn transition(&mut self, state: ConnectionState, connection_id: Option) { + self.machine.transition(state, connection_id); + self.emit_state(); + } + + async fn handle_command(&mut self, command: Command) { + match command { + Command::Connect(request, response) => self.handle_connect(request, response).await, + Command::Disconnect(request, response) => { + self.handle_disconnect(request, response).await + } + Command::GetState(response) => { + let _ = response.send(self.machine.snapshot()); + } + } + } + + async fn handle_connect( + &mut self, + request: ConnectRequest, + response: oneshot::Sender>, + ) { + match self.machine.connect_decision(&request.connection_id) { + ConnectDecision::Same => { + let _ = response.send(Ok(self.machine.snapshot())); + } + ConnectDecision::Busy => { + let error = Error::Busy; + self.emit_error(&error, Some(request.connection_id)); + let _ = response.send(Err(error)); + } + ConnectDecision::Start => { + let connection_id = request.connection_id.clone(); + self.transition(ConnectionState::Connecting, Some(connection_id.clone())); + let internal_tx = self.internal_tx.clone(); + let task = async_runtime::spawn(async move { + let room = Room::connect( + &request.server_url, + request.participant_token.expose_secret(), + RoomOptions::default(), + ) + .await + .ok() + .map(|(room, events)| ConnectedRoom { + room: OwnedRoom::LiveKit(room), + events, + }); + let _ = internal_tx.send(InternalMessage::ConnectFinished { + connection_id, + room, + }); + }); + self.pending = Some(PendingConnection { + connection_id: request.connection_id, + task, + response, + }); + } + } + } + + async fn handle_disconnect( + &mut self, + request: DisconnectRequest, + response: oneshot::Sender>, + ) { + match self.machine.disconnect_decision(&request.connection_id) { + DisconnectDecision::None => { + let _ = response.send(Ok(self.machine.snapshot())); + } + DisconnectDecision::Stale => { + let error = Error::StaleConnection; + self.emit_error(&error, Some(request.connection_id)); + let _ = response.send(Err(error)); + } + DisconnectDecision::CancelPending => { + if let Some(pending) = self.pending.take() { + pending.task.abort(); + let _ = pending.task.await; + close_queued_rooms(&mut self.internal_rx).await; + } + self.transition(ConnectionState::Disconnecting, Some(request.connection_id)); + self.transition(ConnectionState::Idle, None); + let _ = response.send(Ok(self.machine.snapshot())); + } + DisconnectDecision::CloseActive => { + let active = self.active.take(); + self.transition( + ConnectionState::Disconnecting, + Some(request.connection_id.clone()), + ); + let Some(active) = active else { + self.transition(ConnectionState::Idle, None); + let _ = response.send(Ok(self.machine.snapshot())); + return; + }; + + let close_result = active.room.close().await; + self.transition(ConnectionState::Idle, None); + if close_result.is_err() { + let error = Error::CloseFailed; + self.emit_error(&error, Some(active.connection_id)); + let _ = response.send(Err(error)); + } else { + let _ = response.send(Ok(self.machine.snapshot())); + } + } + } + } + + async fn handle_internal(&mut self, message: InternalMessage) { + match message { + InternalMessage::ConnectFinished { + connection_id, + room, + } => { + let Some(pending) = self.pending.take() else { + close_connected_room(room).await; + return; + }; + if pending.connection_id != connection_id { + close_connected_room(room).await; + let _ = pending.task.await; + return; + } + let _ = pending.task.await; + + let Some(ConnectedRoom { room, events }) = room else { + let error = Error::ConnectFailed; + self.emit_error(&error, Some(connection_id)); + self.transition(ConnectionState::Idle, None); + let _ = pending.response.send(Err(error)); + return; + }; + + self.active = Some(ActiveConnection { + connection_id: connection_id.clone(), + room, + events, + }); + self.transition(ConnectionState::Connected, Some(connection_id)); + let _ = pending.response.send(Ok(self.machine.snapshot())); + } + } + } + + async fn handle_room_event(&mut self, event: RoomEvent) { + match event { + RoomEvent::Reconnecting => { + if let Some(connection_id) = self.machine.connection_id.clone() { + self.transition(ConnectionState::Reconnecting, Some(connection_id)); + } + } + RoomEvent::Reconnected => { + if let Some(connection_id) = self.machine.connection_id.clone() { + self.transition(ConnectionState::Connected, Some(connection_id)); + } + } + RoomEvent::Disconnected { .. } => { + close_active_connection(self.active.take()).await; + self.transition(ConnectionState::Idle, None); + } + _ => {} + } + } + + async fn cleanup(&mut self) { + if let Some(pending) = self.pending.take() { + pending.task.abort(); + let _ = pending.task.await; + let _ = pending.response.send(Err(Error::ActorUnavailable)); + } + close_queued_rooms(&mut self.internal_rx).await; + + close_active_connection(self.active.take()).await; + } +} + +async fn close_active_connection(active: Option) { + if let Some(active) = active { + let _ = active.room.close().await; + } +} + +async fn close_connected_room(room: Option) { + if let Some(room) = room { + let _ = room.room.close().await; + } +} + +async fn close_queued_rooms(internal_rx: &mut mpsc::UnboundedReceiver) { + while let Ok(message) = internal_rx.try_recv() { + let InternalMessage::ConnectFinished { + room: Some(room), .. + } = message + else { + continue; + }; + close_connected_room(Some(room)).await; + } +} + +#[cfg(test)] +mod tests { + use super::{ConnectDecision, DisconnectDecision, StateMachine}; + use crate::error::Error; + use crate::models::{CallLifecycleError, ConnectRequest, ConnectionState}; + use secrecy::SecretString; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + #[test] + fn same_id_is_idempotent_and_different_id_is_busy() { + let mut machine = StateMachine::default(); + assert_eq!(machine.connect_decision("one"), ConnectDecision::Start); + machine.transition(ConnectionState::Connecting, Some("one".into())); + assert_eq!(machine.connect_decision("one"), ConnectDecision::Same); + assert_eq!(machine.connect_decision("two"), ConnectDecision::Busy); + assert_eq!(machine.snapshot().revision, 1); + } + + #[test] + fn stale_disconnect_does_not_close_active_connection() { + let mut machine = StateMachine::default(); + machine.transition(ConnectionState::Connected, Some("one".into())); + assert_eq!( + machine.disconnect_decision("two"), + DisconnectDecision::Stale + ); + assert_eq!(machine.snapshot().state, ConnectionState::Connected); + assert_eq!( + machine.disconnect_decision("one"), + DisconnectDecision::CloseActive + ); + } + + #[test] + fn mismatched_disconnect_is_typed_stale_error() { + let mut machine = StateMachine::default(); + machine.transition(ConnectionState::Connected, Some("one".into())); + assert_eq!( + machine.disconnect_decision("two"), + DisconnectDecision::Stale + ); + assert_eq!(machine.snapshot().state, ConnectionState::Connected); + assert_eq!(machine.snapshot().connection_id.as_deref(), Some("one")); + } + + #[test] + fn participant_token_debug_is_redacted() { + let request = ConnectRequest { + connection_id: "connection".into(), + server_url: "wss://livekit.example".into(), + participant_token: SecretString::from("secret-jwt"), + }; + let debug = format!("{request:?}"); + assert!(!debug.contains("secret-jwt")); + assert!(debug.contains("REDACTED")); + } + + #[test] + fn errors_and_states_have_stable_sanitized_shapes() { + let error = serde_json::to_value(Error::StaleConnection).unwrap(); + assert_eq!( + error, + serde_json::json!({ + "code": "stale_connection", + "message": "connection ID does not match active call" + }) + ); + + let state = serde_json::to_value(ConnectionState::Reconnecting).unwrap(); + assert_eq!(state, serde_json::json!("reconnecting")); + + let event = CallLifecycleError { + revision: 7, + code: "stale_connection", + message: "connection ID does not match active call", + connection_id: Some("old".into()), + }; + let event = serde_json::to_value(event).unwrap(); + assert_eq!(event["revision"], 7); + } + + #[tokio::test] + async fn queued_completed_room_is_closed_once_when_disconnect_wins() { + let closes = Arc::new(AtomicUsize::new(0)); + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let (_events_sender, events) = tokio::sync::mpsc::unbounded_channel(); + sender + .send(super::InternalMessage::ConnectFinished { + connection_id: "one".into(), + room: Some(super::ConnectedRoom { + room: super::OwnedRoom::Fake(closes.clone()), + events, + }), + }) + .unwrap(); + + super::close_queued_rooms(&mut receiver).await; + super::close_queued_rooms(&mut receiver).await; + + assert_eq!(closes.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn terminal_active_room_paths_close_owned_rooms_once() { + let event_receiver_closes = Arc::new(AtomicUsize::new(0)); + let (_events_sender, events) = tokio::sync::mpsc::unbounded_channel::(); + super::close_active_connection(Some(super::ActiveConnection { + connection_id: "event-ended".into(), + room: super::OwnedRoom::Fake(event_receiver_closes.clone()), + events, + })) + .await; + + let disconnected_closes = Arc::new(AtomicUsize::new(0)); + let (_events_sender, events) = tokio::sync::mpsc::unbounded_channel::(); + super::close_active_connection(Some(super::ActiveConnection { + connection_id: "disconnected".into(), + room: super::OwnedRoom::Fake(disconnected_closes.clone()), + events, + })) + .await; + + assert_eq!(event_receiver_closes.load(Ordering::SeqCst), 1); + assert_eq!(disconnected_closes.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn stale_completion_closes_owned_room_once() { + let closes = Arc::new(AtomicUsize::new(0)); + let (_events_sender, events) = tokio::sync::mpsc::unbounded_channel::(); + super::close_connected_room(Some(super::ConnectedRoom { + room: super::OwnedRoom::Fake(closes.clone()), + events, + })) + .await; + + assert_eq!(closes.load(Ordering::SeqCst), 1); + } + + #[test] + fn explicit_disconnect_has_one_close_transition() { + let mut machine = StateMachine::default(); + machine.transition(ConnectionState::Connected, Some("one".into())); + assert_eq!( + machine.disconnect_decision("one"), + DisconnectDecision::CloseActive + ); + machine.transition(ConnectionState::Disconnecting, Some("one".into())); + machine.transition(ConnectionState::Idle, None); + assert_eq!(machine.disconnect_decision("one"), DisconnectDecision::None); + assert_eq!(machine.snapshot().revision, 3); + } +} diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/src/commands.rs b/src-tauri/plugins/tauri-plugin-call-lifecycle/src/commands.rs new file mode 100644 index 0000000000..64aed22aef --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/src/commands.rs @@ -0,0 +1,26 @@ +use tauri::{command, AppHandle, Runtime}; + +use crate::models::*; +use crate::CallLifecycleExt; +use crate::Result; + +#[command] +pub(crate) async fn connect( + app: AppHandle, + payload: ConnectRequest, +) -> Result { + app.call_lifecycle().connect(payload).await +} + +#[command] +pub(crate) async fn disconnect( + app: AppHandle, + payload: DisconnectRequest, +) -> Result { + app.call_lifecycle().disconnect(payload).await +} + +#[command] +pub(crate) async fn get_state(app: AppHandle) -> Result { + app.call_lifecycle().get_state().await +} diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/src/error.rs b/src-tauri/plugins/tauri-plugin-call-lifecycle/src/error.rs new file mode 100644 index 0000000000..34a16b7176 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/src/error.rs @@ -0,0 +1,51 @@ +use serde::{ser::SerializeStruct, ser::Serializer, Serialize}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("call is already active")] + Busy, + #[error("call connection failed")] + ConnectFailed, + #[error("connection ID does not match active call")] + StaleConnection, + #[error("call shutdown failed")] + CloseFailed, + #[error("call lifecycle actor unavailable")] + ActorUnavailable, +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + let mut error = serializer.serialize_struct("Error", 2)?; + error.serialize_field("code", self.code())?; + error.serialize_field("message", self.message())?; + error.end() + } +} + +impl Error { + pub fn code(&self) -> &'static str { + match self { + Self::Busy => "busy", + Self::ConnectFailed => "connect_failed", + Self::StaleConnection => "stale_connection", + Self::CloseFailed => "close_failed", + Self::ActorUnavailable => "actor_unavailable", + } + } + + pub fn message(&self) -> &'static str { + match self { + Self::Busy => "another call is active", + Self::ConnectFailed => "call connection failed", + Self::StaleConnection => "connection ID does not match active call", + Self::CloseFailed => "call shutdown failed", + Self::ActorUnavailable => "call lifecycle unavailable", + } + } +} diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/src/lib.rs b/src-tauri/plugins/tauri-plugin-call-lifecycle/src/lib.rs new file mode 100644 index 0000000000..1bd0f776f7 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/src/lib.rs @@ -0,0 +1,42 @@ +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +pub use models::*; + +mod actor; +mod commands; +mod error; +mod models; + +pub use error::{Error, Result}; + +use actor::CallLifecycle; + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the call-lifecycle APIs. +pub trait CallLifecycleExt { + fn call_lifecycle(&self) -> &CallLifecycle; +} + +impl> crate::CallLifecycleExt for T { + fn call_lifecycle(&self) -> &CallLifecycle { + self.state::>().inner() + } +} + +/// Initializes the plugin. +pub fn init() -> TauriPlugin { + Builder::new("call-lifecycle") + .invoke_handler(tauri::generate_handler![ + commands::connect, + commands::disconnect, + commands::get_state + ]) + .setup(|app, _api| { + let call_lifecycle = CallLifecycle::new(app.clone()); + app.manage(call_lifecycle); + Ok(()) + }) + .build() +} diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/src/models.rs b/src-tauri/plugins/tauri-plugin-call-lifecycle/src/models.rs new file mode 100644 index 0000000000..41cbc444f3 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/src/models.rs @@ -0,0 +1,43 @@ +use secrecy::SecretString; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectRequest { + pub connection_id: String, + pub server_url: String, + pub participant_token: SecretString, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DisconnectRequest { + pub connection_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionState { + Idle, + Connecting, + Connected, + Reconnecting, + Disconnecting, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CallState { + pub revision: u64, + pub state: ConnectionState, + pub connection_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CallLifecycleError { + pub revision: u64, + pub code: &'static str, + pub message: &'static str, + pub connection_id: Option, +} diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/tsconfig.json b/src-tauri/plugins/tauri-plugin-call-lifecycle/tsconfig.json new file mode 100644 index 0000000000..0591122701 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2021", + "module": "esnext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noImplicitAny": true, + "noEmit": true + }, + "include": ["guest-js/*.ts"], + "exclude": ["dist-js", "node_modules"] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 30fec56684..c723f3ce92 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -341,7 +341,8 @@ pub fn run() { #[cfg(desktop)] let builder = builder .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_dialog::init()); + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_call_lifecycle::init()); #[cfg(target_os = "ios")] let builder = builder diff --git a/src/app/cs-api.ts b/src/app/cs-api.ts index ec985b97b4..386ca3d19b 100644 --- a/src/app/cs-api.ts +++ b/src/app/cs-api.ts @@ -1,4 +1,5 @@ import to from 'await-to-js'; +import type { LivekitTransportConfig } from '$types/matrix-sdk'; import { trimTrailingSlash } from './utils/common'; export enum AutoDiscoveryAction { @@ -22,12 +23,22 @@ export type AutoDiscoveryInfo = Record & { account?: string; issuer?: string; }; - 'org.matrix.msc4143.rtc_foci'?: [ - { - livekit_service_url: string; - type: 'livekit'; - }, - ]; + 'org.matrix.msc4143.rtc_foci'?: LivekitTransportConfig[]; +}; + +export const getLivekitTransports = ( + discovery: Pick | undefined +): LivekitTransportConfig[] => { + const foci = discovery?.['org.matrix.msc4143.rtc_foci']; + if (!Array.isArray(foci)) return []; + + return foci.filter( + (focus): focus is LivekitTransportConfig => + typeof focus === 'object' && + focus !== null && + focus.type === 'livekit' && + typeof focus.livekit_service_url === 'string' + ); }; export const autoDiscovery = async ( diff --git a/src/app/features/call/livekitProvisioning.test.ts b/src/app/features/call/livekitProvisioning.test.ts new file mode 100644 index 0000000000..17fc5041f2 --- /dev/null +++ b/src/app/features/call/livekitProvisioning.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IOpenIDToken, MatrixClient } from '$types/matrix-sdk'; + +const fetchMock = vi.hoisted(() => vi.fn()); + +vi.mock('$utils/fetch', () => ({ fetch: fetchMock })); + +import { getPreferredLivekitTransport, provisionLivekitToken } from './livekitProvisioning'; + +const openidToken: IOpenIDToken = { + access_token: 'openid-secret', + token_type: 'Bearer', + matrix_server_name: 'example.org', + expires_in: 3600, +}; + +const response = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { status }); + +type TestClient = Pick; + +const client = (overrides: Partial = {}): TestClient => ({ + getOpenIdToken: vi.fn().mockResolvedValue(openidToken), + _unstable_getRTCTransports: vi + .fn() + .mockResolvedValue([]), + ...overrides, +}); + +describe('getPreferredLivekitTransport', () => { + it('prefers the SDK LiveKit transport over discovery', async () => { + const mx = client({ + _unstable_getRTCTransports: vi + .fn() + .mockResolvedValue([{ type: 'livekit', livekit_service_url: 'https://sdk.example' }]), + }); + + await expect( + getPreferredLivekitTransport(mx, { + 'org.matrix.msc4143.rtc_foci': [ + { type: 'livekit', livekit_service_url: 'https://discovery.example' }, + ], + }) + ).resolves.toEqual({ type: 'livekit', livekit_service_url: 'https://sdk.example' }); + }); + + it('falls back to discovery when SDK transport discovery fails', async () => { + const mx = client({ + _unstable_getRTCTransports: vi + .fn() + .mockRejectedValue(new Error('unsupported')), + }); + + await expect( + getPreferredLivekitTransport(mx, { + 'org.matrix.msc4143.rtc_foci': [ + { type: 'livekit', livekit_service_url: 'https://discovery.example' }, + ], + }) + ).resolves.toEqual({ type: 'livekit', livekit_service_url: 'https://discovery.example' }); + }); +}); + +describe('provisionLivekitToken', () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + const options = { + mx: client(), + roomId: '!room:example.org', + deviceId: 'DEVICE', + serviceUrl: 'https://sfu.example///', + memberId: 'member-id', + userId: '@alice:example.org', + }; + + it('provisions through the modern endpoint with the MatrixRTC request body', async () => { + fetchMock.mockResolvedValueOnce( + response(200, { url: 'wss://livekit.example', jwt: 'jwt-secret' }) + ); + + await expect(provisionLivekitToken(options)).resolves.toEqual({ + url: 'wss://livekit.example', + jwt: 'jwt-secret', + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, request] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://sfu.example/get_token'); + expect(JSON.parse(request.body as string)).toEqual({ + room_id: '!room:example.org', + slot_id: 'm.call#!room:example.org', + openid_token: openidToken, + member: { + id: 'member-id', + claimed_user_id: '@alice:example.org', + claimed_device_id: 'DEVICE', + }, + }); + }); + + it('falls back to the legacy endpoint after a modern request failure', async () => { + fetchMock + .mockResolvedValueOnce(response(404, { error: 'not found' })) + .mockResolvedValueOnce(response(200, { url: 'wss://legacy.example', jwt: 'legacy-jwt' })); + + await expect(provisionLivekitToken(options)).resolves.toEqual({ + url: 'wss://legacy.example', + jwt: 'legacy-jwt', + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [url, request] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(url).toBe('https://sfu.example/sfu/get'); + expect(JSON.parse(request.body as string)).toEqual({ + room: '!room:example.org', + openid_token: openidToken, + device_id: 'DEVICE', + }); + }); + + it('rejects invalid responses after trying both endpoints', async () => { + fetchMock + .mockResolvedValueOnce(response(200, { url: 'wss://livekit.example' })) + .mockResolvedValueOnce(response(200, { jwt: 'jwt-secret' })); + + await expect(provisionLivekitToken(options)).rejects.toThrow( + 'LiveKit token provisioning failed' + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not expose token values in errors', async () => { + fetchMock.mockRejectedValue(new Error('request failed: jwt-secret openid-secret')); + const provisioning = provisionLivekitToken(options); + + await expect(provisioning).rejects.toThrow('LiveKit token provisioning failed'); + await expect(provisioning).rejects.not.toThrow('openid-secret'); + await expect(provisioning).rejects.not.toThrow('jwt-secret'); + }); +}); diff --git a/src/app/features/call/livekitProvisioning.ts b/src/app/features/call/livekitProvisioning.ts new file mode 100644 index 0000000000..bfab7ac86d --- /dev/null +++ b/src/app/features/call/livekitProvisioning.ts @@ -0,0 +1,135 @@ +import type { AutoDiscoveryInfo } from '../../cs-api'; +import { getLivekitTransports } from '../../cs-api'; +import { fetch as appFetch } from '$utils/fetch'; +import { trimTrailingSlash } from '$utils/common'; +import type { + IOpenIDToken, + LivekitTransportConfig, + MatrixClient, + Transport, +} from '$types/matrix-sdk'; + +export type LivekitProvisioningOptions = { + mx: Pick; + roomId: string; + deviceId: string; + serviceUrl: string; + memberId?: string; + userId?: string; +}; + +export type LivekitProvisioningResult = { + url: string; + jwt: string; +}; + +const isLivekitTransportConfig = (transport: Transport): transport is LivekitTransportConfig => + transport.type === 'livekit' && typeof transport.livekit_service_url === 'string'; + +const isProvisioningResult = (value: unknown): value is LivekitProvisioningResult => + typeof value === 'object' && + value !== null && + 'url' in value && + typeof value.url === 'string' && + value.url.length > 0 && + 'jwt' in value && + typeof value.jwt === 'string' && + value.jwt.length > 0; + +export const getPreferredLivekitTransport = async ( + mx: Pick, + discovery?: Pick +): Promise => { + const transports = await mx['_unstable_getRTCTransports']().catch(() => undefined); + const livekitTransport = transports?.find(isLivekitTransportConfig); + if (livekitTransport) return livekitTransport; + + return getLivekitTransports(discovery)[0]; +}; + +type ModernProvisioningRequest = { + room_id: string; + slot_id: string; + openid_token: IOpenIDToken; + member?: { + id: string; + claimed_user_id: string; + claimed_device_id: string; + }; +}; + +type LegacyProvisioningRequest = { + room: string; + openid_token: IOpenIDToken; + device_id: string; +}; + +const requestLivekitToken = async ( + endpoint: string, + body: ModernProvisioningRequest | LegacyProvisioningRequest +): Promise => { + const response = await appFetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (response.status < 200 || response.status >= 300) { + throw new Error('LiveKit provisioning request failed'); + } + + const data = (await response.json()) as unknown; + + if (!isProvisioningResult(data)) { + throw new Error('LiveKit provisioning response was invalid'); + } + + return { url: data.url, jwt: data.jwt }; +}; + +export const provisionLivekitToken = async ({ + mx, + roomId, + deviceId, + serviceUrl, + memberId, + userId, +}: LivekitProvisioningOptions): Promise => { + let openidToken: IOpenIDToken; + try { + openidToken = await mx.getOpenIdToken(); + } catch { + throw new Error('Unable to obtain an OpenID token for LiveKit provisioning'); + } + + const endpoint = trimTrailingSlash(serviceUrl); + const modernRequest: ModernProvisioningRequest = { + room_id: roomId, + slot_id: `m.call#${roomId}`, + openid_token: openidToken, + }; + + if (memberId && userId && deviceId) { + modernRequest.member = { + id: memberId, + claimed_user_id: userId, + claimed_device_id: deviceId, + }; + } + + try { + return await requestLivekitToken(`${endpoint}/get_token`, modernRequest); + } catch { + const legacyRequest: LegacyProvisioningRequest = { + room: roomId, + openid_token: openidToken, + device_id: deviceId, + }; + + try { + return await requestLivekitToken(`${endpoint}/sfu/get`, legacyRequest); + } catch { + throw new Error('LiveKit token provisioning failed'); + } + } +}; diff --git a/src/app/plugins/call/callLifecycle.test.ts b/src/app/plugins/call/callLifecycle.test.ts new file mode 100644 index 0000000000..8a213bd509 --- /dev/null +++ b/src/app/plugins/call/callLifecycle.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const pluginConnect = vi.hoisted(() => vi.fn<(request: unknown) => Promise>()); +const pluginDisconnect = vi.hoisted(() => vi.fn<(request: unknown) => Promise>()); +const pluginGetState = vi.hoisted(() => vi.fn<() => Promise>()); +const listen = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise>()); + +vi.mock('tauri-plugin-call-lifecycle-api', () => ({ + connect: pluginConnect, + disconnect: pluginDisconnect, + getState: pluginGetState, +})); +vi.mock('@tauri-apps/api/event', () => ({ listen })); + +import { + connect, + disconnect, + getState, + onError, + onState, + type CallLifecycleError, + type CallState, +} from './callLifecycle'; + +describe('call lifecycle wrapper', () => { + beforeEach(() => { + pluginConnect.mockReset(); + pluginDisconnect.mockReset(); + pluginGetState.mockReset(); + listen.mockReset(); + listen.mockResolvedValue(vi.fn()); + }); + + it('delegates lifecycle commands to the guest API', async () => { + const state = { + revision: 1, + state: 'connecting', + connectionId: 'connection-id', + } satisfies CallState; + pluginConnect.mockResolvedValue(state); + pluginDisconnect.mockResolvedValue(state); + pluginGetState.mockResolvedValue(state); + + const request = { + connectionId: 'connection-id', + serverUrl: 'wss://livekit.example', + participantToken: 'token', + }; + await expect(connect(request)).resolves.toEqual(state); + await expect(disconnect({ connectionId: 'connection-id' })).resolves.toEqual(state); + await expect(getState()).resolves.toEqual(state); + + expect(pluginConnect).toHaveBeenCalledWith(request); + expect(pluginDisconnect).toHaveBeenCalledWith({ + connectionId: 'connection-id', + }); + expect(pluginGetState).toHaveBeenCalledWith(); + }); + + it('registers typed state and error listeners', async () => { + const state = { + revision: 2, + state: 'connected', + connectionId: 'connection-id', + } satisfies CallState; + const error = { + revision: 3, + code: 'connect_failed', + message: 'call connection failed', + connectionId: 'connection-id', + } satisfies CallLifecycleError; + const onStateEvent = vi.fn<(value: CallState) => void>(); + const onErrorEvent = vi.fn<(value: CallLifecycleError) => void>(); + + const stateUnlisten = await onState(onStateEvent); + const stateListener = listen.mock.calls[0]?.[1] as (event: { payload: CallState }) => void; + stateListener({ payload: state }); + + const errorUnlisten = await onError(onErrorEvent); + const errorListener = listen.mock.calls[1]?.[1] as (event: { + payload: CallLifecycleError; + }) => void; + errorListener({ payload: error }); + + expect(listen).toHaveBeenNthCalledWith( + 1, + 'plugin:call-lifecycle://state', + expect.any(Function) + ); + expect(listen).toHaveBeenNthCalledWith( + 2, + 'plugin:call-lifecycle://error', + expect.any(Function) + ); + expect(onStateEvent).toHaveBeenCalledWith(state); + expect(onErrorEvent).toHaveBeenCalledWith(error); + expect(stateUnlisten).toBeTypeOf('function'); + expect(errorUnlisten).toBeTypeOf('function'); + }); +}); diff --git a/src/app/plugins/call/callLifecycle.ts b/src/app/plugins/call/callLifecycle.ts new file mode 100644 index 0000000000..dc2d201619 --- /dev/null +++ b/src/app/plugins/call/callLifecycle.ts @@ -0,0 +1,28 @@ +import { + connect as pluginConnect, + disconnect as pluginDisconnect, + getState as pluginGetState, + type CallLifecycleError, + type CallState, + type ConnectRequest, + type ConnectionState, + type DisconnectRequest, +} from 'tauri-plugin-call-lifecycle-api'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +const STATE_EVENT = 'plugin:call-lifecycle://state'; +const ERROR_EVENT = 'plugin:call-lifecycle://error'; + +export type { CallLifecycleError, CallState, ConnectRequest, ConnectionState, DisconnectRequest }; + +export const connect = pluginConnect; +export const disconnect = pluginDisconnect; +export const getState = pluginGetState; + +export function onState(handler: (state: CallState) => void): Promise { + return listen(STATE_EVENT, (event) => handler(event.payload)); +} + +export function onError(handler: (error: CallLifecycleError) => void): Promise { + return listen(ERROR_EVENT, (event) => handler(event.payload)); +} diff --git a/src/app/plugins/call/index.ts b/src/app/plugins/call/index.ts index a4c6bb365c..8514f5beab 100644 --- a/src/app/plugins/call/index.ts +++ b/src/app/plugins/call/index.ts @@ -1,3 +1,4 @@ export * from './CallEmbed'; +export * from './callLifecycle'; export * from './hooks'; export * from './types'; diff --git a/src/types/matrix-sdk.ts b/src/types/matrix-sdk.ts index f1bea4e091..18cba3bd41 100644 --- a/src/types/matrix-sdk.ts +++ b/src/types/matrix-sdk.ts @@ -55,5 +55,11 @@ export * from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; export * from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; export * from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; export * from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager'; +export { type CallMembershipIdentityParts } from 'matrix-js-sdk/lib/matrixrtc/EncryptionManager'; +export { + type LivekitTransport, + type LivekitTransportConfig, +} from 'matrix-js-sdk/lib/matrixrtc/LivekitTransport'; +export { type Transport } from 'matrix-js-sdk/lib/matrixrtc/types'; export * from 'matrix-js-sdk/lib/models/thread'; From 433867399ccdca89671c50fb04cbf0aa5134a0be Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Tue, 28 Jul 2026 00:05:19 +0200 Subject: [PATCH 2/9] feat(call): add native transport probe --- .gitignore | 3 + src-tauri/Cargo.lock | 8 + .../tauri-plugin-call-lifecycle/Cargo.toml | 4 +- src/app/features/call/CallView.tsx | 50 +- .../features/call/livekitProvisioning.test.ts | 3 +- src/app/features/call/livekitProvisioning.ts | 4 +- .../call/nativeCallController.test.ts | 483 ++++++++++++++++++ src/app/features/call/nativeCallController.ts | 387 ++++++++++++++ src/app/features/call/nativeCallProbe.test.ts | 41 ++ src/app/features/call/nativeCallProbe.ts | 17 + src/app/features/call/useCallSignaling.ts | 31 +- src/app/features/room/RoomView.tsx | 6 +- src/app/hooks/useCallEmbed.ts | 47 +- src/app/state/nativeCall.ts | 16 + 14 files changed, 1078 insertions(+), 22 deletions(-) create mode 100644 src/app/features/call/nativeCallController.test.ts create mode 100644 src/app/features/call/nativeCallController.ts create mode 100644 src/app/features/call/nativeCallProbe.test.ts create mode 100644 src/app/features/call/nativeCallProbe.ts create mode 100644 src/app/state/nativeCall.ts diff --git a/.gitignore b/.gitignore index 96e0abe449..73e50380a8 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ result .direnv ## auto-generated pre-commit config .pre-commit-config.yaml + +# Local deepwork state +.slim/deepwork/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f1d1bd48a5..bbfdcc68c8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3748,6 +3748,7 @@ dependencies = [ "prost", "rand 0.9.5", "reqwest 0.12.28", + "rustls-native-certs", "scopeguard", "serde", "serde_json", @@ -3755,6 +3756,7 @@ dependencies = [ "signature", "thiserror 2.0.19", "tokio", + "tokio-rustls", "tokio-tungstenite", "url", ] @@ -7876,7 +7878,11 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", ] @@ -8298,6 +8304,8 @@ dependencies = [ "httparse", "log", "rand 0.9.5", + "rustls", + "rustls-pki-types", "sha1", "thiserror 2.0.19", "url", diff --git a/src-tauri/plugins/tauri-plugin-call-lifecycle/Cargo.toml b/src-tauri/plugins/tauri-plugin-call-lifecycle/Cargo.toml index fe3358b727..10f24d4cbe 100644 --- a/src-tauri/plugins/tauri-plugin-call-lifecycle/Cargo.toml +++ b/src-tauri/plugins/tauri-plugin-call-lifecycle/Cargo.toml @@ -9,8 +9,8 @@ exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"] links = "tauri-plugin-call-lifecycle" [dependencies] -tauri = { version = "2.11.3" } -livekit = "0.7" +tauri = { version = "2.11.3", default-features = false } +livekit = { version = "0.7", features = ["rustls-tls-native-roots"] } secrecy = { version = "0.10", features = ["serde"] } serde = "1.0" thiserror = "2" diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 62d79cd6d7..012e07078e 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; -import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; +import { Badge, Box, Button, color, Header, Scroll, Text, toRem } from 'folds'; import { useAtomValue } from 'jotai'; import { ContainerColor } from '$styles/ContainerColor.css'; import { useRoom } from '$hooks/useRoom'; @@ -13,6 +13,8 @@ import { CallMemberRenderer } from './CallMemberCard'; import { PrescreenControls } from './PrescreenControls'; import { callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed'; import { canJoinCall } from './callStartCapabilities'; +import { nativeCallAtom } from '$state/nativeCall'; +import { isDesktopTauri } from '$utils/platform'; function LivekitServerMissingMessage() { return ( @@ -80,6 +82,35 @@ function WidgetPreparationErrorMessage({ message }: { message: string }) { ); } +function NativeCallProbe({ + lifecycle, + error, + onHangup, +}: { + lifecycle: string; + error?: string; + onHangup: () => void; +}) { + return ( + + Native call: {lifecycle} + + Media is not implemented. + + {error && ( + + {error} + + )} + + + ); +} + function CallPrescreen() { const room = useRoom(); const callEmbed = useAtomValue(callEmbedAtom); @@ -168,8 +199,11 @@ export function CallView({ resizable }: CallViewProps) { const callEmbed = useCallEmbed(); const callJoined = useCallJoined(callEmbed); + const nativeCall = useAtomValue(nativeCallAtom); - const currentJoined = callEmbed?.roomId === room.roomId && callJoined; + const nativeCallForRoom = + isDesktopTauri() && nativeCall?.roomId === room.roomId ? nativeCall : undefined; + const currentJoined = !nativeCallForRoom && callEmbed?.roomId === room.roomId && callJoined; const [heightRatio, setHeightRatio] = useState(isMobile ? 0.3 : 0.72); const [availableHeight, setAvailableHeight] = useState(0); @@ -284,8 +318,16 @@ export function CallView({ resizable }: CallViewProps) { /> )} - {!currentJoined && } - + {!currentJoined && !nativeCallForRoom && } + {nativeCallForRoom ? ( + void nativeCallForRoom.hangup()} + /> + ) : ( + + )} {resizable && ( + + + + ); +} + +const livekitJsLifecycleLabels: Record = { + idle: 'Idle', + 'joining-matrix': 'Joining call', + provisioning: 'Preparing connection', + 'connecting-livekit': 'Connecting to LiveKit', + active: 'Connected', + stopping: 'Ending connection', + failed: 'Connection failed', +}; + +function LivekitJsE2EEStatus({ + lifecycle, + failure, +}: Pick) { + if (failure === 'e2ee-unsupported') { + return Unavailable on this device; + } + if (failure === 'setup-failed') { + return Unavailable; + } + if (failure === 'e2ee-import-failed') { + return Failed to prepare; + } + if (lifecycle === 'active') return Ready; + return Waiting; +} + +export function LivekitJsCallProbe({ + session, + onHangup, +}: { + session: Pick; + onHangup: () => void; +}) { + const failureMessage = + session.failure === 'e2ee-unsupported' + ? 'End-to-end encryption is unavailable on this device.' + : session.failure === 'e2ee-import-failed' + ? 'End-to-end encryption setup failed.' + : session.failure === 'setup-failed' + ? 'LiveKit JS connection setup failed.' + : undefined; + + return ( + + LiveKit JS connection probe + + Connection-only experiment · media is not published + + + Connection: {livekitJsLifecycleLabels[session.lifecycle]} + + E2EE readiness: + + {failureMessage && ( + + {failureMessage} + + )} + + ), + color: { Critical: { Main: 'red' } }, + IconButton: ({ + children, + onClick, + disabled, + 'aria-label': ariaLabel, + }: { + children: ReactNode; + onClick: () => void; + disabled?: boolean; + 'aria-label': string; + }) => ( + + ), + Text: ({ children }: { children: ReactNode }) => {children}, +})); + +const room = { + connect: vi.fn<() => Promise>(), + disconnect: vi.fn<() => Promise>(), +} as unknown as Room; + +const makeSession = (overrides: Partial = {}): LivekitJsCallSession => ({ + roomId: '!room:example.org', + lifecycle: 'active', + failure: null, + room, + media: { + setMicrophoneEnabled: vi.fn<() => Promise>().mockResolvedValue(), + setCameraEnabled: vi.fn<() => Promise>().mockResolvedValue(), + setScreenShareEnabled: vi.fn<() => Promise>().mockResolvedValue(), + }, + hangup: vi.fn<() => Promise>().mockResolvedValue(), + ...overrides, +}); + +beforeEach(() => { + mocks.mockUseTracks.mockReset(); + mocks.mockUseTracks.mockReturnValue([]); + mocks.mockObservedRoom.mockReset(); + mocks.mockUseLocalParticipant.mockReset(); + mocks.mockUseLocalParticipant.mockImplementation(() => { + const activeRoom = useContext(mocks.mockRoomContext); + if (activeRoom) mocks.mockObservedRoom(activeRoom); + return { + localParticipant: { identity: 'local-user' }, + isMicrophoneEnabled: false, + isCameraEnabled: false, + isScreenShareEnabled: false, + }; + }); +}); + +describe('LiveKit JS manual media test', () => { + it('renders only for an enabled active session with the controller bridge', () => { + const session = makeSession(); + expect(canRenderLivekitJsMediaTest(true, session)).toBe(true); + expect(canRenderLivekitJsMediaTest(false, session)).toBe(false); + expect(canRenderLivekitJsMediaTest(true, { ...session, lifecycle: 'joining-matrix' })).toBe( + false + ); + expect(canRenderLivekitJsMediaTest(true, { ...session, media: undefined })).toBe(false); + }); + + it('provides the controller-owned Room and does not connect or capture on render', () => { + const session = makeSession(); + render( + {}} /> + ); + + expect(mocks.mockObservedRoom).toHaveBeenCalledWith(room); + expect(room.connect).not.toHaveBeenCalled(); + expect(room.disconnect).not.toHaveBeenCalled(); + expect(session.media!.setMicrophoneEnabled).not.toHaveBeenCalled(); + expect(session.media!.setCameraEnabled).not.toHaveBeenCalled(); + expect(session.media!.setScreenShareEnabled).not.toHaveBeenCalled(); + expect(screen.getByText(/manual local media test only/i)).toBeInTheDocument(); + expect(screen.getByTestId('room-audio-renderer')).toBeInTheDocument(); + }); + + it('calls the narrow facade directly from manual controls', async () => { + const session = makeSession(); + render( + {}} /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Turn on microphone' })); + await waitFor(() => expect(session.media!.setMicrophoneEnabled).toHaveBeenCalledWith(true)); + fireEvent.click(screen.getByRole('button', { name: 'Start camera' })); + await waitFor(() => expect(session.media!.setCameraEnabled).toHaveBeenCalledWith(true)); + fireEvent.click(screen.getByRole('button', { name: 'Start screen sharing' })); + + await waitFor(() => expect(session.media!.setScreenShareEnabled).toHaveBeenCalledWith(true)); + }); + + it('shows safe controller failure text without exposing raw errors', async () => { + const session = makeSession(); + session.media!.setCameraEnabled = vi + .fn<() => Promise>() + .mockRejectedValue({ code: 'e2ee-key-not-ready', secret: 'do-not-show' }); + render( + {}} /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Start camera' })); + + expect(await screen.findByText('Waiting for encrypted media keys.')).toBeInTheDocument(); + expect(screen.queryByText(/do-not-show|secret/i)).not.toBeInTheDocument(); + }); + + it('keeps End as the only teardown action', () => { + const onHangup = vi.fn<() => void>(); + const session = makeSession(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'End' })); + + expect(onHangup).toHaveBeenCalledOnce(); + expect(room.disconnect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/call/LivekitJsMediaTest.tsx b/src/app/features/call/LivekitJsMediaTest.tsx new file mode 100644 index 0000000000..b09abd9e50 --- /dev/null +++ b/src/app/features/call/LivekitJsMediaTest.tsx @@ -0,0 +1,227 @@ +import { useState } from 'react'; +import { + Microphone, + MicrophoneSlash, + ScreenShare, + VideoCamera, + VideoCameraSlash, + sizedIcon, +} from '$components/icons/phosphor'; +import { Box, Button, color, IconButton, Text } from 'folds'; +import { + ParticipantTile, + RoomAudioRenderer, + RoomContext, + useLocalParticipant, + useTracks, + VideoTrack, +} from '@livekit/components-react'; +import { Track, type Room } from 'livekit-client'; +import type { LivekitJsCallSession } from '$state/livekitJsCall'; +import type { LivekitJsMediaFacade, LivekitJsMediaFailure } from './livekitJsController'; + +export const canRenderLivekitJsMediaTest = ( + enabled: boolean, + session: LivekitJsCallSession | undefined +): session is LivekitJsCallSession & { room: Room; media: LivekitJsMediaFacade } => + enabled && + session?.lifecycle === 'active' && + session.room !== undefined && + session.media !== undefined; + +const mediaFailureMessages: Record = { + 'media-test-disabled': 'Manual media test is disabled.', + 'e2ee-unsupported': 'Encrypted media is unavailable.', + 'e2ee-key-not-ready': 'Waiting for encrypted media keys.', + 'e2ee-key-failed': 'Encrypted media setup failed.', + 'room-not-active': 'The LiveKit room is not active.', + 'media-operation-failed': 'The media operation failed.', +}; + +const getMediaFailureMessage = (error: unknown): string => { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof error.code === 'string' && + error.code in mediaFailureMessages + ) { + return mediaFailureMessages[error.code as LivekitJsMediaFailure]; + } + return 'The media operation failed.'; +}; + +function VideoTiles({ localIdentity }: { localIdentity: string }) { + const tracks = useTracks([Track.Source.Camera, Track.Source.ScreenShare], { + onlySubscribed: false, + }); + + if (tracks.length === 0) { + return No camera or screen-share tracks are active.; + } + + return ( + + {tracks.map((track) => ( + + + + {track.participant.identity === localIdentity ? 'You' : track.participant.identity} + + + ))} + + ); +} + +function MediaControl({ + label, + enabled, + pending, + icon, + onClick, +}: { + label: string; + enabled: boolean; + pending: boolean; + icon: typeof Microphone; + onClick: () => void; +}) { + return ( + + {sizedIcon(icon, '100', { filled: enabled })} + + ); +} + +export function LivekitJsMediaTestSurface({ + room, + media, + onHangup, +}: { + room: Room; + media: LivekitJsMediaFacade; + onHangup: () => void; +}) { + return ( + + + + ); +} + +function LivekitJsMediaTestContent({ + media, + onHangup, +}: { + media: LivekitJsMediaFacade; + onHangup: () => void; +}) { + const { localParticipant, isMicrophoneEnabled, isCameraEnabled, isScreenShareEnabled } = + useLocalParticipant(); + const [pending, setPending] = useState<'microphone' | 'camera' | 'screen' | undefined>(); + const [error, setError] = useState(); + + const runMediaAction = async ( + kind: 'microphone' | 'camera' | 'screen', + action: () => Promise + ): Promise => { + if (pending) return; + setPending(kind); + setError(undefined); + try { + await action(); + } catch (actionError) { + setError(getMediaFailureMessage(actionError)); + } finally { + setPending(undefined); + } + }; + + return ( + + + LiveKit JS manual media test + + Manual local media test only · encrypted media is required · not release-ready + + + + + void runMediaAction('microphone', () => + media.setMicrophoneEnabled(!isMicrophoneEnabled) + ) + } + /> + + void runMediaAction('camera', () => media.setCameraEnabled(!isCameraEnabled)) + } + /> + + void runMediaAction('screen', () => media.setScreenShareEnabled(!isScreenShareEnabled)) + } + /> + + {error && ( + + {error} + + )} + + + ); +} diff --git a/src/app/features/call/callStartSelection.test.ts b/src/app/features/call/callStartSelection.test.ts new file mode 100644 index 0000000000..eeb520ea2e --- /dev/null +++ b/src/app/features/call/callStartSelection.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { selectCallStartOwner } from './callStartSelection'; + +describe('selectCallStartOwner', () => { + it('prioritizes the enabled supported LiveKit JS probe', () => { + expect(selectCallStartOwner({ livekitJsProbeEnabled: true, nativeProbeEnabled: true })).toBe( + 'livekit-js' + ); + }); + + it('preserves native then Element Call fallback priority', () => { + expect(selectCallStartOwner({ livekitJsProbeEnabled: false, nativeProbeEnabled: true })).toBe( + 'native' + ); + expect(selectCallStartOwner({ livekitJsProbeEnabled: false, nativeProbeEnabled: false })).toBe( + 'element' + ); + }); +}); diff --git a/src/app/features/call/callStartSelection.ts b/src/app/features/call/callStartSelection.ts new file mode 100644 index 0000000000..e408896872 --- /dev/null +++ b/src/app/features/call/callStartSelection.ts @@ -0,0 +1,13 @@ +export type CallStartOwner = 'livekit-js' | 'native' | 'element'; + +export const selectCallStartOwner = ({ + livekitJsProbeEnabled, + nativeProbeEnabled, +}: { + livekitJsProbeEnabled: boolean; + nativeProbeEnabled: boolean; +}): CallStartOwner => { + if (livekitJsProbeEnabled) return 'livekit-js'; + if (nativeProbeEnabled) return 'native'; + return 'element'; +}; diff --git a/src/app/features/call/livekitJsCallProbe.test.ts b/src/app/features/call/livekitJsCallProbe.test.ts new file mode 100644 index 0000000000..b7038c5e69 --- /dev/null +++ b/src/app/features/call/livekitJsCallProbe.test.ts @@ -0,0 +1,20 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const desktopTauri = vi.hoisted(() => vi.fn<() => boolean>()); + +vi.mock('$utils/platform', () => ({ isDesktopTauri: desktopTauri })); + +import { isLivekitJsCallProbeEnabled } from './livekitJsCallProbe'; + +beforeEach(() => desktopTauri.mockReset()); + +describe('isLivekitJsCallProbeEnabled', () => { + it('requires both the persisted setting and supported desktop Tauri runtime', () => { + desktopTauri.mockReturnValue(true); + expect(isLivekitJsCallProbeEnabled(false)).toBe(false); + expect(isLivekitJsCallProbeEnabled(true)).toBe(true); + + desktopTauri.mockReturnValue(false); + expect(isLivekitJsCallProbeEnabled(true)).toBe(false); + }); +}); diff --git a/src/app/features/call/livekitJsCallProbe.ts b/src/app/features/call/livekitJsCallProbe.ts new file mode 100644 index 0000000000..2ef9076ad7 --- /dev/null +++ b/src/app/features/call/livekitJsCallProbe.ts @@ -0,0 +1,4 @@ +import { isDesktopTauri } from '$utils/platform'; + +export const isLivekitJsCallProbeEnabled = (enabled = false): boolean => + enabled && isDesktopTauri(); diff --git a/src/app/features/call/livekitJsController.test.ts b/src/app/features/call/livekitJsController.test.ts new file mode 100644 index 0000000000..acd3fd3fe9 --- /dev/null +++ b/src/app/features/call/livekitJsController.test.ts @@ -0,0 +1,917 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { + MatrixRTCSessionEvent, + type CallMembership, + type MatrixRTCSession, +} from '$types/matrix-sdk'; +import type { MatrixClient, Room as MatrixRoom } from '$types/matrix-sdk'; +import type { Room as LivekitRoom, RoomOptions } from 'livekit-client'; +import { + createLivekitJsController, + type LivekitJsControllerDependencies, + type LivekitJsPlatformBridge, + type LivekitJsPlatformStartRequest, +} from './livekitJsController'; +import type { PlatformCallEvent } from '$plugins/call/platformCallLifecycle'; +import type { + LivekitMatrixKeyProvider, + LivekitMatrixKeyProviderState, +} from './livekitMatrixKeyProvider'; +import { resetCallOwnerForTests } from '$state/callOwner'; + +const transport = { type: 'livekit' as const, livekit_service_url: 'https://sfu.example' }; +const room = { roomId: '!room:example.org' } as MatrixRoom; +const membership = { + userId: '@alice:example.org', + deviceId: 'DEVICE', + rtcBackendIdentity: 'local-backend-identity', +} as CallMembership; + +const deferred = (): { + promise: Promise; + resolve: (value: T) => void; +} => { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +}; + +type SessionHandler = (...args: unknown[]) => void; + +type TestSession = MatrixRTCSession & { + handlers: Map; +}; + +const makeSession = (order: string[] = []): TestSession => { + const handlers = new Map(); + const session = { + handlers, + memberships: [] as CallMembership[], + slotId: 'm.call#real-slot', + on: vi.fn<(...args: unknown[]) => void>().mockImplementation((event, handler) => { + handlers.set(event as MatrixRTCSessionEvent, handler as SessionHandler); + }), + removeListener: vi.fn<(...args: unknown[]) => void>().mockImplementation((event, handler) => { + if (handlers.get(event as MatrixRTCSessionEvent) === handler) { + handlers.delete(event as MatrixRTCSessionEvent); + } + }), + joinRTCSession: vi + .fn<(...args: unknown[]) => void>() + .mockImplementation(() => order.push('join')), + leaveRoomSession: vi.fn().mockImplementation(async () => { + order.push('leave'); + return true; + }), + } as unknown as TestSession; + return session; +}; + +const emitOwnMembership = (session: TestSession): void => { + session.memberships = [membership]; + session.handlers.get(MatrixRTCSessionEvent.MembershipsChanged)?.([], [membership]); +}; + +type FakeProvider = { + state: LivekitMatrixKeyProviderState; + attach: Mock<(session: MatrixRTCSession) => void>; + detach: Mock<() => void>; + setLocalOutboundIdentity: Mock<(identity: string | undefined) => void>; + getKeyState: Mock<() => LivekitMatrixKeyProviderState>; + subscribe: Mock< + (listener: (state: Readonly) => void) => () => void + >; +}; + +const makeProvider = (state: Partial = {}): FakeProvider => { + const provider = { + state: { + ready: false, + localOutboundIdentity: null, + keyIndex: null, + lastImportFailure: null, + ...state, + }, + attach: vi.fn<(session: MatrixRTCSession) => void>(), + detach: vi.fn<() => void>(), + setLocalOutboundIdentity: vi.fn<(identity: string | undefined) => void>(), + getKeyState: vi.fn<() => LivekitMatrixKeyProviderState>(), + subscribe: + vi.fn<(listener: (state: Readonly) => void) => () => void>(), + } as FakeProvider; + provider.getKeyState.mockImplementation(() => provider.state); + provider.subscribe.mockImplementation((listener) => { + listener(provider.state); + return () => undefined; + }); + return provider; +}; + +const makeClient = (session: MatrixRTCSession): MatrixClient => + ({ + getDeviceId: () => 'DEVICE', + getSafeUserId: () => '@alice:example.org', + matrixRTC: { getRoomSession: () => session }, + }) as unknown as MatrixClient; + +const makeDependencies = ( + session: TestSession, + order: string[], + provider = makeProvider() +): { + dependencies: LivekitJsControllerDependencies; + provider: FakeProvider; + roomOptions: { value?: RoomOptions }; + livekitRoom: { + connect: Mock<(url: string, token: string, options?: unknown) => Promise>; + disconnect: Mock<() => Promise>; + localParticipant: { + setMicrophoneEnabled: Mock; + setCameraEnabled: Mock; + setScreenShareEnabled: Mock; + }; + }; + mediaParticipant: { + setMicrophoneEnabled: Mock; + setCameraEnabled: Mock; + setScreenShareEnabled: Mock; + }; +} => { + const worker = { terminate: vi.fn<() => void>() } as unknown as Worker; + provider.attach.mockImplementation(() => order.push('attach')); + provider.detach.mockImplementation(() => order.push('detach')); + const mediaParticipant = { + setMicrophoneEnabled: vi + .fn() + .mockResolvedValue(undefined), + setCameraEnabled: vi + .fn() + .mockResolvedValue(undefined), + setScreenShareEnabled: vi + .fn() + .mockResolvedValue(undefined), + }; + const livekitRoom = { + localParticipant: mediaParticipant, + connect: vi + .fn<(url: string, token: string, options?: unknown) => Promise>() + .mockImplementation(async () => { + order.push('connect'); + }), + disconnect: vi.fn<() => Promise>().mockImplementation(async () => { + order.push('disconnect'); + }), + }; + const roomOptions: { value?: RoomOptions } = {}; + return { + provider, + roomOptions, + livekitRoom, + mediaParticipant, + dependencies: { + createKeyProvider: () => provider as unknown as LivekitMatrixKeyProvider, + isE2EESupported: () => true, + createWorker: () => { + order.push('worker'); + return worker; + }, + createRoom: (options) => { + order.push('room'); + roomOptions.value = options; + return livekitRoom; + }, + getPreferredTransport: async () => { + order.push('transport'); + return transport; + }, + provisionToken: async () => { + order.push('provision'); + return { url: 'wss://livekit.example', jwt: 'jwt' }; + }, + }, + }; +}; + +const connectToActive = async ( + controller: ReturnType, + session: TestSession +): Promise => { + const connectPromise = controller.connect({ mx: makeClient(session), room }); + await vi.waitFor(() => + expect(session.on).toHaveBeenCalledWith( + MatrixRTCSessionEvent.MembershipsChanged, + expect.any(Function) + ) + ); + emitOwnMembership(session); + await connectPromise; +}; + +type FakePlatform = { + bridge: LivekitJsPlatformBridge; + getCapabilities: Mock<() => Promise>; + start: Mock<(request: LivekitJsPlatformStartRequest) => Promise>; + stop: Mock<(request: { sessionId: string }) => Promise>; + emit: (event: PlatformCallEvent) => void; +}; + +const makePlatform = (supported: boolean, order: string[]): FakePlatform => { + let handler: ((event: PlatformCallEvent) => void) | undefined; + const getCapabilities = vi.fn<() => Promise>().mockImplementation(async () => { + order.push('platform-caps'); + return { supported, microphone: supported, playback: supported }; + }); + const start = vi + .fn<(request: LivekitJsPlatformStartRequest) => Promise>() + .mockImplementation(async (request) => { + order.push(`platform-start:${request.microphone}:${request.playback}`); + return { sessionId: request.sessionId }; + }); + const stop = vi + .fn<(request: { sessionId: string }) => Promise>() + .mockImplementation(async () => { + order.push('platform-stop'); + return {}; + }); + const bridge: LivekitJsPlatformBridge = { + getCapabilities: getCapabilities as LivekitJsPlatformBridge['getCapabilities'], + start: start as LivekitJsPlatformBridge['start'], + stop: stop as LivekitJsPlatformBridge['stop'], + onEvent: async (nextHandler) => { + handler = nextHandler; + return () => { + order.push('platform-unlisten'); + }; + }, + }; + return { bridge, getCapabilities, start, stop, emit: (event) => handler?.(event) }; +}; + +describe('livekit JS controller', () => { + beforeEach(() => resetCallOwnerForTests()); + + it('attaches E2EE before joining and provisions before connecting one no-media Room', async () => { + const order: string[] = []; + const session = makeSession(order); + const { dependencies, provider, roomOptions, mediaParticipant, livekitRoom } = makeDependencies( + session, + order + ); + const controller = createLivekitJsController(dependencies); + + await connectToActive(controller, session); + + expect(order).toEqual([ + 'attach', + 'transport', + 'join', + 'provision', + 'worker', + 'room', + 'connect', + ]); + expect(provider.attach).toHaveBeenCalledBefore(session.joinRTCSession as Mock); + expect(session.joinRTCSession).toHaveBeenCalledWith( + { userId: '@alice:example.org', deviceId: 'DEVICE', memberId: '@alice:example.org:DEVICE' }, + [transport], + undefined, + { callIntent: 'audio', notificationType: 'notification', manageMediaKeys: true } + ); + expect(roomOptions.value?.encryption).toEqual({ + keyProvider: provider, + worker: expect.anything(), + }); + expect(mediaParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); + expect(mediaParticipant.setCameraEnabled).not.toHaveBeenCalled(); + expect(mediaParticipant.setScreenShareEnabled).not.toHaveBeenCalled(); + expect(controller.getState().room).toBe(livekitRoom); + expect(controller.getState().media).toBeUndefined(); + expect(controller.getState().lifecycle).toBe('active'); + }); + + it('refuses unsupported E2EE without joining or creating a Room', async () => { + const session = makeSession(); + const { dependencies } = makeDependencies(session, []); + dependencies.isE2EESupported = () => false; + const controller = createLivekitJsController(dependencies); + + await controller.connect({ mx: makeClient(session), room }); + + expect(session.joinRTCSession).not.toHaveBeenCalled(); + expect(controller.getState()).toMatchObject({ + lifecycle: 'failed', + failure: 'e2ee-unsupported', + }); + }); + + it('refuses a provider import failure without connecting LiveKit', async () => { + const session = makeSession(); + const provider = makeProvider({ lastImportFailure: 'import-failed' }); + const { dependencies } = makeDependencies(session, [], provider); + const controller = createLivekitJsController(dependencies); + + await controller.connect({ mx: makeClient(session), room }); + + expect(session.joinRTCSession).not.toHaveBeenCalled(); + expect(controller.getState()).toMatchObject({ + lifecycle: 'failed', + failure: 'e2ee-import-failed', + }); + }); + + it('disconnects LiveKit before leaving MatrixRTC and cleans up idempotently', async () => { + const order: string[] = []; + const session = makeSession(order); + const { dependencies, provider } = makeDependencies(session, order); + const controller = createLivekitJsController(dependencies); + await connectToActive(controller, session); + + await Promise.all([controller.disconnect(), controller.disconnect()]); + + expect(order.indexOf('disconnect')).toBeGreaterThan(-1); + expect(order.indexOf('detach')).toBeGreaterThan(order.indexOf('disconnect')); + expect(order.indexOf('disconnect')).toBeLessThan(order.indexOf('leave')); + expect(order.indexOf('detach')).toBeLessThan(order.indexOf('leave')); + expect(provider.detach).toHaveBeenCalledOnce(); + expect(controller.getState().room).toBeUndefined(); + expect(controller.getState().media).toBeUndefined(); + expect(controller.getState().lifecycle).toBe('idle'); + }); + + it('detaches once when setup fails after MatrixRTC join', async () => { + const session = makeSession(); + const { dependencies, provider } = makeDependencies(session, []); + dependencies.provisionToken = vi + .fn>() + .mockRejectedValue(new Error('provision failed')); + const controller = createLivekitJsController(dependencies); + const connectPromise = controller.connect({ mx: makeClient(session), room }); + + await vi.waitFor(() => + expect(session.on).toHaveBeenCalledWith( + MatrixRTCSessionEvent.MembershipsChanged, + expect.any(Function) + ) + ); + emitOwnMembership(session); + await connectPromise; + + expect(provider.detach).toHaveBeenCalledOnce(); + expect(session.leaveRoomSession).toHaveBeenCalledOnce(); + expect(controller.getState().room).toBeUndefined(); + expect(controller.getState().media).toBeUndefined(); + expect(controller.getState().lifecycle).toBe('failed'); + }); + + it('cancels during provisioning without creating a Room or becoming active', async () => { + const session = makeSession(); + const pendingProvision = deferred<{ url: string; jwt: string }>(); + const { dependencies, provider, roomOptions } = makeDependencies(session, []); + dependencies.provisionToken = vi + .fn>() + .mockImplementation(() => pendingProvision.promise); + const controller = createLivekitJsController(dependencies); + const connectPromise = controller.connect({ mx: makeClient(session), room }); + + await vi.waitFor(() => + expect(session.on).toHaveBeenCalledWith( + MatrixRTCSessionEvent.MembershipsChanged, + expect.any(Function) + ) + ); + emitOwnMembership(session); + await vi.waitFor(() => expect(dependencies.provisionToken).toHaveBeenCalledOnce()); + const disconnectPromise = controller.disconnect(); + pendingProvision.resolve({ url: 'wss://livekit.example', jwt: 'jwt' }); + await Promise.all([connectPromise, disconnectPromise]); + + expect(roomOptions.value).toBeUndefined(); + expect(provider.detach).toHaveBeenCalledOnce(); + expect(session.leaveRoomSession).toHaveBeenCalledOnce(); + expect(controller.getState().room).toBeUndefined(); + expect(controller.getState().media).toBeUndefined(); + expect(controller.getState().lifecycle).toBe('idle'); + }); + + it('cancels during Room.connect and cleans up its stale room before leaving', async () => { + const session = makeSession(); + const pendingConnect = deferred(); + const { dependencies, provider } = makeDependencies(session, []); + const livekitRoom = { + connect: vi + .fn<(url: string, token: string, options?: unknown) => Promise>() + .mockImplementation(() => pendingConnect.promise), + disconnect: vi.fn<() => Promise>().mockResolvedValue(undefined), + }; + dependencies.createRoom = () => livekitRoom; + const controller = createLivekitJsController(dependencies); + const connectPromise = controller.connect({ mx: makeClient(session), room }); + + await vi.waitFor(() => + expect(session.on).toHaveBeenCalledWith( + MatrixRTCSessionEvent.MembershipsChanged, + expect.any(Function) + ) + ); + emitOwnMembership(session); + await vi.waitFor(() => expect(livekitRoom.connect).toHaveBeenCalledOnce()); + const disconnectPromise = controller.disconnect(); + pendingConnect.resolve(); + await Promise.all([connectPromise, disconnectPromise]); + + expect(livekitRoom.disconnect).toHaveBeenCalledOnce(); + expect(provider.detach).toHaveBeenCalledOnce(); + expect(session.leaveRoomSession).toHaveBeenCalledOnce(); + expect(controller.getState().lifecycle).toBe('idle'); + expect(controller.getState().lifecycle).not.toBe('active'); + }); + + it('rejects duplicate setup and cancellation leaves started MatrixRTC membership', async () => { + const session = makeSession(); + const { dependencies, provider } = makeDependencies(session, []); + const controller = createLivekitJsController(dependencies); + const firstConnect = controller.connect({ mx: makeClient(session), room }); + await vi.waitFor(() => + expect(session.on).toHaveBeenCalledWith( + MatrixRTCSessionEvent.MembershipsChanged, + expect.any(Function) + ) + ); + + await expect(controller.connect({ mx: makeClient(session), room })).rejects.toThrow( + 'already in use' + ); + await controller.disconnect(); + await firstConnect; + + expect(session.leaveRoomSession).toHaveBeenCalledWith(5000); + expect(provider.detach).toHaveBeenCalledOnce(); + expect(controller.getState().lifecycle).toBe('idle'); + }); + + it('does not expose media publication methods', () => { + const session = makeSession(); + const { dependencies } = makeDependencies(session, []); + const controller = createLivekitJsController(dependencies); + + expect(Object.keys(controller).toSorted()).toEqual([ + 'connect', + 'disconnect', + 'getState', + 'subscribe', + ]); + expect('publishTrack' in controller).toBe(false); + expect('setMicrophoneEnabled' in controller).toBe(false); + expect('setCameraEnabled' in controller).toBe(false); + expect('setScreenShareEnabled' in controller).toBe(false); + }); + + it('disconnects the old mode before replacing it with a manual-media controller', async () => { + const firstSession = makeSession(); + const first = makeDependencies(firstSession, []); + const firstController = createLivekitJsController(first.dependencies); + await connectToActive(firstController, firstSession); + + const replacementSession = makeSession(); + const replacement = makeDependencies(replacementSession, [], makeProvider({ ready: true })); + const replacementController = createLivekitJsController(replacement.dependencies, { + manualMediaTest: true, + }); + + await firstController.disconnect(); + expect(firstController.getState().room).toBeUndefined(); + await connectToActive(replacementController, replacementSession); + + expect(replacementController.getState().media).toBeDefined(); + }); + + it('publishes media only through the explicit manual-media-test controller', async () => { + const session = makeSession(); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies, mediaParticipant, livekitRoom } = makeDependencies(session, [], provider); + // No platform bridge configured: use an explicit unsupported (desktop) bridge. + dependencies.platformBridge = makePlatform(false, []).bridge; + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + + await connectToActive(controller, session); + await controller.setMicrophoneEnabled(true); + await controller.setCameraEnabled(true); + await controller.setScreenShareEnabled(true); + + expect(mediaParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true); + expect(mediaParticipant.setCameraEnabled).toHaveBeenCalledWith(true); + expect(mediaParticipant.setScreenShareEnabled).toHaveBeenCalledWith(true); + expect(controller.getState().room).toBe(livekitRoom); + expect(controller.getState().media?.setMicrophoneEnabled).toBeDefined(); + }); + + it('refuses manual media when the local key is not ready or has failed', async () => { + const session = makeSession(); + const provider = makeProvider(); + const { dependencies } = makeDependencies(session, [], provider); + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + + await connectToActive(controller, session); + await expect(controller.setMicrophoneEnabled(true)).rejects.toMatchObject({ + code: 'e2ee-key-not-ready', + }); + + provider.state.lastImportFailure = 'import-failed'; + await expect(controller.setCameraEnabled(true)).rejects.toMatchObject({ + code: 'e2ee-key-failed', + }); + }); + + it('refuses manual media when runtime E2EE support is unavailable', async () => { + const session = makeSession(); + const { dependencies } = makeDependencies(session, []); + dependencies.isE2EESupported = () => false; + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + + await expect(controller.setScreenShareEnabled(true)).rejects.toMatchObject({ + code: 'e2ee-unsupported', + }); + expect(controller.getState().mediaFailure).toBe('e2ee-unsupported'); + }); + + it('treats unsupported desktop platform capabilities as a no-op', async () => { + const order: string[] = []; + const session = makeSession(order); + const platform = makePlatform(false, order); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies, mediaParticipant } = makeDependencies(session, order, provider); + dependencies.platformBridge = platform.bridge; + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + + await connectToActive(controller, session); + await controller.setMicrophoneEnabled(true); + + expect(platform.getCapabilities).toHaveBeenCalledOnce(); + expect(platform.start).not.toHaveBeenCalled(); + expect(platform.stop).not.toHaveBeenCalled(); + expect(mediaParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true); + expect(controller.getState().platform).toBeUndefined(); + }); + + it('starts the platform lifecycle before enabling the local microphone', async () => { + const order: string[] = []; + const session = makeSession(order); + const platform = makePlatform(true, order); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies, mediaParticipant } = makeDependencies(session, order, provider); + dependencies.platformBridge = platform.bridge; + dependencies.createPlatformSessionId = () => 'opaque-session-1'; + mediaParticipant.setMicrophoneEnabled.mockImplementation(async () => { + order.push('mic'); + return undefined; + }); + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + + await connectToActive(controller, session); + expect(platform.start).not.toHaveBeenCalled(); + await controller.setMicrophoneEnabled(true); + + expect(order.indexOf('platform-caps')).toBeGreaterThan(order.indexOf('connect')); + expect(order.indexOf('platform-start:true:true')).toBeGreaterThan(order.indexOf('connect')); + expect(order.indexOf('platform-start:true:true')).toBeLessThan(order.indexOf('mic')); + expect(platform.start).toHaveBeenCalledWith({ + sessionId: 'opaque-session-1', + microphone: true, + playback: true, + }); + expect(controller.getState().platform?.active).toBe(true); + }); + + it('stops the platform lifecycle after Room disconnect and before MatrixRTC leave', async () => { + const order: string[] = []; + const session = makeSession(order); + const platform = makePlatform(true, order); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies, mediaParticipant } = makeDependencies(session, order, provider); + dependencies.platformBridge = platform.bridge; + mediaParticipant.setMicrophoneEnabled.mockImplementation(async () => { + order.push('mic'); + return undefined; + }); + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + await connectToActive(controller, session); + await controller.setMicrophoneEnabled(true); + + await controller.disconnect(); + + expect(order.lastIndexOf('mic')).toBeLessThan(order.indexOf('disconnect')); + expect(order.indexOf('disconnect')).toBeLessThan(order.indexOf('platform-stop')); + expect(order.indexOf('platform-stop')).toBeLessThan(order.indexOf('detach')); + expect(order.indexOf('platform-stop')).toBeLessThan(order.indexOf('leave')); + expect(platform.stop).toHaveBeenCalledOnce(); + expect(controller.getState().platform).toBeUndefined(); + expect(controller.getState().lifecycle).toBe('idle'); + }); + + it('refuses the media operation and skips platform stop when platform start fails', async () => { + const order: string[] = []; + const session = makeSession(order); + const platform = makePlatform(true, order); + platform.start.mockRejectedValue(new Error('native start failed')); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies, mediaParticipant } = makeDependencies(session, order, provider); + dependencies.platformBridge = platform.bridge; + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + await connectToActive(controller, session); + + await expect(controller.setMicrophoneEnabled(true)).rejects.toMatchObject({ + code: 'platform-lifecycle-failed', + }); + + expect(mediaParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); + expect(controller.getState().mediaFailure).toBe('platform-lifecycle-failed'); + expect(controller.getState().platform?.active).toBe(false); + + await controller.disconnect(); + expect(platform.stop).not.toHaveBeenCalled(); + expect(provider.detach).toHaveBeenCalledOnce(); + }); + + it('restarts platform lifecycle with the same session when flags change', async () => { + const order: string[] = []; + const session = makeSession(order); + const platform = makePlatform(true, order); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies } = makeDependencies(session, order, provider); + dependencies.platformBridge = platform.bridge; + dependencies.createPlatformSessionId = () => 'opaque-session-1'; + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + await connectToActive(controller, session); + + await controller.setCameraEnabled(true); + expect(platform.start).toHaveBeenLastCalledWith({ + sessionId: 'opaque-session-1', + microphone: false, + playback: true, + }); + expect(platform.stop).not.toHaveBeenCalled(); + + await controller.setMicrophoneEnabled(true); + expect(platform.stop).toHaveBeenCalledWith({ sessionId: 'opaque-session-1' }); + expect(platform.start).toHaveBeenLastCalledWith({ + sessionId: 'opaque-session-1', + microphone: true, + playback: true, + }); + expect(platform.start).toHaveBeenCalledTimes(2); + + const stopsBefore = platform.stop.mock.calls.length; + await controller.setMicrophoneEnabled(true); + expect(platform.stop.mock.calls.length).toBe(stopsBefore); + expect(platform.start).toHaveBeenCalledTimes(2); + }); + + it('filters stale session and revision platform events', async () => { + const order: string[] = []; + const session = makeSession(order); + const platform = makePlatform(true, order); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies } = makeDependencies(session, order, provider); + dependencies.platformBridge = platform.bridge; + dependencies.createPlatformSessionId = () => 'opaque-session-1'; + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + await connectToActive(controller, session); + await controller.setCameraEnabled(true); + + platform.emit({ revision: 2, sessionId: 'other-session', type: 'media_reset' }); + platform.emit({ revision: 3, sessionId: 'opaque-session-1', type: 'media_reset' }); + platform.emit({ + revision: 3, + sessionId: 'opaque-session-1', + type: 'focus_changed', + focused: false, + }); + platform.emit({ + revision: 4, + sessionId: 'opaque-session-1', + type: 'focus_changed', + focused: false, + }); + await vi.waitFor(() => expect(controller.getState().platform?.focused).toBe(false)); + + const snapshot = controller.getState().platform; + expect(snapshot?.mediaReset).toBe(true); + expect(snapshot?.focused).toBe(false); + expect(snapshot?.active).toBe(true); + expect(controller.getState().mediaFailure).toBeNull(); + }); + + it('refuses manual media when the platform capability lookup fails', async () => { + const order: string[] = []; + const session = makeSession(order); + const platform = makePlatform(false, order); + platform.getCapabilities.mockReset(); + platform.getCapabilities.mockRejectedValue(new Error('capability bridge unavailable')); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies, mediaParticipant } = makeDependencies(session, order, provider); + dependencies.platformBridge = platform.bridge; + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + await connectToActive(controller, session); + + await expect(controller.setMicrophoneEnabled(true)).rejects.toMatchObject({ + code: 'platform-lifecycle-failed', + }); + + expect(mediaParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); + expect(platform.start).not.toHaveBeenCalled(); + expect(controller.getState().mediaFailure).toBe('platform-lifecycle-failed'); + expect(controller.getState().platform).toBeUndefined(); + }); + + it.each([ + ['malformed supported', { supported: 'yes', microphone: true, playback: true }], + ['missing microphone', { supported: true, playback: true }], + ['malformed microphone', { supported: true, microphone: 1, playback: true }], + ['missing playback', { supported: true, microphone: true }], + ['malformed playback', { supported: true, microphone: true, playback: null }], + ['missing everything', {}], + ['a null result', null], + ['an undefined result', undefined], + ])( + 'refuses manual media without calling media methods on %s capability result', + async (_label, capabilities) => { + const order: string[] = []; + const session = makeSession(order); + const platform = makePlatform(false, order); + platform.getCapabilities.mockReset(); + platform.getCapabilities.mockResolvedValue(capabilities); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies, mediaParticipant } = makeDependencies(session, order, provider); + dependencies.platformBridge = platform.bridge; + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + await connectToActive(controller, session); + + await expect(controller.setMicrophoneEnabled(true)).rejects.toMatchObject({ + code: 'platform-lifecycle-failed', + }); + await expect(controller.setCameraEnabled(true)).rejects.toMatchObject({ + code: 'platform-lifecycle-failed', + }); + + expect(mediaParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); + expect(mediaParticipant.setCameraEnabled).not.toHaveBeenCalled(); + expect(mediaParticipant.setScreenShareEnabled).not.toHaveBeenCalled(); + expect(platform.start).not.toHaveBeenCalled(); + expect(controller.getState().mediaFailure).toBe('platform-lifecycle-failed'); + expect(controller.getState().platform).toBeUndefined(); + } + ); + + it('disables the LiveKit microphone track before downgrading the platform lifecycle', async () => { + const order: string[] = []; + const session = makeSession(order); + const platform = makePlatform(true, order); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies, mediaParticipant } = makeDependencies(session, order, provider); + dependencies.platformBridge = platform.bridge; + dependencies.createPlatformSessionId = () => 'opaque-session-1'; + mediaParticipant.setMicrophoneEnabled.mockImplementation(async (enabled) => { + order.push(enabled ? 'mic-on' : 'mic-off'); + return undefined; + }); + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + await connectToActive(controller, session); + await controller.setMicrophoneEnabled(true); + expect(platform.start).toHaveBeenLastCalledWith({ + sessionId: 'opaque-session-1', + microphone: true, + playback: true, + }); + + await controller.setMicrophoneEnabled(false); + + expect(order.indexOf('mic-on')).toBeLessThan(order.indexOf('mic-off')); + expect(order.indexOf('mic-off')).toBeLessThan(order.indexOf('platform-stop')); + expect(order.indexOf('platform-stop')).toBeLessThan(order.indexOf('platform-start:false:true')); + expect(platform.start).toHaveBeenLastCalledWith({ + sessionId: 'opaque-session-1', + microphone: false, + playback: true, + }); + expect(controller.getState().platform?.active).toBe(true); + expect(controller.getState().mediaFailure).toBeNull(); + }); + + it('retains the microphone lifecycle when the JS microphone disable fails', async () => { + const order: string[] = []; + const session = makeSession(order); + const platform = makePlatform(true, order); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies, mediaParticipant } = makeDependencies(session, order, provider); + dependencies.platformBridge = platform.bridge; + dependencies.createPlatformSessionId = () => 'opaque-session-1'; + mediaParticipant.setMicrophoneEnabled.mockImplementation(async (enabled) => { + order.push(enabled ? 'mic-on' : 'mic-off'); + return undefined; + }); + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + await connectToActive(controller, session); + await controller.setMicrophoneEnabled(true); + + mediaParticipant.setMicrophoneEnabled.mockRejectedValueOnce(new Error('track stuck')); + await expect(controller.setMicrophoneEnabled(false)).rejects.toMatchObject({ + code: 'media-operation-failed', + }); + + expect(platform.stop).not.toHaveBeenCalled(); + expect(platform.start).toHaveBeenCalledTimes(1); + expect(controller.getState().mediaFailure).toBe('media-operation-failed'); + expect(controller.getState().platform?.active).toBe(true); + + // A later media operation must not downgrade the microphone lifecycle. + await controller.setCameraEnabled(true); + expect(platform.stop).not.toHaveBeenCalled(); + expect(platform.start).toHaveBeenCalledTimes(1); + + // Teardown still stops the retained microphone lifecycle. + await controller.disconnect(); + expect(platform.stop).toHaveBeenCalledWith({ sessionId: 'opaque-session-1' }); + expect(order.lastIndexOf('platform-stop')).toBeLessThan(order.indexOf('leave')); + expect(controller.getState().lifecycle).toBe('idle'); + }); + + it('stops manual media safely on a platform failure event', async () => { + const order: string[] = []; + const session = makeSession(order); + const platform = makePlatform(true, order); + const provider = makeProvider({ + ready: true, + localOutboundIdentity: 'local-backend-identity', + keyIndex: 1, + }); + const { dependencies, mediaParticipant } = makeDependencies(session, order, provider); + dependencies.platformBridge = platform.bridge; + dependencies.createPlatformSessionId = () => 'opaque-session-1'; + const controller = createLivekitJsController(dependencies, { manualMediaTest: true }); + await connectToActive(controller, session); + await controller.setMicrophoneEnabled(true); + + platform.emit({ + revision: 1, + sessionId: 'opaque-session-1', + type: 'failed', + code: 'audio_unavailable', + }); + await vi.waitFor(() => + expect(controller.getState().mediaFailure).toBe('platform-lifecycle-failed') + ); + + expect(controller.getState().platform).toMatchObject({ + active: false, + failure: 'audio_unavailable', + }); + expect(mediaParticipant.setMicrophoneEnabled).toHaveBeenLastCalledWith(false); + expect(controller.getState().lifecycle).toBe('active'); + }); +}); diff --git a/src/app/features/call/livekitJsController.ts b/src/app/features/call/livekitJsController.ts new file mode 100644 index 0000000000..02b441148d --- /dev/null +++ b/src/app/features/call/livekitJsController.ts @@ -0,0 +1,738 @@ +import { Room as LivekitRoom, type RoomOptions } from 'livekit-client'; +import type { AutoDiscoveryInfo } from '../../cs-api'; +import type { MatrixClient, MatrixRTCSession, Room as MatrixRoom } from '$types/matrix-sdk'; +import { + disconnectLivekitThenLeaveMatrixRTC, + joinAndProvisionMatrixRTC, +} from './matrixRtcCallLifecycle'; +import { + LivekitMatrixKeyProvider, + type LivekitMatrixKeyProviderState, + isLivekitE2EESupported, +} from './livekitMatrixKeyProvider'; +import { getPreferredLivekitTransport, provisionLivekitToken } from './livekitProvisioning'; +import { acquireCallOwner, type CallOwnerLease } from '$state/callOwner'; +import { + getPlatformCapabilities as getPluginPlatformCapabilities, + onPlatformCallEvent, + startPlatformLifecycle, + stopPlatformLifecycle, + type PlatformCallCapabilities, + type PlatformCallEvent, + type PlatformCallFailureCode, + type PlatformCallRoute, +} from '$plugins/call/platformCallLifecycle'; + +export type LivekitJsControllerLifecycle = + | 'idle' + | 'joining-matrix' + | 'provisioning' + | 'connecting-livekit' + | 'active' + | 'stopping' + | 'failed'; + +export type LivekitJsControllerFailure = 'e2ee-unsupported' | 'e2ee-import-failed' | 'setup-failed'; + +export type LivekitJsMediaFailure = + | 'media-test-disabled' + | 'e2ee-unsupported' + | 'e2ee-key-not-ready' + | 'e2ee-key-failed' + | 'room-not-active' + | 'platform-lifecycle-failed' + | 'media-operation-failed'; + +export class LivekitJsMediaError extends Error { + public constructor(public readonly code: LivekitJsMediaFailure) { + super(`LiveKit JS media test refused: ${code}`); + this.name = 'LivekitJsMediaError'; + } +} + +export type LivekitJsMediaFacade = { + setMicrophoneEnabled: (enabled: boolean) => Promise; + setCameraEnabled: (enabled: boolean) => Promise; + setScreenShareEnabled: (enabled: boolean) => Promise; +}; + +export type LivekitJsPlatformStartRequest = { + sessionId: string; + microphone: boolean; + playback: boolean; +}; + +export type LivekitJsPlatformBridge = { + getCapabilities: () => Promise; + start: (request: LivekitJsPlatformStartRequest) => Promise; + stop: (request: { sessionId: string }) => Promise; + onEvent: (handler: (event: PlatformCallEvent) => void) => Promise<() => void>; +}; + +export type LivekitJsPlatformState = { + active: boolean; + focused: boolean; + route: PlatformCallRoute | null; + interrupted: boolean; + mediaReset: boolean; + failure: PlatformCallFailureCode | null; +}; + +export type LivekitJsControllerState = { + lifecycle: LivekitJsControllerLifecycle; + failure: LivekitJsControllerFailure | null; + mediaFailure: LivekitJsMediaFailure | null; + room?: LivekitRoom; + media?: LivekitJsMediaFacade; + platform?: LivekitJsPlatformState; + e2ee: Readonly; +}; + +export type LivekitJsControllerStateListener = (state: Readonly) => void; + +export type LivekitJsConnectOptions = { + mx: MatrixClient; + room: MatrixRoom; + discovery?: Pick; + callIntent?: 'audio' | 'video'; + dm?: boolean; + ongoing?: boolean; +}; + +type LivekitLocalParticipantLike = Pick< + LivekitRoom['localParticipant'], + 'setMicrophoneEnabled' | 'setCameraEnabled' | 'setScreenShareEnabled' +>; + +type LivekitRoomLike = Pick & { + localParticipant?: LivekitLocalParticipantLike; +}; + +type ManualMediaMethods = LivekitJsMediaFacade; + +type LivekitJsControllerBase = { + connect: (options: LivekitJsConnectOptions) => Promise; + disconnect: () => Promise; + getState: () => Readonly; + subscribe: (listener: LivekitJsControllerStateListener) => () => void; +}; + +type LivekitJsManualController = LivekitJsControllerBase & ManualMediaMethods; + +export type LivekitJsControllerOptions = { + manualMediaTest?: boolean; +}; + +export type LivekitJsControllerDependencies = { + createRoom?: (options: RoomOptions) => LivekitRoomLike; + createWorker?: () => Worker; + createKeyProvider?: () => LivekitMatrixKeyProvider; + isE2EESupported?: () => boolean; + getPreferredTransport?: typeof getPreferredLivekitTransport; + provisionToken?: typeof provisionLivekitToken; + platformBridge?: LivekitJsPlatformBridge; + createPlatformSessionId?: () => string; +}; + +type ControllerRecord = { + session: MatrixRTCSession; + provider: LivekitMatrixKeyProvider; + worker?: Worker; + room?: LivekitRoomLike; + matrixJoinStarted: boolean; + providerDetached: boolean; + ownerLease: CallOwnerLease; + cancelled: boolean; + e2eeFailure: boolean; + cancelMembershipWait?: () => void; + removeKeyStateListener?: () => void; + platformSessionId?: string; + platformCaps?: Promise; + platformActive: boolean; + platformFlags: { microphone: boolean; playback: boolean }; + platformDesired: { microphone: boolean; playback: boolean }; + platformRevision: number; + platformUnlisten?: () => void; + platformEventOp?: Promise; + mediaPublished: boolean; + cleanupPromise?: Promise; + resourcesReady: Promise; + resolveResources: () => void; +}; + +const initialE2EEState: LivekitMatrixKeyProviderState = { + ready: false, + localOutboundIdentity: null, + keyIndex: null, + lastImportFailure: null, +}; + +const defaultCreateRoom = (options: RoomOptions): LivekitRoomLike => new LivekitRoom(options); + +const defaultCreateWorker = (): Worker => + new Worker(new URL('livekit-client/e2ee-worker', import.meta.url), { type: 'module' }); + +const defaultPlatformBridge: LivekitJsPlatformBridge = { + getCapabilities: getPluginPlatformCapabilities, + start: startPlatformLifecycle, + stop: stopPlatformLifecycle, + onEvent: onPlatformCallEvent, +}; + +let platformSessionCounter = 0; +const defaultCreatePlatformSessionId = (): string => { + platformSessionCounter += 1; + const random = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${platformSessionCounter.toString(36)}`; + return `livekit-js-platform-${random}`; +}; + +export function createLivekitJsController( + dependencies: LivekitJsControllerDependencies | undefined, + options: { manualMediaTest: true } +): LivekitJsManualController; +export function createLivekitJsController( + dependencies?: LivekitJsControllerDependencies, + options?: LivekitJsControllerOptions +): LivekitJsControllerBase; +export function createLivekitJsController( + dependencies: LivekitJsControllerDependencies = {}, + controllerOptions: LivekitJsControllerOptions = {} +) { + const createRoom = dependencies.createRoom ?? defaultCreateRoom; + const createWorker = dependencies.createWorker ?? defaultCreateWorker; + const createKeyProvider = + dependencies.createKeyProvider ?? (() => new LivekitMatrixKeyProvider()); + const supportsE2EE = dependencies.isE2EESupported ?? isLivekitE2EESupported; + const getPreferredTransport = dependencies.getPreferredTransport ?? getPreferredLivekitTransport; + const provisionToken = dependencies.provisionToken ?? provisionLivekitToken; + const platformBridge = dependencies.platformBridge ?? defaultPlatformBridge; + const createPlatformSessionId = + dependencies.createPlatformSessionId ?? defaultCreatePlatformSessionId; + const manualMediaTest = controllerOptions.manualMediaTest === true; + + let state: LivekitJsControllerState = { + lifecycle: 'idle', + failure: null, + mediaFailure: null, + room: undefined, + media: undefined, + e2ee: initialE2EEState, + }; + let record: ControllerRecord | undefined; + let operation: Promise | undefined; + const listeners = new Set(); + + const publish = (changes: Partial): void => { + state = { ...state, ...changes }; + const snapshot = { ...state, e2ee: { ...state.e2ee } }; + listeners.forEach((listener) => { + try { + listener(snapshot); + } catch { + // A state observer must not interrupt lifecycle cleanup. + } + }); + }; + + const cleanup = async ( + current: ControllerRecord, + result: 'idle' | 'failed', + failure: LivekitJsControllerFailure | null + ): Promise => { + if (current.cleanupPromise) { + await current.cleanupPromise; + return; + } + + current.cancelled = true; + current.cancelMembershipWait?.(); + current.cancelMembershipWait = undefined; + publish({ lifecycle: 'stopping' }); + const detachProvider = (): void => { + if (current.providerDetached) return; + current.providerDetached = true; + try { + current.provider.detach(); + } catch {} + }; + current.cleanupPromise = (async () => { + if (!current.room && !current.worker) await current.resourcesReady; + const stopRoom = async (): Promise => { + if (current.mediaPublished && current.room?.localParticipant) { + try { + await current.room.localParticipant.setMicrophoneEnabled(false); + await current.room.localParticipant.setCameraEnabled(false); + await current.room.localParticipant.setScreenShareEnabled(false); + } catch { + // Best-effort unpublish; disconnect clears any remaining tracks. + } + } + await current.room?.disconnect(); + }; + const stopPlatform = async (): Promise => { + if (current.platformEventOp) { + try { + await current.platformEventOp; + } catch { + // Event settling must not block platform stop. + } + } + if (current.platformActive && current.platformSessionId) { + try { + await platformBridge.stop({ sessionId: current.platformSessionId }); + } catch { + // Platform stop failures never block room/provider cleanup. + } + current.platformActive = false; + } + current.platformUnlisten?.(); + current.platformUnlisten = undefined; + publish({ platform: undefined }); + }; + if (current.matrixJoinStarted) { + await disconnectLivekitThenLeaveMatrixRTC(async () => { + try { + await stopRoom(); + } finally { + await stopPlatform(); + detachProvider(); + } + }, current.session); + } else { + try { + await stopRoom(); + } catch { + // Cleanup continues even when a setup room rejects disconnect. + } finally { + await stopPlatform(); + detachProvider(); + } + } + current.worker?.terminate(); + current.removeKeyStateListener?.(); + current.removeKeyStateListener = undefined; + current.ownerLease.release(); + if (record === current) record = undefined; + publish({ + lifecycle: result, + failure, + mediaFailure: result === 'idle' ? null : state.mediaFailure, + room: undefined, + media: undefined, + ...(result === 'idle' ? { e2ee: initialE2EEState } : {}), + }); + })(); + await current.cleanupPromise; + }; + + const setup = async ( + current: ControllerRecord, + connectOptions: LivekitJsConnectOptions + ): Promise => { + let failure: LivekitJsControllerFailure | null = null; + try { + publish({ lifecycle: 'joining-matrix', failure: null }); + if (!supportsE2EE()) { + failure = 'e2ee-unsupported'; + } else { + current.provider.attach(current.session); + if (current.e2eeFailure || current.provider.getKeyState().lastImportFailure) { + failure = 'e2ee-import-failed'; + } else { + const joined = await joinAndProvisionMatrixRTC({ + mx: connectOptions.mx, + room: connectOptions.room, + session: current.session, + discovery: connectOptions.discovery, + getPreferredTransport, + provisionToken, + callIntent: connectOptions.callIntent ?? 'audio', + ...(connectOptions.ongoing + ? {} + : { notificationType: connectOptions.dm ? 'ring' : 'notification' }), + manageMediaKeys: true, + isCancelled: () => current.cancelled, + onStage: (stage) => publish({ lifecycle: stage }), + onMembershipWait: (cancel) => { + current.cancelMembershipWait = cancel; + }, + onJoinStarted: () => { + current.matrixJoinStarted = true; + }, + }); + + current.provider.setLocalOutboundIdentity(joined.ownMembership?.rtcBackendIdentity); + if (current.e2eeFailure || current.provider.getKeyState().lastImportFailure) { + failure = 'e2ee-import-failed'; + } else if (!current.cancelled) { + publish({ lifecycle: 'connecting-livekit' }); + current.worker = createWorker(); + current.room = createRoom({ + encryption: { + keyProvider: current.provider, + worker: current.worker, + }, + }); + await current.room.connect(joined.provisioned.url, joined.provisioned.jwt); + if (current.e2eeFailure) failure = 'e2ee-import-failed'; + } + } + } + } catch { + failure = current.e2eeFailure ? 'e2ee-import-failed' : 'setup-failed'; + } finally { + current.resolveResources(); + } + + if (failure) { + await cleanup(current, 'failed', failure); + } else if (current.cancelled) { + await cleanup(current, 'idle', null); + } else { + publish({ + lifecycle: 'active', + failure: null, + room: current.room as LivekitRoom, + media: manualMediaTest + ? { + setMicrophoneEnabled, + setCameraEnabled, + setScreenShareEnabled, + } + : undefined, + }); + } + }; + + const requireMediaParticipant = (): LivekitLocalParticipantLike => { + if (!manualMediaTest) throw new LivekitJsMediaError('media-test-disabled'); + if (!supportsE2EE()) throw new LivekitJsMediaError('e2ee-unsupported'); + if (state.lifecycle !== 'active' || !record?.room?.localParticipant) { + throw new LivekitJsMediaError('room-not-active'); + } + + const keyState = record.provider.getKeyState(); + if (keyState.lastImportFailure) throw new LivekitJsMediaError('e2ee-key-failed'); + if (!keyState.ready || !keyState.localOutboundIdentity) { + throw new LivekitJsMediaError('e2ee-key-not-ready'); + } + return record.room.localParticipant; + }; + + const publishPlatform = (current: ControllerRecord, patch: Partial) => { + if (record !== current) return; + const base: LivekitJsPlatformState = state.platform ?? { + active: current.platformActive, + focused: true, + route: null, + interrupted: false, + mediaReset: false, + failure: null, + }; + publish({ platform: { ...base, ...patch } }); + }; + + const getPlatformCapabilities = ( + current: ControllerRecord + ): Promise => { + if (!current.platformCaps) { + current.platformCaps = platformBridge.getCapabilities(); + } + return current.platformCaps; + }; + + const applyPlatformEvent = async ( + current: ControllerRecord, + event: PlatformCallEvent + ): Promise => { + if (current.cancelled || event.sessionId !== current.platformSessionId) return; + if (event.revision <= current.platformRevision) return; + current.platformRevision = event.revision; + switch (event.type) { + case 'focus_changed': + publishPlatform(current, { focused: event.focused }); + break; + case 'route_changed': + publishPlatform(current, { route: event.route }); + break; + case 'interrupted': + publishPlatform(current, { interrupted: event.state === 'began' }); + break; + case 'media_reset': + publishPlatform(current, { mediaReset: true }); + break; + case 'failed': { + // Native code never controls tracks; JS fails the manual test safely. + current.platformActive = false; + publishPlatform(current, { active: false, failure: event.code }); + publish({ mediaFailure: 'platform-lifecycle-failed' }); + const participant = current.room?.localParticipant; + if (participant && current.mediaPublished) { + try { + await participant.setMicrophoneEnabled(false); + await participant.setCameraEnabled(false); + await participant.setScreenShareEnabled(false); + } catch { + // Disconnect cleanup unpublishes any remaining tracks. + } + } + break; + } + default: + break; + } + }; + + const ensurePlatformListener = async (current: ControllerRecord): Promise => { + if (current.platformUnlisten) return; + const unlisten = await platformBridge.onEvent((event) => { + current.platformEventOp = applyPlatformEvent(current, event).finally(() => { + current.platformEventOp = undefined; + }); + }); + if (current.cancelled || !current.platformActive) { + unlisten(); + return; + } + current.platformUnlisten = unlisten; + }; + + const ensurePlatformLifecycle = async (current: ControllerRecord): Promise => { + let capabilities: PlatformCallCapabilities; + try { + capabilities = await getPlatformCapabilities(current); + } catch { + // A rejected capability request is not the desktop no-op case. + throw new LivekitJsMediaError('platform-lifecycle-failed'); + } + if ( + !capabilities || + typeof capabilities.supported !== 'boolean' || + typeof capabilities.microphone !== 'boolean' || + typeof capabilities.playback !== 'boolean' + ) { + // A malformed capability result fails closed like a rejection. + throw new LivekitJsMediaError('platform-lifecycle-failed'); + } + if (!capabilities.supported || current.cancelled) return; + + const desired = { ...current.platformDesired }; + if ( + current.platformActive && + current.platformFlags.microphone === desired.microphone && + current.platformFlags.playback === desired.playback + ) { + return; + } + + try { + if (current.platformActive && current.platformSessionId) { + await platformBridge.stop({ sessionId: current.platformSessionId }); + current.platformActive = false; + } + if (!current.platformSessionId) current.platformSessionId = createPlatformSessionId(); + await platformBridge.start({ sessionId: current.platformSessionId, ...desired }); + current.platformFlags = desired; + current.platformActive = true; + publishPlatform(current, { active: true, failure: null }); + await ensurePlatformListener(current); + } catch { + current.platformActive = false; + publishPlatform(current, { active: false }); + throw new LivekitJsMediaError('platform-lifecycle-failed'); + } + }; + + const runMediaAction = async ( + action: (participant: LivekitLocalParticipantLike) => Promise, + platformOrder: 'platform-first' | 'platform-after' = 'platform-first' + ): Promise => { + let current: ControllerRecord; + let participant: LivekitLocalParticipantLike; + try { + participant = requireMediaParticipant(); + // requireMediaParticipant guarantees an active record; narrow for TS. + if (!record) throw new LivekitJsMediaError('room-not-active'); + current = record; + if (platformOrder === 'platform-first') await ensurePlatformLifecycle(current); + } catch (error) { + if (error instanceof LivekitJsMediaError) publish({ mediaFailure: error.code }); + throw error; + } + + try { + await action(participant); + } catch { + const error = new LivekitJsMediaError('media-operation-failed'); + publish({ mediaFailure: error.code }); + throw error; + } + if (platformOrder === 'platform-after') { + try { + await ensurePlatformLifecycle(current); + } catch (error) { + if (error instanceof LivekitJsMediaError) publish({ mediaFailure: error.code }); + throw error; + } + } + publish({ mediaFailure: null }); + }; + + const markMediaPublished = (promise: Promise, published: boolean): Promise => + promise.then(() => { + if (published && record) record.mediaPublished = true; + }); + + const setMicrophoneEnabled = (enabled: boolean): Promise => { + if (record) record.platformDesired.microphone = enabled; + const promise = markMediaPublished( + // A microphone disable must unpublish the JS track before the platform + // lifecycle is stopped or downgraded. + runMediaAction( + (participant) => participant.setMicrophoneEnabled(enabled), + enabled ? 'platform-first' : 'platform-after' + ), + enabled + ); + if (enabled) return promise; + return promise.catch((error) => { + // The JS track failed to disable, so the microphone may still be + // live: retain the microphone platform lifecycle. + if ( + record && + error instanceof LivekitJsMediaError && + error.code === 'media-operation-failed' + ) { + record.platformDesired.microphone = true; + } + throw error; + }); + }; + + const setCameraEnabled = (enabled: boolean): Promise => + markMediaPublished( + runMediaAction((participant) => participant.setCameraEnabled(enabled)), + enabled + ); + + const setScreenShareEnabled = (enabled: boolean): Promise => + markMediaPublished( + runMediaAction((participant) => participant.setScreenShareEnabled(enabled)), + enabled + ); + + const connect = (connectOptions: LivekitJsConnectOptions): Promise => { + if (state.lifecycle === 'failed' && !record && !operation) { + publish({ + lifecycle: 'idle', + failure: null, + mediaFailure: null, + room: undefined, + media: undefined, + e2ee: initialE2EEState, + }); + } + if (record || operation || state.lifecycle !== 'idle') { + return Promise.reject(new Error('LiveKit JS call controller is already in use')); + } + + const ownerLease = acquireCallOwner('livekit-js', connectOptions.room.roomId); + if (!ownerLease) { + publish({ + lifecycle: 'failed', + failure: 'setup-failed', + mediaFailure: null, + room: undefined, + media: undefined, + }); + return Promise.resolve(); + } + + let resolveResources!: () => void; + const resourcesReady = new Promise((resolve) => { + resolveResources = resolve; + }); + let session: MatrixRTCSession; + let provider: LivekitMatrixKeyProvider; + try { + session = connectOptions.mx.matrixRTC.getRoomSession(connectOptions.room); + provider = createKeyProvider(); + } catch { + ownerLease.release(); + publish({ + lifecycle: 'failed', + failure: 'setup-failed', + mediaFailure: null, + room: undefined, + media: undefined, + }); + return Promise.resolve(); + } + const current: ControllerRecord = { + session, + provider, + matrixJoinStarted: false, + cancelled: false, + e2eeFailure: false, + providerDetached: false, + ownerLease, + resourcesReady, + resolveResources, + platformActive: false, + platformFlags: { microphone: false, playback: false }, + platformDesired: { microphone: false, playback: true }, + platformRevision: -1, + mediaPublished: false, + }; + record = current; + current.removeKeyStateListener = current.provider.subscribe((e2ee) => { + publish({ e2ee }); + if (e2ee.lastImportFailure) { + current.e2eeFailure = true; + current.cancelMembershipWait?.(); + if (current.room) void cleanup(current, 'failed', 'e2ee-import-failed'); + } + }); + + operation = setup(current, connectOptions).finally(() => { + operation = undefined; + }); + return operation; + }; + + const disconnect = async (): Promise => { + if (!record) { + if (state.lifecycle === 'failed') { + publish({ lifecycle: 'idle', failure: null, mediaFailure: null }); + } + return; + } + await cleanup(record, 'idle', null); + await operation; + }; + + const controller: LivekitJsControllerBase = { + connect, + disconnect, + getState: (): Readonly => ({ + ...state, + e2ee: { ...state.e2ee }, + }), + subscribe: (listener: LivekitJsControllerStateListener): (() => void) => { + listeners.add(listener); + listener({ ...state, e2ee: { ...state.e2ee } }); + return () => listeners.delete(listener); + }, + }; + + if (!manualMediaTest) return controller; + return { + ...controller, + setMicrophoneEnabled, + setCameraEnabled, + setScreenShareEnabled, + }; +} diff --git a/src/app/features/call/livekitMatrixKeyProvider.test.ts b/src/app/features/call/livekitMatrixKeyProvider.test.ts new file mode 100644 index 0000000000..3d482e163e --- /dev/null +++ b/src/app/features/call/livekitMatrixKeyProvider.test.ts @@ -0,0 +1,299 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { MatrixRTCSessionEvent } from '$types/matrix-sdk'; +import { LivekitMatrixKeyProvider, isLivekitE2EESupported } from './livekitMatrixKeyProvider'; + +type EncryptionKeyChangedHandler = ( + key: Uint8Array, + keyIndex: number, + membershipParts: unknown, + rtcBackendIdentity: string +) => void; + +type SessionOn = (event: MatrixRTCSessionEvent, handler: EncryptionKeyChangedHandler) => void; + +type TestSession = { + on: Mock; + off: Mock; + reemitEncryptionKeys: Mock<() => void>; +}; + +const deferred = (): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +}; + +const session = (): TestSession => ({ + on: vi.fn(), + off: vi.fn(), + reemitEncryptionKeys: vi.fn<() => void>(), +}); + +const handlerFor = (testSession: TestSession): EncryptionKeyChangedHandler => + testSession.on.mock.calls.find( + ([event]) => event === MatrixRTCSessionEvent.EncryptionKeyChanged + )?.[1] as EncryptionKeyChangedHandler; + +describe('LivekitMatrixKeyProvider', () => { + const importedKey = { imported: true } as unknown as CryptoKey; + const importKey = vi.fn().mockResolvedValue(importedKey); + + beforeEach(() => { + importKey.mockClear(); + vi.stubGlobal('crypto', { subtle: { importKey } }); + }); + + it('attaches, re-emits tracked keys, and forwards HKDF material with identity and index', async () => { + const testSession = session(); + const provider = new LivekitMatrixKeyProvider(); + const onSetEncryptionKey = vi.spyOn( + provider as unknown as { + onSetEncryptionKey: (key: CryptoKey, identity: string, index: number) => void; + }, + 'onSetEncryptionKey' + ); + const matrixKey = new Uint8Array([1, 2, 3, 4]); + provider.setLocalOutboundIdentity('hashed-member'); + + expect(provider.getOptions()).toMatchObject({ + ratchetWindowSize: 10, + keyringSize: 256, + sharedKey: false, + }); + + provider.attach(testSession as never); + + expect(testSession.on).toHaveBeenCalledWith( + MatrixRTCSessionEvent.EncryptionKeyChanged, + expect.any(Function) + ); + expect(testSession.reemitEncryptionKeys).toHaveBeenCalledOnce(); + + handlerFor(testSession)(matrixKey, 7, { userId: '@alice:example.org' }, 'hashed-member'); + await vi.waitFor(() => expect(onSetEncryptionKey).toHaveBeenCalledOnce()); + + expect(importKey).toHaveBeenCalledWith('raw', matrixKey, 'HKDF', false, [ + 'deriveBits', + 'deriveKey', + ]); + expect(onSetEncryptionKey).toHaveBeenCalledWith(importedKey, 'hashed-member', 7); + expect(provider.getKeyState()).toEqual({ + ready: true, + localOutboundIdentity: 'hashed-member', + keyIndex: 7, + lastImportFailure: null, + }); + }); + + it('removes the old session listener before attaching a new one', () => { + const oldSession = session(); + const newSession = session(); + const provider = new LivekitMatrixKeyProvider(); + + provider.attach(oldSession as never); + const oldHandler = handlerFor(oldSession); + provider.attach(newSession as never); + + expect(oldSession.off).toHaveBeenCalledWith( + MatrixRTCSessionEvent.EncryptionKeyChanged, + oldHandler + ); + expect(newSession.on).toHaveBeenCalledWith( + MatrixRTCSessionEvent.EncryptionKeyChanged, + oldHandler + ); + }); + + it('detaches the exact listener and does not expose raw key material', async () => { + const testSession = session(); + const provider = new LivekitMatrixKeyProvider(); + const rawKey = new Uint8Array([9, 8, 7]); + + provider.attach(testSession as never); + const handler = handlerFor(testSession); + handler(rawKey, 3, {}, 'member'); + await vi.waitFor(() => expect(provider.getKeys()).toHaveLength(1)); + provider.detach(); + + expect(testSession.off).toHaveBeenCalledWith( + MatrixRTCSessionEvent.EncryptionKeyChanged, + handler + ); + expect(provider.getKeys()[0]?.key).toBe(importedKey); + expect(provider.getKeys()[0]?.key).not.toBe(rawKey); + }); + + it('does not forward an import that completes after detach', async () => { + const testSession = session(); + const pendingImport = deferred(); + importKey.mockImplementationOnce(() => pendingImport.promise); + const provider = new LivekitMatrixKeyProvider(); + const onSetEncryptionKey = vi.spyOn( + provider as unknown as { + onSetEncryptionKey: (key: CryptoKey, identity: string, index: number) => void; + }, + 'onSetEncryptionKey' + ); + + provider.attach(testSession as never); + handlerFor(testSession)(new Uint8Array([1]), 1, {}, 'member'); + await vi.waitFor(() => expect(importKey).toHaveBeenCalledOnce()); + provider.detach(); + pendingImport.resolve(importedKey); + await Promise.resolve(); + await Promise.resolve(); + + expect(onSetEncryptionKey).not.toHaveBeenCalled(); + }); + + it('does not forward an import that completes after attaching a replacement session', async () => { + const oldSession = session(); + const newSession = session(); + const pendingImport = deferred(); + importKey.mockImplementationOnce(() => pendingImport.promise); + const provider = new LivekitMatrixKeyProvider(); + const onSetEncryptionKey = vi.spyOn( + provider as unknown as { + onSetEncryptionKey: (key: CryptoKey, identity: string, index: number) => void; + }, + 'onSetEncryptionKey' + ); + + provider.attach(oldSession as never); + handlerFor(oldSession)(new Uint8Array([1]), 1, {}, 'old-member'); + await vi.waitFor(() => expect(importKey).toHaveBeenCalledOnce()); + provider.attach(newSession as never); + pendingImport.resolve(importedKey); + await Promise.resolve(); + await Promise.resolve(); + + expect(onSetEncryptionKey).not.toHaveBeenCalled(); + }); + + it('keeps the effective key index from regressing when imports complete out of order', async () => { + const testSession = session(); + const firstImport = deferred(); + const secondImport = deferred(); + importKey + .mockImplementationOnce(() => firstImport.promise) + .mockImplementationOnce(() => secondImport.promise); + const provider = new LivekitMatrixKeyProvider(); + provider.setLocalOutboundIdentity('member'); + const onSetEncryptionKey = vi.spyOn( + provider as unknown as { + onSetEncryptionKey: (key: CryptoKey, identity: string, index: number) => void; + }, + 'onSetEncryptionKey' + ); + + provider.attach(testSession as never); + const handler = handlerFor(testSession); + handler(new Uint8Array([1]), 1, {}, 'member'); + handler(new Uint8Array([2]), 2, {}, 'member'); + await vi.waitFor(() => expect(importKey).toHaveBeenCalledTimes(2)); + + secondImport.resolve(importedKey); + await Promise.resolve(); + expect(onSetEncryptionKey).not.toHaveBeenCalled(); + firstImport.resolve(importedKey); + await vi.waitFor(() => expect(onSetEncryptionKey).toHaveBeenCalledTimes(2)); + + expect(onSetEncryptionKey.mock.calls[0]).toEqual([importedKey, 'member', 1]); + expect(onSetEncryptionKey.mock.calls[1]).toEqual([importedKey, 'member', 2]); + expect(provider.getKeyState().keyIndex).toBe(2); + }); + + it('tracks key-index regression independently for each participant', async () => { + const testSession = session(); + const provider = new LivekitMatrixKeyProvider(); + provider.setLocalOutboundIdentity('local-member'); + const onSetEncryptionKey = vi.spyOn( + provider as unknown as { + onSetEncryptionKey: (key: CryptoKey, identity: string, index: number) => void; + }, + 'onSetEncryptionKey' + ); + + provider.attach(testSession as never); + const handler = handlerFor(testSession); + handler(new Uint8Array([1]), 10, {}, 'remote-member'); + handler(new Uint8Array([2]), 1, {}, 'local-member'); + + await vi.waitFor(() => expect(onSetEncryptionKey).toHaveBeenCalledTimes(2)); + + expect(onSetEncryptionKey).toHaveBeenNthCalledWith(1, importedKey, 'remote-member', 10); + expect(onSetEncryptionKey).toHaveBeenNthCalledWith(2, importedKey, 'local-member', 1); + expect(provider.getKeyState()).toMatchObject({ + ready: true, + localOutboundIdentity: 'local-member', + keyIndex: 1, + lastImportFailure: null, + }); + }); + + it('records a safe failure when key import is rejected', async () => { + const testSession = session(); + importKey.mockRejectedValueOnce(new Error('raw key internals')); + const provider = new LivekitMatrixKeyProvider(); + provider.setLocalOutboundIdentity('member'); + + provider.attach(testSession as never); + handlerFor(testSession)(new Uint8Array([1]), 1, {}, 'member'); + + await vi.waitFor(() => expect(provider.getKeyState().lastImportFailure).toBe('import-failed')); + expect(JSON.stringify(provider.getKeyState())).not.toContain('raw key internals'); + + handlerFor(testSession)(new Uint8Array([2]), 2, {}, 'member'); + await vi.waitFor(() => expect(provider.getKeyState().ready).toBe(true)); + expect(provider.getKeyState().lastImportFailure).toBeNull(); + }); + + it('records a safe failure when WebCrypto import support is missing', async () => { + vi.stubGlobal('crypto', { subtle: {} }); + const testSession = session(); + const provider = new LivekitMatrixKeyProvider(); + + provider.attach(testSession as never); + handlerFor(testSession)(new Uint8Array([1]), 1, {}, 'member'); + + await vi.waitFor(() => + expect(provider.getKeyState().lastImportFailure).toBe('webcrypto-unavailable') + ); + }); +}); + +describe('isLivekitE2EESupported', () => { + beforeEach(() => { + vi.stubGlobal('crypto', { subtle: { importKey: vi.fn() } }); + }); + + it('fails closed when the current LiveKit API reports unsupported E2EE', () => { + Object.defineProperty(window, 'RTCRtpScriptTransform', { + configurable: true, + value: undefined, + }); + Object.defineProperty(window, 'RTCRtpSender', { + configurable: true, + value: undefined, + }); + + expect(isLivekitE2EESupported()).toBe(false); + }); + + it('reports support when LiveKit and WebCrypto are available', () => { + Object.defineProperty(window, 'RTCRtpScriptTransform', { + configurable: true, + value: vi.fn<() => void>(), + }); + + expect(isLivekitE2EESupported()).toBe(true); + }); +}); diff --git a/src/app/features/call/livekitMatrixKeyProvider.ts b/src/app/features/call/livekitMatrixKeyProvider.ts new file mode 100644 index 0000000000..716d2fcab4 --- /dev/null +++ b/src/app/features/call/livekitMatrixKeyProvider.ts @@ -0,0 +1,199 @@ +import { BaseKeyProvider, isE2EESupported } from 'livekit-client'; +import { + MatrixRTCSessionEvent, + type CallMembershipIdentityParts, + type MatrixRTCSession, +} from '$types/matrix-sdk'; + +export const isLivekitE2EESupported = (): boolean => { + const subtle = globalThis.crypto?.subtle; + return typeof subtle?.importKey === 'function' && isE2EESupported(); +}; + +export type LivekitMatrixKeyImportFailure = 'webcrypto-unavailable' | 'import-failed'; + +export type LivekitMatrixKeyProviderState = { + ready: boolean; + localOutboundIdentity: string | null; + keyIndex: number | null; + lastImportFailure: LivekitMatrixKeyImportFailure | null; +}; + +export type LivekitMatrixKeyProviderStateListener = ( + state: Readonly +) => void; + +type KeyImportResult = + | { + keyMaterial: CryptoKey; + rtcBackendIdentity: string; + encryptionKeyIndex: number; + } + | { failure: LivekitMatrixKeyImportFailure }; + +export class LivekitMatrixKeyProvider extends BaseKeyProvider { + private rtcSession?: MatrixRTCSession; + private attachmentGeneration = 0; + private nextImportSequence = 0; + private nextUpdateSequence = 0; + private readonly pendingUpdates = new Map(); + private readonly acceptedKeyIndices = new Map(); + private readonly stateListeners = new Set(); + private localOutboundIdentity: string | null = null; + private state: LivekitMatrixKeyProviderState = { + ready: false, + localOutboundIdentity: null, + keyIndex: null, + lastImportFailure: null, + }; + + public constructor() { + super({ + ratchetWindowSize: 10, + keyringSize: 256, + sharedKey: false, + }); + } + + public attach(session: MatrixRTCSession): void { + this.detach(); + this.rtcSession = session; + session.on(MatrixRTCSessionEvent.EncryptionKeyChanged, this.onEncryptionKeyChanged); + session.reemitEncryptionKeys(); + } + + public detach(): void { + this.attachmentGeneration += 1; + this.nextImportSequence = 0; + this.nextUpdateSequence = 0; + this.pendingUpdates.clear(); + this.acceptedKeyIndices.clear(); + this.updateState({ + ready: false, + keyIndex: null, + lastImportFailure: null, + }); + + if (!this.rtcSession) return; + + this.rtcSession.off(MatrixRTCSessionEvent.EncryptionKeyChanged, this.onEncryptionKeyChanged); + this.rtcSession = undefined; + } + + public setLocalOutboundIdentity(identity: string | undefined): void { + this.localOutboundIdentity = identity ?? null; + const keyIndex = identity === undefined ? undefined : this.acceptedKeyIndices.get(identity); + this.updateState({ + localOutboundIdentity: this.localOutboundIdentity, + ready: keyIndex !== undefined, + keyIndex: keyIndex ?? null, + }); + } + + public getKeyState(): Readonly { + return { ...this.state }; + } + + public subscribe(listener: LivekitMatrixKeyProviderStateListener): () => void { + this.stateListeners.add(listener); + listener(this.getKeyState()); + return () => { + this.stateListeners.delete(listener); + }; + } + + private readonly onEncryptionKeyChanged = ( + encryptionKey: Uint8Array, + encryptionKeyIndex: number, + _membershipParts: CallMembershipIdentityParts, + rtcBackendIdentity: string + ): void => { + const generation = this.attachmentGeneration; + const sequence = this.nextImportSequence++; + const subtle = globalThis.crypto?.subtle; + if (!subtle || typeof subtle.importKey !== 'function') { + this.enqueueUpdate(generation, sequence, { failure: 'webcrypto-unavailable' }); + return; + } + + let importPromise: Promise; + try { + importPromise = subtle.importKey('raw', encryptionKey, 'HKDF', false, [ + 'deriveBits', + 'deriveKey', + ]); + } catch { + this.enqueueUpdate(generation, sequence, { failure: 'import-failed' }); + return; + } + + void importPromise.then( + (keyMaterial) => { + this.enqueueUpdate(generation, sequence, { + keyMaterial, + rtcBackendIdentity, + encryptionKeyIndex, + }); + }, + () => { + this.enqueueUpdate(generation, sequence, { failure: 'import-failed' }); + } + ); + }; + + private enqueueUpdate(generation: number, sequence: number, result: KeyImportResult): void { + if (generation !== this.attachmentGeneration) return; + this.pendingUpdates.set(sequence, result); + + while (this.pendingUpdates.has(this.nextUpdateSequence)) { + const update = this.pendingUpdates.get(this.nextUpdateSequence); + this.pendingUpdates.delete(this.nextUpdateSequence); + this.nextUpdateSequence += 1; + if (!update) continue; + + if ('failure' in update) { + this.updateState({ lastImportFailure: update.failure }); + continue; + } + + const lastParticipantKeyIndex = this.acceptedKeyIndices.get(update.rtcBackendIdentity) ?? -1; + if (update.encryptionKeyIndex < lastParticipantKeyIndex) continue; + + try { + this.onSetEncryptionKey( + update.keyMaterial, + update.rtcBackendIdentity, + update.encryptionKeyIndex + ); + } catch { + this.updateState({ lastImportFailure: 'import-failed' }); + continue; + } + + this.acceptedKeyIndices.set( + update.rtcBackendIdentity, + Math.max( + this.acceptedKeyIndices.get(update.rtcBackendIdentity) ?? -1, + update.encryptionKeyIndex + ) + ); + + this.updateState({ lastImportFailure: null }); + if (update.rtcBackendIdentity === this.localOutboundIdentity) { + this.updateState({ ready: true, keyIndex: update.encryptionKeyIndex }); + } + } + } + + private updateState(changes: Partial): void { + this.state = { ...this.state, ...changes }; + const state = this.getKeyState(); + this.stateListeners.forEach((listener) => { + try { + listener(state); + } catch { + // A state observer must not interrupt key updates. + } + }); + } +} diff --git a/src/app/features/call/matrixRtcCallLifecycle.ts b/src/app/features/call/matrixRtcCallLifecycle.ts new file mode 100644 index 0000000000..8cb8b50750 --- /dev/null +++ b/src/app/features/call/matrixRtcCallLifecycle.ts @@ -0,0 +1,212 @@ +import type { AutoDiscoveryInfo } from '../../cs-api'; +import { + MatrixRTCSessionEvent, + type CallMembership, + type CallMembershipIdentityParts, + type JoinSessionConfig, + type MatrixClient, + type MatrixRTCSession, + type Room, +} from '$types/matrix-sdk'; +import { getPreferredLivekitTransport, provisionLivekitToken } from './livekitProvisioning'; +import type { LivekitProvisioningResult } from './livekitProvisioning'; +import type { LivekitTransportConfig } from '$types/matrix-sdk'; + +const membershipWaitTimeoutMs = 10_000; + +export type MatrixRTCJoinProvisionOptions = { + mx: MatrixClient; + room: Room; + session: MatrixRTCSession; + discovery?: Pick; + getPreferredTransport?: typeof getPreferredLivekitTransport; + provisionToken?: typeof provisionLivekitToken; + callIntent: JoinSessionConfig['callIntent']; + notificationType?: JoinSessionConfig['notificationType']; + manageMediaKeys?: boolean; + isCancelled?: () => boolean; + onStage?: (stage: 'joining-matrix' | 'provisioning') => void; + onMembershipWait?: (cancel: (() => void) | undefined) => void; + onJoinStarted?: () => void; + onMembershipError?: (error: unknown) => void; +}; + +export type MatrixRTCJoinProvisionResult = { + session: MatrixRTCSession; + transport: LivekitTransportConfig; + identity: CallMembershipIdentityParts; + ownMembership: CallMembership | undefined; + slotId: string; + provisioned: LivekitProvisioningResult; +}; + +type MembershipWait = { + promise: Promise; + cancel: () => void; +}; + +const waitForOwnMembership = ( + session: MatrixRTCSession, + userId: string, + deviceId: string, + onMembershipError?: (error: unknown) => void +): MembershipWait => { + let resolveWait!: () => void; + let rejectWait!: (reason?: unknown) => void; + let settled = false; + let timeout: ReturnType | undefined; + let membershipsListenerInstalled = false; + let membershipErrorListenerInstalled = false; + + const handleMembershipsChanged = ( + _oldMemberships: CallMembership[], + memberships: CallMembership[] + ): void => { + if ( + memberships.some( + (membership) => membership.userId === userId && membership.deviceId === deviceId + ) + ) { + settle(resolveWait); + } + }; + + const handleMembershipManagerError = (error: unknown): void => { + onMembershipError?.(error); + settle(() => rejectWait(new Error('MatrixRTC membership publication failed'))); + }; + + const removeListeners = (): void => { + if (membershipsListenerInstalled) { + try { + session.removeListener(MatrixRTCSessionEvent.MembershipsChanged, handleMembershipsChanged); + } catch {} + membershipsListenerInstalled = false; + } + if (membershipErrorListenerInstalled) { + try { + session.removeListener( + MatrixRTCSessionEvent.MembershipManagerError, + handleMembershipManagerError + ); + } catch {} + membershipErrorListenerInstalled = false; + } + }; + + const settle = (settlePromise: () => void): void => { + if (settled) return; + settled = true; + if (timeout !== undefined) clearTimeout(timeout); + removeListeners(); + settlePromise(); + }; + + const promise = new Promise((resolve, reject) => { + resolveWait = resolve; + rejectWait = reject; + }); + + try { + session.on(MatrixRTCSessionEvent.MembershipsChanged, handleMembershipsChanged); + membershipsListenerInstalled = true; + session.on(MatrixRTCSessionEvent.MembershipManagerError, handleMembershipManagerError); + membershipErrorListenerInstalled = true; + timeout = setTimeout( + () => settle(() => rejectWait(new Error('MatrixRTC membership publication timed out'))), + membershipWaitTimeoutMs + ); + } catch { + settle(() => rejectWait(new Error('MatrixRTC membership listener setup failed'))); + } + + return { + promise, + cancel: () => settle(() => rejectWait(new Error('MatrixRTC membership wait cancelled'))), + }; +}; + +export const joinAndProvisionMatrixRTC = async ({ + mx, + room, + session, + discovery, + getPreferredTransport = getPreferredLivekitTransport, + provisionToken = provisionLivekitToken, + callIntent, + notificationType, + manageMediaKeys = false, + isCancelled, + onStage, + onMembershipWait, + onJoinStarted, + onMembershipError, +}: MatrixRTCJoinProvisionOptions): Promise => { + const deviceId = mx.getDeviceId(); + if (!deviceId) throw new Error('MatrixRTC device unavailable'); + + const transport = await getPreferredTransport(mx, discovery); + if (!transport) throw new Error('No LiveKit transport available'); + + const userId = mx.getSafeUserId(); + const identity = { userId, deviceId, memberId: `${userId}:${deviceId}` }; + if (isCancelled?.()) throw new Error('MatrixRTC setup cancelled'); + const membershipWait = waitForOwnMembership( + session, + identity.userId, + identity.deviceId, + onMembershipError + ); + onMembershipWait?.(membershipWait.cancel); + onStage?.('joining-matrix'); + + try { + const joinConfig: JoinSessionConfig = { + callIntent, + ...(notificationType ? { notificationType } : {}), + ...(manageMediaKeys ? { manageMediaKeys: true } : {}), + }; + onJoinStarted?.(); + session.joinRTCSession(identity, [transport], undefined, joinConfig); + await membershipWait.promise; + } catch (error) { + membershipWait.cancel(); + throw error; + } finally { + onMembershipWait?.(undefined); + } + + if (isCancelled?.()) throw new Error('MatrixRTC setup cancelled'); + const slotId = session.slotId; + if (!slotId) throw new Error('MatrixRTC slot was not assigned'); + const ownMembership = session.memberships?.find( + (membership) => + membership.userId === identity.userId && membership.deviceId === identity.deviceId + ); + + onStage?.('provisioning'); + const provisioned = await provisionToken({ + mx, + roomId: room.roomId, + slotId, + deviceId, + serviceUrl: transport.livekit_service_url, + memberId: identity.memberId, + userId: identity.userId, + }); + if (isCancelled?.()) throw new Error('MatrixRTC setup cancelled'); + + return { session, transport, identity, ownMembership, slotId, provisioned }; +}; + +export const disconnectLivekitThenLeaveMatrixRTC = async ( + disconnect: () => Promise, + session: MatrixRTCSession +): Promise => { + try { + await disconnect(); + } catch {} + try { + await session.leaveRoomSession(5000); + } catch {} +}; diff --git a/src/app/features/call/nativeCallController.test.ts b/src/app/features/call/nativeCallController.test.ts index a61224e071..72b2a72cc9 100644 --- a/src/app/features/call/nativeCallController.test.ts +++ b/src/app/features/call/nativeCallController.test.ts @@ -1,10 +1,18 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MatrixRTCSessionEvent, type CallMembership } from '$types/matrix-sdk'; import type { MatrixClient, MatrixRTCSession, Room } from '$types/matrix-sdk'; import { createNativeCallController } from './nativeCallController'; import type { NativeCallSession } from '$state/nativeCall'; import type { CallLifecycleError, CallState } from '$plugins/call/callLifecycle'; +const { debugError } = vi.hoisted(() => ({ + debugError: vi.fn<(...args: unknown[]) => void>(), +})); + +vi.mock('$utils/debugLogger', () => ({ + createDebugLogger: () => ({ error: debugError }), +})); + const room = { roomId: '!room:example.org' } as Room; const makeSession = (slotId = 'm.call#real-slot') => @@ -66,6 +74,10 @@ const waitForMembershipErrorListener = async (session: MatrixRTCSession): Promis }; describe('native call controller', () => { + beforeEach(() => { + debugError.mockClear(); + }); + it('joins with one identity, provisions the session slot, then connects', async () => { const session = makeSession(); const setSession = vi.fn<(session: NativeCallSession | undefined) => void>(); @@ -146,7 +158,9 @@ describe('native call controller', () => { }, getPreferredTransport: async () => transport, provisionToken: async () => { - throw new Error('openid-secret jwt-secret'); + throw Object.assign(new Error('openid-secret jwt-secret'), { + cause: { name: 'PluginError', message: 'request-body-secret' }, + }); }, }); @@ -177,11 +191,67 @@ describe('native call controller', () => { expect(setSession).toHaveBeenLastCalledWith( expect.objectContaining({ lifecycle: 'error', - error: 'Native call setup failed during token provisioning.', + error: 'Native call setup failed during authorizing.', }) ); expect(setSession.mock.calls.flat()).not.toContain('openid-secret'); expect(setSession.mock.calls.flat()).not.toContain('jwt-secret'); + expect(JSON.stringify(setSession.mock.calls)).not.toContain('openid-secret'); + expect(JSON.stringify(setSession.mock.calls)).not.toContain('jwt-secret'); + expect(debugError).toHaveBeenCalledWith( + 'call', + 'Native call setup failed', + expect.objectContaining({ + stage: 'authorizing', + errorName: 'Error', + errorMessage: 'redacted', + cause: { errorName: 'UnknownError', errorMessage: 'redacted' }, + }) + ); + expect(JSON.stringify(debugError.mock.calls)).not.toContain('openid-secret'); + expect(JSON.stringify(debugError.mock.calls)).not.toContain('jwt-secret'); + expect(JSON.stringify(debugError.mock.calls)).not.toContain('request-body-secret'); + }); + + it('logs allowlisted transport failures with a stage-safe session error', async () => { + const session = makeSession(); + const setSession = vi.fn<(session: NativeCallSession | undefined) => void>(); + const disconnect = vi + .fn<(request: { connectionId: string }) => Promise>() + .mockResolvedValue({ revision: 1, state: 'idle', connectionId: null }); + const controller = createNativeCallController({ + setSession, + connectionId: () => 'connection-id', + disconnect, + onState: async () => vi.fn<() => void>(), + onError: async () => vi.fn<() => void>(), + getPreferredTransport: async () => undefined, + }); + + await controller.start({ + mx: makeClient(session), + room, + elementCallActive: false, + dm: false, + video: false, + ongoing: false, + }); + + expect(setSession).toHaveBeenLastCalledWith( + expect.objectContaining({ + lifecycle: 'error', + error: 'Native call setup failed during no transport.', + }) + ); + expect(debugError).toHaveBeenCalledWith( + 'call', + 'Native call setup failed', + expect.objectContaining({ + stage: 'no transport', + errorName: 'Error', + errorMessage: 'No LiveKit transport available', + }) + ); }); it('cleans up when the membership manager fails before publication', async () => { @@ -235,10 +305,21 @@ describe('native call controller', () => { expect(setSession).toHaveBeenLastCalledWith( expect.objectContaining({ lifecycle: 'error', - error: 'Native call setup failed during MatrixRTC.', + error: 'Native call setup failed during joining the call.', }) ); expect(setSession.mock.calls.flat()).not.toContain('secret-membership-error'); + expect(JSON.stringify(setSession.mock.calls)).not.toContain('secret-membership-error'); + expect(debugError).toHaveBeenCalledWith( + 'call', + 'Native call setup failed', + expect.objectContaining({ + stage: 'joining the call', + errorName: 'Error', + errorMessage: 'redacted', + }) + ); + expect(JSON.stringify(debugError.mock.calls)).not.toContain('secret-membership-error'); }); it('cleans up when local membership publication times out', async () => { @@ -297,7 +378,7 @@ describe('native call controller', () => { expect(setSession).toHaveBeenLastCalledWith( expect.objectContaining({ lifecycle: 'error', - error: 'Native call setup failed during MatrixRTC.', + error: 'Native call setup failed during joining the call.', }) ); } finally { @@ -343,7 +424,7 @@ describe('native call controller', () => { expect(setSession).toHaveBeenLastCalledWith( expect.objectContaining({ lifecycle: 'error', - error: 'Native call setup failed during MatrixRTC.', + error: 'Native call setup failed during joining the call.', }) ); expect(setSession.mock.calls.flat()).not.toContain('secret-device'); @@ -480,4 +561,276 @@ describe('native call controller', () => { expect.objectContaining({ lifecycle: 'error', error: 'Native call ended.' }) ); }); + + it('redacts secret-bearing lifecycle errors and keeps the allowlisted code', async () => { + const session = makeSession(); + const setSession = vi.fn<(session: NativeCallSession | undefined) => void>(); + let errorHandler: ((error: CallLifecycleError) => void) | undefined; + const disconnect = vi.fn<(request: { connectionId: string }) => Promise>(); + disconnect.mockResolvedValue({ revision: 3, state: 'idle', connectionId: null }); + const controller = createNativeCallController({ + setSession, + connectionId: () => 'connection-id', + disconnect, + onState: async () => vi.fn<() => void>(), + onError: async (handler: (error: CallLifecycleError) => void) => { + errorHandler = handler; + return vi.fn<() => void>(); + }, + getPreferredTransport: async () => transport, + provisionToken: async () => ({ url: 'wss://livekit.example', jwt: 'jwt' }), + connect: async () => ({ revision: 2, state: 'connected', connectionId: 'connection-id' }), + }); + + const startPromise = controller.start({ + mx: makeClient(session), + room, + elementCallActive: false, + dm: false, + video: false, + ongoing: false, + }); + await waitForMembershipListener(session); + emitOwnMembership(session); + await startPromise; + errorHandler?.({ + revision: 3, + code: 'connect_failed', + message: 'wss://sfu-secret.example jwt-secret-token', + connectionId: 'connection-id', + }); + + await vi.waitFor(() => + expect(disconnect).toHaveBeenCalledWith({ connectionId: 'connection-id' }) + ); + expect(setSession).toHaveBeenLastCalledWith( + expect.objectContaining({ lifecycle: 'error', error: 'Native call setup failed.' }) + ); + expect(debugError).toHaveBeenCalledWith( + 'call', + 'Native call connection failed', + expect.objectContaining({ + stage: 'connecting', + errorName: 'UnknownError', + errorMessage: 'redacted', + code: 'connect_failed', + }) + ); + expect(JSON.stringify(debugError.mock.calls)).not.toContain('sfu-secret'); + expect(JSON.stringify(debugError.mock.calls)).not.toContain('jwt-secret-token'); + expect(JSON.stringify(debugError.mock.calls)).not.toContain('connection-id'); + expect(JSON.stringify(setSession.mock.calls)).not.toContain('sfu-secret'); + expect(JSON.stringify(setSession.mock.calls)).not.toContain('jwt-secret-token'); + }); + + it('redacts unknown lifecycle error codes instead of logging them', async () => { + const session = makeSession(); + const setSession = vi.fn<(session: NativeCallSession | undefined) => void>(); + let errorHandler: ((error: CallLifecycleError) => void) | undefined; + const disconnect = vi.fn<(request: { connectionId: string }) => Promise>(); + disconnect.mockResolvedValue({ revision: 3, state: 'idle', connectionId: null }); + const controller = createNativeCallController({ + setSession, + connectionId: () => 'connection-id', + disconnect, + onState: async () => vi.fn<() => void>(), + onError: async (handler: (error: CallLifecycleError) => void) => { + errorHandler = handler; + return vi.fn<() => void>(); + }, + getPreferredTransport: async () => transport, + provisionToken: async () => ({ url: 'wss://livekit.example', jwt: 'jwt' }), + connect: async () => ({ revision: 2, state: 'connected', connectionId: 'connection-id' }), + }); + + const startPromise = controller.start({ + mx: makeClient(session), + room, + elementCallActive: false, + dm: false, + video: false, + ongoing: false, + }); + await waitForMembershipListener(session); + emitOwnMembership(session); + await startPromise; + errorHandler?.({ + revision: 3, + code: 'secret-lifecycle-code', + message: 'payload-secret', + connectionId: 'connection-id', + }); + + await vi.waitFor(() => + expect(disconnect).toHaveBeenCalledWith({ connectionId: 'connection-id' }) + ); + expect(debugError).toHaveBeenCalledWith( + 'call', + 'Native call connection failed', + expect.objectContaining({ + stage: 'connecting', + errorName: 'UnknownError', + errorMessage: 'redacted', + code: 'unknown', + }) + ); + expect(JSON.stringify(debugError.mock.calls)).not.toContain('secret-lifecycle-code'); + expect(JSON.stringify(debugError.mock.calls)).not.toContain('payload-secret'); + }); + + it.each([ + ['audio_failed', 'native audio failed'], + ['camera_failed', 'native camera failed'], + ['screen_share_failed', 'native screen share failed'], + ['video_failed', 'native video failed'], + ['media_unsupported', 'media kind is not supported on this platform'], + ])('keeps the call alive when a media control fails with %s', async (code, message) => { + const session = makeSession(); + const setSession = vi.fn<(session: NativeCallSession | undefined) => void>(); + let errorHandler: ((error: CallLifecycleError) => void) | undefined; + const disconnect = vi.fn<(request: { connectionId: string }) => Promise>(); + disconnect.mockResolvedValue({ revision: 3, state: 'idle', connectionId: null }); + const controller = createNativeCallController({ + setSession, + connectionId: () => 'connection-id', + disconnect, + onState: async () => vi.fn<() => void>(), + onError: async (handler: (error: CallLifecycleError) => void) => { + errorHandler = handler; + return vi.fn<() => void>(); + }, + getPreferredTransport: async () => transport, + provisionToken: async () => ({ url: 'wss://livekit.example', jwt: 'jwt' }), + connect: async () => ({ revision: 2, state: 'connected', connectionId: 'connection-id' }), + }); + + const startPromise = controller.start({ + mx: makeClient(session), + room, + elementCallActive: false, + dm: false, + video: false, + ongoing: false, + }); + await waitForMembershipListener(session); + emitOwnMembership(session); + await startPromise; + errorHandler?.({ revision: 3, code, message, connectionId: 'connection-id' }); + + expect(disconnect).not.toHaveBeenCalled(); + expect(session.leaveRoomSession).not.toHaveBeenCalled(); + expect(setSession).toHaveBeenLastCalledWith( + expect.objectContaining({ lifecycle: 'connected' }) + ); + expect(debugError).toHaveBeenCalledWith( + 'call', + 'Native call connection failed', + expect.objectContaining({ stage: 'media control', code, errorMessage: message }) + ); + }); + + it('redacts raw media failure details without hanging up the call', async () => { + const session = makeSession(); + const setSession = vi.fn<(session: NativeCallSession | undefined) => void>(); + let errorHandler: ((error: CallLifecycleError) => void) | undefined; + const disconnect = vi.fn<(request: { connectionId: string }) => Promise>(); + disconnect.mockResolvedValue({ revision: 3, state: 'idle', connectionId: null }); + const controller = createNativeCallController({ + setSession, + connectionId: () => 'connection-id', + disconnect, + onState: async () => vi.fn<() => void>(), + onError: async (handler: (error: CallLifecycleError) => void) => { + errorHandler = handler; + return vi.fn<() => void>(); + }, + getPreferredTransport: async () => transport, + provisionToken: async () => ({ url: 'wss://livekit.example', jwt: 'jwt' }), + connect: async () => ({ revision: 2, state: 'connected', connectionId: 'connection-id' }), + }); + + const startPromise = controller.start({ + mx: makeClient(session), + room, + elementCallActive: false, + dm: false, + video: false, + ongoing: false, + }); + await waitForMembershipListener(session); + emitOwnMembership(session); + await startPromise; + errorHandler?.({ + revision: 3, + code: 'camera_failed', + message: 'https://sfu-secret.example/track?access_token=jwt-secret-token', + connectionId: 'connection-id', + }); + + expect(disconnect).not.toHaveBeenCalled(); + expect(session.leaveRoomSession).not.toHaveBeenCalled(); + expect(debugError).toHaveBeenCalledWith( + 'call', + 'Native call connection failed', + expect.objectContaining({ + stage: 'media control', + code: 'camera_failed', + errorMessage: 'redacted', + }) + ); + expect(JSON.stringify(debugError.mock.calls)).not.toContain('sfu-secret'); + expect(JSON.stringify(debugError.mock.calls)).not.toContain('jwt-secret-token'); + }); + + it('ignores lifecycle errors for stale or anonymous connections', async () => { + const session = makeSession(); + const setSession = vi.fn<(session: NativeCallSession | undefined) => void>(); + let errorHandler: ((error: CallLifecycleError) => void) | undefined; + const disconnect = vi.fn<(request: { connectionId: string }) => Promise>(); + disconnect.mockResolvedValue({ revision: 3, state: 'idle', connectionId: null }); + const controller = createNativeCallController({ + setSession, + connectionId: () => 'connection-id', + disconnect, + onState: async () => vi.fn<() => void>(), + onError: async (handler: (error: CallLifecycleError) => void) => { + errorHandler = handler; + return vi.fn<() => void>(); + }, + getPreferredTransport: async () => transport, + provisionToken: async () => ({ url: 'wss://livekit.example', jwt: 'jwt' }), + connect: async () => ({ revision: 2, state: 'connected', connectionId: 'connection-id' }), + }); + + const startPromise = controller.start({ + mx: makeClient(session), + room, + elementCallActive: false, + dm: false, + video: false, + ongoing: false, + }); + await waitForMembershipListener(session); + emitOwnMembership(session); + await startPromise; + debugError.mockClear(); + setSession.mockClear(); + + errorHandler?.({ + revision: 3, + code: 'stale_connection', + message: 'stale-secret', + connectionId: 'other-connection', + }); + errorHandler?.({ + revision: 4, + code: 'connect_failed', + message: 'null-secret', + connectionId: null, + }); + + expect(debugError).not.toHaveBeenCalled(); + expect(disconnect).not.toHaveBeenCalled(); + expect(setSession).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/features/call/nativeCallController.ts b/src/app/features/call/nativeCallController.ts index 69c5e4e908..7bb4c9b839 100644 --- a/src/app/features/call/nativeCallController.ts +++ b/src/app/features/call/nativeCallController.ts @@ -1,12 +1,5 @@ import type { AutoDiscoveryInfo } from '../../cs-api'; -import { - MatrixRTCSessionEvent, - type CallMembership, - type JoinSessionConfig, - type MatrixClient, - type MatrixRTCSession, - type Room, -} from '$types/matrix-sdk'; +import { type MatrixClient, type MatrixRTCSession, type Room } from '$types/matrix-sdk'; import { connect, disconnect, @@ -18,6 +11,11 @@ import { } from '$plugins/call/callLifecycle'; import { getPreferredLivekitTransport, provisionLivekitToken } from './livekitProvisioning'; import type { NativeCallLifecycle, NativeCallSession } from '$state/nativeCall'; +import { createDebugLogger } from '$utils/debugLogger'; +import { + joinAndProvisionMatrixRTC, + disconnectLivekitThenLeaveMatrixRTC, +} from './matrixRtcCallLifecycle'; type SetNativeCall = (session: NativeCallSession | undefined) => void; @@ -30,6 +28,7 @@ type NativeCallControllerDependencies = { getPreferredTransport: typeof getPreferredLivekitTransport; provisionToken: typeof provisionLivekitToken; connectionId?: () => string; + onCleanup?: () => void; }; type NativeCallStartOptions = { @@ -54,95 +53,128 @@ type NativeCallRecord = { cleanupPromise?: Promise; }; +const debugLog = createDebugLogger('nativeCallController'); + const errorMessage = 'Native call setup failed.'; const endedMessage = 'Native call ended.'; -type SetupStage = 'MatrixRTC' | 'LiveKit transport' | 'token provisioning' | 'LiveKit connection'; +type SetupStage = + | 'joining the call' + | 'no transport' + | 'authorizing' + | 'connecting' + | 'media control'; + +const safeErrorNames = new Set([ + 'AggregateError', + 'AbortError', + 'DataError', + 'DOMException', + 'Error', + 'EvalError', + 'InvalidStateError', + 'NetworkError', + 'NotAllowedError', + 'NotSupportedError', + 'OperationError', + 'QuotaExceededError', + 'RangeError', + 'ReferenceError', + 'SecurityError', + 'SyntaxError', + 'TimeoutError', + 'TypeError', + 'URIError', + 'UnknownError', +]); + +const safeErrorMessages = new Set([ + 'MatrixRTC device unavailable', + 'MatrixRTC membership listener setup failed', + 'MatrixRTC membership publication failed', + 'MatrixRTC membership wait cancelled', + 'media kind is not supported on this platform', + 'native audio failed', + 'native camera failed', + 'native screen share failed', + 'native video failed', + 'MatrixRTC slot was not assigned', + 'No LiveKit transport available', +]); + +const safeLifecycleErrorCodes = new Set([ + 'actor_unavailable', + 'audio_failed', + 'busy', + 'camera_failed', + 'close_failed', + 'connect_failed', + 'media_unsupported', + 'screen_share_failed', + 'stale_connection', + 'video_failed', +]); + +const mediaControlErrorCodes = new Set([ + 'audio_failed', + 'camera_failed', + 'media_unsupported', + 'screen_share_failed', + 'video_failed', +]); + +const isMediaControlError = (error: CallLifecycleError): boolean => + typeof error.code === 'string' && mediaControlErrorCodes.has(error.code); + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const getErrorProperty = (error: unknown, property: 'name' | 'message' | 'cause'): unknown => { + if (error instanceof Error) return error[property]; + return isRecord(error) ? error[property] : undefined; +}; -const membershipWaitTimeoutMs = 10_000; +const safeErrorName = (error: unknown): string => { + const name = getErrorProperty(error, 'name'); + return typeof name === 'string' && safeErrorNames.has(name) ? name : 'UnknownError'; +}; -type MembershipWait = { - promise: Promise; - cancel: () => void; +const safeErrorMessage = (error: unknown): string => { + const message = getErrorProperty(error, 'message'); + return typeof message === 'string' && safeErrorMessages.has(message) ? message : 'redacted'; }; -const waitForOwnMembership = ( - session: MatrixRTCSession, - userId: string, - deviceId: string -): MembershipWait => { - let resolveWait!: () => void; - let rejectWait!: (reason?: unknown) => void; - let settled = false; - let timeout: ReturnType | undefined; - let membershipsListenerInstalled = false; - let membershipErrorListenerInstalled = false; - - const removeListeners = (): void => { - if (membershipsListenerInstalled) { - try { - session.removeListener(MatrixRTCSessionEvent.MembershipsChanged, handleMembershipsChanged); - } catch {} - membershipsListenerInstalled = false; - } - if (membershipErrorListenerInstalled) { - try { - session.removeListener( - MatrixRTCSessionEvent.MembershipManagerError, - handleMembershipManagerError - ); - } catch {} - membershipErrorListenerInstalled = false; - } +const setupFailureDiagnostics = (stage: SetupStage, error: unknown): Record => { + const cause = getErrorProperty(error, 'cause'); + return { + stage, + errorName: safeErrorName(error), + errorMessage: safeErrorMessage(error), + ...(cause !== undefined + ? { + cause: { + errorName: safeErrorName(cause), + errorMessage: safeErrorMessage(cause), + }, + } + : {}), }; +}; - const settle = (settlePromise: () => void): void => { - if (settled) return; - settled = true; - if (timeout !== undefined) clearTimeout(timeout); - removeListeners(); - settlePromise(); - }; +const logSetupFailure = (stage: SetupStage, error: unknown): void => { + debugLog.error('call', 'Native call setup failed', setupFailureDiagnostics(stage, error)); +}; - const handleMembershipsChanged = ( - _oldMemberships: CallMembership[], - memberships: CallMembership[] - ): void => { - if ( - memberships.some( - (membership) => membership.userId === userId && membership.deviceId === deviceId - ) - ) { - settle(resolveWait); - } - }; +const safeLifecycleErrorCode = (code: unknown): string => + typeof code === 'string' && safeLifecycleErrorCodes.has(code) ? code : 'unknown'; - const handleMembershipManagerError = (): void => { - settle(() => rejectWait(new Error('MatrixRTC membership publication failed'))); - }; +const lifecycleFailureDiagnostics = (error: CallLifecycleError): Record => ({ + ...setupFailureDiagnostics(isMediaControlError(error) ? 'media control' : 'connecting', error), + code: safeLifecycleErrorCode(error.code), +}); - const promise = new Promise((resolve, reject) => { - resolveWait = resolve; - rejectWait = reject; - }); - - try { - session.on(MatrixRTCSessionEvent.MembershipsChanged, handleMembershipsChanged); - membershipsListenerInstalled = true; - session.on(MatrixRTCSessionEvent.MembershipManagerError, handleMembershipManagerError); - membershipErrorListenerInstalled = true; - timeout = setTimeout( - () => settle(() => rejectWait(new Error('MatrixRTC membership publication timed out'))), - membershipWaitTimeoutMs - ); - } catch { - settle(() => rejectWait(new Error('MatrixRTC membership listener setup failed'))); - } - - return { - promise, - cancel: () => settle(() => rejectWait(new Error('MatrixRTC membership wait cancelled'))), - }; +const logLifecycleFailure = (error: CallLifecycleError): void => { + debugLog.error('call', 'Native call connection failed', lifecycleFailureDiagnostics(error)); }; const setupErrorMessage = (stage: SetupStage): string => @@ -170,6 +202,7 @@ export const createNativeCallController = ( | 'getPreferredTransport' | 'provisionToken' | 'connectionId' + | 'onCleanup' > > ) => { @@ -182,6 +215,7 @@ export const createNativeCallController = ( getPreferredTransport: dependencies.getPreferredTransport ?? getPreferredLivekitTransport, provisionToken: dependencies.provisionToken ?? provisionLivekitToken, connectionId: dependencies.connectionId ?? (() => crypto.randomUUID()), + onCleanup: dependencies.onCleanup, }; let activeRecord: NativeCallRecord | undefined; let displayedRecord: NativeCallRecord | undefined; @@ -254,12 +288,10 @@ export const createNativeCallController = ( } record.cleanupPromise = (async () => { - try { - await deps.disconnect({ connectionId: record.connectionId }); - } catch {} - try { - await record.session.leaveRoomSession(5000); - } catch {} + await disconnectLivekitThenLeaveMatrixRTC( + () => deps.disconnect({ connectionId: record.connectionId }).then(() => undefined), + record.session + ); await Promise.allSettled([ record.stateUnlistenPromise?.then((unlisten) => unlisten()) ?? Promise.resolve(), record.errorUnlistenPromise?.then((unlisten) => unlisten()) ?? Promise.resolve(), @@ -273,6 +305,7 @@ export const createNativeCallController = ( deps.setSession(undefined); } catch {} } + deps.onCleanup?.(); })(); await record.cleanupPromise; }; @@ -295,6 +328,10 @@ export const createNativeCallController = ( const handleError = (record: NativeCallRecord, error: CallLifecycleError): void => { if (!isCurrent(record)) return; if (!isMatchingConnection(error.connectionId, record)) return; + logLifecycleFailure(error); + // Media toggle failures are recoverable: keep the call alive and only + // record a safe diagnostic. + if (isMediaControlError(error)) return; void cleanup(record, errorMessage, false); }; @@ -307,15 +344,17 @@ export const createNativeCallController = ( video, ongoing, }: NativeCallStartOptions) => { - if (activeRecord || elementCallActive) return; + if (activeRecord || elementCallActive) { + deps.onCleanup?.(); + return; + } let record: NativeCallRecord | undefined; - let stage: SetupStage = 'MatrixRTC'; + let stage: SetupStage = 'joining the call'; try { const deviceId = mx.getDeviceId(); if (!deviceId) { - setSetupError(room.roomId, setupErrorMessage(stage)); - return; + throw new Error('MatrixRTC device unavailable'); } const connectionId = deps.connectionId!(); @@ -335,53 +374,44 @@ export const createNativeCallController = ( record.errorUnlistenPromise = deps.onError((error) => handleError(currentRecord, error)); await Promise.all([record.stateUnlistenPromise, record.errorUnlistenPromise]); - stage = 'LiveKit transport'; - const transport = await deps.getPreferredTransport(mx, discovery); - if (!transport) throw new Error('No LiveKit transport available'); - - const userId = mx.getSafeUserId(); - const identity = { userId, deviceId, memberId: `${userId}:${deviceId}` }; - const joinConfig: JoinSessionConfig = { + stage = 'no transport'; + const joined = await joinAndProvisionMatrixRTC({ + mx, + room, + session, + discovery, + getPreferredTransport: deps.getPreferredTransport, + provisionToken: deps.provisionToken, callIntent: video ? 'video' : 'audio', ...(ongoing ? {} : { notificationType: dm ? 'ring' : 'notification' }), - }; - stage = 'MatrixRTC'; - const membershipWait = waitForOwnMembership(session, identity.userId, identity.deviceId); - record.cancelMembershipWait = membershipWait.cancel; - session.joinRTCSession(identity, [transport], undefined, joinConfig); - await membershipWait.promise; - record.cancelMembershipWait = undefined; - const slotId = session.slotId; - if (!slotId) throw new Error('MatrixRTC slot was not assigned'); - if (!isCurrent(record)) return; - - stage = 'token provisioning'; - const provisioned = await deps.provisionToken({ - mx, - roomId: room.roomId, - slotId, - deviceId, - serviceUrl: transport.livekit_service_url, - memberId: identity.memberId, - userId: identity.userId, + isCancelled: () => !isCurrent(currentRecord), + onStage: (joinStage) => { + stage = joinStage === 'joining-matrix' ? 'joining the call' : 'authorizing'; + }, + onMembershipWait: (cancel) => { + currentRecord.cancelMembershipWait = cancel; + }, + onMembershipError: (error) => logSetupFailure('joining the call', error), }); - if (!isCurrent(record)) return; + if (!isCurrent(currentRecord)) return; - stage = 'LiveKit connection'; + stage = 'connecting'; const state = await deps.connect({ connectionId, - serverUrl: provisioned.url, - participantToken: provisioned.jwt, + serverUrl: joined.provisioned.url, + participantToken: joined.provisioned.jwt, audio: true, video, screenShare: false, }); - handleState(record, state); - } catch { + handleState(currentRecord, state); + } catch (error) { + logSetupFailure(stage, error); if (record) { await cleanup(record, setupErrorMessage(stage), false); } else { setSetupError(room.roomId, setupErrorMessage(stage)); + deps.onCleanup?.(); } } }; diff --git a/src/app/features/call/useCallSignaling.ts b/src/app/features/call/useCallSignaling.ts index 878a6fb74f..41bf833cbe 100644 --- a/src/app/features/call/useCallSignaling.ts +++ b/src/app/features/call/useCallSignaling.ts @@ -12,6 +12,7 @@ import { type IncomingCall, } from '$state/callEmbed'; import { nativeCallAtom } from '$state/nativeCall'; +import { livekitJsCallAtom, selectActiveCallSession } from '$state/livekitJsCall'; import { settingsAtom } from '$state/settings'; import { parseIncomingRtcNotification, @@ -54,6 +55,7 @@ export function useIncomingCallSignaling() { const store = useStore(); const callEmbed = useAtomValue(callEmbedAtom); const nativeCall = useAtomValue(nativeCallAtom); + const livekitJsCall = useAtomValue(livekitJsCallAtom); const mDirects = useAtomValue(mDirectAtom); const settings = useAtomValue(settingsAtom); const incomingCall = useAtomValue(incomingCallAtom); @@ -92,6 +94,7 @@ export function useIncomingCallSignaling() { type SignalingHandlerRefs = { callEmbed: typeof callEmbed; nativeCall: typeof nativeCall; + livekitJsCall: typeof livekitJsCall; mDirects: typeof mDirects; outgoingRingbackAllowed: boolean; handleIncomingCall: (incoming: IncomingCall) => void; @@ -120,7 +123,7 @@ export function useIncomingCallSignaling() { hasCallBeenActiveRef.current = false; outgoingRingRoomIdRef.current = null; outgoingStartRef.current = null; - }, [callEmbed?.roomId, nativeCall?.connectionId]); + }, [callEmbed?.roomId, nativeCall?.connectionId, livekitJsCall?.roomId]); useEffect(() => { ringtoneManager.syncSources( @@ -150,7 +153,7 @@ export function useIncomingCallSignaling() { const handleOutgoingDecline = useCallback( (decline: OutgoingDeclineEvent) => { - const activeCall = callEmbed ?? nativeCall; + const activeCall = selectActiveCallSession(callEmbed, nativeCall, livekitJsCall); if (!activeCall || activeCall.roomId !== decline.roomId) { return; } @@ -211,7 +214,9 @@ export function useIncomingCallSignaling() { Sentry.metrics.count('sable.call.outgoing.declined', 1); stopOutgoingRing(); - const hangup = callEmbed?.hangup() ?? nativeCall?.hangup() ?? Promise.resolve(); + const hangup = + selectActiveCallSession(callEmbed, nativeCall, livekitJsCall)?.hangup() ?? + Promise.resolve(); void hangup .catch((error) => { debugLog.warn('call', 'Failed to hang up after outgoing decline', { @@ -227,7 +232,7 @@ export function useIncomingCallSignaling() { }, OUTGOING_DECLINE_EMBED_CLEAR_MS); }); }, - [callEmbed, mDirects, mx, nativeCall, setCallEmbed, stopOutgoingRing, store] + [callEmbed, livekitJsCall, mDirects, mx, nativeCall, setCallEmbed, stopOutgoingRing, store] ); const callAudioAllowed = canPlayCallAudio({ @@ -287,6 +292,7 @@ export function useIncomingCallSignaling() { signalingHandlerRefs.current = { callEmbed, nativeCall, + livekitJsCall, mDirects, outgoingRingbackAllowed, handleIncomingCall, @@ -425,11 +431,12 @@ export function useIncomingCallSignaling() { if (!senderId || !eventId) return; if (senderId === myUserId) { - if ( - type === RTC_NOTIFICATION_EVENT_TYPE && - (handlers().callEmbed?.roomId === room.roomId || - handlers().nativeCall?.roomId === room.roomId) - ) { + const activeCall = selectActiveCallSession( + handlers().callEmbed, + handlers().nativeCall, + handlers().livekitJsCall + ); + if (type === RTC_NOTIFICATION_EVENT_TYPE && activeCall?.roomId === room.roomId) { activeOutgoingNotificationIdRef.current = eventId; } return; @@ -444,7 +451,11 @@ export function useIncomingCallSignaling() { // Only inspect declines for the active outgoing call room. Cleartext declines are // cheap; encrypted events are decrypted only when they might be RTC declines. - const activeCall = handlers().callEmbed ?? handlers().nativeCall; + const activeCall = selectActiveCallSession( + handlers().callEmbed, + handlers().nativeCall, + handlers().livekitJsCall + ); if (!activeCall || activeCall.roomId !== room.roomId) { return; } @@ -504,8 +515,11 @@ export function useIncomingCallSignaling() { let outgoingRingTimeoutId: number | undefined; const evaluateOutgoingFallback = () => { - const activeCallRoomId = - handlers().callEmbed?.roomId ?? handlers().nativeCall?.roomId; + const activeCallRoomId = selectActiveCallSession( + handlers().callEmbed, + handlers().nativeCall, + handlers().livekitJsCall + )?.roomId; const stop = () => { handlers().stopOutgoingRing(); diff --git a/src/app/features/call/useCallStartCapabilities.ts b/src/app/features/call/useCallStartCapabilities.ts index bad149ca5f..f3c3c7f7f5 100644 --- a/src/app/features/call/useCallStartCapabilities.ts +++ b/src/app/features/call/useCallStartCapabilities.ts @@ -2,6 +2,8 @@ import { useCallback, useMemo } from 'react'; import type { Room } from '$types/matrix-sdk'; import { EventType } from '$types/matrix-sdk'; import { useCallEmbed } from '$hooks/useCallEmbed'; +import { useAtomValue } from 'jotai'; +import { livekitJsCallAtom } from '$state/livekitJsCall'; import { useLivekitSupport } from '$hooks/useLivekitSupport'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useStateEventCallback } from '$hooks/useStateEventCallback'; @@ -15,6 +17,7 @@ import { export const useCallStartCapabilities = (room: Room): CallStartCapabilities => { const mx = useMatrixClient(); const callEmbed = useCallEmbed(); + const livekitJsCall = useAtomValue(livekitJsCallAtom); const livekitSupported = useLivekitSupport(); const rtcSupported = webRTCSupported(); const myUserId = mx.getSafeUserId(); @@ -42,9 +45,17 @@ export const useCallStartCapabilities = (room: Room): CallStartCapabilities => { return evaluateCallStartCapabilities({ room, myUserId, - activeCallRoomId: callEmbed?.roomId, + activeCallRoomId: callEmbed?.roomId ?? livekitJsCall?.roomId, livekitSupported, rtcSupported, }); - }, [room, myUserId, callEmbed?.roomId, livekitSupported, rtcSupported, updateCount]); + }, [ + room, + myUserId, + callEmbed?.roomId, + livekitJsCall?.roomId, + livekitSupported, + rtcSupported, + updateCount, + ]); }; diff --git a/src/app/features/settings/experimental/Experimental.test.tsx b/src/app/features/settings/experimental/Experimental.test.tsx new file mode 100644 index 0000000000..b151189009 --- /dev/null +++ b/src/app/features/settings/experimental/Experimental.test.tsx @@ -0,0 +1,164 @@ +import type { ReactNode } from 'react'; +import type * as SettingsModule from '$state/settings'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Experimental } from './Experimental'; + +const { + mockPlatformSupported, + mockSetLiveKitJsCallsEnabled, + mockSetLiveKitJsMediaTestEnabled, + mockUseSetting, +} = vi.hoisted(() => ({ + mockPlatformSupported: vi.fn<() => boolean>(), + mockSetLiveKitJsCallsEnabled: vi.fn<(value: boolean) => void>(), + mockSetLiveKitJsMediaTestEnabled: vi.fn<(value: boolean) => void>(), + mockUseSetting: + vi.fn<(_atom: unknown, key: string) => readonly [boolean, (value: boolean) => void]>(), +})); + +vi.mock('$state/settings', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, settingsAtom: {} }; +}); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: mockUseSetting, +})); + +vi.mock('$features/call/nativeCallProbe', () => ({ + isNativeCallProbePlatformSupported: mockPlatformSupported, +})); + +vi.mock('$components/page', () => ({ + PageContent: ({ children }: { children: ReactNode }) =>
{children}
, + SettingsSectionPage: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('$components/info-card', () => ({ + InfoCard: () =>
Experimental warning
, +})); + +vi.mock('$components/setting-tile', () => ({ + SettingToggle: ({ + title, + description, + focusId, + value, + onChange, + }: { + title: string; + description: ReactNode; + focusId: string; + value: boolean; + onChange: (value: boolean) => void; + }) => ( +
+ {title} + {description} + +
+ ), +})); + +vi.mock('./BandwithSavingEmojis', () => ({ BandwidthSavingEmojis: () => null })); +vi.mock('./MSC4268HistoryShare', () => ({ MSC4268HistoryShare: () => null })); +vi.mock('./MSC4274MediaGalleries', () => ({ MSC4274MediaGalleries: () => null })); + +vi.mock('folds', () => ({ + Box: ({ children }: { children: ReactNode }) =>
{children}
, + Scroll: ({ children }: { children: ReactNode }) =>
{children}
, + Text: ({ children }: { children: ReactNode }) => {children}, +})); + +beforeEach(() => { + mockPlatformSupported.mockReset(); + mockPlatformSupported.mockReturnValue(true); + mockSetLiveKitJsCallsEnabled.mockReset(); + mockSetLiveKitJsMediaTestEnabled.mockReset(); + mockUseSetting.mockImplementation((_atom: unknown, key: string) => { + if (key === 'livekitJsCallsEnabled') return [false, mockSetLiveKitJsCallsEnabled]; + if (key === 'livekitJsMediaTestEnabled') return [false, mockSetLiveKitJsMediaTestEnabled]; + return [false, vi.fn<() => void>()]; + }); +}); + +describe('Experimental LiveKit JS calls setting', () => { + it('shows the connection probe toggle on supported Tauri platforms', () => { + render( {}} />); + + expect(screen.getByText('LiveKit JS Calls')).toBeInTheDocument(); + expect(screen.getByText('Try the LiveKit JS connection probe')).toBeInTheDocument(); + expect( + screen.getByText( + 'Runs an experimental LiveKit JS connection probe. It does not publish media. Element Call remains the normal fallback.' + ) + ).toBeInTheDocument(); + }); + + it('hides the connection probe toggle outside supported Tauri platforms', () => { + mockPlatformSupported.mockReturnValue(false); + + render( {}} />); + + expect(screen.queryByText('LiveKit JS Calls')).not.toBeInTheDocument(); + expect(screen.queryByText('Try the LiveKit JS connection probe')).not.toBeInTheDocument(); + }); + + it('persists the opt-in through the settings hook', () => { + render( {}} />); + + fireEvent.click(screen.getByRole('button', { name: 'livekit-js-calls' })); + + expect(mockSetLiveKitJsCallsEnabled).toHaveBeenCalledWith(true); + }); + + it('shows the manual media test only when the connection probe is enabled', () => { + const { rerender } = render( {}} />); + + expect(screen.queryByText('Enable the manual LiveKit JS media test')).not.toBeInTheDocument(); + + mockUseSetting.mockImplementation((_atom: unknown, key: string) => { + if (key === 'livekitJsCallsEnabled') return [true, mockSetLiveKitJsCallsEnabled]; + if (key === 'livekitJsMediaTestEnabled') return [false, mockSetLiveKitJsMediaTestEnabled]; + return [false, vi.fn<() => void>()]; + }); + rerender( {}} />); + + expect(screen.getByText('Enable the manual LiveKit JS media test')).toBeInTheDocument(); + expect( + screen.getByText( + 'Manual local media test only. Encrypted media is required. There is no fallback or automatic call selection. This is not release-ready.' + ) + ).toBeInTheDocument(); + }); + + it('does not show the manual media test on unsupported platforms', () => { + mockPlatformSupported.mockReturnValue(false); + mockUseSetting.mockImplementation((_atom: unknown, key: string) => { + if (key === 'livekitJsCallsEnabled') return [true, mockSetLiveKitJsCallsEnabled]; + if (key === 'livekitJsMediaTestEnabled') return [false, mockSetLiveKitJsMediaTestEnabled]; + return [false, vi.fn<() => void>()]; + }); + + render( {}} />); + + expect(screen.queryByText('Enable the manual LiveKit JS media test')).not.toBeInTheDocument(); + }); + + it('persists the manual media test opt-in through the settings hook', () => { + mockUseSetting.mockImplementation((_atom: unknown, key: string) => { + if (key === 'livekitJsCallsEnabled') return [true, mockSetLiveKitJsCallsEnabled]; + if (key === 'livekitJsMediaTestEnabled') return [false, mockSetLiveKitJsMediaTestEnabled]; + return [false, vi.fn<() => void>()]; + }); + + render( {}} />); + + fireEvent.click(screen.getByRole('button', { name: 'livekit-js-media-test' })); + + expect(mockSetLiveKitJsMediaTestEnabled).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 910272fe4e..93eb06d012 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -52,6 +52,41 @@ function NativeCallsToggle() { ); } +function LiveKitJsCallsToggle() { + const [livekitJsCallsEnabled, setLivekitJsCallsEnabled] = useSetting( + settingsAtom, + 'livekitJsCallsEnabled' + ); + const [livekitJsMediaTestEnabled, setLivekitJsMediaTestEnabled] = useSetting( + settingsAtom, + 'livekitJsMediaTestEnabled' + ); + + if (!isNativeCallProbePlatformSupported()) return null; + + return ( + + LiveKit JS Calls + + {livekitJsCallsEnabled && ( + + )} + + ); +} + type ExperimentalProps = { requestBack?: () => void; requestClose: () => void; @@ -80,6 +115,7 @@ export function Experimental({ requestBack, requestClose }: Readonly +
diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index 4d9caaff5e..c0cf22449d 100644 --- a/src/app/generated/tauri/commands.ts +++ b/src/app/generated/tauri/commands.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-28T09:36:39.569954197+00:00 + * Generated at: 2026-07-29T12:35:15.472053709+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -34,6 +34,14 @@ export async function exportDiagnostics(params: types.ExportDiagnosticsParams): return invoke('export_diagnostics', params); } +export async function getPlatformCallCapabilities(): Promise { + return invoke('getPlatformCallCapabilities'); +} + +export async function getPlatformCallState(): Promise { + return invoke('getPlatformCallState'); +} + export async function getDesktopRuntimeState(): Promise { return invoke('get_desktop_runtime_state'); } @@ -70,6 +78,10 @@ export async function saveDownload(params: types.SaveDownloadParams): Promise { + return invoke('set_media_enabled', params); +} + export async function setMediaEncryption(params: types.SetMediaEncryptionParams): Promise { return invoke('set_media_encryption', params); } @@ -110,10 +122,26 @@ export async function showSnapOverlay(): Promise { return invoke('show_snap_overlay'); } +export async function startPlatformCallLifecycle(params: types.StartPlatformCallLifecycleParams): Promise { + return invoke('startPlatformCallLifecycle', params); +} + +export async function startCallForegroundService(): Promise { + return invoke('start_call_foreground_service'); +} + export async function startWindowTrackingWithTarget(params: types.StartWindowTrackingWithTargetParams): Promise { return invoke('start_window_tracking_with_target', params); } +export async function stopPlatformCallLifecycle(params: types.StopPlatformCallLifecycleParams): Promise { + return invoke('stopPlatformCallLifecycle', params); +} + +export async function stopCallForegroundService(): Promise { + return invoke('stop_call_foreground_service'); +} + export async function stopWindowTracking(): Promise { return invoke('stop_window_tracking'); } diff --git a/src/app/generated/tauri/events.ts b/src/app/generated/tauri/events.ts index d5ce042cee..401ce89830 100644 --- a/src/app/generated/tauri/events.ts +++ b/src/app/generated/tauri/events.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-28T09:36:39.570756294+00:00 + * Generated at: 2026-07-29T12:35:15.472585808+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts index 7cf70421dc..a300506863 100644 --- a/src/app/generated/tauri/index.ts +++ b/src/app/generated/tauri/index.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-28T09:36:39.570886134+00:00 + * Generated at: 2026-07-29T12:35:15.472650188+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index a1c43ba916..84d27a3514 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-28T09:36:39.566252609+00:00 + * Generated at: 2026-07-29T12:35:15.471191832+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -49,6 +49,9 @@ export interface LoopbackFetchResponse { body: number[]; } + +export type MediaKind = "Microphone" | "Camera" | "ScreenShare"; + export interface NativeUploadResponse { status: number; body: string; @@ -59,6 +62,12 @@ export interface ProgressPayload { total: number; } +export interface SetMediaEnabledRequest { + connectionId: string; + kind: MediaKind; + enabled: boolean; +} + export interface ShareBatch { batchId: string; items: ShareItem[]; @@ -71,6 +80,16 @@ export interface ShareItem { mime?: string | null; } +export interface StartPlatformCallLifecycleRequest { + sessionId: string; + microphone: boolean; + playback: boolean; +} + +export interface StopPlatformCallLifecycleRequest { + sessionId: string; +} + export interface WindowTarget { window_class?: string | null; exe_name?: string | null; @@ -131,6 +150,11 @@ export interface SaveDownloadParams { [key: string]: unknown; } +export interface SetMediaEnabledParams { + payload: SetMediaEnabledRequest; + [key: string]: unknown; +} + export interface SetMediaEncryptionParams { url: string; key: string; @@ -179,11 +203,21 @@ export interface ShareInboxReadParams { [key: string]: unknown; } +export interface StartPlatformCallLifecycleParams { + payload: StartPlatformCallLifecycleRequest; + [key: string]: unknown; +} + export interface StartWindowTrackingWithTargetParams { target: WindowTarget; [key: string]: unknown; } +export interface StopPlatformCallLifecycleParams { + payload: StopPlatformCallLifecycleRequest; + [key: string]: unknown; +} + export interface SyncDesktopSettingsParams { settings: DesktopSettings; [key: string]: unknown; diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index 81da3b6dd0..316233035c 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -1,5 +1,13 @@ import type { RefObject } from 'react'; -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { MatrixClient, Room } from '$types/matrix-sdk'; import { useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; @@ -22,6 +30,11 @@ import { useAutoDiscoveryInfo } from './useAutoDiscoveryInfo'; import { useStore } from 'jotai'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; +import { livekitJsCallAtom, isLivekitJsCallActive } from '$state/livekitJsCall'; +import { acquireCallOwner, type CallOwnerLease } from '$state/callOwner'; +import { createLivekitJsController } from '$features/call/livekitJsController'; +import { isLivekitJsCallProbeEnabled } from '$features/call/livekitJsCallProbe'; +import { selectCallStartOwner } from '$features/call/callStartSelection'; const debugLog = createDebugLogger('useCallEmbed'); @@ -76,19 +89,89 @@ export const useCallStart = (dm = false) => { const callEmbedRef = useCallEmbedRef(); const store = useStore(); const [nativeCallsEnabled] = useSetting(settingsAtom, 'nativeCallsEnabled'); + const [livekitJsCallsEnabled] = useSetting(settingsAtom, 'livekitJsCallsEnabled'); + const [livekitJsMediaTestEnabled] = useSetting(settingsAtom, 'livekitJsMediaTestEnabled'); const discovery = useAutoDiscoveryInfo(); + const nativeOwnerLeaseRef = useRef(undefined); + const livekitJsRoomIdRef = useRef(undefined); + const livekitJsController = useMemo( + () => createLivekitJsController(undefined, { manualMediaTest: livekitJsMediaTestEnabled }), + [livekitJsMediaTestEnabled] + ); const nativeCallController = useMemo( () => createNativeCallController({ setSession: setNativeCall, + onCleanup: () => { + nativeOwnerLeaseRef.current?.release(); + nativeOwnerLeaseRef.current = undefined; + }, }), [setNativeCall] ); + useEffect(() => { + const unsubscribe = livekitJsController.subscribe((controllerState) => { + const roomId = livekitJsRoomIdRef.current; + if (!roomId) return; + if (controllerState.lifecycle === 'idle') { + store.set(livekitJsCallAtom, undefined); + return; + } + store.set(livekitJsCallAtom, { + roomId, + lifecycle: controllerState.lifecycle, + failure: controllerState.failure, + room: controllerState.lifecycle === 'active' ? controllerState.room : undefined, + media: controllerState.lifecycle === 'active' ? controllerState.media : undefined, + hangup: () => livekitJsController.disconnect(), + }); + }); + return () => { + unsubscribe(); + livekitJsRoomIdRef.current = undefined; + void livekitJsController.disconnect().finally(() => { + store.set(livekitJsCallAtom, undefined); + }); + }; + }, [livekitJsController, store]); + const startCall = useCallback( (room: Room, pref?: CallPreferences) => { - if (isNativeCallProbeEnabled(nativeCallsEnabled)) { - if (store.get(callEmbedAtom) || isNativeCallActive(store.get(nativeCallAtom))) return; + const startOwner = selectCallStartOwner({ + livekitJsProbeEnabled: isLivekitJsCallProbeEnabled(livekitJsCallsEnabled), + nativeProbeEnabled: isNativeCallProbeEnabled(nativeCallsEnabled), + }); + if (startOwner === 'livekit-js') { + if ( + store.get(callEmbedAtom) || + isNativeCallActive(store.get(nativeCallAtom)) || + isLivekitJsCallActive(store.get(livekitJsCallAtom)) + ) + return; + livekitJsRoomIdRef.current = room.roomId; + void livekitJsController + .connect({ + mx, + room, + discovery, + callIntent: pref?.video ? 'video' : 'audio', + dm, + ongoing: mx.matrixRTC.getRoomSession(room).memberships.length > 0, + }) + .catch(() => undefined); + return; + } + if (startOwner === 'native') { + if ( + store.get(callEmbedAtom) || + isNativeCallActive(store.get(nativeCallAtom)) || + isLivekitJsCallActive(store.get(livekitJsCallAtom)) + ) + return; + const ownerLease = acquireCallOwner('native', room.roomId); + if (!ownerLease) return; + nativeOwnerLeaseRef.current = ownerLease; const ongoing = mx.matrixRTC.getRoomSession(room).memberships.length > 0; void nativeCallController .start({ @@ -103,8 +186,11 @@ export const useCallStart = (dm = false) => { .catch(() => undefined); return; } + const ownerLease = acquireCallOwner('element', room.roomId); + if (!ownerLease) return; const container = callEmbedRef.current; if (!container) { + ownerLease.release(); debugLog.error('call', 'Failed to start call — no embed container', { roomId: room.roomId, }); @@ -130,6 +216,7 @@ export const useCallStart = (dm = false) => { ); setCallEmbed(callEmbed); } catch (err) { + ownerLease.release(); debugLog.error('call', 'Call embed creation failed', { roomId: room.roomId, error: err instanceof Error ? err.message : String(err), @@ -152,6 +239,8 @@ export const useCallStart = (dm = false) => { discovery, nativeCallController, nativeCallsEnabled, + livekitJsCallsEnabled, + livekitJsController, ] ); diff --git a/src/app/plugins/call/callLifecycle.test.ts b/src/app/plugins/call/callLifecycle.test.ts index 8a213bd509..78dfdb1723 100644 --- a/src/app/plugins/call/callLifecycle.test.ts +++ b/src/app/plugins/call/callLifecycle.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const pluginConnect = vi.hoisted(() => vi.fn<(request: unknown) => Promise>()); const pluginDisconnect = vi.hoisted(() => vi.fn<(request: unknown) => Promise>()); const pluginGetState = vi.hoisted(() => vi.fn<() => Promise>()); +const invoke = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise>()); const listen = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise>()); vi.mock('tauri-plugin-call-lifecycle-api', () => ({ @@ -10,6 +11,7 @@ vi.mock('tauri-plugin-call-lifecycle-api', () => ({ disconnect: pluginDisconnect, getState: pluginGetState, })); +vi.mock('@tauri-apps/api/core', () => ({ invoke })); vi.mock('@tauri-apps/api/event', () => ({ listen })); import { @@ -18,6 +20,7 @@ import { getState, onError, onState, + setMediaEnabled, type CallLifecycleError, type CallState, } from './callLifecycle'; @@ -27,10 +30,28 @@ describe('call lifecycle wrapper', () => { pluginConnect.mockReset(); pluginDisconnect.mockReset(); pluginGetState.mockReset(); + invoke.mockReset(); listen.mockReset(); listen.mockResolvedValue(vi.fn()); }); + it('uses the namespaced media command and native snake-case payload', async () => { + const state = { + revision: 1, + state: 'connected', + connectionId: 'connection-id', + } satisfies CallState; + invoke.mockResolvedValue(state); + + await expect( + setMediaEnabled({ connectionId: 'connection-id', kind: 'screen_share', enabled: true }) + ).resolves.toEqual(state); + + expect(invoke).toHaveBeenCalledWith('plugin:call-lifecycle|set_media_enabled', { + payload: { connectionId: 'connection-id', kind: 'screen_share', enabled: true }, + }); + }); + it('delegates lifecycle commands to the guest API', async () => { const state = { revision: 1, diff --git a/src/app/plugins/call/callLifecycle.ts b/src/app/plugins/call/callLifecycle.ts index dc2d201619..410cdcab78 100644 --- a/src/app/plugins/call/callLifecycle.ts +++ b/src/app/plugins/call/callLifecycle.ts @@ -8,6 +8,7 @@ import { type ConnectionState, type DisconnectRequest, } from 'tauri-plugin-call-lifecycle-api'; +import { invoke } from '@tauri-apps/api/core'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; const STATE_EVENT = 'plugin:call-lifecycle://state'; @@ -15,10 +16,22 @@ const ERROR_EVENT = 'plugin:call-lifecycle://error'; export type { CallLifecycleError, CallState, ConnectRequest, ConnectionState, DisconnectRequest }; +export type MediaKind = 'microphone' | 'camera' | 'screen_share'; + +export type SetMediaEnabledRequest = { + connectionId: string; + kind: MediaKind; + enabled: boolean; +}; + export const connect = pluginConnect; export const disconnect = pluginDisconnect; export const getState = pluginGetState; +export function setMediaEnabled(request: SetMediaEnabledRequest): Promise { + return invoke('plugin:call-lifecycle|set_media_enabled', { payload: request }); +} + export function onState(handler: (state: CallState) => void): Promise { return listen(STATE_EVENT, (event) => handler(event.payload)); } diff --git a/src/app/plugins/call/platformCallLifecycle.test.ts b/src/app/plugins/call/platformCallLifecycle.test.ts new file mode 100644 index 0000000000..b4c743f03d --- /dev/null +++ b/src/app/plugins/call/platformCallLifecycle.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const pluginGetCapabilities = vi.hoisted(() => vi.fn<() => Promise>()); +const pluginGetState = vi.hoisted(() => vi.fn<() => Promise>()); +const pluginListenEvent = vi.hoisted(() => + vi.fn<(handler: (event: unknown) => void) => Promise>() +); +const pluginStart = vi.hoisted(() => vi.fn<(request: unknown) => Promise>()); +const pluginStop = vi.hoisted(() => vi.fn<(request: unknown) => Promise>()); + +vi.mock('tauri-plugin-call-lifecycle-api', () => ({ + getPlatformCallCapabilities: pluginGetCapabilities, + getPlatformCallState: pluginGetState, + listenPlatformCallEvent: pluginListenEvent, + startPlatformCallLifecycle: pluginStart, + stopPlatformCallLifecycle: pluginStop, +})); + +import { + getPlatformCapabilities, + getPlatformState, + onPlatformCallEvent, + startPlatformLifecycle, + stopPlatformLifecycle, + type PlatformCallEvent, + type PlatformCallState, +} from './platformCallLifecycle'; + +describe('platform call lifecycle wrapper', () => { + beforeEach(() => { + pluginGetCapabilities.mockReset(); + pluginGetState.mockReset(); + pluginListenEvent.mockReset(); + pluginStart.mockReset(); + pluginStop.mockReset(); + pluginListenEvent.mockResolvedValue(vi.fn()); + }); + + it('delegates capabilities/start/stop/state commands with opaque session payloads', async () => { + const capabilities = { supported: true, microphone: true, playback: true }; + const state = { + revision: 2, + state: 'active', + sessionId: 'opaque-session', + microphone: true, + playback: true, + capabilities, + } satisfies PlatformCallState; + pluginGetCapabilities.mockResolvedValue(capabilities); + pluginStart.mockResolvedValue(state); + pluginStop.mockResolvedValue(state); + pluginGetState.mockResolvedValue(state); + + await expect(getPlatformCapabilities()).resolves.toEqual(capabilities); + await expect( + startPlatformLifecycle({ sessionId: 'opaque-session', microphone: true, playback: true }) + ).resolves.toEqual(state); + await expect(stopPlatformLifecycle({ sessionId: 'opaque-session' })).resolves.toEqual(state); + await expect(getPlatformState()).resolves.toEqual(state); + + expect(pluginStart).toHaveBeenCalledWith({ + sessionId: 'opaque-session', + microphone: true, + playback: true, + }); + expect(pluginStop).toHaveBeenCalledWith({ sessionId: 'opaque-session' }); + }); + + it('forwards bounded platform events and drops malformed payloads', async () => { + const handler = vi.fn<(event: PlatformCallEvent) => void>(); + await onPlatformCallEvent(handler); + const listener = pluginListenEvent.mock.calls[0]?.[0] as (event: unknown) => void; + + const routeEvent: PlatformCallEvent = { + revision: 4, + sessionId: 'opaque-session', + type: 'route_changed', + route: 'speaker', + }; + listener(routeEvent); + listener({ revision: 5, sessionId: 'opaque-session', type: 'media_reset' }); + listener({ + revision: 6, + sessionId: 'opaque-session', + type: 'failed', + code: 'audio_unavailable', + }); + + // Malformed or out-of-contract payloads never reach the handler. + listener({ revision: 7, sessionId: 'opaque-session', type: 'route_changed', route: 'eth0' }); + listener({ revision: 8, sessionId: 'opaque-session', type: 'publish_track' }); + listener({ revision: '9', sessionId: 'opaque-session', type: 'media_reset' }); + listener({ revision: 10, type: 'media_reset' }); + listener({ revision: 11, sessionId: 'opaque-session', type: 'failed', code: 'raw-native' }); + + expect(handler).toHaveBeenCalledTimes(3); + expect(handler).toHaveBeenNthCalledWith(1, routeEvent); + }); +}); diff --git a/src/app/plugins/call/platformCallLifecycle.ts b/src/app/plugins/call/platformCallLifecycle.ts new file mode 100644 index 0000000000..3b73c3babd --- /dev/null +++ b/src/app/plugins/call/platformCallLifecycle.ts @@ -0,0 +1,81 @@ +import { + getPlatformCallCapabilities as pluginGetCapabilities, + getPlatformCallState as pluginGetState, + listenPlatformCallEvent as pluginListenEvent, + startPlatformCallLifecycle as pluginStart, + stopPlatformCallLifecycle as pluginStop, + type PlatformCallCapabilities, + type PlatformCallEvent, + type PlatformCallFailureCode, + type PlatformCallRoute, + type PlatformCallState, + type StartPlatformCallLifecycleRequest, + type StopPlatformCallLifecycleRequest, +} from 'tauri-plugin-call-lifecycle-api'; +import type { UnlistenFn } from '@tauri-apps/api/event'; + +export type { + PlatformCallCapabilities, + PlatformCallEvent, + PlatformCallFailureCode, + PlatformCallRoute, + PlatformCallState, + StartPlatformCallLifecycleRequest, + StopPlatformCallLifecycleRequest, +}; + +const EVENT_TYPES = new Set([ + 'focus_changed', + 'route_changed', + 'interrupted', + 'media_reset', + 'failed', +]); + +const ROUTES = new Set(['earpiece', 'speaker', 'wired', 'bluetooth', 'unknown']); + +const FAILURE_CODES = new Set([ + 'unsupported', + 'permission_denied', + 'audio_unavailable', + 'start_failed', + 'stop_failed', + 'stale_session', + 'busy', +]); + +const isBoundedEvent = (payload: unknown): payload is PlatformCallEvent => { + if (!payload || typeof payload !== 'object') return false; + const event = payload as Record; + if (typeof event.revision !== 'number' || typeof event.sessionId !== 'string') return false; + if (typeof event.type !== 'string' || !EVENT_TYPES.has(event.type)) return false; + switch (event.type) { + case 'focus_changed': + return typeof event.focused === 'boolean'; + case 'route_changed': + return typeof event.route === 'string' && ROUTES.has(event.route as PlatformCallRoute); + case 'interrupted': + return event.state === 'began' || event.state === 'ended'; + case 'media_reset': + return true; + case 'failed': + return ( + typeof event.code === 'string' && FAILURE_CODES.has(event.code as PlatformCallFailureCode) + ); + default: + return false; + } +}; + +export const getPlatformCapabilities = pluginGetCapabilities; +export const startPlatformLifecycle = pluginStart; +export const stopPlatformLifecycle = pluginStop; +export const getPlatformState = pluginGetState; + +export function onPlatformCallEvent( + handler: (event: PlatformCallEvent) => void +): Promise { + return pluginListenEvent((payload) => { + if (isBoundedEvent(payload)) handler(payload); + }); +} diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index 3dcc6c7459..6a81124540 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -2,6 +2,7 @@ import { atom } from 'jotai'; import * as Sentry from '@sentry/react'; import type { CallEmbed } from '../plugins/call'; import type { CallEmbedStartError } from '$plugins/call/callEmbedError'; +import { releaseCallOwner } from './callOwner'; const baseCallEmbedAtom = atom(undefined); const baseCallEmbedStartErrorAtom = atom(null); @@ -25,6 +26,7 @@ export const callEmbedAtom = atom resetCallOwnerForTests()); + +describe('call owner guard', () => { + it('allows one owner and rejects competing owners until release', () => { + const native = acquireCallOwner('native', '!room:example.org'); + + expect(native).toBeDefined(); + expect(acquireCallOwner('livekit-js', '!room:example.org')).toBeUndefined(); + expect(getActiveCallOwner()).toMatchObject({ kind: 'native' }); + + native?.release(); + expect(acquireCallOwner('element', '!room:example.org')).toBeDefined(); + }); + + it('makes release idempotent and cannot release a replacement lease', () => { + const first = acquireCallOwner('element', '!room:example.org'); + first?.release(); + const second = acquireCallOwner('livekit-js', '!room:example.org'); + + first?.release(); + expect(getActiveCallOwner()).toMatchObject({ kind: 'livekit-js' }); + second?.release(); + expect(getActiveCallOwner()).toBeUndefined(); + }); +}); diff --git a/src/app/state/callOwner.ts b/src/app/state/callOwner.ts new file mode 100644 index 0000000000..a266a13008 --- /dev/null +++ b/src/app/state/callOwner.ts @@ -0,0 +1,40 @@ +export type CallOwnerKind = 'element' | 'native' | 'livekit-js'; + +export type CallOwnerLease = { + kind: CallOwnerKind; + roomId: string; + release: () => void; +}; + +let activeOwner: CallOwnerLease | undefined; + +export const acquireCallOwner = ( + kind: CallOwnerKind, + roomId: string +): CallOwnerLease | undefined => { + if (activeOwner) return undefined; + + let released = false; + const lease: CallOwnerLease = { + kind, + roomId, + release: () => { + if (released || activeOwner !== lease) return; + released = true; + activeOwner = undefined; + }, + }; + activeOwner = lease; + return lease; +}; + +export const getActiveCallOwner = (): Pick | undefined => + activeOwner; + +export const releaseCallOwner = (kind: CallOwnerKind, roomId: string): void => { + if (activeOwner?.kind === kind && activeOwner.roomId === roomId) activeOwner.release(); +}; + +export const resetCallOwnerForTests = (): void => { + activeOwner = undefined; +}; diff --git a/src/app/state/livekitJsCall.test.ts b/src/app/state/livekitJsCall.test.ts new file mode 100644 index 0000000000..bb5a075c6f --- /dev/null +++ b/src/app/state/livekitJsCall.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { selectActiveCallSession } from './livekitJsCall'; +import { acquireCallOwner, getActiveCallOwner, resetCallOwnerForTests } from './callOwner'; + +describe('selectActiveCallSession', () => { + it('selects the JS owner when Element Call and native sessions are absent', () => { + const livekitSession = { + roomId: '!room:example.org', + lifecycle: 'active' as const, + failure: null, + hangup: async () => undefined, + }; + + expect(selectActiveCallSession(undefined, undefined, livekitSession)).toBe(livekitSession); + }); + + it('preserves Element Call and native precedence', () => { + const element = { roomId: '!element:example.org' }; + const native = { + roomId: '!native:example.org', + connectionId: 'native', + lifecycle: 'connected' as const, + hangup: async () => undefined, + }; + const livekit = { + roomId: '!livekit:example.org', + lifecycle: 'active' as const, + failure: null, + hangup: async () => undefined, + }; + + expect(selectActiveCallSession(element, native, livekit)).toBe(element); + expect(selectActiveCallSession(undefined, native, livekit)).toBe(native); + }); + + it('routes past a failed native session to the active JS owner and releases its lease', async () => { + resetCallOwnerForTests(); + const lease = acquireCallOwner('livekit-js', '!room:example.org'); + const livekit = { + roomId: '!room:example.org', + lifecycle: 'active' as const, + failure: null, + hangup: async () => lease?.release(), + }; + const native = { + roomId: '!room:example.org', + connectionId: 'native', + lifecycle: 'error' as const, + hangup: async () => undefined, + }; + + await selectActiveCallSession(undefined, native, livekit)?.hangup(); + + expect(getActiveCallOwner()).toBeUndefined(); + }); + + it('routes past a failed JS session to Element Call and releases its lease', async () => { + resetCallOwnerForTests(); + const lease = acquireCallOwner('element', '!room:example.org'); + const element = { + roomId: '!room:example.org', + hangup: async () => lease?.release(), + }; + const livekit = { + roomId: '!room:example.org', + lifecycle: 'failed' as const, + failure: 'setup-failed' as const, + hangup: async () => undefined, + }; + + await selectActiveCallSession(element, undefined, livekit)?.hangup(); + + expect(getActiveCallOwner()).toBeUndefined(); + }); +}); diff --git a/src/app/state/livekitJsCall.ts b/src/app/state/livekitJsCall.ts new file mode 100644 index 0000000000..278c4951f5 --- /dev/null +++ b/src/app/state/livekitJsCall.ts @@ -0,0 +1,35 @@ +import { atom } from 'jotai'; +import type { + LivekitJsControllerFailure, + LivekitJsControllerLifecycle, + LivekitJsMediaFacade, +} from '$features/call/livekitJsController'; +import type { Room as LivekitRoom } from 'livekit-client'; +import type { NativeCallSession } from './nativeCall'; + +export type LivekitJsCallSession = { + roomId: string; + lifecycle: LivekitJsControllerLifecycle; + failure: LivekitJsControllerFailure | null; + room?: LivekitRoom; + media?: LivekitJsMediaFacade; + hangup: () => Promise; +}; + +export const livekitJsCallAtom = atom(undefined); + +export const isLivekitJsCallActive = (session: LivekitJsCallSession | undefined): boolean => + session?.lifecycle !== undefined && + session.lifecycle !== 'idle' && + session.lifecycle !== 'failed'; + +export const selectActiveCallSession = ( + elementCall: Element | undefined, + nativeCall: NativeCallSession | undefined, + livekitJsCall: LivekitJsCallSession | undefined +): Element | NativeCallSession | LivekitJsCallSession | undefined => { + if (elementCall) return elementCall; + if (nativeCall && nativeCall.lifecycle !== 'error') return nativeCall; + if (isLivekitJsCallActive(livekitJsCall)) return livekitJsCall; + return undefined; +}; diff --git a/src/app/state/settings.defaults.test.ts b/src/app/state/settings.defaults.test.ts index 159d3f7a6f..377abb661e 100644 --- a/src/app/state/settings.defaults.test.ts +++ b/src/app/state/settings.defaults.test.ts @@ -21,6 +21,24 @@ describe('mergePersistedSettings', () => { ); }); + it('defaults the LiveKit JS connection probe off and persists the opt-in', () => { + expect(defaultSettings.livekitJsCallsEnabled).toBe(false); + + localStorage.setItem('settings', JSON.stringify({ livekitJsCallsEnabled: true })); + expect(mergePersistedSettings(localStorage.getItem('settings'), {}).livekitJsCallsEnabled).toBe( + true + ); + }); + + it('defaults the LiveKit JS media test off and persists the opt-in', () => { + expect(defaultSettings.livekitJsMediaTestEnabled).toBe(false); + + localStorage.setItem('settings', JSON.stringify({ livekitJsMediaTestEnabled: true })); + expect( + mergePersistedSettings(localStorage.getItem('settings'), {}).livekitJsMediaTestEnabled + ).toBe(true); + }); + it('layers deployer defaults over code defaults when localStorage is empty', () => { const merged = mergePersistedSettings(null, { twitterEmoji: false }); expect(merged.twitterEmoji).toBe(false); @@ -97,6 +115,20 @@ describe('sanitizeSettingsDefaults', () => { }); }); + it('accepts the LiveKit JS connection probe setting', () => { + expect(sanitizeSettingsDefaults({ livekitJsCallsEnabled: true })).toEqual({ + livekitJsCallsEnabled: true, + }); + expect(sanitizeSettingsDefaults({ livekitJsCallsEnabled: 'yes' })).toEqual({}); + }); + + it('accepts the LiveKit JS media test setting', () => { + expect(sanitizeSettingsDefaults({ livekitJsMediaTestEnabled: true })).toEqual({ + livekitJsMediaTestEnabled: true, + }); + expect(sanitizeSettingsDefaults({ livekitJsMediaTestEnabled: 'yes' })).toEqual({}); + }); + it('drops unknown keys', () => { expect(sanitizeSettingsDefaults({ notARealSetting: true, hour24Clock: true })).toEqual({ hour24Clock: true, diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index a007f9e247..de19059235 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -181,6 +181,8 @@ export interface Settings { enableMSC4268CMD: boolean; enableMediaGalleries: boolean; nativeCallsEnabled: boolean; + livekitJsCallsEnabled: boolean; + livekitJsMediaTestEnabled: boolean; settingsSyncEnabled: boolean; // Cosmetics! @@ -339,6 +341,8 @@ export const defaultSettings: Settings = { enableMSC4268CMD: false, enableMediaGalleries: false, nativeCallsEnabled: false, + livekitJsCallsEnabled: false, + livekitJsMediaTestEnabled: false, // Push notifications (SW/Sygnal): default on for mobile, opt-in on desktop. // In-app pill banner: default on for mobile (primary foreground alert), opt-in on desktop.