Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
261d7852cb | ||
|
|
06fe43c3cd | ||
|
|
bcbc5ecdfb | ||
|
|
61ab464b45 | ||
|
|
12ad4bf681 | ||
|
|
e2e43aa08c | ||
|
|
2be36d14a9 | ||
|
|
9f0f782bbb | ||
|
|
90c007a95e | ||
|
|
a754bb0a93 | ||
|
|
0d86f19935 | ||
|
|
ea69cd770e | ||
|
|
3cd429d45c | ||
|
|
32a71ebb73 | ||
|
|
884877a55b | ||
|
|
bf5b6d7b45 | ||
|
|
29f83df53c | ||
|
|
11e102f7da | ||
|
|
8ee90444ee | ||
|
|
bb5364b53c | ||
|
|
5b4528e4e5 |
@@ -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
@@ -24,5 +24,16 @@
|
|||||||
"basename": "/"
|
"basename": "/"
|
||||||
},
|
},
|
||||||
"gifApiKey": "",
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
//! WebKitGTK workarounds for NVIDIA's proprietary driver on Linux.
|
//! WebKitGTK workarounds for NVIDIA's proprietary driver on Linux.
|
||||||
//!
|
//!
|
||||||
//! On NVIDIA + Wayland (reported on CachyOS/KDE, driver 615, webkit2gtk 2.52)
|
//! Diagnosed on CachyOS/KDE Wayland, RTX 3070, driver 615.71.09, webkit2gtk
|
||||||
//! the app never shows a window: GDK dies with "Error 71 (Protocol error)
|
//! 2.52.6 (reported 2026-09-27, WAYLAND_DEBUG trace in the PR):
|
||||||
//! dispatching to Wayland display", and under XWayland WebKit's DMA-BUF
|
//! - Native Wayland: the compositor kills the connection with
|
||||||
//! renderer then fails with "Failed to create GBM buffer … Invalid argument".
|
//! `wp_linux_drm_syncobj_surface_v1 … "explicit sync is used, but no acquire
|
||||||
//! `WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=x11` makes it run normally.
|
//! 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:
|
//! Opt-ins / opt-outs (anything the user already set always wins):
|
||||||
//! - `WEBKIT_DISABLE_DMABUF_RENDERER=1`;
|
//! - `LOTUS_GPU_SAFE_MODE=1`: last resort, shared-memory rendering on Wayland
|
||||||
//! - on a Wayland session with XWayland available, `GDK_BACKEND=x11`.
|
//! too (slower, especially at high resolutions).
|
||||||
//!
|
//! - `LOTUS_NO_GPU_WORKAROUNDS=1`: change nothing.
|
||||||
//! 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).
|
|
||||||
|
|
||||||
/// Environment variables to set: pure, for tests.
|
/// Environment variables to set: pure, for tests.
|
||||||
pub(crate) fn decide(
|
pub(crate) fn decide(
|
||||||
@@ -22,15 +27,30 @@ pub(crate) fn decide(
|
|||||||
if !nvidia || set("LOTUS_NO_GPU_WORKAROUNDS") {
|
if !nvidia || set("LOTUS_NO_GPU_WORKAROUNDS") {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let mut out = Vec::new();
|
let wayland_session = set("WAYLAND_DISPLAY")
|
||||||
if !set("WEBKIT_DISABLE_DMABUF_RENDERER") {
|
|
||||||
out.push(("WEBKIT_DISABLE_DMABUF_RENDERER", "1"));
|
|
||||||
}
|
|
||||||
let wayland = set("WAYLAND_DISPLAY")
|
|
||||||
|| get("XDG_SESSION_TYPE").is_some_and(|v| v.eq_ignore_ascii_case("wayland"));
|
|| 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.
|
// GTK uses Wayland unless GDK_BACKEND says otherwise (it may list
|
||||||
if wayland && set("DISPLAY") && !set("GDK_BACKEND") {
|
// several, e.g. "wayland,x11": the first one wins).
|
||||||
out.push(("GDK_BACKEND", "x11"));
|
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
|
out
|
||||||
}
|
}
|
||||||
@@ -44,9 +64,20 @@ fn nvidia_driver_loaded() -> bool {
|
|||||||
/// Call first thing in `main`, before anything starts GTK or spawns threads.
|
/// Call first thing in `main`, before anything starts GTK or spawns threads.
|
||||||
pub fn apply() {
|
pub fn apply() {
|
||||||
#[cfg(target_os = "linux")]
|
#[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)");
|
let changes = decide(nvidia_driver_loaded(), |k| std::env::var(k).ok());
|
||||||
std::env::set_var(key, value);
|
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]
|
#[test]
|
||||||
fn nvidia_wayland_gets_both() {
|
fn nvidia_wayland_keeps_gpu_renderer_and_disables_explicit_sync() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
run(true, WAYLAND),
|
run(true, WAYLAND),
|
||||||
vec![
|
vec![("__NV_DISABLE_EXPLICIT_SYNC", "1")]
|
||||||
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
|
|
||||||
("GDK_BACKEND", "x11")
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nvidia_x11_session_only_disables_dmabuf() {
|
fn never_forces_x11() {
|
||||||
assert_eq!(
|
for env in [
|
||||||
run(true, &[("DISPLAY", ":0")]),
|
WAYLAND,
|
||||||
vec![("WEBKIT_DISABLE_DMABUF_RENDERER", "1")]
|
&[("DISPLAY", ":0")][..],
|
||||||
);
|
&[("WAYLAND_DISPLAY", "w")][..],
|
||||||
}
|
] {
|
||||||
|
assert!(run(true, env).iter().all(|(k, _)| *k != "GDK_BACKEND"));
|
||||||
#[test]
|
}
|
||||||
fn wayland_without_xwayland_keeps_wayland() {
|
|
||||||
assert_eq!(
|
|
||||||
run(true, &[("WAYLAND_DISPLAY", "wayland-0")]),
|
|
||||||
vec![("WEBKIT_DISABLE_DMABUF_RENDERER", "1")]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn xdg_session_type_counts_as_wayland() {
|
fn xdg_session_type_counts_as_wayland() {
|
||||||
assert_eq!(
|
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![
|
vec![
|
||||||
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
|
("__NV_DISABLE_EXPLICIT_SYNC", "1"),
|
||||||
("GDK_BACKEND", "x11")
|
("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn user_settings_win() {
|
fn user_settings_win() {
|
||||||
let env = [
|
let mut env = WAYLAND.to_vec();
|
||||||
("WAYLAND_DISPLAY", "wayland-0"),
|
env.push(("__NV_DISABLE_EXPLICIT_SYNC", "0"));
|
||||||
("DISPLAY", ":0"),
|
|
||||||
("WEBKIT_DISABLE_DMABUF_RENDERER", "0"),
|
|
||||||
("GDK_BACKEND", "wayland"),
|
|
||||||
];
|
|
||||||
assert!(run(true, &env).is_empty());
|
assert!(run(true, &env).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+128
-3
@@ -189,6 +189,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 +252,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 +989,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,
|
||||||
@@ -1128,9 +1179,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
|
||||||
@@ -1289,6 +1340,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::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user