Compare commits

...
Author SHA1 Message Date
Lotus CI 28dee7bea0 Merge remote-tracking branch 'origin/main' into desktop-keychain
# Conflicts:
#	src-tauri/src/lib.rs
2026-09-30 20:18:53 -04:00
jared 03e3937e21 Merge pull request 'Call page on its own origin, http://127.0.0.1:44548 (cinny #43)' (#27) from desktop-call-origin into main
Build Lotus Chat Desktop / prepare (push) Canceled after 0s
Build Lotus Chat Desktop / build-windows (push) Canceled after 0s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s
Merge pull request #27: call page on its own origin, http://127.0.0.1:44548 (cinny #43)
2026-09-30 20:17:24 -04:00
jared 97ccc97a70 Merge pull request 'WebView permissions only for the app's own origin (#22)' (#26) from webview-permission-origin into main
Build Lotus Chat Desktop / prepare (push) Canceled after 0s
Build Lotus Chat Desktop / build-windows (push) Canceled after 0s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s
Merge pull request #26: WebView permissions only for the app's own origin (#22)
2026-09-30 20:17:19 -04:00
Lotus CI 86947dba50 chore: bump cinny submodule to 8e30c73e
Build Lotus Chat Desktop / prepare (push) Canceled after 0s
Build Lotus Chat Desktop / build-windows (push) Canceled after 0s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s
2026-10-01 00:11:54 +00:00
jared 4e98503c49 Merge pull request 'Updater: Linux package installs update through their package manager' (#32) from fix-linux-pkg-update into main
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m33s
Build Lotus Chat Desktop / build-arch (push) Successful in 10s
Build Lotus Chat Desktop / build-windows (push) Successful in 30m18s
Build Lotus Chat Desktop / update-manifest (push) Successful in 2s
Merge pull request #32: Linux package installs update via their package manager
2026-09-30 19:00:55 -04:00
Lotus CIandClaude Opus 5.5 261d7852cb fix(updater): package installs update through their package manager
On CachyOS the in-app update failed with "Permission denied (os error 13)
at path /usr/bin/tauri_current_app…": the app was installed from the
Arch package, and Tauri's Linux updater can only replace an AppImage — for
anything else it tries to write next to the binary in /usr/bin.

- update_install_kind command: "in-app" on Windows and for an AppImage
  ($APPIMAGE set); on Linux package installs "pacman" (ID/ID_LIKE arch:
  Arch, CachyOS, Manjaro, EndeavourOS…), "deb" (debian/ubuntu and
  derivatives) or "manual". The web UI shows the matching update command.
- install_update refuses up front on a package install
  ("install: package-managed (…)") instead of downloading the whole
  update and failing at the last step.

Tests: CachyOS/Arch → pacman; Ubuntu/Debian/Mint → deb; Fedora/unknown →
manual; AppImage and Windows → in-app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 18:46:11 -04:00
jared 06fe43c3cd Merge pull request 'Windows smoke: foreign-page mic check on a local origin (#19)' (#31) from smoke-foreign-origin into main
Build Lotus Chat Desktop / prepare (push) Successful in 2s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m28s
Build Lotus Chat Desktop / build-arch (push) Successful in 14s
Build Lotus Chat Desktop / build-windows (push) Successful in 26m6s
Build Lotus Chat Desktop / update-manifest (push) Successful in 6s
Merge pull request #31: smoke foreign-page check (#19)
2026-09-30 12:46:31 -04:00
Lotus CIandClaude Opus 5.5 bcbc5ecdfb windows smoke: foreign-page mic check on a local origin, navigation verified
The example.com check could pass vacuously if the runner can't reach the
internet (goto failed silently, the mic request then came from the app's
own page). Serve a page on http://localhost:9333 instead, confirm the
navigation happened, and fail on builds that should refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 12:46:30 -04:00
Lotus CI bd32048461 Merge remote-tracking branch 'origin/main' into desktop-keychain 2026-09-30 11:00:19 -04:00
Lotus CI 63a8ebe103 Merge remote-tracking branch 'origin/webview-permission-origin' into desktop-call-origin
# Conflicts:
#	config.json
2026-09-30 11:00:17 -04:00
Lotus CI eeb838c00c Merge remote-tracking branch 'origin/main' into webview-permission-origin 2026-09-30 10:59:51 -04:00
jared 61ab464b45 Merge pull request 'CI: Windows smoke test of the installed app (#19)' (#30) from ci-windows-smoke into main
Build Lotus Chat Desktop / prepare (push) Successful in 36s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m53s
Build Lotus Chat Desktop / build-windows (push) Successful in 25m1s
Build Lotus Chat Desktop / build-arch (push) Successful in 38s
Build Lotus Chat Desktop / update-manifest (push) Successful in 6s
Merge pull request #30: Windows smoke test (#19)
2026-09-30 10:59:37 -04:00
Lotus CI 2be36d14a9 chore: bump cinny submodule to 747400ea (nightly catch-up)
Build Lotus Chat Desktop / prepare (push) Successful in 2s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m13s
Build Lotus Chat Desktop / build-arch (push) Successful in 15s
Build Lotus Chat Desktop / build-windows (push) Successful in 24m37s
Build Lotus Chat Desktop / update-manifest (push) Successful in 2s
2026-09-30 04:01:05 +00:00
Lotus CIandClaude Opus 5.5 032b6e04e7 feat: secure_session commands, login tokens in the OS keychain (cinny #105, step 1)
Commands for the web client to keep a copy of the login tokens in the OS
keychain: secure_session_supported / _set / _get / _clear.

- Windows: Credential Manager via the keyring crate (3.6, windows-native),
  entry "session" in service "Lotus Chat". Only the secrets are stored
  (userId, deviceId, accessToken, refreshToken); the serialized value is
  capped at 1200 chars (Windows' limit is 2560 bytes).
- Other platforms: supported = false and the other commands answer "not
  supported on this platform" (Linux Secret Service can prompt to unlock a
  wallet at startup; that needs its own testing). No new Linux dependency:
  without a platform feature the crate only has its mock store.
- Keychain calls run on the blocking pool, off the main thread.

Step 1 is a mirror only (the web client still reads its session from
localStorage); see the cinny PR.

Tests: round trip + clear, clearing an empty keychain, incomplete and
oversized sessions rejected with nothing written, the JSON shape the web
client sends, a realistic OIDC session fits (keyring's mock store). Linux
release build: commands answer as designed and login is unaffected.
Windows: type-checked only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 00:25:01 -04:00
Lotus CIandClaude Opus 5.5 5c3ac68328 feat: call page on its own origin, http://127.0.0.1:44548 (cinny #43)
The bundled Element Call page ran on the app's own origin
(http://localhost:44548), so the call frame could read the app's storage
(login token) and DOM. Serve it from http://127.0.0.1:44548 instead: the
same local server and bundle, a different origin.

- The local server binds 127.0.0.1 explicitly. The app is still loaded as
  http://localhost:44548 (its storage stays where it is; the engines try
  127.0.0.1 for `localhost`). Binding the name `localhost` could pick ::1
  only (Windows lists it first), and then 127.0.0.1 wouldn't answer.
- config.json: desktopCallOrigin = http://127.0.0.1:44548. cinny loads the
  call page from there only when this is set (cinny #43 PR).
- CSP frame-src allows http://127.0.0.1:44548.
- Permissions (on top of #22): the call page's origin gets microphone/
  camera/screen only; nothing else.
- The call page gets no IPC: the capability only matches
  http://localhost:44548.

Tested (Linux release build): the server listens on 127.0.0.1:44548 and
the app loads as http://localhost:44548; the call page loads from
127.0.0.1 inside the app under its CSP; from that frame parent.localStorage
and parent.document are SecurityError, while a same-origin frame (the old
setup) reads the app's storage. The call itself was tested in a simulated
desktop (Chromium, the WebView2 engine) against a local Synapse + LiveKit;
see the cinny PR. Rust tests 17 passed; Windows code type-checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 00:09:41 -04:00
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
9 changed files with 709 additions and 56 deletions
+1 -1
Submodule cinny updated: d6548c56fd...8e30c73e2f
+1
View File
@@ -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
View File
@@ -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);
+13
View File
@@ -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"
+5
View File
@@ -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
View File
@@ -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::*;
+181
View File
@@ -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();
}
}
+273
View File
@@ -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
);
}
}
}
+1 -1
View File
@@ -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'"
} }
} }
} }