From 7b5cdca0ffb0707504290aa83575d80f7bf79ab9 Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 30 Apr 2026 13:26:05 +0200 Subject: [PATCH] (feat): update grid view stuff (feat): add desktop app screensharing --- apps/tauri/package.json | 20 +- apps/tauri/src-tauri/Cargo.lock | 531 +++++++++++++++++- apps/tauri/src-tauri/Cargo.toml | 5 + apps/tauri/src-tauri/src/lib.rs | 249 +++++++- apps/tauri/src/context.tsx | 104 +++- bun.lock | 2 + packages/call/package.json | 2 + .../src/components/buttons/screenshare.tsx | 125 +++-- .../components/buttons/screenshareDialog.tsx | 325 +++++++++++ packages/call/src/store.tsx | 175 +++++- packages/call/src/views/main/grid.tsx | 18 +- 11 files changed, 1469 insertions(+), 87 deletions(-) create mode 100644 packages/call/src/components/buttons/screenshareDialog.tsx diff --git a/apps/tauri/package.json b/apps/tauri/package.json index 5b6a262..eb466ad 100644 --- a/apps/tauri/package.json +++ b/apps/tauri/package.json @@ -4,10 +4,22 @@ "version": "0.0.0", "type": "module", "exports": { - "./context": "./src/context.tsx", - "./controls": "./src/windowControls.tsx", - "./deeplinkHandler": "./src/deeplinkHandler.tsx", - "./qrCodeScanner": "./src/qrCodeScanner.tsx" + "./context": { + "types": "./src/context.tsx", + "default": "./src/context.tsx" + }, + "./controls": { + "types": "./src/windowControls.tsx", + "default": "./src/windowControls.tsx" + }, + "./deeplinkHandler": { + "types": "./src/deeplinkHandler.tsx", + "default": "./src/deeplinkHandler.tsx" + }, + "./qrCodeScanner": { + "types": "./src/qrCodeScanner.tsx", + "default": "./src/qrCodeScanner.tsx" + } }, "scripts": { "dev:mobile:raw": "tauri android dev", diff --git a/apps/tauri/src-tauri/Cargo.lock b/apps/tauri/src-tauri/Cargo.lock index f7e2a6c..9ffbdd2 100644 --- a/apps/tauri/src-tauri/Cargo.lock +++ b/apps/tauri/src-tauri/Cargo.lock @@ -375,6 +375,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -588,6 +594,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -853,6 +865,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + [[package]] name = "deranged" version = "0.5.8" @@ -934,6 +957,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a0d569e003ff27784e0e14e4a594048698e0c0f0b66cabcb51511be55a7caa0" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -1013,6 +1048,12 @@ dependencies = [ "tendril 0.5.0", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "download-cef" version = "2.3.1" @@ -1868,7 +1909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -1986,6 +2027,33 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2202,6 +2270,12 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2244,6 +2318,15 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2276,6 +2359,22 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[package]] +name = "libwayshot" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2efa01ecfd021b1e7db27f21f4e79b35b048081c9cae9d2f898eddc98444d69" +dependencies = [ + "image 0.24.9", + "log", + "memmap2", + "nix", + "thiserror 1.0.69", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2363,6 +2462,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2399,6 +2507,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.2" @@ -2414,7 +2532,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -2456,6 +2574,17 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2530,6 +2659,38 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "objc2-av-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" +dependencies = [ + "bitflags 2.11.0", + "block2", + "dispatch2 0.3.1", + "objc2", + "objc2-avf-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-video", + "objc2-foundation", + "objc2-image-io", + "objc2-media-toolbox", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -2541,6 +2702,28 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2 0.3.1", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + [[package]] name = "objc2-core-data" version = "0.3.2" @@ -2559,7 +2742,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", - "dispatch2", + "block2", + "dispatch2 0.3.1", + "libc", "objc2", ] @@ -2570,10 +2755,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.11.0", - "dispatch2", + "block2", + "dispatch2 0.3.1", + "libc", "objc2", "objc2-core-foundation", "objc2-io-surface", + "objc2-metal", ] [[package]] @@ -2586,6 +2774,22 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "bitflags 2.11.0", + "block2", + "dispatch2 0.3.1", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -2605,10 +2809,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.11.0", + "block2", "objc2", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", + "objc2-metal", ] [[package]] @@ -2639,6 +2845,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-image-io" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -2660,6 +2877,29 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-media-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" +dependencies = [ + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-media", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3051,7 +3291,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.1", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3069,6 +3309,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3197,6 +3450,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -3206,6 +3474,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.45" @@ -4091,7 +4368,7 @@ dependencies = [ "core-foundation", "core-graphics", "crossbeam-channel", - "dispatch2", + "dispatch2 0.3.1", "dlopen2", "dpi", "gdkwayland-sys", @@ -4112,7 +4389,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4194,7 +4471,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4250,7 +4527,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4349,7 +4626,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -4371,7 +4648,7 @@ dependencies = [ "tauri-utils 2.8.3 (git+https://github.com/tauri-apps/tauri?branch=feat%2Fcef)", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", ] [[package]] @@ -4398,7 +4675,7 @@ dependencies = [ "tauri-runtime", "tauri-utils 2.8.3 (git+https://github.com/tauri-apps/tauri?branch=feat%2Fcef)", "url", - "windows", + "windows 0.61.3", "x11-dl", ] @@ -4424,7 +4701,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4551,6 +4828,8 @@ dependencies = [ name = "tensamin" version = "0.1.0" dependencies = [ + "base64 0.22.1", + "image 0.25.10", "serde", "serde_json", "tauri", @@ -4558,6 +4837,7 @@ dependencies = [ "tauri-plugin-barcode-scanner", "tauri-plugin-deep-link", "tauri-plugin-opener", + "xcap", ] [[package]] @@ -4874,7 +5154,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -5263,6 +5543,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.2", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.94" @@ -5356,9 +5706,9 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", + "windows-implement 0.60.2", "windows-interface", ] @@ -5380,10 +5730,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -5430,17 +5786,39 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529" +dependencies = [ + "windows-collections 0.1.1", + "windows-core 0.60.1", + "windows-future 0.1.1", + "windows-link 0.1.3", + "windows-numerics 0.1.1", +] + [[package]] name = "windows" version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows-collections" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec" +dependencies = [ + "windows-core 0.60.1", ] [[package]] @@ -5452,13 +5830,26 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247" +dependencies = [ + "windows-implement 0.59.0", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.3.1", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", + "windows-implement 0.60.2", "windows-interface", "windows-link 0.1.3", "windows-result 0.3.4", @@ -5471,13 +5862,23 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", + "windows-implement 0.60.2", "windows-interface", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", ] +[[package]] +name = "windows-future" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0" +dependencies = [ + "windows-core 0.60.1", + "windows-link 0.1.3", +] + [[package]] name = "windows-future" version = "0.2.1" @@ -5489,6 +5890,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5523,6 +5935,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed" +dependencies = [ + "windows-core 0.60.1", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -5562,6 +5984,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -5998,7 +6429,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -6035,6 +6466,45 @@ dependencies = [ "rustix", ] +[[package]] +name = "xcap" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd25cdb442bb7f63f13fdee2f59d991b04668d37c69aee00dc2a1cc9d0e9a1" +dependencies = [ + "dbus", + "dispatch2 0.2.0", + "image 0.25.10", + "lazy_static", + "libwayshot", + "log", + "objc2", + "objc2-app-kit", + "objc2-av-foundation", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation", + "percent-encoding", + "scopeguard", + "thiserror 2.0.18", + "widestring", + "windows 0.60.0", + "xcb", +] + +[[package]] +name = "xcb" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" +dependencies = [ + "bitflags 1.3.2", + "libc", + "quick-xml 0.30.0", +] + [[package]] name = "yoke" version = "0.8.2" @@ -6205,6 +6675,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/apps/tauri/src-tauri/Cargo.toml b/apps/tauri/src-tauri/Cargo.toml index 02ee39a..7c2b00d 100644 --- a/apps/tauri/src-tauri/Cargo.toml +++ b/apps/tauri/src-tauri/Cargo.toml @@ -18,11 +18,16 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +base64 = "0.22" +image = { version = "0.25", default-features = false, features = ["jpeg"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-deep-link = "2" +[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))'.dependencies] +xcap = "0.4.1" + [target.'cfg(target_os = "android")'.dependencies.tauri] version = "2" features = [] diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 0b08a18..2509428 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -1,12 +1,235 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +use serde::Serialize; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScreenShareSource { + id: String, + kind: String, + name: String, + subtitle: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScreenShareAudioOutput { + id: String, + name: String, + is_default: bool, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScreenShareCapabilities { + platform: String, + #[serde(rename = "usesPipeWireAudioPicker")] + uses_pipewire_audio_picker: bool, + #[serde(rename = "supportsSystemAudioSwitch")] + supports_system_audio_switch: bool, + #[serde(rename = "supportsReliableSystemAudio")] + supports_reliable_system_audio: bool, +} + #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn list_screen_share_sources() -> Result, String> { + #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] + { + use xcap::{Monitor, Window}; + + let mut sources = Vec::new(); + let mut errors = Vec::new(); + + match Monitor::all() { + Ok(monitors) => { + for (index, monitor) in monitors.into_iter().enumerate() { + let mut subtitle = None; + + if monitor.is_primary().unwrap_or(false) { + subtitle = Some("Primary display".to_string()); + } + + sources.push(ScreenShareSource { + id: format!("screen:{index}"), + kind: "screen".to_string(), + name: monitor + .name() + .unwrap_or_else(|_| format!("Display {}", index + 1)), + subtitle, + }); + } + } + Err(error) => errors.push(format!("display listing failed: {error}")), + } + + match Window::all() { + Ok(windows) => { + for (index, window) in windows.into_iter().enumerate() { + if window.is_minimized().unwrap_or(false) { + continue; + } + + let title = window.title().unwrap_or_default(); + + if title.trim().is_empty() { + continue; + } + + sources.push(ScreenShareSource { + id: format!("window:{index}"), + kind: "window".to_string(), + name: title, + subtitle: None, + }); + } + } + Err(error) => errors.push(format!("window listing failed: {error}")), + } + + if sources.is_empty() { + if errors.is_empty() { + return Ok(sources); + } + + return Err(errors.join("; ")); + } + + return Ok(sources); + } + + #[allow(unreachable_code)] + Ok(Vec::new()) +} + +#[tauri::command] +fn list_screen_share_audio_outputs() -> Result, String> { + #[cfg(target_os = "linux")] + { + use std::process::Command; + + let output = Command::new("pactl") + .args(["list", "short", "sinks"]) + .output() + .map_err(|error| error.to_string())?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + + let mut outputs = Vec::new(); + + for line in String::from_utf8_lossy(&output.stdout).lines() { + let mut parts = line.split('\t'); + + let Some(id) = parts.next() else { + continue; + }; + + let Some(name) = parts.next() else { + continue; + }; + + let description = parts.next_back().unwrap_or(name).to_string(); + + outputs.push(ScreenShareAudioOutput { + id: id.to_string(), + name: description, + is_default: false, + }); + } + + return Ok(outputs); + } + + #[allow(unreachable_code)] + Ok(Vec::new()) +} + +#[tauri::command] +fn capture_screen_share_frame(source_id: &str) -> Result { + #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] + { + use base64::Engine; + use image::{codecs::jpeg::JpegEncoder, DynamicImage}; + use std::io::Cursor; + use xcap::{Monitor, Window}; + + let Some((kind, index)) = source_id.split_once(':') else { + return Err("Invalid source id".to_string()); + }; + + let index = index + .parse::() + .map_err(|_| "Invalid source index".to_string())?; + + let frame = match kind { + "screen" => Monitor::all() + .map_err(|error| error.to_string())? + .into_iter() + .nth(index) + .ok_or_else(|| "Screen source not found".to_string())? + .capture_image() + .map_err(|error| error.to_string())?, + "window" => Window::all() + .map_err(|error| error.to_string())? + .into_iter() + .filter(|window| !window.is_minimized().unwrap_or(false)) + .nth(index) + .ok_or_else(|| "Window source not found".to_string())? + .capture_image() + .map_err(|error| error.to_string())?, + _ => return Err("Unsupported source kind".to_string()), + }; + + let mut buffer = Cursor::new(Vec::new()); + let image = DynamicImage::ImageRgba8(frame); + + JpegEncoder::new_with_quality(&mut buffer, 70) + .encode_image(&image) + .map_err(|error| error.to_string())?; + + let encoded = base64::engine::general_purpose::STANDARD.encode(buffer.into_inner()); + return Ok(format!("data:image/jpeg;base64,{encoded}")); + } + + #[allow(unreachable_code)] + Err("Screen capture is not supported on this platform".to_string()) +} + +#[tauri::command] +fn get_screen_share_capabilities() -> ScreenShareCapabilities { + ScreenShareCapabilities { + platform: if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "other" + } + .to_string(), + uses_pipewire_audio_picker: cfg!(target_os = "linux"), + supports_system_audio_switch: cfg!(any(target_os = "windows", target_os = "macos")), + supports_reliable_system_audio: cfg!(target_os = "windows"), + } } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let builder = tauri::Builder::default() + let builder = tauri::Builder::default(); + + #[cfg(any(target_os = "linux", target_os = "macos"))] + let builder = builder.command_line_args([ + ("enable-media-stream", None::), + ("enable-usermedia-screen-capturing", None::), + ("allow-http-screen-capture", None::), + #[cfg(target_os = "linux")] + ( + "enable-features", + Some("WebRTCPipeWireCapturer".to_string()), + ), + ]); + + let builder = builder .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()); @@ -14,15 +237,27 @@ pub fn run() { let builder = builder.plugin(tauri_plugin_barcode_scanner::init()); builder - .setup(|_app| { + .setup(|app| { #[cfg(any(target_os = "linux", windows))] { use tauri_plugin_deep_link::DeepLinkExt; - _app.deep_link().register_all()?; + + if let Err(error) = app.deep_link().register_all() { + if cfg!(debug_assertions) { + eprintln!("Skipping deep link registration during dev: {error}"); + } else { + return Err(error.into()); + } + } } Ok(()) }) - .invoke_handler(tauri::generate_handler![greet]) + .invoke_handler(tauri::generate_handler![ + list_screen_share_sources, + list_screen_share_audio_outputs, + get_screen_share_capabilities, + capture_screen_share_frame + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/apps/tauri/src/context.tsx b/apps/tauri/src/context.tsx index 5e5b560..97fd664 100644 --- a/apps/tauri/src/context.tsx +++ b/apps/tauri/src/context.tsx @@ -1,19 +1,101 @@ -import { createContext } from "react"; +import { + createContext, + useContext, + useMemo, + type ReactNode, +} from "react"; +import { invoke, isTauri } from "@tauri-apps/api/core"; -type contextType = { - nothing: undefined; +export type DesktopScreenShareSource = { + id: string; + kind: "screen" | "window"; + name: string; + subtitle?: string | null; }; -export const context = createContext(undefined); +export type DesktopScreenShareAudioOutput = { + id: string; + name: string; + isDefault: boolean; +}; -export default function Provider({ children }: { children: React.ReactNode }) { +export type DesktopScreenShareCapabilities = { + platform: "linux" | "macos" | "windows" | "other"; + usesPipeWireAudioPicker: boolean; + supportsSystemAudioSwitch: boolean; + supportsReliableSystemAudio: boolean; +}; + +type DesktopMediaContextValue = { + isDesktopTauri: boolean; + getScreenShareCapabilities: () => Promise; + listScreenShareSources: () => Promise; + listScreenShareAudioOutputs: () => Promise; +}; + +const defaultCapabilities: DesktopScreenShareCapabilities = { + platform: "other", + usesPipeWireAudioPicker: false, + supportsSystemAudioSwitch: false, + supportsReliableSystemAudio: false, +}; + +const desktopMediaContext = createContext( + undefined, +); + +async function listScreenShareSources(): Promise { + if (!isTauri()) { + return []; + } + + return invoke("list_screen_share_sources"); +} + +async function listScreenShareAudioOutputs(): Promise< + DesktopScreenShareAudioOutput[] +> { + if (!isTauri()) { + return []; + } + + return invoke( + "list_screen_share_audio_outputs", + ); +} + +async function getScreenShareCapabilities(): Promise { + if (!isTauri()) { + return defaultCapabilities; + } + + return invoke("get_screen_share_capabilities"); +} + +export function useDesktopMedia() { + const value = useContext(desktopMediaContext); + + if (!value) { + throw new Error("useDesktopMedia must be used within the desktop provider"); + } + + return value; +} + +export default function Provider({ children }: { children: ReactNode }) { + const value = useMemo( + () => ({ + isDesktopTauri: isTauri(), + getScreenShareCapabilities, + listScreenShareSources, + listScreenShareAudioOutputs, + }), + [], + ); + return ( - + {children} - + ); } diff --git a/bun.lock b/bun.lock index 286109d..c93e836 100644 --- a/bun.lock +++ b/bun.lock @@ -101,10 +101,12 @@ "@tanstack/react-query": "^5.0.0", "@tanstack/react-router": "^1.0.0", "@tanstack/react-virtual": "^3.0.0", + "@tauri-apps/api": "^2", "@tensamin/crypto": "workspace:*", "@tensamin/markdown": "workspace:*", "@tensamin/shared": "workspace:*", "@tensamin/storage": "workspace:*", + "@tensamin/tauri": "workspace:*", "@tensamin/ttp": "workspace:*", "@tensamin/ui": "*", "@tensamin/user": "workspace:*", diff --git a/packages/call/package.json b/packages/call/package.json index e98ab97..c6cf396 100644 --- a/packages/call/package.json +++ b/packages/call/package.json @@ -19,10 +19,12 @@ "@tanstack/react-query": "^5.0.0", "@tanstack/react-router": "^1.0.0", "@tanstack/react-virtual": "^3.0.0", + "@tauri-apps/api": "^2", "@tensamin/crypto": "workspace:*", "@tensamin/markdown": "workspace:*", "@tensamin/shared": "workspace:*", "@tensamin/storage": "workspace:*", + "@tensamin/tauri": "workspace:*", "@tensamin/ttp": "workspace:*", "@tensamin/ui": "*", "@tensamin/user": "workspace:*", diff --git a/packages/call/src/components/buttons/screenshare.tsx b/packages/call/src/components/buttons/screenshare.tsx index 8c4f5e1..be82c55 100644 --- a/packages/call/src/components/buttons/screenshare.tsx +++ b/packages/call/src/components/buttons/screenshare.tsx @@ -1,6 +1,10 @@ +import { useState } from "react"; import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui"; import { MonitorDot, ScreenShare } from "lucide-react"; +import { toast } from "@tensamin/shared/log"; +import { useDesktopMedia } from "@tensamin/tauri/context"; import { setScreenShareEnabled, useCall } from "../../store"; +import ScreenShareDialog from "./screenshareDialog"; export default function ScreenshareButton({ className, @@ -10,43 +14,98 @@ export default function ScreenshareButton({ iconSize?: number; }) { const isScreensharing = useCall((state) => state.screenShareEnabled); + const [dialogOpen, setDialogOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const { isDesktopTauri } = useDesktopMedia(); + + async function startWebShare() { + try { + await setScreenShareEnabled(true, { + audio: true, + systemAudio: "include", + surfaceSwitching: "include", + video: true, + }); + } catch (error) { + console.error("Failed to start web screen share", error); + toast( + "error", + error instanceof Error + ? error.message + : "Failed to start screen sharing.", + ); + } + } + + async function stopShare() { + try { + await setScreenShareEnabled(false); + } catch (error) { + console.error("Failed to stop screen share", error); + toast("error", "Failed to stop screen sharing."); + } + } return ( - - + + + {isScreensharing ? ( + + ) : ( + + )} + + } + /> + - } - /> - - - - - - + + + + + + {isDesktopTauri && ( + + )} + ); } diff --git a/packages/call/src/components/buttons/screenshareDialog.tsx b/packages/call/src/components/buttons/screenshareDialog.tsx new file mode 100644 index 0000000..bef1196 --- /dev/null +++ b/packages/call/src/components/buttons/screenshareDialog.tsx @@ -0,0 +1,325 @@ +import { useEffect, useState } from "react"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from "@tensamin/ui"; +import { + type DesktopScreenShareAudioOutput, + type DesktopScreenShareCapabilities, + type DesktopScreenShareSource, + useDesktopMedia, +} from "@tensamin/tauri/context"; +import { toast } from "@tensamin/shared/log"; +import { AppWindow, Loader2, MonitorUp } from "lucide-react"; +import type { ScreenShareCaptureOptions } from "livekit-client"; +import { + setScreenShareEnabled, + startLinuxDesktopScreenShare, +} from "../../store"; + +const NONE_AUDIO_OUTPUT = "__none__"; + +function buildScreenShareOptions( + source: DesktopScreenShareSource, + capabilities: DesktopScreenShareCapabilities, + selectedAudioOutputId: string, + shareAudio: boolean, +): ScreenShareCaptureOptions { + const wantsAudio = capabilities.usesPipeWireAudioPicker + ? selectedAudioOutputId !== NONE_AUDIO_OUTPUT + : capabilities.supportsReliableSystemAudio && shareAudio; + + return { + audio: wantsAudio + ? { + autoGainControl: false, + echoCancellation: false, + noiseSuppression: false, + } + : false, + video: { + displaySurface: source.kind === "window" ? "window" : "monitor", + }, + systemAudio: wantsAudio ? "include" : "exclude", + surfaceSwitching: "exclude", + selfBrowserSurface: "exclude", + contentHint: "detail", + }; +} + +export default function ScreenShareDialog({ + open, + onOpenChange, + isScreensharing, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + isScreensharing: boolean; +}) { + const { + getScreenShareCapabilities, + listScreenShareAudioOutputs, + listScreenShareSources, + } = useDesktopMedia(); + + const [loading, setLoading] = useState(false); + const [sources, setSources] = useState([]); + const [audioOutputs, setAudioOutputs] = useState( + [], + ); + const [capabilities, setCapabilities] = + useState(null); + const [selectedSourceId, setSelectedSourceId] = useState(null); + const [selectedAudioOutputId, setSelectedAudioOutputId] = + useState(NONE_AUDIO_OUTPUT); + const [shareAudio, setShareAudio] = useState(false); + + useEffect(() => { + if (!open) { + return; + } + + let active = true; + + setLoading(true); + setSelectedSourceId(null); + setSelectedAudioOutputId(NONE_AUDIO_OUTPUT); + setShareAudio(false); + + Promise.all([listScreenShareSources(), getScreenShareCapabilities()]) + .then(async ([nextSources, nextCapabilities]) => { + if (!active) { + return; + } + + setSources(nextSources); + setCapabilities(nextCapabilities); + + if (nextCapabilities.usesPipeWireAudioPicker) { + const nextOutputs = await listScreenShareAudioOutputs(); + + if (!active) { + return; + } + + setAudioOutputs(nextOutputs); + } else { + setAudioOutputs([]); + } + }) + .catch((error) => { + console.error("Failed to load desktop share sources", error); + toast("error", "Failed to load screen share sources."); + }) + .finally(() => { + if (active) { + setLoading(false); + } + }); + + return () => { + active = false; + }; + }, [ + getScreenShareCapabilities, + listScreenShareAudioOutputs, + listScreenShareSources, + open, + ]); + + const selectedSource = + sources.find((source) => source.id === selectedSourceId) ?? null; + + async function startSharing() { + if (!selectedSource || !capabilities) { + return; + } + + setLoading(true); + + try { + if (capabilities.platform === "linux") { + await startLinuxDesktopScreenShare(selectedSource.id); + } else { + await setScreenShareEnabled( + true, + buildScreenShareOptions( + selectedSource, + capabilities, + selectedAudioOutputId, + shareAudio, + ), + ); + } + onOpenChange(false); + } catch (error) { + console.error("Failed to start screen share", error); + toast( + "error", + error instanceof Error + ? error.message + : "Failed to start screen sharing.", + ); + } finally { + setLoading(false); + } + } + + async function stopSharing() { + setLoading(true); + + try { + await setScreenShareEnabled(false); + onOpenChange(false); + } catch (error) { + console.error("Failed to stop screen share", error); + toast("error", "Failed to stop screen sharing."); + } finally { + setLoading(false); + } + } + + return ( + + + + Share your screen + + Pick the window or display you want to share with the call. + + + +
+
+ {sources.map((source) => { + const selected = source.id === selectedSourceId; + + return ( + + ); + })} +
+ + {!loading && sources.length === 0 && ( +

+ No windows or displays are currently available for capture. +

+ )} + + {capabilities?.usesPipeWireAudioPicker ? ( +
+ + +

+ Select a PipeWire output to try sharing system audio with your + screen. +

+
+ ) : capabilities?.supportsSystemAudioSwitch ? ( +
+
+
+ +

+ Share system audio alongside your screen when the runtime can + provide it. +

+
+ +
+ {!capabilities.supportsReliableSystemAudio && ( +

+ System audio sharing is not available on this platform/runtime + yet. +

+ )} +
+ ) : null} + + {loading && ( +
+ + Loading available share targets... +
+ )} +
+ + + + {isScreensharing && ( + + )} + + +
+
+ ); +} diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index 5965180..44eaf6d 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { create } from "zustand"; import { useLocation, useNavigate } from "@tanstack/react-router"; +import { invoke } from "@tauri-apps/api/core"; import { useTTP } from "@tensamin/ttp"; import { log, toast } from "@tensamin/shared/log"; import { ttp } from "@tensamin/shared/data"; @@ -11,9 +12,11 @@ import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter"; import { ExternalE2EEKeyProvider, LocalAudioTrack, + type LocalTrack, Room, RoomEvent, type RemoteTrack, + type ScreenShareCaptureOptions, Track, setLogExtension, getLogger, @@ -62,6 +65,11 @@ type Runtime = { getUser: GetUserFn; }; +type ScreenShareSession = { + tracks: Array; + cleanup?: () => void; +}; + type CallStore = { state: CallState; view: CallView; @@ -73,6 +81,7 @@ type CallStore = { deaf: boolean; micEnabled: boolean; screenShareEnabled: boolean; + screenShareSession: ScreenShareSession | null; isEncrypted: boolean; room: Room; keyProvider: ExternalE2EEKeyProvider; @@ -136,11 +145,12 @@ function requireRuntime(runtime: Runtime | null): Runtime { } export function syncParticipantState() { - const { room } = useCall.getState(); + const { room, screenShareSession } = useCall.getState(); useCall.setState({ micEnabled: room.localParticipant.isMicrophoneEnabled, - screenShareEnabled: room.localParticipant.isScreenShareEnabled, + screenShareEnabled: + screenShareSession != null || room.localParticipant.isScreenShareEnabled, isEncrypted: room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted, }); @@ -225,6 +235,8 @@ export async function connect(callId: string) { } export function disconnect() { + void clearPublishedScreenShare(); + useCall.setState({ state: "closing", invitedUserId: null, @@ -234,6 +246,7 @@ export function disconnect() { currentCallData: null, deaf: false, view: "preview", + screenShareSession: null, }); room.remoteParticipants.forEach((participant) => { @@ -325,11 +338,163 @@ export async function toggleMute() { syncParticipantState(); } -export async function setScreenShareEnabled(enabled: boolean) { - await room.localParticipant.setScreenShareEnabled(enabled); +async function clearPublishedScreenShare() { + const screenShareSession = useCall.getState().screenShareSession; + + if (!screenShareSession) { + return; + } + + await Promise.all( + screenShareSession.tracks.map((track) => + room.localParticipant.unpublishTrack(track, true).catch((error) => { + log(1, "call", "red", "Failed to unpublish screen share track", error); + }), + ), + ); + + screenShareSession.cleanup?.(); + + useCall.setState({ screenShareSession: null }); +} + +async function publishScreenShareTracks( + tracks: Array, + cleanup?: () => void, +) { + if (tracks.length === 0) { + throw new Error("No screen share tracks were created."); + } + + await Promise.all( + tracks.map((track) => + room.localParticipant.publishTrack(track, { + source: + track.kind === Track.Kind.Video + ? Track.Source.ScreenShare + : Track.Source.ScreenShareAudio, + }), + ), + ); + + for (const track of tracks) { + const mediaStreamTrack = + track instanceof MediaStreamTrack ? track : track.mediaStreamTrack; + + mediaStreamTrack.addEventListener( + "ended", + () => { + void stopScreenShare(); + }, + { once: true }, + ); + } + + useCall.setState({ screenShareSession: { tracks, cleanup } }); syncParticipantState(); } +export async function startScreenShare(options?: ScreenShareCaptureOptions) { + await clearPublishedScreenShare(); + + const tracks = await room.localParticipant.createScreenTracks(options); + + await publishScreenShareTracks( + tracks, + () => tracks.forEach((track) => track.stop()), + ); +} + +export async function startLinuxDesktopScreenShare(sourceId: string) { + await clearPublishedScreenShare(); + + const canvas = document.createElement("canvas"); + canvas.width = 1280; + canvas.height = 720; + canvas.style.display = "none"; + document.body.appendChild(canvas); + + const context = canvas.getContext("2d"); + + if (!context) { + canvas.remove(); + throw new Error("Failed to initialize the screen share canvas."); + } + + const stream = canvas.captureStream(8); + const videoTrack = stream.getVideoTracks()[0]; + + if (!videoTrack) { + canvas.remove(); + throw new Error("Failed to create a video track for screen sharing."); + } + + const image = new Image(); + let stopped = false; + let frameRequestInFlight = false; + + const renderFrame = async () => { + if (stopped || frameRequestInFlight) { + return; + } + + frameRequestInFlight = true; + + try { + const dataUrl = await invoke("capture_screen_share_frame", { + sourceId, + }); + + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("Failed to decode screen share frame.")); + image.src = dataUrl; + }); + + if (canvas.width !== image.naturalWidth || canvas.height !== image.naturalHeight) { + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + } + + context.drawImage(image, 0, 0, canvas.width, canvas.height); + } finally { + frameRequestInFlight = false; + } + }; + + await renderFrame(); + + const interval = window.setInterval(() => { + void renderFrame().catch((error) => { + log(1, "call", "red", "Failed to capture Linux screen share frame", error); + }); + }, 125); + + await publishScreenShareTracks([videoTrack], () => { + stopped = true; + window.clearInterval(interval); + stream.getTracks().forEach((track) => track.stop()); + canvas.remove(); + }); +} + +export async function stopScreenShare() { + await clearPublishedScreenShare(); + syncParticipantState(); +} + +export async function setScreenShareEnabled( + enabled: boolean, + options?: ScreenShareCaptureOptions, +) { + if (enabled) { + await startScreenShare(options); + return; + } + + await stopScreenShare(); +} + export function resetCallState() { useCall.setState({ state: "closed", @@ -340,6 +505,7 @@ export function resetCallState() { livekitToken: null, currentCallData: null, deaf: false, + screenShareSession: null, }); syncParticipantState(); @@ -375,6 +541,7 @@ export const useCall = create(() => ({ deaf: false, micEnabled: room.localParticipant.isMicrophoneEnabled, screenShareEnabled: room.localParticipant.isScreenShareEnabled, + screenShareSession: null, isEncrypted: room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted, room, diff --git a/packages/call/src/views/main/grid.tsx b/packages/call/src/views/main/grid.tsx index e3912ab..a0cab25 100644 --- a/packages/call/src/views/main/grid.tsx +++ b/packages/call/src/views/main/grid.tsx @@ -137,7 +137,7 @@ export default function View() { ); const rows = useMemo(() => { - let nextParticipant = 1; + let nextParticipant = 0; return layout.rowCounts.map((count, rowIndex) => { const participants = Array.from( @@ -148,21 +148,29 @@ export default function View() { }); }, [layout.rowCounts]); + const users = useMemo(() => { + const participants = [ + ...room.remoteParticipants.values(), + room.localParticipant, + ]; + return participants; + }, [room.localParticipant, room.remoteParticipants]); + return ( -
+
{rows.map((row) => (
- {row.participants.map((participant) => ( + {row.participants.map((userIndex) => (
- Test {participant} + Test {users[userIndex]?.identity}
))}