Author SHA1 Message Date
sami e0f22f808f merge: lane/pkg-qa
CI / clang-format (push) Waiting to run
CI / testserver (push) Waiting to run
CI / bootstrap-script (push) Waiting to run
CI / bootstrap-script-2604 (push) Waiting to run
CI / extension-lint (push) Waiting to run
CI / build (clang) (push) Waiting to run
CI / build (gcc) (push) Waiting to run
CI / sanitizers (dev) (push) Waiting to run
CI / sanitizers (tsan) (push) Waiting to run
CI / clang-tidy (push) Waiting to run
CI / conformance (push) Waiting to run
CI / nightly-integration (push) Waiting to run
CI / gui-dod (push) Waiting to run
CI / gui-dod-nightly (push) Waiting to run
2026-09-15 18:46:34 +04:00
sami 111229686c merge: lane/daemon 2026-09-15 18:46:34 +04:00
sami a5551db1c3 merge: lane/core 2026-09-15 18:46:34 +04:00
samiandClaude Sonnet 5 83b92d118b core: select platform/linux sources via velox_platform_sources()
Replace the hardcoded src/io/platform/linux/file_ops.cpp entry with a
velox_platform_sources(veloxcore src/io) call, now that PKG/QA's
cmake/platform.cmake owns VELOX_OS_* detection and source selection
(ADR 0020 §5). Adding platform/macos/ later needs no edit to this file.

Build-wiring only; full suite stays green (59/59).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-15 18:46:13 +04:00
samiandClaude Sonnet 5 78f2b22c8c daemon: use velox_platform_sources() instead of a hardcoded platform/linux/ list
PKG's cmake/platform.cmake (VELOX_OS_* + velox_platform_sources(<target> <dir>))
is on main now. Swap the hardcoded src/rpc/platform/linux/*.cpp list in
veloxd_rpc for velox_platform_sources(veloxd_rpc src/rpc), matching ADR 0020 §5
(source selection lives in cmake/platform.cmake and nowhere else). PORT can now
add platform/macos/ with no edit to this file.

Build-wiring only, no behaviour change. Full suite 59/59 green.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-15 18:46:07 +04:00
samiandClaude Sonnet 5 77bdb0c427 docs: mark GUI's bootstrap.sh apt-name-validation request (R2) resolved
tools/bootstrap.sh already validates every apt package name via apt-cache policy
(landed in an earlier session), so R2 was done but still read as an open request.
Mark it closed so it stops resurfacing.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0176u3fTrxegrGNm2yC7r69W
2026-09-15 18:39:03 +04:00
sami d60cf94905 merge: lane/pkg-qa — local .deb, cmake/platform.cmake, Linux-gated deps
CI / clang-format (push) Canceled after 0s
CI / testserver (push) Canceled after 0s
CI / bootstrap-script (push) Canceled after 0s
CI / bootstrap-script-2604 (push) Canceled after 0s
CI / extension-lint (push) Canceled after 0s
CI / build (clang) (push) Canceled after 0s
CI / build (gcc) (push) Canceled after 0s
CI / sanitizers (dev) (push) Canceled after 0s
CI / sanitizers (tsan) (push) Canceled after 0s
CI / clang-tidy (push) Canceled after 0s
CI / conformance (push) Canceled after 0s
CI / nightly-integration (push) Canceled after 0s
CI / gui-dod (push) Canceled after 0s
CI / gui-dod-nightly (push) Canceled after 0s
2026-09-15 18:35:11 +04:00
samiandClaude Sonnet 5 0cd5bf6882 pkg: cmake/platform.cmake; gate libsecret and Qt6::DBus to Linux
Root build infra, per ADR 0020 as amended in 9624e68: PORT owns the per-OS
backend directories and macOS/Windows packaging, but cmake/platform*.cmake stays
PKG/QA's — the amendment exists because Phase 0 (CORE's and DAEMON's own seams,
on Linux) needs velox_platform_sources() to select platform/linux/*.cpp, and PORT
can't start until Phase 0 lands. Landing it as PORT-owned would have deadlocked
both lanes waiting on each other.

cmake/platform.cmake sets exactly one of VELOX_OS_LINUX/VELOX_OS_MACOS/
VELOX_OS_WINDOWS from CMAKE_SYSTEM_NAME and exposes velox_platform_sources(target
dir), which globs dir/platform/<os>/*.cpp. No seam consumes it yet (Phase 0 hasn't
landed), so on Ubuntu this changes nothing observable — VELOX_OS_LINUX is simply
true and no lane calls the new function.

pkg_check_modules(LIBSECRET ...) was unconditional in the root CMakeLists.txt,
so macOS could not configure at all (docs/08-porting.md's stated blocker); it's
now gated on VELOX_OS_LINUX, same as the Qt6::DBus component this also adds
(gui/docs/pkg-qa-requests-m1.md R4 — backs the org.freedesktop.portal
GlobalShortcuts path, which is Linux/portal-only same as libsecret). Both are
still REQUIRED on Linux; PORT adds the macOS sides (Keychain, and no DBus
equivalent needed) behind their own seams, not by touching this find.

Verified: fresh configure + build of veloxd/velox/velox-gui/velox-nmhost still
green (libsecret-1 and Qt6::DBus both found), the .deb from the previous commit
still builds and installs.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0176u3fTrxegrGNm2yC7r69W
2026-09-15 18:03:50 +04:00
samiandClaude Sonnet 5 31eadffce5 pkg: build a local .deb; de-duplicate onto DAEMON's systemd/nativehost/man page
DAEMON landed packaging/nativehost/, packaging/systemd/{velox.service,velox.socket}
(real sd_listen_fds() socket activation) and cli/man/velox.1 on main while this was
in flight, duplicating paths this branch had drafted independently. Per CLAUDE.md
(packaging/nativehost/ is DAEMON's; the rest of packaging/ is PKG/QA's), DAEMON's
copies are kept as the single source and this branch's packaging/native-messaging/,
packaging/man/velox.1 and stale packaging/systemd/velox.service are dropped rather
than maintained twice. The root CMakeLists.txt's install() rules now point at
DAEMON's paths directly, install both systemd units (not just velox.service), and
docs/07-packaging.md / packaging/README.md no longer claim there's no socket
activation — there is, and this package now ships it.

Adds the local-test-only .deb itself: debian/{control,rules,postinst,postrm,
changelog,copyright}, a placeholder icon set, the desktop entry and PKG/QA's own
man pages (veloxd.8, velox-gui.1 — velox.1 stays DAEMON's). libveloxcore stays
static (no consumer needs a .so yet); pairing has no real approval UI in this
build (D1 unbuilt), so postinst and packaging/README.md both say VELOX_PAIR_AUTO=1
is required and print it prominently. No PPA, no GPG — local dpkg -i only.

Verified live: dpkg-buildpackage -us -uc -b, dpkg -i, socket activation (systemctl
--user enable --now velox.socket), `velox ls` against the running daemon, lintian
clean, dpkg -r removes the manifest/units and leaves $XDG_DATA_HOME/velox alone.

.gitignore now excludes dpkg-buildpackage's debhelper build tree and obj-*/ (the
debuild output dir) — debian/control et al. stay tracked, everything debhelper
regenerates does not.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0176u3fTrxegrGNm2yC7r69W
2026-09-15 18:02:11 +04:00
sami 5b42e1f0e5 merge: lane/gui
CI / clang-format (push) Canceled after 0s
CI / testserver (push) Canceled after 0s
CI / bootstrap-script (push) Canceled after 0s
CI / bootstrap-script-2604 (push) Canceled after 0s
CI / extension-lint (push) Canceled after 0s
CI / build (clang) (push) Canceled after 0s
CI / build (gcc) (push) Canceled after 0s
CI / sanitizers (dev) (push) Canceled after 0s
CI / sanitizers (tsan) (push) Canceled after 0s
CI / clang-tidy (push) Canceled after 0s
CI / conformance (push) Canceled after 0s
CI / nightly-integration (push) Canceled after 0s
CI / gui-dod (push) Canceled after 0s
CI / gui-dod-nightly (push) Canceled after 0s
2026-09-15 17:57:36 +04:00
sami e8af9b46b1 merge: lane/daemon 2026-09-15 17:57:36 +04:00
sami 8abfc6a428 merge: lane/core 2026-09-15 17:57:36 +04:00
samiandClaude Sonnet 5 936136db06 core: introduce the file_ops platform seam (ADR 0020 Phase 0)
Pure refactor, zero behaviour change. posix_fallocate/ftruncate,
posix_fadvise(DONTNEED), and fdatasync move out of sparse_file.cpp and
meta/veloxpart.cpp into core/src/io/platform/linux/file_ops.cpp behind
a new seam (core/src/io/platform/file_ops.hpp). Neither caller names a
Linux-only syscall anymore, and no #ifdef exists outside platform/linux/.
Clears the way for the PORT lane's macOS backend.

Full suite: 44/44 green (build/dev).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-15 17:56:02 +04:00
samiandClaude Sonnet 5 6d7c4f7bc4 daemon: Phase 0 platform seams (ADR 0020) — eventfd/SO_PEERCRED/instance-lock/XDG behind platform/, zero behaviour change
Introduces the four rpc-layer seams docs/08-porting.md calls for this lane:
platform::Wakeup (eventfd), platform::peer_of (SO_PEERCRED/struct ucred, same-UID
check preserved unchanged), platform::acquire_instance_lock (abstract-namespace
socket), platform::runtime_base_dir/data_base_dir (XDG lookups). Today's Linux
code moves unchanged into daemon/src/rpc/platform/linux/; the seam headers
carry no OS types and no #ifdef.

No fifth interface for timerfd: EventLoop gains a portable add_timer() that
folds the next deadline into poll()'s own timeout, replacing both timerfd
instances in main.cpp — the loop already computes a deadline, so this needs no
per-OS backend at all.

Full suite 59/59 green; no #ifdef outside platform/linux/, no behaviour change.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-15 17:54:32 +04:00
samiandClaude Sonnet 5 9487202ea8 gui: ship the real icon set, wire toolbar/tray/window icon, rebase onto main
Adwaita-derived (GNOME Project, LGPL-3-or-CC-BY-SA), recoloured per light/dark
skin and laid out in IDM's toolbar positions per docs/03-gui-spec.md §1.
Licence and per-file provenance recorded in gui/resources/icons/LICENSE.

New IconTheme mirrors ThemeManager's colorSchemeChanged hook so a live
light/dark switch swaps every action's glyph, not just the QSS. Toolbar/menu
actions, the tray icon and the window icon all now use it instead of the
SP_ArrowDown placeholder. Install-path notes for PKG filed as R5 in
gui/docs/pkg-qa-requests-m1.md.

Also rebases lane/gui onto main (13 commits, no gui/ conflicts).

Verified: full build clean, all 13 gui ctest targets pass (including
gui_no_download_logic), unhappy-path DoD gate PASS against mockd
(--drop-connection).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-15 17:50:51 +04:00
samiandClaude Opus 5 9624e685af docs: cmake/platform.cmake belongs to PKG/QA, not PORT (ADR 0020 amendment)
CI / clang-format (push) Canceled after 0s
CI / testserver (push) Canceled after 0s
CI / bootstrap-script (push) Canceled after 0s
CI / bootstrap-script-2604 (push) Canceled after 0s
CI / extension-lint (push) Canceled after 0s
CI / build (clang) (push) Canceled after 0s
CI / build (gcc) (push) Canceled after 0s
CI / sanitizers (dev) (push) Canceled after 0s
CI / sanitizers (tsan) (push) Canceled after 0s
CI / clang-tidy (push) Canceled after 0s
CI / conformance (push) Canceled after 0s
CI / nightly-integration (push) Canceled after 0s
CI / gui-dod (push) Canceled after 0s
CI / gui-dod-nightly (push) Canceled after 0s
As written, ADR 0020 gave PORT ownership of cmake/platform*.cmake while also
making PORT wait for Phase 0 — but Phase 0's seams are exactly what needs
velox_platform_sources() to select platform/linux/*.cpp. PORT cannot start until
Phase 0 lands, and Phase 0 cannot land without the file PORT owns. PKG/QA caught
the circularity before writing anything, which was the right call.

Central OS detection is root build infrastructure, so it stays with PKG/QA and
lands during Phase 0. PORT keeps the per-OS backend directories and the macOS
and Windows packaging, and owns no part of cmake/.

Also drops a hardcoded test count from the PORT definition of done; the suite
number moves every round and the gate is that it stays green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-15 17:46:48 +04:00
sami 4e87730ce9 merge: lane/daemon — velox-nmhost, systemd units, velox(1) man page
CI / clang-format (push) Canceled after 0s
CI / testserver (push) Canceled after 0s
CI / bootstrap-script (push) Canceled after 0s
CI / bootstrap-script-2604 (push) Canceled after 0s
CI / extension-lint (push) Canceled after 0s
CI / build (clang) (push) Canceled after 0s
CI / build (gcc) (push) Canceled after 0s
CI / sanitizers (dev) (push) Canceled after 0s
CI / sanitizers (tsan) (push) Canceled after 0s
CI / clang-tidy (push) Canceled after 0s
CI / conformance (push) Canceled after 0s
CI / nightly-integration (push) Canceled after 0s
CI / gui-dod (push) Canceled after 0s
CI / gui-dod-nightly (push) Canceled after 0s
2026-09-15 17:38:46 +04:00
samiandClaude Opus 5 facd851824 docs: define the cross-platform porting strategy (ADR 0020, macOS first)
CI / clang-format (push) Canceled after 0s
CI / testserver (push) Canceled after 0s
CI / bootstrap-script (push) Canceled after 0s
CI / bootstrap-script-2604 (push) Canceled after 0s
CI / extension-lint (push) Canceled after 0s
CI / build (clang) (push) Canceled after 0s
CI / build (gcc) (push) Canceled after 0s
CI / sanitizers (dev) (push) Canceled after 0s
CI / sanitizers (tsan) (push) Canceled after 0s
CI / clang-tidy (push) Canceled after 0s
CI / conformance (push) Canceled after 0s
CI / nightly-integration (push) Canceled after 0s
CI / gui-dod (push) Canceled after 0s
CI / gui-dod-nightly (push) Canceled after 0s
The tree is Ubuntu-first, but the Linux-specific surface is about ten files and
already sits at the bottom of the I/O and RPC stacks. Rather than fork the repo
or scatter ifdefs, platform differences go behind narrow seams with one backend
directory per OS, selected centrally in cmake/platform.cmake.

Linux stays the reference implementation and its behaviour does not change: the
owning lanes (CORE, DAEMON) move today's Linux code behind the seams on Ubuntu
first, with the suite still green, before any macOS code exists. A new PORT lane
owns only the per-OS backends, so macOS work never writes shared logic and
CLAUDE.md's lane rule stays intact.

Adds ADR 0020 (the decision and phases), docs/08-porting.md (seam list, API
mapping for macOS and Windows, dependency table, verification gates) and
docs/agents/AGENT-PORT.md (the lane brief), plus the PORT row in the ownership
table.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-15 17:36:27 +04:00
sami 3e553f08c0 merge: lane/pkg-qa — GUI DoD gates wired, conformance required
CI / clang-format (push) Canceled after 0s
CI / testserver (push) Canceled after 0s
CI / bootstrap-script (push) Canceled after 0s
CI / bootstrap-script-2604 (push) Canceled after 0s
CI / extension-lint (push) Canceled after 0s
CI / build (clang) (push) Canceled after 0s
CI / build (gcc) (push) Canceled after 0s
CI / sanitizers (dev) (push) Canceled after 0s
CI / sanitizers (tsan) (push) Canceled after 0s
CI / clang-tidy (push) Canceled after 0s
CI / conformance (push) Canceled after 0s
CI / nightly-integration (push) Canceled after 0s
CI / gui-dod (push) Canceled after 0s
CI / gui-dod-nightly (push) Canceled after 0s
2026-09-12 22:05:06 +04:00
samiandClaude Sonnet 5 1d359af5a3 pkg: wire GUI's DoD harness into CI, mark live-veloxd conformance required
gui-dod (per-PR: scroll-60fps + unhappy-path) and gui-dod-nightly (rss-flat,
schedule/workflow_dispatch) are live in ci.yml, driving GUI's newly-landed
gui/tests/dod/run.sh + gui-dod-harness. No Xvfb step: run.sh already runs
QT_QPA_PLATFORM=offscreen itself.

Each gate forced red once before being trusted (tests/integration/README.md
has the transcripts): VELOX_DOD_FRAME_BUDGET_MS=0.01 for scroll-60fps,
VELOX_DOD_RSS_SLACK_KIB=-999999999 for rss-flat, and — since run.sh always
starts a working mockd — a direct gui-dod-harness invocation against an
unreachable socket for unhappy-path, which hit the harness's own 75s
watchdog exactly as documented.

Recorded GUI's live finding (gui/docs/proto-requests-m1.md) that mockd
--drop-connection is a no-op over the UDS transport, so unhappy-path's
drop-connection phase can't yet exercise a real drop — coordinating with
PROTO on the fix rather than working around it locally. gui-dod stays
required regardless: its other two phases and the crash/hang/watchdog paths
still catch real regressions.

Added gui-dod to BRANCH_PROTECTION.md's required-checks table.

ADR 0019: the live-veloxd conformance runner (run.sh step 3b, already
unconditional inside the already-required conformance job) stays required
as PROTO's xfail list shrinks (18 entries now, down from 34; 57/57 fixtures
passing on main). No CI change needed — it was already inside a required
check; this records the decision not to carve out an exception for it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RBPR7iM3YPyxrjWsVtZDPJ
2026-09-12 22:04:09 +04:00
sami 75536304bb merge: lane/proto — conformance stops downloading real files 2026-09-12 21:44:55 +04:00
sami 27865888be merge: lane/gui 2026-09-12 21:40:31 +04:00
sami 939a19b7ea merge: lane/core 2026-09-12 21:40:31 +04:00
samiandClaude Sonnet 5 79c29b47e8 core: finish the hostile-mode matrix -- 8 remaining end-to-end cases
Was 7/16 of tools/testserver/README.md's mode table covered by
engine_test.cpp. Adds the rest:

- engine_expiring_signed_url_recovers_via_refresh_url: an expired signed
  URL 403s, the engine asks (paused, decision_calls >= 1) rather than
  failing terminally, and DownloadHandle::refresh_url() with a freshly
  signed URL completes it -- exercises both do_refresh_url() fixes and
  the probe-level referrer retry's second-403 path from the previous
  commit.
- engine_403_without_referer_retries_with_origin: no spec.referrer set,
  the automatic single retry (previous commit) recovers with zero
  decisions asked.
- engine_redirect_chain_follows_to_completion: 5 hops of a plain 302.
  No core-side change needed -- documents that CURLOPT_FOLLOWLOCATION/
  MAXREDIRS (already on, RequestOptions::follow_redirects) cover both
  the probe's and every worker's own request, not just one of the two.
- engine_slow_loris_stall_timeout_fires: proves curl's stall detector
  (CURLOPT_LOW_SPEED_LIMIT/_TIME, download_task.cpp's hardcoded 1024 B/s
  for 30s) actually fires rather than hanging. Needed a real fix, not
  just a test: every other test in this file relies on TestServer's
  short 1s loris dribble to keep runtime down, but 1s of trickle
  followed by full-speed streaming never accumulates curl's required 30
  CONSECUTIVE seconds under the floor, so it would never actually abort
  -- a test built on the default dribble would pass by the download
  merely finishing a bit late, not by observing the stall timeout fire.
  testserver_fixture.hpp's TestServer gained an explicit-loris-seconds
  constructor (default ctor unchanged, still 1s) so this one test can
  ask for a dribble (40s) that genuinely outlasts the threshold.
- engine_401_digest_then_provide_auth_completes: same shape as the
  existing 401-basic test: http_client.cpp already asks libcurl for
  CURLAUTH_ANY regardless of net::AuthScheme, so this needed no core
  change -- it passed on the first run and is here to prove that's true
  end-to-end, not just at the http_client unit level.
- engine_chunked_no_length_completes_single_segment: Transfer-Encoding:
  chunked, no Content-Length anywhere (including HEAD). No core change
  needed -- takes the same size-agnostic "unknown size, one plain-GET
  segment" path as the existing no-range test.
- utf8/legacy-content-disposition: already covered end-to-end by
  probe_reads_utf8_content_disposition and
  probe_reads_legacy_content_disposition in probe_test.cpp (probe-level,
  as these modes only affect the initial request) -- verified passing,
  no new test needed.

All 20 engine_test.cpp cases and all 10 probe_test.cpp cases pass. Every
testserver.py spawned while writing and running this was reaped by
TestServer's destructor; verified no stragglers with `ps aux` after each
run.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q3QrF7rCt21bkAjt9BCDFQ
2026-09-12 21:34:24 +04:00
samiandClaude Sonnet 5 8e7d14ba7e core: retry once with the original referrer on a 403, at probe and worker
docs/04-engine-design.md §7's failure policy table has said "403 after
redirect: retry once with the original referrer; many CDNs require it"
since it was written, and Error::forbidden's own enum comment says the
same -- but grepping download_task.cpp and http_client.cpp for 403 turned
up nothing. It was never built.

Implemented at both points a 403 can surface:

- The probe (net::Prober, a separate request path from segment workers):
  on_probe_result() now retries once via restart_probe(false), with
  effective_referrer set to the download URL's own origin (origin_of(),
  via net::split_url()), when the failure is Error::forbidden and this is
  the first retry. A second 403 asks rather than fails outright --
  auto_pause_locked(..., false, true), the same "ask, don't just fail"
  path 416/etag-mismatch already use -- specifically so DownloadHandle::
  refresh_url() stays usable afterward (its own contract requires a
  non-terminal task); this is what makes the expiring-signed-url mode's
  README-documented refresh_url() recovery actually reachable.

- Each segment worker (SegWorker::forbidden, set in seg_head() on a 403
  HEAD): the same one-shot referrer retry via retry_worker(), landing on
  auto_pause_locked() on a second 403 for the same reason.

Both paths route the retry's Referer through a new effective_referrer
field rather than spec.referrer directly, since the origin-retry must not
overwrite what the caller actually asked for -- start_worker_locked() and
restart_probe() were switched to send effective_referrer instead.

do_refresh_url() had two latent bugs surfaced by actually exercising the
expiring-signed-url recovery path end-to-end:

1. It unconditionally proceeded to resume even when the refresh probe
   itself failed -- a bad refresh URL would silently un-pause a task with
   nothing behind it. Now returns (stays paused) on !r.has_value().
2. It only handled "already probed once, just refreshing a few fields" --
   for a task whose first-ever probe never succeeded (every hostile mode
   this commit adds a test for that pauses at the initial probe, not
   mid-download), s->registered was never true, so the existing
   `if (s->registered) set_want()` never fired and nothing happened. Now
   detects !s->have_probe and calls finish_probe_locked() directly, the
   actual first-time registration/segmenter-construction path.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q3QrF7rCt21bkAjt9BCDFQ
2026-09-12 21:34:24 +04:00
samiandClaude Sonnet 5 c2eef96175 gui: floating drop target, clipboard global shortcut, theming, UI watchdog
Continues the build order past Options/Scheduler/Speed Limiter/Batch/
Grabber/tray.

- DropTargetWidget: frameless always-on-top drop target (docs/03-gui-spec.md
  §5), position persisted, accepts a dropped http(s) URL or link text and
  opens FileInfoDialog directly (skipping Add URL, since the URL is already
  known). Shown/hidden from general.showDropTarget, live via
  event.settings.changed, same pattern MainWindow already used for
  general.minimizeToTray.
- Clipboard, explicit path #2 (docs/06-risks-and-spikes.md R2):
  GlobalShortcut wraps org.freedesktop.portal.GlobalShortcuts
  (CreateSession -> BindShortcuts -> Activated), triggering the same Add URL
  flow. Guarded end-to-end on `if(TARGET Qt6::DBus)` / VELOX_GUI_HAVE_DBUS
  so a build without the component degrades to "feature skipped," not
  broken (gui/docs/pkg-qa-requests-m1.md R4). Best-effort by design per the
  risk doc: fails silent, never advertised.

  Verified live against the real portal (a real Wayland session, not just
  offscreen): `CreateSession` refuses every caller with "An app id is
  required" — reproduced identically via a bare `busctl` call with no Qt
  involved at all, so this is the portal requiring a sandboxed caller
  identity, not something fixable from an unconfined process. Recorded as
  a partial Spike S2 answer in docs/06-risks-and-spikes.md: this explicit
  path likely doesn't work for Velox as a traditionally-packaged app on
  stock GNOME, only if/when it ships confined. Also fixed a real leak this
  verification caught: QDBusInterface's introspection cache reads as a
  LeakSanitizer leak the first time anything touches D-Bus (tst_rtl went
  red under ASan) — switched to QDBusMessage::createMethodCall, which
  needs no introspection.
- Theming (docs/03-gui-spec.md §7): gui/resources/qss/{idm-like,dark}.qss,
  each with a documented palette block up top (QSS itself has no variable
  syntax), applied by ThemeManager and kept live via
  QStyleHints::colorSchemeChanged. util/Theme.hpp gives the handful of
  inline C++ styles (status dot, offline banner, the eleven identical
  error-label styles across dialogs) named constants instead of a twelfth
  copy of the same hex.
- UiThreadWatchdog: the M1 DoD's 200 ms debug-build watchdog. A background
  std::thread pings the UI thread every 50 ms via a queued invokeMethod and
  warns once (not per-poll) if a ping goes unanswered past 200 ms; no
  QThread, no Qt event loop of its own, so the watchdog itself can never be
  what blocks the thread it watches. No-op in a release build. Proven both
  ways in tst_uithreadwatchdog: fires on a genuinely blocked UI thread
  (synchronous sleep, no processEvents) and stays silent on a responsive
  one.

Full non-conformance suite (55 tests across every lane, `ctest -LE
conformance`) passes clean at this point, including the whole gui label
under ASan+UBSan.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
2026-09-12 21:30:13 +04:00
samiandClaude Sonnet 5 1fd2e0a0db gui: Options — Capture tab, saveTo.allowedRoots, lock coverage to the schema
Options was missing 8 of the 43 real settings.* keys: capture.enabled,
monitoredExtensions, monitoredMimeTypes, minSizeBytes, excludedHosts,
bypassModifier, autoStartTypes, and saveTo.allowedRoots. The capture.* keys
were dropped in an earlier pass on the mistaken read that the spec's "File
Types" tab meant per-category extension lists (which do live on Category,
not settings.*) — they're real settings.* keys for a real daemon feature
(the extension's auto-capture policy), so the tab exists now, named
"Capture" to match what it actually configures rather than the spec's
label.

New tst_optionsdialog case (allKeysMatchesTheSchemaExactly) loads
Settings.schema.json itself at test time and diffs its property set against
OptionsDialog::allKeys() — this drifted silently once already, so the
regression is now a build-time gate an unused import or a future key
addition would trip, not something that needs re-discovering by hand again.

Verified against a real veloxd (not just mockd): settings.get across all
43 keys, a settings.set/get round trip on a scalar (connection.timeoutSec)
and on array-valued keys in the shapes OptionsDialog::currentValues()
actually produces (capture.monitoredExtensions, proxy.bypassHosts,
saveTo.allowedRoots), and event.settings.changed fanning out to a second
subscribed client — all round-tripped and restored to their original
values afterward. The real OptionsDialog widget also loads and renders
correctly against that same daemon's live defaults with no crash under
ASan+UBSan.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
2026-09-12 21:29:19 +04:00
samiandClaude Sonnet 5 755d85964e gui: M1 DoD harness — scroll-60fps, rss-flat, unhappy-path
gui/docs/pkg-qa-requests-m1.md R3, filed by the previous session: three GUI
M1 DoD items (10k rows at 60 fps, flat RSS over 10 minutes,
--slow/--flaky/--drop-connection recovery) had nowhere to run in CI. This is
the harness — `gui/tests/dod/run.sh <gate> [--json <path>]`, exactly the
path/invocation contract tests/integration/README.md already specified —
plus `gui/tests/dod/dod_harness.cpp`, the Qt/RpcClient-driven binary that
actually runs each gate against a real mockd run.sh starts and tears down
itself.

- scroll-60fps: an eased scripted scroll over the whole loaded table,
  timing each step's synchronous repaint; p99 against a 16.6 ms budget
  (auto-scaled 4x under a sanitized build — ASan/UBSan overhead, not a
  loosened bar, see the harness's isSanitizedBuild()).
- rss-flat: samples this process's own VmRSS at 1 Hz across the run,
  discards a warm-up window, checks post-warm-up growth against a stated
  20 MiB slack.
- unhappy-path: three phases (slow/flaky/drop-connection), each its own
  mockd instance; passes when the client reaches and holds Connected with
  no crash or hang. A watchdog (the harness's own QTimer, backstopped by
  run.sh's external `timeout`) turns a genuine hang into a bounded non-zero
  exit rather than needing the CI caller to timeout(1) around it.

Every gate honours the exit-code and --json contract PKG/QA's pre-drafted
CI job expects unchanged (one addition needed: the build step must also
build the `gui-dod-harness` target, noted in the R3 update). No leaked mockd
processes on any exit path (`trap cleanup EXIT INT TERM`); no writes outside
a tempdir except the caller's own --json path.

Verified live end-to-end (not just unit-level): all three gates run against
a real mockd under the exact `ASAN_OPTIONS=detect_leaks=1:halt_on_error=1`
`.github/workflows/ci.yml`'s sanitizers job already sets, all pass, and
scroll-60fps was forced red once on purpose
(VELOX_DOD_FRAME_BUDGET_MS=1) to prove the fail path and exit code actually
work. Building this is also what surfaced the two RpcClient bugs fixed in
the previous commit, and one real gap in mockd itself — --drop-connection
never worked over the Unix socket transport (only WebSocket) — filed as
gui/docs/proto-requests-m1.md since tools/mockd is PROTO's file.

gui/docs/pkg-qa-requests-m1.md R3 and R4 (an unrelated, non-blocking Qt6::DBus
CMake hygiene note filed while wiring the clipboard global-shortcut path)
are updated with the concrete findings above.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
2026-09-12 21:28:36 +04:00
samiandClaude Sonnet 5 c6f864ea30 gui: fix RpcClient double-free on stop() and the 1000-row list cap
Both found live while building gui/tests/dod's DoD harness, not from reading
the code — see that commit for how.

RpcClient::stop() left conn_ dangling after joining the worker thread: the
thread's own finish() flushes the DeferredDelete stop()'s
connect(&thread_, &QThread::finished, conn_, &QObject::deleteLater) already
posted, so conn_ is gone by the time stop() returns, but nothing cleared the
pointer. Any caller that calls stop() and later lets the client destruct
(the harness's own client.stop() at shutdown; also plain, correct API usage)
hit a double-free in the destructor's leftover `delete conn_`. Caught by
ASan on the very first run that actually exercised the stop-then-destroy
path.

requestInitialList() also called download.list with a hardcoded
`{"limit": 1000}`, silently capping the table at 1000 rows no matter how
many the daemon actually has — download.list.schema.json's own description
says "the GUI pages", not "the GUI takes it all in one call". The
scroll-60fps DoD gate refused to run against mockd --tasks 10000 rather
than "pass" against a 1000-row table, which is what surfaced it.
requestInitialList() now pages (5000 per call, the schema's own max) until
`total` is satisfied, then resets the model once with everything.

Separately: RpcConnection's session.subscribe list never included
event.settings.changed or event.grabber.progress, even though RpcClient has
carried signals for both since the Options/Grabber work — session.subscribe
"replaces the previous selection" and "nothing is delivered until this is
called", so both events were being silently dropped by any real daemon that
enforces the subscription (mockd does; verified live with a second
subscribed client actually receiving event.settings.changed after this
fix, round-tripped through a real veloxd's settings.set). GrabberWizard's
5 s poll fallback is exactly why this went unnoticed until now — it covered
for the missing push the whole time.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
2026-09-12 21:27:28 +04:00
sami c1c5c82f8b merge: lane/ext — real-veloxd verification, session.subscribe fix 2026-09-12 17:20:19 +04:00
samiandClaude Sonnet 5 4e177ec809 proto: stop conformance from downloading real files, ADR the null-clearing gap
1. download.add.json had startMode "now" against a real, large (~6 GB)
   Ubuntu ISO with saveDir hardcoded to /home/sami/Downloads/Programs.
   Against a real veloxd (tests/conformance/run.sh) that's a real
   download into the real user's real home, every single run — it had
   already happened twice. startMode -> "later" (exercises the add path,
   hands nothing to the engine) and saveDir is dropped entirely (resolves
   to saveTo.defaultDir instead, checked against allowedRoots the same
   way). Documented the rule this fixture was breaking in
   contracts/fixtures/README.md so it doesn't happen a third time.

   Auditing the rest for the same shape (now real: capture.offer, D7)
   found a second, subtler instance: capture.offer.take.json's "take"
   admits a real, immediately-started task the same way download.add
   does, and the "Programs" category's saveDir is a migration-seeded
   builtin (~/Downloads/Programs) that no isolated test setup can
   redirect -- so even after pointing the URL at example.org (RFC 2606),
   a real ~6 GB sparse .veloxpart still landed in the real home on the
   declared Content-Length alone. Shrunk to a plausible-but-small 5 MiB.
   Also scoped to "transport": "uds" -- a real "take" persists an active
   task, so replaying the same fixture again on the second live transport
   against the same shared daemon was hitting capture.offer's own
   dedupe-by-URL and failing on a missing taskId, not a bug.
   download.add's other real-URL siblings (errors/*.invalid-path,
   *.invalid-params, *.disk-full) all fail before admission or are
   requires-gated; left alone.

2. ADR 0018: DAEMON can set a nullable field through download.update /
   settings.set but never clear it back to null, because the generated
   C++ parser collapses "absent" and "explicit null" to the same
   std::nullopt for every optional field (contracts/codegen/gen_cpp.py,
   on purpose, and correct for create-style params -- just wrong for
   patch-style ones, which is the only place the schema documents
   "explicit null clears"). Decision: an opt-in x-clearable schema
   annotation makes just those fields std::optional<std::optional<T>> in
   C++ (TS already round-trips this natively); not a blanket rule
   (would retype response fields like TaskSummary.effectiveUrl that have
   no clear-vs-absent distinction to make), not an explicit clear-list
   field (would redesign a wire contract DAEMON already built against
   just to route around a generator gap). Recorded, not implemented here
   -- that's its own PROTO PR (schema annotations + gen_cpp.py + gen_ts.py
   + regeneration + a minor VERSION bump per ADR 0015), not bundled into
   a fixture-safety pass. Left a pointer to the ADR at the generator
   comment it concerns.

3. Re-verified every xfail entry against current deferrals.md rather
   than trust the reasons already on file: D7/D8 (capture.offer/
   getRules), D3d/e/f/g/h/i (rules, queue.reorder, schedule, limiter,
   download.update/refreshUrl) and D9 (settings) have all closed since
   the list was last pruned, so most of it was stale. Removed everything
   that now cleanly passes; kept and re-reasoned everything that doesn't:
   - errors/download.provideAuth.not-found.json stays, as asked: real
     bug, on_download_provideAuth never checks the task exists.
   - category.list.json (mimeTypes -- documented D3a gap), schedule.set.json
     (nextRunAt -- documented D3f gap): unchanged in substance, reason
     text was already accurate.
   - download.probe/get/list/update.json, session.hello.json,
     queue.start/reorder.json, category.remove.json: not bugs -- each
     golden depicts a richer lifecycle/config state (a probed download,
     real queue or category membership, media/grabber capabilities) than
     this harness's fresh, never-started bound tasks and empty isolated
     DB can produce.
   - limiter.get.json: real fixture bug, not a daemon one -- applyToRunning
     is a write-only instruction on limiter.set, on_limiter_get never
     returns it; the golden shouldn't have had it either. Fixed the
     fixture and tools/mockd's own limiter.get, which had the same field
     hardcoded into its in-memory state independent of the fixture file.
   - grabber.*/media.*: still genuinely stub (M4 territory).
   Only remaining unexpected-pass surfaced while re-verifying
   (errors/capture.offer.ignore.json, always "take" instead of "ignore")
   traced to capture.minSizeBytes defaulting to 0 on a fresh daemon,
   making its below-minimum-size scenario unreachable -- not a bug, so
   raised the setting in run.sh's isolated seeding instead of xfailing it.

ctest -L conformance: green, 100% (2/2), ~87s.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SFeUKLbdHizrJjLBeK7ffz
2026-09-12 16:56:35 +04:00
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
sami 0468b0176a merge: lane/daemon 2026-09-12 16:27:29 +04:00
sami d8b7c128be merge: lane/proto 2026-09-12 16:27:29 +04:00
samiandClaude Sonnet 5 e30d994d74 proto: fix conformance run.sh flakiness, prune the veloxd xfail list
Two run.sh fixes plus the xfail prune, all requested together:

1. VELOX_PAIR_AUTO=1 for the isolated veloxd. Pairing is the D1 dev stub
   (EnvAutoApprover) and denies without it, so session.pair never issued
   a token and the WS half of the veloxd step could never even connect.

2. WS_PORT was hardcoded to 52080 with no free-port search, so one leaked
   mockd made every future run fail EADDRINUSE. free_port() binds :0 and
   asks the kernel instead. The EXIT trap's stop() used `pkill -P "$pid"`,
   which only reaps direct children — tsx's actual listener is often a
   grandchild, which that missed and left holding the port. Every server
   (mockd, slow mockd, veloxd) now launches under `setsid`, making it the
   leader of its own process group, so stop() does `kill -TERM -"$pid"`
   (a process-group kill) and reaches everything it spawned in one shot.

3. Pruned the xfail list now that D2, D4b and most of D3 have landed.

Pruning surfaced two more bugs than expected, both in the test harness
itself, not veloxd — worth recording since they were indistinguishable
from real daemon hangs until isolated:

- errors/session.hello.version-mismatch.json documents that the *server*
  closes the connection after replying (correct, intended behavior). The
  harness replays every fixture on one shared connection per transport,
  so once this fixture ran, every later UDS fixture sent into the dead
  socket and just sat there until its own timeout — including ones still
  on the xfail list, which applyXfail waved through as "expected -32603"
  regardless of the real reason. Fixed with a `closesConnection` fixture
  flag: replay() reconnects (fresh session.hello) right after such a
  fixture instead of leaving the rest of the run to time out one by one.
  This is what was actually behind queue.*/session.*/download.remove
  appearing to hang — none of them do; verified individually and via a
  raw probe script before finding the real cause.
- category.remove.json (deletes the "firmware" category) sorted before
  category.upsert.json (creates it) alphabetically, so it was failing
  -32602 "no such category" against a fresh DB — never a daemon bug.
  Added it to DESTRUCTIVE so it now replays after every other fixture.

Also fixed while verifying "confirm each really passes": download.addBatch.json's
`defaults.categoryId` was "compressed", a category nothing ever creates —
real veloxd correctly enforces the FK on tasks.category_id, so all three
batch items failed instead of the two expected. Changed to "programs" (a
migration-seeded builtin).

Of the 15 fixtures named for pruning, 10 turned out to cleanly pass and
are gone from the list entirely: download.pause/resume/start/cancel,
download.remove, download.addBatch, queue.upsert/stop, download.probe's
success path (D2, including errors/download.probe.probe-failed.json),
and category.upsert. Two do NOT cleanly pass and are kept, with reasons
rewritten to match what's actually happening now instead of the stale D3
text: download.probe.json (see below) and errors/download.provideAuth.not-found.json,
a real bug — on_download_provideAuth never checks the task exists, so an
unknown taskId gets a normal `{ok:false}` result instead of -32010.

Five more fixtures newly needed xfail entries to reach green, none of
them stubs:
- category.list.json — documented gap (deferrals.md's D3a note): the
  categories table has no mimeTypes/sortOrder columns.
- download.probe.json, download.get.json, download.list.json,
  session.hello.json — not bugs. Each golden depicts a richer lifecycle
  state (a probed/in-progress download, a daemon with media/grabber/
  Secret Service implemented) than this harness's bound tasks, which are
  always fresh and never started, can produce. Optional/omit-if-absent
  fields (effectiveUrl, requiresAuth, capabilities) are correctly absent;
  the mismatch is against the golden's illustrative values, not the
  contract.
- queue.start.json, category.remove.json — same class: startedTaskIds /
  reassignedTaskIds are correctly empty because this run's queue/category
  have no real membership.

`ctest -L conformance` is green: 100% (2/2), 81.7s (down from ~240s now
that pairing and the port/reconnect fixes remove the retries and the
5-10s timeouts the connection-death bug was producing).

One thing NOT fixed here, flagged for a follow-up decision rather than
touched mid-task: download.add.json's fixture is `startMode: "now"`
against a real, large (~6GB) Ubuntu ISO on the real internet, with
saveDir hardcoded to /home/sami/Downloads/Programs. Every run against a
real veloxd writes a real multi-GB file into that path — confirmed by
running this repeatedly during verification. Isolating the daemon's XDG
dirs doesn't isolate this. Worth its own change (startMode: "later"
would still exercise the add path without the transfer) but out of scope
for a fixture I wasn't asked to touch beyond what blocked this task.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SFeUKLbdHizrJjLBeK7ffz
2026-09-12 14:04:18 +04:00
134 changed files with 4814 additions and 424 deletions
+2 -1
View File
@@ -18,8 +18,9 @@ policy so it can be re-applied or audited.
| `bootstrap-script-2604` | now — real `--with-clang` install in a 26.04 container; the release the project ships on | | `bootstrap-script-2604` | now — real `--with-clang` install in a 26.04 container; the release the project ships on |
| `build (gcc)` / `build (clang)` | now — core, daemon and gui have merged | | `build (gcc)` / `build (clang)` | now — core, daemon and gui have merged |
| `sanitizers (dev)` / `sanitizers (tsan)` | now — core, daemon and gui have merged | | `sanitizers (dev)` / `sanitizers (tsan)` | now — core, daemon and gui have merged |
| `conformance` | **now — `tests/conformance/` has landed; this is the M0 exit gate** | | `conformance` | **now — `tests/conformance/` has landed; this is the M0 exit gate.** Includes the live-`veloxd` runner (step 3b of `run.sh`), unconditional in the script — see `docs/adr/0019-live-veloxd-conformance-is-required.md`. |
| `extension-lint` | now — `extension/` has merged (MV3 manifest + esbuild build) | | `extension-lint` | now — `extension/` has merged (MV3 manifest + esbuild build) |
| `gui-dod` | now — `gui/tests/dod/` has landed (GUI M1 DoD gates R3: `scroll-60fps`, `unhappy-path`); see `tests/integration/README.md#gui-m1-definition-of-done-gates-r3`. `gui-dod-nightly` (`rss-flat`) is schedule-only and cannot be a required PR check. |
`clang-tidy` is intentionally **not** required through M1 (`continue-on-error: true`, `clang-tidy` is intentionally **not** required through M1 (`continue-on-error: true`,
`.clang-tidy` has `WarningsAsErrors: ''`). Make it required at M2. `.clang-tidy` has `WarningsAsErrors: ''`). Make it required at M2.
+53
View File
@@ -248,3 +248,56 @@ jobs:
run: cmake --build --preset dev --target veloxd run: cmake --build --preset dev --target veloxd
- name: Nightly integration run - name: Nightly integration run
run: python3 tests/integration/nightly_run.py --veloxd build/dev/bin/veloxd --tasks 50 --timeout 180 run: python3 tests/integration/nightly_run.py --veloxd build/dev/bin/veloxd --tasks 50 --timeout 180
gui-dod:
# Per-PR GUI M1 DoD gates (gui/docs/pkg-qa-requests-m1.md R3): scroll-60fps and
# unhappy-path. The 10-minute rss-flat gate is gui-dod-nightly, not here. GUI's
# harness defaults QT_QPA_PLATFORM=offscreen itself, so no Xvfb/compositor needed.
# See tests/integration/README.md#gui-m1-definition-of-done-gates-r3 for what each
# gate catches and the forced-failure transcript proving it isn't vacuous.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Bootstrap toolchain
run: sudo ./tools/bootstrap.sh
- uses: actions/setup-node@v4
with:
node-version: '22' # tools/mockd
- name: Configure + build
run: |
cmake --preset dev
cmake --build --preset dev --target gui-dod-harness
- name: Install mockd
run: cd tools/mockd && npm ci
- name: Gates
run: |
gui/tests/dod/run.sh scroll-60fps --json scroll.json
gui/tests/dod/run.sh unhappy-path --json unhappy.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: gui-dod-${{ github.run_id }}
path: "*.json"
gui-dod-nightly:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Bootstrap toolchain
run: sudo ./tools/bootstrap.sh
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Configure + build
run: |
cmake --preset dev
cmake --build --preset dev --target gui-dod-harness
- run: cd tools/mockd && npm ci
- name: RSS soak (10 min)
run: gui/tests/dod/run.sh rss-flat --json rss.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: gui-dod-rss-${{ github.run_id }}
path: rss.json
+14
View File
@@ -45,3 +45,17 @@ crash-*
oom-* oom-*
leak-* leak-*
timeout-* timeout-*
# dpkg-buildpackage output — debhelper's build tree and the produced .deb/.changes, plus
# the classic out-of-tree autotools-style build dir some debhelper versions still create.
# debian/control, changelog, copyright, rules, postinst, postrm, source/format and
# *.lintian-overrides are real packaging source and stay tracked; everything below is
# regenerated by `dpkg-buildpackage` on every run.
debian/.debhelper/
debian/velox/
debian/files
debian/*.substvars
debian/*.debhelper.log
debian/*.debhelper
debian/debhelper-build-stamp
obj-*/
+1
View File
@@ -12,6 +12,7 @@ Read this before touching anything. Then read your lane brief in [docs/agents/](
| GUI | `gui/` | everything else | | GUI | `gui/` | everything else |
| EXT | `extension/` | everything else | | EXT | `extension/` | everything else |
| PKG/QA | `packaging/`, `.github/`, `tools/testserver/`, `tests/integration/`, `tests/e2e/`, root build files | any lane's feature code | | PKG/QA | `packaging/`, `.github/`, `tools/testserver/`, `tests/integration/`, `tests/e2e/`, root build files | any lane's feature code |
| PORT | `**/platform/<os>/**`, `packaging/macos/`, `packaging/windows/` | all logic, and `cmake/` — see [ADR 0020](docs/adr/0020-cross-platform-strategy.md) |
If your task seems to require editing another lane's files, that is a signal the interface If your task seems to require editing another lane's files, that is a signal the interface
is wrong. **File the request; don't reach across.** is wrong. **File the request; don't reach across.**
+76
View File
@@ -29,6 +29,10 @@ option(VELOX_BUILD_FUZZ "Build libFuzzer targets (clang only)" OFF)
option(VELOX_ENABLE_MEDIA "Build the HLS/DASH media grabber (M4)" OFF) option(VELOX_ENABLE_MEDIA "Build the HLS/DASH media grabber (M4)" OFF)
option(VELOX_WERROR "Treat warnings as errors" ON) option(VELOX_WERROR "Treat warnings as errors" ON)
# Central OS detection (docs/adr/0020-cross-platform-strategy.md). Included early: the
# dependency finds below gate Linux-only libraries on VELOX_OS_LINUX.
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/platform.cmake)
# Project-wide warning flags. Set via add_compile_options (a directory property), not the # Project-wide warning flags. Set via add_compile_options (a directory property), not the
# CMAKE_CXX_FLAGS cache variable, because the dev/tsan presets overwrite that cache var # CMAKE_CXX_FLAGS cache variable, because the dev/tsan presets overwrite that cache var
# wholesale for sanitizer flags — a target's warnings must not ride on it. # wholesale for sanitizer flags — a target's warnings must not ride on it.
@@ -55,12 +59,24 @@ endif()
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/daemon/CMakeLists.txt) if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/daemon/CMakeLists.txt)
find_package(SQLite3 REQUIRED) find_package(SQLite3 REQUIRED)
find_package(nlohmann_json 3.11 REQUIRED) find_package(nlohmann_json 3.11 REQUIRED)
# libsecret / Secret Service is Linux-only (docs/adr/0020-cross-platform-strategy.md,
# docs/08-porting.md): macOS uses Keychain behind the same credential-store seam, so
# this stays REQUIRED on Linux and simply absent elsewhere — PORT's job is to add the
# macOS side of that seam, not to touch this find.
if(VELOX_OS_LINUX)
find_package(PkgConfig REQUIRED) find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBSECRET REQUIRED IMPORTED_TARGET libsecret-1) pkg_check_modules(LIBSECRET REQUIRED IMPORTED_TARGET libsecret-1)
endif() endif()
endif()
if(VELOX_BUILD_GUI AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/gui/CMakeLists.txt) if(VELOX_BUILD_GUI AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/gui/CMakeLists.txt)
# DBus (gui/docs/pkg-qa-requests-m1.md R4) backs the org.freedesktop.portal
# GlobalShortcuts path — Linux/portal-only, same reasoning as libsecret above.
if(VELOX_OS_LINUX)
find_package(Qt6 6.6 REQUIRED COMPONENTS Widgets Svg Network DBus LinguistTools)
else()
find_package(Qt6 6.6 REQUIRED COMPONENTS Widgets Svg Network LinguistTools) find_package(Qt6 6.6 REQUIRED COMPONENTS Widgets Svg Network LinguistTools)
endif()
qt_standard_project_setup() qt_standard_project_setup()
endif() endif()
@@ -105,3 +121,63 @@ if(VELOX_BUILD_TESTS)
endif() endif()
endforeach() endforeach()
endif() endif()
# --- Install rules (M6 packaging; docs/07-packaging.md; owned by PKG/QA) ------------
#
# Guarded by `if(TARGET ...)`, not by editing each lane's own CMakeLists.txt: install()
# is a packaging concern, and reaching into daemon/CMakeLists.txt (DAEMON's), cli's build
# file (also DAEMON's) or gui/CMakeLists.txt (GUI's) to add it there would cross a lane
# boundary CLAUDE.md draws on purpose. Every target below is defined by its own lane;
# this only says where the packaged binary already built by that lane's rules goes.
#
# libveloxcore is intentionally absent here: it stays a static library linked into each
# binary (user decision, see docs/07-packaging.md) — there is no .so to install.
#
# CMAKE_INSTALL_LIBDIR is multiarch-adjusted (lib/x86_64-linux-gnu/) by GNUInstallDirs on
# Debian; systemd user units and the Mozilla native-messaging directory are NOT
# architecture-specific paths, so those two destinations are written literally
# (lib/systemd/user, lib/mozilla/...) rather than built from that variable.
include(GNUInstallDirs)
if(TARGET veloxd)
install(TARGETS veloxd RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(FILES ${CMAKE_SOURCE_DIR}/packaging/man/veloxd.8
DESTINATION ${CMAKE_INSTALL_MANDIR}/man8)
# velox.socket ships alongside velox.service: DAEMON wired real sd_listen_fds()
# socket activation (daemon/src/rpc/systemd_activation.cpp) — see
# packaging/systemd/README.md for the pair's own rationale.
install(FILES ${CMAKE_SOURCE_DIR}/packaging/systemd/velox.service
${CMAKE_SOURCE_DIR}/packaging/systemd/velox.socket
DESTINATION lib/systemd/user)
endif()
if(TARGET velox)
install(TARGETS velox RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
# DAEMON owns cli/ and its man page (CLAUDE.md); packaging only installs it.
install(FILES ${CMAKE_SOURCE_DIR}/cli/man/velox.1
DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
endif()
if(TARGET velox-gui)
install(TARGETS velox-gui RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(FILES ${CMAKE_SOURCE_DIR}/packaging/man/velox-gui.1
DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
install(FILES ${CMAKE_SOURCE_DIR}/packaging/desktop/velox.desktop
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
foreach(iconsize 16 22 24 32 48 64 128 256)
install(FILES
${CMAKE_SOURCE_DIR}/packaging/icons/hicolor/${iconsize}x${iconsize}/apps/velox.png
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/${iconsize}x${iconsize}/apps)
endforeach()
endif()
# nmhost and its manifest are DAEMON's (CLAUDE.md: nmhost/ and packaging/nativehost/).
# This lights up the moment DAEMON's target exists, the same way the add_subdirectory()
# guards above do — no coordinated edit needed when it lands.
if(TARGET velox-nmhost)
install(TARGETS velox-nmhost RUNTIME DESTINATION libexec/velox)
install(FILES ${CMAKE_SOURCE_DIR}/packaging/nativehost/com.velox.host.json
DESTINATION lib/mozilla/native-messaging-hosts)
else()
message(STATUS "velox: nmhost has not landed yet — native-messaging manifest not installed.")
endif()
+45
View File
@@ -0,0 +1,45 @@
# cmake/platform.cmake — central OS detection and per-OS source selection.
#
# Owned by PKG/QA (docs/adr/0020-cross-platform-strategy.md §4-5, amended: PORT owns
# **/platform/<os>/** and the macOS/Windows packaging trees, but this file is root build
# infrastructure and lands in Phase 0 — CORE's and DAEMON's own seams need
# velox_platform_sources() to select their platform/linux/*.cpp before PORT ever starts).
#
# Sets exactly one of VELOX_OS_LINUX / VELOX_OS_MACOS / VELOX_OS_WINDOWS. Selection lives
# here and nowhere else — no lane's own CMakeLists.txt should reimplement this check.
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
set(VELOX_OS_LINUX TRUE)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
set(VELOX_OS_MACOS TRUE)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
set(VELOX_OS_WINDOWS TRUE)
else()
message(FATAL_ERROR "velox: unsupported CMAKE_SYSTEM_NAME '${CMAKE_SYSTEM_NAME}' — "
"expected Linux, Darwin or Windows.")
endif()
# velox_platform_sources(<target> <dir>) adds <dir>/platform/<os>/*.cpp to <target>, where
# <os> is linux, macos or windows to match this file's VELOX_OS_* selection. <dir> is
# relative to the calling lane's own CMakeLists.txt (e.g. src/io, src/rpc) — the seam
# headers themselves (<dir>/platform/*.hpp) are not globbed here, they're ordinary sources
# the owning lane already lists.
function(velox_platform_sources target dir)
if(VELOX_OS_LINUX)
set(os_dir "linux")
elseif(VELOX_OS_MACOS)
set(os_dir "macos")
elseif(VELOX_OS_WINDOWS)
set(os_dir "windows")
endif()
file(GLOB platform_sources CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/${dir}/platform/${os_dir}/*.cpp")
if(NOT platform_sources)
message(WARNING "velox: velox_platform_sources(${target} ${dir}) found no "
"sources under ${dir}/platform/${os_dir}/ — is the seam missing "
"its ${os_dir} backend?")
endif()
target_sources(${target} PRIVATE ${platform_sources})
endfunction()
+6 -1
View File
@@ -506,7 +506,12 @@ def emit_field_parse(f: Field, indent: str) -> list[str]:
f'{i} const auto it = j.find("{f.name}");'] f'{i} const auto it = j.find("{f.name}");']
if f.optional: if f.optional:
# Absent and null mean the same thing: the field is not set. A client that omits # Absent and null mean the same thing: the field is not set. A client that omits
# a nullable field and one that sends null are treated identically on purpose. # a nullable field and one that sends null are treated identically on purpose --
# correct for create-style params, where there is no existing value to distinguish
# "never set" from "explicitly cleared". Patch-style fields need the distinction
# (download.update's patch: "an explicit null clears a nullable field") and get an
# opt-in exception via x-clearable per ADR 0018 (not implemented yet: this is the
# decision record, not the generator change).
o.append(f"{i} if (it != j.end() && !it->is_null()) {{") o.append(f"{i} if (it != j.end() && !it->is_null()) {{")
o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ") o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ")
o.append(f"{i} out.{m} = std::move(val);") o.append(f"{i} out.{m} = std::move(val);")
+25 -1
View File
@@ -21,7 +21,7 @@ fixtures/
```jsonc ```jsonc
{ {
"name": "download.add — start an ISO now, into the Programs category", "name": "download.add — add an ISO for later, into the Programs category",
"description": "Why this case is worth pinning.", "description": "Why this case is worth pinning.",
"transport": "uds", // optional: replay only on this transport "transport": "uds", // optional: replay only on this transport
"requires": "...", // optional: a condition a plain server cannot produce "requires": "...", // optional: a condition a plain server cannot produce
@@ -81,3 +81,27 @@ cases in `tests/integration/`.
correct response is *no response*: past 750 ms the extension must abandon the offer and let correct response is *no response*: past 750 ms the extension must abandon the offer and let
Firefox download normally. A download manager that eats downloads when its daemon is down Firefox download normally. A download manager that eats downloads when its daemon is down
is worse than no download manager. is worse than no download manager.
## No fixture may pair a real external URL with `startMode: "now"`
This suite replays every fixture against a real, live `veloxd` (`tests/conformance/run.sh`),
not just `mockd`. `mockd` never actually fetches anything, so it hid this for a while: a
fixture with `startMode: "now"` (or `"queue"` into a running queue — anything that gets
admitted to the scheduler right away) and a real, resolvable URL makes a **real** daemon
actually start downloading it, for real, onto whatever machine runs the suite. This
happened — twice, with `download.add.json` pointed at a ~6 GB Ubuntu ISO, straight into the
developer's real `~/Downloads`.
The fix in each case is one of:
- `startMode: "later"` — exercises the add path (validation, category assignment, the
event) without ever handing the task to the engine;
- a URL under `example.org`/`example.com` (IANA-reserved for exactly this, RFC 2606) —
resolvable enough to validate as a URL, never a real download source;
- `requires`, if the fixture's entire point needs a real transfer to fail in a specific way
(see `errors/download.add.disk-full.json`) — skipped by default, so it only ever runs
where the condition has actually been arranged.
A real `saveDir` gets the same treatment for the same reason: an absolute path like
`/home/sami/Downloads/...` only means anything on the machine that fixture was written on.
Omit `saveDir` and let `saveTo.defaultDir` apply, or use a relative-feeling path under a
root the runner controls.
+6 -5
View File
@@ -1,22 +1,23 @@
{ {
"name": "capture.offer — attachment on a monitored type is taken", "name": "capture.offer — attachment on a monitored type is taken",
"description": "Golden fixture. tests/conformance replays this against the real daemon AND the TS client. If either side drifts, this goes red before the lanes ever integrate.", "description": "Golden fixture. tests/conformance replays this against the real daemon AND the TS client. If either side drifts, this goes red before the lanes ever integrate. url is example.org (RFC 2606), not a real download source: 'take' against a real veloxd (tests/conformance/run.sh) admits a real task and hands it to the engine for real, and no fixture may do that against a real external URL. contentLength is a plausible-but-small 5 MiB rather than a real ISO's size: the 'Programs' category's saveDir is a migration-seeded builtin (~/Downloads/Programs, daemon/src/store/migrations/0001_initial.sql), not something an isolated test run's settings can redirect, so 'take' always sparse-preallocates into that real path on whatever machine runs this suite -- keeping the declared size small keeps that footprint trivial instead of a real ISO's worth of disk. transport is uds only: a real 'take' persists an active task, so replaying this same fixture again on a second live transport against the same daemon would correctly dedupe against it (capture.offer dedupes by exact URL) and get 'ignore' instead -- an artifact of replaying one fixture against one shared daemon over two transports, not a behaviour to golden.",
"transport": "uds",
"request": { "request": {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 42, "id": 42,
"method": "capture.offer", "method": "capture.offer",
"params": { "params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", "url": "https://example.org/dl/ubuntu-26.04-desktop-amd64.iso",
"method": "GET", "method": "GET",
"tabUrl": "https://releases.ubuntu.com/26.04/", "tabUrl": "https://example.org/26.04/",
"headers": { "headers": {
"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:154.0) Gecko/20100101 Firefox/154.0", "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:154.0) Gecko/20100101 Firefox/154.0",
"Referer": "https://releases.ubuntu.com/26.04/", "Referer": "https://example.org/26.04/",
"Accept": "*/*" "Accept": "*/*"
}, },
"cookies": [], "cookies": [],
"contentType": "application/octet-stream", "contentType": "application/octet-stream",
"contentLength": 6228541440, "contentLength": 5242880,
"contentDisposition": "attachment; filename=\"ubuntu-26.04-desktop-amd64.iso\"", "contentDisposition": "attachment; filename=\"ubuntu-26.04-desktop-amd64.iso\"",
"filename": "ubuntu-26.04-desktop-amd64.iso", "filename": "ubuntu-26.04-desktop-amd64.iso",
"origin": "moz-extension://11111111-2222-3333-4444-555555555555" "origin": "moz-extension://11111111-2222-3333-4444-555555555555"
+6 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "download.add \u2014 start an ISO now, into the Programs category", "name": "download.add — add an ISO for later, into the Programs category",
"description": "The ordinary add path. saveDir is canonicalized and checked against the allowed roots before anything is written.", "description": "The ordinary add path. saveDir is canonicalized and checked against the allowed roots before anything is written. startMode is 'later' deliberately: this suite replays against a real veloxd (tests/conformance/run.sh), and a real daemon given startMode 'now' would actually start fetching url for real. No fixture may pair a real external URL with startMode 'now' -- see contracts/fixtures/README.md.",
"request": { "request": {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 11, "id": 11,
@@ -8,10 +8,9 @@
"params": { "params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"filename": "ubuntu-26.04-desktop-amd64.iso", "filename": "ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/Programs",
"categoryId": "programs", "categoryId": "programs",
"segments": 8, "segments": 8,
"startMode": "now" "startMode": "later"
} }
}, },
"response": { "response": {
@@ -19,13 +18,13 @@
"id": 11, "id": 11,
"result": { "result": {
"taskId": "$uuid", "taskId": "$uuid",
"state": "connecting", "state": "paused",
"duplicate": null "duplicate": null
} }
}, },
"assertions": [ "assertions": [
"the .veloxpart file is created sparse and preallocated at the final size", "saveDir is omitted here on purpose: it resolves to saveTo.defaultDir, which is itself checked against saveTo.allowedRoots the same way an explicit saveDir would be -- see errors/download.add.invalid-path.json for the -32011 case",
"saveDir resolves inside saveTo.allowedRoots, or the call fails -32011 having written nothing", "startMode 'later' lands the task in 'paused' and never hands it to the engine, so nothing is fetched and no .veloxpart is created yet -- that only happens once the task is actually started (download.start.json, or startMode 'now'/'queue' against a source this suite controls)",
"event.task.added is emitted to every subscriber before this reply is sent" "event.task.added is emitted to every subscriber before this reply is sent"
] ]
} }
+1 -1
View File
@@ -20,7 +20,7 @@
], ],
"defaults": { "defaults": {
"url": "https://example.org/", "url": "https://example.org/",
"categoryId": "compressed", "categoryId": "programs",
"startMode": "queue", "startMode": "queue",
"queueId": "main" "queueId": "main"
} }
@@ -30,5 +30,6 @@
"the version check is transport-independent; this is replayed on the Unix socket so it is not masked by -32002", "the version check is transport-independent; this is replayed on the Unix socket so it is not masked by -32002",
"data.expected is the daemon's own current protocol version string (kProtocolVersion), not a bare major and not pinnable in a golden file -- the conformance compare on error payloads is on `code` only, structural elsewhere, so echoing the live version is fine" "data.expected is the daemon's own current protocol version string (kProtocolVersion), not a bare major and not pinnable in a golden file -- the conformance compare on error payloads is on `code` only, structural elsewhere, so echoing the live version is fine"
], ],
"transport": "uds" "transport": "uds",
"closesConnection": true
} }
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "limiter.get \u2014 the limiter is off", "name": "limiter.get \u2014 the limiter is off",
"description": "globalBps still carries the last configured value so the GUI can restore it when the user re-enables the limit.", "description": "globalBps still carries the last configured value so the GUI can restore it when the user re-enables the limit. applyToRunning is a write-only instruction on limiter.set (\"retune already-running transfers now\", not a persisted setting), so it never comes back from get.",
"request": { "request": {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 52, "id": 52,
@@ -12,11 +12,11 @@
"id": 52, "id": 52,
"result": { "result": {
"enabled": false, "enabled": false,
"globalBps": 2097152, "globalBps": 2097152
"applyToRunning": false
} }
}, },
"assertions": [ "assertions": [
"enabled false means no throttling regardless of globalBps" "enabled false means no throttling regardless of globalBps",
"applyToRunning is absent, not false: it's meaningless outside a limiter.set call"
] ]
} }
+5
View File
@@ -32,6 +32,11 @@ add_library(veloxcore STATIC
) )
add_library(velox::core ALIAS veloxcore) add_library(velox::core ALIAS veloxcore)
# Per-OS backend behind src/io/platform/*.hpp (docs/adr/0020, docs/08-porting.md). Only
# the Linux backend exists (Phase 0); source selection lives in cmake/platform.cmake and
# nowhere else, so PORT's platform/macos/ addition needs no edit here.
velox_platform_sources(veloxcore src/io)
target_include_directories(veloxcore target_include_directories(veloxcore
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src # net/*.cpp -> "net/curl_error.hpp" PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src # net/*.cpp -> "net/curl_error.hpp"
+38
View File
@@ -0,0 +1,38 @@
// vdm/io/platform/file_ops.hpp — platform seam for the three Linux-only file-I/O
// primitives sparse_file.cpp and meta/veloxpart.cpp use (ADR 0020, docs/08-porting.md).
//
// One implementation file per OS under platform/<os>/, selected at build time. No #ifdef
// here, no OS-specific types in the signatures — a reader of the callers must not need to
// know which OS they're on. Linux (platform/linux/file_ops.cpp) is the reference
// implementation and is the only one that exists until the PORT lane adds macOS.
//
// This header compiles standalone.
#ifndef VDM_IO_PLATFORM_FILE_OPS_HPP
#define VDM_IO_PLATFORM_FILE_OPS_HPP
#include <cstdint>
#include "vdm/util/result.hpp"
namespace vdm::io::platform {
// Reserve `bytes` for `fd` so a large transfer doesn't fragment or hit ENOSPC mid-write.
// Linux: posix_fallocate, falling back to ftruncate on EOPNOTSUPP/ENOSYS/EINVAL exactly as
// SparseFile did before this seam existed. Returns whether a true extent-reserving
// preallocation happened (false when the ftruncate fallback was used) so callers can keep
// reporting SparseFile::preallocated() unchanged.
[[nodiscard]] Result<bool> preallocate(int fd, std::uint64_t bytes);
// Drop cached pages for [offset, offset+len) from the OS page cache, best-effort. A
// platform with no equivalent (macOS) is a documented no-op, not a failure — never let
// this block or fail the transfer path.
void advise_dontneed(int fd, std::uint64_t offset, std::uint64_t len) noexcept;
// Flush `fd`'s data to durable storage without waiting on metadata that doesn't affect
// data readback. Linux: fdatasync.
[[nodiscard]] Result<void> flush_durable(int fd);
} // namespace vdm::io::platform
#endif // VDM_IO_PLATFORM_FILE_OPS_HPP
+73
View File
@@ -0,0 +1,73 @@
// vdm/io/platform/linux/file_ops.cpp — Linux backend for the file_ops seam.
//
// Reference implementation (ADR 0020): moved unchanged from sparse_file.cpp and
// meta/veloxpart.cpp. Linux behaviour must never change as a result of a port; if a port
// needs a semantic change here, that's an ADR, not a seam edit.
#include "io/platform/file_ops.hpp"
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <string>
namespace vdm::io::platform {
namespace {
Error errno_to_error(int e) noexcept {
switch (e) {
case ENOSPC:
case EDQUOT:
return Error::disk_full;
case EACCES:
case EPERM:
case EROFS:
return Error::permission_denied;
case ENOENT:
case ENOTDIR:
case EISDIR:
case ENAMETOOLONG:
case ELOOP:
return Error::path_rejected;
default:
return Error::io_error;
}
}
ErrorInfo sys_error(std::string_view what, int e) {
return ErrorInfo(errno_to_error(e), std::string(what) + ": " + std::strerror(e));
}
} // namespace
Result<bool> preallocate(int fd, std::uint64_t bytes) {
// posix_fallocate returns the error number directly and does not set errno.
int rc = ::posix_fallocate(fd, 0, static_cast<off_t>(bytes));
if (rc == 0)
return true;
if (rc == EOPNOTSUPP || rc == ENOSYS || rc == EINVAL) {
if (::ftruncate(fd, static_cast<off_t>(bytes)) != 0)
return sys_error("ftruncate", errno);
return false;
}
return sys_error("posix_fallocate", rc);
}
void advise_dontneed(int fd, std::uint64_t offset, std::uint64_t len) noexcept {
if (len == 0)
return;
::posix_fadvise(fd, static_cast<off_t>(offset), static_cast<off_t>(len), POSIX_FADV_DONTNEED);
}
Result<void> flush_durable(int fd) {
while (::fdatasync(fd) != 0) {
if (errno == EINTR)
continue;
return sys_error("fdatasync", errno);
}
return ok();
}
} // namespace vdm::io::platform
+8 -19
View File
@@ -9,6 +9,8 @@
#include <cstring> #include <cstring>
#include <utility> #include <utility>
#include "io/platform/file_ops.hpp"
namespace vdm::io { namespace vdm::io {
namespace { namespace {
@@ -90,20 +92,12 @@ Result<void> SparseFile::open(std::string_view path, const OpenOptions &opts) {
bool prealloc = false; bool prealloc = false;
if (opts.total_size > 0) { if (opts.total_size > 0) {
if (opts.preallocate) { if (opts.preallocate) {
// posix_fallocate returns the error number directly and does not set errno. Result<bool> r = platform::preallocate(fd, opts.total_size);
int rc = ::posix_fallocate(fd, 0, static_cast<off_t>(opts.total_size)); if (!r.has_value()) {
if (rc == 0) {
prealloc = true;
} else if (rc == EOPNOTSUPP || rc == ENOSYS || rc == EINVAL) {
if (::ftruncate(fd, static_cast<off_t>(opts.total_size)) != 0) {
int e = errno;
::close(fd); ::close(fd);
return sys_error("ftruncate " + p, e); return std::move(r).error();
}
} else {
::close(fd);
return sys_error("posix_fallocate " + p, rc);
} }
prealloc = *r;
} else if (!opts.truncate_existing) { } else if (!opts.truncate_existing) {
// Resuming: make sure the file is at least total_size so pwrite offsets land. // Resuming: make sure the file is at least total_size so pwrite offsets land.
if (::ftruncate(fd, static_cast<off_t>(opts.total_size)) != 0) { if (::ftruncate(fd, static_cast<off_t>(opts.total_size)) != 0) {
@@ -147,18 +141,13 @@ Result<void> SparseFile::write_at(std::uint64_t offset, ConstByteSpan data) {
Result<void> SparseFile::sync() { Result<void> SparseFile::sync() {
if (fd_ < 0) if (fd_ < 0)
return ErrorInfo(Error::internal, "sync on a closed SparseFile"); return ErrorInfo(Error::internal, "sync on a closed SparseFile");
while (::fdatasync(fd_) != 0) { return platform::flush_durable(fd_);
if (errno == EINTR)
continue;
return sys_error("fdatasync", errno);
}
return ok();
} }
void SparseFile::advise_dontneed(std::uint64_t offset, std::uint64_t len) noexcept { void SparseFile::advise_dontneed(std::uint64_t offset, std::uint64_t len) noexcept {
if (fd_ < 0 || len == 0) if (fd_ < 0 || len == 0)
return; return;
::posix_fadvise(fd_, static_cast<off_t>(offset), static_cast<off_t>(len), POSIX_FADV_DONTNEED); platform::advise_dontneed(fd_, offset, len);
} }
Result<void> SparseFile::resize(std::uint64_t size) { Result<void> SparseFile::resize(std::uint64_t size) {
+4 -5
View File
@@ -13,6 +13,7 @@
#include <string> #include <string>
#include <utility> #include <utility>
#include "io/platform/file_ops.hpp"
#include "vdm/util/crc32.hpp" #include "vdm/util/crc32.hpp"
namespace vdm::meta { namespace vdm::meta {
@@ -272,13 +273,11 @@ Result<void> write_veloxpart_file(std::string_view path, const VeloxPart &vp, bo
} }
if (fsync) { if (fsync) {
while (::fdatasync(fd) != 0) { Result<void> r = vdm::io::platform::flush_durable(fd);
if (errno == EINTR) if (!r.has_value()) {
continue;
int e = errno;
::close(fd); ::close(fd);
::unlink(tmp.c_str()); ::unlink(tmp.c_str());
return sys_error("fdatasync " + tmp, e); return ErrorInfo(r.error().code, "fdatasync " + tmp + ": " + r.error().context);
} }
} }
if (::close(fd) != 0) { if (::close(fd) != 0) {
+124 -6
View File
@@ -64,6 +64,19 @@ std::string lower(std::string s) {
return s; return s;
} }
// scheme://host[:port] of `url`, with no path/query/fragment -- what docs/04 §7's "403
// after redirect: retry once with the original referrer" retries with as the Referer
// header. Empty on an unparseable URL (the caller just won't get a referrer retry).
std::string origin_of(std::string_view url) {
auto s = net::split_url(url);
if (!s.valid)
return {};
std::string out = s.scheme + "://" + s.host;
if (s.port)
out += ":" + std::to_string(*s.port);
return out;
}
} // namespace } // namespace
// What to do once every worker has drained (see DownloadTaskState::begin_drain_locked). // What to do once every worker has drained (see DownloadTaskState::begin_drain_locked).
@@ -88,6 +101,7 @@ struct SegWorker {
bool needs_auth = false; bool needs_auth = false;
bool wrong_status = false; bool wrong_status = false;
bool range_bad = false; bool range_bad = false;
bool forbidden = false; // 403 -- docs/04 §7's "retry once with the original referrer"
bool auth_handshake = false; // saw a 401/407 and let libcurl resend with credentials bool auth_handshake = false; // saw a 401/407 and let libcurl resend with credentials
std::string resp_etag, resp_last_modified; // captured on a wrong_status 200, for demote std::string resp_etag, resp_last_modified; // captured on a wrong_status 200, for demote
std::optional<ErrorInfo> flush_error; std::optional<ErrorInfo> flush_error;
@@ -135,6 +149,16 @@ struct DownloadTaskState : std::enable_shared_from_this<DownloadTaskState> {
std::optional<std::uint64_t> total_size; std::optional<std::uint64_t> total_size;
std::string origin_host; std::string origin_host;
// docs/04 §7's "403 after redirect: retry once with the original referrer" -- many
// CDNs 403 a bare/foreign Referer. Starts as spec.referrer (the browser's, verbatim);
// start_worker_locked() sends this, not spec.referrer directly, so a 403 retry can
// override it (to the download URL's own origin) without touching what the caller
// actually asked for. referrer_retried bounds it to exactly once per task -- a second
// 403 with a same-origin Referer already set is a real, honest failure
// (Error::forbidden), not something a referrer swap can fix.
std::string effective_referrer;
bool referrer_retried = false;
std::unique_ptr<segment::Segmenter> seg; std::unique_ptr<segment::Segmenter> seg;
std::unique_ptr<io::SparseFile> file; std::unique_ptr<io::SparseFile> file;
std::unordered_map<std::uint32_t, std::unique_ptr<SegWorker>> workers; std::unordered_map<std::uint32_t, std::unique_ptr<SegWorker>> workers;
@@ -164,7 +188,7 @@ struct DownloadTaskState : std::enable_shared_from_this<DownloadTaskState> {
std::vector<std::function<void()>> deferred; std::vector<std::function<void()>> deferred;
DownloadTaskState(TaskHost &h, TaskId i, DownloadSpec s, DownloadCallbacks c) DownloadTaskState(TaskHost &h, TaskId i, DownloadSpec s, DownloadCallbacks c)
: host(h), id(i), spec(std::move(s)), cbs(std::move(c)) {} : host(h), id(i), spec(std::move(s)), cbs(std::move(c)), effective_referrer(spec.referrer) {}
// --- deferred callbacks ------------------------------------------------------------- // --- deferred callbacks -------------------------------------------------------------
void defer(std::function<void()> fn) { void defer(std::function<void()> fn) {
@@ -286,7 +310,7 @@ void DownloadTaskState::restart_probe(bool with_auth) {
pr.url = spec.url; pr.url = spec.url;
pr.headers = spec.headers; pr.headers = spec.headers;
pr.cookies = spec.cookies; pr.cookies = spec.cookies;
pr.referrer = spec.referrer; pr.referrer = effective_referrer;
pr.user_agent = spec.user_agent; pr.user_agent = spec.user_agent;
pr.proxy = spec.proxy; pr.proxy = spec.proxy;
if (with_auth) if (with_auth)
@@ -299,12 +323,37 @@ void DownloadTaskState::restart_probe(bool with_auth) {
} }
void DownloadTaskState::on_probe_result(Result<net::ProbeResult> r) { void DownloadTaskState::on_probe_result(Result<net::ProbeResult> r) {
bool retry_probe_with_referrer = false;
{ {
std::unique_lock lk(mu); std::unique_lock lk(mu);
if (retired.load() || is_terminal(state)) if (retired.load() || is_terminal(state))
return; return;
if (!r.has_value()) { if (!r.has_value()) {
fail_locked(std::move(r).error()); ErrorInfo e = std::move(r).error();
// docs/04 §7's referrer retry applies here too: a probe (HEAD, or the
// ranged-GET fallback when HEAD is refused -- probe.cpp) can be the request
// that actually gets 403'd, before any segment worker exists to retry it
// (net::Prober builds its own request from ProbeRequest::referrer, not
// through start_worker_locked() -- see restart_probe()'s use of
// effective_referrer below). Same one-shot bound via referrer_retried as the
// worker-level retry (seg_finished's w->forbidden branch) shares.
if (e.code == Error::forbidden && !referrer_retried) {
referrer_retried = true;
effective_referrer = origin_of(spec.url);
retry_probe_with_referrer = true;
} else if (e.code == Error::forbidden) {
// Already retried with the origin referrer and still 403 -- not something
// another blind retry fixes (an expired signed URL, a private resource).
// Ask rather than fail outright, the same "ask, don't just fail" shape as
// wrong_status/range_bad/the worker-level 403 branch: refresh_url() is a
// no-op once the task is terminal, and tools/testserver's expiring-signed-
// url mode (also a bare 403, indistinguishable from any other without
// parsing the body -- CLAUDE.md §3, core never does) is meant to be
// recovered exactly that way.
auto_pause_locked(std::move(e), false, true);
} else {
fail_locked(std::move(e));
}
} else { } else {
probe = std::move(r).value(); probe = std::move(r).value();
have_probe = true; have_probe = true;
@@ -319,6 +368,8 @@ void DownloadTaskState::on_probe_result(Result<net::ProbeResult> r) {
} }
} }
flush_deferred(); flush_deferred();
if (retry_probe_with_referrer)
restart_probe(false);
} }
void DownloadTaskState::finish_probe_locked() { void DownloadTaskState::finish_probe_locked() {
@@ -472,7 +523,7 @@ void DownloadTaskState::start_worker_locked(std::uint32_t seg_idx) {
req.url = current_url(); req.url = current_url();
req.headers = spec.headers; req.headers = spec.headers;
req.cookies = spec.cookies; req.cookies = spec.cookies;
req.referrer = spec.referrer; req.referrer = effective_referrer;
req.user_agent = spec.user_agent; req.user_agent = spec.user_agent;
req.proxy = spec.proxy; req.proxy = spec.proxy;
req.auth = spec.auth; req.auth = spec.auth;
@@ -552,6 +603,10 @@ net::DataAction DownloadTaskState::seg_head(std::uint32_t seg_idx, const net::Re
w->range_bad = true; w->range_bad = true;
return net::DataAction::abort; return net::DataAction::abort;
} }
if (h.status == 403) {
w->forbidden = true;
return net::DataAction::abort;
}
if (h.status >= 400) if (h.status >= 400)
return net::DataAction::abort; return net::DataAction::abort;
seg->set_segment_state(seg_idx, segment::SegState::downloading); seg->set_segment_state(seg_idx, segment::SegState::downloading);
@@ -637,7 +692,7 @@ void DownloadTaskState::seg_finished(std::uint32_t seg_idx, Result<net::Transfer
// (content-length-mismatch's honest-length lie, flaky-reset's tail, a proxy RST after // (content-length-mismatch's honest-length lie, flaky-reset's tail, a proxy RST after
// the last byte). If the segment is fully covered, that's a success. // the last byte). If the segment is fully covered, that's a success.
if (seg && !cancel_requested && !pause_requested && !w->needs_auth && !w->wrong_status && if (seg && !cancel_requested && !pause_requested && !w->needs_auth && !w->wrong_status &&
!w->flush_error && !w->range_bad) { !w->flush_error && !w->range_bad && !w->forbidden) {
const std::uint64_t len = seg->segment_end(seg_idx) - seg->segment_start(seg_idx) + 1; const std::uint64_t len = seg->segment_end(seg_idx) - seg->segment_start(seg_idx) + 1;
if (len != 0 && seg->segment_completed(seg_idx) >= len) { if (len != 0 && seg->segment_completed(seg_idx) >= len) {
r = Result<net::TransferStats>(net::TransferStats{}); r = Result<net::TransferStats>(net::TransferStats{});
@@ -756,6 +811,35 @@ void DownloadTaskState::seg_finished(std::uint32_t seg_idx, Result<net::Transfer
auto_pause_locked(ErrorInfo(Error::range_not_satisfiable, "416"), false, true); auto_pause_locked(ErrorInfo(Error::range_not_satisfiable, "416"), false, true);
return done(); return done();
} }
if (w->forbidden) {
// docs/04 §7: "403 after redirect: retry once with the original referrer -- many
// CDNs require it." Bare/foreign Referer is the common cause; origin_of() rebuilds
// it from the (possibly redirected) URL the response actually came from. Exactly
// once per task, not a backoff series -- a second 403 with a same-origin Referer
// already set isn't something another blind retry can fix (a private/expired
// resource, an expiring signed URL past its window, ...). That's not necessarily
// terminal, though: ask (same "ask, don't just fail outright" shape as
// wrong_status/range_bad above) rather than fail_locked() outright, specifically
// so DownloadHandle::refresh_url() -- do_refresh_url() is a no-op once the task is
// terminal -- stays usable for the case tools/testserver's README pairs it with:
// a caller that gets a fresh signed URL and hands it back.
release_slot();
if (!referrer_retried) {
referrer_retried = true;
effective_referrer = origin_of(current_url());
seg->set_segment_state(seg_idx, segment::SegState::stalled);
auto wp = weak_from_this();
host.schedule(std::chrono::steady_clock::now(), [wp, seg_idx] {
if (auto s = wp.lock())
s->retry_worker(seg_idx);
});
if (workers.empty())
transition(EngineState::retry_wait, std::nullopt);
} else {
auto_pause_locked(ErrorInfo(Error::forbidden, "403", w->http_status), false, true);
}
return done();
}
if (!r.has_value()) { if (!r.has_value()) {
ErrorInfo e = std::move(r).error(); ErrorInfo e = std::move(r).error();
@@ -1215,6 +1299,7 @@ void DownloadTaskState::do_refresh_url(std::string url, std::vector<net::HeaderF
net::ProbeRequest pr; net::ProbeRequest pr;
pr.url = spec.url; pr.url = spec.url;
pr.headers = spec.headers; pr.headers = spec.headers;
pr.referrer = effective_referrer;
pr.auth = spec.auth; pr.auth = spec.auth;
pr.proxy = spec.proxy; pr.proxy = spec.proxy;
host.probe(std::move(pr), [wp](Result<net::ProbeResult> r) { host.probe(std::move(pr), [wp](Result<net::ProbeResult> r) {
@@ -1224,10 +1309,43 @@ void DownloadTaskState::do_refresh_url(std::string url, std::vector<net::HeaderF
std::unique_lock lk(s->mu); std::unique_lock lk(s->mu);
if (s->retired.load() || is_terminal(s->state)) if (s->retired.load() || is_terminal(s->state))
return; return;
if (r.has_value()) { if (!r.has_value()) {
lk.unlock();
s->flush_deferred();
return; // still paused; the caller can retry refresh_url() or decide()
}
if (!s->have_probe) {
// The task's *first* probe never succeeded (e.g. this session's own
// expiring-signed-url path: 403, one referrer retry, still 403 -> ask rather
// than fail outright -- see on_probe_result() -- specifically so this branch
// exists to recover it). finish_probe_locked() is what actually registers the
// task with the budget and builds its Segmenter; nothing downstream of a
// partial field copy would ever start a worker without it.
s->probe = std::move(r).value();
s->have_probe = true;
s->awaiting_auth = false;
s->awaiting_decision = false;
s->finish_probe_locked();
lk.unlock();
s->flush_deferred();
return;
}
s->probe.effective_url = r.value().effective_url; s->probe.effective_url = r.value().effective_url;
s->probe.etag = r.value().etag; s->probe.etag = r.value().etag;
s->probe.last_modified = r.value().last_modified; s->probe.last_modified = r.value().last_modified;
// refresh_url()'s own contract is "on a live OR PAUSED task, without losing
// progress" -- distinct from do_decide(restart), which discards progress. A task
// can be paused here for any of three reasons (a plain user pause, awaiting_auth,
// or awaiting_decision -- e.g. this session's own 403-after-referrer-retry path,
// or the pre-existing wrong_status/range_bad ones); apply_slot_target()'s guard
// blocks on awaiting_auth/awaiting_decision specifically, so leaving either set
// would have set_want() below recompute a target that nothing ever acts on --
// the caller's new URL re-probed successfully and then the task just sat there.
// Clear both and leave `paused` the same way do_decide(restart) does.
if (s->state == EngineState::paused) {
s->awaiting_auth = false;
s->awaiting_decision = false;
s->transition(EngineState::connecting, std::nullopt);
} }
if (s->registered) if (s->registered)
s->host.budget().set_want(s->id, s->want_slots()); s->host.budget().set_want(s->id, s->want_slots());
+10 -2
View File
@@ -28,7 +28,14 @@ namespace vdm::testing {
class TestServer { class TestServer {
public: public:
TestServer() { TestServer() : TestServer(1.0) {}
// loris_seconds overrides the dribble duration slow-loris mode uses (default matches the
// no-arg ctor's long-standing 1s). A test that needs curl's stall detector
// (CURLOPT_LOW_SPEED_TIME, hardcoded to 30s in download_task.cpp) to actually fire needs a
// dribble that outlasts that threshold, not the short one every other test relies on to
// keep runtime down.
explicit TestServer(double loris_seconds) {
const char *script = VDM_TESTSERVER_PY; const char *script = VDM_TESTSERVER_PY;
if (!script || !*script || ::access(script, R_OK) != 0) if (!script || !*script || ::access(script, R_OK) != 0)
return; return;
@@ -50,8 +57,9 @@ class TestServer {
int devnull = ::open("/dev/null", O_WRONLY); int devnull = ::open("/dev/null", O_WRONLY);
if (devnull >= 0) if (devnull >= 0)
::dup2(devnull, STDERR_FILENO); ::dup2(devnull, STDERR_FILENO);
std::string loris_str = std::to_string(loris_seconds);
::execlp("python3", "python3", script, "--port", "0", "--seed", "9", "--loris-seconds", ::execlp("python3", "python3", script, "--port", "0", "--seed", "9", "--loris-seconds",
"1", "--throttle-bps", "131072", static_cast<char *>(nullptr)); loris_str.c_str(), "--throttle-bps", "131072", static_cast<char *>(nullptr));
::_exit(127); ::_exit(127);
} }
::close(pipefd[1]); ::close(pipefd[1]);
+185
View File
@@ -124,6 +124,31 @@ std::string server_sha(TestServer &srv, const std::string &mode, const std::stri
return out.substr(open + 1, close - open - 1); return out.substr(open + 1, close - open - 1);
} }
// Small, deliberately identical extraction to server_sha's: GET /<mode>/sign/<size>?ttl=N
// and pull the "url" field's value out of the {"url":..., "exp":...} JSON body.
std::string sign_url(TestServer &srv, const std::string &mode, const std::string &size,
int ttl_seconds) {
std::string url =
srv.url("/" + mode + "/sign/" + size + "?ttl=" + std::to_string(ttl_seconds));
std::string cmd = "curl -s '" + url + "'";
std::string out;
if (FILE *f = ::popen(cmd.c_str(), "r")) {
char buf[1024];
while (std::fgets(buf, sizeof buf, f))
out += buf;
::pclose(f);
}
auto q = out.find("\"url\"");
if (q == std::string::npos)
return {};
auto colon = out.find(':', q);
auto open = out.find('"', colon);
auto close = out.find('"', open + 1);
if (open == std::string::npos || close == std::string::npos)
return {};
return out.substr(open + 1, close - open - 1);
}
DownloadSpec spec_for(TestServer &srv, const std::string &urlpath, const std::string &save) { DownloadSpec spec_for(TestServer &srv, const std::string &urlpath, const std::string &save) {
DownloadSpec s; DownloadSpec s;
s.url = srv.url(urlpath); s.url = srv.url(urlpath);
@@ -328,6 +353,30 @@ VT_TEST(engine_401_then_provide_auth_completes) {
VT_CHECK_EQ(file_size(td.file("au.bin")), 1u * 1024 * 1024); VT_CHECK_EQ(file_size(td.file("au.bin")), 1u * 1024 * 1024);
} }
VT_TEST(engine_401_digest_then_provide_auth_completes) {
// Same shape as engine_401_then_provide_auth_completes, but the challenge is HTTP
// Digest (qop=auth) rather than Basic. provide_auth() doesn't know or care which --
// http_client.cpp always asks libcurl for CURLAUTH_ANY (net::AuthScheme::any) and lets
// curl negotiate against whatever WWW-Authenticate the server actually sent -- so this
// exists purely to prove that's true end-to-end, not just at the unit level.
TestServer srv;
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
DownloadHandle h;
auto cbs = rec.cbs(&h, "test", "test");
h = eng.start(spec_for(srv, "/401-digest/file/1M", td.file("dg.bin")), std::move(cbs));
rec.arm(h);
auto r = rec.wait();
VT_REQUIRE(r.has_value());
VT_CHECK(rec.auth_calls.load() >= 1);
VT_CHECK_EQ(file_size(td.file("dg.bin")), 1u * 1024 * 1024);
auto got = hash_file(td.file("dg.bin"), Checksum::Algo::sha256);
VT_CHECK_EQ(got.value(), server_sha(srv, "401-digest", "1M"));
}
// --- hostile-mode matrix: the four where a bug is silent corruption, not a visible // --- hostile-mode matrix: the four where a bug is silent corruption, not a visible
// failure (docs/04 §5 "ask, never silently corrupt" / §7's failure-policy table). --- // failure (docs/04 §5 "ask, never silently corrupt" / §7's failure-policy table). ---
@@ -458,6 +507,142 @@ VT_TEST(engine_content_length_mismatch_fails_honestly) {
VT_CHECK_EQ(::access(td.file("clm.bin").c_str(), F_OK), -1); // never renamed into place VT_CHECK_EQ(::access(td.file("clm.bin").c_str(), F_OK), -1); // never renamed into place
} }
// --- remaining hostile-mode matrix (tools/testserver/README.md's mode table). ---
VT_TEST(engine_expiring_signed_url_recovers_via_refresh_url) {
// A signed URL past its ttl 403s (tools/testserver's own JSON body distinguishes
// "expired" from "bad signature", but core never parses response bodies -- CLAUDE.md
// §3 -- so both just read as a 403). The one automatic referrer retry (see
// engine_403_without_referer_retries_with_origin, below) can't fix an expired
// signature, so the second 403 asks -- via the same auto_pause_locked(..., false,
// true) "ask, don't just fail" path as wrong_status/range_bad -- rather than
// terminally failing outright, specifically so DownloadHandle::refresh_url() (its own
// contract: works "on a live or paused task", never on a terminal one) stays usable:
// the README pairs this mode with exactly that recovery.
TestServer srv;
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
std::string expired = sign_url(srv, "expiring-signed-url", "64K", /*ttl=*/1);
VT_REQUIRE(!expired.empty());
std::this_thread::sleep_for(1500ms); // let the ttl actually pass before the first request
DownloadSpec s;
s.url = expired;
s.save_path = td.file("exp.bin");
auto h = eng.start(std::move(s), rec.cbs());
for (int i = 0; i < 300 && rec.decision_calls.load() == 0; ++i)
std::this_thread::sleep_for(20ms);
VT_REQUIRE(rec.decision_calls.load() >= 1);
VT_CHECK_EQ(h.state(), EngineState::paused);
std::string fresh = sign_url(srv, "expiring-signed-url", "64K", /*ttl=*/60);
VT_REQUIRE(!fresh.empty());
h.refresh_url(fresh);
auto r = rec.wait(60s);
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(file_size(td.file("exp.bin")), 64u * 1024);
auto got = hash_file(td.file("exp.bin"), Checksum::Algo::sha256);
VT_CHECK_EQ(got.value(), server_sha(srv, "expiring-signed-url", "64K"));
}
VT_TEST(engine_403_without_referer_retries_with_origin) {
// docs/04 §7: "403 after redirect: retry once with the original referrer -- many CDNs
// require it." No spec.referrer is set here (the common case for anything not
// initiated from a browser page, e.g. `velox add <url>`), so the first attempt 403s;
// the engine's own retry supplies the download URL's own origin as Referer, which
// this mode accepts, and the download completes with no decision ever asked.
TestServer srv;
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
auto h = eng.start(spec_for(srv, "/403-without-referer/file/128K", td.file("ref.bin")),
rec.cbs());
auto r = rec.wait(30s);
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(rec.decision_calls.load(), 0); // recovered automatically, not asked
VT_CHECK_EQ(file_size(td.file("ref.bin")), 128u * 1024);
auto got = hash_file(td.file("ref.bin"), Checksum::Algo::sha256);
VT_CHECK_EQ(got.value(), server_sha(srv, "403-without-referer", "128K"));
}
VT_TEST(engine_redirect_chain_follows_to_completion) {
// 5 hops (tools/testserver's own --redirect-depth default) of a plain 302, query
// string preserved across each. No CORE-side logic needed for this one -- libcurl's
// own CURLOPT_FOLLOWLOCATION (RequestOptions::follow_redirects, already on) and
// CURLOPT_MAXREDIRS (default 20, well over 5) do the whole thing -- this is here as
// the end-to-end check that they're actually wired through both the probe and every
// segment worker's own request, not just one of the two.
TestServer srv;
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
auto h = eng.start(spec_for(srv, "/redirect-chain/file/1M", td.file("rc.bin")), rec.cbs());
auto r = rec.wait(30s);
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(file_size(td.file("rc.bin")), 1u * 1024 * 1024);
auto got = hash_file(td.file("rc.bin"), Checksum::Algo::sha256);
VT_CHECK_EQ(got.value(), server_sha(srv, "redirect-chain", "1M"));
}
VT_TEST(engine_slow_loris_stall_timeout_fires) {
// Status line, headers, and body dribbled out one byte at a time for --loris-seconds,
// then (if the dribble hasn't already been cut off) normal streaming -- a connection
// that's technically alive (bytes ARE arriving, just far too slowly) but must not be
// allowed to hang the task forever. http_client.cpp sets CURLOPT_LOW_SPEED_LIMIT/_TIME
// (RequestOptions::low_speed_bytes_per_sec/low_speed_secs, hardcoded in
// download_task.cpp to 1024 B/s for 30s) for exactly this.
//
// Every other test in this file uses TestServer's default 1s loris dribble to keep
// runtime down, but 1s is far shorter than curl's 30s low_speed_time: a 1s trickle
// followed by full-speed streaming never accumulates 30 CONSECUTIVE seconds under the
// floor, so curl would never actually abort it -- the download would just complete
// slightly late, which would make this test pass for the wrong reason (or not exercise
// the stall timeout at all). Explicitly ask for a dribble that outlasts the 30s
// threshold so the stall timeout is the thing actually observed firing, not assumed.
TestServer srv(40.0);
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
auto s = spec_for(srv, "/slow-loris/file/64K", td.file("sl.bin"));
s.segments = 1;
s.max_retries = 1;
auto h = eng.start(std::move(s), rec.cbs());
auto r = rec.wait(60s); // stall timeout fires ~30s in; must resolve, not hang to 60s
VT_REQUIRE(!r.has_value());
VT_CHECK(is_retryable(r.error().code) || r.error().code == Error::max_retries_exhausted);
}
VT_TEST(engine_chunked_no_length_completes_single_segment) {
// No Content-Length anywhere (HEAD gets none either, since it's the same handler path)
// -- the probe can't know total_size or prove resumability, so this should take the
// exact same "unknown size, one plain-GET segment" path as engine_non_resumable_single_
// segment, just arriving there via a chunked body instead of a server that plainly
// refuses Range. No core-side work needed if that demotion is already size-agnostic;
// this is here to prove it, since every other test's server tells the probe the size
// up front.
TestServer srv;
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
auto h = eng.start(spec_for(srv, "/chunked-no-length/file/2M", td.file("ch.bin")),
rec.cbs());
auto r = rec.wait();
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(rec.decision_calls.load(), 0);
VT_CHECK_EQ(file_size(td.file("ch.bin")), 2u * 1024 * 1024);
auto got = hash_file(td.file("ch.bin"), Checksum::Algo::sha256);
VT_CHECK_EQ(got.value(), server_sha(srv, "chunked-no-length", "2M"));
}
// --- DAEMON-reported bug: Progress.speed_bps reads 0 for the whole life of a live // --- DAEMON-reported bug: Progress.speed_bps reads 0 for the whole life of a live
// download while downloaded bytes visibly advance. DAEMON reads progress by polling // download while downloaded bytes visibly advance. DAEMON reads progress by polling
// DownloadHandle::progress() (engine_port_core.hpp), not the on_progress push callback -- // DownloadHandle::progress() (engine_port_core.hpp), not the on_progress push callback --
+5 -1
View File
@@ -74,7 +74,6 @@ target_link_libraries(veloxd_sched PUBLIC velox::proto velox::core veloxd_store
# --- veloxd_rpc the RPC transports + dispatcher ------------------------------------ # --- veloxd_rpc the RPC transports + dispatcher ------------------------------------
add_library(veloxd_rpc STATIC add_library(veloxd_rpc STATIC
src/rpc/runtime_dir.cpp src/rpc/runtime_dir.cpp
src/rpc/single_instance.cpp
src/rpc/systemd_activation.cpp src/rpc/systemd_activation.cpp
src/rpc/event_loop.cpp src/rpc/event_loop.cpp
src/rpc/event_hub.cpp src/rpc/event_hub.cpp
@@ -87,6 +86,11 @@ add_library(veloxd_rpc STATIC
) )
add_library(velox::daemon_rpc ALIAS veloxd_rpc) add_library(velox::daemon_rpc ALIAS veloxd_rpc)
# Per-OS backends behind daemon/src/rpc/platform/*.hpp (docs/adr/0020, docs/08-porting.md).
# Source selection lives in cmake/platform.cmake and nowhere else PORT adds
# platform/macos/ with no edit here.
velox_platform_sources(veloxd_rpc src/rpc)
target_include_directories(veloxd_rpc PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) target_include_directories(veloxd_rpc PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_features(veloxd_rpc PUBLIC cxx_std_23) target_compile_features(veloxd_rpc PUBLIC cxx_std_23)
target_compile_options(veloxd_rpc PRIVATE -Wall -Wextra -Wpedantic -Werror) target_compile_options(veloxd_rpc PRIVATE -Wall -Wextra -Wpedantic -Werror)
+1
View File
@@ -27,4 +27,5 @@ close. Kept here (not buried in commit messages) so the next pass can see them a
| ~~D5~~ | **Mostly closed**`rpc/event_hub` fans out per-subscription; `session.subscribe` on both transports registers/updates/tears down a real subscription; `Scheduler::transition()` publishes `event.task.state` (with `previousState`) on every state change, scheduler-driven or engine-reported; `dispatcher::on_download_add` publishes `event.task.added`; a 250 ms timer batches `Scheduler::progress_snapshot()` into one `event.task.progress` array per AGENT-DAEMON.md item 5 / the schema's `x-maxRateHz: 4`. Verified live end to end. | — | `event.task.removed` has no source yet (`download.remove` is D3); `event.speed.global`, `event.notify`, `event.auth.required`, `event.settings.changed`, `event.grabber.progress` are unpublished — each lands with its owning handler | as each owning D3 handler lands | | ~~D5~~ | **Mostly closed**`rpc/event_hub` fans out per-subscription; `session.subscribe` on both transports registers/updates/tears down a real subscription; `Scheduler::transition()` publishes `event.task.state` (with `previousState`) on every state change, scheduler-driven or engine-reported; `dispatcher::on_download_add` publishes `event.task.added`; a 250 ms timer batches `Scheduler::progress_snapshot()` into one `event.task.progress` array per AGENT-DAEMON.md item 5 / the schema's `x-maxRateHz: 4`. Verified live end to end. | — | `event.task.removed` has no source yet (`download.remove` is D3); `event.speed.global`, `event.notify`, `event.auth.required`, `event.settings.changed`, `event.grabber.progress` are unpublished — each lands with its owning handler | as each owning D3 handler lands |
| ~~D6~~ | **Closed** — engine numbers now reach the store: `Scheduler::tick()` probes (`EnginePort::probe`) before every `start()`, persisting `sizeBytes`/`resumable`/validators via `Tasks::set_probe_result` before a byte moves; `Scheduler::persist_progress()` (called from `progress_snapshot()` *and* once more from `on_engine_state` right before `release()`/unmap on every terminal transition) writes `downloadedBytes`/`speedBps`/`segments`/`segmentDetail` from the engine's `Progress`, so a task that finishes between two 250 ms ticks (the common case for anything small or fast) still leaves real numbers instead of the pre-persistence defaults. `TaskSummary.segments` is sourced from `segments.size()` when the task has any (matching what actually lands in `segmentDetail`, per the schema's "exactly `segments` entries"), falling back to the engine's `effective_segments` (budget slots *held*, not necessarily physical range count — see `core/include/vdm/task/download.hpp`'s `Progress` comment) only pre-segmentation. `Tasks::set_final_bytes` tops up `on_finished`'s byte count as a last-resort backstop. Migration `0002` adds `speed_bps` to both `tasks` and `segments`, and fixes `segments.state`'s CHECK to include `'pending'` (0001 omitted it, so a pre-connect snapshot could never be written). Verified against real `veloxd` + `tools/testserver` (not just unit tests): `download.list`/`download.get` correct immediately after completion and after a daemon restart. | `sched/scheduler.{cpp,hpp}`, `store/{tasks,segments}.{cpp,hpp}`, `store/migrations/0002_*.sql` | — | done | | ~~D6~~ | **Closed** — engine numbers now reach the store: `Scheduler::tick()` probes (`EnginePort::probe`) before every `start()`, persisting `sizeBytes`/`resumable`/validators via `Tasks::set_probe_result` before a byte moves; `Scheduler::persist_progress()` (called from `progress_snapshot()` *and* once more from `on_engine_state` right before `release()`/unmap on every terminal transition) writes `downloadedBytes`/`speedBps`/`segments`/`segmentDetail` from the engine's `Progress`, so a task that finishes between two 250 ms ticks (the common case for anything small or fast) still leaves real numbers instead of the pre-persistence defaults. `TaskSummary.segments` is sourced from `segments.size()` when the task has any (matching what actually lands in `segmentDetail`, per the schema's "exactly `segments` entries"), falling back to the engine's `effective_segments` (budget slots *held*, not necessarily physical range count — see `core/include/vdm/task/download.hpp`'s `Progress` comment) only pre-segmentation. `Tasks::set_final_bytes` tops up `on_finished`'s byte count as a last-resort backstop. Migration `0002` adds `speed_bps` to both `tasks` and `segments`, and fixes `segments.state`'s CHECK to include `'pending'` (0001 omitted it, so a pre-connect snapshot could never be written). Verified against real `veloxd` + `tools/testserver` (not just unit tests): `download.list`/`download.get` correct immediately after completion and after a daemon restart. | `sched/scheduler.{cpp,hpp}`, `store/{tasks,segments}.{cpp,hpp}`, `store/migrations/0002_*.sql` | — | done |
| ~~D11~~ | **Closed — build order items 7 (the systemd half) and 9: `velox-nmhost`, socket activation, the systemd user units, and `velox(1)`.** `nmhost/src/main.cpp` (185 lines): a `poll()`-driven byte pump between Firefox's native-messaging framing on stdio (4-byte native-byte-order length prefix) and `veloxd`'s own NDJSON framing on the Unix socket — reframes each direction, no JSON parsing, no retry/backoff, exits the moment either side closes. Deliberately dependency-free (no `veloxd_*` library, no `nlohmann_json`) since it runs unconfined outside Firefox's sandbox whatever the packaging format. Two real bugs found and fixed while getting the integration test to actually pass rather than hang: (1) never set the pumped fds non-blocking, so the "drain what's available" read loop blocked on its own second `read()` instead of returning to `poll()`; (2) stdin and stdout are two different descriptors (0 and 1), not one — an early draft polled `POLLOUT` on fd 0, which is opened read-only, so EOF/writability were never both observable through the same `pollfd` entry. Both are exactly the class of bug a "trivial pump" invites and unit tests over the real binary (not just its helper functions) exist specifically to catch. `packaging/nativehost/com.velox.host.json` + its own `README.md` supersede `AGENT-DAEMON.md`'s stale "four locations" (spike S1 / ADR 0003 found only three are real — the fourth, `~/snap/firefox/common/.mozilla/...`, is not read by snap Firefox at all) and spell out the per-user-manifest / postinst implication for PKG/QA. `EnginePort`-style: `rpc/systemd_activation.cpp` is a from-scratch `sd_listen_fds()` (env vars only, no `libsystemd` link — `LISTEN_PID`/`LISTEN_FDS`, fd 3) that `UdsServer::start()` checks first, skipping its own create/bind/chmod/listen when systemd already bound the socket; `packaging/systemd/velox.socket` + `velox.service` are the unit pair, verified both by `systemd-analyze verify` and by an actual fork/dup2/execve simulation of the activation handshake (a real `session.hello` round-tripped over the handed-off fd with no `bind()` ever called inside the daemon for that run). `velox.service` deliberately skips `ProtectSystem=`/`ProtectHome=`/`ReadWritePaths=``saveTo.allowedRoots` is user-configurable to anywhere on the filesystem, and a sandbox here would turn a legitimately-configured save location into an opaque `EROFS`/`EACCES` instead of the daemon's own clear `-32011`. `cli/man/velox.1` documents the CLI as it actually exists today (`add`/`ls`/`pause`/`resume`/`rm`, `--json`, the three-tier `queue`/`settings` subcommands `AGENT-DAEMON.md` build step 8 originally sketched are not implemented in `cli/src/main.cpp` yet, so the page doesn't claim they are) — checked warning-free with `groff -mandoc -ww -z`. | `nmhost/{CMakeLists.txt,src/main.cpp,tests/}`, `daemon/src/rpc/{systemd_activation.{hpp,cpp},uds_server.cpp}`, `packaging/{nativehost,systemd}/`, `cli/man/velox.1` | — | done | | ~~D11~~ | **Closed — build order items 7 (the systemd half) and 9: `velox-nmhost`, socket activation, the systemd user units, and `velox(1)`.** `nmhost/src/main.cpp` (185 lines): a `poll()`-driven byte pump between Firefox's native-messaging framing on stdio (4-byte native-byte-order length prefix) and `veloxd`'s own NDJSON framing on the Unix socket — reframes each direction, no JSON parsing, no retry/backoff, exits the moment either side closes. Deliberately dependency-free (no `veloxd_*` library, no `nlohmann_json`) since it runs unconfined outside Firefox's sandbox whatever the packaging format. Two real bugs found and fixed while getting the integration test to actually pass rather than hang: (1) never set the pumped fds non-blocking, so the "drain what's available" read loop blocked on its own second `read()` instead of returning to `poll()`; (2) stdin and stdout are two different descriptors (0 and 1), not one — an early draft polled `POLLOUT` on fd 0, which is opened read-only, so EOF/writability were never both observable through the same `pollfd` entry. Both are exactly the class of bug a "trivial pump" invites and unit tests over the real binary (not just its helper functions) exist specifically to catch. `packaging/nativehost/com.velox.host.json` + its own `README.md` supersede `AGENT-DAEMON.md`'s stale "four locations" (spike S1 / ADR 0003 found only three are real — the fourth, `~/snap/firefox/common/.mozilla/...`, is not read by snap Firefox at all) and spell out the per-user-manifest / postinst implication for PKG/QA. `EnginePort`-style: `rpc/systemd_activation.cpp` is a from-scratch `sd_listen_fds()` (env vars only, no `libsystemd` link — `LISTEN_PID`/`LISTEN_FDS`, fd 3) that `UdsServer::start()` checks first, skipping its own create/bind/chmod/listen when systemd already bound the socket; `packaging/systemd/velox.socket` + `velox.service` are the unit pair, verified both by `systemd-analyze verify` and by an actual fork/dup2/execve simulation of the activation handshake (a real `session.hello` round-tripped over the handed-off fd with no `bind()` ever called inside the daemon for that run). `velox.service` deliberately skips `ProtectSystem=`/`ProtectHome=`/`ReadWritePaths=``saveTo.allowedRoots` is user-configurable to anywhere on the filesystem, and a sandbox here would turn a legitimately-configured save location into an opaque `EROFS`/`EACCES` instead of the daemon's own clear `-32011`. `cli/man/velox.1` documents the CLI as it actually exists today (`add`/`ls`/`pause`/`resume`/`rm`, `--json`, the three-tier `queue`/`settings` subcommands `AGENT-DAEMON.md` build step 8 originally sketched are not implemented in `cli/src/main.cpp` yet, so the page doesn't claim they are) — checked warning-free with `groff -mandoc -ww -z`. | `nmhost/{CMakeLists.txt,src/main.cpp,tests/}`, `daemon/src/rpc/{systemd_activation.{hpp,cpp},uds_server.cpp}`, `packaging/{nativehost,systemd}/`, `cli/man/velox.1` | — | done |
| ~~D12~~ | **Closed — ADR 0020 Phase 0 for `daemon/`.** Pure refactor, zero behaviour change: the four Linux-only surfaces docs/08-porting.md lists for this lane now sit behind seams in `daemon/src/rpc/platform/{wakeup,peer_id,instance_lock,runtime_paths}.hpp`, with today's syscalls moved unchanged into `platform/linux/*.cpp` (`eventfd` -> `platform::Wakeup`, `SO_PEERCRED`/`struct ucred` -> `platform::peer_of` — same-UID check preserved verbatim as the security property, not touched — abstract-namespace socket -> `platform::acquire_instance_lock`, XDG env lookups -> `platform::runtime_base_dir`/`data_base_dir`, with the 0700-and-owned enforcement itself staying portable POSIX logic in `rpc/runtime_dir.cpp` rather than moving). No fifth seam for `timerfd`: both daemon timers (1 s scheduler tick, 250 ms progress batch) now go through a new portable `EventLoop::add_timer`, which folds the next timer deadline into the existing `poll()` timeout instead of a platform timer fd — exactly the case docs/08-porting.md calls out as not needing a backend ("the loop already has a deadline set"), so it stays in `rpc/event_loop.{hpp,cpp}` untouched by lane PORT. `daemon/CMakeLists.txt` lists the four `platform/linux/*.cpp` files directly (no `cmake/platform.cmake` yet — that and `platform/macos/` are PORT's, per the CLAUDE.md lane table added alongside ADR 0020); PORT's Phase 1 replaces the hardcoded list with `velox_platform_sources()`. Old `rpc/single_instance.{hpp,cpp}` deleted (folded into the new seam); `single_instance_test.cpp` retargeted at `rpc/platform/instance_lock.hpp` with no behaviour change. Full suite 59/59 green; `git diff` outside `platform/linux/` is moves/call-site updates only, no `#ifdef` anywhere in `daemon/`. | `rpc/platform/{wakeup,peer_id,instance_lock,runtime_paths}.hpp`, `rpc/platform/linux/*.cpp`, `rpc/event_loop.{hpp,cpp}`, `rpc/{uds_server,runtime_dir}.cpp`, `main.cpp`, `CMakeLists.txt` | — | done |
| — | ~~Observed, not fixed (CORE, not this lane)~~**routed to CORE by the user.** `vdm::task::Progress.speed_bps` reads back as `0` for the whole lifetime of a live, real (non-fake) throttled download, despite `downloadedBytes` visibly advancing between polls — `core/src/task/download_task.cpp`'s per-worker EWMA never seems to produce a nonzero aggregate in this build. DAEMON passes `EnginePort::progress()`'s `speed_bps` straight through (`Scheduler::persist_progress`); nothing in this lane drops it. Still reproduces in the D4b live checks above (0 throughout a paused/resumed/cancelled transfer whose `downloadedBytes` visibly moved) — not re-filed, since it's already CORE's. | | — | ~~Observed, not fixed (CORE, not this lane)~~**routed to CORE by the user.** `vdm::task::Progress.speed_bps` reads back as `0` for the whole lifetime of a live, real (non-fake) throttled download, despite `downloadedBytes` visibly advancing between polls — `core/src/task/download_task.cpp`'s per-worker EWMA never seems to produce a nonzero aggregate in this build. DAEMON passes `EnginePort::progress()`'s `speed_bps` straight through (`Scheduler::persist_progress`); nothing in this lane drops it. Still reproduces in the D4b live checks above (0 throughout a paused/resumed/cancelled transfer whose `downloadedBytes` visibly moved) — not re-filed, since it's already CORE's. |
+8 -35
View File
@@ -5,6 +5,7 @@
// (AGENT-DAEMON.md build order, steps 1 and 3). The scheduler and the engine link land // (AGENT-DAEMON.md build order, steps 1 and 3). The scheduler and the engine link land
// next. // next.
#include <chrono>
#include <csignal> #include <csignal>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
@@ -15,16 +16,14 @@
#include <sys/un.h> #include <sys/un.h>
#include <unistd.h> #include <unistd.h>
#include <sys/timerfd.h>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "rpc/dispatcher.hpp" #include "rpc/dispatcher.hpp"
#include "rpc/event_hub.hpp" #include "rpc/event_hub.hpp"
#include "rpc/event_loop.hpp" #include "rpc/event_loop.hpp"
#include "rpc/pairing.hpp" #include "rpc/pairing.hpp"
#include "rpc/platform/instance_lock.hpp"
#include "rpc/runtime_dir.hpp" #include "rpc/runtime_dir.hpp"
#include "rpc/single_instance.hpp"
#include "rpc/uds_server.hpp" #include "rpc/uds_server.hpp"
#include "rpc/ws_server.hpp" #include "rpc/ws_server.hpp"
#include "sched/engine_port_core.hpp" #include "sched/engine_port_core.hpp"
@@ -56,7 +55,7 @@ int main() {
return 1; return 1;
} }
const int lock_fd = velox::daemon::rpc::acquire_single_instance_lock(rt.path); const int lock_fd = velox::daemon::rpc::platform::acquire_instance_lock(rt.path);
if (lock_fd < 0) { if (lock_fd < 0) {
std::cerr << "veloxd: another instance is already running for this runtime " std::cerr << "veloxd: another instance is already running for this runtime "
"directory (" << rt.path << ")\n"; "directory (" << rt.path << ")\n";
@@ -109,32 +108,15 @@ int main() {
}); });
// A 1 s timer re-runs the scheduler so schedule windows opening/closing and any // A 1 s timer re-runs the scheduler so schedule windows opening/closing and any
// missed nudge are picked up. Registered on the loop, no extra thread. // missed nudge are picked up. Expressed as a poll() timeout behind EventLoop rather
const int tick_fd = ::timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); // than a timerfd (docs/08-porting.md: "the loop already has a deadline set" — no
if (tick_fd >= 0) { // per-OS backend needed here, unlike the other three daemon/rpc seams).
itimerspec spec{}; loop.add_timer(std::chrono::seconds(1), [&] { (void)scheduler.tick(); });
spec.it_value.tv_sec = 1;
spec.it_interval.tv_sec = 1;
::timerfd_settime(tick_fd, 0, &spec, nullptr);
loop.add_fd(tick_fd, velox::daemon::rpc::kRead, [&](int fd, unsigned) {
std::uint64_t ticks = 0;
[[maybe_unused]] ssize_t n = ::read(fd, &ticks, sizeof(ticks));
(void)scheduler.tick();
});
}
// event.task.progress: one array message at <=4 Hz (schema x-maxRateHz), never one // event.task.progress: one array message at <=4 Hz (schema x-maxRateHz), never one
// notification per task (AGENT-DAEMON.md item 5). 250 ms keeps every active task's // notification per task (AGENT-DAEMON.md item 5). 250 ms keeps every active task's
// segment bar under 4 Hz without depending on how many tasks are running. // segment bar under 4 Hz without depending on how many tasks are running.
const int progress_fd = ::timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); loop.add_timer(std::chrono::milliseconds(250), [&] {
if (progress_fd >= 0) {
itimerspec spec{};
spec.it_value.tv_nsec = 250'000'000;
spec.it_interval.tv_nsec = 250'000'000;
::timerfd_settime(progress_fd, 0, &spec, nullptr);
loop.add_fd(progress_fd, velox::daemon::rpc::kRead, [&](int fd, unsigned) {
std::uint64_t ticks = 0;
[[maybe_unused]] ssize_t n = ::read(fd, &ticks, sizeof(ticks));
const auto rows = scheduler.progress_snapshot(); const auto rows = scheduler.progress_snapshot();
if (rows.empty()) return; if (rows.empty()) return;
@@ -159,7 +141,6 @@ int main() {
hub.publish(velox::proto::Event::TaskProgress, hub.publish(velox::proto::Event::TaskProgress,
velox::proto::make_notification(velox::proto::Event::TaskProgress, params)); velox::proto::make_notification(velox::proto::Event::TaskProgress, params));
}); });
}
velox::daemon::rpc::UdsServer uds(loop, dispatcher, hub, rt.socket_path(), &scheduler); velox::daemon::rpc::UdsServer uds(loop, dispatcher, hub, rt.socket_path(), &scheduler);
if (const auto ec = uds.start()) { if (const auto ec = uds.start()) {
@@ -186,14 +167,6 @@ int main() {
loop.run(); loop.run();
std::cout << "veloxd: shutting down\n"; std::cout << "veloxd: shutting down\n";
if (tick_fd >= 0) {
loop.del_fd(tick_fd);
::close(tick_fd);
}
if (progress_fd >= 0) {
loop.del_fd(progress_fd);
::close(progress_fd);
}
g_loop = nullptr; g_loop = nullptr;
::close(lock_fd); ::close(lock_fd);
return 0; return 0;
+44 -21
View File
@@ -1,9 +1,8 @@
#include "rpc/event_loop.hpp" #include "rpc/event_loop.hpp"
#include <poll.h> #include <poll.h>
#include <sys/eventfd.h>
#include <unistd.h>
#include <algorithm>
#include <cerrno> #include <cerrno>
#include <cstdint> #include <cstdint>
#include <stdexcept> #include <stdexcept>
@@ -12,14 +11,10 @@
namespace velox::daemon::rpc { namespace velox::daemon::rpc {
EventLoop::EventLoop() { EventLoop::EventLoop() {
wake_fd_ = ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); fds_.emplace(wakeup_.pollfd(), Entry{kRead, [this](int, unsigned) { wakeup_.drain(); }});
if (wake_fd_ < 0) throw std::runtime_error("eventfd() failed");
fds_.emplace(wake_fd_, Entry{kRead, [this](int, unsigned) { drain_wakeup(); }});
} }
EventLoop::~EventLoop() { EventLoop::~EventLoop() = default;
if (wake_fd_ >= 0) ::close(wake_fd_);
}
void EventLoop::add_fd(int fd, unsigned interest, Callback cb) { void EventLoop::add_fd(int fd, unsigned interest, Callback cb) {
fds_[fd] = Entry{interest, std::move(cb)}; fds_[fd] = Entry{interest, std::move(cb)};
@@ -30,15 +25,11 @@ void EventLoop::mod_fd(int fd, unsigned interest) {
} }
void EventLoop::del_fd(int fd) { void EventLoop::del_fd(int fd) {
if (fd == wake_fd_) return; // internal, never removed if (fd == wakeup_.pollfd()) return; // internal, never removed
fds_.erase(fd); fds_.erase(fd);
} }
void EventLoop::wake() noexcept { void EventLoop::wake() noexcept { wakeup_.signal(); }
const std::uint64_t one = 1;
// Best-effort: an EAGAIN here means a wakeup is already pending, which is fine.
[[maybe_unused]] ssize_t n = ::write(wake_fd_, &one, sizeof(one));
}
void EventLoop::stop() noexcept { void EventLoop::stop() noexcept {
stop_requested_ = true; stop_requested_ = true;
@@ -62,9 +53,39 @@ void EventLoop::drain_posts() {
for (auto& fn : batch) fn(); for (auto& fn : batch) fn();
} }
void EventLoop::drain_wakeup() noexcept { EventLoop::TimerId EventLoop::add_timer(std::chrono::milliseconds interval,
std::uint64_t sink = 0; std::function<void()> cb) {
while (::read(wake_fd_, &sink, sizeof(sink)) > 0) { const TimerId id = next_timer_id_++;
timers_.emplace(id, Timer{std::chrono::steady_clock::now() + interval, interval,
std::move(cb)});
wake(); // the loop may already be blocked on a longer timeout
return id;
}
void EventLoop::remove_timer(TimerId id) { timers_.erase(id); }
int EventLoop::next_timeout_ms() const {
if (timers_.empty()) return -1;
auto soonest = timers_.begin()->second.next;
for (const auto& [id, t] : timers_) soonest = std::min(soonest, t.next);
const auto now = std::chrono::steady_clock::now();
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(soonest - now);
return ms.count() > 0 ? static_cast<int>(ms.count()) : 0;
}
void EventLoop::run_due_timers() {
const auto now = std::chrono::steady_clock::now();
// Snapshot ids first: a callback may add/remove timers, which would invalidate
// iteration over timers_ directly.
std::vector<TimerId> due;
for (auto& [id, t] : timers_) {
if (t.next <= now) due.push_back(id);
}
for (const TimerId id : due) {
const auto it = timers_.find(id);
if (it == timers_.end()) continue; // removed by an earlier callback this pass
it->second.next = now + it->second.interval;
it->second.cb();
} }
} }
@@ -83,18 +104,22 @@ void EventLoop::run() {
short ev = 0; short ev = 0;
if (e.interest & kRead) ev |= POLLIN; if (e.interest & kRead) ev |= POLLIN;
if (e.interest & kWrite) ev |= POLLOUT; if (e.interest & kWrite) ev |= POLLOUT;
if (ev == 0 && fd != wake_fd_) continue; if (ev == 0 && fd != wakeup_.pollfd()) continue;
pollfd p{}; pollfd p{};
p.fd = fd; p.fd = fd;
p.events = ev; p.events = ev;
pfds.push_back(p); pfds.push_back(p);
} }
const int rc = ::poll(pfds.data(), pfds.size(), -1); const int rc = ::poll(pfds.data(), pfds.size(), next_timeout_ms());
if (rc < 0) { if (rc < 0) {
if (errno == EINTR) continue; if (errno == EINTR) continue;
throw std::runtime_error("poll() failed"); throw std::runtime_error("poll() failed");
} }
drain_posts();
run_due_timers();
if (rc == 0) continue; if (rc == 0) continue;
// Snapshot the fds that fired before invoking any callback: a callback may erase // Snapshot the fds that fired before invoking any callback: a callback may erase
@@ -104,8 +129,6 @@ void EventLoop::run() {
if (p.revents != 0) fired.push_back(p.fd); if (p.revents != 0) fired.push_back(p.fd);
} }
drain_posts();
for (const int fd : fired) { for (const int fd : fired) {
const auto it = fds_.find(fd); const auto it = fds_.find(fd);
if (it == fds_.end()) continue; // removed by an earlier callback this pass if (it == fds_.end()) continue; // removed by an earlier callback this pass
+27 -3
View File
@@ -10,12 +10,15 @@
// from any thread or a signal handler — they only write() a byte to an internal eventfd. // from any thread or a signal handler — they only write() a byte to an internal eventfd.
#include <atomic> #include <atomic>
#include <chrono>
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <mutex> #include <mutex>
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
#include "rpc/platform/wakeup.hpp"
namespace velox::daemon::rpc { namespace velox::daemon::rpc {
enum Interest : unsigned { enum Interest : unsigned {
@@ -58,22 +61,43 @@ public:
// marshal an engine-thread callback back onto the RPC loop. // marshal an engine-thread callback back onto the RPC loop.
void post(std::function<void()> fn); void post(std::function<void()> fn);
using TimerId = std::uint64_t;
// Fire `cb` roughly every `interval` for as long as the loop runs, expressed as a
// poll(2) timeout rather than a platform timer fd (docs/08-porting.md: "the loop
// already has a deadline set" — no per-OS backend needed for this one). Not
// reentrant-safe to call from inside a timer callback other than the one running.
TimerId add_timer(std::chrono::milliseconds interval, std::function<void()> cb);
void remove_timer(TimerId id);
private: private:
struct Entry { struct Entry {
unsigned interest; unsigned interest;
Callback cb; Callback cb;
}; };
void drain_wakeup() noexcept; struct Timer {
void drain_posts(); std::chrono::steady_clock::time_point next;
std::chrono::milliseconds interval;
std::function<void()> cb;
};
int wake_fd_; // eventfd, always registered void drain_posts();
// Milliseconds until the next timer is due, or -1 if there are none (poll()'s "block
// forever" convention).
int next_timeout_ms() const;
void run_due_timers();
platform::Wakeup wakeup_;
bool running_ = false; bool running_ = false;
std::atomic<bool> stop_requested_ = false; // set from stop(), read by run() std::atomic<bool> stop_requested_ = false; // set from stop(), read by run()
std::unordered_map<int, Entry> fds_; std::unordered_map<int, Entry> fds_;
std::mutex post_mu_; std::mutex post_mu_;
std::vector<std::function<void()>> posts_; std::vector<std::function<void()>> posts_;
TimerId next_timer_id_ = 1;
std::unordered_map<TimerId, Timer> timers_;
}; };
} // namespace velox::daemon::rpc } // namespace velox::daemon::rpc
+20
View File
@@ -0,0 +1,20 @@
#pragma once
// Single-instance guard, keyed by the resolved runtime directory (see rpc/runtime_dir.hpp)
// so isolated instances pointed at different runtime dirs never contend (docs/01 §2). The
// mechanism is Linux's abstract-namespace Unix socket; macOS has no abstract namespace and
// uses a real socket file plus flock() instead, which must unlink a stale socket left by a
// crashed process (docs/08-porting.md "API mapping" — the abstract version got that for
// free from the kernel).
#include <string>
#include <system_error>
namespace velox::daemon::rpc::platform {
// Returns the held fd (kept open for the process lifetime; closing it releases the lock)
// or -1 if another process already holds the lock for this exact `runtime_dir`, or on any
// other error acquiring it.
int acquire_instance_lock(const std::string& runtime_dir);
} // namespace velox::daemon::rpc::platform
@@ -1,4 +1,4 @@
#include "rpc/single_instance.hpp" #include "rpc/platform/instance_lock.hpp"
#include <cstddef> #include <cstddef>
#include <cstring> #include <cstring>
@@ -9,9 +9,9 @@
#include "util/crypto.hpp" #include "util/crypto.hpp"
namespace velox::daemon::rpc { namespace velox::daemon::rpc::platform {
int acquire_single_instance_lock(const std::string& runtime_dir) { int acquire_instance_lock(const std::string& runtime_dir) {
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) return -1; if (fd < 0) return -1;
@@ -35,4 +35,4 @@ int acquire_single_instance_lock(const std::string& runtime_dir) {
return fd; return fd;
} }
} // namespace velox::daemon::rpc } // namespace velox::daemon::rpc::platform
+17
View File
@@ -0,0 +1,17 @@
#include "rpc/platform/peer_id.hpp"
#include <sys/socket.h>
namespace velox::daemon::rpc::platform {
std::error_code peer_of(int fd, PeerId& out) {
ucred cred{};
socklen_t len = sizeof(cred);
if (::getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0) {
return std::error_code(errno, std::generic_category());
}
out.uid = cred.uid;
return {};
}
} // namespace velox::daemon::rpc::platform
@@ -0,0 +1,44 @@
#include "rpc/platform/runtime_paths.hpp"
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <cerrno>
#include <cstdlib>
namespace velox::daemon::rpc::platform {
namespace {
std::error_code errc(int e) { return std::error_code(e, std::generic_category()); }
} // namespace
std::error_code runtime_base_dir(std::string& out) {
if (const char* xdg = ::getenv("XDG_RUNTIME_DIR"); xdg != nullptr && xdg[0] != '\0') {
out = xdg;
return {};
}
const std::string base = "/run/user/" + std::to_string(::geteuid());
struct stat st{};
if (::stat(base.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) {
// No XDG_RUNTIME_DIR and no /run/user/<uid>: refuse rather than pick an insecure
// fallback. The caller surfaces this as "cannot start".
return errc(ENOENT);
}
out = base;
return {};
}
std::error_code data_base_dir(std::string& out) {
if (const char* xdg = ::getenv("XDG_DATA_HOME"); xdg != nullptr && xdg[0] != '\0') {
out = xdg;
return {};
}
if (const char* home = ::getenv("HOME"); home != nullptr && home[0] != '\0') {
out = std::string(home) + "/.local/share";
return {};
}
return errc(ENOENT);
}
} // namespace velox::daemon::rpc::platform
+32
View File
@@ -0,0 +1,32 @@
#include "rpc/platform/wakeup.hpp"
#include <sys/eventfd.h>
#include <unistd.h>
#include <cstdint>
#include <stdexcept>
namespace velox::daemon::rpc::platform {
Wakeup::Wakeup() {
fd_ = ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
if (fd_ < 0) throw std::runtime_error("eventfd() failed");
}
Wakeup::~Wakeup() {
if (fd_ >= 0) ::close(fd_);
}
void Wakeup::signal() noexcept {
const std::uint64_t one = 1;
// Best-effort: an EAGAIN here means a wakeup is already pending, which is fine.
[[maybe_unused]] ssize_t n = ::write(fd_, &one, sizeof(one));
}
void Wakeup::drain() noexcept {
std::uint64_t sink = 0;
while (::read(fd_, &sink, sizeof(sink)) > 0) {
}
}
} // namespace velox::daemon::rpc::platform
+22
View File
@@ -0,0 +1,22 @@
#pragma once
// Identifies the process on the other end of a connected Unix-domain socket, for the
// same-UID check that is the Unix transport's authorization boundary (docs/01 §2,
// CLAUDE.md §4). `SO_PEERCRED`/`struct ucred` is Linux-only; macOS has `getpeereid`,
// Windows named pipes carry a token instead (docs/08-porting.md "The seams" /
// "API mapping"). The same-UID check itself is the security property and must not change
// per-OS (docs/adr/0020 decision 2).
#include <system_error>
namespace velox::daemon::rpc::platform {
struct PeerId {
unsigned int uid = 0;
};
// On success, fills `out` with the peer's identity of the already-connected `fd`. On
// failure, `out` is untouched and the error_code explains why (matches errno on Linux).
std::error_code peer_of(int fd, PeerId& out);
} // namespace velox::daemon::rpc::platform
+22
View File
@@ -0,0 +1,22 @@
#pragma once
// Where the OS wants ephemeral runtime state and persistent user data to live, before
// velox appends its own "/velox" subdirectory and applies the shared 0700-and-owned check
// (rpc/runtime_dir.cpp — that enforcement is portable POSIX logic and stays there; only
// "which base directory" is per-OS). Linux: XDG. macOS: $TMPDIR (runtime) and
// ~/Library/Application Support (data) — see docs/08-porting.md "API mapping".
#include <string>
#include <system_error>
namespace velox::daemon::rpc::platform {
// The base directory ephemeral runtime state (sockets, lock files) should live under,
// e.g. "$XDG_RUNTIME_DIR" or "/run/user/<uid>" on Linux. No trailing slash.
std::error_code runtime_base_dir(std::string& out);
// The base directory persistent user data should live under, e.g. "$XDG_DATA_HOME" or
// "~/.local/share" on Linux. No trailing slash.
std::error_code data_base_dir(std::string& out);
} // namespace velox::daemon::rpc::platform
+36
View File
@@ -0,0 +1,36 @@
#pragma once
// The event-loop wakeup primitive (docs/adr/0020, docs/08-porting.md). EventLoop uses this
// to interrupt a blocked poll() from another thread or a signal handler — the eventfd
// mechanics themselves are Linux-only; every other OS backend just needs something
// poll()-able that signal()/drain() can drive the same way (docs/08 §"The seams": a
// self-pipe on macOS, an event object on Windows).
//
// One implementation file per OS under platform/<os>/wakeup.cpp; this header carries no
// OS types and no #ifdef (ADR 0020 decision 1).
namespace velox::daemon::rpc::platform {
class Wakeup {
public:
Wakeup();
~Wakeup();
Wakeup(const Wakeup&) = delete;
Wakeup& operator=(const Wakeup&) = delete;
// The fd to register with poll(2) for readability.
int pollfd() const noexcept { return fd_; }
// Make pollfd() readable. Async-signal-safe and thread-safe.
void signal() noexcept;
// Drain whatever signal() queued so pollfd() stops being readable. Call this from the
// loop thread once pollfd() fires.
void drain() noexcept;
private:
int fd_ = -1;
};
} // namespace velox::daemon::rpc::platform
+4 -19
View File
@@ -5,9 +5,10 @@
#include <unistd.h> #include <unistd.h>
#include <cerrno> #include <cerrno>
#include <cstdlib>
#include <string> #include <string>
#include "rpc/platform/runtime_paths.hpp"
namespace velox::daemon::rpc { namespace velox::daemon::rpc {
namespace { namespace {
@@ -33,17 +34,7 @@ std::error_code ensure_private_dir(const std::string& dir) {
std::error_code resolve_runtime_dir(RuntimeDir& out) { std::error_code resolve_runtime_dir(RuntimeDir& out) {
std::string base; std::string base;
if (const char* xdg = ::getenv("XDG_RUNTIME_DIR"); xdg != nullptr && xdg[0] != '\0') { if (auto ec = platform::runtime_base_dir(base)) return ec;
base = xdg;
} else {
base = "/run/user/" + std::to_string(::geteuid());
struct stat st{};
if (::stat(base.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) {
// No XDG_RUNTIME_DIR and no /run/user/<uid>: we refuse rather than pick an
// insecure fallback. The caller surfaces this as "cannot start".
return errc(ENOENT);
}
}
if (!base.empty() && base.back() == '/') base.pop_back(); if (!base.empty() && base.back() == '/') base.pop_back();
const std::string dir = base + "/velox"; const std::string dir = base + "/velox";
@@ -55,13 +46,7 @@ std::error_code resolve_runtime_dir(RuntimeDir& out) {
std::error_code resolve_data_dir(std::string& out) { std::error_code resolve_data_dir(std::string& out) {
std::string base; std::string base;
if (const char* xdg = ::getenv("XDG_DATA_HOME"); xdg != nullptr && xdg[0] != '\0') { if (auto ec = platform::data_base_dir(base)) return ec;
base = xdg;
} else if (const char* home = ::getenv("HOME"); home != nullptr && home[0] != '\0') {
base = std::string(home) + "/.local/share";
} else {
return errc(ENOENT);
}
if (!base.empty() && base.back() == '/') base.pop_back(); if (!base.empty() && base.back() == '/') base.pop_back();
// Create the XDG base components leniently, then the velox dir with a strict check. // Create the XDG base components leniently, then the velox dir with a strict check.
-27
View File
@@ -1,27 +0,0 @@
#pragma once
// Single-instance guard: bind an abstract-namespace Unix socket whose name is derived
// from the canonical runtime directory (resolve_runtime_dir's result — already the
// per-user default, /run/user/<uid>/velox, unless XDG_RUNTIME_DIR says otherwise). A
// second daemon pointed at the same runtime dir gets EADDRINUSE and exits; one pointed at
// a different (isolated / test) runtime dir gets its own lock and starts fine. The kernel
// reclaims an abstract-namespace address when the holding process dies, so a crash never
// wedges it (docs/01 §2).
//
// Naming this "velox-daemon-<euid>" alone (the old scheme) meant exactly one name per
// user system-wide, so XDG_RUNTIME_DIR isolation never reached it: a leaked test veloxd
// with the same euid held the lock for every isolated instance too, real or test, until
// it was killed. Hashing the resolved runtime dir path instead keeps the real per-user
// daemon unique (its runtime dir is unique to it) while letting isolated instances that
// each point at their own runtime dir coexist.
#include <string>
namespace velox::daemon::rpc {
// Returns the held fd (kept open for the process lifetime; closing it releases the lock)
// or -1 if another process already holds the lock for this exact `runtime_dir`, or on any
// other socket error.
int acquire_single_instance_lock(const std::string& runtime_dir);
} // namespace velox::daemon::rpc
+3 -4
View File
@@ -15,6 +15,7 @@
#include <fcntl.h> #include <fcntl.h>
#include "rpc/event_loop.hpp" #include "rpc/event_loop.hpp"
#include "rpc/platform/peer_id.hpp"
#include "rpc/systemd_activation.hpp" #include "rpc/systemd_activation.hpp"
#include "version.hpp" #include "version.hpp"
@@ -140,10 +141,8 @@ void UdsServer::on_listener_readable() {
break; // EMFILE/ENFILE: stop accepting this pass; loop retries on next readable break; // EMFILE/ENFILE: stop accepting this pass; loop retries on next readable
} }
ucred cred{}; platform::PeerId peer{};
socklen_t len = sizeof(cred); if (platform::peer_of(cfd, peer) || peer.uid != ::geteuid()) {
if (::getsockopt(cfd, SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0 ||
cred.uid != ::geteuid()) {
// Not the same user. The socket mode should already prevent this; refuse hard // Not the same user. The socket mode should already prevent this; refuse hard
// regardless — this is the authorization on the Unix transport (docs/01 §2). // regardless — this is the authorization on the Unix transport (docs/01 §2).
::close(cfd); ::close(cfd);
+7 -7
View File
@@ -5,15 +5,15 @@
#include <unistd.h> #include <unistd.h>
#include "check.hpp" #include "check.hpp"
#include "rpc/single_instance.hpp" #include "rpc/platform/instance_lock.hpp"
using namespace velox::daemon::rpc; using namespace velox::daemon::rpc::platform;
void run() { void run() {
// Two different runtime dirs: both acquire the lock independently. // Two different runtime dirs: both acquire the lock independently.
{ {
const int a = acquire_single_instance_lock("/run/user/1000/velox-test-a"); const int a = acquire_instance_lock("/run/user/1000/velox-test-a");
const int b = acquire_single_instance_lock("/run/user/1000/velox-test-b"); const int b = acquire_instance_lock("/run/user/1000/velox-test-b");
CHECK(a >= 0); CHECK(a >= 0);
CHECK(b >= 0); CHECK(b >= 0);
if (a >= 0) ::close(a); if (a >= 0) ::close(a);
@@ -22,16 +22,16 @@ void run() {
// Same runtime dir: the second attempt is refused while the first still holds it. // Same runtime dir: the second attempt is refused while the first still holds it.
{ {
const int first = acquire_single_instance_lock("/run/user/1000/velox-test-shared"); const int first = acquire_instance_lock("/run/user/1000/velox-test-shared");
CHECK(first >= 0); CHECK(first >= 0);
const int second = acquire_single_instance_lock("/run/user/1000/velox-test-shared"); const int second = acquire_instance_lock("/run/user/1000/velox-test-shared");
CHECK(second < 0); CHECK(second < 0);
if (first >= 0) ::close(first); if (first >= 0) ::close(first);
if (second >= 0) ::close(second); if (second >= 0) ::close(second);
// Releasing (closing) the fd frees the abstract-namespace name immediately — a // Releasing (closing) the fd frees the abstract-namespace name immediately — a
// third attempt at the same dir succeeds once the first is gone. // third attempt at the same dir succeeds once the first is gone.
const int third = acquire_single_instance_lock("/run/user/1000/velox-test-shared"); const int third = acquire_instance_lock("/run/user/1000/velox-test-shared");
CHECK(third >= 0); CHECK(third >= 0);
if (third >= 0) ::close(third); if (third >= 0) ::close(third);
} }
+11
View File
@@ -0,0 +1,11 @@
velox (0.1.0-1) UNRELEASED; urgency=medium
* Local test build only. Not signed, not uploaded anywhere — see
packaging/README.md for what this package is and is not.
* libveloxcore is linked statically; no libveloxcore.so is shipped.
* Placeholder app icon (packaging/icons/) — replace before any real release.
* No real pairing-approval UI is built yet (needs libdbus-1-dev, not in this
build's Build-Depends): pairing requires VELOX_PAIR_AUTO=1. See
packaging/README.md and postinst's own notice.
-- Velox Local Test Build <[email protected]> Sat, 12 Sep 2026 22:30:43 +0400
+35
View File
@@ -0,0 +1,35 @@
Source: velox
Section: net
Priority: optional
Maintainer: Velox Local Test Build <[email protected]>
Build-Depends: debhelper-compat (= 13),
cmake (>= 3.28),
ninja-build,
pkg-config,
g++ (>= 13),
qt6-base-dev,
qt6-tools-dev,
qt6-tools-dev-tools,
qt6-svg-dev,
libcurl4-openssl-dev,
libssl-dev,
libsqlite3-dev,
nlohmann-json3-dev,
libsecret-1-dev
Standards-Version: 4.6.2
Rules-Requires-Root: no
Package: velox
Architecture: amd64
Depends: ${shlibs:Depends}, ${misc:Depends}
Description: IDM-class download manager for Linux (local test build)
Velox is a segmented, resumable download manager for Linux: a daemon
(veloxd) that does the transfer work, a Qt GUI, a CLI, and a Firefox
extension for capture.
.
This package ships veloxd, the velox CLI, the velox-gui Qt client and
velox-nmhost, the Firefox native-messaging host.
.
THIS IS A LOCAL TEST BUILD, not a release: it uses a placeholder app
icon and has no real pairing-approval UI (see /usr/share/doc/velox, or
packaging/README.md in the source tree, for both limitations).
+26
View File
@@ -0,0 +1,26 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: velox
Source: (no public upstream repository yet — local test build only)
Files: *
Copyright: 2026, the Velox project
License: Unlicensed-TODO
Files: debian/*
Copyright: 2026, the Velox project
License: Unlicensed-TODO
License: Unlicensed-TODO
No license has been chosen for Velox's own source yet. This is a genuine,
open project decision — not something packaging can invent on its own —
and is tracked as a release blocker in packaging/README.md. `lintian` is
expected to flag this package over it (a package normally must declare a
real license); that is correct behaviour on lintian's part, not a bug in
this packaging, and stays that way until the project adopts one.
.
Nothing above changes the licenses of this package's dynamically-linked
runtime dependencies (Qt, libcurl, SQLite, OpenSSL, nlohmann-json,
libsecret and friends) — those remain under their own upstream licenses,
covered by their own packages' Depends, and are not redistributed by this
source package. See packaging/README.md's licence-audit note for what
that still needs before a real release.
Vendored Executable
+92
View File
@@ -0,0 +1,92 @@
#!/bin/sh
# postinst for velox (local test build). See packaging/README.md.
set -e
NM_MANIFEST_SRC=/usr/lib/mozilla/native-messaging-hosts/com.velox.host.json
NM_MANIFEST_NAME=com.velox.host.json
# Every real (human) user with a home directory — UID range matches Debian's own
# adduser default (login.defs UID_MIN=1000), not just "everyone in /home" (a service
# account can have a home dir too) and not root.
real_users() {
getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $6 != "" {print $1":"$6}'
}
# Per-user native-messaging manifest (ADR 0003 / docs/05 §4.A): needed for BOTH
# deb/tarball and snap Firefox — the system-wide manifest this package also installs at
# $NM_MANIFEST_SRC only covers deb/tarball Firefox, never snap. Runs unconditionally,
# not just when snap is detected: a per-user login.defs-based install can't assume every
# user runs the same Firefox flavour, and dropping a JSON file nobody reads is harmless.
install_per_user_manifests() {
[ -f "$NM_MANIFEST_SRC" ] || {
echo "velox: native-messaging host not built yet (nmhost lane not landed) —" \
"skipping per-user manifest install."
return 0
}
real_users | while IFS=: read -r user home; do
[ -d "$home" ] || continue
dest_dir="$home/.mozilla/native-messaging-hosts"
install -d -o "$user" -g "$user" -m 0755 "$dest_dir" 2>/dev/null || {
echo "velox: could not create $dest_dir for $user — skipping" >&2
continue
}
cp "$NM_MANIFEST_SRC" "$dest_dir/$NM_MANIFEST_NAME"
chown "$user:$user" "$dest_dir/$NM_MANIFEST_NAME"
chmod 0644 "$dest_dir/$NM_MANIFEST_NAME"
done
}
# docs/07-packaging.md: detect snap Firefox and say so — the extension pairs over
# loopback WebSocket there (ADR 0003), not native messaging from inside the sandbox.
note_snap_firefox() {
if command -v snap >/dev/null 2>&1 && snap list firefox >/dev/null 2>&1; then
cat <<'EOF'
velox: Firefox is installed as a snap on this system. The extension will pair with
veloxd over the loopback WebSocket (ws://127.0.0.1:<port>), not native
messaging — this is expected (see docs/adr/0003, ADR 0003) and needs no
action from you.
EOF
fi
}
case "$1" in
configure)
install_per_user_manifests
note_snap_firefox
cat <<'EOF'
============================================================================
velox: LOCAL TEST BUILD — read this before pairing the extension
============================================================================
No real pairing-approval UI is built into this package yet (it needs
libdbus-1-dev, which this build's dependencies do not include). veloxd's
only pairing approver in this build auto-rejects every request UNLESS you
set VELOX_PAIR_AUTO=1 in its environment — there is no prompt to accept or
decline; it is all-or-nothing. Do not run this on a machine or account
where you would not want every pairing request accepted automatically.
systemctl --user edit velox.service
# add, in the [Service] section:
# Environment=VELOX_PAIR_AUTO=1
debhelper's dh_installsystemduser marks velox.service and velox.socket enabled (the
pair together, via velox.service's Also=velox.socket) for your next login session
automatically. For your CURRENT session, start socket activation now yourself — a
root postinst has no live user session to reach:
systemctl --user daemon-reload
systemctl --user enable --now velox.socket
See packaging/README.md in the source tree for the rest of this build's
testing-only limitations (placeholder icon, no license chosen yet, no
libveloxcore.so — it is linked statically).
============================================================================
EOF
;;
esac
#DEBHELPER#
exit 0
Vendored Executable
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# postrm for velox (local test build). See packaging/README.md.
set -e
NM_MANIFEST_NAME=com.velox.host.json
real_users() {
getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $6 != "" {print $1":"$6}'
}
# Undoes exactly what postinst's install_per_user_manifests() did — never anything
# beyond that file. In particular this never touches a user's Velox data
# ($XDG_DATA_HOME/velox, i.e. normally ~/.local/share/velox — the task database and
# any settings) on either `remove` or `purge`: per-user, root-owned deletion across
# every account on the system is exactly the kind of destructive multi-user operation
# that deserves its own careful design and testing, not a first cut bolted onto this
# local test package. Documented as a deliberate simplification in
# packaging/README.md, not a silent gap.
remove_per_user_manifests() {
real_users | while IFS=: read -r user home; do
f="$home/.mozilla/native-messaging-hosts/$NM_MANIFEST_NAME"
[ -e "$f" ] && rm -f "$f"
done
}
case "$1" in
remove|purge)
remove_per_user_manifests
;;
esac
#DEBHELPER#
exit 0
Vendored Executable
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/make -f
# Velox — local test .deb build. Not a release build (no signing, no LTO tuning beyond
# what the flags below ask for): see packaging/README.md.
#
# Explicit cmake flags rather than a CMakePresets.json preset: dh_auto_configure already
# sets its own CMAKE_INSTALL_PREFIX / build-dir conventions, which fighting a preset's own
# cache variables (build/<preset>/, etc.) would only complicate. The flags mirror the
# `release` preset's intent (RelWithDebInfo, no tests) without inheriting its binaryDir.
export DEB_BUILD_MAINT_OPTIONS = hardening=+all
%:
dh $@
override_dh_auto_configure:
dh_auto_configure -- \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DVELOX_BUILD_TESTS=OFF \
-DVELOX_BUILD_GUI=ON \
-DVELOX_WERROR=OFF
# dh_installsystemd (system units) is disabled: velox.service/velox.socket are --user
# units, not system ones, so that helper has nothing to do here. dh_installsystemduser is
# NOT overridden — it runs normally, finds both units under lib/systemd/user/ in the
# install tree, and marks them enabled for each user's *next* systemd --user login (it
# cannot reach an already-running session from a root maintainer script at install time;
# postinst prints the manual start commands for the current session, matching
# packaging/README.md's own instructions).
override_dh_installsystemd:
+1
View File
@@ -0,0 +1 @@
3.0 (native)
+10
View File
@@ -0,0 +1,10 @@
# initial-upload-closes-no-bugs: this is a local test build (packaging/README.md),
# never intended for the Debian/Ubuntu archive — there is no ITP bug to close.
initial-upload-closes-no-bugs
# maintainer-script-calls-systemctl (postinst): both flagged lines are inside a
# heredoc of instructions printed for the *admin* to run themselves (velox.service is
# a --user unit; postinst has no live user session to invoke systemctl against
# itself). lintian's check is a text scan and can't tell printed advice from an actual
# invocation — verified by reading the built postinst, see packaging/README.md.
maintainer-script-calls-systemctl
+18
View File
@@ -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 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.** 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 ## R3 — AMO review friction 🟠 MEDIUM
+14 -2
View File
@@ -9,16 +9,28 @@ Owner: lane **PKG/QA**. Target: Ubuntu 26.04 LTS.
/usr/bin/velox-gui /usr/bin/velox-gui
/usr/bin/velox # CLI /usr/bin/velox # CLI
/usr/libexec/velox/velox-nmhost # native messaging host /usr/libexec/velox/velox-nmhost # native messaging host
/usr/lib/x86_64-linux-gnu/libveloxcore.so.1
/usr/share/applications/velox.desktop /usr/share/applications/velox.desktop
/usr/share/icons/hicolor/*/apps/velox.png /usr/share/icons/hicolor/*/apps/velox.png
/usr/share/man/man1/velox.1.gz /usr/share/man/man1/velox.1.gz
/usr/lib/systemd/user/velox.service /usr/lib/systemd/user/velox.service
/usr/lib/systemd/user/velox.socket # socket activation /usr/lib/systemd/user/velox.socket # real socket activation, see below
/usr/lib/mozilla/native-messaging-hosts/com.velox.host.json /usr/lib/mozilla/native-messaging-hosts/com.velox.host.json
/etc/xdg/autostart/velox-gui.desktop # optional, off by default /etc/xdg/autostart/velox-gui.desktop # optional, off by default
``` ```
`libveloxcore` is **static**, linked into `veloxd`, `velox` and `velox-gui` directly —
there is no `libveloxcore.so.*` to install. A shared, versioned `libveloxcore.so.1` was
the original plan here; the user decided against building one for now (no consumer needs
it as a shared object yet, and it would need CORE's `core/CMakeLists.txt` to grow a
`SOVERSION`). Revisit as a CORE-lane request if that changes — this doc was corrected in
the same commit as the decision rather than left describing something unshipped.
`velox.service` and `velox.socket` are both DAEMON's (`packaging/systemd/README.md`,
`daemon/src/rpc/systemd_activation.cpp`) — real `sd_listen_fds()` socket activation, not a
plain `exec`. This package installs both units verbatim and lets `dh_installsystemduser`
(compat 13) pick them up from the install tree; see `packaging/README.md` for what that
does and does not do at install time for an already-running session.
`postinst` additionally drops per-user native-messaging manifests for the packaging formats `postinst` additionally drops per-user native-messaging manifests for the packaging formats
that need them, and **detects whether Firefox is a snap** — if so it prints (and the GUI's that need them, and **detects whether Firefox is a snap** — if so it prints (and the GUI's
first-run wizard shows) a one-line note that the extension will pair over loopback. first-run wizard shows) a one-line note that the extension will pair over loopback.
+85
View File
@@ -0,0 +1,85 @@
# 08 — Porting guide (macOS first, Windows later)
Read `docs/adr/0020-cross-platform-strategy.md` first; this file is the concrete inventory
it refers to. **Rule: Linux behaviour never changes. `#ifdef` never appears in logic.**
## Directory layout
Platform backends live beside the code they serve, one directory per OS:
```
core/src/io/platform/linux/file_ops.cpp core/src/io/platform/macos/file_ops.cpp
core/src/io/platform/file_ops.hpp # the seam — no #ifdef, no OS types
daemon/src/rpc/platform/linux/{wakeup,peercred,instance_lock,runtime_dir}.cpp
daemon/src/rpc/platform/macos/{...}.cpp
daemon/src/rpc/platform/*.hpp # the seams
cmake/platform.cmake # VELOX_OS_* + source selection
```
`cmake/platform.cmake` sets exactly one of `VELOX_OS_LINUX`, `VELOX_OS_MACOS`,
`VELOX_OS_WINDOWS`, and exposes `velox_platform_sources(<target> <dir>)` which adds
`<dir>/platform/<os>/*.cpp`. Selection happens there and nowhere else.
## The seams
Five interfaces cover the whole port. Signatures are indicative, not binding — the owning
lane settles them in Phase 0.
| Seam | Interface | Why |
|---|---|---|
| File preallocation | `Result<void> preallocate(int fd, uint64_t bytes)` | `posix_fallocate` is Linux/glibc |
| Cache advice | `void advise_dontneed(int fd, uint64_t off, uint64_t len)` | `posix_fadvise` has no macOS equivalent |
| Durable flush | `Result<void> flush_durable(int fd)` | `fdatasync` vs `F_FULLFSYNC` |
| Loop wakeup | `class Wakeup { int pollfd(); void signal(); void drain(); }` | `eventfd` is Linux-only |
| Peer identity | `Result<PeerId> peer_of(int fd)` | `SO_PEERCRED` vs `LOCAL_PEERCRED` |
| Single instance | `Result<Lock> acquire(string_view runtime_dir)` | abstract sockets are Linux-only |
| Runtime dir | `string runtime_dir()`, `string data_dir()` | XDG vs `~/Library` |
## API mapping
| Linux (today) | macOS | Windows (later) | Note |
|---|---|---|---|
| `posix_fallocate(fd,0,n)` | `fcntl(F_PREALLOCATE)` then `ftruncate(n)` | `SetFileValidData` / `SetEndOfFile` | macOS needs the `ftruncate`; `F_PREALLOCATE` alone does not set size. Fall back to `ftruncate` on failure, exactly as the Linux path already does for `EOPNOTSUPP` |
| `posix_fadvise(DONTNEED)` | no equivalent — **no-op** | `FILE_FLAG_NO_BUFFERING` | Do **not** substitute `F_NOCACHE`: it changes caching for the whole descriptor, not a written range. A no-op is honest; record it |
| `fdatasync(fd)` | `fcntl(fd, F_FULLFSYNC)`, fall back to `fsync` | `FlushFileBuffers` | `fsync` on macOS does **not** guarantee the drive flushed. `.veloxpart` resume integrity depends on this — use `F_FULLFSYNC` |
| `pwrite` | same | `WriteFile` + `OVERLAPPED` | POSIX, no work |
| `O_NOFOLLOW` | same | `FILE_FLAG_OPEN_REPARSE_POINT` | POSIX, no work |
| `eventfd` | self-pipe (`pipe2`/`O_NONBLOCK|O_CLOEXEC`) | event object | `poll(2)` already used, so a pipe read-end drops straight in |
| `timerfd` | `poll()` timeout computed from the next deadline | waitable timer | Simplest port: the loop already has a deadline set |
| `SO_PEERCRED` + `struct ucred` | `getpeereid(fd,&uid,&gid)` | named-pipe token | Same-UID check is the security property; keep it |
| abstract socket `\0velox-daemon-<hash>` | socket file in the runtime dir + `flock(LOCK_EX|LOCK_NB)` | named mutex | macOS has no abstract namespace. Must unlink stale sockets on start — the abstract version got that free |
| `$XDG_RUNTIME_DIR` | `$TMPDIR` (per-user, already private) | `%LOCALAPPDATA%` | macOS has no XDG runtime dir |
| `$XDG_DATA_HOME` | `~/Library/Application Support/Velox` | `%APPDATA%` | |
| `libsecret` / Secret Service | Keychain (`Security.framework`) | DPAPI / Credential Manager | Behind the credential-store seam. CLAUDE.md §4 still applies: never SQLite, never logs |
| `systemd` user units | `launchd` plist (`~/Library/LaunchAgents`) | Service/Task Scheduler | Phase 3 |
## Build dependencies
| Ubuntu | macOS (Homebrew) |
|---|---|
| `qt6-base-dev`, `qt6-svg-dev`, `qt6-tools-dev` | `qt@6` |
| `libcurl4-openssl-dev` | system libcurl, or `curl` |
| `libsqlite3-dev` | system sqlite, or `sqlite` |
| `libssl-dev` | `openssl@3` (set `OPENSSL_ROOT_DIR`) |
| `libsecret-1-dev` | **none** — Keychain is in the SDK |
| `nlohmann-json3-dev` | `nlohmann-json` |
| `nodejs`, `npm` | `node` |
The root `CMakeLists.txt` currently does `pkg_check_modules(LIBSECRET REQUIRED ...)`
unconditionally. That must become Linux-only, or macOS cannot configure at all. It is the
single hard blocker for a first macOS build.
## What does not change
`contracts/` and both generated clients, `tools/mockd`, `tests/conformance`,
`tools/testserver`, the whole extension, and every hostile-mode expectation. If a port
tempts you to change a fixture or a schema, stop — that is a contract change and it goes
through PROTO.
## Verification gates
- **Phase 0 done:** Ubuntu suite still 57/57, and `git diff` shows only moves behind seams.
- **Phase 1 done:** `veloxd`, `velox`, `libveloxcore` build on macOS; core unit tests pass.
- **Phase 2 done:** conformance (mockd **and** live veloxd) and the engine hostile-mode
matrix pass on macOS; one real download completes with a matching SHA-256.
- **Phase 3 done:** `.dmg` or Homebrew formula installs and runs on a clean machine.
@@ -0,0 +1,107 @@
# ADR 0018 — Nullable optional fields: absent vs. explicit null
**Status:** accepted · **Date:** 2026-09-13 · **Lane:** PROTO
**Prompted by:** a DAEMON report against `download.update`: the generated C++ parser gives
`VeloxDispatcher` no way to tell "the caller left this field alone" from "the caller wants
it cleared," so `download.update` and (the moment a nullable `SettingKey` exists)
`settings.set` can set a nullable field but never clear it back to `null`.
## Context
`download.update`'s `patch` object documents the convention plainly: "Only the present
fields change. An explicit null clears a nullable field." That is a deliberate, already-
committed wire contract — not something up for redesign here. The gap is one layer down:
`contracts/codegen/gen_cpp.py`'s `emit_field_parse` collapses "key absent" and "key present
with value `null`" to the same `std::nullopt`, on purpose, and the comment says so:
> Absent and null mean the same thing: the field is not set. A client that omits a
> nullable field and one that sends null are treated identically on purpose.
That collapse is *correct* for the common case — most nullable-optional fields are on
create-style params (`DownloadSpec.saveDir`, `.categoryId`, …) where there is no existing
value to distinguish "never set" from "explicitly cleared" in the first place; either way
the daemon just uses a default. It is wrong specifically for **patch-style** params, where
a field can already hold a value and the caller needs to say which of two different things
they mean: "leave it" or "clear it."
The schema IR (`schema_ir.py`) already tracks `required` and `nullable` as two independent
booleans per `Field`, so the information needed to make this distinction exists all the way
through parsing — `emit_field_parse` just doesn't act on it. Only `download.update`'s
`patch` object is affected today (`filename`, `saveDir`, `categoryId`, `queueId`,
`description`, `segments`, `bufferBytes`, `checksum` — all eight of its fields are
nullable-and-optional with exactly this "leave vs. clear" meaning). No `SettingKey` is
nullable yet, so `settings.set` has no live instance of the bug, but the same shape
(`values` patches an existing bag) means the first nullable settings key will hit the exact
same gap.
## Decision
**A JSON-null-aware optional, opt in per field via a new `x-clearable: true` annotation —
not a blanket rule and not a companion "clear list" field.**
- New per-field schema annotation, `x-clearable: true`, valid only on a field whose type
already includes `null` (schema error otherwise — clearable implies nullable). Marks
"this field distinguishes absent from explicit null"; every other nullable-optional field
keeps today's collapse.
- The generated C++ type for a `x-clearable` field becomes `std::optional<std::optional<T>>`:
outer `nullopt` = absent (leave unchanged), outer engaged with an inner `nullopt` =
explicit `null` (clear it), outer engaged with an inner value = set it. One field, three
states, no parallel bitset to keep in sync and no second field to forget to check.
- `emit_field_parse` for such a field stops folding `is_null()` into "absent": absent skips
the assignment (outer stays `nullopt`); present-and-null assigns an engaged-but-empty
inner optional; present-and-valued parses normally into the inner optional. Every other
field's codegen (the `required`/`nullable`-but-not-`clearable` majority) is unchanged.
- TypeScript needs no generator change: `field?: T | null` already round-trips this exactly
the way JSON does — an omitted key serializes as absent, `null` serializes as `null`, and
`"field" in obj` / `obj.field === null` already distinguish the three states natively.
This gap is a C++-generator-only problem.
- Applies now to `download.update`'s eight `patch` fields. `Settings` gets no annotation
today (nothing nullable to mark); the day a nullable `SettingKey` is added, it gets
`x-clearable: true` in the same PR, not left to rediscover this ADR.
## Versioning
Per ADR 0015: this retypes a generated C++ field (`optional<T>` -> `optional<optional<T>>`)
with the wire byte-for-byte unchanged — a client sending the same JSON parses correctly
either way. **Minor bump, with a migration note** for anyone reading `patch.filename` et al.
directly (unwrap twice: check the outer, then the inner). Not major; `session.hello`'s
major-only check must not refuse a wire-compatible peer over a binding-only change.
## Consequences
- `on_download_update` (DAEMON, not this lane) can finally implement "explicit null
clears": read the outer optional for presence, the inner for clear-vs-value, exactly the
three states the schema already promised.
- The collapse comment in `emit_field_parse` stays as the default behavior and gets a
pointer to this ADR for the opt-in exception, instead of being read as an oversight.
- Implementation (schema annotation support in `schema_ir.py`, the `gen_cpp.py` emission
change above, regenerating `core/generated/`, the `x-clearable: true` annotations on
`download.update`'s eight fields, the VERSION bump and migration note) is **not** done in
this change — recorded here so DAEMON isn't blocked on relitigating the design, tracked as
its own PROTO PR per the normal contracts process (schema + regenerated code + fixtures +
VERSION bump together, CLAUDE.md §2).
## Alternatives rejected
**An explicit clear list** (e.g. `patch.clearFields: ["categoryId", …]`, plain non-nullable
`optional<T>` fields otherwise). Rejected: the wire contract "an explicit null clears a
nullable field" is already written into `download.update`'s schema description and is what
DAEMON built against — this would be a real, disruptive wire redesign to route around a
generator gap, not a fix for it. It also doesn't compose: every patch-shaped object gains a
second array to keep in sync with the first, by hand, forever.
**A parallel "which fields were present" bitset** (struct of `optional<T>` fields plus a
sibling presence-flags struct or bitset). Rejected: two things to check per field instead
of one, and nothing stops a caller from reading the optional and forgetting the presence
bit — exactly the class of bug this ADR exists to close.
**Apply the tri-state to every `nullable && !required` field automatically**, using the IR
flags already present, no annotation needed. Rejected: `emit_field_parse` only backs
`parse<T>()`, used for *params* types the daemon receives — but the conformance C++ runner
also instantiates `parse<T>()` for **result** types (round-tripping golden fixtures), and
plenty of those are nullable-optional with no patch semantics at all (`TaskSummary.effectiveUrl`,
"null until the first probe succeeds" — a plain nullable value, not a leave-or-clear
choice). Blanket application would retype those too, forcing every read site across the
daemon that already does `if (summary.effectiveUrl)` into an unwanted double-unwrap for a
distinction that field doesn't have. Opt-in keeps the blast radius at exactly the fields
that need it.
@@ -0,0 +1,45 @@
# ADR 0019 — The live-`veloxd` conformance runner is a required check, as-is
**Status:** accepted · **Date:** 2026-09-12 · **Lane:** PKG/QA
## Context
`tests/conformance/run.sh` step 3b (ADR 0014's `conformance` ctest, already required
per `.github/BRANCH_PROTECTION.md`) replays every fixture against a real, isolated
`veloxd` it builds and starts — not just mockd, which only proves the TS client and the
fixtures agree with each other. This is the runner that can catch `veloxd` disagreeing
with its own contract, and it is unconditional in `run.sh` (`set -euo pipefail`, no
skip flag): it already runs, and already blocks, inside the `conformance` job.
What was open was whether to treat that as a settled, defended gate or as something
still provisional while `daemon/docs/deferrals.md`'s D1-D4b stub handlers were excused
via `veloxd-xfail.json`. PROTO's update: 57/57 fixtures pass on `main`, and the xfail
allowlist is down to 18 entries from 34 as DAEMON lands the deferred handlers behind
them.
## Decision
The live-`veloxd` conformance runner stays required — no change to CI is needed, since
it already runs inside the already-required `conformance` job (ADR 0014). What this ADR
records is the standing: PKG/QA is not carving out an exception, a `continue-on-error`,
or a separate advisory job for it while the xfail list shrinks. A regression here fails
the same required check a schema mismatch would.
Verified, not assumed: `tests/conformance/veloxd-xfail.json` has 18 entries as of this
ADR (`python3 -c "import json; print(len(json.load(open('tests/conformance/veloxd-xfail.json'))))"`).
Each remaining entry excuses one still-stubbed handler on `deferrals.md`'s D-list, not a
real disagreement between `veloxd` and its contract — `tests/conformance/README.md`
already draws that line (an entry for anything else is a `run.sh`-detected "xfail entry
unexpectedly passed" or a straight failure, not a quiet pass).
## Consequences
- No `ci.yml` or `BRANCH_PROTECTION.md` change: `conformance` was already listed
required, and this runner was already inside it.
- The xfail list is a visible, shrinking number, not a static allowance — as DAEMON
clears more of `deferrals.md`'s D-list, entries come out of
`tests/conformance/veloxd-xfail.json`, and `replay.ts` fails loudly (per
`applyXfail`'s "unexpectedly passed" check) if one is left in after its handler ships.
- Nothing here changes who owns what: `veloxd-xfail.json` and `run.sh` stay PROTO's;
PKG/QA's role is the branch-protection policy this ADR confirms, not the runner
itself.
+72
View File
@@ -0,0 +1,72 @@
# ADR 0020 — Porting to macOS (and later Windows) without forking the tree
Status: **accepted** · Supersedes nothing · Applies to every lane
## Context
The project was built Ubuntu-first and says so in its own project description. A survey of
`main` (2026-09-15, 57/57 green) found the Linux-specific surface is far smaller than the
Ubuntu-first framing suggests — roughly ten files, all of them already isolated at the
bottom of the stack:
| Area | Linux-only thing | Files |
|---|---|---|
| `core/io` | `posix_fallocate`, `posix_fadvise`, `fdatasync` | `sparse_file.cpp` |
| `core/meta` | `fdatasync` | `veloxpart.cpp` |
| `daemon/rpc` | `eventfd` | `event_loop.{hpp,cpp}`, `main.cpp` |
| `daemon/rpc` | `timerfd` | `main.cpp`, `sched/scheduler.hpp` |
| `daemon/rpc` | `SO_PEERCRED` / `struct ucred` | `uds_server.{hpp,cpp}` |
| `daemon/rpc` | abstract-namespace socket lock | `single_instance.{hpp,cpp}` |
| `daemon/rpc` | `$XDG_RUNTIME_DIR` layout | `runtime_dir.{hpp,cpp}` |
| build | `libsecret-1` is `REQUIRED` at the root | `CMakeLists.txt` |
Everything else is already portable: the event loop is `poll(2)` (POSIX, not `epoll`), the
transfer engine is libcurl, the GUI is Qt 6, `tools/mockd` and `tests/conformance` are
Node, `tools/testserver` is stdlib Python, and the extension is a WebExtension.
## Decision
**One tree, one logic, per-OS backends behind seams.**
1. **No `#ifdef` in logic.** Platform differences live behind a narrow interface, with one
implementation file per OS. A reader of `sparse_file.cpp` must not need to know which OS
they are on. `#ifdef` is allowed only inside a `platform/<os>/` file.
2. **Linux is the reference implementation.** A port may never change Linux behaviour. The
gate is mechanical: the full suite stays green on Ubuntu, and the Linux backend keeps
the same syscalls it uses today. If a port needs a semantic change, that is an ADR of
its own, not a port commit.
3. **Seams are introduced by the owning lane, on Linux, before any port work.** CORE
introduces `core`'s seams; DAEMON introduces `daemon`'s. This is a pure refactor: move
the existing Linux code behind the interface unchanged, prove the suite is still green.
4. **A new lane, PORT, owns only the per-OS backends**`**/platform/<os>/**`. It never
writes logic, and it does not own `cmake/`: the central OS detection module is root
build infrastructure and stays with PKG/QA, who must land it during Phase 0 because
Phase 0's own seams consume it. PORT owning it would deadlock — PORT cannot start until
Phase 0 lands, and Phase 0 cannot select platform sources without it. That keeps CLAUDE.md §1 intact: CORE
still owns `core/`'s logic, DAEMON still owns `daemon/`'s, and macOS work can proceed in
parallel without cross-lane writes.
5. **`VELOX_OS_*` is set once, centrally**, in `cmake/platform.cmake` (owned by PKG/QA),
and platform sources are selected there — not by globbing, not per-target ad hoc.
6. **Optional dependencies are gated, never removed.** `libsecret` stays `REQUIRED` on
Linux and is replaced by Keychain on macOS behind the same credential-store seam.
## Phases
- **Phase 0 — on Ubuntu, by CORE + DAEMON.** Introduce the seams and move today's Linux
code behind them. Zero behaviour change; 57/57 stays green. No macOS code exists yet.
- **Phase 1 — on macOS, by PORT.** Implement the macOS backends until `veloxd`, `velox`
and `libveloxcore` build and their unit tests pass.
- **Phase 2 — parity.** `tools/testserver`, conformance and the engine's hostile-mode
matrix pass on macOS. The GUI builds against Qt 6 for macOS.
- **Phase 3 — packaging.** `.dmg`/Homebrew, `launchd` instead of `systemd`, and the macOS
native-messaging manifest locations. Deliberately last.
## Consequences
- Windows later is the same shape: a third backend directory, no new strategy. The seams
chosen here are POSIX-flavoured but interface-level, so a Win32 backend is additive.
- CI must build both, or macOS rots silently. Until a macOS runner exists, Phase 1 is
verified by hand on the porting machine and the Linux gate stays authoritative.
- The cost is one indirection at the bottom of the I/O and RPC stacks. Measured against
the alternative — `#ifdef` drift, or a forked repo that diverges in a month — it is
cheap.
+51
View File
@@ -0,0 +1,51 @@
# Agent brief — PORT (per-OS backends)
**Starts after Phase 0: CORE and DAEMON have landed the seams on Ubuntu.**
## You own
```
core/src/**/platform/<os>/** daemon/src/**/platform/<os>/**
packaging/macos/** packaging/windows/**
```
You may read everything. **You never write logic** — not in `core/src/io/*.cpp`, not in
`daemon/src/rpc/*.cpp`, not in `gui/`, never in `contracts/`.
## Read first
`docs/adr/0020-cross-platform-strategy.md`, then `docs/08-porting.md` — it has the seam
list, the API mapping and the dependency table. Then `CLAUDE.md`.
## The one rule that matters
**Linux behaviour never changes.** If your port needs a seam that doesn't exist, or a seam
whose shape is wrong, you do not widen it yourself — you file a request with the owning
lane (CORE for `core/`, DAEMON for `daemon/`) exactly as every other lane does. A port that
quietly edits shared logic is how one tree becomes two.
## Build order (macOS)
1. **Configure at all.** The root `CMakeLists.txt` requires `libsecret-1` unconditionally;
that must be Linux-gated before anything else compiles. PKG/QA owns that, and also owns
`cmake/platform.cmake` (the `VELOX_OS_*` detection and `velox_platform_sources()`); both
land in Phase 0, before you start. File anything else you need with them.
2. `core/``preallocate`, `advise_dontneed`, `flush_durable`. Use `F_FULLFSYNC`, not
`fsync`: `.veloxpart` resume correctness depends on a real flush.
3. `daemon/``Wakeup` (self-pipe), `peer_of` (`getpeereid`), `instance_lock` (socket file
+ `flock`, with stale-socket cleanup), `runtime_dir` (`$TMPDIR`, `~/Library/Application
Support/Velox`).
4. Unit tests green for both lanes.
5. `tools/testserver` (stdlib Python) and conformance (Node) — expected to run unchanged.
If they don't, that's a bug worth reporting, not patching around.
6. GUI against Qt 6 for macOS.
7. Packaging — `.dmg`/Homebrew, `launchd`, native-messaging manifest locations. Last.
## Definition of done (Phase 12)
- macOS builds `libveloxcore`, `veloxd` and `velox` with `-Wall -Wextra -Werror`.
- Core and daemon unit tests pass on macOS.
- One real download completes on macOS against `tools/testserver` with a SHA-256 that
matches the server's reference — the same gate the Linux vertical slice passed.
- `kill -9` mid-download, restart, resume completes and the checksum still matches.
- The Ubuntu suite is **still** green from the same commit.
## Do not
- Do not add `#ifdef` outside a `platform/<os>/` file.
- Do not change a fixture, a schema or a hostile-mode expectation to make macOS pass.
- Do not substitute `F_NOCACHE` for `posix_fadvise(DONTNEED)` — see `docs/08` for why.
- Do not start with packaging.
+28 -2
View File
@@ -19,7 +19,12 @@ import {
} from './context-menus.js'; } from './context-menus.js';
import { MediaBridge, notifyTab } from './media-bridge.js'; import { MediaBridge, notifyTab } from './media-bridge.js';
import { createTransport, transportStorage, type TransportStatus, type VeloxTransport } from './transport/index.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 transport: VeloxTransport | undefined;
let rules: CaptureRules = DEFAULT_CAPTURE_RULES; 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 { function onTransportState(status: TransportStatus): void {
const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : ''); const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : '');
console.debug(`[velox] transport ${status.state}${detail ? `${detail}` : ''}`); 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> { 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,
);
});
});
+39
View File
@@ -35,10 +35,34 @@ add_library(velox-gui-lib STATIC
src/dialogs/BatchDialog.cpp src/dialogs/BatchDialog.cpp
src/dialogs/GrabberWizard.cpp src/dialogs/GrabberWizard.cpp
src/tray/TrayIcon.cpp src/tray/TrayIcon.cpp
src/widgets/DropTargetWidget.cpp
src/util/ThemeManager.cpp
src/util/IconTheme.cpp
src/util/UiThreadWatchdog.cpp
src/mainwindow/CategoryPanel.cpp src/mainwindow/CategoryPanel.cpp
src/mainwindow/MainWindow.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
)
# gui/resources/icons/LICENSE: Adwaita-derived (GNOME Project), LGPL-3 or CC-BY-SA-3/4.
# light/dark are the same glyphs recoloured for each toolbar skin (IconTheme picks
# between them the same way ThemeManager picks QSS); app/ is the fixed full-colour
# window/tray glyph.
file(GLOB VELOX_GUI_ICONS_LIGHT resources/icons/light/*.svg)
file(GLOB VELOX_GUI_ICONS_DARK resources/icons/dark/*.svg)
qt_add_resources(velox-gui-lib "icons"
PREFIX "/icons"
BASE "resources/icons"
FILES ${VELOX_GUI_ICONS_LIGHT} ${VELOX_GUI_ICONS_DARK} resources/icons/app/velox.svg
)
target_include_directories(velox-gui-lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) target_include_directories(velox-gui-lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_features(velox-gui-lib PUBLIC cxx_std_23) target_compile_features(velox-gui-lib PUBLIC cxx_std_23)
target_compile_options(velox-gui-lib PRIVATE -Wall -Wextra -Wpedantic -Werror) target_compile_options(velox-gui-lib PRIVATE -Wall -Wextra -Wpedantic -Werror)
@@ -47,8 +71,23 @@ target_link_libraries(velox-gui-lib PUBLIC
Qt6::Widgets Qt6::Widgets
Qt6::Svg Qt6::Svg
Qt6::Network 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) add_executable(velox-gui src/main.cpp)
target_compile_options(velox-gui PRIVATE -Wall -Wextra -Wpedantic -Werror) target_compile_options(velox-gui PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(velox-gui PRIVATE velox-gui-lib) target_link_libraries(velox-gui PRIVATE velox-gui-lib)
+132 -20
View File
@@ -67,7 +67,12 @@ failure for the whole project — not a skipped guard, not a GUI-only problem.
--- ---
## R2 — `--check` verifies the outcome, not the thing that can break ## R2 — `--check` verifies the outcome, not the thing that can break — RESOLVED
**Status: done.** `tools/bootstrap.sh` now validates every apt name in `PKGS` via
`apt-cache policy` and greps for the `Candidate: (none)` line rather than trusting the
exit code (which is 0 either way) — the exact loop this request drafted. This is the
check that would have caught `libqt6svg6-dev` (R1) before the VM did.
`--check` today runs the "verify toolchain" block: `command -v` for the binaries, `--check` today runs the "verify toolchain" block: `command -v` for the binaries,
`pkg-config --exists` for the dev libs (`Qt6Core`, `Qt6Widgets`, `Qt6Svg`, `libcurl`, `pkg-config --exists` for the dev libs (`Qt6Core`, `Qt6Widgets`, `Qt6Svg`, `libcurl`,
@@ -121,26 +126,133 @@ 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 **Status: done on GUI's side.** The harness `tests/integration/README.md` was waiting on
inherits `dev`, and once `gui/` is on `main` the `build` and `sanitizers` jobs configure now exists, builds, and has been run end-to-end (all three gates, all three unhappy-path
and build `velox-gui` and run `ctest --preset ci`, which picks up all three GUI checks sub-phases) against a real `mockd` with no changes needed to the pre-drafted job below.
(`gui_downloadtablemodel`, `gui_rtl`, `gui_no_download_logic`). That part is covered.
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 <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.
1. **10 000 rows scroll at 60 fps** (`mockd --tasks 10000`) — needs a frame-timing probe **One change from the pre-drafted job:** the "Configure + build" step needs to also build
against the offscreen (or Xvfb) view; red when a scroll frame exceeds ~16 ms at the the harness binary, not just `velox-gui`:
99th percentile. ```diff
2. **Flat memory over 10 minutes of progress events** — needs RSS sampled across a - cmake --build --preset dev --target velox-gui
10-minute `mockd --tasks 10000` run; red when RSS grows more than a small fixed slack + cmake --build --preset dev --target velox-gui gui-dod-harness
(a leak in the progress-patch path is the thing this catches). ```
3. **Unhappy-path recovery**`mockd --slow`, `--flaky <f>`, `--drop-connection <s>`: Nothing else in the pre-drafted YAML needs to change — the `# TODO(GUI): path` comment on
the client must show the banner and recover without a freeze or crash; red on a crash, the `scroll-60fps` line can just come off along with the marker on the line below it.
a hang (watchdog), or the connection state never returning to `Connected`.
GUI owns writing that harness (`gui/tests/` + a driver script, headless against `mockd`). **`rss-flat`'s slack: 20 MiB (`VELOX_DOD_RSS_SLACK_KIB`, default `20480`).** Chosen by
Wiring it into `.github/workflows/ci.yml` as its own job — with the 10-minute one likely running the gate locally (`VELOX_DOD_RSS_DURATION_SEC=8` override, i.e. not the real
`nightly` rather than per-PR — is PKG/QA. Say the word and it comes over as a follow-up 10-minute number) a handful of times against `mockd --tasks 10000 --seed 1` and looking at
request with the job stanza pre-written. 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).
---
## R5 — real icon set has landed; stop shipping the placeholder, here's the install path
`gui/resources/icons/.gitkeep` is gone. There's a real, licensed icon set now (Adwaita,
GNOME Project, LGPL-3-or-CC-BY-SA — full text and per-file provenance in
`gui/resources/icons/LICENSE`), embedded into `velox-gui` via `qt_add_resources` in
`gui/CMakeLists.txt` (`:/icons/{light,dark}/<name>.svg` for toolbar/menu actions,
`:/icons/app/velox.svg` for the window and tray icon). Nothing needs installing
separately for the binary itself — the SVGs are baked into the Qt resource system at
build time, not read from disk at runtime.
What that means for whatever `.deb` postinst / desktop-entry work PKG is doing:
* **Stop bundling a placeholder icon file in the test `.deb`.** There's no longer a gap
to fill — `velox-gui`'s own binary already carries its icon.
* **App icon for the `.desktop` file / desktop menu entry**, if PKG is generating one:
the source SVG is `gui/resources/icons/app/velox.svg` (full colour, unmodified Adwaita
`folder-download` glyph, same in light and dark). If the packaging pipeline wants a
filesystem-installed icon (standard XDG icon-theme layout, e.g.
`/usr/share/icons/hicolor/scalable/apps/velox.svg`, referenced from the `.desktop`
file's `Icon=velox` key) rather than relying on the binary's embedded window icon for
the taskbar/launcher, that single file is the one to install — no rasterization needed,
it's already a clean scalable SVG. GUI has no packaging-stage code to add this itself
(CLAUDE.md §1: `packaging/` is PKG's lane), so filing the path here rather than reaching
across.
* Licence compliance for the `.deb`: `gui/resources/icons/LICENSE` should ship somewhere
discoverable from the package (e.g. copied into `debian/copyright` or
`/usr/share/doc/velox/copyright` alongside whatever other third-party attributions PKG
already tracks) — it documents the Adwaita provenance and dual LGPL-3/CC-BY-SA license
per file, which needs to be preserved wherever the binary that embeds these SVGs ships.
+105
View File
@@ -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 <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.
View File
+66
View File
@@ -0,0 +1,66 @@
Icon set for Velox Download Manager
====================================
Source: GNOME Adwaita icon theme (`adwaita-icon-theme`), version bundled with
Debian/Ubuntu, downloaded from <https://download.gnome.org/sources/adwaita-icon-theme/>.
This is the same "compatibly-licensed set" path called out in
docs/03-gui-spec.md's legal note (Papirus/Breeze-derived is the other option; we used
what was locally available and equally suitable) and gui/docs/agents/AGENT-GUI.md.
Nothing in this directory is IDM's artwork. Files below are recolored (fill colour only —
geometry untouched) derivatives of Adwaita SVGs, laid out in IDM's toolbar positions per
docs/03-gui-spec.md §1, so the *positions* rhyme with IDM's muscle memory while the
*pictures* are GNOME's.
Copyright
---------
(c) 2002-2014 The GNOME Project and Adwaita icon theme contributors, including:
Ulisse Perusin, Riccardo Buzzotta, Josef Vybíral, Hylke Bons, Ricardo González,
Lapo Calamandrei, Rodney Dawes, Luca Ferretti, Tuomas Kuosmanen, Andreas Nilsson,
Jakub Steiner, Claire Alexander, Darren Wilson, and other contributors.
License
-------
Dual-licensed, at the recipient's option:
- GNU Lesser General Public License v3.0 (LGPL-3.0-only), or
- Creative Commons Attribution-Share Alike 3.0 or 4.0 (CC-BY-SA-3.0 / CC-BY-SA-4.0)
Full text: https://www.gnu.org/licenses/lgpl-3.0.html
https://creativecommons.org/licenses/by-sa/4.0/
Attribution: "GNOME Project" (https://www.gnome.org), per the upstream copyright file's
own attribution note.
Provenance and modifications
-----------------------------
All files trace back to `/usr/share/icons/Adwaita/{symbolic,scalable}/...` from the
`adwaita-icon-theme` Debian package. Only the `fill` colour was changed (geometry, paths
and viewBox are untouched) to produce a light-toolbar and a dark-toolbar variant of each
symbolic glyph:
light/*.svg fill #2e2e2e — used when the app is in the idm-like (light) theme
dark/*.svg fill #eeeeec — used when the app is in the dark theme
app/velox.svg unmodified — full-colour "folder-download" glyph, used for the
window icon, the tray icon, and the .desktop icon
Adwaita source file -> our name
-----------------------------------------------------------------------------
symbolic/actions/list-add-symbolic.svg -> add-url.svg
symbolic/actions/media-playback-start-symbolic.svg -> resume.svg
symbolic/actions/media-playback-pause-symbolic.svg -> pause.svg
symbolic/actions/media-playback-stop-symbolic.svg -> stop-all.svg
symbolic/actions/edit-delete-symbolic.svg -> delete.svg
symbolic/actions/edit-clear-all-symbolic.svg -> delete-completed.svg
symbolic/actions/appointment-new-symbolic.svg -> scheduler.svg
symbolic/categories/preferences-system-symbolic.svg -> options.svg
symbolic/legacy/web-browser-symbolic.svg -> grabber.svg
symbolic/actions/document-properties-symbolic.svg -> properties.svg
symbolic/actions/insert-link-symbolic.svg -> batch.svg
scalable/places/folder-download.svg -> app/velox.svg (unmodified)
Toolbar/menu -> icon mapping (docs/03-gui-spec.md §1 toolbar order)
---------------------------------------------------------------------
Add URL -> add-url · Resume -> resume · Pause -> pause · Stop All -> stop-all ·
Delete -> delete · Delete Completed -> delete-completed · Scheduler -> scheduler ·
Options -> options · Grabber -> grabber. Properties and Batch (menu-only, not on the
default toolbar) use properties and batch respectively.
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
<stop offset="0" stop-color="#62a0ea"/>
<stop offset="0.0576991" stop-color="#afd4ff"/>
<stop offset="0.122204" stop-color="#62a0ea"/>
<stop offset="0.873306" stop-color="#62a0ea"/>
<stop offset="0.955997" stop-color="#c0d5ea"/>
<stop offset="1" stop-color="#62a0ea"/>
</linearGradient>
<path d="m 21.976562 12 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 86.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -72.085938 c 0 -6.628906 -5.359375 -12 -11.972656 -12 h -46.027344 c -2.453125 0 -4.695312 -1.386718 -5.796875 -3.582031 l -1.503906 -2.992187 c -1.65625 -3.292969 -5.019531 -5.371094 -8.699219 -5.371094 z m 0 0" fill="#438de6"/>
<path d="m 65.976562 36 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 54.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -62.03125 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="url(#a)"/>
<path d="m 65.976562 32 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 55.976562 c 0 5.539063 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.460937 9.980469 -10 v -63.976562 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="#a4caee"/>
<path d="m 64 60 c -1.101562 0 -2 0.898438 -2 2 v 17.171875 l -4.585938 -4.585937 c -0.375 -0.375 -0.882812 -0.585938 -1.414062 -0.585938 s -1.039062 0.210938 -1.414062 0.585938 c -0.78125 0.78125 -0.78125 2.046874 0 2.828124 l 8 8 c 0.78125 0.78125 2.046874 0.78125 2.828124 0 l 8 -8 c 0.78125 -0.78125 0.78125 -2.046874 0 -2.828124 s -2.046874 -0.78125 -2.828124 0 l -4.585938 4.585937 v -17.171875 c 0 -1.101562 -0.898438 -2 -2 -2 z m -12 28 v 4 h 24 v -4 z m 0 0" fill="#438de6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 7 1 v 6 h -6 v 2 h 6 v 6 h 2 v -6 h 6 v -2 h -6 v -6 z m 0 0" fill="#eeeeec"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="15.980469px" viewBox="0 0 16 15.980469" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#eeeeec">
<path d="m 5 11.976562 v 1 h 0.007812 c -0.003906 0.261719 0.101563 0.515626 0.285157 0.707032 l 2 1.996094 c 0.390625 0.390624 1.023437 0.390624 1.414062 0 l 2 -1.996094 c 0.1875 -0.191406 0.289063 -0.445313 0.289063 -0.707032 h 0.003906 v -1 z m 0 0"/>
<path d="m 7 1.972656 c -1.296875 0 -2.40625 0.839844 -2.824219 2 h 7.652344 c -0.417969 -1.160156 -1.53125 -2 -2.828125 -2 z m -5.996094 3 c -0.558594 0 -1.003906 0.445313 -1.003906 1 c 0 0.554688 0.445312 1 1.003906 1 h 4.996094 c 0.554688 0 1 -0.445312 1 -1 c 0 -0.554687 -0.445312 -1 -1 -1 z m 9 0 c -0.558594 0 -1.003906 0.445313 -1.003906 1 c 0 0.554688 0.445312 1 1.003906 1 h 4.996094 c 0.554688 0 1 -0.445312 1 -1 c 0 -0.554687 -0.445312 -1 -1 -1 z m -5.828125 3 c 0.414063 1.160156 1.527344 1.996094 2.824219 1.996094 h 2 c 1.296875 0 2.414062 -0.835938 2.828125 -1.996094 h -2.792969 c -0.011718 0 -0.023437 0.003906 -0.035156 0.003906 h -2 c -0.011719 0 -0.023438 -0.003906 -0.035156 -0.003906 z m 0 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#eeeeec">
<path d="m 2 0 c -1.109375 0 -2 0.890625 -2 2 v 6 c 0 1.109375 0.890625 2 2 2 v -8 h 8 c 0 -1.109375 -0.890625 -2 -2 -2 z m 0 0"/>
<path d="m 8 6 c -1.109375 0 -2 0.890625 -2 2 v 6 c 0 1.109375 0.890625 2 2 2 h 6 c 1.109375 0 2 -0.890625 2 -2 v -6 c 0 -1.109375 -0.890625 -2 -2 -2 z m 0.003906 2 h 1 h 0.03125 c 0.253906 0.011719 0.507813 0.128906 0.6875 0.3125 l 1.28125 1.28125 l 1.308594 -1.28125 c 0.265625 -0.230469 0.445312 -0.304688 0.6875 -0.3125 h 1 v 1 c 0 0.285156 -0.035156 0.550781 -0.25 0.75 l -1.28125 1.28125 l 1.25 1.25 c 0.1875 0.1875 0.28125 0.453125 0.28125 0.71875 v 1 h -1 c -0.265625 0 -0.53125 -0.09375 -0.71875 -0.28125 l -1.28125 -1.28125 l -1.28125 1.28125 c -0.1875 0.1875 -0.453125 0.28125 -0.71875 0.28125 h -0.996094 v -1 c 0 -0.265625 0.09375 -0.53125 0.28125 -0.71875 l 1.277344 -1.25 l -1.277344 -1.28125 c -0.210937 -0.195312 -0.304687 -0.46875 -0.28125 -0.75 z m 0 0"/>
<path d="m 5 3 c -1.109375 0 -2 0.890625 -2 2 v 6 c 0 1.109375 0.890625 2 2 2 v -8 h 8 c 0 -1.109375 -0.890625 -2 -2 -2 z m 0 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 0 c -4.410156 0 -8 3.589844 -8 8 s 3.589844 8 8 8 s 8 -3.589844 8 -8 s -3.589844 -8 -8 -8 z m 0 2 c 3.332031 0 6 2.667969 6 6 s -2.667969 6 -6 6 s -6 -2.667969 -6 -6 s 2.667969 -6 6 -6 z m -2.03125 2.96875 c -0.265625 0 -0.519531 0.105469 -0.707031 0.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 1.292969 1.292969 l -1.292969 1.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 1.292969 -1.292969 l 1.292969 1.292969 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 l -1.292969 -1.292969 l 1.292969 -1.292969 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 s -0.519531 0.105469 -0.707031 0.292969 l -1.292969 1.292969 l -1.292969 -1.292969 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 z m 0 0" fill="#eeeeec"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16.004"><g color="#000" fill="#eeeeec"><path d="M8 4.846v10.38l-2.344-2.288-1.34 2.734c-.327.74-2.033.145-1.548-.844l1.326-2.839H1.136z" style="marker:none" display="block" overflow="visible"/><path d="M7.156 1.063C3.33 1.522.578 5.02 1.063 8.843c.131 1.035.293 1.383.293 1.383l1.675-1.632A4.992 4.992 0 017.406 3.03a4.992 4.992 0 015.563 4.375c.314 2.608-1.391 5.025-3.969 5.532l.031 2s.521-.106.624-.131c3.416-.834 5.706-4.128 5.283-7.65-.46-3.827-3.955-6.555-7.782-6.095z" style="line-height:normal;-inkscape-font-specification:Sans;text-indent:0;text-align:start;text-decoration-line:none;text-transform:none;marker:none" font-weight="400" font-family="Sans" overflow="visible"/><path d="M4.113 7.815v-.206l-.198.019.05-.393h-.117l-.115.15-.116.056-.165-.093-.017-.206.033-.225.248-.186h.199l.033-.113.247.056.182.225.033-.374.314-.262.116-.28.231-.094.133-.187.297-.056.149-.225h-.446l.28-.13h.199l.28-.094.034-.112-.1-.094-.115-.037.033-.112-.083-.169-.198.075.033-.15-.231-.13-.182.317.017.113-.182.075-.116.243-.05-.225-.313-.13-.05-.169.413-.243.182-.168.017-.206-.1-.056L4.84 4l-.082.206s-.139.027-.174.036C4.13 4.66 3.214 5.562 3 7.266c.008.04.155.268.155.268l.347.206.347.093m3.966-4.3l-.43-.168-.496.056-.611.168-.116.112.38.262v.15l-.149.15.199.392.132-.075.165-.262a5.51 5.51 0 00.727-.28l.199-.505m2.529.342l-.375.094-.219.156v.125l-.375.25.094.344.219-.157.125.157.156.093.094-.28L10 4.5l.063-.094.218-.187h.094l-.094.218v.188c.09-.024.16-.051.25-.063l-.25.188v.125l-.312.219-.281-.063v-.156l-.125.062.062.157h-.219l-.125.218-.156.157-.094.03v.188l.032.157H9.03v.53l.063-.03.094-.219L9.375 6l.031-.094.282-.062.156.187.187.094-.093.187.156-.03.062-.22-.187-.218h.062l.22.156.03.219.157.219.062-.313.094-.031c.096.1.169.231.25.344h.281l.188.125-.094.093-.156.157h-.25l-.344-.094h-.188l-.125.156-.343-.375-.25-.062-.375.062-.157.094V9l.032.031.25-.156.093.094h.281l.125.156-.093.312.187.188V10l.125.25-.093.25c-.01.161 0 .307 0 .469.08.219.143.435.218.656l.063.344v.187h.125l.219-.125h.25l.375-.437-.032-.157.25-.218-.187-.188.219-.187.218-.125.094-.125-.062-.25V9.75l.187-.375.188-.25.25-.563v-.156c-.117.015-.23.023-.344.031-.072.005-.145 0-.219 0a7.442 7.442 0 01-.312-.78l-.157-.188-.093-.313.062-.062.219.25.25.562.156.156-.062.22.156.155.25-.25.312-.218.157-.188v-.219c-.04-.073-.055-.145-.094-.218l-.156.187-.125-.156-.188-.125v-.281l.219.218.219-.03c.101.091.192.207.28.312L13 7.28c0-.174-.2-1.02-.625-1.75S11.22 4.125 11.22 4.125l-.063.094-.218.218-.25-.25h.25l.125-.125-.47-.093-.25-.094z" style="marker:none" overflow="visible" opacity=".3"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 13.855469 0 l -1.539063 1.4375 c -0.453125 0.421875 -0.53125 1.148438 -0.269531 1.707031 l -5.886719 5.996094 c -0.011718 0 -0.019531 0 -0.03125 0 c -0.257812 -0.128906 -0.550781 -0.183594 -0.839844 -0.148437 c -0.328124 0.046874 -0.632812 0.199218 -0.867187 0.441406 l -3.945313 3.996094 c -0.3906245 0.375 -0.5468745 0.933593 -0.4062495 1.457031 c 0.1406255 0.523437 0.5546875 0.929687 1.0820315 1.058593 c 0.527344 0.132813 1.082031 -0.03125 1.453125 -0.425781 l 3.945312 -3.996093 c 0.472657 -0.453126 0.59375 -1.15625 0.296875 -1.738282 l 5.890625 -5.964844 c 0.558594 0.25 1.273438 0.148438 1.707031 -0.289062 l 1.414063 -1.5625 z m -10.308594 0.0898438 c -0.398437 0 -0.785156 0.0937502 -1.140625 0.2187502 l 1.882812 1.878906 c 0.390626 0.382812 0.390626 1 0 1.386719 l -0.710937 0.707031 c -0.386719 0.386719 -1 0.386719 -1.390625 0 l -1.882812 -1.878906 c -0.125 0.355468 -0.2187505 0.742187 -0.2187505 1.140625 c 0 1.90625 1.5507815 3.453125 3.4609375 3.453125 c 0.402344 0 0.789063 -0.09375 1.144531 -0.21875 l 1.175782 1.171875 h 0.058593 l 2.070313 -2.0625 l -1.203125 -1.203125 c 0.125 -0.359375 0.214843 -0.742188 0.214843 -1.140625 c 0 -1.90625 -1.546874 -3.4531252 -3.460937 -3.4531252 z m 6.550781 7.8906252 l -2.070312 2.066406 c 0.011718 0.027344 0.023437 0.058594 0.03125 0.089844 l 1.144531 1.140625 c -0.125 0.355468 -0.21875 0.742187 -0.21875 1.140625 c 0 1.902343 1.550781 3.449219 3.460937 3.449219 c 0.433594 0 0.855469 -0.101563 1.238282 -0.246094 l -2.007813 -2 c -0.386719 -0.386719 -0.386719 -1.035156 0 -1.417969 l 0.679688 -0.679687 c 0.195312 -0.191407 0.457031 -0.308594 0.710937 -0.308594 s 0.515625 0.117187 0.710938 0.308594 l 1.945312 1.941406 c 0.105469 -0.328125 0.183594 -0.683594 0.183594 -1.046875 c 0 -1.90625 -1.546875 -3.453125 -3.460938 -3.453125 c -0.398437 0 -0.785156 0.09375 -1.140624 0.21875 z m 0 0" fill="#eeeeec"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#eeeeec">
<path d="m 3 1 h 3 c 0.550781 0 1 0.449219 1 1 v 12 c 0 0.550781 -0.449219 1 -1 1 h -3 c -0.550781 0 -1 -0.449219 -1 -1 v -12 c 0 -0.550781 0.449219 -1 1 -1 z m 0 0"/>
<path d="m 10 1 h 3 c 0.550781 0 1 0.449219 1 1 v 12 c 0 0.550781 -0.449219 1 -1 1 h -3 c -0.550781 0 -1 -0.449219 -1 -1 v -12 c 0 -0.550781 0.449219 -1 1 -1 z m 0 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 519 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 4 0 c -1.644531 0 -3 1.355469 -3 3 v 10 c 0 1.644531 1.355469 3 3 3 h 3 c 0.550781 0 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 h -3 c -0.570312 0 -1 -0.429688 -1 -1 v -10 c 0 -0.570312 0.429688 -1 1 -1 h 5.585938 l 3.414062 3.414062 v 6.585938 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -7 c 0 -0.265625 -0.105469 -0.519531 -0.292969 -0.707031 l -4 -4 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 z m 4.640625 6.167969 c -0.199219 0.003906 -0.402344 0.019531 -0.597656 0.046875 l 1.171875 2.074218 c 0.242187 0.425782 -0.027344 0.914063 -0.539063 1.210938 l -0.925781 0.53125 c -0.515625 0.289062 -1.089844 0.25 -1.332031 -0.175781 l -1.171875 -2.074219 c -0.25 0.308594 -0.476563 0.648438 -0.617188 1.019531 c -0.679687 1.761719 0.285156 3.46875 2.152344 3.804688 c 0.386719 0.074219 0.800781 0.054687 1.191406 0.007812 c 0.03125 -0.007812 1.859375 3.386719 1.859375 3.386719 l 4.167969 0.003906 l -3.199219 -4.957031 c 0.246094 -0.308594 0.445313 -0.652344 0.585938 -1.019531 c 0.679687 -1.765625 -0.285157 -3.472656 -2.148438 -3.808594 c -0.195312 -0.039062 -0.398437 -0.050781 -0.597656 -0.046875 z m 0 0" fill="#eeeeec"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 2 2.5 v 11 c 0 1.5 1.269531 1.492188 1.269531 1.492188 h 0.128907 c 0.246093 0.003906 0.488281 -0.050782 0.699218 -0.171876 l 9.796875 -5.597656 c 0.433594 -0.242187 0.65625 -0.734375 0.65625 -1.226562 c 0 -0.492188 -0.222656 -0.984375 -0.65625 -1.222656 l -9.796875 -5.597657 c -0.210937 -0.121093 -0.453125 -0.175781 -0.699218 -0.175781 h -0.128907 s -1.269531 0 -1.269531 1.5 z m 0 0" fill="#eeeeec"/>
</svg>

After

Width:  |  Height:  |  Size: 554 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 6.5 0 c -3.578125 0 -6.5 2.921875 -6.5 6.5 s 2.921875 6.5 6.5 6.5 c 0.167969 0 0.335938 -0.007812 0.5 -0.019531 v -2.007813 c -0.164062 0.019532 -0.332031 0.027344 -0.5 0.027344 c -2.496094 0 -4.5 -2.003906 -4.5 -4.5 s 2.003906 -4.5 4.5 -4.5 s 4.5 2.003906 4.5 4.5 c 0 0.167969 -0.007812 0.335938 -0.027344 0.5 h 2.007813 c 0.011719 -0.164062 0.019531 -0.332031 0.019531 -0.5 c 0 -3.578125 -2.921875 -6.5 -6.5 -6.5 z m 0 3 c -0.277344 0 -0.5 0.222656 -0.5 0.5 v 2.5 h -1.5 c -0.277344 0 -0.5 0.222656 -0.5 0.5 s 0.222656 0.5 0.5 0.5 h 2 c 0.277344 0 0.5 -0.222656 0.5 -0.5 v -3 c 0 -0.277344 -0.222656 -0.5 -0.5 -0.5 z m 4.5 5 v 3 h -3 v 2 h 3 v 3 h 2 v -3 h 3 v -2 h -3 v -3 z m 0 0" fill="#eeeeec"/>
</svg>

After

Width:  |  Height:  |  Size: 851 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 3.5 2 h 9 c 0.828125 0 1.5 0.671875 1.5 1.5 v 9 c 0 0.828125 -0.671875 1.5 -1.5 1.5 h -9 c -0.828125 0 -1.5 -0.671875 -1.5 -1.5 v -9 c 0 -0.828125 0.671875 -1.5 1.5 -1.5 z m 0 0" fill="#eeeeec"/>
</svg>

After

Width:  |  Height:  |  Size: 345 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 7 1 v 6 h -6 v 2 h 6 v 6 h 2 v -6 h 6 v -2 h -6 v -6 z m 0 0" fill="#2e2e2e"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="15.980469px" viewBox="0 0 16 15.980469" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e2e2e">
<path d="m 5 11.976562 v 1 h 0.007812 c -0.003906 0.261719 0.101563 0.515626 0.285157 0.707032 l 2 1.996094 c 0.390625 0.390624 1.023437 0.390624 1.414062 0 l 2 -1.996094 c 0.1875 -0.191406 0.289063 -0.445313 0.289063 -0.707032 h 0.003906 v -1 z m 0 0"/>
<path d="m 7 1.972656 c -1.296875 0 -2.40625 0.839844 -2.824219 2 h 7.652344 c -0.417969 -1.160156 -1.53125 -2 -2.828125 -2 z m -5.996094 3 c -0.558594 0 -1.003906 0.445313 -1.003906 1 c 0 0.554688 0.445312 1 1.003906 1 h 4.996094 c 0.554688 0 1 -0.445312 1 -1 c 0 -0.554687 -0.445312 -1 -1 -1 z m 9 0 c -0.558594 0 -1.003906 0.445313 -1.003906 1 c 0 0.554688 0.445312 1 1.003906 1 h 4.996094 c 0.554688 0 1 -0.445312 1 -1 c 0 -0.554687 -0.445312 -1 -1 -1 z m -5.828125 3 c 0.414063 1.160156 1.527344 1.996094 2.824219 1.996094 h 2 c 1.296875 0 2.414062 -0.835938 2.828125 -1.996094 h -2.792969 c -0.011718 0 -0.023437 0.003906 -0.035156 0.003906 h -2 c -0.011719 0 -0.023438 -0.003906 -0.035156 -0.003906 z m 0 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e2e2e">
<path d="m 2 0 c -1.109375 0 -2 0.890625 -2 2 v 6 c 0 1.109375 0.890625 2 2 2 v -8 h 8 c 0 -1.109375 -0.890625 -2 -2 -2 z m 0 0"/>
<path d="m 8 6 c -1.109375 0 -2 0.890625 -2 2 v 6 c 0 1.109375 0.890625 2 2 2 h 6 c 1.109375 0 2 -0.890625 2 -2 v -6 c 0 -1.109375 -0.890625 -2 -2 -2 z m 0.003906 2 h 1 h 0.03125 c 0.253906 0.011719 0.507813 0.128906 0.6875 0.3125 l 1.28125 1.28125 l 1.308594 -1.28125 c 0.265625 -0.230469 0.445312 -0.304688 0.6875 -0.3125 h 1 v 1 c 0 0.285156 -0.035156 0.550781 -0.25 0.75 l -1.28125 1.28125 l 1.25 1.25 c 0.1875 0.1875 0.28125 0.453125 0.28125 0.71875 v 1 h -1 c -0.265625 0 -0.53125 -0.09375 -0.71875 -0.28125 l -1.28125 -1.28125 l -1.28125 1.28125 c -0.1875 0.1875 -0.453125 0.28125 -0.71875 0.28125 h -0.996094 v -1 c 0 -0.265625 0.09375 -0.53125 0.28125 -0.71875 l 1.277344 -1.25 l -1.277344 -1.28125 c -0.210937 -0.195312 -0.304687 -0.46875 -0.28125 -0.75 z m 0 0"/>
<path d="m 5 3 c -1.109375 0 -2 0.890625 -2 2 v 6 c 0 1.109375 0.890625 2 2 2 v -8 h 8 c 0 -1.109375 -0.890625 -2 -2 -2 z m 0 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 0 c -4.410156 0 -8 3.589844 -8 8 s 3.589844 8 8 8 s 8 -3.589844 8 -8 s -3.589844 -8 -8 -8 z m 0 2 c 3.332031 0 6 2.667969 6 6 s -2.667969 6 -6 6 s -6 -2.667969 -6 -6 s 2.667969 -6 6 -6 z m -2.03125 2.96875 c -0.265625 0 -0.519531 0.105469 -0.707031 0.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 1.292969 1.292969 l -1.292969 1.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 1.292969 -1.292969 l 1.292969 1.292969 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 l -1.292969 -1.292969 l 1.292969 -1.292969 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 s -0.519531 0.105469 -0.707031 0.292969 l -1.292969 1.292969 l -1.292969 -1.292969 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 z m 0 0" fill="#2e2e2e"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16.004"><g color="#000" fill="#2e2e2e"><path d="M8 4.846v10.38l-2.344-2.288-1.34 2.734c-.327.74-2.033.145-1.548-.844l1.326-2.839H1.136z" style="marker:none" display="block" overflow="visible"/><path d="M7.156 1.063C3.33 1.522.578 5.02 1.063 8.843c.131 1.035.293 1.383.293 1.383l1.675-1.632A4.992 4.992 0 017.406 3.03a4.992 4.992 0 015.563 4.375c.314 2.608-1.391 5.025-3.969 5.532l.031 2s.521-.106.624-.131c3.416-.834 5.706-4.128 5.283-7.65-.46-3.827-3.955-6.555-7.782-6.095z" style="line-height:normal;-inkscape-font-specification:Sans;text-indent:0;text-align:start;text-decoration-line:none;text-transform:none;marker:none" font-weight="400" font-family="Sans" overflow="visible"/><path d="M4.113 7.815v-.206l-.198.019.05-.393h-.117l-.115.15-.116.056-.165-.093-.017-.206.033-.225.248-.186h.199l.033-.113.247.056.182.225.033-.374.314-.262.116-.28.231-.094.133-.187.297-.056.149-.225h-.446l.28-.13h.199l.28-.094.034-.112-.1-.094-.115-.037.033-.112-.083-.169-.198.075.033-.15-.231-.13-.182.317.017.113-.182.075-.116.243-.05-.225-.313-.13-.05-.169.413-.243.182-.168.017-.206-.1-.056L4.84 4l-.082.206s-.139.027-.174.036C4.13 4.66 3.214 5.562 3 7.266c.008.04.155.268.155.268l.347.206.347.093m3.966-4.3l-.43-.168-.496.056-.611.168-.116.112.38.262v.15l-.149.15.199.392.132-.075.165-.262a5.51 5.51 0 00.727-.28l.199-.505m2.529.342l-.375.094-.219.156v.125l-.375.25.094.344.219-.157.125.157.156.093.094-.28L10 4.5l.063-.094.218-.187h.094l-.094.218v.188c.09-.024.16-.051.25-.063l-.25.188v.125l-.312.219-.281-.063v-.156l-.125.062.062.157h-.219l-.125.218-.156.157-.094.03v.188l.032.157H9.03v.53l.063-.03.094-.219L9.375 6l.031-.094.282-.062.156.187.187.094-.093.187.156-.03.062-.22-.187-.218h.062l.22.156.03.219.157.219.062-.313.094-.031c.096.1.169.231.25.344h.281l.188.125-.094.093-.156.157h-.25l-.344-.094h-.188l-.125.156-.343-.375-.25-.062-.375.062-.157.094V9l.032.031.25-.156.093.094h.281l.125.156-.093.312.187.188V10l.125.25-.093.25c-.01.161 0 .307 0 .469.08.219.143.435.218.656l.063.344v.187h.125l.219-.125h.25l.375-.437-.032-.157.25-.218-.187-.188.219-.187.218-.125.094-.125-.062-.25V9.75l.187-.375.188-.25.25-.563v-.156c-.117.015-.23.023-.344.031-.072.005-.145 0-.219 0a7.442 7.442 0 01-.312-.78l-.157-.188-.093-.313.062-.062.219.25.25.562.156.156-.062.22.156.155.25-.25.312-.218.157-.188v-.219c-.04-.073-.055-.145-.094-.218l-.156.187-.125-.156-.188-.125v-.281l.219.218.219-.03c.101.091.192.207.28.312L13 7.28c0-.174-.2-1.02-.625-1.75S11.22 4.125 11.22 4.125l-.063.094-.218.218-.25-.25h.25l.125-.125-.47-.093-.25-.094z" style="marker:none" overflow="visible" opacity=".3"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 13.855469 0 l -1.539063 1.4375 c -0.453125 0.421875 -0.53125 1.148438 -0.269531 1.707031 l -5.886719 5.996094 c -0.011718 0 -0.019531 0 -0.03125 0 c -0.257812 -0.128906 -0.550781 -0.183594 -0.839844 -0.148437 c -0.328124 0.046874 -0.632812 0.199218 -0.867187 0.441406 l -3.945313 3.996094 c -0.3906245 0.375 -0.5468745 0.933593 -0.4062495 1.457031 c 0.1406255 0.523437 0.5546875 0.929687 1.0820315 1.058593 c 0.527344 0.132813 1.082031 -0.03125 1.453125 -0.425781 l 3.945312 -3.996093 c 0.472657 -0.453126 0.59375 -1.15625 0.296875 -1.738282 l 5.890625 -5.964844 c 0.558594 0.25 1.273438 0.148438 1.707031 -0.289062 l 1.414063 -1.5625 z m -10.308594 0.0898438 c -0.398437 0 -0.785156 0.0937502 -1.140625 0.2187502 l 1.882812 1.878906 c 0.390626 0.382812 0.390626 1 0 1.386719 l -0.710937 0.707031 c -0.386719 0.386719 -1 0.386719 -1.390625 0 l -1.882812 -1.878906 c -0.125 0.355468 -0.2187505 0.742187 -0.2187505 1.140625 c 0 1.90625 1.5507815 3.453125 3.4609375 3.453125 c 0.402344 0 0.789063 -0.09375 1.144531 -0.21875 l 1.175782 1.171875 h 0.058593 l 2.070313 -2.0625 l -1.203125 -1.203125 c 0.125 -0.359375 0.214843 -0.742188 0.214843 -1.140625 c 0 -1.90625 -1.546874 -3.4531252 -3.460937 -3.4531252 z m 6.550781 7.8906252 l -2.070312 2.066406 c 0.011718 0.027344 0.023437 0.058594 0.03125 0.089844 l 1.144531 1.140625 c -0.125 0.355468 -0.21875 0.742187 -0.21875 1.140625 c 0 1.902343 1.550781 3.449219 3.460937 3.449219 c 0.433594 0 0.855469 -0.101563 1.238282 -0.246094 l -2.007813 -2 c -0.386719 -0.386719 -0.386719 -1.035156 0 -1.417969 l 0.679688 -0.679687 c 0.195312 -0.191407 0.457031 -0.308594 0.710937 -0.308594 s 0.515625 0.117187 0.710938 0.308594 l 1.945312 1.941406 c 0.105469 -0.328125 0.183594 -0.683594 0.183594 -1.046875 c 0 -1.90625 -1.546875 -3.453125 -3.460938 -3.453125 c -0.398437 0 -0.785156 0.09375 -1.140624 0.21875 z m 0 0" fill="#2e2e2e"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e2e2e">
<path d="m 3 1 h 3 c 0.550781 0 1 0.449219 1 1 v 12 c 0 0.550781 -0.449219 1 -1 1 h -3 c -0.550781 0 -1 -0.449219 -1 -1 v -12 c 0 -0.550781 0.449219 -1 1 -1 z m 0 0"/>
<path d="m 10 1 h 3 c 0.550781 0 1 0.449219 1 1 v 12 c 0 0.550781 -0.449219 1 -1 1 h -3 c -0.550781 0 -1 -0.449219 -1 -1 v -12 c 0 -0.550781 0.449219 -1 1 -1 z m 0 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 519 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 4 0 c -1.644531 0 -3 1.355469 -3 3 v 10 c 0 1.644531 1.355469 3 3 3 h 3 c 0.550781 0 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 h -3 c -0.570312 0 -1 -0.429688 -1 -1 v -10 c 0 -0.570312 0.429688 -1 1 -1 h 5.585938 l 3.414062 3.414062 v 6.585938 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -7 c 0 -0.265625 -0.105469 -0.519531 -0.292969 -0.707031 l -4 -4 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 z m 4.640625 6.167969 c -0.199219 0.003906 -0.402344 0.019531 -0.597656 0.046875 l 1.171875 2.074218 c 0.242187 0.425782 -0.027344 0.914063 -0.539063 1.210938 l -0.925781 0.53125 c -0.515625 0.289062 -1.089844 0.25 -1.332031 -0.175781 l -1.171875 -2.074219 c -0.25 0.308594 -0.476563 0.648438 -0.617188 1.019531 c -0.679687 1.761719 0.285156 3.46875 2.152344 3.804688 c 0.386719 0.074219 0.800781 0.054687 1.191406 0.007812 c 0.03125 -0.007812 1.859375 3.386719 1.859375 3.386719 l 4.167969 0.003906 l -3.199219 -4.957031 c 0.246094 -0.308594 0.445313 -0.652344 0.585938 -1.019531 c 0.679687 -1.765625 -0.285157 -3.472656 -2.148438 -3.808594 c -0.195312 -0.039062 -0.398437 -0.050781 -0.597656 -0.046875 z m 0 0" fill="#2e2e2e"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 2 2.5 v 11 c 0 1.5 1.269531 1.492188 1.269531 1.492188 h 0.128907 c 0.246093 0.003906 0.488281 -0.050782 0.699218 -0.171876 l 9.796875 -5.597656 c 0.433594 -0.242187 0.65625 -0.734375 0.65625 -1.226562 c 0 -0.492188 -0.222656 -0.984375 -0.65625 -1.222656 l -9.796875 -5.597657 c -0.210937 -0.121093 -0.453125 -0.175781 -0.699218 -0.175781 h -0.128907 s -1.269531 0 -1.269531 1.5 z m 0 0" fill="#2e2e2e"/>
</svg>

After

Width:  |  Height:  |  Size: 554 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 6.5 0 c -3.578125 0 -6.5 2.921875 -6.5 6.5 s 2.921875 6.5 6.5 6.5 c 0.167969 0 0.335938 -0.007812 0.5 -0.019531 v -2.007813 c -0.164062 0.019532 -0.332031 0.027344 -0.5 0.027344 c -2.496094 0 -4.5 -2.003906 -4.5 -4.5 s 2.003906 -4.5 4.5 -4.5 s 4.5 2.003906 4.5 4.5 c 0 0.167969 -0.007812 0.335938 -0.027344 0.5 h 2.007813 c 0.011719 -0.164062 0.019531 -0.332031 0.019531 -0.5 c 0 -3.578125 -2.921875 -6.5 -6.5 -6.5 z m 0 3 c -0.277344 0 -0.5 0.222656 -0.5 0.5 v 2.5 h -1.5 c -0.277344 0 -0.5 0.222656 -0.5 0.5 s 0.222656 0.5 0.5 0.5 h 2 c 0.277344 0 0.5 -0.222656 0.5 -0.5 v -3 c 0 -0.277344 -0.222656 -0.5 -0.5 -0.5 z m 4.5 5 v 3 h -3 v 2 h 3 v 3 h 2 v -3 h 3 v -2 h -3 v -3 z m 0 0" fill="#2e2e2e"/>
</svg>

After

Width:  |  Height:  |  Size: 851 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 3.5 2 h 9 c 0.828125 0 1.5 0.671875 1.5 1.5 v 9 c 0 0.828125 -0.671875 1.5 -1.5 1.5 h -9 c -0.828125 0 -1.5 -0.671875 -1.5 -1.5 v -9 c 0 -0.828125 0.671875 -1.5 1.5 -1.5 z m 0 0" fill="#2e2e2e"/>
</svg>

After

Width:  |  Height:  |  Size: 345 B

+112
View File
@@ -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;
}
+119
View File
@@ -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;
}
+172
View File
@@ -0,0 +1,172 @@
#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
@@ -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 <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
+2 -1
View File
@@ -21,6 +21,7 @@
#include "rpc/Protocol.hpp" #include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp" #include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui { namespace velox::gui {
namespace { namespace {
@@ -164,7 +165,7 @@ BatchDialog::BatchDialog(rpc::RpcClient *client, QJsonArray categories, QJsonArr
}); });
queueCombo_->setEnabled(false); queueCombo_->setEnabled(false);
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); errorLabel_->setStyleSheet(theme::errorLabelStyle());
errorLabel_->setWordWrap(true); errorLabel_->setWordWrap(true);
errorLabel_->hide(); errorLabel_->hide();
+2 -1
View File
@@ -18,6 +18,7 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include "rpc/RpcClient.hpp" #include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui { namespace velox::gui {
namespace { namespace {
@@ -101,7 +102,7 @@ FileInfoDialog::FileInfoDialog(rpc::RpcClient *client, QString url, QJsonArray c
form->addRow(tr("Buffer:"), bufferCombo_); form->addRow(tr("Buffer:"), bufferCombo_);
form->addRow(QString(), remember); form->addRow(QString(), remember);
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); errorLabel_->setStyleSheet(theme::errorLabelStyle());
errorLabel_->setWordWrap(true); errorLabel_->setWordWrap(true);
errorLabel_->hide(); errorLabel_->hide();
+2 -1
View File
@@ -23,6 +23,7 @@
#include "rpc/Protocol.hpp" #include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp" #include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui { namespace velox::gui {
namespace { namespace {
@@ -177,7 +178,7 @@ class GrabberReviewPage : public QWizardPage {
startModeCombo_->addItem(QObject::tr("Download Later"), QStringLiteral("later")); startModeCombo_->addItem(QObject::tr("Download Later"), QStringLiteral("later"));
errorLabel_ = new QLabel(this); errorLabel_ = new QLabel(this);
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); errorLabel_->setStyleSheet(theme::errorLabelStyle());
errorLabel_->hide(); errorLabel_->hide();
auto *footer = new QFormLayout; auto *footer = new QFormLayout;
+92 -3
View File
@@ -9,6 +9,7 @@
#include <QJsonArray> #include <QJsonArray>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QPlainTextEdit>
#include <QPointer> #include <QPointer>
#include <QPushButton> #include <QPushButton>
#include <QSpinBox> #include <QSpinBox>
@@ -18,6 +19,7 @@
#include "rpc/Protocol.hpp" #include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp" #include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui { namespace velox::gui {
namespace { namespace {
@@ -43,7 +45,7 @@ void setBufferCombo(QComboBox *combo, qint64 bytes) {
combo->setCurrentIndex(combo->count() / 2); // an unrecognized value: land near the middle combo->setCurrentIndex(combo->count() / 2); // an unrecognized value: land near the middle
} }
QStringList splitHosts(const QString &text) { QStringList splitCsv(const QString &text) {
QStringList out; QStringList out;
for (const QString &h : text.split(QLatin1Char(','), Qt::SkipEmptyParts)) { for (const QString &h : text.split(QLatin1Char(','), Qt::SkipEmptyParts)) {
out << h.trimmed(); out << h.trimmed();
@@ -51,6 +53,14 @@ QStringList splitHosts(const QString &text) {
return out; return out;
} }
QString joinArray(const QJsonArray &a) {
QStringList items;
for (const QJsonValue &v : a) {
items << v.toString();
}
return items.join(QStringLiteral(", "));
}
} // namespace } // namespace
QStringList OptionsDialog::allKeys() { QStringList OptionsDialog::allKeys() {
@@ -61,10 +71,18 @@ QStringList OptionsDialog::allKeys() {
"general.confirmOnExit", "general.confirmOnExit",
"general.language", "general.language",
"general.checkForUpdates", "general.checkForUpdates",
"capture.enabled",
"capture.monitoredExtensions",
"capture.monitoredMimeTypes",
"capture.minSizeBytes",
"capture.excludedHosts",
"capture.bypassModifier",
"capture.autoStartTypes",
"saveTo.defaultDir", "saveTo.defaultDir",
"saveTo.tempDir", "saveTo.tempDir",
"saveTo.fileExistsPolicy", "saveTo.fileExistsPolicy",
"saveTo.createSubfolderPerSite", "saveTo.createSubfolderPerSite",
"saveTo.allowedRoots",
"connection.preset", "connection.preset",
"connection.maxSegmentsPerDownload", "connection.maxSegmentsPerDownload",
"connection.bufferBytes", "connection.bufferBytes",
@@ -112,13 +130,14 @@ OptionsDialog::OptionsDialog(rpc::RpcClient *client, QWidget *parent)
resize(560, 480); resize(560, 480);
buildGeneralTab(); buildGeneralTab();
buildCaptureTab();
buildSaveToTab(); buildSaveToTab();
buildConnectionTab(); buildConnectionTab();
buildDownloadsTab(); buildDownloadsTab();
buildProxyTab(); buildProxyTab();
buildSoundsTab(); buildSoundsTab();
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); statusLabel_->setStyleSheet(theme::errorLabelStyle());
statusLabel_->hide(); statusLabel_->hide();
auto *buttons = new QDialogButtonBox( auto *buttons = new QDialogButtonBox(
@@ -183,6 +202,37 @@ void OptionsDialog::buildGeneralTab() {
tabs_->addTab(page, tr("General")); 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() { void OptionsDialog::buildSaveToTab() {
auto *page = new QWidget(this); auto *page = new QWidget(this);
defaultDir_ = new QLineEdit(page); defaultDir_ = new QLineEdit(page);
@@ -193,6 +243,11 @@ void OptionsDialog::buildSaveToTab() {
fileExistsPolicy_->addItem(tr("Overwrite"), QStringLiteral("overwrite")); fileExistsPolicy_->addItem(tr("Overwrite"), QStringLiteral("overwrite"));
fileExistsPolicy_->addItem(tr("Resume"), QStringLiteral("resume")); fileExistsPolicy_->addItem(tr("Resume"), QStringLiteral("resume"));
createSubfolderPerSite_ = new QCheckBox(tr("Create a subfolder per site"), page); 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 *defaultDirRow = new QWidget(page);
auto *defaultDirLayout = new QHBoxLayout(defaultDirRow); auto *defaultDirLayout = new QHBoxLayout(defaultDirRow);
@@ -215,6 +270,7 @@ void OptionsDialog::buildSaveToTab() {
form->addRow(tr("Temp folder:"), tempDirRow); form->addRow(tr("Temp folder:"), tempDirRow);
form->addRow(tr("If a file already exists:"), fileExistsPolicy_); form->addRow(tr("If a file already exists:"), fileExistsPolicy_);
form->addRow(createSubfolderPerSite_); form->addRow(createSubfolderPerSite_);
form->addRow(tr("Allowed save roots:"), allowedRoots_);
tabs_->addTab(page, tr("Save To")); tabs_->addTab(page, tr("Save To"));
} }
@@ -390,12 +446,27 @@ void OptionsDialog::populateFrom(const QJsonObject &v) {
language_->setCurrentIndex(langIdx >= 0 ? langIdx : 0); language_->setCurrentIndex(langIdx >= 0 ? langIdx : 0);
checkForUpdates_->setChecked(v.value("general.checkForUpdates").toBool(true)); 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()); defaultDir_->setText(v.value("saveTo.defaultDir").toString());
tempDir_->setText(v.value("saveTo.tempDir").toString()); tempDir_->setText(v.value("saveTo.tempDir").toString());
const int policyIdx = const int policyIdx =
fileExistsPolicy_->findData(v.value("saveTo.fileExistsPolicy").toString("ask")); fileExistsPolicy_->findData(v.value("saveTo.fileExistsPolicy").toString("ask"));
fileExistsPolicy_->setCurrentIndex(policyIdx >= 0 ? policyIdx : 0); fileExistsPolicy_->setCurrentIndex(policyIdx >= 0 ? policyIdx : 0);
createSubfolderPerSite_->setChecked(v.value("saveTo.createSubfolderPerSite").toBool()); 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 = const int presetIdx =
connectionPreset_->findData(v.value("connection.preset").toString("auto")); connectionPreset_->findData(v.value("connection.preset").toString("auto"));
@@ -452,10 +523,28 @@ QJsonObject OptionsDialog::currentValues() const {
v["general.language"] = language_->currentData().toString(); v["general.language"] = language_->currentData().toString();
v["general.checkForUpdates"] = checkForUpdates_->isChecked(); 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.defaultDir"] = defaultDir_->text();
v["saveTo.tempDir"] = tempDir_->text(); v["saveTo.tempDir"] = tempDir_->text();
v["saveTo.fileExistsPolicy"] = fileExistsPolicy_->currentData().toString(); v["saveTo.fileExistsPolicy"] = fileExistsPolicy_->currentData().toString();
v["saveTo.createSubfolderPerSite"] = createSubfolderPerSite_->isChecked(); 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.preset"] = connectionPreset_->currentData().toString();
v["connection.maxSegmentsPerDownload"] = maxSegmentsPerDownload_->value(); v["connection.maxSegmentsPerDownload"] = maxSegmentsPerDownload_->value();
@@ -479,7 +568,7 @@ QJsonObject OptionsDialog::currentValues() const {
v["proxy.host"] = proxyHost_->text(); v["proxy.host"] = proxyHost_->text();
v["proxy.port"] = proxyPort_->value(); v["proxy.port"] = proxyPort_->value();
v["proxy.username"] = proxyUsername_->text(); 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["proxy.pacUrl"] = proxyPacUrl_->text();
v["sounds.enabled"] = soundsEnabled_->isChecked(); v["sounds.enabled"] = soundsEnabled_->isChecked();
+18 -5
View File
@@ -1,11 +1,12 @@
// The Options dialog. Lane GUI. // The Options dialog. Lane GUI.
// //
// docs/03-gui-spec.md §4: every control here maps 1:1 onto a settings.* key from // 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" // contracts/schema/types/Settings.schema.json. The spec's "File Types" tab turned out to
// tabs have no backing key (per-category extension lists live on Category via // have real backing after all (capture.monitoredExtensions/monitoredMimeTypes/
// category.upsert, not settings.*; login credentials go to the Secret Service) — a real // autoStartTypes are settings.* keys, not Category — a mistake in an earlier pass here,
// tab either binds to a real key or does not exist here, so those two are left out rather // caught while checking this dialog covers all 43 keys against the now-live real veloxd);
// than shipped as fake affordances. // 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 #pragma once
@@ -16,6 +17,7 @@ class QCheckBox;
class QComboBox; class QComboBox;
class QLabel; class QLabel;
class QLineEdit; class QLineEdit;
class QPlainTextEdit;
class QSpinBox; class QSpinBox;
class QTabWidget; class QTabWidget;
@@ -45,6 +47,7 @@ class OptionsDialog : public QDialog {
private: private:
void buildGeneralTab(); void buildGeneralTab();
void buildCaptureTab();
void buildSaveToTab(); void buildSaveToTab();
void buildConnectionTab(); void buildConnectionTab();
void buildDownloadsTab(); void buildDownloadsTab();
@@ -67,11 +70,21 @@ class OptionsDialog : public QDialog {
QComboBox *language_; QComboBox *language_;
QCheckBox *checkForUpdates_; QCheckBox *checkForUpdates_;
// Capture
QCheckBox *captureEnabled_;
QLineEdit *monitoredExtensions_;
QLineEdit *monitoredMimeTypes_;
QSpinBox *minSizeKiB_;
QLineEdit *excludedHosts_;
QComboBox *bypassModifier_;
QLineEdit *autoStartTypes_;
// Save To // Save To
QLineEdit *defaultDir_; QLineEdit *defaultDir_;
QLineEdit *tempDir_; QLineEdit *tempDir_;
QComboBox *fileExistsPolicy_; QComboBox *fileExistsPolicy_;
QCheckBox *createSubfolderPerSite_; QCheckBox *createSubfolderPerSite_;
QPlainTextEdit *allowedRoots_;
// Connection // Connection
QComboBox *connectionPreset_; QComboBox *connectionPreset_;
+2 -1
View File
@@ -17,6 +17,7 @@
#include "rpc/Protocol.hpp" #include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp" #include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui { namespace velox::gui {
namespace { namespace {
@@ -126,7 +127,7 @@ SchedulerDialog::SchedulerDialog(rpc::RpcClient *client, QWidget *parent)
} }
}); });
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); statusLabel_->setStyleSheet(theme::errorLabelStyle());
statusLabel_->hide(); statusLabel_->hide();
form_->setEnabled(false); // no queue selected yet form_->setEnabled(false); // no queue selected yet
+2 -1
View File
@@ -12,6 +12,7 @@
#include "rpc/Protocol.hpp" #include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp" #include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui { namespace velox::gui {
@@ -38,7 +39,7 @@ SpeedLimiterDialog::SpeedLimiterDialog(rpc::RpcClient *client, QWidget *parent)
kibps_->setEnabled(false); kibps_->setEnabled(false);
connect(enabled_, &QCheckBox::toggled, kibps_, &QWidget::setEnabled); connect(enabled_, &QCheckBox::toggled, kibps_, &QWidget::setEnabled);
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;")); statusLabel_->setStyleSheet(theme::errorLabelStyle());
statusLabel_->hide(); statusLabel_->hide();
auto *form = new QFormLayout; auto *form = new QFormLayout;
+12
View File
@@ -11,6 +11,8 @@
#include "mainwindow/MainWindow.hpp" #include "mainwindow/MainWindow.hpp"
#include "rpc/Protocol.hpp" #include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp" #include "rpc/RpcClient.hpp"
#include "util/ThemeManager.hpp"
#include "util/UiThreadWatchdog.hpp"
namespace { namespace {
@@ -45,6 +47,16 @@ int main(int argc, char **argv) {
app.installTranslator(&translator); 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::rpc::RpcClient client(defaultSocketPath());
velox::gui::MainWindow window(&client); velox::gui::MainWindow window(&client);
window.show(); window.show();
+78 -17
View File
@@ -37,8 +37,15 @@
#include "rpc/RpcClient.hpp" #include "rpc/RpcClient.hpp"
#include "tray/TrayIcon.hpp" #include "tray/TrayIcon.hpp"
#include "util/Format.hpp" #include "util/Format.hpp"
#include "util/IconTheme.hpp"
#include "util/Theme.hpp"
#include "widgets/DropTargetWidget.hpp"
#include "widgets/ProgressDelegate.hpp" #include "widgets/ProgressDelegate.hpp"
#ifdef VELOX_GUI_HAVE_DBUS
#include "clipboard/GlobalShortcut.hpp"
#endif
namespace velox::gui { namespace velox::gui {
namespace { namespace {
@@ -61,8 +68,12 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
offlineBanner_(new QWidget(this)), offlineBanner_(new QWidget(this)),
countsTimer_(new QTimer(this)) { countsTimer_(new QTimer(this)) {
setWindowTitle(tr("Velox Download Manager")); setWindowTitle(tr("Velox Download Manager"));
setWindowIcon(IconTheme::appIcon());
resize(1040, 600); resize(1040, 600);
iconTheme_ = new IconTheme(this);
connect(iconTheme_, &IconTheme::iconsChanged, this, &MainWindow::applyIcons);
proxy_->setSourceModel(model_); proxy_->setSourceModel(model_);
proxy_->setSortCaseSensitivity(Qt::CaseInsensitive); proxy_->setSortCaseSensitivity(Qt::CaseInsensitive);
@@ -88,7 +99,9 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
bannerLabel->setObjectName(QStringLiteral("offlineBannerLabel")); bannerLabel->setObjectName(QStringLiteral("offlineBannerLabel"));
bannerLayout->addWidget(bannerLabel); bannerLayout->addWidget(bannerLabel);
bannerLayout->addStretch(); 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); offlineBanner_->setVisible(false);
auto *splitter = new QSplitter(Qt::Horizontal, this); auto *splitter = new QSplitter(Qt::Horizontal, this);
@@ -108,12 +121,14 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
setCentralWidget(central); setCentralWidget(central);
buildActions(); buildActions();
applyIcons();
buildMenus(); buildMenus();
buildToolBar(); buildToolBar();
buildTray(); buildTray();
buildDropTarget();
// --- status bar ----------------------------------------------------------------- // --- status bar -----------------------------------------------------------------
connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b"))); connDot_->setStyleSheet(dotStyle(QLatin1String(theme::kDanger)));
statusBar()->addPermanentWidget(countsLabel_, 1); statusBar()->addPermanentWidget(countsLabel_, 1);
statusBar()->addPermanentWidget(connText_); statusBar()->addPermanentWidget(connText_);
statusBar()->addPermanentWidget(connDot_); statusBar()->addPermanentWidget(connDot_);
@@ -196,6 +211,24 @@ void MainWindow::buildActions() {
connect(actShow_, &QAction::triggered, this, &MainWindow::showAndRaise); connect(actShow_, &QAction::triggered, this, &MainWindow::showAndRaise);
} }
// gui/resources/icons/LICENSE: toolbar-position -> icon-stem mapping. Re-run on
// IconTheme::iconsChanged so a live light/dark switch swaps every action's glyph, the
// same way ThemeManager swaps the QSS on the same signal.
void MainWindow::applyIcons() {
actAddUrl_->setIcon(IconTheme::icon(QStringLiteral("add-url")));
actResume_->setIcon(IconTheme::icon(QStringLiteral("resume")));
actPause_->setIcon(IconTheme::icon(QStringLiteral("pause")));
actStop_->setIcon(IconTheme::icon(QStringLiteral("stop-all")));
actRemove_->setIcon(IconTheme::icon(QStringLiteral("delete")));
actResumeAll_->setIcon(IconTheme::icon(QStringLiteral("resume")));
actPauseAll_->setIcon(IconTheme::icon(QStringLiteral("pause")));
actProperties_->setIcon(IconTheme::icon(QStringLiteral("properties")));
actOptions_->setIcon(IconTheme::icon(QStringLiteral("options")));
actScheduler_->setIcon(IconTheme::icon(QStringLiteral("scheduler")));
actBatch_->setIcon(IconTheme::icon(QStringLiteral("batch")));
actGrabber_->setIcon(IconTheme::icon(QStringLiteral("grabber")));
}
void MainWindow::buildMenus() { void MainWindow::buildMenus() {
QMenu *tasks = menuBar()->addMenu(tr("&Tasks")); QMenu *tasks = menuBar()->addMenu(tr("&Tasks"));
tasks->addAction(actAddUrl_); tasks->addAction(actAddUrl_);
@@ -261,6 +294,23 @@ void MainWindow::buildTray() {
trayIcon_->show(); 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) { void MainWindow::closeEvent(QCloseEvent *event) {
if (minimizeToTrayEnabled_ && trayIcon_ && trayIcon_->isVisible()) { if (minimizeToTrayEnabled_ && trayIcon_ && trayIcon_->isVisible()) {
hide(); hide();
@@ -275,11 +325,11 @@ void MainWindow::closeEvent(QCloseEvent *event) {
void MainWindow::onConnectionState(rpc::ConnectionState state) { void MainWindow::onConnectionState(rpc::ConnectionState state) {
connText_->setText(tr(rpc::toString(state))); connText_->setText(tr(rpc::toString(state)));
QString colour = QStringLiteral("#c0392b"); // red QString colour = QLatin1String(theme::kDanger);
if (state == rpc::ConnectionState::Connected) { if (state == rpc::ConnectionState::Connected) {
colour = QStringLiteral("#27ae60"); // green colour = QLatin1String(theme::kSuccess);
} else if (state != rpc::ConnectionState::Disconnected) { } else if (state != rpc::ConnectionState::Disconnected) {
colour = QStringLiteral("#e67e22"); // amber colour = QLatin1String(theme::kWarning);
} }
connDot_->setStyleSheet(dotStyle(colour)); connDot_->setStyleSheet(dotStyle(colour));
@@ -299,7 +349,7 @@ void MainWindow::onConnectionState(rpc::ConnectionState state) {
if (online) { if (online) {
fetchTree(); fetchTree();
fetchMinimizeToTraySetting(); fetchGeneralUiSettings();
} }
} }
@@ -318,29 +368,40 @@ void MainWindow::fetchTree() {
}); });
} }
void MainWindow::fetchMinimizeToTraySetting() { void MainWindow::fetchGeneralUiSettings() {
client_->call(QStringLiteral("settings.get"), client_->call(
QJsonObject{{"keys", QJsonArray{QStringLiteral("general.minimizeToTray")}}}, QStringLiteral("settings.get"),
QJsonObject{{"keys", QJsonArray{QStringLiteral("general.minimizeToTray"),
QStringLiteral("general.showDropTarget")}}},
[this](const rpc::RpcReply &reply) { [this](const rpc::RpcReply &reply) {
if (reply.ok()) { if (!reply.ok()) {
minimizeToTrayEnabled_ = reply.result.toObject() return;
.value("values") }
.toObject() const QJsonObject values = reply.result.toObject().value("values").toObject();
.value("general.minimizeToTray") minimizeToTrayEnabled_ = values.value("general.minimizeToTray").toBool();
.toBool(); if (dropTarget_) {
dropTarget_->setVisible(values.value("general.showDropTarget").toBool(true));
} }
}); });
} }
void MainWindow::onSettingsChanged(const QJsonObject &params) { void MainWindow::onSettingsChanged(const QJsonObject &params) {
for (const QJsonValue &key : params.value("keys").toArray()) { for (const QJsonValue &key : params.value("keys").toArray()) {
if (key.toString() == QLatin1String("general.minimizeToTray")) { const QString k = key.toString();
fetchMinimizeToTraySetting(); if (k == QLatin1String("general.minimizeToTray") ||
k == QLatin1String("general.showDropTarget")) {
fetchGeneralUiSettings();
break; 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() { void MainWindow::openAddUrlDialog() {
AddUrlDialog dlg(this); AddUrlDialog dlg(this);
if (dlg.exec() != QDialog::Accepted) { if (dlg.exec() != QDialog::Accepted) {
+14 -1
View File
@@ -26,6 +26,11 @@ namespace velox::gui {
class DownloadTableModel; class DownloadTableModel;
class CategoryPanel; class CategoryPanel;
class TrayIcon; class TrayIcon;
class DropTargetWidget;
class IconTheme;
#ifdef VELOX_GUI_HAVE_DBUS
class GlobalShortcut;
#endif
namespace rpc { namespace rpc {
class RpcClient; class RpcClient;
} // namespace rpc } // namespace rpc
@@ -64,14 +69,17 @@ class MainWindow : public QMainWindow {
void openGrabberWizard(); void openGrabberWizard();
void showAndRaise(); void showAndRaise();
void onSettingsChanged(const QJsonObject &params); void onSettingsChanged(const QJsonObject &params);
void onUrlDropped(const QString &url);
void applyIcons();
private: private:
void buildActions(); void buildActions();
void buildMenus(); void buildMenus();
void buildToolBar(); void buildToolBar();
void buildTray(); void buildTray();
void buildDropTarget();
void fetchTree(); void fetchTree();
void fetchMinimizeToTraySetting(); void fetchGeneralUiSettings();
QStringList selectedTaskIds() const; QStringList selectedTaskIds() const;
QStringList allTaskIds() const; QStringList allTaskIds() const;
void actOnTasks(const char *methodName, const QStringList &ids); void actOnTasks(const char *methodName, const QStringList &ids);
@@ -101,8 +109,13 @@ class MainWindow : public QMainWindow {
QJsonArray categoriesCache_; QJsonArray categoriesCache_;
QJsonArray queuesCache_; QJsonArray queuesCache_;
IconTheme *iconTheme_ = nullptr;
TrayIcon *trayIcon_ = nullptr; TrayIcon *trayIcon_ = nullptr;
DropTargetWidget *dropTarget_ = nullptr;
bool minimizeToTrayEnabled_ = false; bool minimizeToTrayEnabled_ = false;
#ifdef VELOX_GUI_HAVE_DBUS
GlobalShortcut *globalShortcut_ = nullptr;
#endif
QLabel *connDot_; QLabel *connDot_;
QLabel *connText_; QLabel *connText_;
+40 -6
View File
@@ -56,6 +56,13 @@ void RpcClient::stop() {
QMetaObject::invokeMethod(conn_, "stop", Qt::QueuedConnection); QMetaObject::invokeMethod(conn_, "stop", Qt::QueuedConnection);
thread_.quit(); thread_.quit();
thread_.wait(); 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, void RpcClient::call(const QString &methodName, const QJsonObject &params,
@@ -76,17 +83,44 @@ void RpcClient::onConnectionState(int state) {
} }
void RpcClient::requestInitialList() { void RpcClient::requestInitialList() {
call(QString::fromLatin1(method::kDownloadList), QJsonObject{{"limit", 1000}}, fetchListPage(0, {});
[this](const RpcReply &reply) { }
// 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()) { if (!reply.ok()) {
qCWarning(lcRpc, "download.list failed: %d %s", reply.error.code, qCWarning(lcRpc, "download.list failed: %d %s", reply.error.code,
qUtf8Printable(reply.error.message)); qUtf8Printable(reply.error.message));
if (!accumulated.isEmpty()) {
emit taskListReset(accumulated); // show what we got rather than nothing
}
return; return;
} }
const QJsonArray items = reply.result.toObject().value("items").toArray(); const QJsonObject result = reply.result.toObject();
qCInfo(lcRpc, "initial download.list: %lld row(s)", const QJsonArray page = result.value("items").toArray();
static_cast<long long>(items.size())); const qint64 total = static_cast<qint64>(result.value("total").toDouble());
emit taskListReset(items); 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);
}); });
} }
+1
View File
@@ -67,6 +67,7 @@ class RpcClient : public QObject {
private: private:
void requestInitialList(); void requestInitialList();
void fetchListPage(int offset, QJsonArray accumulated);
QThread thread_; QThread thread_;
RpcConnection *conn_ = nullptr; // owned by thread_ affinity, deleted on thread finish RpcConnection *conn_ = nullptr; // owned by thread_ affinity, deleted on thread finish
+6 -4
View File
@@ -154,10 +154,12 @@ void RpcConnection::dispatchFrame(const QJsonObject &frame) {
socket_->abort(); // version mismatch or refused — bounce and retry socket_->abort(); // version mismatch or refused — bounce and retry
return; return;
} }
sendRaw(kSubscribeId, QString::fromLatin1(method::kSessionSubscribe), sendRaw(
QJsonObject{{"events", QJsonArray{event::kTaskAdded, event::kTaskRemoved, kSubscribeId, QString::fromLatin1(method::kSessionSubscribe),
event::kTaskState, event::kTaskProgress, QJsonObject{
event::kSpeedGlobal, event::kNotify}}}); {"events", QJsonArray{event::kTaskAdded, event::kTaskRemoved, event::kTaskState,
event::kTaskProgress, event::kSpeedGlobal, event::kNotify,
event::kSettingsChanged, event::kGrabberProgress}}});
return; return;
} }
if (id == kSubscribeId) { if (id == kSubscribeId) {

Some files were not shown because too many files have changed in this diff Show More