1 Commits
Author SHA1 Message Date
samiandClaude Sonnet 5 b5c6e1f47d ext: verify against a real veloxd, not just FakeDaemon
main.cpp's single-instance lock is now keyed to the runtime directory
instead of the euid, so an isolated XDG_RUNTIME_DIR/XDG_DATA_HOME/
XDG_CONFIG_HOME/HOME gets its own veloxd alongside anyone else's.
tests/live/real-veloxd.test.ts spawns one (VELOX_PAIR_AUTO=1 standing
in for the GUI's Allow click) plus tools/testserver/testserver.py, and
exercises the real WebSocketTransport end to end: session.hello,
pairing, the token surviving a reconnect, a wrong token rejected and
then rate-limiting the next pairing attempt, download.add reaching a
real running task, the real capture.offer path (rules table + category
folder + dedupe) taking a monitored download and ignoring its own
duplicate, and fail-open proven by SIGKILLing the daemon mid-offer —
the hook still resolves to {} inside its 750ms budget. A last case
proves fail-open at the transport layer too: a call against a closed
socket rejects instead of hanging.

Guarded behind  so it skips itself (with a clear message)
when no daemon binary is around — npm test and CI are unaffected;
run it with VELOXD_BIN=/path/to/veloxd npx vitest run tests/live.

Real-daemon testing found one actual bug, fixed here: background/
index.ts never called session.subscribe, so event.task.progress and
friends never reached this connection at all — FakeDaemon's tests
never caught it because FakeDaemon broadcasts regardless of
subscription state. Now subscribed to the full event set on every
connect (first connect and every reconnect), which is what the popup's
live-progress path actually depends on against a real daemon.

Per instructions: ctest -L conformance was not run (pending PROTO's
fixture fix).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
2026-09-12 16:43:55 +04:00
36 changed files with 483 additions and 2113 deletions
-18
View File
@@ -53,24 +53,6 @@ capture, and the Add-URL dialog pre-fills from the clipboard when opened. Backgr
monitoring is a bonus if the spike says yes. **Do not let this block the release, and do
not promise it in the UI before S2 answers.**
**Spike S2, item (c) answered — verified live, not the full spike:** on this desktop
(GNOME/Mutter, Ubuntu 26.04, plain non-Flatpak/non-snap process), `CreateSession` on
`org.freedesktop.portal.GlobalShortcuts` refuses every caller with `"An app id is
required"` — reproduced two ways: `gui/src/clipboard/GlobalShortcut.cpp`'s real async
D-Bus call, and a bare `busctl --user call … CreateSession` from an interactive shell
(no Qt involved at all), both under a real Wayland session (`WAYLAND_DISPLAY` set), not
just offscreen. Because the second reproduction has no Qt/app-level identity to configure
at all and still fails identically, this looks like the portal requiring the caller's
*bus connection* to already carry a sandboxed app id (Flatpak/snap portal-confined) —
something no amount of `QGuiApplication::setDesktopFileName()` or similar can supply from
an unconfined process. **Practical read:** the global-shortcut explicit path likely does
not work at all for Velox as a traditionally-packaged (.deb/AppImage) app on stock
GNOME — only if/when it ships confined. The code is still in (best-effort, fails silent
exactly like this, never advertised — see the file's own header), since it costs nothing
and activates automatically the day that changes. Items (a) `QClipboard::dataChanged`
cross-app, (b) `wlr-data-control`, and (d) XWayland fallback are **still unanswered**
this was one item of S2's four, not the full spike.
---
## R3 — AMO review friction 🟠 MEDIUM
+28 -2
View File
@@ -19,7 +19,12 @@ import {
} from './context-menus.js';
import { MediaBridge, notifyTab } from './media-bridge.js';
import { createTransport, transportStorage, type TransportStatus, type VeloxTransport } from './transport/index.js';
import type { CaptureOfferParams, CaptureRules, DownloadSpec } from '../shared/protocol/index.js';
import {
SESSION_SUBSCRIBE_PARAMS_EVENTS_ITEM_VALUES,
type CaptureOfferParams,
type CaptureRules,
type DownloadSpec,
} from '../shared/protocol/index.js';
let transport: VeloxTransport | undefined;
let rules: CaptureRules = DEFAULT_CAPTURE_RULES;
@@ -75,10 +80,31 @@ async function refreshRules(): Promise<void> {
}
}
/**
* "Nothing is delivered until this is called" (session.subscribe's own description) —
* without it, event.task.progress et al. never reach this connection at all, no matter
* how many listeners bridge.ts registers locally. Requests the whole set every time
* because any popup/options document could open at any moment and none of them narrow
* per-tab; a fresh connection (first connect, or after a drop) starts with nothing
* subscribed until this runs again.
*/
async function subscribeToEvents(): Promise<void> {
try {
await mustTransport().call('session.subscribe', {
events: [...SESSION_SUBSCRIBE_PARAMS_EVENTS_ITEM_VALUES],
});
} catch {
// Best-effort; a reconnect (or the next event.settings.changed-driven refresh) retries.
}
}
function onTransportState(status: TransportStatus): void {
const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : '');
console.debug(`[velox] transport ${status.state}${detail ? `${detail}` : ''}`);
if (status.state === 'connected') void refreshRules();
if (status.state === 'connected') {
void refreshRules();
void subscribeToEvents();
}
}
async function setOverride(override: 'auto' | 'ws' | 'uds'): Promise<void> {
+389
View File
@@ -0,0 +1,389 @@
// Runs the transport, the capture hook, and the popup event path against a REAL veloxd
// — not FakeDaemon. Everything else in this suite is faithful to the documented wire
// protocol, but "faithful" isn't "real"; this is what actually proves it.
//
// Requires VELOXD_BIN (path to a built veloxd) in the environment. Skips itself with a
// clear message otherwise, so `npm test` and CI (no daemon binary lying around) are
// unaffected. Run it like:
//
// VELOXD_BIN=/path/to/build/dev/bin/veloxd npx vitest run tests/live
//
// Each veloxd instance gets its own scratch XDG_RUNTIME_DIR/XDG_DATA_HOME/
// XDG_CONFIG_HOME/HOME (main.cpp's single-instance lock is keyed to the runtime dir, so
// this can run alongside another developer's or CI's own veloxd on the same machine).
// VELOX_PAIR_AUTO=1 stands in for the GUI's Allow-prompt approver during dev/test
// (rpc/pairing.hpp's EnvAutoApprover) — pairing itself is exercised for real, only the
// human click is stubbed.
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebSocket as WsClient } from 'ws';
import { RpcError, TransportClosedError } from '../../src/background/transport/types.js';
import { WebSocketTransport, type WebSocketCtor, type WebSocketTransportDeps } from '../../src/background/transport/websocket.js';
import { CaptureHook } from '../../src/background/capture/index.js';
import type { OnHeadersReceivedDetails } from '../../src/background/capture/index.js';
import type { CaptureRules, DownloadSpec, TaskProgressEvent, TaskStateEvent } from '../../src/shared/protocol/index.js';
const VELOXD_BIN = process.env.VELOXD_BIN;
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const TESTSERVER_PY = join(REPO_ROOT, 'tools/testserver/testserver.py');
// A fixed moz-extension origin, used both as session.pair's extensionId and as the WS
// upgrade's Origin header — real Firefox sets the latter itself; ws's client needs it
// spelled out (docs/05 §4: the daemon refuses the upgrade without a moz-extension:// Origin).
const EXTENSION_ID = '11111111-2222-3333-4444-555555555555';
const ORIGIN = `moz-extension://${EXTENSION_ID}`;
class OriginWebSocket extends WsClient {
constructor(url: string) {
super(url, { origin: ORIGIN });
}
}
const CTOR = OriginWebSocket as unknown as WebSocketCtor;
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function waitFor(cond: () => Promise<boolean> | boolean, timeoutMs: number, what: string): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (await cond()) return;
if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
await sleep(50);
}
}
interface VeloxdInstance {
proc: ChildProcessWithoutNullStreams;
scratch: string;
wsPort: number;
/** True once the process has actually exited, by signal or otherwise. Node only sets
* `proc.exitCode` for a normal exit — a signal-killed process reports its death via
* `signalCode` and an `exit` event instead, never a non-null `exitCode`. */
hasExited(): boolean;
kill(signal?: NodeJS.Signals): void;
}
async function startVeloxd(bin: string): Promise<VeloxdInstance> {
const scratch = mkdtempSync(join(tmpdir(), 'velox-live-'));
const runtime = join(scratch, 'rt');
const data = join(scratch, 'data');
const config = join(scratch, 'cfg');
const home = join(scratch, 'home');
mkdirSync(runtime, { mode: 0o700 });
mkdirSync(data, { recursive: true });
mkdirSync(config, { recursive: true });
mkdirSync(join(home, 'Downloads'), { recursive: true });
const proc = spawn(bin, [], {
env: {
...process.env,
VELOX_PAIR_AUTO: '1',
XDG_RUNTIME_DIR: runtime,
XDG_DATA_HOME: data,
XDG_CONFIG_HOME: config,
HOME: home,
},
});
let exited = false;
proc.on('exit', () => {
exited = true;
});
let stderr = '';
proc.stderr.on('data', (d) => {
stderr += String(d);
});
const portFile = join(runtime, 'velox', 'ws.port');
try {
await waitFor(async () => {
if (exited) throw new Error(`veloxd exited early (code ${proc.exitCode}, signal ${proc.signalCode}): ${stderr}`);
try {
await readFile(portFile);
return true;
} catch {
return false;
}
}, 10_000, 'veloxd to write ws.port');
} catch (e) {
proc.kill('SIGKILL');
rmSync(scratch, { recursive: true, force: true });
throw e;
}
const wsPort = Number((await readFile(portFile, 'utf8')).trim());
return {
proc,
scratch,
wsPort,
hasExited: () => exited,
kill(signal: NodeJS.Signals = 'SIGTERM') {
proc.kill(signal);
},
};
}
interface TestServerInstance {
proc: ChildProcessWithoutNullStreams;
baseUrl: string;
}
async function startTestServer(): Promise<TestServerInstance> {
const proc = spawn('python3', [TESTSERVER_PY, '--port', '0'], {});
let stdout = '';
let port: number | null = null;
proc.stdout.on('data', (d) => {
stdout += String(d);
const m = /^(\d+)\s*$/m.exec(stdout);
if (m) port = Number(m[1]);
});
await waitFor(() => port !== null, 5_000, 'testserver to print its port');
const baseUrl = `http://127.0.0.1:${port}`;
await waitFor(async () => {
try {
const res = await fetch(`${baseUrl}/__health`);
return res.ok;
} catch {
return false;
}
}, 5_000, 'testserver /__health');
return { proc, baseUrl };
}
function memDeps(init: { token?: string | null } = {}): { deps: WebSocketTransportDeps; store: { token: string | null } } {
const store = { token: init.token ?? null };
return {
store,
deps: {
getToken: async () => store.token,
setToken: async (t) => {
store.token = t;
},
getCachedPort: async () => null,
setCachedPort: async () => undefined,
extensionId: EXTENSION_ID,
},
};
}
function makeTransport(port: number, deps: WebSocketTransportDeps, extra: Partial<WebSocketTransportDeps> = {}): WebSocketTransport {
return new WebSocketTransport({
...deps,
...extra,
webSocketCtor: CTOR,
portRange: { start: port, end: port },
openTimeoutMs: 2000,
});
}
const maybeDescribe = VELOXD_BIN ? describe : describe.skip;
if (!VELOXD_BIN) {
console.warn('tests/live/real-veloxd.test.ts: VELOXD_BIN not set — skipping (see file header).');
}
maybeDescribe('WebSocketTransport against a real veloxd', () => {
let daemon: VeloxdInstance;
let testserver: TestServerInstance;
// The mid-test fail-open case kills `daemon` and a later test starts a replacement —
// every scratch dir that ever existed gets cleaned up here, not just the last one.
const allScratchDirs: string[] = [];
async function freshVeloxd(): Promise<VeloxdInstance> {
const d = await startVeloxd(VELOXD_BIN!);
allScratchDirs.push(d.scratch);
return d;
}
beforeAll(async () => {
daemon = await freshVeloxd();
testserver = await startTestServer();
}, 20_000);
afterAll(() => {
daemon?.kill('SIGKILL');
testserver?.proc.kill('SIGKILL');
for (const dir of allScratchDirs) rmSync(dir, { recursive: true, force: true });
});
it('session.hello without a token surfaces NotPaired / needsPairing', async () => {
const { deps } = memDeps();
const t = makeTransport(daemon.wsPort, deps, { autoPair: false });
await expect(t.connect()).rejects.toBeInstanceOf(RpcError);
expect(t.status.needsPairing).toBe(true);
t.disconnect();
});
let pairedToken: string;
it('pairs (VELOX_PAIR_AUTO=1 stands in for the human Allow click) and hellos with the issued token', async () => {
const { deps, store } = memDeps();
const t = makeTransport(daemon.wsPort, deps); // autoPair: true (default)
await t.connect();
expect(t.state).toBe('connected');
expect(t.status.daemonVersion).toBeTruthy();
expect(store.token).toBeTruthy();
pairedToken = store.token!;
t.disconnect();
});
it('the pairing token survives a reconnect: a fresh transport reuses it with no fresh pairing', async () => {
const { deps } = memDeps({ token: pairedToken });
// autoPair: false — if this succeeds at all, it can only be because the stored
// token from the previous test was accepted outright, not because this transport
// silently re-paired.
const t = makeTransport(daemon.wsPort, deps, { autoPair: false });
await t.connect();
expect(t.state).toBe('connected');
t.disconnect();
});
it('a wrong token is rejected, and repeating it rate-limits the next pairing attempt', async () => {
// Five failed session.hello attempts from this origin (ws_server.cpp records a
// rate-limiter failure on every not-paired hello, not only on a failed session.pair)
// exhausts the window; the sixth thing this origin tries — a pairing attempt — gets
// RateLimited rather than a fresh token.
for (let i = 0; i < 5; i += 1) {
const { deps } = memDeps({ token: 'not-the-real-token' });
const t = makeTransport(daemon.wsPort, deps, { autoPair: false });
await expect(t.connect()).rejects.toBeInstanceOf(RpcError);
t.disconnect();
}
const { deps } = memDeps(); // no token -> autoPair kicks in -> session.pair
const t = makeTransport(daemon.wsPort, deps);
await expect(t.connect()).rejects.toBeInstanceOf(RpcError);
expect(t.status.needsPairing).toBe(true);
expect(t.status.retryAfterSec).toBeGreaterThan(0);
t.disconnect();
});
it('download.add creates a real task the engine picks up', async () => {
const { deps } = memDeps({ token: pairedToken });
const t = makeTransport(daemon.wsPort, deps, { autoPair: false });
await t.connect();
try {
const spec: DownloadSpec = { url: `${testserver.baseUrl}/plain/file/64K`, filename: 'plain-download.bin' };
const added = await t.call('download.add', spec);
expect(added.taskId).toBeTruthy();
await waitFor(async () => {
const detail = await t.call('download.get', { taskId: added.taskId });
const state = detail.summary.state;
return state === 'complete' || state === 'downloading' || state === 'verifying';
}, 10_000, 'the task to leave the queued state');
} finally {
t.disconnect();
}
}, 15_000);
it('capture.offer end to end: the real capture path takes a monitored download, ignores its own duplicate, and fails open when the daemon dies mid-offer', async () => {
const { deps } = memDeps({ token: pairedToken });
const t = makeTransport(daemon.wsPort, deps, { autoPair: false });
await t.connect();
const rules: CaptureRules = await t.call('capture.getRules', {});
expect(rules.monitoredExtensions).toContain('zip'); // seeded default (0001_initial.sql)
const hook = new CaptureHook({
offer: (params, opts) => t.call('capture.offer', params, opts),
stash: { take: () => undefined, peek: () => undefined },
getCookies: async () => [],
getRules: () => rules,
origin: ORIGIN,
});
// A throttled URL so the task the first offer creates is still active (not yet
// complete) when the dedupe offer for the same URL follows immediately after.
const url = `${testserver.baseUrl}/throttled/file/512K`;
const details: OnHeadersReceivedDetails = {
requestId: 'live-1',
url,
method: 'GET',
type: 'other',
statusCode: 200,
tabId: 1,
responseHeaders: [{ name: 'content-disposition', value: 'attachment; filename="live-capture.zip"' }],
};
const first = await hook.handle(details);
expect(first).toEqual({ cancel: true }); // the daemon took it — Firefox never starts its own download
const list = await t.call('download.list', { filter: { query: 'live-capture' } });
expect(list.items.length).toBeGreaterThan(0);
const task = list.items[0]!;
expect(task.categoryId).toBe('programs'); // "zip" routes to the built-in Programs category
expect(task.saveDir).toContain('Downloads/Programs');
// Same URL again, task still active: the daemon's own dedupe (has_active_duplicate)
// says Ignore, so the hook proceeds instead of cancelling a second time.
const dup = await hook.handle({ ...details, requestId: 'live-2' });
expect(dup).toEqual({});
// Now kill the daemon mid-offer and prove fail-open holds against the REAL binary,
// not just FakeDaemon: the hook must still resolve to {} (Firefox downloads
// normally) well inside its own 750 ms budget.
daemon.kill('SIGKILL');
await waitFor(() => daemon.hasExited(), 5_000, 'veloxd to actually die');
const started = Date.now();
const afterDeath = await hook.handle({ ...details, requestId: 'live-3', url: `${url}?after-death=1` });
const elapsedMs = Date.now() - started;
expect(afterDeath).toEqual({}); // fail open — never {cancel: true} with a dead daemon
expect(elapsedMs).toBeLessThan(900); // budget is 750ms; the hook's own timer bounds this
t.disconnect();
}, 20_000);
it('event.task.progress reaches a subscribed client (the popup\'s own path)', async () => {
if (daemon.hasExited()) {
// The previous test kills the daemon on purpose to prove fail-open; start a fresh
// one so this test still exercises the real event path end to end.
daemon = await freshVeloxd();
}
const { deps } = memDeps();
const t = makeTransport(daemon.wsPort, deps); // fresh daemon instance -> fresh pairing
await t.connect();
try {
await t.call('session.subscribe', {
events: ['event.task.added', 'event.task.state', 'event.task.progress'],
});
const progressEvents: TaskProgressEvent[] = [];
const stateEvents: TaskStateEvent[] = [];
t.on('event.task.progress', (p) => progressEvents.push(p as TaskProgressEvent));
t.on('event.task.state', (p) => stateEvents.push(p as TaskStateEvent));
const spec: DownloadSpec = { url: `${testserver.baseUrl}/throttled/file/1M`, filename: 'progress-check.bin' };
const added = await t.call('download.add', spec);
// /throttled defaults to 1 MiB/s, so a 1 MiB file takes ~1s — long enough that at
// least one 4 Hz progress tick (event.task.progress's documented cap) lands before
// it completes, exactly the path popup/store.ts consumes in the real extension.
await waitFor(
() => progressEvents.some((e) => e.tasks.some((row) => row.taskId === added.taskId)),
8_000,
'a live event.task.progress tick for our task',
);
expect(stateEvents.some((e) => e.taskId === added.taskId)).toBe(true);
} finally {
t.disconnect();
}
}, 15_000);
it('fail-open also holds through the transport itself: a call against a dead socket rejects, never hangs past its deadline', async () => {
const { deps } = memDeps();
const t = makeTransport(daemon.wsPort, deps);
await t.connect();
t.disconnect(); // closes the socket without telling the daemon anything is wrong
await expect(t.call('capture.offer', { url: 'https://example.com/x.zip', method: 'GET', tabUrl: '' }, { timeoutMs: 200 })).rejects.toBeInstanceOf(
TransportClosedError,
);
});
});
-26
View File
@@ -35,21 +35,10 @@ add_library(velox-gui-lib STATIC
src/dialogs/BatchDialog.cpp
src/dialogs/GrabberWizard.cpp
src/tray/TrayIcon.cpp
src/widgets/DropTargetWidget.cpp
src/util/ThemeManager.cpp
src/util/UiThreadWatchdog.cpp
src/mainwindow/CategoryPanel.cpp
src/mainwindow/MainWindow.cpp
)
# docs/03-gui-spec.md §7: the two QSS skins ThemeManager picks between, embedded so the
# app needs no external file at runtime.
qt_add_resources(velox-gui-lib "theme"
PREFIX "/qss"
BASE "resources/qss"
FILES resources/qss/idm-like.qss resources/qss/dark.qss
)
target_include_directories(velox-gui-lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_features(velox-gui-lib PUBLIC cxx_std_23)
target_compile_options(velox-gui-lib PRIVATE -Wall -Wextra -Wpedantic -Werror)
@@ -58,23 +47,8 @@ target_link_libraries(velox-gui-lib PUBLIC
Qt6::Widgets
Qt6::Svg
Qt6::Network
Threads::Threads
)
# docs/06-risks-and-spikes.md R2's explicit path #2 (a global shortcut via
# org.freedesktop.portal.GlobalShortcuts) needs Qt6::DBus, which the root CMakeLists.txt
# does not request yet (gui/docs/pkg-qa-requests-m1.md R4 — PKG/QA's file, not ours).
# Guarded exactly like the veloxproto check above: compiles in automatically the moment
# that lands, and MainWindow only wires it up when VELOX_GUI_HAVE_DBUS is defined.
if(TARGET Qt6::DBus)
target_sources(velox-gui-lib PRIVATE src/clipboard/GlobalShortcut.cpp)
target_link_libraries(velox-gui-lib PUBLIC Qt6::DBus)
target_compile_definitions(velox-gui-lib PUBLIC VELOX_GUI_HAVE_DBUS)
else()
message(STATUS "velox-gui: Qt6::DBus not available — the clipboard global-shortcut "
"path (gui/docs/pkg-qa-requests-m1.md R4) is skipped, not broken.")
endif()
add_executable(velox-gui src/main.cpp)
target_compile_options(velox-gui PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(velox-gui PRIVATE velox-gui-lib)
+19 -94
View File
@@ -121,101 +121,26 @@ Notes for whoever applies it:
---
## R3 — the GUI DoD gates have nowhere to run in CI — RESOLVED, ready to wire in
## R3 — the GUI DoD gates have nowhere to run in CI
**Status: done on GUI's side.** The harness `tests/integration/README.md` was waiting on
now exists, builds, and has been run end-to-end (all three gates, all three unhappy-path
sub-phases) against a real `mockd` with no changes needed to the pre-drafted job below.
To be precise about what already works: `VELOX_BUILD_GUI` defaults `ON`, the `ci` preset
inherits `dev`, and once `gui/` is on `main` the `build` and `sanitizers` jobs configure
and build `velox-gui` and run `ctest --preset ci`, which picks up all three GUI checks
(`gui_downloadtablemodel`, `gui_rtl`, `gui_no_download_logic`). That part is covered.
**Path:** `gui/tests/dod/run.sh <gate> [--json <path>]`, exactly the contract
`tests/integration/README.md` specifies. `<gate>` is `scroll-60fps` | `rss-flat` |
`unhappy-path`. It builds and tears down its own `mockd` (isolated `XDG_RUNTIME_DIR` via
`mktemp -d`), needs no network, and leaves nothing running on any exit path (`trap
cleanup EXIT INT TERM`) — verified live by checking for orphaned `tsx`/`mockd` processes
after both a passing and a forced-failing run of each gate.
What has no home is the part of the GUI M1 definition of done that isn't a unit test:
**One change from the pre-drafted job:** the "Configure + build" step needs to also build
the harness binary, not just `velox-gui`:
```diff
- cmake --build --preset dev --target velox-gui
+ cmake --build --preset dev --target velox-gui gui-dod-harness
```
Nothing else in the pre-drafted YAML needs to change — the `# TODO(GUI): path` comment on
the `scroll-60fps` line can just come off along with the marker on the line below it.
1. **10 000 rows scroll at 60 fps** (`mockd --tasks 10000`) — needs a frame-timing probe
against the offscreen (or Xvfb) view; red when a scroll frame exceeds ~16 ms at the
99th percentile.
2. **Flat memory over 10 minutes of progress events** — needs RSS sampled across a
10-minute `mockd --tasks 10000` run; red when RSS grows more than a small fixed slack
(a leak in the progress-patch path is the thing this catches).
3. **Unhappy-path recovery**`mockd --slow`, `--flaky <f>`, `--drop-connection <s>`:
the client must show the banner and recover without a freeze or crash; red on a crash,
a hang (watchdog), or the connection state never returning to `Connected`.
**`rss-flat`'s slack: 20 MiB (`VELOX_DOD_RSS_SLACK_KIB`, default `20480`).** Chosen by
running the gate locally (`VELOX_DOD_RSS_DURATION_SEC=8` override, i.e. not the real
10-minute number) a handful of times against `mockd --tasks 10000 --seed 1` and looking at
actual post-warm-up growth (single-digit MiB per run here) — 20 MiB gives real headroom
above that noise floor without being so loose a genuine per-tick leak in the progress-patch
path could hide under it. This has **not** been proven against a full real 10-minute
sanitized run (that's the nightly job's own first execution) or tuned against
production-length data yet; treat it as GUI's stated starting number, not a
load-tested constant, and expect it may need retuning after the first few real
`gui-dod-nightly` runs land actual series data.
**One caveat worth deciding on explicitly: the pre-drafted job builds with `cmake --preset
dev`, i.e. ASan+UBSan (CLAUDE.md: "default for all lanes").** `scroll-60fps`'s frame
budget already compensates (`gui/tests/dod/dod_harness.cpp` multiplies 16.6 ms by 4× when
it detects a sanitized build — measured p99 here was ~50 ms against ASan overhead, well
under the scaled 66.4 ms budget, so the multiplier is doing real work, not padding for no
reason). `rss-flat`'s slack does **not** get a similar adjustment — ASan's allocator
(redzones, quarantine) can look like real growth over a long run in a way this hasn't been
validated against yet. Two honest options: run `gui-dod-nightly` against a `release`-preset
build instead of `dev` (loses the sanitizers' own bug-catching value for this one job), or
accept `VELOX_DOD_RSS_SLACK_KIB` may need a second, larger number for the ASan build once
real 10-minute data exists. GUI's preference is the second (keep sanitizers on
everywhere), but this is genuinely PKG/QA's call since it's their job definition.
**Verified live**, under the exact `ASAN_OPTIONS=detect_leaks=1:halt_on_error=1` the
`sanitizers` job already sets (checked against `.github/workflows/ci.yml` rather than
assumed) — no leak-suppression flag needed, on any of the three gates:
* `scroll-60fps` against `mockd --tasks 10000 --seed 1`: PASS at p99 ≈ 50 ms (budget
66.4 ms sanitized). Forced red once via `VELOX_DOD_FRAME_BUDGET_MS=1` to confirm the
fail path and exit code actually work, not just the pass path.
* `rss-flat` at `VELOX_DOD_RSS_DURATION_SEC=6/8`: PASS, ~4-6 MiB growth against the
20 MiB slack.
* `unhappy-path`, all three phases (`--slow 900`, `--flaky 0.3`, `--drop-connection 5`):
PASS. One real finding from building this: `--drop-connection` is currently a no-op
over the Unix socket transport in `mockd` itself (only wired for WebSocket) — filed as
`gui/docs/proto-requests-m1.md` since that's PROTO's file to fix, not GUI's. The gate
still passes today on the weaker (but real, and the actually-documented) condition that
the client reaches and holds `Connected`; it just isn't proving a real mid-session drop
yet for that one phase.
Two bugs surfaced and fixed *by* building this harness, both in `gui/`'s own RPC client
(caught by ASan, not assumed): `RpcClient::stop()` left `conn_` dangling after joining its
worker thread, so any caller that called `stop()` and then let the client destruct hit a
double-free — the harness's own `client.stop()` at shutdown found it on the first run.
Separately, `RpcClient`'s initial `download.list` call had a hardcoded `limit: 1000` with
no paging, silently capping the table at 1000 rows regardless of how many the daemon
actually has — `scroll-60fps` against `--tasks 10000` refused to run rather than
"passing" against a 1000-row table, which is what caught it. Both fixed on `lane/gui`
before this request was filed.
---
## R4 — root `CMakeLists.txt`'s `find_package(Qt6 ...)` should list `DBus` explicitly
Not currently broken — flagging a "works, but by accident" for the record. `gui/src/
clipboard/GlobalShortcut.cpp` (docs/06-risks-and-spikes.md R2's explicit path #2:
`org.freedesktop.portal.GlobalShortcuts`) needs `Qt6::DBus`. **Verified live: the target
already exists and links today**, even though the root `find_package` doesn't list `DBus`
in `COMPONENTS` — this Qt 6 packaging apparently exports every module's CMake target once
any component pulls in the shared prefix, `DBus` included. `gui/CMakeLists.txt` still
guards the clipboard sources on `if(TARGET Qt6::DBus)` (same pattern the file already uses
for `veloxproto`), so if that turns out to be environment-specific rather than a general
Qt 6 CMake guarantee, the build degrades to "feature skipped," not "build broken," on
whatever machine finds out otherwise.
Worth making explicit anyway, since relying on undocumented target leakage is fragile:
```diff
- find_package(Qt6 6.6 REQUIRED COMPONENTS Widgets Svg Network LinguistTools)
+ find_package(Qt6 6.6 REQUIRED COMPONENTS Widgets Svg Network DBus LinguistTools)
```
No apt change needed either way — `qt6-base-dev` (already in `APT_GUI`) ships `QtDBus`'s
headers directly (verified live: `dpkg -L qt6-base-dev | grep -i dbus` lists the whole
`QtDBus/` include tree).
GUI owns writing that harness (`gui/tests/` + a driver script, headless against `mockd`).
Wiring it into `.github/workflows/ci.yml` as its own job — with the 10-minute one likely
`nightly` rather than per-PR — is PKG/QA. Say the word and it comes over as a follow-up
request with the job stanza pre-written.
-105
View File
@@ -1,105 +0,0 @@
# GUI → PROTO requests (M1)
Filed by lane GUI while building `gui/tests/dod/` (gui/docs/pkg-qa-requests-m1.md R3's
harness). Touches `tools/mockd/` — PROTO-owned (CLAUDE.md §1) — so GUI is not making the
edit. Apply-ready below.
---
## `mockd --drop-connection` is a no-op over the Unix socket transport
`--drop-connection <s>` is documented as "terminate every connection every N seconds, to
exercise reconnect logic" and is exactly what `gui/tests/dod/run.sh unhappy-path` needs
for its drop-connection phase. It works — but only over WebSocket.
**Repro:** `tools/mockd/src/index.ts`'s `startUds()` call passes `args.slow` and stops
there:
```ts
startUds(args.uds, dispatcher, connections, log, args.slow);
```
`startWs()`, two lines below, gets the full options object including `dropEverySec`.
`startUds()`'s own signature (`tools/mockd/src/transport/uds.ts`) has no
`dropEverySec` parameter at all, and nothing in it ever calls `socket.destroy()` — the
periodic-drop `setInterval` that `startWs` has (its last ~6 lines) simply does not exist
on the UDS side.
**Verified live**, not inferred from reading: ran `mockd --no-ws --drop-connection 5`,
connected `gui/tests/dod/dod_harness unhappy-path --phase drop-connection` against it
(UDS, the GUI's only transport) with a 45 s observation window, and `stateChanged` never
fired — the connection sat in `Connected` the entire time. Same command with `--flaky 0.3`
correctly leaves the connection state alone (that flag only fails individual call
replies, which is right), so this is specific to `--drop-connection` and the UDS
transport, not a harness-side detection problem.
**Effect:** every GUI/CLI/nmhost consumer of mockd — the only transport they actually
use — cannot be tested against a dropped connection at all today. `gui/tests/dod/run.sh`
ships its `unhappy-path` drop-connection phase anyway (log intentionally records
`sawDisruption` in its JSON so this is visible, not silently green), but it is currently
only proving the client survives 45 quiet seconds, not a real drop.
### Fix — mirror `ws.ts`'s existing pattern onto `uds.ts`
**`tools/mockd/src/transport/uds.ts`:**
```diff
export function startUds(
path: string,
dispatcher: Dispatcher,
connections: Set<Connection>,
log: (msg: string) => void,
delayMs: number,
+ dropEverySec: number = 0,
): Server {
mkdirSync(dirname(path), { recursive: true });
rmSync(path, { force: true });
+ const sockets = new Set<Socket>();
const server = createServer((socket: Socket) => {
+ sockets.add(socket);
const session: Session = { transport: 'uds', paired: true, subscribed: new Set(), sessionId: randomUUID() };
const conn: Connection = {
session,
send: (frame) => {
if (!socket.destroyed) socket.write(JSON.stringify(frame) + '\n');
},
};
connections.add(conn);
log(`uds: client connected (${connections.size} open)`);
...
socket.on('error', (err) => log(`uds: socket error: ${err.message}`));
socket.on('close', () => {
connections.delete(conn);
+ sockets.delete(socket);
log(`uds: client disconnected (${connections.size} open)`);
});
});
server.listen(path, () => log(`uds: listening on ${path}`));
+
+ if (dropEverySec > 0) {
+ setInterval(() => {
+ log(`uds: dropping ${sockets.size} connection(s) (--drop-connection)`);
+ for (const s of sockets) s.destroy();
+ }, dropEverySec * 1000).unref();
+ }
+
return server;
}
```
**`tools/mockd/src/index.ts`** (~line 205):
```diff
- startUds(args.uds, dispatcher, connections, log, args.slow);
+ startUds(args.uds, dispatcher, connections, log, args.slow, args.dropEverySec);
```
Both use `.unref()`/existing shutdown handling already in `index.ts`, so no change needed
there. `socket.destroy()` (vs. `.end()`) matches `ws.ts`'s `.terminate()` — an abrupt drop,
which is the point of the flag.
Not urgent for M0/M1 GUI work — `gui/tests/dod/run.sh`'s other two unhappy-path phases
(`--slow`, `--flaky`) both work correctly over UDS today, and the drop-connection phase
still exercises 45 s of otherwise-idle connection handling. But the flag's whole purpose
is unmet on the transport every real consumer uses, and the fix is a direct port of code
that already exists two files over.
-112
View File
@@ -1,112 +0,0 @@
/* Velox — dark theme. Lane GUI. Structured identically to idm-like.qss — diff the two
* when changing either.
*
* --- palette -----------------------------------------------------------------------
* --bg #202225 window and dialog background
* --surface #2b2d31 table/tree, input field backgrounds
* --surface-alt #313338 alternating row colour
* --border #3f4147 panel/header/input borders
* --text #e6e7ea primary text
* --text-muted #9a9da3 secondary text (headers, disabled)
* --accent #4c94e0 selection, focus ring, progress fill
* --accent-hover #5da2ea hovered accent (buttons, tabs)
* -------------------------------------------------------------------------------------
*/
QMainWindow, QDialog {
background: #202225;
color: #e6e7ea;
}
QTreeView, QTableView, QListView {
background: #2b2d31;
alternate-background-color: #313338;
color: #e6e7ea;
border: 1px solid #3f4147;
selection-background-color: #4c94e0;
selection-color: #202225;
}
QHeaderView::section {
background: #313338;
color: #9a9da3;
border: none;
border-right: 1px solid #3f4147;
border-bottom: 1px solid #3f4147;
padding: 4px 6px;
}
QLineEdit, QPlainTextEdit, QSpinBox, QComboBox {
background: #2b2d31;
border: 1px solid #3f4147;
border-radius: 3px;
padding: 2px 4px;
color: #e6e7ea;
}
QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, QComboBox:focus {
border: 1px solid #4c94e0;
}
QPushButton {
background: #2b2d31;
border: 1px solid #3f4147;
border-radius: 3px;
padding: 4px 12px;
color: #e6e7ea;
}
QPushButton:hover {
border-color: #5da2ea;
}
QPushButton:default {
background: #4c94e0;
border-color: #4c94e0;
color: #202225;
}
QPushButton:default:hover {
background: #5da2ea;
}
QTabWidget::pane {
border: 1px solid #3f4147;
background: #2b2d31;
}
QTabBar::tab {
background: #313338;
border: 1px solid #3f4147;
border-bottom: none;
padding: 4px 12px;
color: #9a9da3;
}
QTabBar::tab:selected {
background: #2b2d31;
color: #e6e7ea;
}
QProgressBar {
border: 1px solid #3f4147;
border-radius: 3px;
background: #313338;
text-align: center;
color: #e6e7ea;
}
QProgressBar::chunk {
background: #4c94e0;
}
QMenu {
background: #2b2d31;
border: 1px solid #3f4147;
color: #e6e7ea;
}
QMenu::item:selected {
background: #4c94e0;
color: #202225;
}
-119
View File
@@ -1,119 +0,0 @@
/* Velox — light theme. Lane GUI.
*
* docs/agents/AGENT-GUI.md build order step 8: "colours in one variables block at the
* top of the QSS; no hard-coded hex scattered through widget code." QSS itself has no
* variable syntax (Qt has never added one), so this block is the actual palette, kept in
* one place and referenced from every rule below by comment rather than repeated ad hoc —
* every hex value that appears more than once below is listed here first. dark.qss is
* structured identically with its own values, so the two stay easy to diff against each
* other when one changes.
*
* --- palette -----------------------------------------------------------------------
* --bg #f4f5f7 window and dialog background
* --surface #ffffff table/tree, input field backgrounds
* --surface-alt #eef0f3 alternating row colour
* --border #d3d7dc panel/header/input borders
* --text #202225 primary text
* --text-muted #6b7078 secondary text (headers, disabled)
* --accent #2f7dd1 selection, focus ring, progress fill
* --accent-hover #3f8ce0 hovered accent (buttons, tabs)
* -------------------------------------------------------------------------------------
*/
QMainWindow, QDialog {
background: #f4f5f7;
color: #202225;
}
QTreeView, QTableView, QListView {
background: #ffffff;
alternate-background-color: #eef0f3;
color: #202225;
border: 1px solid #d3d7dc;
selection-background-color: #2f7dd1;
selection-color: #ffffff;
}
QHeaderView::section {
background: #eef0f3;
color: #6b7078;
border: none;
border-right: 1px solid #d3d7dc;
border-bottom: 1px solid #d3d7dc;
padding: 4px 6px;
}
QLineEdit, QPlainTextEdit, QSpinBox, QComboBox {
background: #ffffff;
border: 1px solid #d3d7dc;
border-radius: 3px;
padding: 2px 4px;
color: #202225;
}
QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, QComboBox:focus {
border: 1px solid #2f7dd1;
}
QPushButton {
background: #ffffff;
border: 1px solid #d3d7dc;
border-radius: 3px;
padding: 4px 12px;
color: #202225;
}
QPushButton:hover {
border-color: #3f8ce0;
}
QPushButton:default {
background: #2f7dd1;
border-color: #2f7dd1;
color: #ffffff;
}
QPushButton:default:hover {
background: #3f8ce0;
}
QTabWidget::pane {
border: 1px solid #d3d7dc;
background: #ffffff;
}
QTabBar::tab {
background: #eef0f3;
border: 1px solid #d3d7dc;
border-bottom: none;
padding: 4px 12px;
color: #6b7078;
}
QTabBar::tab:selected {
background: #ffffff;
color: #202225;
}
QProgressBar {
border: 1px solid #d3d7dc;
border-radius: 3px;
background: #eef0f3;
text-align: center;
color: #202225;
}
QProgressBar::chunk {
background: #2f7dd1;
}
QMenu {
background: #ffffff;
border: 1px solid #d3d7dc;
color: #202225;
}
QMenu::item:selected {
background: #2f7dd1;
color: #ffffff;
}
-172
View File
@@ -1,172 +0,0 @@
#include "clipboard/GlobalShortcut.hpp"
#include <QCoreApplication>
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusMessage>
#include <QDBusObjectPath>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QLoggingCategory>
#include <QRandomGenerator>
namespace velox::gui {
namespace {
Q_LOGGING_CATEGORY(lcShortcut, "velox.gui.globalshortcut")
constexpr auto kService = "org.freedesktop.portal.Desktop";
constexpr auto kObjectPath = "/org/freedesktop/portal/desktop";
constexpr auto kShortcutsIface = "org.freedesktop.portal.GlobalShortcuts";
constexpr auto kRequestIface = "org.freedesktop.portal.Request";
constexpr auto kShortcutId = "add-url-from-clipboard";
QString newToken(const QString &prefix) {
return prefix + QString::number(QRandomGenerator::global()->generate64(), 16);
}
// org.freedesktop.portal.Request object paths embed the caller's own unique bus name
// with ':' and '.' rewritten to '_' — reconstructing that is documented but fragile;
// every portal client instead just uses the exact path CreateSession/BindShortcuts hand
// back in their reply, which is what every call below does.
void connectToRequestResponse(const QDBusObjectPath &requestPath, QObject *receiver,
const char *slot) {
QDBusConnection::sessionBus().connect(QString::fromLatin1(kService), requestPath.path(),
QString::fromLatin1(kRequestIface),
QStringLiteral("Response"), receiver, slot);
}
} // namespace
GlobalShortcut::GlobalShortcut(QObject *parent) : QObject(parent) {}
void GlobalShortcut::requestBinding() {
if (requested_) {
return;
}
requested_ = true;
if (!QDBusConnection::sessionBus().isConnected()) {
qCInfo(lcShortcut, "no D-Bus session bus — global shortcut unavailable this session");
return;
}
// GlobalShortcuts is an *impl* portal some desktops never install; check the name is
// even owned before making a call whose only failure mode would otherwise be a vague
// D-Bus service-unknown error.
if (!QDBusConnection::sessionBus().interface()->isServiceRegistered(
QString::fromLatin1(kService))) {
qCInfo(lcShortcut, "no xdg-desktop-portal on this session bus");
return;
}
// QDBusMessage::createMethodCall + asyncCall, not QDBusInterface: the interface class
// introspects the remote object on first use and caches the result in a process-wide
// QDBusMetaObject table it never frees — by design (Qt intends it to live for the
// process's lifetime so repeated calls skip introspection), but that reads as a real
// LeakSanitizer leak the first time anything in this binary touches D-Bus at all,
// which is exactly what happened here (caught live, `ctest -L gui`'s tst_rtl went red
// under ASan). A raw method-call message needs no introspection and allocates nothing
// that outlives this call.
QDBusMessage call = QDBusMessage::createMethodCall(
QString::fromLatin1(kService), QString::fromLatin1(kObjectPath),
QString::fromLatin1(kShortcutsIface), QStringLiteral("CreateSession"));
const QVariantMap options{
{QStringLiteral("handle_token"), newToken(QStringLiteral("velox_create_"))},
{QStringLiteral("session_handle_token"), newToken(QStringLiteral("velox_session_"))},
};
call << options;
auto *watcher =
new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<QDBusObjectPath> reply = *watcher;
if (reply.isError()) {
qCInfo(lcShortcut, "CreateSession failed: %s", qUtf8Printable(reply.error().message()));
return;
}
connectToRequestResponse(reply.value(), this,
SLOT(onCreateSessionResponse(uint, QVariantMap)));
});
}
void GlobalShortcut::onCreateSessionResponse(uint code, const QVariantMap &results) {
if (code != 0) {
qCInfo(lcShortcut, "CreateSession request denied/failed (code %u)", code);
return;
}
sessionHandle_ = results.value(QStringLiteral("session_handle")).toString();
if (sessionHandle_.isEmpty()) {
qCWarning(lcShortcut, "CreateSession succeeded with no session_handle — portal bug?");
return;
}
bindShortcuts();
}
void GlobalShortcut::bindShortcuts() {
// a(sa{sv}): one (id, properties) pair per shortcut. QtDBus has no automatic
// marshalling for a struct-in-array-of-variants shape this specific, so it is built by
// hand with QDBusArgument — the documented escape hatch for exactly this case.
QDBusArgument shortcutsArg;
shortcutsArg.beginArray(qMetaTypeId<QDBusArgument>());
shortcutsArg.beginStructure();
shortcutsArg << QString::fromLatin1(kShortcutId);
QVariantMap props{
{QStringLiteral("description"),
QCoreApplication::translate("velox::gui::GlobalShortcut",
"Add URL from clipboard (Velox)")},
};
shortcutsArg << props;
shortcutsArg.endStructure();
shortcutsArg.endArray();
QDBusMessage call = QDBusMessage::createMethodCall(
QString::fromLatin1(kService), QString::fromLatin1(kObjectPath),
QString::fromLatin1(kShortcutsIface), QStringLiteral("BindShortcuts"));
const QVariantMap options{
{QStringLiteral("handle_token"), newToken(QStringLiteral("velox_bind_"))}};
call << QVariant::fromValue(QDBusObjectPath(sessionHandle_))
<< QVariant::fromValue(shortcutsArg) << QString() << options;
auto *watcher =
new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<QDBusObjectPath> reply = *watcher;
if (reply.isError()) {
qCInfo(lcShortcut, "BindShortcuts failed: %s", qUtf8Printable(reply.error().message()));
return;
}
connectToRequestResponse(reply.value(), this,
SLOT(onBindShortcutsResponse(uint, QVariantMap)));
});
}
void GlobalShortcut::onBindShortcutsResponse(uint code, const QVariantMap &results) {
if (code != 0) {
// The user declined the "let Velox bind a shortcut" prompt, or the compositor
// doesn't implement the portal even though the service exists. Both silent,
// permanent for this session — see the header comment.
qCInfo(lcShortcut, "BindShortcuts request declined/failed (code %u)", code);
return;
}
qCInfo(lcShortcut, "global shortcut bound: %s", kShortcutId);
Q_UNUSED(results);
QDBusConnection::sessionBus().connect(
QString::fromLatin1(kService), QString::fromLatin1(kObjectPath),
QString::fromLatin1(kShortcutsIface), QStringLiteral("Activated"), this,
SLOT(onPortalActivated(QDBusObjectPath, QString, qulonglong, QVariantMap)));
}
void GlobalShortcut::onPortalActivated(const QDBusObjectPath &sessionHandle,
const QString &shortcutId, qulonglong timestamp,
const QVariantMap &options) {
Q_UNUSED(timestamp);
Q_UNUSED(options);
if (sessionHandle.path() != sessionHandle_ || shortcutId != QLatin1String(kShortcutId)) {
return; // another session/shortcut on the same signal, not ours
}
emit activated();
}
} // namespace velox::gui
-59
View File
@@ -1,59 +0,0 @@
// Explicit clipboard capture, path #2. Lane GUI.
//
// docs/06-risks-and-spikes.md R2: a Wayland client cannot passively observe clipboard
// changes made by other applications — not a bug, a deliberate security property, and
// the mechanism IDM's clipboard capture relies on does not exist here. The ship-regardless
// design has three *explicit* paths instead; this is the second one — a global shortcut
// via org.freedesktop.portal.GlobalShortcuts that reads the clipboard on demand when the
// user presses it. (#1 is the extension's context menu, EXT's; #3 is AddUrlDialog's
// clipboard prefill on open, already in place.)
//
// Best-effort by design, same as the risk doc says to treat all of this: the portal may
// not exist on this desktop, the compositor may not implement it even if the portal
// service does, or the user may decline the one-time "let Velox bind a global shortcut"
// prompt. Every one of those is silent, permanent for this session, and never surfaced as
// an error — there is nothing actionable for the user to do about a desktop that doesn't
// have this, and the explicit paths (menu, prefill) still work regardless. Never promise
// this in the UI before it has actually fired once.
#pragma once
#include <QObject>
#include <QVariantMap>
class QDBusObjectPath;
namespace velox::gui {
class GlobalShortcut : public QObject {
Q_OBJECT
public:
explicit GlobalShortcut(QObject *parent = nullptr);
/// Fire-and-forget: asks the portal for a session, then to bind one shortcut. There is
/// no synchronous "is this supported" answer — connect activated() and find out from
/// whether it ever fires. Safe to call once at startup; safe to call on a desktop with
/// no portal at all (logs and returns, does nothing further).
void requestBinding();
signals:
/// The bound shortcut was pressed. No payload on purpose: the receiver reads the
/// clipboard itself at this moment (the "on demand" part of the explicit-path design),
/// so nothing here ever touches clipboard content that wasn't asked for right now.
void activated();
private slots:
void onCreateSessionResponse(uint code, const QVariantMap &results);
void onBindShortcutsResponse(uint code, const QVariantMap &results);
void onPortalActivated(const QDBusObjectPath &sessionHandle, const QString &shortcutId,
qulonglong timestamp, const QVariantMap &options);
private:
void bindShortcuts();
QString sessionHandle_;
bool requested_ = false;
};
} // namespace velox::gui
+1 -2
View File
@@ -21,7 +21,6 @@
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui {
namespace {
@@ -165,7 +164,7 @@ BatchDialog::BatchDialog(rpc::RpcClient *client, QJsonArray categories, QJsonArr
});
queueCombo_->setEnabled(false);
errorLabel_->setStyleSheet(theme::errorLabelStyle());
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
errorLabel_->setWordWrap(true);
errorLabel_->hide();
+1 -2
View File
@@ -18,7 +18,6 @@
#include <QVBoxLayout>
#include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui {
namespace {
@@ -102,7 +101,7 @@ FileInfoDialog::FileInfoDialog(rpc::RpcClient *client, QString url, QJsonArray c
form->addRow(tr("Buffer:"), bufferCombo_);
form->addRow(QString(), remember);
errorLabel_->setStyleSheet(theme::errorLabelStyle());
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
errorLabel_->setWordWrap(true);
errorLabel_->hide();
+1 -2
View File
@@ -23,7 +23,6 @@
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui {
namespace {
@@ -178,7 +177,7 @@ class GrabberReviewPage : public QWizardPage {
startModeCombo_->addItem(QObject::tr("Download Later"), QStringLiteral("later"));
errorLabel_ = new QLabel(this);
errorLabel_->setStyleSheet(theme::errorLabelStyle());
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
errorLabel_->hide();
auto *footer = new QFormLayout;
+3 -92
View File
@@ -9,7 +9,6 @@
#include <QJsonArray>
#include <QLabel>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QPointer>
#include <QPushButton>
#include <QSpinBox>
@@ -19,7 +18,6 @@
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui {
namespace {
@@ -45,7 +43,7 @@ void setBufferCombo(QComboBox *combo, qint64 bytes) {
combo->setCurrentIndex(combo->count() / 2); // an unrecognized value: land near the middle
}
QStringList splitCsv(const QString &text) {
QStringList splitHosts(const QString &text) {
QStringList out;
for (const QString &h : text.split(QLatin1Char(','), Qt::SkipEmptyParts)) {
out << h.trimmed();
@@ -53,14 +51,6 @@ QStringList splitCsv(const QString &text) {
return out;
}
QString joinArray(const QJsonArray &a) {
QStringList items;
for (const QJsonValue &v : a) {
items << v.toString();
}
return items.join(QStringLiteral(", "));
}
} // namespace
QStringList OptionsDialog::allKeys() {
@@ -71,18 +61,10 @@ QStringList OptionsDialog::allKeys() {
"general.confirmOnExit",
"general.language",
"general.checkForUpdates",
"capture.enabled",
"capture.monitoredExtensions",
"capture.monitoredMimeTypes",
"capture.minSizeBytes",
"capture.excludedHosts",
"capture.bypassModifier",
"capture.autoStartTypes",
"saveTo.defaultDir",
"saveTo.tempDir",
"saveTo.fileExistsPolicy",
"saveTo.createSubfolderPerSite",
"saveTo.allowedRoots",
"connection.preset",
"connection.maxSegmentsPerDownload",
"connection.bufferBytes",
@@ -130,14 +112,13 @@ OptionsDialog::OptionsDialog(rpc::RpcClient *client, QWidget *parent)
resize(560, 480);
buildGeneralTab();
buildCaptureTab();
buildSaveToTab();
buildConnectionTab();
buildDownloadsTab();
buildProxyTab();
buildSoundsTab();
statusLabel_->setStyleSheet(theme::errorLabelStyle());
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
statusLabel_->hide();
auto *buttons = new QDialogButtonBox(
@@ -202,37 +183,6 @@ void OptionsDialog::buildGeneralTab() {
tabs_->addTab(page, tr("General"));
}
void OptionsDialog::buildCaptureTab() {
auto *page = new QWidget(this);
captureEnabled_ = new QCheckBox(tr("Capture downloads from the browser extension"), page);
monitoredExtensions_ = new QLineEdit(page);
monitoredExtensions_->setPlaceholderText(tr("comma-separated, e.g. zip, iso, mp4"));
monitoredMimeTypes_ = new QLineEdit(page);
monitoredMimeTypes_->setPlaceholderText(tr("comma-separated, e.g. application/zip"));
minSizeKiB_ = new QSpinBox(page);
minSizeKiB_->setRange(0, 2000000);
minSizeKiB_->setSuffix(tr(" KiB"));
excludedHosts_ = new QLineEdit(page);
excludedHosts_->setPlaceholderText(tr("comma-separated, e.g. *.google.com"));
bypassModifier_ = new QComboBox(page);
bypassModifier_->addItem(tr("Alt"), QStringLiteral("alt"));
bypassModifier_->addItem(tr("Ctrl"), QStringLiteral("ctrl"));
bypassModifier_->addItem(tr("Shift"), QStringLiteral("shift"));
bypassModifier_->addItem(tr("None"), QStringLiteral("none"));
autoStartTypes_ = new QLineEdit(page);
autoStartTypes_->setPlaceholderText(tr("extensions that skip the File Info dialog"));
auto *form = new QFormLayout(page);
form->addRow(captureEnabled_);
form->addRow(tr("Monitored extensions:"), monitoredExtensions_);
form->addRow(tr("Monitored MIME types:"), monitoredMimeTypes_);
form->addRow(tr("Minimum size:"), minSizeKiB_);
form->addRow(tr("Never capture from:"), excludedHosts_);
form->addRow(tr("Bypass-capture modifier key:"), bypassModifier_);
form->addRow(tr("Auto-start these types:"), autoStartTypes_);
tabs_->addTab(page, tr("Capture"));
}
void OptionsDialog::buildSaveToTab() {
auto *page = new QWidget(this);
defaultDir_ = new QLineEdit(page);
@@ -243,11 +193,6 @@ void OptionsDialog::buildSaveToTab() {
fileExistsPolicy_->addItem(tr("Overwrite"), QStringLiteral("overwrite"));
fileExistsPolicy_->addItem(tr("Resume"), QStringLiteral("resume"));
createSubfolderPerSite_ = new QCheckBox(tr("Create a subfolder per site"), page);
allowedRoots_ = new QPlainTextEdit(page);
allowedRoots_->setPlaceholderText(
tr("One directory per line — every save path must "
"canonicalize inside one of these"));
allowedRoots_->setMaximumHeight(80);
auto *defaultDirRow = new QWidget(page);
auto *defaultDirLayout = new QHBoxLayout(defaultDirRow);
@@ -270,7 +215,6 @@ void OptionsDialog::buildSaveToTab() {
form->addRow(tr("Temp folder:"), tempDirRow);
form->addRow(tr("If a file already exists:"), fileExistsPolicy_);
form->addRow(createSubfolderPerSite_);
form->addRow(tr("Allowed save roots:"), allowedRoots_);
tabs_->addTab(page, tr("Save To"));
}
@@ -446,27 +390,12 @@ void OptionsDialog::populateFrom(const QJsonObject &v) {
language_->setCurrentIndex(langIdx >= 0 ? langIdx : 0);
checkForUpdates_->setChecked(v.value("general.checkForUpdates").toBool(true));
captureEnabled_->setChecked(v.value("capture.enabled").toBool(true));
monitoredExtensions_->setText(joinArray(v.value("capture.monitoredExtensions").toArray()));
monitoredMimeTypes_->setText(joinArray(v.value("capture.monitoredMimeTypes").toArray()));
minSizeKiB_->setValue(static_cast<int>(v.value("capture.minSizeBytes").toDouble() / 1024));
excludedHosts_->setText(joinArray(v.value("capture.excludedHosts").toArray()));
const int bypassIdx =
bypassModifier_->findData(v.value("capture.bypassModifier").toString("alt"));
bypassModifier_->setCurrentIndex(bypassIdx >= 0 ? bypassIdx : 0);
autoStartTypes_->setText(joinArray(v.value("capture.autoStartTypes").toArray()));
defaultDir_->setText(v.value("saveTo.defaultDir").toString());
tempDir_->setText(v.value("saveTo.tempDir").toString());
const int policyIdx =
fileExistsPolicy_->findData(v.value("saveTo.fileExistsPolicy").toString("ask"));
fileExistsPolicy_->setCurrentIndex(policyIdx >= 0 ? policyIdx : 0);
createSubfolderPerSite_->setChecked(v.value("saveTo.createSubfolderPerSite").toBool());
QStringList roots;
for (const QJsonValue &r : v.value("saveTo.allowedRoots").toArray()) {
roots << r.toString();
}
allowedRoots_->setPlainText(roots.join(QLatin1Char('\n')));
const int presetIdx =
connectionPreset_->findData(v.value("connection.preset").toString("auto"));
@@ -523,28 +452,10 @@ QJsonObject OptionsDialog::currentValues() const {
v["general.language"] = language_->currentData().toString();
v["general.checkForUpdates"] = checkForUpdates_->isChecked();
v["capture.enabled"] = captureEnabled_->isChecked();
v["capture.monitoredExtensions"] =
QJsonArray::fromStringList(splitCsv(monitoredExtensions_->text()));
v["capture.monitoredMimeTypes"] =
QJsonArray::fromStringList(splitCsv(monitoredMimeTypes_->text()));
v["capture.minSizeBytes"] = static_cast<qint64>(minSizeKiB_->value()) * 1024;
v["capture.excludedHosts"] = QJsonArray::fromStringList(splitCsv(excludedHosts_->text()));
v["capture.bypassModifier"] = bypassModifier_->currentData().toString();
v["capture.autoStartTypes"] = QJsonArray::fromStringList(splitCsv(autoStartTypes_->text()));
v["saveTo.defaultDir"] = defaultDir_->text();
v["saveTo.tempDir"] = tempDir_->text();
v["saveTo.fileExistsPolicy"] = fileExistsPolicy_->currentData().toString();
v["saveTo.createSubfolderPerSite"] = createSubfolderPerSite_->isChecked();
QStringList roots;
for (const QString &line : allowedRoots_->toPlainText().split(QLatin1Char('\n'))) {
const QString trimmed = line.trimmed();
if (!trimmed.isEmpty()) {
roots << trimmed;
}
}
v["saveTo.allowedRoots"] = QJsonArray::fromStringList(roots);
v["connection.preset"] = connectionPreset_->currentData().toString();
v["connection.maxSegmentsPerDownload"] = maxSegmentsPerDownload_->value();
@@ -568,7 +479,7 @@ QJsonObject OptionsDialog::currentValues() const {
v["proxy.host"] = proxyHost_->text();
v["proxy.port"] = proxyPort_->value();
v["proxy.username"] = proxyUsername_->text();
v["proxy.bypassHosts"] = QJsonArray::fromStringList(splitCsv(proxyBypassHosts_->text()));
v["proxy.bypassHosts"] = QJsonArray::fromStringList(splitHosts(proxyBypassHosts_->text()));
v["proxy.pacUrl"] = proxyPacUrl_->text();
v["sounds.enabled"] = soundsEnabled_->isChecked();
+5 -18
View File
@@ -1,12 +1,11 @@
// The Options dialog. Lane GUI.
//
// docs/03-gui-spec.md §4: every control here maps 1:1 onto a settings.* key from
// contracts/schema/types/Settings.schema.json. The spec's "File Types" tab turned out to
// have real backing after all (capture.monitoredExtensions/monitoredMimeTypes/
// autoStartTypes are settings.* keys, not Category — a mistake in an earlier pass here,
// caught while checking this dialog covers all 43 keys against the now-live real veloxd);
// it is named "Capture" below to match what it actually configures. "Site Logins" still
// has no settings.* key (credentials go to the Secret Service) and stays out.
// contracts/schema/types/Settings.schema.json. The spec's "File Types" and "Site Logins"
// tabs have no backing key (per-category extension lists live on Category via
// category.upsert, not settings.*; login credentials go to the Secret Service) — a real
// tab either binds to a real key or does not exist here, so those two are left out rather
// than shipped as fake affordances.
#pragma once
@@ -17,7 +16,6 @@ class QCheckBox;
class QComboBox;
class QLabel;
class QLineEdit;
class QPlainTextEdit;
class QSpinBox;
class QTabWidget;
@@ -47,7 +45,6 @@ class OptionsDialog : public QDialog {
private:
void buildGeneralTab();
void buildCaptureTab();
void buildSaveToTab();
void buildConnectionTab();
void buildDownloadsTab();
@@ -70,21 +67,11 @@ class OptionsDialog : public QDialog {
QComboBox *language_;
QCheckBox *checkForUpdates_;
// Capture
QCheckBox *captureEnabled_;
QLineEdit *monitoredExtensions_;
QLineEdit *monitoredMimeTypes_;
QSpinBox *minSizeKiB_;
QLineEdit *excludedHosts_;
QComboBox *bypassModifier_;
QLineEdit *autoStartTypes_;
// Save To
QLineEdit *defaultDir_;
QLineEdit *tempDir_;
QComboBox *fileExistsPolicy_;
QCheckBox *createSubfolderPerSite_;
QPlainTextEdit *allowedRoots_;
// Connection
QComboBox *connectionPreset_;
+1 -2
View File
@@ -17,7 +17,6 @@
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui {
namespace {
@@ -127,7 +126,7 @@ SchedulerDialog::SchedulerDialog(rpc::RpcClient *client, QWidget *parent)
}
});
statusLabel_->setStyleSheet(theme::errorLabelStyle());
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
statusLabel_->hide();
form_->setEnabled(false); // no queue selected yet
+1 -2
View File
@@ -12,7 +12,6 @@
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui {
@@ -39,7 +38,7 @@ SpeedLimiterDialog::SpeedLimiterDialog(rpc::RpcClient *client, QWidget *parent)
kibps_->setEnabled(false);
connect(enabled_, &QCheckBox::toggled, kibps_, &QWidget::setEnabled);
statusLabel_->setStyleSheet(theme::errorLabelStyle());
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
statusLabel_->hide();
auto *form = new QFormLayout;
-12
View File
@@ -11,8 +11,6 @@
#include "mainwindow/MainWindow.hpp"
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
#include "util/ThemeManager.hpp"
#include "util/UiThreadWatchdog.hpp"
namespace {
@@ -47,16 +45,6 @@ int main(int argc, char **argv) {
app.installTranslator(&translator);
}
// docs/03-gui-spec.md §7: follows QStyleHints::colorScheme() live, not just at
// startup. Owned by main() (not MainWindow) since it's an application-wide concern.
velox::gui::ThemeManager theme;
theme.apply();
// AGENT-GUI.md M1 DoD: "no blocking call on the UI thread: verified with a 200 ms
// watchdog in debug builds." No-op in a release build — see UiThreadWatchdog::start().
velox::gui::UiThreadWatchdog watchdog;
watchdog.start();
velox::gui::rpc::RpcClient client(defaultSocketPath());
velox::gui::MainWindow window(&client);
window.show();
+20 -57
View File
@@ -37,14 +37,8 @@
#include "rpc/RpcClient.hpp"
#include "tray/TrayIcon.hpp"
#include "util/Format.hpp"
#include "util/Theme.hpp"
#include "widgets/DropTargetWidget.hpp"
#include "widgets/ProgressDelegate.hpp"
#ifdef VELOX_GUI_HAVE_DBUS
#include "clipboard/GlobalShortcut.hpp"
#endif
namespace velox::gui {
namespace {
@@ -94,9 +88,7 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
bannerLabel->setObjectName(QStringLiteral("offlineBannerLabel"));
bannerLayout->addWidget(bannerLabel);
bannerLayout->addStretch();
offlineBanner_->setStyleSheet(
QStringLiteral("background: %1; color: %2;")
.arg(QLatin1String(theme::kOfflineBannerBg), QLatin1String(theme::kOfflineBannerText)));
offlineBanner_->setStyleSheet(QStringLiteral("background: #5a3a00; color: #ffd9a0;"));
offlineBanner_->setVisible(false);
auto *splitter = new QSplitter(Qt::Horizontal, this);
@@ -119,10 +111,9 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
buildMenus();
buildToolBar();
buildTray();
buildDropTarget();
// --- status bar -----------------------------------------------------------------
connDot_->setStyleSheet(dotStyle(QLatin1String(theme::kDanger)));
connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b")));
statusBar()->addPermanentWidget(countsLabel_, 1);
statusBar()->addPermanentWidget(connText_);
statusBar()->addPermanentWidget(connDot_);
@@ -270,23 +261,6 @@ void MainWindow::buildTray() {
trayIcon_->show();
}
void MainWindow::buildDropTarget() {
// Qt::Tool + a parent keeps it grouped with the main window (no separate taskbar
// entry, destroyed when MainWindow is) while still floating independently per spec.
dropTarget_ = new DropTargetWidget(this);
connect(dropTarget_, &DropTargetWidget::urlDropped, this, &MainWindow::onUrlDropped);
connect(dropTarget_, &DropTargetWidget::addUrlRequested, this, &MainWindow::openAddUrlDialog);
#ifdef VELOX_GUI_HAVE_DBUS
// docs/06-risks-and-spikes.md R2, explicit path #2. Best-effort: requestBinding() is
// silent and permanent-for-this-session on any desktop that lacks the portal or
// declines the prompt — see GlobalShortcut's own header for why that's by design.
globalShortcut_ = new GlobalShortcut(this);
connect(globalShortcut_, &GlobalShortcut::activated, this, &MainWindow::openAddUrlDialog);
globalShortcut_->requestBinding();
#endif
}
void MainWindow::closeEvent(QCloseEvent *event) {
if (minimizeToTrayEnabled_ && trayIcon_ && trayIcon_->isVisible()) {
hide();
@@ -301,11 +275,11 @@ void MainWindow::closeEvent(QCloseEvent *event) {
void MainWindow::onConnectionState(rpc::ConnectionState state) {
connText_->setText(tr(rpc::toString(state)));
QString colour = QLatin1String(theme::kDanger);
QString colour = QStringLiteral("#c0392b"); // red
if (state == rpc::ConnectionState::Connected) {
colour = QLatin1String(theme::kSuccess);
colour = QStringLiteral("#27ae60"); // green
} else if (state != rpc::ConnectionState::Disconnected) {
colour = QLatin1String(theme::kWarning);
colour = QStringLiteral("#e67e22"); // amber
}
connDot_->setStyleSheet(dotStyle(colour));
@@ -325,7 +299,7 @@ void MainWindow::onConnectionState(rpc::ConnectionState state) {
if (online) {
fetchTree();
fetchGeneralUiSettings();
fetchMinimizeToTraySetting();
}
}
@@ -344,40 +318,29 @@ void MainWindow::fetchTree() {
});
}
void MainWindow::fetchGeneralUiSettings() {
client_->call(
QStringLiteral("settings.get"),
QJsonObject{{"keys", QJsonArray{QStringLiteral("general.minimizeToTray"),
QStringLiteral("general.showDropTarget")}}},
[this](const rpc::RpcReply &reply) {
if (!reply.ok()) {
return;
}
const QJsonObject values = reply.result.toObject().value("values").toObject();
minimizeToTrayEnabled_ = values.value("general.minimizeToTray").toBool();
if (dropTarget_) {
dropTarget_->setVisible(values.value("general.showDropTarget").toBool(true));
}
});
void MainWindow::fetchMinimizeToTraySetting() {
client_->call(QStringLiteral("settings.get"),
QJsonObject{{"keys", QJsonArray{QStringLiteral("general.minimizeToTray")}}},
[this](const rpc::RpcReply &reply) {
if (reply.ok()) {
minimizeToTrayEnabled_ = reply.result.toObject()
.value("values")
.toObject()
.value("general.minimizeToTray")
.toBool();
}
});
}
void MainWindow::onSettingsChanged(const QJsonObject &params) {
for (const QJsonValue &key : params.value("keys").toArray()) {
const QString k = key.toString();
if (k == QLatin1String("general.minimizeToTray") ||
k == QLatin1String("general.showDropTarget")) {
fetchGeneralUiSettings();
if (key.toString() == QLatin1String("general.minimizeToTray")) {
fetchMinimizeToTraySetting();
break;
}
}
}
void MainWindow::onUrlDropped(const QString &url) {
auto *info = new FileInfoDialog(client_, url, categoriesCache_, queuesCache_, this);
info->setAttribute(Qt::WA_DeleteOnClose);
info->show();
}
void MainWindow::openAddUrlDialog() {
AddUrlDialog dlg(this);
if (dlg.exec() != QDialog::Accepted) {
+1 -11
View File
@@ -26,10 +26,6 @@ namespace velox::gui {
class DownloadTableModel;
class CategoryPanel;
class TrayIcon;
class DropTargetWidget;
#ifdef VELOX_GUI_HAVE_DBUS
class GlobalShortcut;
#endif
namespace rpc {
class RpcClient;
} // namespace rpc
@@ -68,16 +64,14 @@ class MainWindow : public QMainWindow {
void openGrabberWizard();
void showAndRaise();
void onSettingsChanged(const QJsonObject &params);
void onUrlDropped(const QString &url);
private:
void buildActions();
void buildMenus();
void buildToolBar();
void buildTray();
void buildDropTarget();
void fetchTree();
void fetchGeneralUiSettings();
void fetchMinimizeToTraySetting();
QStringList selectedTaskIds() const;
QStringList allTaskIds() const;
void actOnTasks(const char *methodName, const QStringList &ids);
@@ -108,11 +102,7 @@ class MainWindow : public QMainWindow {
QJsonArray categoriesCache_;
QJsonArray queuesCache_;
TrayIcon *trayIcon_ = nullptr;
DropTargetWidget *dropTarget_ = nullptr;
bool minimizeToTrayEnabled_ = false;
#ifdef VELOX_GUI_HAVE_DBUS
GlobalShortcut *globalShortcut_ = nullptr;
#endif
QLabel *connDot_;
QLabel *connText_;
+6 -40
View File
@@ -56,13 +56,6 @@ void RpcClient::stop() {
QMetaObject::invokeMethod(conn_, "stop", Qt::QueuedConnection);
thread_.quit();
thread_.wait();
// thread_.wait() does not return until thread_'s own finish() has already flushed the
// DeferredDelete this class's own connect(&thread_, &QThread::finished, conn_,
// &QObject::deleteLater) posted — conn_ is gone by now. Null it out so a later call
// (stop() is a public slot; a caller stopping and then destroying the client is normal
// use, and the destructor's own `delete conn_` for the never-started case must not
// run a second time against memory this path already freed).
conn_ = nullptr;
}
void RpcClient::call(const QString &methodName, const QJsonObject &params,
@@ -83,44 +76,17 @@ void RpcClient::onConnectionState(int state) {
}
void RpcClient::requestInitialList() {
fetchListPage(0, {});
}
// download.list.schema.json: "Filtering, sorting and paging all happen in the daemon so
// the GUI never materializes 100k rows to show 40" — limit maxes out at 5000, so one call
// cannot ever return everything for a table the DoD's own gate says can hold 10 000 rows.
// A single fixed-limit call here silently truncated the table below that (caught by
// gui/tests/dod's scroll-60fps gate refusing to run against a 1000-row table when mockd
// seeded 10000). Page until `total` is satisfied, then reset the model exactly once.
void RpcClient::fetchListPage(int offset, QJsonArray accumulated) {
constexpr int kPageSize = 5000; // download.list's own maximum
constexpr int kMaxPages = 100; // 500 000 rows — a safety cap, not an expected ceiling
call(QString::fromLatin1(method::kDownloadList),
QJsonObject{{"offset", offset}, {"limit", kPageSize}},
[this, offset, accumulated](const RpcReply &reply) mutable {
call(QString::fromLatin1(method::kDownloadList), QJsonObject{{"limit", 1000}},
[this](const RpcReply &reply) {
if (!reply.ok()) {
qCWarning(lcRpc, "download.list failed: %d %s", reply.error.code,
qUtf8Printable(reply.error.message));
if (!accumulated.isEmpty()) {
emit taskListReset(accumulated); // show what we got rather than nothing
}
return;
}
const QJsonObject result = reply.result.toObject();
const QJsonArray page = result.value("items").toArray();
const qint64 total = static_cast<qint64>(result.value("total").toDouble());
for (const QJsonValue &item : page) {
accumulated.append(item);
}
const bool morePages =
!page.isEmpty() && accumulated.size() < total && (offset / kPageSize) < kMaxPages;
if (morePages) {
fetchListPage(offset + static_cast<int>(page.size()), accumulated);
return;
}
qCInfo(lcRpc, "initial download.list: %lld of %lld row(s)",
static_cast<long long>(accumulated.size()), static_cast<long long>(total));
emit taskListReset(accumulated);
const QJsonArray items = reply.result.toObject().value("items").toArray();
qCInfo(lcRpc, "initial download.list: %lld row(s)",
static_cast<long long>(items.size()));
emit taskListReset(items);
});
}
-1
View File
@@ -67,7 +67,6 @@ class RpcClient : public QObject {
private:
void requestInitialList();
void fetchListPage(int offset, QJsonArray accumulated);
QThread thread_;
RpcConnection *conn_ = nullptr; // owned by thread_ affinity, deleted on thread finish
+4 -6
View File
@@ -154,12 +154,10 @@ void RpcConnection::dispatchFrame(const QJsonObject &frame) {
socket_->abort(); // version mismatch or refused — bounce and retry
return;
}
sendRaw(
kSubscribeId, QString::fromLatin1(method::kSessionSubscribe),
QJsonObject{
{"events", QJsonArray{event::kTaskAdded, event::kTaskRemoved, event::kTaskState,
event::kTaskProgress, event::kSpeedGlobal, event::kNotify,
event::kSettingsChanged, event::kGrabberProgress}}});
sendRaw(kSubscribeId, QString::fromLatin1(method::kSessionSubscribe),
QJsonObject{{"events", QJsonArray{event::kTaskAdded, event::kTaskRemoved,
event::kTaskState, event::kTaskProgress,
event::kSpeedGlobal, event::kNotify}}});
return;
}
if (id == kSubscribeId) {
-33
View File
@@ -1,33 +0,0 @@
// Named semantic colours for the handful of places that set an inline style directly
// (status dot, offline banner, error labels) rather than through the QSS skin. Lane GUI.
//
// docs/agents/AGENT-GUI.md build order step 8: "colours in one variables block... no
// hard-coded hex scattered through widget code." QSS itself has no variable syntax, so
// ThemeManager's stylesheets carry their own documented palette block for everything QSS
// covers; these are the few colours C++ sets directly (a connection-state dot, an error
// label) because they're driven by application state rather than a widget's style role,
// and belong here instead of a fourth copy of the same hex string.
#pragma once
#include <QString>
namespace velox::gui::theme {
// Status-dot / banner colours. Deliberately the same in light and dark — a red "you're
// disconnected" dot needs to stay legible and unambiguous regardless of theme, not
// follow it.
inline constexpr auto kDanger = "#c0392b"; // disconnected, errors
inline constexpr auto kSuccess = "#27ae60"; // connected
inline constexpr auto kWarning = "#e67e22"; // reconnecting
inline constexpr auto kOfflineBannerBg = "#5a3a00";
inline constexpr auto kOfflineBannerText = "#ffd9a0";
/// The inline style every dialog's error label already used identically eleven times
/// over, spelled out once.
inline QString errorLabelStyle() {
return QStringLiteral("color: %1;").arg(QLatin1String(kDanger));
}
} // namespace velox::gui::theme
-42
View File
@@ -1,42 +0,0 @@
#include "util/ThemeManager.hpp"
#include <QApplication>
#include <QFile>
#include <QLoggingCategory>
#include <QStyleHints>
namespace velox::gui {
namespace {
Q_LOGGING_CATEGORY(lcTheme, "velox.gui.theme")
QString loadQss(const QString &resourcePath) {
QFile f(resourcePath);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
qCWarning(lcTheme, "could not load %s", qUtf8Printable(resourcePath));
return {};
}
return QString::fromUtf8(f.readAll());
}
} // namespace
ThemeManager::ThemeManager(QObject *parent) : QObject(parent) {
connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this,
&ThemeManager::onColorSchemeChanged);
}
void ThemeManager::apply() {
const bool dark = QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Dark;
const QString qss =
loadQss(dark ? QStringLiteral(":/qss/dark.qss") : QStringLiteral(":/qss/idm-like.qss"));
if (!qss.isEmpty()) {
qApp->setStyleSheet(qss);
}
}
void ThemeManager::onColorSchemeChanged() {
apply();
}
} // namespace velox::gui
-25
View File
@@ -1,25 +0,0 @@
// Applies gui/resources/qss/{idm-like,dark}.qss and follows the system light/dark
// preference live. Lane GUI. docs/03-gui-spec.md §7.
#pragma once
#include <QObject>
namespace velox::gui {
class ThemeManager : public QObject {
Q_OBJECT
public:
explicit ThemeManager(QObject *parent = nullptr);
/// Loads and applies the stylesheet matching the current
/// QStyleHints::colorScheme(), and connects to colorSchemeChanged() so a live
/// light/dark switch (e.g. GNOME's night-light toggle) re-applies without a restart.
void apply();
private slots:
void onColorSchemeChanged();
};
} // namespace velox::gui
-66
View File
@@ -1,66 +0,0 @@
#include "util/UiThreadWatchdog.hpp"
#include <chrono>
#include <QDateTime>
#include <QLoggingCategory>
#include <QMetaObject>
namespace velox::gui {
namespace {
Q_LOGGING_CATEGORY(lcWatchdog, "velox.gui.watchdog")
constexpr int kPollMs = 50;
constexpr int kStallThresholdMs = 200;
qint64 nowMs() {
return QDateTime::currentMSecsSinceEpoch();
}
} // namespace
UiThreadWatchdog::UiThreadWatchdog(QObject *parent) : QObject(parent) {}
UiThreadWatchdog::~UiThreadWatchdog() {
running_.store(false);
if (worker_.joinable()) {
worker_.join();
}
}
void UiThreadWatchdog::start() {
#ifdef QT_NO_DEBUG
return; // release build: no thread, no overhead
#endif
running_.store(true);
worker_ = std::thread([this] { loop(); });
}
void UiThreadWatchdog::loop() {
while (running_.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(kPollMs));
if (pingInFlight_.load()) {
const qint64 elapsed = nowMs() - pingSentAtMs_.load();
if (elapsed >= kStallThresholdMs && !stalledAlready_.exchange(true)) {
qCWarning(lcWatchdog,
"UI thread has not answered a ping in %lld ms (budget %d ms) — "
"something is blocking it",
static_cast<long long>(elapsed), kStallThresholdMs);
}
continue; // don't pile up a second ping while one is still outstanding
}
stalledAlready_.store(false);
pingSentAtMs_.store(nowMs());
pingInFlight_.store(true);
QMetaObject::invokeMethod(this, "ackFromUiThread", Qt::QueuedConnection);
}
}
void UiThreadWatchdog::ackFromUiThread() {
pingInFlight_.store(false);
}
} // namespace velox::gui
-48
View File
@@ -1,48 +0,0 @@
// Debug-build UI-thread watchdog. Lane GUI.
//
// docs/agents/AGENT-GUI.md M1 DoD: "No blocking call on the UI thread: verified with a
// 200 ms watchdog in debug builds." A background std::thread pings the UI thread every
// 50 ms via a queued QMetaObject::invokeMethod and checks the previous ping actually got
// answered within 200 ms; if not, it logs once (not once per poll — a real stall can last
// seconds, and re-warning every 50 ms of it says nothing new). No Qt event loop, no
// QThread subclass: the only cross-thread contact is the queued invoke itself and three
// atomics, so the watchdog itself can never be what blocks the thread it's watching.
//
// No-op in a release build (`start()` returns immediately when QT_NO_DEBUG is defined) —
// this is a diagnostic, not a feature; it must add zero overhead to what ships.
#pragma once
#include <QObject>
#include <atomic>
#include <thread>
namespace velox::gui {
class UiThreadWatchdog : public QObject {
Q_OBJECT
public:
explicit UiThreadWatchdog(QObject *parent = nullptr);
~UiThreadWatchdog() override;
/// Call once, from the UI thread, after the event loop exists (i.e. anywhere in
/// main() before QApplication::exec()). No-op in a release build.
void start();
public slots:
/// Queued-invoked onto the UI thread by the watchdog's own background thread. Not
/// meant to be called directly.
void ackFromUiThread();
private:
void loop();
std::thread worker_;
std::atomic<bool> running_{false};
std::atomic<bool> pingInFlight_{false};
std::atomic<bool> stalledAlready_{false};
std::atomic<qint64> pingSentAtMs_{0};
};
} // namespace velox::gui
-140
View File
@@ -1,140 +0,0 @@
#include "widgets/DropTargetWidget.hpp"
#include <QCloseEvent>
#include <QContextMenuEvent>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QGuiApplication>
#include <QMenu>
#include <QMimeData>
#include <QMouseEvent>
#include <QPainter>
#include <QScreen>
#include <QSettings>
#include <QUrl>
namespace velox::gui {
namespace {
constexpr int kSize = 56;
} // namespace
DropTargetWidget::DropTargetWidget(QWidget *parent) : QWidget(parent) {
setWindowFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool);
setAttribute(Qt::WA_TranslucentBackground);
setAcceptDrops(true);
setFixedSize(kSize, kSize);
setToolTip(tr("Drop a link here to download it with Velox"));
setMouseTracking(true);
restorePosition();
}
void DropTargetWidget::restorePosition() {
QSettings settings;
const QVariant saved = settings.value(QStringLiteral("dropTarget/pos"));
if (saved.canConvert<QPoint>()) {
move(saved.toPoint());
return;
}
// First run: bottom-right corner of the primary screen, inset from the edge — IDM's
// own default corner.
if (const QScreen *screen = QGuiApplication::primaryScreen()) {
const QRect avail = screen->availableGeometry();
move(avail.right() - kSize - 24, avail.bottom() - kSize - 24);
}
}
void DropTargetWidget::savePosition() {
QSettings settings;
settings.setValue(QStringLiteral("dropTarget/pos"), pos());
}
void DropTargetWidget::closeEvent(QCloseEvent *event) {
savePosition();
QWidget::closeEvent(event);
}
void DropTargetWidget::paintEvent(QPaintEvent * /*event*/) {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
QColor fill = palette().highlight().color();
fill.setAlpha(hovered_ ? 220 : 170);
p.setBrush(fill);
p.setPen(Qt::NoPen);
p.drawEllipse(rect().adjusted(2, 2, -2, -2));
p.setPen(QPen(palette().highlightedText().color(), 2));
const QRectF arrow = rect().adjusted(kSize / 3, kSize / 4, -kSize / 3, -kSize / 3);
p.drawLine(QPointF(arrow.center().x(), arrow.top()),
QPointF(arrow.center().x(), arrow.bottom()));
p.drawLine(QPointF(arrow.center().x(), arrow.bottom()),
QPointF(arrow.left(), arrow.center().y()));
p.drawLine(QPointF(arrow.center().x(), arrow.bottom()),
QPointF(arrow.right(), arrow.center().y()));
}
QString DropTargetWidget::firstUrlFrom(const QMimeData *mime) {
if (mime->hasUrls()) {
for (const QUrl &u : mime->urls()) {
if (u.scheme() == QLatin1String("http") || u.scheme() == QLatin1String("https")) {
return u.toString();
}
}
}
if (mime->hasText()) {
const QUrl u(mime->text().trimmed());
if (u.isValid() &&
(u.scheme() == QLatin1String("http") || u.scheme() == QLatin1String("https"))) {
return u.toString();
}
}
return {};
}
void DropTargetWidget::dragEnterEvent(QDragEnterEvent *event) {
if (!firstUrlFrom(event->mimeData()).isEmpty()) {
event->acceptProposedAction();
hovered_ = true;
update();
}
}
void DropTargetWidget::dropEvent(QDropEvent *event) {
hovered_ = false;
update();
const QString url = firstUrlFrom(event->mimeData());
if (!url.isEmpty()) {
event->acceptProposedAction();
emit urlDropped(url);
}
}
void DropTargetWidget::mousePressEvent(QMouseEvent *event) {
if (event->button() == Qt::LeftButton) {
dragging_ = true;
dragStartOffset_ = event->pos();
}
}
void DropTargetWidget::mouseMoveEvent(QMouseEvent *event) {
if (dragging_) {
move(event->globalPosition().toPoint() - dragStartOffset_);
}
}
void DropTargetWidget::mouseReleaseEvent(QMouseEvent *event) {
if (event->button() == Qt::LeftButton && dragging_) {
dragging_ = false;
savePosition();
}
}
void DropTargetWidget::contextMenuEvent(QContextMenuEvent *event) {
QMenu menu(this);
menu.addAction(tr("Add URL…"), this, &DropTargetWidget::addUrlRequested);
menu.addSeparator();
menu.addAction(tr("Hide"), this, &QWidget::close);
menu.exec(event->globalPos());
}
} // namespace velox::gui
-50
View File
@@ -1,50 +0,0 @@
// The floating drop target. Lane GUI.
//
// docs/03-gui-spec.md §5: "frameless always-on-top QWidget, accepts dropped links,
// right-click menu, position remembered. IDM's drop box, minus the branding." Shown only
// when general.showDropTarget is on (MainWindow owns fetching that setting and toggling
// this widget's visibility, same as it already does for general.minimizeToTray).
#pragma once
#include <QPoint>
#include <QWidget>
class QMimeData;
namespace velox::gui {
class DropTargetWidget : public QWidget {
Q_OBJECT
public:
explicit DropTargetWidget(QWidget *parent = nullptr);
signals:
/// A URL was dropped (from a link, or from plain text that parses as one). The
/// receiver decides what "add a download" means — same contract as AddUrlDialog's
/// accepted URL, just skipping the dialog since this one already has the URL.
void urlDropped(const QString &url);
void addUrlRequested(); // right-click menu's explicit "Add URL…" entry
protected:
void paintEvent(QPaintEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
void dropEvent(QDropEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void contextMenuEvent(QContextMenuEvent *event) override;
void closeEvent(QCloseEvent *event) override;
private:
void restorePosition();
void savePosition();
static QString firstUrlFrom(const QMimeData *mime);
bool dragging_ = false;
QPoint dragStartOffset_;
bool hovered_ = false;
};
} // namespace velox::gui
+3 -20
View File
@@ -82,14 +82,12 @@ set_tests_properties(gui_fileinfodialog PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# tst_optionsdialog OptionsDialog::diffChanged, the "only send what changed" logic, and
# allKeys() against the real schema.
# Red when an unchanged key gets resent, a key missing from `original` stops counting
# as changed, or allKeys() drifts from Settings.schema.json in either direction.
# tst_optionsdialog OptionsDialog::diffChanged, the "only send what changed" logic.
# Red when an unchanged key gets resent, or a key missing from `original` stops
# counting as changed.
add_executable(tst_optionsdialog tst_optionsdialog.cpp)
target_compile_features(tst_optionsdialog PRIVATE cxx_std_23)
target_compile_options(tst_optionsdialog PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_compile_definitions(tst_optionsdialog PRIVATE VELOX_REPO_ROOT="${CMAKE_SOURCE_DIR}")
target_link_libraries(tst_optionsdialog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
add_test(NAME gui_optionsdialog COMMAND tst_optionsdialog)
set_tests_properties(gui_optionsdialog PROPERTIES
@@ -146,21 +144,6 @@ set_tests_properties(gui_grabberwizard PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# tst_uithreadwatchdog the 200 ms debug-build UI-thread watchdog.
# Red when a genuinely blocked UI thread (synchronous sleep, no processEvents) stops
# producing a warning, or a responsive one starts producing a false-positive one.
add_executable(tst_uithreadwatchdog tst_uithreadwatchdog.cpp)
target_compile_features(tst_uithreadwatchdog PRIVATE cxx_std_23)
target_compile_options(tst_uithreadwatchdog PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(tst_uithreadwatchdog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
add_test(NAME gui_uithreadwatchdog COMMAND tst_uithreadwatchdog)
set_tests_properties(gui_uithreadwatchdog PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# gui-dod-harness the M1 DoD gates (scroll-60fps / rss-flat / unhappy-path).
add_subdirectory(dod)
# gui_no_download_logic CLAUDE.md §3 as an executable check, not a hope.
# Red when: a download-logic token (curl, raw pwrite, sqlite, QSqlDatabase) appears
# under gui/src. grep exits 0 only when it finds a match, so a hit fails the test.
-11
View File
@@ -1,11 +0,0 @@
# gui-dod-harness the GUI M1 DoD gates. Lane GUI.
#
# Not a ctest target: run.sh invokes this directly against a mockd it starts and
# tears down itself (gui/docs/pkg-qa-requests-m1.md R3). Built under the same
# VELOX_BUILD_TESTS gate as the rest of gui/tests since it only ever runs in CI/dev, never
# ships.
add_executable(gui-dod-harness dod_harness.cpp)
target_compile_features(gui-dod-harness PRIVATE cxx_std_23)
target_compile_options(gui-dod-harness PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(gui-dod-harness PRIVATE velox-gui-lib Qt6::Widgets)
-416
View File
@@ -1,416 +0,0 @@
// GUI M1 DoD gate harness. Lane GUI.
//
// gui/docs/pkg-qa-requests-m1.md R3 / tests/integration/README.md: PKG/QA's CI job
// invokes this (via run.sh) as `<gate> --sock <path> [--json <path>]`, one gate per run:
//
// scroll-60fps — mockd --tasks 10000, a scripted scroll over the whole table; fail on
// a p99 per-step paint time over budget (16.6 ms, i.e. 60 fps).
// rss-flat — mockd --tasks 10000 streaming progress for --duration-sec (default
// 600 = 10 min); fail if RSS grows past a fixed slack after warm-up.
// unhappy-path — one phase (--phase slow|flaky|drop-connection, label only: the actual
// mockd flag is run.sh's job) against a client that must reach
// Connected and hold it, no crash, no hang.
//
// Exit 0 pass, non-zero fail. --json <path> writes one result object. A watchdog timer
// converts a hang into a non-zero exit itself — nothing here should ever need an external
// timeout(1) to end it.
//
// "Fling scroll" and "frame" are approximate in a headless/offscreen run: there is no
// compositor to hand a real frame to, so what is measured is wall-clock time for one
// scroll step's model-driven repaint — the CPU cost a real frame would also have to pay,
// just without a GPU present/vsync on top of it. That is the part a progress-patch
// regression or a delegate doing needless work would actually blow.
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <functional>
#include <numeric>
#include <vector>
#include <QApplication>
#include <QCommandLineParser>
#include <QElapsedTimer>
#include <QEventLoop>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <QScrollBar>
#include <QTimer>
#include <QTreeView>
#include "models/DownloadTableModel.hpp"
#include "rpc/RpcClient.hpp"
#include "widgets/ProgressDelegate.hpp"
using velox::gui::DownloadTableModel;
using velox::gui::ProgressDelegate;
namespace rpc = velox::gui::rpc;
namespace {
// Pumps the event loop in small slices until `pred` is true or `timeoutMs` elapses.
// Never blocks longer than that — every wait in this file is bounded, which is what lets
// the process reach its own exit(1) instead of needing the watchdog for the common case.
bool waitFor(const std::function<bool()> &pred, int timeoutMs) {
QElapsedTimer t;
t.start();
while (!pred() && t.elapsed() < timeoutMs) {
QCoreApplication::processEvents(QEventLoop::AllEvents, 20);
}
return pred();
}
qint64 readRssKiB() {
QFile f(QStringLiteral("/proc/self/status"));
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
return -1;
}
static const QRegularExpression kWs(QStringLiteral("\\s+"));
for (const QByteArray &lineBytes : f.readAll().split('\n')) {
const QString line = QString::fromLatin1(lineBytes);
if (line.startsWith(QLatin1String("VmRSS:"))) {
const QStringList parts = line.split(kWs, Qt::SkipEmptyParts);
if (parts.size() >= 2) {
bool ok = false;
const qint64 kib = parts[1].toLongLong(&ok);
return ok ? kib : -1;
}
}
}
return -1;
}
double percentile(std::vector<double> v, double p) {
if (v.empty()) {
return 0.0;
}
std::sort(v.begin(), v.end());
int idx = static_cast<int>(std::ceil(p * static_cast<double>(v.size()))) - 1;
idx = std::clamp(idx, 0, static_cast<int>(v.size()) - 1);
return v[static_cast<std::size_t>(idx)];
}
// ASan/UBSan add real overhead to every paint; the pre-drafted CI job builds this with
// `cmake --preset dev`, which is ASan+UBSan by default (CLAUDE.md: "default for all
// lanes"). Scaling the budget under a sanitized build is an honest adjustment for
// instrumentation cost, not a loosened bar — VELOX_DOD_FRAME_BUDGET_MS still overrides it
// outright for whoever wants to tune this per-runner.
bool isSanitizedBuild() {
#if defined(__SANITIZE_ADDRESS__) || defined(__SANITIZE_THREAD__)
return true;
#elif defined(__has_feature)
#if __has_feature(address_sanitizer) || __has_feature(thread_sanitizer)
return true;
#else
return false;
#endif
#else
return false;
#endif
}
void writeJson(const QString &path, const QJsonObject &obj) {
if (path.isEmpty()) {
return;
}
QFile f(path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
std::fprintf(stderr, "warning: could not write --json output to %s\n",
qUtf8Printable(path));
return;
}
f.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
}
void wireModel(rpc::RpcClient *client, DownloadTableModel *model) {
QObject::connect(client, &rpc::RpcClient::taskListReset, model,
&DownloadTableModel::resetFromJson);
QObject::connect(client, &rpc::RpcClient::taskProgress, model,
&DownloadTableModel::applyProgress);
QObject::connect(client, &rpc::RpcClient::taskAdded, model,
&DownloadTableModel::applyTaskAdded);
QObject::connect(client, &rpc::RpcClient::taskStateChanged, model,
&DownloadTableModel::applyTaskState);
QObject::connect(client, &rpc::RpcClient::taskRemoved, model,
&DownloadTableModel::applyTaskRemoved);
}
int runScroll60Fps(rpc::RpcClient &client, const QString &jsonPath) {
DownloadTableModel model;
wireModel(&client, &model);
if (!waitFor([&] { return client.state() == rpc::ConnectionState::Connected; }, 15000)) {
std::fprintf(stderr, "FAIL: never reached Connected\n");
return 1;
}
if (!waitFor([&] { return model.rowCount() >= 9000; }, 15000)) {
std::fprintf(stderr,
"FAIL: table never loaded (rowCount=%d) — run mockd with "
"--tasks 10000\n",
model.rowCount());
return 1;
}
QTreeView view;
view.setModel(&model);
view.setUniformRowHeights(true);
view.setItemDelegateForColumn(DownloadTableModel::ColStatus, new ProgressDelegate(&view));
view.resize(1000, 700);
view.show();
waitFor([] { return false; }, 100); // let the initial show/layout settle
auto *bar = view.verticalScrollBar();
const int maxV = bar->maximum();
if (maxV <= 0) {
std::fprintf(stderr, "FAIL: nothing to scroll (scrollbar max=%d)\n", maxV);
return 1;
}
// A scripted "fling": ease-out steps (big jumps first, settling to small ones), the
// shape a real flick-scroll decelerates through, rather than a uniform crawl.
constexpr int kSteps = 240;
std::vector<double> frameMs;
frameMs.reserve(kSteps);
for (int i = 1; i <= kSteps; ++i) {
const double t = static_cast<double>(i) / kSteps;
const double eased = 1.0 - std::pow(1.0 - t, 3.0);
const int value = static_cast<int>(static_cast<double>(maxV) * eased);
QElapsedTimer frame;
frame.start();
bar->setValue(value);
QCoreApplication::sendPostedEvents();
view.viewport()->repaint(); // synchronous: times the paint, not just the request
frameMs.push_back(static_cast<double>(frame.nsecsElapsed()) / 1e6);
}
const double p99 = percentile(frameMs, 0.99);
const double maxMs = *std::max_element(frameMs.begin(), frameMs.end());
const double meanMs =
std::accumulate(frameMs.begin(), frameMs.end(), 0.0) / static_cast<double>(frameMs.size());
double budgetMs = 16.6;
if (isSanitizedBuild()) {
budgetMs *= 4.0; // instrumentation overhead, not a lowered bar — see isSanitizedBuild()
}
const QString override = qEnvironmentVariable("VELOX_DOD_FRAME_BUDGET_MS");
if (!override.isEmpty()) {
bool ok = false;
const double v = override.toDouble(&ok);
if (ok) {
budgetMs = v;
}
}
const bool pass = p99 <= budgetMs;
std::printf("%s: p99=%.2f ms mean=%.2f ms max=%.2f ms budget=%.2f ms over %d steps, %d rows\n",
pass ? "PASS" : "FAIL", p99, meanMs, maxMs, budgetMs, kSteps, model.rowCount());
writeJson(jsonPath, QJsonObject{
{"gate", "scroll-60fps"},
{"rows", model.rowCount()},
{"steps", kSteps},
{"p99Ms", p99},
{"meanMs", meanMs},
{"maxMs", maxMs},
{"budgetMs", budgetMs},
{"sanitized", isSanitizedBuild()},
{"pass", pass},
});
return pass ? 0 : 1;
}
int runRssFlat(rpc::RpcClient &client, const QString &jsonPath, int durationSec) {
DownloadTableModel model;
wireModel(&client, &model);
if (!waitFor([&] { return client.state() == rpc::ConnectionState::Connected; }, 15000)) {
std::fprintf(stderr, "FAIL: never reached Connected\n");
return 1;
}
if (!waitFor([&] { return model.rowCount() >= 9000; }, 15000)) {
std::fprintf(stderr,
"FAIL: table never loaded (rowCount=%d) — run mockd with "
"--tasks 10000\n",
model.rowCount());
return 1;
}
// Kept visible: a hidden model-only run would miss any leak that lives in painting
// (delegate scratch state, style caches) rather than in the model's own row patches.
QTreeView view;
view.setModel(&model);
view.setUniformRowHeights(true);
view.setItemDelegateForColumn(DownloadTableModel::ColStatus, new ProgressDelegate(&view));
view.resize(1000, 700);
view.show();
const int warmupSec = std::min(30, std::max(1, durationSec / 10));
QJsonArray series;
std::vector<qint64> afterWarmup;
QEventLoop loop;
QTimer sampler;
sampler.setInterval(1000);
int elapsedSec = 0;
QObject::connect(&sampler, &QTimer::timeout, [&] {
++elapsedSec;
const qint64 rssKiB = readRssKiB();
series.append(QJsonObject{{"t", elapsedSec}, {"rssKiB", rssKiB}});
if (elapsedSec > warmupSec) {
afterWarmup.push_back(rssKiB);
}
if (elapsedSec >= durationSec) {
loop.quit();
}
});
sampler.start();
loop.exec();
qint64 growthKiB = 0;
if (afterWarmup.size() >= 2) {
const qint64 minRss = *std::min_element(afterWarmup.begin(), afterWarmup.end());
growthKiB = afterWarmup.back() - minRss;
}
qint64 slackKiB = 20 * 1024; // 20 MiB: see gui/docs/pkg-qa-requests-m1.md R3 for why
const QString override = qEnvironmentVariable("VELOX_DOD_RSS_SLACK_KIB");
if (!override.isEmpty()) {
bool ok = false;
const qint64 v = override.toLongLong(&ok);
if (ok) {
slackKiB = v;
}
}
const bool pass = afterWarmup.size() >= 2 && growthKiB <= slackKiB;
std::printf("%s: growth=%lld KiB slack=%lld KiB over %ds (warmup %ds), %d rows\n",
pass ? "PASS" : "FAIL", static_cast<long long>(growthKiB),
static_cast<long long>(slackKiB), durationSec, warmupSec, model.rowCount());
writeJson(jsonPath, QJsonObject{
{"gate", "rss-flat"},
{"rows", model.rowCount()},
{"durationSec", durationSec},
{"warmupSec", warmupSec},
{"growthKiB", growthKiB},
{"slackKiB", slackKiB},
{"series", series},
{"pass", pass},
});
return pass ? 0 : 1;
}
int runUnhappyPath(rpc::RpcClient &client, const QString &jsonPath, const QString &phase) {
bool sawDisconnectOrReconnecting = false;
QObject::connect(&client, &rpc::RpcClient::stateChanged, &client, [&](rpc::ConnectionState s) {
if (s == rpc::ConnectionState::Reconnecting || s == rpc::ConnectionState::Disconnected) {
sawDisconnectOrReconnecting = true;
}
});
// 45 s covers mockd's slowest documented --slow value plus a couple of backoff
// cycles; the harness's own watchdog (see main()) is the real ceiling on a hang.
constexpr int kObserveMs = 45000;
const bool reachedConnected =
waitFor([&] { return client.state() == rpc::ConnectionState::Connected; }, kObserveMs);
QElapsedTimer t;
t.start();
while (t.elapsed() < kObserveMs) {
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
}
const bool finalConnected = client.state() == rpc::ConnectionState::Connected;
const bool pass = reachedConnected && finalConnected;
std::printf("%s [%s]: reachedConnected=%d finalConnected=%d sawDisruption=%d\n",
pass ? "PASS" : "FAIL", qUtf8Printable(phase), reachedConnected, finalConnected,
sawDisconnectOrReconnecting);
writeJson(jsonPath, QJsonObject{
{"gate", "unhappy-path"},
{"phase", phase},
{"reachedConnected", reachedConnected},
{"finalConnected", finalConnected},
{"sawDisruption", sawDisconnectOrReconnecting},
{"pass", pass},
});
return pass ? 0 : 1;
}
} // namespace
int main(int argc, char **argv) {
QApplication app(argc, argv);
qRegisterMetaType<rpc::ConnectionState>();
qRegisterMetaType<rpc::RpcReply>();
QCommandLineParser parser;
parser.setApplicationDescription(
QStringLiteral("GUI M1 DoD gate harness (gui/docs/pkg-qa-requests-m1.md R3)"));
parser.addHelpOption();
parser.addPositionalArgument(QStringLiteral("gate"),
QStringLiteral("scroll-60fps | rss-flat | unhappy-path"));
QCommandLineOption sockOpt(QStringLiteral("sock"), QStringLiteral("veloxd UDS socket path"),
QStringLiteral("path"));
QCommandLineOption jsonOpt(QStringLiteral("json"),
QStringLiteral("write one result object here"),
QStringLiteral("path"));
QCommandLineOption durationOpt(QStringLiteral("duration-sec"),
QStringLiteral("rss-flat duration override (default 600)"),
QStringLiteral("sec"));
QCommandLineOption phaseOpt(QStringLiteral("phase"),
QStringLiteral("unhappy-path sub-phase label, for the JSON only"),
QStringLiteral("phase"), QStringLiteral("unspecified"));
parser.addOption(sockOpt);
parser.addOption(jsonOpt);
parser.addOption(durationOpt);
parser.addOption(phaseOpt);
parser.process(app);
const QStringList pos = parser.positionalArguments();
if (pos.isEmpty() || !parser.isSet(sockOpt)) {
std::fprintf(stderr,
"usage: dod_harness <gate> --sock <path> [--json <path>] "
"[--duration-sec <n>] [--phase <label>]\n");
return 2;
}
const QString gate = pos.first();
const int durationSec = parser.isSet(durationOpt) ? parser.value(durationOpt).toInt() : 600;
rpc::RpcClient client(parser.value(sockOpt));
client.start();
// The harness's own watchdog: whatever gate is running, it must exit on its own by
// this ceiling. Firing is itself a failure (a hang), not a signal for the caller to
// timeout(1) around — see the file comment.
int watchdogSec = 120;
if (gate == QLatin1String("rss-flat")) {
watchdogSec = durationSec + 90;
} else if (gate == QLatin1String("unhappy-path")) {
watchdogSec = 75;
}
QTimer watchdog;
watchdog.setSingleShot(true);
QObject::connect(&watchdog, &QTimer::timeout, [watchdogSec] {
std::fprintf(stderr, "FAIL: dod_harness watchdog fired — hung past %ds\n", watchdogSec);
std::exit(3);
});
watchdog.start(watchdogSec * 1000);
int rc = 2;
if (gate == QLatin1String("scroll-60fps")) {
rc = runScroll60Fps(client, parser.value(jsonOpt));
} else if (gate == QLatin1String("rss-flat")) {
rc = runRssFlat(client, parser.value(jsonOpt), durationSec);
} else if (gate == QLatin1String("unhappy-path")) {
rc = runUnhappyPath(client, parser.value(jsonOpt), parser.value(phaseOpt));
} else {
std::fprintf(stderr, "unknown gate: %s\n", qUtf8Printable(gate));
}
client.stop();
return rc;
}
-197
View File
@@ -1,197 +0,0 @@
#!/usr/bin/env bash
# GUI M1 DoD gate driver. Lane GUI.
#
# gui/docs/pkg-qa-requests-m1.md R3 / tests/integration/README.md's invocation contract:
#
# gui/tests/dod/run.sh <gate> [--json <path>]
#
# <gate> is one of: scroll-60fps | rss-flat | unhappy-path
#
# Headless-capable: works under offscreen QT_QPA_PLATFORM (the default here) or under
# Xvfb (xvfb-run -a gui/tests/dod/run.sh ...) if DISPLAY is already set. Exit 0 pass,
# non-zero fail. Spawns and tears down its own mockd; no network, no writes outside a
# tempdir except the caller's --json path; never leaves a child process running, on
# either exit path (see cleanup() / the EXIT trap).
#
# Env overrides, for local iteration — CI's real run uses none of these:
# VELOX_BUILD_DIR build directory holding bin/gui-dod-harness (default: the
# first of build/ci, build/dev that has the binary)
# VELOX_DOD_RSS_DURATION_SEC shorten the 10-minute rss-flat soak
# VELOX_DOD_FRAME_BUDGET_MS override the scroll-60fps per-step budget (default 16.6,
# x4 under a sanitized build — see dod_harness.cpp)
# VELOX_DOD_RSS_SLACK_KIB override the rss-flat growth slack (default 20*1024)
set -u -o pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
MOCKD_DIR="$REPO_ROOT/tools/mockd"
usage() {
echo "usage: $0 <scroll-60fps|rss-flat|unhappy-path> [--json <path>]" >&2
exit 2
}
GATE="${1:-}"
[ -n "$GATE" ] || usage
shift || true
JSON_PATH=""
while [ $# -gt 0 ]; do
case "$1" in
--json) JSON_PATH="$2"; shift 2 ;;
*) echo "unknown argument: $1" >&2; usage ;;
esac
done
case "$GATE" in
scroll-60fps|rss-flat|unhappy-path) ;;
*) usage ;;
esac
# --- locate the harness binary -----------------------------------------------------------
HARNESS=""
for d in "${VELOX_BUILD_DIR:-}" "$REPO_ROOT/build/ci" "$REPO_ROOT/build/dev"; do
[ -n "$d" ] || continue
if [ -x "$d/bin/gui-dod-harness" ]; then
HARNESS="$d/bin/gui-dod-harness"
break
fi
done
if [ -z "$HARNESS" ]; then
echo "FAIL: gui-dod-harness not found. Build it first:" >&2
echo " cmake --preset dev && cmake --build --preset dev --target gui-dod-harness" >&2
exit 2
fi
# --- locate mockd's runner ----------------------------------------------------------------
MOCKD_RUNNER=""
if [ -x "$MOCKD_DIR/node_modules/.bin/tsx" ]; then
MOCKD_RUNNER=("$MOCKD_DIR/node_modules/.bin/tsx" "$MOCKD_DIR/src/index.ts")
elif command -v npx >/dev/null 2>&1; then
MOCKD_RUNNER=(npx --prefix "$MOCKD_DIR" tsx "$MOCKD_DIR/src/index.ts")
else
echo "FAIL: no tsx runner for mockd found. Run: (cd tools/mockd && npm ci)" >&2
exit 2
fi
: "${QT_QPA_PLATFORM:=offscreen}"
export QT_QPA_PLATFORM
# --- isolated runtime dir, and the socket mockd/the harness will use -----------------------
WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/velox-gui-dod.XXXXXX")"
export XDG_RUNTIME_DIR="$WORKDIR/xdg"
mkdir -p "$XDG_RUNTIME_DIR/velox"
SOCK="$XDG_RUNTIME_DIR/velox/velox.sock"
MOCKD_PID=""
cleanup() {
if [ -n "$MOCKD_PID" ] && kill -0 "$MOCKD_PID" 2>/dev/null; then
kill "$MOCKD_PID" 2>/dev/null
wait "$MOCKD_PID" 2>/dev/null
fi
rm -rf "$WORKDIR"
}
trap cleanup EXIT INT TERM
start_mockd() {
# "$@" are extra mockd flags for this phase.
rm -f "$SOCK"
( cd "$MOCKD_DIR" && exec "${MOCKD_RUNNER[@]}" --no-ws "$@" ) \
>"$WORKDIR/mockd.log" 2>&1 &
MOCKD_PID=$!
local waited=0
while [ ! -S "$SOCK" ]; do
if ! kill -0 "$MOCKD_PID" 2>/dev/null; then
echo "FAIL: mockd exited before listening. Log:" >&2
cat "$WORKDIR/mockd.log" >&2
return 1
fi
sleep 0.2
waited=$((waited + 1))
if [ "$waited" -gt 100 ]; then
echo "FAIL: mockd never created its socket within 20s" >&2
return 1
fi
done
return 0
}
stop_mockd() {
if [ -n "$MOCKD_PID" ] && kill -0 "$MOCKD_PID" 2>/dev/null; then
kill "$MOCKD_PID" 2>/dev/null
wait "$MOCKD_PID" 2>/dev/null
fi
MOCKD_PID=""
}
# run_harness <extra harness args...> — belt-and-braces external timeout on top of the
# harness's own internal watchdog: if the Qt event loop itself ever wedges, its QTimer
# watchdog can't fire either, and this is what still turns that into a bounded failure
# instead of a wait forever. Either way this script's own caller never needs timeout(1).
run_harness() {
timeout --signal=KILL "$1" "$HARNESS" --sock "$SOCK" "${@:2}"
}
JSON_ARGS=()
[ -n "$JSON_PATH" ] && JSON_ARGS=(--json "$JSON_PATH")
case "$GATE" in
scroll-60fps)
start_mockd --tasks 10000 --seed 1 || exit 1
run_harness 90 scroll-60fps "${JSON_ARGS[@]}"
RC=$?
stop_mockd
exit "$RC"
;;
rss-flat)
DURATION="${VELOX_DOD_RSS_DURATION_SEC:-600}"
start_mockd --tasks 10000 --seed 1 || exit 1
run_harness "$((DURATION + 120))" rss-flat --duration-sec "$DURATION" "${JSON_ARGS[@]}"
RC=$?
stop_mockd
exit "$RC"
;;
unhappy-path)
# Three phases, one mockd flag each; every phase must pass. mockd's own README
# documents these flags (--slow/--flaky/--drop-connection).
OVERALL_RC=0
declare -A PHASE_FLAGS=(
[slow]="--slow 900"
[flaky]="--flaky 0.3"
[drop-connection]="--drop-connection 5"
)
MERGED="{}"
for phase in slow flaky drop-connection; do
# shellcheck disable=SC2206
flags=(${PHASE_FLAGS[$phase]})
start_mockd "${flags[@]}" || { OVERALL_RC=1; continue; }
PHASE_JSON="$WORKDIR/phase-$phase.json"
run_harness 75 unhappy-path --phase "$phase" --json "$PHASE_JSON"
RC=$?
stop_mockd
[ "$RC" -eq 0 ] || OVERALL_RC=1
if [ -n "$JSON_PATH" ] && [ -f "$PHASE_JSON" ]; then
MERGED="$(python3 -c "
import json, sys
merged = json.loads(sys.argv[1])
phase = json.load(open(sys.argv[2]))
merged.setdefault('gate', 'unhappy-path')
merged.setdefault('phases', {})
merged['phases'][sys.argv[3]] = phase
print(json.dumps(merged))
" "$MERGED" "$PHASE_JSON" "$phase")"
fi
done
if [ -n "$JSON_PATH" ]; then
python3 -c "
import json, sys
merged = json.loads(sys.argv[1])
merged['pass'] = sys.argv[2] == '0'
json.dump(merged, open(sys.argv[3], 'w'), indent=2)
" "$MERGED" "$OVERALL_RC" "$JSON_PATH"
fi
exit "$OVERALL_RC"
;;
esac
-22
View File
@@ -5,8 +5,6 @@
// effect" contract and spams event.settings.changed with noise), or a key present in
// `current` but absent from `original` stops being treated as changed.
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QtTest>
@@ -22,7 +20,6 @@ class TstOptionsDialog : public QObject {
void onlyChangedKeysAreReturned();
void keyAbsentFromOriginalCountsAsChanged();
void allKeysAreNonEmptyAndUnique();
void allKeysMatchesTheSchemaExactly();
};
void TstOptionsDialog::identicalValuesProduceNoDiff() {
@@ -53,24 +50,5 @@ void TstOptionsDialog::allKeysAreNonEmptyAndUnique() {
QCOMPARE(QSet<QString>(keys.begin(), keys.end()).size(), keys.size());
}
// Red when a key is added to (or removed from) Settings.schema.json without the same
// change landing here — either direction is a real bug: an invented key settings.set
// would reject with -32602, or a real key the dialog silently never shows.
void TstOptionsDialog::allKeysMatchesTheSchemaExactly() {
QFile f(QStringLiteral(VELOX_REPO_ROOT "/contracts/schema/types/Settings.schema.json"));
QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable(f.errorString()));
const QJsonObject schema = QJsonDocument::fromJson(f.readAll()).object();
const QJsonObject properties = schema.value("properties").toObject();
QVERIFY(!properties.isEmpty());
QSet<QString> schemaKeys;
for (auto it = properties.constBegin(); it != properties.constEnd(); ++it) {
schemaKeys.insert(it.key());
}
const QStringList dialogKeysList = OptionsDialog::allKeys();
const QSet<QString> dialogKeys(dialogKeysList.begin(), dialogKeysList.end());
QCOMPARE(dialogKeys, schemaKeys);
}
QTEST_MAIN(TstOptionsDialog)
#include "tst_optionsdialog.moc"
-89
View File
@@ -1,89 +0,0 @@
// UiThreadWatchdog unit tests. Lane GUI.
//
// Red when a genuinely blocked UI thread (a synchronous sleep with no processEvents in
// between — the exact shape of the bug this exists to catch) stops producing a warning,
// or a responsive one starts producing a false-positive one.
#include <QMutex>
#include <QMutexLocker>
#include <QThread>
#include <QtTest>
#include "util/UiThreadWatchdog.hpp"
using velox::gui::UiThreadWatchdog;
namespace {
QMutex g_mutex;
QString g_lastWarning;
QtMessageHandler g_prevHandler = nullptr;
// Qt's own message handler is process-global and can run on any thread — the watchdog's
// warning comes from its background thread while this test's main thread is deliberately
// blocked, so this needs real synchronization, not just a plain global (this test runs
// under the `tsan` preset too).
void captureHandler(QtMsgType type, const QMessageLogContext &ctx, const QString &msg) {
if (type == QtWarningMsg) {
QMutexLocker locker(&g_mutex);
g_lastWarning = msg;
}
if (g_prevHandler) {
g_prevHandler(type, ctx, msg);
}
}
QString lastWarning() {
QMutexLocker locker(&g_mutex);
return g_lastWarning;
}
} // namespace
class TstUiThreadWatchdog : public QObject {
Q_OBJECT
private slots:
void init();
void cleanup();
void firesOnABlockedUiThread();
void staysQuietWhenResponsive();
};
void TstUiThreadWatchdog::init() {
QMutexLocker locker(&g_mutex);
g_lastWarning.clear();
g_prevHandler = qInstallMessageHandler(captureHandler);
}
void TstUiThreadWatchdog::cleanup() {
qInstallMessageHandler(g_prevHandler);
}
void TstUiThreadWatchdog::firesOnABlockedUiThread() {
UiThreadWatchdog wd;
wd.start();
// Block this thread (the watchdog's "UI thread" here) synchronously and well past
// the 200 ms budget — no processEvents at all, exactly what a real stall looks like
// and exactly what this exists to catch.
QThread::msleep(500);
// Let the event loop run so the queued ack the watchdog sent before the sleep started
// finally lands (harmless — the warning it's checking for already fired mid-sleep,
// from the watchdog's own background thread).
QTest::qWait(150);
QVERIFY2(lastWarning().contains(QStringLiteral("blocking")), qUtf8Printable(lastWarning()));
}
void TstUiThreadWatchdog::staysQuietWhenResponsive() {
UiThreadWatchdog wd;
wd.start();
QTest::qWait(400); // event loop stays responsive throughout — well past the budget
QVERIFY(lastWarning().isEmpty());
}
QTEST_MAIN(TstUiThreadWatchdog)
#include "tst_uithreadwatchdog.moc"