Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 3e136d3729 fix: WebView permissions only for the app's own origin (#22)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 20:03:11 -04:00
6 changed files with 331 additions and 404 deletions
-136
View File
@@ -1,136 +0,0 @@
# cinny-desktop #19: smoke-test the Windows app on the `windows` runner.
#
# Installs the NSIS bundle silently, starts the installed app with WebView2's
# DevTools port open, and drives its real page with scripts/windows-smoke.mjs
# (playwright-core over CDP): boots to the login screen, local server address,
# microphone permission for the app and not for a foreign page, the call page's
# own origin + isolation, the Credential Manager round trip. Features a build
# doesn't have are reported "n/a".
#
# Run it from the Actions tab (workflow_dispatch):
# - no `ref`: tests the published nightly installer (a couple of minutes);
# - `ref` = a branch (e.g. a PR branch): builds that branch, then tests it.
name: Windows smoke
on:
workflow_dispatch:
inputs:
ref:
description: 'Branch to build and test (empty: test the published nightly)'
required: false
default: ''
env:
GITEA_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
jobs:
smoke:
runs-on: windows
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Download the published nightly installer
if: ${{ inputs.ref == '' }}
shell: powershell
run: |
New-Item -ItemType Directory -Force -Path smoke-installer | Out-Null
Invoke-WebRequest -Uri "$env:GITEA_URL/$env:REPO/releases/download/latest/LotusChat-x86_64-setup.exe" -OutFile smoke-installer\setup.exe
Get-Item smoke-installer\setup.exe | Select-Object Name, Length
# A second actions/checkout in one job trips over the host runner's
# action cache on Windows ("Access is denied" on its pack files), so
# fetch the branch into a worktree with plain git instead.
- name: Check out the branch to build
if: ${{ inputs.ref != '' }}
shell: powershell
run: |
git fetch --depth=1 origin "${{ inputs.ref }}"
git worktree add --force build-src FETCH_HEAD
git -C build-src log --oneline -1
- name: Build the branch
if: ${{ inputs.ref != '' }}
shell: powershell
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
NODE_OPTIONS: '--max_old_space_size=4096'
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
CARGO_HTTP_MULTIPLEXING: 'false'
CARGO_NET_RETRY: '5'
run: |
cd build-src
git submodule update --init --depth=1
cd cinny; npm ci; cd ..
node scripts/sync-web-config.mjs
npm ci
$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH"
$toolchain = Get-ChildItem "$env:USERPROFILE\.rustup\toolchains" -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match 'stable' } | Select-Object -First 1
if ($toolchain) { $env:PATH = "$($toolchain.FullName)\bin;$env:PATH" }
npm run tauri -- build --bundles nsis
New-Item -ItemType Directory -Force -Path ..\smoke-installer | Out-Null
$exe = Get-ChildItem src-tauri\target\release\bundle\nsis\*-setup.exe | Select-Object -First 1
Copy-Item $exe.FullName ..\smoke-installer\setup.exe
- name: Install silently
shell: powershell
run: |
Get-Process cinny -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Process smoke-installer\setup.exe -ArgumentList '/S' -Wait
$app = Join-Path $env:LOCALAPPDATA 'Lotus Chat\cinny.exe'
if (-not (Test-Path $app)) { Write-Error "not installed at $app"; exit 1 }
(Get-Item $app).VersionInfo | Select-Object ProductVersion, FileVersion
- name: Start the app and smoke test it
shell: powershell
run: |
New-Item -ItemType Directory -Force -Path smoke-deps | Out-Null
Push-Location smoke-deps
npm init -y | Out-Null
npm install --no-audit --no-fund playwright-core@1 | Out-Null
Pop-Location
Copy-Item scripts\windows-smoke.mjs smoke-deps\windows-smoke.mjs
# Same step as the test: a process started in an earlier step may be
# cleaned up when that step ends.
# The app sets its own WebView2 arguments, so WebView2's env var doesn't
# apply; the app opens the DevTools port itself when asked (#19).
$env:LOTUS_WEBVIEW2_DEBUG_PORT = '9222'
$app = Join-Path $env:LOCALAPPDATA 'Lotus Chat\cinny.exe'
Start-Process $app
Start-Sleep -Seconds 15
Write-Host "--- processes"
Get-Process cinny, msedgewebview2 -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId | Format-Table | Out-String | Write-Host
Write-Host "--- listening ports 9222/44548"
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalPort -in 9222, 44548 } | Select-Object LocalAddress, LocalPort, OwningProcess | Format-Table | Out-String | Write-Host
Write-Host "--- WebView2 runtime"
Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' -ErrorAction SilentlyContinue | Select-Object pv | Out-String | Write-Host
Write-Host "--- whoami: $(whoami)"
Write-Host "--- webview2 command lines"
Get-CimInstance Win32_Process -Filter "Name='msedgewebview2.exe'" | Select-Object -First 2 | ForEach-Object { Write-Host $_.CommandLine.Substring(0, [Math]::Min(600, $_.CommandLine.Length)) }
Write-Host "--- netstat"
netstat -ano | Select-String "LISTENING" | Select-String ":9222 |:44548 " | ForEach-Object { Write-Host $_ }
Write-Host "--- http probes"
try { (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:9222/json/version -TimeoutSec 5).Content | Write-Host } catch { Write-Host "9222: $($_.Exception.Message)" }
try { (Invoke-WebRequest -UseBasicParsing http://localhost:44548/ -TimeoutSec 5).StatusCode | Write-Host } catch { Write-Host "44548: $($_.Exception.Message)" }
node smoke-deps\windows-smoke.mjs smoke-out
- name: Stop the app
if: ${{ always() }}
shell: powershell
run: |
Get-Process cinny -ErrorAction SilentlyContinue | Stop-Process -Force
if (Test-Path smoke-out\results.json) { Get-Content smoke-out\results.json }
- name: Upload results and screenshots
if: ${{ always() }}
uses: actions/upload-artifact@v3
with:
name: windows-smoke
path: smoke-out
+1 -1
Submodule cinny updated: d6548c56fd...be8e49a2bb
+1 -12
View File
@@ -24,16 +24,5 @@
"basename": "/"
},
"gifApiKey": "",
"webAppUrl": "https://chat.lotusguild.org",
"statusPages": {
"matrix.lotusguild.org": {
"url": "https://isitup.lotusguild.org",
"slug": "matrix",
"groups": {
"homeserver": "Homeserver",
"calls": "Voice calls",
"login": "Login"
}
}
}
"webAppUrl": "https://chat.lotusguild.org"
}
-175
View File
@@ -1,175 +0,0 @@
// cinny-desktop #19: Windows smoke test against the INSTALLED app.
//
// The app is started with WebView2's DevTools port open
// (WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222) and this
// script drives its real page over CDP with playwright-core. No login: every
// check runs on the login screen or through the app's own Tauri commands.
//
// node scripts/windows-smoke.mjs <outDir>
//
// Checks whose feature isn't in the build under test are reported "n/a"
// 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 { chromium } from 'playwright-core';
const OUT = process.argv[2] || 'smoke-out';
mkdirSync(OUT, { recursive: true });
const APP = 'http://localhost:44548';
const results = [];
const record = (name, status, detail = '') => {
results.push({ name, status, detail });
console.log(`${status.toUpperCase().padEnd(4)} ${name}${detail ? ` — ${detail}` : ''}`);
};
const until = async (fn, ms, step = 500) => {
const end = Date.now() + ms;
for (;;) {
let v;
try {
v = await fn();
} catch {
v = undefined;
}
if (v || Date.now() > end) return v;
await new Promise((r) => setTimeout(r, step));
}
};
// 1. Connect to the running app's WebView2.
const browser = await until(() => chromium.connectOverCDP('http://127.0.0.1:9222'), 60_000, 1000);
if (!browser) {
record('connect to the app over CDP', 'fail', 'no DevTools endpoint on :9222');
writeFileSync(`${OUT}/results.json`, JSON.stringify(results, null, 2));
process.exit(1);
}
const page = await until(
() => browser.contexts().flatMap((c) => c.pages()).find((p) => p.url().startsWith(APP)),
60_000,
);
if (!page) {
record('app page found', 'fail', browser.contexts().flatMap((c) => c.pages()).map((p) => p.url()).join(', '));
process.exit(1);
}
record('app page found', 'pass', page.url());
// 2. Boots to the login screen.
const booted = await until(async () => /Login|Homeserver/.test(await page.locator('body').innerText()), 60_000);
record('boots to the login screen', booted ? 'pass' : 'fail');
await page.screenshot({ path: `${OUT}/01-login.png` });
// 3. The local server's address (cinny #43 binds 127.0.0.1 explicitly).
try {
const listen = execSync(
'powershell -NoProfile -Command "(Get-NetTCPConnection -LocalPort 44548 -State Listen).LocalAddress -join \',\'"',
)
.toString()
.trim();
record('local server listening', listen ? 'pass' : 'fail', listen);
} catch (e) {
record('local server listening', 'fail', String(e).slice(0, 120));
}
const cfg = await page.evaluate(() => fetch('/config.json').then((r) => r.json()));
// 4. Microphone for the app itself (WebView2 PermissionRequested handler, #22).
const appMic = 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}: ${e.message}`;
}
});
record('microphone allowed for the app', appMic === 'ok' ? 'pass' : 'fail', appMic);
// 5. Call page on its own origin, isolated from the app (cinny #43 / #27).
if (cfg.desktopCallOrigin) {
const src = `${cfg.desktopCallOrigin}/public/element-call/index.html`;
await page.evaluate((s) => {
const f = document.createElement('iframe');
f.id = 'smoke-call';
f.src = s;
f.allow = 'microphone; camera; display-capture; autoplay; clipboard-write;';
f.sandbox = 'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads';
document.body.appendChild(f);
}, src);
const frame = await until(() => page.frames().find((f) => f.url().startsWith(src)), 30_000);
if (!frame) {
record('call page loads from its own origin', 'fail', 'frame did not load (CSP?)');
} else {
await frame.waitForLoadState('domcontentloaded').catch(() => undefined);
const iso = await frame.evaluate(async () => {
const r = { origin: location.origin };
try {
r.parentStorage = String(parent.localStorage.length);
} catch (e) {
r.parentStorage = e.name;
}
try {
const s = await navigator.mediaDevices.getUserMedia({ audio: true });
s.getTracks().forEach((t) => t.stop());
r.mic = 'ok';
} catch (e) {
r.mic = e.name;
}
return r;
});
record('call page loads from its own origin', 'pass', iso.origin);
record('call page cannot read the app storage', iso.parentStorage === 'SecurityError' ? 'pass' : 'fail', iso.parentStorage);
record('call page gets the microphone', iso.mic === 'ok' ? 'pass' : 'fail', iso.mic);
}
} else {
record('call page on its own origin', 'n/a', 'desktopCallOrigin not set in this build');
}
// 6. OS keychain round trip (cinny #105, step 1).
const kc = await page.evaluate(async () => {
const inv = window.__TAURI_INTERNALS__?.invoke;
if (!inv) return { error: 'no Tauri bridge' };
try {
const supported = await inv('secure_session_supported');
if (!supported) return { supported };
const tokens = { userId: '@smoke:ci.invalid', deviceId: 'SMOKE', accessToken: `smoke-${Date.now()}` };
const before = await inv('secure_session_get');
await inv('secure_session_set', { tokens });
const back = await inv('secure_session_get');
// Put back whatever was there (nothing, on a clean runner).
if (before) await inv('secure_session_set', { tokens: before });
else await inv('secure_session_clear');
const after = await inv('secure_session_get');
return { supported, roundTrip: back?.accessToken === tokens.accessToken, restored: JSON.stringify(after) === JSON.stringify(before ?? null) };
} catch (e) {
return { error: String(e).slice(0, 160) };
}
});
if (kc.error && /not found/i.test(kc.error)) record('keychain round trip', 'n/a', 'commands not in this build');
else if (kc.error) record('keychain round trip', 'fail', kc.error);
else if (!kc.supported) record('keychain round trip', 'fail', 'secure_session_supported = false on Windows');
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;
}
});
record(
'microphone refused to a foreign page',
foreignMic === 'NotAllowedError' ? 'pass' : 'info',
`${foreignMic}${foreignMic === 'ok' ? ' (build without the #22 origin check)' : ''}`,
);
await page.goto(APP).catch(() => undefined);
await page.screenshot({ path: `${OUT}/02-end.png` }).catch(() => undefined);
writeFileSync(`${OUT}/results.json`, JSON.stringify(results, null, 2));
await browser.close().catch(() => undefined);
const failed = results.filter((r) => r.status === 'fail');
console.log(`\n${results.filter((r) => r.status === 'pass').length} passed, ${failed.length} failed`);
process.exit(failed.length ? 1 : 0);
+108 -80
View File
@@ -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.
@@ -1128,9 +1130,9 @@ pub fn run() {
// Tauri's default WebView2 args (setting this overrides them) and
// appends the Chromium background-throttling disables. Windows-only
// in effect; harmless elsewhere. Does not block system sleep.
.additional_browser_args(&webview2_browser_args(
std::env::var("LOTUS_WEBVIEW2_DEBUG_PORT").ok().as_deref(),
))
.additional_browser_args(
"--disable-features=msWebOOUI,msPdfOOUI --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows",
)
.on_page_load(move |window, payload| {
if matches!(payload.event(), PageLoadEvent::Finished) {
// Reveal only on the FIRST settle: later page loads (e.g. a
@@ -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::<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
});
})?;
@@ -1289,44 +1355,6 @@ pub fn run() {
.expect("error while building tauri application");
}
/// WebView2 browser arguments. Setting them replaces Tauri's defaults, so
/// they're kept, plus the Chromium background-throttling disables (P5-42).
///
/// cinny-desktop #19: `LOTUS_WEBVIEW2_DEBUG_PORT=<port>` in the environment
/// opens WebView2's DevTools port on localhost so the Windows smoke test can
/// drive the installed app. WebView2's own `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`
/// doesn't apply because the app sets its arguments explicitly. Off unless the
/// variable holds a valid port; only whoever launches the app can set it.
pub(crate) fn webview2_browser_args(debug_port: Option<&str>) -> String {
let mut args = String::from(
"--disable-features=msWebOOUI,msPdfOOUI --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows",
);
if let Some(port) = debug_port
.and_then(|p| p.trim().parse::<u16>().ok())
.filter(|p| *p >= 1024)
{
eprintln!("webview: DevTools port {port} open (LOTUS_WEBVIEW2_DEBUG_PORT)");
args.push_str(&format!(" --remote-debugging-port={port}"));
}
args
}
#[cfg(test)]
mod webview2_args_tests {
use super::webview2_browser_args;
#[test]
fn debug_port_only_when_asked_for_and_valid() {
let base = webview2_browser_args(None);
assert!(base.contains("--disable-renderer-backgrounding"));
assert!(!base.contains("remote-debugging"));
assert!(webview2_browser_args(Some("9222")).ends_with(" --remote-debugging-port=9222"));
for bad in ["", "abc", "80", "70000", "9222 --evil", "-1"] {
assert_eq!(webview2_browser_args(Some(bad)), base, "{bad}");
}
}
}
#[cfg(test)]
mod tray_tests {
use super::*;
+221
View File
@@ -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<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.
#[derive(Clone, Debug)]
pub(crate) struct AppOrigins(Vec<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(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
);
}
}
}