Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0f22f808f | ||
|
|
111229686c | ||
|
|
a5551db1c3 | ||
|
|
83b92d118b | ||
|
|
78f2b22c8c | ||
|
|
77bdb0c427 | ||
|
|
d60cf94905 | ||
|
|
0cd5bf6882 | ||
|
|
31eadffce5 | ||
|
|
5b42e1f0e5 | ||
|
|
e8af9b46b1 | ||
|
|
8abfc6a428 | ||
|
|
936136db06 | ||
|
|
6d7c4f7bc4 | ||
|
|
9487202ea8 | ||
|
|
9624e685af | ||
|
|
4e87730ce9 | ||
|
|
facd851824 | ||
|
|
3e553f08c0 | ||
|
|
1d359af5a3 | ||
|
|
75536304bb | ||
|
|
27865888be | ||
|
|
939a19b7ea | ||
|
|
79c29b47e8 | ||
|
|
8e7d14ba7e | ||
|
|
c2eef96175 | ||
|
|
1fd2e0a0db | ||
|
|
755d85964e | ||
|
|
c6f864ea30 | ||
|
|
c1c5c82f8b | ||
|
|
4e177ec809 | ||
|
|
b5c6e1f47d | ||
|
|
0468b0176a | ||
|
|
d8b7c128be | ||
|
|
e30d994d74 |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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-*/
|
||||||
|
|||||||
@@ -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.**
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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);")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 --
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.0 (native)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 1–2)
|
||||||
|
- 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.
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 ¶ms) {
|
void MainWindow::onSettingsChanged(const QJsonObject ¶ms) {
|
||||||
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) {
|
||||||
|
|||||||
@@ -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 ¶ms);
|
void onSettingsChanged(const QJsonObject ¶ms);
|
||||||
|
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_;
|
||||||
|
|||||||
@@ -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 ¶ms,
|
void RpcClient::call(const QString &methodName, const QJsonObject ¶ms,
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||