Compare commits
18
Commits
ci-windows-smoke
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151199459f | ||
|
|
ec643c677a | ||
|
|
28dee7bea0 | ||
|
|
03e3937e21 | ||
|
|
97ccc97a70 | ||
|
|
86947dba50 | ||
|
|
4e98503c49 | ||
|
|
261d7852cb | ||
|
|
06fe43c3cd | ||
|
|
bcbc5ecdfb | ||
|
|
bd32048461 | ||
|
|
63a8ebe103 | ||
|
|
eeb838c00c | ||
|
|
61ab464b45 | ||
|
|
2be36d14a9 | ||
|
|
032b6e04e7 | ||
|
|
5c3ac68328 | ||
|
|
3e136d3729 |
+1
-1
Submodule cinny updated: d6548c56fd...d1993f2084
@@ -25,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"gifApiKey": "",
|
"gifApiKey": "",
|
||||||
"webAppUrl": "https://chat.lotusguild.org",
|
"webAppUrl": "https://chat.lotusguild.org",
|
||||||
|
"desktopCallOrigin": "http://127.0.0.1:44548",
|
||||||
"statusPages": {
|
"statusPages": {
|
||||||
"matrix.lotusguild.org": {
|
"matrix.lotusguild.org": {
|
||||||
"url": "https://isitup.lotusguild.org",
|
"url": "https://isitup.lotusguild.org",
|
||||||
|
|||||||
+32
-14
@@ -11,6 +11,7 @@
|
|||||||
// instead of failing, so the same script runs on main and on PR branches.
|
// instead of failing, so the same script runs on main and on PR branches.
|
||||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
import { chromium } from 'playwright-core';
|
import { chromium } from 'playwright-core';
|
||||||
|
|
||||||
const OUT = process.argv[2] || 'smoke-out';
|
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));
|
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).
|
// 7. A foreign page loaded in the window must not get the microphone (#22).
|
||||||
await page.goto('https://example.com/').catch(() => undefined);
|
// Served locally on another port (a different origin, still a secure context),
|
||||||
const foreignMic = await page.evaluate(async () => {
|
// so the check doesn't depend on the runner reaching the internet.
|
||||||
try {
|
const foreign = createServer((_, res) => {
|
||||||
const s = await navigator.mediaDevices.getUserMedia({ audio: true });
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
s.getTracks().forEach((t) => t.stop());
|
res.end('<!doctype html><title>foreign</title>foreign page');
|
||||||
return 'ok';
|
|
||||||
} catch (e) {
|
|
||||||
return e.name;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
record(
|
await new Promise((r) => foreign.listen(9333, '127.0.0.1', r));
|
||||||
'microphone refused to a foreign page',
|
const FOREIGN = 'http://localhost:9333/';
|
||||||
foreignMic === 'NotAllowedError' ? 'pass' : 'info',
|
await page.goto(FOREIGN).catch(() => undefined);
|
||||||
`${foreignMic}${foreignMic === 'ok' ? ' (build without the #22 origin check)' : ''}`,
|
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.goto(APP).catch(() => undefined);
|
||||||
await page.screenshot({ path: `${OUT}/02-end.png` }).catch(() => undefined);
|
await page.screenshot({ path: `${OUT}/02-end.png` }).catch(() => undefined);
|
||||||
|
|
||||||
|
|||||||
Generated
+13
@@ -498,6 +498,7 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
|||||||
name = "cinny"
|
name = "cinny"
|
||||||
version = "4.12.2"
|
version = "4.12.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"keyring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -2090,6 +2091,18 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "keyring"
|
||||||
|
version = "3.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"log",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kuchikiki"
|
name = "kuchikiki"
|
||||||
version = "0.8.8-speedreader"
|
version = "0.8.8-speedreader"
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ tauri-plugin-single-instance = "2"
|
|||||||
tauri-plugin-autostart = "2" # P6-1 launch-on-login
|
tauri-plugin-autostart = "2" # P6-1 launch-on-login
|
||||||
# Update retry backoff (already in the tree via tauri; adds only the timer).
|
# Update retry backoff (already in the tree via tauri; adds only the timer).
|
||||||
tokio = { version = "1", features = ["time"] }
|
tokio = { version = "1", features = ["time"] }
|
||||||
|
# cinny #105: login tokens in the OS keychain. Without a platform feature
|
||||||
|
# the crate only has its in-memory mock store (used by the tests); Windows
|
||||||
|
# turns on Credential Manager below.
|
||||||
|
keyring = "3.6"
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
# P6-1 desktop parity: screensaver inhibit (no-sleep in calls) + Unity launcher
|
# P6-1 desktop parity: screensaver inhibit (no-sleep in calls) + Unity launcher
|
||||||
@@ -50,6 +54,7 @@ zbus = "5"
|
|||||||
webkit2gtk = "2.0"
|
webkit2gtk = "2.0"
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
keyring = { version = "3.6", features = ["windows-native"] }
|
||||||
webview2-com = "0.38"
|
webview2-com = "0.38"
|
||||||
window-vibrancy = "0.6"
|
window-vibrancy = "0.6"
|
||||||
windows = { version = "0.61", features = [
|
windows = { version = "0.61", features = [
|
||||||
|
|||||||
+202
-40
@@ -13,6 +13,9 @@ use tauri_plugin_opener::OpenerExt;
|
|||||||
|
|
||||||
pub mod gpu_workarounds;
|
pub mod gpu_workarounds;
|
||||||
mod native;
|
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 /
|
/// Bring the main window to the foreground from the tray / a hidden /
|
||||||
/// minimized state. Shared by the tray, single-instance, and deep-link paths.
|
/// minimized state. Shared by the tray, single-instance, and deep-link paths.
|
||||||
@@ -189,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]
|
#[tauri::command]
|
||||||
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
|
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
@@ -211,6 +255,15 @@ async fn install_update(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
{
|
{
|
||||||
use std::time::{Duration, Instant};
|
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| {
|
let emit = |detail: serde_json::Value| {
|
||||||
native::emit_to_web(&app, "lotus-update-progress", &detail.to_string());
|
native::emit_to_web(&app, "lotus-update-progress", &detail.to_string());
|
||||||
};
|
};
|
||||||
@@ -939,6 +992,7 @@ pub fn run() {
|
|||||||
send_notification,
|
send_notification,
|
||||||
check_for_update,
|
check_for_update,
|
||||||
install_update,
|
install_update,
|
||||||
|
update_install_kind,
|
||||||
native::power::set_call_active,
|
native::power::set_call_active,
|
||||||
native::jumplist::set_jump_list,
|
native::jumplist::set_jump_list,
|
||||||
native::thumbbar::set_thumbbar,
|
native::thumbbar::set_thumbbar,
|
||||||
@@ -952,8 +1006,22 @@ pub fn run() {
|
|||||||
native::focus_assist::get_focus_assist,
|
native::focus_assist::get_focus_assist,
|
||||||
native::hotkeys::global_hotkeys_supported,
|
native::hotkeys::global_hotkeys_supported,
|
||||||
native::hotkeys::set_global_hotkeys,
|
native::hotkeys::set_global_hotkeys,
|
||||||
|
secure_session::secure_session_supported,
|
||||||
|
secure_session::secure_session_set,
|
||||||
|
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(
|
.plugin(
|
||||||
// DECORATIONS is excluded: the custom-chrome toggle (set_custom_chrome)
|
// DECORATIONS is excluded: the custom-chrome toggle (set_custom_chrome)
|
||||||
// owns the decorated flag. Letting window-state restore a saved
|
// owns the decorated flag. Letting window-state restore a saved
|
||||||
@@ -1221,41 +1289,76 @@ pub fn run() {
|
|||||||
let _ = window_vibrancy::apply_mica(&window, Some(true));
|
let _ = window_vibrancy::apply_mica(&window, Some(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-grant camera, microphone, and notification permissions in WebView2.
|
// cinny-desktop #22: the app's own page gets the microphone, camera
|
||||||
#[cfg(target_os = "windows")]
|
// and notifications without a prompt; other origins (room widgets,
|
||||||
window.with_webview(|webview| {
|
// link-preview embeds) are refused them. See webview_permissions.
|
||||||
use webview2_com::{
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
Microsoft::Web::WebView2::Win32::{
|
let app_origins = webview_permissions::AppOrigins::new(
|
||||||
COREWEBVIEW2_PERMISSION_KIND,
|
port,
|
||||||
COREWEBVIEW2_PERMISSION_KIND_CAMERA,
|
app.config().build.dev_url.as_ref(),
|
||||||
COREWEBVIEW2_PERMISSION_KIND_MICROPHONE,
|
);
|
||||||
COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS,
|
|
||||||
COREWEBVIEW2_PERMISSION_STATE_ALLOW,
|
|
||||||
},
|
|
||||||
PermissionRequestedEventHandler,
|
|
||||||
};
|
|
||||||
|
|
||||||
let controller = webview.controller();
|
#[cfg(target_os = "windows")]
|
||||||
if let Ok(core) = unsafe { controller.CoreWebView2() } {
|
window.with_webview({
|
||||||
let handler = PermissionRequestedEventHandler::create(Box::new(
|
let app_origins = app_origins.clone();
|
||||||
|_sender, args| {
|
move |webview| {
|
||||||
if let Some(args) = args {
|
use webview2_com::{
|
||||||
let mut kind = COREWEBVIEW2_PERMISSION_KIND(0);
|
Microsoft::Web::WebView2::Win32::{
|
||||||
unsafe { args.PermissionKind(&mut kind) }?;
|
COREWEBVIEW2_PERMISSION_KIND,
|
||||||
if kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE
|
COREWEBVIEW2_PERMISSION_KIND_CAMERA,
|
||||||
|| kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA
|
COREWEBVIEW2_PERMISSION_KIND_GEOLOCATION,
|
||||||
|| kind == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS
|
COREWEBVIEW2_PERMISSION_KIND_MICROPHONE,
|
||||||
{
|
COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS,
|
||||||
unsafe {
|
COREWEBVIEW2_PERMISSION_STATE_ALLOW,
|
||||||
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)
|
COREWEBVIEW2_PERMISSION_STATE_DENY,
|
||||||
}?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
},
|
},
|
||||||
));
|
PermissionRequestedEventHandler,
|
||||||
let mut token = Default::default();
|
};
|
||||||
let _ = unsafe { core.add_PermissionRequested(&handler, &mut token) };
|
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 +1366,48 @@ pub fn run() {
|
|||||||
// default (unlike WebView2/WKWebView), which leaves
|
// default (unlike WebView2/WKWebView), which leaves
|
||||||
// `navigator.mediaDevices` undefined and makes Element Call
|
// `navigator.mediaDevices` undefined and makes Element Call
|
||||||
// report "browser does not support WebRTC". Turn them on and
|
// report "browser does not support WebRTC". Turn them on and
|
||||||
// auto-grant the resulting camera/mic permission prompt, mirroring
|
// answer the permission requests, mirroring the WebView2 handling
|
||||||
// the WebView2 handling above.
|
// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
window.with_webview(|webview| {
|
window.with_webview(move |webview| {
|
||||||
use webkit2gtk::{PermissionRequestExt, SettingsExt, WebViewExt};
|
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();
|
let wv = webview.inner();
|
||||||
if let Some(settings) = WebViewExt::settings(&wv) {
|
if let Some(settings) = WebViewExt::settings(&wv) {
|
||||||
settings.set_enable_media_stream(true);
|
settings.set_enable_media_stream(true);
|
||||||
settings.set_enable_webrtc(true);
|
settings.set_enable_webrtc(true);
|
||||||
}
|
}
|
||||||
wv.connect_permission_request(|_webview, request| {
|
wv.connect_permission_request(move |wv, request| {
|
||||||
request.allow();
|
let kind = if request.is::<UserMediaPermissionRequest>() {
|
||||||
|
Kind::Media
|
||||||
|
} else if request.is::<DeviceInfoPermissionRequest>() {
|
||||||
|
Kind::DeviceInfo
|
||||||
|
} else if request.is::<NotificationPermissionRequest>() {
|
||||||
|
Kind::Notifications
|
||||||
|
} else if request.is::<GeolocationPermissionRequest>() {
|
||||||
|
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
|
true
|
||||||
});
|
});
|
||||||
})?;
|
})?;
|
||||||
@@ -1333,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)]
|
#[cfg(test)]
|
||||||
mod tray_tests {
|
mod tray_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
//! Login tokens in the OS keychain (cinny #105, step 1).
|
||||||
|
//!
|
||||||
|
//! Step 1 only MIRRORS the session tokens into the keychain (Windows
|
||||||
|
//! Credential Manager): the web client keeps reading its session from
|
||||||
|
//! localStorage exactly as before, so nothing about login changes and a
|
||||||
|
//! keychain problem can't log anyone out. Once the mirror has proven itself on
|
||||||
|
//! real installs, step 2 switches reads to the keychain and drops the tokens
|
||||||
|
//! from localStorage.
|
||||||
|
//!
|
||||||
|
//! Only the secrets are stored (user id + device id to match them to the
|
||||||
|
//! session, the access token and the refresh token); the rest of the session
|
||||||
|
//! stays in localStorage. Windows caps a credential at 2560 bytes, so the
|
||||||
|
//! serialized value is limited well below that.
|
||||||
|
//!
|
||||||
|
//! Other platforms: not supported yet (Linux Secret Service can prompt to
|
||||||
|
//! unlock a wallet at startup; that needs its own testing), and the commands
|
||||||
|
//! say so instead of failing.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[cfg_attr(not(any(target_os = "windows", test)), allow(dead_code))]
|
||||||
|
pub(crate) const SERVICE: &str = "Lotus Chat";
|
||||||
|
#[cfg_attr(not(any(target_os = "windows", test)), allow(dead_code))]
|
||||||
|
pub(crate) const ACCOUNT: &str = "session";
|
||||||
|
/// Serialized-length cap (chars). Windows' limit is 2560 bytes; staying under
|
||||||
|
/// 1200 chars keeps us safe whether the value is stored as UTF-8 or UTF-16.
|
||||||
|
pub(crate) const MAX_LEN: usize = 1200;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionTokens {
|
||||||
|
pub user_id: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub access_token: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn store(entry: &keyring::Entry, tokens: &SessionTokens) -> Result<(), String> {
|
||||||
|
if tokens.user_id.is_empty() || tokens.device_id.is_empty() || tokens.access_token.is_empty() {
|
||||||
|
return Err("incomplete session".into());
|
||||||
|
}
|
||||||
|
let value = serde_json::to_string(tokens).map_err(|e| e.to_string())?;
|
||||||
|
if value.chars().count() > MAX_LEN {
|
||||||
|
return Err(format!(
|
||||||
|
"session too large for the keychain ({} chars)",
|
||||||
|
value.chars().count()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
entry.set_password(&value).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load(entry: &keyring::Entry) -> Result<Option<SessionTokens>, String> {
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(value) => serde_json::from_str(&value)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| format!("unreadable keychain entry: {e}")),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear(entry: &keyring::Entry) -> Result<(), String> {
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn entry() -> Result<keyring::Entry, String> {
|
||||||
|
keyring::Entry::new(SERVICE, ACCOUNT).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn entry() -> Result<keyring::Entry, String> {
|
||||||
|
Err("not supported on this platform".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keychain calls can block (credential store, AV scanners): keep them off
|
||||||
|
/// the main thread.
|
||||||
|
async fn blocking<T: Send + 'static>(
|
||||||
|
f: impl FnOnce() -> Result<T, String> + Send + 'static,
|
||||||
|
) -> Result<T, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(f)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this platform stores the session in the keychain.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn secure_session_supported() -> bool {
|
||||||
|
cfg!(target_os = "windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn secure_session_set(tokens: SessionTokens) -> Result<(), String> {
|
||||||
|
blocking(move || store(&entry()?, &tokens)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn secure_session_get() -> Result<Option<SessionTokens>, String> {
|
||||||
|
blocking(|| load(&entry()?)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn secure_session_clear() -> Result<(), String> {
|
||||||
|
blocking(|| clear(&entry()?)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn mock_entry() -> keyring::Entry {
|
||||||
|
keyring::set_default_credential_builder(keyring::mock::default_credential_builder());
|
||||||
|
keyring::Entry::new(SERVICE, ACCOUNT).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokens() -> SessionTokens {
|
||||||
|
SessionTokens {
|
||||||
|
user_id: "@alice:lotusguild.org".into(),
|
||||||
|
device_id: "ABCDEFGHIJ".into(),
|
||||||
|
access_token: "syt_YWxpY2U_abcdefghijklmnopqrst_0AbCdE".into(),
|
||||||
|
refresh_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_and_clear() {
|
||||||
|
let e = mock_entry();
|
||||||
|
assert_eq!(load(&e).unwrap(), None);
|
||||||
|
store(&e, &tokens()).unwrap();
|
||||||
|
assert_eq!(load(&e).unwrap(), Some(tokens()));
|
||||||
|
let mut oidc = tokens();
|
||||||
|
oidc.refresh_token = Some("mar_refresh_token_value_0123456789".into());
|
||||||
|
store(&e, &oidc).unwrap();
|
||||||
|
assert_eq!(load(&e).unwrap(), Some(oidc));
|
||||||
|
clear(&e).unwrap();
|
||||||
|
assert_eq!(load(&e).unwrap(), None);
|
||||||
|
// Clearing an empty keychain is fine (logout twice, or never mirrored).
|
||||||
|
clear(&e).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_incomplete_or_oversized_sessions() {
|
||||||
|
let e = mock_entry();
|
||||||
|
let mut t = tokens();
|
||||||
|
t.access_token = String::new();
|
||||||
|
assert!(store(&e, &t).is_err());
|
||||||
|
let mut big = tokens();
|
||||||
|
big.access_token = "x".repeat(MAX_LEN);
|
||||||
|
assert!(store(&e, &big).unwrap_err().contains("too large"));
|
||||||
|
assert_eq!(load(&e).unwrap(), None, "nothing written on error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialized_shape_matches_the_web_client() {
|
||||||
|
let json = serde_json::to_value(tokens()).unwrap();
|
||||||
|
assert_eq!(json["userId"], "@alice:lotusguild.org");
|
||||||
|
assert_eq!(json["deviceId"], "ABCDEFGHIJ");
|
||||||
|
assert!(json.get("refreshToken").is_none());
|
||||||
|
let back: SessionTokens = serde_json::from_str(
|
||||||
|
r#"{"userId":"@a:b","deviceId":"D","accessToken":"t","refreshToken":"r"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(back.refresh_token.as_deref(), Some("r"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_realistic_oidc_session_fits() {
|
||||||
|
let e = mock_entry();
|
||||||
|
let t = SessionTokens {
|
||||||
|
user_id: format!("@{}:matrix.lotusguild.org", "a".repeat(60)),
|
||||||
|
device_id: "X".repeat(40),
|
||||||
|
access_token: "mat_".to_string() + &"A".repeat(200),
|
||||||
|
refresh_token: Some("mar_".to_string() + &"B".repeat(200)),
|
||||||
|
};
|
||||||
|
store(&e, &t).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u16>);
|
||||||
|
|
||||||
|
fn origin_of(uri: &str) -> Option<Origin> {
|
||||||
|
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<Origin>,
|
||||||
|
call: Option<Origin>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"security": {
|
"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'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user