diff --git a/.changeset/add-new-calls.md b/.changeset/add-new-calls.md
new file mode 100644
index 000000000..05125226e
--- /dev/null
+++ b/.changeset/add-new-calls.md
@@ -0,0 +1,7 @@
+---
+default: minor
+---
+
+# Add experimental new calls
+
+Introduce an opt-in new call system behind a single experimental setting. It uses LiveKit JS on web and desktop and native LiveKit on supported mobile devices, with Element Call remaining the fallback when the setting is off.
diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml
index fac99cb0f..0b2d29fad 100644
--- a/.github/workflows/tauri-build.yml
+++ b/.github/workflows/tauri-build.yml
@@ -461,6 +461,10 @@ jobs:
shell: bash
run: mise -y run tauri:setup:ios
+ - name: Download iOS frameworks
+ shell: bash
+ run: pnpm ios:frameworks
+
- name: Symlink icons to the ios folder
shell: bash
run: mise run icons:symlink --write --force
diff --git a/.ignore b/.ignore
new file mode 100644
index 000000000..b2f5e7e8a
--- /dev/null
+++ b/.ignore
@@ -0,0 +1,9 @@
+!.slim/deepwork/
+!.slim/deepwork/**
+
+# BEGIN oh-my-opencode-slim worktrees
+!.slim/
+!.slim/worktrees.json
+!.slim/worktrees/
+!.slim/worktrees/**
+# END oh-my-opencode-slim worktrees
diff --git a/package.json b/package.json
index e40a6aefe..864342332 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"typecheck": "tsc",
"tauri": "tauri",
"tauri:cef": "node scripts/tauri.js cef",
+ "ios:frameworks": "node scripts/download-ios-frameworks.js",
"tauri:wry": "node scripts/tauri.js wry",
"test": "vitest",
"test:ui": "vitest --ui",
@@ -46,6 +47,7 @@
"@choochmeque/tauri-plugin-sharekit-api": "0.4.0-rc.5",
"@fontsource-variable/nunito": "5.2.7",
"@fontsource/space-mono": "5.2.9",
+ "@livekit/components-react": "2.9.23",
"@lottiefiles/dotlottie-react": "^0.12.0",
"@noble/hashes": "^2.2.0",
"@phosphor-icons/react": "^2.1.10",
@@ -97,6 +99,7 @@
"leaflet": "1.9.4",
"linkify-react": "^4.3.3",
"linkifyjs": "^4.3.3",
+ "livekit-client": "2.21.0",
"marked": "^18.0.5",
"matrix-js-sdk": "42.0.0",
"matrix-widget-api": "^1.17.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 73d8ac5e0..3deb3da51 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -43,6 +43,9 @@ importers:
'@fontsource/space-mono':
specifier: 5.2.9
version: 5.2.9
+ '@livekit/components-react':
+ specifier: 2.9.23
+ version: 2.9.23(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1)
'@lottiefiles/dotlottie-react':
specifier: ^0.12.0
version: 0.12.3(react@18.3.1)
@@ -196,6 +199,9 @@ importers:
linkifyjs:
specifier: ^4.3.3
version: 4.3.3
+ livekit-client:
+ specifier: 2.21.0
+ version: 2.21.0(@types/dom-mediacapture-record@1.0.22)
marked:
specifier: ^18.0.5
version: 18.0.5
@@ -952,6 +958,9 @@ packages:
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
+ '@bufbuild/protobuf@1.10.1':
+ resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
+
'@choochmeque/tauri-plugin-notifications-api@0.5.0-rc.11':
resolution: {integrity: sha512-TSuf8HQ4Ayp9IKKB5v2yTdgKMa/I0bqbcACj0IZ46CSpzn/HKner/BKG7rM8tGOarEcyBd/VXnDhK5cJA/YMtw==}
@@ -1401,6 +1410,15 @@ packages:
'@noble/hashes':
optional: true
+ '@floating-ui/core@1.8.0':
+ resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
+
+ '@floating-ui/dom@1.7.6':
+ resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
+
+ '@floating-ui/utils@0.2.12':
+ resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
+
'@fontsource-variable/nunito@5.2.7':
resolution: {integrity: sha512-2N8QhatkyKgSUbAGZO2FYLioxA32+RyI1EplVLawbpkGjUeui9Qg9VMrpkCaik1ydjFjfLV+kzQ0cGEsMrMenQ==}
@@ -1622,6 +1640,32 @@ packages:
'@kwsites/file-exists@1.1.1':
resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==}
+ '@livekit/components-core@0.12.14':
+ resolution: {integrity: sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ livekit-client: ^2.20.1
+ tslib: ^2.6.2
+
+ '@livekit/components-react@2.9.23':
+ resolution: {integrity: sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@livekit/krisp-noise-filter': ^0.2.12 || ^0.3.0 || ^0.4.0
+ livekit-client: ^2.20.1
+ react: '>=18'
+ react-dom: '>=18'
+ tslib: ^2.6.2
+ peerDependenciesMeta:
+ '@livekit/krisp-noise-filter':
+ optional: true
+
+ '@livekit/mutex@1.1.1':
+ resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
+
+ '@livekit/protocol@1.50.4':
+ resolution: {integrity: sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==}
+
'@lottiefiles/dotlottie-react@0.12.3':
resolution: {integrity: sha512-b0k0Lakj2hmhyIkwJSpKySh6xoelpgWRaijyrCb6fraOCnzuitKglVTtYs0VWf72rk+2aSmC/0IK8j8wruVfOw==}
peerDependencies:
@@ -3101,6 +3145,9 @@ packages:
'@types/dockerode@4.0.1':
resolution: {integrity: sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==}
+ '@types/dom-mediacapture-record@1.0.22':
+ resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
+
'@types/estree@0.0.39':
resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
@@ -4346,6 +4393,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
+ jose@6.2.4:
+ resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==}
+
jotai@2.20.1:
resolution: {integrity: sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==}
engines: {node: '>=12.20.0'}
@@ -4516,6 +4566,11 @@ packages:
linkifyjs@4.3.3:
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
+ livekit-client@2.21.0:
+ resolution: {integrity: sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==}
+ peerDependencies:
+ '@types/dom-mediacapture-record': ^1
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -4532,6 +4587,10 @@ packages:
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+ loglevel@1.9.1:
+ resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==}
+ engines: {node: '>= 0.6.0'}
+
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
@@ -5061,6 +5120,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ rxjs@7.8.2:
+ resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+
safe-array-concat@1.1.4:
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
engines: {node: '>=0.4'}
@@ -5092,10 +5154,17 @@ packages:
scroll-into-view-if-needed@3.1.0:
resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
+ sdp-transform@2.15.0:
+ resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==}
+ hasBin: true
+
sdp-transform@3.0.0:
resolution: {integrity: sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==}
hasBin: true
+ sdp@3.2.2:
+ resolution: {integrity: sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==}
+
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -5441,6 +5510,9 @@ packages:
resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
engines: {node: '>= 0.4'}
+ typed-emitter@2.1.0:
+ resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -5519,6 +5591,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ usehooks-ts@3.1.1:
+ resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==}
+ engines: {node: '>=16.15.0'}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
+
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -5729,6 +5807,10 @@ packages:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
+ webrtc-adapter@9.0.6:
+ resolution: {integrity: sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==}
+ engines: {node: '>=6.0.0', npm: '>=3.10.0'}
+
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
@@ -6648,6 +6730,8 @@ snapshots:
dependencies:
css-tree: 3.2.1
+ '@bufbuild/protobuf@1.10.1': {}
+
'@choochmeque/tauri-plugin-notifications-api@0.5.0-rc.11':
dependencies:
'@tauri-apps/api': 2.11.1
@@ -6929,6 +7013,17 @@ snapshots:
optionalDependencies:
'@noble/hashes': 2.2.0
+ '@floating-ui/core@1.8.0':
+ dependencies:
+ '@floating-ui/utils': 0.2.12
+
+ '@floating-ui/dom@1.7.6':
+ dependencies:
+ '@floating-ui/core': 1.8.0
+ '@floating-ui/utils': 0.2.12
+
+ '@floating-ui/utils@0.2.12': {}
+
'@fontsource-variable/nunito@5.2.7': {}
'@fontsource/space-mono@5.2.9': {}
@@ -7110,6 +7205,32 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@livekit/components-core@0.12.14(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22)
+ loglevel: 1.9.1
+ rxjs: 7.8.2
+ tslib: 2.8.1
+
+ '@livekit/components-react@2.9.23(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1)':
+ dependencies:
+ '@livekit/components-core': 0.12.14(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
+ clsx: 2.1.1
+ events: 3.3.0
+ jose: 6.2.4
+ livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ tslib: 2.8.1
+ usehooks-ts: 3.1.1(react@18.3.1)
+
+ '@livekit/mutex@1.1.1': {}
+
+ '@livekit/protocol@1.50.4':
+ dependencies:
+ '@bufbuild/protobuf': 1.10.1
+
'@lottiefiles/dotlottie-react@0.12.3(react@18.3.1)':
dependencies:
'@lottiefiles/dotlottie-web': 0.40.1
@@ -8165,6 +8286,8 @@ snapshots:
'@types/node': 24.13.2
'@types/ssh2': 1.15.5
+ '@types/dom-mediacapture-record@1.0.22': {}
+
'@types/estree@0.0.39': {}
'@types/estree@1.0.9': {}
@@ -9577,6 +9700,8 @@ snapshots:
jiti@2.7.0: {}
+ jose@6.2.4: {}
+
jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(react@18.3.1):
optionalDependencies:
'@babel/core': 7.29.7
@@ -9722,6 +9847,19 @@ snapshots:
linkifyjs@4.3.3: {}
+ livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22):
+ dependencies:
+ '@livekit/mutex': 1.1.1
+ '@livekit/protocol': 1.50.4
+ '@types/dom-mediacapture-record': 1.0.22
+ events: 3.3.0
+ jose: 6.2.4
+ loglevel: 1.9.2
+ sdp-transform: 2.15.0
+ tslib: 2.8.1
+ typed-emitter: 2.1.0
+ webrtc-adapter: 9.0.6
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -9734,6 +9872,8 @@ snapshots:
lodash@4.18.1: {}
+ loglevel@1.9.1: {}
+
loglevel@1.9.2: {}
long@5.3.2: {}
@@ -10389,6 +10529,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.62.0
fsevents: 2.3.3
+ rxjs@7.8.2:
+ dependencies:
+ tslib: 2.8.1
+
safe-array-concat@1.1.4:
dependencies:
call-bind: 1.0.9
@@ -10426,8 +10570,12 @@ snapshots:
dependencies:
compute-scroll-into-view: 3.1.1
+ sdp-transform@2.15.0: {}
+
sdp-transform@3.0.0: {}
+ sdp@3.2.2: {}
+
semver@6.3.1: {}
semver@7.8.4: {}
@@ -10904,6 +11052,10 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
+ typed-emitter@2.1.0:
+ optionalDependencies:
+ rxjs: 7.8.2
+
typescript@5.9.3: {}
ua-is-frozen@0.1.2: {}
@@ -10966,6 +11118,11 @@ snapshots:
dependencies:
react: 18.3.1
+ usehooks-ts@3.1.1(react@18.3.1):
+ dependencies:
+ lodash.debounce: 4.0.8
+ react: 18.3.1
+
util-deprecate@1.0.2: {}
uuid@10.0.0: {}
@@ -11119,6 +11276,10 @@ snapshots:
webidl-conversions@8.0.1: {}
+ webrtc-adapter@9.0.6:
+ dependencies:
+ sdp: 3.2.2
+
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1(@noble/hashes@2.2.0):
diff --git a/scripts/download-ios-frameworks.js b/scripts/download-ios-frameworks.js
new file mode 100644
index 000000000..143d4d624
--- /dev/null
+++ b/scripts/download-ios-frameworks.js
@@ -0,0 +1,150 @@
+#!/usr/bin/env node
+//MISE description="Download and verify the iOS XCFrameworks used by tauri-plugin-livekit-mobile"
+//
+// iOS-only: LiveKit ships the WebRTC engine and the Rust UniFFI bindings as
+// binary XCFrameworks. The app links them via `bundle.iOS.frameworks` in
+// src-tauri/tauri.conf.json, so they must exist before `tauri ios build`.
+// Binaries are not committed; this script provisions them reproducibly into
+// the gitignored src-tauri/Frameworks/ directory.
+//
+// Safety properties:
+// - every download is SHA-256 verified against a pinned digest before use
+// (fail closed: a checksum mismatch changes nothing on disk);
+// - extraction happens in a temp directory and is moved into place with a
+// single rename, so a failure never leaves a partial install;
+// - re-running is a no-op when the installed frameworks already match the
+// pinned digests (see Frameworks/.manifest.json).
+import { createHash } from 'node:crypto';
+import { spawnSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
+import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
+import process from 'node:process';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { PrefixedLogger } from './utils/console-style.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const log = new PrefixedLogger('[ios-frameworks]');
+
+const FRAMEWORKS_DIR = resolve(__dirname, '../src-tauri/Frameworks');
+const MANIFEST_PATH = join(FRAMEWORKS_DIR, '.manifest.json');
+
+const FRAMEWORKS = [
+ {
+ name: 'LiveKitWebRTC',
+ url: 'https://github.com/livekit/webrtc-xcframework/releases/download/144.7559.11/LiveKitWebRTC.xcframework.zip',
+ sha256: '07c5caf718058af3c528dcabd257298c40e5a8527e4fb9f47c48336ba5899853',
+ },
+ {
+ name: 'RustLiveKitUniFFI',
+ url: 'https://github.com/livekit/livekit-uniffi-xcframework/releases/download/0.0.6/RustLiveKitUniFFI.xcframework.zip',
+ sha256: '0d3f2ce159a224c728f8b131068d53bbf9b13d968cda0edc68a6a2290f2651ed',
+ },
+];
+
+function sha256Hex(buffer) {
+ return createHash('sha256').update(buffer).digest('hex');
+}
+
+async function readManifest() {
+ try {
+ return JSON.parse(await readFile(MANIFEST_PATH, 'utf8'));
+ } catch {
+ return {};
+ }
+}
+
+async function writeManifest(manifest) {
+ await writeFile(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`);
+}
+
+// XCFramework zips contain symlinks; use the platform unzip tool rather than
+// adding a JS dependency. iOS builds are macOS-only, so ditto is always
+// available in practice; unzip is the fallback.
+function findExtractor() {
+ for (const tool of ['ditto', 'unzip']) {
+ if (!spawnSync(tool, ['--help'], { stdio: 'ignore' }).error) return tool;
+ }
+ throw new Error('Neither ditto nor unzip is available to extract XCFramework archives');
+}
+
+function extractZip(zipPath, destination) {
+ const tool = findExtractor();
+ const args =
+ tool === 'ditto' ? ['-x', '-k', zipPath, destination] : ['-q', zipPath, '-d', destination];
+ const result = spawnSync(tool, args, { stdio: 'inherit' });
+ if (result.status !== 0) {
+ throw new Error(`${tool} failed to extract ${zipPath} (exit ${result.status})`);
+ }
+}
+
+async function provision(framework, manifest) {
+ const { name, url, sha256 } = framework;
+ const target = join(FRAMEWORKS_DIR, `${name}.xcframework`);
+
+ if (manifest[name] === sha256 && existsSync(join(target, 'Info.plist'))) {
+ log.info(`${name}.xcframework already installed (verified), skipping`);
+ return;
+ }
+
+ log.info(`Downloading ${name} from ${url}`);
+ const response = await fetch(url, { redirect: 'follow' });
+ if (!response.ok) {
+ throw new Error(`Failed to download ${name}: HTTP ${response.status} ${response.statusText}`);
+ }
+ const zipBuffer = Buffer.from(await response.arrayBuffer());
+
+ const actual = sha256Hex(zipBuffer);
+ if (actual !== sha256) {
+ throw new Error(
+ `Checksum mismatch for ${name}: expected ${sha256}, got ${actual}. Refusing to install.`
+ );
+ }
+ log.info(`Verified SHA-256 for ${name}`);
+
+ // Extract to a temp directory on the same filesystem so the final rename
+ // into Frameworks/ is atomic and a failure touches nothing installed.
+ const tempDir = await mkdtemp(join(FRAMEWORKS_DIR, '.tmp-'));
+ try {
+ const zipPath = join(tempDir, `${name}.zip`);
+ await writeFile(zipPath, zipBuffer);
+ extractZip(zipPath, tempDir);
+
+ const extracted = join(tempDir, `${name}.xcframework`);
+ if (!existsSync(join(extracted, 'Info.plist'))) {
+ throw new Error(
+ `Downloaded archive for ${name} did not contain ${name}.xcframework; refusing to install.`
+ );
+ }
+
+ // Swap the verified framework into place. The previous install, if any,
+ // is moved aside (not deleted) until the new one is in position.
+ if (existsSync(target)) {
+ await rename(target, join(tempDir, '__replaced__'));
+ }
+ await rename(extracted, target);
+ } finally {
+ await rm(tempDir, { recursive: true, force: true });
+ }
+
+ manifest[name] = sha256;
+ await writeManifest(manifest);
+ log.info(`Installed ${name}.xcframework`);
+}
+
+async function main() {
+ await mkdir(FRAMEWORKS_DIR, { recursive: true });
+ const manifest = await readManifest();
+ // Deliberately sequential: each framework is verified, installed, and
+ // recorded in the manifest as its own atomic step, so a failure on a later
+ // framework never disturbs an earlier verified install.
+ for (const framework of FRAMEWORKS) {
+ // oxlint-disable-next-line no-await-in-loop
+ await provision(framework, manifest);
+ }
+}
+
+main().catch((error) => {
+ log.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+});
diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore
index 489be3277..dd5680a87 100644
--- a/src-tauri/.gitignore
+++ b/src-tauri/.gitignore
@@ -2,3 +2,6 @@
# will have compiled files and executables
/target/
/gen/schemas
+
+# Downloaded by scripts/download-ios-frameworks.js (pinned, checksummed binaries)
+/Frameworks/
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index c33e2e7ff..fc6944632 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -5411,6 +5411,7 @@ dependencies = [
"tauri-plugin-fs",
"tauri-plugin-global-shortcut",
"tauri-plugin-http",
+ "tauri-plugin-livekit-mobile",
"tauri-plugin-log",
"tauri-plugin-notifications",
"tauri-plugin-opener",
@@ -6717,6 +6718,20 @@ dependencies = [
"urlpattern",
]
+[[package]]
+name = "tauri-plugin-livekit-mobile"
+version = "0.2.0"
+source = "git+https://github.com/SableClient/tauri-plugin-livekit-mobile.git?rev=b936c9ca87b2b845e4cfff8cd846028e45005d95#b936c9ca87b2b845e4cfff8cd846028e45005d95"
+dependencies = [
+ "base64 0.22.1",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.19",
+ "tokio",
+]
+
[[package]]
name = "tauri-plugin-log"
version = "2.9.0"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index ee6cfdbb3..8cdc48092 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -124,6 +124,7 @@ tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugi
] }
tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" }
tauri-plugin-sharekit = { git = "https://github.com/Choochmeque/tauri-plugin-sharekit", rev = "9f2b4c5d8a4f0ab910d900ba234ed8bae0ab854b" }
+tauri-plugin-livekit-mobile = { git = "https://github.com/SableClient/tauri-plugin-livekit-mobile.git", rev = "b936c9ca87b2b845e4cfff8cd846028e45005d95" }
[target.'cfg(target_os = "ios")'.dependencies]
objc2 = "0.6"
diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist
index 99b9ecb70..e1425ba12 100644
--- a/src-tauri/Info.plist
+++ b/src-tauri/Info.plist
@@ -10,5 +10,10 @@
Sable needs camera access for video calls.
NSMicrophoneUsageDescription
Sable needs microphone access for voice and video calls.
+ UIBackgroundModes
+
+ audio
+ voip
+
diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json
index e5500a57d..1cb676dbd 100644
--- a/src-tauri/capabilities/mobile.json
+++ b/src-tauri/capabilities/mobile.json
@@ -7,6 +7,7 @@
"core:window:allow-set-badge-count",
"deep-link:default",
"edge-to-edge:default",
+ "livekit-mobile:default",
"notifications:default",
"sharekit:default"
]
diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts
index 10397a780..5d277fb4f 100644
--- a/src-tauri/gen/android/app/build.gradle.kts
+++ b/src-tauri/gen/android/app/build.gradle.kts
@@ -61,6 +61,11 @@ android {
)
}
}
+ compileOptions {
+ // tauri-plugin-livekit-mobile requires it: LiveKit/WebRTC call java.time
+ // APIs that only exist from API 26, and minSdk here is 24.
+ isCoreLibraryDesugaringEnabled = true
+ }
kotlinOptions {
jvmTarget = "1.8"
}
@@ -74,10 +79,21 @@ rust {
}
dependencies {
+ // livekit-android pulls protobuf-javalite while tink (via unifiedpush connector)
+ // pulls protobuf-java, and the two ship the same com.google.protobuf classes.
+ // Only the full runtime has the RuntimeVersion class tink's gencode needs, so it
+ // wins; it is a superset of the lite classes livekit's gencode references.
+ modules {
+ module("com.google.protobuf:protobuf-javalite") {
+ replacedBy("com.google.protobuf:protobuf-java")
+ }
+ }
+
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
diff --git a/src-tauri/gen/android/build.gradle.kts b/src-tauri/gen/android/build.gradle.kts
index a91af1aef..d5b85e453 100644
--- a/src-tauri/gen/android/build.gradle.kts
+++ b/src-tauri/gen/android/build.gradle.kts
@@ -14,6 +14,9 @@ allprojects {
repositories {
google()
mavenCentral()
+ // livekit-android pulls com.github.davidliu:audioswitch, published only
+ // on JitPack.
+ maven { setUrl("https://jitpack.io") }
}
}
diff --git a/src-tauri/ios-entitlements/sable.debug.entitlements b/src-tauri/ios-entitlements/sable.debug.entitlements
new file mode 100644
index 000000000..6631ffa6f
--- /dev/null
+++ b/src-tauri/ios-entitlements/sable.debug.entitlements
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src-tauri/ios-entitlements/sable.release.entitlements b/src-tauri/ios-entitlements/sable.release.entitlements
new file mode 100644
index 000000000..723ff878d
--- /dev/null
+++ b/src-tauri/ios-entitlements/sable.release.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ aps-environment
+ development
+ com.apple.security.application-groups
+
+ group.moe.sable.client
+
+
+
diff --git a/src-tauri/ios-entitlements/sable.share-extension.debug.entitlements b/src-tauri/ios-entitlements/sable.share-extension.debug.entitlements
new file mode 100644
index 000000000..6631ffa6f
--- /dev/null
+++ b/src-tauri/ios-entitlements/sable.share-extension.debug.entitlements
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src-tauri/ios-entitlements/sable.share-extension.release.entitlements b/src-tauri/ios-entitlements/sable.share-extension.release.entitlements
new file mode 100644
index 000000000..6707d84a1
--- /dev/null
+++ b/src-tauri/ios-entitlements/sable.share-extension.release.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.moe.sable.client
+
+
+
diff --git a/src-tauri/ios-project.yml b/src-tauri/ios-project.yml
index 07080a98b..71f67f721 100644
--- a/src-tauri/ios-project.yml
+++ b/src-tauri/ios-project.yml
@@ -60,9 +60,7 @@ targets:
# APPLICATION_EXTENSION_API_ONLY must stay unset: NO fails Xcode's
# validation, YES breaks UIApplication.open in ShareViewController.
SKIP_INSTALL: YES
- {{#if apple.development-team}}
- DEVELOPMENT_TEAM: {{apple.development-team}}
- {{/if}}
+ DEVELOPMENT_TEAM: PK66NJM372
{{app.name}}_iOS:
type: application
platform: iOS
@@ -92,7 +90,7 @@ targets:
properties:
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
- UIBackgroundModes: [audio]
+ UIBackgroundModes: [audio, voip]
NSMicrophoneUsageDescription: Sable uses your microphone for calls.
NSCameraUsageDescription: Sable uses your camera for video calls.
UIRequiredDeviceCapabilities: [arm64, metal]
@@ -107,7 +105,6 @@ targets:
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: {{apple.bundle-version-short}}
CFBundleVersion: "{{apple.bundle-version}}"
- UIBackgroundModes: [audio]
NSPhotoLibraryAddUsageDescription: Sable saves images you download to your photo library.
# Mirrors plugins.deep-link in tauri.conf.json: the plugin only patches
# these in when cargo reruns its build script, so a fresh `tauri ios
@@ -121,10 +118,6 @@ targets:
{{this.key}}: {{this.value}}{{/each}}
entitlements:
path: {{app.name}}_iOS/{{app.name}}_iOS.entitlements
- properties:
- aps-environment: development
- com.apple.security.application-groups:
- - group.{{app.identifier}}
scheme:
environmentVariables:
RUST_BACKTRACE: full
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 20fa2fb6e..3fb0ad629 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -355,6 +355,7 @@ pub fn run() {
#[cfg(mobile)]
let builder = builder
.plugin(tauri_plugin_edge_to_edge::init())
+ .plugin(tauri_plugin_livekit_mobile::init())
.plugin(tauri_plugin_sharekit::init());
#[cfg(target_os = "android")]
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 7fb809737..bccd176b9 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -24,7 +24,13 @@
},
"iOS": {
"template": "src-tauri/ios-project.yml",
- "frameworks": ["AudioToolbox", "Photos"]
+ "minimumSystemVersion": "16.0",
+ "frameworks": [
+ "AudioToolbox",
+ "Photos",
+ "Frameworks/LiveKitWebRTC.xcframework",
+ "Frameworks/RustLiveKitUniFFI.xcframework"
+ ]
},
"linux": {
"deb": {
diff --git a/src-tauri/tauri.local.ios.conf.json b/src-tauri/tauri.local.ios.conf.json
new file mode 100644
index 000000000..97948f54d
--- /dev/null
+++ b/src-tauri/tauri.local.ios.conf.json
@@ -0,0 +1,3 @@
+{
+ "identifier": "moe.sable.client.local"
+}
diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx
index 78b2b5ee0..b5bbc05ba 100644
--- a/src/app/components/CallEmbedProvider.tsx
+++ b/src/app/components/CallEmbedProvider.tsx
@@ -17,6 +17,7 @@ import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { IncomingCallModal } from '$features/call/IncomingCallModal';
import { toCallEmbedStartError } from '$plugins/call/callEmbedError';
+import { LivekitJsCallManagerProvider } from '$features/call/livekitJsCallManager';
function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
@@ -80,8 +81,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
{callEmbed && }
-
- {children}
+
+
+ {children}
+
& {
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-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx
index 5162b8b08..c716c65bd 100644
--- a/src/app/features/call-status/CallControl.tsx
+++ b/src/app/features/call-status/CallControl.tsx
@@ -23,7 +23,7 @@ type MicrophoneButtonProps = {
onToggle: () => Promise;
disabled?: boolean;
};
-function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
+export function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
return (
void;
disabled?: boolean;
};
-function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
+export function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
return (
Promise;
disabled?: boolean;
};
-function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
+export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
return (
void;
disabled?: boolean;
};
-function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
+export function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
return (
();
+
+export function HangupChip({
+ compact,
+ onHangup,
+}: {
+ compact: boolean;
+ onHangup: () => Promise;
+}) {
+ const [hangupState, hangup] = useAsyncCallback(useCallback(() => onHangup(), [onHangup]));
+ const exiting =
+ hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
+
+ return (
+
+ ) : (
+ sizedIcon(PhoneDisconnect, '50', { filled: true })
+ )
+ }
+ disabled={exiting}
+ outlined
+ onClick={() => hangup()}
+ >
+ {!compact && (
+
+ End
+
+ )}
+
+ );
+}
+
+/**
+ * The persistent call bar both engines render into. Only the control cluster
+ * differs, so it is passed in.
+ */
+export function CallStatusShell({
+ room,
+ compact,
+ connected,
+ controls,
+}: {
+ room: Room;
+ compact: boolean;
+ connected: boolean;
+ controls: ReactNode;
+}) {
+ const callSession = useCallSession(room);
+ const callMembers = useCallMembers(room, callSession);
+ const memberVisible = connected && callMembers.length > 0;
+
+ return (
+
+
+ {memberVisible ? (
+
+
+
+ ) : (
+
+ )}
+
+ {!compact && }
+
+ {memberVisible && (
+
+
+
+ )}
+
+ {memberVisible && !compact && }
+
+ {compact && (
+
+
+
+ )}
+ {controls}
+
+
+ );
+}
diff --git a/src/app/features/call-status/LivekitCallStatus.tsx b/src/app/features/call-status/LivekitCallStatus.tsx
new file mode 100644
index 000000000..3afc8183e
--- /dev/null
+++ b/src/app/features/call-status/LivekitCallStatus.tsx
@@ -0,0 +1,72 @@
+import { Box } from 'folds';
+import { useAtom } from 'jotai';
+import { RoomContext, useLocalParticipant } from '@livekit/components-react';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { ScreenSize, useScreenSize } from '$hooks/useScreenSize';
+import { livekitJsCallSoundAtom, type LivekitJsCallSession } from '$state/livekitJsCall';
+import { MicrophoneButton, ScreenShareButton, SoundButton, VideoButton } from './CallControl';
+import { CallStatusShell, HangupChip } from './CallStatusShell';
+import { StatusDivider } from './components';
+
+function LivekitCallControl({
+ compact,
+ onHangup,
+}: {
+ compact: boolean;
+ onHangup: () => Promise;
+}) {
+ const { localParticipant, isMicrophoneEnabled, isCameraEnabled, isScreenShareEnabled } =
+ useLocalParticipant();
+ const [sound, setSound] = useAtom(livekitJsCallSoundAtom);
+
+ return (
+
+
+ localParticipant.setMicrophoneEnabled(!isMicrophoneEnabled)}
+ />
+ setSound(!sound)} />
+ {!compact && }
+ localParticipant.setCameraEnabled(!isCameraEnabled)}
+ />
+ {!compact && (
+ void localParticipant.setScreenShareEnabled(!isScreenShareEnabled)}
+ />
+ )}
+
+
+
+
+ );
+}
+
+export function LivekitCallStatus({ session }: { session: LivekitJsCallSession }) {
+ const mx = useMatrixClient();
+ const screenSize = useScreenSize();
+ const room = mx.getRoom(session.roomId);
+ const compact = screenSize === ScreenSize.Mobile;
+
+ if (!room) return null;
+
+ return (
+
+
+
+ ) : (
+
+ )
+ }
+ />
+ );
+}
diff --git a/src/app/features/call-status/NativeCallStatus.tsx b/src/app/features/call-status/NativeCallStatus.tsx
new file mode 100644
index 000000000..2a8f44df6
--- /dev/null
+++ b/src/app/features/call-status/NativeCallStatus.tsx
@@ -0,0 +1,50 @@
+import { Box } from 'folds';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { ScreenSize, useScreenSize } from '$hooks/useScreenSize';
+import type { NativeCallSession } from '$state/nativeCall';
+import { MicrophoneButton, VideoButton } from './CallControl';
+import { CallStatusShell, HangupChip } from './CallStatusShell';
+import { StatusDivider } from './components';
+
+function NativeCallControl({ session, compact }: { session: NativeCallSession; compact: boolean }) {
+ // Media commands are rejected until the native room has connected.
+ const disabled = session.lifecycle !== 'connected';
+
+ return (
+
+
+ session.setMicrophoneEnabled(!session.microphoneEnabled)}
+ disabled={disabled}
+ />
+ {!compact && }
+ session.setCameraEnabled(!session.cameraEnabled)}
+ disabled={disabled}
+ />
+
+
+
+
+ );
+}
+
+export function NativeCallStatus({ session }: { session: NativeCallSession }) {
+ const mx = useMatrixClient();
+ const screenSize = useScreenSize();
+ const room = mx.getRoom(session.roomId);
+ const compact = screenSize === ScreenSize.Mobile;
+
+ if (!room) return null;
+
+ return (
+ }
+ />
+ );
+}
diff --git a/src/app/features/call/CallDevicePreview.css.ts b/src/app/features/call/CallDevicePreview.css.ts
new file mode 100644
index 000000000..90252a911
--- /dev/null
+++ b/src/app/features/call/CallDevicePreview.css.ts
@@ -0,0 +1,51 @@
+import { style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+export const PreviewSurface = style({
+ position: 'relative',
+ width: '100%',
+ aspectRatio: '16 / 9',
+ borderRadius: config.radii.R400,
+ background: '#14171f',
+ color: color.Surface.OnContainer,
+ overflow: 'hidden',
+});
+
+export const PreviewVideo = style({
+ position: 'absolute',
+ inset: 0,
+ width: '100%',
+ height: '100%',
+ objectFit: 'cover',
+ // Front cameras read as a mirror to the person looking at them.
+ transform: 'scaleX(-1)',
+});
+
+export const DeviceSelect = style({
+ width: '100%',
+ minWidth: 0,
+ minHeight: toRem(36),
+ padding: `0 ${config.space.S200}`,
+ borderRadius: config.radii.R400,
+ border: `1px solid ${color.Surface.ContainerLine}`,
+ background: color.Surface.Container,
+ color: color.Surface.OnContainer,
+ font: 'inherit',
+ fontSize: toRem(14),
+});
+
+export const LevelTrack = style({
+ width: '100%',
+ height: toRem(6),
+ borderRadius: config.radii.R400,
+ background: color.Surface.ContainerLine,
+ overflow: 'hidden',
+});
+
+export const LevelFill = style({
+ width: '100%',
+ height: '100%',
+ transformOrigin: 'left center',
+ background: color.Success.Main,
+ transition: 'transform 80ms linear',
+});
diff --git a/src/app/features/call/CallDevicePreview.test.tsx b/src/app/features/call/CallDevicePreview.test.tsx
new file mode 100644
index 000000000..24bcd4f1e
--- /dev/null
+++ b/src/app/features/call/CallDevicePreview.test.tsx
@@ -0,0 +1,96 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { CallDevicePreview } from './CallDevicePreview';
+
+const mocks = vi.hoisted(() => ({
+ usePreviewTracks: vi.fn<() => unknown[] | undefined>(),
+ useMediaDeviceSelect: vi.fn<(o: { kind: string }) => { devices: MediaDeviceInfo[] }>(),
+ previewOptions: undefined as unknown,
+ attach: vi.fn<(el: HTMLElement) => void>(),
+ detach: vi.fn<(el: HTMLElement) => void>(),
+}));
+
+vi.mock('@livekit/components-react', () => ({
+ // Mirror the real hook: it only returns tracks for the sources requested.
+ usePreviewTracks: (options: { audio: unknown; video: unknown }) => {
+ mocks.previewOptions = options;
+ const tracks = mocks.usePreviewTracks() ?? [];
+ return tracks.filter((track) => {
+ const { kind } = track as { kind: string };
+ return kind === 'video' ? options.video !== false : options.audio !== false;
+ });
+ },
+ useMediaDeviceSelect: (options: { kind: string }) => mocks.useMediaDeviceSelect(options),
+ useTrackVolume: () => 0.25,
+}));
+
+vi.mock('livekit-client', () => ({
+ Track: { Kind: { Video: 'video', Audio: 'audio' } },
+}));
+
+const device = (deviceId: string, label: string, kind: string) =>
+ ({ deviceId, label, kind }) as MediaDeviceInfo;
+
+const videoTrack = { kind: 'video', attach: mocks.attach, detach: mocks.detach };
+const audioTrack = { kind: 'audio' };
+
+const props = {
+ microphone: true,
+ video: true,
+ onAudioDeviceChange: vi.fn<(id: string) => void>(),
+ onVideoDeviceChange: vi.fn<(id: string) => void>(),
+};
+
+beforeEach(() => {
+ mocks.usePreviewTracks.mockReset().mockReturnValue([videoTrack, audioTrack]);
+ mocks.useMediaDeviceSelect.mockReset().mockImplementation(({ kind }) => ({
+ devices:
+ kind === 'audioinput'
+ ? [device('mic-1', 'Built-in Mic', kind), device('mic-2', 'USB Mic', kind)]
+ : [device('cam-1', 'FaceTime HD', kind)],
+ }));
+ mocks.attach.mockReset();
+ mocks.detach.mockReset();
+ props.onAudioDeviceChange.mockReset();
+ props.onVideoDeviceChange.mockReset();
+});
+
+describe('CallDevicePreview', () => {
+ it('attaches the preview camera track to a video element', () => {
+ render();
+
+ expect(mocks.attach).toHaveBeenCalledOnce();
+ expect(screen.queryByText('Camera is off')).not.toBeInTheDocument();
+ });
+
+ it('detaches the camera track on unmount so the call can claim it', () => {
+ const { unmount } = render();
+ unmount();
+
+ expect(mocks.detach).toHaveBeenCalledOnce();
+ });
+
+ it('requests only the devices the user actually enabled', () => {
+ render();
+
+ expect(mocks.previewOptions).toEqual({ audio: { deviceId: 'mic-2' }, video: false });
+ expect(screen.getByText('Camera is off')).toBeInTheDocument();
+ });
+
+ it('reports a chosen microphone so the call can honour it', async () => {
+ render();
+
+ await userEvent.selectOptions(screen.getByLabelText('Microphone'), 'mic-2');
+
+ expect(props.onAudioDeviceChange).toHaveBeenCalledWith('mic-2');
+ });
+
+ it('shows a microphone level only while the microphone is on', () => {
+ const { rerender } = render();
+ expect(screen.getByRole('meter', { name: 'Microphone level' })).toBeInTheDocument();
+
+ rerender();
+ expect(screen.queryByRole('meter', { name: 'Microphone level' })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/app/features/call/CallDevicePreview.tsx b/src/app/features/call/CallDevicePreview.tsx
new file mode 100644
index 000000000..c5573c0a1
--- /dev/null
+++ b/src/app/features/call/CallDevicePreview.tsx
@@ -0,0 +1,141 @@
+import { useEffect, useMemo, useRef } from 'react';
+import { Box, Text, config, toRem } from 'folds';
+import { useMediaDeviceSelect, usePreviewTracks, useTrackVolume } from '@livekit/components-react';
+import { Track, type LocalAudioTrack, type LocalVideoTrack } from 'livekit-client';
+import { VideoCameraSlash, sizedIcon } from '$components/icons/phosphor';
+import * as css from './CallDevicePreview.css';
+
+type DeviceSelectProps = {
+ label: string;
+ kind: MediaDeviceKind;
+ deviceId?: string;
+ onChange: (deviceId: string) => void;
+ permissionsGranted: boolean;
+};
+
+function DeviceSelect({ label, kind, deviceId, onChange, permissionsGranted }: DeviceSelectProps) {
+ const { devices } = useMediaDeviceSelect({ kind, requestPermissions: permissionsGranted });
+
+ return (
+
+ {label}
+
+
+ );
+}
+
+function MicrophoneLevel({ track }: { track?: LocalAudioTrack }) {
+ const volume = useTrackVolume(track);
+ const level = Math.min(1, volume * 3);
+
+ return (
+
+ Microphone level
+
+
+ );
+}
+
+function VideoPreview({ track }: { track?: LocalVideoTrack }) {
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ const element = videoRef.current;
+ if (!track || !element) return undefined;
+ track.attach(element);
+ return () => {
+ track.detach(element);
+ };
+ }, [track]);
+
+ if (!track) {
+ return (
+
+ {sizedIcon(VideoCameraSlash, '400')}
+ Camera is off
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+export type CallDevicePreviewProps = {
+ microphone: boolean;
+ video: boolean;
+ audioDeviceId?: string;
+ videoDeviceId?: string;
+ onAudioDeviceChange: (deviceId: string) => void;
+ onVideoDeviceChange: (deviceId: string) => void;
+};
+
+export function CallDevicePreview({
+ microphone,
+ video,
+ audioDeviceId,
+ videoDeviceId,
+ onAudioDeviceChange,
+ onVideoDeviceChange,
+}: CallDevicePreviewProps) {
+ const tracks = usePreviewTracks({
+ audio: microphone ? { deviceId: audioDeviceId } : false,
+ video: video ? { deviceId: videoDeviceId } : false,
+ });
+
+ const videoTrack = useMemo(
+ () => tracks?.find((track) => track.kind === Track.Kind.Video) as LocalVideoTrack | undefined,
+ [tracks]
+ );
+ const audioTrack = useMemo(
+ () => tracks?.find((track) => track.kind === Track.Kind.Audio) as LocalAudioTrack | undefined,
+ [tracks]
+ );
+
+ return (
+
+
+ {microphone && }
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/call/CallView.test.tsx b/src/app/features/call/CallView.test.tsx
new file mode 100644
index 000000000..4b4afd1c5
--- /dev/null
+++ b/src/app/features/call/CallView.test.tsx
@@ -0,0 +1,123 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { LivekitJsCallStatus } from './CallView';
+import { NativeCallSurface } from './NativeCallSurface';
+import type { NativeCallSession } from '$state/nativeCall';
+
+vi.mock('./livekitMobileBridge', () => ({
+ getNativeCallState: vi.fn<() => Promise>(() => Promise.reject(new Error('not on device'))),
+ listenNativeCallSnapshot: vi.fn<() => Promise<() => void>>(() => Promise.resolve(() => {})),
+ setNativeCallRemoteVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})),
+ clearNativeCallRemoteVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})),
+ setNativeCallLocalVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})),
+ clearNativeCallLocalVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})),
+}));
+
+vi.mock('$hooks/useRoom', () => ({ useRoom: () => ({ roomId: '!room:example.org' }) }));
+vi.mock('$hooks/router/useSelectedRoom', () => ({
+ useSelectedRoom: () => '!room:example.org',
+}));
+vi.mock('$hooks/useCall', () => ({ useCallSession: () => ({}), useCallMembers: () => [] }));
+vi.mock('./LivekitCallParticipant', () => ({
+ useCallParticipantProfile: () => ({ name: 'Bob' }),
+ CallParticipantAvatar: () => ,
+}));
+
+const nativeSession = (lifecycle: NativeCallSession['lifecycle']): NativeCallSession => ({
+ backend: 'livekit-mobile',
+ roomId: '!room:example.org',
+ callId: 'call-id',
+ lifecycle,
+ microphoneEnabled: true,
+ cameraEnabled: false,
+ setMicrophoneEnabled: async () => {},
+ setCameraEnabled: async () => {},
+ switchCamera: async () => {},
+ listAudioRoutes: async () => [],
+ selectAudioRoute: async () => {},
+ hangup: vi.fn<() => Promise>().mockResolvedValue(undefined),
+});
+
+describe('LiveKit JS call status', () => {
+ it('reports progress without exposing backend or transport details', () => {
+ render(
+ {}}
+ />
+ );
+
+ expect(screen.getByText('Preparing call')).toBeInTheDocument();
+ expect(screen.queryByText(/livekit|token|url|secret|e2ee/i)).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'End' })).toBeInTheDocument();
+ });
+
+ it('explains a setup failure in plain language', () => {
+ render(
+ {}}
+ />
+ );
+
+ expect(screen.getByText('Call failed')).toBeInTheDocument();
+ expect(screen.getByText('Could not connect to the call.')).toBeInTheDocument();
+ expect(screen.queryByText(/token|url|secret|error:/i)).not.toBeInTheDocument();
+ });
+
+ it('gives an unsupported-encryption failure a dismiss route', () => {
+ const onHangup = vi.fn<() => void>();
+ render(
+
+ );
+
+ expect(
+ screen.getByText('Encrypted calls are not supported on this device.')
+ ).toBeInTheDocument();
+ screen.getByRole('button', { name: 'Dismiss' }).click();
+ expect(onHangup).toHaveBeenCalledOnce();
+ });
+});
+
+describe('native call surface', () => {
+ it('shows the local tile and call controls when connected', () => {
+ render( {}} />);
+
+ expect(screen.getByText('You')).toBeInTheDocument();
+ expect(screen.getAllByRole('button')).toHaveLength(3);
+ expect(screen.getByRole('button', { name: 'Mute microphone' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Start camera' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'End call' })).toBeInTheDocument();
+ });
+
+ it('keeps media toggles disabled while connecting', () => {
+ render( {}} />);
+
+ expect(screen.getByText('Connecting')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Mute microphone' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Start camera' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'End call' })).toBeEnabled();
+ });
+
+ it('gives failed calls an explicit dismiss route', () => {
+ const onHangup = vi.fn<() => void>();
+ render(
+
+ );
+
+ expect(screen.getByText('Call failed')).toBeInTheDocument();
+ expect(screen.getByText('Native call connection failed.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument();
+ screen.getByRole('button', { name: 'Dismiss' }).click();
+ expect(onHangup).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx
index 62d79cd6d..ae324a009 100644
--- a/src/app/features/call/CallView.tsx
+++ b/src/app/features/call/CallView.tsx
@@ -13,6 +13,13 @@ import { CallMemberRenderer } from './CallMemberCard';
import { PrescreenControls } from './PrescreenControls';
import { callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed';
import { canJoinCall } from './callStartCapabilities';
+import type { LivekitJsCallSession } from '$state/livekitJsCall';
+import { livekitJsCallAtom } from '$state/livekitJsCall';
+import { nativeCallAtom } from '$state/nativeCall';
+import { LivekitJsCallSurface } from './LivekitJsCallSurface';
+import { NativeCallSurface } from './NativeCallSurface';
+import { CallStatusBar } from './callChrome';
+import { livekitJsCallStatus } from './callClient';
function LivekitServerMissingMessage() {
return (
@@ -80,6 +87,16 @@ function WidgetPreparationErrorMessage({ message }: { message: string }) {
);
}
+export function LivekitJsCallStatus({
+ session,
+ onHangup,
+}: {
+ session: Pick;
+ onHangup: () => void;
+}) {
+ return ;
+}
+
function CallPrescreen() {
const room = useRoom();
const callEmbed = useAtomValue(callEmbedAtom);
@@ -168,11 +185,22 @@ export function CallView({ resizable }: CallViewProps) {
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
-
- const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
-
+ const livekitJsCall = useAtomValue(livekitJsCallAtom);
+ const nativeCall = useAtomValue(nativeCallAtom);
+
+ const livekitJsCallForRoom = livekitJsCall?.roomId === room.roomId ? livekitJsCall : undefined;
+ const nativeCallForRoom = nativeCall?.roomId === room.roomId ? nativeCall : undefined;
+ const livekitJsRoom =
+ livekitJsCallForRoom?.lifecycle === 'active' ? livekitJsCallForRoom.room : undefined;
+ const currentJoined =
+ !livekitJsCallForRoom && !nativeCallForRoom && callEmbed?.roomId === room.roomId && callJoined;
+
+ // A native call renders video tiles and a control bar, which need most of the
+ // viewport; the 0.3 default is sized for the Element Call participant list.
const [heightRatio, setHeightRatio] = useState(isMobile ? 0.3 : 0.72);
const [availableHeight, setAvailableHeight] = useState(0);
+ const effectiveHeightRatio =
+ isMobile && nativeCallForRoom ? Math.max(heightRatio, 0.75) : heightRatio;
useEffect(() => {
if (!resizable || !callViewRef.current) return undefined;
@@ -263,12 +291,18 @@ export function CallView({ resizable }: CallViewProps) {
minWidth: toRem(280),
height: resizable
? availableHeight > 0
- ? `${availableHeight * heightRatio}px`
- : `${heightRatio * 100}dvh`
+ ? `${availableHeight * effectiveHeightRatio}px`
+ : `${effectiveHeightRatio * 100}dvh`
: undefined,
borderBottom: `1px solid var(--sable-surface-container-line)`,
zIndex: 20,
- backgroundColor: currentJoined ? 'transparent' : undefined,
+ backgroundColor:
+ livekitJsRoom || nativeCallForRoom
+ ? color.Background.Container
+ : currentJoined
+ ? 'transparent'
+ : undefined,
+ overflow: livekitJsRoom || nativeCallForRoom ? 'hidden' : undefined,
pointerEvents: currentJoined ? 'none' : 'all',
}}
>
@@ -284,8 +318,27 @@ export function CallView({ resizable }: CallViewProps) {
/>
)}
- {!currentJoined && }
-
+ {!currentJoined && !livekitJsCallForRoom && !nativeCallForRoom && }
+ {livekitJsCallForRoom && livekitJsRoom ? (
+ void livekitJsCallForRoom.hangup()}
+ />
+ ) : livekitJsCallForRoom ? (
+ void livekitJsCallForRoom.hangup()}
+ />
+ ) : nativeCallForRoom ? (
+ void nativeCallForRoom.hangup()}
+ />
+ ) : (
+
+ )}
{resizable && (