diff --git a/cinny b/cinny
index 747400e..8e30c73 160000
--- a/cinny
+++ b/cinny
@@ -1 +1 @@
-Subproject commit 747400ea258fbb9fd1297c5e82d0081251bbfff0
+Subproject commit 8e30c73e2fd5584c843f39e9ffdab7ab79ff71b1
diff --git a/config.json b/config.json
index 4fa9e5d..150d1cd 100644
--- a/config.json
+++ b/config.json
@@ -25,6 +25,7 @@
},
"gifApiKey": "",
"webAppUrl": "https://chat.lotusguild.org",
+ "desktopCallOrigin": "http://127.0.0.1:44548",
"statusPages": {
"matrix.lotusguild.org": {
"url": "https://isitup.lotusguild.org",
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);
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index a676eb6..e76c2c6 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -14,6 +14,8 @@ use tauri_plugin_opener::OpenerExt;
pub mod gpu_workarounds;
mod native;
mod secure_session;
+#[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.
@@ -190,6 +192,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")))]
@@ -212,6 +255,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());
};
@@ -940,6 +992,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,
@@ -958,7 +1011,17 @@ pub fn run() {
secure_session::secure_session_get,
secure_session::secure_session_clear,
])
- .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
@@ -1226,41 +1289,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) };
+ }
}
})?;
@@ -1268,19 +1366,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
});
})?;
@@ -1338,6 +1465,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::*;
diff --git a/src-tauri/src/webview_permissions.rs b/src-tauri/src/webview_permissions.rs
new file mode 100644
index 0000000..328e420
--- /dev/null
+++ b/src-tauri/src/webview_permissions.rs
@@ -0,0 +1,273 @@
+//! 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, 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;
+
+/// 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.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;
+ }
+ 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, and the call page's.
+#[derive(Clone, Debug)]
+pub(crate) struct AppOrigins {
+ app: Vec,
+ call: Option,
+}
+
+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 {
+ 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.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))
+ }
+}
+
+#[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 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();
+ 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
+ );
+ }
+ }
+}
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