Compare commits

..
Author SHA1 Message Date
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
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 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 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
Lotus CIandClaude Opus 5.5 bb5364b53c fix(linux): NVIDIA + Wayland keeps the GPU renderer (explicit-sync fix)
The 4.12.343 workaround (WEBKIT_DISABLE_DMABUF_RENDERER=1 + GDK_BACKEND=x11)
made the app launch but pushed every frame through shared memory under
XWayland: noticeably laggy at 3840x2058. The reporter's WAYLAND_DEBUG trace
showed the real cause on native Wayland:

  wl_display#1.error(wp_linux_drm_syncobj_surface_v1#51, 4,
    "explicit sync is used, but no acquire point is set")
  Gdk-Message: Error 71 (Protocol error) dispatching to Wayland display.

An explicit-sync bug, not a GBM format/modifier one. Their test matrix
(RTX 3070, driver 615.71.09, webkit2gtk 2.52.6, KDE Wayland):
- __NV_DISABLE_EXPLICIT_SYNC=1 alone: clean, GPU (DMA-BUF) renderer, smooth;
- WEBKIT_DISABLE_DMABUF_RENDERER=1 alone on Wayland: clean (no X11 needed);
- WEBKIT_DMABUF_RENDERER_DISABLE_GBM=1: same explicit-sync error on Wayland,
  "Failed to import DMABuf" under XWayland;
- X11/XWayland with the DMA-BUF renderer: "Failed to create GBM buffer".

New defaults when the NVIDIA driver is loaded:
- native Wayland: __NV_DISABLE_EXPLICIT_SYNC=1, GPU renderer kept;
- X11 session or user-forced GDK_BACKEND=x11: WEBKIT_DISABLE_DMABUF_RENDERER=1;
- never force X11 any more;
- LOTUS_GPU_SAFE_MODE=1: opt-in shared-memory rendering on Wayland too;
- LOTUS_NO_GPU_WORKAROUNDS=1 / user-set values: untouched.
One stderr line says what was set. 9 unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 22:22:00 -04:00
jared 5b4528e4e5 Merge pull request #23: launch on NVIDIA + Wayland; webAppUrl
Build Lotus Chat Desktop / prepare (push) Successful in 2s
Build Lotus Chat Desktop / build-windows (push) Successful in 31m44s
Build Lotus Chat Desktop / build-linux (push) Successful in 27m16s
Build Lotus Chat Desktop / build-arch (push) Successful in 13s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-09-27 17:10:51 -04:00
6 changed files with 488 additions and 54 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...747400ea25
+12 -1
View File
@@ -24,5 +24,16 @@
"basename": "/"
},
"gifApiKey": "",
"webAppUrl": "https://chat.lotusguild.org"
"webAppUrl": "https://chat.lotusguild.org",
"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);
+99 -49
View File
@@ -1,17 +1,22 @@
//! WebKitGTK workarounds for NVIDIA's proprietary driver on Linux.
//!
//! On NVIDIA + Wayland (reported on CachyOS/KDE, driver 615, webkit2gtk 2.52)
//! the app never shows a window: GDK dies with "Error 71 (Protocol error)
//! dispatching to Wayland display", and under XWayland WebKit's DMA-BUF
//! renderer then fails with "Failed to create GBM buffer … Invalid argument".
//! `WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=x11` makes it run normally.
//! Diagnosed on CachyOS/KDE Wayland, RTX 3070, driver 615.71.09, webkit2gtk
//! 2.52.6 (reported 2026-09-27, WAYLAND_DEBUG trace in the PR):
//! - Native Wayland: the compositor kills the connection with
//! `wp_linux_drm_syncobj_surface_v1 … "explicit sync is used, but no acquire
//! point is set"` (GDK: "Error 71 (Protocol error)"), i.e. an explicit-sync
//! bug between WebKit and the driver, not a buffer-format problem.
//! `__NV_DISABLE_EXPLICIT_SYNC=1` fixes it and keeps WebKit's GPU (DMA-BUF)
//! renderer, so that is the default on native Wayland.
//! - X11 / XWayland: the DMA-BUF renderer can't allocate or import buffers
//! ("Failed to create GBM buffer … Invalid argument", "Failed to import
//! DMABuf"), so there it is disabled (`WEBKIT_DISABLE_DMABUF_RENDERER=1`,
//! shared-memory frames). We never force X11 any more.
//!
//! So, before GTK/WebKit initialise, and only when the NVIDIA driver is loaded:
//! - `WEBKIT_DISABLE_DMABUF_RENDERER=1`;
//! - on a Wayland session with XWayland available, `GDK_BACKEND=x11`.
//!
//! Anything the user already set wins, and `LOTUS_NO_GPU_WORKAROUNDS=1`
//! disables all of it (e.g. once a newer driver/WebKit fixes this).
//! Opt-ins / opt-outs (anything the user already set always wins):
//! - `LOTUS_GPU_SAFE_MODE=1`: last resort, shared-memory rendering on Wayland
//! too (slower, especially at high resolutions).
//! - `LOTUS_NO_GPU_WORKAROUNDS=1`: change nothing.
/// Environment variables to set: pure, for tests.
pub(crate) fn decide(
@@ -22,15 +27,30 @@ pub(crate) fn decide(
if !nvidia || set("LOTUS_NO_GPU_WORKAROUNDS") {
return Vec::new();
}
let mut out = Vec::new();
if !set("WEBKIT_DISABLE_DMABUF_RENDERER") {
out.push(("WEBKIT_DISABLE_DMABUF_RENDERER", "1"));
}
let wayland = set("WAYLAND_DISPLAY")
let wayland_session = set("WAYLAND_DISPLAY")
|| get("XDG_SESSION_TYPE").is_some_and(|v| v.eq_ignore_ascii_case("wayland"));
// Only fall back to X11 when there is an X server (XWayland) to talk to.
if wayland && set("DISPLAY") && !set("GDK_BACKEND") {
out.push(("GDK_BACKEND", "x11"));
// GTK uses Wayland unless GDK_BACKEND says otherwise (it may list
// several, e.g. "wayland,x11": the first one wins).
let native_wayland = wayland_session
&& get("GDK_BACKEND").map_or(true, |v| {
v.is_empty()
|| v.trim_start().to_ascii_lowercase().starts_with("wayland")
|| v.trim() == "*"
});
let mut out = Vec::new();
let mut want = |k: &'static str, v: &'static str| {
if !set(k) {
out.push((k, v));
}
};
if native_wayland {
want("__NV_DISABLE_EXPLICIT_SYNC", "1");
if set("LOTUS_GPU_SAFE_MODE") {
want("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
} else {
want("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
out
}
@@ -44,9 +64,20 @@ fn nvidia_driver_loaded() -> bool {
/// Call first thing in `main`, before anything starts GTK or spawns threads.
pub fn apply() {
#[cfg(target_os = "linux")]
for (key, value) in decide(nvidia_driver_loaded(), |k| std::env::var(k).ok()) {
eprintln!("gpu-workarounds: NVIDIA driver detected, setting {key}={value} (LOTUS_NO_GPU_WORKAROUNDS=1 to disable)");
std::env::set_var(key, value);
{
let changes = decide(nvidia_driver_loaded(), |k| std::env::var(k).ok());
if changes.is_empty() {
return;
}
let list: Vec<String> = changes.iter().map(|(k, v)| format!("{k}={v}")).collect();
eprintln!(
"gpu-workarounds: NVIDIA driver detected, setting {} \
(LOTUS_GPU_SAFE_MODE=1 for shared-memory rendering, LOTUS_NO_GPU_WORKAROUNDS=1 to disable)",
list.join(" ")
);
for (key, value) in changes {
std::env::set_var(key, value);
}
}
}
@@ -71,51 +102,70 @@ mod tests {
}
#[test]
fn nvidia_wayland_gets_both() {
fn nvidia_wayland_keeps_gpu_renderer_and_disables_explicit_sync() {
assert_eq!(
run(true, WAYLAND),
vec![
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
("GDK_BACKEND", "x11")
]
vec![("__NV_DISABLE_EXPLICIT_SYNC", "1")]
);
}
#[test]
fn nvidia_x11_session_only_disables_dmabuf() {
assert_eq!(
run(true, &[("DISPLAY", ":0")]),
vec![("WEBKIT_DISABLE_DMABUF_RENDERER", "1")]
);
}
#[test]
fn wayland_without_xwayland_keeps_wayland() {
assert_eq!(
run(true, &[("WAYLAND_DISPLAY", "wayland-0")]),
vec![("WEBKIT_DISABLE_DMABUF_RENDERER", "1")]
);
fn never_forces_x11() {
for env in [
WAYLAND,
&[("DISPLAY", ":0")][..],
&[("WAYLAND_DISPLAY", "w")][..],
] {
assert!(run(true, env).iter().all(|(k, _)| *k != "GDK_BACKEND"));
}
}
#[test]
fn xdg_session_type_counts_as_wayland() {
assert_eq!(
run(true, &[("XDG_SESSION_TYPE", "wayland"), ("DISPLAY", ":1")]),
run(true, &[("XDG_SESSION_TYPE", "wayland")]),
vec![("__NV_DISABLE_EXPLICIT_SYNC", "1")]
);
}
#[test]
fn nvidia_x11_session_disables_dmabuf_renderer() {
assert_eq!(
run(true, &[("DISPLAY", ":0"), ("XDG_SESSION_TYPE", "x11")]),
vec![("WEBKIT_DISABLE_DMABUF_RENDERER", "1")]
);
}
#[test]
fn user_forced_x11_on_wayland_counts_as_x11() {
let mut env = WAYLAND.to_vec();
env.push(("GDK_BACKEND", "x11"));
assert_eq!(
run(true, &env),
vec![("WEBKIT_DISABLE_DMABUF_RENDERER", "1")]
);
let mut env = WAYLAND.to_vec();
env.push(("GDK_BACKEND", "wayland,x11"));
assert_eq!(run(true, &env), vec![("__NV_DISABLE_EXPLICIT_SYNC", "1")]);
}
#[test]
fn safe_mode_adds_shared_memory_rendering_on_wayland() {
let mut env = WAYLAND.to_vec();
env.push(("LOTUS_GPU_SAFE_MODE", "1"));
assert_eq!(
run(true, &env),
vec![
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
("GDK_BACKEND", "x11")
("__NV_DISABLE_EXPLICIT_SYNC", "1"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
]
);
}
#[test]
fn user_settings_win() {
let env = [
("WAYLAND_DISPLAY", "wayland-0"),
("DISPLAY", ":0"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "0"),
("GDK_BACKEND", "wayland"),
];
let mut env = WAYLAND.to_vec();
env.push(("__NV_DISABLE_EXPLICIT_SYNC", "0"));
assert!(run(true, &env).is_empty());
}
+47 -3
View File
@@ -1128,9 +1128,9 @@ pub fn run() {
// Tauri's default WebView2 args (setting this overrides them) and
// appends the Chromium background-throttling disables. Windows-only
// in effect; harmless elsewhere. Does not block system sleep.
.additional_browser_args(
"--disable-features=msWebOOUI,msPdfOOUI --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows",
)
.additional_browser_args(&webview2_browser_args(
std::env::var("LOTUS_WEBVIEW2_DEBUG_PORT").ok().as_deref(),
))
.on_page_load(move |window, payload| {
if matches!(payload.event(), PageLoadEvent::Finished) {
// Reveal only on the FIRST settle: later page loads (e.g. a
@@ -1289,6 +1289,50 @@ pub fn run() {
.expect("error while building tauri application");
}
/// WebView2 browser arguments. Setting them replaces Tauri's defaults, so
/// they're kept, plus the Chromium background-throttling disables (P5-42).
///
/// cinny-desktop #19: `LOTUS_WEBVIEW2_DEBUG_PORT=<port>` in the environment
/// opens WebView2's DevTools port on localhost so the Windows smoke test can
/// drive the installed app, 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 tray_tests {
use super::*;