Compare commits

..
Author SHA1 Message Date
Lotus CI 151199459f chore: bump cinny submodule to d1993f20 (nightly catch-up)
Build Lotus Chat Desktop / prepare (push) Successful in 2s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m39s
Build Lotus Chat Desktop / build-arch (push) Successful in 15s
Build Lotus Chat Desktop / build-windows (push) Successful in 25m41s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-10-01 04:01:02 +00:00
jared ec643c677a Merge pull request 'Login tokens in the OS keychain: secure_session commands (cinny #105, step 1)' (#28) from desktop-keychain into main
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 25m18s
Build Lotus Chat Desktop / build-arch (push) Successful in 17s
Build Lotus Chat Desktop / build-windows (push) Successful in 30m3s
Build Lotus Chat Desktop / update-manifest (push) Successful in 2s
Merge pull request #28: login tokens in the OS keychain (cinny #105, step 1)
2026-09-30 20:19:01 -04:00
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 CIandClaude Opus 5.5 12ad4bf681 windows smoke: fake capture devices in the opt-in test mode
The CI VM has no microphone (getUserMedia → NotFoundError). With
LOTUS_WEBVIEW2_DEBUG_PORT set the app also passes
--use-fake-device-for-media-stream; permission requests still go through
the real PermissionRequested handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 10:32:45 -04:00
Lotus CIandClaude Opus 5.5 e2e43aa08c ci(windows-smoke): fetch the branch to build into a worktree
A second actions/checkout in the same job fails on the Windows host runner
(Access is denied on the cached action's pack file).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 10:07:39 -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 9f0f782bbb windows smoke: opt-in WebView2 DevTools port via LOTUS_WEBVIEW2_DEBUG_PORT (#19)
WebView2's WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS is ignored because the app
sets its browser arguments explicitly (seen on the runner: the WebView2
command line had only the app's arguments). The app now appends
--remote-debugging-port only when LOTUS_WEBVIEW2_DEBUG_PORT holds a valid
port (>= 1024); otherwise the arguments are exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 13:19:21 -04:00
Lotus CIandClaude Opus 5.5 90c007a95e ci(windows-smoke): log WebView2 args, netstat and port probes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 13:14:13 -04:00
Lotus CIandClaude Opus 5.5 a754bb0a93 ci(windows-smoke): start the app in the test step; log processes/ports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 13:09:26 -04:00
Lotus CIandClaude Opus 5.5 0d86f19935 ci: Windows smoke test on the windows runner (#19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 13:04:41 -04:00
jared ea69cd770e Merge pull request 'Status banner from Kuma: statusPages config (cinny #124)' (#29) from desktop-status-banner into main
Build Lotus Chat Desktop / prepare (push) Successful in 2s
Build Lotus Chat Desktop / build-windows (push) Successful in 27m58s
Build Lotus Chat Desktop / build-linux (push) Successful in 30m51s
Build Lotus Chat Desktop / build-arch (push) Successful in 15s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
Merge pull request #29: status banner config (cinny #124)
2026-09-29 12:29:01 -04:00
Lotus CIandClaude Opus 5.5 3cd429d45c config: homeserver status banner from Kuma (cinny #124)
Adds the same `statusPages` entry the web app uses, so the desktop app
shows the status banner for matrix.lotusguild.org too (Kuma status page
https://isitup.lotusguild.org/status/matrix). The desktop CSP already
allows https: connections; no other change is needed. Inert until the
bundled cinny includes the banner (cinny PR #255).

Tested on a Linux release build: with a local fake Kuma reporting calls
down, the desktop app shows "Voice calls are down right now. Messages
still work." and polls both endpoints; from inside the desktop app the
real Kuma page answers 200 on both (CSP and CORS allow it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 12:27:46 -04:00
Lotus CI 32a71ebb73 chore: bump cinny submodule to d6548c56
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-windows (push) Successful in 29m12s
Build Lotus Chat Desktop / build-linux (push) Successful in 32m3s
Build Lotus Chat Desktop / build-arch (push) Successful in 31s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-09-29 13:38:27 +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 CI 884877a55b chore: bump cinny submodule to 91f82d60 (nightly catch-up)
Build Lotus Chat Desktop / prepare (push) Successful in 10s
Build Lotus Chat Desktop / build-linux (push) Successful in 26m49s
Build Lotus Chat Desktop / build-arch (push) Successful in 15s
Build Lotus Chat Desktop / build-windows (push) Successful in 36m5s
Build Lotus Chat Desktop / update-manifest (push) Successful in 5s
2026-09-29 04:01:40 +00:00
Lotus CI bf5b6d7b45 chore: bump cinny submodule to 899e160a
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 31m52s
Build Lotus Chat Desktop / build-windows (push) Successful in 34m30s
Build Lotus Chat Desktop / build-arch (push) Successful in 14s
Build Lotus Chat Desktop / update-manifest (push) Successful in 9s
2026-09-29 02:18:04 +00: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
Lotus CI 29f83df53c chore: bump cinny submodule to c0c93213
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 26m6s
Build Lotus Chat Desktop / build-arch (push) Successful in 44s
Build Lotus Chat Desktop / build-windows (push) Successful in 30m4s
Build Lotus Chat Desktop / update-manifest (push) Successful in 2s
2026-09-28 23:55:43 +00:00
Lotus CI 11e102f7da chore: bump cinny submodule to be8e49a2
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 25m9s
Build Lotus Chat Desktop / build-arch (push) Successful in 12s
Build Lotus Chat Desktop / build-windows (push) Successful in 26m53s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-09-28 03:30:38 +00:00
jared 8ee90444ee Merge pull request #24: NVIDIA + Wayland keeps the GPU renderer
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m39s
Build Lotus Chat Desktop / build-arch (push) Successful in 13s
Build Lotus Chat Desktop / build-windows (push) Successful in 25m24s
Build Lotus Chat Desktop / update-manifest (push) Successful in 2s
2026-09-27 22:26:51 -04:00
10 changed files with 1065 additions and 46 deletions
+136
View File
@@ -0,0 +1,136 @@
# 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: e2b23397bd...d1993f2084
+13 -1
View File
@@ -24,5 +24,17 @@
"basename": "/" "basename": "/"
}, },
"gifApiKey": "", "gifApiKey": "",
"webAppUrl": "https://chat.lotusguild.org" "webAppUrl": "https://chat.lotusguild.org",
"desktopCallOrigin": "http://127.0.0.1:44548",
"statusPages": {
"matrix.lotusguild.org": {
"url": "https://isitup.lotusguild.org",
"slug": "matrix",
"groups": {
"homeserver": "Homeserver",
"calls": "Voice calls",
"login": "Login"
}
}
}
} }
+193
View File
@@ -0,0 +1,193 @@
// 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 { createServer } from 'node:http';
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).
// Served locally on another port (a different origin, still a secure context),
// so the check doesn't depend on the runner reaching the internet.
const foreign = createServer((_, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<!doctype html><title>foreign</title>foreign page');
});
await new Promise((r) => foreign.listen(9333, '127.0.0.1', r));
const FOREIGN = 'http://localhost:9333/';
await page.goto(FOREIGN).catch(() => undefined);
if (!page.url().startsWith(FOREIGN)) {
record('microphone refused to a foreign page', 'fail', `navigation did not happen (at ${page.url()})`);
} else {
const foreignMic = await page.evaluate(async () => {
try {
const s = await navigator.mediaDevices.getUserMedia({ audio: true });
s.getTracks().forEach((t) => t.stop());
return 'ok';
} catch (e) {
return e.name;
}
});
const guarded = cfg.desktopCallOrigin !== undefined; // builds with #22 also carry #43
let status = 'info';
if (foreignMic === 'NotAllowedError') status = 'pass';
else if (guarded) status = 'fail';
record(
'microphone refused to a foreign page',
status,
`${foreignMic} at ${page.url()}${status === 'info' ? ' (build without the #22 origin check)' : ''}`,
);
}
foreign.close();
await page.goto(APP).catch(() => undefined);
await page.screenshot({ path: `${OUT}/02-end.png` }).catch(() => undefined);
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);
+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 = [
+249 -43
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
@@ -1128,9 +1196,9 @@ pub fn run() {
// Tauri's default WebView2 args (setting this overrides them) and // Tauri's default WebView2 args (setting this overrides them) and
// appends the Chromium background-throttling disables. Windows-only // appends the Chromium background-throttling disables. Windows-only
// in effect; harmless elsewhere. Does not block system sleep. // in effect; harmless elsewhere. Does not block system sleep.
.additional_browser_args( .additional_browser_args(&webview2_browser_args(
"--disable-features=msWebOOUI,msPdfOOUI --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows", std::env::var("LOTUS_WEBVIEW2_DEBUG_PORT").ok().as_deref(),
) ))
.on_page_load(move |window, payload| { .on_page_load(move |window, payload| {
if matches!(payload.event(), PageLoadEvent::Finished) { if matches!(payload.event(), PageLoadEvent::Finished) {
// Reveal only on the FIRST settle: later page loads (e.g. a // Reveal only on the FIRST settle: later page loads (e.g. a
@@ -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
}); });
})?; })?;
@@ -1289,6 +1421,80 @@ pub fn run() {
.expect("error while building tauri application"); .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, and gives it Chromium's fake capture devices (the
/// CI VM has no microphone; permission requests still go through the app's
/// real PermissionRequested handler). 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} --use-fake-device-for-media-stream"
));
}
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 --use-fake-device-for-media-stream"));
for bad in ["", "abc", "80", "70000", "9222 --evil", "-1"] {
assert_eq!(webview2_browser_args(Some(bad)), base, "{bad}");
}
}
}
#[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'"
} }
} }
} }