From 3e136d3729f53d361278c633efca696207e7ff40 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 28 Sep 2026 20:03:11 -0400 Subject: [PATCH 1/5] fix: WebView permissions only for the app's own origin (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux (WebKitGTK) allowed every permission request of every kind; Windows (WebView2) auto-allowed mic/camera/notifications without checking who asked. Now (src-tauri/src/webview_permissions.rs, unit-tested): - Linux: microphone/camera/screen, device labels, notifications and location are granted when the page in the window is the app (http://localhost:44548; debug builds also the bundled/dev page). Everything else is denied (WebKitGTK has no prompt of its own). WebKitGTK doesn't say which frame asked; frames are gated earlier by the Permissions Policy (cinny gives microphone/camera only to the same-origin call frame). - Windows: the same grants (minus location, which keeps WebView2's prompt), checked against the origin of the frame that asked (args.Uri()). Other origins are denied mic/camera/notifications/location; other kinds keep WebView2's default handling. - Denials are logged ("webview: denied …"). Tested on Linux with a release build under Xvfb + PulseAudio (no WebDriver: WebKit's automation mode bypasses the handler), before/after: - app page: mic, device labels, location allowed (unchanged) - same-origin call frame: mic allowed (unchanged) - cross-origin frame without allow=: blocked before the handler (unchanged) - foreign top-level page: mic, device labels, location now denied (were allowed) Real cinny build: boots, logs in, no denials. Windows code type-checked (x86_64-pc-windows-gnu). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src-tauri/src/lib.rs | 144 ++++++++++++----- src-tauri/src/webview_permissions.rs | 221 +++++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 39 deletions(-) create mode 100644 src-tauri/src/webview_permissions.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8cad3b2..77c2efc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,8 @@ use tauri_plugin_opener::OpenerExt; pub mod gpu_workarounds; mod native; +#[cfg(any(target_os = "linux", target_os = "windows", test))] +mod webview_permissions; /// Bring the main window to the foreground from the tray / a hidden / /// minimized state. Shared by the tray, single-instance, and deep-link paths. @@ -1221,41 +1223,76 @@ pub fn run() { let _ = window_vibrancy::apply_mica(&window, Some(true)); } - // Auto-grant camera, microphone, and notification permissions in WebView2. - #[cfg(target_os = "windows")] - window.with_webview(|webview| { - use webview2_com::{ - Microsoft::Web::WebView2::Win32::{ - COREWEBVIEW2_PERMISSION_KIND, - COREWEBVIEW2_PERMISSION_KIND_CAMERA, - COREWEBVIEW2_PERMISSION_KIND_MICROPHONE, - COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS, - COREWEBVIEW2_PERMISSION_STATE_ALLOW, - }, - PermissionRequestedEventHandler, - }; + // cinny-desktop #22: the app's own page gets the microphone, camera + // and notifications without a prompt; other origins (room widgets, + // link-preview embeds) are refused them. See webview_permissions. + #[cfg(any(target_os = "linux", target_os = "windows"))] + let app_origins = webview_permissions::AppOrigins::new( + port, + app.config().build.dev_url.as_ref(), + ); - let controller = webview.controller(); - if let Ok(core) = unsafe { controller.CoreWebView2() } { - let handler = PermissionRequestedEventHandler::create(Box::new( - |_sender, args| { - if let Some(args) = args { - let mut kind = COREWEBVIEW2_PERMISSION_KIND(0); - unsafe { args.PermissionKind(&mut kind) }?; - if kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE - || kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA - || kind == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS - { - unsafe { - args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW) - }?; - } - } - Ok(()) + #[cfg(target_os = "windows")] + window.with_webview({ + let app_origins = app_origins.clone(); + move |webview| { + use webview2_com::{ + Microsoft::Web::WebView2::Win32::{ + COREWEBVIEW2_PERMISSION_KIND, + COREWEBVIEW2_PERMISSION_KIND_CAMERA, + COREWEBVIEW2_PERMISSION_KIND_GEOLOCATION, + COREWEBVIEW2_PERMISSION_KIND_MICROPHONE, + COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS, + COREWEBVIEW2_PERMISSION_STATE_ALLOW, + COREWEBVIEW2_PERMISSION_STATE_DENY, }, - )); - let mut token = Default::default(); - let _ = unsafe { core.add_PermissionRequested(&handler, &mut token) }; + PermissionRequestedEventHandler, + }; + use webview_permissions::{decide, Decision, Kind, WINDOWS_GRANTS}; + + let controller = webview.controller(); + if let Ok(core) = unsafe { controller.CoreWebView2() } { + let handler = PermissionRequestedEventHandler::create(Box::new( + move |_sender, args| { + if let Some(args) = args { + let mut raw = COREWEBVIEW2_PERMISSION_KIND(0); + unsafe { args.PermissionKind(&mut raw) }?; + let kind = if raw == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE + || raw == COREWEBVIEW2_PERMISSION_KIND_CAMERA + { + Kind::Media + } else if raw == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS { + Kind::Notifications + } else if raw == COREWEBVIEW2_PERMISSION_KIND_GEOLOCATION { + Kind::Geolocation + } else { + Kind::Other + }; + // The origin of the frame that asked. + let mut uri = windows::core::PWSTR::null(); + unsafe { args.Uri(&mut uri) }?; + let uri = webview2_com::take_pwstr(uri); + match decide(kind, &uri, &app_origins, WINDOWS_GRANTS) { + Decision::Allow => unsafe { + args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW) + }?, + Decision::Deny => { + eprintln!( + "webview: denied {kind:?} permission to {uri}" + ); + unsafe { + args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY) + }? + } + Decision::Default => {} + } + } + Ok(()) + }, + )); + let mut token = Default::default(); + let _ = unsafe { core.add_PermissionRequested(&handler, &mut token) }; + } } })?; @@ -1263,19 +1300,48 @@ pub fn run() { // default (unlike WebView2/WKWebView), which leaves // `navigator.mediaDevices` undefined and makes Element Call // report "browser does not support WebRTC". Turn them on and - // auto-grant the resulting camera/mic permission prompt, mirroring - // the WebView2 handling above. + // answer the permission requests, mirroring the WebView2 handling + // above. WebKitGTK doesn't say which frame asked, so the origin + // checked is the page in the window (see webview_permissions). #[cfg(target_os = "linux")] - window.with_webview(|webview| { - use webkit2gtk::{PermissionRequestExt, SettingsExt, WebViewExt}; + window.with_webview(move |webview| { + use webkit2gtk::glib::prelude::ObjectExt; + use webkit2gtk::{ + DeviceInfoPermissionRequest, GeolocationPermissionRequest, + NotificationPermissionRequest, PermissionRequestExt, SettingsExt, + UserMediaPermissionRequest, WebViewExt, + }; + use webview_permissions::{decide, Decision, Kind, LINUX_GRANTS}; let wv = webview.inner(); if let Some(settings) = WebViewExt::settings(&wv) { settings.set_enable_media_stream(true); settings.set_enable_webrtc(true); } - wv.connect_permission_request(|_webview, request| { - request.allow(); + wv.connect_permission_request(move |wv, request| { + let kind = if request.is::() { + Kind::Media + } else if request.is::() { + Kind::DeviceInfo + } else if request.is::() { + Kind::Notifications + } else if request.is::() { + Kind::Geolocation + } else { + Kind::Other + }; + let uri = wv.uri().map(|u| u.to_string()).unwrap_or_default(); + match decide(kind, &uri, &app_origins, LINUX_GRANTS) { + Decision::Allow => request.allow(), + // No prompt of our own: anything not granted is denied. + Decision::Deny | Decision::Default => { + eprintln!( + "webview: denied {} to {uri}", + request.type_().name() + ); + request.deny(); + } + } true }); })?; diff --git a/src-tauri/src/webview_permissions.rs b/src-tauri/src/webview_permissions.rs new file mode 100644 index 0000000..8aaa39c --- /dev/null +++ b/src-tauri/src/webview_permissions.rs @@ -0,0 +1,221 @@ +//! Which WebView permission requests the app grants (cinny-desktop #22). +//! +//! The web client asks for the microphone/camera/screen (calls, voice +//! messages), the device list (audio-output picker), notifications and the +//! location (location sharing). Those are granted without a prompt, but only +//! to the app's own origin. Everything else is left alone (Windows: WebView2's +//! own prompt) or denied (Linux: WebKitGTK has no prompt of its own). +//! +//! What "the requesting origin" means differs per engine: +//! - WebView2 reports the origin of the frame that asked (`args.Uri()`), so a +//! room widget or link-preview embed is refused here. +//! - WebKitGTK doesn't say which frame asked; the check is on the page loaded +//! in the window. Frames are gated before the request gets this far by the +//! Permissions Policy: cinny only puts `microphone; camera` in the `allow=` +//! of the call frame (same origin on desktop), and cross-origin frames get +//! neither location nor notifications. + +use tauri::Url; + +/// A permission request, reduced to what the policy cares about. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Kind { + /// Microphone, camera or screen capture (getUserMedia/getDisplayMedia). + Media, + /// Device labels/ids from enumerateDevices (WebKitGTK only). + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + DeviceInfo, + Notifications, + Geolocation, + /// Anything else: clipboard read, storage access, pointer lock, DRM, … + Other, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Decision { + Allow, + Deny, + /// Let the engine decide (WebView2 prompts; WebKitGTK denies). + Default, +} + +/// What the Linux (WebKitGTK) handler grants to the app. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) const LINUX_GRANTS: &[Kind] = &[ + Kind::Media, + Kind::DeviceInfo, + Kind::Notifications, + Kind::Geolocation, +]; +/// What the Windows (WebView2) handler grants to the app. Location keeps +/// WebView2's own prompt, as before. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) const WINDOWS_GRANTS: &[Kind] = &[Kind::Media, Kind::Notifications]; + +/// The decision for a request of `kind` from `uri`. +pub(crate) fn decide(kind: Kind, uri: &str, app: &AppOrigins, grants: &[Kind]) -> Decision { + if kind == Kind::Other { + return Decision::Default; + } + if !app.contains(uri) { + return Decision::Deny; + } + if grants.contains(&kind) { + Decision::Allow + } else { + Decision::Default + } +} + +/// scheme, host, port (explicit or the scheme's default). +type Origin = (String, String, Option); + +fn origin_of(uri: &str) -> Option { + let url = Url::parse(uri).ok()?; + let host = url.host_str()?.to_ascii_lowercase(); + Some((url.scheme().to_owned(), host, url.port_or_known_default())) +} + +/// The origins the app's own page is served from. +#[derive(Clone, Debug)] +pub(crate) struct AppOrigins(Vec); + +impl AppOrigins { + /// Release builds load `http://localhost:{port}` (tauri-plugin-localhost). + /// Debug builds load the bundled page (`tauri://localhost`, or + /// `http://tauri.localhost` on Windows) or, under `tauri dev`, `dev_url`. + pub(crate) fn new(port: u16, dev_url: Option<&Url>) -> Self { + let mut uris = vec![format!("http://localhost:{port}/")]; + if cfg!(debug_assertions) { + uris.push("tauri://localhost/".into()); + uris.push("http://tauri.localhost/".into()); + if let Some(dev) = dev_url { + uris.push(dev.to_string()); + } + } + Self(uris.iter().filter_map(|u| origin_of(u)).collect()) + } + + pub(crate) fn contains(&self, uri: &str) -> bool { + origin_of(uri).is_some_and(|o| self.0.contains(&o)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> AppOrigins { + AppOrigins::new(44548, None) + } + + #[test] + fn app_origin_matches_only_the_app() { + let app = app(); + assert!(app.contains("http://localhost:44548/")); + assert!(app.contains("http://localhost:44548/#/home/!room:server")); + assert!(app.contains("http://LOCALHOST:44548/public/element-call/index.html")); + for other in [ + "http://localhost:44549/", + "https://localhost:44548/", + "http://127.0.0.1:44548/", + "http://localhost/", + "http://localhost.evil.example:44548/", + "http://evil.example/?http://localhost:44548/", + "https://www.youtube-nocookie.com/embed/x", + "https://chat.lotusguild.org/", + "about:blank", + "data:text/html,hi", + "null", + "", + ] { + assert!(!app.contains(other), "{other}"); + } + } + + #[test] + fn debug_builds_also_accept_the_bundled_and_dev_pages() { + let dev = Url::parse("http://localhost:8080").unwrap(); + let app = AppOrigins::new(44548, Some(&dev)); + assert_eq!( + app.contains("tauri://localhost/index.html"), + cfg!(debug_assertions) + ); + assert_eq!( + app.contains("http://tauri.localhost/"), + cfg!(debug_assertions) + ); + assert_eq!( + app.contains("http://localhost:8080/"), + cfg!(debug_assertions) + ); + assert!(app.contains("http://localhost:44548/")); + assert!(!app.contains("tauri://evil/")); + } + + #[test] + fn app_gets_its_grants_without_a_prompt() { + let app = app(); + let uri = "http://localhost:44548/"; + for kind in [ + Kind::Media, + Kind::DeviceInfo, + Kind::Notifications, + Kind::Geolocation, + ] { + assert_eq!( + decide(kind, uri, &app, LINUX_GRANTS), + Decision::Allow, + "{kind:?}" + ); + } + assert_eq!( + decide(Kind::Media, uri, &app, WINDOWS_GRANTS), + Decision::Allow + ); + assert_eq!( + decide(Kind::Notifications, uri, &app, WINDOWS_GRANTS), + Decision::Allow + ); + // Location on Windows keeps WebView2's prompt. + assert_eq!( + decide(Kind::Geolocation, uri, &app, WINDOWS_GRANTS), + Decision::Default + ); + } + + #[test] + fn other_origins_are_refused() { + let app = app(); + for uri in [ + "https://widget.example/", + "https://www.youtube-nocookie.com/embed/x", + "", + ] { + for kind in [ + Kind::Media, + Kind::DeviceInfo, + Kind::Notifications, + Kind::Geolocation, + ] { + assert_eq!(decide(kind, uri, &app, LINUX_GRANTS), Decision::Deny); + assert_eq!(decide(kind, uri, &app, WINDOWS_GRANTS), Decision::Deny); + } + } + } + + #[test] + fn other_kinds_are_left_to_the_engine() { + let app = app(); + for uri in ["http://localhost:44548/", "https://widget.example/"] { + assert_eq!( + decide(Kind::Other, uri, &app, LINUX_GRANTS), + Decision::Default + ); + assert_eq!( + decide(Kind::Other, uri, &app, WINDOWS_GRANTS), + Decision::Default + ); + } + } +} From 5c3ac6832882b9374845c855a56de45c4ff515ec Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Tue, 29 Sep 2026 00:09:41 -0400 Subject: [PATCH 2/5] feat: call page on its own origin, http://127.0.0.1:44548 (cinny #43) The bundled Element Call page ran on the app's own origin (http://localhost:44548), so the call frame could read the app's storage (login token) and DOM. Serve it from http://127.0.0.1:44548 instead: the same local server and bundle, a different origin. - The local server binds 127.0.0.1 explicitly. The app is still loaded as http://localhost:44548 (its storage stays where it is; the engines try 127.0.0.1 for `localhost`). Binding the name `localhost` could pick ::1 only (Windows lists it first), and then 127.0.0.1 wouldn't answer. - config.json: desktopCallOrigin = http://127.0.0.1:44548. cinny loads the call page from there only when this is set (cinny #43 PR). - CSP frame-src allows http://127.0.0.1:44548. - Permissions (on top of #22): the call page's origin gets microphone/ camera/screen only; nothing else. - The call page gets no IPC: the capability only matches http://localhost:44548. Tested (Linux release build): the server listens on 127.0.0.1:44548 and the app loads as http://localhost:44548; the call page loads from 127.0.0.1 inside the app under its CSP; from that frame parent.localStorage and parent.document are SecurityError, while a same-origin frame (the old setup) reads the app's storage. The call itself was tested in a simulated desktop (Chromium, the WebView2 engine) against a local Synapse + LiveKit; see the cinny PR. Rust tests 17 passed; Windows code type-checked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- config.json | 3 +- src-tauri/src/lib.rs | 12 +++++- src-tauri/src/webview_permissions.rs | 64 +++++++++++++++++++++++++--- src-tauri/tauri.conf.json | 2 +- 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/config.json b/config.json index d524158..58aaf48 100644 --- a/config.json +++ b/config.json @@ -24,5 +24,6 @@ "basename": "/" }, "gifApiKey": "", - "webAppUrl": "https://chat.lotusguild.org" + "webAppUrl": "https://chat.lotusguild.org", + "desktopCallOrigin": "http://127.0.0.1:44548" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 77c2efc..c8de72a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -955,7 +955,17 @@ pub fn run() { native::hotkeys::global_hotkeys_supported, native::hotkeys::set_global_hotkeys, ]) - .plugin(tauri_plugin_localhost::Builder::new(port).build()) + // Bound to 127.0.0.1 explicitly (cinny #43). The app is still loaded as + // http://localhost:{port} (its storage lives under that origin, and the + // engines try 127.0.0.1 for `localhost`); the bundled call page is + // loaded as http://127.0.0.1:{port}, a separate origin on the same + // server. Binding the name `localhost` could pick ::1 only (Windows + // lists it first), and then the call page wouldn't load. + .plugin( + tauri_plugin_localhost::Builder::new(port) + .host("127.0.0.1") + .build(), + ) .plugin( // DECORATIONS is excluded: the custom-chrome toggle (set_custom_chrome) // owns the decorated flag. Letting window-state restore a saved diff --git a/src-tauri/src/webview_permissions.rs b/src-tauri/src/webview_permissions.rs index 8aaa39c..328e420 100644 --- a/src-tauri/src/webview_permissions.rs +++ b/src-tauri/src/webview_permissions.rs @@ -12,8 +12,13 @@ //! - WebKitGTK doesn't say which frame asked; the check is on the page loaded //! in the window. Frames are gated before the request gets this far by the //! Permissions Policy: cinny only puts `microphone; camera` in the `allow=` -//! of the call frame (same origin on desktop), and cross-origin frames get -//! neither location nor notifications. +//! of the call frame, and cross-origin frames get neither location nor +//! notifications. +//! +//! The call frame (cinny #43): the bundled Element Call page is loaded from +//! `http://127.0.0.1:{port}`, the same local server on a second origin, so it +//! can't reach the app's storage. WebView2 reports that origin for the call's +//! microphone/camera requests; it gets media and nothing else. use tauri::Url; @@ -57,6 +62,14 @@ pub(crate) fn decide(kind: Kind, uri: &str, app: &AppOrigins, grants: &[Kind]) - if kind == Kind::Other { return Decision::Default; } + if app.is_call_frame(uri) { + // The call page: microphone/camera/screen only. + return if kind == Kind::Media && grants.contains(&kind) { + Decision::Allow + } else { + Decision::Deny + }; + } if !app.contains(uri) { return Decision::Deny; } @@ -76,9 +89,12 @@ fn origin_of(uri: &str) -> Option { Some((url.scheme().to_owned(), host, url.port_or_known_default())) } -/// The origins the app's own page is served from. +/// The origins the app's own page is served from, and the call page's. #[derive(Clone, Debug)] -pub(crate) struct AppOrigins(Vec); +pub(crate) struct AppOrigins { + app: Vec, + call: Option, +} impl AppOrigins { /// Release builds load `http://localhost:{port}` (tauri-plugin-localhost). @@ -93,11 +109,20 @@ impl AppOrigins { uris.push(dev.to_string()); } } - Self(uris.iter().filter_map(|u| origin_of(u)).collect()) + Self { + app: uris.iter().filter_map(|u| origin_of(u)).collect(), + call: origin_of(&format!("http://127.0.0.1:{port}/")), + } } + /// The app's own page. pub(crate) fn contains(&self, uri: &str) -> bool { - origin_of(uri).is_some_and(|o| self.0.contains(&o)) + origin_of(uri).is_some_and(|o| self.app.contains(&o)) + } + + /// The call page on its own origin (`http://127.0.0.1:{port}`). + pub(crate) fn is_call_frame(&self, uri: &str) -> bool { + origin_of(uri).is_some_and(|o| self.call.as_ref() == Some(&o)) } } @@ -204,6 +229,33 @@ mod tests { } } + #[test] + fn call_frame_gets_media_only() { + let app = app(); + let call = "http://127.0.0.1:44548/public/element-call/index.html?widgetId=x"; + assert!(app.is_call_frame(call)); + assert!(!app.contains(call)); + for grants in [LINUX_GRANTS, WINDOWS_GRANTS] { + assert_eq!(decide(Kind::Media, call, &app, grants), Decision::Allow); + for kind in [Kind::DeviceInfo, Kind::Notifications, Kind::Geolocation] { + assert_eq!(decide(kind, call, &app, grants), Decision::Deny, "{kind:?}"); + } + assert_eq!(decide(Kind::Other, call, &app, grants), Decision::Default); + } + for not_call in [ + "http://127.0.0.1:44549/", + "https://127.0.0.1:44548/", + "http://127.0.0.2:44548/", + "http://[::1]:44548/", + ] { + assert!(!app.is_call_frame(not_call), "{not_call}"); + assert_eq!( + decide(Kind::Media, not_call, &app, WINDOWS_GRANTS), + Decision::Deny + ); + } + } + #[test] fn other_kinds_are_left_to_the_engine() { let app = app(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0509432..7f094cb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -71,7 +71,7 @@ }, "app": { "security": { - "csp": "default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-dT6noyex1I8o5CS9Sx/y8UOqwpZYIridpGz92gcObIM='; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data: mediastream: http: https:; worker-src 'self' blob:; frame-src 'self' blob: https://www.openstreetmap.org https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com https://www.tiktok.com https://www.dailymotion.com https://geo.dailymotion.com https://streamable.com https://player.twitch.tv https://clips.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://platform.twitter.com https://www.instagram.com https://embed.tidal.com https://www.redditmedia.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.mixcloud.com https://widget.deezer.com https://store.steampowered.com; connect-src 'self' blob: data: ipc: ws: wss: http: https: http://ipc.localhost; object-src 'none'; base-uri 'self'" + "csp": "default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-dT6noyex1I8o5CS9Sx/y8UOqwpZYIridpGz92gcObIM='; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data: mediastream: http: https:; worker-src 'self' blob:; frame-src 'self' blob: http://127.0.0.1:44548 https://www.openstreetmap.org https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com https://www.tiktok.com https://www.dailymotion.com https://geo.dailymotion.com https://streamable.com https://player.twitch.tv https://clips.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://platform.twitter.com https://www.instagram.com https://embed.tidal.com https://www.redditmedia.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.mixcloud.com https://widget.deezer.com https://store.steampowered.com; connect-src 'self' blob: data: ipc: ws: wss: http: https: http://ipc.localhost; object-src 'none'; base-uri 'self'" } } } \ No newline at end of file From bcbc5ecdfb58e388cb31dffc95167ef9518792de Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Wed, 30 Sep 2026 12:46:30 -0400 Subject: [PATCH 3/5] windows smoke: foreign-page mic check on a local origin, navigation verified The example.com check could pass vacuously if the runner can't reach the internet (goto failed silently, the mic request then came from the app's own page). Serve a page on http://localhost:9333 instead, confirm the navigation happened, and fail on builds that should refuse it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- scripts/windows-smoke.mjs | 46 +++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/scripts/windows-smoke.mjs b/scripts/windows-smoke.mjs index a757340..1808d1b 100644 --- a/scripts/windows-smoke.mjs +++ b/scripts/windows-smoke.mjs @@ -11,6 +11,7 @@ // instead of failing, so the same script runs on main and on PR branches. import { writeFileSync, mkdirSync } from 'node:fs'; import { execSync } from 'node:child_process'; +import { createServer } from 'node:http'; import { chromium } from 'playwright-core'; const OUT = process.argv[2] || 'smoke-out'; @@ -150,21 +151,38 @@ else if (!kc.supported) record('keychain round trip', 'fail', 'secure_session_su else record('keychain round trip', kc.roundTrip && kc.restored ? 'pass' : 'fail', JSON.stringify(kc)); // 7. A foreign page loaded in the window must not get the microphone (#22). -await page.goto('https://example.com/').catch(() => undefined); -const foreignMic = await page.evaluate(async () => { - try { - const s = await navigator.mediaDevices.getUserMedia({ audio: true }); - s.getTracks().forEach((t) => t.stop()); - return 'ok'; - } catch (e) { - return e.name; - } +// Served locally on another port (a different origin, still a secure context), +// so the check doesn't depend on the runner reaching the internet. +const foreign = createServer((_, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('foreignforeign page'); }); -record( - 'microphone refused to a foreign page', - foreignMic === 'NotAllowedError' ? 'pass' : 'info', - `${foreignMic}${foreignMic === 'ok' ? ' (build without the #22 origin check)' : ''}`, -); +await new Promise((r) => foreign.listen(9333, '127.0.0.1', r)); +const FOREIGN = 'http://localhost:9333/'; +await page.goto(FOREIGN).catch(() => undefined); +if (!page.url().startsWith(FOREIGN)) { + record('microphone refused to a foreign page', 'fail', `navigation did not happen (at ${page.url()})`); +} else { + const foreignMic = await page.evaluate(async () => { + try { + const s = await navigator.mediaDevices.getUserMedia({ audio: true }); + s.getTracks().forEach((t) => t.stop()); + return 'ok'; + } catch (e) { + return e.name; + } + }); + const guarded = cfg.desktopCallOrigin !== undefined; // builds with #22 also carry #43 + let status = 'info'; + if (foreignMic === 'NotAllowedError') status = 'pass'; + else if (guarded) status = 'fail'; + record( + 'microphone refused to a foreign page', + status, + `${foreignMic} at ${page.url()}${status === 'info' ? ' (build without the #22 origin check)' : ''}`, + ); +} +foreign.close(); await page.goto(APP).catch(() => undefined); await page.screenshot({ path: `${OUT}/02-end.png` }).catch(() => undefined); From 261d7852cb7c0d74b700935c750e6c97fe03902e Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Wed, 30 Sep 2026 18:46:11 -0400 Subject: [PATCH 4/5] fix(updater): package installs update through their package manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On CachyOS the in-app update failed with "Permission denied (os error 13) at path /usr/bin/tauri_current_app…": the app was installed from the Arch package, and Tauri's Linux updater can only replace an AppImage — for anything else it tries to write next to the binary in /usr/bin. - update_install_kind command: "in-app" on Windows and for an AppImage ($APPIMAGE set); on Linux package installs "pacman" (ID/ID_LIKE arch: Arch, CachyOS, Manjaro, EndeavourOS…), "deb" (debian/ubuntu and derivatives) or "manual". The web UI shows the matching update command. - install_update refuses up front on a package install ("install: package-managed (…)") instead of downloading the whole update and failing at the last step. Tests: CachyOS/Arch → pacman; Ubuntu/Debian/Mint → deb; Fedora/unknown → manual; AppImage and Windows → in-app. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src-tauri/src/lib.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d0b9753..f6c1830 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -189,6 +189,47 @@ mod update_retry { } } +/// How this copy of the app gets updated. +/// +/// Tauri's updater can replace the Windows install and a Linux AppImage, but +/// not a copy installed by a package manager: it tries to write next to the +/// binary in /usr/bin and fails with "Permission denied (os error 13)" +/// (reported on CachyOS). Those installs update through their package +/// manager instead; the web UI shows the right command. +pub(crate) fn install_kind(linux: bool, appimage: bool, os_release: &str) -> &'static str { + if !linux || appimage { + return "in-app"; + } + let field = |key: &str| { + os_release + .lines() + .find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix('='))) + .map(|v| v.trim().trim_matches('"').to_ascii_lowercase()) + .unwrap_or_default() + }; + let ids = format!("{} {}", field("ID"), field("ID_LIKE")); + let has = |name: &str| ids.split_whitespace().any(|w| w == name); + if has("arch") { + "pacman" + } else if has("debian") || has("ubuntu") { + "deb" + } else { + "manual" + } +} + +#[tauri::command] +fn update_install_kind() -> &'static str { + let linux = cfg!(target_os = "linux"); + let appimage = std::env::var_os("APPIMAGE").is_some(); + let os_release = if linux { + std::fs::read_to_string("/etc/os-release").unwrap_or_default() + } else { + String::new() + }; + install_kind(linux, appimage, &os_release) +} + #[tauri::command] async fn check_for_update(app: tauri::AppHandle) -> Result { #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -211,6 +252,15 @@ async fn install_update(app: tauri::AppHandle) -> Result<(), String> { { use std::time::{Duration, Instant}; + // A package-manager install can't be replaced in place (see + // install_kind); refuse before downloading anything. + let kind = update_install_kind(); + if kind != "in-app" { + return Err(format!( + "install: package-managed ({kind}): update Lotus Chat with your package manager" + )); + } + let emit = |detail: serde_json::Value| { native::emit_to_web(&app, "lotus-update-progress", &detail.to_string()); }; @@ -939,6 +989,7 @@ pub fn run() { send_notification, check_for_update, install_update, + update_install_kind, native::power::set_call_active, native::jumplist::set_jump_list, native::thumbbar::set_thumbbar, @@ -1333,6 +1384,36 @@ mod webview2_args_tests { } } +#[cfg(test)] +mod install_kind_tests { + use super::install_kind; + + #[test] + fn package_installs_update_through_their_package_manager() { + let cachy = "NAME=\"CachyOS Linux\"\nID=cachyos\nID_LIKE=arch\n"; + let arch = "NAME=\"Arch Linux\"\nID=arch\n"; + let ubuntu = "NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"; + let debian = "ID=debian\n"; + let mint = "ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"; + let fedora = "ID=fedora\n"; + assert_eq!(install_kind(true, false, cachy), "pacman"); + assert_eq!(install_kind(true, false, arch), "pacman"); + assert_eq!(install_kind(true, false, ubuntu), "deb"); + assert_eq!(install_kind(true, false, debian), "deb"); + assert_eq!(install_kind(true, false, mint), "deb"); + assert_eq!(install_kind(true, false, fedora), "manual"); + assert_eq!(install_kind(true, false, ""), "manual"); + // ID_LIKE mentioning arch only as part of a longer word doesn't count. + assert_eq!(install_kind(true, false, "ID=x\nID_LIKE=archlike\n"), "manual"); + } + + #[test] + fn appimage_and_windows_update_in_app() { + assert_eq!(install_kind(true, true, "ID=cachyos\nID_LIKE=arch\n"), "in-app"); + assert_eq!(install_kind(false, false, ""), "in-app"); + } +} + #[cfg(test)] mod tray_tests { use super::*; From 86947dba50bafee961a883246bf41948ba2ade6d Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Thu, 1 Oct 2026 00:11:54 +0000 Subject: [PATCH 5/5] chore: bump cinny submodule to 8e30c73e --- cinny | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cinny b/cinny index 747400e..8e30c73 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 747400ea258fbb9fd1297c5e82d0081251bbfff0 +Subproject commit 8e30c73e2fd5584c843f39e9ffdab7ab79ff71b1