diff --git a/docs/06-risks-and-spikes.md b/docs/06-risks-and-spikes.md index 251fcfe..40713d1 100644 --- a/docs/06-risks-and-spikes.md +++ b/docs/06-risks-and-spikes.md @@ -53,6 +53,24 @@ 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 diff --git a/gui/CMakeLists.txt b/gui/CMakeLists.txt index 69dcb44..e6d0da8 100644 --- a/gui/CMakeLists.txt +++ b/gui/CMakeLists.txt @@ -35,10 +35,21 @@ 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) @@ -47,8 +58,23 @@ 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) diff --git a/gui/docs/pkg-qa-requests-m1.md b/gui/docs/pkg-qa-requests-m1.md index 96579be..f4acf3b 100644 --- a/gui/docs/pkg-qa-requests-m1.md +++ b/gui/docs/pkg-qa-requests-m1.md @@ -121,26 +121,101 @@ Notes for whoever applies it: --- -## R3 — the GUI DoD gates have nowhere to run in CI +## R3 — the GUI DoD gates have nowhere to run in CI — RESOLVED, ready to wire in -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. +**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. -What has no home is the part of the GUI M1 definition of done that isn't a unit test: +**Path:** `gui/tests/dod/run.sh [--json ]`, exactly the contract +`tests/integration/README.md` specifies. `` 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. -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 `, `--drop-connection `: - 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`. +**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. -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. +**`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). diff --git a/gui/docs/proto-requests-m1.md b/gui/docs/proto-requests-m1.md new file mode 100644 index 0000000..32439ac --- /dev/null +++ b/gui/docs/proto-requests-m1.md @@ -0,0 +1,105 @@ +# 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 ` 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, + log: (msg: string) => void, + delayMs: number, ++ dropEverySec: number = 0, + ): Server { + mkdirSync(dirname(path), { recursive: true }); + rmSync(path, { force: true }); + ++ const sockets = new Set(); + 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. diff --git a/gui/resources/qss/dark.qss b/gui/resources/qss/dark.qss new file mode 100644 index 0000000..4eb29cf --- /dev/null +++ b/gui/resources/qss/dark.qss @@ -0,0 +1,112 @@ +/* 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; +} diff --git a/gui/resources/qss/idm-like.qss b/gui/resources/qss/idm-like.qss new file mode 100644 index 0000000..ab728ef --- /dev/null +++ b/gui/resources/qss/idm-like.qss @@ -0,0 +1,119 @@ +/* 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; +} diff --git a/gui/src/clipboard/GlobalShortcut.cpp b/gui/src/clipboard/GlobalShortcut.cpp new file mode 100644 index 0000000..5a4cf76 --- /dev/null +++ b/gui/src/clipboard/GlobalShortcut.cpp @@ -0,0 +1,172 @@ +#include "clipboard/GlobalShortcut.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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()); + 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 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 diff --git a/gui/src/clipboard/GlobalShortcut.hpp b/gui/src/clipboard/GlobalShortcut.hpp new file mode 100644 index 0000000..c72dca5 --- /dev/null +++ b/gui/src/clipboard/GlobalShortcut.hpp @@ -0,0 +1,59 @@ +// 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 +#include + +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 diff --git a/gui/src/dialogs/BatchDialog.cpp b/gui/src/dialogs/BatchDialog.cpp index 4046695..9e79d69 100644 --- a/gui/src/dialogs/BatchDialog.cpp +++ b/gui/src/dialogs/BatchDialog.cpp @@ -21,6 +21,7 @@ #include "rpc/Protocol.hpp" #include "rpc/RpcClient.hpp" +#include "util/Theme.hpp" namespace velox::gui { namespace { @@ -164,7 +165,7 @@ BatchDialog::BatchDialog(rpc::RpcClient *client, QJsonArray categories, QJsonArr }); queueCombo_->setEnabled(false); - errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); + errorLabel_->setStyleSheet(theme::errorLabelStyle()); errorLabel_->setWordWrap(true); errorLabel_->hide(); diff --git a/gui/src/dialogs/FileInfoDialog.cpp b/gui/src/dialogs/FileInfoDialog.cpp index 7ce8b35..a7b0cf4 100644 --- a/gui/src/dialogs/FileInfoDialog.cpp +++ b/gui/src/dialogs/FileInfoDialog.cpp @@ -18,6 +18,7 @@ #include #include "rpc/RpcClient.hpp" +#include "util/Theme.hpp" namespace velox::gui { namespace { @@ -101,7 +102,7 @@ FileInfoDialog::FileInfoDialog(rpc::RpcClient *client, QString url, QJsonArray c form->addRow(tr("Buffer:"), bufferCombo_); form->addRow(QString(), remember); - errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); + errorLabel_->setStyleSheet(theme::errorLabelStyle()); errorLabel_->setWordWrap(true); errorLabel_->hide(); diff --git a/gui/src/dialogs/GrabberWizard.cpp b/gui/src/dialogs/GrabberWizard.cpp index 94c5ea2..061eece 100644 --- a/gui/src/dialogs/GrabberWizard.cpp +++ b/gui/src/dialogs/GrabberWizard.cpp @@ -23,6 +23,7 @@ #include "rpc/Protocol.hpp" #include "rpc/RpcClient.hpp" +#include "util/Theme.hpp" namespace velox::gui { namespace { @@ -177,7 +178,7 @@ class GrabberReviewPage : public QWizardPage { startModeCombo_->addItem(QObject::tr("Download Later"), QStringLiteral("later")); errorLabel_ = new QLabel(this); - errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); + errorLabel_->setStyleSheet(theme::errorLabelStyle()); errorLabel_->hide(); auto *footer = new QFormLayout; diff --git a/gui/src/dialogs/OptionsDialog.cpp b/gui/src/dialogs/OptionsDialog.cpp index 4240687..8dc1864 100644 --- a/gui/src/dialogs/OptionsDialog.cpp +++ b/gui/src/dialogs/OptionsDialog.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include "rpc/Protocol.hpp" #include "rpc/RpcClient.hpp" +#include "util/Theme.hpp" namespace velox::gui { namespace { @@ -43,7 +45,7 @@ void setBufferCombo(QComboBox *combo, qint64 bytes) { combo->setCurrentIndex(combo->count() / 2); // an unrecognized value: land near the middle } -QStringList splitHosts(const QString &text) { +QStringList splitCsv(const QString &text) { QStringList out; for (const QString &h : text.split(QLatin1Char(','), Qt::SkipEmptyParts)) { out << h.trimmed(); @@ -51,6 +53,14 @@ QStringList splitHosts(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() { @@ -61,10 +71,18 @@ 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", @@ -112,13 +130,14 @@ OptionsDialog::OptionsDialog(rpc::RpcClient *client, QWidget *parent) resize(560, 480); buildGeneralTab(); + buildCaptureTab(); buildSaveToTab(); buildConnectionTab(); buildDownloadsTab(); buildProxyTab(); buildSoundsTab(); - statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); + statusLabel_->setStyleSheet(theme::errorLabelStyle()); statusLabel_->hide(); auto *buttons = new QDialogButtonBox( @@ -183,6 +202,37 @@ 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); @@ -193,6 +243,11 @@ 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); @@ -215,6 +270,7 @@ 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")); } @@ -390,12 +446,27 @@ 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(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")); @@ -452,10 +523,28 @@ 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(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(); @@ -479,7 +568,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(splitHosts(proxyBypassHosts_->text())); + v["proxy.bypassHosts"] = QJsonArray::fromStringList(splitCsv(proxyBypassHosts_->text())); v["proxy.pacUrl"] = proxyPacUrl_->text(); v["sounds.enabled"] = soundsEnabled_->isChecked(); diff --git a/gui/src/dialogs/OptionsDialog.hpp b/gui/src/dialogs/OptionsDialog.hpp index b0f4249..6985436 100644 --- a/gui/src/dialogs/OptionsDialog.hpp +++ b/gui/src/dialogs/OptionsDialog.hpp @@ -1,11 +1,12 @@ // 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" 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. +// 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. #pragma once @@ -16,6 +17,7 @@ class QCheckBox; class QComboBox; class QLabel; class QLineEdit; +class QPlainTextEdit; class QSpinBox; class QTabWidget; @@ -45,6 +47,7 @@ class OptionsDialog : public QDialog { private: void buildGeneralTab(); + void buildCaptureTab(); void buildSaveToTab(); void buildConnectionTab(); void buildDownloadsTab(); @@ -67,11 +70,21 @@ 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_; diff --git a/gui/src/dialogs/SchedulerDialog.cpp b/gui/src/dialogs/SchedulerDialog.cpp index d6acea4..46df0b5 100644 --- a/gui/src/dialogs/SchedulerDialog.cpp +++ b/gui/src/dialogs/SchedulerDialog.cpp @@ -17,6 +17,7 @@ #include "rpc/Protocol.hpp" #include "rpc/RpcClient.hpp" +#include "util/Theme.hpp" namespace velox::gui { namespace { @@ -126,7 +127,7 @@ SchedulerDialog::SchedulerDialog(rpc::RpcClient *client, QWidget *parent) } }); - statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); + statusLabel_->setStyleSheet(theme::errorLabelStyle()); statusLabel_->hide(); form_->setEnabled(false); // no queue selected yet diff --git a/gui/src/dialogs/SpeedLimiterDialog.cpp b/gui/src/dialogs/SpeedLimiterDialog.cpp index 2ee2854..d321203 100644 --- a/gui/src/dialogs/SpeedLimiterDialog.cpp +++ b/gui/src/dialogs/SpeedLimiterDialog.cpp @@ -12,6 +12,7 @@ #include "rpc/Protocol.hpp" #include "rpc/RpcClient.hpp" +#include "util/Theme.hpp" namespace velox::gui { @@ -38,7 +39,7 @@ SpeedLimiterDialog::SpeedLimiterDialog(rpc::RpcClient *client, QWidget *parent) kibps_->setEnabled(false); connect(enabled_, &QCheckBox::toggled, kibps_, &QWidget::setEnabled); - statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); + statusLabel_->setStyleSheet(theme::errorLabelStyle()); statusLabel_->hide(); auto *form = new QFormLayout; diff --git a/gui/src/main.cpp b/gui/src/main.cpp index a05f0db..6f5ef21 100644 --- a/gui/src/main.cpp +++ b/gui/src/main.cpp @@ -11,6 +11,8 @@ #include "mainwindow/MainWindow.hpp" #include "rpc/Protocol.hpp" #include "rpc/RpcClient.hpp" +#include "util/ThemeManager.hpp" +#include "util/UiThreadWatchdog.hpp" namespace { @@ -45,6 +47,16 @@ 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(); diff --git a/gui/src/mainwindow/MainWindow.cpp b/gui/src/mainwindow/MainWindow.cpp index c1f797e..940854d 100644 --- a/gui/src/mainwindow/MainWindow.cpp +++ b/gui/src/mainwindow/MainWindow.cpp @@ -37,8 +37,14 @@ #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 { @@ -88,7 +94,9 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent) bannerLabel->setObjectName(QStringLiteral("offlineBannerLabel")); bannerLayout->addWidget(bannerLabel); bannerLayout->addStretch(); - offlineBanner_->setStyleSheet(QStringLiteral("background: #5a3a00; color: #ffd9a0;")); + offlineBanner_->setStyleSheet( + QStringLiteral("background: %1; color: %2;") + .arg(QLatin1String(theme::kOfflineBannerBg), QLatin1String(theme::kOfflineBannerText))); offlineBanner_->setVisible(false); auto *splitter = new QSplitter(Qt::Horizontal, this); @@ -111,9 +119,10 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent) buildMenus(); buildToolBar(); buildTray(); + buildDropTarget(); // --- status bar ----------------------------------------------------------------- - connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b"))); + connDot_->setStyleSheet(dotStyle(QLatin1String(theme::kDanger))); statusBar()->addPermanentWidget(countsLabel_, 1); statusBar()->addPermanentWidget(connText_); statusBar()->addPermanentWidget(connDot_); @@ -261,6 +270,23 @@ 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(); @@ -275,11 +301,11 @@ void MainWindow::closeEvent(QCloseEvent *event) { void MainWindow::onConnectionState(rpc::ConnectionState state) { connText_->setText(tr(rpc::toString(state))); - QString colour = QStringLiteral("#c0392b"); // red + QString colour = QLatin1String(theme::kDanger); if (state == rpc::ConnectionState::Connected) { - colour = QStringLiteral("#27ae60"); // green + colour = QLatin1String(theme::kSuccess); } else if (state != rpc::ConnectionState::Disconnected) { - colour = QStringLiteral("#e67e22"); // amber + colour = QLatin1String(theme::kWarning); } connDot_->setStyleSheet(dotStyle(colour)); @@ -299,7 +325,7 @@ void MainWindow::onConnectionState(rpc::ConnectionState state) { if (online) { fetchTree(); - fetchMinimizeToTraySetting(); + fetchGeneralUiSettings(); } } @@ -318,29 +344,40 @@ void MainWindow::fetchTree() { }); } -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::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::onSettingsChanged(const QJsonObject ¶ms) { for (const QJsonValue &key : params.value("keys").toArray()) { - if (key.toString() == QLatin1String("general.minimizeToTray")) { - fetchMinimizeToTraySetting(); + const QString k = key.toString(); + if (k == QLatin1String("general.minimizeToTray") || + k == QLatin1String("general.showDropTarget")) { + fetchGeneralUiSettings(); 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) { diff --git a/gui/src/mainwindow/MainWindow.hpp b/gui/src/mainwindow/MainWindow.hpp index a75f856..d8f0f6b 100644 --- a/gui/src/mainwindow/MainWindow.hpp +++ b/gui/src/mainwindow/MainWindow.hpp @@ -26,6 +26,10 @@ 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 @@ -64,14 +68,16 @@ class MainWindow : public QMainWindow { void openGrabberWizard(); void showAndRaise(); void onSettingsChanged(const QJsonObject ¶ms); + void onUrlDropped(const QString &url); private: void buildActions(); void buildMenus(); void buildToolBar(); void buildTray(); + void buildDropTarget(); void fetchTree(); - void fetchMinimizeToTraySetting(); + void fetchGeneralUiSettings(); QStringList selectedTaskIds() const; QStringList allTaskIds() const; void actOnTasks(const char *methodName, const QStringList &ids); @@ -102,7 +108,11 @@ 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_; diff --git a/gui/src/rpc/RpcClient.cpp b/gui/src/rpc/RpcClient.cpp index 529950c..c1cdb95 100644 --- a/gui/src/rpc/RpcClient.cpp +++ b/gui/src/rpc/RpcClient.cpp @@ -56,6 +56,13 @@ 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 ¶ms, @@ -76,17 +83,44 @@ void RpcClient::onConnectionState(int state) { } void RpcClient::requestInitialList() { - call(QString::fromLatin1(method::kDownloadList), QJsonObject{{"limit", 1000}}, - [this](const RpcReply &reply) { + 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 { 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 QJsonArray items = reply.result.toObject().value("items").toArray(); - qCInfo(lcRpc, "initial download.list: %lld row(s)", - static_cast(items.size())); - emit taskListReset(items); + const QJsonObject result = reply.result.toObject(); + const QJsonArray page = result.value("items").toArray(); + const qint64 total = static_cast(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(page.size()), accumulated); + return; + } + qCInfo(lcRpc, "initial download.list: %lld of %lld row(s)", + static_cast(accumulated.size()), static_cast(total)); + emit taskListReset(accumulated); }); } diff --git a/gui/src/rpc/RpcClient.hpp b/gui/src/rpc/RpcClient.hpp index 80dec01..ba1270f 100644 --- a/gui/src/rpc/RpcClient.hpp +++ b/gui/src/rpc/RpcClient.hpp @@ -67,6 +67,7 @@ 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 diff --git a/gui/src/rpc/RpcConnection.cpp b/gui/src/rpc/RpcConnection.cpp index ced660c..e27c69c 100644 --- a/gui/src/rpc/RpcConnection.cpp +++ b/gui/src/rpc/RpcConnection.cpp @@ -154,10 +154,12 @@ 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}}}); + 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}}}); return; } if (id == kSubscribeId) { diff --git a/gui/src/util/Theme.hpp b/gui/src/util/Theme.hpp new file mode 100644 index 0000000..256a19e --- /dev/null +++ b/gui/src/util/Theme.hpp @@ -0,0 +1,33 @@ +// 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 + +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 diff --git a/gui/src/util/ThemeManager.cpp b/gui/src/util/ThemeManager.cpp new file mode 100644 index 0000000..09d78f2 --- /dev/null +++ b/gui/src/util/ThemeManager.cpp @@ -0,0 +1,42 @@ +#include "util/ThemeManager.hpp" + +#include +#include +#include +#include + +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 diff --git a/gui/src/util/ThemeManager.hpp b/gui/src/util/ThemeManager.hpp new file mode 100644 index 0000000..c806877 --- /dev/null +++ b/gui/src/util/ThemeManager.hpp @@ -0,0 +1,25 @@ +// 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 + +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 diff --git a/gui/src/util/UiThreadWatchdog.cpp b/gui/src/util/UiThreadWatchdog.cpp new file mode 100644 index 0000000..51d40b8 --- /dev/null +++ b/gui/src/util/UiThreadWatchdog.cpp @@ -0,0 +1,66 @@ +#include "util/UiThreadWatchdog.hpp" + +#include + +#include +#include +#include + +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(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 diff --git a/gui/src/util/UiThreadWatchdog.hpp b/gui/src/util/UiThreadWatchdog.hpp new file mode 100644 index 0000000..ca9d8f8 --- /dev/null +++ b/gui/src/util/UiThreadWatchdog.hpp @@ -0,0 +1,48 @@ +// 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 +#include +#include + +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 running_{false}; + std::atomic pingInFlight_{false}; + std::atomic stalledAlready_{false}; + std::atomic pingSentAtMs_{0}; +}; + +} // namespace velox::gui diff --git a/gui/src/widgets/DropTargetWidget.cpp b/gui/src/widgets/DropTargetWidget.cpp new file mode 100644 index 0000000..5c1329d --- /dev/null +++ b/gui/src/widgets/DropTargetWidget.cpp @@ -0,0 +1,140 @@ +#include "widgets/DropTargetWidget.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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()) { + 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 diff --git a/gui/src/widgets/DropTargetWidget.hpp b/gui/src/widgets/DropTargetWidget.hpp new file mode 100644 index 0000000..6b0dc85 --- /dev/null +++ b/gui/src/widgets/DropTargetWidget.hpp @@ -0,0 +1,50 @@ +// 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 +#include + +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 diff --git a/gui/tests/CMakeLists.txt b/gui/tests/CMakeLists.txt index 01d36a6..6f44fb0 100644 --- a/gui/tests/CMakeLists.txt +++ b/gui/tests/CMakeLists.txt @@ -82,12 +82,14 @@ set_tests_properties(gui_fileinfodialog PROPERTIES LABELS "gui" ENVIRONMENT "QT_QPA_PLATFORM=offscreen") -# 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. +# 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. 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 @@ -144,6 +146,21 @@ 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. diff --git a/gui/tests/dod/CMakeLists.txt b/gui/tests/dod/CMakeLists.txt new file mode 100644 index 0000000..1413114 --- /dev/null +++ b/gui/tests/dod/CMakeLists.txt @@ -0,0 +1,11 @@ +# 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) diff --git a/gui/tests/dod/dod_harness.cpp b/gui/tests/dod/dod_harness.cpp new file mode 100644 index 0000000..45e0c61 --- /dev/null +++ b/gui/tests/dod/dod_harness.cpp @@ -0,0 +1,416 @@ +// 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 ` --sock [--json ]`, 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 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 +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 &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 v, double p) { + if (v.empty()) { + return 0.0; + } + std::sort(v.begin(), v.end()); + int idx = static_cast(std::ceil(p * static_cast(v.size()))) - 1; + idx = std::clamp(idx, 0, static_cast(v.size()) - 1); + return v[static_cast(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 frameMs; + frameMs.reserve(kSteps); + for (int i = 1; i <= kSteps; ++i) { + const double t = static_cast(i) / kSteps; + const double eased = 1.0 - std::pow(1.0 - t, 3.0); + const int value = static_cast(static_cast(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(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(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 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(growthKiB), + static_cast(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(); + qRegisterMetaType(); + + 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 --sock [--json ] " + "[--duration-sec ] [--phase