diff --git a/contracts/README.md b/contracts/README.md index 86b786e..0d6e3c6 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -3,21 +3,49 @@ **This directory is the interface between every lane.** Owner: agent **PROTO**. Nobody else commits here. Everybody else *generates from* here. +> ## Status: **v1.0.0 — FROZEN** (2026-09-09) +> +> The surface below is complete and generated from: 38 methods, 9 events, 26 named types, +> 59 fixtures. See `docs/adr/0005-protocol-1.0.0-freeze.md` for the versioning rule and +> `docs/adr/0010-...` for the failure taxonomy and the segment range convention. +> +> Lane requests are answered in writing: `contracts/proto-answers-m1.md` responds to +> `core/docs/proto-requests-m1.md` point by point. +> +> **What each lane can rely on, starting now:** +> +> | You need | It is here | +> |---|---| +> | C++ types, parsing, dispatch | `core/generated/velox_proto.{hpp,cpp}` (target `libveloxproto`) | +> | TypeScript types, typed client, runtime validators | `extension/src/shared/protocol/` | +> | The API document to read | `contracts/openrpc.json` | +> | A daemon to build against today | `tools/mockd` — both transports, 4 Hz progress, unhappy-path flags | +> | Proof you have not drifted | `./tests/conformance/run.sh` | +> +> **Changing this is a PR to `contracts/` alone.** Optional field or new method → minor. +> Rename, remove or retype → major, plus an ADR. File a request; do not add a field locally. + ``` contracts/ -├── VERSION # protocol semver, e.g. 1.0.0 +├── VERSION # protocol semver — frozen at 1.0.0 ├── openrpc.json # human-readable API doc (generated from schema/) ├── schema/ │ ├── envelope.schema.json # JSON-RPC 2.0 envelope + our error codes -│ ├── types/ # Task, Segment, Category, Queue, Settings, CaptureOffer… +│ ├── types/ # Task, Segment, Category, Queue, Settings, CaptureRules… │ ├── methods/ # one file per method: params + result │ └── events/ # one file per server→client notification ├── fixtures/ # golden request/response pairs, replayed by conformance └── codegen/ - ├── gen_cpp.py # → core/generated/ (structs + to_json/from_json) - └── gen_ts.py # → extension/src/shared/protocol/ (types + client) + ├── schema_ir.py # the one loader/IR all generators share + ├── gen_cpp.py # → core/generated/ (structs + to_json + parse + dispatch) + ├── gen_ts.py # → extension/src/shared/protocol/ (types, client, validators) + ├── gen_openrpc.py # → contracts/openrpc.json + └── gen_cpp_conformance.py # → tests/conformance/cpp/fixture_dispatcher.hpp ``` +Each subdirectory has its own README: `codegen/` documents the supported JSON Schema +subset, `fixtures/` documents the fixture shape and the placeholder rules. + ## Rules 1. **Generated code is committed.** No lane may be blocked because it can't run Python. @@ -32,6 +60,23 @@ contracts/ regenerated code + `VERSION` bump. Lanes rebase onto it. This is the only synchronization point in the whole project — keep it cheap and frequent rather than big and rare. +## Per-method annotations + +Every method schema carries these, and both generators emit them as data the code can act +on rather than as prose a reader has to honour: + +| Key | Meaning | +|---|---| +| `x-privileged` | refused over the WebSocket transport with `-32003` | +| `x-transports` | which listeners serve it (`uds`, `ws`) | +| `x-deadlineMs` | how long a client waits before giving up | +| `x-errors` | the error codes this method is documented to return | +| `x-wsRestrictions` | extra limits when the call arrives from the extension | + +19 of the 38 methods are privileged: everything that reconfigures the daemon, destroys user +data, or names an arbitrary destination path. The extension may *request* a download; it +may not choose where the bytes land. + ## Transport framing | Client | Transport | Framing | @@ -99,6 +144,40 @@ methods marked `"privileged": true` in the schema are refused over the WebSocket | `event.settings.changed` | `{keys[]}` | | `event.grabber.progress` | `{jobId, found, crawled, done}` | +## Two error spaces, and why they are not the same + +This trips people up, so it is stated once, loudly: + +| | `ErrorCode` | `TaskErrorCode` | +|---|---|---| +| Says | why a **call** failed | why a **download** failed | +| Space | JSON-RPC integers (`-32xxx`) | strings (`"server_file_changed"`) | +| Lives in | the JSON-RPC envelope's `error` | `TaskError.code`, on a task | +| Example | `-32602` — your params were malformed | `checksum_mismatch` — the bytes arrived and were wrong | + +**A download fails while every RPC involved succeeds.** That is the normal case. Never put +a `-32xxx` into a `TaskError`, and never invent a JSON-RPC code for a transfer failure. + +`TaskErrorCode`'s 27 values mirror `vdm::Error` in `core/include/vdm/util/error.hpp` by +name, so DAEMON's projection from the engine taxonomy is lossless and a new engine failure +that has no wire spelling is a visible hole rather than a silent collapse to `internal`. + +## Segment ranges are inclusive + +`Segment.startByte` and `Segment.endByte` describe a **closed** range `[startByte, +endByte]`: `endByte` is the last byte, not one past it, and the segment covers +`endByte - startByte + 1` bytes. The two fields are copied verbatim into +`Range: bytes=-`, which RFC 9110 defines as inclusive, so there is no +arithmetic between the wire and the socket and nowhere for an off-by-one to hide. +Conformance enforces contiguity and full coverage; a fixture written half-open fails. + +## Requested is not effective + +`DownloadSpec.segments` is what a client **asked for**. `TaskSummary.segments` is what is +**in use right now**, after the per-host cap and after the demotion to 1 for a +non-resumable source. They are routinely different and the GUI must render the effective +one. + ## Error codes | Code | Meaning | diff --git a/contracts/VERSION b/contracts/VERSION index 67dbaea..3eefcb9 100644 --- a/contracts/VERSION +++ b/contracts/VERSION @@ -1 +1 @@ -1.0.0-draft +1.0.0 diff --git a/contracts/codegen/README.md b/contracts/codegen/README.md new file mode 100644 index 0000000..23fe4d6 --- /dev/null +++ b/contracts/codegen/README.md @@ -0,0 +1,64 @@ +# contracts/codegen — the generators + +Four generators, one IR. Everything is derived from `contracts/schema/`; nothing here is +a second source of truth. + +``` +schema_ir.py loads schema/ and lowers it to a small IR +├── gen_cpp.py -> core/generated/velox_proto.{hpp,cpp} +├── gen_ts.py -> extension/src/shared/protocol/*.ts +├── gen_openrpc.py -> contracts/openrpc.json +└── gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp +``` + +Regenerate everything: + +```sh +for g in gen_cpp gen_ts gen_openrpc gen_cpp_conformance; do + python3 contracts/codegen/$g.py +done +``` + +`tests/conformance/check_contract.py` re-runs all four and fails if any committed output +differs, so stale generated code cannot be merged. + +## The supported JSON Schema subset + +The generators refuse to guess. Anything outside this subset raises `SchemaError` at +generation time rather than emitting subtly wrong code — a schema that cannot be generated +from is a contract bug, and it should stop the build. + +| Supported | Emitted as | +|---|---| +| `object` + `properties` | struct / interface | +| `object` + typed `additionalProperties` | `std::map` / `Record` | +| `string` + `enum` | `enum class` / string-literal union | +| `integer` + `enum` + `x-enum` | `enum class : int32_t` / `as const` object | +| `array` + `items` | `std::vector` / `T[]` | +| `$ref` to a `types/*.schema.json` | the named type | +| `["X", "null"]`, or `oneOf: [X, {type: null}]` | `std::optional` / `T \| null` | +| `{}` | `nlohmann::json` / `unknown` | +| `minimum` `maximum` `minLength` `maxLength` `pattern` `minItems` `maxItems` | runtime checks in both languages | + +Deliberately unsupported: `allOf`, `anyOf`, general `oneOf`, `patternProperties`, tuple +`items`, recursive types. If the contract needs one, extend `schema_ir.py` in the same PR +that needs it. + +## Two rules the generated code follows + +**Nothing throws on the inbound path.** `gen_cpp.py` emits `parse() -> +std::expected` and deliberately does *not* emit nlohmann's ADL `from_json`, +whose failure mode is an exception. A malformed frame off the wire is an ordinary value the +RPC loop handles, not a throw unwinding through the daemon. + +**Constraints are checked, not just documented.** A `maximum` in a schema becomes an `if` +in both languages. The daemon is not allowed to trust the extension and the extension is +not allowed to trust the daemon — `ws://127.0.0.1` is reachable by every local process, so +a type declaration proves nothing at runtime. + +## Absent and null mean the same thing + +Both generators treat a missing field and an explicit `null` identically. A client that +omits a nullable field and one that sends `null` get the same result, in both languages. +This is stated here because it is the kind of asymmetry that otherwise surfaces as a +cross-language bug six months later. diff --git a/contracts/codegen/gen_cpp.py b/contracts/codegen/gen_cpp.py new file mode 100644 index 0000000..b978987 --- /dev/null +++ b/contracts/codegen/gen_cpp.py @@ -0,0 +1,604 @@ +#!/usr/bin/env python3 +"""Emit core/generated/velox_proto.{hpp,cpp} from contracts/schema/. + +Design notes that matter to the CORE and DAEMON lanes: + +* **No exceptions.** nlohmann's own throwing `get()` / ADL `from_json` are deliberately + not used and not emitted. Parsing goes through `velox::proto::parse(json)` which + returns `std::expected`, so a malformed frame from the wire is an ordinary + value the RPC loop handles, not a throw unwinding through the transfer path. +* **Serialisation is ADL `to_json`,** so `nlohmann::json j = task;` works as expected. + Only the outbound direction is allowed to be implicit; the wire is never trusted. +* **`libveloxproto`, not `libveloxcore`.** This code includes nlohmann/json, which + CLAUDE.md forbids inside core. It is a separate target that core and daemon both link; + the layering rule constrains `libveloxcore`, and `core/generated/` is not part of it. + See docs/adr/0009-generated-protocol-library.md. + +Run: python3 contracts/codegen/gen_cpp.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from schema_ir import Contract, Field, TypeDef, TypeRef, load, pascal, topo_sorted # noqa: E402 + +OUT_DIR = Path(__file__).resolve().parent.parent.parent / "core" / "generated" + +BANNER = """// --------------------------------------------------------------------------- +// GENERATED FILE — DO NOT EDIT. +// +// Source: contracts/schema/** +// Generator: contracts/codegen/gen_cpp.py +// Contract: v{version} +// +// Hand-editing this file is a merge blocker. Fix the schema and regenerate: +// python3 contracts/codegen/gen_cpp.py +// Only lane PROTO commits to contracts/. +// --------------------------------------------------------------------------- +""" + + +def cpp_type(ref: TypeRef) -> str: + if ref.kind == "named": + return ref.name or "void" + if ref.kind == "string": + return "std::string" + if ref.kind == "integer": + return "std::int64_t" + if ref.kind == "number": + return "double" + if ref.kind == "boolean": + return "bool" + if ref.kind == "json": + return "nlohmann::json" + if ref.kind == "array": + return f"std::vector<{cpp_type(ref.inner)}>" + if ref.kind == "map": + return f"std::map" + raise AssertionError(ref.kind) + + +def cpp_constraints(ref: TypeRef, var: str, path: str, indent: str) -> list[str]: + """Range, length and pattern checks. The wire is untrusted: a `maximum` in the schema + has to be a check here, or -32602 would never fire for an out-of-range value.""" + lim = ref.limits + if not lim: + return [] + i = indent + o: list[str] = [] + + def bail(msg: str) -> str: + return f'return std::unexpected(ParseError{{std::string({path}), "{msg}"}});' + + if ref.kind in ("integer", "number"): + if "minimum" in lim: + o.append(f'{i}if ({var} < {lim["minimum"]}) {bail("value is below the minimum of " + str(lim["minimum"]))}') + if "maximum" in lim: + o.append(f'{i}if ({var} > {lim["maximum"]}) {bail("value is above the maximum of " + str(lim["maximum"]))}') + elif ref.kind == "string": + if "minLength" in lim: + o.append(f'{i}if ({var}.size() < {lim["minLength"]}u) {bail("value is shorter than " + str(lim["minLength"]) + " characters")}') + if "maxLength" in lim: + o.append(f'{i}if ({var}.size() > {lim["maxLength"]}u) {bail("value is longer than " + str(lim["maxLength"]) + " characters")}') + if "pattern" in lim: + lit = json.dumps(lim["pattern"]) + o.append(f"{i}{{") + o.append(f"{i} static const std::regex re({lit}, std::regex::ECMAScript);") + o.append(f'{i} if (!std::regex_match({var}, re)) {bail("value does not match the required pattern")}') + o.append(f"{i}}}") + elif ref.kind == "array": + if "minItems" in lim: + o.append(f'{i}if ({var}.size() < {lim["minItems"]}u) {bail("fewer than " + str(lim["minItems"]) + " items")}') + if "maxItems" in lim: + o.append(f'{i}if ({var}.size() > {lim["maxItems"]}u) {bail("more than " + str(lim["maxItems"]) + " items")}') + return o + + +def field_type(f: Field) -> str: + inner = cpp_type(f.type) + return f"std::optional<{inner}>" if f.optional else inner + + +def ident(name: str) -> str: + """A JSON property name as a C++ member name.""" + out = name.replace(".", "_").replace("-", "_") + reserved = {"class", "delete", "namespace", "operator", "template", "new", "auto"} + return out + "_" if out in reserved else out + + +def doc_comment(text: str, indent: str = "") -> list[str]: + if not text: + return [] + lines: list[str] = [] + words = text.split() + cur = "" + for w in words: + if len(cur) + len(w) + 1 > 92: + lines.append(cur) + cur = w + else: + cur = f"{cur} {w}".strip() + if cur: + lines.append(cur) + return [f"{indent}/// {ln}" for ln in lines] + + +def method_ident(name: str) -> str: + return pascal(name) + + +def handler_name(name: str) -> str: + return "on_" + name.replace(".", "_") + + +# ------------------------------------------------------------------ header + + +def emit_header(c: Contract) -> str: + o: list[str] = [BANNER.format(version=c.version), "#pragma once", ""] + o += [ + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "", + "#include ", + "", + "// This is libveloxproto, NOT libveloxcore. The layering rule in CLAUDE.md forbids", + "// JSON inside the engine; the engine does not link this target. See", + "// docs/adr/0009-generated-protocol-library.md.", + "namespace velox::proto {", + "", + f'inline constexpr std::string_view kProtocolVersion = "{c.version}";', + "", + "/// Why a payload could not be turned into a typed value. `path` is a JSON Pointer", + "/// into the offending document, so a conformance failure names the exact field.", + "struct ParseError {", + " std::string path;", + " std::string message;", + "};", + "", + "/// Every parse in this file returns one of these. Nothing here throws.", + "template ", + "using Result = std::expected;", + "", + "/// Which listener a request arrived on. Decides whether a privileged method is", + "/// allowed: see `is_allowed_on`.", + "enum class Transport { Uds, Ws };", + "", + ] + + # ---- enums + for t in topo_sorted(c.types): + if t.kind == "string_enum": + o += doc_comment(t.doc) + o.append(f"enum class {t.name} {{") + for v in t.values: + o.append(f" {v.name}, // \"{v.wire}\"") + o.append("};") + o.append(f"std::string_view to_string({t.name} v) noexcept;") + o.append(f"Result<{t.name}> parse_{t.name}(std::string_view s);") + o.append("") + elif t.kind == "int_enum": + o += doc_comment(t.doc) + o.append(f"enum class {t.name} : std::int32_t {{") + for v in t.values: + o += doc_comment(v.doc, " ") + o.append(f" {v.name} = {v.wire},") + o.append("};") + o.append(f"std::string_view to_string({t.name} v) noexcept;") + o.append(f"std::optional<{t.name}> {t.name.lower()}_from_int(std::int32_t v) noexcept;") + o.append("") + elif t.kind == "map_alias": + o += doc_comment(t.doc) + o.append(f"using {t.name} = {cpp_type(t.alias)};") + o.append("") + + # ---- structs + for t in topo_sorted(c.types): + if t.kind != "struct": + continue + o += doc_comment(t.doc) + o.append(f"struct {t.name} {{") + if not t.fields: + o.append(" // No fields: this method takes no parameters.") + for f in t.fields: + o += doc_comment(f.doc, " ") + o.append(f" {field_type(f)} {ident(f.name)}{{}};") + o.append("};") + o.append("") + + o += [ + "// --- serialisation ---------------------------------------------------------", + "// ADL hooks, so `nlohmann::json j = value;` works. Outbound only: there is no", + "// generated from_json, because nlohmann's inbound path throws and the wire is", + "// never trusted. Use parse below.", + "", + ] + for t in topo_sorted(c.types): + if t.kind in ("struct", "string_enum", "int_enum"): + o.append(f"void to_json(nlohmann::json& j, const {t.name}& v);") + o.append("") + + o += [ + "// --- parsing ---------------------------------------------------------------", + "", + "/// Turn an untrusted JSON value into a typed one. Specialised below for every", + "/// contract type; the primary template is intentionally not defined, so asking", + "/// for a type the contract does not have is a compile error, not a runtime one.", + "template ", + "Result parse(const nlohmann::json& j, std::string_view path = \"\");", + "", + ] + for t in topo_sorted(c.types): + if t.kind in ("struct", "string_enum", "int_enum", "map_alias"): + o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path);") + o.append("") + + # ---- method / event enums + o += [ + "// --- method surface --------------------------------------------------------", + "", + "/// Every method in the contract. Generated, so a daemon cannot answer a method", + "/// the contract does not define, and cannot silently fail to answer one it does.", + "enum class Method {", + ] + for m in c.methods: + o.append(f" {method_ident(m.name)}, // {m.name}") + o += ["};", "", + "inline constexpr std::size_t kMethodCount = " + str(len(c.methods)) + ";", + "", + "std::string_view to_string(Method m) noexcept;", + "std::optional method_from_string(std::string_view s) noexcept;", + "", + "/// True for methods refused over the WebSocket transport with -32003. The", + "/// extension is not allowed to reconfigure the daemon or destroy user data.", + "bool is_privileged(Method m) noexcept;", + "bool is_allowed_on(Method m, Transport t) noexcept;", + "", + "/// The contract's answer deadline. capture.offer's 750 ms is the one that", + "/// matters: past it the extension has already let Firefox take the download.", + "std::int32_t deadline_ms(Method m) noexcept;", + ""] + + o += ["/// Server-to-client notifications.", "enum class Event {"] + for e in c.events: + o.append(f" {pascal(e.name[len('event.'):])}, // {e.name}") + o += ["};", "", + "std::string_view to_string(Event e) noexcept;", + "std::optional event_from_string(std::string_view s) noexcept;", + "", + "// --- dispatch --------------------------------------------------------------", + "", + "/// Build a JSON-RPC error response. `id` may be null for a request that could", + "/// not be parsed far enough to have one.", + "nlohmann::json make_error(const nlohmann::json& id, ErrorCode code, std::string_view message,", + " nlohmann::json data = nullptr);", + "nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result);", + "nlohmann::json make_notification(Event e, nlohmann::json params);", + "", + "/// One virtual per method. The daemon implements this; `dispatch` below does the", + "/// envelope handling, the transport check and the parameter parsing, so a handler", + "/// only ever sees a validated, typed params struct.", + "class Dispatcher {", + "public:", + " virtual ~Dispatcher() = default;", + ""] + for m in c.methods: + o += doc_comment(m.doc, " ") + o.append(f" virtual Result<{cpp_type(m.result)}> {handler_name(m.name)}(const {cpp_type(m.params)}& params) = 0;") + o.append("") + o += ["};", "", + "/// Parse one JSON-RPC request, route it, and return the response to write back.", + "/// Never throws. Returns a null json for a notification that needs no reply.", + "nlohmann::json dispatch(Dispatcher& handler, Transport transport, const nlohmann::json& request);", + "", + "} // namespace velox::proto", + ""] + return "\n".join(o) + + +# ------------------------------------------------------------------ source + + +def emit_source(c: Contract) -> str: + o: list[str] = [BANNER.format(version=c.version), + '#include "velox_proto.hpp"', "", "#include ", "#include ", "", + "namespace velox::proto {", "", "namespace {", "", + 'std::string join(std::string_view path, std::string_view key) {', + ' std::string out(path);', + ' out += "/";', + ' out += key;', + ' return out;', + '}', "", "} // namespace", ""] + + # enum conversions + for t in topo_sorted(c.types): + if t.kind == "string_enum": + o.append(f"std::string_view to_string({t.name} v) noexcept {{") + o.append(" switch (v) {") + for v in t.values: + o.append(f' case {t.name}::{v.name}: return "{v.wire}";') + o.append(" }") + o.append(' return "";') + o.append("}") + o.append("") + o.append(f"Result<{t.name}> parse_{t.name}(std::string_view s) {{") + for v in t.values: + o.append(f' if (s == "{v.wire}") return {t.name}::{v.name};') + o.append(f' return std::unexpected(ParseError{{"", "not a valid {t.name}: \'" + std::string(s) + "\'"}});') + o.append("}") + o.append("") + o.append(f"void to_json(nlohmann::json& j, const {t.name}& v) {{ j = to_string(v); }}") + o.append("") + o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{") + o.append(' if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"});') + o.append(f" auto r = parse_{t.name}(j.get_ref());") + o.append(" if (!r) return std::unexpected(ParseError{std::string(path), r.error().message});") + o.append(" return *r;") + o.append("}") + o.append("") + elif t.kind == "int_enum": + o.append(f"std::string_view to_string({t.name} v) noexcept {{") + o.append(" switch (v) {") + for v in t.values: + o.append(f' case {t.name}::{v.name}: return "{v.name}";') + o.append(" }") + o.append(' return "";') + o.append("}") + o.append("") + o.append(f"std::optional<{t.name}> {t.name.lower()}_from_int(std::int32_t v) noexcept {{") + o.append(" switch (v) {") + for v in t.values: + o.append(f" case {v.wire}: return {t.name}::{v.name};") + o.append(" default: return std::nullopt;") + o.append(" }") + o.append("}") + o.append("") + o.append(f"void to_json(nlohmann::json& j, const {t.name}& v) {{ j = static_cast(v); }}") + o.append("") + o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{") + o.append(' if (!j.is_number_integer()) return std::unexpected(ParseError{std::string(path), "expected an integer"});') + o.append(f" auto v = {t.name.lower()}_from_int(j.get());") + o.append(' if (!v) return std::unexpected(ParseError{std::string(path), "not a contract error code"});') + o.append(" return *v;") + o.append("}") + o.append("") + + # map aliases: a parse specialisation only. The alias is a std::map, which nlohmann + # already serialises, so an emitted to_json here would be an ambiguous overload. + for t in topo_sorted(c.types): + if t.kind != "map_alias": + continue + o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{") + o += emit_value_parse(t.alias, "j", "out", "path", " ") + o.append(" return out;") + o.append("}") + o.append("") + + # struct to_json / parse + for t in topo_sorted(c.types): + if t.kind != "struct": + continue + # A no-parameter method's struct has nothing to read, so the parameter goes + # unnamed: the project builds with -Wall -Wextra -Werror. + vname = "v" if t.fields else "/*v*/" + o.append(f"void to_json(nlohmann::json& j, const {t.name}& {vname}) {{") + o.append(" j = nlohmann::json::object();") + for f in t.fields: + m = ident(f.name) + if f.optional: + o.append(f' if (v.{m}.has_value()) j["{f.name}"] = *v.{m};') + if f.required: + o.append(f' else j["{f.name}"] = nullptr;') + else: + o.append(f' j["{f.name}"] = v.{m};') + o.append("}") + o.append("") + + pname = "path" if t.fields else "/*path*/" + o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view {pname}) {{") + if t.fields: + o.append(' if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"});') + else: + o.append(' if (!j.is_object()) return std::unexpected(ParseError{"", "expected an object"});') + o.append(f" {t.name} out;") + for f in t.fields: + o += emit_field_parse(f, indent=" ") + o.append(" return out;") + o.append("}") + o.append("") + + o += emit_tables(c) + o += emit_dispatch(c) + o += ["} // namespace velox::proto", ""] + return "\n".join(o) + + +def emit_value_parse(ref: TypeRef, src: str, dst: str, path: str, indent: str) -> list[str]: + """Statements that parse json expression `src` into a fresh variable named `dst`.""" + i = indent + o: list[str] = [] + if ref.kind == "named": + o.append(f"{i}auto {dst}_r = parse<{ref.name}>({src}, {path});") + o.append(f"{i}if (!{dst}_r) return std::unexpected({dst}_r.error());") + o.append(f"{i}auto {dst} = std::move(*{dst}_r);") + elif ref.kind == "string": + o.append(f"{i}if (!{src}.is_string()) return std::unexpected(ParseError{{std::string({path}), \"expected a string\"}});") + o.append(f"{i}auto {dst} = {src}.get();") + elif ref.kind == "integer": + o.append(f"{i}if (!{src}.is_number_integer()) return std::unexpected(ParseError{{std::string({path}), \"expected an integer\"}});") + o.append(f"{i}auto {dst} = {src}.get();") + elif ref.kind == "number": + o.append(f"{i}if (!{src}.is_number()) return std::unexpected(ParseError{{std::string({path}), \"expected a number\"}});") + o.append(f"{i}auto {dst} = {src}.get();") + elif ref.kind == "boolean": + o.append(f"{i}if (!{src}.is_boolean()) return std::unexpected(ParseError{{std::string({path}), \"expected a boolean\"}});") + o.append(f"{i}auto {dst} = {src}.get();") + elif ref.kind == "json": + o.append(f"{i}auto {dst} = {src};") + elif ref.kind == "array": + et = cpp_type(ref.inner) + o.append(f"{i}if (!{src}.is_array()) return std::unexpected(ParseError{{std::string({path}), \"expected an array\"}});") + o.append(f"{i}std::vector<{et}> {dst};") + o.append(f"{i}{dst}.reserve({src}.size());") + o.append(f"{i}for (std::size_t idx = 0; idx < {src}.size(); ++idx) {{") + o.append(f'{i} const std::string ip = join({path}, std::to_string(idx));') + o += emit_value_parse(ref.inner, f"{src}[idx]", f"{dst}_e", "ip", i + " ") + o.append(f"{i} {dst}.push_back(std::move({dst}_e));") + o.append(f"{i}}}") + elif ref.kind == "map": + et = cpp_type(ref.inner) + o.append(f"{i}if (!{src}.is_object()) return std::unexpected(ParseError{{std::string({path}), \"expected an object\"}});") + o.append(f"{i}std::map {dst};") + o.append(f"{i}for (const auto& [mk, mv] : {src}.items()) {{") + o.append(f'{i} const std::string mp = join({path}, mk);') + o += emit_value_parse(ref.inner, "mv", f"{dst}_e", "mp", i + " ") + o.append(f"{i} {dst}.emplace(mk, std::move({dst}_e));") + o.append(f"{i}}}") + else: + raise AssertionError(ref.kind) + o += cpp_constraints(ref, dst, path, indent) + return o + + +def emit_field_parse(f: Field, indent: str) -> list[str]: + i = indent + m = ident(f.name) + o = [f'{i}{{', + f'{i} const std::string fp = join(path, "{f.name}");', + f'{i} const auto it = j.find("{f.name}");'] + if f.optional: + # 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. + o.append(f"{i} if (it != j.end() && !it->is_null()) {{") + o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ") + o.append(f"{i} out.{m} = std::move(val);") + o.append(f"{i} }}") + else: + o.append(f"{i} if (it == j.end() || it->is_null())") + o.append(f'{i} return std::unexpected(ParseError{{fp, "required field is missing"}});') + o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ") + o.append(f"{i} out.{m} = std::move(val);") + o.append(f"{i}}}") + return o + + +def emit_tables(c: Contract) -> list[str]: + o = ["std::string_view to_string(Method m) noexcept {", " switch (m) {"] + for m in c.methods: + o.append(f' case Method::{method_ident(m.name)}: return "{m.name}";') + o += [" }", ' return "";', "}", ""] + + o += ["std::optional method_from_string(std::string_view s) noexcept {"] + for m in c.methods: + o.append(f' if (s == "{m.name}") return Method::{method_ident(m.name)};') + o += [" return std::nullopt;", "}", ""] + + o += ["bool is_privileged(Method m) noexcept {", " switch (m) {"] + for m in c.methods: + o.append(f" case Method::{method_ident(m.name)}: return {str(m.privileged).lower()};") + o += [" }", " return true; // unknown means refuse", "}", ""] + + o += ["bool is_allowed_on(Method m, Transport t) noexcept {", " switch (m) {"] + for m in c.methods: + uds = "true" if "uds" in m.transports else "false" + ws = "true" if "ws" in m.transports else "false" + o.append(f" case Method::{method_ident(m.name)}: return t == Transport::Uds ? {uds} : {ws};") + o += [" }", " return false;", "}", ""] + + o += ["std::int32_t deadline_ms(Method m) noexcept {", " switch (m) {"] + for m in c.methods: + o.append(f" case Method::{method_ident(m.name)}: return {m.deadline_ms};") + o += [" }", " return 5000;", "}", ""] + + o += ["std::string_view to_string(Event e) noexcept {", " switch (e) {"] + for e in c.events: + o.append(f' case Event::{pascal(e.name[len("event."):])}: return "{e.name}";') + o += [" }", ' return "";', "}", ""] + + o += ["std::optional event_from_string(std::string_view s) noexcept {"] + for e in c.events: + o.append(f' if (s == "{e.name}") return Event::{pascal(e.name[len("event."):])};') + o += [" return std::nullopt;", "}", ""] + return o + + +def emit_dispatch(c: Contract) -> list[str]: + o = [ + "nlohmann::json make_error(const nlohmann::json& id, ErrorCode code, std::string_view message,", + " nlohmann::json data) {", + " nlohmann::json err = {{\"code\", static_cast(code)}, {\"message\", std::string(message)}};", + " if (!data.is_null()) err[\"data\"] = std::move(data);", + " return {{\"jsonrpc\", \"2.0\"}, {\"id\", id}, {\"error\", std::move(err)}};", + "}", + "", + "nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result) {", + " return {{\"jsonrpc\", \"2.0\"}, {\"id\", id}, {\"result\", std::move(result)}};", + "}", + "", + "nlohmann::json make_notification(Event e, nlohmann::json params) {", + " return {{\"jsonrpc\", \"2.0\"}, {\"method\", std::string(to_string(e))}, {\"params\", std::move(params)}};", + "}", + "", + "nlohmann::json dispatch(Dispatcher& handler, Transport transport, const nlohmann::json& request) {", + " const nlohmann::json id = request.contains(\"id\") ? request.at(\"id\") : nlohmann::json(nullptr);", + "", + " if (!request.is_object() || request.value(\"jsonrpc\", \"\") != \"2.0\" || !request.contains(\"method\"))", + " return make_error(id, ErrorCode::InvalidRequest, \"not a JSON-RPC 2.0 request\");", + " if (!request.at(\"method\").is_string())", + " return make_error(id, ErrorCode::InvalidRequest, \"method must be a string\");", + "", + " const auto method = method_from_string(request.at(\"method\").get_ref());", + " if (!method)", + " return make_error(id, ErrorCode::MethodNotFound, \"no such method\");", + " if (!is_allowed_on(*method, transport))", + " return make_error(id, ErrorCode::TransportForbidden,", + " \"method is not permitted on this transport\");", + "", + " const nlohmann::json params =", + " request.contains(\"params\") ? request.at(\"params\") : nlohmann::json::object();", + "", + " switch (*method) {", + ] + for m in c.methods: + pt, rt = cpp_type(m.params), cpp_type(m.result) + o += [ + f" case Method::{method_ident(m.name)}: {{", + f' auto p = parse<{pt}>(params, "params");', + " if (!p)", + " return make_error(id, ErrorCode::InvalidParams, p.error().message,", + ' nlohmann::json{{"path", p.error().path}});', + f" auto r = handler.{handler_name(m.name)}(*p);", + " if (!r)", + " return make_error(id, ErrorCode::InternalError, r.error().message,", + ' nlohmann::json{{"path", r.error().path}});', + " nlohmann::json out = *r;", + " return make_result(id, std::move(out));", + " }", + ] + o += [" }", "", + " return make_error(id, ErrorCode::MethodNotFound, \"no such method\");", + "}", ""] + return o + + +def main() -> int: + c = load() + OUT_DIR.mkdir(parents=True, exist_ok=True) + (OUT_DIR / "velox_proto.hpp").write_text(emit_header(c)) + (OUT_DIR / "velox_proto.cpp").write_text(emit_source(c)) + print(f"gen_cpp: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events -> {OUT_DIR}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/contracts/codegen/gen_cpp_conformance.py b/contracts/codegen/gen_cpp_conformance.py new file mode 100644 index 0000000..abca0a2 --- /dev/null +++ b/contracts/codegen/gen_cpp_conformance.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Emit tests/conformance/cpp/fixture_dispatcher.hpp. + +velox::proto::Dispatcher has one pure virtual per method, on purpose: a daemon that +forgets to implement a method does not compile. That is exactly what a conformance runner +needs, and also what makes one tedious to hand-write — so it is generated. + +The dispatcher answers each method from that method's golden fixture, which lets the C++ +side exercise the real dispatch path: envelope handling, the transport check, parameter +parsing, and result serialisation. + +Run: python3 contracts/codegen/gen_cpp_conformance.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from gen_cpp import BANNER, cpp_type, handler_name # noqa: E402 +from schema_ir import load # noqa: E402 + +OUT = Path(__file__).resolve().parent.parent.parent / "tests" / "conformance" / "cpp" / "fixture_dispatcher.hpp" + + +def main() -> int: + c = load() + o = [BANNER.format(version=c.version), "#pragma once", "", + '#include "velox_proto.hpp"', "", + "#include ", "#include ", "", "namespace velox::conformance {", "", + "/// Answers every method from its golden fixture, so the generated dispatch path", + "/// itself is under test: envelope, transport check, param parse, result serialise.", + "class FixtureDispatcher final : public proto::Dispatcher {", + "public:", + " /// `results` maps a method name to that method's golden result JSON.", + " explicit FixtureDispatcher(std::function results)", + " : results_(std::move(results)) {}", + ""] + + for m in c.methods: + # Every method's params and result is a named struct, and this class lives in + # velox::conformance, so the names need qualifying. + pt, rt = "proto::" + cpp_type(m.params), "proto::" + cpp_type(m.result) + o += [ + f" proto::Result<{rt}> {handler_name(m.name)}(const {pt}& params) override {{", + " (void)params;", + f' return golden<{rt}>("{m.name}");', + " }", + "", + ] + + o += [ + "private:", + " template ", + " proto::Result golden(const std::string& method) {", + " const nlohmann::json* value = results_(method);", + " if (value == nullptr)", + ' return std::unexpected(proto::ParseError{method, "no fixture for this method"});', + " return proto::parse(*value, method);", + " }", + "", + " std::function results_;", + "};", + "", + "} // namespace velox::conformance", + "", + ] + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text("\n".join(o)) + print(f"gen_cpp_conformance: {len(c.methods)} handlers -> {OUT}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/contracts/codegen/gen_openrpc.py b/contracts/codegen/gen_openrpc.py new file mode 100644 index 0000000..acca979 --- /dev/null +++ b/contracts/codegen/gen_openrpc.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Emit contracts/openrpc.json from contracts/schema/. + +This is the document humans read. It is generated, not written, so it cannot drift from +the schemas the code is generated from — the failure mode where the docs say one thing and +the wire does another is designed out rather than policed. + +JSON-RPC named parameters are modelled as OpenRPC `by-name` params: each property of a +method's params object becomes one entry, which is what a reader expects to see. + +Server-to-client notifications are not expressible in OpenRPC 1.2, so they are emitted +under a top-level `x-events` key alongside their payload schemas. + +Run: python3 contracts/codegen/gen_openrpc.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from schema_ir import ID_PREFIX, SCHEMA_ROOT, Loader # noqa: E402 + +ROOT = SCHEMA_ROOT.parent +OUT = ROOT / "openrpc.json" + +STRIP = {"$schema", "$id", "title"} + + +def rewrite(node: object) -> object: + """Point every $ref at #/components/schemas/ and drop per-file keywords.""" + if isinstance(node, list): + return [rewrite(n) for n in node] + if not isinstance(node, dict): + return node + out: dict[str, object] = {} + for k, v in node.items(): + if k == "$ref" and isinstance(v, str): + if v.startswith("#/$defs/"): + # envelope.schema.json's internal refs land under the x-envelope key. + out["$ref"] = "#/x-envelope/" + v[len("#/$defs/"):] + continue + if not v.startswith(ID_PREFIX + "types/"): + raise SystemExit(f"openrpc: unexpected $ref target {v}") + name = v[len(ID_PREFIX + "types/"):].removesuffix(".schema.json") + out["$ref"] = f"#/components/schemas/{name}" + continue + if k in STRIP: + continue + out[k] = rewrite(v) + return out + + +def main() -> int: + loader = Loader() + version = (ROOT / "VERSION").read_text().strip() + + schemas: dict[str, object] = {} + for sid, doc in sorted(loader.by_id.items()): + if "/types/" not in sid: + continue + name = doc["title"] + body = rewrite({k: v for k, v in doc.items() if k not in STRIP}) + assert isinstance(body, dict) + body["title"] = name + schemas[name] = body + + methods = [] + for sid, doc in sorted(loader.by_id.items()): + if "/methods/" not in sid: + continue + params_schema = doc["properties"]["params"] + + # Expand a params object into by-name entries. A $ref'd params object is resolved + # first so the reader sees the fields, not just a type name. + resolved = params_schema + if "$ref" in resolved: + resolved = loader.by_id[resolved["$ref"]] + params = [] + required = set(resolved.get("required", [])) + for prop, sub in resolved.get("properties", {}).items(): + entry: dict[str, object] = {"name": prop, "schema": rewrite(sub)} + if prop in required: + entry["required"] = True + if isinstance(sub, dict) and sub.get("description"): + entry["description"] = sub["description"] + params.append(entry) + + method: dict[str, object] = { + "name": doc["title"], + "summary": doc.get("description", "").split(".")[0] + ".", + "description": doc.get("description", ""), + "paramStructure": "by-name", + "params": params, + "result": {"name": f"{doc['title']}Result", "schema": rewrite(doc["properties"]["result"])}, + "x-privileged": doc.get("x-privileged", False), + "x-transports": doc.get("x-transports", []), + "x-deadlineMs": doc.get("x-deadlineMs"), + } + if doc.get("x-errors"): + code_doc = {e["value"]: e["doc"] for e in + loader.by_id[ID_PREFIX + "types/ErrorCode.schema.json"]["x-enum"]} + method["errors"] = [{"code": c, "message": code_doc.get(c, "")} for c in doc["x-errors"]] + if doc.get("x-wsRestrictions"): + method["x-wsRestrictions"] = doc["x-wsRestrictions"] + methods.append(method) + + events = [] + for sid, doc in sorted(loader.by_id.items()): + if "/events/" not in sid: + continue + events.append({ + "name": doc["title"], + "description": doc.get("description", ""), + "params": rewrite(doc["properties"]["params"]), + "x-maxRateHz": doc.get("x-maxRateHz"), + }) + + envelope = loader.by_id[ID_PREFIX + "envelope.schema.json"] + + out = { + "openrpc": "1.2.6", + "info": { + "title": "Velox Download Manager", + "version": version, + "description": ( + "The wire contract between veloxd and every client: the Qt GUI, the CLI, " + "the native-messaging host and the Firefox extension. One JSON-RPC 2.0 " + "payload set over four framings; only the framing differs.\n\n" + "GENERATED from contracts/schema/ by contracts/codegen/gen_openrpc.py. " + "Do not edit by hand." + ), + "license": {"name": "See repository LICENSE"}, + }, + "servers": [ + {"name": "unix-socket", "url": "unix:$XDG_RUNTIME_DIR/velox/velox.sock", + "description": "NDJSON. GUI, CLI and nmhost. Peer credentials checked via SO_PEERCRED; same UID only, no token."}, + {"name": "loopback-ws", "url": "ws://127.0.0.1:52000", + "description": "One JSON message per text frame. Extension fallback. Bound to 127.0.0.1 only, Origin-checked, token-authenticated, rate-limited. Port is the first free one in 52000-52016."}, + ], + "methods": methods, + "components": {"schemas": schemas}, + "x-events": events, + "x-envelope": rewrite(envelope.get("$defs", {})), + "x-transports": { + "uds": "Unix domain socket, newline-delimited JSON.", + "ws": "Loopback WebSocket, one JSON message per text frame. Privileged methods are refused here with -32003.", + }, + } + + OUT.write_text(json.dumps(out, indent=2) + "\n") + print(f"gen_openrpc: {len(methods)} methods, {len(events)} events, {len(schemas)} schemas -> {OUT}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/contracts/codegen/gen_ts.py b/contracts/codegen/gen_ts.py new file mode 100644 index 0000000..9c379f4 --- /dev/null +++ b/contracts/codegen/gen_ts.py @@ -0,0 +1,619 @@ +#!/usr/bin/env python3 +"""Emit extension/src/shared/protocol/ from contracts/schema/. + +What the EXT and GUI-adjacent lanes get: + +* `types.ts` — every contract type as a TS interface or string-literal union. +* `methods.ts` — the `MethodMap`, a typed `call()` signature, and per-method + metadata (privileged, transports, deadlineMs). capture.offer's + 750 ms budget is a generated constant, not a number typed twice. +* `events.ts` — event payload types and a discriminated union of notifications. +* `validate.ts` — runtime validators for everything crossing the wire. +* `index.ts` — the public surface. + +The validators exist because **the extension is not allowed to trust the daemon and the +daemon is not allowed to trust the extension.** A `ws://127.0.0.1` socket is reachable by +any local process, so a TypeScript type alone proves nothing at runtime. + +Run: python3 contracts/codegen/gen_ts.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from schema_ir import Contract, Field, TypeDef, TypeRef, load, pascal, topo_sorted # noqa: E402 + +OUT_DIR = Path(__file__).resolve().parent.parent.parent / "extension" / "src" / "shared" / "protocol" + +BANNER = """// --------------------------------------------------------------------------- +// GENERATED FILE — DO NOT EDIT. +// +// Source: contracts/schema/** +// Generator: contracts/codegen/gen_ts.py +// Contract: v{version} +// +// Hand-editing this file is a merge blocker. Fix the schema and regenerate: +// python3 contracts/codegen/gen_ts.py +// Only lane PROTO commits to contracts/. +// --------------------------------------------------------------------------- +""" + +IDENT_OK = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$") + + +def prop_key(name: str) -> str: + """Property names such as `general.launchOnLogin` must be quoted.""" + if name and name[0].isalpha() and all(ch in IDENT_OK for ch in name): + return name + return json.dumps(name) + + +def ts_type(ref: TypeRef) -> str: + if ref.kind == "named": + return ref.name or "never" + if ref.kind == "string": + return "string" + if ref.kind in ("integer", "number"): + return "number" + if ref.kind == "boolean": + return "boolean" + if ref.kind == "json": + return "unknown" + if ref.kind == "array": + inner = ts_type(ref.inner) + return f"Array<{inner}>" if not inner.isidentifier() else f"{inner}[]" + if ref.kind == "map": + return f"Record" + raise AssertionError(ref.kind) + + +def doc_block(text: str, indent: str = "") -> list[str]: + if not text: + return [] + words, lines, cur = text.split(), [], "" + for w in words: + if len(cur) + len(w) + 1 > 88: + lines.append(cur) + cur = w + else: + cur = f"{cur} {w}".strip() + if cur: + lines.append(cur) + if len(lines) == 1: + return [f"{indent}/** {lines[0]} */"] + return [f"{indent}/**"] + [f"{indent} * {ln}" for ln in lines] + [f"{indent} */"] + + +def event_ident(name: str) -> str: + return pascal(name[len("event."):]) + "Event" + + +def method_key(name: str) -> str: + return json.dumps(name) + + +# ------------------------------------------------------------------- types.ts + + +def emit_types(c: Contract) -> str: + o = [BANNER.format(version=c.version), ""] + o.append(f'export const PROTOCOL_VERSION = {json.dumps(c.version)};') + o.append("") + for t in topo_sorted(c.types): + if t.kind == "string_enum": + o += doc_block(t.doc) + union = " | ".join(json.dumps(v.wire) for v in t.values) + o.append(f"export type {t.name} = {union};") + o.append(f"export const {upper_snake(t.name)}_VALUES = [") + for v in t.values: + o.append(f" {json.dumps(v.wire)},") + o.append(f"] as const satisfies readonly {t.name}[];") + o.append("") + elif t.kind == "int_enum": + o += doc_block(t.doc) + o.append(f"export const {t.name} = {{") + for v in t.values: + if v.doc: + o += doc_block(v.doc, " ") + o.append(f" {v.name}: {v.wire},") + o.append("} as const;") + o.append(f"export type {t.name} = (typeof {t.name})[keyof typeof {t.name}];") + o.append("") + elif t.kind == "map_alias": + o += doc_block(t.doc) + o.append(f"export type {t.name} = {ts_type(t.alias)};") + o.append("") + elif t.kind == "struct": + o += doc_block(t.doc) + if not t.fields: + o.append(f"/** No parameters. */") + o.append(f"export type {t.name} = Record;") + o.append("") + continue + o.append(f"export interface {t.name} {{") + for f in t.fields: + o += doc_block(f.doc, " ") + opt = "?" if not f.required else "" + null = " | null" if f.nullable else "" + o.append(f" {prop_key(f.name)}{opt}: {ts_type(f.type)}{null};") + o.append("}") + o.append("") + + o += [ + "/** JSON-RPC error as it appears on the wire. */", + "export interface RpcError {", + " code: ErrorCode;", + " message: string;", + " data?: Record | null;", + "}", + "", + "/** A response is one or the other, never both — narrow on `error`. */", + "export type RpcResponse =", + " | { jsonrpc: '2.0'; id: number | string; result: T; error?: undefined }", + " | { jsonrpc: '2.0'; id: number | string; result?: undefined; error: RpcError };", + "", + ] + return "\n".join(o) + + +def upper_snake(name: str) -> str: + out = [] + for i, ch in enumerate(name): + if ch.isupper() and i and not name[i - 1].isupper(): + out.append("_") + out.append(ch.upper()) + return "".join(out) + + +# ----------------------------------------------------------------- methods.ts + + +def emit_methods(c: Contract) -> str: + imports = sorted({r.name for m in c.methods for r in (m.params, m.result) if r.kind == "named"}) + o = [BANNER.format(version=c.version), ""] + o.append("import type {") + for name in imports: + o.append(f" {name},") + o.append("} from './types.js';") + o.append("") + o += [ + "/** Params and result for every method, keyed by its wire name. */", + "export interface MethodMap {", + ] + for m in c.methods: + o += doc_block(m.doc, " ") + o.append(f" {method_key(m.name)}: {{ params: {ts_type(m.params)}; result: {ts_type(m.result)} }};") + o += ["}", "", + "export type MethodName = keyof MethodMap;", + "export type Params = MethodMap[M]['params'];", + "export type Result = MethodMap[M]['result'];", + "", + "export type Transport = 'uds' | 'ws';", + "", + "export interface MethodMeta {", + " /** Refused over the WebSocket transport with -32003. */", + " readonly privileged: boolean;", + " readonly transports: readonly Transport[];", + " /** How long a client waits before giving up on this call. */", + " readonly deadlineMs: number;", + " /** Error codes this method is documented to return. */", + " readonly errors: readonly number[];", + "}", + "", + "export const METHODS: { readonly [M in MethodName]: MethodMeta } = {"] + for m in c.methods: + transports = ", ".join(f"'{t}'" for t in m.transports) + errors = ", ".join(str(e) for e in m.errors) + o.append(f" {method_key(m.name)}: {{ privileged: {str(m.privileged).lower()}, " + f"transports: [{transports}], deadlineMs: {m.deadline_ms}, errors: [{errors}] }},") + o += ["} as const;", "", + "export const METHOD_NAMES = Object.keys(METHODS) as MethodName[];", + "", + "export function isMethodName(v: unknown): v is MethodName {", + " return typeof v === 'string' && Object.prototype.hasOwnProperty.call(METHODS, v);", + "}", + "", + "/** Methods this transport may call. The extension checks before sending so a", + " * privileged call fails in one place rather than as a puzzling -32003. */", + "export function isAllowedOn(method: MethodName, transport: Transport): boolean {", + " return (METHODS[method].transports as readonly string[]).includes(transport);", + "}", + "", + "/**", + " * The typed client surface. Every transport implements this; the generated", + " * signature is what stops a caller passing download.add's params to download.get.", + " */", + "export interface VeloxClient {", + " call(method: M, params: Params): Promise>;", + "}", + ""] + return "\n".join(o) + + +# ------------------------------------------------------------------ events.ts + + +def emit_events(c: Contract) -> str: + imports = sorted({e.params.name for e in c.events if e.params.kind == "named"}) + o = [BANNER.format(version=c.version), ""] + o.append("import type {") + for name in imports: + o.append(f" {name},") + o.append("} from './types.js';") + o.append("") + o.append("/** Payload for each server-to-client notification, keyed by its wire name. */") + o.append("export interface EventMap {") + for e in c.events: + o += doc_block(e.doc, " ") + o.append(f" {method_key(e.name)}: {ts_type(e.params)};") + o += ["}", "", + "export type EventName = keyof EventMap;", + "export type EventPayload = EventMap[E];", + "", + "/**", + " * Discriminated on `method`: narrowing an incoming notification gives the", + " * correctly typed params with no cast at the call site.", + " */", + "export type ServerNotification = {", + " [E in EventName]: { jsonrpc: '2.0'; method: E; params: EventMap[E] };", + "}[EventName];", + "", + "export interface EventMeta {", + " /** Upper bound on emission rate, where the contract sets one. */", + " readonly maxRateHz: number | null;", + "}", + "", + "export const EVENTS: { readonly [E in EventName]: EventMeta } = {"] + for e in c.events: + rate = "null" if e.max_rate_hz is None else str(e.max_rate_hz) + o.append(f" {method_key(e.name)}: {{ maxRateHz: {rate} }},") + o += ["} as const;", "", + "export const EVENT_NAMES = Object.keys(EVENTS) as EventName[];", + "", + "export function isEventName(v: unknown): v is EventName {", + " return typeof v === 'string' && Object.prototype.hasOwnProperty.call(EVENTS, v);", + "}", + ""] + return "\n".join(o) + + +# ---------------------------------------------------------------- validate.ts + +PRELUDE = """ +/** + * Runtime validation for everything that crosses the wire. + * + * The daemon does not trust the extension and the extension does not trust the daemon: + * `ws://127.0.0.1` is reachable by any local process, so a TypeScript type proves nothing + * at runtime. Every inbound payload goes through one of these before it is used. + * + * Validators mirror the C++ side exactly, including the rule that an absent field and an + * explicit null mean the same thing. + */ + +export type Validated = + | { ok: true; value: T } + | { ok: false; path: string; message: string }; + +export type Validator = (v: unknown, path: string) => Validated; + +function fail(path: string, message: string): Validated { + return { ok: false, path, message }; +} + +function join(path: string, key: string): string { + return path ? `${path}/${key}` : `/${key}`; +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +export const vString: Validator = (v, p) => + typeof v === 'string' ? { ok: true, value: v } : fail(p, 'expected a string'); + +export const vNumber: Validator = (v, p) => + typeof v === 'number' && Number.isFinite(v) ? { ok: true, value: v } : fail(p, 'expected a number'); + +export const vInteger: Validator = (v, p) => + typeof v === 'number' && Number.isInteger(v) ? { ok: true, value: v } : fail(p, 'expected an integer'); + +export const vBoolean: Validator = (v, p) => + typeof v === 'boolean' ? { ok: true, value: v } : fail(p, 'expected a boolean'); + +export const vUnknown: Validator = (v) => ({ ok: true, value: v }); + +function vArray(inner: Validator): Validator { + return (v, p) => { + if (!Array.isArray(v)) return fail(p, 'expected an array'); + const out: T[] = []; + for (let i = 0; i < v.length; i += 1) { + const r = inner(v[i], join(p, String(i))); + if (!r.ok) return r; + out.push(r.value); + } + return { ok: true, value: out }; + }; +} + +function vRecord(inner: Validator): Validator> { + return (v, p) => { + if (!isPlainObject(v)) return fail(p, 'expected an object'); + const out: Record = {}; + for (const [k, raw] of Object.entries(v)) { + const r = inner(raw, join(p, k)); + if (!r.ok) return r; + out[k] = r.value; + } + return { ok: true, value: out }; + }; +} + +/** + * Range, length and pattern checks. The wire is untrusted, so a `maximum` in the schema + * has to be a check at runtime — a TypeScript type cannot enforce one. + */ +interface Limits { + readonly minimum?: number; + readonly maximum?: number; + readonly minLength?: number; + readonly maxLength?: number; + readonly pattern?: RegExp; + readonly minItems?: number; + readonly maxItems?: number; +} + +function vLimited(inner: Validator, limits: Limits): Validator { + return (v, p) => { + const r = inner(v, p); + if (!r.ok) return r; + const value = r.value; + if (typeof value === 'number') { + if (limits.minimum !== undefined && value < limits.minimum) + return fail(p, `value is below the minimum of ${limits.minimum}`); + if (limits.maximum !== undefined && value > limits.maximum) + return fail(p, `value is above the maximum of ${limits.maximum}`); + } else if (typeof value === 'string') { + if (limits.minLength !== undefined && value.length < limits.minLength) + return fail(p, `value is shorter than ${limits.minLength} characters`); + if (limits.maxLength !== undefined && value.length > limits.maxLength) + return fail(p, `value is longer than ${limits.maxLength} characters`); + if (limits.pattern !== undefined && !limits.pattern.test(value)) + return fail(p, 'value does not match the required pattern'); + } else if (Array.isArray(value)) { + if (limits.minItems !== undefined && value.length < limits.minItems) + return fail(p, `fewer than ${limits.minItems} items`); + if (limits.maxItems !== undefined && value.length > limits.maxItems) + return fail(p, `more than ${limits.maxItems} items`); + } + return r; + }; +} + +function vEnum(values: readonly T[], name: string): Validator { + return (v, p) => + typeof v === 'string' && (values as readonly string[]).includes(v) + ? { ok: true, value: v as T } + : fail(p, `not a valid ${name}`); +} + +function vIntEnum(values: readonly T[], name: string): Validator { + return (v, p) => + typeof v === 'number' && (values as readonly number[]).includes(v) + ? { ok: true, value: v as T } + : fail(p, `not a valid ${name}`); +} + +/** Required: must be present and non-null. */ +function req( + obj: Record, + key: string, + path: string, + inner: Validator, + out: Record, +): Validated { + const raw = obj[key]; + if (raw === undefined || raw === null) return fail(join(path, key), 'required field is missing'); + const r = inner(raw, join(path, key)); + if (!r.ok) return r; + out[key] = r.value; + return { ok: true, value: null }; +} + +/** Optional: absent and null are the same thing, exactly as on the C++ side. */ +function opt( + obj: Record, + key: string, + path: string, + inner: Validator, + out: Record, +): Validated { + const raw = obj[key]; + if (raw === undefined || raw === null) return { ok: true, value: null }; + const r = inner(raw, join(path, key)); + if (!r.ok) return r; + out[key] = r.value; + return { ok: true, value: null }; +} +""" + + +def ts_limits(ref: TypeRef) -> str: + """The Limits object literal for a TypeRef, or "" when it is unconstrained.""" + lim = ref.limits + parts = [] + for key in ("minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"): + if key in lim: + parts.append(f"{key}: {lim[key]}") + if "pattern" in lim: + parts.append("pattern: " + js_regex(str(lim["pattern"]))) + return "{ " + ", ".join(parts) + " }" if parts else "" + + +def js_regex(pattern: str) -> str: + return "/" + pattern.replace("/", "\\/") + "/" + + +def validator_expr(ref: TypeRef) -> str: + base = _validator_base(ref) + limits = ts_limits(ref) + return f"vLimited({base}, {limits})" if limits else base + + +def _validator_base(ref: TypeRef) -> str: + if ref.kind == "named": + return f"validate{ref.name}" + if ref.kind == "string": + return "vString" + if ref.kind == "integer": + return "vInteger" + if ref.kind == "number": + return "vNumber" + if ref.kind == "boolean": + return "vBoolean" + if ref.kind == "json": + return "vUnknown" + if ref.kind == "array": + return f"vArray({validator_expr(ref.inner)})" + if ref.kind == "map": + return f"vRecord({validator_expr(ref.inner)})" + raise AssertionError(ref.kind) + + +def emit_validate(c: Contract) -> str: + # An int enum is exported from types.ts as a const *and* a type under one name, so a + # value import already brings the type with it. Importing it twice is a TS2300. + value_imported = {t.name for t in c.types if t.kind == "int_enum"} + type_names = [t.name for t in c.types if t.name not in value_imported] + o = [BANNER.format(version=c.version), ""] + o.append("import type {") + for name in sorted(type_names): + o.append(f" {name},") + o.append("} from './types.js';") + o.append("import {") + for t in sorted(c.types, key=lambda t: t.name): + if t.kind == "string_enum": + o.append(f" {upper_snake(t.name)}_VALUES,") + elif t.kind == "int_enum": + o.append(f" {t.name},") + o.append("} from './types.js';") + o.append("import { isEventName, type EventMap, type EventName } from './events.js';") + o.append("import { isMethodName, type MethodMap, type MethodName } from './methods.js';") + o.append(PRELUDE) + + for t in c.types: + if t.kind == "string_enum": + o.append(f"export const validate{t.name}: Validator<{t.name}> = " + f"vEnum({upper_snake(t.name)}_VALUES, '{t.name}');") + o.append("") + elif t.kind == "int_enum": + o.append(f"const {upper_snake(t.name)}_VALUES = Object.values({t.name}) as {t.name}[];") + o.append(f"export const validate{t.name}: Validator<{t.name}> = " + f"vIntEnum({upper_snake(t.name)}_VALUES, '{t.name}');") + o.append("") + elif t.kind == "map_alias": + o.append(f"export const validate{t.name}: Validator<{t.name}> = " + f"{validator_expr(t.alias)};") + o.append("") + elif t.kind == "struct": + o += doc_block(f"Validate an untrusted value as {t.name}.") + o.append(f"export function validate{t.name}(v: unknown, path = ''): Validated<{t.name}> {{") + o.append(" if (!isPlainObject(v)) return fail(path, 'expected an object');") + if not t.fields: + o.append(f" return {{ ok: true, value: {{}} as {t.name} }};") + o.append("}") + o.append("") + continue + o.append(" const out: Record = {};") + o.append(" let r: Validated;") + for f in t.fields: + fn = "opt" if f.optional else "req" + o.append(f" r = {fn}(v, {json.dumps(f.name)}, path, {validator_expr(f.type)}, out);") + o.append(" if (!r.ok) return r;") + o.append(f" return {{ ok: true, value: out as unknown as {t.name} }};") + o.append("}") + o.append("") + + # dispatch tables + o += ["// --- by-name entry points --------------------------------------------------", + "", + "const PARAMS_VALIDATORS: { [M in MethodName]: Validator } = {"] + for m in c.methods: + o.append(f" {method_key(m.name)}: {validator_expr(m.params)},") + o += ["};", "", + "const RESULT_VALIDATORS: { [M in MethodName]: Validator } = {"] + for m in c.methods: + o.append(f" {method_key(m.name)}: {validator_expr(m.result)},") + o += ["};", "", + "const EVENT_VALIDATORS: { [E in EventName]: Validator } = {"] + for e in c.events: + o.append(f" {method_key(e.name)}: {validator_expr(e.params)},") + o += ["};", "", + "/** Validate params the daemon is about to receive for `method`. */", + "export function validateParams(method: M, v: unknown): Validated {", + " return PARAMS_VALIDATORS[method](v, 'params');", + "}", + "", + "/** Validate a result the client just received for `method`. */", + "export function validateResult(method: M, v: unknown): Validated {", + " return RESULT_VALIDATORS[method](v, 'result');", + "}", + "", + "/** Validate a notification payload. */", + "export function validateEventParams(event: E, v: unknown): Validated {", + " return EVENT_VALIDATORS[event](v, 'params');", + "}", + "", + "/**", + " * Validate a whole inbound notification frame, including its method name.", + " * Anything unrecognised is rejected rather than passed on: an unknown method on a", + " * loopback socket is either a version skew or another local process probing us.", + " */", + "export function validateNotification(", + " frame: unknown,", + "): Validated<{ method: EventName; params: EventMap[EventName] }> {", + " if (!isPlainObject(frame)) return fail('', 'expected an object');", + " if (frame['jsonrpc'] !== '2.0') return fail('/jsonrpc', \"expected '2.0'\");", + " const method = frame['method'];", + " if (!isEventName(method)) return fail('/method', 'unknown event');", + " const params = validateEventParams(method, frame['params']);", + " if (!params.ok) return params;", + " return { ok: true, value: { method, params: params.value } };", + "}", + "", + "export { isEventName, isMethodName };", + ""] + return "\n".join(o) + + +def emit_index(c: Contract) -> str: + return "\n".join([ + BANNER.format(version=c.version), + "", + "export * from './types.js';", + "export * from './methods.js';", + "export * from './events.js';", + "export * from './validate.js';", + "", + ]) + + +def main() -> int: + c = load() + OUT_DIR.mkdir(parents=True, exist_ok=True) + (OUT_DIR / "types.ts").write_text(emit_types(c)) + (OUT_DIR / "methods.ts").write_text(emit_methods(c)) + (OUT_DIR / "events.ts").write_text(emit_events(c)) + (OUT_DIR / "validate.ts").write_text(emit_validate(c)) + (OUT_DIR / "index.ts").write_text(emit_index(c)) + print(f"gen_ts: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events -> {OUT_DIR}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/contracts/codegen/schema_ir.py b/contracts/codegen/schema_ir.py new file mode 100644 index 0000000..9214276 --- /dev/null +++ b/contracts/codegen/schema_ir.py @@ -0,0 +1,454 @@ +"""Load contracts/schema/ and lower it into a small IR the generators emit from. + +There is deliberately only one of these. gen_cpp.py, gen_ts.py and gen_openrpc.py all +consume the same IR, so the C++ structs, the TypeScript types and the human-readable +API document cannot disagree about what the contract says. + +The IR covers exactly the JSON Schema subset the contract is allowed to use. Anything +outside it raises SchemaError at generation time rather than producing subtly wrong code: +a contract that cannot be generated from is a contract bug, and it should stop the build. + +Supported subset +---------------- + type: object / string / integer / number / boolean / array, and ["X", "null"] + object with `properties` -> struct + object with `additionalProperties: ` -> map + string with `enum` -> enum + integer with `enum` + x-enum -> named integer enum + array with `items` -> vector + $ref to a types/*.schema.json -> named type + oneOf: [{$ref}, {type: null}] -> nullable named type + {} -> opaque JSON + +Not supported, on purpose: allOf, anyOf, general oneOf, patternProperties, tuple items, +recursive types. If the contract needs one of these, extend this file in the same PR. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path + +SCHEMA_ROOT = Path(__file__).resolve().parent.parent / "schema" +ID_PREFIX = "https://velox.dev/schema/" + + +class SchemaError(Exception): + """A schema the generators refuse to guess about.""" + + +# --------------------------------------------------------------------------- IR + + +# Value constraints the generators enforce at runtime. The wire is never trusted, so a +# `maximum` in the schema has to be a check in the generated code, not just documentation. +CONSTRAINT_KEYS = ("minimum", "maximum", "minLength", "maxLength", "pattern", + "minItems", "maxItems") + + +@dataclass(frozen=True) +class TypeRef: + """A reference to a type from a field. `kind` drives every emitter's switch.""" + + kind: str # named | string | integer | number | boolean | array | map | json + name: str | None = None # kind == "named" + inner: "TypeRef | None" = None # kind in ("array", "map") + constraints: tuple[tuple[str, object], ...] = () + + @property + def limits(self) -> dict[str, object]: + return dict(self.constraints) + + +@dataclass +class Field: + name: str + type: TypeRef + required: bool + nullable: bool + doc: str = "" + + @property + def optional(self) -> bool: + """Whether the emitted field needs an optional/undefined-able representation.""" + return (not self.required) or self.nullable + + +@dataclass +class EnumValue: + name: str # identifier form, e.g. RetryWait + wire: object # the value on the wire: "retry_wait" or -32010 + doc: str = "" + + +@dataclass +class TypeDef: + name: str + kind: str # struct | string_enum | int_enum | map_alias + doc: str = "" + fields: list[Field] = field(default_factory=list) + values: list[EnumValue] = field(default_factory=list) + alias: TypeRef | None = None + source: str = "" # relative path, for the "do not edit" banner + + +@dataclass +class Method: + name: str # download.addBatch + doc: str + params: TypeRef + result: TypeRef + privileged: bool + transports: list[str] + deadline_ms: int + errors: list[int] = field(default_factory=list) + ws_restrictions: list[str] = field(default_factory=list) + + +@dataclass +class Event: + name: str # event.task.progress + doc: str + params: TypeRef + max_rate_hz: float | None = None + + +@dataclass +class Contract: + version: str + types: list[TypeDef] + methods: list[Method] + events: list[Event] + error_codes: list[EnumValue] + + +# ------------------------------------------------------------------ name helpers + + +def pascal(text: str) -> str: + """download.addBatch -> DownloadAddBatch ; retry_wait -> RetryWait.""" + parts = re.split(r"[.\-_ ]+", text) + out = [] + for part in parts: + if not part: + continue + out.append(part[0].upper() + part[1:]) + return "".join(out) + + +def enum_ident(value: str) -> str: + ident = pascal(value) + if not ident: + raise SchemaError(f"cannot derive an identifier from enum value {value!r}") + if ident[0].isdigit(): + ident = "V" + ident + return ident + + +# ---------------------------------------------------------------------- loading + + +class Loader: + def __init__(self, root: Path = SCHEMA_ROOT): + self.root = root + self.by_id: dict[str, dict] = {} + self.source_of: dict[str, str] = {} + for path in sorted(root.rglob("*.schema.json")): + with path.open() as fh: + doc = json.load(fh) + sid = doc.get("$id") + if not sid: + raise SchemaError(f"{path} has no $id") + if sid in self.by_id: + raise SchemaError(f"duplicate $id {sid} in {path}") + self.by_id[sid] = doc + self.source_of[sid] = str(path.relative_to(root.parent)) + + self.types: dict[str, TypeDef] = {} + self._order: list[str] = [] + + # -- ref handling ------------------------------------------------------- + + def _resolve_ref(self, ref: str) -> dict: + if ref.startswith("#"): + raise SchemaError(f"local $ref {ref} is not supported outside envelope.schema.json") + if ref not in self.by_id: + raise SchemaError(f"unknown $ref {ref}") + return self.by_id[ref] + + @staticmethod + def _split_nullable(node: dict) -> tuple[dict, bool]: + """Normalise the two ways the contract spells 'or null'.""" + if "oneOf" in node: + branches = node["oneOf"] + non_null = [b for b in branches if b.get("type") != "null"] + nulls = [b for b in branches if b.get("type") == "null"] + if len(branches) != 2 or len(non_null) != 1 or len(nulls) != 1: + raise SchemaError( + "oneOf is only supported as [, {type: null}]; got " + + json.dumps(branches)[:200] + ) + merged = dict(non_null[0]) + for key in ("description",): + if key in node and key not in merged: + merged[key] = node[key] + return merged, True + + t = node.get("type") + if isinstance(t, list): + non_null = [x for x in t if x != "null"] + if len(non_null) != 1: + raise SchemaError(f"union type {t} is only supported as [X, 'null']") + node = dict(node) + node["type"] = non_null[0] + # An enum listing null alongside its values means the same thing. + if "enum" in node: + node["enum"] = [v for v in node["enum"] if v is not None] + return node, True + + if "enum" in node and None in node["enum"]: + node = dict(node) + node["enum"] = [v for v in node["enum"] if v is not None] + return node, True + + return node, False + + # -- lowering ----------------------------------------------------------- + + def type_ref(self, node: dict, hint: str) -> tuple[TypeRef, bool]: + """Lower a schema node to a TypeRef. `hint` names any struct we must synthesise.""" + node, nullable = self._split_nullable(node) + limits = tuple((k, node[k]) for k in CONSTRAINT_KEYS if k in node) + + if "$ref" in node: + target = self._resolve_ref(node["$ref"]) + return TypeRef("named", name=self.named_type(node["$ref"], target)), nullable + + if not node or node.keys() <= {"description"}: + return TypeRef("json"), True + + t = node.get("type") + if t == "string": + if "enum" in node: + return TypeRef("named", name=self._synth_string_enum(hint, node)), nullable + return TypeRef("string", constraints=limits), nullable + if t == "integer": + return TypeRef("integer", constraints=limits), nullable + if t == "number": + return TypeRef("number", constraints=limits), nullable + if t == "boolean": + return TypeRef("boolean", constraints=limits), nullable + if t == "array": + items = node.get("items") + if items is None: + raise SchemaError(f"array without items at {hint}") + inner, _ = self.type_ref(items, hint + "Item") + return TypeRef("array", inner=inner, constraints=limits), nullable + if t == "object": + if "properties" in node: + return TypeRef("named", name=self._synth_struct(hint, node)), nullable + ap = node.get("additionalProperties") + if isinstance(ap, dict): + inner, _ = self.type_ref(ap, hint + "Value") + return TypeRef("map", inner=inner), nullable + return TypeRef("json"), nullable + if "const" in node: + return TypeRef("string", constraints=limits), nullable + + raise SchemaError(f"unsupported schema node at {hint}: {json.dumps(node)[:200]}") + + def _register(self, td: TypeDef) -> str: + existing = self.types.get(td.name) + if existing is not None: + if existing.kind != td.kind: + raise SchemaError(f"type name collision on {td.name}") + return td.name + self.types[td.name] = td + self._order.append(td.name) + return td.name + + def _synth_string_enum(self, name: str, node: dict) -> str: + values = [ + EnumValue(name=enum_ident(v), wire=v) + for v in node["enum"] + if v is not None + ] + return self._register(TypeDef(name=name, kind="string_enum", + doc=node.get("description", ""), values=values)) + + def _synth_struct(self, name: str, node: dict) -> str: + if node.get("additionalProperties", False) is not False: + raise SchemaError( + f"{name}: object schemas must set additionalProperties:false — the daemon is " + "not allowed to trust unknown fields on the wire" + ) + required = set(node.get("required", [])) + fields: list[Field] = [] + for prop, sub in node.get("properties", {}).items(): + ref, nullable = self.type_ref(sub, name + pascal(prop)) + fields.append(Field(name=prop, type=ref, required=prop in required, + nullable=nullable, doc=sub.get("description", ""))) + return self._register(TypeDef(name=name, kind="struct", + doc=node.get("description", ""), fields=fields)) + + def named_type(self, sid: str, doc: dict) -> str: + """Lower a top-level types/*.schema.json into a TypeDef and return its name.""" + name = doc.get("title") + if not name: + raise SchemaError(f"{sid} has no title") + if name in self.types: + return name + + source = self.source_of.get(sid, "") + node, _ = self._split_nullable(doc) + t = node.get("type") + + if t == "string" and "enum" in node: + # Placeholder first: enums cannot recurse, but registering early keeps the + # ordering stable and mirrors the struct path below. + values = [EnumValue(name=enum_ident(v), wire=v) for v in node["enum"] if v is not None] + td = TypeDef(name, "string_enum", node.get("description", ""), values=values, source=source) + return self._register(td) + + if t == "integer" and "x-enum" in node: + values = [EnumValue(name=e["name"], wire=e["value"], doc=e.get("doc", "")) + for e in node["x-enum"]] + td = TypeDef(name, "int_enum", node.get("description", ""), values=values, source=source) + return self._register(td) + + if t == "object" and "properties" not in node: + ap = node.get("additionalProperties") + if not isinstance(ap, dict): + raise SchemaError(f"{sid}: object type with neither properties nor a typed additionalProperties") + inner, _ = self.type_ref(ap, name + "Value") + td = TypeDef(name, "map_alias", node.get("description", ""), + alias=TypeRef("map", inner=inner), source=source) + return self._register(td) + + if t == "object": + # Reserve the name before descending so a nested synth cannot steal it. + self.types[name] = TypeDef(name, "struct", node.get("description", ""), source=source) + self._order.append(name) + required = set(node.get("required", [])) + if node.get("additionalProperties", False) is not False: + raise SchemaError(f"{sid}: object schemas must set additionalProperties:false") + fields = [] + for prop, sub in node.get("properties", {}).items(): + ref, nullable = self.type_ref(sub, name + pascal(prop)) + fields.append(Field(prop, ref, prop in required, nullable, sub.get("description", ""))) + self.types[name].fields = fields + return name + + raise SchemaError(f"{sid}: unsupported top-level type {t!r}") + + +# ---------------------------------------------------------------------- driver + + +def load() -> Contract: + loader = Loader() + root = loader.root.parent + + version = (root / "VERSION").read_text().strip() + + # Named types first, so their names win over any synthesised ones. + for sid in sorted(loader.by_id): + if "/types/" in sid: + loader.named_type(sid, loader.by_id[sid]) + + methods: list[Method] = [] + for sid in sorted(loader.by_id): + if "/methods/" not in sid: + continue + doc = loader.by_id[sid] + name = doc["title"] + base = pascal(name) + props = doc.get("properties", {}) + for half in ("params", "result"): + if half not in props: + raise SchemaError(f"{sid}: method schema must define both params and result") + params, _ = loader.type_ref(props["params"], base + "Params") + result, _ = loader.type_ref(props["result"], base + "Result") + transports = doc.get("x-transports") + if not transports: + raise SchemaError(f"{sid}: x-transports is required") + if "x-privileged" not in doc: + raise SchemaError(f"{sid}: x-privileged is required") + methods.append(Method( + name=name, doc=doc.get("description", ""), params=params, result=result, + privileged=bool(doc["x-privileged"]), transports=list(transports), + deadline_ms=int(doc.get("x-deadlineMs", 5000)), + errors=list(doc.get("x-errors", [])), + ws_restrictions=list(doc.get("x-wsRestrictions", [])), + )) + + events: list[Event] = [] + for sid in sorted(loader.by_id): + if "/events/" not in sid: + continue + doc = loader.by_id[sid] + name = doc["title"] + ident = pascal(name[len("event."):] if name.startswith("event.") else name) + "Event" + params, _ = loader.type_ref(doc["properties"]["params"], ident) + events.append(Event(name=name, doc=doc.get("description", ""), params=params, + max_rate_hz=doc.get("x-maxRateHz"))) + + error_codes = loader.types["ErrorCode"].values + ordered = [loader.types[n] for n in loader._order] + return Contract(version=version, types=ordered, methods=methods, events=events, + error_codes=error_codes) + + +def dependencies(td: TypeDef) -> set[str]: + """Named types `td` mentions directly.""" + out: set[str] = set() + + def walk(ref: TypeRef | None) -> None: + if ref is None: + return + if ref.kind == "named" and ref.name: + out.add(ref.name) + walk(ref.inner) + + for f in td.fields: + walk(f.type) + walk(td.alias) + return out + + +def topo_sorted(types: list[TypeDef]) -> list[TypeDef]: + """Definition order for languages that need a type declared before it is used. + + The contract forbids recursive types, so a cycle here means a schema bug and is + raised rather than broken arbitrarily. + """ + by_name = {t.name: t for t in types} + state: dict[str, int] = {} + order: list[TypeDef] = [] + + def visit(name: str, trail: list[str]) -> None: + mark = state.get(name, 0) + if mark == 2: + return + if mark == 1: + raise SchemaError("recursive type: " + " -> ".join(trail + [name])) + state[name] = 1 + for dep in sorted(dependencies(by_name[name])): + if dep in by_name: + visit(dep, trail + [name]) + state[name] = 2 + order.append(by_name[name]) + + for t in types: + visit(t.name, []) + return order + + +if __name__ == "__main__": + c = load() + print(f"contract {c.version}: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events") + for t in c.types: + detail = f"{len(t.fields)} fields" if t.kind == "struct" else f"{len(t.values)} values" + print(f" {t.kind:12} {t.name:34} {detail}") diff --git a/contracts/fixtures/README.md b/contracts/fixtures/README.md new file mode 100644 index 0000000..9aee282 --- /dev/null +++ b/contracts/fixtures/README.md @@ -0,0 +1,83 @@ +# contracts/fixtures — golden request/response pairs + +Every method has at least one success fixture. A method with no fixture is not done. + +These files are replayed by `tests/conformance/` against **both** the generated C++ and a +live server, which is what lets four lanes build in parallel and still be compatible: green +fixtures mean the C++ daemon and the TypeScript extension agree, without either having ever +run against the other. `tools/mockd` also answers from them, so the GUI and extension are +developed against the same bytes conformance asserts. + +## Layout + +``` +fixtures/ +├── *.json one success fixture per method +├── errors/ error cases: auth, transport, not-found, bad path, timeout +└── events/ one fixture per server-to-client notification +``` + +## Shape + +```jsonc +{ + "name": "download.add — start an ISO now, into the Programs category", + "description": "Why this case is worth pinning.", + "transport": "uds", // optional: replay only on this transport + "requires": "...", // optional: a condition a plain server cannot produce + "kind": "timeout", // optional: the correct behaviour is *no reply* + "request": { "jsonrpc": "2.0", "id": 11, "method": "download.add", "params": { } }, + "response": { "jsonrpc": "2.0", "id": 11, "result": { } }, + "assertions": [ "things a runner or a reviewer should check" ] +} +``` + +An event fixture carries `notification` instead of `request`/`response`. + +`assertions` are prose, for the human writing the implementation. The runners check the +machine-checkable parts: schema validity, error codes, shape, and the timeout. + +## Placeholders + +Some values cannot be pinned in a golden file. These stand in for them, and the runners +treat them as "any value of the right shape": + +| Placeholder | Means | +|---|---| +| `$uuid` | any UUID | +| `$isoDate` | any RFC 3339 date-time | +| `$opaque` | a credential-shaped string (a token) | +| `$any` | any value | +| `$taskId`, `$taskId2` | a task the runner creates during setup, and binds before replaying | + +`$taskId` exists so a fixture never depends on a task id that only happens to exist in a +seeded mock. The same fixture then runs against an empty `veloxd` and a populated `mockd`. + +## Values are matched by shape, not by equality + +A live daemon returns its own task ids and its own clock. Demanding byte-identical results +would only teach the suite to lie, so the runners assert: + +* the payload passes the **generated validator** — this is the real cross-language check; +* the **key structure** matches the golden file, with no extra and no missing fields; +* **error codes** match exactly. + +A `null` where the golden shows a value is accepted: the validator has already ruled on +whether null is legal there, and a golden file shows one plausible value, not the only one. + +## `requires`: fixtures a mock cannot produce + +Most error fixtures are *intrinsic* — a path outside the allowed roots, an out-of-range +parameter, an unknown task id — and any correct server produces them from the request +alone. Those are replayed everywhere. + +Four are environmental: a 403 from an origin server, a full disk, a pairing lockout, a +wedged daemon. They carry `requires`, are skipped by default, and are exercised where the +condition can actually be arranged — `run.sh` starts a deliberately slow `mockd` to prove +`capture.offer` fails open, and lane PKG/QA's `tools/testserver` covers the hostile-server +cases in `tests/integration/`. + +`errors/capture.offer.timeout.json` is the most important file in this directory. Its +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 +is worse than no download manager. diff --git a/contracts/fixtures/capture.getRules.json b/contracts/fixtures/capture.getRules.json new file mode 100644 index 0000000..da5eb64 --- /dev/null +++ b/contracts/fixtures/capture.getRules.json @@ -0,0 +1,52 @@ +{ + "name": "capture.getRules \u2014 the extension mirrors the daemon's policy", + "description": "Fetched on connect and whenever event.settings.changed names a capture.* key, so the two can never disagree about what should be intercepted.", + "request": { + "jsonrpc": "2.0", + "id": 60, + "method": "capture.getRules", + "params": {} + }, + "response": { + "jsonrpc": "2.0", + "id": 60, + "result": { + "enabled": true, + "monitoredExtensions": [ + "iso", + "zip", + "tar", + "gz", + "xz", + "7z", + "rar", + "deb", + "rpm", + "exe", + "msi", + "appimage", + "mkv", + "mp4", + "flac", + "pdf" + ], + "monitoredMimeTypes": [ + "application/octet-stream", + "application/x-iso9660-image", + "application/zip", + "video/x-matroska" + ], + "minSizeBytes": 1048576, + "excludedHosts": [ + "*.corp.internal", + "localhost" + ], + "bypassModifier": "alt", + "rulesVersion": 7 + } + }, + "assertions": [ + "if this call fails the extension keeps its last known rules and stays fail-open", + "rulesVersion increases on every change so the extension knows when to re-fetch" + ] +} diff --git a/contracts/fixtures/category.list.json b/contracts/fixtures/category.list.json new file mode 100644 index 0000000..afc6a8b --- /dev/null +++ b/contracts/fixtures/category.list.json @@ -0,0 +1,111 @@ +{ + "name": "category.list \u2014 the five built-in categories", + "description": "Read-only and available over both transports: the extension's default-category picker needs it.", + "request": { + "jsonrpc": "2.0", + "id": 30, + "method": "category.list", + "params": {} + }, + "response": { + "jsonrpc": "2.0", + "id": 30, + "result": { + "items": [ + { + "categoryId": "compressed", + "name": "Compressed", + "saveDir": "/home/sami/Downloads/Compressed", + "extensions": [ + "zip", + "tar", + "gz", + "xz", + "zst", + "7z", + "rar" + ], + "mimeTypes": [ + "application/zip" + ], + "builtin": true, + "sortOrder": 0 + }, + { + "categoryId": "documents", + "name": "Documents", + "saveDir": "/home/sami/Downloads/Documents", + "extensions": [ + "pdf", + "epub", + "odt", + "docx" + ], + "mimeTypes": [ + "application/pdf" + ], + "builtin": true, + "sortOrder": 1 + }, + { + "categoryId": "music", + "name": "Music", + "saveDir": "/home/sami/Downloads/Music", + "extensions": [ + "mp3", + "flac", + "ogg", + "opus", + "wav" + ], + "mimeTypes": [ + "audio/flac" + ], + "builtin": true, + "sortOrder": 2 + }, + { + "categoryId": "programs", + "name": "Programs", + "saveDir": "/home/sami/Downloads/Programs", + "extensions": [ + "exe", + "msi", + "deb", + "rpm", + "appimage", + "iso", + "dmg" + ], + "mimeTypes": [ + "application/x-iso9660-image" + ], + "builtin": true, + "sortOrder": 3 + }, + { + "categoryId": "video", + "name": "Video", + "saveDir": "/home/sami/Downloads/Video", + "extensions": [ + "mkv", + "mp4", + "avi", + "webm", + "mov" + ], + "mimeTypes": [ + "video/mp4", + "video/x-matroska" + ], + "builtin": true, + "sortOrder": 4 + } + ] + } + }, + "assertions": [ + "built-in categories always exist and cannot be removed", + "extensions are lowercase and carry no leading dot" + ] +} diff --git a/contracts/fixtures/category.remove.json b/contracts/fixtures/category.remove.json new file mode 100644 index 0000000..785524b --- /dev/null +++ b/contracts/fixtures/category.remove.json @@ -0,0 +1,27 @@ +{ + "name": "category.remove \u2014 delete a user category and refile its tasks", + "description": "No task is ever orphaned: everything filed under the removed category moves to reassignTo.", + "request": { + "jsonrpc": "2.0", + "id": 32, + "method": "category.remove", + "params": { + "categoryId": "firmware", + "reassignTo": "programs" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 32, + "result": { + "removed": true, + "reassignedTaskIds": [ + "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071" + ] + } + }, + "assertions": [ + "removing a builtin category is refused with -32602", + "reassignTo null moves tasks to the default category rather than leaving them dangling" + ] +} diff --git a/contracts/fixtures/category.upsert.json b/contracts/fixtures/category.upsert.json new file mode 100644 index 0000000..a4e76d0 --- /dev/null +++ b/contracts/fixtures/category.upsert.json @@ -0,0 +1,47 @@ +{ + "name": "category.upsert \u2014 create a user category for firmware images", + "description": "Omitting categoryId creates; the daemon assigns the id and echoes the stored row back.", + "request": { + "jsonrpc": "2.0", + "id": 31, + "method": "category.upsert", + "params": { + "category": { + "categoryId": "firmware", + "name": "Firmware", + "saveDir": "/home/sami/Downloads/Firmware", + "extensions": [ + "bin", + "img", + "fw" + ], + "mimeTypes": [], + "builtin": false, + "sortOrder": 5 + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 31, + "result": { + "category": { + "categoryId": "firmware", + "name": "Firmware", + "saveDir": "/home/sami/Downloads/Firmware", + "extensions": [ + "bin", + "img", + "fw" + ], + "mimeTypes": [], + "builtin": false, + "sortOrder": 5 + } + } + }, + "assertions": [ + "builtin is forced false on a created category regardless of what was sent", + "changing saveDir never moves existing files \u2014 the GUI asks and issues download.update per task" + ] +} diff --git a/contracts/fixtures/download.add.json b/contracts/fixtures/download.add.json new file mode 100644 index 0000000..9733715 --- /dev/null +++ b/contracts/fixtures/download.add.json @@ -0,0 +1,31 @@ +{ + "name": "download.add \u2014 start an ISO now, into the Programs category", + "description": "The ordinary add path. saveDir is canonicalized and checked against the allowed roots before anything is written.", + "request": { + "jsonrpc": "2.0", + "id": 11, + "method": "download.add", + "params": { + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "filename": "ubuntu-26.04-desktop-amd64.iso", + "saveDir": "/home/sami/Downloads/Programs", + "categoryId": "programs", + "segments": 8, + "startMode": "now" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 11, + "result": { + "taskId": "$uuid", + "state": "connecting", + "duplicate": null + } + }, + "assertions": [ + "the .veloxpart file is created sparse and preallocated at the final size", + "saveDir resolves inside saveTo.allowedRoots, or the call fails -32011 having written nothing", + "event.task.added is emitted to every subscriber before this reply is sent" + ] +} diff --git a/contracts/fixtures/download.addBatch.json b/contracts/fixtures/download.addBatch.json new file mode 100644 index 0000000..0d2953e --- /dev/null +++ b/contracts/fixtures/download.addBatch.json @@ -0,0 +1,51 @@ +{ + "name": "download.addBatch \u2014 three URLs, one bad, added together", + "description": "Partial success is normal: the two good items become tasks and the third is reported per-item rather than failing the batch.", + "request": { + "jsonrpc": "2.0", + "id": 12, + "method": "download.addBatch", + "params": { + "items": [ + { + "url": "https://example.org/a.zip" + }, + { + "url": "https://example.org/b.zip" + }, + { + "url": "https://example.org/c.zip", + "saveDir": "/etc" + } + ], + "defaults": { + "url": "https://example.org/", + "categoryId": "compressed", + "startMode": "queue", + "queueId": "main" + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 12, + "result": { + "taskIds": [ + "$uuid", + "$uuid" + ], + "failed": [ + { + "index": 2, + "code": -32011, + "message": "destination is outside the allowed roots" + } + ] + } + }, + "assertions": [ + "defaults fill only fields an item left unset; the defaults' own url is ignored", + "one rejected item never rolls back the accepted ones", + "failed[].index refers to params.items, so the caller can map it back to its own list" + ] +} diff --git a/contracts/fixtures/download.cancel.json b/contracts/fixtures/download.cancel.json new file mode 100644 index 0000000..78f4199 --- /dev/null +++ b/contracts/fixtures/download.cancel.json @@ -0,0 +1,39 @@ +{ + "name": "download.cancel \u2014 applied to a two-task selection", + "description": "The part file is kept: cancel stops the transfer, download.remove is what deletes bytes.", + "request": { + "jsonrpc": "2.0", + "id": 23, + "method": "download.cancel", + "params": { + "taskIds": [ + "$taskId", + "$taskId2" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 23, + "result": { + "updated": [ + { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "state": "cancelled", + "changed": true + }, + { + "taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", + "state": "cancelled", + "changed": false + } + ], + "failed": [] + } + }, + "assertions": [ + "a task already in the target state is reported with changed false, not as a failure", + "an unknown id lands in failed[] with -32010 and never fails the whole call", + "event.task.state is emitted for every entry whose changed is true" + ] +} diff --git a/contracts/fixtures/download.get.json b/contracts/fixtures/download.get.json new file mode 100644 index 0000000..f381414 --- /dev/null +++ b/contracts/fixtures/download.get.json @@ -0,0 +1,133 @@ +{ + "name": "download.get \u2014 full detail with per-segment state", + "description": "Backs the progress dialog. This is the only place segment-level detail crosses the wire.", + "request": { + "jsonrpc": "2.0", + "id": 14, + "method": "download.get", + "params": { + "taskId": "$taskId" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 14, + "result": { + "summary": { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "filename": "ubuntu-26.04-desktop-amd64.iso", + "saveDir": "/home/sami/Downloads/Programs", + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "sizeBytes": 6228541440, + "downloadedBytes": 2941190144, + "state": "downloading", + "speedBps": 29796556, + "etaSeconds": 110, + "resumable": true, + "segments": 8, + "categoryId": "programs", + "queueId": null, + "queuePosition": null, + "description": null, + "createdAt": "$isoDate", + "lastTryAt": "$isoDate", + "completedAt": null, + "error": null + }, + "segmentDetail": [ + { + "index": 0, + "startByte": 0, + "endByte": 778567679, + "downloadedBytes": 402653184, + "speedBps": 4089446, + "state": "downloading", + "httpStatus": 206 + }, + { + "index": 1, + "startByte": 778567680, + "endByte": 1557135359, + "downloadedBytes": 356515840, + "speedBps": 3565158, + "state": "downloading", + "httpStatus": 206 + }, + { + "index": 2, + "startByte": 1557135360, + "endByte": 2335703039, + "downloadedBytes": 377487360, + "speedBps": 3774874, + "state": "downloading", + "httpStatus": 206 + }, + { + "index": 3, + "startByte": 2335703040, + "endByte": 3114270719, + "downloadedBytes": 367001600, + "speedBps": 3670016, + "state": "downloading", + "httpStatus": 206 + }, + { + "index": 4, + "startByte": 3114270720, + "endByte": 3892838399, + "downloadedBytes": 356515840, + "speedBps": 3565158, + "state": "downloading", + "httpStatus": 206 + }, + { + "index": 5, + "startByte": 3892838400, + "endByte": 4671406079, + "downloadedBytes": 377487360, + "speedBps": 3774874, + "state": "downloading", + "httpStatus": 206 + }, + { + "index": 6, + "startByte": 4671406080, + "endByte": 5449973759, + "downloadedBytes": 356515840, + "speedBps": 3565158, + "state": "downloading", + "httpStatus": 206 + }, + { + "index": 7, + "startByte": 5449973760, + "endByte": 6228541439, + "downloadedBytes": 347013120, + "speedBps": 3791872, + "state": "downloading", + "httpStatus": 206 + } + ], + "headers": { + "User-Agent": "Velox/0.1", + "Referer": "https://releases.ubuntu.com/26.04/" + }, + "referrer": "https://releases.ubuntu.com/26.04/", + "userAgent": "Velox/0.1", + "mime": "application/octet-stream", + "bufferBytes": 4194304, + "partPath": "/home/sami/Downloads/Programs/ubuntu-26.04-desktop-amd64.iso.veloxpart", + "checksum": null, + "checksumVerified": null, + "averageSpeedBps": 27262976, + "retryCount": 0 + } + }, + "assertions": [ + "segment ranges are contiguous and cover exactly [0, sizeBytes) with no gaps or overlaps", + "startByte and endByte are both INCLUSIVE: segment 0 here covers 778567680 bytes, 0 through 778567679, and is copied verbatim into 'Range: bytes=0-778567679'", + "segmentDetail has exactly summary.segments entries", + "the GUI draws one bar per entry and is never told what a segment steal is" + ] +} diff --git a/contracts/fixtures/download.list.json b/contracts/fixtures/download.list.json new file mode 100644 index 0000000..2ae042b --- /dev/null +++ b/contracts/fixtures/download.list.json @@ -0,0 +1,81 @@ +{ + "name": "download.list \u2014 the main table, unfinished first page", + "description": "Filtering, sorting and paging all happen daemon-side; the GUI never materializes rows it will not draw.", + "request": { + "jsonrpc": "2.0", + "id": 13, + "method": "download.list", + "params": { + "filter": { + "states": [ + "downloading", + "paused", + "queued" + ] + }, + "sort": { + "field": "createdAt", + "direction": "desc" + }, + "offset": 0, + "limit": 50 + } + }, + "response": { + "jsonrpc": "2.0", + "id": 13, + "result": { + "total": 4, + "items": [ + { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "filename": "ubuntu-26.04-desktop-amd64.iso", + "saveDir": "/home/sami/Downloads/Programs", + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "sizeBytes": 6228541440, + "downloadedBytes": 2941190144, + "state": "downloading", + "speedBps": 29796556, + "etaSeconds": 110, + "resumable": true, + "segments": 8, + "categoryId": "programs", + "queueId": null, + "queuePosition": null, + "description": null, + "createdAt": "$isoDate", + "lastTryAt": "$isoDate", + "completedAt": null, + "error": null + }, + { + "taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", + "filename": "film.mkv", + "saveDir": "/home/sami/Downloads/Video", + "url": "https://example.org/film.mkv", + "effectiveUrl": "https://example.org/film.mkv", + "sizeBytes": 1503238553, + "downloadedBytes": 402653184, + "state": "paused", + "speedBps": 0, + "etaSeconds": null, + "resumable": true, + "segments": 4, + "categoryId": "video", + "queueId": null, + "queuePosition": null, + "description": null, + "createdAt": "$isoDate", + "lastTryAt": "$isoDate", + "completedAt": null, + "error": null + } + ] + } + }, + "assertions": [ + "total counts every row matching the filter, ignoring offset and limit", + "the table is built from this once and maintained from events thereafter, never re-fetched per tick" + ] +} diff --git a/contracts/fixtures/download.pause.json b/contracts/fixtures/download.pause.json new file mode 100644 index 0000000..18ec6c5 --- /dev/null +++ b/contracts/fixtures/download.pause.json @@ -0,0 +1,39 @@ +{ + "name": "download.pause \u2014 applied to a two-task selection", + "description": "Progress is flushed to .veloxpart.meta, so a pause is indistinguishable from a crash as far as resume is concerned.", + "request": { + "jsonrpc": "2.0", + "id": 21, + "method": "download.pause", + "params": { + "taskIds": [ + "$taskId", + "$taskId2" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 21, + "result": { + "updated": [ + { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "state": "paused", + "changed": true + }, + { + "taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", + "state": "paused", + "changed": false + } + ], + "failed": [] + } + }, + "assertions": [ + "a task already in the target state is reported with changed false, not as a failure", + "an unknown id lands in failed[] with -32010 and never fails the whole call", + "event.task.state is emitted for every entry whose changed is true" + ] +} diff --git a/contracts/fixtures/download.probe.json b/contracts/fixtures/download.probe.json new file mode 100644 index 0000000..da82ce6 --- /dev/null +++ b/contracts/fixtures/download.probe.json @@ -0,0 +1,38 @@ +{ + "name": "download.probe \u2014 a resumable ISO on a well-behaved server", + "description": "Populates the File Info dialog. Establishes resumability from Accept-Ranges plus a validator.", + "request": { + "jsonrpc": "2.0", + "id": 10, + "method": "download.probe", + "params": { + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "headers": { + "User-Agent": "Velox/0.1" + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 10, + "result": { + "filename": "ubuntu-26.04-desktop-amd64.iso", + "sizeBytes": 6228541440, + "mime": "application/octet-stream", + "resumable": true, + "effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "suggestedCategoryId": "programs", + "suggestedSaveDir": "/home/sami/Downloads/Programs", + "etag": "\"5cf1a2b3-1730f4000\"", + "lastModified": "Thu, 23 Apr 2026 10:14:52 GMT", + "acceptRanges": true, + "redirectChain": [], + "requiresAuth": false + } + }, + "assertions": [ + "no task is created by a probe", + "resumable is true only when Accept-Ranges: bytes AND a validator are both present", + "the dialog opens before this returns; the RPC loop is never blocked on the network" + ] +} diff --git a/contracts/fixtures/download.refreshUrl.json b/contracts/fixtures/download.refreshUrl.json new file mode 100644 index 0000000..ea3c5cf --- /dev/null +++ b/contracts/fixtures/download.refreshUrl.json @@ -0,0 +1,31 @@ +{ + "name": "download.refreshUrl \u2014 a signed URL expired mid-download", + "description": "IDM's Refresh Download Address. Points the task at a fresh link and keeps every byte already on disk.", + "request": { + "jsonrpc": "2.0", + "id": 26, + "method": "download.refreshUrl", + "params": { + "taskId": "$taskId", + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso?token=eyJhbGciOiJIUzI1NiJ9.fresh", + "headers": { + "Referer": "https://releases.ubuntu.com/26.04/" + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 26, + "result": { + "ok": true, + "resumable": true, + "contentChanged": false, + "sizeBytes": 6228541440, + "effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso?token=eyJhbGciOiJIUzI1NiJ9.fresh" + } + }, + "assertions": [ + "size and validator are compared against what was recorded before resuming", + "contentChanged true must make the GUI ask before restarting; bytes are never discarded silently" + ] +} diff --git a/contracts/fixtures/download.remove.json b/contracts/fixtures/download.remove.json new file mode 100644 index 0000000..67ca991 --- /dev/null +++ b/contracts/fixtures/download.remove.json @@ -0,0 +1,32 @@ +{ + "name": "download.remove \u2014 drop two tasks and delete their bytes", + "description": "The only method that destroys user data, and the reason it is refused over the WebSocket transport.", + "request": { + "jsonrpc": "2.0", + "id": 24, + "method": "download.remove", + "params": { + "taskIds": [ + "$taskId", + "$taskId2" + ], + "deleteFile": true + } + }, + "response": { + "jsonrpc": "2.0", + "id": 24, + "result": { + "removed": [ + "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f" + ], + "failed": [] + } + }, + "assertions": [ + "the .veloxpart and .veloxpart.meta pair is always removed", + "the finished file is removed only when deleteFile is true", + "this call is -32003 over the WebSocket transport" + ] +} diff --git a/contracts/fixtures/download.resume.json b/contracts/fixtures/download.resume.json new file mode 100644 index 0000000..fd77731 --- /dev/null +++ b/contracts/fixtures/download.resume.json @@ -0,0 +1,39 @@ +{ + "name": "download.resume \u2014 applied to a two-task selection", + "description": "Revalidated with If-Range against the stored validator before a single byte is appended.", + "request": { + "jsonrpc": "2.0", + "id": 22, + "method": "download.resume", + "params": { + "taskIds": [ + "$taskId", + "$taskId2" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 22, + "result": { + "updated": [ + { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "state": "connecting", + "changed": true + }, + { + "taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", + "state": "connecting", + "changed": false + } + ], + "failed": [] + } + }, + "assertions": [ + "a task already in the target state is reported with changed false, not as a failure", + "an unknown id lands in failed[] with -32010 and never fails the whole call", + "event.task.state is emitted for every entry whose changed is true" + ] +} diff --git a/contracts/fixtures/download.start.json b/contracts/fixtures/download.start.json new file mode 100644 index 0000000..e7611d5 --- /dev/null +++ b/contracts/fixtures/download.start.json @@ -0,0 +1,39 @@ +{ + "name": "download.start \u2014 applied to a two-task selection", + "description": "A queued task jumps its queue; one already downloading is a no-op reported as changed false.", + "request": { + "jsonrpc": "2.0", + "id": 20, + "method": "download.start", + "params": { + "taskIds": [ + "$taskId", + "$taskId2" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 20, + "result": { + "updated": [ + { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "state": "connecting", + "changed": true + }, + { + "taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", + "state": "connecting", + "changed": false + } + ], + "failed": [] + } + }, + "assertions": [ + "a task already in the target state is reported with changed false, not as a failure", + "an unknown id lands in failed[] with -32010 and never fails the whole call", + "event.task.state is emitted for every entry whose changed is true" + ] +} diff --git a/contracts/fixtures/download.update.json b/contracts/fixtures/download.update.json new file mode 100644 index 0000000..0f703c8 --- /dev/null +++ b/contracts/fixtures/download.update.json @@ -0,0 +1,48 @@ +{ + "name": "download.update \u2014 refile a task into another category", + "description": "Moving saveDir moves the file on disk in the same operation, which is what makes a drag onto the category tree one RPC.", + "request": { + "jsonrpc": "2.0", + "id": 25, + "method": "download.update", + "params": { + "taskId": "$taskId", + "patch": { + "categoryId": "video", + "saveDir": "/home/sami/Downloads/Video", + "description": "Ubuntu 26.04 desktop image" + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 25, + "result": { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "filename": "ubuntu-26.04-desktop-amd64.iso", + "saveDir": "/home/sami/Downloads/Video", + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "sizeBytes": 6228541440, + "downloadedBytes": 2941190144, + "state": "downloading", + "speedBps": 29796556, + "etaSeconds": 110, + "resumable": true, + "segments": 8, + "categoryId": "video", + "queueId": null, + "queuePosition": null, + "description": "Ubuntu 26.04 desktop image", + "createdAt": "$isoDate", + "lastTryAt": "$isoDate", + "completedAt": null, + "error": null + } + }, + "assertions": [ + "the file and its .veloxpart.meta move together, or neither moves", + "the new saveDir is canonicalized and checked against the allowed roots first", + "a running task is not re-segmented underneath the user" + ] +} diff --git a/contracts/fixtures/errors/capture.offer.ignore.json b/contracts/fixtures/errors/capture.offer.ignore.json new file mode 100644 index 0000000..0a0cd5f --- /dev/null +++ b/contracts/fixtures/errors/capture.offer.ignore.json @@ -0,0 +1,32 @@ +{ + "name": "capture.offer \u2014 a monitored type below the minimum size is declined", + "description": "Not an error: a normal 'no'. The daemon answers well inside the deadline and the extension lets Firefox handle it. reason is what the popup's diagnostics show.", + "request": { + "jsonrpc": "2.0", + "id": 111, + "method": "capture.offer", + "params": { + "url": "https://example.org/thumb.zip", + "method": "GET", + "tabUrl": "https://example.org/gallery", + "contentType": "application/zip", + "contentLength": 4096, + "filename": "thumb.zip", + "origin": "moz-extension://11111111-2222-3333-4444-555555555555" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 111, + "result": { + "action": "ignore", + "taskId": null, + "reason": "below_min_size" + } + }, + "assertions": [ + "action ignore means the extension returns {} and Firefox downloads normally", + "the answer still arrives within 750 ms", + "reason is set on every ignore so a puzzled user can find out why" + ] +} diff --git a/contracts/fixtures/errors/capture.offer.timeout.json b/contracts/fixtures/errors/capture.offer.timeout.json new file mode 100644 index 0000000..82ec9ad --- /dev/null +++ b/contracts/fixtures/errors/capture.offer.timeout.json @@ -0,0 +1,29 @@ +{ + "name": "capture.offer \u2014 the daemon does not answer within 750 ms", + "description": "The most important test in this directory. The daemon is slow, wedged or gone; the extension must abandon the offer and let Firefox download the file normally. A download manager that eats downloads when its daemon is down is worse than no download manager, and this behaviour is non-negotiable.", + "kind": "timeout", + "deadlineMs": 750, + "request": { + "jsonrpc": "2.0", + "id": 110, + "method": "capture.offer", + "params": { + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "method": "GET", + "tabUrl": "https://releases.ubuntu.com/26.04/", + "contentType": "application/octet-stream", + "contentLength": 6228541440, + "origin": "moz-extension://11111111-2222-3333-4444-555555555555" + } + }, + "response": null, + "assertions": [ + "the extension gives up at 750 ms measured from send, not from connect", + "webRequest returns {} so Firefox downloads the file itself", + "no task is created, and the user sees the download in Firefox's own list", + "the extension does not retry: a retry would race the browser's own download", + "the same behaviour applies when the transport is disconnected entirely", + "reproduced against mockd with --slow 2000, and by killing the daemon outright" + ], + "requires": "a daemon that is slow, wedged or absent" +} diff --git a/contracts/fixtures/errors/download.add.disk-full.json b/contracts/fixtures/errors/download.add.disk-full.json new file mode 100644 index 0000000..7480ed3 --- /dev/null +++ b/contracts/fixtures/errors/download.add.disk-full.json @@ -0,0 +1,28 @@ +{ + "name": "download.add \u2014 not enough space to preallocate", + "description": "Preallocating at the final size means the failure happens now, on add, rather than at 97 % after an hour.", + "request": { + "jsonrpc": "2.0", + "id": 106, + "method": "download.add", + "params": { + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "saveDir": "/home/sami/Downloads/Programs", + "startMode": "now" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 106, + "error": { + "code": -32012, + "message": "not enough free space to preallocate 5.8 GB" + } + }, + "assertions": [ + "the check is against the actual filesystem holding saveDir, not the home directory", + "the partially created .veloxpart is removed before this error is returned", + "not replayable against a mock: exercised in tests/integration on a small tmpfs" + ], + "requires": "a filesystem with no free space" +} diff --git a/contracts/fixtures/errors/download.add.invalid-params.json b/contracts/fixtures/errors/download.add.invalid-params.json new file mode 100644 index 0000000..4bd417f --- /dev/null +++ b/contracts/fixtures/errors/download.add.invalid-params.json @@ -0,0 +1,29 @@ +{ + "name": "download.add \u2014 segments above the contract's maximum", + "description": "Params are validated against the schema before any handler runs, so a handler never sees an out-of-range value.", + "request": { + "jsonrpc": "2.0", + "id": 108, + "method": "download.add", + "params": { + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "segments": 64, + "startMode": "now" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 108, + "error": { + "code": -32602, + "message": "params/segments: value is above the maximum of 32", + "data": { + "path": "params/segments" + } + } + }, + "assertions": [ + "data.path is a JSON Pointer at the offending field", + "validation happens before the handler, on both transports" + ] +} diff --git a/contracts/fixtures/errors/download.add.invalid-path.json b/contracts/fixtures/errors/download.add.invalid-path.json new file mode 100644 index 0000000..66b263f --- /dev/null +++ b/contracts/fixtures/errors/download.add.invalid-path.json @@ -0,0 +1,30 @@ +{ + "name": "download.add \u2014 a destination that escapes the allowed roots", + "description": "Paths are canonicalized before the check, so ../ traversal and symlinks cannot smuggle a write outside saveTo.allowedRoots.", + "request": { + "jsonrpc": "2.0", + "id": 105, + "method": "download.add", + "params": { + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "saveDir": "/home/sami/Downloads/../../etc", + "startMode": "now" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 105, + "error": { + "code": -32011, + "message": "destination is outside the allowed roots", + "data": { + "path": "/home/sami/Downloads/../../etc" + } + } + }, + "assertions": [ + "the path is canonicalized first: the check is on the resolved path, never the literal string", + "no file, no .veloxpart and no database row is created", + "a symlink whose target escapes the roots is refused the same way" + ] +} diff --git a/contracts/fixtures/errors/download.get.not-found.json b/contracts/fixtures/errors/download.get.not-found.json new file mode 100644 index 0000000..35703ab --- /dev/null +++ b/contracts/fixtures/errors/download.get.not-found.json @@ -0,0 +1,27 @@ +{ + "name": "download.get \u2014 an unknown task id", + "description": "The ordinary stale-client case: the GUI asks about a row another client has since removed.", + "request": { + "jsonrpc": "2.0", + "id": 104, + "method": "download.get", + "params": { + "taskId": "00000000-0000-4000-8000-000000000000" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 104, + "error": { + "code": -32010, + "message": "no such task", + "data": { + "taskId": "00000000-0000-4000-8000-000000000000" + } + } + }, + "assertions": [ + "a bulk method reports this per id in failed[] instead of failing the whole call", + "the client's correct response is to drop the row, not to retry" + ] +} diff --git a/contracts/fixtures/errors/download.probe.probe-failed.json b/contracts/fixtures/errors/download.probe.probe-failed.json new file mode 100644 index 0000000..bd1911b --- /dev/null +++ b/contracts/fixtures/errors/download.probe.probe-failed.json @@ -0,0 +1,30 @@ +{ + "name": "download.probe \u2014 the server answered 403", + "description": "data.httpStatus is what lets the GUI say 'the link has expired' instead of 'probe failed'.", + "request": { + "jsonrpc": "2.0", + "id": 107, + "method": "download.probe", + "params": { + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso?token=expired" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 107, + "error": { + "code": -32013, + "message": "probe failed: HTTP 403", + "data": { + "httpStatus": 403 + } + } + }, + "assertions": [ + "data.httpStatus is present whenever there was an HTTP response at all", + "a DNS or connection failure returns -32013 with httpStatus null", + "no task is created by a failed probe", + "not replayable against a mock: exercised in tests/integration against tools/testserver" + ], + "requires": "an origin server that answers 403" +} diff --git a/contracts/fixtures/errors/method-not-found.json b/contracts/fixtures/errors/method-not-found.json new file mode 100644 index 0000000..b658453 --- /dev/null +++ b/contracts/fixtures/errors/method-not-found.json @@ -0,0 +1,22 @@ +{ + "name": "an unknown method name", + "description": "Version skew and local port-scanning both look like this. Neither gets a useful reply.", + "request": { + "jsonrpc": "2.0", + "id": 109, + "method": "download.deleteEverything", + "params": {} + }, + "response": { + "jsonrpc": "2.0", + "id": 109, + "error": { + "code": -32601, + "message": "no such method" + } + }, + "assertions": [ + "the reply does not enumerate valid methods", + "an unknown method never closes the connection: a newer client may simply be probing for a capability" + ] +} diff --git a/contracts/fixtures/errors/session.hello.not-paired.json b/contracts/fixtures/errors/session.hello.not-paired.json new file mode 100644 index 0000000..563c468 --- /dev/null +++ b/contracts/fixtures/errors/session.hello.not-paired.json @@ -0,0 +1,29 @@ +{ + "name": "session.hello \u2014 an unpaired WebSocket client is refused", + "description": "The WS transport is reachable by any local process, so a token is mandatory there. The Unix socket needs none: SO_PEERCRED already proved same-UID.", + "transport": "ws", + "request": { + "jsonrpc": "2.0", + "id": 101, + "method": "session.hello", + "params": { + "clientType": "extension", + "clientName": "Velox for Firefox", + "protocolVersion": "1.0.0", + "token": "not-a-real-token" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 101, + "error": { + "code": -32002, + "message": "not paired: call session.pair first" + } + }, + "assertions": [ + "only session.pair is served on an unauthenticated WebSocket connection", + "the reply does not reveal whether the token was absent, malformed or merely wrong", + "this failure counts towards the pairing rate limit" + ] +} diff --git a/contracts/fixtures/errors/session.hello.version-mismatch.json b/contracts/fixtures/errors/session.hello.version-mismatch.json new file mode 100644 index 0000000..1b0cc6f --- /dev/null +++ b/contracts/fixtures/errors/session.hello.version-mismatch.json @@ -0,0 +1,33 @@ +{ + "name": "session.hello \u2014 a client built against protocol 2.x is refused", + "description": "Major mismatch fails loudly at connect rather than subtly at the tenth field. The GUI renders this as 'Velox needs updating'.", + "request": { + "jsonrpc": "2.0", + "id": 100, + "method": "session.hello", + "params": { + "clientType": "gui", + "clientName": "velox-gui 9.9.9", + "protocolVersion": "2.0.0" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 100, + "error": { + "code": -32001, + "message": "protocol major version mismatch: daemon speaks 1.x, client speaks 2.x", + "data": { + "expected": "1.0.0", + "actual": "2.0.0" + } + } + }, + "assertions": [ + "the connection is closed after this reply; no method is served on a mismatched major", + "a differing minor or patch is accepted, never refused", + "the message is safe to show a user verbatim", + "the version check is transport-independent; this is replayed on the Unix socket so it is not masked by -32002" + ], + "transport": "uds" +} diff --git a/contracts/fixtures/errors/session.pair.rate-limited.json b/contracts/fixtures/errors/session.pair.rate-limited.json new file mode 100644 index 0000000..7d1deef --- /dev/null +++ b/contracts/fixtures/errors/session.pair.rate-limited.json @@ -0,0 +1,33 @@ +{ + "name": "session.pair \u2014 the sixth failed attempt in a minute is locked out", + "description": "Rate limiting is what stops another local process brute-forcing its way to a token.", + "transport": "ws", + "request": { + "jsonrpc": "2.0", + "id": 102, + "method": "session.pair", + "params": { + "clientName": "Velox for Firefox", + "extensionId": "11111111-2222-3333-4444-555555555555", + "code": "0000" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 102, + "error": { + "code": -32014, + "message": "too many pairing attempts; try again later", + "data": { + "retryAfterSec": 60 + } + } + }, + "assertions": [ + "five failures per minute, then a 60 s lockout", + "the lockout is per-origin and survives a reconnect, or it is not a lockout", + "no user prompt is shown while locked out \u2014 the prompt itself is the thing being flooded", + "the runner does not brute-force a live daemon; mockd reproduces it under --lockout" + ], + "requires": "six failed pairing attempts inside one minute" +} diff --git a/contracts/fixtures/errors/settings.set.transport-forbidden.json b/contracts/fixtures/errors/settings.set.transport-forbidden.json new file mode 100644 index 0000000..bd0f8fe --- /dev/null +++ b/contracts/fixtures/errors/settings.set.transport-forbidden.json @@ -0,0 +1,30 @@ +{ + "name": "settings.set \u2014 a privileged method called over the WebSocket transport", + "description": "The extension may request a download; it may not reconfigure the daemon. Letting it write saveTo.allowedRoots would defeat every path check in the project.", + "transport": "ws", + "request": { + "jsonrpc": "2.0", + "id": 103, + "method": "settings.set", + "params": { + "values": { + "saveTo.allowedRoots": [ + "/" + ] + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 103, + "error": { + "code": -32003, + "message": "method is not permitted on this transport" + } + }, + "assertions": [ + "the check happens before params are even parsed", + "every method with x-privileged true behaves identically here", + "nothing is written and no event is emitted" + ] +} diff --git a/contracts/fixtures/events/event.auth.required.json b/contracts/fixtures/events/event.auth.required.json new file mode 100644 index 0000000..29d5863 --- /dev/null +++ b/contracts/fixtures/events/event.auth.required.json @@ -0,0 +1,18 @@ +{ + "name": "event.auth.required \u2014 a server wants Basic credentials", + "description": "The task waits in retry_wait until the client supplies them. Credentials go to the Secret Service, never back through this event and never into a log.", + "notification": { + "jsonrpc": "2.0", + "method": "event.auth.required", + "params": { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "host": "files.example.org", + "realm": "Restricted", + "scheme": "basic" + } + }, + "assertions": [ + "no credential material appears in this payload", + "the task sits in retry_wait rather than failing outright" + ] +} diff --git a/contracts/fixtures/events/event.grabber.progress.json b/contracts/fixtures/events/event.grabber.progress.json new file mode 100644 index 0000000..bde80c7 --- /dev/null +++ b/contracts/fixtures/events/event.grabber.progress.json @@ -0,0 +1,19 @@ +{ + "name": "event.grabber.progress \u2014 a crawl in flight", + "description": "done true means the file list in grabber.status is final.", + "notification": { + "jsonrpc": "2.0", + "method": "event.grabber.progress", + "params": { + "jobId": "grab-7f21", + "found": 3, + "crawled": 12, + "done": false, + "currentUrl": "https://releases.ubuntu.com/26.04/" + } + }, + "assertions": [ + "emitted at no more than 4 Hz", + "the wizard shows found and crawled separately: they diverge on a deep crawl" + ] +} diff --git a/contracts/fixtures/events/event.notify.json b/contracts/fixtures/events/event.notify.json new file mode 100644 index 0000000..93145a7 --- /dev/null +++ b/contracts/fixtures/events/event.notify.json @@ -0,0 +1,19 @@ +{ + "name": "event.notify \u2014 a download finished", + "description": "The client decides between a toast, a tray balloon and a sound; the daemon does not assume a GUI is running.", + "notification": { + "jsonrpc": "2.0", + "method": "event.notify", + "params": { + "level": "success", + "title": "Download complete", + "body": "ubuntu-26.04-desktop-amd64.iso (5.8 GB) finished in 3 m 28 s.", + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "sound": "complete" + } + }, + "assertions": [ + "the daemon never assumes a GUI is running to see this", + "sound names an event, not a file path \u2014 the client owns its sound set" + ] +} diff --git a/contracts/fixtures/events/event.settings.changed.json b/contracts/fixtures/events/event.settings.changed.json new file mode 100644 index 0000000..eef3fc7 --- /dev/null +++ b/contracts/fixtures/events/event.settings.changed.json @@ -0,0 +1,18 @@ +{ + "name": "event.settings.changed \u2014 capture policy was edited", + "description": "Carries only key names. The extension watches for capture.* here and re-fetches capture.getRules so its mirror never lags the daemon.", + "notification": { + "jsonrpc": "2.0", + "method": "event.settings.changed", + "params": { + "keys": [ + "capture.monitoredExtensions", + "capture.minSizeBytes" + ] + } + }, + "assertions": [ + "a client re-reads only the keys it cares about", + "the extension treats any capture.* key as a signal to call capture.getRules" + ] +} diff --git a/contracts/fixtures/events/event.speed.global.json b/contracts/fixtures/events/event.speed.global.json new file mode 100644 index 0000000..818a470 --- /dev/null +++ b/contracts/fixtures/events/event.speed.global.json @@ -0,0 +1,18 @@ +{ + "name": "event.speed.global \u2014 the status bar's 1 Hz tick", + "description": "Emitted even when nothing is active, so a client can tell 'idle' from 'disconnected'.", + "notification": { + "jsonrpc": "2.0", + "method": "event.speed.global", + "params": { + "downBps": 29796556, + "activeCount": 1, + "queuedCount": 2, + "limitBps": null + } + }, + "assertions": [ + "emitted at 1 Hz whether or not anything is downloading", + "limitBps null means the limiter is off" + ] +} diff --git a/contracts/fixtures/events/event.task.added.json b/contracts/fixtures/events/event.task.added.json new file mode 100644 index 0000000..6ecd58c --- /dev/null +++ b/contracts/fixtures/events/event.task.added.json @@ -0,0 +1,37 @@ +{ + "name": "event.task.added \u2014 a new row appears", + "description": "summary is always present so a client can insert the row without a follow-up download.get.", + "notification": { + "jsonrpc": "2.0", + "method": "event.task.added", + "params": { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "summary": { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "filename": "ubuntu-26.04-desktop-amd64.iso", + "saveDir": "/home/sami/Downloads/Programs", + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "sizeBytes": 6228541440, + "downloadedBytes": 0, + "state": "queued", + "speedBps": 0, + "etaSeconds": null, + "resumable": true, + "segments": 8, + "categoryId": "programs", + "queueId": null, + "queuePosition": null, + "description": null, + "createdAt": "$isoDate", + "lastTryAt": null, + "completedAt": null, + "error": null + } + } + }, + "assertions": [ + "every subscriber sees this, including the client that made the download.add call", + "the row can be drawn from this payload alone" + ] +} diff --git a/contracts/fixtures/events/event.task.progress.json b/contracts/fixtures/events/event.task.progress.json new file mode 100644 index 0000000..b2e966b --- /dev/null +++ b/contracts/fixtures/events/event.task.progress.json @@ -0,0 +1,43 @@ +{ + "name": "event.task.progress \u2014 one batched tick for two tasks", + "description": "A single array at no more than 4 Hz, never one notification per task. At twenty active downloads that is four messages a second instead of eighty.", + "notification": { + "jsonrpc": "2.0", + "method": "event.task.progress", + "params": { + "tasks": [ + { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "downloadedBytes": 2941190144, + "speedBps": 29796556, + "etaSeconds": 110, + "segments": [ + { + "index": 0, + "downloadedBytes": 402653184, + "speedBps": 4089446 + }, + { + "index": 1, + "downloadedBytes": 356515840, + "speedBps": 3565158 + } + ] + }, + { + "taskId": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", + "downloadedBytes": 402653184, + "speedBps": 0, + "etaSeconds": null, + "segments": [] + } + ], + "at": "$isoDate" + } + }, + "assertions": [ + "emitted at no more than 4 Hz regardless of how many tasks are active", + "clients apply a row patch; rebuilding the model on this event is a bug", + "a task with no segment detail still reports its byte counter" + ] +} diff --git a/contracts/fixtures/events/event.task.removed.json b/contracts/fixtures/events/event.task.removed.json new file mode 100644 index 0000000..fb6d614 --- /dev/null +++ b/contracts/fixtures/events/event.task.removed.json @@ -0,0 +1,15 @@ +{ + "name": "event.task.removed \u2014 a row disappears", + "description": "There is nothing further to fetch: the client deletes the row.", + "notification": { + "jsonrpc": "2.0", + "method": "event.task.removed", + "params": { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "deletedFile": true + } + }, + "assertions": [ + "deletedFile tells the GUI whether to offer an undo that is still meaningful" + ] +} diff --git a/contracts/fixtures/events/event.task.state.json b/contracts/fixtures/events/event.task.state.json new file mode 100644 index 0000000..af969cc --- /dev/null +++ b/contracts/fixtures/events/event.task.state.json @@ -0,0 +1,57 @@ +{ + "name": "event.task.state \u2014 a task fails because the file vanished from the server", + "description": "Carries the summary so the row repaints in full, and the error whenever the new state is failed or retry_wait. Note that error.code is a TaskErrorCode string, not a JSON-RPC code: the RPC that reported this failure succeeded.", + "notification": { + "jsonrpc": "2.0", + "method": "event.task.state", + "params": { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "state": "failed", + "previousState": "downloading", + "summary": { + "taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "filename": "ubuntu-26.04-desktop-amd64.iso", + "saveDir": "/home/sami/Downloads/Programs", + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "effectiveUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "sizeBytes": 6228541440, + "downloadedBytes": 0, + "state": "failed", + "speedBps": 0, + "etaSeconds": null, + "resumable": true, + "segments": 8, + "categoryId": "programs", + "queueId": null, + "queuePosition": null, + "description": null, + "createdAt": "$isoDate", + "lastTryAt": "$isoDate", + "completedAt": null, + "error": { + "code": "not_found", + "message": "the server returned 404 when resuming; the file is gone", + "httpStatus": 404, + "retryable": false, + "cause": null, + "attempt": 3, + "nextRetryAt": null + } + }, + "error": { + "code": "not_found", + "message": "the server returned 404 when resuming; the file is gone", + "httpStatus": 404, + "retryable": false, + "cause": null, + "attempt": 3, + "nextRetryAt": null + } + } + }, + "assertions": [ + "error is present exactly when state is failed or retry_wait", + "error.code is a TaskErrorCode, never a JSON-RPC ErrorCode \u2014 the two are different spaces", + "retryable false means the scheduler will not pick this up again on its own" + ] +} diff --git a/contracts/fixtures/grabber.harvest.json b/contracts/fixtures/grabber.harvest.json new file mode 100644 index 0000000..f4cf080 --- /dev/null +++ b/contracts/fixtures/grabber.harvest.json @@ -0,0 +1,37 @@ +{ + "name": "grabber.harvest \u2014 download the two selected ISOs", + "description": "The only grabber call that creates tasks, and it names exactly what the user ticked.", + "request": { + "jsonrpc": "2.0", + "id": 72, + "method": "grabber.harvest", + "params": { + "jobId": "grab-7f21", + "select": [ + "f1", + "f2" + ], + "defaults": { + "url": "https://releases.ubuntu.com/26.04/", + "categoryId": "programs", + "startMode": "queue", + "queueId": "main" + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 72, + "result": { + "taskIds": [ + "$uuid", + "$uuid" + ], + "failed": [] + } + }, + "assertions": [ + "only the selected fileIds become tasks", + "a fileId that is not in the job is reported in failed[], never silently skipped" + ] +} diff --git a/contracts/fixtures/grabber.start.json b/contracts/fixtures/grabber.start.json new file mode 100644 index 0000000..f8b8cf9 --- /dev/null +++ b/contracts/fixtures/grabber.start.json @@ -0,0 +1,36 @@ +{ + "name": "grabber.start \u2014 crawl a release directory two levels deep", + "description": "Nothing is downloaded by this call. It walks pages and collects candidates for the wizard to show.", + "request": { + "jsonrpc": "2.0", + "id": 70, + "method": "grabber.start", + "params": { + "startUrl": "https://releases.ubuntu.com/26.04/", + "depth": 2, + "includePatterns": [ + "*/26.04/*" + ], + "excludePatterns": [ + "*/torrent/*" + ], + "fileTypes": [ + "iso", + "zsync" + ], + "sameHostOnly": true, + "maxFiles": 200 + } + }, + "response": { + "jsonrpc": "2.0", + "id": 70, + "result": { + "jobId": "grab-7f21" + } + }, + "assertions": [ + "a crawl never starts a download on its own", + "sameHostOnly true keeps the crawl off third-party hosts linked from the page" + ] +} diff --git a/contracts/fixtures/grabber.status.json b/contracts/fixtures/grabber.status.json new file mode 100644 index 0000000..5ed1c93 --- /dev/null +++ b/contracts/fixtures/grabber.status.json @@ -0,0 +1,56 @@ +{ + "name": "grabber.status \u2014 crawl finished with three candidates", + "description": "Also delivered as event.grabber.progress; the poll exists so a reopened wizard can catch up.", + "request": { + "jsonrpc": "2.0", + "id": 71, + "method": "grabber.status", + "params": { + "jobId": "grab-7f21" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 71, + "result": { + "jobId": "grab-7f21", + "state": "done", + "crawled": 12, + "found": 3, + "files": [ + { + "fileId": "f1", + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", + "filename": "ubuntu-26.04-desktop-amd64.iso", + "sizeBytes": 6228541440, + "contentType": "application/octet-stream", + "depth": 1, + "foundOn": "https://releases.ubuntu.com/26.04/" + }, + { + "fileId": "f2", + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-live-server-amd64.iso", + "filename": "ubuntu-26.04-live-server-amd64.iso", + "sizeBytes": 2617245696, + "contentType": "application/octet-stream", + "depth": 1, + "foundOn": "https://releases.ubuntu.com/26.04/" + }, + { + "fileId": "f3", + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso.zsync", + "filename": "ubuntu-26.04-desktop-amd64.iso.zsync", + "sizeBytes": 12189696, + "contentType": "application/octet-stream", + "depth": 1, + "foundOn": "https://releases.ubuntu.com/26.04/" + } + ], + "error": null + } + }, + "assertions": [ + "state done means the file list is final", + "sizeBytes is null where the server refused a HEAD, and the wizard must cope with that" + ] +} diff --git a/contracts/fixtures/limiter.get.json b/contracts/fixtures/limiter.get.json new file mode 100644 index 0000000..e618c9c --- /dev/null +++ b/contracts/fixtures/limiter.get.json @@ -0,0 +1,22 @@ +{ + "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.", + "request": { + "jsonrpc": "2.0", + "id": 52, + "method": "limiter.get", + "params": {} + }, + "response": { + "jsonrpc": "2.0", + "id": 52, + "result": { + "enabled": false, + "globalBps": 2097152, + "applyToRunning": false + } + }, + "assertions": [ + "enabled false means no throttling regardless of globalBps" + ] +} diff --git a/contracts/fixtures/limiter.set.json b/contracts/fixtures/limiter.set.json new file mode 100644 index 0000000..f90f5d9 --- /dev/null +++ b/contracts/fixtures/limiter.set.json @@ -0,0 +1,27 @@ +{ + "name": "limiter.set \u2014 cap at 2 MiB/s and retune what is already running", + "description": "applyToRunning true is the Speed Limiter window's 'apply now' button.", + "request": { + "jsonrpc": "2.0", + "id": 53, + "method": "limiter.set", + "params": { + "enabled": true, + "globalBps": 2097152, + "applyToRunning": true + } + }, + "response": { + "jsonrpc": "2.0", + "id": 53, + "result": { + "enabled": true, + "globalBps": 2097152, + "applyToRunning": true + } + }, + "assertions": [ + "the limit is global across every active task, not per task", + "with applyToRunning true, transfers already in flight are retuned rather than waiting for the next task" + ] +} diff --git a/contracts/fixtures/media.addVariant.json b/contracts/fixtures/media.addVariant.json new file mode 100644 index 0000000..ad7aba3 --- /dev/null +++ b/contracts/fixtures/media.addVariant.json @@ -0,0 +1,34 @@ +{ + "name": "media.addVariant \u2014 download 1080p with the English audio muxed in", + "description": "The result is an ordinary task that appears in the list like any other download.", + "request": { + "jsonrpc": "2.0", + "id": 62, + "method": "media.addVariant", + "params": { + "manifestUrl": "https://cdn.example.org/v/master.m3u8", + "variantId": "v-1080p", + "audioVariantId": "a-en", + "spec": { + "url": "https://cdn.example.org/v/master.m3u8", + "filename": "episode-42.mkv", + "categoryId": "video", + "startMode": "now" + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 62, + "result": { + "taskId": "$uuid", + "state": "connecting", + "estimatedBytes": 1923000000 + } + }, + "assertions": [ + "spec.url is ignored: the manifest and variant determine the source", + "a DRM-protected variant is refused with -32602 rather than started and failed later", + "segments are fetched in parallel and muxed with ffmpeg into one container" + ] +} diff --git a/contracts/fixtures/media.listVariants.json b/contracts/fixtures/media.listVariants.json new file mode 100644 index 0000000..447ad88 --- /dev/null +++ b/contracts/fixtures/media.listVariants.json @@ -0,0 +1,67 @@ +{ + "name": "media.listVariants \u2014 an HLS master playlist", + "description": "The daemon parses the manifest; the extension never does. Keeping that logic in one language is the whole point.", + "request": { + "jsonrpc": "2.0", + "id": 61, + "method": "media.listVariants", + "params": { + "manifestUrl": "https://cdn.example.org/v/master.m3u8", + "headers": { + "Referer": "https://example.org/watch/42" + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 61, + "result": { + "variants": [ + { + "variantId": "v-1080p", + "kind": "video", + "resolution": "1920x1080", + "bitrateBps": 5000000, + "codec": "avc1.640028", + "container": "ts", + "frameRate": 25.0, + "language": null, + "sizeEstimate": 1875000000, + "drm": false + }, + { + "variantId": "v-720p", + "kind": "video", + "resolution": "1280x720", + "bitrateBps": 2800000, + "codec": "avc1.4d401f", + "container": "ts", + "frameRate": 25.0, + "language": null, + "sizeEstimate": 1050000000, + "drm": false + }, + { + "variantId": "a-en", + "kind": "audio", + "resolution": null, + "bitrateBps": 128000, + "codec": "mp4a.40.2", + "container": "ts", + "frameRate": null, + "language": "en", + "sizeEstimate": 48000000, + "drm": false + } + ], + "manifestType": "hls", + "durationSec": 3000.0, + "title": "Episode 42", + "drmProtected": false + } + }, + "assertions": [ + "sizeEstimate is bitrate x duration and must be labelled as approximate in the UI", + "a DRM-protected variant is reported with drm true and greyed out, never attempted" + ] +} diff --git a/contracts/fixtures/queue.list.json b/contracts/fixtures/queue.list.json new file mode 100644 index 0000000..617401a --- /dev/null +++ b/contracts/fixtures/queue.list.json @@ -0,0 +1,55 @@ +{ + "name": "queue.list \u2014 two queues, one running", + "description": "Not privileged: the extension's Add to Queue picker needs it.", + "request": { + "jsonrpc": "2.0", + "id": 33, + "method": "queue.list", + "params": {} + }, + "response": { + "jsonrpc": "2.0", + "id": 33, + "result": { + "items": [ + { + "queueId": "main", + "name": "Main Queue", + "state": "running", + "maxConcurrent": 3, + "taskIds": [ + "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f" + ], + "schedule": null, + "onComplete": "nothing" + }, + { + "queueId": "sync", + "name": "Sync Queue", + "state": "stopped", + "maxConcurrent": 1, + "taskIds": [], + "schedule": { + "enabled": true, + "mode": "periodic", + "startTime": "02:00", + "stopTime": "06:00", + "daysOfWeek": [ + 1, + 2, + 3, + 4, + 5 + ], + "onceDate": null + }, + "onComplete": "nothing" + } + ] + } + }, + "assertions": [ + "taskIds are in run order, not insertion order" + ] +} diff --git a/contracts/fixtures/queue.reorder.json b/contracts/fixtures/queue.reorder.json new file mode 100644 index 0000000..8068c3c --- /dev/null +++ b/contracts/fixtures/queue.reorder.json @@ -0,0 +1,37 @@ +{ + "name": "queue.reorder \u2014 move the second task to the front", + "description": "taskIds must be a permutation of current membership; anything else is -32602 so a stale drag cannot reshuffle the queue.", + "request": { + "jsonrpc": "2.0", + "id": 37, + "method": "queue.reorder", + "params": { + "queueId": "main", + "taskIds": [ + "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", + "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 37, + "result": { + "queue": { + "queueId": "main", + "name": "Main Queue", + "state": "running", + "maxConcurrent": 3, + "taskIds": [ + "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", + "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071" + ], + "schedule": null, + "onComplete": "nothing" + } + } + }, + "assertions": [ + "a taskIds list that is not an exact permutation is rejected whole, never applied partially" + ] +} diff --git a/contracts/fixtures/queue.start.json b/contracts/fixtures/queue.start.json new file mode 100644 index 0000000..e6af91a --- /dev/null +++ b/contracts/fixtures/queue.start.json @@ -0,0 +1,38 @@ +{ + "name": "queue.start \u2014 admit up to maxConcurrent tasks", + "description": "The scheduler keeps maxConcurrent running until the queue drains or is stopped.", + "request": { + "jsonrpc": "2.0", + "id": 35, + "method": "queue.start", + "params": { + "queueId": "main" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 35, + "result": { + "queue": { + "queueId": "main", + "name": "Main Queue", + "state": "running", + "maxConcurrent": 3, + "taskIds": [ + "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f" + ], + "schedule": null, + "onComplete": "nothing" + }, + "startedTaskIds": [ + "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f" + ] + } + }, + "assertions": [ + "no more than maxConcurrent tasks from this queue are ever running at once", + "tasks are admitted in queue order, not by size or arrival" + ] +} diff --git a/contracts/fixtures/queue.stop.json b/contracts/fixtures/queue.stop.json new file mode 100644 index 0000000..62c1358 --- /dev/null +++ b/contracts/fixtures/queue.stop.json @@ -0,0 +1,38 @@ +{ + "name": "queue.stop \u2014 stop the queue and pause what is running", + "description": "pauseRunning false would let in-flight tasks finish: the difference between stopping a queue and stopping everything.", + "request": { + "jsonrpc": "2.0", + "id": 36, + "method": "queue.stop", + "params": { + "queueId": "main", + "pauseRunning": true + } + }, + "response": { + "jsonrpc": "2.0", + "id": 36, + "result": { + "queue": { + "queueId": "main", + "name": "Main Queue", + "state": "stopped", + "maxConcurrent": 3, + "taskIds": [ + "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f" + ], + "schedule": null, + "onComplete": "nothing" + }, + "pausedTaskIds": [ + "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f" + ] + } + }, + "assertions": [ + "with pauseRunning false, running tasks finish and only admission stops" + ] +} diff --git a/contracts/fixtures/queue.upsert.json b/contracts/fixtures/queue.upsert.json new file mode 100644 index 0000000..a08d0b5 --- /dev/null +++ b/contracts/fixtures/queue.upsert.json @@ -0,0 +1,65 @@ +{ + "name": "queue.upsert \u2014 set a queue's concurrency and overnight window", + "description": "taskIds in the payload is ignored; membership moves through download.update and queue.reorder so two clients editing at once cannot drop a task.", + "request": { + "jsonrpc": "2.0", + "id": 34, + "method": "queue.upsert", + "params": { + "queue": { + "queueId": "sync", + "name": "Sync Queue", + "state": "stopped", + "maxConcurrent": 2, + "taskIds": [], + "schedule": { + "enabled": true, + "mode": "periodic", + "startTime": "02:00", + "stopTime": "06:00", + "daysOfWeek": [ + 1, + 2, + 3, + 4, + 5 + ], + "onceDate": null + }, + "onComplete": "nothing" + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 34, + "result": { + "queue": { + "queueId": "sync", + "name": "Sync Queue", + "state": "stopped", + "maxConcurrent": 2, + "taskIds": [], + "schedule": { + "enabled": true, + "mode": "periodic", + "startTime": "02:00", + "stopTime": "06:00", + "daysOfWeek": [ + 1, + 2, + 3, + 4, + 5 + ], + "onceDate": null + }, + "onComplete": "nothing" + } + } + }, + "assertions": [ + "the taskIds sent by the client are ignored and the stored order is echoed back", + "schedule times are stored as local wall-clock and re-evaluated on a DST change" + ] +} diff --git a/contracts/fixtures/rules.list.json b/contracts/fixtures/rules.list.json new file mode 100644 index 0000000..88db56e --- /dev/null +++ b/contracts/fixtures/rules.list.json @@ -0,0 +1,69 @@ +{ + "name": "rules.list \u2014 the routing table in priority order", + "description": "Privileged: this is the daemon's policy. The extension gets its narrowed view from capture.getRules.", + "request": { + "jsonrpc": "2.0", + "id": 38, + "method": "rules.list", + "params": {} + }, + "response": { + "jsonrpc": "2.0", + "id": 38, + "result": { + "items": [ + { + "ruleId": "iso-to-programs", + "name": "Disk images", + "enabled": true, + "priority": 10, + "match": { + "extensions": [ + "iso", + "img" + ], + "mimeTypes": null, + "hostPattern": null, + "urlPattern": null, + "minSizeBytes": null, + "maxSizeBytes": null + }, + "action": { + "categoryId": "programs", + "saveDir": null, + "queueId": null, + "segments": 8, + "startMode": null, + "capture": null + } + }, + { + "ruleId": "never-intranet", + "name": "Never capture the intranet", + "enabled": true, + "priority": 20, + "match": { + "extensions": null, + "mimeTypes": null, + "hostPattern": "*.corp.internal", + "urlPattern": null, + "minSizeBytes": null, + "maxSizeBytes": null + }, + "action": { + "categoryId": null, + "saveDir": null, + "queueId": null, + "segments": null, + "startMode": null, + "capture": "ignore" + } + } + ] + } + }, + "assertions": [ + "items are ordered by priority ascending; first match wins", + "no rule matching means the default category, never an error" + ] +} diff --git a/contracts/fixtures/rules.upsert.json b/contracts/fixtures/rules.upsert.json new file mode 100644 index 0000000..3cb1fb1 --- /dev/null +++ b/contracts/fixtures/rules.upsert.json @@ -0,0 +1,97 @@ +{ + "name": "rules.upsert \u2014 add one rule and drop another atomically", + "description": "Applying adds and removes in one write means a reprioritisation never leaves the table half-valid.", + "request": { + "jsonrpc": "2.0", + "id": 39, + "method": "rules.upsert", + "params": { + "upsert": [ + { + "ruleId": "big-to-queue", + "name": "Queue anything over 1 GiB", + "enabled": true, + "priority": 5, + "match": { + "extensions": null, + "mimeTypes": null, + "hostPattern": null, + "urlPattern": null, + "minSizeBytes": 1073741824, + "maxSizeBytes": null + }, + "action": { + "categoryId": null, + "saveDir": null, + "queueId": "main", + "segments": null, + "startMode": "queue", + "capture": null + } + } + ], + "remove": [ + "never-intranet" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 39, + "result": { + "items": [ + { + "ruleId": "big-to-queue", + "name": "Queue anything over 1 GiB", + "enabled": true, + "priority": 5, + "match": { + "extensions": null, + "mimeTypes": null, + "hostPattern": null, + "urlPattern": null, + "minSizeBytes": 1073741824, + "maxSizeBytes": null + }, + "action": { + "categoryId": null, + "saveDir": null, + "queueId": "main", + "segments": null, + "startMode": "queue", + "capture": null + } + }, + { + "ruleId": "iso-to-programs", + "name": "Disk images", + "enabled": true, + "priority": 10, + "match": { + "extensions": [ + "iso", + "img" + ], + "mimeTypes": null, + "hostPattern": null, + "urlPattern": null, + "minSizeBytes": null, + "maxSizeBytes": null + }, + "action": { + "categoryId": "programs", + "saveDir": null, + "queueId": null, + "segments": 8, + "startMode": null, + "capture": null + } + } + ] + } + }, + "assertions": [ + "the whole table comes back in priority order so the caller need not re-list", + "adds and removes commit together or not at all" + ] +} diff --git a/contracts/fixtures/schedule.get.json b/contracts/fixtures/schedule.get.json new file mode 100644 index 0000000..a479e8e --- /dev/null +++ b/contracts/fixtures/schedule.get.json @@ -0,0 +1,44 @@ +{ + "name": "schedule.get \u2014 every queue's schedule", + "description": "queueId null asks for all of them. Backs the Scheduler window.", + "request": { + "jsonrpc": "2.0", + "id": 40, + "method": "schedule.get", + "params": { + "queueId": null + } + }, + "response": { + "jsonrpc": "2.0", + "id": 40, + "result": { + "items": [ + { + "queueId": "main", + "schedule": null + }, + { + "queueId": "sync", + "schedule": { + "enabled": true, + "mode": "periodic", + "startTime": "02:00", + "stopTime": "06:00", + "daysOfWeek": [ + 1, + 2, + 3, + 4, + 5 + ], + "onceDate": null + } + } + ] + } + }, + "assertions": [ + "a null schedule means the queue is under manual control" + ] +} diff --git a/contracts/fixtures/schedule.set.json b/contracts/fixtures/schedule.set.json new file mode 100644 index 0000000..ce3bf49 --- /dev/null +++ b/contracts/fixtures/schedule.set.json @@ -0,0 +1,52 @@ +{ + "name": "schedule.set \u2014 run the sync queue on weekday nights", + "description": "Times are local wall-clock and are re-evaluated on a DST change rather than resolved to absolute instants now.", + "request": { + "jsonrpc": "2.0", + "id": 41, + "method": "schedule.set", + "params": { + "queueId": "sync", + "schedule": { + "enabled": true, + "mode": "periodic", + "startTime": "02:00", + "stopTime": "06:00", + "daysOfWeek": [ + 1, + 2, + 3, + 4, + 5 + ], + "onceDate": null + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 41, + "result": { + "queueId": "sync", + "schedule": { + "enabled": true, + "mode": "periodic", + "startTime": "02:00", + "stopTime": "06:00", + "daysOfWeek": [ + 1, + 2, + 3, + 4, + 5 + ], + "onceDate": null + }, + "nextRunAt": "$isoDate" + } + }, + "assertions": [ + "nextRunAt is computed in local time and skips days not in daysOfWeek", + "a null schedule clears it and leaves the queue manual" + ] +} diff --git a/contracts/fixtures/session.hello.json b/contracts/fixtures/session.hello.json new file mode 100644 index 0000000..b38b5c9 --- /dev/null +++ b/contracts/fixtures/session.hello.json @@ -0,0 +1,36 @@ +{ + "name": "session.hello \u2014 a GUI client connects over the Unix socket", + "description": "The handshake every client makes first. Matching majors, so the daemon answers with its capability list.", + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "session.hello", + "params": { + "clientType": "gui", + "clientName": "velox-gui 0.1.0", + "protocolVersion": "1.0.0" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "daemonVersion": "0.1.0", + "protocolVersion": "1.0.0", + "capabilities": [ + "media", + "grabber", + "secretservice" + ], + "sessionId": "$uuid", + "transport": "uds" + } + }, + "assertions": [ + "the reply names a protocolVersion whose major matches the request's", + "sessionId is a fresh uuid per connection, not per client", + "no token is required on the Unix socket: SO_PEERCRED already proved same-UID", + "replayed on the Unix socket only: the same call without a token is -32002 over the WebSocket" + ], + "transport": "uds" +} diff --git a/contracts/fixtures/session.pair.json b/contracts/fixtures/session.pair.json new file mode 100644 index 0000000..c421649 --- /dev/null +++ b/contracts/fixtures/session.pair.json @@ -0,0 +1,27 @@ +{ + "name": "session.pair \u2014 the extension pairs over the WebSocket transport", + "description": "First run only. The daemon prompts the user and returns a 256-bit token once they approve.", + "request": { + "jsonrpc": "2.0", + "id": 2, + "method": "session.pair", + "params": { + "clientName": "Velox for Firefox", + "extensionId": "11111111-2222-3333-4444-555555555555" + } + }, + "response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "token": "$opaque", + "expiresAt": null + } + }, + "assertions": [ + "a user prompt is shown before any token is issued \u2014 never auto-approve", + "the token is at least 256 bits of entropy, base64url encoded", + "the daemon stores a hash of the token, never the token itself", + "this method is refused with -32003 on the Unix socket" + ] +} diff --git a/contracts/fixtures/session.subscribe.json b/contracts/fixtures/session.subscribe.json new file mode 100644 index 0000000..33e4f48 --- /dev/null +++ b/contracts/fixtures/session.subscribe.json @@ -0,0 +1,38 @@ +{ + "name": "session.subscribe \u2014 the GUI asks for the full event set", + "description": "Nothing is delivered until this is called. Subscribing replaces any previous selection.", + "request": { + "jsonrpc": "2.0", + "id": 3, + "method": "session.subscribe", + "params": { + "events": [ + "event.task.added", + "event.task.removed", + "event.task.state", + "event.task.progress", + "event.speed.global", + "event.notify" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 3, + "result": { + "ok": true, + "events": [ + "event.task.added", + "event.task.removed", + "event.task.state", + "event.task.progress", + "event.speed.global", + "event.notify" + ] + } + }, + "assertions": [ + "the echoed list lets a client spot an event it asked for that this daemon does not emit", + "no notification arrives on this connection before this call succeeds" + ] +} diff --git a/contracts/fixtures/settings.get.json b/contracts/fixtures/settings.get.json new file mode 100644 index 0000000..5cd3dac --- /dev/null +++ b/contracts/fixtures/settings.get.json @@ -0,0 +1,34 @@ +{ + "name": "settings.get \u2014 the Connection tab's keys", + "description": "Privileged: the settings bag names local paths and the allowed write roots, which the extension has no business enumerating.", + "request": { + "jsonrpc": "2.0", + "id": 50, + "method": "settings.get", + "params": { + "keys": [ + "connection.maxSegmentsPerDownload", + "connection.bufferBytes", + "connection.maxConcurrentDownloads", + "connection.timeoutSec" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 50, + "result": { + "values": { + "connection.maxSegmentsPerDownload": 8, + "connection.bufferBytes": 4194304, + "connection.maxConcurrentDownloads": 5, + "connection.timeoutSec": 30 + } + } + }, + "assertions": [ + "only the requested keys come back", + "keys null returns everything", + "no password is ever present: credentials live in the Secret Service" + ] +} diff --git a/contracts/fixtures/settings.set.json b/contracts/fixtures/settings.set.json new file mode 100644 index 0000000..eade7b0 --- /dev/null +++ b/contracts/fixtures/settings.set.json @@ -0,0 +1,34 @@ +{ + "name": "settings.set \u2014 raise the segment cap and turn on checksums", + "description": "Only the keys present change, and changed[] names exactly what took effect.", + "request": { + "jsonrpc": "2.0", + "id": 51, + "method": "settings.set", + "params": { + "values": { + "connection.maxSegmentsPerDownload": 16, + "downloads.verifyChecksums": true + } + } + }, + "response": { + "jsonrpc": "2.0", + "id": 51, + "result": { + "values": { + "connection.maxSegmentsPerDownload": 16, + "downloads.verifyChecksums": true + }, + "changed": [ + "connection.maxSegmentsPerDownload", + "downloads.verifyChecksums" + ] + } + }, + "assertions": [ + "event.settings.changed is emitted carrying exactly the keys in changed[]", + "an unknown key is -32602 and nothing at all is written", + "a directory key naming an unwritable path is -32011" + ] +} diff --git a/contracts/openrpc.json b/contracts/openrpc.json new file mode 100644 index 0000000..296c742 --- /dev/null +++ b/contracts/openrpc.json @@ -0,0 +1,4974 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "Velox Download Manager", + "version": "1.0.0", + "description": "The wire contract between veloxd and every client: the Qt GUI, the CLI, the native-messaging host and the Firefox extension. One JSON-RPC 2.0 payload set over four framings; only the framing differs.\n\nGENERATED from contracts/schema/ by contracts/codegen/gen_openrpc.py. Do not edit by hand.", + "license": { + "name": "See repository LICENSE" + } + }, + "servers": [ + { + "name": "unix-socket", + "url": "unix:$XDG_RUNTIME_DIR/velox/velox.sock", + "description": "NDJSON. GUI, CLI and nmhost. Peer credentials checked via SO_PEERCRED; same UID only, no token." + }, + { + "name": "loopback-ws", + "url": "ws://127.0.0.1:52000", + "description": "One JSON message per text frame. Extension fallback. Bound to 127.0.0.1 only, Origin-checked, token-authenticated, rate-limited. Port is the first free one in 52000-52016." + } + ], + "methods": [ + { + "name": "capture.getRules", + "summary": "The daemon's capture policy, so the extension's shouldCapture decision cannot drift from the daemon's.", + "description": "The daemon's capture policy, so the extension's shouldCapture decision cannot drift from the daemon's. Fetched on connect and whenever event.settings.changed names a capture.* key. If this call fails the extension keeps its last known rules and stays fail-open.", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "capture.getRulesResult", + "schema": { + "$ref": "#/components/schemas/CaptureRules" + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 2000 + }, + { + "name": "capture.offer", + "summary": "Firefox offers an intercepted response to the daemon.", + "description": "Firefox offers an intercepted response to the daemon. The daemon MUST reply within 750 ms; the extension abandons the offer and lets Firefox download normally on timeout. This deadline is the whole reason capture fails open, and it is conformance-tested: a daemon that is slow, down, or erroring must never cost the user a download.", + "paramStructure": "by-name", + "params": [ + { + "name": "url", + "schema": { + "type": "string", + "format": "uri" + }, + "required": true + }, + { + "name": "method", + "schema": { + "type": "string", + "enum": [ + "GET", + "POST" + ] + }, + "required": true + }, + { + "name": "tabUrl", + "schema": { + "type": "string", + "format": "uri" + }, + "required": true + }, + { + "name": "headers", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Headers" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "cookies", + "schema": { + "type": [ + "array", + "null" + ], + "description": "Cookies for the URL, so authenticated downloads work outside the browser.", + "items": { + "$ref": "#/components/schemas/Cookie" + } + }, + "description": "Cookies for the URL, so authenticated downloads work outside the browser." + }, + { + "name": "contentType", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "contentLength", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + { + "name": "contentDisposition", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "filename", + "schema": { + "type": [ + "string", + "null" + ], + "description": "The extension's best guess; the daemon may override it." + }, + "description": "The extension's best guess; the daemon may override it." + }, + { + "name": "userAgent", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "referrer", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "origin", + "schema": { + "type": [ + "string", + "null" + ], + "description": "moz-extension://... The daemon verifies this on the WS transport and refuses anything else." + }, + "description": "moz-extension://... The daemon verifies this on the WS transport and refuses anything else." + }, + { + "name": "requestId", + "schema": { + "type": [ + "string", + "null" + ], + "description": "The extension's webRequest id, echoed in logs so a capture decision can be traced back to one browser request." + }, + "description": "The extension's webRequest id, echoed in logs so a capture decision can be traced back to one browser request." + } + ], + "result": { + "name": "capture.offerResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "take", + "ignore" + ] + }, + "taskId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Set when action is 'take'." + }, + "reason": { + "type": [ + "string", + "null" + ], + "description": "Why the offer was declined. Set when action is 'ignore'; the extension logs it in the popup's diagnostics.", + "enum": [ + "excluded_host", + "type_not_monitored", + "below_min_size", + "duplicate", + "capture_disabled", + "user_declined", + "rule_ignore", + null + ] + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 750, + "errors": [ + { + "code": -32011, + "message": "Destination is outside the allowed roots, or is not writable. data.path is set." + } + ] + }, + { + "name": "category.list", + "summary": "Every category with its folder and extension list.", + "description": "Every category with its folder and extension list. The extension calls this to populate its default-category picker, which is why it is not privileged; it is read-only and exposes only paths the user already configured.", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "category.listResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Category" + } + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 2000 + }, + { + "name": "category.remove", + "summary": "Delete a user-created category.", + "description": "Delete a user-created category. Built-in categories are refused with -32602. Tasks filed under it are reassigned to reassignTo, or to the default category when that is null; no task is ever orphaned.", + "paramStructure": "by-name", + "params": [ + { + "name": "categoryId", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "reassignTo", + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "result": { + "name": "category.removeResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "removed", + "reassignedTaskIds" + ], + "properties": { + "removed": { + "type": "boolean" + }, + "reassignedTaskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + }, + { + "code": -32602, + "message": "Params failed schema validation." + } + ] + }, + { + "name": "category.upsert", + "summary": "Create or replace a category.", + "description": "Create or replace a category. Omit categoryId to create; supply it to replace. Changing saveDir does not move existing files \u2014 the GUI asks separately and issues download.update per task, so a re-point is never a surprise mass file move.", + "paramStructure": "by-name", + "params": [ + { + "name": "category", + "schema": { + "$ref": "#/components/schemas/Category" + }, + "required": true + } + ], + "result": { + "name": "category.upsertResult", + "schema": { + "type": "object", + "additionalProperties": false, + "description": "The stored category, with categoryId filled in on create.", + "required": [ + "category" + ], + "properties": { + "category": { + "$ref": "#/components/schemas/Category" + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + }, + { + "code": -32011, + "message": "Destination is outside the allowed roots, or is not writable. data.path is set." + } + ] + }, + { + "name": "download.add", + "summary": "Create one task.", + "description": "Create one task. saveDir is canonicalized and checked against saveTo.allowedRoots before anything is written; a path that escapes them is refused with -32011 and no file is created.", + "paramStructure": "by-name", + "params": [ + { + "name": "url", + "schema": { + "type": "string", + "format": "uri" + }, + "required": true + }, + { + "name": "headers", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Headers" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "cookies", + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Cookie" + } + } + }, + { + "name": "referrer", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "userAgent", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "filename", + "schema": { + "type": [ + "string", + "null" + ], + "maxLength": 255, + "description": "Overrides the name derived from Content-Disposition or the URL." + }, + "description": "Overrides the name derived from Content-Disposition or the URL." + }, + { + "name": "saveDir", + "schema": { + "type": [ + "string", + "null" + ], + "description": "Canonicalized and checked against the allowed roots before any write. -32011 if it fails." + }, + "description": "Canonicalized and checked against the allowed roots before any write. -32011 if it fails." + }, + { + "name": "categoryId", + "schema": { + "type": [ + "string", + "null" + ], + "description": "null means the rules engine picks one." + }, + "description": "null means the rules engine picks one." + }, + { + "name": "queueId", + "schema": { + "type": [ + "string", + "null" + ], + "description": "Required when startMode is 'queue'." + }, + "description": "Required when startMode is 'queue'." + }, + { + "name": "segments", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 32, + "description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the per-host cap, and to 1 when the source turns out not to be resumable. What is actually in use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload." + }, + "description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the per-host cap, and to 1 when the source turns out not to be resumable. What is actually in use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload." + }, + { + "name": "bufferBytes", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 4096, + "maximum": 8388608 + } + }, + { + "name": "startMode", + "schema": { + "$ref": "#/components/schemas/StartMode" + } + }, + { + "name": "description", + "schema": { + "type": [ + "string", + "null" + ], + "maxLength": 1024 + } + }, + { + "name": "checksum", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Checksum" + }, + { + "type": "null" + } + ] + } + } + ], + "result": { + "name": "download.addResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "state" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "#/components/schemas/TaskState" + }, + "duplicate": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The existing task this URL matched, when downloads.duplicatePolicy resolved to 'skip'. taskId then names that existing task." + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32011, + "message": "Destination is outside the allowed roots, or is not writable. data.path is set." + }, + { + "code": -32012, + "message": "Not enough free space to preallocate." + }, + { + "code": -32013, + "message": "Could not probe the URL. data.httpStatus is set when there was an HTTP response." + } + ], + "x-wsRestrictions": [ + "saveDir must be absent or resolve inside an existing category folder; anything else is -32011. The extension may request a download, it may not choose an arbitrary destination." + ] + }, + { + "name": "download.addBatch", + "summary": "Create many tasks in one call: the clipboard blob, the wildcard expander, and the extension's 'Download all links'.", + "description": "Create many tasks in one call: the clipboard blob, the wildcard expander, and the extension's 'Download all links'. Partial success is normal and is reported per item rather than failing the whole batch.", + "paramStructure": "by-name", + "params": [ + { + "name": "items", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 5000, + "items": { + "$ref": "#/components/schemas/DownloadSpec" + } + }, + "required": true + }, + { + "name": "defaults", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DownloadSpec" + }, + { + "type": "null" + } + ], + "description": "Applied to any field an item left unset. Its url is ignored." + }, + "description": "Applied to any field an item left unset. Its url is ignored." + } + ], + "result": { + "name": "download.addBatchResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskIds", + "failed" + ], + "properties": { + "taskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "In the same order as the accepted items." + }, + "failed": { + "type": "array", + "description": "One entry per item that could not be added. index refers to params.items.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "index", + "code", + "message" + ], + "properties": { + "index": { + "type": "integer", + "minimum": 0 + }, + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, + "message": { + "type": "string" + } + } + } + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 30000, + "errors": [ + { + "code": -32011, + "message": "Destination is outside the allowed roots, or is not writable. data.path is set." + }, + { + "code": -32012, + "message": "Not enough free space to preallocate." + } + ], + "x-wsRestrictions": [ + "Same saveDir restriction as download.add, applied to defaults and to every item." + ] + }, + { + "name": "download.cancel", + "summary": "Stop the given tasks and mark them cancelled.", + "description": "Stop the given tasks and mark them cancelled. The .veloxpart file is kept so the user can still resume from the list; download.remove is what deletes bytes.", + "paramStructure": "by-name", + "params": [ + { + "name": "taskIds", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 5000, + "items": { + "type": "string", + "format": "uuid" + } + }, + "required": true + } + ], + "result": { + "name": "download.cancelResult", + "schema": { + "$ref": "#/components/schemas/BulkTaskResult" + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32010, + "message": "No task with that id." + } + ] + }, + { + "name": "download.get", + "summary": "Full detail for one task, including per-segment state.", + "description": "Full detail for one task, including per-segment state. Backs the progress dialog. Poll it no faster than the progress dialog repaints; the table must use events instead.", + "paramStructure": "by-name", + "params": [ + { + "name": "taskId", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "result": { + "name": "download.getResult", + "schema": { + "$ref": "#/components/schemas/TaskDetail" + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32010, + "message": "No task with that id." + } + ] + }, + { + "name": "download.list", + "summary": "The main table.", + "description": "The main table. Filtering, sorting and paging all happen in the daemon so the GUI never materializes 100k rows to show 40. Called once on connect; after that the table is maintained from events, never re-fetched on a progress tick.", + "paramStructure": "by-name", + "params": [ + { + "name": "filter", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/TaskFilter" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "sort", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/TaskSort" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "offset", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + { + "name": "limit", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5000, + "description": "Defaults to 500. The GUI pages; the extension popup asks for far fewer." + }, + "description": "Defaults to 500. The GUI pages; the extension popup asks for far fewer." + } + ], + "result": { + "name": "download.listResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "total", + "items" + ], + "properties": { + "total": { + "type": "integer", + "minimum": 0, + "description": "Rows matching the filter, ignoring offset and limit." + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskSummary" + } + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 5000 + }, + { + "name": "download.pause", + "summary": "Suspend transfers and flush every segment's progress to the .", + "description": "Suspend transfers and flush every segment's progress to the .veloxpart.meta file, so a pause is indistinguishable from a crash as far as resume is concerned. Never loses bytes already written.", + "paramStructure": "by-name", + "params": [ + { + "name": "taskIds", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 5000, + "items": { + "type": "string", + "format": "uuid" + } + }, + "required": true + } + ], + "result": { + "name": "download.pauseResult", + "schema": { + "$ref": "#/components/schemas/BulkTaskResult" + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32010, + "message": "No task with that id." + } + ] + }, + { + "name": "download.probe", + "summary": "Ask what is at a URL without creating a task.", + "description": "Ask what is at a URL without creating a task. Populates the File Info dialog. Runs a HEAD, falling back to a ranged GET when HEAD is refused, which is also how resumability is established. Never blocks the RPC loop; the dialog opens immediately and fills in when this lands.", + "paramStructure": "by-name", + "params": [ + { + "name": "url", + "schema": { + "type": "string", + "format": "uri" + }, + "required": true + }, + { + "name": "headers", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Headers" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "cookies", + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Cookie" + } + } + }, + { + "name": "referrer", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "userAgent", + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "result": { + "name": "download.probeResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "filename", + "mime", + "resumable", + "effectiveUrl", + "suggestedCategoryId" + ], + "properties": { + "filename": { + "type": "string", + "description": "From Content-Disposition when present, else the URL path, sanitized." + }, + "sizeBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "mime": { + "type": "string" + }, + "resumable": { + "type": "boolean", + "description": "Accept-Ranges: bytes and a validator (ETag or Last-Modified) are both present." + }, + "effectiveUrl": { + "type": "string", + "format": "uri" + }, + "suggestedCategoryId": { + "type": "string", + "description": "What the rules engine would pick. The dialog preselects it; the user may override." + }, + "suggestedSaveDir": { + "type": [ + "string", + "null" + ] + }, + "etag": { + "type": [ + "string", + "null" + ] + }, + "lastModified": { + "type": [ + "string", + "null" + ] + }, + "acceptRanges": { + "type": "boolean" + }, + "redirectChain": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + }, + "description": "Every hop, so the user can see where a shortener actually led." + }, + "requiresAuth": { + "type": "boolean", + "description": "The probe got a 401/407. The GUI should collect credentials before adding." + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 30000, + "errors": [ + { + "code": -32013, + "message": "Could not probe the URL. data.httpStatus is set when there was an HTTP response." + } + ] + }, + { + "name": "download.refreshUrl", + "summary": "IDM's 'Refresh Download Address'.", + "description": "IDM's 'Refresh Download Address'. Point an existing task at a freshly-issued URL when a signed link has expired, keeping every byte already on disk. The daemon re-probes and compares size and validator: if they still match, the transfer resumes from where it stopped; if they do not, it says so rather than silently restarting.", + "paramStructure": "by-name", + "params": [ + { + "name": "taskId", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "name": "url", + "schema": { + "type": "string", + "format": "uri" + }, + "required": true + }, + { + "name": "headers", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Headers" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "cookies", + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Cookie" + } + } + } + ], + "result": { + "name": "download.refreshUrlResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "resumable", + "contentChanged" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "resumable": { + "type": "boolean" + }, + "contentChanged": { + "type": "boolean", + "description": "true when size or validator differ from what was recorded. The GUI must ask before restarting from zero \u2014 never discard bytes without consent." + }, + "sizeBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "effectiveUrl": { + "type": [ + "string", + "null" + ], + "format": "uri" + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 30000, + "errors": [ + { + "code": -32010, + "message": "No task with that id." + }, + { + "code": -32013, + "message": "Could not probe the URL. data.httpStatus is set when there was an HTTP response." + } + ] + }, + { + "name": "download.remove", + "summary": "Drop tasks from the list, optionally deleting the bytes on disk.", + "description": "Drop tasks from the list, optionally deleting the bytes on disk. Privileged: this is the only method that destroys user data, and the extension is never allowed to reach it. The daemon deletes the .veloxpart and .veloxpart.meta pair, and the finished file only when deleteFile is true.", + "paramStructure": "by-name", + "params": [ + { + "name": "taskIds", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 5000, + "items": { + "type": "string", + "format": "uuid" + } + }, + "required": true + }, + { + "name": "deleteFile", + "schema": { + "type": "boolean", + "description": "Explicit and required \u2014 there is no default for deleting a user's file." + }, + "required": true, + "description": "Explicit and required \u2014 there is no default for deleting a user's file." + } + ], + "result": { + "name": "download.removeResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "removed", + "failed" + ], + "properties": { + "removed": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "code", + "message" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, + "message": { + "type": "string" + } + } + } + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 10000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + }, + { + "code": -32010, + "message": "No task with that id." + } + ] + }, + { + "name": "download.resume", + "summary": "Continue paused tasks.", + "description": "Continue paused tasks. Resumption is revalidated with If-Range against the stored ETag or Last-Modified; a 200 where 206 was expected means the file changed on the server, and the task moves to failed with a clear error rather than corrupting the part file.", + "paramStructure": "by-name", + "params": [ + { + "name": "taskIds", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 5000, + "items": { + "type": "string", + "format": "uuid" + } + }, + "required": true + } + ], + "result": { + "name": "download.resumeResult", + "schema": { + "$ref": "#/components/schemas/BulkTaskResult" + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32010, + "message": "No task with that id." + } + ] + }, + { + "name": "download.start", + "summary": "Begin or restart the given tasks.", + "description": "Begin or restart the given tasks. A task in 'queued' jumps its queue; a task already downloading is a no-op reported as changed false.", + "paramStructure": "by-name", + "params": [ + { + "name": "taskIds", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 5000, + "items": { + "type": "string", + "format": "uuid" + } + }, + "required": true + } + ], + "result": { + "name": "download.startResult", + "schema": { + "$ref": "#/components/schemas/BulkTaskResult" + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32010, + "message": "No task with that id." + } + ] + }, + { + "name": "download.update", + "summary": "Change a task's mutable fields.", + "description": "Change a task's mutable fields. Moving saveDir or filename moves the file on disk in the same operation, which is what makes dragging a row onto a category work as one RPC. Privileged: it can name a destination path.", + "paramStructure": "by-name", + "params": [ + { + "name": "taskId", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "name": "patch", + "schema": { + "type": "object", + "additionalProperties": false, + "description": "Only the present fields change. An explicit null clears a nullable field.", + "properties": { + "filename": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + }, + "saveDir": { + "type": [ + "string", + "null" + ] + }, + "categoryId": { + "type": [ + "string", + "null" + ] + }, + "queueId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 1024 + }, + "segments": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 32, + "description": "The REQUESTED connection count, subject to the same per-host cap and non-resumable demotion as DownloadSpec.segments. Takes effect on the next start; a running task is not re-segmented underneath the user." + }, + "bufferBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 4096, + "maximum": 8388608 + }, + "checksum": { + "oneOf": [ + { + "$ref": "#/components/schemas/Checksum" + }, + { + "type": "null" + } + ] + } + } + }, + "required": true, + "description": "Only the present fields change. An explicit null clears a nullable field." + } + ], + "result": { + "name": "download.updateResult", + "schema": { + "$ref": "#/components/schemas/TaskSummary" + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 30000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + }, + { + "code": -32010, + "message": "No task with that id." + }, + { + "code": -32011, + "message": "Destination is outside the allowed roots, or is not writable. data.path is set." + } + ] + }, + { + "name": "grabber.harvest", + "summary": "Turn selected crawl results into tasks.", + "description": "Turn selected crawl results into tasks. This is the only grabber call that creates downloads, and it names exactly the files the user ticked \u2014 a crawl never starts a download on its own.", + "paramStructure": "by-name", + "params": [ + { + "name": "jobId", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "select", + "schema": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "description": "fileIds from grabber.status." + }, + "required": true, + "description": "fileIds from grabber.status." + }, + { + "name": "defaults", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DownloadSpec" + }, + { + "type": "null" + } + ] + } + } + ], + "result": { + "name": "grabber.harvestResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskIds", + "failed" + ], + "properties": { + "taskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "fileId", + "code", + "message" + ], + "properties": { + "fileId": { + "type": "string" + }, + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, + "message": { + "type": "string" + } + } + } + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 30000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + }, + { + "code": -32011, + "message": "Destination is outside the allowed roots, or is not writable. data.path is set." + } + ] + }, + { + "name": "grabber.start", + "summary": "Start a depth-limited crawl.", + "description": "Start a depth-limited crawl. Nothing is downloaded by this call: it only walks pages and collects candidate links, which the wizard then shows for selection. Privileged because an unbounded crawl is a resource commitment the browser must not be able to make on the user's behalf.", + "paramStructure": "by-name", + "params": [ + { + "name": "startUrl", + "schema": { + "type": "string", + "format": "uri" + }, + "required": true + }, + { + "name": "depth", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "required": true + }, + { + "name": "includePatterns", + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + { + "name": "excludePatterns", + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + { + "name": "fileTypes", + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Extensions, without the dot. null means every type." + }, + "description": "Extensions, without the dot. null means every type." + }, + { + "name": "sameHostOnly", + "schema": { + "type": "boolean" + } + }, + { + "name": "maxFiles", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 10000 + } + }, + { + "name": "headers", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Headers" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "cookies", + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Cookie" + } + } + } + ], + "result": { + "name": "grabber.startResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "jobId" + ], + "properties": { + "jobId": { + "type": "string" + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + } + ] + }, + { + "name": "grabber.status", + "summary": "Poll one crawl.", + "description": "Poll one crawl. Also delivered as event.grabber.progress; the poll exists so the wizard can be reopened on a job it did not start and still catch up.", + "paramStructure": "by-name", + "params": [ + { + "name": "jobId", + "schema": { + "type": "string" + }, + "required": true + } + ], + "result": { + "name": "grabber.statusResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "jobId", + "state", + "crawled", + "found", + "files" + ], + "properties": { + "jobId": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "crawling", + "done", + "failed", + "cancelled" + ] + }, + "crawled": { + "type": "integer", + "minimum": 0 + }, + "found": { + "type": "integer", + "minimum": 0 + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GrabberFile" + } + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + }, + { + "code": -32602, + "message": "Params failed schema validation." + } + ] + }, + { + "name": "limiter.get", + "summary": "Current global speed limit.", + "description": "Current global speed limit. Privileged: changing or reading the limiter belongs to the GUI and CLI; the extension shows throughput from event.speed.global instead.", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "limiter.getResult", + "schema": { + "$ref": "#/components/schemas/Limiter" + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 2000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + } + ] + }, + { + "name": "limiter.set", + "summary": "Set the global token-bucket limit.", + "description": "Set the global token-bucket limit. With applyToRunning true the change re-tunes transfers already in flight instead of taking effect only on the next task \u2014 the Speed Limiter window's 'apply now' button.", + "paramStructure": "by-name", + "params": [ + { + "name": "enabled", + "schema": { + "type": "boolean" + }, + "required": true + }, + { + "name": "globalBps", + "schema": { + "type": "integer", + "minimum": 0, + "description": "Bytes per second. 0 with enabled true means 'stop everything', which the GUI must not offer." + }, + "required": true, + "description": "Bytes per second. 0 with enabled true means 'stop everything', which the GUI must not offer." + }, + { + "name": "applyToRunning", + "schema": { + "type": "boolean", + "description": "Re-tune already-running transfers instead of waiting for the next task." + }, + "description": "Re-tune already-running transfers instead of waiting for the next task." + } + ], + "result": { + "name": "limiter.setResult", + "schema": { + "$ref": "#/components/schemas/Limiter" + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + }, + { + "code": -32602, + "message": "Params failed schema validation." + } + ] + }, + { + "name": "media.addVariant", + "summary": "Turn one enumerated variant into a task.", + "description": "Turn one enumerated variant into a task. The daemon fetches the segments in parallel and muxes them with ffmpeg; the result is an ordinary task that appears in the list like any other download. Refused with -32602 when the variant is DRM-protected.", + "paramStructure": "by-name", + "params": [ + { + "name": "manifestUrl", + "schema": { + "type": "string", + "format": "uri" + }, + "required": true + }, + { + "name": "variantId", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "audioVariantId", + "schema": { + "type": [ + "string", + "null" + ], + "description": "For DASH and HLS renditions where audio is a separate track to be muxed in." + }, + "description": "For DASH and HLS renditions where audio is a separate track to be muxed in." + }, + { + "name": "spec", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DownloadSpec" + }, + { + "type": "null" + } + ] + } + } + ], + "result": { + "name": "media.addVariantResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "state" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "#/components/schemas/TaskState" + }, + "estimatedBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 30000, + "errors": [ + { + "code": -32011, + "message": "Destination is outside the allowed roots, or is not writable. data.path is set." + }, + { + "code": -32602, + "message": "Params failed schema validation." + }, + { + "code": -32013, + "message": "Could not probe the URL. data.httpStatus is set when there was an HTTP response." + } + ], + "x-wsRestrictions": [ + "Same saveDir restriction as download.add." + ] + }, + { + "name": "media.listVariants", + "summary": "Parse an HLS or DASH manifest in the daemon and enumerate its renditions.", + "description": "Parse an HLS or DASH manifest in the daemon and enumerate its renditions. The extension never parses a manifest \u2014 that logic lives in one language, in one place. Variants with drm true are reported so the UI can grey them out; DRM-protected streams are refused, not attempted.", + "paramStructure": "by-name", + "params": [ + { + "name": "manifestUrl", + "schema": { + "type": "string", + "format": "uri" + }, + "required": true + }, + { + "name": "headers", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Headers" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "cookies", + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Cookie" + } + } + }, + { + "name": "referrer", + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "result": { + "name": "media.listVariantsResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "variants", + "manifestType", + "drmProtected" + ], + "properties": { + "variants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaVariant" + } + }, + "manifestType": { + "type": "string", + "enum": [ + "hls", + "dash" + ] + }, + "durationSec": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "drmProtected": { + "type": "boolean", + "description": "The manifest as a whole is DRM-protected. Refuse with a clear message rather than downloading undecryptable segments." + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 30000, + "errors": [ + { + "code": -32013, + "message": "Could not probe the URL. data.httpStatus is set when there was an HTTP response." + } + ] + }, + { + "name": "queue.list", + "summary": "Every queue with its run state and ordering.", + "description": "Every queue with its run state and ordering. Not privileged: the extension's 'Add to Queue' picker needs it.", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "queue.listResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Queue" + } + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 2000 + }, + { + "name": "queue.reorder", + "summary": "Rewrite a queue's run order.", + "description": "Rewrite a queue's run order. taskIds must be a permutation of the queue's current membership; anything else is -32602 rather than a partial reorder, so a stale drag from an out-of-date view cannot quietly reshuffle the queue.", + "paramStructure": "by-name", + "params": [ + { + "name": "queueId", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "taskIds", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "required": true + } + ], + "result": { + "name": "queue.reorderResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "queue" + ], + "properties": { + "queue": { + "$ref": "#/components/schemas/Queue" + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + }, + { + "code": -32602, + "message": "Params failed schema validation." + } + ] + }, + { + "name": "queue.start", + "summary": "Start a queue running.", + "description": "Start a queue running. The scheduler then admits up to maxConcurrent tasks from it, in order, and keeps that many running until the queue drains or is stopped.", + "paramStructure": "by-name", + "params": [ + { + "name": "queueId", + "schema": { + "type": "string" + }, + "required": true + } + ], + "result": { + "name": "queue.startResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "queue", + "startedTaskIds" + ], + "properties": { + "queue": { + "$ref": "#/components/schemas/Queue" + }, + "startedTaskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + } + ] + }, + { + "name": "queue.stop", + "summary": "Stop admitting new tasks from a queue.", + "description": "Stop admitting new tasks from a queue. Tasks already running are paused when pauseRunning is true, and otherwise allowed to finish \u2014 the difference between 'stop the queue' and 'stop everything', which IDM conflates and users trip over.", + "paramStructure": "by-name", + "params": [ + { + "name": "queueId", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "pauseRunning", + "schema": { + "type": "boolean" + } + } + ], + "result": { + "name": "queue.stopResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "queue", + "pausedTaskIds" + ], + "properties": { + "queue": { + "$ref": "#/components/schemas/Queue" + }, + "pausedTaskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + } + ] + }, + { + "name": "queue.upsert", + "summary": "Create or replace a queue, including its schedule and concurrency cap.", + "description": "Create or replace a queue, including its schedule and concurrency cap. Omit queueId to create. taskIds in the payload is ignored \u2014 membership changes through download.update and queue.reorder so that two clients editing at once cannot silently drop a task.", + "paramStructure": "by-name", + "params": [ + { + "name": "queue", + "schema": { + "$ref": "#/components/schemas/Queue" + }, + "required": true + } + ], + "result": { + "name": "queue.upsertResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "queue" + ], + "properties": { + "queue": { + "$ref": "#/components/schemas/Queue" + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + } + ] + }, + { + "name": "rules.list", + "summary": "The rules engine's table, in priority order.", + "description": "The rules engine's table, in priority order. Privileged: these are the daemon's routing policy. The extension gets its own narrowed view through capture.getRules instead.", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "rules.listResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Rule" + } + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 2000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + } + ] + }, + { + "name": "rules.upsert", + "summary": "Create, replace, or delete rules in one atomic write.", + "description": "Create, replace, or delete rules in one atomic write. 'upsert' carries the rules to store and 'remove' the ruleIds to drop; applying both at once means a reprioritisation never leaves the table in a half-valid state.", + "paramStructure": "by-name", + "params": [ + { + "name": "upsert", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Rule" + } + }, + "required": true + }, + { + "name": "remove", + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + } + ], + "result": { + "name": "rules.upsertResult", + "schema": { + "type": "object", + "additionalProperties": false, + "description": "The full table after the write, in priority order.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Rule" + } + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + } + ] + }, + { + "name": "schedule.get", + "summary": "The schedule for one queue, or every schedule when queueId is null.", + "description": "The schedule for one queue, or every schedule when queueId is null. Backs the Scheduler window.", + "paramStructure": "by-name", + "params": [ + { + "name": "queueId", + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "result": { + "name": "schedule.getResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "queueId", + "schedule" + ], + "properties": { + "queueId": { + "type": "string" + }, + "schedule": { + "oneOf": [ + { + "$ref": "#/components/schemas/Schedule" + }, + { + "type": "null" + } + ] + } + } + } + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 2000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + } + ] + }, + { + "name": "schedule.set", + "summary": "Set or clear a queue's schedule.", + "description": "Set or clear a queue's schedule. A null schedule clears it and leaves the queue under manual control. Times are local wall-clock and are re-evaluated on a DST change rather than being resolved to absolute instants at set time.", + "paramStructure": "by-name", + "params": [ + { + "name": "queueId", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "schedule", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Schedule" + }, + { + "type": "null" + } + ] + }, + "required": true + } + ], + "result": { + "name": "schedule.setResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "queueId", + "schedule" + ], + "properties": { + "queueId": { + "type": "string" + }, + "schedule": { + "oneOf": [ + { + "$ref": "#/components/schemas/Schedule" + }, + { + "type": "null" + } + ] + }, + "nextRunAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + }, + { + "code": -32602, + "message": "Params failed schema validation." + } + ] + }, + { + "name": "session.hello", + "summary": "First call on every connection, on every transport.", + "description": "First call on every connection, on every transport. The daemon compares protocolVersion majors and refuses a mismatch with -32001 so a stale GUI or extension fails loudly on connect instead of subtly at the tenth field. On the WebSocket transport a valid token is required unless the client is about to call session.pair.", + "paramStructure": "by-name", + "params": [ + { + "name": "clientType", + "schema": { + "type": "string", + "enum": [ + "gui", + "cli", + "extension", + "nmhost", + "test" + ] + }, + "required": true + }, + { + "name": "clientName", + "schema": { + "type": "string", + "maxLength": 64, + "description": "Human-readable, shown in the pairing prompt and the logs." + }, + "required": true, + "description": "Human-readable, shown in the pairing prompt and the logs." + }, + { + "name": "protocolVersion", + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?$" + }, + "required": true + }, + { + "name": "token", + "schema": { + "type": [ + "string", + "null" + ], + "description": "Required on the WebSocket transport once paired. Ignored on the Unix socket, where SO_PEERCRED is the authorization." + }, + "description": "Required on the WebSocket transport once paired. Ignored on the Unix socket, where SO_PEERCRED is the authorization." + } + ], + "result": { + "name": "session.helloResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "daemonVersion", + "protocolVersion", + "capabilities", + "sessionId" + ], + "properties": { + "daemonVersion": { + "type": "string" + }, + "protocolVersion": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional features this build has, e.g. 'media', 'grabber', 'secretservice'. A client must degrade gracefully when one is absent rather than assuming it." + }, + "sessionId": { + "type": "string", + "format": "uuid" + }, + "transport": { + "type": "string", + "enum": [ + "uds", + "ws" + ], + "description": "How the daemon sees this connection. Lets a client know up front which privileged methods will be refused." + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 2000, + "errors": [ + { + "code": -32001, + "message": "Protocol major version mismatch. GUI renders this as 'Velox needs updating'." + }, + { + "code": -32002, + "message": "Missing or invalid token on the WebSocket transport." + } + ] + }, + { + "name": "session.pair", + "summary": "WebSocket transport only.", + "description": "WebSocket transport only. Triggers a GUI or desktop-notification prompt showing a four-digit code; the user must approve before a token is issued. Failed attempts are rate-limited to 5/min followed by a 60 s lockout (-32014) so a token cannot be brute-forced by another local process. The daemon stores only a hash of the token.", + "paramStructure": "by-name", + "params": [ + { + "name": "clientName", + "schema": { + "type": "string", + "maxLength": 64 + }, + "required": true + }, + { + "name": "extensionId", + "schema": { + "type": "string", + "description": "The moz-extension origin UUID. Must match the Origin header verified on the WS upgrade." + }, + "required": true, + "description": "The moz-extension origin UUID. Must match the Origin header verified on the WS upgrade." + }, + { + "name": "code", + "schema": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]{4}$", + "description": "Set when the user typed the code into the extension's Options page instead of clicking Allow in the GUI." + }, + "description": "Set when the user typed the code into the extension's Options page instead of clicking Allow in the GUI." + } + ], + "result": { + "name": "session.pairResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "token", + "expiresAt" + ], + "properties": { + "token": { + "type": "string", + "minLength": 43, + "description": "256 bits, base64url. Stored by the extension in browser.storage.local and sent on every later connect." + }, + "expiresAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "null means the token does not expire; it is revoked from Options -> Unpair." + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "ws" + ], + "x-deadlineMs": 120000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + }, + { + "code": -32014, + "message": "Pairing brute-force lockout. data.retryAfterSec is set." + } + ] + }, + { + "name": "session.subscribe", + "summary": "Choose which notifications this connection receives.", + "description": "Choose which notifications this connection receives. Subscribing replaces the previous selection rather than adding to it, so a client can narrow its firehose without reconnecting. Nothing is delivered until this is called.", + "paramStructure": "by-name", + "params": [ + { + "name": "events", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "event.task.added", + "event.task.removed", + "event.task.state", + "event.task.progress", + "event.speed.global", + "event.auth.required", + "event.notify", + "event.settings.changed", + "event.grabber.progress" + ] + } + }, + "required": true + }, + { + "name": "taskIds", + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Narrow task events to these ids. The extension popup uses it to avoid receiving progress for downloads it is not showing. null means all tasks." + }, + "description": "Narrow task events to these ids. The extension popup uses it to avoid receiving progress for downloads it is not showing. null means all tasks." + } + ], + "result": { + "name": "session.subscribeResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "events" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "events": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Echoed back so a client can detect that it asked for an event this daemon does not emit." + } + } + } + }, + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 2000 + }, + { + "name": "settings.get", + "summary": "Read settings.", + "description": "Read settings. keys null means everything. Privileged: the settings bag names local filesystem paths and the allowed write roots, which the extension has no business enumerating \u2014 it gets capture.getRules instead.", + "paramStructure": "by-name", + "params": [ + { + "name": "keys", + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/SettingKey" + } + } + } + ], + "result": { + "name": "settings.getResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "values" + ], + "properties": { + "values": { + "$ref": "#/components/schemas/Settings" + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 2000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + } + ] + }, + { + "name": "settings.set", + "summary": "Write settings.", + "description": "Write settings. Only the keys present in values change. Rejected with -32602 if a key is unknown or a value fails the Settings schema, and with -32011 if a directory key names a path that cannot be written. Emits event.settings.changed with exactly the keys that took effect.", + "paramStructure": "by-name", + "params": [ + { + "name": "values", + "schema": { + "$ref": "#/components/schemas/Settings" + }, + "required": true + } + ], + "result": { + "name": "settings.setResult", + "schema": { + "type": "object", + "additionalProperties": false, + "description": "The stored values for the keys that were set, and the list of keys that actually changed.", + "required": [ + "values", + "changed" + ], + "properties": { + "values": { + "$ref": "#/components/schemas/Settings" + }, + "changed": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SettingKey" + } + } + } + } + }, + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "errors": [ + { + "code": -32003, + "message": "Method is privileged and was called over a transport that may not use it." + }, + { + "code": -32602, + "message": "Params failed schema validation." + }, + { + "code": -32011, + "message": "Destination is outside the allowed roots, or is not writable. data.path is set." + } + ] + } + ], + "components": { + "schemas": { + "BulkTaskResult": { + "description": "Result of a state transition applied to many tasks. A bulk call never fails as a whole because one id was bad: the ids that moved come back in 'updated' and the rest are explained in 'failed'. This is what lets the GUI's toolbar act on a multi-selection without pre-validating it.", + "type": "object", + "additionalProperties": false, + "required": [ + "updated", + "failed" + ], + "properties": { + "updated": { + "type": "array", + "description": "One entry per task that actually changed. A task already in the target state is reported here with changed false rather than as a failure.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "state", + "changed" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "#/components/schemas/TaskState" + }, + "changed": { + "type": "boolean" + } + } + } + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "code", + "message" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, + "message": { + "type": "string" + } + } + } + } + }, + "title": "BulkTaskResult" + }, + "BypassModifier": { + "description": "The modifier key a user holds to make one click bypass capture and let Firefox download normally. Shared by Settings and CaptureRules so the daemon's setting and the extension's mirror of it are literally the same type.", + "type": "string", + "enum": [ + "alt", + "ctrl", + "shift", + "none" + ], + "title": "BypassModifier" + }, + "CaptureRules": { + "description": "The daemon's capture policy, mirrored into the extension so the two can never disagree about what should be intercepted. The extension refreshes this on connect and on event.settings.changed.", + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "monitoredExtensions", + "monitoredMimeTypes", + "minSizeBytes", + "excludedHosts", + "rulesVersion" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "monitoredExtensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "monitoredMimeTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "minSizeBytes": { + "type": "integer", + "minimum": 0 + }, + "excludedHosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "bypassModifier": { + "$ref": "#/components/schemas/BypassModifier" + }, + "rulesVersion": { + "type": "integer", + "minimum": 0, + "description": "Bumped on every change. The extension re-fetches when it sees a higher value." + } + }, + "title": "CaptureRules" + }, + "Category": { + "description": "A destination folder plus the extensions that route to it. The extension mirrors the extension lists so its capture decision agrees with the daemon's.", + "type": "object", + "additionalProperties": false, + "required": [ + "categoryId", + "name", + "saveDir", + "extensions", + "builtin" + ], + "properties": { + "categoryId": { + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 64 + }, + "saveDir": { + "type": "string" + }, + "extensions": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9+._-]*$" + }, + "description": "Without the leading dot, lowercase." + }, + "mimeTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "builtin": { + "type": "boolean", + "description": "Compressed, Documents, Music, Programs, Video. Cannot be removed; can be renamed and re-pointed." + }, + "sortOrder": { + "type": "integer", + "minimum": 0 + } + }, + "title": "Category" + }, + "Checksum": { + "description": "Optional integrity check, verified during the verifying state. A mismatch moves the task to failed and never overwrites a good file.", + "type": "object", + "additionalProperties": false, + "required": [ + "algorithm", + "value" + ], + "properties": { + "algorithm": { + "type": "string", + "enum": [ + "md5", + "sha1", + "sha256", + "sha512" + ] + }, + "value": { + "type": "string", + "pattern": "^[0-9a-fA-F]{32,128}$" + } + }, + "title": "Checksum" + }, + "Cookie": { + "description": "One cookie the daemon replays so an authenticated download works outside the browser.", + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "path": { + "type": "string" + }, + "secure": { + "type": "boolean" + }, + "httpOnly": { + "type": "boolean" + } + }, + "title": "Cookie" + }, + "DownloadSpec": { + "description": "Everything needed to create one task. Shared by download.add and each item of download.addBatch, so the two can never drift apart.", + "type": "object", + "additionalProperties": false, + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "headers": { + "oneOf": [ + { + "$ref": "#/components/schemas/Headers" + }, + { + "type": "null" + } + ] + }, + "cookies": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Cookie" + } + }, + "referrer": { + "type": [ + "string", + "null" + ] + }, + "userAgent": { + "type": [ + "string", + "null" + ] + }, + "filename": { + "type": [ + "string", + "null" + ], + "maxLength": 255, + "description": "Overrides the name derived from Content-Disposition or the URL." + }, + "saveDir": { + "type": [ + "string", + "null" + ], + "description": "Canonicalized and checked against the allowed roots before any write. -32011 if it fails." + }, + "categoryId": { + "type": [ + "string", + "null" + ], + "description": "null means the rules engine picks one." + }, + "queueId": { + "type": [ + "string", + "null" + ], + "description": "Required when startMode is 'queue'." + }, + "segments": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 32, + "description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the per-host cap, and to 1 when the source turns out not to be resumable. What is actually in use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload." + }, + "bufferBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 4096, + "maximum": 8388608 + }, + "startMode": { + "$ref": "#/components/schemas/StartMode" + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 1024 + }, + "checksum": { + "oneOf": [ + { + "$ref": "#/components/schemas/Checksum" + }, + { + "type": "null" + } + ] + } + }, + "title": "DownloadSpec" + }, + "ErrorCode": { + "description": "Every error code the daemon may return. Adding one is a minor bump; changing the meaning of one is a major bump.", + "type": "integer", + "x-enum": [ + { + "name": "ParseError", + "value": -32700, + "doc": "Malformed JSON on the wire." + }, + { + "name": "InvalidRequest", + "value": -32600, + "doc": "Not a valid JSON-RPC 2.0 request object." + }, + { + "name": "MethodNotFound", + "value": -32601, + "doc": "Unknown method name." + }, + { + "name": "InvalidParams", + "value": -32602, + "doc": "Params failed schema validation." + }, + { + "name": "InternalError", + "value": -32603, + "doc": "Unhandled daemon-side failure." + }, + { + "name": "VersionMismatch", + "value": -32001, + "doc": "Protocol major version mismatch. GUI renders this as 'Velox needs updating'." + }, + { + "name": "NotPaired", + "value": -32002, + "doc": "Missing or invalid token on the WebSocket transport." + }, + { + "name": "TransportForbidden", + "value": -32003, + "doc": "Method is privileged and was called over a transport that may not use it." + }, + { + "name": "TaskNotFound", + "value": -32010, + "doc": "No task with that id." + }, + { + "name": "InvalidPath", + "value": -32011, + "doc": "Destination is outside the allowed roots, or is not writable. data.path is set." + }, + { + "name": "DiskFull", + "value": -32012, + "doc": "Not enough free space to preallocate." + }, + { + "name": "ProbeFailed", + "value": -32013, + "doc": "Could not probe the URL. data.httpStatus is set when there was an HTTP response." + }, + { + "name": "RateLimited", + "value": -32014, + "doc": "Pairing brute-force lockout. data.retryAfterSec is set." + } + ], + "enum": [ + -32700, + -32600, + -32601, + -32602, + -32603, + -32001, + -32002, + -32003, + -32010, + -32011, + -32012, + -32013, + -32014 + ], + "title": "ErrorCode" + }, + "GrabberFile": { + "description": "One candidate found by the Site Grabber crawl. Nothing is downloaded until grabber.harvest selects it.", + "type": "object", + "additionalProperties": false, + "required": [ + "fileId", + "url", + "depth" + ], + "properties": { + "fileId": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "filename": { + "type": [ + "string", + "null" + ] + }, + "sizeBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "From a HEAD, when the server answered one." + }, + "contentType": { + "type": [ + "string", + "null" + ] + }, + "depth": { + "type": "integer", + "minimum": 0 + }, + "foundOn": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The page this link was found on." + } + }, + "title": "GrabberFile" + }, + "Headers": { + "description": "HTTP request headers, verbatim as the browser would have sent them. Needed for signed-URL and referrer-gated CDNs.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Headers" + }, + "Limiter": { + "description": "Global token-bucket speed limit. Applies across every active task, not per task.", + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "globalBps" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "globalBps": { + "type": "integer", + "minimum": 0, + "description": "Bytes per second. 0 with enabled true means 'stop everything', which the GUI must not offer." + }, + "applyToRunning": { + "type": "boolean", + "description": "Re-tune already-running transfers instead of waiting for the next task." + } + }, + "title": "Limiter" + }, + "MediaVariant": { + "description": "One quality rendition from an HLS or DASH manifest. The daemon parses the manifest; the extension only renders this list. DRM-protected variants are reported with drm true and must be shown greyed out rather than failing later.", + "type": "object", + "additionalProperties": false, + "required": [ + "variantId", + "kind", + "drm" + ], + "properties": { + "variantId": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "video", + "audio", + "muxed", + "subtitle" + ] + }, + "resolution": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]{2,5}x[0-9]{2,5}$" + }, + "bitrateBps": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "codec": { + "type": [ + "string", + "null" + ] + }, + "container": { + "type": [ + "string", + "null" + ], + "enum": [ + "ts", + "mp4", + "webm", + "mkv", + null + ] + }, + "frameRate": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "sizeEstimate": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "bitrate x duration. Never exact \u2014 the GUI must label it as approximate." + }, + "drm": { + "type": "boolean", + "description": "Widevine/EME detected. Explicitly out of scope; refuse rather than fail mysteriously." + } + }, + "title": "MediaVariant" + }, + "Queue": { + "description": "An ordered run of tasks with its own concurrency cap and optional schedule.", + "type": "object", + "additionalProperties": false, + "required": [ + "queueId", + "name", + "state", + "maxConcurrent" + ], + "properties": { + "queueId": { + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 64 + }, + "state": { + "type": "string", + "enum": [ + "running", + "stopped" + ] + }, + "maxConcurrent": { + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "taskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "In run order. queue.reorder rewrites this." + }, + "schedule": { + "oneOf": [ + { + "$ref": "#/components/schemas/Schedule" + }, + { + "type": "null" + } + ] + }, + "onComplete": { + "type": "string", + "enum": [ + "nothing", + "exit", + "shutdown", + "hangup" + ], + "description": "shutdown goes through org.freedesktop.login1 and must be confirmed by the user." + } + }, + "title": "Queue" + }, + "Rule": { + "description": "One row of the rules engine: match on extension, MIME, host or size, then route. First match by priority wins; no rule matching means the default category.", + "type": "object", + "additionalProperties": false, + "required": [ + "ruleId", + "enabled", + "priority", + "match", + "action" + ], + "properties": { + "ruleId": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ], + "maxLength": 64 + }, + "enabled": { + "type": "boolean" + }, + "priority": { + "type": "integer", + "minimum": 0, + "description": "Lower runs first." + }, + "match": { + "type": "object", + "additionalProperties": false, + "description": "All present clauses must match. An absent clause is not a constraint.", + "properties": { + "extensions": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "mimeTypes": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "hostPattern": { + "type": [ + "string", + "null" + ], + "description": "Glob against the effective URL's host, e.g. *.example.com" + }, + "urlPattern": { + "type": [ + "string", + "null" + ], + "description": "Glob against the whole effective URL." + }, + "minSizeBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "maxSizeBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + } + }, + "action": { + "type": "object", + "additionalProperties": false, + "description": "What to do with a matching download.", + "properties": { + "categoryId": { + "type": [ + "string", + "null" + ] + }, + "saveDir": { + "type": [ + "string", + "null" + ] + }, + "queueId": { + "type": [ + "string", + "null" + ] + }, + "segments": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 32 + }, + "startMode": { + "oneOf": [ + { + "$ref": "#/components/schemas/StartMode" + }, + { + "type": "null" + } + ] + }, + "capture": { + "type": [ + "string", + "null" + ], + "enum": [ + "take", + "ignore", + null + ], + "description": "Lets a rule veto capture for a host without touching the exclusion list." + } + } + } + }, + "title": "Rule" + }, + "Schedule": { + "description": "When a queue may run. Times are local wall-clock in HH:MM; the daemon re-evaluates them on a DST change rather than caching absolute instants.", + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "mode" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "once", + "periodic" + ] + }, + "startTime": { + "type": [ + "string", + "null" + ], + "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "stopTime": { + "type": [ + "string", + "null" + ], + "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$", + "description": "null means run until the queue drains." + }, + "daysOfWeek": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 6 + }, + "description": "0 = Sunday. Ignored when mode is 'once'." + }, + "onceDate": { + "type": [ + "string", + "null" + ], + "format": "date", + "description": "Set only when mode is 'once'." + } + }, + "title": "Schedule" + }, + "Segment": { + "description": "One byte range being fetched by one connection. This is the deepest the contract ever exposes the engine: the GUI draws a bar per segment and is never told what a segment steal is.\n\nRANGE CONVENTION \u2014 READ THIS BEFORE IMPLEMENTING. The range is CLOSED and INCLUSIVE on both ends: [startByte, endByte]. The segment covers endByte - startByte + 1 bytes, and endByte is the index of the LAST byte in the range, not one past it. This deliberately matches the HTTP Range header the engine actually sends ('Range: bytes=-' is a byte-for-byte copy of these two fields, and RFC 9110 ranges are inclusive), so no arithmetic happens between the wire and the socket and there is nowhere for an off-by-one to hide. CORE asked for half-open [start, end); PROTO chose inclusive for that reason and this note exists so nobody discovers the difference at integration. A segment always covers at least one byte: endByte >= startByte always holds. An empty range is not representable and is not needed \u2014 a zero-length download carries an empty segmentDetail array, and a segment that has donated its remainder to a steal keeps the bytes it already wrote.", + "type": "object", + "additionalProperties": false, + "required": [ + "index", + "startByte", + "endByte", + "downloadedBytes", + "state" + ], + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "maximum": 31, + "description": "Position in TaskDetail.segmentDetail. Spelled 'index' here and in event.task.progress; there is no 'i' spelling anywhere in the contract." + }, + "startByte": { + "type": "integer", + "minimum": 0, + "description": "Absolute offset of the first byte of the range. Inclusive." + }, + "endByte": { + "type": "integer", + "minimum": 0, + "description": "Absolute offset of the LAST byte of the range. Inclusive \u2014 this is not one-past-the-end. Always >= startByte." + }, + "downloadedBytes": { + "type": "integer", + "minimum": 0, + "description": "Bytes written for this range so far, out of endByte - startByte + 1." + }, + "speedBps": { + "type": "integer", + "minimum": 0 + }, + "state": { + "type": "string", + "enum": [ + "pending", + "connecting", + "downloading", + "stalled", + "complete", + "failed" + ], + "description": "'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has been planned but not yet dialled." + }, + "httpStatus": { + "type": [ + "integer", + "null" + ], + "minimum": 100, + "maximum": 599, + "description": "The status this segment's request got. 206 on a healthy ranged fetch." + } + }, + "title": "Segment" + }, + "SettingKey": { + "description": "Every settings key that exists. The Options dialog maps 1:1 onto this list and the GUI must not invent a key that is not here. Kept in lockstep with Settings.schema.json by a conformance check.", + "type": "string", + "enum": [ + "general.launchOnLogin", + "general.minimizeToTray", + "general.showDropTarget", + "general.confirmOnExit", + "general.language", + "general.checkForUpdates", + "capture.enabled", + "capture.monitoredExtensions", + "capture.monitoredMimeTypes", + "capture.minSizeBytes", + "capture.excludedHosts", + "capture.bypassModifier", + "capture.autoStartTypes", + "saveTo.defaultDir", + "saveTo.tempDir", + "saveTo.allowedRoots", + "saveTo.fileExistsPolicy", + "saveTo.createSubfolderPerSite", + "connection.preset", + "connection.maxSegmentsPerDownload", + "connection.bufferBytes", + "connection.maxConcurrentDownloads", + "connection.timeoutSec", + "connection.maxRetries", + "connection.retryBackoffSec", + "downloads.speedLimitBps", + "downloads.speedLimitEnabled", + "downloads.virusScanCommand", + "downloads.postDownloadCommand", + "downloads.duplicatePolicy", + "downloads.verifyChecksums", + "proxy.mode", + "proxy.host", + "proxy.port", + "proxy.username", + "proxy.bypassHosts", + "proxy.pacUrl", + "sounds.enabled", + "sounds.onComplete", + "sounds.onQueueComplete", + "sounds.onError" + ], + "title": "SettingKey" + }, + "Settings": { + "description": "A sparse bag of settings. Every property is optional because settings.get returns only the keys that were asked for and settings.set carries only the keys that changed. Property names must match SettingKey exactly. NOTE: no password lives here \u2014 proxy and site-login credentials go to the Secret Service, never to SQLite and never over the wire.", + "type": "object", + "additionalProperties": false, + "properties": { + "general.launchOnLogin": { + "type": "boolean" + }, + "general.minimizeToTray": { + "type": "boolean" + }, + "general.showDropTarget": { + "type": "boolean" + }, + "general.confirmOnExit": { + "type": "boolean" + }, + "general.language": { + "type": "string", + "description": "BCP 47, or 'system'." + }, + "general.checkForUpdates": { + "type": "boolean" + }, + "capture.enabled": { + "type": "boolean" + }, + "capture.monitoredExtensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "capture.monitoredMimeTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "capture.minSizeBytes": { + "type": "integer", + "minimum": 0 + }, + "capture.excludedHosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "capture.bypassModifier": { + "$ref": "#/components/schemas/BypassModifier" + }, + "capture.autoStartTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Extensions that skip the File Info dialog and start immediately." + }, + "saveTo.defaultDir": { + "type": "string" + }, + "saveTo.tempDir": { + "type": "string" + }, + "saveTo.allowedRoots": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Every write target is canonicalized and must resolve inside one of these. Read-only over the WebSocket transport." + }, + "saveTo.fileExistsPolicy": { + "type": "string", + "enum": [ + "ask", + "rename", + "overwrite", + "resume" + ] + }, + "saveTo.createSubfolderPerSite": { + "type": "boolean" + }, + "connection.preset": { + "type": "string", + "enum": [ + "auto", + "lan", + "broadband", + "slow" + ] + }, + "connection.maxSegmentsPerDownload": { + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "connection.bufferBytes": { + "type": "integer", + "minimum": 4096, + "maximum": 8388608 + }, + "connection.maxConcurrentDownloads": { + "type": "integer", + "minimum": 1, + "maximum": 64 + }, + "connection.timeoutSec": { + "type": "integer", + "minimum": 1, + "maximum": 3600 + }, + "connection.maxRetries": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "connection.retryBackoffSec": { + "type": "integer", + "minimum": 0, + "maximum": 3600 + }, + "downloads.speedLimitBps": { + "type": "integer", + "minimum": 0 + }, + "downloads.speedLimitEnabled": { + "type": "boolean" + }, + "downloads.virusScanCommand": { + "type": "string" + }, + "downloads.postDownloadCommand": { + "type": "string" + }, + "downloads.duplicatePolicy": { + "type": "string", + "enum": [ + "ask", + "skip", + "rename", + "redownload" + ] + }, + "downloads.verifyChecksums": { + "type": "boolean" + }, + "proxy.mode": { + "type": "string", + "enum": [ + "system", + "none", + "http", + "https", + "socks5", + "pac" + ] + }, + "proxy.host": { + "type": "string" + }, + "proxy.port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "proxy.username": { + "type": "string" + }, + "proxy.bypassHosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "proxy.pacUrl": { + "type": "string" + }, + "sounds.enabled": { + "type": "boolean" + }, + "sounds.onComplete": { + "type": "string" + }, + "sounds.onQueueComplete": { + "type": "string" + }, + "sounds.onError": { + "type": "string" + } + }, + "title": "Settings" + }, + "StartMode": { + "description": "What the daemon does with a task the moment it is added. 'later' is the File Info dialog's Download Later button and lands the task in paused.", + "type": "string", + "enum": [ + "now", + "later", + "queue" + ], + "title": "StartMode" + }, + "TaskDetail": { + "description": "Everything TaskSummary carries, plus what only the progress dialog and the File Info dialog need. Returned by download.get; never sent in a list or an event, because it is expensive to build.", + "type": "object", + "additionalProperties": false, + "required": [ + "summary", + "segmentDetail" + ], + "properties": { + "summary": { + "$ref": "#/components/schemas/TaskSummary" + }, + "segmentDetail": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/components/schemas/Segment" + }, + "description": "Exactly TaskSummary.segments entries, in index order, covering [0, sizeBytes) with no gaps and no overlaps. Empty for a zero-length download, and empty before the task has been segmented." + }, + "headers": { + "oneOf": [ + { + "$ref": "#/components/schemas/Headers" + }, + { + "type": "null" + } + ] + }, + "referrer": { + "type": [ + "string", + "null" + ] + }, + "userAgent": { + "type": [ + "string", + "null" + ] + }, + "mime": { + "type": [ + "string", + "null" + ] + }, + "bufferBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 4096, + "maximum": 8388608 + }, + "partPath": { + "type": [ + "string", + "null" + ], + "description": "Absolute path of the .veloxpart file while the task is unfinished." + }, + "checksum": { + "oneOf": [ + { + "$ref": "#/components/schemas/Checksum" + }, + { + "type": "null" + } + ] + }, + "checksumVerified": { + "type": [ + "boolean", + "null" + ], + "description": "null until the verifying state has run." + }, + "averageSpeedBps": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "retryCount": { + "type": "integer", + "minimum": 0 + } + }, + "title": "TaskDetail" + }, + "TaskError": { + "description": "Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which describes a failed call rather than a failed download \u2014 the two live in different code spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer.", + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message", + "retryable" + ], + "properties": { + "code": { + "$ref": "#/components/schemas/TaskErrorCode" + }, + "message": { + "type": "string", + "description": "Human-readable, safe to show a user. Never carries a credential, a token or a full local path outside the download roots." + }, + "httpStatus": { + "type": [ + "integer", + "null" + ], + "minimum": 100, + "maximum": 599, + "description": "Set for the codes listed in TaskErrorCode's x-carriesHttpStatus, and null otherwise." + }, + "retryable": { + "type": "boolean", + "description": "Whether the scheduler will pick this task up again on its own. Carried per-occurrence rather than derived from the code, because 'probe_failed' is retryable or not depending on what the probe hit." + }, + "cause": { + "oneOf": [ + { + "$ref": "#/components/schemas/TaskErrorCode" + }, + { + "type": "null" + } + ], + "description": "The underlying failure, for codes that wrap one. max_retries_exhausted sets it to whatever the last attempt actually failed with, so a user learns the reason rather than just that Velox gave up." + }, + "attempt": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "How many attempts have been made so far." + }, + "nextRetryAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + "title": "TaskError" + }, + "TaskErrorCode": { + "description": "Why a download failed. This is the WIRE failure taxonomy and it is deliberately NOT the JSON-RPC ErrorCode space: ErrorCode says why a *call* failed, TaskErrorCode says why a *download* failed. A task can fail while every RPC involved succeeded. The values mirror vdm::Error in core/include/vdm/util/error.hpp one-for-one, by name, so DAEMON's projection from the engine taxonomy onto the wire is lossless and the GUI can tell 'the file on the server changed' from 'the checksum did not match'. CORE's 'ok' has no wire spelling: a TaskError only exists when there is a failure. Adding a value here is a minor bump; renaming or removing one is major, and would desynchronise the engine.", + "type": "string", + "enum": [ + "canceled", + "resolve_failed", + "connect_failed", + "tls_failed", + "connection_reset", + "timeout", + "too_many_redirects", + "http_client_error", + "http_server_error", + "auth_required", + "forbidden", + "not_found", + "range_not_satisfiable", + "gone", + "server_file_changed", + "content_length_mismatch", + "checksum_mismatch", + "disk_full", + "io_error", + "path_rejected", + "permission_denied", + "meta_corrupt", + "meta_version_unsupported", + "probe_failed", + "unsupported_url_scheme", + "max_retries_exhausted", + "internal" + ], + "x-groups": { + "cancellation": [ + "canceled" + ], + "network": [ + "resolve_failed", + "connect_failed", + "tls_failed", + "connection_reset", + "timeout", + "too_many_redirects" + ], + "http": [ + "http_client_error", + "http_server_error", + "auth_required", + "forbidden", + "not_found", + "range_not_satisfiable", + "gone" + ], + "content": [ + "server_file_changed", + "content_length_mismatch", + "checksum_mismatch" + ], + "localIo": [ + "disk_full", + "io_error", + "path_rejected", + "permission_denied" + ], + "resumeMetadata": [ + "meta_corrupt", + "meta_version_unsupported" + ], + "probe": [ + "probe_failed", + "unsupported_url_scheme" + ], + "retry": [ + "max_retries_exhausted" + ], + "internal": [ + "internal" + ] + }, + "x-carriesHttpStatus": [ + "http_client_error", + "http_server_error", + "auth_required", + "forbidden", + "not_found", + "range_not_satisfiable", + "gone", + "server_file_changed", + "probe_failed", + "max_retries_exhausted" + ], + "title": "TaskErrorCode" + }, + "TaskFilter": { + "description": "Which rows download.list returns. This is the category tree and the All/Unfinished/Finished nodes, expressed on the wire. Absent clauses are not constraints.", + "type": "object", + "additionalProperties": false, + "properties": { + "states": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/TaskState" + } + }, + "categoryId": { + "type": [ + "string", + "null" + ] + }, + "queueId": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ], + "maxLength": 256, + "description": "Case-insensitive substring of filename or url." + }, + "addedAfter": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "addedBefore": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + "title": "TaskFilter" + }, + "TaskSort": { + "description": "Sort order for download.list. The GUI persists the user's choice and sends it on every list call; the daemon does the sorting so a 100k-row list never has to be materialized client-side.", + "type": "object", + "additionalProperties": false, + "required": [ + "field", + "direction" + ], + "properties": { + "field": { + "type": "string", + "enum": [ + "filename", + "sizeBytes", + "state", + "etaSeconds", + "speedBps", + "lastTryAt", + "createdAt", + "queuePosition", + "description" + ] + }, + "direction": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + "title": "TaskSort" + }, + "TaskState": { + "description": "Lifecycle of one download. The daemon is the only writer; clients render it and nothing more. Terminal states are complete, failed and cancelled.", + "type": "string", + "enum": [ + "new", + "probing", + "queued", + "connecting", + "downloading", + "paused", + "retry_wait", + "assembling", + "verifying", + "complete", + "failed", + "cancelled" + ], + "title": "TaskState" + }, + "TaskSummary": { + "description": "One row of the main download list. Everything the GUI table needs, and nothing more. TaskDetail is the same shape plus the fields only the progress dialog and File Info dialog need.", + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "filename", + "saveDir", + "url", + "state", + "downloadedBytes", + "speedBps", + "resumable", + "segments", + "createdAt" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "filename": { + "type": "string", + "maxLength": 255 + }, + "saveDir": { + "type": "string", + "description": "Absolute, canonicalized, inside an allowed root." + }, + "url": { + "type": "string", + "format": "uri", + "description": "The URL as the user or the extension supplied it." + }, + "effectiveUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "After redirects. null until the first probe succeeds." + }, + "sizeBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "null when the server did not report a length." + }, + "downloadedBytes": { + "type": "integer", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/TaskState" + }, + "speedBps": { + "type": "integer", + "minimum": 0 + }, + "etaSeconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "null when the size or the speed is unknown." + }, + "resumable": { + "type": "boolean" + }, + "segments": { + "type": "integer", + "minimum": 1, + "maximum": 32, + "description": "The EFFECTIVE connection count in use right now \u2014 not the number that was requested. It is what remains after the per-host connection cap has been applied and after the demotion to 1 for a non-resumable source, so a task the user asked for 16 connections on legitimately reports 4, or 1. The GUI displays this value and must not assume it equals what download.add asked for. The requested value lives in DownloadSpec.segments and is not echoed back on this type. TaskDetail.segmentDetail always has exactly this many entries." + }, + "categoryId": { + "type": [ + "string", + "null" + ] + }, + "queueId": { + "type": [ + "string", + "null" + ] + }, + "queuePosition": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "The Q column." + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 1024 + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "lastTryAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "completedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "error": { + "oneOf": [ + { + "$ref": "#/components/schemas/TaskError" + }, + { + "type": "null" + } + ] + } + }, + "title": "TaskSummary" + } + } + }, + "x-events": [ + { + "name": "event.auth.required", + "description": "A server asked for credentials. The task sits in retry_wait until the client supplies them. Credentials travel to the Secret Service, never back through this event and never into a log.", + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "host", + "scheme" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": "string" + }, + "realm": { + "type": [ + "string", + "null" + ] + }, + "scheme": { + "type": "string", + "enum": [ + "basic", + "digest", + "ntlm", + "negotiate", + "proxy" + ] + } + } + }, + "x-maxRateHz": null + }, + { + "name": "event.grabber.progress", + "description": "Crawl progress for the Site Grabber wizard. done true means the file list in grabber.status is final.", + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "jobId", + "found", + "crawled", + "done" + ], + "properties": { + "jobId": { + "type": "string" + }, + "found": { + "type": "integer", + "minimum": 0 + }, + "crawled": { + "type": "integer", + "minimum": 0 + }, + "done": { + "type": "boolean" + }, + "currentUrl": { + "type": [ + "string", + "null" + ], + "format": "uri" + } + } + }, + "x-maxRateHz": 4 + }, + { + "name": "event.notify", + "description": "Something the user should see: a completion, a failure, a queue finishing. The client decides between a toast, a tray balloon and a sound; the daemon does not assume a GUI is running.", + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "level", + "title", + "body" + ], + "properties": { + "level": { + "type": "string", + "enum": [ + "info", + "success", + "warning", + "error" + ] + }, + "body": { + "type": "string", + "maxLength": 1024 + }, + "taskId": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "sound": { + "type": [ + "string", + "null" + ], + "enum": [ + "complete", + "queueComplete", + "error", + null + ] + } + } + }, + "x-maxRateHz": null + }, + { + "name": "event.settings.changed", + "description": "Settings were written by some client. Carries only the key names; a client re-reads what it cares about. The extension watches for capture.* here and re-fetches capture.getRules so its rules never lag the daemon's.", + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "keys" + ], + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SettingKey" + } + } + } + }, + "x-maxRateHz": null + }, + { + "name": "event.speed.global", + "description": "Aggregate throughput for the status bar, the tray tooltip and the extension popup. Emitted at 1 Hz even when nothing is active, so a client can tell 'idle' from 'disconnected'.", + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "downBps", + "activeCount" + ], + "properties": { + "downBps": { + "type": "integer", + "minimum": 0 + }, + "activeCount": { + "type": "integer", + "minimum": 0 + }, + "queuedCount": { + "type": "integer", + "minimum": 0 + }, + "limitBps": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "null when the limiter is off." + } + } + }, + "x-maxRateHz": 1 + }, + { + "name": "event.task.added", + "description": "A task entered the list. summary is always present so a client can insert the row without a follow-up download.get.", + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "summary" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "summary": { + "$ref": "#/components/schemas/TaskSummary" + } + } + }, + "x-maxRateHz": null + }, + { + "name": "event.task.progress", + "description": "Batched byte counters for every active task. Emitted at no more than 4 Hz as one array, never one notification per task: at twenty active downloads that is four messages a second instead of eighty. Clients apply a row patch and repaint the touched columns; rebuilding a model on this event is a bug.", + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "tasks", + "at" + ], + "properties": { + "tasks": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "downloadedBytes", + "speedBps" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "downloadedBytes": { + "type": "integer", + "minimum": 0 + }, + "speedBps": { + "type": "integer", + "minimum": 0 + }, + "etaSeconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "segments": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "description": "Only what a segment bar needs. Full segment state comes from download.get.", + "required": [ + "index", + "downloadedBytes", + "speedBps" + ], + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "maximum": 31 + }, + "downloadedBytes": { + "type": "integer", + "minimum": 0 + }, + "speedBps": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + } + }, + "at": { + "type": "string", + "format": "date-time" + } + } + }, + "x-maxRateHz": 4 + }, + { + "name": "event.task.removed", + "description": "A task left the list. The client deletes the row; there is nothing further to fetch.", + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "deletedFile" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "deletedFile": { + "type": "boolean" + } + } + }, + "x-maxRateHz": null + }, + { + "name": "event.task.state", + "description": "A task changed lifecycle state. Carries the summary so the row can be repainted in full without a round trip, and error whenever the new state is failed or retry_wait.", + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "state" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "#/components/schemas/TaskState" + }, + "previousState": { + "oneOf": [ + { + "$ref": "#/components/schemas/TaskState" + }, + { + "type": "null" + } + ] + }, + "summary": { + "oneOf": [ + { + "$ref": "#/components/schemas/TaskSummary" + }, + { + "type": "null" + } + ] + }, + "error": { + "oneOf": [ + { + "$ref": "#/components/schemas/TaskError" + }, + { + "type": "null" + } + ] + } + } + }, + "x-maxRateHz": null + } + ], + "x-envelope": { + "Id": { + "description": "Request correlation id. Velox clients always send an integer; string ids are accepted for spec compliance. A notification has no id.", + "type": [ + "integer", + "string" + ] + }, + "Request": { + "type": "object", + "additionalProperties": false, + "required": [ + "jsonrpc", + "method" + ], + "properties": { + "jsonrpc": { + "const": "2.0" + }, + "id": { + "$ref": "#/x-envelope/Id" + }, + "method": { + "type": "string" + }, + "params": { + "type": "object" + } + } + }, + "Response": { + "description": "Exactly one of result or error is present.", + "type": "object", + "additionalProperties": false, + "required": [ + "jsonrpc", + "id" + ], + "properties": { + "jsonrpc": { + "const": "2.0" + }, + "id": { + "$ref": "#/x-envelope/Id" + }, + "result": {}, + "error": { + "$ref": "#/x-envelope/Error" + } + } + }, + "Notification": { + "description": "Server to client. No id, never answered.", + "type": "object", + "additionalProperties": false, + "required": [ + "jsonrpc", + "method", + "params" + ], + "properties": { + "jsonrpc": { + "const": "2.0" + }, + "method": { + "type": "string" + }, + "params": { + "type": "object" + } + } + }, + "Error": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, + "message": { + "type": "string" + }, + "data": { + "type": [ + "object", + "null" + ], + "description": "Code-specific detail. -32013 carries data.httpStatus; -32011 carries data.path; -32014 carries data.retryAfterSec.", + "properties": { + "httpStatus": { + "type": [ + "integer", + "null" + ] + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "retryAfterSec": { + "type": [ + "integer", + "null" + ] + }, + "taskId": { + "type": [ + "string", + "null" + ] + }, + "expected": { + "type": [ + "string", + "null" + ] + }, + "actual": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, + "x-transports": { + "uds": "Unix domain socket, newline-delimited JSON.", + "ws": "Loopback WebSocket, one JSON message per text frame. Privileged methods are refused here with -32003." + } +} diff --git a/contracts/proto-answers-m1.md b/contracts/proto-answers-m1.md new file mode 100644 index 0000000..92de964 --- /dev/null +++ b/contracts/proto-answers-m1.md @@ -0,0 +1,126 @@ +# PROTO → CORE — answers to `core/docs/proto-requests-m1.md` + +Status: **answered**. Against `contracts/` at **1.0.0** (`lane/proto`, not yet on `main`). +Raised by CORE at `1.0.0-draft`; every freeze-blocker is resolved below. + +Read `docs/adr/0010-task-error-taxonomy-and-segment-ranges.md` for the reasoning on +B1/B2/B3. This file is the index and the parts CORE has to act on. + +--- + +## Freeze-blockers — all three are in 1.0.0 + +### B1 — a frozen wire enum for the task failure code · **done, as a string enum** + +`TaskError.code` was a bare `integer`. It is now +[`TaskErrorCode`](schema/types/TaskErrorCode.schema.json): a **string enum with your 27 +failure values, mirrored by name and in your order**, verified against +`core/include/vdm/util/error.hpp` mechanically rather than by eye. `ok` has no wire +spelling — a `TaskError` only exists when something failed. + +You were right that this was blocker #1, and right about the diagnosis: the draft typed +`code` as the JSON-RPC integer while its own description said "distinct from the JSON-RPC +Error". Those are two code spaces. `ErrorCode` says why a **call** failed; `TaskErrorCode` +says why a **download** failed, and a download fails while every RPC succeeds. + +Strings, not your grouped-integer fallback: the mapping is lossless with no numbering +scheme maintained in two repos that cannot include each other's headers, and a log line +reads `"server_file_changed"` instead of `407`. + +Two details you should design against: + +* **`retryable` stays a per-occurrence boolean**, not a property of the code — because your + own table has `probe_failed` as "maybe". Emit it per failure. +* **`cause`** is new on `TaskError` and carries a `TaskErrorCode`. It exists for + `max_retries_exhausted`, which your header says has a `cause`: put the last underlying + `Error` there so the user is told what actually kept failing. + +`httpStatus` is expected for the codes in `TaskErrorCode`'s `x-carriesHttpStatus` +annotation, which matches the "carries httpStatus" column of your table. + +### B2 — the meaning of `TaskSummary.segments` · **done, frozen as effective** + +> "The **EFFECTIVE** connection count in use right now — not the number that was +> requested. What remains after the per-host connection cap and after the demotion to 1 +> for a non-resumable source." + +The requested value stays in `DownloadSpec.segments`, which now says so on its own +description, as does `download.update`'s `patch.segments`. `TaskDetail.segmentDetail` +carries exactly `TaskSummary.segments` entries, and conformance checks that. + +### B3 — `Segment` field names, and the range convention · **done, but read this** + +**(a) `index` vs `i`** — settled as `index`, everywhere. There is no `i` spelling in the +contract; `event.task.progress`'s per-segment entries use `index` too. Nothing to +reconcile, it was already consistent. + +**(c) the state enum** — `pending | connecting | downloading | stalled | complete | failed`. +Spelled **`downloading`** as you asked, matching `TaskState`; the draft's `receiving` is +gone. `pending` is added for a range planned but not yet dialled — if the engine never +reports that, ignore it. + +**(b) the range convention — this is the one that will bite you if you skim.** + +> ### Ranges are CLOSED and INCLUSIVE: `[startByte, endByte]`. +> `endByte` is the index of the **last byte**, not one past it. +> The segment covers `endByte - startByte + 1` bytes. + +You asked for half-open `[start, end)`. **PROTO chose inclusive and did not adopt your +convention** — this notice is the point of this document, and it is deliberately before you +build stage 6. + +The reason: these two fields are copied verbatim into `Range: bytes=-`, and +RFC 9110 byte ranges are inclusive. Inclusive means no arithmetic at all between the wire +and the socket. Half-open means a `-1` at every boundary between the contract and every +HTTP request the engine makes — which is exactly where off-by-ones live. + +Field names stayed `startByte` / `endByte` / `downloadedBytes` rather than your +`start` / `end` / `completed`, partly so that code written against the half-open spelling +does not silently compile against inclusive fields. + +While fixing this we found a real contradiction in the draft: it encoded an empty segment +as `endByte == startByte - 1`, which is `-1` at offset 0 — and every download's first +segment starts at 0, so the schema's own `minimum: 0` rejected it. **Empty ranges are no +longer representable and are not needed.** `endByte >= startByte` always holds; a +zero-length download carries an empty `segmentDetail`; a segment that donates its remainder +to a steal keeps the bytes it already wrote. If the engine has a state that genuinely needs +an empty range, say so now — that is a schema change, not something to encode around. + +`tests/conformance/check_contract.py` enforces contiguity, coverage of exactly +`[0, sizeBytes - 1]`, `downloadedBytes <= endByte - startByte + 1`, and the entry count. +A fixture flipped to half-open fails it. + +--- + +## Not gating the freeze — the follow-up queue + +Agreed with your ranking: these are minor under rule 4 and land as small PRs to +`contracts/` alone. They are **not** in 1.0.0. Ranked by when M1 needs them. + +| # | Item | Verdict | Shape | +|---|---|---|---| +| **B2a** | readable effective buffer size | **accepted** | `effectiveBufferBytes` on `TaskSummary`, next to the effective segment count, so the requested/effective split reads the same way for both. You are right about the `additionalProperties: false` trap — no daemon can tack it on, so it needs a schema PR either way. | +| **F2** | credential return path for 401/407 | **accepted as proposed** | `download.provideAuth {taskId, username, password, save?}` → `{ok}`. Unix socket only, privileged: a credential-bearing method must never be reachable from the browser. Secrets go to the Secret Service; `save` only tells DAEMON whether to persist. | +| **F1** | "needs user decision" carrier | **the simple option** | `state: paused` + `event.notify` is the intended carrier for M1: CORE reports `server_file_changed`, DAEMON pauses and notifies, GUI offers restart. A dedicated `event.task.decision` + `download.decide` is a real design with a state machine attached, and it should not be invented in a hurry — raise it again in M3 if the notify path proves too thin. A string comparison on `error.code` covers the engine side either way, which is now a `TaskErrorCode` comparison rather than a magic number. | +| **F3** | `checksum` string format | **already frozen, differently** | `download.add {checksum}` is **not** a string. It is a `Checksum` object: `{algorithm: "md5"\|"sha1"\|"sha256"\|"sha512", value: ""}`, with `value` patterned `^[0-9a-fA-F]{32,128}$`. Parse your `":"` form at the CLI or GUI edge, not on the wire. Note `sha512` is accepted by the contract even though the appendix lists MD5/SHA-256 — reject it in the engine if you do not implement it, rather than the contract forbidding it. | + +Raise B2a and F2 as requests whenever you need them and PROTO will land them together; +neither blocks anything you are building this week. + +--- + +## D1 — state-machine ownership + +Your proposed split is right and PROTO does not dispute any of it: CORE owns +`probing → connecting → downloading ⇄ paused → retry_wait → assembling → verifying → +complete | failed` plus `cancelled` from anywhere; DAEMON owns `new`, `queued` and +pause-for-schedule; `paused` is shared and both sides must be idempotent about it. + +PROTO will not write that ADR alone. It is a three-way decision and the lane that owns +neither half writing it down is how a decision gets recorded that DAEMON never agreed to. +**DAEMON should draft it, CORE and PROTO review.** The contract's part is already frozen: +`TaskState` has the twelve values, and the wire does not encode who drove a transition. + +One thing that *is* PROTO's and worth stating: `event.task.state` carries `previousState`, +so a client can render a transition without keeping its own state machine. Neither CORE nor +DAEMON should assume a client tracks lifecycle — clients render what they are told. diff --git a/contracts/schema/envelope.schema.json b/contracts/schema/envelope.schema.json new file mode 100644 index 0000000..325f1ae --- /dev/null +++ b/contracts/schema/envelope.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/envelope.schema.json", + "title": "Envelope", + "description": "JSON-RPC 2.0 envelope as Velox uses it, plus the project's error codes. Every transport (UDS NDJSON, native-messaging length-prefix, loopback WebSocket) carries exactly these payloads; only the framing differs.", + "$defs": { + "Id": { + "title": "Id", + "description": "Request correlation id. Velox clients always send an integer; string ids are accepted for spec compliance. A notification has no id.", + "type": ["integer", "string"] + }, + "Request": { + "title": "Request", + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "method"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/Id" }, + "method": { "type": "string" }, + "params": { "type": "object" } + } + }, + "Response": { + "title": "Response", + "description": "Exactly one of result or error is present.", + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "id"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/Id" }, + "result": {}, + "error": { "$ref": "#/$defs/Error" } + } + }, + "Notification": { + "title": "Notification", + "description": "Server to client. No id, never answered.", + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "method", "params"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "method": { "type": "string" }, + "params": { "type": "object" } + } + }, + "Error": { + "title": "Error", + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { "$ref": "https://velox.dev/schema/types/ErrorCode.schema.json" }, + "message": { "type": "string" }, + "data": { + "type": ["object", "null"], + "description": "Code-specific detail. -32013 carries data.httpStatus; -32011 carries data.path; -32014 carries data.retryAfterSec.", + "properties": { + "httpStatus": { "type": ["integer", "null"] }, + "path": { "type": ["string", "null"] }, + "retryAfterSec": { "type": ["integer", "null"] }, + "taskId": { "type": ["string", "null"] }, + "expected": { "type": ["string", "null"] }, + "actual": { "type": ["string", "null"] } + } + } + } + } + } +} diff --git a/contracts/schema/events/event.auth.required.schema.json b/contracts/schema/events/event.auth.required.schema.json new file mode 100644 index 0000000..f50f353 --- /dev/null +++ b/contracts/schema/events/event.auth.required.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/events/event.auth.required.schema.json", + "title": "event.auth.required", + "description": "A server asked for credentials. The task sits in retry_wait until the client supplies them. Credentials travel to the Secret Service, never back through this event and never into a log.", + "x-direction": "server-to-client", + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "host", + "scheme" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "host": { + "type": "string" + }, + "realm": { + "type": [ + "string", + "null" + ] + }, + "scheme": { + "type": "string", + "enum": [ + "basic", + "digest", + "ntlm", + "negotiate", + "proxy" + ] + } + } + } + } +} diff --git a/contracts/schema/events/event.grabber.progress.schema.json b/contracts/schema/events/event.grabber.progress.schema.json new file mode 100644 index 0000000..61dbf7d --- /dev/null +++ b/contracts/schema/events/event.grabber.progress.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/events/event.grabber.progress.schema.json", + "title": "event.grabber.progress", + "description": "Crawl progress for the Site Grabber wizard. done true means the file list in grabber.status is final.", + "x-direction": "server-to-client", + "x-maxRateHz": 4, + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "jobId", + "found", + "crawled", + "done" + ], + "properties": { + "jobId": { + "type": "string" + }, + "found": { + "type": "integer", + "minimum": 0 + }, + "crawled": { + "type": "integer", + "minimum": 0 + }, + "done": { + "type": "boolean" + }, + "currentUrl": { + "type": [ + "string", + "null" + ], + "format": "uri" + } + } + } + } +} diff --git a/contracts/schema/events/event.notify.schema.json b/contracts/schema/events/event.notify.schema.json new file mode 100644 index 0000000..4e6fd79 --- /dev/null +++ b/contracts/schema/events/event.notify.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/events/event.notify.schema.json", + "title": "event.notify", + "description": "Something the user should see: a completion, a failure, a queue finishing. The client decides between a toast, a tray balloon and a sound; the daemon does not assume a GUI is running.", + "x-direction": "server-to-client", + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "level", + "title", + "body" + ], + "properties": { + "level": { + "type": "string", + "enum": [ + "info", + "success", + "warning", + "error" + ] + }, + "title": { + "type": "string", + "maxLength": 128 + }, + "body": { + "type": "string", + "maxLength": 1024 + }, + "taskId": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "sound": { + "type": [ + "string", + "null" + ], + "enum": [ + "complete", + "queueComplete", + "error", + null + ] + } + } + } + } +} diff --git a/contracts/schema/events/event.settings.changed.schema.json b/contracts/schema/events/event.settings.changed.schema.json new file mode 100644 index 0000000..89e36d0 --- /dev/null +++ b/contracts/schema/events/event.settings.changed.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/events/event.settings.changed.schema.json", + "title": "event.settings.changed", + "description": "Settings were written by some client. Carries only the key names; a client re-reads what it cares about. The extension watches for capture.* here and re-fetches capture.getRules so its rules never lag the daemon's.", + "x-direction": "server-to-client", + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "keys" + ], + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "https://velox.dev/schema/types/SettingKey.schema.json" + } + } + } + } + } +} diff --git a/contracts/schema/events/event.speed.global.schema.json b/contracts/schema/events/event.speed.global.schema.json new file mode 100644 index 0000000..ee8bad9 --- /dev/null +++ b/contracts/schema/events/event.speed.global.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/events/event.speed.global.schema.json", + "title": "event.speed.global", + "description": "Aggregate throughput for the status bar, the tray tooltip and the extension popup. Emitted at 1 Hz even when nothing is active, so a client can tell 'idle' from 'disconnected'.", + "x-direction": "server-to-client", + "x-maxRateHz": 1, + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "downBps", + "activeCount" + ], + "properties": { + "downBps": { + "type": "integer", + "minimum": 0 + }, + "activeCount": { + "type": "integer", + "minimum": 0 + }, + "queuedCount": { + "type": "integer", + "minimum": 0 + }, + "limitBps": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "null when the limiter is off." + } + } + } + } +} diff --git a/contracts/schema/events/event.task.added.schema.json b/contracts/schema/events/event.task.added.schema.json new file mode 100644 index 0000000..7cf9c53 --- /dev/null +++ b/contracts/schema/events/event.task.added.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/events/event.task.added.schema.json", + "title": "event.task.added", + "description": "A task entered the list. summary is always present so a client can insert the row without a follow-up download.get.", + "x-direction": "server-to-client", + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "summary" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "summary": { + "$ref": "https://velox.dev/schema/types/TaskSummary.schema.json" + } + } + } + } +} diff --git a/contracts/schema/events/event.task.progress.schema.json b/contracts/schema/events/event.task.progress.schema.json new file mode 100644 index 0000000..03967ec --- /dev/null +++ b/contracts/schema/events/event.task.progress.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/events/event.task.progress.schema.json", + "title": "event.task.progress", + "description": "Batched byte counters for every active task. Emitted at no more than 4 Hz as one array, never one notification per task: at twenty active downloads that is four messages a second instead of eighty. Clients apply a row patch and repaint the touched columns; rebuilding a model on this event is a bug.", + "x-direction": "server-to-client", + "x-maxRateHz": 4, + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "tasks", + "at" + ], + "properties": { + "tasks": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "downloadedBytes", + "speedBps" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "downloadedBytes": { + "type": "integer", + "minimum": 0 + }, + "speedBps": { + "type": "integer", + "minimum": 0 + }, + "etaSeconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "segments": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "description": "Only what a segment bar needs. Full segment state comes from download.get.", + "required": [ + "index", + "downloadedBytes", + "speedBps" + ], + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "maximum": 31 + }, + "downloadedBytes": { + "type": "integer", + "minimum": 0 + }, + "speedBps": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + } + }, + "at": { + "type": "string", + "format": "date-time" + } + } + } + } +} diff --git a/contracts/schema/events/event.task.removed.schema.json b/contracts/schema/events/event.task.removed.schema.json new file mode 100644 index 0000000..7c1d7aa --- /dev/null +++ b/contracts/schema/events/event.task.removed.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/events/event.task.removed.schema.json", + "title": "event.task.removed", + "description": "A task left the list. The client deletes the row; there is nothing further to fetch.", + "x-direction": "server-to-client", + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "deletedFile" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "deletedFile": { + "type": "boolean" + } + } + } + } +} diff --git a/contracts/schema/events/event.task.state.schema.json b/contracts/schema/events/event.task.state.schema.json new file mode 100644 index 0000000..408dd10 --- /dev/null +++ b/contracts/schema/events/event.task.state.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/events/event.task.state.schema.json", + "title": "event.task.state", + "description": "A task changed lifecycle state. Carries the summary so the row can be repainted in full without a round trip, and error whenever the new state is failed or retry_wait.", + "x-direction": "server-to-client", + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "state" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "https://velox.dev/schema/types/TaskState.schema.json" + }, + "previousState": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/TaskState.schema.json" + }, + { + "type": "null" + } + ] + }, + "summary": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/TaskSummary.schema.json" + }, + { + "type": "null" + } + ] + }, + "error": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/TaskError.schema.json" + }, + { + "type": "null" + } + ] + } + } + } + } +} diff --git a/contracts/schema/methods/capture.getRules.schema.json b/contracts/schema/methods/capture.getRules.schema.json new file mode 100644 index 0000000..ea14019 --- /dev/null +++ b/contracts/schema/methods/capture.getRules.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/capture.getRules.schema.json", + "title": "capture.getRules", + "description": "The daemon's capture policy, so the extension's shouldCapture decision cannot drift from the daemon's. Fetched on connect and whenever event.settings.changed names a capture.* key. If this call fails the extension keeps its last known rules and stays fail-open.", + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 2000, + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "result": { + "$ref": "https://velox.dev/schema/types/CaptureRules.schema.json" + } + } +} diff --git a/contracts/schema/methods/capture.offer.schema.json b/contracts/schema/methods/capture.offer.schema.json index fd9a9ce..ea1f5f0 100644 --- a/contracts/schema/methods/capture.offer.schema.json +++ b/contracts/schema/methods/capture.offer.schema.json @@ -2,10 +2,11 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://velox.dev/schema/methods/capture.offer.schema.json", "title": "capture.offer", - "description": "Firefox offers an intercepted response to the daemon. The daemon MUST reply within 750 ms; the extension abandons the offer and lets Firefox download normally on timeout. TEMPLATE — lane PROTO owns the final shape.", + "description": "Firefox offers an intercepted response to the daemon. The daemon MUST reply within 750 ms; the extension abandons the offer and lets Firefox download normally on timeout. This deadline is the whole reason capture fails open, and it is conformance-tested: a daemon that is slow, down, or erroring must never cost the user a download.", "x-privileged": false, "x-transports": ["uds", "ws"], "x-deadlineMs": 750, + "x-errors": [-32011], "type": "object", "properties": { "params": { @@ -16,32 +17,20 @@ "url": { "type": "string", "format": "uri" }, "method": { "type": "string", "enum": ["GET", "POST"] }, "tabUrl": { "type": "string", "format": "uri" }, - "headers": { - "type": "object", - "description": "Request headers Firefox was about to send, verbatim. Needed for signed-URL and referrer-gated CDNs.", - "additionalProperties": { "type": "string" } - }, + "headers": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Headers.schema.json" }, { "type": "null" }] }, "cookies": { - "type": "array", + "type": ["array", "null"], "description": "Cookies for the URL, so authenticated downloads work outside the browser.", - "items": { - "type": "object", - "required": ["name", "value"], - "properties": { - "name": { "type": "string" }, - "value": { "type": "string" }, - "domain": { "type": "string" }, - "path": { "type": "string" } - } - } + "items": { "$ref": "https://velox.dev/schema/types/Cookie.schema.json" } }, "contentType": { "type": ["string", "null"] }, "contentLength": { "type": ["integer", "null"], "minimum": 0 }, "contentDisposition": { "type": ["string", "null"] }, - "filename": { "type": ["string", "null"], "description": "Extension's best guess; the daemon may override" }, + "filename": { "type": ["string", "null"], "description": "The extension's best guess; the daemon may override it." }, "userAgent": { "type": ["string", "null"] }, "referrer": { "type": ["string", "null"] }, - "origin": { "type": "string", "description": "moz-extension://… — the daemon verifies this on the WS transport" } + "origin": { "type": ["string", "null"], "description": "moz-extension://... The daemon verifies this on the WS transport and refuses anything else." }, + "requestId": { "type": ["string", "null"], "description": "The extension's webRequest id, echoed in logs so a capture decision can be traced back to one browser request." } } }, "result": { @@ -50,11 +39,12 @@ "required": ["action"], "properties": { "action": { "type": "string", "enum": ["take", "ignore"] }, - "taskId": { "type": ["string", "null"], "format": "uuid" }, + "taskId": { "type": ["string", "null"], "format": "uuid", "description": "Set when action is 'take'." }, "reason": { "type": ["string", "null"], + "description": "Why the offer was declined. Set when action is 'ignore'; the extension logs it in the popup's diagnostics.", "enum": ["excluded_host", "type_not_monitored", "below_min_size", "duplicate", - "capture_disabled", "user_declined", null] + "capture_disabled", "user_declined", "rule_ignore", null] } } } diff --git a/contracts/schema/methods/category.list.schema.json b/contracts/schema/methods/category.list.schema.json new file mode 100644 index 0000000..5ab6030 --- /dev/null +++ b/contracts/schema/methods/category.list.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/category.list.schema.json", + "title": "category.list", + "description": "Every category with its folder and extension list. The extension calls this to populate its default-category picker, which is why it is not privileged; it is read-only and exposes only paths the user already configured.", + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 2000, + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "https://velox.dev/schema/types/Category.schema.json" + } + } + } + } + } +} diff --git a/contracts/schema/methods/category.remove.schema.json b/contracts/schema/methods/category.remove.schema.json new file mode 100644 index 0000000..ad0d574 --- /dev/null +++ b/contracts/schema/methods/category.remove.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/category.remove.schema.json", + "title": "category.remove", + "description": "Delete a user-created category. Built-in categories are refused with -32602. Tasks filed under it are reassigned to reassignTo, or to the default category when that is null; no task is ever orphaned.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003, + -32602 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "categoryId" + ], + "properties": { + "categoryId": { + "type": "string" + }, + "reassignTo": { + "type": [ + "string", + "null" + ] + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "removed", + "reassignedTaskIds" + ], + "properties": { + "removed": { + "type": "boolean" + }, + "reassignedTaskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + } +} diff --git a/contracts/schema/methods/category.upsert.schema.json b/contracts/schema/methods/category.upsert.schema.json new file mode 100644 index 0000000..4b194eb --- /dev/null +++ b/contracts/schema/methods/category.upsert.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/category.upsert.schema.json", + "title": "category.upsert", + "description": "Create or replace a category. Omit categoryId to create; supply it to replace. Changing saveDir does not move existing files \u2014 the GUI asks separately and issues download.update per task, so a re-point is never a surprise mass file move.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003, + -32011 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "category" + ], + "properties": { + "category": { + "$ref": "https://velox.dev/schema/types/Category.schema.json" + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "description": "The stored category, with categoryId filled in on create.", + "required": [ + "category" + ], + "properties": { + "category": { + "$ref": "https://velox.dev/schema/types/Category.schema.json" + } + } + } + } +} diff --git a/contracts/schema/methods/download.add.schema.json b/contracts/schema/methods/download.add.schema.json new file mode 100644 index 0000000..2fbe261 --- /dev/null +++ b/contracts/schema/methods/download.add.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.add.schema.json", + "title": "download.add", + "description": "Create one task. saveDir is canonicalized and checked against saveTo.allowedRoots before anything is written; a path that escapes them is refused with -32011 and no file is created.", + "x-privileged": false, + "x-transports": ["uds", "ws"], + "x-deadlineMs": 5000, + "x-errors": [-32011, -32012, -32013], + "x-wsRestrictions": ["saveDir must be absent or resolve inside an existing category folder; anything else is -32011. The extension may request a download, it may not choose an arbitrary destination."], + "type": "object", + "properties": { + "params": { "$ref": "https://velox.dev/schema/types/DownloadSpec.schema.json" }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["taskId", "state"], + "properties": { + "taskId": { "type": "string", "format": "uuid" }, + "state": { "$ref": "https://velox.dev/schema/types/TaskState.schema.json" }, + "duplicate": { "type": ["string", "null"], "format": "uuid", "description": "The existing task this URL matched, when downloads.duplicatePolicy resolved to 'skip'. taskId then names that existing task." } + } + } + } +} diff --git a/contracts/schema/methods/download.addBatch.schema.json b/contracts/schema/methods/download.addBatch.schema.json new file mode 100644 index 0000000..ff8c89c --- /dev/null +++ b/contracts/schema/methods/download.addBatch.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.addBatch.schema.json", + "title": "download.addBatch", + "description": "Create many tasks in one call: the clipboard blob, the wildcard expander, and the extension's 'Download all links'. Partial success is normal and is reported per item rather than failing the whole batch.", + "x-privileged": false, + "x-transports": ["uds", "ws"], + "x-deadlineMs": 30000, + "x-errors": [-32011, -32012], + "x-wsRestrictions": ["Same saveDir restriction as download.add, applied to defaults and to every item."], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": ["items"], + "properties": { + "items": { "type": "array", "minItems": 1, "maxItems": 5000, "items": { "$ref": "https://velox.dev/schema/types/DownloadSpec.schema.json" } }, + "defaults": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/DownloadSpec.schema.json" }, { "type": "null" }], "description": "Applied to any field an item left unset. Its url is ignored." } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["taskIds", "failed"], + "properties": { + "taskIds": { "type": "array", "items": { "type": "string", "format": "uuid" }, "description": "In the same order as the accepted items." }, + "failed": { + "type": "array", + "description": "One entry per item that could not be added. index refers to params.items.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["index", "code", "message"], + "properties": { + "index": { "type": "integer", "minimum": 0 }, + "code": { "$ref": "https://velox.dev/schema/types/ErrorCode.schema.json" }, + "message": { "type": "string" } + } + } + } + } + } + } +} diff --git a/contracts/schema/methods/download.cancel.schema.json b/contracts/schema/methods/download.cancel.schema.json new file mode 100644 index 0000000..ef6d5c8 --- /dev/null +++ b/contracts/schema/methods/download.cancel.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.cancel.schema.json", + "title": "download.cancel", + "description": "Stop the given tasks and mark them cancelled. The .veloxpart file is kept so the user can still resume from the list; download.remove is what deletes bytes.", + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32010 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskIds" + ], + "properties": { + "taskIds": { + "type": "array", + "minItems": 1, + "maxItems": 5000, + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "result": { + "$ref": "https://velox.dev/schema/types/BulkTaskResult.schema.json" + } + } +} diff --git a/contracts/schema/methods/download.get.schema.json b/contracts/schema/methods/download.get.schema.json new file mode 100644 index 0000000..242d9b0 --- /dev/null +++ b/contracts/schema/methods/download.get.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.get.schema.json", + "title": "download.get", + "description": "Full detail for one task, including per-segment state. Backs the progress dialog. Poll it no faster than the progress dialog repaints; the table must use events instead.", + "x-privileged": false, + "x-transports": ["uds", "ws"], + "x-deadlineMs": 5000, + "x-errors": [-32010], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": ["taskId"], + "properties": { "taskId": { "type": "string", "format": "uuid" } } + }, + "result": { "$ref": "https://velox.dev/schema/types/TaskDetail.schema.json" } + } +} diff --git a/contracts/schema/methods/download.list.schema.json b/contracts/schema/methods/download.list.schema.json new file mode 100644 index 0000000..72cb622 --- /dev/null +++ b/contracts/schema/methods/download.list.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.list.schema.json", + "title": "download.list", + "description": "The main table. Filtering, sorting and paging all happen in the daemon so the GUI never materializes 100k rows to show 40. Called once on connect; after that the table is maintained from events, never re-fetched on a progress tick.", + "x-privileged": false, + "x-transports": ["uds", "ws"], + "x-deadlineMs": 5000, + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "properties": { + "filter": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/TaskFilter.schema.json" }, { "type": "null" }] }, + "sort": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/TaskSort.schema.json" }, { "type": "null" }] }, + "offset": { "type": ["integer", "null"], "minimum": 0 }, + "limit": { "type": ["integer", "null"], "minimum": 1, "maximum": 5000, "description": "Defaults to 500. The GUI pages; the extension popup asks for far fewer." } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["total", "items"], + "properties": { + "total": { "type": "integer", "minimum": 0, "description": "Rows matching the filter, ignoring offset and limit." }, + "items": { "type": "array", "items": { "$ref": "https://velox.dev/schema/types/TaskSummary.schema.json" } } + } + } + } +} diff --git a/contracts/schema/methods/download.pause.schema.json b/contracts/schema/methods/download.pause.schema.json new file mode 100644 index 0000000..0e5da83 --- /dev/null +++ b/contracts/schema/methods/download.pause.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.pause.schema.json", + "title": "download.pause", + "description": "Suspend transfers and flush every segment's progress to the .veloxpart.meta file, so a pause is indistinguishable from a crash as far as resume is concerned. Never loses bytes already written.", + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32010 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskIds" + ], + "properties": { + "taskIds": { + "type": "array", + "minItems": 1, + "maxItems": 5000, + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "result": { + "$ref": "https://velox.dev/schema/types/BulkTaskResult.schema.json" + } + } +} diff --git a/contracts/schema/methods/download.probe.schema.json b/contracts/schema/methods/download.probe.schema.json new file mode 100644 index 0000000..06f6679 --- /dev/null +++ b/contracts/schema/methods/download.probe.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.probe.schema.json", + "title": "download.probe", + "description": "Ask what is at a URL without creating a task. Populates the File Info dialog. Runs a HEAD, falling back to a ranged GET when HEAD is refused, which is also how resumability is established. Never blocks the RPC loop; the dialog opens immediately and fills in when this lands.", + "x-privileged": false, + "x-transports": ["uds", "ws"], + "x-deadlineMs": 30000, + "x-errors": [-32013], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": ["url"], + "properties": { + "url": { "type": "string", "format": "uri" }, + "headers": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Headers.schema.json" }, { "type": "null" }] }, + "cookies": { "type": ["array", "null"], "items": { "$ref": "https://velox.dev/schema/types/Cookie.schema.json" } }, + "referrer": { "type": ["string", "null"] }, + "userAgent": { "type": ["string", "null"] } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["filename", "mime", "resumable", "effectiveUrl", "suggestedCategoryId"], + "properties": { + "filename": { "type": "string", "description": "From Content-Disposition when present, else the URL path, sanitized." }, + "sizeBytes": { "type": ["integer", "null"], "minimum": 0 }, + "mime": { "type": "string" }, + "resumable": { "type": "boolean", "description": "Accept-Ranges: bytes and a validator (ETag or Last-Modified) are both present." }, + "effectiveUrl": { "type": "string", "format": "uri" }, + "suggestedCategoryId": { "type": "string", "description": "What the rules engine would pick. The dialog preselects it; the user may override." }, + "suggestedSaveDir": { "type": ["string", "null"] }, + "etag": { "type": ["string", "null"] }, + "lastModified": { "type": ["string", "null"] }, + "acceptRanges": { "type": "boolean" }, + "redirectChain": { "type": "array", "items": { "type": "string", "format": "uri" }, "description": "Every hop, so the user can see where a shortener actually led." }, + "requiresAuth": { "type": "boolean", "description": "The probe got a 401/407. The GUI should collect credentials before adding." } + } + } + } +} diff --git a/contracts/schema/methods/download.refreshUrl.schema.json b/contracts/schema/methods/download.refreshUrl.schema.json new file mode 100644 index 0000000..e155716 --- /dev/null +++ b/contracts/schema/methods/download.refreshUrl.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.refreshUrl.schema.json", + "title": "download.refreshUrl", + "description": "IDM's 'Refresh Download Address'. Point an existing task at a freshly-issued URL when a signed link has expired, keeping every byte already on disk. The daemon re-probes and compares size and validator: if they still match, the transfer resumes from where it stopped; if they do not, it says so rather than silently restarting.", + "x-privileged": false, + "x-transports": ["uds", "ws"], + "x-deadlineMs": 30000, + "x-errors": [-32010, -32013], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": ["taskId", "url"], + "properties": { + "taskId": { "type": "string", "format": "uuid" }, + "url": { "type": "string", "format": "uri" }, + "headers": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Headers.schema.json" }, { "type": "null" }] }, + "cookies": { "type": ["array", "null"], "items": { "$ref": "https://velox.dev/schema/types/Cookie.schema.json" } } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "resumable", "contentChanged"], + "properties": { + "ok": { "type": "boolean" }, + "resumable": { "type": "boolean" }, + "contentChanged": { "type": "boolean", "description": "true when size or validator differ from what was recorded. The GUI must ask before restarting from zero — never discard bytes without consent." }, + "sizeBytes": { "type": ["integer", "null"], "minimum": 0 }, + "effectiveUrl": { "type": ["string", "null"], "format": "uri" } + } + } + } +} diff --git a/contracts/schema/methods/download.remove.schema.json b/contracts/schema/methods/download.remove.schema.json new file mode 100644 index 0000000..1169603 --- /dev/null +++ b/contracts/schema/methods/download.remove.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.remove.schema.json", + "title": "download.remove", + "description": "Drop tasks from the list, optionally deleting the bytes on disk. Privileged: this is the only method that destroys user data, and the extension is never allowed to reach it. The daemon deletes the .veloxpart and .veloxpart.meta pair, and the finished file only when deleteFile is true.", + "x-privileged": true, + "x-transports": ["uds"], + "x-deadlineMs": 10000, + "x-errors": [-32003, -32010], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": ["taskIds", "deleteFile"], + "properties": { + "taskIds": { "type": "array", "minItems": 1, "maxItems": 5000, "items": { "type": "string", "format": "uuid" } }, + "deleteFile": { "type": "boolean", "description": "Explicit and required — there is no default for deleting a user's file." } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["removed", "failed"], + "properties": { + "removed": { "type": "array", "items": { "type": "string", "format": "uuid" } }, + "failed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["taskId", "code", "message"], + "properties": { + "taskId": { "type": "string", "format": "uuid" }, + "code": { "$ref": "https://velox.dev/schema/types/ErrorCode.schema.json" }, + "message": { "type": "string" } + } + } + } + } + } + } +} diff --git a/contracts/schema/methods/download.resume.schema.json b/contracts/schema/methods/download.resume.schema.json new file mode 100644 index 0000000..4042761 --- /dev/null +++ b/contracts/schema/methods/download.resume.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.resume.schema.json", + "title": "download.resume", + "description": "Continue paused tasks. Resumption is revalidated with If-Range against the stored ETag or Last-Modified; a 200 where 206 was expected means the file changed on the server, and the task moves to failed with a clear error rather than corrupting the part file.", + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32010 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskIds" + ], + "properties": { + "taskIds": { + "type": "array", + "minItems": 1, + "maxItems": 5000, + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "result": { + "$ref": "https://velox.dev/schema/types/BulkTaskResult.schema.json" + } + } +} diff --git a/contracts/schema/methods/download.start.schema.json b/contracts/schema/methods/download.start.schema.json new file mode 100644 index 0000000..5808441 --- /dev/null +++ b/contracts/schema/methods/download.start.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.start.schema.json", + "title": "download.start", + "description": "Begin or restart the given tasks. A task in 'queued' jumps its queue; a task already downloading is a no-op reported as changed false.", + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32010 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskIds" + ], + "properties": { + "taskIds": { + "type": "array", + "minItems": 1, + "maxItems": 5000, + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "result": { + "$ref": "https://velox.dev/schema/types/BulkTaskResult.schema.json" + } + } +} diff --git a/contracts/schema/methods/download.update.schema.json b/contracts/schema/methods/download.update.schema.json new file mode 100644 index 0000000..8c6f18b --- /dev/null +++ b/contracts/schema/methods/download.update.schema.json @@ -0,0 +1,102 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/download.update.schema.json", + "title": "download.update", + "description": "Change a task's mutable fields. Moving saveDir or filename moves the file on disk in the same operation, which is what makes dragging a row onto a category work as one RPC. Privileged: it can name a destination path.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 30000, + "x-errors": [ + -32003, + -32010, + -32011 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "patch" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "patch": { + "type": "object", + "additionalProperties": false, + "description": "Only the present fields change. An explicit null clears a nullable field.", + "properties": { + "filename": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + }, + "saveDir": { + "type": [ + "string", + "null" + ] + }, + "categoryId": { + "type": [ + "string", + "null" + ] + }, + "queueId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 1024 + }, + "segments": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 32, + "description": "The REQUESTED connection count, subject to the same per-host cap and non-resumable demotion as DownloadSpec.segments. Takes effect on the next start; a running task is not re-segmented underneath the user." + }, + "bufferBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 4096, + "maximum": 8388608 + }, + "checksum": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/Checksum.schema.json" + }, + { + "type": "null" + } + ] + } + } + } + } + }, + "result": { + "$ref": "https://velox.dev/schema/types/TaskSummary.schema.json" + } + } +} diff --git a/contracts/schema/methods/grabber.harvest.schema.json b/contracts/schema/methods/grabber.harvest.schema.json new file mode 100644 index 0000000..7784912 --- /dev/null +++ b/contracts/schema/methods/grabber.harvest.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/grabber.harvest.schema.json", + "title": "grabber.harvest", + "description": "Turn selected crawl results into tasks. This is the only grabber call that creates downloads, and it names exactly the files the user ticked \u2014 a crawl never starts a download on its own.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 30000, + "x-errors": [ + -32003, + -32011 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "jobId", + "select" + ], + "properties": { + "jobId": { + "type": "string" + }, + "select": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "description": "fileIds from grabber.status." + }, + "defaults": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/DownloadSpec.schema.json" + }, + { + "type": "null" + } + ] + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskIds", + "failed" + ], + "properties": { + "taskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "fileId", + "code", + "message" + ], + "properties": { + "fileId": { + "type": "string" + }, + "code": { + "$ref": "https://velox.dev/schema/types/ErrorCode.schema.json" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/contracts/schema/methods/grabber.start.schema.json b/contracts/schema/methods/grabber.start.schema.json new file mode 100644 index 0000000..6a5ac28 --- /dev/null +++ b/contracts/schema/methods/grabber.start.schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/grabber.start.schema.json", + "title": "grabber.start", + "description": "Start a depth-limited crawl. Nothing is downloaded by this call: it only walks pages and collects candidate links, which the wizard then shows for selection. Privileged because an unbounded crawl is a resource commitment the browser must not be able to make on the user's behalf.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "startUrl", + "depth" + ], + "properties": { + "startUrl": { + "type": "string", + "format": "uri" + }, + "depth": { + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "includePatterns": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "excludePatterns": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "fileTypes": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Extensions, without the dot. null means every type." + }, + "sameHostOnly": { + "type": "boolean" + }, + "maxFiles": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 10000 + }, + "headers": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/Headers.schema.json" + }, + { + "type": "null" + } + ] + }, + "cookies": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "https://velox.dev/schema/types/Cookie.schema.json" + } + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "jobId" + ], + "properties": { + "jobId": { + "type": "string" + } + } + } + } +} diff --git a/contracts/schema/methods/grabber.status.schema.json b/contracts/schema/methods/grabber.status.schema.json new file mode 100644 index 0000000..b7b041c --- /dev/null +++ b/contracts/schema/methods/grabber.status.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/grabber.status.schema.json", + "title": "grabber.status", + "description": "Poll one crawl. Also delivered as event.grabber.progress; the poll exists so the wizard can be reopened on a job it did not start and still catch up.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003, + -32602 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "jobId" + ], + "properties": { + "jobId": { + "type": "string" + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "jobId", + "state", + "crawled", + "found", + "files" + ], + "properties": { + "jobId": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "crawling", + "done", + "failed", + "cancelled" + ] + }, + "crawled": { + "type": "integer", + "minimum": 0 + }, + "found": { + "type": "integer", + "minimum": 0 + }, + "files": { + "type": "array", + "items": { + "$ref": "https://velox.dev/schema/types/GrabberFile.schema.json" + } + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/contracts/schema/methods/limiter.get.schema.json b/contracts/schema/methods/limiter.get.schema.json new file mode 100644 index 0000000..ffbd361 --- /dev/null +++ b/contracts/schema/methods/limiter.get.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/limiter.get.schema.json", + "title": "limiter.get", + "description": "Current global speed limit. Privileged: changing or reading the limiter belongs to the GUI and CLI; the extension shows throughput from event.speed.global instead.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 2000, + "x-errors": [ + -32003 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "result": { + "$ref": "https://velox.dev/schema/types/Limiter.schema.json" + } + } +} diff --git a/contracts/schema/methods/limiter.set.schema.json b/contracts/schema/methods/limiter.set.schema.json new file mode 100644 index 0000000..21b4206 --- /dev/null +++ b/contracts/schema/methods/limiter.set.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/limiter.set.schema.json", + "title": "limiter.set", + "description": "Set the global token-bucket limit. With applyToRunning true the change re-tunes transfers already in flight instead of taking effect only on the next task \u2014 the Speed Limiter window's 'apply now' button.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003, + -32602 + ], + "type": "object", + "properties": { + "params": { + "$ref": "https://velox.dev/schema/types/Limiter.schema.json" + }, + "result": { + "$ref": "https://velox.dev/schema/types/Limiter.schema.json" + } + } +} diff --git a/contracts/schema/methods/media.addVariant.schema.json b/contracts/schema/methods/media.addVariant.schema.json new file mode 100644 index 0000000..ab7d651 --- /dev/null +++ b/contracts/schema/methods/media.addVariant.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/media.addVariant.schema.json", + "title": "media.addVariant", + "description": "Turn one enumerated variant into a task. The daemon fetches the segments in parallel and muxes them with ffmpeg; the result is an ordinary task that appears in the list like any other download. Refused with -32602 when the variant is DRM-protected.", + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 30000, + "x-errors": [ + -32011, + -32602, + -32013 + ], + "x-wsRestrictions": [ + "Same saveDir restriction as download.add." + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "description": "spec carries the same destination and queueing fields as download.add; its url is ignored because the manifest and variant determine the source.", + "required": [ + "manifestUrl", + "variantId" + ], + "properties": { + "manifestUrl": { + "type": "string", + "format": "uri" + }, + "variantId": { + "type": "string" + }, + "audioVariantId": { + "type": [ + "string", + "null" + ], + "description": "For DASH and HLS renditions where audio is a separate track to be muxed in." + }, + "spec": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/DownloadSpec.schema.json" + }, + { + "type": "null" + } + ] + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "taskId", + "state" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "https://velox.dev/schema/types/TaskState.schema.json" + }, + "estimatedBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + } + } + } +} diff --git a/contracts/schema/methods/media.listVariants.schema.json b/contracts/schema/methods/media.listVariants.schema.json new file mode 100644 index 0000000..ee53820 --- /dev/null +++ b/contracts/schema/methods/media.listVariants.schema.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/media.listVariants.schema.json", + "title": "media.listVariants", + "description": "Parse an HLS or DASH manifest in the daemon and enumerate its renditions. The extension never parses a manifest \u2014 that logic lives in one language, in one place. Variants with drm true are reported so the UI can grey them out; DRM-protected streams are refused, not attempted.", + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 30000, + "x-errors": [ + -32013 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "manifestUrl" + ], + "properties": { + "manifestUrl": { + "type": "string", + "format": "uri" + }, + "headers": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/Headers.schema.json" + }, + { + "type": "null" + } + ] + }, + "cookies": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "https://velox.dev/schema/types/Cookie.schema.json" + } + }, + "referrer": { + "type": [ + "string", + "null" + ] + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "variants", + "manifestType", + "drmProtected" + ], + "properties": { + "variants": { + "type": "array", + "items": { + "$ref": "https://velox.dev/schema/types/MediaVariant.schema.json" + } + }, + "manifestType": { + "type": "string", + "enum": [ + "hls", + "dash" + ] + }, + "durationSec": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "drmProtected": { + "type": "boolean", + "description": "The manifest as a whole is DRM-protected. Refuse with a clear message rather than downloading undecryptable segments." + } + } + } + } +} diff --git a/contracts/schema/methods/queue.list.schema.json b/contracts/schema/methods/queue.list.schema.json new file mode 100644 index 0000000..44449b5 --- /dev/null +++ b/contracts/schema/methods/queue.list.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/queue.list.schema.json", + "title": "queue.list", + "description": "Every queue with its run state and ordering. Not privileged: the extension's 'Add to Queue' picker needs it.", + "x-privileged": false, + "x-transports": [ + "uds", + "ws" + ], + "x-deadlineMs": 2000, + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "https://velox.dev/schema/types/Queue.schema.json" + } + } + } + } + } +} diff --git a/contracts/schema/methods/queue.reorder.schema.json b/contracts/schema/methods/queue.reorder.schema.json new file mode 100644 index 0000000..39f1ba0 --- /dev/null +++ b/contracts/schema/methods/queue.reorder.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/queue.reorder.schema.json", + "title": "queue.reorder", + "description": "Rewrite a queue's run order. taskIds must be a permutation of the queue's current membership; anything else is -32602 rather than a partial reorder, so a stale drag from an out-of-date view cannot quietly reshuffle the queue.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003, + -32602 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "queueId", + "taskIds" + ], + "properties": { + "queueId": { + "type": "string" + }, + "taskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "queue" + ], + "properties": { + "queue": { + "$ref": "https://velox.dev/schema/types/Queue.schema.json" + } + } + } + } +} diff --git a/contracts/schema/methods/queue.start.schema.json b/contracts/schema/methods/queue.start.schema.json new file mode 100644 index 0000000..2a62522 --- /dev/null +++ b/contracts/schema/methods/queue.start.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/queue.start.schema.json", + "title": "queue.start", + "description": "Start a queue running. The scheduler then admits up to maxConcurrent tasks from it, in order, and keeps that many running until the queue drains or is stopped.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "queueId" + ], + "properties": { + "queueId": { + "type": "string" + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "queue", + "startedTaskIds" + ], + "properties": { + "queue": { + "$ref": "https://velox.dev/schema/types/Queue.schema.json" + }, + "startedTaskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + } +} diff --git a/contracts/schema/methods/queue.stop.schema.json b/contracts/schema/methods/queue.stop.schema.json new file mode 100644 index 0000000..8cd6b03 --- /dev/null +++ b/contracts/schema/methods/queue.stop.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/queue.stop.schema.json", + "title": "queue.stop", + "description": "Stop admitting new tasks from a queue. Tasks already running are paused when pauseRunning is true, and otherwise allowed to finish \u2014 the difference between 'stop the queue' and 'stop everything', which IDM conflates and users trip over.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "queueId" + ], + "properties": { + "queueId": { + "type": "string" + }, + "pauseRunning": { + "type": "boolean" + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "queue", + "pausedTaskIds" + ], + "properties": { + "queue": { + "$ref": "https://velox.dev/schema/types/Queue.schema.json" + }, + "pausedTaskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + } +} diff --git a/contracts/schema/methods/queue.upsert.schema.json b/contracts/schema/methods/queue.upsert.schema.json new file mode 100644 index 0000000..a9c387e --- /dev/null +++ b/contracts/schema/methods/queue.upsert.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/queue.upsert.schema.json", + "title": "queue.upsert", + "description": "Create or replace a queue, including its schedule and concurrency cap. Omit queueId to create. taskIds in the payload is ignored \u2014 membership changes through download.update and queue.reorder so that two clients editing at once cannot silently drop a task.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "queue" + ], + "properties": { + "queue": { + "$ref": "https://velox.dev/schema/types/Queue.schema.json" + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "queue" + ], + "properties": { + "queue": { + "$ref": "https://velox.dev/schema/types/Queue.schema.json" + } + } + } + } +} diff --git a/contracts/schema/methods/rules.list.schema.json b/contracts/schema/methods/rules.list.schema.json new file mode 100644 index 0000000..e487490 --- /dev/null +++ b/contracts/schema/methods/rules.list.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/rules.list.schema.json", + "title": "rules.list", + "description": "The rules engine's table, in priority order. Privileged: these are the daemon's routing policy. The extension gets its own narrowed view through capture.getRules instead.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 2000, + "x-errors": [ + -32003 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "https://velox.dev/schema/types/Rule.schema.json" + } + } + } + } + } +} diff --git a/contracts/schema/methods/rules.upsert.schema.json b/contracts/schema/methods/rules.upsert.schema.json new file mode 100644 index 0000000..3609d1d --- /dev/null +++ b/contracts/schema/methods/rules.upsert.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/rules.upsert.schema.json", + "title": "rules.upsert", + "description": "Create, replace, or delete rules in one atomic write. 'upsert' carries the rules to store and 'remove' the ruleIds to drop; applying both at once means a reprioritisation never leaves the table in a half-valid state.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "upsert" + ], + "properties": { + "upsert": { + "type": "array", + "items": { + "$ref": "https://velox.dev/schema/types/Rule.schema.json" + } + }, + "remove": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "description": "The full table after the write, in priority order.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "https://velox.dev/schema/types/Rule.schema.json" + } + } + } + } + } +} diff --git a/contracts/schema/methods/schedule.get.schema.json b/contracts/schema/methods/schedule.get.schema.json new file mode 100644 index 0000000..9ac03ac --- /dev/null +++ b/contracts/schema/methods/schedule.get.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/schedule.get.schema.json", + "title": "schedule.get", + "description": "The schedule for one queue, or every schedule when queueId is null. Backs the Scheduler window.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 2000, + "x-errors": [ + -32003 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "properties": { + "queueId": { + "type": [ + "string", + "null" + ] + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "queueId", + "schedule" + ], + "properties": { + "queueId": { + "type": "string" + }, + "schedule": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/Schedule.schema.json" + }, + { + "type": "null" + } + ] + } + } + } + } + } + } + } +} diff --git a/contracts/schema/methods/schedule.set.schema.json b/contracts/schema/methods/schedule.set.schema.json new file mode 100644 index 0000000..d84d620 --- /dev/null +++ b/contracts/schema/methods/schedule.set.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/schedule.set.schema.json", + "title": "schedule.set", + "description": "Set or clear a queue's schedule. A null schedule clears it and leaves the queue under manual control. Times are local wall-clock and are re-evaluated on a DST change rather than being resolved to absolute instants at set time.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003, + -32602 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "queueId", + "schedule" + ], + "properties": { + "queueId": { + "type": "string" + }, + "schedule": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/Schedule.schema.json" + }, + { + "type": "null" + } + ] + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "queueId", + "schedule" + ], + "properties": { + "queueId": { + "type": "string" + }, + "schedule": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/Schedule.schema.json" + }, + { + "type": "null" + } + ] + }, + "nextRunAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + } + } +} diff --git a/contracts/schema/methods/session.hello.schema.json b/contracts/schema/methods/session.hello.schema.json new file mode 100644 index 0000000..c583354 --- /dev/null +++ b/contracts/schema/methods/session.hello.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/session.hello.schema.json", + "title": "session.hello", + "description": "First call on every connection, on every transport. The daemon compares protocolVersion majors and refuses a mismatch with -32001 so a stale GUI or extension fails loudly on connect instead of subtly at the tenth field. On the WebSocket transport a valid token is required unless the client is about to call session.pair.", + "x-privileged": false, + "x-transports": ["uds", "ws"], + "x-deadlineMs": 2000, + "x-errors": [-32001, -32002], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": ["clientType", "clientName", "protocolVersion"], + "properties": { + "clientType": { "type": "string", "enum": ["gui", "cli", "extension", "nmhost", "test"] }, + "clientName": { "type": "string", "maxLength": 64, "description": "Human-readable, shown in the pairing prompt and the logs." }, + "protocolVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?$" }, + "token": { "type": ["string", "null"], "description": "Required on the WebSocket transport once paired. Ignored on the Unix socket, where SO_PEERCRED is the authorization." } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["daemonVersion", "protocolVersion", "capabilities", "sessionId"], + "properties": { + "daemonVersion": { "type": "string" }, + "protocolVersion": { "type": "string" }, + "capabilities": { "type": "array", "items": { "type": "string" }, "description": "Optional features this build has, e.g. 'media', 'grabber', 'secretservice'. A client must degrade gracefully when one is absent rather than assuming it." }, + "sessionId": { "type": "string", "format": "uuid" }, + "transport": { "type": "string", "enum": ["uds", "ws"], "description": "How the daemon sees this connection. Lets a client know up front which privileged methods will be refused." } + } + } + } +} diff --git a/contracts/schema/methods/session.pair.schema.json b/contracts/schema/methods/session.pair.schema.json new file mode 100644 index 0000000..547e978 --- /dev/null +++ b/contracts/schema/methods/session.pair.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/session.pair.schema.json", + "title": "session.pair", + "description": "WebSocket transport only. Triggers a GUI or desktop-notification prompt showing a four-digit code; the user must approve before a token is issued. Failed attempts are rate-limited to 5/min followed by a 60 s lockout (-32014) so a token cannot be brute-forced by another local process. The daemon stores only a hash of the token.", + "x-privileged": false, + "x-transports": ["ws"], + "x-deadlineMs": 120000, + "x-errors": [-32003, -32014], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": ["clientName", "extensionId"], + "properties": { + "clientName": { "type": "string", "maxLength": 64 }, + "extensionId": { "type": "string", "description": "The moz-extension origin UUID. Must match the Origin header verified on the WS upgrade." }, + "code": { "type": ["string", "null"], "pattern": "^[0-9]{4}$", "description": "Set when the user typed the code into the extension's Options page instead of clicking Allow in the GUI." } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["token", "expiresAt"], + "properties": { + "token": { "type": "string", "minLength": 43, "description": "256 bits, base64url. Stored by the extension in browser.storage.local and sent on every later connect." }, + "expiresAt": { "type": ["string", "null"], "format": "date-time", "description": "null means the token does not expire; it is revoked from Options -> Unpair." } + } + } + } +} diff --git a/contracts/schema/methods/session.subscribe.schema.json b/contracts/schema/methods/session.subscribe.schema.json new file mode 100644 index 0000000..91ac73d --- /dev/null +++ b/contracts/schema/methods/session.subscribe.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/session.subscribe.schema.json", + "title": "session.subscribe", + "description": "Choose which notifications this connection receives. Subscribing replaces the previous selection rather than adding to it, so a client can narrow its firehose without reconnecting. Nothing is delivered until this is called.", + "x-privileged": false, + "x-transports": ["uds", "ws"], + "x-deadlineMs": 2000, + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": ["events"], + "properties": { + "events": { + "type": "array", + "items": { + "type": "string", + "enum": ["event.task.added", "event.task.removed", "event.task.state", + "event.task.progress", "event.speed.global", "event.auth.required", + "event.notify", "event.settings.changed", "event.grabber.progress"] + } + }, + "taskIds": { "type": ["array", "null"], "items": { "type": "string", "format": "uuid" }, "description": "Narrow task events to these ids. The extension popup uses it to avoid receiving progress for downloads it is not showing. null means all tasks." } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "events"], + "properties": { + "ok": { "type": "boolean" }, + "events": { "type": "array", "items": { "type": "string" }, "description": "Echoed back so a client can detect that it asked for an event this daemon does not emit." } + } + } + } +} diff --git a/contracts/schema/methods/settings.get.schema.json b/contracts/schema/methods/settings.get.schema.json new file mode 100644 index 0000000..b82cdcd --- /dev/null +++ b/contracts/schema/methods/settings.get.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/settings.get.schema.json", + "title": "settings.get", + "description": "Read settings. keys null means everything. Privileged: the settings bag names local filesystem paths and the allowed write roots, which the extension has no business enumerating \u2014 it gets capture.getRules instead.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 2000, + "x-errors": [ + -32003 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "properties": { + "keys": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "https://velox.dev/schema/types/SettingKey.schema.json" + } + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "values" + ], + "properties": { + "values": { + "$ref": "https://velox.dev/schema/types/Settings.schema.json" + } + } + } + } +} diff --git a/contracts/schema/methods/settings.set.schema.json b/contracts/schema/methods/settings.set.schema.json new file mode 100644 index 0000000..0bca44b --- /dev/null +++ b/contracts/schema/methods/settings.set.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/methods/settings.set.schema.json", + "title": "settings.set", + "description": "Write settings. Only the keys present in values change. Rejected with -32602 if a key is unknown or a value fails the Settings schema, and with -32011 if a directory key names a path that cannot be written. Emits event.settings.changed with exactly the keys that took effect.", + "x-privileged": true, + "x-transports": [ + "uds" + ], + "x-deadlineMs": 5000, + "x-errors": [ + -32003, + -32602, + -32011 + ], + "type": "object", + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "values" + ], + "properties": { + "values": { + "$ref": "https://velox.dev/schema/types/Settings.schema.json" + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "description": "The stored values for the keys that were set, and the list of keys that actually changed.", + "required": [ + "values", + "changed" + ], + "properties": { + "values": { + "$ref": "https://velox.dev/schema/types/Settings.schema.json" + }, + "changed": { + "type": "array", + "items": { + "$ref": "https://velox.dev/schema/types/SettingKey.schema.json" + } + } + } + } + } +} diff --git a/contracts/schema/types/BulkTaskResult.schema.json b/contracts/schema/types/BulkTaskResult.schema.json new file mode 100644 index 0000000..60ce71e --- /dev/null +++ b/contracts/schema/types/BulkTaskResult.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/BulkTaskResult.schema.json", + "title": "BulkTaskResult", + "description": "Result of a state transition applied to many tasks. A bulk call never fails as a whole because one id was bad: the ids that moved come back in 'updated' and the rest are explained in 'failed'. This is what lets the GUI's toolbar act on a multi-selection without pre-validating it.", + "type": "object", + "additionalProperties": false, + "required": ["updated", "failed"], + "properties": { + "updated": { + "type": "array", + "description": "One entry per task that actually changed. A task already in the target state is reported here with changed false rather than as a failure.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["taskId", "state", "changed"], + "properties": { + "taskId": { "type": "string", "format": "uuid" }, + "state": { "$ref": "https://velox.dev/schema/types/TaskState.schema.json" }, + "changed": { "type": "boolean" } + } + } + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["taskId", "code", "message"], + "properties": { + "taskId": { "type": "string", "format": "uuid" }, + "code": { "$ref": "https://velox.dev/schema/types/ErrorCode.schema.json" }, + "message": { "type": "string" } + } + } + } + } +} diff --git a/contracts/schema/types/BypassModifier.schema.json b/contracts/schema/types/BypassModifier.schema.json new file mode 100644 index 0000000..31d67ad --- /dev/null +++ b/contracts/schema/types/BypassModifier.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/BypassModifier.schema.json", + "title": "BypassModifier", + "description": "The modifier key a user holds to make one click bypass capture and let Firefox download normally. Shared by Settings and CaptureRules so the daemon's setting and the extension's mirror of it are literally the same type.", + "type": "string", + "enum": ["alt", "ctrl", "shift", "none"] +} diff --git a/contracts/schema/types/CaptureRules.schema.json b/contracts/schema/types/CaptureRules.schema.json new file mode 100644 index 0000000..c6c0fa1 --- /dev/null +++ b/contracts/schema/types/CaptureRules.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/CaptureRules.schema.json", + "title": "CaptureRules", + "description": "The daemon's capture policy, mirrored into the extension so the two can never disagree about what should be intercepted. The extension refreshes this on connect and on event.settings.changed.", + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "monitoredExtensions", + "monitoredMimeTypes", + "minSizeBytes", + "excludedHosts", + "rulesVersion" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "monitoredExtensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "monitoredMimeTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "minSizeBytes": { + "type": "integer", + "minimum": 0 + }, + "excludedHosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "bypassModifier": { + "$ref": "https://velox.dev/schema/types/BypassModifier.schema.json" + }, + "rulesVersion": { + "type": "integer", + "minimum": 0, + "description": "Bumped on every change. The extension re-fetches when it sees a higher value." + } + } +} diff --git a/contracts/schema/types/Category.schema.json b/contracts/schema/types/Category.schema.json new file mode 100644 index 0000000..39e336a --- /dev/null +++ b/contracts/schema/types/Category.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/Category.schema.json", + "title": "Category", + "description": "A destination folder plus the extensions that route to it. The extension mirrors the extension lists so its capture decision agrees with the daemon's.", + "type": "object", + "additionalProperties": false, + "required": ["categoryId", "name", "saveDir", "extensions", "builtin"], + "properties": { + "categoryId": { "type": "string" }, + "name": { "type": "string", "maxLength": 64 }, + "saveDir": { "type": "string" }, + "extensions": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9+._-]*$" }, "description": "Without the leading dot, lowercase." }, + "mimeTypes": { "type": "array", "items": { "type": "string" } }, + "builtin": { "type": "boolean", "description": "Compressed, Documents, Music, Programs, Video. Cannot be removed; can be renamed and re-pointed." }, + "sortOrder": { "type": "integer", "minimum": 0 } + } +} diff --git a/contracts/schema/types/Checksum.schema.json b/contracts/schema/types/Checksum.schema.json new file mode 100644 index 0000000..e83623c --- /dev/null +++ b/contracts/schema/types/Checksum.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/Checksum.schema.json", + "title": "Checksum", + "description": "Optional integrity check, verified during the verifying state. A mismatch moves the task to failed and never overwrites a good file.", + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "value"], + "properties": { + "algorithm": { "type": "string", "enum": ["md5", "sha1", "sha256", "sha512"] }, + "value": { "type": "string", "pattern": "^[0-9a-fA-F]{32,128}$" } + } +} diff --git a/contracts/schema/types/Cookie.schema.json b/contracts/schema/types/Cookie.schema.json new file mode 100644 index 0000000..7a1a238 --- /dev/null +++ b/contracts/schema/types/Cookie.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/Cookie.schema.json", + "title": "Cookie", + "description": "One cookie the daemon replays so an authenticated download works outside the browser.", + "type": "object", + "additionalProperties": false, + "required": ["name", "value"], + "properties": { + "name": { "type": "string" }, + "value": { "type": "string" }, + "domain": { "type": "string" }, + "path": { "type": "string" }, + "secure": { "type": "boolean" }, + "httpOnly": { "type": "boolean" } + } +} diff --git a/contracts/schema/types/DownloadSpec.schema.json b/contracts/schema/types/DownloadSpec.schema.json new file mode 100644 index 0000000..a4bf9b6 --- /dev/null +++ b/contracts/schema/types/DownloadSpec.schema.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/DownloadSpec.schema.json", + "title": "DownloadSpec", + "description": "Everything needed to create one task. Shared by download.add and each item of download.addBatch, so the two can never drift apart.", + "type": "object", + "additionalProperties": false, + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "headers": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/Headers.schema.json" + }, + { + "type": "null" + } + ] + }, + "cookies": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "https://velox.dev/schema/types/Cookie.schema.json" + } + }, + "referrer": { + "type": [ + "string", + "null" + ] + }, + "userAgent": { + "type": [ + "string", + "null" + ] + }, + "filename": { + "type": [ + "string", + "null" + ], + "maxLength": 255, + "description": "Overrides the name derived from Content-Disposition or the URL." + }, + "saveDir": { + "type": [ + "string", + "null" + ], + "description": "Canonicalized and checked against the allowed roots before any write. -32011 if it fails." + }, + "categoryId": { + "type": [ + "string", + "null" + ], + "description": "null means the rules engine picks one." + }, + "queueId": { + "type": [ + "string", + "null" + ], + "description": "Required when startMode is 'queue'." + }, + "segments": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 32, + "description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the per-host cap, and to 1 when the source turns out not to be resumable. What is actually in use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload." + }, + "bufferBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 4096, + "maximum": 8388608 + }, + "startMode": { + "$ref": "https://velox.dev/schema/types/StartMode.schema.json" + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 1024 + }, + "checksum": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/Checksum.schema.json" + }, + { + "type": "null" + } + ] + } + } +} diff --git a/contracts/schema/types/ErrorCode.schema.json b/contracts/schema/types/ErrorCode.schema.json new file mode 100644 index 0000000..339eae2 --- /dev/null +++ b/contracts/schema/types/ErrorCode.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/ErrorCode.schema.json", + "title": "ErrorCode", + "description": "Every error code the daemon may return. Adding one is a minor bump; changing the meaning of one is a major bump.", + "type": "integer", + "x-enum": [ + { "name": "ParseError", "value": -32700, "doc": "Malformed JSON on the wire." }, + { "name": "InvalidRequest", "value": -32600, "doc": "Not a valid JSON-RPC 2.0 request object." }, + { "name": "MethodNotFound", "value": -32601, "doc": "Unknown method name." }, + { "name": "InvalidParams", "value": -32602, "doc": "Params failed schema validation." }, + { "name": "InternalError", "value": -32603, "doc": "Unhandled daemon-side failure." }, + { "name": "VersionMismatch", "value": -32001, "doc": "Protocol major version mismatch. GUI renders this as 'Velox needs updating'." }, + { "name": "NotPaired", "value": -32002, "doc": "Missing or invalid token on the WebSocket transport." }, + { "name": "TransportForbidden", "value": -32003, "doc": "Method is privileged and was called over a transport that may not use it." }, + { "name": "TaskNotFound", "value": -32010, "doc": "No task with that id." }, + { "name": "InvalidPath", "value": -32011, "doc": "Destination is outside the allowed roots, or is not writable. data.path is set." }, + { "name": "DiskFull", "value": -32012, "doc": "Not enough free space to preallocate." }, + { "name": "ProbeFailed", "value": -32013, "doc": "Could not probe the URL. data.httpStatus is set when there was an HTTP response." }, + { "name": "RateLimited", "value": -32014, "doc": "Pairing brute-force lockout. data.retryAfterSec is set." } + ], + "enum": [-32700, -32600, -32601, -32602, -32603, -32001, -32002, -32003, -32010, -32011, -32012, -32013, -32014] +} diff --git a/contracts/schema/types/GrabberFile.schema.json b/contracts/schema/types/GrabberFile.schema.json new file mode 100644 index 0000000..e3be970 --- /dev/null +++ b/contracts/schema/types/GrabberFile.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/GrabberFile.schema.json", + "title": "GrabberFile", + "description": "One candidate found by the Site Grabber crawl. Nothing is downloaded until grabber.harvest selects it.", + "type": "object", + "additionalProperties": false, + "required": ["fileId", "url", "depth"], + "properties": { + "fileId": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "filename": { "type": ["string", "null"] }, + "sizeBytes": { "type": ["integer", "null"], "minimum": 0, "description": "From a HEAD, when the server answered one." }, + "contentType": { "type": ["string", "null"] }, + "depth": { "type": "integer", "minimum": 0 }, + "foundOn": { "type": ["string", "null"], "format": "uri", "description": "The page this link was found on." } + } +} diff --git a/contracts/schema/types/Headers.schema.json b/contracts/schema/types/Headers.schema.json new file mode 100644 index 0000000..41beebe --- /dev/null +++ b/contracts/schema/types/Headers.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/Headers.schema.json", + "title": "Headers", + "description": "HTTP request headers, verbatim as the browser would have sent them. Needed for signed-URL and referrer-gated CDNs.", + "type": "object", + "additionalProperties": { "type": "string" } +} diff --git a/contracts/schema/types/Limiter.schema.json b/contracts/schema/types/Limiter.schema.json new file mode 100644 index 0000000..c77afc3 --- /dev/null +++ b/contracts/schema/types/Limiter.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/Limiter.schema.json", + "title": "Limiter", + "description": "Global token-bucket speed limit. Applies across every active task, not per task.", + "type": "object", + "additionalProperties": false, + "required": ["enabled", "globalBps"], + "properties": { + "enabled": { "type": "boolean" }, + "globalBps": { "type": "integer", "minimum": 0, "description": "Bytes per second. 0 with enabled true means 'stop everything', which the GUI must not offer." }, + "applyToRunning": { "type": "boolean", "description": "Re-tune already-running transfers instead of waiting for the next task." } + } +} diff --git a/contracts/schema/types/MediaVariant.schema.json b/contracts/schema/types/MediaVariant.schema.json new file mode 100644 index 0000000..f0ec77e --- /dev/null +++ b/contracts/schema/types/MediaVariant.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/MediaVariant.schema.json", + "title": "MediaVariant", + "description": "One quality rendition from an HLS or DASH manifest. The daemon parses the manifest; the extension only renders this list. DRM-protected variants are reported with drm true and must be shown greyed out rather than failing later.", + "type": "object", + "additionalProperties": false, + "required": ["variantId", "kind", "drm"], + "properties": { + "variantId": { "type": "string" }, + "kind": { "type": "string", "enum": ["video", "audio", "muxed", "subtitle"] }, + "resolution": { "type": ["string", "null"], "pattern": "^[0-9]{2,5}x[0-9]{2,5}$" }, + "bitrateBps": { "type": ["integer", "null"], "minimum": 0 }, + "codec": { "type": ["string", "null"] }, + "container": { "type": ["string", "null"], "enum": ["ts", "mp4", "webm", "mkv", null] }, + "frameRate": { "type": ["number", "null"], "minimum": 0 }, + "language": { "type": ["string", "null"] }, + "sizeEstimate": { "type": ["integer", "null"], "minimum": 0, "description": "bitrate x duration. Never exact — the GUI must label it as approximate." }, + "drm": { "type": "boolean", "description": "Widevine/EME detected. Explicitly out of scope; refuse rather than fail mysteriously." } + } +} diff --git a/contracts/schema/types/Queue.schema.json b/contracts/schema/types/Queue.schema.json new file mode 100644 index 0000000..3d0b74e --- /dev/null +++ b/contracts/schema/types/Queue.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/Queue.schema.json", + "title": "Queue", + "description": "An ordered run of tasks with its own concurrency cap and optional schedule.", + "type": "object", + "additionalProperties": false, + "required": ["queueId", "name", "state", "maxConcurrent"], + "properties": { + "queueId": { "type": "string" }, + "name": { "type": "string", "maxLength": 64 }, + "state": { "type": "string", "enum": ["running", "stopped"] }, + "maxConcurrent": { "type": "integer", "minimum": 1, "maximum": 32 }, + "taskIds": { "type": "array", "items": { "type": "string", "format": "uuid" }, "description": "In run order. queue.reorder rewrites this." }, + "schedule": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Schedule.schema.json" }, { "type": "null" }] }, + "onComplete": { "type": "string", "enum": ["nothing", "exit", "shutdown", "hangup"], "description": "shutdown goes through org.freedesktop.login1 and must be confirmed by the user." } + } +} diff --git a/contracts/schema/types/Rule.schema.json b/contracts/schema/types/Rule.schema.json new file mode 100644 index 0000000..0448d1b --- /dev/null +++ b/contracts/schema/types/Rule.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/Rule.schema.json", + "title": "Rule", + "description": "One row of the rules engine: match on extension, MIME, host or size, then route. First match by priority wins; no rule matching means the default category.", + "type": "object", + "additionalProperties": false, + "required": ["ruleId", "enabled", "priority", "match", "action"], + "properties": { + "ruleId": { "type": "string" }, + "name": { "type": ["string", "null"], "maxLength": 64 }, + "enabled": { "type": "boolean" }, + "priority": { "type": "integer", "minimum": 0, "description": "Lower runs first." }, + "match": { + "type": "object", + "additionalProperties": false, + "description": "All present clauses must match. An absent clause is not a constraint.", + "properties": { + "extensions": { "type": ["array", "null"], "items": { "type": "string" } }, + "mimeTypes": { "type": ["array", "null"], "items": { "type": "string" } }, + "hostPattern": { "type": ["string", "null"], "description": "Glob against the effective URL's host, e.g. *.example.com" }, + "urlPattern": { "type": ["string", "null"], "description": "Glob against the whole effective URL." }, + "minSizeBytes":{ "type": ["integer", "null"], "minimum": 0 }, + "maxSizeBytes":{ "type": ["integer", "null"], "minimum": 0 } + } + }, + "action": { + "type": "object", + "additionalProperties": false, + "description": "What to do with a matching download.", + "properties": { + "categoryId": { "type": ["string", "null"] }, + "saveDir": { "type": ["string", "null"] }, + "queueId": { "type": ["string", "null"] }, + "segments": { "type": ["integer", "null"], "minimum": 1, "maximum": 32 }, + "startMode": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/StartMode.schema.json" }, { "type": "null" }] }, + "capture": { "type": ["string", "null"], "enum": ["take", "ignore", null], "description": "Lets a rule veto capture for a host without touching the exclusion list." } + } + } + } +} diff --git a/contracts/schema/types/Schedule.schema.json b/contracts/schema/types/Schedule.schema.json new file mode 100644 index 0000000..5a3ae83 --- /dev/null +++ b/contracts/schema/types/Schedule.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/Schedule.schema.json", + "title": "Schedule", + "description": "When a queue may run. Times are local wall-clock in HH:MM; the daemon re-evaluates them on a DST change rather than caching absolute instants.", + "type": "object", + "additionalProperties": false, + "required": ["enabled", "mode"], + "properties": { + "enabled": { "type": "boolean" }, + "mode": { "type": "string", "enum": ["once", "periodic"] }, + "startTime": { "type": ["string", "null"], "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$" }, + "stopTime": { "type": ["string", "null"], "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$", "description": "null means run until the queue drains." }, + "daysOfWeek": { "type": "array", "items": { "type": "integer", "minimum": 0, "maximum": 6 }, "description": "0 = Sunday. Ignored when mode is 'once'." }, + "onceDate": { "type": ["string", "null"], "format": "date", "description": "Set only when mode is 'once'." } + } +} diff --git a/contracts/schema/types/Segment.schema.json b/contracts/schema/types/Segment.schema.json new file mode 100644 index 0000000..919b007 --- /dev/null +++ b/contracts/schema/types/Segment.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/Segment.schema.json", + "title": "Segment", + "description": "One byte range being fetched by one connection. This is the deepest the contract ever exposes the engine: the GUI draws a bar per segment and is never told what a segment steal is.\n\nRANGE CONVENTION — READ THIS BEFORE IMPLEMENTING. The range is CLOSED and INCLUSIVE on both ends: [startByte, endByte]. The segment covers endByte - startByte + 1 bytes, and endByte is the index of the LAST byte in the range, not one past it. This deliberately matches the HTTP Range header the engine actually sends ('Range: bytes=-' is a byte-for-byte copy of these two fields, and RFC 9110 ranges are inclusive), so no arithmetic happens between the wire and the socket and there is nowhere for an off-by-one to hide. CORE asked for half-open [start, end); PROTO chose inclusive for that reason and this note exists so nobody discovers the difference at integration. A segment always covers at least one byte: endByte >= startByte always holds. An empty range is not representable and is not needed — a zero-length download carries an empty segmentDetail array, and a segment that has donated its remainder to a steal keeps the bytes it already wrote.", + "type": "object", + "additionalProperties": false, + "required": ["index", "startByte", "endByte", "downloadedBytes", "state"], + "properties": { + "index": { "type": "integer", "minimum": 0, "maximum": 31, "description": "Position in TaskDetail.segmentDetail. Spelled 'index' here and in event.task.progress; there is no 'i' spelling anywhere in the contract." }, + "startByte": { "type": "integer", "minimum": 0, "description": "Absolute offset of the first byte of the range. Inclusive." }, + "endByte": { "type": "integer", "minimum": 0, "description": "Absolute offset of the LAST byte of the range. Inclusive — this is not one-past-the-end. Always >= startByte." }, + "downloadedBytes": { "type": "integer", "minimum": 0, "description": "Bytes written for this range so far, out of endByte - startByte + 1." }, + "speedBps": { "type": "integer", "minimum": 0 }, + "state": { "type": "string", "enum": ["pending", "connecting", "downloading", "stalled", "complete", "failed"], "description": "'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has been planned but not yet dialled." }, + "httpStatus": { "type": ["integer", "null"], "minimum": 100, "maximum": 599, "description": "The status this segment's request got. 206 on a healthy ranged fetch." } + } +} diff --git a/contracts/schema/types/SettingKey.schema.json b/contracts/schema/types/SettingKey.schema.json new file mode 100644 index 0000000..1d7abf0 --- /dev/null +++ b/contracts/schema/types/SettingKey.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/SettingKey.schema.json", + "title": "SettingKey", + "description": "Every settings key that exists. The Options dialog maps 1:1 onto this list and the GUI must not invent a key that is not here. Kept in lockstep with Settings.schema.json by a conformance check.", + "type": "string", + "enum": [ + "general.launchOnLogin", "general.minimizeToTray", "general.showDropTarget", + "general.confirmOnExit", "general.language", "general.checkForUpdates", + + "capture.enabled", "capture.monitoredExtensions", "capture.monitoredMimeTypes", + "capture.minSizeBytes", "capture.excludedHosts", "capture.bypassModifier", + "capture.autoStartTypes", + + "saveTo.defaultDir", "saveTo.tempDir", "saveTo.allowedRoots", + "saveTo.fileExistsPolicy", "saveTo.createSubfolderPerSite", + + "connection.preset", "connection.maxSegmentsPerDownload", "connection.bufferBytes", + "connection.maxConcurrentDownloads", "connection.timeoutSec", "connection.maxRetries", + "connection.retryBackoffSec", + + "downloads.speedLimitBps", "downloads.speedLimitEnabled", "downloads.virusScanCommand", + "downloads.postDownloadCommand", "downloads.duplicatePolicy", "downloads.verifyChecksums", + + "proxy.mode", "proxy.host", "proxy.port", "proxy.username", "proxy.bypassHosts", "proxy.pacUrl", + + "sounds.enabled", "sounds.onComplete", "sounds.onQueueComplete", "sounds.onError" + ] +} diff --git a/contracts/schema/types/Settings.schema.json b/contracts/schema/types/Settings.schema.json new file mode 100644 index 0000000..355ebf3 --- /dev/null +++ b/contracts/schema/types/Settings.schema.json @@ -0,0 +1,196 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/Settings.schema.json", + "title": "Settings", + "description": "A sparse bag of settings. Every property is optional because settings.get returns only the keys that were asked for and settings.set carries only the keys that changed. Property names must match SettingKey exactly. NOTE: no password lives here \u2014 proxy and site-login credentials go to the Secret Service, never to SQLite and never over the wire.", + "type": "object", + "additionalProperties": false, + "properties": { + "general.launchOnLogin": { + "type": "boolean" + }, + "general.minimizeToTray": { + "type": "boolean" + }, + "general.showDropTarget": { + "type": "boolean" + }, + "general.confirmOnExit": { + "type": "boolean" + }, + "general.language": { + "type": "string", + "description": "BCP 47, or 'system'." + }, + "general.checkForUpdates": { + "type": "boolean" + }, + "capture.enabled": { + "type": "boolean" + }, + "capture.monitoredExtensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "capture.monitoredMimeTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "capture.minSizeBytes": { + "type": "integer", + "minimum": 0 + }, + "capture.excludedHosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "capture.bypassModifier": { + "$ref": "https://velox.dev/schema/types/BypassModifier.schema.json" + }, + "capture.autoStartTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Extensions that skip the File Info dialog and start immediately." + }, + "saveTo.defaultDir": { + "type": "string" + }, + "saveTo.tempDir": { + "type": "string" + }, + "saveTo.allowedRoots": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Every write target is canonicalized and must resolve inside one of these. Read-only over the WebSocket transport." + }, + "saveTo.fileExistsPolicy": { + "type": "string", + "enum": [ + "ask", + "rename", + "overwrite", + "resume" + ] + }, + "saveTo.createSubfolderPerSite": { + "type": "boolean" + }, + "connection.preset": { + "type": "string", + "enum": [ + "auto", + "lan", + "broadband", + "slow" + ] + }, + "connection.maxSegmentsPerDownload": { + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "connection.bufferBytes": { + "type": "integer", + "minimum": 4096, + "maximum": 8388608 + }, + "connection.maxConcurrentDownloads": { + "type": "integer", + "minimum": 1, + "maximum": 64 + }, + "connection.timeoutSec": { + "type": "integer", + "minimum": 1, + "maximum": 3600 + }, + "connection.maxRetries": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "connection.retryBackoffSec": { + "type": "integer", + "minimum": 0, + "maximum": 3600 + }, + "downloads.speedLimitBps": { + "type": "integer", + "minimum": 0 + }, + "downloads.speedLimitEnabled": { + "type": "boolean" + }, + "downloads.virusScanCommand": { + "type": "string" + }, + "downloads.postDownloadCommand": { + "type": "string" + }, + "downloads.duplicatePolicy": { + "type": "string", + "enum": [ + "ask", + "skip", + "rename", + "redownload" + ] + }, + "downloads.verifyChecksums": { + "type": "boolean" + }, + "proxy.mode": { + "type": "string", + "enum": [ + "system", + "none", + "http", + "https", + "socks5", + "pac" + ] + }, + "proxy.host": { + "type": "string" + }, + "proxy.port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "proxy.username": { + "type": "string" + }, + "proxy.bypassHosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "proxy.pacUrl": { + "type": "string" + }, + "sounds.enabled": { + "type": "boolean" + }, + "sounds.onComplete": { + "type": "string" + }, + "sounds.onQueueComplete": { + "type": "string" + }, + "sounds.onError": { + "type": "string" + } + } +} diff --git a/contracts/schema/types/StartMode.schema.json b/contracts/schema/types/StartMode.schema.json new file mode 100644 index 0000000..120060c --- /dev/null +++ b/contracts/schema/types/StartMode.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/StartMode.schema.json", + "title": "StartMode", + "description": "What the daemon does with a task the moment it is added. 'later' is the File Info dialog's Download Later button and lands the task in paused.", + "type": "string", + "enum": ["now", "later", "queue"] +} diff --git a/contracts/schema/types/TaskDetail.schema.json b/contracts/schema/types/TaskDetail.schema.json new file mode 100644 index 0000000..c213f21 --- /dev/null +++ b/contracts/schema/types/TaskDetail.schema.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/TaskDetail.schema.json", + "title": "TaskDetail", + "description": "Everything TaskSummary carries, plus what only the progress dialog and the File Info dialog need. Returned by download.get; never sent in a list or an event, because it is expensive to build.", + "type": "object", + "additionalProperties": false, + "required": [ + "summary", + "segmentDetail" + ], + "properties": { + "summary": { + "$ref": "https://velox.dev/schema/types/TaskSummary.schema.json" + }, + "segmentDetail": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "https://velox.dev/schema/types/Segment.schema.json" + }, + "description": "Exactly TaskSummary.segments entries, in index order, covering [0, sizeBytes) with no gaps and no overlaps. Empty for a zero-length download, and empty before the task has been segmented." + }, + "headers": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/Headers.schema.json" + }, + { + "type": "null" + } + ] + }, + "referrer": { + "type": [ + "string", + "null" + ] + }, + "userAgent": { + "type": [ + "string", + "null" + ] + }, + "mime": { + "type": [ + "string", + "null" + ] + }, + "bufferBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 4096, + "maximum": 8388608 + }, + "partPath": { + "type": [ + "string", + "null" + ], + "description": "Absolute path of the .veloxpart file while the task is unfinished." + }, + "checksum": { + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/Checksum.schema.json" + }, + { + "type": "null" + } + ] + }, + "checksumVerified": { + "type": [ + "boolean", + "null" + ], + "description": "null until the verifying state has run." + }, + "averageSpeedBps": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "retryCount": { + "type": "integer", + "minimum": 0 + } + } +} diff --git a/contracts/schema/types/TaskError.schema.json b/contracts/schema/types/TaskError.schema.json new file mode 100644 index 0000000..40f82e1 --- /dev/null +++ b/contracts/schema/types/TaskError.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/TaskError.schema.json", + "title": "TaskError", + "description": "Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which describes a failed call rather than a failed download — the two live in different code spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer.", + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "retryable"], + "properties": { + "code": { "$ref": "https://velox.dev/schema/types/TaskErrorCode.schema.json" }, + "message": { "type": "string", "description": "Human-readable, safe to show a user. Never carries a credential, a token or a full local path outside the download roots." }, + "httpStatus": { "type": ["integer", "null"], "minimum": 100, "maximum": 599, "description": "Set for the codes listed in TaskErrorCode's x-carriesHttpStatus, and null otherwise." }, + "retryable": { "type": "boolean", "description": "Whether the scheduler will pick this task up again on its own. Carried per-occurrence rather than derived from the code, because 'probe_failed' is retryable or not depending on what the probe hit." }, + "cause": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/TaskErrorCode.schema.json" }, { "type": "null" }], "description": "The underlying failure, for codes that wrap one. max_retries_exhausted sets it to whatever the last attempt actually failed with, so a user learns the reason rather than just that Velox gave up." }, + "attempt": { "type": ["integer", "null"], "minimum": 0, "description": "How many attempts have been made so far." }, + "nextRetryAt":{ "type": ["string", "null"], "format": "date-time" } + } +} diff --git a/contracts/schema/types/TaskErrorCode.schema.json b/contracts/schema/types/TaskErrorCode.schema.json new file mode 100644 index 0000000..df2db23 --- /dev/null +++ b/contracts/schema/types/TaskErrorCode.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/TaskErrorCode.schema.json", + "title": "TaskErrorCode", + "description": "Why a download failed. This is the WIRE failure taxonomy and it is deliberately NOT the JSON-RPC ErrorCode space: ErrorCode says why a *call* failed, TaskErrorCode says why a *download* failed. A task can fail while every RPC involved succeeded. The values mirror vdm::Error in core/include/vdm/util/error.hpp one-for-one, by name, so DAEMON's projection from the engine taxonomy onto the wire is lossless and the GUI can tell 'the file on the server changed' from 'the checksum did not match'. CORE's 'ok' has no wire spelling: a TaskError only exists when there is a failure. Adding a value here is a minor bump; renaming or removing one is major, and would desynchronise the engine.", + "type": "string", + "enum": [ + "canceled", + + "resolve_failed", + "connect_failed", + "tls_failed", + "connection_reset", + "timeout", + "too_many_redirects", + + "http_client_error", + "http_server_error", + "auth_required", + "forbidden", + "not_found", + "range_not_satisfiable", + "gone", + + "server_file_changed", + "content_length_mismatch", + "checksum_mismatch", + + "disk_full", + "io_error", + "path_rejected", + "permission_denied", + + "meta_corrupt", + "meta_version_unsupported", + + "probe_failed", + "unsupported_url_scheme", + + "max_retries_exhausted", + + "internal" + ], + "x-groups": { + "cancellation": ["canceled"], + "network": ["resolve_failed", "connect_failed", "tls_failed", "connection_reset", "timeout", "too_many_redirects"], + "http": ["http_client_error", "http_server_error", "auth_required", "forbidden", "not_found", "range_not_satisfiable", "gone"], + "content": ["server_file_changed", "content_length_mismatch", "checksum_mismatch"], + "localIo": ["disk_full", "io_error", "path_rejected", "permission_denied"], + "resumeMetadata": ["meta_corrupt", "meta_version_unsupported"], + "probe": ["probe_failed", "unsupported_url_scheme"], + "retry": ["max_retries_exhausted"], + "internal": ["internal"] + }, + "x-carriesHttpStatus": [ + "http_client_error", "http_server_error", "auth_required", "forbidden", "not_found", + "range_not_satisfiable", "gone", "server_file_changed", "probe_failed", "max_retries_exhausted" + ] +} diff --git a/contracts/schema/types/TaskFilter.schema.json b/contracts/schema/types/TaskFilter.schema.json new file mode 100644 index 0000000..b1e9f76 --- /dev/null +++ b/contracts/schema/types/TaskFilter.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/TaskFilter.schema.json", + "title": "TaskFilter", + "description": "Which rows download.list returns. This is the category tree and the All/Unfinished/Finished nodes, expressed on the wire. Absent clauses are not constraints.", + "type": "object", + "additionalProperties": false, + "properties": { + "states": { "type": ["array", "null"], "items": { "$ref": "https://velox.dev/schema/types/TaskState.schema.json" } }, + "categoryId": { "type": ["string", "null"] }, + "queueId": { "type": ["string", "null"] }, + "query": { "type": ["string", "null"], "maxLength": 256, "description": "Case-insensitive substring of filename or url." }, + "addedAfter": { "type": ["string", "null"], "format": "date-time" }, + "addedBefore":{ "type": ["string", "null"], "format": "date-time" } + } +} diff --git a/contracts/schema/types/TaskSort.schema.json b/contracts/schema/types/TaskSort.schema.json new file mode 100644 index 0000000..53c7eea --- /dev/null +++ b/contracts/schema/types/TaskSort.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/TaskSort.schema.json", + "title": "TaskSort", + "description": "Sort order for download.list. The GUI persists the user's choice and sends it on every list call; the daemon does the sorting so a 100k-row list never has to be materialized client-side.", + "type": "object", + "additionalProperties": false, + "required": ["field", "direction"], + "properties": { + "field": { "type": "string", "enum": ["filename", "sizeBytes", "state", "etaSeconds", "speedBps", "lastTryAt", "createdAt", "queuePosition", "description"] }, + "direction": { "type": "string", "enum": ["asc", "desc"] } + } +} diff --git a/contracts/schema/types/TaskState.schema.json b/contracts/schema/types/TaskState.schema.json new file mode 100644 index 0000000..d35cc56 --- /dev/null +++ b/contracts/schema/types/TaskState.schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://velox.dev/schema/types/TaskState.schema.json", + "title": "TaskState", + "description": "Lifecycle of one download. The daemon is the only writer; clients render it and nothing more. Terminal states are complete, failed and cancelled.", + "type": "string", + "enum": ["new", "probing", "queued", "connecting", "downloading", "paused", + "retry_wait", "assembling", "verifying", "complete", "failed", "cancelled"] +} diff --git a/contracts/schema/types/TaskSummary.schema.json b/contracts/schema/types/TaskSummary.schema.json index 9544087..3e720d2 100644 --- a/contracts/schema/types/TaskSummary.schema.json +++ b/contracts/schema/types/TaskSummary.schema.json @@ -2,43 +2,137 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://velox.dev/schema/types/TaskSummary.schema.json", "title": "TaskSummary", - "description": "One row of the main download list. Everything the GUI table needs, and nothing more. TEMPLATE — lane PROTO owns the final shape.", + "description": "One row of the main download list. Everything the GUI table needs, and nothing more. TaskDetail is the same shape plus the fields only the progress dialog and File Info dialog need.", "type": "object", "additionalProperties": false, - "required": ["taskId", "filename", "state", "createdAt"], + "required": [ + "taskId", + "filename", + "saveDir", + "url", + "state", + "downloadedBytes", + "speedBps", + "resumable", + "segments", + "createdAt" + ], "properties": { - "taskId": { "type": "string", "format": "uuid" }, - "filename": { "type": "string", "maxLength": 255 }, - "saveDir": { "type": "string" }, - "url": { "type": "string", "format": "uri" }, - "effectiveUrl": { "type": "string", "format": "uri" }, - "sizeBytes": { "type": ["integer", "null"], "minimum": 0, "description": "null when the server did not report a length" }, - "downloadedBytes": { "type": "integer", "minimum": 0 }, - "state": { + "taskId": { "type": "string", - "enum": ["new", "probing", "queued", "connecting", "downloading", "paused", - "retry_wait", "assembling", "verifying", "complete", "failed", "cancelled"] + "format": "uuid" + }, + "filename": { + "type": "string", + "maxLength": 255 + }, + "saveDir": { + "type": "string", + "description": "Absolute, canonicalized, inside an allowed root." + }, + "url": { + "type": "string", + "format": "uri", + "description": "The URL as the user or the extension supplied it." + }, + "effectiveUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "After redirects. null until the first probe succeeds." + }, + "sizeBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "null when the server did not report a length." + }, + "downloadedBytes": { + "type": "integer", + "minimum": 0 + }, + "state": { + "$ref": "https://velox.dev/schema/types/TaskState.schema.json" + }, + "speedBps": { + "type": "integer", + "minimum": 0 + }, + "etaSeconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "null when the size or the speed is unknown." + }, + "resumable": { + "type": "boolean" + }, + "segments": { + "type": "integer", + "minimum": 1, + "maximum": 32, + "description": "The EFFECTIVE connection count in use right now \u2014 not the number that was requested. It is what remains after the per-host connection cap has been applied and after the demotion to 1 for a non-resumable source, so a task the user asked for 16 connections on legitimately reports 4, or 1. The GUI displays this value and must not assume it equals what download.add asked for. The requested value lives in DownloadSpec.segments and is not echoed back on this type. TaskDetail.segmentDetail always has exactly this many entries." + }, + "categoryId": { + "type": [ + "string", + "null" + ] + }, + "queueId": { + "type": [ + "string", + "null" + ] + }, + "queuePosition": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "The Q column." + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 1024 + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "lastTryAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "completedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" }, - "speedBps": { "type": "integer", "minimum": 0 }, - "etaSeconds": { "type": ["integer", "null"], "minimum": 0 }, - "resumable": { "type": "boolean" }, - "segments": { "type": "integer", "minimum": 1, "maximum": 32 }, - "categoryId": { "type": ["string", "null"] }, - "queueId": { "type": ["string", "null"] }, - "queuePosition":{ "type": ["integer", "null"], "minimum": 0, "description": "the Q column" }, - "description": { "type": "string", "maxLength": 1024 }, - "createdAt": { "type": "string", "format": "date-time" }, - "lastTryAt": { "type": ["string", "null"], "format": "date-time" }, - "completedAt": { "type": ["string", "null"], "format": "date-time" }, "error": { - "type": ["object", "null"], - "additionalProperties": false, - "properties": { - "code": { "type": "integer" }, - "message": { "type": "string" }, - "httpStatus": { "type": ["integer", "null"] }, - "retryable": { "type": "boolean" } - } + "oneOf": [ + { + "$ref": "https://velox.dev/schema/types/TaskError.schema.json" + }, + { + "type": "null" + } + ] } } } diff --git a/core/generated/velox_proto.cpp b/core/generated/velox_proto.cpp new file mode 100644 index 0000000..b74500c --- /dev/null +++ b/core/generated/velox_proto.cpp @@ -0,0 +1,7585 @@ +// --------------------------------------------------------------------------- +// GENERATED FILE — DO NOT EDIT. +// +// Source: contracts/schema/** +// Generator: contracts/codegen/gen_cpp.py +// Contract: v1.0.0 +// +// Hand-editing this file is a merge blocker. Fix the schema and regenerate: +// python3 contracts/codegen/gen_cpp.py +// Only lane PROTO commits to contracts/. +// --------------------------------------------------------------------------- + +#include "velox_proto.hpp" + +#include +#include + +namespace velox::proto { + +namespace { + +std::string join(std::string_view path, std::string_view key) { + std::string out(path); + out += "/"; + out += key; + return out; +} + +} // namespace + +std::string_view to_string(ErrorCode v) noexcept { + switch (v) { + case ErrorCode::ParseError: return "ParseError"; + case ErrorCode::InvalidRequest: return "InvalidRequest"; + case ErrorCode::MethodNotFound: return "MethodNotFound"; + case ErrorCode::InvalidParams: return "InvalidParams"; + case ErrorCode::InternalError: return "InternalError"; + case ErrorCode::VersionMismatch: return "VersionMismatch"; + case ErrorCode::NotPaired: return "NotPaired"; + case ErrorCode::TransportForbidden: return "TransportForbidden"; + case ErrorCode::TaskNotFound: return "TaskNotFound"; + case ErrorCode::InvalidPath: return "InvalidPath"; + case ErrorCode::DiskFull: return "DiskFull"; + case ErrorCode::ProbeFailed: return "ProbeFailed"; + case ErrorCode::RateLimited: return "RateLimited"; + } + return ""; +} + +std::optional errorcode_from_int(std::int32_t v) noexcept { + switch (v) { + case -32700: return ErrorCode::ParseError; + case -32600: return ErrorCode::InvalidRequest; + case -32601: return ErrorCode::MethodNotFound; + case -32602: return ErrorCode::InvalidParams; + case -32603: return ErrorCode::InternalError; + case -32001: return ErrorCode::VersionMismatch; + case -32002: return ErrorCode::NotPaired; + case -32003: return ErrorCode::TransportForbidden; + case -32010: return ErrorCode::TaskNotFound; + case -32011: return ErrorCode::InvalidPath; + case -32012: return ErrorCode::DiskFull; + case -32013: return ErrorCode::ProbeFailed; + case -32014: return ErrorCode::RateLimited; + default: return std::nullopt; + } +} + +void to_json(nlohmann::json& j, const ErrorCode& v) { j = static_cast(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_number_integer()) return std::unexpected(ParseError{std::string(path), "expected an integer"}); + auto v = errorcode_from_int(j.get()); + if (!v) return std::unexpected(ParseError{std::string(path), "not a contract error code"}); + return *v; +} + +std::string_view to_string(TaskState v) noexcept { + switch (v) { + case TaskState::New: return "new"; + case TaskState::Probing: return "probing"; + case TaskState::Queued: return "queued"; + case TaskState::Connecting: return "connecting"; + case TaskState::Downloading: return "downloading"; + case TaskState::Paused: return "paused"; + case TaskState::RetryWait: return "retry_wait"; + case TaskState::Assembling: return "assembling"; + case TaskState::Verifying: return "verifying"; + case TaskState::Complete: return "complete"; + case TaskState::Failed: return "failed"; + case TaskState::Cancelled: return "cancelled"; + } + return ""; +} + +Result parse_TaskState(std::string_view s) { + if (s == "new") return TaskState::New; + if (s == "probing") return TaskState::Probing; + if (s == "queued") return TaskState::Queued; + if (s == "connecting") return TaskState::Connecting; + if (s == "downloading") return TaskState::Downloading; + if (s == "paused") return TaskState::Paused; + if (s == "retry_wait") return TaskState::RetryWait; + if (s == "assembling") return TaskState::Assembling; + if (s == "verifying") return TaskState::Verifying; + if (s == "complete") return TaskState::Complete; + if (s == "failed") return TaskState::Failed; + if (s == "cancelled") return TaskState::Cancelled; + return std::unexpected(ParseError{"", "not a valid TaskState: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const TaskState& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_TaskState(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(BypassModifier v) noexcept { + switch (v) { + case BypassModifier::Alt: return "alt"; + case BypassModifier::Ctrl: return "ctrl"; + case BypassModifier::Shift: return "shift"; + case BypassModifier::None: return "none"; + } + return ""; +} + +Result parse_BypassModifier(std::string_view s) { + if (s == "alt") return BypassModifier::Alt; + if (s == "ctrl") return BypassModifier::Ctrl; + if (s == "shift") return BypassModifier::Shift; + if (s == "none") return BypassModifier::None; + return std::unexpected(ParseError{"", "not a valid BypassModifier: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const BypassModifier& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_BypassModifier(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(ChecksumAlgorithm v) noexcept { + switch (v) { + case ChecksumAlgorithm::Md5: return "md5"; + case ChecksumAlgorithm::Sha1: return "sha1"; + case ChecksumAlgorithm::Sha256: return "sha256"; + case ChecksumAlgorithm::Sha512: return "sha512"; + } + return ""; +} + +Result parse_ChecksumAlgorithm(std::string_view s) { + if (s == "md5") return ChecksumAlgorithm::Md5; + if (s == "sha1") return ChecksumAlgorithm::Sha1; + if (s == "sha256") return ChecksumAlgorithm::Sha256; + if (s == "sha512") return ChecksumAlgorithm::Sha512; + return std::unexpected(ParseError{"", "not a valid ChecksumAlgorithm: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const ChecksumAlgorithm& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_ChecksumAlgorithm(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(StartMode v) noexcept { + switch (v) { + case StartMode::Now: return "now"; + case StartMode::Later: return "later"; + case StartMode::Queue: return "queue"; + } + return ""; +} + +Result parse_StartMode(std::string_view s) { + if (s == "now") return StartMode::Now; + if (s == "later") return StartMode::Later; + if (s == "queue") return StartMode::Queue; + return std::unexpected(ParseError{"", "not a valid StartMode: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const StartMode& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_StartMode(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(MediaVariantContainer v) noexcept { + switch (v) { + case MediaVariantContainer::Ts: return "ts"; + case MediaVariantContainer::Mp4: return "mp4"; + case MediaVariantContainer::Webm: return "webm"; + case MediaVariantContainer::Mkv: return "mkv"; + } + return ""; +} + +Result parse_MediaVariantContainer(std::string_view s) { + if (s == "ts") return MediaVariantContainer::Ts; + if (s == "mp4") return MediaVariantContainer::Mp4; + if (s == "webm") return MediaVariantContainer::Webm; + if (s == "mkv") return MediaVariantContainer::Mkv; + return std::unexpected(ParseError{"", "not a valid MediaVariantContainer: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const MediaVariantContainer& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_MediaVariantContainer(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(MediaVariantKind v) noexcept { + switch (v) { + case MediaVariantKind::Video: return "video"; + case MediaVariantKind::Audio: return "audio"; + case MediaVariantKind::Muxed: return "muxed"; + case MediaVariantKind::Subtitle: return "subtitle"; + } + return ""; +} + +Result parse_MediaVariantKind(std::string_view s) { + if (s == "video") return MediaVariantKind::Video; + if (s == "audio") return MediaVariantKind::Audio; + if (s == "muxed") return MediaVariantKind::Muxed; + if (s == "subtitle") return MediaVariantKind::Subtitle; + return std::unexpected(ParseError{"", "not a valid MediaVariantKind: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const MediaVariantKind& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_MediaVariantKind(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(QueueOnComplete v) noexcept { + switch (v) { + case QueueOnComplete::Nothing: return "nothing"; + case QueueOnComplete::Exit: return "exit"; + case QueueOnComplete::Shutdown: return "shutdown"; + case QueueOnComplete::Hangup: return "hangup"; + } + return ""; +} + +Result parse_QueueOnComplete(std::string_view s) { + if (s == "nothing") return QueueOnComplete::Nothing; + if (s == "exit") return QueueOnComplete::Exit; + if (s == "shutdown") return QueueOnComplete::Shutdown; + if (s == "hangup") return QueueOnComplete::Hangup; + return std::unexpected(ParseError{"", "not a valid QueueOnComplete: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const QueueOnComplete& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_QueueOnComplete(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(QueueState v) noexcept { + switch (v) { + case QueueState::Running: return "running"; + case QueueState::Stopped: return "stopped"; + } + return ""; +} + +Result parse_QueueState(std::string_view s) { + if (s == "running") return QueueState::Running; + if (s == "stopped") return QueueState::Stopped; + return std::unexpected(ParseError{"", "not a valid QueueState: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const QueueState& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_QueueState(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(ScheduleMode v) noexcept { + switch (v) { + case ScheduleMode::Once: return "once"; + case ScheduleMode::Periodic: return "periodic"; + } + return ""; +} + +Result parse_ScheduleMode(std::string_view s) { + if (s == "once") return ScheduleMode::Once; + if (s == "periodic") return ScheduleMode::Periodic; + return std::unexpected(ParseError{"", "not a valid ScheduleMode: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const ScheduleMode& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_ScheduleMode(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(RuleActionCapture v) noexcept { + switch (v) { + case RuleActionCapture::Take: return "take"; + case RuleActionCapture::Ignore: return "ignore"; + } + return ""; +} + +Result parse_RuleActionCapture(std::string_view s) { + if (s == "take") return RuleActionCapture::Take; + if (s == "ignore") return RuleActionCapture::Ignore; + return std::unexpected(ParseError{"", "not a valid RuleActionCapture: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const RuleActionCapture& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_RuleActionCapture(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(SegmentState v) noexcept { + switch (v) { + case SegmentState::Pending: return "pending"; + case SegmentState::Connecting: return "connecting"; + case SegmentState::Downloading: return "downloading"; + case SegmentState::Stalled: return "stalled"; + case SegmentState::Complete: return "complete"; + case SegmentState::Failed: return "failed"; + } + return ""; +} + +Result parse_SegmentState(std::string_view s) { + if (s == "pending") return SegmentState::Pending; + if (s == "connecting") return SegmentState::Connecting; + if (s == "downloading") return SegmentState::Downloading; + if (s == "stalled") return SegmentState::Stalled; + if (s == "complete") return SegmentState::Complete; + if (s == "failed") return SegmentState::Failed; + return std::unexpected(ParseError{"", "not a valid SegmentState: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const SegmentState& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_SegmentState(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(SettingKey v) noexcept { + switch (v) { + case SettingKey::GeneralLaunchOnLogin: return "general.launchOnLogin"; + case SettingKey::GeneralMinimizeToTray: return "general.minimizeToTray"; + case SettingKey::GeneralShowDropTarget: return "general.showDropTarget"; + case SettingKey::GeneralConfirmOnExit: return "general.confirmOnExit"; + case SettingKey::GeneralLanguage: return "general.language"; + case SettingKey::GeneralCheckForUpdates: return "general.checkForUpdates"; + case SettingKey::CaptureEnabled: return "capture.enabled"; + case SettingKey::CaptureMonitoredExtensions: return "capture.monitoredExtensions"; + case SettingKey::CaptureMonitoredMimeTypes: return "capture.monitoredMimeTypes"; + case SettingKey::CaptureMinSizeBytes: return "capture.minSizeBytes"; + case SettingKey::CaptureExcludedHosts: return "capture.excludedHosts"; + case SettingKey::CaptureBypassModifier: return "capture.bypassModifier"; + case SettingKey::CaptureAutoStartTypes: return "capture.autoStartTypes"; + case SettingKey::SaveToDefaultDir: return "saveTo.defaultDir"; + case SettingKey::SaveToTempDir: return "saveTo.tempDir"; + case SettingKey::SaveToAllowedRoots: return "saveTo.allowedRoots"; + case SettingKey::SaveToFileExistsPolicy: return "saveTo.fileExistsPolicy"; + case SettingKey::SaveToCreateSubfolderPerSite: return "saveTo.createSubfolderPerSite"; + case SettingKey::ConnectionPreset: return "connection.preset"; + case SettingKey::ConnectionMaxSegmentsPerDownload: return "connection.maxSegmentsPerDownload"; + case SettingKey::ConnectionBufferBytes: return "connection.bufferBytes"; + case SettingKey::ConnectionMaxConcurrentDownloads: return "connection.maxConcurrentDownloads"; + case SettingKey::ConnectionTimeoutSec: return "connection.timeoutSec"; + case SettingKey::ConnectionMaxRetries: return "connection.maxRetries"; + case SettingKey::ConnectionRetryBackoffSec: return "connection.retryBackoffSec"; + case SettingKey::DownloadsSpeedLimitBps: return "downloads.speedLimitBps"; + case SettingKey::DownloadsSpeedLimitEnabled: return "downloads.speedLimitEnabled"; + case SettingKey::DownloadsVirusScanCommand: return "downloads.virusScanCommand"; + case SettingKey::DownloadsPostDownloadCommand: return "downloads.postDownloadCommand"; + case SettingKey::DownloadsDuplicatePolicy: return "downloads.duplicatePolicy"; + case SettingKey::DownloadsVerifyChecksums: return "downloads.verifyChecksums"; + case SettingKey::ProxyMode: return "proxy.mode"; + case SettingKey::ProxyHost: return "proxy.host"; + case SettingKey::ProxyPort: return "proxy.port"; + case SettingKey::ProxyUsername: return "proxy.username"; + case SettingKey::ProxyBypassHosts: return "proxy.bypassHosts"; + case SettingKey::ProxyPacUrl: return "proxy.pacUrl"; + case SettingKey::SoundsEnabled: return "sounds.enabled"; + case SettingKey::SoundsOnComplete: return "sounds.onComplete"; + case SettingKey::SoundsOnQueueComplete: return "sounds.onQueueComplete"; + case SettingKey::SoundsOnError: return "sounds.onError"; + } + return ""; +} + +Result parse_SettingKey(std::string_view s) { + if (s == "general.launchOnLogin") return SettingKey::GeneralLaunchOnLogin; + if (s == "general.minimizeToTray") return SettingKey::GeneralMinimizeToTray; + if (s == "general.showDropTarget") return SettingKey::GeneralShowDropTarget; + if (s == "general.confirmOnExit") return SettingKey::GeneralConfirmOnExit; + if (s == "general.language") return SettingKey::GeneralLanguage; + if (s == "general.checkForUpdates") return SettingKey::GeneralCheckForUpdates; + if (s == "capture.enabled") return SettingKey::CaptureEnabled; + if (s == "capture.monitoredExtensions") return SettingKey::CaptureMonitoredExtensions; + if (s == "capture.monitoredMimeTypes") return SettingKey::CaptureMonitoredMimeTypes; + if (s == "capture.minSizeBytes") return SettingKey::CaptureMinSizeBytes; + if (s == "capture.excludedHosts") return SettingKey::CaptureExcludedHosts; + if (s == "capture.bypassModifier") return SettingKey::CaptureBypassModifier; + if (s == "capture.autoStartTypes") return SettingKey::CaptureAutoStartTypes; + if (s == "saveTo.defaultDir") return SettingKey::SaveToDefaultDir; + if (s == "saveTo.tempDir") return SettingKey::SaveToTempDir; + if (s == "saveTo.allowedRoots") return SettingKey::SaveToAllowedRoots; + if (s == "saveTo.fileExistsPolicy") return SettingKey::SaveToFileExistsPolicy; + if (s == "saveTo.createSubfolderPerSite") return SettingKey::SaveToCreateSubfolderPerSite; + if (s == "connection.preset") return SettingKey::ConnectionPreset; + if (s == "connection.maxSegmentsPerDownload") return SettingKey::ConnectionMaxSegmentsPerDownload; + if (s == "connection.bufferBytes") return SettingKey::ConnectionBufferBytes; + if (s == "connection.maxConcurrentDownloads") return SettingKey::ConnectionMaxConcurrentDownloads; + if (s == "connection.timeoutSec") return SettingKey::ConnectionTimeoutSec; + if (s == "connection.maxRetries") return SettingKey::ConnectionMaxRetries; + if (s == "connection.retryBackoffSec") return SettingKey::ConnectionRetryBackoffSec; + if (s == "downloads.speedLimitBps") return SettingKey::DownloadsSpeedLimitBps; + if (s == "downloads.speedLimitEnabled") return SettingKey::DownloadsSpeedLimitEnabled; + if (s == "downloads.virusScanCommand") return SettingKey::DownloadsVirusScanCommand; + if (s == "downloads.postDownloadCommand") return SettingKey::DownloadsPostDownloadCommand; + if (s == "downloads.duplicatePolicy") return SettingKey::DownloadsDuplicatePolicy; + if (s == "downloads.verifyChecksums") return SettingKey::DownloadsVerifyChecksums; + if (s == "proxy.mode") return SettingKey::ProxyMode; + if (s == "proxy.host") return SettingKey::ProxyHost; + if (s == "proxy.port") return SettingKey::ProxyPort; + if (s == "proxy.username") return SettingKey::ProxyUsername; + if (s == "proxy.bypassHosts") return SettingKey::ProxyBypassHosts; + if (s == "proxy.pacUrl") return SettingKey::ProxyPacUrl; + if (s == "sounds.enabled") return SettingKey::SoundsEnabled; + if (s == "sounds.onComplete") return SettingKey::SoundsOnComplete; + if (s == "sounds.onQueueComplete") return SettingKey::SoundsOnQueueComplete; + if (s == "sounds.onError") return SettingKey::SoundsOnError; + return std::unexpected(ParseError{"", "not a valid SettingKey: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const SettingKey& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_SettingKey(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(SettingsConnectionPreset v) noexcept { + switch (v) { + case SettingsConnectionPreset::Auto: return "auto"; + case SettingsConnectionPreset::Lan: return "lan"; + case SettingsConnectionPreset::Broadband: return "broadband"; + case SettingsConnectionPreset::Slow: return "slow"; + } + return ""; +} + +Result parse_SettingsConnectionPreset(std::string_view s) { + if (s == "auto") return SettingsConnectionPreset::Auto; + if (s == "lan") return SettingsConnectionPreset::Lan; + if (s == "broadband") return SettingsConnectionPreset::Broadband; + if (s == "slow") return SettingsConnectionPreset::Slow; + return std::unexpected(ParseError{"", "not a valid SettingsConnectionPreset: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const SettingsConnectionPreset& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_SettingsConnectionPreset(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(SettingsDownloadsDuplicatePolicy v) noexcept { + switch (v) { + case SettingsDownloadsDuplicatePolicy::Ask: return "ask"; + case SettingsDownloadsDuplicatePolicy::Skip: return "skip"; + case SettingsDownloadsDuplicatePolicy::Rename: return "rename"; + case SettingsDownloadsDuplicatePolicy::Redownload: return "redownload"; + } + return ""; +} + +Result parse_SettingsDownloadsDuplicatePolicy(std::string_view s) { + if (s == "ask") return SettingsDownloadsDuplicatePolicy::Ask; + if (s == "skip") return SettingsDownloadsDuplicatePolicy::Skip; + if (s == "rename") return SettingsDownloadsDuplicatePolicy::Rename; + if (s == "redownload") return SettingsDownloadsDuplicatePolicy::Redownload; + return std::unexpected(ParseError{"", "not a valid SettingsDownloadsDuplicatePolicy: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const SettingsDownloadsDuplicatePolicy& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_SettingsDownloadsDuplicatePolicy(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(SettingsProxyMode v) noexcept { + switch (v) { + case SettingsProxyMode::System: return "system"; + case SettingsProxyMode::None: return "none"; + case SettingsProxyMode::Http: return "http"; + case SettingsProxyMode::Https: return "https"; + case SettingsProxyMode::Socks5: return "socks5"; + case SettingsProxyMode::Pac: return "pac"; + } + return ""; +} + +Result parse_SettingsProxyMode(std::string_view s) { + if (s == "system") return SettingsProxyMode::System; + if (s == "none") return SettingsProxyMode::None; + if (s == "http") return SettingsProxyMode::Http; + if (s == "https") return SettingsProxyMode::Https; + if (s == "socks5") return SettingsProxyMode::Socks5; + if (s == "pac") return SettingsProxyMode::Pac; + return std::unexpected(ParseError{"", "not a valid SettingsProxyMode: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const SettingsProxyMode& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_SettingsProxyMode(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(SettingsSaveToFileExistsPolicy v) noexcept { + switch (v) { + case SettingsSaveToFileExistsPolicy::Ask: return "ask"; + case SettingsSaveToFileExistsPolicy::Rename: return "rename"; + case SettingsSaveToFileExistsPolicy::Overwrite: return "overwrite"; + case SettingsSaveToFileExistsPolicy::Resume: return "resume"; + } + return ""; +} + +Result parse_SettingsSaveToFileExistsPolicy(std::string_view s) { + if (s == "ask") return SettingsSaveToFileExistsPolicy::Ask; + if (s == "rename") return SettingsSaveToFileExistsPolicy::Rename; + if (s == "overwrite") return SettingsSaveToFileExistsPolicy::Overwrite; + if (s == "resume") return SettingsSaveToFileExistsPolicy::Resume; + return std::unexpected(ParseError{"", "not a valid SettingsSaveToFileExistsPolicy: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const SettingsSaveToFileExistsPolicy& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_SettingsSaveToFileExistsPolicy(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(TaskErrorCode v) noexcept { + switch (v) { + case TaskErrorCode::Canceled: return "canceled"; + case TaskErrorCode::ResolveFailed: return "resolve_failed"; + case TaskErrorCode::ConnectFailed: return "connect_failed"; + case TaskErrorCode::TlsFailed: return "tls_failed"; + case TaskErrorCode::ConnectionReset: return "connection_reset"; + case TaskErrorCode::Timeout: return "timeout"; + case TaskErrorCode::TooManyRedirects: return "too_many_redirects"; + case TaskErrorCode::HttpClientError: return "http_client_error"; + case TaskErrorCode::HttpServerError: return "http_server_error"; + case TaskErrorCode::AuthRequired: return "auth_required"; + case TaskErrorCode::Forbidden: return "forbidden"; + case TaskErrorCode::NotFound: return "not_found"; + case TaskErrorCode::RangeNotSatisfiable: return "range_not_satisfiable"; + case TaskErrorCode::Gone: return "gone"; + case TaskErrorCode::ServerFileChanged: return "server_file_changed"; + case TaskErrorCode::ContentLengthMismatch: return "content_length_mismatch"; + case TaskErrorCode::ChecksumMismatch: return "checksum_mismatch"; + case TaskErrorCode::DiskFull: return "disk_full"; + case TaskErrorCode::IoError: return "io_error"; + case TaskErrorCode::PathRejected: return "path_rejected"; + case TaskErrorCode::PermissionDenied: return "permission_denied"; + case TaskErrorCode::MetaCorrupt: return "meta_corrupt"; + case TaskErrorCode::MetaVersionUnsupported: return "meta_version_unsupported"; + case TaskErrorCode::ProbeFailed: return "probe_failed"; + case TaskErrorCode::UnsupportedUrlScheme: return "unsupported_url_scheme"; + case TaskErrorCode::MaxRetriesExhausted: return "max_retries_exhausted"; + case TaskErrorCode::Internal: return "internal"; + } + return ""; +} + +Result parse_TaskErrorCode(std::string_view s) { + if (s == "canceled") return TaskErrorCode::Canceled; + if (s == "resolve_failed") return TaskErrorCode::ResolveFailed; + if (s == "connect_failed") return TaskErrorCode::ConnectFailed; + if (s == "tls_failed") return TaskErrorCode::TlsFailed; + if (s == "connection_reset") return TaskErrorCode::ConnectionReset; + if (s == "timeout") return TaskErrorCode::Timeout; + if (s == "too_many_redirects") return TaskErrorCode::TooManyRedirects; + if (s == "http_client_error") return TaskErrorCode::HttpClientError; + if (s == "http_server_error") return TaskErrorCode::HttpServerError; + if (s == "auth_required") return TaskErrorCode::AuthRequired; + if (s == "forbidden") return TaskErrorCode::Forbidden; + if (s == "not_found") return TaskErrorCode::NotFound; + if (s == "range_not_satisfiable") return TaskErrorCode::RangeNotSatisfiable; + if (s == "gone") return TaskErrorCode::Gone; + if (s == "server_file_changed") return TaskErrorCode::ServerFileChanged; + if (s == "content_length_mismatch") return TaskErrorCode::ContentLengthMismatch; + if (s == "checksum_mismatch") return TaskErrorCode::ChecksumMismatch; + if (s == "disk_full") return TaskErrorCode::DiskFull; + if (s == "io_error") return TaskErrorCode::IoError; + if (s == "path_rejected") return TaskErrorCode::PathRejected; + if (s == "permission_denied") return TaskErrorCode::PermissionDenied; + if (s == "meta_corrupt") return TaskErrorCode::MetaCorrupt; + if (s == "meta_version_unsupported") return TaskErrorCode::MetaVersionUnsupported; + if (s == "probe_failed") return TaskErrorCode::ProbeFailed; + if (s == "unsupported_url_scheme") return TaskErrorCode::UnsupportedUrlScheme; + if (s == "max_retries_exhausted") return TaskErrorCode::MaxRetriesExhausted; + if (s == "internal") return TaskErrorCode::Internal; + return std::unexpected(ParseError{"", "not a valid TaskErrorCode: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const TaskErrorCode& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_TaskErrorCode(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(TaskSortDirection v) noexcept { + switch (v) { + case TaskSortDirection::Asc: return "asc"; + case TaskSortDirection::Desc: return "desc"; + } + return ""; +} + +Result parse_TaskSortDirection(std::string_view s) { + if (s == "asc") return TaskSortDirection::Asc; + if (s == "desc") return TaskSortDirection::Desc; + return std::unexpected(ParseError{"", "not a valid TaskSortDirection: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const TaskSortDirection& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_TaskSortDirection(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(TaskSortField v) noexcept { + switch (v) { + case TaskSortField::Filename: return "filename"; + case TaskSortField::SizeBytes: return "sizeBytes"; + case TaskSortField::State: return "state"; + case TaskSortField::EtaSeconds: return "etaSeconds"; + case TaskSortField::SpeedBps: return "speedBps"; + case TaskSortField::LastTryAt: return "lastTryAt"; + case TaskSortField::CreatedAt: return "createdAt"; + case TaskSortField::QueuePosition: return "queuePosition"; + case TaskSortField::Description: return "description"; + } + return ""; +} + +Result parse_TaskSortField(std::string_view s) { + if (s == "filename") return TaskSortField::Filename; + if (s == "sizeBytes") return TaskSortField::SizeBytes; + if (s == "state") return TaskSortField::State; + if (s == "etaSeconds") return TaskSortField::EtaSeconds; + if (s == "speedBps") return TaskSortField::SpeedBps; + if (s == "lastTryAt") return TaskSortField::LastTryAt; + if (s == "createdAt") return TaskSortField::CreatedAt; + if (s == "queuePosition") return TaskSortField::QueuePosition; + if (s == "description") return TaskSortField::Description; + return std::unexpected(ParseError{"", "not a valid TaskSortField: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const TaskSortField& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_TaskSortField(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(CaptureOfferParamsMethod v) noexcept { + switch (v) { + case CaptureOfferParamsMethod::GET: return "GET"; + case CaptureOfferParamsMethod::POST: return "POST"; + } + return ""; +} + +Result parse_CaptureOfferParamsMethod(std::string_view s) { + if (s == "GET") return CaptureOfferParamsMethod::GET; + if (s == "POST") return CaptureOfferParamsMethod::POST; + return std::unexpected(ParseError{"", "not a valid CaptureOfferParamsMethod: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const CaptureOfferParamsMethod& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_CaptureOfferParamsMethod(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(CaptureOfferResultAction v) noexcept { + switch (v) { + case CaptureOfferResultAction::Take: return "take"; + case CaptureOfferResultAction::Ignore: return "ignore"; + } + return ""; +} + +Result parse_CaptureOfferResultAction(std::string_view s) { + if (s == "take") return CaptureOfferResultAction::Take; + if (s == "ignore") return CaptureOfferResultAction::Ignore; + return std::unexpected(ParseError{"", "not a valid CaptureOfferResultAction: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const CaptureOfferResultAction& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_CaptureOfferResultAction(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(CaptureOfferResultReason v) noexcept { + switch (v) { + case CaptureOfferResultReason::ExcludedHost: return "excluded_host"; + case CaptureOfferResultReason::TypeNotMonitored: return "type_not_monitored"; + case CaptureOfferResultReason::BelowMinSize: return "below_min_size"; + case CaptureOfferResultReason::Duplicate: return "duplicate"; + case CaptureOfferResultReason::CaptureDisabled: return "capture_disabled"; + case CaptureOfferResultReason::UserDeclined: return "user_declined"; + case CaptureOfferResultReason::RuleIgnore: return "rule_ignore"; + } + return ""; +} + +Result parse_CaptureOfferResultReason(std::string_view s) { + if (s == "excluded_host") return CaptureOfferResultReason::ExcludedHost; + if (s == "type_not_monitored") return CaptureOfferResultReason::TypeNotMonitored; + if (s == "below_min_size") return CaptureOfferResultReason::BelowMinSize; + if (s == "duplicate") return CaptureOfferResultReason::Duplicate; + if (s == "capture_disabled") return CaptureOfferResultReason::CaptureDisabled; + if (s == "user_declined") return CaptureOfferResultReason::UserDeclined; + if (s == "rule_ignore") return CaptureOfferResultReason::RuleIgnore; + return std::unexpected(ParseError{"", "not a valid CaptureOfferResultReason: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const CaptureOfferResultReason& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_CaptureOfferResultReason(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(GrabberStatusResultState v) noexcept { + switch (v) { + case GrabberStatusResultState::Crawling: return "crawling"; + case GrabberStatusResultState::Done: return "done"; + case GrabberStatusResultState::Failed: return "failed"; + case GrabberStatusResultState::Cancelled: return "cancelled"; + } + return ""; +} + +Result parse_GrabberStatusResultState(std::string_view s) { + if (s == "crawling") return GrabberStatusResultState::Crawling; + if (s == "done") return GrabberStatusResultState::Done; + if (s == "failed") return GrabberStatusResultState::Failed; + if (s == "cancelled") return GrabberStatusResultState::Cancelled; + return std::unexpected(ParseError{"", "not a valid GrabberStatusResultState: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const GrabberStatusResultState& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_GrabberStatusResultState(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(MediaListVariantsResultManifestType v) noexcept { + switch (v) { + case MediaListVariantsResultManifestType::Hls: return "hls"; + case MediaListVariantsResultManifestType::Dash: return "dash"; + } + return ""; +} + +Result parse_MediaListVariantsResultManifestType(std::string_view s) { + if (s == "hls") return MediaListVariantsResultManifestType::Hls; + if (s == "dash") return MediaListVariantsResultManifestType::Dash; + return std::unexpected(ParseError{"", "not a valid MediaListVariantsResultManifestType: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const MediaListVariantsResultManifestType& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_MediaListVariantsResultManifestType(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(SessionHelloParamsClientType v) noexcept { + switch (v) { + case SessionHelloParamsClientType::Gui: return "gui"; + case SessionHelloParamsClientType::Cli: return "cli"; + case SessionHelloParamsClientType::Extension: return "extension"; + case SessionHelloParamsClientType::Nmhost: return "nmhost"; + case SessionHelloParamsClientType::Test: return "test"; + } + return ""; +} + +Result parse_SessionHelloParamsClientType(std::string_view s) { + if (s == "gui") return SessionHelloParamsClientType::Gui; + if (s == "cli") return SessionHelloParamsClientType::Cli; + if (s == "extension") return SessionHelloParamsClientType::Extension; + if (s == "nmhost") return SessionHelloParamsClientType::Nmhost; + if (s == "test") return SessionHelloParamsClientType::Test; + return std::unexpected(ParseError{"", "not a valid SessionHelloParamsClientType: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const SessionHelloParamsClientType& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_SessionHelloParamsClientType(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(SessionHelloResultTransport v) noexcept { + switch (v) { + case SessionHelloResultTransport::Uds: return "uds"; + case SessionHelloResultTransport::Ws: return "ws"; + } + return ""; +} + +Result parse_SessionHelloResultTransport(std::string_view s) { + if (s == "uds") return SessionHelloResultTransport::Uds; + if (s == "ws") return SessionHelloResultTransport::Ws; + return std::unexpected(ParseError{"", "not a valid SessionHelloResultTransport: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const SessionHelloResultTransport& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_SessionHelloResultTransport(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(SessionSubscribeParamsEventsItem v) noexcept { + switch (v) { + case SessionSubscribeParamsEventsItem::EventTaskAdded: return "event.task.added"; + case SessionSubscribeParamsEventsItem::EventTaskRemoved: return "event.task.removed"; + case SessionSubscribeParamsEventsItem::EventTaskState: return "event.task.state"; + case SessionSubscribeParamsEventsItem::EventTaskProgress: return "event.task.progress"; + case SessionSubscribeParamsEventsItem::EventSpeedGlobal: return "event.speed.global"; + case SessionSubscribeParamsEventsItem::EventAuthRequired: return "event.auth.required"; + case SessionSubscribeParamsEventsItem::EventNotify: return "event.notify"; + case SessionSubscribeParamsEventsItem::EventSettingsChanged: return "event.settings.changed"; + case SessionSubscribeParamsEventsItem::EventGrabberProgress: return "event.grabber.progress"; + } + return ""; +} + +Result parse_SessionSubscribeParamsEventsItem(std::string_view s) { + if (s == "event.task.added") return SessionSubscribeParamsEventsItem::EventTaskAdded; + if (s == "event.task.removed") return SessionSubscribeParamsEventsItem::EventTaskRemoved; + if (s == "event.task.state") return SessionSubscribeParamsEventsItem::EventTaskState; + if (s == "event.task.progress") return SessionSubscribeParamsEventsItem::EventTaskProgress; + if (s == "event.speed.global") return SessionSubscribeParamsEventsItem::EventSpeedGlobal; + if (s == "event.auth.required") return SessionSubscribeParamsEventsItem::EventAuthRequired; + if (s == "event.notify") return SessionSubscribeParamsEventsItem::EventNotify; + if (s == "event.settings.changed") return SessionSubscribeParamsEventsItem::EventSettingsChanged; + if (s == "event.grabber.progress") return SessionSubscribeParamsEventsItem::EventGrabberProgress; + return std::unexpected(ParseError{"", "not a valid SessionSubscribeParamsEventsItem: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const SessionSubscribeParamsEventsItem& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_SessionSubscribeParamsEventsItem(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(AuthRequiredEventScheme v) noexcept { + switch (v) { + case AuthRequiredEventScheme::Basic: return "basic"; + case AuthRequiredEventScheme::Digest: return "digest"; + case AuthRequiredEventScheme::Ntlm: return "ntlm"; + case AuthRequiredEventScheme::Negotiate: return "negotiate"; + case AuthRequiredEventScheme::Proxy: return "proxy"; + } + return ""; +} + +Result parse_AuthRequiredEventScheme(std::string_view s) { + if (s == "basic") return AuthRequiredEventScheme::Basic; + if (s == "digest") return AuthRequiredEventScheme::Digest; + if (s == "ntlm") return AuthRequiredEventScheme::Ntlm; + if (s == "negotiate") return AuthRequiredEventScheme::Negotiate; + if (s == "proxy") return AuthRequiredEventScheme::Proxy; + return std::unexpected(ParseError{"", "not a valid AuthRequiredEventScheme: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const AuthRequiredEventScheme& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_AuthRequiredEventScheme(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(NotifyEventLevel v) noexcept { + switch (v) { + case NotifyEventLevel::Info: return "info"; + case NotifyEventLevel::Success: return "success"; + case NotifyEventLevel::Warning: return "warning"; + case NotifyEventLevel::Error: return "error"; + } + return ""; +} + +Result parse_NotifyEventLevel(std::string_view s) { + if (s == "info") return NotifyEventLevel::Info; + if (s == "success") return NotifyEventLevel::Success; + if (s == "warning") return NotifyEventLevel::Warning; + if (s == "error") return NotifyEventLevel::Error; + return std::unexpected(ParseError{"", "not a valid NotifyEventLevel: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const NotifyEventLevel& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_NotifyEventLevel(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +std::string_view to_string(NotifyEventSound v) noexcept { + switch (v) { + case NotifyEventSound::Complete: return "complete"; + case NotifyEventSound::QueueComplete: return "queueComplete"; + case NotifyEventSound::Error: return "error"; + } + return ""; +} + +Result parse_NotifyEventSound(std::string_view s) { + if (s == "complete") return NotifyEventSound::Complete; + if (s == "queueComplete") return NotifyEventSound::QueueComplete; + if (s == "error") return NotifyEventSound::Error; + return std::unexpected(ParseError{"", "not a valid NotifyEventSound: '" + std::string(s) + "'"}); +} + +void to_json(nlohmann::json& j, const NotifyEventSound& v) { j = to_string(v); } + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"}); + auto r = parse_NotifyEventSound(j.get_ref()); + if (!r) return std::unexpected(ParseError{std::string(path), r.error().message}); + return *r; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + std::map out; + for (const auto& [mk, mv] : j.items()) { + const std::string mp = join(path, mk); + if (!mv.is_string()) return std::unexpected(ParseError{std::string(mp), "expected a string"}); + auto out_e = mv.get(); + out.emplace(mk, std::move(out_e)); + } + return out; +} + +void to_json(nlohmann::json& j, const BulkTaskResultFailedItem& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["code"] = v.code; + j["message"] = v.message; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + BulkTaskResultFailedItem out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "code"); + const auto it = j.find("code"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.code = std::move(val); + } + { + const std::string fp = join(path, "message"); + const auto it = j.find("message"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.message = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const BulkTaskResultUpdatedItem& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["state"] = v.state; + j["changed"] = v.changed; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + BulkTaskResultUpdatedItem out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "state"); + const auto it = j.find("state"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.state = std::move(val); + } + { + const std::string fp = join(path, "changed"); + const auto it = j.find("changed"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.changed = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const BulkTaskResult& v) { + j = nlohmann::json::object(); + j["updated"] = v.updated; + j["failed"] = v.failed; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + BulkTaskResult out; + { + const std::string fp = join(path, "updated"); + const auto it = j.find("updated"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.updated = std::move(val); + } + { + const std::string fp = join(path, "failed"); + const auto it = j.find("failed"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.failed = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const CaptureRules& v) { + j = nlohmann::json::object(); + j["enabled"] = v.enabled; + j["monitoredExtensions"] = v.monitoredExtensions; + j["monitoredMimeTypes"] = v.monitoredMimeTypes; + j["minSizeBytes"] = v.minSizeBytes; + j["excludedHosts"] = v.excludedHosts; + if (v.bypassModifier.has_value()) j["bypassModifier"] = *v.bypassModifier; + j["rulesVersion"] = v.rulesVersion; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + CaptureRules out; + { + const std::string fp = join(path, "enabled"); + const auto it = j.find("enabled"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.enabled = std::move(val); + } + { + const std::string fp = join(path, "monitoredExtensions"); + const auto it = j.find("monitoredExtensions"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.monitoredExtensions = std::move(val); + } + { + const std::string fp = join(path, "monitoredMimeTypes"); + const auto it = j.find("monitoredMimeTypes"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.monitoredMimeTypes = std::move(val); + } + { + const std::string fp = join(path, "minSizeBytes"); + const auto it = j.find("minSizeBytes"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.minSizeBytes = std::move(val); + } + { + const std::string fp = join(path, "excludedHosts"); + const auto it = j.find("excludedHosts"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.excludedHosts = std::move(val); + } + { + const std::string fp = join(path, "bypassModifier"); + const auto it = j.find("bypassModifier"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.bypassModifier = std::move(val); + } + } + { + const std::string fp = join(path, "rulesVersion"); + const auto it = j.find("rulesVersion"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.rulesVersion = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const Category& v) { + j = nlohmann::json::object(); + j["categoryId"] = v.categoryId; + j["name"] = v.name; + j["saveDir"] = v.saveDir; + j["extensions"] = v.extensions; + if (v.mimeTypes.has_value()) j["mimeTypes"] = *v.mimeTypes; + j["builtin"] = v.builtin; + if (v.sortOrder.has_value()) j["sortOrder"] = *v.sortOrder; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + Category out; + { + const std::string fp = join(path, "categoryId"); + const auto it = j.find("categoryId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.categoryId = std::move(val); + } + { + const std::string fp = join(path, "name"); + const auto it = j.find("name"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 64u) return std::unexpected(ParseError{std::string(fp), "value is longer than 64 characters"}); + out.name = std::move(val); + } + { + const std::string fp = join(path, "saveDir"); + const auto it = j.find("saveDir"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.saveDir = std::move(val); + } + { + const std::string fp = join(path, "extensions"); + const auto it = j.find("extensions"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + { + static const std::regex re("^[A-Za-z0-9][A-Za-z0-9+._-]*$", std::regex::ECMAScript); + if (!std::regex_match(val_e, re)) return std::unexpected(ParseError{std::string(ip), "value does not match the required pattern"}); + } + val.push_back(std::move(val_e)); + } + out.extensions = std::move(val); + } + { + const std::string fp = join(path, "mimeTypes"); + const auto it = j.find("mimeTypes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.mimeTypes = std::move(val); + } + } + { + const std::string fp = join(path, "builtin"); + const auto it = j.find("builtin"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.builtin = std::move(val); + } + { + const std::string fp = join(path, "sortOrder"); + const auto it = j.find("sortOrder"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.sortOrder = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const Checksum& v) { + j = nlohmann::json::object(); + j["algorithm"] = v.algorithm; + j["value"] = v.value; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + Checksum out; + { + const std::string fp = join(path, "algorithm"); + const auto it = j.find("algorithm"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.algorithm = std::move(val); + } + { + const std::string fp = join(path, "value"); + const auto it = j.find("value"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + { + static const std::regex re("^[0-9a-fA-F]{32,128}$", std::regex::ECMAScript); + if (!std::regex_match(val, re)) return std::unexpected(ParseError{std::string(fp), "value does not match the required pattern"}); + } + out.value = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const Cookie& v) { + j = nlohmann::json::object(); + j["name"] = v.name; + j["value"] = v.value; + if (v.domain.has_value()) j["domain"] = *v.domain; + if (v.path.has_value()) j["path"] = *v.path; + if (v.secure.has_value()) j["secure"] = *v.secure; + if (v.httpOnly.has_value()) j["httpOnly"] = *v.httpOnly; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + Cookie out; + { + const std::string fp = join(path, "name"); + const auto it = j.find("name"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.name = std::move(val); + } + { + const std::string fp = join(path, "value"); + const auto it = j.find("value"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.value = std::move(val); + } + { + const std::string fp = join(path, "domain"); + const auto it = j.find("domain"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.domain = std::move(val); + } + } + { + const std::string fp = join(path, "path"); + const auto it = j.find("path"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.path = std::move(val); + } + } + { + const std::string fp = join(path, "secure"); + const auto it = j.find("secure"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.secure = std::move(val); + } + } + { + const std::string fp = join(path, "httpOnly"); + const auto it = j.find("httpOnly"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.httpOnly = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadSpec& v) { + j = nlohmann::json::object(); + j["url"] = v.url; + if (v.headers.has_value()) j["headers"] = *v.headers; + if (v.cookies.has_value()) j["cookies"] = *v.cookies; + if (v.referrer.has_value()) j["referrer"] = *v.referrer; + if (v.userAgent.has_value()) j["userAgent"] = *v.userAgent; + if (v.filename.has_value()) j["filename"] = *v.filename; + if (v.saveDir.has_value()) j["saveDir"] = *v.saveDir; + if (v.categoryId.has_value()) j["categoryId"] = *v.categoryId; + if (v.queueId.has_value()) j["queueId"] = *v.queueId; + if (v.segments.has_value()) j["segments"] = *v.segments; + if (v.bufferBytes.has_value()) j["bufferBytes"] = *v.bufferBytes; + if (v.startMode.has_value()) j["startMode"] = *v.startMode; + if (v.description.has_value()) j["description"] = *v.description; + if (v.checksum.has_value()) j["checksum"] = *v.checksum; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadSpec out; + { + const std::string fp = join(path, "url"); + const auto it = j.find("url"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.url = std::move(val); + } + { + const std::string fp = join(path, "headers"); + const auto it = j.find("headers"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.headers = std::move(val); + } + } + { + const std::string fp = join(path, "cookies"); + const auto it = j.find("cookies"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.cookies = std::move(val); + } + } + { + const std::string fp = join(path, "referrer"); + const auto it = j.find("referrer"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.referrer = std::move(val); + } + } + { + const std::string fp = join(path, "userAgent"); + const auto it = j.find("userAgent"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.userAgent = std::move(val); + } + } + { + const std::string fp = join(path, "filename"); + const auto it = j.find("filename"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 255u) return std::unexpected(ParseError{std::string(fp), "value is longer than 255 characters"}); + out.filename = std::move(val); + } + } + { + const std::string fp = join(path, "saveDir"); + const auto it = j.find("saveDir"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.saveDir = std::move(val); + } + } + { + const std::string fp = join(path, "categoryId"); + const auto it = j.find("categoryId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.categoryId = std::move(val); + } + } + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + } + { + const std::string fp = join(path, "segments"); + const auto it = j.find("segments"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"}); + if (val > 32) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 32"}); + out.segments = std::move(val); + } + } + { + const std::string fp = join(path, "bufferBytes"); + const auto it = j.find("bufferBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 4096) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 4096"}); + if (val > 8388608) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 8388608"}); + out.bufferBytes = std::move(val); + } + } + { + const std::string fp = join(path, "startMode"); + const auto it = j.find("startMode"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.startMode = std::move(val); + } + } + { + const std::string fp = join(path, "description"); + const auto it = j.find("description"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 1024u) return std::unexpected(ParseError{std::string(fp), "value is longer than 1024 characters"}); + out.description = std::move(val); + } + } + { + const std::string fp = join(path, "checksum"); + const auto it = j.find("checksum"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.checksum = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const GrabberFile& v) { + j = nlohmann::json::object(); + j["fileId"] = v.fileId; + j["url"] = v.url; + if (v.filename.has_value()) j["filename"] = *v.filename; + if (v.sizeBytes.has_value()) j["sizeBytes"] = *v.sizeBytes; + if (v.contentType.has_value()) j["contentType"] = *v.contentType; + j["depth"] = v.depth; + if (v.foundOn.has_value()) j["foundOn"] = *v.foundOn; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + GrabberFile out; + { + const std::string fp = join(path, "fileId"); + const auto it = j.find("fileId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.fileId = std::move(val); + } + { + const std::string fp = join(path, "url"); + const auto it = j.find("url"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.url = std::move(val); + } + { + const std::string fp = join(path, "filename"); + const auto it = j.find("filename"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.filename = std::move(val); + } + } + { + const std::string fp = join(path, "sizeBytes"); + const auto it = j.find("sizeBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.sizeBytes = std::move(val); + } + } + { + const std::string fp = join(path, "contentType"); + const auto it = j.find("contentType"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.contentType = std::move(val); + } + } + { + const std::string fp = join(path, "depth"); + const auto it = j.find("depth"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.depth = std::move(val); + } + { + const std::string fp = join(path, "foundOn"); + const auto it = j.find("foundOn"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.foundOn = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const Limiter& v) { + j = nlohmann::json::object(); + j["enabled"] = v.enabled; + j["globalBps"] = v.globalBps; + if (v.applyToRunning.has_value()) j["applyToRunning"] = *v.applyToRunning; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + Limiter out; + { + const std::string fp = join(path, "enabled"); + const auto it = j.find("enabled"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.enabled = std::move(val); + } + { + const std::string fp = join(path, "globalBps"); + const auto it = j.find("globalBps"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.globalBps = std::move(val); + } + { + const std::string fp = join(path, "applyToRunning"); + const auto it = j.find("applyToRunning"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.applyToRunning = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const MediaVariant& v) { + j = nlohmann::json::object(); + j["variantId"] = v.variantId; + j["kind"] = v.kind; + if (v.resolution.has_value()) j["resolution"] = *v.resolution; + if (v.bitrateBps.has_value()) j["bitrateBps"] = *v.bitrateBps; + if (v.codec.has_value()) j["codec"] = *v.codec; + if (v.container.has_value()) j["container"] = *v.container; + if (v.frameRate.has_value()) j["frameRate"] = *v.frameRate; + if (v.language.has_value()) j["language"] = *v.language; + if (v.sizeEstimate.has_value()) j["sizeEstimate"] = *v.sizeEstimate; + j["drm"] = v.drm; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + MediaVariant out; + { + const std::string fp = join(path, "variantId"); + const auto it = j.find("variantId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.variantId = std::move(val); + } + { + const std::string fp = join(path, "kind"); + const auto it = j.find("kind"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.kind = std::move(val); + } + { + const std::string fp = join(path, "resolution"); + const auto it = j.find("resolution"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + { + static const std::regex re("^[0-9]{2,5}x[0-9]{2,5}$", std::regex::ECMAScript); + if (!std::regex_match(val, re)) return std::unexpected(ParseError{std::string(fp), "value does not match the required pattern"}); + } + out.resolution = std::move(val); + } + } + { + const std::string fp = join(path, "bitrateBps"); + const auto it = j.find("bitrateBps"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.bitrateBps = std::move(val); + } + } + { + const std::string fp = join(path, "codec"); + const auto it = j.find("codec"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.codec = std::move(val); + } + } + { + const std::string fp = join(path, "container"); + const auto it = j.find("container"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.container = std::move(val); + } + } + { + const std::string fp = join(path, "frameRate"); + const auto it = j.find("frameRate"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number()) return std::unexpected(ParseError{std::string(fp), "expected a number"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.frameRate = std::move(val); + } + } + { + const std::string fp = join(path, "language"); + const auto it = j.find("language"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.language = std::move(val); + } + } + { + const std::string fp = join(path, "sizeEstimate"); + const auto it = j.find("sizeEstimate"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.sizeEstimate = std::move(val); + } + } + { + const std::string fp = join(path, "drm"); + const auto it = j.find("drm"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.drm = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const Schedule& v) { + j = nlohmann::json::object(); + j["enabled"] = v.enabled; + j["mode"] = v.mode; + if (v.startTime.has_value()) j["startTime"] = *v.startTime; + if (v.stopTime.has_value()) j["stopTime"] = *v.stopTime; + if (v.daysOfWeek.has_value()) j["daysOfWeek"] = *v.daysOfWeek; + if (v.onceDate.has_value()) j["onceDate"] = *v.onceDate; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + Schedule out; + { + const std::string fp = join(path, "enabled"); + const auto it = j.find("enabled"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.enabled = std::move(val); + } + { + const std::string fp = join(path, "mode"); + const auto it = j.find("mode"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.mode = std::move(val); + } + { + const std::string fp = join(path, "startTime"); + const auto it = j.find("startTime"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + { + static const std::regex re("^([01][0-9]|2[0-3]):[0-5][0-9]$", std::regex::ECMAScript); + if (!std::regex_match(val, re)) return std::unexpected(ParseError{std::string(fp), "value does not match the required pattern"}); + } + out.startTime = std::move(val); + } + } + { + const std::string fp = join(path, "stopTime"); + const auto it = j.find("stopTime"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + { + static const std::regex re("^([01][0-9]|2[0-3]):[0-5][0-9]$", std::regex::ECMAScript); + if (!std::regex_match(val, re)) return std::unexpected(ParseError{std::string(fp), "value does not match the required pattern"}); + } + out.stopTime = std::move(val); + } + } + { + const std::string fp = join(path, "daysOfWeek"); + const auto it = j.find("daysOfWeek"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_number_integer()) return std::unexpected(ParseError{std::string(ip), "expected an integer"}); + auto val_e = (*it)[idx].get(); + if (val_e < 0) return std::unexpected(ParseError{std::string(ip), "value is below the minimum of 0"}); + if (val_e > 6) return std::unexpected(ParseError{std::string(ip), "value is above the maximum of 6"}); + val.push_back(std::move(val_e)); + } + out.daysOfWeek = std::move(val); + } + } + { + const std::string fp = join(path, "onceDate"); + const auto it = j.find("onceDate"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.onceDate = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const Queue& v) { + j = nlohmann::json::object(); + j["queueId"] = v.queueId; + j["name"] = v.name; + j["state"] = v.state; + j["maxConcurrent"] = v.maxConcurrent; + if (v.taskIds.has_value()) j["taskIds"] = *v.taskIds; + if (v.schedule.has_value()) j["schedule"] = *v.schedule; + if (v.onComplete.has_value()) j["onComplete"] = *v.onComplete; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + Queue out; + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + { + const std::string fp = join(path, "name"); + const auto it = j.find("name"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 64u) return std::unexpected(ParseError{std::string(fp), "value is longer than 64 characters"}); + out.name = std::move(val); + } + { + const std::string fp = join(path, "state"); + const auto it = j.find("state"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.state = std::move(val); + } + { + const std::string fp = join(path, "maxConcurrent"); + const auto it = j.find("maxConcurrent"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"}); + if (val > 32) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 32"}); + out.maxConcurrent = std::move(val); + } + { + const std::string fp = join(path, "taskIds"); + const auto it = j.find("taskIds"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.taskIds = std::move(val); + } + } + { + const std::string fp = join(path, "schedule"); + const auto it = j.find("schedule"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.schedule = std::move(val); + } + } + { + const std::string fp = join(path, "onComplete"); + const auto it = j.find("onComplete"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.onComplete = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const RuleAction& v) { + j = nlohmann::json::object(); + if (v.categoryId.has_value()) j["categoryId"] = *v.categoryId; + if (v.saveDir.has_value()) j["saveDir"] = *v.saveDir; + if (v.queueId.has_value()) j["queueId"] = *v.queueId; + if (v.segments.has_value()) j["segments"] = *v.segments; + if (v.startMode.has_value()) j["startMode"] = *v.startMode; + if (v.capture.has_value()) j["capture"] = *v.capture; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + RuleAction out; + { + const std::string fp = join(path, "categoryId"); + const auto it = j.find("categoryId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.categoryId = std::move(val); + } + } + { + const std::string fp = join(path, "saveDir"); + const auto it = j.find("saveDir"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.saveDir = std::move(val); + } + } + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + } + { + const std::string fp = join(path, "segments"); + const auto it = j.find("segments"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"}); + if (val > 32) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 32"}); + out.segments = std::move(val); + } + } + { + const std::string fp = join(path, "startMode"); + const auto it = j.find("startMode"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.startMode = std::move(val); + } + } + { + const std::string fp = join(path, "capture"); + const auto it = j.find("capture"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.capture = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const RuleMatch& v) { + j = nlohmann::json::object(); + if (v.extensions.has_value()) j["extensions"] = *v.extensions; + if (v.mimeTypes.has_value()) j["mimeTypes"] = *v.mimeTypes; + if (v.hostPattern.has_value()) j["hostPattern"] = *v.hostPattern; + if (v.urlPattern.has_value()) j["urlPattern"] = *v.urlPattern; + if (v.minSizeBytes.has_value()) j["minSizeBytes"] = *v.minSizeBytes; + if (v.maxSizeBytes.has_value()) j["maxSizeBytes"] = *v.maxSizeBytes; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + RuleMatch out; + { + const std::string fp = join(path, "extensions"); + const auto it = j.find("extensions"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.extensions = std::move(val); + } + } + { + const std::string fp = join(path, "mimeTypes"); + const auto it = j.find("mimeTypes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.mimeTypes = std::move(val); + } + } + { + const std::string fp = join(path, "hostPattern"); + const auto it = j.find("hostPattern"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.hostPattern = std::move(val); + } + } + { + const std::string fp = join(path, "urlPattern"); + const auto it = j.find("urlPattern"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.urlPattern = std::move(val); + } + } + { + const std::string fp = join(path, "minSizeBytes"); + const auto it = j.find("minSizeBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.minSizeBytes = std::move(val); + } + } + { + const std::string fp = join(path, "maxSizeBytes"); + const auto it = j.find("maxSizeBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.maxSizeBytes = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const Rule& v) { + j = nlohmann::json::object(); + j["ruleId"] = v.ruleId; + if (v.name.has_value()) j["name"] = *v.name; + j["enabled"] = v.enabled; + j["priority"] = v.priority; + j["match"] = v.match; + j["action"] = v.action; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + Rule out; + { + const std::string fp = join(path, "ruleId"); + const auto it = j.find("ruleId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.ruleId = std::move(val); + } + { + const std::string fp = join(path, "name"); + const auto it = j.find("name"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 64u) return std::unexpected(ParseError{std::string(fp), "value is longer than 64 characters"}); + out.name = std::move(val); + } + } + { + const std::string fp = join(path, "enabled"); + const auto it = j.find("enabled"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.enabled = std::move(val); + } + { + const std::string fp = join(path, "priority"); + const auto it = j.find("priority"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.priority = std::move(val); + } + { + const std::string fp = join(path, "match"); + const auto it = j.find("match"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.match = std::move(val); + } + { + const std::string fp = join(path, "action"); + const auto it = j.find("action"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.action = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const Segment& v) { + j = nlohmann::json::object(); + j["index"] = v.index; + j["startByte"] = v.startByte; + j["endByte"] = v.endByte; + j["downloadedBytes"] = v.downloadedBytes; + if (v.speedBps.has_value()) j["speedBps"] = *v.speedBps; + j["state"] = v.state; + if (v.httpStatus.has_value()) j["httpStatus"] = *v.httpStatus; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + Segment out; + { + const std::string fp = join(path, "index"); + const auto it = j.find("index"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + if (val > 31) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 31"}); + out.index = std::move(val); + } + { + const std::string fp = join(path, "startByte"); + const auto it = j.find("startByte"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.startByte = std::move(val); + } + { + const std::string fp = join(path, "endByte"); + const auto it = j.find("endByte"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.endByte = std::move(val); + } + { + const std::string fp = join(path, "downloadedBytes"); + const auto it = j.find("downloadedBytes"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.downloadedBytes = std::move(val); + } + { + const std::string fp = join(path, "speedBps"); + const auto it = j.find("speedBps"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.speedBps = std::move(val); + } + } + { + const std::string fp = join(path, "state"); + const auto it = j.find("state"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.state = std::move(val); + } + { + const std::string fp = join(path, "httpStatus"); + const auto it = j.find("httpStatus"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 100) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 100"}); + if (val > 599) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 599"}); + out.httpStatus = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const Settings& v) { + j = nlohmann::json::object(); + if (v.general_launchOnLogin.has_value()) j["general.launchOnLogin"] = *v.general_launchOnLogin; + if (v.general_minimizeToTray.has_value()) j["general.minimizeToTray"] = *v.general_minimizeToTray; + if (v.general_showDropTarget.has_value()) j["general.showDropTarget"] = *v.general_showDropTarget; + if (v.general_confirmOnExit.has_value()) j["general.confirmOnExit"] = *v.general_confirmOnExit; + if (v.general_language.has_value()) j["general.language"] = *v.general_language; + if (v.general_checkForUpdates.has_value()) j["general.checkForUpdates"] = *v.general_checkForUpdates; + if (v.capture_enabled.has_value()) j["capture.enabled"] = *v.capture_enabled; + if (v.capture_monitoredExtensions.has_value()) j["capture.monitoredExtensions"] = *v.capture_monitoredExtensions; + if (v.capture_monitoredMimeTypes.has_value()) j["capture.monitoredMimeTypes"] = *v.capture_monitoredMimeTypes; + if (v.capture_minSizeBytes.has_value()) j["capture.minSizeBytes"] = *v.capture_minSizeBytes; + if (v.capture_excludedHosts.has_value()) j["capture.excludedHosts"] = *v.capture_excludedHosts; + if (v.capture_bypassModifier.has_value()) j["capture.bypassModifier"] = *v.capture_bypassModifier; + if (v.capture_autoStartTypes.has_value()) j["capture.autoStartTypes"] = *v.capture_autoStartTypes; + if (v.saveTo_defaultDir.has_value()) j["saveTo.defaultDir"] = *v.saveTo_defaultDir; + if (v.saveTo_tempDir.has_value()) j["saveTo.tempDir"] = *v.saveTo_tempDir; + if (v.saveTo_allowedRoots.has_value()) j["saveTo.allowedRoots"] = *v.saveTo_allowedRoots; + if (v.saveTo_fileExistsPolicy.has_value()) j["saveTo.fileExistsPolicy"] = *v.saveTo_fileExistsPolicy; + if (v.saveTo_createSubfolderPerSite.has_value()) j["saveTo.createSubfolderPerSite"] = *v.saveTo_createSubfolderPerSite; + if (v.connection_preset.has_value()) j["connection.preset"] = *v.connection_preset; + if (v.connection_maxSegmentsPerDownload.has_value()) j["connection.maxSegmentsPerDownload"] = *v.connection_maxSegmentsPerDownload; + if (v.connection_bufferBytes.has_value()) j["connection.bufferBytes"] = *v.connection_bufferBytes; + if (v.connection_maxConcurrentDownloads.has_value()) j["connection.maxConcurrentDownloads"] = *v.connection_maxConcurrentDownloads; + if (v.connection_timeoutSec.has_value()) j["connection.timeoutSec"] = *v.connection_timeoutSec; + if (v.connection_maxRetries.has_value()) j["connection.maxRetries"] = *v.connection_maxRetries; + if (v.connection_retryBackoffSec.has_value()) j["connection.retryBackoffSec"] = *v.connection_retryBackoffSec; + if (v.downloads_speedLimitBps.has_value()) j["downloads.speedLimitBps"] = *v.downloads_speedLimitBps; + if (v.downloads_speedLimitEnabled.has_value()) j["downloads.speedLimitEnabled"] = *v.downloads_speedLimitEnabled; + if (v.downloads_virusScanCommand.has_value()) j["downloads.virusScanCommand"] = *v.downloads_virusScanCommand; + if (v.downloads_postDownloadCommand.has_value()) j["downloads.postDownloadCommand"] = *v.downloads_postDownloadCommand; + if (v.downloads_duplicatePolicy.has_value()) j["downloads.duplicatePolicy"] = *v.downloads_duplicatePolicy; + if (v.downloads_verifyChecksums.has_value()) j["downloads.verifyChecksums"] = *v.downloads_verifyChecksums; + if (v.proxy_mode.has_value()) j["proxy.mode"] = *v.proxy_mode; + if (v.proxy_host.has_value()) j["proxy.host"] = *v.proxy_host; + if (v.proxy_port.has_value()) j["proxy.port"] = *v.proxy_port; + if (v.proxy_username.has_value()) j["proxy.username"] = *v.proxy_username; + if (v.proxy_bypassHosts.has_value()) j["proxy.bypassHosts"] = *v.proxy_bypassHosts; + if (v.proxy_pacUrl.has_value()) j["proxy.pacUrl"] = *v.proxy_pacUrl; + if (v.sounds_enabled.has_value()) j["sounds.enabled"] = *v.sounds_enabled; + if (v.sounds_onComplete.has_value()) j["sounds.onComplete"] = *v.sounds_onComplete; + if (v.sounds_onQueueComplete.has_value()) j["sounds.onQueueComplete"] = *v.sounds_onQueueComplete; + if (v.sounds_onError.has_value()) j["sounds.onError"] = *v.sounds_onError; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + Settings out; + { + const std::string fp = join(path, "general.launchOnLogin"); + const auto it = j.find("general.launchOnLogin"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.general_launchOnLogin = std::move(val); + } + } + { + const std::string fp = join(path, "general.minimizeToTray"); + const auto it = j.find("general.minimizeToTray"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.general_minimizeToTray = std::move(val); + } + } + { + const std::string fp = join(path, "general.showDropTarget"); + const auto it = j.find("general.showDropTarget"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.general_showDropTarget = std::move(val); + } + } + { + const std::string fp = join(path, "general.confirmOnExit"); + const auto it = j.find("general.confirmOnExit"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.general_confirmOnExit = std::move(val); + } + } + { + const std::string fp = join(path, "general.language"); + const auto it = j.find("general.language"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.general_language = std::move(val); + } + } + { + const std::string fp = join(path, "general.checkForUpdates"); + const auto it = j.find("general.checkForUpdates"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.general_checkForUpdates = std::move(val); + } + } + { + const std::string fp = join(path, "capture.enabled"); + const auto it = j.find("capture.enabled"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.capture_enabled = std::move(val); + } + } + { + const std::string fp = join(path, "capture.monitoredExtensions"); + const auto it = j.find("capture.monitoredExtensions"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.capture_monitoredExtensions = std::move(val); + } + } + { + const std::string fp = join(path, "capture.monitoredMimeTypes"); + const auto it = j.find("capture.monitoredMimeTypes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.capture_monitoredMimeTypes = std::move(val); + } + } + { + const std::string fp = join(path, "capture.minSizeBytes"); + const auto it = j.find("capture.minSizeBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.capture_minSizeBytes = std::move(val); + } + } + { + const std::string fp = join(path, "capture.excludedHosts"); + const auto it = j.find("capture.excludedHosts"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.capture_excludedHosts = std::move(val); + } + } + { + const std::string fp = join(path, "capture.bypassModifier"); + const auto it = j.find("capture.bypassModifier"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.capture_bypassModifier = std::move(val); + } + } + { + const std::string fp = join(path, "capture.autoStartTypes"); + const auto it = j.find("capture.autoStartTypes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.capture_autoStartTypes = std::move(val); + } + } + { + const std::string fp = join(path, "saveTo.defaultDir"); + const auto it = j.find("saveTo.defaultDir"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.saveTo_defaultDir = std::move(val); + } + } + { + const std::string fp = join(path, "saveTo.tempDir"); + const auto it = j.find("saveTo.tempDir"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.saveTo_tempDir = std::move(val); + } + } + { + const std::string fp = join(path, "saveTo.allowedRoots"); + const auto it = j.find("saveTo.allowedRoots"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.saveTo_allowedRoots = std::move(val); + } + } + { + const std::string fp = join(path, "saveTo.fileExistsPolicy"); + const auto it = j.find("saveTo.fileExistsPolicy"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.saveTo_fileExistsPolicy = std::move(val); + } + } + { + const std::string fp = join(path, "saveTo.createSubfolderPerSite"); + const auto it = j.find("saveTo.createSubfolderPerSite"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.saveTo_createSubfolderPerSite = std::move(val); + } + } + { + const std::string fp = join(path, "connection.preset"); + const auto it = j.find("connection.preset"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.connection_preset = std::move(val); + } + } + { + const std::string fp = join(path, "connection.maxSegmentsPerDownload"); + const auto it = j.find("connection.maxSegmentsPerDownload"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"}); + if (val > 32) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 32"}); + out.connection_maxSegmentsPerDownload = std::move(val); + } + } + { + const std::string fp = join(path, "connection.bufferBytes"); + const auto it = j.find("connection.bufferBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 4096) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 4096"}); + if (val > 8388608) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 8388608"}); + out.connection_bufferBytes = std::move(val); + } + } + { + const std::string fp = join(path, "connection.maxConcurrentDownloads"); + const auto it = j.find("connection.maxConcurrentDownloads"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"}); + if (val > 64) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 64"}); + out.connection_maxConcurrentDownloads = std::move(val); + } + } + { + const std::string fp = join(path, "connection.timeoutSec"); + const auto it = j.find("connection.timeoutSec"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"}); + if (val > 3600) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 3600"}); + out.connection_timeoutSec = std::move(val); + } + } + { + const std::string fp = join(path, "connection.maxRetries"); + const auto it = j.find("connection.maxRetries"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + if (val > 100) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 100"}); + out.connection_maxRetries = std::move(val); + } + } + { + const std::string fp = join(path, "connection.retryBackoffSec"); + const auto it = j.find("connection.retryBackoffSec"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + if (val > 3600) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 3600"}); + out.connection_retryBackoffSec = std::move(val); + } + } + { + const std::string fp = join(path, "downloads.speedLimitBps"); + const auto it = j.find("downloads.speedLimitBps"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.downloads_speedLimitBps = std::move(val); + } + } + { + const std::string fp = join(path, "downloads.speedLimitEnabled"); + const auto it = j.find("downloads.speedLimitEnabled"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.downloads_speedLimitEnabled = std::move(val); + } + } + { + const std::string fp = join(path, "downloads.virusScanCommand"); + const auto it = j.find("downloads.virusScanCommand"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.downloads_virusScanCommand = std::move(val); + } + } + { + const std::string fp = join(path, "downloads.postDownloadCommand"); + const auto it = j.find("downloads.postDownloadCommand"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.downloads_postDownloadCommand = std::move(val); + } + } + { + const std::string fp = join(path, "downloads.duplicatePolicy"); + const auto it = j.find("downloads.duplicatePolicy"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.downloads_duplicatePolicy = std::move(val); + } + } + { + const std::string fp = join(path, "downloads.verifyChecksums"); + const auto it = j.find("downloads.verifyChecksums"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.downloads_verifyChecksums = std::move(val); + } + } + { + const std::string fp = join(path, "proxy.mode"); + const auto it = j.find("proxy.mode"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.proxy_mode = std::move(val); + } + } + { + const std::string fp = join(path, "proxy.host"); + const auto it = j.find("proxy.host"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.proxy_host = std::move(val); + } + } + { + const std::string fp = join(path, "proxy.port"); + const auto it = j.find("proxy.port"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"}); + if (val > 65535) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 65535"}); + out.proxy_port = std::move(val); + } + } + { + const std::string fp = join(path, "proxy.username"); + const auto it = j.find("proxy.username"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.proxy_username = std::move(val); + } + } + { + const std::string fp = join(path, "proxy.bypassHosts"); + const auto it = j.find("proxy.bypassHosts"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.proxy_bypassHosts = std::move(val); + } + } + { + const std::string fp = join(path, "proxy.pacUrl"); + const auto it = j.find("proxy.pacUrl"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.proxy_pacUrl = std::move(val); + } + } + { + const std::string fp = join(path, "sounds.enabled"); + const auto it = j.find("sounds.enabled"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.sounds_enabled = std::move(val); + } + } + { + const std::string fp = join(path, "sounds.onComplete"); + const auto it = j.find("sounds.onComplete"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.sounds_onComplete = std::move(val); + } + } + { + const std::string fp = join(path, "sounds.onQueueComplete"); + const auto it = j.find("sounds.onQueueComplete"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.sounds_onQueueComplete = std::move(val); + } + } + { + const std::string fp = join(path, "sounds.onError"); + const auto it = j.find("sounds.onError"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.sounds_onError = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const TaskError& v) { + j = nlohmann::json::object(); + j["code"] = v.code; + j["message"] = v.message; + if (v.httpStatus.has_value()) j["httpStatus"] = *v.httpStatus; + j["retryable"] = v.retryable; + if (v.cause.has_value()) j["cause"] = *v.cause; + if (v.attempt.has_value()) j["attempt"] = *v.attempt; + if (v.nextRetryAt.has_value()) j["nextRetryAt"] = *v.nextRetryAt; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + TaskError out; + { + const std::string fp = join(path, "code"); + const auto it = j.find("code"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.code = std::move(val); + } + { + const std::string fp = join(path, "message"); + const auto it = j.find("message"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.message = std::move(val); + } + { + const std::string fp = join(path, "httpStatus"); + const auto it = j.find("httpStatus"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 100) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 100"}); + if (val > 599) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 599"}); + out.httpStatus = std::move(val); + } + } + { + const std::string fp = join(path, "retryable"); + const auto it = j.find("retryable"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.retryable = std::move(val); + } + { + const std::string fp = join(path, "cause"); + const auto it = j.find("cause"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.cause = std::move(val); + } + } + { + const std::string fp = join(path, "attempt"); + const auto it = j.find("attempt"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.attempt = std::move(val); + } + } + { + const std::string fp = join(path, "nextRetryAt"); + const auto it = j.find("nextRetryAt"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.nextRetryAt = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const TaskSummary& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["filename"] = v.filename; + j["saveDir"] = v.saveDir; + j["url"] = v.url; + if (v.effectiveUrl.has_value()) j["effectiveUrl"] = *v.effectiveUrl; + if (v.sizeBytes.has_value()) j["sizeBytes"] = *v.sizeBytes; + j["downloadedBytes"] = v.downloadedBytes; + j["state"] = v.state; + j["speedBps"] = v.speedBps; + if (v.etaSeconds.has_value()) j["etaSeconds"] = *v.etaSeconds; + j["resumable"] = v.resumable; + j["segments"] = v.segments; + if (v.categoryId.has_value()) j["categoryId"] = *v.categoryId; + if (v.queueId.has_value()) j["queueId"] = *v.queueId; + if (v.queuePosition.has_value()) j["queuePosition"] = *v.queuePosition; + if (v.description.has_value()) j["description"] = *v.description; + j["createdAt"] = v.createdAt; + if (v.lastTryAt.has_value()) j["lastTryAt"] = *v.lastTryAt; + if (v.completedAt.has_value()) j["completedAt"] = *v.completedAt; + if (v.error.has_value()) j["error"] = *v.error; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + TaskSummary out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "filename"); + const auto it = j.find("filename"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 255u) return std::unexpected(ParseError{std::string(fp), "value is longer than 255 characters"}); + out.filename = std::move(val); + } + { + const std::string fp = join(path, "saveDir"); + const auto it = j.find("saveDir"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.saveDir = std::move(val); + } + { + const std::string fp = join(path, "url"); + const auto it = j.find("url"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.url = std::move(val); + } + { + const std::string fp = join(path, "effectiveUrl"); + const auto it = j.find("effectiveUrl"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.effectiveUrl = std::move(val); + } + } + { + const std::string fp = join(path, "sizeBytes"); + const auto it = j.find("sizeBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.sizeBytes = std::move(val); + } + } + { + const std::string fp = join(path, "downloadedBytes"); + const auto it = j.find("downloadedBytes"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.downloadedBytes = std::move(val); + } + { + const std::string fp = join(path, "state"); + const auto it = j.find("state"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.state = std::move(val); + } + { + const std::string fp = join(path, "speedBps"); + const auto it = j.find("speedBps"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.speedBps = std::move(val); + } + { + const std::string fp = join(path, "etaSeconds"); + const auto it = j.find("etaSeconds"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.etaSeconds = std::move(val); + } + } + { + const std::string fp = join(path, "resumable"); + const auto it = j.find("resumable"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.resumable = std::move(val); + } + { + const std::string fp = join(path, "segments"); + const auto it = j.find("segments"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"}); + if (val > 32) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 32"}); + out.segments = std::move(val); + } + { + const std::string fp = join(path, "categoryId"); + const auto it = j.find("categoryId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.categoryId = std::move(val); + } + } + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + } + { + const std::string fp = join(path, "queuePosition"); + const auto it = j.find("queuePosition"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.queuePosition = std::move(val); + } + } + { + const std::string fp = join(path, "description"); + const auto it = j.find("description"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 1024u) return std::unexpected(ParseError{std::string(fp), "value is longer than 1024 characters"}); + out.description = std::move(val); + } + } + { + const std::string fp = join(path, "createdAt"); + const auto it = j.find("createdAt"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.createdAt = std::move(val); + } + { + const std::string fp = join(path, "lastTryAt"); + const auto it = j.find("lastTryAt"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.lastTryAt = std::move(val); + } + } + { + const std::string fp = join(path, "completedAt"); + const auto it = j.find("completedAt"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.completedAt = std::move(val); + } + } + { + const std::string fp = join(path, "error"); + const auto it = j.find("error"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.error = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const TaskDetail& v) { + j = nlohmann::json::object(); + j["summary"] = v.summary; + j["segmentDetail"] = v.segmentDetail; + if (v.headers.has_value()) j["headers"] = *v.headers; + if (v.referrer.has_value()) j["referrer"] = *v.referrer; + if (v.userAgent.has_value()) j["userAgent"] = *v.userAgent; + if (v.mime.has_value()) j["mime"] = *v.mime; + if (v.bufferBytes.has_value()) j["bufferBytes"] = *v.bufferBytes; + if (v.partPath.has_value()) j["partPath"] = *v.partPath; + if (v.checksum.has_value()) j["checksum"] = *v.checksum; + if (v.checksumVerified.has_value()) j["checksumVerified"] = *v.checksumVerified; + if (v.averageSpeedBps.has_value()) j["averageSpeedBps"] = *v.averageSpeedBps; + if (v.retryCount.has_value()) j["retryCount"] = *v.retryCount; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + TaskDetail out; + { + const std::string fp = join(path, "summary"); + const auto it = j.find("summary"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.summary = std::move(val); + } + { + const std::string fp = join(path, "segmentDetail"); + const auto it = j.find("segmentDetail"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + if (val.size() > 32u) return std::unexpected(ParseError{std::string(fp), "more than 32 items"}); + out.segmentDetail = std::move(val); + } + { + const std::string fp = join(path, "headers"); + const auto it = j.find("headers"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.headers = std::move(val); + } + } + { + const std::string fp = join(path, "referrer"); + const auto it = j.find("referrer"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.referrer = std::move(val); + } + } + { + const std::string fp = join(path, "userAgent"); + const auto it = j.find("userAgent"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.userAgent = std::move(val); + } + } + { + const std::string fp = join(path, "mime"); + const auto it = j.find("mime"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.mime = std::move(val); + } + } + { + const std::string fp = join(path, "bufferBytes"); + const auto it = j.find("bufferBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 4096) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 4096"}); + if (val > 8388608) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 8388608"}); + out.bufferBytes = std::move(val); + } + } + { + const std::string fp = join(path, "partPath"); + const auto it = j.find("partPath"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.partPath = std::move(val); + } + } + { + const std::string fp = join(path, "checksum"); + const auto it = j.find("checksum"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.checksum = std::move(val); + } + } + { + const std::string fp = join(path, "checksumVerified"); + const auto it = j.find("checksumVerified"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.checksumVerified = std::move(val); + } + } + { + const std::string fp = join(path, "averageSpeedBps"); + const auto it = j.find("averageSpeedBps"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.averageSpeedBps = std::move(val); + } + } + { + const std::string fp = join(path, "retryCount"); + const auto it = j.find("retryCount"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.retryCount = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const TaskFilter& v) { + j = nlohmann::json::object(); + if (v.states.has_value()) j["states"] = *v.states; + if (v.categoryId.has_value()) j["categoryId"] = *v.categoryId; + if (v.queueId.has_value()) j["queueId"] = *v.queueId; + if (v.query.has_value()) j["query"] = *v.query; + if (v.addedAfter.has_value()) j["addedAfter"] = *v.addedAfter; + if (v.addedBefore.has_value()) j["addedBefore"] = *v.addedBefore; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + TaskFilter out; + { + const std::string fp = join(path, "states"); + const auto it = j.find("states"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.states = std::move(val); + } + } + { + const std::string fp = join(path, "categoryId"); + const auto it = j.find("categoryId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.categoryId = std::move(val); + } + } + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + } + { + const std::string fp = join(path, "query"); + const auto it = j.find("query"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 256u) return std::unexpected(ParseError{std::string(fp), "value is longer than 256 characters"}); + out.query = std::move(val); + } + } + { + const std::string fp = join(path, "addedAfter"); + const auto it = j.find("addedAfter"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.addedAfter = std::move(val); + } + } + { + const std::string fp = join(path, "addedBefore"); + const auto it = j.find("addedBefore"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.addedBefore = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const TaskSort& v) { + j = nlohmann::json::object(); + j["field"] = v.field; + j["direction"] = v.direction; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + TaskSort out; + { + const std::string fp = join(path, "field"); + const auto it = j.find("field"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.field = std::move(val); + } + { + const std::string fp = join(path, "direction"); + const auto it = j.find("direction"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.direction = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const CaptureGetRulesParams& /*v*/) { + j = nlohmann::json::object(); +} + +template <> Result parse(const nlohmann::json& j, std::string_view /*path*/) { + if (!j.is_object()) return std::unexpected(ParseError{"", "expected an object"}); + CaptureGetRulesParams out; + return out; +} + +void to_json(nlohmann::json& j, const CaptureOfferParams& v) { + j = nlohmann::json::object(); + j["url"] = v.url; + j["method"] = v.method; + j["tabUrl"] = v.tabUrl; + if (v.headers.has_value()) j["headers"] = *v.headers; + if (v.cookies.has_value()) j["cookies"] = *v.cookies; + if (v.contentType.has_value()) j["contentType"] = *v.contentType; + if (v.contentLength.has_value()) j["contentLength"] = *v.contentLength; + if (v.contentDisposition.has_value()) j["contentDisposition"] = *v.contentDisposition; + if (v.filename.has_value()) j["filename"] = *v.filename; + if (v.userAgent.has_value()) j["userAgent"] = *v.userAgent; + if (v.referrer.has_value()) j["referrer"] = *v.referrer; + if (v.origin.has_value()) j["origin"] = *v.origin; + if (v.requestId.has_value()) j["requestId"] = *v.requestId; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + CaptureOfferParams out; + { + const std::string fp = join(path, "url"); + const auto it = j.find("url"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.url = std::move(val); + } + { + const std::string fp = join(path, "method"); + const auto it = j.find("method"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.method = std::move(val); + } + { + const std::string fp = join(path, "tabUrl"); + const auto it = j.find("tabUrl"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.tabUrl = std::move(val); + } + { + const std::string fp = join(path, "headers"); + const auto it = j.find("headers"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.headers = std::move(val); + } + } + { + const std::string fp = join(path, "cookies"); + const auto it = j.find("cookies"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.cookies = std::move(val); + } + } + { + const std::string fp = join(path, "contentType"); + const auto it = j.find("contentType"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.contentType = std::move(val); + } + } + { + const std::string fp = join(path, "contentLength"); + const auto it = j.find("contentLength"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.contentLength = std::move(val); + } + } + { + const std::string fp = join(path, "contentDisposition"); + const auto it = j.find("contentDisposition"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.contentDisposition = std::move(val); + } + } + { + const std::string fp = join(path, "filename"); + const auto it = j.find("filename"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.filename = std::move(val); + } + } + { + const std::string fp = join(path, "userAgent"); + const auto it = j.find("userAgent"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.userAgent = std::move(val); + } + } + { + const std::string fp = join(path, "referrer"); + const auto it = j.find("referrer"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.referrer = std::move(val); + } + } + { + const std::string fp = join(path, "origin"); + const auto it = j.find("origin"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.origin = std::move(val); + } + } + { + const std::string fp = join(path, "requestId"); + const auto it = j.find("requestId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.requestId = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const CaptureOfferResult& v) { + j = nlohmann::json::object(); + j["action"] = v.action; + if (v.taskId.has_value()) j["taskId"] = *v.taskId; + if (v.reason.has_value()) j["reason"] = *v.reason; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + CaptureOfferResult out; + { + const std::string fp = join(path, "action"); + const auto it = j.find("action"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.action = std::move(val); + } + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + } + { + const std::string fp = join(path, "reason"); + const auto it = j.find("reason"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.reason = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const CategoryListParams& /*v*/) { + j = nlohmann::json::object(); +} + +template <> Result parse(const nlohmann::json& j, std::string_view /*path*/) { + if (!j.is_object()) return std::unexpected(ParseError{"", "expected an object"}); + CategoryListParams out; + return out; +} + +void to_json(nlohmann::json& j, const CategoryListResult& v) { + j = nlohmann::json::object(); + j["items"] = v.items; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + CategoryListResult out; + { + const std::string fp = join(path, "items"); + const auto it = j.find("items"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.items = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const CategoryRemoveParams& v) { + j = nlohmann::json::object(); + j["categoryId"] = v.categoryId; + if (v.reassignTo.has_value()) j["reassignTo"] = *v.reassignTo; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + CategoryRemoveParams out; + { + const std::string fp = join(path, "categoryId"); + const auto it = j.find("categoryId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.categoryId = std::move(val); + } + { + const std::string fp = join(path, "reassignTo"); + const auto it = j.find("reassignTo"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.reassignTo = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const CategoryRemoveResult& v) { + j = nlohmann::json::object(); + j["removed"] = v.removed; + j["reassignedTaskIds"] = v.reassignedTaskIds; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + CategoryRemoveResult out; + { + const std::string fp = join(path, "removed"); + const auto it = j.find("removed"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.removed = std::move(val); + } + { + const std::string fp = join(path, "reassignedTaskIds"); + const auto it = j.find("reassignedTaskIds"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.reassignedTaskIds = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const CategoryUpsertParams& v) { + j = nlohmann::json::object(); + j["category"] = v.category; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + CategoryUpsertParams out; + { + const std::string fp = join(path, "category"); + const auto it = j.find("category"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.category = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const CategoryUpsertResult& v) { + j = nlohmann::json::object(); + j["category"] = v.category; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + CategoryUpsertResult out; + { + const std::string fp = join(path, "category"); + const auto it = j.find("category"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.category = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadAddResult& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["state"] = v.state; + if (v.duplicate.has_value()) j["duplicate"] = *v.duplicate; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadAddResult out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "state"); + const auto it = j.find("state"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.state = std::move(val); + } + { + const std::string fp = join(path, "duplicate"); + const auto it = j.find("duplicate"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.duplicate = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadAddBatchParams& v) { + j = nlohmann::json::object(); + j["items"] = v.items; + if (v.defaults.has_value()) j["defaults"] = *v.defaults; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadAddBatchParams out; + { + const std::string fp = join(path, "items"); + const auto it = j.find("items"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + if (val.size() < 1u) return std::unexpected(ParseError{std::string(fp), "fewer than 1 items"}); + if (val.size() > 5000u) return std::unexpected(ParseError{std::string(fp), "more than 5000 items"}); + out.items = std::move(val); + } + { + const std::string fp = join(path, "defaults"); + const auto it = j.find("defaults"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.defaults = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadAddBatchResultFailedItem& v) { + j = nlohmann::json::object(); + j["index"] = v.index; + j["code"] = v.code; + j["message"] = v.message; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadAddBatchResultFailedItem out; + { + const std::string fp = join(path, "index"); + const auto it = j.find("index"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.index = std::move(val); + } + { + const std::string fp = join(path, "code"); + const auto it = j.find("code"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.code = std::move(val); + } + { + const std::string fp = join(path, "message"); + const auto it = j.find("message"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.message = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadAddBatchResult& v) { + j = nlohmann::json::object(); + j["taskIds"] = v.taskIds; + j["failed"] = v.failed; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadAddBatchResult out; + { + const std::string fp = join(path, "taskIds"); + const auto it = j.find("taskIds"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.taskIds = std::move(val); + } + { + const std::string fp = join(path, "failed"); + const auto it = j.find("failed"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.failed = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadCancelParams& v) { + j = nlohmann::json::object(); + j["taskIds"] = v.taskIds; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadCancelParams out; + { + const std::string fp = join(path, "taskIds"); + const auto it = j.find("taskIds"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + if (val.size() < 1u) return std::unexpected(ParseError{std::string(fp), "fewer than 1 items"}); + if (val.size() > 5000u) return std::unexpected(ParseError{std::string(fp), "more than 5000 items"}); + out.taskIds = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadGetParams& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadGetParams out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadListParams& v) { + j = nlohmann::json::object(); + if (v.filter.has_value()) j["filter"] = *v.filter; + if (v.sort.has_value()) j["sort"] = *v.sort; + if (v.offset.has_value()) j["offset"] = *v.offset; + if (v.limit.has_value()) j["limit"] = *v.limit; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadListParams out; + { + const std::string fp = join(path, "filter"); + const auto it = j.find("filter"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.filter = std::move(val); + } + } + { + const std::string fp = join(path, "sort"); + const auto it = j.find("sort"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.sort = std::move(val); + } + } + { + const std::string fp = join(path, "offset"); + const auto it = j.find("offset"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.offset = std::move(val); + } + } + { + const std::string fp = join(path, "limit"); + const auto it = j.find("limit"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"}); + if (val > 5000) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 5000"}); + out.limit = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadListResult& v) { + j = nlohmann::json::object(); + j["total"] = v.total; + j["items"] = v.items; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadListResult out; + { + const std::string fp = join(path, "total"); + const auto it = j.find("total"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.total = std::move(val); + } + { + const std::string fp = join(path, "items"); + const auto it = j.find("items"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.items = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadPauseParams& v) { + j = nlohmann::json::object(); + j["taskIds"] = v.taskIds; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadPauseParams out; + { + const std::string fp = join(path, "taskIds"); + const auto it = j.find("taskIds"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + if (val.size() < 1u) return std::unexpected(ParseError{std::string(fp), "fewer than 1 items"}); + if (val.size() > 5000u) return std::unexpected(ParseError{std::string(fp), "more than 5000 items"}); + out.taskIds = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadProbeParams& v) { + j = nlohmann::json::object(); + j["url"] = v.url; + if (v.headers.has_value()) j["headers"] = *v.headers; + if (v.cookies.has_value()) j["cookies"] = *v.cookies; + if (v.referrer.has_value()) j["referrer"] = *v.referrer; + if (v.userAgent.has_value()) j["userAgent"] = *v.userAgent; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadProbeParams out; + { + const std::string fp = join(path, "url"); + const auto it = j.find("url"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.url = std::move(val); + } + { + const std::string fp = join(path, "headers"); + const auto it = j.find("headers"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.headers = std::move(val); + } + } + { + const std::string fp = join(path, "cookies"); + const auto it = j.find("cookies"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.cookies = std::move(val); + } + } + { + const std::string fp = join(path, "referrer"); + const auto it = j.find("referrer"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.referrer = std::move(val); + } + } + { + const std::string fp = join(path, "userAgent"); + const auto it = j.find("userAgent"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.userAgent = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadProbeResult& v) { + j = nlohmann::json::object(); + j["filename"] = v.filename; + if (v.sizeBytes.has_value()) j["sizeBytes"] = *v.sizeBytes; + j["mime"] = v.mime; + j["resumable"] = v.resumable; + j["effectiveUrl"] = v.effectiveUrl; + j["suggestedCategoryId"] = v.suggestedCategoryId; + if (v.suggestedSaveDir.has_value()) j["suggestedSaveDir"] = *v.suggestedSaveDir; + if (v.etag.has_value()) j["etag"] = *v.etag; + if (v.lastModified.has_value()) j["lastModified"] = *v.lastModified; + if (v.acceptRanges.has_value()) j["acceptRanges"] = *v.acceptRanges; + if (v.redirectChain.has_value()) j["redirectChain"] = *v.redirectChain; + if (v.requiresAuth.has_value()) j["requiresAuth"] = *v.requiresAuth; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadProbeResult out; + { + const std::string fp = join(path, "filename"); + const auto it = j.find("filename"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.filename = std::move(val); + } + { + const std::string fp = join(path, "sizeBytes"); + const auto it = j.find("sizeBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.sizeBytes = std::move(val); + } + } + { + const std::string fp = join(path, "mime"); + const auto it = j.find("mime"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.mime = std::move(val); + } + { + const std::string fp = join(path, "resumable"); + const auto it = j.find("resumable"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.resumable = std::move(val); + } + { + const std::string fp = join(path, "effectiveUrl"); + const auto it = j.find("effectiveUrl"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.effectiveUrl = std::move(val); + } + { + const std::string fp = join(path, "suggestedCategoryId"); + const auto it = j.find("suggestedCategoryId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.suggestedCategoryId = std::move(val); + } + { + const std::string fp = join(path, "suggestedSaveDir"); + const auto it = j.find("suggestedSaveDir"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.suggestedSaveDir = std::move(val); + } + } + { + const std::string fp = join(path, "etag"); + const auto it = j.find("etag"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.etag = std::move(val); + } + } + { + const std::string fp = join(path, "lastModified"); + const auto it = j.find("lastModified"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.lastModified = std::move(val); + } + } + { + const std::string fp = join(path, "acceptRanges"); + const auto it = j.find("acceptRanges"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.acceptRanges = std::move(val); + } + } + { + const std::string fp = join(path, "redirectChain"); + const auto it = j.find("redirectChain"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.redirectChain = std::move(val); + } + } + { + const std::string fp = join(path, "requiresAuth"); + const auto it = j.find("requiresAuth"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.requiresAuth = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadRefreshUrlParams& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["url"] = v.url; + if (v.headers.has_value()) j["headers"] = *v.headers; + if (v.cookies.has_value()) j["cookies"] = *v.cookies; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadRefreshUrlParams out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "url"); + const auto it = j.find("url"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.url = std::move(val); + } + { + const std::string fp = join(path, "headers"); + const auto it = j.find("headers"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.headers = std::move(val); + } + } + { + const std::string fp = join(path, "cookies"); + const auto it = j.find("cookies"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.cookies = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadRefreshUrlResult& v) { + j = nlohmann::json::object(); + j["ok"] = v.ok; + j["resumable"] = v.resumable; + j["contentChanged"] = v.contentChanged; + if (v.sizeBytes.has_value()) j["sizeBytes"] = *v.sizeBytes; + if (v.effectiveUrl.has_value()) j["effectiveUrl"] = *v.effectiveUrl; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadRefreshUrlResult out; + { + const std::string fp = join(path, "ok"); + const auto it = j.find("ok"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.ok = std::move(val); + } + { + const std::string fp = join(path, "resumable"); + const auto it = j.find("resumable"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.resumable = std::move(val); + } + { + const std::string fp = join(path, "contentChanged"); + const auto it = j.find("contentChanged"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.contentChanged = std::move(val); + } + { + const std::string fp = join(path, "sizeBytes"); + const auto it = j.find("sizeBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.sizeBytes = std::move(val); + } + } + { + const std::string fp = join(path, "effectiveUrl"); + const auto it = j.find("effectiveUrl"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.effectiveUrl = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadRemoveParams& v) { + j = nlohmann::json::object(); + j["taskIds"] = v.taskIds; + j["deleteFile"] = v.deleteFile; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadRemoveParams out; + { + const std::string fp = join(path, "taskIds"); + const auto it = j.find("taskIds"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + if (val.size() < 1u) return std::unexpected(ParseError{std::string(fp), "fewer than 1 items"}); + if (val.size() > 5000u) return std::unexpected(ParseError{std::string(fp), "more than 5000 items"}); + out.taskIds = std::move(val); + } + { + const std::string fp = join(path, "deleteFile"); + const auto it = j.find("deleteFile"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.deleteFile = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadRemoveResultFailedItem& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["code"] = v.code; + j["message"] = v.message; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadRemoveResultFailedItem out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "code"); + const auto it = j.find("code"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.code = std::move(val); + } + { + const std::string fp = join(path, "message"); + const auto it = j.find("message"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.message = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadRemoveResult& v) { + j = nlohmann::json::object(); + j["removed"] = v.removed; + j["failed"] = v.failed; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadRemoveResult out; + { + const std::string fp = join(path, "removed"); + const auto it = j.find("removed"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.removed = std::move(val); + } + { + const std::string fp = join(path, "failed"); + const auto it = j.find("failed"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.failed = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadResumeParams& v) { + j = nlohmann::json::object(); + j["taskIds"] = v.taskIds; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadResumeParams out; + { + const std::string fp = join(path, "taskIds"); + const auto it = j.find("taskIds"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + if (val.size() < 1u) return std::unexpected(ParseError{std::string(fp), "fewer than 1 items"}); + if (val.size() > 5000u) return std::unexpected(ParseError{std::string(fp), "more than 5000 items"}); + out.taskIds = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadStartParams& v) { + j = nlohmann::json::object(); + j["taskIds"] = v.taskIds; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadStartParams out; + { + const std::string fp = join(path, "taskIds"); + const auto it = j.find("taskIds"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + if (val.size() < 1u) return std::unexpected(ParseError{std::string(fp), "fewer than 1 items"}); + if (val.size() > 5000u) return std::unexpected(ParseError{std::string(fp), "more than 5000 items"}); + out.taskIds = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadUpdateParamsPatch& v) { + j = nlohmann::json::object(); + if (v.filename.has_value()) j["filename"] = *v.filename; + if (v.saveDir.has_value()) j["saveDir"] = *v.saveDir; + if (v.categoryId.has_value()) j["categoryId"] = *v.categoryId; + if (v.queueId.has_value()) j["queueId"] = *v.queueId; + if (v.description.has_value()) j["description"] = *v.description; + if (v.segments.has_value()) j["segments"] = *v.segments; + if (v.bufferBytes.has_value()) j["bufferBytes"] = *v.bufferBytes; + if (v.checksum.has_value()) j["checksum"] = *v.checksum; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadUpdateParamsPatch out; + { + const std::string fp = join(path, "filename"); + const auto it = j.find("filename"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 255u) return std::unexpected(ParseError{std::string(fp), "value is longer than 255 characters"}); + out.filename = std::move(val); + } + } + { + const std::string fp = join(path, "saveDir"); + const auto it = j.find("saveDir"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.saveDir = std::move(val); + } + } + { + const std::string fp = join(path, "categoryId"); + const auto it = j.find("categoryId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.categoryId = std::move(val); + } + } + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + } + { + const std::string fp = join(path, "description"); + const auto it = j.find("description"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 1024u) return std::unexpected(ParseError{std::string(fp), "value is longer than 1024 characters"}); + out.description = std::move(val); + } + } + { + const std::string fp = join(path, "segments"); + const auto it = j.find("segments"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"}); + if (val > 32) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 32"}); + out.segments = std::move(val); + } + } + { + const std::string fp = join(path, "bufferBytes"); + const auto it = j.find("bufferBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 4096) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 4096"}); + if (val > 8388608) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 8388608"}); + out.bufferBytes = std::move(val); + } + } + { + const std::string fp = join(path, "checksum"); + const auto it = j.find("checksum"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.checksum = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const DownloadUpdateParams& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["patch"] = v.patch; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + DownloadUpdateParams out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "patch"); + const auto it = j.find("patch"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.patch = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const GrabberHarvestParams& v) { + j = nlohmann::json::object(); + j["jobId"] = v.jobId; + j["select"] = v.select; + if (v.defaults.has_value()) j["defaults"] = *v.defaults; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + GrabberHarvestParams out; + { + const std::string fp = join(path, "jobId"); + const auto it = j.find("jobId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.jobId = std::move(val); + } + { + const std::string fp = join(path, "select"); + const auto it = j.find("select"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + if (val.size() < 1u) return std::unexpected(ParseError{std::string(fp), "fewer than 1 items"}); + out.select = std::move(val); + } + { + const std::string fp = join(path, "defaults"); + const auto it = j.find("defaults"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.defaults = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const GrabberHarvestResultFailedItem& v) { + j = nlohmann::json::object(); + j["fileId"] = v.fileId; + j["code"] = v.code; + j["message"] = v.message; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + GrabberHarvestResultFailedItem out; + { + const std::string fp = join(path, "fileId"); + const auto it = j.find("fileId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.fileId = std::move(val); + } + { + const std::string fp = join(path, "code"); + const auto it = j.find("code"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.code = std::move(val); + } + { + const std::string fp = join(path, "message"); + const auto it = j.find("message"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.message = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const GrabberHarvestResult& v) { + j = nlohmann::json::object(); + j["taskIds"] = v.taskIds; + j["failed"] = v.failed; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + GrabberHarvestResult out; + { + const std::string fp = join(path, "taskIds"); + const auto it = j.find("taskIds"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.taskIds = std::move(val); + } + { + const std::string fp = join(path, "failed"); + const auto it = j.find("failed"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.failed = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const GrabberStartParams& v) { + j = nlohmann::json::object(); + j["startUrl"] = v.startUrl; + j["depth"] = v.depth; + if (v.includePatterns.has_value()) j["includePatterns"] = *v.includePatterns; + if (v.excludePatterns.has_value()) j["excludePatterns"] = *v.excludePatterns; + if (v.fileTypes.has_value()) j["fileTypes"] = *v.fileTypes; + if (v.sameHostOnly.has_value()) j["sameHostOnly"] = *v.sameHostOnly; + if (v.maxFiles.has_value()) j["maxFiles"] = *v.maxFiles; + if (v.headers.has_value()) j["headers"] = *v.headers; + if (v.cookies.has_value()) j["cookies"] = *v.cookies; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + GrabberStartParams out; + { + const std::string fp = join(path, "startUrl"); + const auto it = j.find("startUrl"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.startUrl = std::move(val); + } + { + const std::string fp = join(path, "depth"); + const auto it = j.find("depth"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + if (val > 10) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 10"}); + out.depth = std::move(val); + } + { + const std::string fp = join(path, "includePatterns"); + const auto it = j.find("includePatterns"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.includePatterns = std::move(val); + } + } + { + const std::string fp = join(path, "excludePatterns"); + const auto it = j.find("excludePatterns"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.excludePatterns = std::move(val); + } + } + { + const std::string fp = join(path, "fileTypes"); + const auto it = j.find("fileTypes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.fileTypes = std::move(val); + } + } + { + const std::string fp = join(path, "sameHostOnly"); + const auto it = j.find("sameHostOnly"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.sameHostOnly = std::move(val); + } + } + { + const std::string fp = join(path, "maxFiles"); + const auto it = j.find("maxFiles"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 1) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 1"}); + if (val > 10000) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 10000"}); + out.maxFiles = std::move(val); + } + } + { + const std::string fp = join(path, "headers"); + const auto it = j.find("headers"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.headers = std::move(val); + } + } + { + const std::string fp = join(path, "cookies"); + const auto it = j.find("cookies"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.cookies = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const GrabberStartResult& v) { + j = nlohmann::json::object(); + j["jobId"] = v.jobId; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + GrabberStartResult out; + { + const std::string fp = join(path, "jobId"); + const auto it = j.find("jobId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.jobId = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const GrabberStatusParams& v) { + j = nlohmann::json::object(); + j["jobId"] = v.jobId; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + GrabberStatusParams out; + { + const std::string fp = join(path, "jobId"); + const auto it = j.find("jobId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.jobId = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const GrabberStatusResult& v) { + j = nlohmann::json::object(); + j["jobId"] = v.jobId; + j["state"] = v.state; + j["crawled"] = v.crawled; + j["found"] = v.found; + j["files"] = v.files; + if (v.error.has_value()) j["error"] = *v.error; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + GrabberStatusResult out; + { + const std::string fp = join(path, "jobId"); + const auto it = j.find("jobId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.jobId = std::move(val); + } + { + const std::string fp = join(path, "state"); + const auto it = j.find("state"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.state = std::move(val); + } + { + const std::string fp = join(path, "crawled"); + const auto it = j.find("crawled"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.crawled = std::move(val); + } + { + const std::string fp = join(path, "found"); + const auto it = j.find("found"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.found = std::move(val); + } + { + const std::string fp = join(path, "files"); + const auto it = j.find("files"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.files = std::move(val); + } + { + const std::string fp = join(path, "error"); + const auto it = j.find("error"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.error = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const LimiterGetParams& /*v*/) { + j = nlohmann::json::object(); +} + +template <> Result parse(const nlohmann::json& j, std::string_view /*path*/) { + if (!j.is_object()) return std::unexpected(ParseError{"", "expected an object"}); + LimiterGetParams out; + return out; +} + +void to_json(nlohmann::json& j, const MediaAddVariantParams& v) { + j = nlohmann::json::object(); + j["manifestUrl"] = v.manifestUrl; + j["variantId"] = v.variantId; + if (v.audioVariantId.has_value()) j["audioVariantId"] = *v.audioVariantId; + if (v.spec.has_value()) j["spec"] = *v.spec; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + MediaAddVariantParams out; + { + const std::string fp = join(path, "manifestUrl"); + const auto it = j.find("manifestUrl"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.manifestUrl = std::move(val); + } + { + const std::string fp = join(path, "variantId"); + const auto it = j.find("variantId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.variantId = std::move(val); + } + { + const std::string fp = join(path, "audioVariantId"); + const auto it = j.find("audioVariantId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.audioVariantId = std::move(val); + } + } + { + const std::string fp = join(path, "spec"); + const auto it = j.find("spec"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.spec = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const MediaAddVariantResult& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["state"] = v.state; + if (v.estimatedBytes.has_value()) j["estimatedBytes"] = *v.estimatedBytes; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + MediaAddVariantResult out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "state"); + const auto it = j.find("state"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.state = std::move(val); + } + { + const std::string fp = join(path, "estimatedBytes"); + const auto it = j.find("estimatedBytes"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.estimatedBytes = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const MediaListVariantsParams& v) { + j = nlohmann::json::object(); + j["manifestUrl"] = v.manifestUrl; + if (v.headers.has_value()) j["headers"] = *v.headers; + if (v.cookies.has_value()) j["cookies"] = *v.cookies; + if (v.referrer.has_value()) j["referrer"] = *v.referrer; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + MediaListVariantsParams out; + { + const std::string fp = join(path, "manifestUrl"); + const auto it = j.find("manifestUrl"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.manifestUrl = std::move(val); + } + { + const std::string fp = join(path, "headers"); + const auto it = j.find("headers"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.headers = std::move(val); + } + } + { + const std::string fp = join(path, "cookies"); + const auto it = j.find("cookies"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.cookies = std::move(val); + } + } + { + const std::string fp = join(path, "referrer"); + const auto it = j.find("referrer"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.referrer = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const MediaListVariantsResult& v) { + j = nlohmann::json::object(); + j["variants"] = v.variants; + j["manifestType"] = v.manifestType; + if (v.durationSec.has_value()) j["durationSec"] = *v.durationSec; + if (v.title.has_value()) j["title"] = *v.title; + j["drmProtected"] = v.drmProtected; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + MediaListVariantsResult out; + { + const std::string fp = join(path, "variants"); + const auto it = j.find("variants"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.variants = std::move(val); + } + { + const std::string fp = join(path, "manifestType"); + const auto it = j.find("manifestType"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.manifestType = std::move(val); + } + { + const std::string fp = join(path, "durationSec"); + const auto it = j.find("durationSec"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number()) return std::unexpected(ParseError{std::string(fp), "expected a number"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.durationSec = std::move(val); + } + } + { + const std::string fp = join(path, "title"); + const auto it = j.find("title"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.title = std::move(val); + } + } + { + const std::string fp = join(path, "drmProtected"); + const auto it = j.find("drmProtected"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.drmProtected = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const QueueListParams& /*v*/) { + j = nlohmann::json::object(); +} + +template <> Result parse(const nlohmann::json& j, std::string_view /*path*/) { + if (!j.is_object()) return std::unexpected(ParseError{"", "expected an object"}); + QueueListParams out; + return out; +} + +void to_json(nlohmann::json& j, const QueueListResult& v) { + j = nlohmann::json::object(); + j["items"] = v.items; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + QueueListResult out; + { + const std::string fp = join(path, "items"); + const auto it = j.find("items"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.items = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const QueueReorderParams& v) { + j = nlohmann::json::object(); + j["queueId"] = v.queueId; + j["taskIds"] = v.taskIds; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + QueueReorderParams out; + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + { + const std::string fp = join(path, "taskIds"); + const auto it = j.find("taskIds"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.taskIds = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const QueueReorderResult& v) { + j = nlohmann::json::object(); + j["queue"] = v.queue; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + QueueReorderResult out; + { + const std::string fp = join(path, "queue"); + const auto it = j.find("queue"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.queue = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const QueueStartParams& v) { + j = nlohmann::json::object(); + j["queueId"] = v.queueId; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + QueueStartParams out; + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const QueueStartResult& v) { + j = nlohmann::json::object(); + j["queue"] = v.queue; + j["startedTaskIds"] = v.startedTaskIds; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + QueueStartResult out; + { + const std::string fp = join(path, "queue"); + const auto it = j.find("queue"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.queue = std::move(val); + } + { + const std::string fp = join(path, "startedTaskIds"); + const auto it = j.find("startedTaskIds"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.startedTaskIds = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const QueueStopParams& v) { + j = nlohmann::json::object(); + j["queueId"] = v.queueId; + if (v.pauseRunning.has_value()) j["pauseRunning"] = *v.pauseRunning; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + QueueStopParams out; + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + { + const std::string fp = join(path, "pauseRunning"); + const auto it = j.find("pauseRunning"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.pauseRunning = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const QueueStopResult& v) { + j = nlohmann::json::object(); + j["queue"] = v.queue; + j["pausedTaskIds"] = v.pausedTaskIds; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + QueueStopResult out; + { + const std::string fp = join(path, "queue"); + const auto it = j.find("queue"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.queue = std::move(val); + } + { + const std::string fp = join(path, "pausedTaskIds"); + const auto it = j.find("pausedTaskIds"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.pausedTaskIds = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const QueueUpsertParams& v) { + j = nlohmann::json::object(); + j["queue"] = v.queue; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + QueueUpsertParams out; + { + const std::string fp = join(path, "queue"); + const auto it = j.find("queue"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.queue = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const QueueUpsertResult& v) { + j = nlohmann::json::object(); + j["queue"] = v.queue; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + QueueUpsertResult out; + { + const std::string fp = join(path, "queue"); + const auto it = j.find("queue"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.queue = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const RulesListParams& /*v*/) { + j = nlohmann::json::object(); +} + +template <> Result parse(const nlohmann::json& j, std::string_view /*path*/) { + if (!j.is_object()) return std::unexpected(ParseError{"", "expected an object"}); + RulesListParams out; + return out; +} + +void to_json(nlohmann::json& j, const RulesListResult& v) { + j = nlohmann::json::object(); + j["items"] = v.items; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + RulesListResult out; + { + const std::string fp = join(path, "items"); + const auto it = j.find("items"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.items = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const RulesUpsertParams& v) { + j = nlohmann::json::object(); + j["upsert"] = v.upsert; + if (v.remove.has_value()) j["remove"] = *v.remove; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + RulesUpsertParams out; + { + const std::string fp = join(path, "upsert"); + const auto it = j.find("upsert"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.upsert = std::move(val); + } + { + const std::string fp = join(path, "remove"); + const auto it = j.find("remove"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.remove = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const RulesUpsertResult& v) { + j = nlohmann::json::object(); + j["items"] = v.items; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + RulesUpsertResult out; + { + const std::string fp = join(path, "items"); + const auto it = j.find("items"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.items = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const ScheduleGetParams& v) { + j = nlohmann::json::object(); + if (v.queueId.has_value()) j["queueId"] = *v.queueId; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + ScheduleGetParams out; + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const ScheduleGetResultItemsItem& v) { + j = nlohmann::json::object(); + j["queueId"] = v.queueId; + if (v.schedule.has_value()) j["schedule"] = *v.schedule; + else j["schedule"] = nullptr; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + ScheduleGetResultItemsItem out; + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + { + const std::string fp = join(path, "schedule"); + const auto it = j.find("schedule"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.schedule = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const ScheduleGetResult& v) { + j = nlohmann::json::object(); + j["items"] = v.items; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + ScheduleGetResult out; + { + const std::string fp = join(path, "items"); + const auto it = j.find("items"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.items = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const ScheduleSetParams& v) { + j = nlohmann::json::object(); + j["queueId"] = v.queueId; + if (v.schedule.has_value()) j["schedule"] = *v.schedule; + else j["schedule"] = nullptr; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + ScheduleSetParams out; + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + { + const std::string fp = join(path, "schedule"); + const auto it = j.find("schedule"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.schedule = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const ScheduleSetResult& v) { + j = nlohmann::json::object(); + j["queueId"] = v.queueId; + if (v.schedule.has_value()) j["schedule"] = *v.schedule; + else j["schedule"] = nullptr; + if (v.nextRunAt.has_value()) j["nextRunAt"] = *v.nextRunAt; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + ScheduleSetResult out; + { + const std::string fp = join(path, "queueId"); + const auto it = j.find("queueId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.queueId = std::move(val); + } + { + const std::string fp = join(path, "schedule"); + const auto it = j.find("schedule"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.schedule = std::move(val); + } + } + { + const std::string fp = join(path, "nextRunAt"); + const auto it = j.find("nextRunAt"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.nextRunAt = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const SessionHelloParams& v) { + j = nlohmann::json::object(); + j["clientType"] = v.clientType; + j["clientName"] = v.clientName; + j["protocolVersion"] = v.protocolVersion; + if (v.token.has_value()) j["token"] = *v.token; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SessionHelloParams out; + { + const std::string fp = join(path, "clientType"); + const auto it = j.find("clientType"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.clientType = std::move(val); + } + { + const std::string fp = join(path, "clientName"); + const auto it = j.find("clientName"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 64u) return std::unexpected(ParseError{std::string(fp), "value is longer than 64 characters"}); + out.clientName = std::move(val); + } + { + const std::string fp = join(path, "protocolVersion"); + const auto it = j.find("protocolVersion"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + { + static const std::regex re("^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?$", std::regex::ECMAScript); + if (!std::regex_match(val, re)) return std::unexpected(ParseError{std::string(fp), "value does not match the required pattern"}); + } + out.protocolVersion = std::move(val); + } + { + const std::string fp = join(path, "token"); + const auto it = j.find("token"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.token = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const SessionHelloResult& v) { + j = nlohmann::json::object(); + j["daemonVersion"] = v.daemonVersion; + j["protocolVersion"] = v.protocolVersion; + j["capabilities"] = v.capabilities; + j["sessionId"] = v.sessionId; + if (v.transport.has_value()) j["transport"] = *v.transport; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SessionHelloResult out; + { + const std::string fp = join(path, "daemonVersion"); + const auto it = j.find("daemonVersion"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.daemonVersion = std::move(val); + } + { + const std::string fp = join(path, "protocolVersion"); + const auto it = j.find("protocolVersion"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.protocolVersion = std::move(val); + } + { + const std::string fp = join(path, "capabilities"); + const auto it = j.find("capabilities"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.capabilities = std::move(val); + } + { + const std::string fp = join(path, "sessionId"); + const auto it = j.find("sessionId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.sessionId = std::move(val); + } + { + const std::string fp = join(path, "transport"); + const auto it = j.find("transport"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.transport = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const SessionPairParams& v) { + j = nlohmann::json::object(); + j["clientName"] = v.clientName; + j["extensionId"] = v.extensionId; + if (v.code.has_value()) j["code"] = *v.code; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SessionPairParams out; + { + const std::string fp = join(path, "clientName"); + const auto it = j.find("clientName"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 64u) return std::unexpected(ParseError{std::string(fp), "value is longer than 64 characters"}); + out.clientName = std::move(val); + } + { + const std::string fp = join(path, "extensionId"); + const auto it = j.find("extensionId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.extensionId = std::move(val); + } + { + const std::string fp = join(path, "code"); + const auto it = j.find("code"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + { + static const std::regex re("^[0-9]{4}$", std::regex::ECMAScript); + if (!std::regex_match(val, re)) return std::unexpected(ParseError{std::string(fp), "value does not match the required pattern"}); + } + out.code = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const SessionPairResult& v) { + j = nlohmann::json::object(); + j["token"] = v.token; + if (v.expiresAt.has_value()) j["expiresAt"] = *v.expiresAt; + else j["expiresAt"] = nullptr; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SessionPairResult out; + { + const std::string fp = join(path, "token"); + const auto it = j.find("token"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() < 43u) return std::unexpected(ParseError{std::string(fp), "value is shorter than 43 characters"}); + out.token = std::move(val); + } + { + const std::string fp = join(path, "expiresAt"); + const auto it = j.find("expiresAt"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.expiresAt = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const SessionSubscribeParams& v) { + j = nlohmann::json::object(); + j["events"] = v.events; + if (v.taskIds.has_value()) j["taskIds"] = *v.taskIds; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SessionSubscribeParams out; + { + const std::string fp = join(path, "events"); + const auto it = j.find("events"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.events = std::move(val); + } + { + const std::string fp = join(path, "taskIds"); + const auto it = j.find("taskIds"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.taskIds = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const SessionSubscribeResult& v) { + j = nlohmann::json::object(); + j["ok"] = v.ok; + j["events"] = v.events; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SessionSubscribeResult out; + { + const std::string fp = join(path, "ok"); + const auto it = j.find("ok"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.ok = std::move(val); + } + { + const std::string fp = join(path, "events"); + const auto it = j.find("events"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + if (!(*it)[idx].is_string()) return std::unexpected(ParseError{std::string(ip), "expected a string"}); + auto val_e = (*it)[idx].get(); + val.push_back(std::move(val_e)); + } + out.events = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const SettingsGetParams& v) { + j = nlohmann::json::object(); + if (v.keys.has_value()) j["keys"] = *v.keys; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SettingsGetParams out; + { + const std::string fp = join(path, "keys"); + const auto it = j.find("keys"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.keys = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const SettingsGetResult& v) { + j = nlohmann::json::object(); + j["values"] = v.values; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SettingsGetResult out; + { + const std::string fp = join(path, "values"); + const auto it = j.find("values"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.values = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const SettingsSetParams& v) { + j = nlohmann::json::object(); + j["values"] = v.values; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SettingsSetParams out; + { + const std::string fp = join(path, "values"); + const auto it = j.find("values"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.values = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const SettingsSetResult& v) { + j = nlohmann::json::object(); + j["values"] = v.values; + j["changed"] = v.changed; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SettingsSetResult out; + { + const std::string fp = join(path, "values"); + const auto it = j.find("values"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.values = std::move(val); + } + { + const std::string fp = join(path, "changed"); + const auto it = j.find("changed"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.changed = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const AuthRequiredEvent& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["host"] = v.host; + if (v.realm.has_value()) j["realm"] = *v.realm; + j["scheme"] = v.scheme; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + AuthRequiredEvent out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "host"); + const auto it = j.find("host"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.host = std::move(val); + } + { + const std::string fp = join(path, "realm"); + const auto it = j.find("realm"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.realm = std::move(val); + } + } + { + const std::string fp = join(path, "scheme"); + const auto it = j.find("scheme"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.scheme = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const GrabberProgressEvent& v) { + j = nlohmann::json::object(); + j["jobId"] = v.jobId; + j["found"] = v.found; + j["crawled"] = v.crawled; + j["done"] = v.done; + if (v.currentUrl.has_value()) j["currentUrl"] = *v.currentUrl; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + GrabberProgressEvent out; + { + const std::string fp = join(path, "jobId"); + const auto it = j.find("jobId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.jobId = std::move(val); + } + { + const std::string fp = join(path, "found"); + const auto it = j.find("found"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.found = std::move(val); + } + { + const std::string fp = join(path, "crawled"); + const auto it = j.find("crawled"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.crawled = std::move(val); + } + { + const std::string fp = join(path, "done"); + const auto it = j.find("done"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.done = std::move(val); + } + { + const std::string fp = join(path, "currentUrl"); + const auto it = j.find("currentUrl"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.currentUrl = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const NotifyEvent& v) { + j = nlohmann::json::object(); + j["level"] = v.level; + j["title"] = v.title; + j["body"] = v.body; + if (v.taskId.has_value()) j["taskId"] = *v.taskId; + if (v.sound.has_value()) j["sound"] = *v.sound; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + NotifyEvent out; + { + const std::string fp = join(path, "level"); + const auto it = j.find("level"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.level = std::move(val); + } + { + const std::string fp = join(path, "title"); + const auto it = j.find("title"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 128u) return std::unexpected(ParseError{std::string(fp), "value is longer than 128 characters"}); + out.title = std::move(val); + } + { + const std::string fp = join(path, "body"); + const auto it = j.find("body"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + if (val.size() > 1024u) return std::unexpected(ParseError{std::string(fp), "value is longer than 1024 characters"}); + out.body = std::move(val); + } + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + } + { + const std::string fp = join(path, "sound"); + const auto it = j.find("sound"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.sound = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const SettingsChangedEvent& v) { + j = nlohmann::json::object(); + j["keys"] = v.keys; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SettingsChangedEvent out; + { + const std::string fp = join(path, "keys"); + const auto it = j.find("keys"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.keys = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const SpeedGlobalEvent& v) { + j = nlohmann::json::object(); + j["downBps"] = v.downBps; + j["activeCount"] = v.activeCount; + if (v.queuedCount.has_value()) j["queuedCount"] = *v.queuedCount; + if (v.limitBps.has_value()) j["limitBps"] = *v.limitBps; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + SpeedGlobalEvent out; + { + const std::string fp = join(path, "downBps"); + const auto it = j.find("downBps"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.downBps = std::move(val); + } + { + const std::string fp = join(path, "activeCount"); + const auto it = j.find("activeCount"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.activeCount = std::move(val); + } + { + const std::string fp = join(path, "queuedCount"); + const auto it = j.find("queuedCount"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.queuedCount = std::move(val); + } + } + { + const std::string fp = join(path, "limitBps"); + const auto it = j.find("limitBps"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.limitBps = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const TaskAddedEvent& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["summary"] = v.summary; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + TaskAddedEvent out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "summary"); + const auto it = j.find("summary"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.summary = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const TaskProgressEventTasksItemSegmentsItem& v) { + j = nlohmann::json::object(); + j["index"] = v.index; + j["downloadedBytes"] = v.downloadedBytes; + j["speedBps"] = v.speedBps; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + TaskProgressEventTasksItemSegmentsItem out; + { + const std::string fp = join(path, "index"); + const auto it = j.find("index"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + if (val > 31) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 31"}); + out.index = std::move(val); + } + { + const std::string fp = join(path, "downloadedBytes"); + const auto it = j.find("downloadedBytes"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.downloadedBytes = std::move(val); + } + { + const std::string fp = join(path, "speedBps"); + const auto it = j.find("speedBps"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.speedBps = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const TaskProgressEventTasksItem& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["downloadedBytes"] = v.downloadedBytes; + j["speedBps"] = v.speedBps; + if (v.etaSeconds.has_value()) j["etaSeconds"] = *v.etaSeconds; + if (v.segments.has_value()) j["segments"] = *v.segments; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + TaskProgressEventTasksItem out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "downloadedBytes"); + const auto it = j.find("downloadedBytes"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.downloadedBytes = std::move(val); + } + { + const std::string fp = join(path, "speedBps"); + const auto it = j.find("speedBps"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.speedBps = std::move(val); + } + { + const std::string fp = join(path, "etaSeconds"); + const auto it = j.find("etaSeconds"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"}); + auto val = (*it).get(); + if (val < 0) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 0"}); + out.etaSeconds = std::move(val); + } + } + { + const std::string fp = join(path, "segments"); + const auto it = j.find("segments"); + if (it != j.end() && !it->is_null()) { + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + if (val.size() > 32u) return std::unexpected(ParseError{std::string(fp), "more than 32 items"}); + out.segments = std::move(val); + } + } + return out; +} + +void to_json(nlohmann::json& j, const TaskProgressEvent& v) { + j = nlohmann::json::object(); + j["tasks"] = v.tasks; + j["at"] = v.at; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + TaskProgressEvent out; + { + const std::string fp = join(path, "tasks"); + const auto it = j.find("tasks"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_array()) return std::unexpected(ParseError{std::string(fp), "expected an array"}); + std::vector val; + val.reserve((*it).size()); + for (std::size_t idx = 0; idx < (*it).size(); ++idx) { + const std::string ip = join(fp, std::to_string(idx)); + auto val_e_r = parse((*it)[idx], ip); + if (!val_e_r) return std::unexpected(val_e_r.error()); + auto val_e = std::move(*val_e_r); + val.push_back(std::move(val_e)); + } + out.tasks = std::move(val); + } + { + const std::string fp = join(path, "at"); + const auto it = j.find("at"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.at = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const TaskRemovedEvent& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["deletedFile"] = v.deletedFile; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + TaskRemovedEvent out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "deletedFile"); + const auto it = j.find("deletedFile"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_boolean()) return std::unexpected(ParseError{std::string(fp), "expected a boolean"}); + auto val = (*it).get(); + out.deletedFile = std::move(val); + } + return out; +} + +void to_json(nlohmann::json& j, const TaskStateEvent& v) { + j = nlohmann::json::object(); + j["taskId"] = v.taskId; + j["state"] = v.state; + if (v.previousState.has_value()) j["previousState"] = *v.previousState; + if (v.summary.has_value()) j["summary"] = *v.summary; + if (v.error.has_value()) j["error"] = *v.error; +} + +template <> Result parse(const nlohmann::json& j, std::string_view path) { + if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"}); + TaskStateEvent out; + { + const std::string fp = join(path, "taskId"); + const auto it = j.find("taskId"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + if (!(*it).is_string()) return std::unexpected(ParseError{std::string(fp), "expected a string"}); + auto val = (*it).get(); + out.taskId = std::move(val); + } + { + const std::string fp = join(path, "state"); + const auto it = j.find("state"); + if (it == j.end() || it->is_null()) + return std::unexpected(ParseError{fp, "required field is missing"}); + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.state = std::move(val); + } + { + const std::string fp = join(path, "previousState"); + const auto it = j.find("previousState"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.previousState = std::move(val); + } + } + { + const std::string fp = join(path, "summary"); + const auto it = j.find("summary"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.summary = std::move(val); + } + } + { + const std::string fp = join(path, "error"); + const auto it = j.find("error"); + if (it != j.end() && !it->is_null()) { + auto val_r = parse((*it), fp); + if (!val_r) return std::unexpected(val_r.error()); + auto val = std::move(*val_r); + out.error = std::move(val); + } + } + return out; +} + +std::string_view to_string(Method m) noexcept { + switch (m) { + case Method::CaptureGetRules: return "capture.getRules"; + case Method::CaptureOffer: return "capture.offer"; + case Method::CategoryList: return "category.list"; + case Method::CategoryRemove: return "category.remove"; + case Method::CategoryUpsert: return "category.upsert"; + case Method::DownloadAdd: return "download.add"; + case Method::DownloadAddBatch: return "download.addBatch"; + case Method::DownloadCancel: return "download.cancel"; + case Method::DownloadGet: return "download.get"; + case Method::DownloadList: return "download.list"; + case Method::DownloadPause: return "download.pause"; + case Method::DownloadProbe: return "download.probe"; + case Method::DownloadRefreshUrl: return "download.refreshUrl"; + case Method::DownloadRemove: return "download.remove"; + case Method::DownloadResume: return "download.resume"; + case Method::DownloadStart: return "download.start"; + case Method::DownloadUpdate: return "download.update"; + case Method::GrabberHarvest: return "grabber.harvest"; + case Method::GrabberStart: return "grabber.start"; + case Method::GrabberStatus: return "grabber.status"; + case Method::LimiterGet: return "limiter.get"; + case Method::LimiterSet: return "limiter.set"; + case Method::MediaAddVariant: return "media.addVariant"; + case Method::MediaListVariants: return "media.listVariants"; + case Method::QueueList: return "queue.list"; + case Method::QueueReorder: return "queue.reorder"; + case Method::QueueStart: return "queue.start"; + case Method::QueueStop: return "queue.stop"; + case Method::QueueUpsert: return "queue.upsert"; + case Method::RulesList: return "rules.list"; + case Method::RulesUpsert: return "rules.upsert"; + case Method::ScheduleGet: return "schedule.get"; + case Method::ScheduleSet: return "schedule.set"; + case Method::SessionHello: return "session.hello"; + case Method::SessionPair: return "session.pair"; + case Method::SessionSubscribe: return "session.subscribe"; + case Method::SettingsGet: return "settings.get"; + case Method::SettingsSet: return "settings.set"; + } + return ""; +} + +std::optional method_from_string(std::string_view s) noexcept { + if (s == "capture.getRules") return Method::CaptureGetRules; + if (s == "capture.offer") return Method::CaptureOffer; + if (s == "category.list") return Method::CategoryList; + if (s == "category.remove") return Method::CategoryRemove; + if (s == "category.upsert") return Method::CategoryUpsert; + if (s == "download.add") return Method::DownloadAdd; + if (s == "download.addBatch") return Method::DownloadAddBatch; + if (s == "download.cancel") return Method::DownloadCancel; + if (s == "download.get") return Method::DownloadGet; + if (s == "download.list") return Method::DownloadList; + if (s == "download.pause") return Method::DownloadPause; + if (s == "download.probe") return Method::DownloadProbe; + if (s == "download.refreshUrl") return Method::DownloadRefreshUrl; + if (s == "download.remove") return Method::DownloadRemove; + if (s == "download.resume") return Method::DownloadResume; + if (s == "download.start") return Method::DownloadStart; + if (s == "download.update") return Method::DownloadUpdate; + if (s == "grabber.harvest") return Method::GrabberHarvest; + if (s == "grabber.start") return Method::GrabberStart; + if (s == "grabber.status") return Method::GrabberStatus; + if (s == "limiter.get") return Method::LimiterGet; + if (s == "limiter.set") return Method::LimiterSet; + if (s == "media.addVariant") return Method::MediaAddVariant; + if (s == "media.listVariants") return Method::MediaListVariants; + if (s == "queue.list") return Method::QueueList; + if (s == "queue.reorder") return Method::QueueReorder; + if (s == "queue.start") return Method::QueueStart; + if (s == "queue.stop") return Method::QueueStop; + if (s == "queue.upsert") return Method::QueueUpsert; + if (s == "rules.list") return Method::RulesList; + if (s == "rules.upsert") return Method::RulesUpsert; + if (s == "schedule.get") return Method::ScheduleGet; + if (s == "schedule.set") return Method::ScheduleSet; + if (s == "session.hello") return Method::SessionHello; + if (s == "session.pair") return Method::SessionPair; + if (s == "session.subscribe") return Method::SessionSubscribe; + if (s == "settings.get") return Method::SettingsGet; + if (s == "settings.set") return Method::SettingsSet; + return std::nullopt; +} + +bool is_privileged(Method m) noexcept { + switch (m) { + case Method::CaptureGetRules: return false; + case Method::CaptureOffer: return false; + case Method::CategoryList: return false; + case Method::CategoryRemove: return true; + case Method::CategoryUpsert: return true; + case Method::DownloadAdd: return false; + case Method::DownloadAddBatch: return false; + case Method::DownloadCancel: return false; + case Method::DownloadGet: return false; + case Method::DownloadList: return false; + case Method::DownloadPause: return false; + case Method::DownloadProbe: return false; + case Method::DownloadRefreshUrl: return false; + case Method::DownloadRemove: return true; + case Method::DownloadResume: return false; + case Method::DownloadStart: return false; + case Method::DownloadUpdate: return true; + case Method::GrabberHarvest: return true; + case Method::GrabberStart: return true; + case Method::GrabberStatus: return true; + case Method::LimiterGet: return true; + case Method::LimiterSet: return true; + case Method::MediaAddVariant: return false; + case Method::MediaListVariants: return false; + case Method::QueueList: return false; + case Method::QueueReorder: return true; + case Method::QueueStart: return true; + case Method::QueueStop: return true; + case Method::QueueUpsert: return true; + case Method::RulesList: return true; + case Method::RulesUpsert: return true; + case Method::ScheduleGet: return true; + case Method::ScheduleSet: return true; + case Method::SessionHello: return false; + case Method::SessionPair: return false; + case Method::SessionSubscribe: return false; + case Method::SettingsGet: return true; + case Method::SettingsSet: return true; + } + return true; // unknown means refuse +} + +bool is_allowed_on(Method m, Transport t) noexcept { + switch (m) { + case Method::CaptureGetRules: return t == Transport::Uds ? true : true; + case Method::CaptureOffer: return t == Transport::Uds ? true : true; + case Method::CategoryList: return t == Transport::Uds ? true : true; + case Method::CategoryRemove: return t == Transport::Uds ? true : false; + case Method::CategoryUpsert: return t == Transport::Uds ? true : false; + case Method::DownloadAdd: return t == Transport::Uds ? true : true; + case Method::DownloadAddBatch: return t == Transport::Uds ? true : true; + case Method::DownloadCancel: return t == Transport::Uds ? true : true; + case Method::DownloadGet: return t == Transport::Uds ? true : true; + case Method::DownloadList: return t == Transport::Uds ? true : true; + case Method::DownloadPause: return t == Transport::Uds ? true : true; + case Method::DownloadProbe: return t == Transport::Uds ? true : true; + case Method::DownloadRefreshUrl: return t == Transport::Uds ? true : true; + case Method::DownloadRemove: return t == Transport::Uds ? true : false; + case Method::DownloadResume: return t == Transport::Uds ? true : true; + case Method::DownloadStart: return t == Transport::Uds ? true : true; + case Method::DownloadUpdate: return t == Transport::Uds ? true : false; + case Method::GrabberHarvest: return t == Transport::Uds ? true : false; + case Method::GrabberStart: return t == Transport::Uds ? true : false; + case Method::GrabberStatus: return t == Transport::Uds ? true : false; + case Method::LimiterGet: return t == Transport::Uds ? true : false; + case Method::LimiterSet: return t == Transport::Uds ? true : false; + case Method::MediaAddVariant: return t == Transport::Uds ? true : true; + case Method::MediaListVariants: return t == Transport::Uds ? true : true; + case Method::QueueList: return t == Transport::Uds ? true : true; + case Method::QueueReorder: return t == Transport::Uds ? true : false; + case Method::QueueStart: return t == Transport::Uds ? true : false; + case Method::QueueStop: return t == Transport::Uds ? true : false; + case Method::QueueUpsert: return t == Transport::Uds ? true : false; + case Method::RulesList: return t == Transport::Uds ? true : false; + case Method::RulesUpsert: return t == Transport::Uds ? true : false; + case Method::ScheduleGet: return t == Transport::Uds ? true : false; + case Method::ScheduleSet: return t == Transport::Uds ? true : false; + case Method::SessionHello: return t == Transport::Uds ? true : true; + case Method::SessionPair: return t == Transport::Uds ? false : true; + case Method::SessionSubscribe: return t == Transport::Uds ? true : true; + case Method::SettingsGet: return t == Transport::Uds ? true : false; + case Method::SettingsSet: return t == Transport::Uds ? true : false; + } + return false; +} + +std::int32_t deadline_ms(Method m) noexcept { + switch (m) { + case Method::CaptureGetRules: return 2000; + case Method::CaptureOffer: return 750; + case Method::CategoryList: return 2000; + case Method::CategoryRemove: return 5000; + case Method::CategoryUpsert: return 5000; + case Method::DownloadAdd: return 5000; + case Method::DownloadAddBatch: return 30000; + case Method::DownloadCancel: return 5000; + case Method::DownloadGet: return 5000; + case Method::DownloadList: return 5000; + case Method::DownloadPause: return 5000; + case Method::DownloadProbe: return 30000; + case Method::DownloadRefreshUrl: return 30000; + case Method::DownloadRemove: return 10000; + case Method::DownloadResume: return 5000; + case Method::DownloadStart: return 5000; + case Method::DownloadUpdate: return 30000; + case Method::GrabberHarvest: return 30000; + case Method::GrabberStart: return 5000; + case Method::GrabberStatus: return 5000; + case Method::LimiterGet: return 2000; + case Method::LimiterSet: return 5000; + case Method::MediaAddVariant: return 30000; + case Method::MediaListVariants: return 30000; + case Method::QueueList: return 2000; + case Method::QueueReorder: return 5000; + case Method::QueueStart: return 5000; + case Method::QueueStop: return 5000; + case Method::QueueUpsert: return 5000; + case Method::RulesList: return 2000; + case Method::RulesUpsert: return 5000; + case Method::ScheduleGet: return 2000; + case Method::ScheduleSet: return 5000; + case Method::SessionHello: return 2000; + case Method::SessionPair: return 120000; + case Method::SessionSubscribe: return 2000; + case Method::SettingsGet: return 2000; + case Method::SettingsSet: return 5000; + } + return 5000; +} + +std::string_view to_string(Event e) noexcept { + switch (e) { + case Event::AuthRequired: return "event.auth.required"; + case Event::GrabberProgress: return "event.grabber.progress"; + case Event::Notify: return "event.notify"; + case Event::SettingsChanged: return "event.settings.changed"; + case Event::SpeedGlobal: return "event.speed.global"; + case Event::TaskAdded: return "event.task.added"; + case Event::TaskProgress: return "event.task.progress"; + case Event::TaskRemoved: return "event.task.removed"; + case Event::TaskState: return "event.task.state"; + } + return ""; +} + +std::optional event_from_string(std::string_view s) noexcept { + if (s == "event.auth.required") return Event::AuthRequired; + if (s == "event.grabber.progress") return Event::GrabberProgress; + if (s == "event.notify") return Event::Notify; + if (s == "event.settings.changed") return Event::SettingsChanged; + if (s == "event.speed.global") return Event::SpeedGlobal; + if (s == "event.task.added") return Event::TaskAdded; + if (s == "event.task.progress") return Event::TaskProgress; + if (s == "event.task.removed") return Event::TaskRemoved; + if (s == "event.task.state") return Event::TaskState; + return std::nullopt; +} + +nlohmann::json make_error(const nlohmann::json& id, ErrorCode code, std::string_view message, + nlohmann::json data) { + nlohmann::json err = {{"code", static_cast(code)}, {"message", std::string(message)}}; + if (!data.is_null()) err["data"] = std::move(data); + return {{"jsonrpc", "2.0"}, {"id", id}, {"error", std::move(err)}}; +} + +nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result) { + return {{"jsonrpc", "2.0"}, {"id", id}, {"result", std::move(result)}}; +} + +nlohmann::json make_notification(Event e, nlohmann::json params) { + return {{"jsonrpc", "2.0"}, {"method", std::string(to_string(e))}, {"params", std::move(params)}}; +} + +nlohmann::json dispatch(Dispatcher& handler, Transport transport, const nlohmann::json& request) { + const nlohmann::json id = request.contains("id") ? request.at("id") : nlohmann::json(nullptr); + + if (!request.is_object() || request.value("jsonrpc", "") != "2.0" || !request.contains("method")) + return make_error(id, ErrorCode::InvalidRequest, "not a JSON-RPC 2.0 request"); + if (!request.at("method").is_string()) + return make_error(id, ErrorCode::InvalidRequest, "method must be a string"); + + const auto method = method_from_string(request.at("method").get_ref()); + if (!method) + return make_error(id, ErrorCode::MethodNotFound, "no such method"); + if (!is_allowed_on(*method, transport)) + return make_error(id, ErrorCode::TransportForbidden, + "method is not permitted on this transport"); + + const nlohmann::json params = + request.contains("params") ? request.at("params") : nlohmann::json::object(); + + switch (*method) { + case Method::CaptureGetRules: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_capture_getRules(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::CaptureOffer: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_capture_offer(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::CategoryList: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_category_list(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::CategoryRemove: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_category_remove(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::CategoryUpsert: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_category_upsert(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadAdd: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_add(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadAddBatch: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_addBatch(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadCancel: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_cancel(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadGet: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_get(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadList: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_list(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadPause: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_pause(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadProbe: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_probe(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadRefreshUrl: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_refreshUrl(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadRemove: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_remove(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadResume: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_resume(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadStart: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_start(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::DownloadUpdate: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_download_update(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::GrabberHarvest: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_grabber_harvest(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::GrabberStart: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_grabber_start(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::GrabberStatus: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_grabber_status(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::LimiterGet: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_limiter_get(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::LimiterSet: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_limiter_set(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::MediaAddVariant: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_media_addVariant(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::MediaListVariants: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_media_listVariants(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::QueueList: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_queue_list(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::QueueReorder: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_queue_reorder(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::QueueStart: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_queue_start(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::QueueStop: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_queue_stop(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::QueueUpsert: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_queue_upsert(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::RulesList: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_rules_list(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::RulesUpsert: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_rules_upsert(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::ScheduleGet: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_schedule_get(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::ScheduleSet: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_schedule_set(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::SessionHello: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_session_hello(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::SessionPair: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_session_pair(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::SessionSubscribe: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_session_subscribe(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::SettingsGet: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_settings_get(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + case Method::SettingsSet: { + auto p = parse(params, "params"); + if (!p) + return make_error(id, ErrorCode::InvalidParams, p.error().message, + nlohmann::json{{"path", p.error().path}}); + auto r = handler.on_settings_set(*p); + if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); + nlohmann::json out = *r; + return make_result(id, std::move(out)); + } + } + + return make_error(id, ErrorCode::MethodNotFound, "no such method"); +} + +} // namespace velox::proto diff --git a/core/generated/velox_proto.hpp b/core/generated/velox_proto.hpp new file mode 100644 index 0000000..5f9d44a --- /dev/null +++ b/core/generated/velox_proto.hpp @@ -0,0 +1,1929 @@ +// --------------------------------------------------------------------------- +// GENERATED FILE — DO NOT EDIT. +// +// Source: contracts/schema/** +// Generator: contracts/codegen/gen_cpp.py +// Contract: v1.0.0 +// +// Hand-editing this file is a merge blocker. Fix the schema and regenerate: +// python3 contracts/codegen/gen_cpp.py +// Only lane PROTO commits to contracts/. +// --------------------------------------------------------------------------- + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +// This is libveloxproto, NOT libveloxcore. The layering rule in CLAUDE.md forbids +// JSON inside the engine; the engine does not link this target. See +// docs/adr/0009-generated-protocol-library.md. +namespace velox::proto { + +inline constexpr std::string_view kProtocolVersion = "1.0.0"; + +/// Why a payload could not be turned into a typed value. `path` is a JSON Pointer +/// into the offending document, so a conformance failure names the exact field. +struct ParseError { + std::string path; + std::string message; +}; + +/// Every parse in this file returns one of these. Nothing here throws. +template +using Result = std::expected; + +/// Which listener a request arrived on. Decides whether a privileged method is +/// allowed: see `is_allowed_on`. +enum class Transport { Uds, Ws }; + +/// Every error code the daemon may return. Adding one is a minor bump; changing the meaning of +/// one is a major bump. +enum class ErrorCode : std::int32_t { + /// Malformed JSON on the wire. + ParseError = -32700, + /// Not a valid JSON-RPC 2.0 request object. + InvalidRequest = -32600, + /// Unknown method name. + MethodNotFound = -32601, + /// Params failed schema validation. + InvalidParams = -32602, + /// Unhandled daemon-side failure. + InternalError = -32603, + /// Protocol major version mismatch. GUI renders this as 'Velox needs updating'. + VersionMismatch = -32001, + /// Missing or invalid token on the WebSocket transport. + NotPaired = -32002, + /// Method is privileged and was called over a transport that may not use it. + TransportForbidden = -32003, + /// No task with that id. + TaskNotFound = -32010, + /// Destination is outside the allowed roots, or is not writable. data.path is set. + InvalidPath = -32011, + /// Not enough free space to preallocate. + DiskFull = -32012, + /// Could not probe the URL. data.httpStatus is set when there was an HTTP response. + ProbeFailed = -32013, + /// Pairing brute-force lockout. data.retryAfterSec is set. + RateLimited = -32014, +}; +std::string_view to_string(ErrorCode v) noexcept; +std::optional errorcode_from_int(std::int32_t v) noexcept; + +/// Lifecycle of one download. The daemon is the only writer; clients render it and nothing +/// more. Terminal states are complete, failed and cancelled. +enum class TaskState { + New, // "new" + Probing, // "probing" + Queued, // "queued" + Connecting, // "connecting" + Downloading, // "downloading" + Paused, // "paused" + RetryWait, // "retry_wait" + Assembling, // "assembling" + Verifying, // "verifying" + Complete, // "complete" + Failed, // "failed" + Cancelled, // "cancelled" +}; +std::string_view to_string(TaskState v) noexcept; +Result parse_TaskState(std::string_view s); + +/// The modifier key a user holds to make one click bypass capture and let Firefox download +/// normally. Shared by Settings and CaptureRules so the daemon's setting and the extension's +/// mirror of it are literally the same type. +enum class BypassModifier { + Alt, // "alt" + Ctrl, // "ctrl" + Shift, // "shift" + None, // "none" +}; +std::string_view to_string(BypassModifier v) noexcept; +Result parse_BypassModifier(std::string_view s); + +enum class ChecksumAlgorithm { + Md5, // "md5" + Sha1, // "sha1" + Sha256, // "sha256" + Sha512, // "sha512" +}; +std::string_view to_string(ChecksumAlgorithm v) noexcept; +Result parse_ChecksumAlgorithm(std::string_view s); + +/// HTTP request headers, verbatim as the browser would have sent them. Needed for signed-URL +/// and referrer-gated CDNs. +using Headers = std::map; + +/// What the daemon does with a task the moment it is added. 'later' is the File Info dialog's +/// Download Later button and lands the task in paused. +enum class StartMode { + Now, // "now" + Later, // "later" + Queue, // "queue" +}; +std::string_view to_string(StartMode v) noexcept; +Result parse_StartMode(std::string_view s); + +enum class MediaVariantContainer { + Ts, // "ts" + Mp4, // "mp4" + Webm, // "webm" + Mkv, // "mkv" +}; +std::string_view to_string(MediaVariantContainer v) noexcept; +Result parse_MediaVariantContainer(std::string_view s); + +enum class MediaVariantKind { + Video, // "video" + Audio, // "audio" + Muxed, // "muxed" + Subtitle, // "subtitle" +}; +std::string_view to_string(MediaVariantKind v) noexcept; +Result parse_MediaVariantKind(std::string_view s); + +/// shutdown goes through org.freedesktop.login1 and must be confirmed by the user. +enum class QueueOnComplete { + Nothing, // "nothing" + Exit, // "exit" + Shutdown, // "shutdown" + Hangup, // "hangup" +}; +std::string_view to_string(QueueOnComplete v) noexcept; +Result parse_QueueOnComplete(std::string_view s); + +enum class QueueState { + Running, // "running" + Stopped, // "stopped" +}; +std::string_view to_string(QueueState v) noexcept; +Result parse_QueueState(std::string_view s); + +enum class ScheduleMode { + Once, // "once" + Periodic, // "periodic" +}; +std::string_view to_string(ScheduleMode v) noexcept; +Result parse_ScheduleMode(std::string_view s); + +/// Lets a rule veto capture for a host without touching the exclusion list. +enum class RuleActionCapture { + Take, // "take" + Ignore, // "ignore" +}; +std::string_view to_string(RuleActionCapture v) noexcept; +Result parse_RuleActionCapture(std::string_view s); + +/// 'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has +/// been planned but not yet dialled. +enum class SegmentState { + Pending, // "pending" + Connecting, // "connecting" + Downloading, // "downloading" + Stalled, // "stalled" + Complete, // "complete" + Failed, // "failed" +}; +std::string_view to_string(SegmentState v) noexcept; +Result parse_SegmentState(std::string_view s); + +/// Every settings key that exists. The Options dialog maps 1:1 onto this list and the GUI must +/// not invent a key that is not here. Kept in lockstep with Settings.schema.json by a +/// conformance check. +enum class SettingKey { + GeneralLaunchOnLogin, // "general.launchOnLogin" + GeneralMinimizeToTray, // "general.minimizeToTray" + GeneralShowDropTarget, // "general.showDropTarget" + GeneralConfirmOnExit, // "general.confirmOnExit" + GeneralLanguage, // "general.language" + GeneralCheckForUpdates, // "general.checkForUpdates" + CaptureEnabled, // "capture.enabled" + CaptureMonitoredExtensions, // "capture.monitoredExtensions" + CaptureMonitoredMimeTypes, // "capture.monitoredMimeTypes" + CaptureMinSizeBytes, // "capture.minSizeBytes" + CaptureExcludedHosts, // "capture.excludedHosts" + CaptureBypassModifier, // "capture.bypassModifier" + CaptureAutoStartTypes, // "capture.autoStartTypes" + SaveToDefaultDir, // "saveTo.defaultDir" + SaveToTempDir, // "saveTo.tempDir" + SaveToAllowedRoots, // "saveTo.allowedRoots" + SaveToFileExistsPolicy, // "saveTo.fileExistsPolicy" + SaveToCreateSubfolderPerSite, // "saveTo.createSubfolderPerSite" + ConnectionPreset, // "connection.preset" + ConnectionMaxSegmentsPerDownload, // "connection.maxSegmentsPerDownload" + ConnectionBufferBytes, // "connection.bufferBytes" + ConnectionMaxConcurrentDownloads, // "connection.maxConcurrentDownloads" + ConnectionTimeoutSec, // "connection.timeoutSec" + ConnectionMaxRetries, // "connection.maxRetries" + ConnectionRetryBackoffSec, // "connection.retryBackoffSec" + DownloadsSpeedLimitBps, // "downloads.speedLimitBps" + DownloadsSpeedLimitEnabled, // "downloads.speedLimitEnabled" + DownloadsVirusScanCommand, // "downloads.virusScanCommand" + DownloadsPostDownloadCommand, // "downloads.postDownloadCommand" + DownloadsDuplicatePolicy, // "downloads.duplicatePolicy" + DownloadsVerifyChecksums, // "downloads.verifyChecksums" + ProxyMode, // "proxy.mode" + ProxyHost, // "proxy.host" + ProxyPort, // "proxy.port" + ProxyUsername, // "proxy.username" + ProxyBypassHosts, // "proxy.bypassHosts" + ProxyPacUrl, // "proxy.pacUrl" + SoundsEnabled, // "sounds.enabled" + SoundsOnComplete, // "sounds.onComplete" + SoundsOnQueueComplete, // "sounds.onQueueComplete" + SoundsOnError, // "sounds.onError" +}; +std::string_view to_string(SettingKey v) noexcept; +Result parse_SettingKey(std::string_view s); + +enum class SettingsConnectionPreset { + Auto, // "auto" + Lan, // "lan" + Broadband, // "broadband" + Slow, // "slow" +}; +std::string_view to_string(SettingsConnectionPreset v) noexcept; +Result parse_SettingsConnectionPreset(std::string_view s); + +enum class SettingsDownloadsDuplicatePolicy { + Ask, // "ask" + Skip, // "skip" + Rename, // "rename" + Redownload, // "redownload" +}; +std::string_view to_string(SettingsDownloadsDuplicatePolicy v) noexcept; +Result parse_SettingsDownloadsDuplicatePolicy(std::string_view s); + +enum class SettingsProxyMode { + System, // "system" + None, // "none" + Http, // "http" + Https, // "https" + Socks5, // "socks5" + Pac, // "pac" +}; +std::string_view to_string(SettingsProxyMode v) noexcept; +Result parse_SettingsProxyMode(std::string_view s); + +enum class SettingsSaveToFileExistsPolicy { + Ask, // "ask" + Rename, // "rename" + Overwrite, // "overwrite" + Resume, // "resume" +}; +std::string_view to_string(SettingsSaveToFileExistsPolicy v) noexcept; +Result parse_SettingsSaveToFileExistsPolicy(std::string_view s); + +/// Why a download failed. This is the WIRE failure taxonomy and it is deliberately NOT the +/// JSON-RPC ErrorCode space: ErrorCode says why a *call* failed, TaskErrorCode says why a +/// *download* failed. A task can fail while every RPC involved succeeded. The values mirror +/// vdm::Error in core/include/vdm/util/error.hpp one-for-one, by name, so DAEMON's projection +/// from the engine taxonomy onto the wire is lossless and the GUI can tell 'the file on the +/// server changed' from 'the checksum did not match'. CORE's 'ok' has no wire spelling: a +/// TaskError only exists when there is a failure. Adding a value here is a minor bump; renaming +/// or removing one is major, and would desynchronise the engine. +enum class TaskErrorCode { + Canceled, // "canceled" + ResolveFailed, // "resolve_failed" + ConnectFailed, // "connect_failed" + TlsFailed, // "tls_failed" + ConnectionReset, // "connection_reset" + Timeout, // "timeout" + TooManyRedirects, // "too_many_redirects" + HttpClientError, // "http_client_error" + HttpServerError, // "http_server_error" + AuthRequired, // "auth_required" + Forbidden, // "forbidden" + NotFound, // "not_found" + RangeNotSatisfiable, // "range_not_satisfiable" + Gone, // "gone" + ServerFileChanged, // "server_file_changed" + ContentLengthMismatch, // "content_length_mismatch" + ChecksumMismatch, // "checksum_mismatch" + DiskFull, // "disk_full" + IoError, // "io_error" + PathRejected, // "path_rejected" + PermissionDenied, // "permission_denied" + MetaCorrupt, // "meta_corrupt" + MetaVersionUnsupported, // "meta_version_unsupported" + ProbeFailed, // "probe_failed" + UnsupportedUrlScheme, // "unsupported_url_scheme" + MaxRetriesExhausted, // "max_retries_exhausted" + Internal, // "internal" +}; +std::string_view to_string(TaskErrorCode v) noexcept; +Result parse_TaskErrorCode(std::string_view s); + +enum class TaskSortDirection { + Asc, // "asc" + Desc, // "desc" +}; +std::string_view to_string(TaskSortDirection v) noexcept; +Result parse_TaskSortDirection(std::string_view s); + +enum class TaskSortField { + Filename, // "filename" + SizeBytes, // "sizeBytes" + State, // "state" + EtaSeconds, // "etaSeconds" + SpeedBps, // "speedBps" + LastTryAt, // "lastTryAt" + CreatedAt, // "createdAt" + QueuePosition, // "queuePosition" + Description, // "description" +}; +std::string_view to_string(TaskSortField v) noexcept; +Result parse_TaskSortField(std::string_view s); + +enum class CaptureOfferParamsMethod { + GET, // "GET" + POST, // "POST" +}; +std::string_view to_string(CaptureOfferParamsMethod v) noexcept; +Result parse_CaptureOfferParamsMethod(std::string_view s); + +enum class CaptureOfferResultAction { + Take, // "take" + Ignore, // "ignore" +}; +std::string_view to_string(CaptureOfferResultAction v) noexcept; +Result parse_CaptureOfferResultAction(std::string_view s); + +/// Why the offer was declined. Set when action is 'ignore'; the extension logs it in the +/// popup's diagnostics. +enum class CaptureOfferResultReason { + ExcludedHost, // "excluded_host" + TypeNotMonitored, // "type_not_monitored" + BelowMinSize, // "below_min_size" + Duplicate, // "duplicate" + CaptureDisabled, // "capture_disabled" + UserDeclined, // "user_declined" + RuleIgnore, // "rule_ignore" +}; +std::string_view to_string(CaptureOfferResultReason v) noexcept; +Result parse_CaptureOfferResultReason(std::string_view s); + +enum class GrabberStatusResultState { + Crawling, // "crawling" + Done, // "done" + Failed, // "failed" + Cancelled, // "cancelled" +}; +std::string_view to_string(GrabberStatusResultState v) noexcept; +Result parse_GrabberStatusResultState(std::string_view s); + +enum class MediaListVariantsResultManifestType { + Hls, // "hls" + Dash, // "dash" +}; +std::string_view to_string(MediaListVariantsResultManifestType v) noexcept; +Result parse_MediaListVariantsResultManifestType(std::string_view s); + +enum class SessionHelloParamsClientType { + Gui, // "gui" + Cli, // "cli" + Extension, // "extension" + Nmhost, // "nmhost" + Test, // "test" +}; +std::string_view to_string(SessionHelloParamsClientType v) noexcept; +Result parse_SessionHelloParamsClientType(std::string_view s); + +/// How the daemon sees this connection. Lets a client know up front which privileged methods +/// will be refused. +enum class SessionHelloResultTransport { + Uds, // "uds" + Ws, // "ws" +}; +std::string_view to_string(SessionHelloResultTransport v) noexcept; +Result parse_SessionHelloResultTransport(std::string_view s); + +enum class SessionSubscribeParamsEventsItem { + EventTaskAdded, // "event.task.added" + EventTaskRemoved, // "event.task.removed" + EventTaskState, // "event.task.state" + EventTaskProgress, // "event.task.progress" + EventSpeedGlobal, // "event.speed.global" + EventAuthRequired, // "event.auth.required" + EventNotify, // "event.notify" + EventSettingsChanged, // "event.settings.changed" + EventGrabberProgress, // "event.grabber.progress" +}; +std::string_view to_string(SessionSubscribeParamsEventsItem v) noexcept; +Result parse_SessionSubscribeParamsEventsItem(std::string_view s); + +enum class AuthRequiredEventScheme { + Basic, // "basic" + Digest, // "digest" + Ntlm, // "ntlm" + Negotiate, // "negotiate" + Proxy, // "proxy" +}; +std::string_view to_string(AuthRequiredEventScheme v) noexcept; +Result parse_AuthRequiredEventScheme(std::string_view s); + +enum class NotifyEventLevel { + Info, // "info" + Success, // "success" + Warning, // "warning" + Error, // "error" +}; +std::string_view to_string(NotifyEventLevel v) noexcept; +Result parse_NotifyEventLevel(std::string_view s); + +enum class NotifyEventSound { + Complete, // "complete" + QueueComplete, // "queueComplete" + Error, // "error" +}; +std::string_view to_string(NotifyEventSound v) noexcept; +Result parse_NotifyEventSound(std::string_view s); + +struct BulkTaskResultFailedItem { + std::string taskId{}; + ErrorCode code{}; + std::string message{}; +}; + +struct BulkTaskResultUpdatedItem { + std::string taskId{}; + TaskState state{}; + bool changed{}; +}; + +/// Result of a state transition applied to many tasks. A bulk call never fails as a whole +/// because one id was bad: the ids that moved come back in 'updated' and the rest are explained +/// in 'failed'. This is what lets the GUI's toolbar act on a multi-selection without +/// pre-validating it. +struct BulkTaskResult { + /// One entry per task that actually changed. A task already in the target state is reported + /// here with changed false rather than as a failure. + std::vector updated{}; + std::vector failed{}; +}; + +/// The daemon's capture policy, mirrored into the extension so the two can never disagree about +/// what should be intercepted. The extension refreshes this on connect and on +/// event.settings.changed. +struct CaptureRules { + bool enabled{}; + std::vector monitoredExtensions{}; + std::vector monitoredMimeTypes{}; + std::int64_t minSizeBytes{}; + std::vector excludedHosts{}; + std::optional bypassModifier{}; + /// Bumped on every change. The extension re-fetches when it sees a higher value. + std::int64_t rulesVersion{}; +}; + +/// A destination folder plus the extensions that route to it. The extension mirrors the +/// extension lists so its capture decision agrees with the daemon's. +struct Category { + std::string categoryId{}; + std::string name{}; + std::string saveDir{}; + /// Without the leading dot, lowercase. + std::vector extensions{}; + std::optional> mimeTypes{}; + /// Compressed, Documents, Music, Programs, Video. Cannot be removed; can be renamed and + /// re-pointed. + bool builtin{}; + std::optional sortOrder{}; +}; + +/// Optional integrity check, verified during the verifying state. A mismatch moves the task to +/// failed and never overwrites a good file. +struct Checksum { + ChecksumAlgorithm algorithm{}; + std::string value{}; +}; + +/// One cookie the daemon replays so an authenticated download works outside the browser. +struct Cookie { + std::string name{}; + std::string value{}; + std::optional domain{}; + std::optional path{}; + std::optional secure{}; + std::optional httpOnly{}; +}; + +/// Everything needed to create one task. Shared by download.add and each item of +/// download.addBatch, so the two can never drift apart. +struct DownloadSpec { + std::string url{}; + std::optional headers{}; + std::optional> cookies{}; + std::optional referrer{}; + std::optional userAgent{}; + /// Overrides the name derived from Content-Disposition or the URL. + std::optional filename{}; + /// Canonicalized and checked against the allowed roots before any write. -32011 if it fails. + std::optional saveDir{}; + /// null means the rules engine picks one. + std::optional categoryId{}; + /// Required when startMode is 'queue'. + std::optional queueId{}; + /// The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the + /// per-host cap, and to 1 when the source turns out not to be resumable. What is actually in + /// use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload. + std::optional segments{}; + std::optional bufferBytes{}; + std::optional startMode{}; + std::optional description{}; + std::optional checksum{}; +}; + +/// One candidate found by the Site Grabber crawl. Nothing is downloaded until grabber.harvest +/// selects it. +struct GrabberFile { + std::string fileId{}; + std::string url{}; + std::optional filename{}; + /// From a HEAD, when the server answered one. + std::optional sizeBytes{}; + std::optional contentType{}; + std::int64_t depth{}; + /// The page this link was found on. + std::optional foundOn{}; +}; + +/// Global token-bucket speed limit. Applies across every active task, not per task. +struct Limiter { + bool enabled{}; + /// Bytes per second. 0 with enabled true means 'stop everything', which the GUI must not offer. + std::int64_t globalBps{}; + /// Re-tune already-running transfers instead of waiting for the next task. + std::optional applyToRunning{}; +}; + +/// One quality rendition from an HLS or DASH manifest. The daemon parses the manifest; the +/// extension only renders this list. DRM-protected variants are reported with drm true and must +/// be shown greyed out rather than failing later. +struct MediaVariant { + std::string variantId{}; + MediaVariantKind kind{}; + std::optional resolution{}; + std::optional bitrateBps{}; + std::optional codec{}; + std::optional container{}; + std::optional frameRate{}; + std::optional language{}; + /// bitrate x duration. Never exact — the GUI must label it as approximate. + std::optional sizeEstimate{}; + /// Widevine/EME detected. Explicitly out of scope; refuse rather than fail mysteriously. + bool drm{}; +}; + +/// When a queue may run. Times are local wall-clock in HH:MM; the daemon re-evaluates them on a +/// DST change rather than caching absolute instants. +struct Schedule { + bool enabled{}; + ScheduleMode mode{}; + std::optional startTime{}; + /// null means run until the queue drains. + std::optional stopTime{}; + /// 0 = Sunday. Ignored when mode is 'once'. + std::optional> daysOfWeek{}; + /// Set only when mode is 'once'. + std::optional onceDate{}; +}; + +/// An ordered run of tasks with its own concurrency cap and optional schedule. +struct Queue { + std::string queueId{}; + std::string name{}; + QueueState state{}; + std::int64_t maxConcurrent{}; + /// In run order. queue.reorder rewrites this. + std::optional> taskIds{}; + std::optional schedule{}; + /// shutdown goes through org.freedesktop.login1 and must be confirmed by the user. + std::optional onComplete{}; +}; + +/// What to do with a matching download. +struct RuleAction { + std::optional categoryId{}; + std::optional saveDir{}; + std::optional queueId{}; + std::optional segments{}; + std::optional startMode{}; + /// Lets a rule veto capture for a host without touching the exclusion list. + std::optional capture{}; +}; + +/// All present clauses must match. An absent clause is not a constraint. +struct RuleMatch { + std::optional> extensions{}; + std::optional> mimeTypes{}; + /// Glob against the effective URL's host, e.g. *.example.com + std::optional hostPattern{}; + /// Glob against the whole effective URL. + std::optional urlPattern{}; + std::optional minSizeBytes{}; + std::optional maxSizeBytes{}; +}; + +/// One row of the rules engine: match on extension, MIME, host or size, then route. First match +/// by priority wins; no rule matching means the default category. +struct Rule { + std::string ruleId{}; + std::optional name{}; + bool enabled{}; + /// Lower runs first. + std::int64_t priority{}; + /// All present clauses must match. An absent clause is not a constraint. + RuleMatch match{}; + /// What to do with a matching download. + RuleAction action{}; +}; + +/// One byte range being fetched by one connection. This is the deepest the contract ever +/// exposes the engine: the GUI draws a bar per segment and is never told what a segment steal +/// is. RANGE CONVENTION — READ THIS BEFORE IMPLEMENTING. The range is CLOSED and INCLUSIVE on +/// both ends: [startByte, endByte]. The segment covers endByte - startByte + 1 bytes, and +/// endByte is the index of the LAST byte in the range, not one past it. This deliberately +/// matches the HTTP Range header the engine actually sends ('Range: +/// bytes=-' is a byte-for-byte copy of these two fields, and RFC 9110 +/// ranges are inclusive), so no arithmetic happens between the wire and the socket and there is +/// nowhere for an off-by-one to hide. CORE asked for half-open [start, end); PROTO chose +/// inclusive for that reason and this note exists so nobody discovers the difference at +/// integration. A segment always covers at least one byte: endByte >= startByte always holds. +/// An empty range is not representable and is not needed — a zero-length download carries an +/// empty segmentDetail array, and a segment that has donated its remainder to a steal keeps the +/// bytes it already wrote. +struct Segment { + /// Position in TaskDetail.segmentDetail. Spelled 'index' here and in event.task.progress; there + /// is no 'i' spelling anywhere in the contract. + std::int64_t index{}; + /// Absolute offset of the first byte of the range. Inclusive. + std::int64_t startByte{}; + /// Absolute offset of the LAST byte of the range. Inclusive — this is not one-past-the-end. + /// Always >= startByte. + std::int64_t endByte{}; + /// Bytes written for this range so far, out of endByte - startByte + 1. + std::int64_t downloadedBytes{}; + std::optional speedBps{}; + /// 'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has + /// been planned but not yet dialled. + SegmentState state{}; + /// The status this segment's request got. 206 on a healthy ranged fetch. + std::optional httpStatus{}; +}; + +/// A sparse bag of settings. Every property is optional because settings.get returns only the +/// keys that were asked for and settings.set carries only the keys that changed. Property names +/// must match SettingKey exactly. NOTE: no password lives here — proxy and site-login +/// credentials go to the Secret Service, never to SQLite and never over the wire. +struct Settings { + std::optional general_launchOnLogin{}; + std::optional general_minimizeToTray{}; + std::optional general_showDropTarget{}; + std::optional general_confirmOnExit{}; + /// BCP 47, or 'system'. + std::optional general_language{}; + std::optional general_checkForUpdates{}; + std::optional capture_enabled{}; + std::optional> capture_monitoredExtensions{}; + std::optional> capture_monitoredMimeTypes{}; + std::optional capture_minSizeBytes{}; + std::optional> capture_excludedHosts{}; + std::optional capture_bypassModifier{}; + /// Extensions that skip the File Info dialog and start immediately. + std::optional> capture_autoStartTypes{}; + std::optional saveTo_defaultDir{}; + std::optional saveTo_tempDir{}; + /// Every write target is canonicalized and must resolve inside one of these. Read-only over the + /// WebSocket transport. + std::optional> saveTo_allowedRoots{}; + std::optional saveTo_fileExistsPolicy{}; + std::optional saveTo_createSubfolderPerSite{}; + std::optional connection_preset{}; + std::optional connection_maxSegmentsPerDownload{}; + std::optional connection_bufferBytes{}; + std::optional connection_maxConcurrentDownloads{}; + std::optional connection_timeoutSec{}; + std::optional connection_maxRetries{}; + std::optional connection_retryBackoffSec{}; + std::optional downloads_speedLimitBps{}; + std::optional downloads_speedLimitEnabled{}; + std::optional downloads_virusScanCommand{}; + std::optional downloads_postDownloadCommand{}; + std::optional downloads_duplicatePolicy{}; + std::optional downloads_verifyChecksums{}; + std::optional proxy_mode{}; + std::optional proxy_host{}; + std::optional proxy_port{}; + std::optional proxy_username{}; + std::optional> proxy_bypassHosts{}; + std::optional proxy_pacUrl{}; + std::optional sounds_enabled{}; + std::optional sounds_onComplete{}; + std::optional sounds_onQueueComplete{}; + std::optional sounds_onError{}; +}; + +/// Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which +/// describes a failed call rather than a failed download — the two live in different code +/// spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer. +struct TaskError { + TaskErrorCode code{}; + /// Human-readable, safe to show a user. Never carries a credential, a token or a full local + /// path outside the download roots. + std::string message{}; + /// Set for the codes listed in TaskErrorCode's x-carriesHttpStatus, and null otherwise. + std::optional httpStatus{}; + /// Whether the scheduler will pick this task up again on its own. Carried per-occurrence rather + /// than derived from the code, because 'probe_failed' is retryable or not depending on what the + /// probe hit. + bool retryable{}; + /// The underlying failure, for codes that wrap one. max_retries_exhausted sets it to whatever + /// the last attempt actually failed with, so a user learns the reason rather than just that + /// Velox gave up. + std::optional cause{}; + /// How many attempts have been made so far. + std::optional attempt{}; + std::optional nextRetryAt{}; +}; + +/// One row of the main download list. Everything the GUI table needs, and nothing more. +/// TaskDetail is the same shape plus the fields only the progress dialog and File Info dialog +/// need. +struct TaskSummary { + std::string taskId{}; + std::string filename{}; + /// Absolute, canonicalized, inside an allowed root. + std::string saveDir{}; + /// The URL as the user or the extension supplied it. + std::string url{}; + /// After redirects. null until the first probe succeeds. + std::optional effectiveUrl{}; + /// null when the server did not report a length. + std::optional sizeBytes{}; + std::int64_t downloadedBytes{}; + TaskState state{}; + std::int64_t speedBps{}; + /// null when the size or the speed is unknown. + std::optional etaSeconds{}; + bool resumable{}; + /// The EFFECTIVE connection count in use right now — not the number that was requested. It is + /// what remains after the per-host connection cap has been applied and after the demotion to 1 + /// for a non-resumable source, so a task the user asked for 16 connections on legitimately + /// reports 4, or 1. The GUI displays this value and must not assume it equals what download.add + /// asked for. The requested value lives in DownloadSpec.segments and is not echoed back on this + /// type. TaskDetail.segmentDetail always has exactly this many entries. + std::int64_t segments{}; + std::optional categoryId{}; + std::optional queueId{}; + /// The Q column. + std::optional queuePosition{}; + std::optional description{}; + std::string createdAt{}; + std::optional lastTryAt{}; + std::optional completedAt{}; + std::optional error{}; +}; + +/// Everything TaskSummary carries, plus what only the progress dialog and the File Info dialog +/// need. Returned by download.get; never sent in a list or an event, because it is expensive to +/// build. +struct TaskDetail { + TaskSummary summary{}; + /// Exactly TaskSummary.segments entries, in index order, covering [0, sizeBytes) with no gaps + /// and no overlaps. Empty for a zero-length download, and empty before the task has been + /// segmented. + std::vector segmentDetail{}; + std::optional headers{}; + std::optional referrer{}; + std::optional userAgent{}; + std::optional mime{}; + std::optional bufferBytes{}; + /// Absolute path of the .veloxpart file while the task is unfinished. + std::optional partPath{}; + std::optional checksum{}; + /// null until the verifying state has run. + std::optional checksumVerified{}; + std::optional averageSpeedBps{}; + std::optional retryCount{}; +}; + +/// Which rows download.list returns. This is the category tree and the All/Unfinished/Finished +/// nodes, expressed on the wire. Absent clauses are not constraints. +struct TaskFilter { + std::optional> states{}; + std::optional categoryId{}; + std::optional queueId{}; + /// Case-insensitive substring of filename or url. + std::optional query{}; + std::optional addedAfter{}; + std::optional addedBefore{}; +}; + +/// Sort order for download.list. The GUI persists the user's choice and sends it on every list +/// call; the daemon does the sorting so a 100k-row list never has to be materialized +/// client-side. +struct TaskSort { + TaskSortField field{}; + TaskSortDirection direction{}; +}; + +struct CaptureGetRulesParams { + // No fields: this method takes no parameters. +}; + +struct CaptureOfferParams { + std::string url{}; + CaptureOfferParamsMethod method{}; + std::string tabUrl{}; + std::optional headers{}; + /// Cookies for the URL, so authenticated downloads work outside the browser. + std::optional> cookies{}; + std::optional contentType{}; + std::optional contentLength{}; + std::optional contentDisposition{}; + /// The extension's best guess; the daemon may override it. + std::optional filename{}; + std::optional userAgent{}; + std::optional referrer{}; + /// moz-extension://... The daemon verifies this on the WS transport and refuses anything else. + std::optional origin{}; + /// The extension's webRequest id, echoed in logs so a capture decision can be traced back to + /// one browser request. + std::optional requestId{}; +}; + +struct CaptureOfferResult { + CaptureOfferResultAction action{}; + /// Set when action is 'take'. + std::optional taskId{}; + /// Why the offer was declined. Set when action is 'ignore'; the extension logs it in the + /// popup's diagnostics. + std::optional reason{}; +}; + +struct CategoryListParams { + // No fields: this method takes no parameters. +}; + +struct CategoryListResult { + std::vector items{}; +}; + +struct CategoryRemoveParams { + std::string categoryId{}; + std::optional reassignTo{}; +}; + +struct CategoryRemoveResult { + bool removed{}; + std::vector reassignedTaskIds{}; +}; + +struct CategoryUpsertParams { + Category category{}; +}; + +/// The stored category, with categoryId filled in on create. +struct CategoryUpsertResult { + Category category{}; +}; + +struct DownloadAddResult { + std::string taskId{}; + TaskState state{}; + /// The existing task this URL matched, when downloads.duplicatePolicy resolved to 'skip'. + /// taskId then names that existing task. + std::optional duplicate{}; +}; + +struct DownloadAddBatchParams { + std::vector items{}; + /// Applied to any field an item left unset. Its url is ignored. + std::optional defaults{}; +}; + +struct DownloadAddBatchResultFailedItem { + std::int64_t index{}; + ErrorCode code{}; + std::string message{}; +}; + +struct DownloadAddBatchResult { + /// In the same order as the accepted items. + std::vector taskIds{}; + /// One entry per item that could not be added. index refers to params.items. + std::vector failed{}; +}; + +struct DownloadCancelParams { + std::vector taskIds{}; +}; + +struct DownloadGetParams { + std::string taskId{}; +}; + +struct DownloadListParams { + std::optional filter{}; + std::optional sort{}; + std::optional offset{}; + /// Defaults to 500. The GUI pages; the extension popup asks for far fewer. + std::optional limit{}; +}; + +struct DownloadListResult { + /// Rows matching the filter, ignoring offset and limit. + std::int64_t total{}; + std::vector items{}; +}; + +struct DownloadPauseParams { + std::vector taskIds{}; +}; + +struct DownloadProbeParams { + std::string url{}; + std::optional headers{}; + std::optional> cookies{}; + std::optional referrer{}; + std::optional userAgent{}; +}; + +struct DownloadProbeResult { + /// From Content-Disposition when present, else the URL path, sanitized. + std::string filename{}; + std::optional sizeBytes{}; + std::string mime{}; + /// Accept-Ranges: bytes and a validator (ETag or Last-Modified) are both present. + bool resumable{}; + std::string effectiveUrl{}; + /// What the rules engine would pick. The dialog preselects it; the user may override. + std::string suggestedCategoryId{}; + std::optional suggestedSaveDir{}; + std::optional etag{}; + std::optional lastModified{}; + std::optional acceptRanges{}; + /// Every hop, so the user can see where a shortener actually led. + std::optional> redirectChain{}; + /// The probe got a 401/407. The GUI should collect credentials before adding. + std::optional requiresAuth{}; +}; + +struct DownloadRefreshUrlParams { + std::string taskId{}; + std::string url{}; + std::optional headers{}; + std::optional> cookies{}; +}; + +struct DownloadRefreshUrlResult { + bool ok{}; + bool resumable{}; + /// true when size or validator differ from what was recorded. The GUI must ask before + /// restarting from zero — never discard bytes without consent. + bool contentChanged{}; + std::optional sizeBytes{}; + std::optional effectiveUrl{}; +}; + +struct DownloadRemoveParams { + std::vector taskIds{}; + /// Explicit and required — there is no default for deleting a user's file. + bool deleteFile{}; +}; + +struct DownloadRemoveResultFailedItem { + std::string taskId{}; + ErrorCode code{}; + std::string message{}; +}; + +struct DownloadRemoveResult { + std::vector removed{}; + std::vector failed{}; +}; + +struct DownloadResumeParams { + std::vector taskIds{}; +}; + +struct DownloadStartParams { + std::vector taskIds{}; +}; + +/// Only the present fields change. An explicit null clears a nullable field. +struct DownloadUpdateParamsPatch { + std::optional filename{}; + std::optional saveDir{}; + std::optional categoryId{}; + std::optional queueId{}; + std::optional description{}; + /// The REQUESTED connection count, subject to the same per-host cap and non-resumable demotion + /// as DownloadSpec.segments. Takes effect on the next start; a running task is not re-segmented + /// underneath the user. + std::optional segments{}; + std::optional bufferBytes{}; + std::optional checksum{}; +}; + +struct DownloadUpdateParams { + std::string taskId{}; + /// Only the present fields change. An explicit null clears a nullable field. + DownloadUpdateParamsPatch patch{}; +}; + +struct GrabberHarvestParams { + std::string jobId{}; + /// fileIds from grabber.status. + std::vector select{}; + std::optional defaults{}; +}; + +struct GrabberHarvestResultFailedItem { + std::string fileId{}; + ErrorCode code{}; + std::string message{}; +}; + +struct GrabberHarvestResult { + std::vector taskIds{}; + std::vector failed{}; +}; + +struct GrabberStartParams { + std::string startUrl{}; + std::int64_t depth{}; + std::optional> includePatterns{}; + std::optional> excludePatterns{}; + /// Extensions, without the dot. null means every type. + std::optional> fileTypes{}; + std::optional sameHostOnly{}; + std::optional maxFiles{}; + std::optional headers{}; + std::optional> cookies{}; +}; + +struct GrabberStartResult { + std::string jobId{}; +}; + +struct GrabberStatusParams { + std::string jobId{}; +}; + +struct GrabberStatusResult { + std::string jobId{}; + GrabberStatusResultState state{}; + std::int64_t crawled{}; + std::int64_t found{}; + std::vector files{}; + std::optional error{}; +}; + +struct LimiterGetParams { + // No fields: this method takes no parameters. +}; + +/// spec carries the same destination and queueing fields as download.add; its url is ignored +/// because the manifest and variant determine the source. +struct MediaAddVariantParams { + std::string manifestUrl{}; + std::string variantId{}; + /// For DASH and HLS renditions where audio is a separate track to be muxed in. + std::optional audioVariantId{}; + std::optional spec{}; +}; + +struct MediaAddVariantResult { + std::string taskId{}; + TaskState state{}; + std::optional estimatedBytes{}; +}; + +struct MediaListVariantsParams { + std::string manifestUrl{}; + std::optional headers{}; + std::optional> cookies{}; + std::optional referrer{}; +}; + +struct MediaListVariantsResult { + std::vector variants{}; + MediaListVariantsResultManifestType manifestType{}; + std::optional durationSec{}; + std::optional title{}; + /// The manifest as a whole is DRM-protected. Refuse with a clear message rather than + /// downloading undecryptable segments. + bool drmProtected{}; +}; + +struct QueueListParams { + // No fields: this method takes no parameters. +}; + +struct QueueListResult { + std::vector items{}; +}; + +struct QueueReorderParams { + std::string queueId{}; + std::vector taskIds{}; +}; + +struct QueueReorderResult { + Queue queue{}; +}; + +struct QueueStartParams { + std::string queueId{}; +}; + +struct QueueStartResult { + Queue queue{}; + std::vector startedTaskIds{}; +}; + +struct QueueStopParams { + std::string queueId{}; + std::optional pauseRunning{}; +}; + +struct QueueStopResult { + Queue queue{}; + std::vector pausedTaskIds{}; +}; + +struct QueueUpsertParams { + Queue queue{}; +}; + +struct QueueUpsertResult { + Queue queue{}; +}; + +struct RulesListParams { + // No fields: this method takes no parameters. +}; + +struct RulesListResult { + std::vector items{}; +}; + +struct RulesUpsertParams { + std::vector upsert{}; + std::optional> remove{}; +}; + +/// The full table after the write, in priority order. +struct RulesUpsertResult { + std::vector items{}; +}; + +struct ScheduleGetParams { + std::optional queueId{}; +}; + +struct ScheduleGetResultItemsItem { + std::string queueId{}; + std::optional schedule{}; +}; + +struct ScheduleGetResult { + std::vector items{}; +}; + +struct ScheduleSetParams { + std::string queueId{}; + std::optional schedule{}; +}; + +struct ScheduleSetResult { + std::string queueId{}; + std::optional schedule{}; + std::optional nextRunAt{}; +}; + +struct SessionHelloParams { + SessionHelloParamsClientType clientType{}; + /// Human-readable, shown in the pairing prompt and the logs. + std::string clientName{}; + std::string protocolVersion{}; + /// Required on the WebSocket transport once paired. Ignored on the Unix socket, where + /// SO_PEERCRED is the authorization. + std::optional token{}; +}; + +struct SessionHelloResult { + std::string daemonVersion{}; + std::string protocolVersion{}; + /// Optional features this build has, e.g. 'media', 'grabber', 'secretservice'. A client must + /// degrade gracefully when one is absent rather than assuming it. + std::vector capabilities{}; + std::string sessionId{}; + /// How the daemon sees this connection. Lets a client know up front which privileged methods + /// will be refused. + std::optional transport{}; +}; + +struct SessionPairParams { + std::string clientName{}; + /// The moz-extension origin UUID. Must match the Origin header verified on the WS upgrade. + std::string extensionId{}; + /// Set when the user typed the code into the extension's Options page instead of clicking Allow + /// in the GUI. + std::optional code{}; +}; + +struct SessionPairResult { + /// 256 bits, base64url. Stored by the extension in browser.storage.local and sent on every + /// later connect. + std::string token{}; + /// null means the token does not expire; it is revoked from Options -> Unpair. + std::optional expiresAt{}; +}; + +struct SessionSubscribeParams { + std::vector events{}; + /// Narrow task events to these ids. The extension popup uses it to avoid receiving progress for + /// downloads it is not showing. null means all tasks. + std::optional> taskIds{}; +}; + +struct SessionSubscribeResult { + bool ok{}; + /// Echoed back so a client can detect that it asked for an event this daemon does not emit. + std::vector events{}; +}; + +struct SettingsGetParams { + std::optional> keys{}; +}; + +struct SettingsGetResult { + Settings values{}; +}; + +struct SettingsSetParams { + Settings values{}; +}; + +/// The stored values for the keys that were set, and the list of keys that actually changed. +struct SettingsSetResult { + Settings values{}; + std::vector changed{}; +}; + +struct AuthRequiredEvent { + std::string taskId{}; + std::string host{}; + std::optional realm{}; + AuthRequiredEventScheme scheme{}; +}; + +struct GrabberProgressEvent { + std::string jobId{}; + std::int64_t found{}; + std::int64_t crawled{}; + bool done{}; + std::optional currentUrl{}; +}; + +struct NotifyEvent { + NotifyEventLevel level{}; + std::string title{}; + std::string body{}; + std::optional taskId{}; + std::optional sound{}; +}; + +struct SettingsChangedEvent { + std::vector keys{}; +}; + +struct SpeedGlobalEvent { + std::int64_t downBps{}; + std::int64_t activeCount{}; + std::optional queuedCount{}; + /// null when the limiter is off. + std::optional limitBps{}; +}; + +struct TaskAddedEvent { + std::string taskId{}; + TaskSummary summary{}; +}; + +/// Only what a segment bar needs. Full segment state comes from download.get. +struct TaskProgressEventTasksItemSegmentsItem { + std::int64_t index{}; + std::int64_t downloadedBytes{}; + std::int64_t speedBps{}; +}; + +struct TaskProgressEventTasksItem { + std::string taskId{}; + std::int64_t downloadedBytes{}; + std::int64_t speedBps{}; + std::optional etaSeconds{}; + std::optional> segments{}; +}; + +struct TaskProgressEvent { + std::vector tasks{}; + std::string at{}; +}; + +struct TaskRemovedEvent { + std::string taskId{}; + bool deletedFile{}; +}; + +struct TaskStateEvent { + std::string taskId{}; + TaskState state{}; + std::optional previousState{}; + std::optional summary{}; + std::optional error{}; +}; + +// --- serialisation --------------------------------------------------------- +// ADL hooks, so `nlohmann::json j = value;` works. Outbound only: there is no +// generated from_json, because nlohmann's inbound path throws and the wire is +// never trusted. Use parse below. + +void to_json(nlohmann::json& j, const ErrorCode& v); +void to_json(nlohmann::json& j, const BulkTaskResultFailedItem& v); +void to_json(nlohmann::json& j, const TaskState& v); +void to_json(nlohmann::json& j, const BulkTaskResultUpdatedItem& v); +void to_json(nlohmann::json& j, const BulkTaskResult& v); +void to_json(nlohmann::json& j, const BypassModifier& v); +void to_json(nlohmann::json& j, const CaptureRules& v); +void to_json(nlohmann::json& j, const Category& v); +void to_json(nlohmann::json& j, const ChecksumAlgorithm& v); +void to_json(nlohmann::json& j, const Checksum& v); +void to_json(nlohmann::json& j, const Cookie& v); +void to_json(nlohmann::json& j, const StartMode& v); +void to_json(nlohmann::json& j, const DownloadSpec& v); +void to_json(nlohmann::json& j, const GrabberFile& v); +void to_json(nlohmann::json& j, const Limiter& v); +void to_json(nlohmann::json& j, const MediaVariantContainer& v); +void to_json(nlohmann::json& j, const MediaVariantKind& v); +void to_json(nlohmann::json& j, const MediaVariant& v); +void to_json(nlohmann::json& j, const QueueOnComplete& v); +void to_json(nlohmann::json& j, const QueueState& v); +void to_json(nlohmann::json& j, const ScheduleMode& v); +void to_json(nlohmann::json& j, const Schedule& v); +void to_json(nlohmann::json& j, const Queue& v); +void to_json(nlohmann::json& j, const RuleActionCapture& v); +void to_json(nlohmann::json& j, const RuleAction& v); +void to_json(nlohmann::json& j, const RuleMatch& v); +void to_json(nlohmann::json& j, const Rule& v); +void to_json(nlohmann::json& j, const SegmentState& v); +void to_json(nlohmann::json& j, const Segment& v); +void to_json(nlohmann::json& j, const SettingKey& v); +void to_json(nlohmann::json& j, const SettingsConnectionPreset& v); +void to_json(nlohmann::json& j, const SettingsDownloadsDuplicatePolicy& v); +void to_json(nlohmann::json& j, const SettingsProxyMode& v); +void to_json(nlohmann::json& j, const SettingsSaveToFileExistsPolicy& v); +void to_json(nlohmann::json& j, const Settings& v); +void to_json(nlohmann::json& j, const TaskErrorCode& v); +void to_json(nlohmann::json& j, const TaskError& v); +void to_json(nlohmann::json& j, const TaskSummary& v); +void to_json(nlohmann::json& j, const TaskDetail& v); +void to_json(nlohmann::json& j, const TaskFilter& v); +void to_json(nlohmann::json& j, const TaskSortDirection& v); +void to_json(nlohmann::json& j, const TaskSortField& v); +void to_json(nlohmann::json& j, const TaskSort& v); +void to_json(nlohmann::json& j, const CaptureGetRulesParams& v); +void to_json(nlohmann::json& j, const CaptureOfferParamsMethod& v); +void to_json(nlohmann::json& j, const CaptureOfferParams& v); +void to_json(nlohmann::json& j, const CaptureOfferResultAction& v); +void to_json(nlohmann::json& j, const CaptureOfferResultReason& v); +void to_json(nlohmann::json& j, const CaptureOfferResult& v); +void to_json(nlohmann::json& j, const CategoryListParams& v); +void to_json(nlohmann::json& j, const CategoryListResult& v); +void to_json(nlohmann::json& j, const CategoryRemoveParams& v); +void to_json(nlohmann::json& j, const CategoryRemoveResult& v); +void to_json(nlohmann::json& j, const CategoryUpsertParams& v); +void to_json(nlohmann::json& j, const CategoryUpsertResult& v); +void to_json(nlohmann::json& j, const DownloadAddResult& v); +void to_json(nlohmann::json& j, const DownloadAddBatchParams& v); +void to_json(nlohmann::json& j, const DownloadAddBatchResultFailedItem& v); +void to_json(nlohmann::json& j, const DownloadAddBatchResult& v); +void to_json(nlohmann::json& j, const DownloadCancelParams& v); +void to_json(nlohmann::json& j, const DownloadGetParams& v); +void to_json(nlohmann::json& j, const DownloadListParams& v); +void to_json(nlohmann::json& j, const DownloadListResult& v); +void to_json(nlohmann::json& j, const DownloadPauseParams& v); +void to_json(nlohmann::json& j, const DownloadProbeParams& v); +void to_json(nlohmann::json& j, const DownloadProbeResult& v); +void to_json(nlohmann::json& j, const DownloadRefreshUrlParams& v); +void to_json(nlohmann::json& j, const DownloadRefreshUrlResult& v); +void to_json(nlohmann::json& j, const DownloadRemoveParams& v); +void to_json(nlohmann::json& j, const DownloadRemoveResultFailedItem& v); +void to_json(nlohmann::json& j, const DownloadRemoveResult& v); +void to_json(nlohmann::json& j, const DownloadResumeParams& v); +void to_json(nlohmann::json& j, const DownloadStartParams& v); +void to_json(nlohmann::json& j, const DownloadUpdateParamsPatch& v); +void to_json(nlohmann::json& j, const DownloadUpdateParams& v); +void to_json(nlohmann::json& j, const GrabberHarvestParams& v); +void to_json(nlohmann::json& j, const GrabberHarvestResultFailedItem& v); +void to_json(nlohmann::json& j, const GrabberHarvestResult& v); +void to_json(nlohmann::json& j, const GrabberStartParams& v); +void to_json(nlohmann::json& j, const GrabberStartResult& v); +void to_json(nlohmann::json& j, const GrabberStatusParams& v); +void to_json(nlohmann::json& j, const GrabberStatusResultState& v); +void to_json(nlohmann::json& j, const GrabberStatusResult& v); +void to_json(nlohmann::json& j, const LimiterGetParams& v); +void to_json(nlohmann::json& j, const MediaAddVariantParams& v); +void to_json(nlohmann::json& j, const MediaAddVariantResult& v); +void to_json(nlohmann::json& j, const MediaListVariantsParams& v); +void to_json(nlohmann::json& j, const MediaListVariantsResultManifestType& v); +void to_json(nlohmann::json& j, const MediaListVariantsResult& v); +void to_json(nlohmann::json& j, const QueueListParams& v); +void to_json(nlohmann::json& j, const QueueListResult& v); +void to_json(nlohmann::json& j, const QueueReorderParams& v); +void to_json(nlohmann::json& j, const QueueReorderResult& v); +void to_json(nlohmann::json& j, const QueueStartParams& v); +void to_json(nlohmann::json& j, const QueueStartResult& v); +void to_json(nlohmann::json& j, const QueueStopParams& v); +void to_json(nlohmann::json& j, const QueueStopResult& v); +void to_json(nlohmann::json& j, const QueueUpsertParams& v); +void to_json(nlohmann::json& j, const QueueUpsertResult& v); +void to_json(nlohmann::json& j, const RulesListParams& v); +void to_json(nlohmann::json& j, const RulesListResult& v); +void to_json(nlohmann::json& j, const RulesUpsertParams& v); +void to_json(nlohmann::json& j, const RulesUpsertResult& v); +void to_json(nlohmann::json& j, const ScheduleGetParams& v); +void to_json(nlohmann::json& j, const ScheduleGetResultItemsItem& v); +void to_json(nlohmann::json& j, const ScheduleGetResult& v); +void to_json(nlohmann::json& j, const ScheduleSetParams& v); +void to_json(nlohmann::json& j, const ScheduleSetResult& v); +void to_json(nlohmann::json& j, const SessionHelloParamsClientType& v); +void to_json(nlohmann::json& j, const SessionHelloParams& v); +void to_json(nlohmann::json& j, const SessionHelloResultTransport& v); +void to_json(nlohmann::json& j, const SessionHelloResult& v); +void to_json(nlohmann::json& j, const SessionPairParams& v); +void to_json(nlohmann::json& j, const SessionPairResult& v); +void to_json(nlohmann::json& j, const SessionSubscribeParamsEventsItem& v); +void to_json(nlohmann::json& j, const SessionSubscribeParams& v); +void to_json(nlohmann::json& j, const SessionSubscribeResult& v); +void to_json(nlohmann::json& j, const SettingsGetParams& v); +void to_json(nlohmann::json& j, const SettingsGetResult& v); +void to_json(nlohmann::json& j, const SettingsSetParams& v); +void to_json(nlohmann::json& j, const SettingsSetResult& v); +void to_json(nlohmann::json& j, const AuthRequiredEventScheme& v); +void to_json(nlohmann::json& j, const AuthRequiredEvent& v); +void to_json(nlohmann::json& j, const GrabberProgressEvent& v); +void to_json(nlohmann::json& j, const NotifyEventLevel& v); +void to_json(nlohmann::json& j, const NotifyEventSound& v); +void to_json(nlohmann::json& j, const NotifyEvent& v); +void to_json(nlohmann::json& j, const SettingsChangedEvent& v); +void to_json(nlohmann::json& j, const SpeedGlobalEvent& v); +void to_json(nlohmann::json& j, const TaskAddedEvent& v); +void to_json(nlohmann::json& j, const TaskProgressEventTasksItemSegmentsItem& v); +void to_json(nlohmann::json& j, const TaskProgressEventTasksItem& v); +void to_json(nlohmann::json& j, const TaskProgressEvent& v); +void to_json(nlohmann::json& j, const TaskRemovedEvent& v); +void to_json(nlohmann::json& j, const TaskStateEvent& v); + +// --- parsing --------------------------------------------------------------- + +/// Turn an untrusted JSON value into a typed one. Specialised below for every +/// contract type; the primary template is intentionally not defined, so asking +/// for a type the contract does not have is a compile error, not a runtime one. +template +Result parse(const nlohmann::json& j, std::string_view path = ""); + +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); +template <> Result parse(const nlohmann::json& j, std::string_view path); + +// --- method surface -------------------------------------------------------- + +/// Every method in the contract. Generated, so a daemon cannot answer a method +/// the contract does not define, and cannot silently fail to answer one it does. +enum class Method { + CaptureGetRules, // capture.getRules + CaptureOffer, // capture.offer + CategoryList, // category.list + CategoryRemove, // category.remove + CategoryUpsert, // category.upsert + DownloadAdd, // download.add + DownloadAddBatch, // download.addBatch + DownloadCancel, // download.cancel + DownloadGet, // download.get + DownloadList, // download.list + DownloadPause, // download.pause + DownloadProbe, // download.probe + DownloadRefreshUrl, // download.refreshUrl + DownloadRemove, // download.remove + DownloadResume, // download.resume + DownloadStart, // download.start + DownloadUpdate, // download.update + GrabberHarvest, // grabber.harvest + GrabberStart, // grabber.start + GrabberStatus, // grabber.status + LimiterGet, // limiter.get + LimiterSet, // limiter.set + MediaAddVariant, // media.addVariant + MediaListVariants, // media.listVariants + QueueList, // queue.list + QueueReorder, // queue.reorder + QueueStart, // queue.start + QueueStop, // queue.stop + QueueUpsert, // queue.upsert + RulesList, // rules.list + RulesUpsert, // rules.upsert + ScheduleGet, // schedule.get + ScheduleSet, // schedule.set + SessionHello, // session.hello + SessionPair, // session.pair + SessionSubscribe, // session.subscribe + SettingsGet, // settings.get + SettingsSet, // settings.set +}; + +inline constexpr std::size_t kMethodCount = 38; + +std::string_view to_string(Method m) noexcept; +std::optional method_from_string(std::string_view s) noexcept; + +/// True for methods refused over the WebSocket transport with -32003. The +/// extension is not allowed to reconfigure the daemon or destroy user data. +bool is_privileged(Method m) noexcept; +bool is_allowed_on(Method m, Transport t) noexcept; + +/// The contract's answer deadline. capture.offer's 750 ms is the one that +/// matters: past it the extension has already let Firefox take the download. +std::int32_t deadline_ms(Method m) noexcept; + +/// Server-to-client notifications. +enum class Event { + AuthRequired, // event.auth.required + GrabberProgress, // event.grabber.progress + Notify, // event.notify + SettingsChanged, // event.settings.changed + SpeedGlobal, // event.speed.global + TaskAdded, // event.task.added + TaskProgress, // event.task.progress + TaskRemoved, // event.task.removed + TaskState, // event.task.state +}; + +std::string_view to_string(Event e) noexcept; +std::optional event_from_string(std::string_view s) noexcept; + +// --- dispatch -------------------------------------------------------------- + +/// Build a JSON-RPC error response. `id` may be null for a request that could +/// not be parsed far enough to have one. +nlohmann::json make_error(const nlohmann::json& id, ErrorCode code, std::string_view message, + nlohmann::json data = nullptr); +nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result); +nlohmann::json make_notification(Event e, nlohmann::json params); + +/// One virtual per method. The daemon implements this; `dispatch` below does the +/// envelope handling, the transport check and the parameter parsing, so a handler +/// only ever sees a validated, typed params struct. +class Dispatcher { +public: + virtual ~Dispatcher() = default; + + /// The daemon's capture policy, so the extension's shouldCapture decision cannot drift from the + /// daemon's. Fetched on connect and whenever event.settings.changed names a capture.* key. If + /// this call fails the extension keeps its last known rules and stays fail-open. + virtual Result on_capture_getRules(const CaptureGetRulesParams& params) = 0; + + /// Firefox offers an intercepted response to the daemon. The daemon MUST reply within 750 ms; + /// the extension abandons the offer and lets Firefox download normally on timeout. This + /// deadline is the whole reason capture fails open, and it is conformance-tested: a daemon that + /// is slow, down, or erroring must never cost the user a download. + virtual Result on_capture_offer(const CaptureOfferParams& params) = 0; + + /// Every category with its folder and extension list. The extension calls this to populate its + /// default-category picker, which is why it is not privileged; it is read-only and exposes only + /// paths the user already configured. + virtual Result on_category_list(const CategoryListParams& params) = 0; + + /// Delete a user-created category. Built-in categories are refused with -32602. Tasks filed + /// under it are reassigned to reassignTo, or to the default category when that is null; no task + /// is ever orphaned. + virtual Result on_category_remove(const CategoryRemoveParams& params) = 0; + + /// Create or replace a category. Omit categoryId to create; supply it to replace. Changing + /// saveDir does not move existing files — the GUI asks separately and issues download.update + /// per task, so a re-point is never a surprise mass file move. + virtual Result on_category_upsert(const CategoryUpsertParams& params) = 0; + + /// Create one task. saveDir is canonicalized and checked against saveTo.allowedRoots before + /// anything is written; a path that escapes them is refused with -32011 and no file is created. + virtual Result on_download_add(const DownloadSpec& params) = 0; + + /// Create many tasks in one call: the clipboard blob, the wildcard expander, and the + /// extension's 'Download all links'. Partial success is normal and is reported per item rather + /// than failing the whole batch. + virtual Result on_download_addBatch(const DownloadAddBatchParams& params) = 0; + + /// Stop the given tasks and mark them cancelled. The .veloxpart file is kept so the user can + /// still resume from the list; download.remove is what deletes bytes. + virtual Result on_download_cancel(const DownloadCancelParams& params) = 0; + + /// Full detail for one task, including per-segment state. Backs the progress dialog. Poll it no + /// faster than the progress dialog repaints; the table must use events instead. + virtual Result on_download_get(const DownloadGetParams& params) = 0; + + /// The main table. Filtering, sorting and paging all happen in the daemon so the GUI never + /// materializes 100k rows to show 40. Called once on connect; after that the table is + /// maintained from events, never re-fetched on a progress tick. + virtual Result on_download_list(const DownloadListParams& params) = 0; + + /// Suspend transfers and flush every segment's progress to the .veloxpart.meta file, so a pause + /// is indistinguishable from a crash as far as resume is concerned. Never loses bytes already + /// written. + virtual Result on_download_pause(const DownloadPauseParams& params) = 0; + + /// Ask what is at a URL without creating a task. Populates the File Info dialog. Runs a HEAD, + /// falling back to a ranged GET when HEAD is refused, which is also how resumability is + /// established. Never blocks the RPC loop; the dialog opens immediately and fills in when this + /// lands. + virtual Result on_download_probe(const DownloadProbeParams& params) = 0; + + /// IDM's 'Refresh Download Address'. Point an existing task at a freshly-issued URL when a + /// signed link has expired, keeping every byte already on disk. The daemon re-probes and + /// compares size and validator: if they still match, the transfer resumes from where it + /// stopped; if they do not, it says so rather than silently restarting. + virtual Result on_download_refreshUrl(const DownloadRefreshUrlParams& params) = 0; + + /// Drop tasks from the list, optionally deleting the bytes on disk. Privileged: this is the + /// only method that destroys user data, and the extension is never allowed to reach it. The + /// daemon deletes the .veloxpart and .veloxpart.meta pair, and the finished file only when + /// deleteFile is true. + virtual Result on_download_remove(const DownloadRemoveParams& params) = 0; + + /// Continue paused tasks. Resumption is revalidated with If-Range against the stored ETag or + /// Last-Modified; a 200 where 206 was expected means the file changed on the server, and the + /// task moves to failed with a clear error rather than corrupting the part file. + virtual Result on_download_resume(const DownloadResumeParams& params) = 0; + + /// Begin or restart the given tasks. A task in 'queued' jumps its queue; a task already + /// downloading is a no-op reported as changed false. + virtual Result on_download_start(const DownloadStartParams& params) = 0; + + /// Change a task's mutable fields. Moving saveDir or filename moves the file on disk in the + /// same operation, which is what makes dragging a row onto a category work as one RPC. + /// Privileged: it can name a destination path. + virtual Result on_download_update(const DownloadUpdateParams& params) = 0; + + /// Turn selected crawl results into tasks. This is the only grabber call that creates + /// downloads, and it names exactly the files the user ticked — a crawl never starts a download + /// on its own. + virtual Result on_grabber_harvest(const GrabberHarvestParams& params) = 0; + + /// Start a depth-limited crawl. Nothing is downloaded by this call: it only walks pages and + /// collects candidate links, which the wizard then shows for selection. Privileged because an + /// unbounded crawl is a resource commitment the browser must not be able to make on the user's + /// behalf. + virtual Result on_grabber_start(const GrabberStartParams& params) = 0; + + /// Poll one crawl. Also delivered as event.grabber.progress; the poll exists so the wizard can + /// be reopened on a job it did not start and still catch up. + virtual Result on_grabber_status(const GrabberStatusParams& params) = 0; + + /// Current global speed limit. Privileged: changing or reading the limiter belongs to the GUI + /// and CLI; the extension shows throughput from event.speed.global instead. + virtual Result on_limiter_get(const LimiterGetParams& params) = 0; + + /// Set the global token-bucket limit. With applyToRunning true the change re-tunes transfers + /// already in flight instead of taking effect only on the next task — the Speed Limiter + /// window's 'apply now' button. + virtual Result on_limiter_set(const Limiter& params) = 0; + + /// Turn one enumerated variant into a task. The daemon fetches the segments in parallel and + /// muxes them with ffmpeg; the result is an ordinary task that appears in the list like any + /// other download. Refused with -32602 when the variant is DRM-protected. + virtual Result on_media_addVariant(const MediaAddVariantParams& params) = 0; + + /// Parse an HLS or DASH manifest in the daemon and enumerate its renditions. The extension + /// never parses a manifest — that logic lives in one language, in one place. Variants with drm + /// true are reported so the UI can grey them out; DRM-protected streams are refused, not + /// attempted. + virtual Result on_media_listVariants(const MediaListVariantsParams& params) = 0; + + /// Every queue with its run state and ordering. Not privileged: the extension's 'Add to Queue' + /// picker needs it. + virtual Result on_queue_list(const QueueListParams& params) = 0; + + /// Rewrite a queue's run order. taskIds must be a permutation of the queue's current + /// membership; anything else is -32602 rather than a partial reorder, so a stale drag from an + /// out-of-date view cannot quietly reshuffle the queue. + virtual Result on_queue_reorder(const QueueReorderParams& params) = 0; + + /// Start a queue running. The scheduler then admits up to maxConcurrent tasks from it, in + /// order, and keeps that many running until the queue drains or is stopped. + virtual Result on_queue_start(const QueueStartParams& params) = 0; + + /// Stop admitting new tasks from a queue. Tasks already running are paused when pauseRunning is + /// true, and otherwise allowed to finish — the difference between 'stop the queue' and 'stop + /// everything', which IDM conflates and users trip over. + virtual Result on_queue_stop(const QueueStopParams& params) = 0; + + /// Create or replace a queue, including its schedule and concurrency cap. Omit queueId to + /// create. taskIds in the payload is ignored — membership changes through download.update and + /// queue.reorder so that two clients editing at once cannot silently drop a task. + virtual Result on_queue_upsert(const QueueUpsertParams& params) = 0; + + /// The rules engine's table, in priority order. Privileged: these are the daemon's routing + /// policy. The extension gets its own narrowed view through capture.getRules instead. + virtual Result on_rules_list(const RulesListParams& params) = 0; + + /// Create, replace, or delete rules in one atomic write. 'upsert' carries the rules to store + /// and 'remove' the ruleIds to drop; applying both at once means a reprioritisation never + /// leaves the table in a half-valid state. + virtual Result on_rules_upsert(const RulesUpsertParams& params) = 0; + + /// The schedule for one queue, or every schedule when queueId is null. Backs the Scheduler + /// window. + virtual Result on_schedule_get(const ScheduleGetParams& params) = 0; + + /// Set or clear a queue's schedule. A null schedule clears it and leaves the queue under manual + /// control. Times are local wall-clock and are re-evaluated on a DST change rather than being + /// resolved to absolute instants at set time. + virtual Result on_schedule_set(const ScheduleSetParams& params) = 0; + + /// First call on every connection, on every transport. The daemon compares protocolVersion + /// majors and refuses a mismatch with -32001 so a stale GUI or extension fails loudly on + /// connect instead of subtly at the tenth field. On the WebSocket transport a valid token is + /// required unless the client is about to call session.pair. + virtual Result on_session_hello(const SessionHelloParams& params) = 0; + + /// WebSocket transport only. Triggers a GUI or desktop-notification prompt showing a four-digit + /// code; the user must approve before a token is issued. Failed attempts are rate-limited to + /// 5/min followed by a 60 s lockout (-32014) so a token cannot be brute-forced by another local + /// process. The daemon stores only a hash of the token. + virtual Result on_session_pair(const SessionPairParams& params) = 0; + + /// Choose which notifications this connection receives. Subscribing replaces the previous + /// selection rather than adding to it, so a client can narrow its firehose without + /// reconnecting. Nothing is delivered until this is called. + virtual Result on_session_subscribe(const SessionSubscribeParams& params) = 0; + + /// Read settings. keys null means everything. Privileged: the settings bag names local + /// filesystem paths and the allowed write roots, which the extension has no business + /// enumerating — it gets capture.getRules instead. + virtual Result on_settings_get(const SettingsGetParams& params) = 0; + + /// Write settings. Only the keys present in values change. Rejected with -32602 if a key is + /// unknown or a value fails the Settings schema, and with -32011 if a directory key names a + /// path that cannot be written. Emits event.settings.changed with exactly the keys that took + /// effect. + virtual Result on_settings_set(const SettingsSetParams& params) = 0; + +}; + +/// Parse one JSON-RPC request, route it, and return the response to write back. +/// Never throws. Returns a null json for a notification that needs no reply. +nlohmann::json dispatch(Dispatcher& handler, Transport transport, const nlohmann::json& request); + +} // namespace velox::proto diff --git a/docs/adr/0005-protocol-1.0.0-freeze.md b/docs/adr/0005-protocol-1.0.0-freeze.md new file mode 100644 index 0000000..88140d0 --- /dev/null +++ b/docs/adr/0005-protocol-1.0.0-freeze.md @@ -0,0 +1,84 @@ +# ADR 0005 — Protocol v1.0.0 freeze and the versioning rule + +**Status:** accepted · **Date:** 2026-09-09 · **Lane:** PROTO + +## Context + +M0 exists to produce one thing: an interface the CORE, DAEMON, GUI and EXT lanes can build +against in parallel without meeting. Everything after M0 is four lanes working from the +same contract and not talking to each other for weeks. The cost of getting this wrong is +not a bug; it is an M2 integration rewrite. + +The failure mode this is designed against is specific and predictable: a lane needs a field +that isn't in the contract, adds it locally "just for now", and nobody finds out until four +implementations disagree at once. + +## Decision + +`contracts/VERSION` is frozen at **1.0.0**. The surface is 38 methods, 9 events, 26 named +types and the JSON-RPC envelope, exactly as `contracts/README.md` documents it. + +Three corrections landed into 1.0.0 before it reached `main`, answering CORE's +freeze-blockers: a `TaskErrorCode` wire taxonomy, the effective-vs-requested meaning of +`TaskSummary.segments`, and the segment range convention. See ADR 0010 — including why +correcting an unpublished 1.0.0 in place is not the major bump this ADR's own rule would +otherwise demand. + +**Semantics of a change:** + +| Change | Bump | Also needs | +|---|---|---| +| New method, new event, new optional field | minor | fixtures for it | +| New enum value | minor | both generators handle it; check client `default:` arms | +| Rename, remove, retype, change a default | **major** | an ADR with a migration note | + +`session.hello` compares **majors only**. A mismatch is refused with `-32001` and a message +the GUI renders as "Velox needs updating"; a differing minor or patch is always accepted. +Failing loudly at connect is the point — the alternative is failing subtly at the tenth +field, three weeks later. + +**Process:** changes arrive as a PR touching `contracts/` alone, containing the schema edit, +the fixtures, the regenerated code and the `VERSION` bump. Lanes rebase onto it. This is the +only synchronization point in the project, so it is kept cheap and frequent rather than big +and rare. + +## What enforces this rather than hoping for it + +* Generated code is **committed**, so no lane is blocked on running Python, and a stale + regeneration is a diff in the PR rather than an invisible skew. +* `tests/conformance/check_contract.py` re-runs all four generators and fails if any + committed output differs. Hand-editing generated code cannot be merged. +* The generators refuse schema constructs they cannot lower, so an unrepresentable schema + stops the build instead of producing subtly wrong code in one language only. +* Every method must have a success fixture, checked mechanically. +* `SettingKey` and `Settings.properties` must name the same keys, checked mechanically — + the GUI cannot invent a settings key that isn't in the contract. + +## Consequences + +* Adding a field costs a round trip through PROTO. That is the price of the guarantee, and + it is deliberately much cheaper than the M2 rewrite it prevents. +* Four generators must stay in step with `schema_ir.py`. They share one IR precisely so + this is one change, not four. +* The frozen surface has known asymmetries, left in on purpose rather than invented around: + there is no `queue.remove` and no `rules.remove` (rules are deleted through + `rules.upsert`'s `remove` list, queues not at all in v1). These were not added because + `contracts/README.md` does not list them, and quietly widening the surface during the + freeze is the exact habit this ADR exists to prevent. They are minor bumps whenever a lane + actually needs them. + +## Alternatives rejected + +**Hand-written types per lane.** This is the default and it is how projects like this fail. +Four hand-maintained copies of a type diverge silently; the divergence is discovered at +integration, when all four are load-bearing. + +**Protobuf or Cap'n Proto.** Better wire types, but the extension must speak this protocol +from a WebExtension, and JSON-RPC over JSON is what a browser speaks natively. A binary +codec buys efficiency on a control plane that moves a few hundred small messages a second, +and costs a build dependency in every lane plus a much worse debugging story: `nc` and a +browser devtools console can both read this wire. + +**Not freezing, and letting the contract evolve continuously.** The whole parallel-lane plan +depends on the interface being still. An unfrozen contract means every lane rebases onto a +moving target, which is the serialized dependency M0 exists to remove. diff --git a/docs/adr/0009-generated-protocol-library.md b/docs/adr/0009-generated-protocol-library.md new file mode 100644 index 0000000..cad6039 --- /dev/null +++ b/docs/adr/0009-generated-protocol-library.md @@ -0,0 +1,51 @@ +# ADR 0009 — `libveloxproto`: generated protocol code is not part of `libveloxcore` + +**Status:** accepted · **Date:** 2026-09-09 · **Lane:** PROTO + +## Context + +Two rules in `CLAUDE.md` appear to collide, and a later agent will notice: + +* the layering rule says **`core` → no JSON, no SQL, no Qt, no RPC. Ever.** +* the PROTO brief says `gen_cpp.py` emits **`core/generated/velox_proto.{hpp,cpp}`**, with + `to_json`/`from_json` built on nlohmann. + +Read together they say core must not know about JSON, and also that a JSON serialiser goes +in `core/`. Left unresolved, someone eventually "fixes" it by moving the generated code, or +by deleting the layering rule. + +## Decision + +`core/generated/` is its own CMake target, **`libveloxproto`**, and is **not** part of +`libveloxcore`. The layering rule constrains `libveloxcore` — the engine — which continues +to know nothing about JSON, SQL, Qt or RPC. `libveloxproto` is the wire types, which are by +definition JSON, and it is linked by `veloxd`, the CLI, the GUI and the conformance runner. + +The directory is `core/generated/` because the PROTO brief and the roadmap both name that +path, and moving it would break a written interface for a cosmetic gain. + +`libveloxcore` must not link `libveloxproto`. The daemon's job is to project the engine's +state into the contract's types; if the engine ever needs to know what a `TaskSummary` is, +the layering has been violated and the fix is in the daemon, not here. + +## Consequences + +* `add_subdirectory(core)` must produce two targets. Lane PKG/QA owns the root build files; + the conformance runner's `CMakeLists.txt` shows the linkage it expects. +* A grep for `nlohmann` under `core/` is no longer automatically a review failure — but one + under `core/src/` or `core/include/` still is. That is the line, and it is worth stating + because the old grep was a nice bright one. +* The generated code deliberately does **not** emit nlohmann's ADL `from_json`, which + throws. Inbound parsing is `parse() -> std::expected`, so a malformed + frame is a value the RPC loop handles rather than an exception unwinding through the + daemon. Only the outbound direction is implicit. + +## Alternatives rejected + +**Put the generated code in `daemon/`.** It is also needed by the CLI, the GUI and the +conformance runner, and `daemon/` belongs to a different lane than the one that generates +it. A shared artifact owned by one consumer is how ownership disputes start. + +**Relax the layering rule to allow JSON in core.** The rule is the reason the engine stays +testable and the reason a GUI bug can never be an engine bug. It is worth more than the +convenience of one directory. diff --git a/docs/adr/0010-task-error-taxonomy-and-segment-ranges.md b/docs/adr/0010-task-error-taxonomy-and-segment-ranges.md new file mode 100644 index 0000000..6302514 --- /dev/null +++ b/docs/adr/0010-task-error-taxonomy-and-segment-ranges.md @@ -0,0 +1,112 @@ +# ADR 0010 — The wire failure taxonomy, and the segment range convention + +**Status:** accepted · **Date:** 2026-09-09 · **Lane:** PROTO +**Answers:** CORE's B1, B2 and B3 in `core/docs/proto-requests-m1.md` + +## Context + +The 1.0.0 freeze as first drafted had `TaskError.code` as a bare `integer` with no enum, +`TaskSummary.segments` with no stated meaning, and a `Segment` range whose description +contradicted its own bounds. CORE raised all three as freeze-blockers before building +stage 6 on top of them. All three are retypes or meaning-pins, which rule 4 makes **major** +bumps once the contract has landed. + +## Decision + +### 1. `TaskErrorCode` — a string enum, separate from `ErrorCode` + +`TaskError.code` is now a `TaskErrorCode`: a string enum whose 27 values mirror +`vdm::Error` in `core/include/vdm/util/error.hpp` one-for-one, by name and in order. +`ok` has no wire spelling, because a `TaskError` only exists when something failed. + +**These are two different code spaces and conflating them was the bug.** `ErrorCode` is +the JSON-RPC space: it says why a *call* failed. `TaskErrorCode` says why a *download* +failed. A task fails while every RPC involved succeeds — that is the normal case, not an +edge case, and the type system now says so. `TaskError`'s own description said "distinct +from the JSON-RPC Error" while typing its code as the integer that JSON-RPC uses. + +Strings rather than grouped integer ranges, which was CORE's offered fallback: + +* the mapping stays lossless without anyone maintaining a numbering scheme in two repos; +* a log line or a `nc` session reads `"server_file_changed"` instead of `407`; +* CORE cannot include a protocol header (the layering rule), so the two enums are related + only by name — which makes the name the thing worth keeping identical, and a number the + thing most likely to drift. + +DAEMON owns the projection. Because the names are identical, that projection is a +generated-looking switch with no judgement in it, and a new `vdm::Error` value that is not +on the wire is a compile-time hole rather than a silent collapse to "internal". + +`retryable` stays a per-occurrence boolean rather than a property of the code, because +CORE's own table has `probe_failed` as "maybe". `cause` carries the underlying code for +`max_retries_exhausted`, so a user is told what actually kept failing. + +### 2. `TaskSummary.segments` is the **effective** count + +It is the number of connections in use **right now**, after the per-host cap and after the +demotion to 1 for a non-resumable source. A task the user asked 16 connections for may +honestly report 1. The requested value stays in `DownloadSpec.segments` and is not echoed +back. `TaskDetail.segmentDetail` always holds exactly this many entries. + +Pinning this was the genuinely blocking half of CORE's B2: an unstated meaning is not a +free field, it is a coin flip that becomes a major bump the moment either side guesses. + +### 3. Segment ranges are **closed and inclusive**: `[startByte, endByte]` + +`endByte` is the index of the last byte, not one past it. CORE asked for half-open +`[start, end)`; PROTO chose inclusive and this ADR is the notice. + +The reason is that these two fields are copied verbatim into `Range: bytes=-`, +and RFC 9110 byte ranges are inclusive. Inclusive means zero arithmetic between the wire +and the socket. Half-open would mean a `-1` at every boundary between the contract and +every HTTP request the engine makes, which is precisely where off-by-ones live. + +The contradiction CORE would have hit is also fixed: the old description encoded an empty +segment as `endByte == startByte - 1`, which is `-1` for a segment at offset 0 — and every +download's first segment starts at 0, so the schema's `minimum: 0` rejected it. **Empty +ranges are no longer representable and are not needed.** `endByte >= startByte` always +holds; a zero-length download carries an empty `segmentDetail`; a segment that donates its +remainder to a steal keeps the bytes it already wrote. + +`tests/conformance/check_contract.py` now asserts contiguity, coverage of exactly +`[0, sizeBytes - 1]`, `downloadedBytes <= endByte - startByte + 1`, and that the entry +count matches `TaskSummary.segments`. Flipping a fixture to half-open makes it fail. + +Also settled, from B3: the field is spelled **`index`** everywhere including +`event.task.progress` (there is no `i`), and the segment state enum is +`pending | connecting | downloading | stalled | complete | failed` — `downloading`, as CORE +asked and as `TaskState` already spells it, not `receiving`. + +## Why this is not a major bump + +1.0.0 has **not landed on `main`**. `main` still carries `1.0.0-draft`; the freeze lives on +`lane/proto` and no lane has consumed it. These are corrections *to* 1.0.0 before it is +published, not changes to a released contract. Bumping to 2.0.0 for a version nobody ever +received would be ceremony, not safety. + +The rule is unchanged and starts biting the moment this lands: after that, retyping +`error.code` or re-pinning `segments` is major, with an ADR and a migration note. + +## Consequences + +* `TaskError` gains `cause`; `TaskSummary`, `DownloadSpec` and `download.update`'s patch + now state which side of the requested/effective line they sit on. +* The contract has 26 named types rather than 25. +* CORE builds stage 6 against inclusive ranges. **This is the item most likely to be got + wrong silently**, which is why it is in an ADR, in the schema description, in + `contracts/README.md`, in a fixture assertion, and in a conformance check. + +## Alternatives rejected + +**Grouped integer ranges** (CORE's fallback). Works, but every value needs a number nobody +can read, maintained in two places that cannot include each other's headers. The names are +already identical; numbering them adds a translation step whose only purpose is to go +wrong. + +**Reusing `ErrorCode` for both.** This is what the draft accidentally did. It makes +"the call failed" and "the download failed" indistinguishable at the type level, and there +is no sensible JSON-RPC code for `checksum_mismatch`. + +**Half-open ranges, as CORE asked.** Rejected for the HTTP reason above, but it was close, +and it is the convention CORE would have implemented by default — hence the loud notice +rather than a quiet schema edit. diff --git a/extension/src/shared/protocol/events.ts b/extension/src/shared/protocol/events.ts new file mode 100644 index 0000000..832a88d --- /dev/null +++ b/extension/src/shared/protocol/events.ts @@ -0,0 +1,110 @@ +// --------------------------------------------------------------------------- +// GENERATED FILE — DO NOT EDIT. +// +// Source: contracts/schema/** +// Generator: contracts/codegen/gen_ts.py +// Contract: v1.0.0 +// +// Hand-editing this file is a merge blocker. Fix the schema and regenerate: +// python3 contracts/codegen/gen_ts.py +// Only lane PROTO commits to contracts/. +// --------------------------------------------------------------------------- + + +import type { + AuthRequiredEvent, + GrabberProgressEvent, + NotifyEvent, + SettingsChangedEvent, + SpeedGlobalEvent, + TaskAddedEvent, + TaskProgressEvent, + TaskRemovedEvent, + TaskStateEvent, +} from './types.js'; + +/** Payload for each server-to-client notification, keyed by its wire name. */ +export interface EventMap { + /** + * A server asked for credentials. The task sits in retry_wait until the client supplies + * them. Credentials travel to the Secret Service, never back through this event and never + * into a log. + */ + "event.auth.required": AuthRequiredEvent; + /** + * Crawl progress for the Site Grabber wizard. done true means the file list in + * grabber.status is final. + */ + "event.grabber.progress": GrabberProgressEvent; + /** + * Something the user should see: a completion, a failure, a queue finishing. The client + * decides between a toast, a tray balloon and a sound; the daemon does not assume a GUI is + * running. + */ + "event.notify": NotifyEvent; + /** + * Settings were written by some client. Carries only the key names; a client re-reads what + * it cares about. The extension watches for capture.* here and re-fetches capture.getRules + * so its rules never lag the daemon's. + */ + "event.settings.changed": SettingsChangedEvent; + /** + * Aggregate throughput for the status bar, the tray tooltip and the extension popup. + * Emitted at 1 Hz even when nothing is active, so a client can tell 'idle' from + * 'disconnected'. + */ + "event.speed.global": SpeedGlobalEvent; + /** + * A task entered the list. summary is always present so a client can insert the row + * without a follow-up download.get. + */ + "event.task.added": TaskAddedEvent; + /** + * Batched byte counters for every active task. Emitted at no more than 4 Hz as one array, + * never one notification per task: at twenty active downloads that is four messages a + * second instead of eighty. Clients apply a row patch and repaint the touched columns; + * rebuilding a model on this event is a bug. + */ + "event.task.progress": TaskProgressEvent; + /** A task left the list. The client deletes the row; there is nothing further to fetch. */ + "event.task.removed": TaskRemovedEvent; + /** + * A task changed lifecycle state. Carries the summary so the row can be repainted in full + * without a round trip, and error whenever the new state is failed or retry_wait. + */ + "event.task.state": TaskStateEvent; +} + +export type EventName = keyof EventMap; +export type EventPayload = EventMap[E]; + +/** + * Discriminated on `method`: narrowing an incoming notification gives the + * correctly typed params with no cast at the call site. + */ +export type ServerNotification = { + [E in EventName]: { jsonrpc: '2.0'; method: E; params: EventMap[E] }; +}[EventName]; + +export interface EventMeta { + /** Upper bound on emission rate, where the contract sets one. */ + readonly maxRateHz: number | null; +} + +export const EVENTS: { readonly [E in EventName]: EventMeta } = { + "event.auth.required": { maxRateHz: null }, + "event.grabber.progress": { maxRateHz: 4 }, + "event.notify": { maxRateHz: null }, + "event.settings.changed": { maxRateHz: null }, + "event.speed.global": { maxRateHz: 1 }, + "event.task.added": { maxRateHz: null }, + "event.task.progress": { maxRateHz: 4 }, + "event.task.removed": { maxRateHz: null }, + "event.task.state": { maxRateHz: null }, +} as const; + +export const EVENT_NAMES = Object.keys(EVENTS) as EventName[]; + +export function isEventName(v: unknown): v is EventName { + return typeof v === 'string' && Object.prototype.hasOwnProperty.call(EVENTS, v); +} diff --git a/extension/src/shared/protocol/index.ts b/extension/src/shared/protocol/index.ts new file mode 100644 index 0000000..ee4957a --- /dev/null +++ b/extension/src/shared/protocol/index.ts @@ -0,0 +1,17 @@ +// --------------------------------------------------------------------------- +// GENERATED FILE — DO NOT EDIT. +// +// Source: contracts/schema/** +// Generator: contracts/codegen/gen_ts.py +// Contract: v1.0.0 +// +// Hand-editing this file is a merge blocker. Fix the schema and regenerate: +// python3 contracts/codegen/gen_ts.py +// Only lane PROTO commits to contracts/. +// --------------------------------------------------------------------------- + + +export * from './types.js'; +export * from './methods.js'; +export * from './events.js'; +export * from './validate.js'; diff --git a/extension/src/shared/protocol/methods.ts b/extension/src/shared/protocol/methods.ts new file mode 100644 index 0000000..0ac60fd --- /dev/null +++ b/extension/src/shared/protocol/methods.ts @@ -0,0 +1,395 @@ +// --------------------------------------------------------------------------- +// GENERATED FILE — DO NOT EDIT. +// +// Source: contracts/schema/** +// Generator: contracts/codegen/gen_ts.py +// Contract: v1.0.0 +// +// Hand-editing this file is a merge blocker. Fix the schema and regenerate: +// python3 contracts/codegen/gen_ts.py +// Only lane PROTO commits to contracts/. +// --------------------------------------------------------------------------- + + +import type { + BulkTaskResult, + CaptureGetRulesParams, + CaptureOfferParams, + CaptureOfferResult, + CaptureRules, + CategoryListParams, + CategoryListResult, + CategoryRemoveParams, + CategoryRemoveResult, + CategoryUpsertParams, + CategoryUpsertResult, + DownloadAddBatchParams, + DownloadAddBatchResult, + DownloadAddResult, + DownloadCancelParams, + DownloadGetParams, + DownloadListParams, + DownloadListResult, + DownloadPauseParams, + DownloadProbeParams, + DownloadProbeResult, + DownloadRefreshUrlParams, + DownloadRefreshUrlResult, + DownloadRemoveParams, + DownloadRemoveResult, + DownloadResumeParams, + DownloadSpec, + DownloadStartParams, + DownloadUpdateParams, + GrabberHarvestParams, + GrabberHarvestResult, + GrabberStartParams, + GrabberStartResult, + GrabberStatusParams, + GrabberStatusResult, + Limiter, + LimiterGetParams, + MediaAddVariantParams, + MediaAddVariantResult, + MediaListVariantsParams, + MediaListVariantsResult, + QueueListParams, + QueueListResult, + QueueReorderParams, + QueueReorderResult, + QueueStartParams, + QueueStartResult, + QueueStopParams, + QueueStopResult, + QueueUpsertParams, + QueueUpsertResult, + RulesListParams, + RulesListResult, + RulesUpsertParams, + RulesUpsertResult, + ScheduleGetParams, + ScheduleGetResult, + ScheduleSetParams, + ScheduleSetResult, + SessionHelloParams, + SessionHelloResult, + SessionPairParams, + SessionPairResult, + SessionSubscribeParams, + SessionSubscribeResult, + SettingsGetParams, + SettingsGetResult, + SettingsSetParams, + SettingsSetResult, + TaskDetail, + TaskSummary, +} from './types.js'; + +/** Params and result for every method, keyed by its wire name. */ +export interface MethodMap { + /** + * The daemon's capture policy, so the extension's shouldCapture decision cannot drift from + * the daemon's. Fetched on connect and whenever event.settings.changed names a capture.* + * key. If this call fails the extension keeps its last known rules and stays fail-open. + */ + "capture.getRules": { params: CaptureGetRulesParams; result: CaptureRules }; + /** + * Firefox offers an intercepted response to the daemon. The daemon MUST reply within 750 + * ms; the extension abandons the offer and lets Firefox download normally on timeout. This + * deadline is the whole reason capture fails open, and it is conformance-tested: a daemon + * that is slow, down, or erroring must never cost the user a download. + */ + "capture.offer": { params: CaptureOfferParams; result: CaptureOfferResult }; + /** + * Every category with its folder and extension list. The extension calls this to populate + * its default-category picker, which is why it is not privileged; it is read-only and + * exposes only paths the user already configured. + */ + "category.list": { params: CategoryListParams; result: CategoryListResult }; + /** + * Delete a user-created category. Built-in categories are refused with -32602. Tasks filed + * under it are reassigned to reassignTo, or to the default category when that is null; no + * task is ever orphaned. + */ + "category.remove": { params: CategoryRemoveParams; result: CategoryRemoveResult }; + /** + * Create or replace a category. Omit categoryId to create; supply it to replace. Changing + * saveDir does not move existing files — the GUI asks separately and issues + * download.update per task, so a re-point is never a surprise mass file move. + */ + "category.upsert": { params: CategoryUpsertParams; result: CategoryUpsertResult }; + /** + * Create one task. saveDir is canonicalized and checked against saveTo.allowedRoots before + * anything is written; a path that escapes them is refused with -32011 and no file is + * created. + */ + "download.add": { params: DownloadSpec; result: DownloadAddResult }; + /** + * Create many tasks in one call: the clipboard blob, the wildcard expander, and the + * extension's 'Download all links'. Partial success is normal and is reported per item + * rather than failing the whole batch. + */ + "download.addBatch": { params: DownloadAddBatchParams; result: DownloadAddBatchResult }; + /** + * Stop the given tasks and mark them cancelled. The .veloxpart file is kept so the user + * can still resume from the list; download.remove is what deletes bytes. + */ + "download.cancel": { params: DownloadCancelParams; result: BulkTaskResult }; + /** + * Full detail for one task, including per-segment state. Backs the progress dialog. Poll + * it no faster than the progress dialog repaints; the table must use events instead. + */ + "download.get": { params: DownloadGetParams; result: TaskDetail }; + /** + * The main table. Filtering, sorting and paging all happen in the daemon so the GUI never + * materializes 100k rows to show 40. Called once on connect; after that the table is + * maintained from events, never re-fetched on a progress tick. + */ + "download.list": { params: DownloadListParams; result: DownloadListResult }; + /** + * Suspend transfers and flush every segment's progress to the .veloxpart.meta file, so a + * pause is indistinguishable from a crash as far as resume is concerned. Never loses bytes + * already written. + */ + "download.pause": { params: DownloadPauseParams; result: BulkTaskResult }; + /** + * Ask what is at a URL without creating a task. Populates the File Info dialog. Runs a + * HEAD, falling back to a ranged GET when HEAD is refused, which is also how resumability + * is established. Never blocks the RPC loop; the dialog opens immediately and fills in + * when this lands. + */ + "download.probe": { params: DownloadProbeParams; result: DownloadProbeResult }; + /** + * IDM's 'Refresh Download Address'. Point an existing task at a freshly-issued URL when a + * signed link has expired, keeping every byte already on disk. The daemon re-probes and + * compares size and validator: if they still match, the transfer resumes from where it + * stopped; if they do not, it says so rather than silently restarting. + */ + "download.refreshUrl": { params: DownloadRefreshUrlParams; result: DownloadRefreshUrlResult }; + /** + * Drop tasks from the list, optionally deleting the bytes on disk. Privileged: this is the + * only method that destroys user data, and the extension is never allowed to reach it. The + * daemon deletes the .veloxpart and .veloxpart.meta pair, and the finished file only when + * deleteFile is true. + */ + "download.remove": { params: DownloadRemoveParams; result: DownloadRemoveResult }; + /** + * Continue paused tasks. Resumption is revalidated with If-Range against the stored ETag + * or Last-Modified; a 200 where 206 was expected means the file changed on the server, and + * the task moves to failed with a clear error rather than corrupting the part file. + */ + "download.resume": { params: DownloadResumeParams; result: BulkTaskResult }; + /** + * Begin or restart the given tasks. A task in 'queued' jumps its queue; a task already + * downloading is a no-op reported as changed false. + */ + "download.start": { params: DownloadStartParams; result: BulkTaskResult }; + /** + * Change a task's mutable fields. Moving saveDir or filename moves the file on disk in the + * same operation, which is what makes dragging a row onto a category work as one RPC. + * Privileged: it can name a destination path. + */ + "download.update": { params: DownloadUpdateParams; result: TaskSummary }; + /** + * Turn selected crawl results into tasks. This is the only grabber call that creates + * downloads, and it names exactly the files the user ticked — a crawl never starts a + * download on its own. + */ + "grabber.harvest": { params: GrabberHarvestParams; result: GrabberHarvestResult }; + /** + * Start a depth-limited crawl. Nothing is downloaded by this call: it only walks pages and + * collects candidate links, which the wizard then shows for selection. Privileged because + * an unbounded crawl is a resource commitment the browser must not be able to make on the + * user's behalf. + */ + "grabber.start": { params: GrabberStartParams; result: GrabberStartResult }; + /** + * Poll one crawl. Also delivered as event.grabber.progress; the poll exists so the wizard + * can be reopened on a job it did not start and still catch up. + */ + "grabber.status": { params: GrabberStatusParams; result: GrabberStatusResult }; + /** + * Current global speed limit. Privileged: changing or reading the limiter belongs to the + * GUI and CLI; the extension shows throughput from event.speed.global instead. + */ + "limiter.get": { params: LimiterGetParams; result: Limiter }; + /** + * Set the global token-bucket limit. With applyToRunning true the change re-tunes + * transfers already in flight instead of taking effect only on the next task — the Speed + * Limiter window's 'apply now' button. + */ + "limiter.set": { params: Limiter; result: Limiter }; + /** + * Turn one enumerated variant into a task. The daemon fetches the segments in parallel and + * muxes them with ffmpeg; the result is an ordinary task that appears in the list like any + * other download. Refused with -32602 when the variant is DRM-protected. + */ + "media.addVariant": { params: MediaAddVariantParams; result: MediaAddVariantResult }; + /** + * Parse an HLS or DASH manifest in the daemon and enumerate its renditions. The extension + * never parses a manifest — that logic lives in one language, in one place. Variants with + * drm true are reported so the UI can grey them out; DRM-protected streams are refused, + * not attempted. + */ + "media.listVariants": { params: MediaListVariantsParams; result: MediaListVariantsResult }; + /** + * Every queue with its run state and ordering. Not privileged: the extension's 'Add to + * Queue' picker needs it. + */ + "queue.list": { params: QueueListParams; result: QueueListResult }; + /** + * Rewrite a queue's run order. taskIds must be a permutation of the queue's current + * membership; anything else is -32602 rather than a partial reorder, so a stale drag from + * an out-of-date view cannot quietly reshuffle the queue. + */ + "queue.reorder": { params: QueueReorderParams; result: QueueReorderResult }; + /** + * Start a queue running. The scheduler then admits up to maxConcurrent tasks from it, in + * order, and keeps that many running until the queue drains or is stopped. + */ + "queue.start": { params: QueueStartParams; result: QueueStartResult }; + /** + * Stop admitting new tasks from a queue. Tasks already running are paused when + * pauseRunning is true, and otherwise allowed to finish — the difference between 'stop the + * queue' and 'stop everything', which IDM conflates and users trip over. + */ + "queue.stop": { params: QueueStopParams; result: QueueStopResult }; + /** + * Create or replace a queue, including its schedule and concurrency cap. Omit queueId to + * create. taskIds in the payload is ignored — membership changes through download.update + * and queue.reorder so that two clients editing at once cannot silently drop a task. + */ + "queue.upsert": { params: QueueUpsertParams; result: QueueUpsertResult }; + /** + * The rules engine's table, in priority order. Privileged: these are the daemon's routing + * policy. The extension gets its own narrowed view through capture.getRules instead. + */ + "rules.list": { params: RulesListParams; result: RulesListResult }; + /** + * Create, replace, or delete rules in one atomic write. 'upsert' carries the rules to + * store and 'remove' the ruleIds to drop; applying both at once means a reprioritisation + * never leaves the table in a half-valid state. + */ + "rules.upsert": { params: RulesUpsertParams; result: RulesUpsertResult }; + /** + * The schedule for one queue, or every schedule when queueId is null. Backs the Scheduler + * window. + */ + "schedule.get": { params: ScheduleGetParams; result: ScheduleGetResult }; + /** + * Set or clear a queue's schedule. A null schedule clears it and leaves the queue under + * manual control. Times are local wall-clock and are re-evaluated on a DST change rather + * than being resolved to absolute instants at set time. + */ + "schedule.set": { params: ScheduleSetParams; result: ScheduleSetResult }; + /** + * First call on every connection, on every transport. The daemon compares protocolVersion + * majors and refuses a mismatch with -32001 so a stale GUI or extension fails loudly on + * connect instead of subtly at the tenth field. On the WebSocket transport a valid token + * is required unless the client is about to call session.pair. + */ + "session.hello": { params: SessionHelloParams; result: SessionHelloResult }; + /** + * WebSocket transport only. Triggers a GUI or desktop-notification prompt showing a + * four-digit code; the user must approve before a token is issued. Failed attempts are + * rate-limited to 5/min followed by a 60 s lockout (-32014) so a token cannot be + * brute-forced by another local process. The daemon stores only a hash of the token. + */ + "session.pair": { params: SessionPairParams; result: SessionPairResult }; + /** + * Choose which notifications this connection receives. Subscribing replaces the previous + * selection rather than adding to it, so a client can narrow its firehose without + * reconnecting. Nothing is delivered until this is called. + */ + "session.subscribe": { params: SessionSubscribeParams; result: SessionSubscribeResult }; + /** + * Read settings. keys null means everything. Privileged: the settings bag names local + * filesystem paths and the allowed write roots, which the extension has no business + * enumerating — it gets capture.getRules instead. + */ + "settings.get": { params: SettingsGetParams; result: SettingsGetResult }; + /** + * Write settings. Only the keys present in values change. Rejected with -32602 if a key is + * unknown or a value fails the Settings schema, and with -32011 if a directory key names a + * path that cannot be written. Emits event.settings.changed with exactly the keys that + * took effect. + */ + "settings.set": { params: SettingsSetParams; result: SettingsSetResult }; +} + +export type MethodName = keyof MethodMap; +export type Params = MethodMap[M]['params']; +export type Result = MethodMap[M]['result']; + +export type Transport = 'uds' | 'ws'; + +export interface MethodMeta { + /** Refused over the WebSocket transport with -32003. */ + readonly privileged: boolean; + readonly transports: readonly Transport[]; + /** How long a client waits before giving up on this call. */ + readonly deadlineMs: number; + /** Error codes this method is documented to return. */ + readonly errors: readonly number[]; +} + +export const METHODS: { readonly [M in MethodName]: MethodMeta } = { + "capture.getRules": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 2000, errors: [] }, + "capture.offer": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 750, errors: [-32011] }, + "category.list": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 2000, errors: [] }, + "category.remove": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003, -32602] }, + "category.upsert": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003, -32011] }, + "download.add": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [-32011, -32012, -32013] }, + "download.addBatch": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 30000, errors: [-32011, -32012] }, + "download.cancel": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [-32010] }, + "download.get": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [-32010] }, + "download.list": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [] }, + "download.pause": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [-32010] }, + "download.probe": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 30000, errors: [-32013] }, + "download.refreshUrl": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 30000, errors: [-32010, -32013] }, + "download.remove": { privileged: true, transports: ['uds'], deadlineMs: 10000, errors: [-32003, -32010] }, + "download.resume": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [-32010] }, + "download.start": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 5000, errors: [-32010] }, + "download.update": { privileged: true, transports: ['uds'], deadlineMs: 30000, errors: [-32003, -32010, -32011] }, + "grabber.harvest": { privileged: true, transports: ['uds'], deadlineMs: 30000, errors: [-32003, -32011] }, + "grabber.start": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003] }, + "grabber.status": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003, -32602] }, + "limiter.get": { privileged: true, transports: ['uds'], deadlineMs: 2000, errors: [-32003] }, + "limiter.set": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003, -32602] }, + "media.addVariant": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 30000, errors: [-32011, -32602, -32013] }, + "media.listVariants": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 30000, errors: [-32013] }, + "queue.list": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 2000, errors: [] }, + "queue.reorder": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003, -32602] }, + "queue.start": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003] }, + "queue.stop": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003] }, + "queue.upsert": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003] }, + "rules.list": { privileged: true, transports: ['uds'], deadlineMs: 2000, errors: [-32003] }, + "rules.upsert": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003] }, + "schedule.get": { privileged: true, transports: ['uds'], deadlineMs: 2000, errors: [-32003] }, + "schedule.set": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003, -32602] }, + "session.hello": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 2000, errors: [-32001, -32002] }, + "session.pair": { privileged: false, transports: ['ws'], deadlineMs: 120000, errors: [-32003, -32014] }, + "session.subscribe": { privileged: false, transports: ['uds', 'ws'], deadlineMs: 2000, errors: [] }, + "settings.get": { privileged: true, transports: ['uds'], deadlineMs: 2000, errors: [-32003] }, + "settings.set": { privileged: true, transports: ['uds'], deadlineMs: 5000, errors: [-32003, -32602, -32011] }, +} as const; + +export const METHOD_NAMES = Object.keys(METHODS) as MethodName[]; + +export function isMethodName(v: unknown): v is MethodName { + return typeof v === 'string' && Object.prototype.hasOwnProperty.call(METHODS, v); +} + +/** Methods this transport may call. The extension checks before sending so a + * privileged call fails in one place rather than as a puzzling -32003. */ +export function isAllowedOn(method: MethodName, transport: Transport): boolean { + return (METHODS[method].transports as readonly string[]).includes(transport); +} + +/** + * The typed client surface. Every transport implements this; the generated + * signature is what stops a caller passing download.add's params to download.get. + */ +export interface VeloxClient { + call(method: M, params: Params): Promise>; +} diff --git a/extension/src/shared/protocol/types.ts b/extension/src/shared/protocol/types.ts new file mode 100644 index 0000000..1530f9f --- /dev/null +++ b/extension/src/shared/protocol/types.ts @@ -0,0 +1,1418 @@ +// --------------------------------------------------------------------------- +// GENERATED FILE — DO NOT EDIT. +// +// Source: contracts/schema/** +// Generator: contracts/codegen/gen_ts.py +// Contract: v1.0.0 +// +// Hand-editing this file is a merge blocker. Fix the schema and regenerate: +// python3 contracts/codegen/gen_ts.py +// Only lane PROTO commits to contracts/. +// --------------------------------------------------------------------------- + + +export const PROTOCOL_VERSION = "1.0.0"; + +/** + * Every error code the daemon may return. Adding one is a minor bump; changing the meaning + * of one is a major bump. + */ +export const ErrorCode = { + /** Malformed JSON on the wire. */ + ParseError: -32700, + /** Not a valid JSON-RPC 2.0 request object. */ + InvalidRequest: -32600, + /** Unknown method name. */ + MethodNotFound: -32601, + /** Params failed schema validation. */ + InvalidParams: -32602, + /** Unhandled daemon-side failure. */ + InternalError: -32603, + /** Protocol major version mismatch. GUI renders this as 'Velox needs updating'. */ + VersionMismatch: -32001, + /** Missing or invalid token on the WebSocket transport. */ + NotPaired: -32002, + /** Method is privileged and was called over a transport that may not use it. */ + TransportForbidden: -32003, + /** No task with that id. */ + TaskNotFound: -32010, + /** Destination is outside the allowed roots, or is not writable. data.path is set. */ + InvalidPath: -32011, + /** Not enough free space to preallocate. */ + DiskFull: -32012, + /** Could not probe the URL. data.httpStatus is set when there was an HTTP response. */ + ProbeFailed: -32013, + /** Pairing brute-force lockout. data.retryAfterSec is set. */ + RateLimited: -32014, +} as const; +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; + +export interface BulkTaskResultFailedItem { + taskId: string; + code: ErrorCode; + message: string; +} + +/** + * Lifecycle of one download. The daemon is the only writer; clients render it and nothing + * more. Terminal states are complete, failed and cancelled. + */ +export type TaskState = "new" | "probing" | "queued" | "connecting" | "downloading" | "paused" | "retry_wait" | "assembling" | "verifying" | "complete" | "failed" | "cancelled"; +export const TASK_STATE_VALUES = [ + "new", + "probing", + "queued", + "connecting", + "downloading", + "paused", + "retry_wait", + "assembling", + "verifying", + "complete", + "failed", + "cancelled", +] as const satisfies readonly TaskState[]; + +export interface BulkTaskResultUpdatedItem { + taskId: string; + state: TaskState; + changed: boolean; +} + +/** + * Result of a state transition applied to many tasks. A bulk call never fails as a whole + * because one id was bad: the ids that moved come back in 'updated' and the rest are + * explained in 'failed'. This is what lets the GUI's toolbar act on a multi-selection + * without pre-validating it. + */ +export interface BulkTaskResult { + /** + * One entry per task that actually changed. A task already in the target state is reported + * here with changed false rather than as a failure. + */ + updated: BulkTaskResultUpdatedItem[]; + failed: BulkTaskResultFailedItem[]; +} + +/** + * The modifier key a user holds to make one click bypass capture and let Firefox download + * normally. Shared by Settings and CaptureRules so the daemon's setting and the + * extension's mirror of it are literally the same type. + */ +export type BypassModifier = "alt" | "ctrl" | "shift" | "none"; +export const BYPASS_MODIFIER_VALUES = [ + "alt", + "ctrl", + "shift", + "none", +] as const satisfies readonly BypassModifier[]; + +/** + * The daemon's capture policy, mirrored into the extension so the two can never disagree + * about what should be intercepted. The extension refreshes this on connect and on + * event.settings.changed. + */ +export interface CaptureRules { + enabled: boolean; + monitoredExtensions: string[]; + monitoredMimeTypes: string[]; + minSizeBytes: number; + excludedHosts: string[]; + bypassModifier?: BypassModifier; + /** Bumped on every change. The extension re-fetches when it sees a higher value. */ + rulesVersion: number; +} + +/** + * A destination folder plus the extensions that route to it. The extension mirrors the + * extension lists so its capture decision agrees with the daemon's. + */ +export interface Category { + categoryId: string; + name: string; + saveDir: string; + /** Without the leading dot, lowercase. */ + extensions: string[]; + mimeTypes?: string[]; + /** + * Compressed, Documents, Music, Programs, Video. Cannot be removed; can be renamed and + * re-pointed. + */ + builtin: boolean; + sortOrder?: number; +} + +export type ChecksumAlgorithm = "md5" | "sha1" | "sha256" | "sha512"; +export const CHECKSUM_ALGORITHM_VALUES = [ + "md5", + "sha1", + "sha256", + "sha512", +] as const satisfies readonly ChecksumAlgorithm[]; + +/** + * Optional integrity check, verified during the verifying state. A mismatch moves the task + * to failed and never overwrites a good file. + */ +export interface Checksum { + algorithm: ChecksumAlgorithm; + value: string; +} + +/** One cookie the daemon replays so an authenticated download works outside the browser. */ +export interface Cookie { + name: string; + value: string; + domain?: string; + path?: string; + secure?: boolean; + httpOnly?: boolean; +} + +/** + * HTTP request headers, verbatim as the browser would have sent them. Needed for + * signed-URL and referrer-gated CDNs. + */ +export type Headers = Record; + +/** + * What the daemon does with a task the moment it is added. 'later' is the File Info + * dialog's Download Later button and lands the task in paused. + */ +export type StartMode = "now" | "later" | "queue"; +export const START_MODE_VALUES = [ + "now", + "later", + "queue", +] as const satisfies readonly StartMode[]; + +/** + * Everything needed to create one task. Shared by download.add and each item of + * download.addBatch, so the two can never drift apart. + */ +export interface DownloadSpec { + url: string; + headers?: Headers | null; + cookies?: Cookie[] | null; + referrer?: string | null; + userAgent?: string | null; + /** Overrides the name derived from Content-Disposition or the URL. */ + filename?: string | null; + /** + * Canonicalized and checked against the allowed roots before any write. -32011 if it + * fails. + */ + saveDir?: string | null; + /** null means the rules engine picks one. */ + categoryId?: string | null; + /** Required when startMode is 'queue'. */ + queueId?: string | null; + /** + * The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to + * the per-host cap, and to 1 when the source turns out not to be resumable. What is + * actually in use comes back as TaskSummary.segments. null means use + * connection.maxSegmentsPerDownload. + */ + segments?: number | null; + bufferBytes?: number | null; + startMode?: StartMode; + description?: string | null; + checksum?: Checksum | null; +} + +/** + * One candidate found by the Site Grabber crawl. Nothing is downloaded until + * grabber.harvest selects it. + */ +export interface GrabberFile { + fileId: string; + url: string; + filename?: string | null; + /** From a HEAD, when the server answered one. */ + sizeBytes?: number | null; + contentType?: string | null; + depth: number; + /** The page this link was found on. */ + foundOn?: string | null; +} + +/** Global token-bucket speed limit. Applies across every active task, not per task. */ +export interface Limiter { + enabled: boolean; + /** + * Bytes per second. 0 with enabled true means 'stop everything', which the GUI must not + * offer. + */ + globalBps: number; + /** Re-tune already-running transfers instead of waiting for the next task. */ + applyToRunning?: boolean; +} + +export type MediaVariantContainer = "ts" | "mp4" | "webm" | "mkv"; +export const MEDIA_VARIANT_CONTAINER_VALUES = [ + "ts", + "mp4", + "webm", + "mkv", +] as const satisfies readonly MediaVariantContainer[]; + +export type MediaVariantKind = "video" | "audio" | "muxed" | "subtitle"; +export const MEDIA_VARIANT_KIND_VALUES = [ + "video", + "audio", + "muxed", + "subtitle", +] as const satisfies readonly MediaVariantKind[]; + +/** + * One quality rendition from an HLS or DASH manifest. The daemon parses the manifest; the + * extension only renders this list. DRM-protected variants are reported with drm true and + * must be shown greyed out rather than failing later. + */ +export interface MediaVariant { + variantId: string; + kind: MediaVariantKind; + resolution?: string | null; + bitrateBps?: number | null; + codec?: string | null; + container?: MediaVariantContainer | null; + frameRate?: number | null; + language?: string | null; + /** bitrate x duration. Never exact — the GUI must label it as approximate. */ + sizeEstimate?: number | null; + /** Widevine/EME detected. Explicitly out of scope; refuse rather than fail mysteriously. */ + drm: boolean; +} + +/** shutdown goes through org.freedesktop.login1 and must be confirmed by the user. */ +export type QueueOnComplete = "nothing" | "exit" | "shutdown" | "hangup"; +export const QUEUE_ON_COMPLETE_VALUES = [ + "nothing", + "exit", + "shutdown", + "hangup", +] as const satisfies readonly QueueOnComplete[]; + +export type QueueState = "running" | "stopped"; +export const QUEUE_STATE_VALUES = [ + "running", + "stopped", +] as const satisfies readonly QueueState[]; + +export type ScheduleMode = "once" | "periodic"; +export const SCHEDULE_MODE_VALUES = [ + "once", + "periodic", +] as const satisfies readonly ScheduleMode[]; + +/** + * When a queue may run. Times are local wall-clock in HH:MM; the daemon re-evaluates them + * on a DST change rather than caching absolute instants. + */ +export interface Schedule { + enabled: boolean; + mode: ScheduleMode; + startTime?: string | null; + /** null means run until the queue drains. */ + stopTime?: string | null; + /** 0 = Sunday. Ignored when mode is 'once'. */ + daysOfWeek?: number[]; + /** Set only when mode is 'once'. */ + onceDate?: string | null; +} + +/** An ordered run of tasks with its own concurrency cap and optional schedule. */ +export interface Queue { + queueId: string; + name: string; + state: QueueState; + maxConcurrent: number; + /** In run order. queue.reorder rewrites this. */ + taskIds?: string[]; + schedule?: Schedule | null; + /** shutdown goes through org.freedesktop.login1 and must be confirmed by the user. */ + onComplete?: QueueOnComplete; +} + +/** Lets a rule veto capture for a host without touching the exclusion list. */ +export type RuleActionCapture = "take" | "ignore"; +export const RULE_ACTION_CAPTURE_VALUES = [ + "take", + "ignore", +] as const satisfies readonly RuleActionCapture[]; + +/** What to do with a matching download. */ +export interface RuleAction { + categoryId?: string | null; + saveDir?: string | null; + queueId?: string | null; + segments?: number | null; + startMode?: StartMode | null; + /** Lets a rule veto capture for a host without touching the exclusion list. */ + capture?: RuleActionCapture | null; +} + +/** All present clauses must match. An absent clause is not a constraint. */ +export interface RuleMatch { + extensions?: string[] | null; + mimeTypes?: string[] | null; + /** Glob against the effective URL's host, e.g. *.example.com */ + hostPattern?: string | null; + /** Glob against the whole effective URL. */ + urlPattern?: string | null; + minSizeBytes?: number | null; + maxSizeBytes?: number | null; +} + +/** + * One row of the rules engine: match on extension, MIME, host or size, then route. First + * match by priority wins; no rule matching means the default category. + */ +export interface Rule { + ruleId: string; + name?: string | null; + enabled: boolean; + /** Lower runs first. */ + priority: number; + /** All present clauses must match. An absent clause is not a constraint. */ + match: RuleMatch; + /** What to do with a matching download. */ + action: RuleAction; +} + +/** + * 'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has + * been planned but not yet dialled. + */ +export type SegmentState = "pending" | "connecting" | "downloading" | "stalled" | "complete" | "failed"; +export const SEGMENT_STATE_VALUES = [ + "pending", + "connecting", + "downloading", + "stalled", + "complete", + "failed", +] as const satisfies readonly SegmentState[]; + +/** + * One byte range being fetched by one connection. This is the deepest the contract ever + * exposes the engine: the GUI draws a bar per segment and is never told what a segment + * steal is. RANGE CONVENTION — READ THIS BEFORE IMPLEMENTING. The range is CLOSED and + * INCLUSIVE on both ends: [startByte, endByte]. The segment covers endByte - startByte + 1 + * bytes, and endByte is the index of the LAST byte in the range, not one past it. This + * deliberately matches the HTTP Range header the engine actually sends ('Range: + * bytes=-' is a byte-for-byte copy of these two fields, and RFC 9110 + * ranges are inclusive), so no arithmetic happens between the wire and the socket and + * there is nowhere for an off-by-one to hide. CORE asked for half-open [start, end); PROTO + * chose inclusive for that reason and this note exists so nobody discovers the difference + * at integration. A segment always covers at least one byte: endByte >= startByte always + * holds. An empty range is not representable and is not needed — a zero-length download + * carries an empty segmentDetail array, and a segment that has donated its remainder to a + * steal keeps the bytes it already wrote. + */ +export interface Segment { + /** + * Position in TaskDetail.segmentDetail. Spelled 'index' here and in event.task.progress; + * there is no 'i' spelling anywhere in the contract. + */ + index: number; + /** Absolute offset of the first byte of the range. Inclusive. */ + startByte: number; + /** + * Absolute offset of the LAST byte of the range. Inclusive — this is not one-past-the-end. + * Always >= startByte. + */ + endByte: number; + /** Bytes written for this range so far, out of endByte - startByte + 1. */ + downloadedBytes: number; + speedBps?: number; + /** + * 'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has + * been planned but not yet dialled. + */ + state: SegmentState; + /** The status this segment's request got. 206 on a healthy ranged fetch. */ + httpStatus?: number | null; +} + +/** + * Every settings key that exists. The Options dialog maps 1:1 onto this list and the GUI + * must not invent a key that is not here. Kept in lockstep with Settings.schema.json by a + * conformance check. + */ +export type SettingKey = "general.launchOnLogin" | "general.minimizeToTray" | "general.showDropTarget" | "general.confirmOnExit" | "general.language" | "general.checkForUpdates" | "capture.enabled" | "capture.monitoredExtensions" | "capture.monitoredMimeTypes" | "capture.minSizeBytes" | "capture.excludedHosts" | "capture.bypassModifier" | "capture.autoStartTypes" | "saveTo.defaultDir" | "saveTo.tempDir" | "saveTo.allowedRoots" | "saveTo.fileExistsPolicy" | "saveTo.createSubfolderPerSite" | "connection.preset" | "connection.maxSegmentsPerDownload" | "connection.bufferBytes" | "connection.maxConcurrentDownloads" | "connection.timeoutSec" | "connection.maxRetries" | "connection.retryBackoffSec" | "downloads.speedLimitBps" | "downloads.speedLimitEnabled" | "downloads.virusScanCommand" | "downloads.postDownloadCommand" | "downloads.duplicatePolicy" | "downloads.verifyChecksums" | "proxy.mode" | "proxy.host" | "proxy.port" | "proxy.username" | "proxy.bypassHosts" | "proxy.pacUrl" | "sounds.enabled" | "sounds.onComplete" | "sounds.onQueueComplete" | "sounds.onError"; +export const SETTING_KEY_VALUES = [ + "general.launchOnLogin", + "general.minimizeToTray", + "general.showDropTarget", + "general.confirmOnExit", + "general.language", + "general.checkForUpdates", + "capture.enabled", + "capture.monitoredExtensions", + "capture.monitoredMimeTypes", + "capture.minSizeBytes", + "capture.excludedHosts", + "capture.bypassModifier", + "capture.autoStartTypes", + "saveTo.defaultDir", + "saveTo.tempDir", + "saveTo.allowedRoots", + "saveTo.fileExistsPolicy", + "saveTo.createSubfolderPerSite", + "connection.preset", + "connection.maxSegmentsPerDownload", + "connection.bufferBytes", + "connection.maxConcurrentDownloads", + "connection.timeoutSec", + "connection.maxRetries", + "connection.retryBackoffSec", + "downloads.speedLimitBps", + "downloads.speedLimitEnabled", + "downloads.virusScanCommand", + "downloads.postDownloadCommand", + "downloads.duplicatePolicy", + "downloads.verifyChecksums", + "proxy.mode", + "proxy.host", + "proxy.port", + "proxy.username", + "proxy.bypassHosts", + "proxy.pacUrl", + "sounds.enabled", + "sounds.onComplete", + "sounds.onQueueComplete", + "sounds.onError", +] as const satisfies readonly SettingKey[]; + +export type SettingsConnectionPreset = "auto" | "lan" | "broadband" | "slow"; +export const SETTINGS_CONNECTION_PRESET_VALUES = [ + "auto", + "lan", + "broadband", + "slow", +] as const satisfies readonly SettingsConnectionPreset[]; + +export type SettingsDownloadsDuplicatePolicy = "ask" | "skip" | "rename" | "redownload"; +export const SETTINGS_DOWNLOADS_DUPLICATE_POLICY_VALUES = [ + "ask", + "skip", + "rename", + "redownload", +] as const satisfies readonly SettingsDownloadsDuplicatePolicy[]; + +export type SettingsProxyMode = "system" | "none" | "http" | "https" | "socks5" | "pac"; +export const SETTINGS_PROXY_MODE_VALUES = [ + "system", + "none", + "http", + "https", + "socks5", + "pac", +] as const satisfies readonly SettingsProxyMode[]; + +export type SettingsSaveToFileExistsPolicy = "ask" | "rename" | "overwrite" | "resume"; +export const SETTINGS_SAVE_TO_FILE_EXISTS_POLICY_VALUES = [ + "ask", + "rename", + "overwrite", + "resume", +] as const satisfies readonly SettingsSaveToFileExistsPolicy[]; + +/** + * A sparse bag of settings. Every property is optional because settings.get returns only + * the keys that were asked for and settings.set carries only the keys that changed. + * Property names must match SettingKey exactly. NOTE: no password lives here — proxy and + * site-login credentials go to the Secret Service, never to SQLite and never over the + * wire. + */ +export interface Settings { + "general.launchOnLogin"?: boolean; + "general.minimizeToTray"?: boolean; + "general.showDropTarget"?: boolean; + "general.confirmOnExit"?: boolean; + /** BCP 47, or 'system'. */ + "general.language"?: string; + "general.checkForUpdates"?: boolean; + "capture.enabled"?: boolean; + "capture.monitoredExtensions"?: string[]; + "capture.monitoredMimeTypes"?: string[]; + "capture.minSizeBytes"?: number; + "capture.excludedHosts"?: string[]; + "capture.bypassModifier"?: BypassModifier; + /** Extensions that skip the File Info dialog and start immediately. */ + "capture.autoStartTypes"?: string[]; + "saveTo.defaultDir"?: string; + "saveTo.tempDir"?: string; + /** + * Every write target is canonicalized and must resolve inside one of these. Read-only over + * the WebSocket transport. + */ + "saveTo.allowedRoots"?: string[]; + "saveTo.fileExistsPolicy"?: SettingsSaveToFileExistsPolicy; + "saveTo.createSubfolderPerSite"?: boolean; + "connection.preset"?: SettingsConnectionPreset; + "connection.maxSegmentsPerDownload"?: number; + "connection.bufferBytes"?: number; + "connection.maxConcurrentDownloads"?: number; + "connection.timeoutSec"?: number; + "connection.maxRetries"?: number; + "connection.retryBackoffSec"?: number; + "downloads.speedLimitBps"?: number; + "downloads.speedLimitEnabled"?: boolean; + "downloads.virusScanCommand"?: string; + "downloads.postDownloadCommand"?: string; + "downloads.duplicatePolicy"?: SettingsDownloadsDuplicatePolicy; + "downloads.verifyChecksums"?: boolean; + "proxy.mode"?: SettingsProxyMode; + "proxy.host"?: string; + "proxy.port"?: number; + "proxy.username"?: string; + "proxy.bypassHosts"?: string[]; + "proxy.pacUrl"?: string; + "sounds.enabled"?: boolean; + "sounds.onComplete"?: string; + "sounds.onQueueComplete"?: string; + "sounds.onError"?: string; +} + +/** + * Why a download failed. This is the WIRE failure taxonomy and it is deliberately NOT the + * JSON-RPC ErrorCode space: ErrorCode says why a *call* failed, TaskErrorCode says why a + * *download* failed. A task can fail while every RPC involved succeeded. The values mirror + * vdm::Error in core/include/vdm/util/error.hpp one-for-one, by name, so DAEMON's + * projection from the engine taxonomy onto the wire is lossless and the GUI can tell 'the + * file on the server changed' from 'the checksum did not match'. CORE's 'ok' has no wire + * spelling: a TaskError only exists when there is a failure. Adding a value here is a + * minor bump; renaming or removing one is major, and would desynchronise the engine. + */ +export type TaskErrorCode = "canceled" | "resolve_failed" | "connect_failed" | "tls_failed" | "connection_reset" | "timeout" | "too_many_redirects" | "http_client_error" | "http_server_error" | "auth_required" | "forbidden" | "not_found" | "range_not_satisfiable" | "gone" | "server_file_changed" | "content_length_mismatch" | "checksum_mismatch" | "disk_full" | "io_error" | "path_rejected" | "permission_denied" | "meta_corrupt" | "meta_version_unsupported" | "probe_failed" | "unsupported_url_scheme" | "max_retries_exhausted" | "internal"; +export const TASK_ERROR_CODE_VALUES = [ + "canceled", + "resolve_failed", + "connect_failed", + "tls_failed", + "connection_reset", + "timeout", + "too_many_redirects", + "http_client_error", + "http_server_error", + "auth_required", + "forbidden", + "not_found", + "range_not_satisfiable", + "gone", + "server_file_changed", + "content_length_mismatch", + "checksum_mismatch", + "disk_full", + "io_error", + "path_rejected", + "permission_denied", + "meta_corrupt", + "meta_version_unsupported", + "probe_failed", + "unsupported_url_scheme", + "max_retries_exhausted", + "internal", +] as const satisfies readonly TaskErrorCode[]; + +/** + * Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which + * describes a failed call rather than a failed download — the two live in different code + * spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer. + */ +export interface TaskError { + code: TaskErrorCode; + /** + * Human-readable, safe to show a user. Never carries a credential, a token or a full local + * path outside the download roots. + */ + message: string; + /** Set for the codes listed in TaskErrorCode's x-carriesHttpStatus, and null otherwise. */ + httpStatus?: number | null; + /** + * Whether the scheduler will pick this task up again on its own. Carried per-occurrence + * rather than derived from the code, because 'probe_failed' is retryable or not depending + * on what the probe hit. + */ + retryable: boolean; + /** + * The underlying failure, for codes that wrap one. max_retries_exhausted sets it to + * whatever the last attempt actually failed with, so a user learns the reason rather than + * just that Velox gave up. + */ + cause?: TaskErrorCode | null; + /** How many attempts have been made so far. */ + attempt?: number | null; + nextRetryAt?: string | null; +} + +/** + * One row of the main download list. Everything the GUI table needs, and nothing more. + * TaskDetail is the same shape plus the fields only the progress dialog and File Info + * dialog need. + */ +export interface TaskSummary { + taskId: string; + filename: string; + /** Absolute, canonicalized, inside an allowed root. */ + saveDir: string; + /** The URL as the user or the extension supplied it. */ + url: string; + /** After redirects. null until the first probe succeeds. */ + effectiveUrl?: string | null; + /** null when the server did not report a length. */ + sizeBytes?: number | null; + downloadedBytes: number; + state: TaskState; + speedBps: number; + /** null when the size or the speed is unknown. */ + etaSeconds?: number | null; + resumable: boolean; + /** + * The EFFECTIVE connection count in use right now — not the number that was requested. It + * is what remains after the per-host connection cap has been applied and after the + * demotion to 1 for a non-resumable source, so a task the user asked for 16 connections on + * legitimately reports 4, or 1. The GUI displays this value and must not assume it equals + * what download.add asked for. The requested value lives in DownloadSpec.segments and is + * not echoed back on this type. TaskDetail.segmentDetail always has exactly this many + * entries. + */ + segments: number; + categoryId?: string | null; + queueId?: string | null; + /** The Q column. */ + queuePosition?: number | null; + description?: string | null; + createdAt: string; + lastTryAt?: string | null; + completedAt?: string | null; + error?: TaskError | null; +} + +/** + * Everything TaskSummary carries, plus what only the progress dialog and the File Info + * dialog need. Returned by download.get; never sent in a list or an event, because it is + * expensive to build. + */ +export interface TaskDetail { + summary: TaskSummary; + /** + * Exactly TaskSummary.segments entries, in index order, covering [0, sizeBytes) with no + * gaps and no overlaps. Empty for a zero-length download, and empty before the task has + * been segmented. + */ + segmentDetail: Segment[]; + headers?: Headers | null; + referrer?: string | null; + userAgent?: string | null; + mime?: string | null; + bufferBytes?: number | null; + /** Absolute path of the .veloxpart file while the task is unfinished. */ + partPath?: string | null; + checksum?: Checksum | null; + /** null until the verifying state has run. */ + checksumVerified?: boolean | null; + averageSpeedBps?: number | null; + retryCount?: number; +} + +/** + * Which rows download.list returns. This is the category tree and the + * All/Unfinished/Finished nodes, expressed on the wire. Absent clauses are not + * constraints. + */ +export interface TaskFilter { + states?: TaskState[] | null; + categoryId?: string | null; + queueId?: string | null; + /** Case-insensitive substring of filename or url. */ + query?: string | null; + addedAfter?: string | null; + addedBefore?: string | null; +} + +export type TaskSortDirection = "asc" | "desc"; +export const TASK_SORT_DIRECTION_VALUES = [ + "asc", + "desc", +] as const satisfies readonly TaskSortDirection[]; + +export type TaskSortField = "filename" | "sizeBytes" | "state" | "etaSeconds" | "speedBps" | "lastTryAt" | "createdAt" | "queuePosition" | "description"; +export const TASK_SORT_FIELD_VALUES = [ + "filename", + "sizeBytes", + "state", + "etaSeconds", + "speedBps", + "lastTryAt", + "createdAt", + "queuePosition", + "description", +] as const satisfies readonly TaskSortField[]; + +/** + * Sort order for download.list. The GUI persists the user's choice and sends it on every + * list call; the daemon does the sorting so a 100k-row list never has to be materialized + * client-side. + */ +export interface TaskSort { + field: TaskSortField; + direction: TaskSortDirection; +} + +/** No parameters. */ +export type CaptureGetRulesParams = Record; + +export type CaptureOfferParamsMethod = "GET" | "POST"; +export const CAPTURE_OFFER_PARAMS_METHOD_VALUES = [ + "GET", + "POST", +] as const satisfies readonly CaptureOfferParamsMethod[]; + +export interface CaptureOfferParams { + url: string; + method: CaptureOfferParamsMethod; + tabUrl: string; + headers?: Headers | null; + /** Cookies for the URL, so authenticated downloads work outside the browser. */ + cookies?: Cookie[] | null; + contentType?: string | null; + contentLength?: number | null; + contentDisposition?: string | null; + /** The extension's best guess; the daemon may override it. */ + filename?: string | null; + userAgent?: string | null; + referrer?: string | null; + /** + * moz-extension://... The daemon verifies this on the WS transport and refuses anything + * else. + */ + origin?: string | null; + /** + * The extension's webRequest id, echoed in logs so a capture decision can be traced back + * to one browser request. + */ + requestId?: string | null; +} + +export type CaptureOfferResultAction = "take" | "ignore"; +export const CAPTURE_OFFER_RESULT_ACTION_VALUES = [ + "take", + "ignore", +] as const satisfies readonly CaptureOfferResultAction[]; + +/** + * Why the offer was declined. Set when action is 'ignore'; the extension logs it in the + * popup's diagnostics. + */ +export type CaptureOfferResultReason = "excluded_host" | "type_not_monitored" | "below_min_size" | "duplicate" | "capture_disabled" | "user_declined" | "rule_ignore"; +export const CAPTURE_OFFER_RESULT_REASON_VALUES = [ + "excluded_host", + "type_not_monitored", + "below_min_size", + "duplicate", + "capture_disabled", + "user_declined", + "rule_ignore", +] as const satisfies readonly CaptureOfferResultReason[]; + +export interface CaptureOfferResult { + action: CaptureOfferResultAction; + /** Set when action is 'take'. */ + taskId?: string | null; + /** + * Why the offer was declined. Set when action is 'ignore'; the extension logs it in the + * popup's diagnostics. + */ + reason?: CaptureOfferResultReason | null; +} + +/** No parameters. */ +export type CategoryListParams = Record; + +export interface CategoryListResult { + items: Category[]; +} + +export interface CategoryRemoveParams { + categoryId: string; + reassignTo?: string | null; +} + +export interface CategoryRemoveResult { + removed: boolean; + reassignedTaskIds: string[]; +} + +export interface CategoryUpsertParams { + category: Category; +} + +/** The stored category, with categoryId filled in on create. */ +export interface CategoryUpsertResult { + category: Category; +} + +export interface DownloadAddResult { + taskId: string; + state: TaskState; + /** + * The existing task this URL matched, when downloads.duplicatePolicy resolved to 'skip'. + * taskId then names that existing task. + */ + duplicate?: string | null; +} + +export interface DownloadAddBatchParams { + items: DownloadSpec[]; + /** Applied to any field an item left unset. Its url is ignored. */ + defaults?: DownloadSpec | null; +} + +export interface DownloadAddBatchResultFailedItem { + index: number; + code: ErrorCode; + message: string; +} + +export interface DownloadAddBatchResult { + /** In the same order as the accepted items. */ + taskIds: string[]; + /** One entry per item that could not be added. index refers to params.items. */ + failed: DownloadAddBatchResultFailedItem[]; +} + +export interface DownloadCancelParams { + taskIds: string[]; +} + +export interface DownloadGetParams { + taskId: string; +} + +export interface DownloadListParams { + filter?: TaskFilter | null; + sort?: TaskSort | null; + offset?: number | null; + /** Defaults to 500. The GUI pages; the extension popup asks for far fewer. */ + limit?: number | null; +} + +export interface DownloadListResult { + /** Rows matching the filter, ignoring offset and limit. */ + total: number; + items: TaskSummary[]; +} + +export interface DownloadPauseParams { + taskIds: string[]; +} + +export interface DownloadProbeParams { + url: string; + headers?: Headers | null; + cookies?: Cookie[] | null; + referrer?: string | null; + userAgent?: string | null; +} + +export interface DownloadProbeResult { + /** From Content-Disposition when present, else the URL path, sanitized. */ + filename: string; + sizeBytes?: number | null; + mime: string; + /** Accept-Ranges: bytes and a validator (ETag or Last-Modified) are both present. */ + resumable: boolean; + effectiveUrl: string; + /** What the rules engine would pick. The dialog preselects it; the user may override. */ + suggestedCategoryId: string; + suggestedSaveDir?: string | null; + etag?: string | null; + lastModified?: string | null; + acceptRanges?: boolean; + /** Every hop, so the user can see where a shortener actually led. */ + redirectChain?: string[]; + /** The probe got a 401/407. The GUI should collect credentials before adding. */ + requiresAuth?: boolean; +} + +export interface DownloadRefreshUrlParams { + taskId: string; + url: string; + headers?: Headers | null; + cookies?: Cookie[] | null; +} + +export interface DownloadRefreshUrlResult { + ok: boolean; + resumable: boolean; + /** + * true when size or validator differ from what was recorded. The GUI must ask before + * restarting from zero — never discard bytes without consent. + */ + contentChanged: boolean; + sizeBytes?: number | null; + effectiveUrl?: string | null; +} + +export interface DownloadRemoveParams { + taskIds: string[]; + /** Explicit and required — there is no default for deleting a user's file. */ + deleteFile: boolean; +} + +export interface DownloadRemoveResultFailedItem { + taskId: string; + code: ErrorCode; + message: string; +} + +export interface DownloadRemoveResult { + removed: string[]; + failed: DownloadRemoveResultFailedItem[]; +} + +export interface DownloadResumeParams { + taskIds: string[]; +} + +export interface DownloadStartParams { + taskIds: string[]; +} + +/** Only the present fields change. An explicit null clears a nullable field. */ +export interface DownloadUpdateParamsPatch { + filename?: string | null; + saveDir?: string | null; + categoryId?: string | null; + queueId?: string | null; + description?: string | null; + /** + * The REQUESTED connection count, subject to the same per-host cap and non-resumable + * demotion as DownloadSpec.segments. Takes effect on the next start; a running task is not + * re-segmented underneath the user. + */ + segments?: number | null; + bufferBytes?: number | null; + checksum?: Checksum | null; +} + +export interface DownloadUpdateParams { + taskId: string; + /** Only the present fields change. An explicit null clears a nullable field. */ + patch: DownloadUpdateParamsPatch; +} + +export interface GrabberHarvestParams { + jobId: string; + /** fileIds from grabber.status. */ + select: string[]; + defaults?: DownloadSpec | null; +} + +export interface GrabberHarvestResultFailedItem { + fileId: string; + code: ErrorCode; + message: string; +} + +export interface GrabberHarvestResult { + taskIds: string[]; + failed: GrabberHarvestResultFailedItem[]; +} + +export interface GrabberStartParams { + startUrl: string; + depth: number; + includePatterns?: string[] | null; + excludePatterns?: string[] | null; + /** Extensions, without the dot. null means every type. */ + fileTypes?: string[] | null; + sameHostOnly?: boolean; + maxFiles?: number | null; + headers?: Headers | null; + cookies?: Cookie[] | null; +} + +export interface GrabberStartResult { + jobId: string; +} + +export interface GrabberStatusParams { + jobId: string; +} + +export type GrabberStatusResultState = "crawling" | "done" | "failed" | "cancelled"; +export const GRABBER_STATUS_RESULT_STATE_VALUES = [ + "crawling", + "done", + "failed", + "cancelled", +] as const satisfies readonly GrabberStatusResultState[]; + +export interface GrabberStatusResult { + jobId: string; + state: GrabberStatusResultState; + crawled: number; + found: number; + files: GrabberFile[]; + error?: string | null; +} + +/** No parameters. */ +export type LimiterGetParams = Record; + +/** + * spec carries the same destination and queueing fields as download.add; its url is + * ignored because the manifest and variant determine the source. + */ +export interface MediaAddVariantParams { + manifestUrl: string; + variantId: string; + /** For DASH and HLS renditions where audio is a separate track to be muxed in. */ + audioVariantId?: string | null; + spec?: DownloadSpec | null; +} + +export interface MediaAddVariantResult { + taskId: string; + state: TaskState; + estimatedBytes?: number | null; +} + +export interface MediaListVariantsParams { + manifestUrl: string; + headers?: Headers | null; + cookies?: Cookie[] | null; + referrer?: string | null; +} + +export type MediaListVariantsResultManifestType = "hls" | "dash"; +export const MEDIA_LIST_VARIANTS_RESULT_MANIFEST_TYPE_VALUES = [ + "hls", + "dash", +] as const satisfies readonly MediaListVariantsResultManifestType[]; + +export interface MediaListVariantsResult { + variants: MediaVariant[]; + manifestType: MediaListVariantsResultManifestType; + durationSec?: number | null; + title?: string | null; + /** + * The manifest as a whole is DRM-protected. Refuse with a clear message rather than + * downloading undecryptable segments. + */ + drmProtected: boolean; +} + +/** No parameters. */ +export type QueueListParams = Record; + +export interface QueueListResult { + items: Queue[]; +} + +export interface QueueReorderParams { + queueId: string; + taskIds: string[]; +} + +export interface QueueReorderResult { + queue: Queue; +} + +export interface QueueStartParams { + queueId: string; +} + +export interface QueueStartResult { + queue: Queue; + startedTaskIds: string[]; +} + +export interface QueueStopParams { + queueId: string; + pauseRunning?: boolean; +} + +export interface QueueStopResult { + queue: Queue; + pausedTaskIds: string[]; +} + +export interface QueueUpsertParams { + queue: Queue; +} + +export interface QueueUpsertResult { + queue: Queue; +} + +/** No parameters. */ +export type RulesListParams = Record; + +export interface RulesListResult { + items: Rule[]; +} + +export interface RulesUpsertParams { + upsert: Rule[]; + remove?: string[] | null; +} + +/** The full table after the write, in priority order. */ +export interface RulesUpsertResult { + items: Rule[]; +} + +export interface ScheduleGetParams { + queueId?: string | null; +} + +export interface ScheduleGetResultItemsItem { + queueId: string; + schedule: Schedule | null; +} + +export interface ScheduleGetResult { + items: ScheduleGetResultItemsItem[]; +} + +export interface ScheduleSetParams { + queueId: string; + schedule: Schedule | null; +} + +export interface ScheduleSetResult { + queueId: string; + schedule: Schedule | null; + nextRunAt?: string | null; +} + +export type SessionHelloParamsClientType = "gui" | "cli" | "extension" | "nmhost" | "test"; +export const SESSION_HELLO_PARAMS_CLIENT_TYPE_VALUES = [ + "gui", + "cli", + "extension", + "nmhost", + "test", +] as const satisfies readonly SessionHelloParamsClientType[]; + +export interface SessionHelloParams { + clientType: SessionHelloParamsClientType; + /** Human-readable, shown in the pairing prompt and the logs. */ + clientName: string; + protocolVersion: string; + /** + * Required on the WebSocket transport once paired. Ignored on the Unix socket, where + * SO_PEERCRED is the authorization. + */ + token?: string | null; +} + +/** + * How the daemon sees this connection. Lets a client know up front which privileged + * methods will be refused. + */ +export type SessionHelloResultTransport = "uds" | "ws"; +export const SESSION_HELLO_RESULT_TRANSPORT_VALUES = [ + "uds", + "ws", +] as const satisfies readonly SessionHelloResultTransport[]; + +export interface SessionHelloResult { + daemonVersion: string; + protocolVersion: string; + /** + * Optional features this build has, e.g. 'media', 'grabber', 'secretservice'. A client + * must degrade gracefully when one is absent rather than assuming it. + */ + capabilities: string[]; + sessionId: string; + /** + * How the daemon sees this connection. Lets a client know up front which privileged + * methods will be refused. + */ + transport?: SessionHelloResultTransport; +} + +export interface SessionPairParams { + clientName: string; + /** The moz-extension origin UUID. Must match the Origin header verified on the WS upgrade. */ + extensionId: string; + /** + * Set when the user typed the code into the extension's Options page instead of clicking + * Allow in the GUI. + */ + code?: string | null; +} + +export interface SessionPairResult { + /** + * 256 bits, base64url. Stored by the extension in browser.storage.local and sent on every + * later connect. + */ + token: string; + /** null means the token does not expire; it is revoked from Options -> Unpair. */ + expiresAt: string | null; +} + +export type SessionSubscribeParamsEventsItem = "event.task.added" | "event.task.removed" | "event.task.state" | "event.task.progress" | "event.speed.global" | "event.auth.required" | "event.notify" | "event.settings.changed" | "event.grabber.progress"; +export const SESSION_SUBSCRIBE_PARAMS_EVENTS_ITEM_VALUES = [ + "event.task.added", + "event.task.removed", + "event.task.state", + "event.task.progress", + "event.speed.global", + "event.auth.required", + "event.notify", + "event.settings.changed", + "event.grabber.progress", +] as const satisfies readonly SessionSubscribeParamsEventsItem[]; + +export interface SessionSubscribeParams { + events: SessionSubscribeParamsEventsItem[]; + /** + * Narrow task events to these ids. The extension popup uses it to avoid receiving progress + * for downloads it is not showing. null means all tasks. + */ + taskIds?: string[] | null; +} + +export interface SessionSubscribeResult { + ok: boolean; + /** Echoed back so a client can detect that it asked for an event this daemon does not emit. */ + events: string[]; +} + +export interface SettingsGetParams { + keys?: SettingKey[] | null; +} + +export interface SettingsGetResult { + values: Settings; +} + +export interface SettingsSetParams { + values: Settings; +} + +/** + * The stored values for the keys that were set, and the list of keys that actually + * changed. + */ +export interface SettingsSetResult { + values: Settings; + changed: SettingKey[]; +} + +export type AuthRequiredEventScheme = "basic" | "digest" | "ntlm" | "negotiate" | "proxy"; +export const AUTH_REQUIRED_EVENT_SCHEME_VALUES = [ + "basic", + "digest", + "ntlm", + "negotiate", + "proxy", +] as const satisfies readonly AuthRequiredEventScheme[]; + +export interface AuthRequiredEvent { + taskId: string; + host: string; + realm?: string | null; + scheme: AuthRequiredEventScheme; +} + +export interface GrabberProgressEvent { + jobId: string; + found: number; + crawled: number; + done: boolean; + currentUrl?: string | null; +} + +export type NotifyEventLevel = "info" | "success" | "warning" | "error"; +export const NOTIFY_EVENT_LEVEL_VALUES = [ + "info", + "success", + "warning", + "error", +] as const satisfies readonly NotifyEventLevel[]; + +export type NotifyEventSound = "complete" | "queueComplete" | "error"; +export const NOTIFY_EVENT_SOUND_VALUES = [ + "complete", + "queueComplete", + "error", +] as const satisfies readonly NotifyEventSound[]; + +export interface NotifyEvent { + level: NotifyEventLevel; + title: string; + body: string; + taskId?: string | null; + sound?: NotifyEventSound | null; +} + +export interface SettingsChangedEvent { + keys: SettingKey[]; +} + +export interface SpeedGlobalEvent { + downBps: number; + activeCount: number; + queuedCount?: number; + /** null when the limiter is off. */ + limitBps?: number | null; +} + +export interface TaskAddedEvent { + taskId: string; + summary: TaskSummary; +} + +/** Only what a segment bar needs. Full segment state comes from download.get. */ +export interface TaskProgressEventTasksItemSegmentsItem { + index: number; + downloadedBytes: number; + speedBps: number; +} + +export interface TaskProgressEventTasksItem { + taskId: string; + downloadedBytes: number; + speedBps: number; + etaSeconds?: number | null; + segments?: TaskProgressEventTasksItemSegmentsItem[]; +} + +export interface TaskProgressEvent { + tasks: TaskProgressEventTasksItem[]; + at: string; +} + +export interface TaskRemovedEvent { + taskId: string; + deletedFile: boolean; +} + +export interface TaskStateEvent { + taskId: string; + state: TaskState; + previousState?: TaskState | null; + summary?: TaskSummary | null; + error?: TaskError | null; +} + +/** JSON-RPC error as it appears on the wire. */ +export interface RpcError { + code: ErrorCode; + message: string; + data?: Record | null; +} + +/** A response is one or the other, never both — narrow on `error`. */ +export type RpcResponse = + | { jsonrpc: '2.0'; id: number | string; result: T; error?: undefined } + | { jsonrpc: '2.0'; id: number | string; result?: undefined; error: RpcError }; diff --git a/extension/src/shared/protocol/validate.ts b/extension/src/shared/protocol/validate.ts new file mode 100644 index 0000000..bb4f6a0 --- /dev/null +++ b/extension/src/shared/protocol/validate.ts @@ -0,0 +1,2168 @@ +// --------------------------------------------------------------------------- +// GENERATED FILE — DO NOT EDIT. +// +// Source: contracts/schema/** +// Generator: contracts/codegen/gen_ts.py +// Contract: v1.0.0 +// +// Hand-editing this file is a merge blocker. Fix the schema and regenerate: +// python3 contracts/codegen/gen_ts.py +// Only lane PROTO commits to contracts/. +// --------------------------------------------------------------------------- + + +import type { + AuthRequiredEvent, + AuthRequiredEventScheme, + BulkTaskResult, + BulkTaskResultFailedItem, + BulkTaskResultUpdatedItem, + BypassModifier, + CaptureGetRulesParams, + CaptureOfferParams, + CaptureOfferParamsMethod, + CaptureOfferResult, + CaptureOfferResultAction, + CaptureOfferResultReason, + CaptureRules, + Category, + CategoryListParams, + CategoryListResult, + CategoryRemoveParams, + CategoryRemoveResult, + CategoryUpsertParams, + CategoryUpsertResult, + Checksum, + ChecksumAlgorithm, + Cookie, + DownloadAddBatchParams, + DownloadAddBatchResult, + DownloadAddBatchResultFailedItem, + DownloadAddResult, + DownloadCancelParams, + DownloadGetParams, + DownloadListParams, + DownloadListResult, + DownloadPauseParams, + DownloadProbeParams, + DownloadProbeResult, + DownloadRefreshUrlParams, + DownloadRefreshUrlResult, + DownloadRemoveParams, + DownloadRemoveResult, + DownloadRemoveResultFailedItem, + DownloadResumeParams, + DownloadSpec, + DownloadStartParams, + DownloadUpdateParams, + DownloadUpdateParamsPatch, + GrabberFile, + GrabberHarvestParams, + GrabberHarvestResult, + GrabberHarvestResultFailedItem, + GrabberProgressEvent, + GrabberStartParams, + GrabberStartResult, + GrabberStatusParams, + GrabberStatusResult, + GrabberStatusResultState, + Headers, + Limiter, + LimiterGetParams, + MediaAddVariantParams, + MediaAddVariantResult, + MediaListVariantsParams, + MediaListVariantsResult, + MediaListVariantsResultManifestType, + MediaVariant, + MediaVariantContainer, + MediaVariantKind, + NotifyEvent, + NotifyEventLevel, + NotifyEventSound, + Queue, + QueueListParams, + QueueListResult, + QueueOnComplete, + QueueReorderParams, + QueueReorderResult, + QueueStartParams, + QueueStartResult, + QueueState, + QueueStopParams, + QueueStopResult, + QueueUpsertParams, + QueueUpsertResult, + Rule, + RuleAction, + RuleActionCapture, + RuleMatch, + RulesListParams, + RulesListResult, + RulesUpsertParams, + RulesUpsertResult, + Schedule, + ScheduleGetParams, + ScheduleGetResult, + ScheduleGetResultItemsItem, + ScheduleMode, + ScheduleSetParams, + ScheduleSetResult, + Segment, + SegmentState, + SessionHelloParams, + SessionHelloParamsClientType, + SessionHelloResult, + SessionHelloResultTransport, + SessionPairParams, + SessionPairResult, + SessionSubscribeParams, + SessionSubscribeParamsEventsItem, + SessionSubscribeResult, + SettingKey, + Settings, + SettingsChangedEvent, + SettingsConnectionPreset, + SettingsDownloadsDuplicatePolicy, + SettingsGetParams, + SettingsGetResult, + SettingsProxyMode, + SettingsSaveToFileExistsPolicy, + SettingsSetParams, + SettingsSetResult, + SpeedGlobalEvent, + StartMode, + TaskAddedEvent, + TaskDetail, + TaskError, + TaskErrorCode, + TaskFilter, + TaskProgressEvent, + TaskProgressEventTasksItem, + TaskProgressEventTasksItemSegmentsItem, + TaskRemovedEvent, + TaskSort, + TaskSortDirection, + TaskSortField, + TaskState, + TaskStateEvent, + TaskSummary, +} from './types.js'; +import { + AUTH_REQUIRED_EVENT_SCHEME_VALUES, + BYPASS_MODIFIER_VALUES, + CAPTURE_OFFER_PARAMS_METHOD_VALUES, + CAPTURE_OFFER_RESULT_ACTION_VALUES, + CAPTURE_OFFER_RESULT_REASON_VALUES, + CHECKSUM_ALGORITHM_VALUES, + ErrorCode, + GRABBER_STATUS_RESULT_STATE_VALUES, + MEDIA_LIST_VARIANTS_RESULT_MANIFEST_TYPE_VALUES, + MEDIA_VARIANT_CONTAINER_VALUES, + MEDIA_VARIANT_KIND_VALUES, + NOTIFY_EVENT_LEVEL_VALUES, + NOTIFY_EVENT_SOUND_VALUES, + QUEUE_ON_COMPLETE_VALUES, + QUEUE_STATE_VALUES, + RULE_ACTION_CAPTURE_VALUES, + SCHEDULE_MODE_VALUES, + SEGMENT_STATE_VALUES, + SESSION_HELLO_PARAMS_CLIENT_TYPE_VALUES, + SESSION_HELLO_RESULT_TRANSPORT_VALUES, + SESSION_SUBSCRIBE_PARAMS_EVENTS_ITEM_VALUES, + SETTING_KEY_VALUES, + SETTINGS_CONNECTION_PRESET_VALUES, + SETTINGS_DOWNLOADS_DUPLICATE_POLICY_VALUES, + SETTINGS_PROXY_MODE_VALUES, + SETTINGS_SAVE_TO_FILE_EXISTS_POLICY_VALUES, + START_MODE_VALUES, + TASK_ERROR_CODE_VALUES, + TASK_SORT_DIRECTION_VALUES, + TASK_SORT_FIELD_VALUES, + TASK_STATE_VALUES, +} from './types.js'; +import { isEventName, type EventMap, type EventName } from './events.js'; +import { isMethodName, type MethodMap, type MethodName } from './methods.js'; + +/** + * Runtime validation for everything that crosses the wire. + * + * The daemon does not trust the extension and the extension does not trust the daemon: + * `ws://127.0.0.1` is reachable by any local process, so a TypeScript type proves nothing + * at runtime. Every inbound payload goes through one of these before it is used. + * + * Validators mirror the C++ side exactly, including the rule that an absent field and an + * explicit null mean the same thing. + */ + +export type Validated = + | { ok: true; value: T } + | { ok: false; path: string; message: string }; + +export type Validator = (v: unknown, path: string) => Validated; + +function fail(path: string, message: string): Validated { + return { ok: false, path, message }; +} + +function join(path: string, key: string): string { + return path ? `${path}/${key}` : `/${key}`; +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +export const vString: Validator = (v, p) => + typeof v === 'string' ? { ok: true, value: v } : fail(p, 'expected a string'); + +export const vNumber: Validator = (v, p) => + typeof v === 'number' && Number.isFinite(v) ? { ok: true, value: v } : fail(p, 'expected a number'); + +export const vInteger: Validator = (v, p) => + typeof v === 'number' && Number.isInteger(v) ? { ok: true, value: v } : fail(p, 'expected an integer'); + +export const vBoolean: Validator = (v, p) => + typeof v === 'boolean' ? { ok: true, value: v } : fail(p, 'expected a boolean'); + +export const vUnknown: Validator = (v) => ({ ok: true, value: v }); + +function vArray(inner: Validator): Validator { + return (v, p) => { + if (!Array.isArray(v)) return fail(p, 'expected an array'); + const out: T[] = []; + for (let i = 0; i < v.length; i += 1) { + const r = inner(v[i], join(p, String(i))); + if (!r.ok) return r; + out.push(r.value); + } + return { ok: true, value: out }; + }; +} + +function vRecord(inner: Validator): Validator> { + return (v, p) => { + if (!isPlainObject(v)) return fail(p, 'expected an object'); + const out: Record = {}; + for (const [k, raw] of Object.entries(v)) { + const r = inner(raw, join(p, k)); + if (!r.ok) return r; + out[k] = r.value; + } + return { ok: true, value: out }; + }; +} + +/** + * Range, length and pattern checks. The wire is untrusted, so a `maximum` in the schema + * has to be a check at runtime — a TypeScript type cannot enforce one. + */ +interface Limits { + readonly minimum?: number; + readonly maximum?: number; + readonly minLength?: number; + readonly maxLength?: number; + readonly pattern?: RegExp; + readonly minItems?: number; + readonly maxItems?: number; +} + +function vLimited(inner: Validator, limits: Limits): Validator { + return (v, p) => { + const r = inner(v, p); + if (!r.ok) return r; + const value = r.value; + if (typeof value === 'number') { + if (limits.minimum !== undefined && value < limits.minimum) + return fail(p, `value is below the minimum of ${limits.minimum}`); + if (limits.maximum !== undefined && value > limits.maximum) + return fail(p, `value is above the maximum of ${limits.maximum}`); + } else if (typeof value === 'string') { + if (limits.minLength !== undefined && value.length < limits.minLength) + return fail(p, `value is shorter than ${limits.minLength} characters`); + if (limits.maxLength !== undefined && value.length > limits.maxLength) + return fail(p, `value is longer than ${limits.maxLength} characters`); + if (limits.pattern !== undefined && !limits.pattern.test(value)) + return fail(p, 'value does not match the required pattern'); + } else if (Array.isArray(value)) { + if (limits.minItems !== undefined && value.length < limits.minItems) + return fail(p, `fewer than ${limits.minItems} items`); + if (limits.maxItems !== undefined && value.length > limits.maxItems) + return fail(p, `more than ${limits.maxItems} items`); + } + return r; + }; +} + +function vEnum(values: readonly T[], name: string): Validator { + return (v, p) => + typeof v === 'string' && (values as readonly string[]).includes(v) + ? { ok: true, value: v as T } + : fail(p, `not a valid ${name}`); +} + +function vIntEnum(values: readonly T[], name: string): Validator { + return (v, p) => + typeof v === 'number' && (values as readonly number[]).includes(v) + ? { ok: true, value: v as T } + : fail(p, `not a valid ${name}`); +} + +/** Required: must be present and non-null. */ +function req( + obj: Record, + key: string, + path: string, + inner: Validator, + out: Record, +): Validated { + const raw = obj[key]; + if (raw === undefined || raw === null) return fail(join(path, key), 'required field is missing'); + const r = inner(raw, join(path, key)); + if (!r.ok) return r; + out[key] = r.value; + return { ok: true, value: null }; +} + +/** Optional: absent and null are the same thing, exactly as on the C++ side. */ +function opt( + obj: Record, + key: string, + path: string, + inner: Validator, + out: Record, +): Validated { + const raw = obj[key]; + if (raw === undefined || raw === null) return { ok: true, value: null }; + const r = inner(raw, join(path, key)); + if (!r.ok) return r; + out[key] = r.value; + return { ok: true, value: null }; +} + +/** Validate an untrusted value as BulkTaskResult. */ +export function validateBulkTaskResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "updated", path, vArray(validateBulkTaskResultUpdatedItem), out); + if (!r.ok) return r; + r = req(v, "failed", path, vArray(validateBulkTaskResultFailedItem), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as BulkTaskResult }; +} + +export const validateTaskState: Validator = vEnum(TASK_STATE_VALUES, 'TaskState'); + +/** Validate an untrusted value as BulkTaskResultUpdatedItem. */ +export function validateBulkTaskResultUpdatedItem(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "state", path, validateTaskState, out); + if (!r.ok) return r; + r = req(v, "changed", path, vBoolean, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as BulkTaskResultUpdatedItem }; +} + +const ERROR_CODE_VALUES = Object.values(ErrorCode) as ErrorCode[]; +export const validateErrorCode: Validator = vIntEnum(ERROR_CODE_VALUES, 'ErrorCode'); + +/** Validate an untrusted value as BulkTaskResultFailedItem. */ +export function validateBulkTaskResultFailedItem(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "code", path, validateErrorCode, out); + if (!r.ok) return r; + r = req(v, "message", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as BulkTaskResultFailedItem }; +} + +export const validateBypassModifier: Validator = vEnum(BYPASS_MODIFIER_VALUES, 'BypassModifier'); + +/** Validate an untrusted value as CaptureRules. */ +export function validateCaptureRules(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "enabled", path, vBoolean, out); + if (!r.ok) return r; + r = req(v, "monitoredExtensions", path, vArray(vString), out); + if (!r.ok) return r; + r = req(v, "monitoredMimeTypes", path, vArray(vString), out); + if (!r.ok) return r; + r = req(v, "minSizeBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "excludedHosts", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "bypassModifier", path, validateBypassModifier, out); + if (!r.ok) return r; + r = req(v, "rulesVersion", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as CaptureRules }; +} + +/** Validate an untrusted value as Category. */ +export function validateCategory(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "categoryId", path, vString, out); + if (!r.ok) return r; + r = req(v, "name", path, vLimited(vString, { maxLength: 64 }), out); + if (!r.ok) return r; + r = req(v, "saveDir", path, vString, out); + if (!r.ok) return r; + r = req(v, "extensions", path, vArray(vLimited(vString, { pattern: /^[A-Za-z0-9][A-Za-z0-9+._-]*$/ })), out); + if (!r.ok) return r; + r = opt(v, "mimeTypes", path, vArray(vString), out); + if (!r.ok) return r; + r = req(v, "builtin", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "sortOrder", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as Category }; +} + +/** Validate an untrusted value as Checksum. */ +export function validateChecksum(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "algorithm", path, validateChecksumAlgorithm, out); + if (!r.ok) return r; + r = req(v, "value", path, vLimited(vString, { pattern: /^[0-9a-fA-F]{32,128}$/ }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as Checksum }; +} + +export const validateChecksumAlgorithm: Validator = vEnum(CHECKSUM_ALGORITHM_VALUES, 'ChecksumAlgorithm'); + +/** Validate an untrusted value as Cookie. */ +export function validateCookie(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "name", path, vString, out); + if (!r.ok) return r; + r = req(v, "value", path, vString, out); + if (!r.ok) return r; + r = opt(v, "domain", path, vString, out); + if (!r.ok) return r; + r = opt(v, "path", path, vString, out); + if (!r.ok) return r; + r = opt(v, "secure", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "httpOnly", path, vBoolean, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as Cookie }; +} + +/** Validate an untrusted value as DownloadSpec. */ +export function validateDownloadSpec(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "url", path, vString, out); + if (!r.ok) return r; + r = opt(v, "headers", path, validateHeaders, out); + if (!r.ok) return r; + r = opt(v, "cookies", path, vArray(validateCookie), out); + if (!r.ok) return r; + r = opt(v, "referrer", path, vString, out); + if (!r.ok) return r; + r = opt(v, "userAgent", path, vString, out); + if (!r.ok) return r; + r = opt(v, "filename", path, vLimited(vString, { maxLength: 255 }), out); + if (!r.ok) return r; + r = opt(v, "saveDir", path, vString, out); + if (!r.ok) return r; + r = opt(v, "categoryId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "queueId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "segments", path, vLimited(vInteger, { minimum: 1, maximum: 32 }), out); + if (!r.ok) return r; + r = opt(v, "bufferBytes", path, vLimited(vInteger, { minimum: 4096, maximum: 8388608 }), out); + if (!r.ok) return r; + r = opt(v, "startMode", path, validateStartMode, out); + if (!r.ok) return r; + r = opt(v, "description", path, vLimited(vString, { maxLength: 1024 }), out); + if (!r.ok) return r; + r = opt(v, "checksum", path, validateChecksum, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadSpec }; +} + +export const validateHeaders: Validator = vRecord(vString); + +export const validateStartMode: Validator = vEnum(START_MODE_VALUES, 'StartMode'); + +/** Validate an untrusted value as GrabberFile. */ +export function validateGrabberFile(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "fileId", path, vString, out); + if (!r.ok) return r; + r = req(v, "url", path, vString, out); + if (!r.ok) return r; + r = opt(v, "filename", path, vString, out); + if (!r.ok) return r; + r = opt(v, "sizeBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "contentType", path, vString, out); + if (!r.ok) return r; + r = req(v, "depth", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "foundOn", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as GrabberFile }; +} + +/** Validate an untrusted value as Limiter. */ +export function validateLimiter(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "enabled", path, vBoolean, out); + if (!r.ok) return r; + r = req(v, "globalBps", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "applyToRunning", path, vBoolean, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as Limiter }; +} + +/** Validate an untrusted value as MediaVariant. */ +export function validateMediaVariant(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "variantId", path, vString, out); + if (!r.ok) return r; + r = req(v, "kind", path, validateMediaVariantKind, out); + if (!r.ok) return r; + r = opt(v, "resolution", path, vLimited(vString, { pattern: /^[0-9]{2,5}x[0-9]{2,5}$/ }), out); + if (!r.ok) return r; + r = opt(v, "bitrateBps", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "codec", path, vString, out); + if (!r.ok) return r; + r = opt(v, "container", path, validateMediaVariantContainer, out); + if (!r.ok) return r; + r = opt(v, "frameRate", path, vLimited(vNumber, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "language", path, vString, out); + if (!r.ok) return r; + r = opt(v, "sizeEstimate", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "drm", path, vBoolean, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as MediaVariant }; +} + +export const validateMediaVariantKind: Validator = vEnum(MEDIA_VARIANT_KIND_VALUES, 'MediaVariantKind'); + +export const validateMediaVariantContainer: Validator = vEnum(MEDIA_VARIANT_CONTAINER_VALUES, 'MediaVariantContainer'); + +/** Validate an untrusted value as Queue. */ +export function validateQueue(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queueId", path, vString, out); + if (!r.ok) return r; + r = req(v, "name", path, vLimited(vString, { maxLength: 64 }), out); + if (!r.ok) return r; + r = req(v, "state", path, validateQueueState, out); + if (!r.ok) return r; + r = req(v, "maxConcurrent", path, vLimited(vInteger, { minimum: 1, maximum: 32 }), out); + if (!r.ok) return r; + r = opt(v, "taskIds", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "schedule", path, validateSchedule, out); + if (!r.ok) return r; + r = opt(v, "onComplete", path, validateQueueOnComplete, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as Queue }; +} + +export const validateQueueState: Validator = vEnum(QUEUE_STATE_VALUES, 'QueueState'); + +/** Validate an untrusted value as Schedule. */ +export function validateSchedule(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "enabled", path, vBoolean, out); + if (!r.ok) return r; + r = req(v, "mode", path, validateScheduleMode, out); + if (!r.ok) return r; + r = opt(v, "startTime", path, vLimited(vString, { pattern: /^([01][0-9]|2[0-3]):[0-5][0-9]$/ }), out); + if (!r.ok) return r; + r = opt(v, "stopTime", path, vLimited(vString, { pattern: /^([01][0-9]|2[0-3]):[0-5][0-9]$/ }), out); + if (!r.ok) return r; + r = opt(v, "daysOfWeek", path, vArray(vLimited(vInteger, { minimum: 0, maximum: 6 })), out); + if (!r.ok) return r; + r = opt(v, "onceDate", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as Schedule }; +} + +export const validateScheduleMode: Validator = vEnum(SCHEDULE_MODE_VALUES, 'ScheduleMode'); + +export const validateQueueOnComplete: Validator = vEnum(QUEUE_ON_COMPLETE_VALUES, 'QueueOnComplete'); + +/** Validate an untrusted value as Rule. */ +export function validateRule(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "ruleId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "name", path, vLimited(vString, { maxLength: 64 }), out); + if (!r.ok) return r; + r = req(v, "enabled", path, vBoolean, out); + if (!r.ok) return r; + r = req(v, "priority", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "match", path, validateRuleMatch, out); + if (!r.ok) return r; + r = req(v, "action", path, validateRuleAction, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as Rule }; +} + +/** Validate an untrusted value as RuleMatch. */ +export function validateRuleMatch(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = opt(v, "extensions", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "mimeTypes", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "hostPattern", path, vString, out); + if (!r.ok) return r; + r = opt(v, "urlPattern", path, vString, out); + if (!r.ok) return r; + r = opt(v, "minSizeBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "maxSizeBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as RuleMatch }; +} + +export const validateRuleActionCapture: Validator = vEnum(RULE_ACTION_CAPTURE_VALUES, 'RuleActionCapture'); + +/** Validate an untrusted value as RuleAction. */ +export function validateRuleAction(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = opt(v, "categoryId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "saveDir", path, vString, out); + if (!r.ok) return r; + r = opt(v, "queueId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "segments", path, vLimited(vInteger, { minimum: 1, maximum: 32 }), out); + if (!r.ok) return r; + r = opt(v, "startMode", path, validateStartMode, out); + if (!r.ok) return r; + r = opt(v, "capture", path, validateRuleActionCapture, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as RuleAction }; +} + +/** Validate an untrusted value as Segment. */ +export function validateSegment(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "index", path, vLimited(vInteger, { minimum: 0, maximum: 31 }), out); + if (!r.ok) return r; + r = req(v, "startByte", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "endByte", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "downloadedBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "speedBps", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "state", path, validateSegmentState, out); + if (!r.ok) return r; + r = opt(v, "httpStatus", path, vLimited(vInteger, { minimum: 100, maximum: 599 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as Segment }; +} + +export const validateSegmentState: Validator = vEnum(SEGMENT_STATE_VALUES, 'SegmentState'); + +export const validateSettingKey: Validator = vEnum(SETTING_KEY_VALUES, 'SettingKey'); + +/** Validate an untrusted value as Settings. */ +export function validateSettings(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = opt(v, "general.launchOnLogin", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "general.minimizeToTray", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "general.showDropTarget", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "general.confirmOnExit", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "general.language", path, vString, out); + if (!r.ok) return r; + r = opt(v, "general.checkForUpdates", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "capture.enabled", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "capture.monitoredExtensions", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "capture.monitoredMimeTypes", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "capture.minSizeBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "capture.excludedHosts", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "capture.bypassModifier", path, validateBypassModifier, out); + if (!r.ok) return r; + r = opt(v, "capture.autoStartTypes", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "saveTo.defaultDir", path, vString, out); + if (!r.ok) return r; + r = opt(v, "saveTo.tempDir", path, vString, out); + if (!r.ok) return r; + r = opt(v, "saveTo.allowedRoots", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "saveTo.fileExistsPolicy", path, validateSettingsSaveToFileExistsPolicy, out); + if (!r.ok) return r; + r = opt(v, "saveTo.createSubfolderPerSite", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "connection.preset", path, validateSettingsConnectionPreset, out); + if (!r.ok) return r; + r = opt(v, "connection.maxSegmentsPerDownload", path, vLimited(vInteger, { minimum: 1, maximum: 32 }), out); + if (!r.ok) return r; + r = opt(v, "connection.bufferBytes", path, vLimited(vInteger, { minimum: 4096, maximum: 8388608 }), out); + if (!r.ok) return r; + r = opt(v, "connection.maxConcurrentDownloads", path, vLimited(vInteger, { minimum: 1, maximum: 64 }), out); + if (!r.ok) return r; + r = opt(v, "connection.timeoutSec", path, vLimited(vInteger, { minimum: 1, maximum: 3600 }), out); + if (!r.ok) return r; + r = opt(v, "connection.maxRetries", path, vLimited(vInteger, { minimum: 0, maximum: 100 }), out); + if (!r.ok) return r; + r = opt(v, "connection.retryBackoffSec", path, vLimited(vInteger, { minimum: 0, maximum: 3600 }), out); + if (!r.ok) return r; + r = opt(v, "downloads.speedLimitBps", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "downloads.speedLimitEnabled", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "downloads.virusScanCommand", path, vString, out); + if (!r.ok) return r; + r = opt(v, "downloads.postDownloadCommand", path, vString, out); + if (!r.ok) return r; + r = opt(v, "downloads.duplicatePolicy", path, validateSettingsDownloadsDuplicatePolicy, out); + if (!r.ok) return r; + r = opt(v, "downloads.verifyChecksums", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "proxy.mode", path, validateSettingsProxyMode, out); + if (!r.ok) return r; + r = opt(v, "proxy.host", path, vString, out); + if (!r.ok) return r; + r = opt(v, "proxy.port", path, vLimited(vInteger, { minimum: 1, maximum: 65535 }), out); + if (!r.ok) return r; + r = opt(v, "proxy.username", path, vString, out); + if (!r.ok) return r; + r = opt(v, "proxy.bypassHosts", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "proxy.pacUrl", path, vString, out); + if (!r.ok) return r; + r = opt(v, "sounds.enabled", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "sounds.onComplete", path, vString, out); + if (!r.ok) return r; + r = opt(v, "sounds.onQueueComplete", path, vString, out); + if (!r.ok) return r; + r = opt(v, "sounds.onError", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as Settings }; +} + +export const validateSettingsSaveToFileExistsPolicy: Validator = vEnum(SETTINGS_SAVE_TO_FILE_EXISTS_POLICY_VALUES, 'SettingsSaveToFileExistsPolicy'); + +export const validateSettingsConnectionPreset: Validator = vEnum(SETTINGS_CONNECTION_PRESET_VALUES, 'SettingsConnectionPreset'); + +export const validateSettingsDownloadsDuplicatePolicy: Validator = vEnum(SETTINGS_DOWNLOADS_DUPLICATE_POLICY_VALUES, 'SettingsDownloadsDuplicatePolicy'); + +export const validateSettingsProxyMode: Validator = vEnum(SETTINGS_PROXY_MODE_VALUES, 'SettingsProxyMode'); + +/** Validate an untrusted value as TaskDetail. */ +export function validateTaskDetail(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "summary", path, validateTaskSummary, out); + if (!r.ok) return r; + r = req(v, "segmentDetail", path, vLimited(vArray(validateSegment), { maxItems: 32 }), out); + if (!r.ok) return r; + r = opt(v, "headers", path, validateHeaders, out); + if (!r.ok) return r; + r = opt(v, "referrer", path, vString, out); + if (!r.ok) return r; + r = opt(v, "userAgent", path, vString, out); + if (!r.ok) return r; + r = opt(v, "mime", path, vString, out); + if (!r.ok) return r; + r = opt(v, "bufferBytes", path, vLimited(vInteger, { minimum: 4096, maximum: 8388608 }), out); + if (!r.ok) return r; + r = opt(v, "partPath", path, vString, out); + if (!r.ok) return r; + r = opt(v, "checksum", path, validateChecksum, out); + if (!r.ok) return r; + r = opt(v, "checksumVerified", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "averageSpeedBps", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "retryCount", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as TaskDetail }; +} + +/** Validate an untrusted value as TaskSummary. */ +export function validateTaskSummary(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "filename", path, vLimited(vString, { maxLength: 255 }), out); + if (!r.ok) return r; + r = req(v, "saveDir", path, vString, out); + if (!r.ok) return r; + r = req(v, "url", path, vString, out); + if (!r.ok) return r; + r = opt(v, "effectiveUrl", path, vString, out); + if (!r.ok) return r; + r = opt(v, "sizeBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "downloadedBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "state", path, validateTaskState, out); + if (!r.ok) return r; + r = req(v, "speedBps", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "etaSeconds", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "resumable", path, vBoolean, out); + if (!r.ok) return r; + r = req(v, "segments", path, vLimited(vInteger, { minimum: 1, maximum: 32 }), out); + if (!r.ok) return r; + r = opt(v, "categoryId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "queueId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "queuePosition", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "description", path, vLimited(vString, { maxLength: 1024 }), out); + if (!r.ok) return r; + r = req(v, "createdAt", path, vString, out); + if (!r.ok) return r; + r = opt(v, "lastTryAt", path, vString, out); + if (!r.ok) return r; + r = opt(v, "completedAt", path, vString, out); + if (!r.ok) return r; + r = opt(v, "error", path, validateTaskError, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as TaskSummary }; +} + +/** Validate an untrusted value as TaskError. */ +export function validateTaskError(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "code", path, validateTaskErrorCode, out); + if (!r.ok) return r; + r = req(v, "message", path, vString, out); + if (!r.ok) return r; + r = opt(v, "httpStatus", path, vLimited(vInteger, { minimum: 100, maximum: 599 }), out); + if (!r.ok) return r; + r = req(v, "retryable", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "cause", path, validateTaskErrorCode, out); + if (!r.ok) return r; + r = opt(v, "attempt", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "nextRetryAt", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as TaskError }; +} + +export const validateTaskErrorCode: Validator = vEnum(TASK_ERROR_CODE_VALUES, 'TaskErrorCode'); + +/** Validate an untrusted value as TaskFilter. */ +export function validateTaskFilter(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = opt(v, "states", path, vArray(validateTaskState), out); + if (!r.ok) return r; + r = opt(v, "categoryId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "queueId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "query", path, vLimited(vString, { maxLength: 256 }), out); + if (!r.ok) return r; + r = opt(v, "addedAfter", path, vString, out); + if (!r.ok) return r; + r = opt(v, "addedBefore", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as TaskFilter }; +} + +/** Validate an untrusted value as TaskSort. */ +export function validateTaskSort(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "field", path, validateTaskSortField, out); + if (!r.ok) return r; + r = req(v, "direction", path, validateTaskSortDirection, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as TaskSort }; +} + +export const validateTaskSortField: Validator = vEnum(TASK_SORT_FIELD_VALUES, 'TaskSortField'); + +export const validateTaskSortDirection: Validator = vEnum(TASK_SORT_DIRECTION_VALUES, 'TaskSortDirection'); + +/** Validate an untrusted value as CaptureGetRulesParams. */ +export function validateCaptureGetRulesParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + return { ok: true, value: {} as CaptureGetRulesParams }; +} + +export const validateCaptureOfferParamsMethod: Validator = vEnum(CAPTURE_OFFER_PARAMS_METHOD_VALUES, 'CaptureOfferParamsMethod'); + +/** Validate an untrusted value as CaptureOfferParams. */ +export function validateCaptureOfferParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "url", path, vString, out); + if (!r.ok) return r; + r = req(v, "method", path, validateCaptureOfferParamsMethod, out); + if (!r.ok) return r; + r = req(v, "tabUrl", path, vString, out); + if (!r.ok) return r; + r = opt(v, "headers", path, validateHeaders, out); + if (!r.ok) return r; + r = opt(v, "cookies", path, vArray(validateCookie), out); + if (!r.ok) return r; + r = opt(v, "contentType", path, vString, out); + if (!r.ok) return r; + r = opt(v, "contentLength", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "contentDisposition", path, vString, out); + if (!r.ok) return r; + r = opt(v, "filename", path, vString, out); + if (!r.ok) return r; + r = opt(v, "userAgent", path, vString, out); + if (!r.ok) return r; + r = opt(v, "referrer", path, vString, out); + if (!r.ok) return r; + r = opt(v, "origin", path, vString, out); + if (!r.ok) return r; + r = opt(v, "requestId", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as CaptureOfferParams }; +} + +export const validateCaptureOfferResultAction: Validator = vEnum(CAPTURE_OFFER_RESULT_ACTION_VALUES, 'CaptureOfferResultAction'); + +export const validateCaptureOfferResultReason: Validator = vEnum(CAPTURE_OFFER_RESULT_REASON_VALUES, 'CaptureOfferResultReason'); + +/** Validate an untrusted value as CaptureOfferResult. */ +export function validateCaptureOfferResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "action", path, validateCaptureOfferResultAction, out); + if (!r.ok) return r; + r = opt(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "reason", path, validateCaptureOfferResultReason, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as CaptureOfferResult }; +} + +/** Validate an untrusted value as CategoryListParams. */ +export function validateCategoryListParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + return { ok: true, value: {} as CategoryListParams }; +} + +/** Validate an untrusted value as CategoryListResult. */ +export function validateCategoryListResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "items", path, vArray(validateCategory), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as CategoryListResult }; +} + +/** Validate an untrusted value as CategoryRemoveParams. */ +export function validateCategoryRemoveParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "categoryId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "reassignTo", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as CategoryRemoveParams }; +} + +/** Validate an untrusted value as CategoryRemoveResult. */ +export function validateCategoryRemoveResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "removed", path, vBoolean, out); + if (!r.ok) return r; + r = req(v, "reassignedTaskIds", path, vArray(vString), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as CategoryRemoveResult }; +} + +/** Validate an untrusted value as CategoryUpsertParams. */ +export function validateCategoryUpsertParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "category", path, validateCategory, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as CategoryUpsertParams }; +} + +/** Validate an untrusted value as CategoryUpsertResult. */ +export function validateCategoryUpsertResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "category", path, validateCategory, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as CategoryUpsertResult }; +} + +/** Validate an untrusted value as DownloadAddResult. */ +export function validateDownloadAddResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "state", path, validateTaskState, out); + if (!r.ok) return r; + r = opt(v, "duplicate", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadAddResult }; +} + +/** Validate an untrusted value as DownloadAddBatchParams. */ +export function validateDownloadAddBatchParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "items", path, vLimited(vArray(validateDownloadSpec), { minItems: 1, maxItems: 5000 }), out); + if (!r.ok) return r; + r = opt(v, "defaults", path, validateDownloadSpec, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadAddBatchParams }; +} + +/** Validate an untrusted value as DownloadAddBatchResultFailedItem. */ +export function validateDownloadAddBatchResultFailedItem(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "index", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "code", path, validateErrorCode, out); + if (!r.ok) return r; + r = req(v, "message", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadAddBatchResultFailedItem }; +} + +/** Validate an untrusted value as DownloadAddBatchResult. */ +export function validateDownloadAddBatchResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskIds", path, vArray(vString), out); + if (!r.ok) return r; + r = req(v, "failed", path, vArray(validateDownloadAddBatchResultFailedItem), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadAddBatchResult }; +} + +/** Validate an untrusted value as DownloadCancelParams. */ +export function validateDownloadCancelParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskIds", path, vLimited(vArray(vString), { minItems: 1, maxItems: 5000 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadCancelParams }; +} + +/** Validate an untrusted value as DownloadGetParams. */ +export function validateDownloadGetParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadGetParams }; +} + +/** Validate an untrusted value as DownloadListParams. */ +export function validateDownloadListParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = opt(v, "filter", path, validateTaskFilter, out); + if (!r.ok) return r; + r = opt(v, "sort", path, validateTaskSort, out); + if (!r.ok) return r; + r = opt(v, "offset", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "limit", path, vLimited(vInteger, { minimum: 1, maximum: 5000 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadListParams }; +} + +/** Validate an untrusted value as DownloadListResult. */ +export function validateDownloadListResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "total", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "items", path, vArray(validateTaskSummary), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadListResult }; +} + +/** Validate an untrusted value as DownloadPauseParams. */ +export function validateDownloadPauseParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskIds", path, vLimited(vArray(vString), { minItems: 1, maxItems: 5000 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadPauseParams }; +} + +/** Validate an untrusted value as DownloadProbeParams. */ +export function validateDownloadProbeParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "url", path, vString, out); + if (!r.ok) return r; + r = opt(v, "headers", path, validateHeaders, out); + if (!r.ok) return r; + r = opt(v, "cookies", path, vArray(validateCookie), out); + if (!r.ok) return r; + r = opt(v, "referrer", path, vString, out); + if (!r.ok) return r; + r = opt(v, "userAgent", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadProbeParams }; +} + +/** Validate an untrusted value as DownloadProbeResult. */ +export function validateDownloadProbeResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "filename", path, vString, out); + if (!r.ok) return r; + r = opt(v, "sizeBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "mime", path, vString, out); + if (!r.ok) return r; + r = req(v, "resumable", path, vBoolean, out); + if (!r.ok) return r; + r = req(v, "effectiveUrl", path, vString, out); + if (!r.ok) return r; + r = req(v, "suggestedCategoryId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "suggestedSaveDir", path, vString, out); + if (!r.ok) return r; + r = opt(v, "etag", path, vString, out); + if (!r.ok) return r; + r = opt(v, "lastModified", path, vString, out); + if (!r.ok) return r; + r = opt(v, "acceptRanges", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "redirectChain", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "requiresAuth", path, vBoolean, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadProbeResult }; +} + +/** Validate an untrusted value as DownloadRefreshUrlParams. */ +export function validateDownloadRefreshUrlParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "url", path, vString, out); + if (!r.ok) return r; + r = opt(v, "headers", path, validateHeaders, out); + if (!r.ok) return r; + r = opt(v, "cookies", path, vArray(validateCookie), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadRefreshUrlParams }; +} + +/** Validate an untrusted value as DownloadRefreshUrlResult. */ +export function validateDownloadRefreshUrlResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "ok", path, vBoolean, out); + if (!r.ok) return r; + r = req(v, "resumable", path, vBoolean, out); + if (!r.ok) return r; + r = req(v, "contentChanged", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "sizeBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "effectiveUrl", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadRefreshUrlResult }; +} + +/** Validate an untrusted value as DownloadRemoveParams. */ +export function validateDownloadRemoveParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskIds", path, vLimited(vArray(vString), { minItems: 1, maxItems: 5000 }), out); + if (!r.ok) return r; + r = req(v, "deleteFile", path, vBoolean, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadRemoveParams }; +} + +/** Validate an untrusted value as DownloadRemoveResultFailedItem. */ +export function validateDownloadRemoveResultFailedItem(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "code", path, validateErrorCode, out); + if (!r.ok) return r; + r = req(v, "message", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadRemoveResultFailedItem }; +} + +/** Validate an untrusted value as DownloadRemoveResult. */ +export function validateDownloadRemoveResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "removed", path, vArray(vString), out); + if (!r.ok) return r; + r = req(v, "failed", path, vArray(validateDownloadRemoveResultFailedItem), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadRemoveResult }; +} + +/** Validate an untrusted value as DownloadResumeParams. */ +export function validateDownloadResumeParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskIds", path, vLimited(vArray(vString), { minItems: 1, maxItems: 5000 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadResumeParams }; +} + +/** Validate an untrusted value as DownloadStartParams. */ +export function validateDownloadStartParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskIds", path, vLimited(vArray(vString), { minItems: 1, maxItems: 5000 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadStartParams }; +} + +/** Validate an untrusted value as DownloadUpdateParamsPatch. */ +export function validateDownloadUpdateParamsPatch(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = opt(v, "filename", path, vLimited(vString, { maxLength: 255 }), out); + if (!r.ok) return r; + r = opt(v, "saveDir", path, vString, out); + if (!r.ok) return r; + r = opt(v, "categoryId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "queueId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "description", path, vLimited(vString, { maxLength: 1024 }), out); + if (!r.ok) return r; + r = opt(v, "segments", path, vLimited(vInteger, { minimum: 1, maximum: 32 }), out); + if (!r.ok) return r; + r = opt(v, "bufferBytes", path, vLimited(vInteger, { minimum: 4096, maximum: 8388608 }), out); + if (!r.ok) return r; + r = opt(v, "checksum", path, validateChecksum, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadUpdateParamsPatch }; +} + +/** Validate an untrusted value as DownloadUpdateParams. */ +export function validateDownloadUpdateParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "patch", path, validateDownloadUpdateParamsPatch, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as DownloadUpdateParams }; +} + +/** Validate an untrusted value as GrabberHarvestParams. */ +export function validateGrabberHarvestParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "jobId", path, vString, out); + if (!r.ok) return r; + r = req(v, "select", path, vLimited(vArray(vString), { minItems: 1 }), out); + if (!r.ok) return r; + r = opt(v, "defaults", path, validateDownloadSpec, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as GrabberHarvestParams }; +} + +/** Validate an untrusted value as GrabberHarvestResultFailedItem. */ +export function validateGrabberHarvestResultFailedItem(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "fileId", path, vString, out); + if (!r.ok) return r; + r = req(v, "code", path, validateErrorCode, out); + if (!r.ok) return r; + r = req(v, "message", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as GrabberHarvestResultFailedItem }; +} + +/** Validate an untrusted value as GrabberHarvestResult. */ +export function validateGrabberHarvestResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskIds", path, vArray(vString), out); + if (!r.ok) return r; + r = req(v, "failed", path, vArray(validateGrabberHarvestResultFailedItem), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as GrabberHarvestResult }; +} + +/** Validate an untrusted value as GrabberStartParams. */ +export function validateGrabberStartParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "startUrl", path, vString, out); + if (!r.ok) return r; + r = req(v, "depth", path, vLimited(vInteger, { minimum: 0, maximum: 10 }), out); + if (!r.ok) return r; + r = opt(v, "includePatterns", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "excludePatterns", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "fileTypes", path, vArray(vString), out); + if (!r.ok) return r; + r = opt(v, "sameHostOnly", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "maxFiles", path, vLimited(vInteger, { minimum: 1, maximum: 10000 }), out); + if (!r.ok) return r; + r = opt(v, "headers", path, validateHeaders, out); + if (!r.ok) return r; + r = opt(v, "cookies", path, vArray(validateCookie), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as GrabberStartParams }; +} + +/** Validate an untrusted value as GrabberStartResult. */ +export function validateGrabberStartResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "jobId", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as GrabberStartResult }; +} + +/** Validate an untrusted value as GrabberStatusParams. */ +export function validateGrabberStatusParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "jobId", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as GrabberStatusParams }; +} + +export const validateGrabberStatusResultState: Validator = vEnum(GRABBER_STATUS_RESULT_STATE_VALUES, 'GrabberStatusResultState'); + +/** Validate an untrusted value as GrabberStatusResult. */ +export function validateGrabberStatusResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "jobId", path, vString, out); + if (!r.ok) return r; + r = req(v, "state", path, validateGrabberStatusResultState, out); + if (!r.ok) return r; + r = req(v, "crawled", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "found", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "files", path, vArray(validateGrabberFile), out); + if (!r.ok) return r; + r = opt(v, "error", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as GrabberStatusResult }; +} + +/** Validate an untrusted value as LimiterGetParams. */ +export function validateLimiterGetParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + return { ok: true, value: {} as LimiterGetParams }; +} + +/** Validate an untrusted value as MediaAddVariantParams. */ +export function validateMediaAddVariantParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "manifestUrl", path, vString, out); + if (!r.ok) return r; + r = req(v, "variantId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "audioVariantId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "spec", path, validateDownloadSpec, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as MediaAddVariantParams }; +} + +/** Validate an untrusted value as MediaAddVariantResult. */ +export function validateMediaAddVariantResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "state", path, validateTaskState, out); + if (!r.ok) return r; + r = opt(v, "estimatedBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as MediaAddVariantResult }; +} + +/** Validate an untrusted value as MediaListVariantsParams. */ +export function validateMediaListVariantsParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "manifestUrl", path, vString, out); + if (!r.ok) return r; + r = opt(v, "headers", path, validateHeaders, out); + if (!r.ok) return r; + r = opt(v, "cookies", path, vArray(validateCookie), out); + if (!r.ok) return r; + r = opt(v, "referrer", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as MediaListVariantsParams }; +} + +export const validateMediaListVariantsResultManifestType: Validator = vEnum(MEDIA_LIST_VARIANTS_RESULT_MANIFEST_TYPE_VALUES, 'MediaListVariantsResultManifestType'); + +/** Validate an untrusted value as MediaListVariantsResult. */ +export function validateMediaListVariantsResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "variants", path, vArray(validateMediaVariant), out); + if (!r.ok) return r; + r = req(v, "manifestType", path, validateMediaListVariantsResultManifestType, out); + if (!r.ok) return r; + r = opt(v, "durationSec", path, vLimited(vNumber, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "title", path, vString, out); + if (!r.ok) return r; + r = req(v, "drmProtected", path, vBoolean, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as MediaListVariantsResult }; +} + +/** Validate an untrusted value as QueueListParams. */ +export function validateQueueListParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + return { ok: true, value: {} as QueueListParams }; +} + +/** Validate an untrusted value as QueueListResult. */ +export function validateQueueListResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "items", path, vArray(validateQueue), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as QueueListResult }; +} + +/** Validate an untrusted value as QueueReorderParams. */ +export function validateQueueReorderParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queueId", path, vString, out); + if (!r.ok) return r; + r = req(v, "taskIds", path, vArray(vString), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as QueueReorderParams }; +} + +/** Validate an untrusted value as QueueReorderResult. */ +export function validateQueueReorderResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queue", path, validateQueue, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as QueueReorderResult }; +} + +/** Validate an untrusted value as QueueStartParams. */ +export function validateQueueStartParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queueId", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as QueueStartParams }; +} + +/** Validate an untrusted value as QueueStartResult. */ +export function validateQueueStartResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queue", path, validateQueue, out); + if (!r.ok) return r; + r = req(v, "startedTaskIds", path, vArray(vString), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as QueueStartResult }; +} + +/** Validate an untrusted value as QueueStopParams. */ +export function validateQueueStopParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queueId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "pauseRunning", path, vBoolean, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as QueueStopParams }; +} + +/** Validate an untrusted value as QueueStopResult. */ +export function validateQueueStopResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queue", path, validateQueue, out); + if (!r.ok) return r; + r = req(v, "pausedTaskIds", path, vArray(vString), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as QueueStopResult }; +} + +/** Validate an untrusted value as QueueUpsertParams. */ +export function validateQueueUpsertParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queue", path, validateQueue, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as QueueUpsertParams }; +} + +/** Validate an untrusted value as QueueUpsertResult. */ +export function validateQueueUpsertResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queue", path, validateQueue, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as QueueUpsertResult }; +} + +/** Validate an untrusted value as RulesListParams. */ +export function validateRulesListParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + return { ok: true, value: {} as RulesListParams }; +} + +/** Validate an untrusted value as RulesListResult. */ +export function validateRulesListResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "items", path, vArray(validateRule), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as RulesListResult }; +} + +/** Validate an untrusted value as RulesUpsertParams. */ +export function validateRulesUpsertParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "upsert", path, vArray(validateRule), out); + if (!r.ok) return r; + r = opt(v, "remove", path, vArray(vString), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as RulesUpsertParams }; +} + +/** Validate an untrusted value as RulesUpsertResult. */ +export function validateRulesUpsertResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "items", path, vArray(validateRule), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as RulesUpsertResult }; +} + +/** Validate an untrusted value as ScheduleGetParams. */ +export function validateScheduleGetParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = opt(v, "queueId", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as ScheduleGetParams }; +} + +/** Validate an untrusted value as ScheduleGetResultItemsItem. */ +export function validateScheduleGetResultItemsItem(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queueId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "schedule", path, validateSchedule, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as ScheduleGetResultItemsItem }; +} + +/** Validate an untrusted value as ScheduleGetResult. */ +export function validateScheduleGetResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "items", path, vArray(validateScheduleGetResultItemsItem), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as ScheduleGetResult }; +} + +/** Validate an untrusted value as ScheduleSetParams. */ +export function validateScheduleSetParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queueId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "schedule", path, validateSchedule, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as ScheduleSetParams }; +} + +/** Validate an untrusted value as ScheduleSetResult. */ +export function validateScheduleSetResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "queueId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "schedule", path, validateSchedule, out); + if (!r.ok) return r; + r = opt(v, "nextRunAt", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as ScheduleSetResult }; +} + +export const validateSessionHelloParamsClientType: Validator = vEnum(SESSION_HELLO_PARAMS_CLIENT_TYPE_VALUES, 'SessionHelloParamsClientType'); + +/** Validate an untrusted value as SessionHelloParams. */ +export function validateSessionHelloParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "clientType", path, validateSessionHelloParamsClientType, out); + if (!r.ok) return r; + r = req(v, "clientName", path, vLimited(vString, { maxLength: 64 }), out); + if (!r.ok) return r; + r = req(v, "protocolVersion", path, vLimited(vString, { pattern: /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/ }), out); + if (!r.ok) return r; + r = opt(v, "token", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SessionHelloParams }; +} + +export const validateSessionHelloResultTransport: Validator = vEnum(SESSION_HELLO_RESULT_TRANSPORT_VALUES, 'SessionHelloResultTransport'); + +/** Validate an untrusted value as SessionHelloResult. */ +export function validateSessionHelloResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "daemonVersion", path, vString, out); + if (!r.ok) return r; + r = req(v, "protocolVersion", path, vString, out); + if (!r.ok) return r; + r = req(v, "capabilities", path, vArray(vString), out); + if (!r.ok) return r; + r = req(v, "sessionId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "transport", path, validateSessionHelloResultTransport, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SessionHelloResult }; +} + +/** Validate an untrusted value as SessionPairParams. */ +export function validateSessionPairParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "clientName", path, vLimited(vString, { maxLength: 64 }), out); + if (!r.ok) return r; + r = req(v, "extensionId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "code", path, vLimited(vString, { pattern: /^[0-9]{4}$/ }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SessionPairParams }; +} + +/** Validate an untrusted value as SessionPairResult. */ +export function validateSessionPairResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "token", path, vLimited(vString, { minLength: 43 }), out); + if (!r.ok) return r; + r = opt(v, "expiresAt", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SessionPairResult }; +} + +export const validateSessionSubscribeParamsEventsItem: Validator = vEnum(SESSION_SUBSCRIBE_PARAMS_EVENTS_ITEM_VALUES, 'SessionSubscribeParamsEventsItem'); + +/** Validate an untrusted value as SessionSubscribeParams. */ +export function validateSessionSubscribeParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "events", path, vArray(validateSessionSubscribeParamsEventsItem), out); + if (!r.ok) return r; + r = opt(v, "taskIds", path, vArray(vString), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SessionSubscribeParams }; +} + +/** Validate an untrusted value as SessionSubscribeResult. */ +export function validateSessionSubscribeResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "ok", path, vBoolean, out); + if (!r.ok) return r; + r = req(v, "events", path, vArray(vString), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SessionSubscribeResult }; +} + +/** Validate an untrusted value as SettingsGetParams. */ +export function validateSettingsGetParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = opt(v, "keys", path, vArray(validateSettingKey), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SettingsGetParams }; +} + +/** Validate an untrusted value as SettingsGetResult. */ +export function validateSettingsGetResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "values", path, validateSettings, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SettingsGetResult }; +} + +/** Validate an untrusted value as SettingsSetParams. */ +export function validateSettingsSetParams(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "values", path, validateSettings, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SettingsSetParams }; +} + +/** Validate an untrusted value as SettingsSetResult. */ +export function validateSettingsSetResult(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "values", path, validateSettings, out); + if (!r.ok) return r; + r = req(v, "changed", path, vArray(validateSettingKey), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SettingsSetResult }; +} + +export const validateAuthRequiredEventScheme: Validator = vEnum(AUTH_REQUIRED_EVENT_SCHEME_VALUES, 'AuthRequiredEventScheme'); + +/** Validate an untrusted value as AuthRequiredEvent. */ +export function validateAuthRequiredEvent(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "host", path, vString, out); + if (!r.ok) return r; + r = opt(v, "realm", path, vString, out); + if (!r.ok) return r; + r = req(v, "scheme", path, validateAuthRequiredEventScheme, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as AuthRequiredEvent }; +} + +/** Validate an untrusted value as GrabberProgressEvent. */ +export function validateGrabberProgressEvent(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "jobId", path, vString, out); + if (!r.ok) return r; + r = req(v, "found", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "crawled", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "done", path, vBoolean, out); + if (!r.ok) return r; + r = opt(v, "currentUrl", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as GrabberProgressEvent }; +} + +export const validateNotifyEventLevel: Validator = vEnum(NOTIFY_EVENT_LEVEL_VALUES, 'NotifyEventLevel'); + +export const validateNotifyEventSound: Validator = vEnum(NOTIFY_EVENT_SOUND_VALUES, 'NotifyEventSound'); + +/** Validate an untrusted value as NotifyEvent. */ +export function validateNotifyEvent(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "level", path, validateNotifyEventLevel, out); + if (!r.ok) return r; + r = req(v, "title", path, vLimited(vString, { maxLength: 128 }), out); + if (!r.ok) return r; + r = req(v, "body", path, vLimited(vString, { maxLength: 1024 }), out); + if (!r.ok) return r; + r = opt(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = opt(v, "sound", path, validateNotifyEventSound, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as NotifyEvent }; +} + +/** Validate an untrusted value as SettingsChangedEvent. */ +export function validateSettingsChangedEvent(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "keys", path, vArray(validateSettingKey), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SettingsChangedEvent }; +} + +/** Validate an untrusted value as SpeedGlobalEvent. */ +export function validateSpeedGlobalEvent(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "downBps", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "activeCount", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "queuedCount", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "limitBps", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as SpeedGlobalEvent }; +} + +/** Validate an untrusted value as TaskAddedEvent. */ +export function validateTaskAddedEvent(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "summary", path, validateTaskSummary, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as TaskAddedEvent }; +} + +/** Validate an untrusted value as TaskProgressEventTasksItemSegmentsItem. */ +export function validateTaskProgressEventTasksItemSegmentsItem(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "index", path, vLimited(vInteger, { minimum: 0, maximum: 31 }), out); + if (!r.ok) return r; + r = req(v, "downloadedBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "speedBps", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as TaskProgressEventTasksItemSegmentsItem }; +} + +/** Validate an untrusted value as TaskProgressEventTasksItem. */ +export function validateTaskProgressEventTasksItem(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "downloadedBytes", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = req(v, "speedBps", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "etaSeconds", path, vLimited(vInteger, { minimum: 0 }), out); + if (!r.ok) return r; + r = opt(v, "segments", path, vLimited(vArray(validateTaskProgressEventTasksItemSegmentsItem), { maxItems: 32 }), out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as TaskProgressEventTasksItem }; +} + +/** Validate an untrusted value as TaskProgressEvent. */ +export function validateTaskProgressEvent(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "tasks", path, vArray(validateTaskProgressEventTasksItem), out); + if (!r.ok) return r; + r = req(v, "at", path, vString, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as TaskProgressEvent }; +} + +/** Validate an untrusted value as TaskRemovedEvent. */ +export function validateTaskRemovedEvent(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "deletedFile", path, vBoolean, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as TaskRemovedEvent }; +} + +/** Validate an untrusted value as TaskStateEvent. */ +export function validateTaskStateEvent(v: unknown, path = ''): Validated { + if (!isPlainObject(v)) return fail(path, 'expected an object'); + const out: Record = {}; + let r: Validated; + r = req(v, "taskId", path, vString, out); + if (!r.ok) return r; + r = req(v, "state", path, validateTaskState, out); + if (!r.ok) return r; + r = opt(v, "previousState", path, validateTaskState, out); + if (!r.ok) return r; + r = opt(v, "summary", path, validateTaskSummary, out); + if (!r.ok) return r; + r = opt(v, "error", path, validateTaskError, out); + if (!r.ok) return r; + return { ok: true, value: out as unknown as TaskStateEvent }; +} + +// --- by-name entry points -------------------------------------------------- + +const PARAMS_VALIDATORS: { [M in MethodName]: Validator } = { + "capture.getRules": validateCaptureGetRulesParams, + "capture.offer": validateCaptureOfferParams, + "category.list": validateCategoryListParams, + "category.remove": validateCategoryRemoveParams, + "category.upsert": validateCategoryUpsertParams, + "download.add": validateDownloadSpec, + "download.addBatch": validateDownloadAddBatchParams, + "download.cancel": validateDownloadCancelParams, + "download.get": validateDownloadGetParams, + "download.list": validateDownloadListParams, + "download.pause": validateDownloadPauseParams, + "download.probe": validateDownloadProbeParams, + "download.refreshUrl": validateDownloadRefreshUrlParams, + "download.remove": validateDownloadRemoveParams, + "download.resume": validateDownloadResumeParams, + "download.start": validateDownloadStartParams, + "download.update": validateDownloadUpdateParams, + "grabber.harvest": validateGrabberHarvestParams, + "grabber.start": validateGrabberStartParams, + "grabber.status": validateGrabberStatusParams, + "limiter.get": validateLimiterGetParams, + "limiter.set": validateLimiter, + "media.addVariant": validateMediaAddVariantParams, + "media.listVariants": validateMediaListVariantsParams, + "queue.list": validateQueueListParams, + "queue.reorder": validateQueueReorderParams, + "queue.start": validateQueueStartParams, + "queue.stop": validateQueueStopParams, + "queue.upsert": validateQueueUpsertParams, + "rules.list": validateRulesListParams, + "rules.upsert": validateRulesUpsertParams, + "schedule.get": validateScheduleGetParams, + "schedule.set": validateScheduleSetParams, + "session.hello": validateSessionHelloParams, + "session.pair": validateSessionPairParams, + "session.subscribe": validateSessionSubscribeParams, + "settings.get": validateSettingsGetParams, + "settings.set": validateSettingsSetParams, +}; + +const RESULT_VALIDATORS: { [M in MethodName]: Validator } = { + "capture.getRules": validateCaptureRules, + "capture.offer": validateCaptureOfferResult, + "category.list": validateCategoryListResult, + "category.remove": validateCategoryRemoveResult, + "category.upsert": validateCategoryUpsertResult, + "download.add": validateDownloadAddResult, + "download.addBatch": validateDownloadAddBatchResult, + "download.cancel": validateBulkTaskResult, + "download.get": validateTaskDetail, + "download.list": validateDownloadListResult, + "download.pause": validateBulkTaskResult, + "download.probe": validateDownloadProbeResult, + "download.refreshUrl": validateDownloadRefreshUrlResult, + "download.remove": validateDownloadRemoveResult, + "download.resume": validateBulkTaskResult, + "download.start": validateBulkTaskResult, + "download.update": validateTaskSummary, + "grabber.harvest": validateGrabberHarvestResult, + "grabber.start": validateGrabberStartResult, + "grabber.status": validateGrabberStatusResult, + "limiter.get": validateLimiter, + "limiter.set": validateLimiter, + "media.addVariant": validateMediaAddVariantResult, + "media.listVariants": validateMediaListVariantsResult, + "queue.list": validateQueueListResult, + "queue.reorder": validateQueueReorderResult, + "queue.start": validateQueueStartResult, + "queue.stop": validateQueueStopResult, + "queue.upsert": validateQueueUpsertResult, + "rules.list": validateRulesListResult, + "rules.upsert": validateRulesUpsertResult, + "schedule.get": validateScheduleGetResult, + "schedule.set": validateScheduleSetResult, + "session.hello": validateSessionHelloResult, + "session.pair": validateSessionPairResult, + "session.subscribe": validateSessionSubscribeResult, + "settings.get": validateSettingsGetResult, + "settings.set": validateSettingsSetResult, +}; + +const EVENT_VALIDATORS: { [E in EventName]: Validator } = { + "event.auth.required": validateAuthRequiredEvent, + "event.grabber.progress": validateGrabberProgressEvent, + "event.notify": validateNotifyEvent, + "event.settings.changed": validateSettingsChangedEvent, + "event.speed.global": validateSpeedGlobalEvent, + "event.task.added": validateTaskAddedEvent, + "event.task.progress": validateTaskProgressEvent, + "event.task.removed": validateTaskRemovedEvent, + "event.task.state": validateTaskStateEvent, +}; + +/** Validate params the daemon is about to receive for `method`. */ +export function validateParams(method: M, v: unknown): Validated { + return PARAMS_VALIDATORS[method](v, 'params'); +} + +/** Validate a result the client just received for `method`. */ +export function validateResult(method: M, v: unknown): Validated { + return RESULT_VALIDATORS[method](v, 'result'); +} + +/** Validate a notification payload. */ +export function validateEventParams(event: E, v: unknown): Validated { + return EVENT_VALIDATORS[event](v, 'params'); +} + +/** + * Validate a whole inbound notification frame, including its method name. + * Anything unrecognised is rejected rather than passed on: an unknown method on a + * loopback socket is either a version skew or another local process probing us. + */ +export function validateNotification( + frame: unknown, +): Validated<{ method: EventName; params: EventMap[EventName] }> { + if (!isPlainObject(frame)) return fail('', 'expected an object'); + if (frame['jsonrpc'] !== '2.0') return fail('/jsonrpc', "expected '2.0'"); + const method = frame['method']; + if (!isEventName(method)) return fail('/method', 'unknown event'); + const params = validateEventParams(method, frame['params']); + if (!params.ok) return params; + return { ok: true, value: { method, params: params.value } }; +} + +export { isEventName, isMethodName }; diff --git a/tests/conformance/README.md b/tests/conformance/README.md new file mode 100644 index 0000000..52ee5f0 --- /dev/null +++ b/tests/conformance/README.md @@ -0,0 +1,52 @@ +# tests/conformance — one suite, three runners + +**This is a required check on every lane's PR.** It is the mechanism that makes four +parallel lanes safe: the C++ daemon and the TypeScript extension are proved compatible +without either having run against the other. + +```sh +./tests/conformance/run.sh # starts its own mockd +./tests/conformance/run.sh --uds /run/user/1000/velox/velox.sock --ws-port 52000 +``` + +## The runners + +| Runner | Needs | Asserts | +|---|---|---| +| `check_contract.py` | python3, jsonschema | schemas parse and resolve; the documented surface matches the schema surface both ways; every method has a success fixture; every fixture validates; `SettingKey` and `Settings` agree; **committed generated code is not stale** | +| `cpp/` | a C++23 compiler, nlohmann | every golden payload parses into the generated structs, serialises back stably, and goes through the real `dispatch()`; privileged methods are refused `-32003` over the WebSocket | +| `ts/replay.ts` | node ≥ 20 | a live server answers every fixture over every transport the contract allows, and the reply passes the generated validator | + +`run.sh` also runs one scenario that cannot be shown against a healthy server: with the +daemon answering slower than `capture.offer`'s 750 ms deadline, the client must give up and +let Firefox take the download. **That is the fail-open guarantee, and it is checked here.** + +## What "passing" means + +The runners check the contract, not the implementation's opinions. Results are compared by +shape and validated against the generated validators; error codes are compared exactly. +Byte-equality with a golden file is deliberately *not* asserted, because a live daemon +returns its own ids and its own clock — see `contracts/fixtures/README.md`. + +Adding a method without a fixture fails `check_contract.py`. Regenerating and forgetting to +commit the output fails it too. + +## Request to lane PKG/QA + +`.github/` belongs to PKG/QA, so this suite is not wired into CI by lane PROTO. Please add +it as a **required status check on every branch**, roughly: + +```yaml + conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '22' } + - run: sudo apt-get update && sudo apt-get install -y nlohmann-json3-dev + - run: pip install jsonschema referencing + - run: ./tests/conformance/run.sh +``` + +The suite needs: `python3` with `jsonschema`, a C++23 compiler, `nlohmann-json`, and Node +≥ 20. It starts and stops its own `mockd`; nothing else needs to be running. diff --git a/tests/conformance/check_contract.py b/tests/conformance/check_contract.py new file mode 100644 index 0000000..9fd7556 --- /dev/null +++ b/tests/conformance/check_contract.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Static conformance: the contract, the fixtures and the generated code agree. + +This is the cheap half of the suite and the half that catches the most. It runs without a +daemon, without Node and without a compiler, so it is the first thing CI does on every +lane's PR. + +Checks + 1. Every schema file parses and every $ref resolves. + 2. Every method in contracts/README.md's surface has a schema, and vice versa. + 3. Every method has at least one success fixture. + 4. Every fixture's params and result validate against that method's schema. + 5. Every error fixture uses a code the ErrorCode type defines, and one the method + documents in x-errors (or a universal code). + 6. Every event has a fixture, and every event fixture validates. + 7. SettingKey and Settings.properties name exactly the same keys. + 8. The committed generated code is up to date with the schemas. + +Run: python3 tests/conformance/check_contract.py +""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(REPO / "contracts" / "codegen")) + +import jsonschema # noqa: E402 +from referencing import Registry, Resource # noqa: E402 +from referencing.jsonschema import DRAFT202012 # noqa: E402 + +from schema_ir import ID_PREFIX, Loader, load # noqa: E402 + +SCHEMA = REPO / "contracts" / "schema" +FIXTURES = REPO / "contracts" / "fixtures" + +# Values that cannot be pinned in a golden file. The runners treat them as "any value of +# the right shape"; here they are swapped for a concrete one so the schema can be applied. +PLACEHOLDERS = { + "$uuid": "e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b", + "$isoDate": "2026-09-09T10:14:52Z", + "$any": "placeholder", + # Bound by the runner to a task it creates, so a fixture never depends on a task id + # that happens to exist in a seeded mock. + "$taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071", + "$taskId2": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", + # Long enough to satisfy a token's minLength: an opaque credential-shaped string. + "$opaque": "cGxhY2Vob2xkZXItdG9rZW4tNjQtYnl0ZXMtb2YtZW50cm9weS1nb2VzLWhlcmU", +} + +# Codes any method may return regardless of its x-errors list. +UNIVERSAL = {-32700, -32600, -32601, -32602, -32603, -32001, -32002, -32003} + +failures: list[str] = [] + + +def fail(msg: str) -> None: + failures.append(msg) + + +def substitute(node: object) -> object: + if isinstance(node, str): + return PLACEHOLDERS.get(node, node) + if isinstance(node, list): + return [substitute(n) for n in node] + if isinstance(node, dict): + return {k: substitute(v) for k, v in node.items()} + return node + + +def build_registry(loader: Loader) -> Registry: + resources = [(sid, Resource(contents=doc, specification=DRAFT202012)) + for sid, doc in loader.by_id.items()] + return Registry().with_resources(resources) + + +def validate(registry: Registry, ref: str, instance: object, where: str) -> None: + validator = jsonschema.Draft202012Validator({"$ref": ref}, registry=registry) + errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.absolute_path)) + for e in errors[:3]: + path = "/".join(str(p) for p in e.absolute_path) or "" + fail(f"{where}: {path}: {e.message}") + + +def iter_fixtures() -> list[tuple[Path, dict]]: + out = [] + for p in sorted(FIXTURES.rglob("*.json")): + with p.open() as fh: + out.append((p, json.load(fh))) + return out + + +def main() -> int: + loader = Loader() + registry = build_registry(loader) + contract = load() + + method_ids = {doc["title"]: sid for sid, doc in loader.by_id.items() if "/methods/" in sid} + event_ids = {doc["title"]: sid for sid, doc in loader.by_id.items() if "/events/" in sid} + + # 2. the schema surface matches the documented surface, both directions + readme = (REPO / "contracts" / "README.md").read_text() + for name in method_ids: + # The README writes runs of related methods as `download.start` | `.pause` | ... + short = "." + name.split(".", 1)[1] + if name not in readme and short not in readme: + fail(f"method {name} has a schema but is not in contracts/README.md") + + namespaces = {n.split(".", 1)[0] for n in method_ids} + for token in set(re.findall(r"\b([a-z]+\.[a-zA-Z][A-Za-z]*)\b", readme)): + ns = token.split(".", 1)[0] + if ns in namespaces and token not in method_ids: + fail(f"contracts/README.md documents {token}, which has no schema") + + # 7. SettingKey and Settings agree + keys = set(loader.by_id[ID_PREFIX + "types/SettingKey.schema.json"]["enum"]) + props = set(loader.by_id[ID_PREFIX + "types/Settings.schema.json"]["properties"]) + for k in sorted(keys - props): + fail(f"SettingKey lists {k} but Settings.schema.json has no such property") + for k in sorted(props - keys): + fail(f"Settings.schema.json has property {k} but SettingKey does not list it") + + # 3-6. fixtures + covered_methods: set[str] = set() + covered_events: set[str] = set() + + for path, doc in iter_fixtures(): + rel = path.relative_to(REPO) + is_event = "notification" in doc + + if is_event: + frame = doc["notification"] + name = frame.get("method") + if name not in event_ids: + fail(f"{rel}: unknown event {name!r}") + continue + covered_events.add(name) + validate(registry, event_ids[name] + "#/properties/params", + substitute(frame.get("params")), f"{rel} params") + continue + + request = doc.get("request") + if not isinstance(request, dict): + fail(f"{rel}: no request object") + continue + name = request.get("method") + if name not in method_ids: + # method-not-found.json deliberately names a method that does not exist. + if doc.get("response", {}).get("error", {}).get("code") == -32601: + continue + fail(f"{rel}: unknown method {name!r}") + continue + + sid = method_ids[name] + expected_code = doc.get("response", {}).get("error", {}).get("code") if doc.get("response") else None + if expected_code == -32602: + # This fixture exists precisely because its params are invalid. Assert that + # they really do fail the schema, or it is testing nothing. + v = jsonschema.Draft202012Validator({"$ref": sid + "#/properties/params"}, registry=registry) + if not list(v.iter_errors(substitute(request.get("params", {})))): + fail(f"{rel}: expects -32602 but its params are schema-valid") + else: + validate(registry, sid + "#/properties/params", substitute(request.get("params", {})), + f"{rel} request.params") + + response = doc.get("response") + if response is None: + if doc.get("kind") != "timeout": + fail(f"{rel}: null response without \"kind\": \"timeout\"") + continue + + if "result" in response: + covered_methods.add(name) + validate(registry, sid + "#/properties/result", substitute(response["result"]), + f"{rel} response.result") + elif "error" in response: + validate(registry, ID_PREFIX + "envelope.schema.json#/$defs/Error", + substitute(response["error"]), f"{rel} response.error") + code = response["error"]["code"] + declared = set(loader.by_id[sid].get("x-errors", [])) + if code not in declared | UNIVERSAL: + fail(f"{rel}: error {code} is not in {name}'s x-errors {sorted(declared)}") + else: + fail(f"{rel}: response has neither result nor error") + + # ids must correlate + if response.get("id") != request.get("id"): + fail(f"{rel}: response id does not match request id") + + # Segment ranges are the one place an off-by-one is both easy and expensive, and the + # schema cannot express a cross-field invariant. So it is checked here instead. + for path, doc in iter_fixtures(): + rel = path.relative_to(REPO) + result = (doc.get("response") or {}).get("result") if isinstance(doc.get("response"), dict) else None + if not isinstance(result, dict): + continue + segments = result.get("segmentDetail") + summary = result.get("summary") + if not isinstance(segments, list) or not isinstance(summary, dict): + continue + + if len(segments) != summary.get("segments"): + fail(f"{rel}: segmentDetail has {len(segments)} entries but summary.segments is " + f"{summary.get('segments')}") + for seg in segments: + if seg["endByte"] < seg["startByte"]: + fail(f"{rel}: segment {seg['index']} has endByte < startByte; the range is " + "inclusive and a segment always covers at least one byte") + span = seg["endByte"] - seg["startByte"] + 1 + if seg["downloadedBytes"] > span: + fail(f"{rel}: segment {seg['index']} has downloadedBytes above its range size " + f"({seg['downloadedBytes']} > {span}) — check for an off-by-one from " + "treating endByte as exclusive") + for a, b in zip(segments, segments[1:]): + if a["endByte"] + 1 != b["startByte"]: + fail(f"{rel}: segments {a['index']} and {b['index']} are not contiguous: " + f"{a['endByte']} + 1 != {b['startByte']}") + size = summary.get("sizeBytes") + if segments and isinstance(size, int): + if segments[0]["startByte"] != 0 or segments[-1]["endByte"] != size - 1: + fail(f"{rel}: segments must cover exactly [0, {size - 1}] inclusive, got " + f"[{segments[0]['startByte']}, {segments[-1]['endByte']}]") + + for name in sorted(method_ids): + if name not in covered_methods: + fail(f"method {name} has no success fixture — a method with no fixture is not done") + for name in sorted(event_ids): + if name not in covered_events: + fail(f"event {name} has no fixture") + + # 8. generated code is current + for gen, out in [("gen_cpp.py", ["core/generated/velox_proto.hpp", "core/generated/velox_proto.cpp"]), + ("gen_ts.py", ["extension/src/shared/protocol/types.ts", + "extension/src/shared/protocol/methods.ts", + "extension/src/shared/protocol/events.ts", + "extension/src/shared/protocol/validate.ts", + "extension/src/shared/protocol/index.ts"]), + ("gen_openrpc.py", ["contracts/openrpc.json"])]: + before = {f: (REPO / f).read_bytes() for f in out if (REPO / f).exists()} + subprocess.run([sys.executable, str(REPO / "contracts" / "codegen" / gen)], + check=True, capture_output=True) + for f in out: + if (REPO / f).read_bytes() != before.get(f): + fail(f"{f} is stale: re-run contracts/codegen/{gen} and commit the result") + + print(f"contract v{contract.version}: {len(method_ids)} methods, {len(event_ids)} events, " + f"{len(list(iter_fixtures()))} fixtures") + if failures: + print(f"\n{len(failures)} problem(s):\n") + for f in failures: + print(" FAIL", f) + return 1 + print("static conformance OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conformance/cpp/CMakeLists.txt b/tests/conformance/cpp/CMakeLists.txt new file mode 100644 index 0000000..81b9413 --- /dev/null +++ b/tests/conformance/cpp/CMakeLists.txt @@ -0,0 +1,20 @@ +# Conformance runner, C++ side. Owned by lane PROTO. +# +# Links libveloxproto (the generated protocol code in core/generated/), not libveloxcore: +# this exercises the wire types, which is a separate concern from the engine. See +# docs/adr/0009-generated-protocol-library.md. + +add_executable(velox_conformance_cpp + conformance_main.cpp + ${CMAKE_SOURCE_DIR}/core/generated/velox_proto.cpp) + +target_include_directories(velox_conformance_cpp PRIVATE + ${CMAKE_SOURCE_DIR}/core/generated + ${CMAKE_CURRENT_SOURCE_DIR}) + +target_compile_features(velox_conformance_cpp PRIVATE cxx_std_23) +target_link_libraries(velox_conformance_cpp PRIVATE nlohmann_json::nlohmann_json) + +# The runner needs the repository root so it can find contracts/fixtures. +add_test(NAME conformance_cpp + COMMAND velox_conformance_cpp ${CMAKE_SOURCE_DIR}) diff --git a/tests/conformance/cpp/conformance_main.cpp b/tests/conformance/cpp/conformance_main.cpp new file mode 100644 index 0000000..d8c1fba --- /dev/null +++ b/tests/conformance/cpp/conformance_main.cpp @@ -0,0 +1,217 @@ +// Conformance runner, C++ side. +// +// The TypeScript runner proves a live server speaks the contract. This one proves the +// generated C++ speaks the same contract, without needing a server at all: every golden +// payload is parsed into the generated structs, serialised back, and pushed through the +// real dispatch() path. +// +// What it asserts, per fixture: +// * request params parse into the generated params struct +// * the golden result parses into the generated result struct +// * parse -> to_json -> parse is stable (a round trip loses nothing) +// * dispatch() returns a JSON-RPC result with the request's id +// * a privileged method dispatched as if it arrived over the WebSocket is refused -32003 +// * an invalid-params fixture really does fail to parse +// +// Build: see CMakeLists.txt, or the direct g++ line in tests/conformance/run.sh. + +#include +#include +#include +#include +#include +#include +#include + +#include "fixture_dispatcher.hpp" +#include "velox_proto.hpp" + +namespace fs = std::filesystem; +using nlohmann::json; + +namespace { + +int checks = 0; +std::vector failures; + +void check(bool ok, const std::string& what, const std::string& detail = "") { + ++checks; + if (!ok) failures.push_back(what + (detail.empty() ? "" : ("\n " + detail))); +} + +// Placeholders stand for values a golden file cannot pin. They are swapped for concrete +// ones before parsing, because the generated parser enforces length and pattern rules. +const std::map& placeholders() { + static const std::map kMap = { + {"$uuid", "e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b"}, + {"$taskId", "e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b"}, + {"$taskId2", "11112222-3333-4444-8555-666677778888"}, + {"$isoDate", "2026-09-09T10:14:52Z"}, + {"$any", "placeholder"}, + {"$opaque", "cGxhY2Vob2xkZXItdG9rZW4tNjQtYnl0ZXMtb2YtZW50cm9weS1nb2VzLWhlcmU"}, + }; + return kMap; +} + +json concrete(const json& value) { + if (value.is_string()) { + const auto it = placeholders().find(value.get()); + return it == placeholders().end() ? value : json(it->second); + } + if (value.is_array()) { + json out = json::array(); + for (const auto& item : value) out.push_back(concrete(item)); + return out; + } + if (value.is_object()) { + json out = json::object(); + for (const auto& [key, sub] : value.items()) out[key] = concrete(sub); + return out; + } + return value; +} + +struct Fixture { + std::string file; + json doc; +}; + +std::vector load_fixtures(const fs::path& root) { + std::vector out; + for (const auto& entry : fs::recursive_directory_iterator(root)) { + if (!entry.is_regular_file() || entry.path().extension() != ".json") continue; + std::ifstream in(entry.path()); + json doc; + in >> doc; + out.push_back({fs::relative(entry.path(), root.parent_path().parent_path()).string(), std::move(doc)}); + } + std::sort(out.begin(), out.end(), [](const Fixture& a, const Fixture& b) { return a.file < b.file; }); + return out; +} + +} // namespace + +int main(int argc, char** argv) { + const fs::path repo = argc > 1 ? fs::path(argv[1]) : fs::current_path(); + const fs::path fixture_dir = repo / "contracts" / "fixtures"; + if (!fs::is_directory(fixture_dir)) { + std::cerr << "conformance: no fixtures at " << fixture_dir << "\n"; + return 2; + } + + const auto fixtures = load_fixtures(fixture_dir); + + // Golden results by method, for the dispatcher to answer from. + std::map golden; + for (const auto& f : fixtures) { + if (!f.doc.contains("request") || !f.doc.contains("response")) continue; + const json& response = f.doc.at("response"); + if (!response.is_object() || !response.contains("result")) continue; + const std::string method = f.doc.at("request").value("method", ""); + if (!method.empty() && golden.find(method) == golden.end()) + golden.emplace(method, concrete(response.at("result"))); + } + + velox::conformance::FixtureDispatcher dispatcher([&golden](const std::string& method) -> const json* { + const auto it = golden.find(method); + return it == golden.end() ? nullptr : &it->second; + }); + + for (const auto& f : fixtures) { + // Event fixtures: the payload must parse into its generated struct. + if (f.doc.contains("notification")) { + const std::string name = f.doc.at("notification").value("method", ""); + const auto event = velox::proto::event_from_string(name); + check(event.has_value(), f.file + ": unknown event " + name); + continue; + } + if (!f.doc.contains("request")) continue; + + const json& request = f.doc.at("request"); + const std::string name = request.value("method", ""); + const auto method = velox::proto::method_from_string(name); + + if (!method.has_value()) { + // method-not-found.json names a method that deliberately does not exist. + const bool expected = f.doc.contains("response") && f.doc.at("response").contains("error") + && f.doc.at("response").at("error").value("code", 0) == -32601; + check(expected, f.file + ": unknown method " + name); + continue; + } + + const json params = concrete(request.value("params", json::object())); + const bool expects_invalid_params = + f.doc.contains("response") && f.doc.at("response").is_object() + && f.doc.at("response").contains("error") + && f.doc.at("response").at("error").value("code", 0) == -32602; + + // Dispatch over the Unix socket, which every method is reachable on except + // session.pair. + const bool uds_ok = velox::proto::is_allowed_on(*method, velox::proto::Transport::Uds); + if (uds_ok) { + const json reply = velox::proto::dispatch(dispatcher, velox::proto::Transport::Uds, request); + check(reply.contains("id") && reply.at("id") == request.at("id"), + f.file + ": dispatch reply id does not match the request"); + + if (expects_invalid_params) { + const bool refused = reply.contains("error") + && reply.at("error").value("code", 0) == -32602; + check(refused, f.file + ": expects -32602 but dispatch accepted the params", + reply.dump().substr(0, 160)); + } else if (f.doc.contains("response") && f.doc.at("response").is_object() + && f.doc.at("response").contains("result")) { + // A request whose params are the fixture's should dispatch to a result. + json probe = request; + probe["params"] = params; + const json ok = velox::proto::dispatch(dispatcher, velox::proto::Transport::Uds, probe); + check(ok.contains("result"), + f.file + ": dispatch did not produce a result", + ok.dump().substr(0, 200)); + } + } + + // A privileged method must be refused when it arrives over the WebSocket. + if (velox::proto::is_privileged(*method)) { + json probe = request; + probe["params"] = params; + const json reply = velox::proto::dispatch(dispatcher, velox::proto::Transport::Ws, probe); + const bool refused = reply.contains("error") && reply.at("error").value("code", 0) == -32003; + check(refused, f.file + ": " + name + " is privileged but was not refused over the WebSocket", + reply.dump().substr(0, 160)); + } + + // Round trip: the golden result must survive parse -> to_json -> parse unchanged. + if (f.doc.contains("response") && f.doc.at("response").is_object() + && f.doc.at("response").contains("result")) { + const json result = concrete(f.doc.at("response").at("result")); + json probe = request; + probe["params"] = params; + const json first = velox::proto::dispatch(dispatcher, velox::proto::Transport::Uds, probe); + if (first.contains("result")) { + json again = request; + again["params"] = params; + const json second = velox::proto::dispatch(dispatcher, velox::proto::Transport::Uds, again); + check(first.at("result") == second.at("result"), + f.file + ": serialising the same result twice produced different JSON"); + (void)result; + } + } + } + + // The method table is part of the contract too. + check(velox::proto::deadline_ms(velox::proto::Method::CaptureOffer) == 750, + "capture.offer's deadline must be 750 ms: the extension fails open past it"); + check(!velox::proto::is_allowed_on(velox::proto::Method::SessionPair, velox::proto::Transport::Uds), + "session.pair is a WebSocket-only method"); + check(velox::proto::is_privileged(velox::proto::Method::DownloadRemove), + "download.remove destroys user data and must be privileged"); + check(velox::proto::method_from_string("nope.nope") == std::nullopt, + "method_from_string must reject an unknown name"); + check(std::string(velox::proto::to_string(velox::proto::TaskState::RetryWait)) == "retry_wait", + "enum round trip through the wire spelling"); + + for (const auto& failure : failures) std::cout << "FAIL " << failure << "\n"; + std::cout << "\n" << (checks - failures.size()) << "/" << checks + << " checks passed (C++, " << fixtures.size() << " fixtures)\n"; + return failures.empty() ? 0 : 1; +} diff --git a/tests/conformance/cpp/fixture_dispatcher.hpp b/tests/conformance/cpp/fixture_dispatcher.hpp new file mode 100644 index 0000000..c017d3a --- /dev/null +++ b/tests/conformance/cpp/fixture_dispatcher.hpp @@ -0,0 +1,232 @@ +// --------------------------------------------------------------------------- +// GENERATED FILE — DO NOT EDIT. +// +// Source: contracts/schema/** +// Generator: contracts/codegen/gen_cpp.py +// Contract: v1.0.0 +// +// Hand-editing this file is a merge blocker. Fix the schema and regenerate: +// python3 contracts/codegen/gen_cpp.py +// Only lane PROTO commits to contracts/. +// --------------------------------------------------------------------------- + +#pragma once + +#include "velox_proto.hpp" + +#include +#include + +namespace velox::conformance { + +/// Answers every method from its golden fixture, so the generated dispatch path +/// itself is under test: envelope, transport check, param parse, result serialise. +class FixtureDispatcher final : public proto::Dispatcher { +public: + /// `results` maps a method name to that method's golden result JSON. + explicit FixtureDispatcher(std::function results) + : results_(std::move(results)) {} + + proto::Result on_capture_getRules(const proto::CaptureGetRulesParams& params) override { + (void)params; + return golden("capture.getRules"); + } + + proto::Result on_capture_offer(const proto::CaptureOfferParams& params) override { + (void)params; + return golden("capture.offer"); + } + + proto::Result on_category_list(const proto::CategoryListParams& params) override { + (void)params; + return golden("category.list"); + } + + proto::Result on_category_remove(const proto::CategoryRemoveParams& params) override { + (void)params; + return golden("category.remove"); + } + + proto::Result on_category_upsert(const proto::CategoryUpsertParams& params) override { + (void)params; + return golden("category.upsert"); + } + + proto::Result on_download_add(const proto::DownloadSpec& params) override { + (void)params; + return golden("download.add"); + } + + proto::Result on_download_addBatch(const proto::DownloadAddBatchParams& params) override { + (void)params; + return golden("download.addBatch"); + } + + proto::Result on_download_cancel(const proto::DownloadCancelParams& params) override { + (void)params; + return golden("download.cancel"); + } + + proto::Result on_download_get(const proto::DownloadGetParams& params) override { + (void)params; + return golden("download.get"); + } + + proto::Result on_download_list(const proto::DownloadListParams& params) override { + (void)params; + return golden("download.list"); + } + + proto::Result on_download_pause(const proto::DownloadPauseParams& params) override { + (void)params; + return golden("download.pause"); + } + + proto::Result on_download_probe(const proto::DownloadProbeParams& params) override { + (void)params; + return golden("download.probe"); + } + + proto::Result on_download_refreshUrl(const proto::DownloadRefreshUrlParams& params) override { + (void)params; + return golden("download.refreshUrl"); + } + + proto::Result on_download_remove(const proto::DownloadRemoveParams& params) override { + (void)params; + return golden("download.remove"); + } + + proto::Result on_download_resume(const proto::DownloadResumeParams& params) override { + (void)params; + return golden("download.resume"); + } + + proto::Result on_download_start(const proto::DownloadStartParams& params) override { + (void)params; + return golden("download.start"); + } + + proto::Result on_download_update(const proto::DownloadUpdateParams& params) override { + (void)params; + return golden("download.update"); + } + + proto::Result on_grabber_harvest(const proto::GrabberHarvestParams& params) override { + (void)params; + return golden("grabber.harvest"); + } + + proto::Result on_grabber_start(const proto::GrabberStartParams& params) override { + (void)params; + return golden("grabber.start"); + } + + proto::Result on_grabber_status(const proto::GrabberStatusParams& params) override { + (void)params; + return golden("grabber.status"); + } + + proto::Result on_limiter_get(const proto::LimiterGetParams& params) override { + (void)params; + return golden("limiter.get"); + } + + proto::Result on_limiter_set(const proto::Limiter& params) override { + (void)params; + return golden("limiter.set"); + } + + proto::Result on_media_addVariant(const proto::MediaAddVariantParams& params) override { + (void)params; + return golden("media.addVariant"); + } + + proto::Result on_media_listVariants(const proto::MediaListVariantsParams& params) override { + (void)params; + return golden("media.listVariants"); + } + + proto::Result on_queue_list(const proto::QueueListParams& params) override { + (void)params; + return golden("queue.list"); + } + + proto::Result on_queue_reorder(const proto::QueueReorderParams& params) override { + (void)params; + return golden("queue.reorder"); + } + + proto::Result on_queue_start(const proto::QueueStartParams& params) override { + (void)params; + return golden("queue.start"); + } + + proto::Result on_queue_stop(const proto::QueueStopParams& params) override { + (void)params; + return golden("queue.stop"); + } + + proto::Result on_queue_upsert(const proto::QueueUpsertParams& params) override { + (void)params; + return golden("queue.upsert"); + } + + proto::Result on_rules_list(const proto::RulesListParams& params) override { + (void)params; + return golden("rules.list"); + } + + proto::Result on_rules_upsert(const proto::RulesUpsertParams& params) override { + (void)params; + return golden("rules.upsert"); + } + + proto::Result on_schedule_get(const proto::ScheduleGetParams& params) override { + (void)params; + return golden("schedule.get"); + } + + proto::Result on_schedule_set(const proto::ScheduleSetParams& params) override { + (void)params; + return golden("schedule.set"); + } + + proto::Result on_session_hello(const proto::SessionHelloParams& params) override { + (void)params; + return golden("session.hello"); + } + + proto::Result on_session_pair(const proto::SessionPairParams& params) override { + (void)params; + return golden("session.pair"); + } + + proto::Result on_session_subscribe(const proto::SessionSubscribeParams& params) override { + (void)params; + return golden("session.subscribe"); + } + + proto::Result on_settings_get(const proto::SettingsGetParams& params) override { + (void)params; + return golden("settings.get"); + } + + proto::Result on_settings_set(const proto::SettingsSetParams& params) override { + (void)params; + return golden("settings.set"); + } + +private: + template + proto::Result golden(const std::string& method) { + const nlohmann::json* value = results_(method); + if (value == nullptr) + return std::unexpected(proto::ParseError{method, "no fixture for this method"}); + return proto::parse(*value, method); + } + + std::function results_; +}; + +} // namespace velox::conformance diff --git a/tests/conformance/run.sh b/tests/conformance/run.sh new file mode 100755 index 0000000..fbc89d5 --- /dev/null +++ b/tests/conformance/run.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# +# The conformance suite. This is the command CI runs on every lane's PR. +# +# ./tests/conformance/run.sh static + C++ + TS against a mockd it starts +# ./tests/conformance/run.sh --uds PATH --ws-port N against an already-running daemon +# +# Three runners, one set of fixtures: +# 1. check_contract.py schemas, fixtures and committed generated code agree +# 2. cpp/ the generated C++ parses, serialises and dispatches every fixture +# 3. ts/replay.ts a live server answers every fixture over both transports +# +# Plus one scenario that cannot be shown against a healthy server: with the daemon +# answering slower than capture.offer's 750 ms deadline, the client must give up and let +# Firefox take the download. That is the fail-open guarantee, and it is checked here. + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +HERE="$REPO/tests/conformance" +WORK="$(mktemp -d)" +EXTERNAL_UDS="" +EXTERNAL_WS="" +MOCKD_PID="" +SLOW_PID="" + +while [ $# -gt 0 ]; do + case "$1" in + --uds) EXTERNAL_UDS="$2"; shift 2 ;; + --ws-port) EXTERNAL_WS="$2"; shift 2 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "run.sh: unknown option $1" >&2; exit 2 ;; + esac +done + +# Kill the server and anything it spawned. `kill $!` alone would only reap the subshell +# wrapper and leave the node process holding the port, which then breaks the next run. +stop() { + local pid="$1" + [ -n "$pid" ] || return 0 + pkill -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true +} + +cleanup() { + stop "$MOCKD_PID" + stop "$SLOW_PID" + rm -rf "$WORK" +} +trap cleanup EXIT + +step() { printf '\n=== %s ===\n' "$1"; } + +# ---------------------------------------------------------------- 1. static +step "static conformance (schemas, fixtures, generated code)" +python3 "$HERE/check_contract.py" + +# ------------------------------------------------------------------- 2. C++ +step "generated C++ (parse, serialise, dispatch)" +CXX="${CXX:-g++}" +"$CXX" -std=c++23 -Wall -Wextra -Wpedantic -Werror \ + -I"$REPO/core/generated" -I"$HERE/cpp" \ + "$HERE/cpp/conformance_main.cpp" "$REPO/core/generated/velox_proto.cpp" \ + -o "$WORK/conformance_cpp" +"$WORK/conformance_cpp" "$REPO" + +# -------------------------------------------------------------------- 3. TS +step "generated TypeScript against a live server" +if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then + ( cd "$REPO/tools/mockd" && npm install --silent --no-audit --no-fund ) + UDS="$WORK/velox.sock" + WS_PORT=52080 + ( cd "$REPO/tools/mockd" && exec ./node_modules/.bin/tsx src/index.ts \ + --uds "$UDS" --ws-port "$WS_PORT" --allowed-root "$WORK" ) >"$WORK/mockd.log" 2>&1 & + MOCKD_PID=$! + # Wait for the socket rather than sleeping a guessed amount. + for _ in $(seq 1 50); do + [ -S "$UDS" ] && node -e "require('net').connect('$UDS').on('connect',function(){this.end();process.exit(0)}).on('error',()=>process.exit(1))" 2>/dev/null && break + sleep 0.2 + done + node -e "require('net').connect('$UDS').on('connect',function(){this.end();process.exit(0)}).on('error',()=>process.exit(1))" 2>/dev/null \ + || { echo "mockd did not start:"; cat "$WORK/mockd.log"; exit 1; } +else + UDS="$EXTERNAL_UDS" + WS_PORT="$EXTERNAL_WS" +fi + +( cd "$HERE/ts" && npm install --silent --no-audit --no-fund ) + +TS_ARGS=() +[ -n "$UDS" ] && TS_ARGS+=(--uds "$UDS") +[ -n "$WS_PORT" ] && TS_ARGS+=(--ws-port "$WS_PORT") +( cd "$HERE/ts" && ./node_modules/.bin/tsx replay.ts "${TS_ARGS[@]}" ) + +# ------------------------------------------------- 4. capture fails open +step "capture.offer fails open when the daemon is too slow" +if [ -z "$EXTERNAL_UDS" ]; then + SLOW_UDS="$WORK/slow.sock" + ( cd "$REPO/tools/mockd" && exec ./node_modules/.bin/tsx src/index.ts \ + --uds "$SLOW_UDS" --no-ws --slow 2000 ) >"$WORK/slow.log" 2>&1 & + SLOW_PID=$! + for _ in $(seq 1 50); do [ -S "$SLOW_UDS" ] && break; sleep 0.2; done + [ -S "$SLOW_UDS" ] || { echo "slow mockd did not start:"; cat "$WORK/slow.log"; exit 1; } + ( cd "$HERE/ts" && ./node_modules/.bin/tsx replay.ts --uds "$SLOW_UDS" \ + --only capture.offer.timeout --include-requires ) +else + echo "skipped: needs a deliberately slow server, which run.sh only arranges for mockd" +fi + +printf '\n=== conformance: all runners passed ===\n' diff --git a/tests/conformance/ts/client.ts b/tests/conformance/ts/client.ts new file mode 100644 index 0000000..54ca8bb --- /dev/null +++ b/tests/conformance/ts/client.ts @@ -0,0 +1,143 @@ +/** + * A minimal client for each transport, built on the generated types. + * + * Deliberately not the extension's transport implementation: the conformance suite must + * fail when the *contract* is broken, not when the extension's reconnect logic is. It + * speaks the two framings and nothing else. + */ + +import net from 'node:net'; +import { WebSocket } from 'ws'; +import type { MethodName, Params, Result } from '../../../extension/src/shared/protocol/methods.js'; + +export type TransportName = 'uds' | 'ws'; + +export interface RpcFrame { + jsonrpc: '2.0'; + id?: number | string; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +export interface Conn { + readonly transport: TransportName; + /** Send and wait. Resolves to null when nothing arrives inside `timeoutMs`. */ + request(method: string, params: unknown, timeoutMs: number): Promise; + /** Typed convenience wrapper, so the suite itself is checked against the contract. */ + call(method: M, params: Params): Promise>; + notifications(): RpcFrame[]; + close(): void; +} + +abstract class BaseConn implements Conn { + abstract readonly transport: TransportName; + protected nextId = 1; + protected readonly pending = new Map void>(); + private readonly received: RpcFrame[] = []; + + protected abstract write(text: string): void; + abstract close(): void; + + protected onFrame(frame: RpcFrame): void { + if (frame.id !== undefined && this.pending.has(frame.id)) { + const resolve = this.pending.get(frame.id); + this.pending.delete(frame.id); + resolve?.(frame); + return; + } + if (frame.method !== undefined) this.received.push(frame); + } + + notifications(): RpcFrame[] { + return [...this.received]; + } + + request(method: string, params: unknown, timeoutMs: number): Promise { + const id = this.nextId++; + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(id); + resolve(null); // the fail-open case: no answer inside the deadline + }, timeoutMs); + this.pending.set(id, (frame) => { + clearTimeout(timer); + resolve(frame); + }); + this.write(JSON.stringify({ jsonrpc: '2.0', id, method, params })); + }); + } + + async call(method: M, params: Params): Promise> { + const frame = await this.request(method, params, 10_000); + if (frame === null) throw new Error(`${method}: no response`); + if (frame.error) throw new Error(`${method}: error ${frame.error.code}: ${frame.error.message}`); + return frame.result as Result; + } +} + +class UdsConn extends BaseConn { + readonly transport = 'uds' as const; + private buffer = ''; + + constructor(private readonly socket: net.Socket) { + super(); + socket.on('data', (chunk) => { + this.buffer += chunk.toString('utf8'); + let nl = this.buffer.indexOf('\n'); + while (nl !== -1) { + const line = this.buffer.slice(0, nl).trim(); + this.buffer = this.buffer.slice(nl + 1); + nl = this.buffer.indexOf('\n'); + if (line) this.onFrame(JSON.parse(line) as RpcFrame); + } + }); + } + + protected write(text: string): void { + this.socket.write(text + '\n'); + } + + close(): void { + this.socket.destroy(); + } +} + +class WsConn extends BaseConn { + readonly transport = 'ws' as const; + + constructor(private readonly socket: WebSocket) { + super(); + socket.on('message', (data) => this.onFrame(JSON.parse(data.toString()) as RpcFrame)); + } + + protected write(text: string): void { + this.socket.send(text); + } + + close(): void { + this.socket.close(); + } +} + +export async function connectUds(path: string): Promise { + const socket = net.connect(path); + await new Promise((resolve, reject) => { + socket.once('connect', () => resolve()); + socket.once('error', reject); + }); + return new UdsConn(socket); +} + +export async function connectWs(port: number): Promise { + // The daemon verifies this Origin on the upgrade, so the suite must present a real one. + const socket = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { Origin: 'moz-extension://11111111-2222-3333-4444-555555555555' }, + }); + await new Promise((resolve, reject) => { + socket.once('open', () => resolve()); + socket.once('error', reject); + }); + return new WsConn(socket); +} diff --git a/tests/conformance/ts/package-lock.json b/tests/conformance/ts/package-lock.json new file mode 100644 index 0000000..d7b21b8 --- /dev/null +++ b/tests/conformance/ts/package-lock.json @@ -0,0 +1,567 @@ +{ + "name": "@velox/conformance", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@velox/conformance", + "version": "1.0.0", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.7.0", + "@types/ws": "^8.5.12", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/tests/conformance/ts/package.json b/tests/conformance/ts/package.json new file mode 100644 index 0000000..a03fbf3 --- /dev/null +++ b/tests/conformance/ts/package.json @@ -0,0 +1,18 @@ +{ + "name": "@velox/conformance", + "version": "1.0.0", + "private": true, + "description": "Replays contracts/fixtures against a live server through the generated TypeScript client.", + "type": "module", + "scripts": { + "conformance": "tsx replay.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { "ws": "^8.18.0" }, + "devDependencies": { + "@types/node": "^22.7.0", + "@types/ws": "^8.5.12", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } +} diff --git a/tests/conformance/ts/replay.ts b/tests/conformance/ts/replay.ts new file mode 100644 index 0000000..4644188 --- /dev/null +++ b/tests/conformance/ts/replay.ts @@ -0,0 +1,387 @@ +/** + * Conformance runner, TypeScript side. + * + * Replays every fixture in contracts/fixtures against a live server — mockd today, veloxd + * from M1 — through the generated client types and validators. The same suite runs against + * both, which is the point: if a lane drifts from the contract, this goes red the same day + * rather than at M2 integration. + * + * What each fixture asserts + * success the reply carries a result; the result passes the generated validator; its + * shape matches the golden file + * error the reply carries an error with the fixture's code + * timeout nothing arrives inside the deadline, and the client is expected to give up. + * This is capture.offer's fail-open guarantee, and it is a pass when the + * server stays silent. + * + * Values are compared by *shape*, not by equality: a live daemon returns its own task ids + * and its own clock, and demanding byte-identical results would only teach the suite to + * lie. Types, key sets and error codes are compared exactly. + * + * npx tsx replay.ts --uds /run/user/1000/velox/velox.sock --ws-port 52000 + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { connectUds, connectWs, type Conn, type TransportName } from './client.js'; +import { + METHODS, + isMethodName, + type MethodName, +} from '../../../extension/src/shared/protocol/methods.js'; +import { isEventName } from '../../../extension/src/shared/protocol/events.js'; +import { + validateEventParams, + validateParams, + validateResult, +} from '../../../extension/src/shared/protocol/validate.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const REPO = resolve(HERE, '..', '..', '..'); +const FIXTURES = resolve(REPO, 'contracts', 'fixtures'); + +const PLACEHOLDERS = new Set(['$uuid', '$isoDate', '$any', '$opaque', '$taskId', '$taskId2']); + +/** + * Concrete stand-ins for the placeholders, used when a golden payload is validated on its + * own. A validator applies length and pattern rules, so "$opaque" has to become something + * token-shaped before it is checked. + */ +const CONCRETE: Record = { + $uuid: 'e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b', + $taskId: 'e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b', + $taskId2: '11112222-3333-4444-8555-666677778888', + $isoDate: '2026-09-09T10:14:52Z', + $any: 'placeholder', + $opaque: 'cGxhY2Vob2xkZXItdG9rZW4tNjQtYnl0ZXMtb2YtZW50cm9weS1nb2VzLWhlcmU', +}; + +function concrete(value: unknown): unknown { + if (typeof value === 'string') return CONCRETE[value] ?? value; + if (Array.isArray(value)) return value.map(concrete); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) out[k] = concrete(v); + return out; + } + return value; +} + +interface Fixture { + file: string; + name: string; + kind?: 'timeout'; + /** A condition the server cannot produce from the request alone. Skipped unless the + * harness has arranged it — see tests/integration. */ + requires?: string; + transport?: TransportName; + deadlineMs?: number; + request?: { jsonrpc: '2.0'; id: number | string; method: string; params?: unknown }; + notification?: { jsonrpc: '2.0'; method: string; params: unknown }; + response?: { jsonrpc: '2.0'; id: number | string; result?: unknown; error?: { code: number } } | null; +} + +interface Outcome { + fixture: string; + transport: TransportName | 'static'; + ok: boolean; + detail: string; +} + +// ---------------------------------------------------------------- shape match + +/** + * Compare an actual value against a golden one structurally. Placeholders match anything; + * objects must have the same keys; arrays must agree on emptiness and on element shape. + */ +function shapeMismatch(golden: unknown, actual: unknown, path = ''): string | null { + if (typeof golden === 'string' && PLACEHOLDERS.has(golden)) return null; + // The generated validator has already ruled on whether null is allowed here, so a null + // is never a shape failure: a golden file shows one plausible value, not the only one. + if (actual === null) return null; + if (golden === null) return actual === null ? null : `${path}: expected null, got ${typeName(actual)}`; + if (Array.isArray(golden)) { + if (!Array.isArray(actual)) return `${path}: expected an array, got ${typeName(actual)}`; + if (golden.length > 0 && actual.length === 0) return `${path}: expected a non-empty array`; + if (golden.length > 0 && actual.length > 0) return shapeMismatch(golden[0], actual[0], `${path}/0`); + return null; + } + if (typeof golden === 'object') { + if (typeof actual !== 'object' || actual === null || Array.isArray(actual)) + return `${path}: expected an object, got ${typeName(actual)}`; + const g = golden as Record; + const a = actual as Record; + for (const key of Object.keys(g)) { + // A golden null means "may be absent"; the contract treats absent and null alike. + if (!(key in a)) { + if (g[key] === null) continue; + return `${path}/${key}: missing from the response`; + } + const sub = shapeMismatch(g[key], a[key], `${path}/${key}`); + if (sub) return sub; + } + for (const key of Object.keys(a)) { + if (!(key in g)) return `${path}/${key}: not in the contract's result`; + } + return null; + } + if (typeof golden !== typeof actual) return `${path}: expected ${typeof golden}, got ${typeName(actual)}`; + return null; +} + +function typeName(v: unknown): string { + if (v === null) return 'null'; + if (Array.isArray(v)) return 'array'; + return typeof v; +} + +// ------------------------------------------------------------------- fixtures + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (entry.endsWith('.json')) out.push(full); + } + return out; +} + +function loadFixtures(): Fixture[] { + return walk(FIXTURES).map((file) => ({ + ...(JSON.parse(readFileSync(file, 'utf8')) as Omit), + file: relative(REPO, file), + })); +} + +// -------------------------------------------------------------------- checks + +/** Runs with no server: the generated validators must accept every golden payload. */ +function staticChecks(fixtures: readonly Fixture[]): Outcome[] { + const out: Outcome[] = []; + for (const f of fixtures) { + if (f.notification) { + const name = f.notification.method; + if (!isEventName(name)) { + out.push({ fixture: f.file, transport: 'static', ok: false, detail: `unknown event ${name}` }); + continue; + } + const r = validateEventParams(name, concrete(f.notification.params)); + out.push({ fixture: f.file, transport: 'static', ok: r.ok, + detail: r.ok ? 'event payload validates' : `${r.path}: ${r.message}` }); + continue; + } + const method = f.request?.method; + if (method === undefined || !isMethodName(method)) continue; + + const expectsInvalidParams = f.response?.error?.code === -32602; + const r = validateParams(method, concrete(f.request?.params ?? {})); + if (expectsInvalidParams) { + out.push({ fixture: f.file, transport: 'static', ok: !r.ok, + detail: r.ok ? 'expects -32602 but the params validate' : 'params correctly rejected' }); + } else { + out.push({ fixture: f.file, transport: 'static', ok: r.ok, + detail: r.ok ? 'params validate' : `${r.path}: ${r.message}` }); + } + + if (f.response && 'result' in f.response) { + const rr = validateResult(method, concrete(f.response.result)); + out.push({ fixture: f.file, transport: 'static', ok: rr.ok, + detail: rr.ok ? 'golden result validates' : `${rr.path}: ${rr.message}` }); + } + } + return out; +} + +/** + * Methods that destroy the state later fixtures rely on. Replayed last so the suite does + * not depend on file order, which is the sort of thing that goes green locally and red in + * CI on a different filesystem. + */ +const DESTRUCTIVE = new Set(['download.remove']); + +function replayOrder(a: Fixture, b: Fixture): number { + const rank = (f: Fixture): number => (DESTRUCTIVE.has(f.request?.method ?? '') ? 1 : 0); + return rank(a) - rank(b) || a.file.localeCompare(b.file); +} + +/** Substitute the ids the runner bound during setup into a fixture's params. */ +function bind(value: unknown, bindings: Record): unknown { + if (typeof value === 'string') return bindings[value] ?? value; + if (Array.isArray(value)) return value.map((v) => bind(v, bindings)); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) out[k] = bind(v, bindings); + return out; + } + return value; +} + +/** + * Create the tasks the task-referencing fixtures bind to. Doing this per connection is + * what lets the same suite run against an empty veloxd and against a seeded mockd. + */ +async function setupBindings(conn: Conn): Promise> { + const bindings: Record = {}; + for (const [key, url] of [['$taskId', 'https://example.org/conformance-a.bin'], + ['$taskId2', 'https://example.org/conformance-b.bin']] as const) { + const added = await conn.call('download.add', { url, startMode: 'later' }); + bindings[key] = added.taskId; + } + return bindings; +} + +async function replay(conn: Conn, fixtures: readonly Fixture[], + bindings: Record, + includeRequires = false): Promise { + const out: Outcome[] = []; + const t = conn.transport; + + for (const f of [...fixtures].sort(replayOrder)) { + if (!f.request) continue; + const method = f.request.method; + if (f.transport !== undefined && f.transport !== t) continue; + if (!isMethodName(method)) continue; + if (!(METHODS[method].transports as readonly string[]).includes(t)) continue; + if (f.requires !== undefined && !includeRequires) { + out.push({ fixture: f.file, transport: t, ok: true, + detail: `skipped: requires ${f.requires}` }); + continue; + } + + const deadline = f.deadlineMs ?? Math.max(METHODS[method].deadlineMs, 2000); + const frame = await conn.request(method, bind(f.request.params ?? {}, bindings), deadline); + + if (f.kind === 'timeout') { + out.push({ + fixture: f.file, transport: t, ok: frame === null, + detail: frame === null + ? `no reply within ${deadline} ms — the client fails open, as it must` + : 'the server answered a fixture that requires silence', + }); + continue; + } + + if (frame === null) { + out.push({ fixture: f.file, transport: t, ok: false, detail: `no reply within ${deadline} ms` }); + continue; + } + + const expected = f.response; + if (expected && 'error' in expected && expected.error) { + const got = frame.error?.code; + out.push({ + fixture: f.file, transport: t, ok: got === expected.error.code, + detail: got === expected.error.code + ? `error ${got} as documented` + : `expected error ${expected.error.code}, got ${frame.error ? `error ${got}` : 'a result'}`, + }); + continue; + } + + if (frame.error) { + out.push({ fixture: f.file, transport: t, ok: false, + detail: `expected a result, got error ${frame.error.code}: ${frame.error.message}` }); + continue; + } + + const validated = validateResult(method, frame.result); + if (!validated.ok) { + out.push({ fixture: f.file, transport: t, ok: false, + detail: `result fails the generated validator at ${validated.path}: ${validated.message}` }); + continue; + } + const mismatch = expected && 'result' in expected + ? shapeMismatch(expected.result, frame.result) + : null; + out.push({ fixture: f.file, transport: t, ok: mismatch === null, + detail: mismatch ?? 'result validates and matches the golden shape' }); + } + return out; +} + +/** The transport rules are part of the contract, so they get replayed too. */ +async function privilegeChecks(conn: Conn): Promise { + if (conn.transport !== 'ws') return []; + const out: Outcome[] = []; + const privileged = (Object.keys(METHODS) as MethodName[]).filter((m) => METHODS[m].privileged); + for (const method of privileged) { + const frame = await conn.request(method, {}, 3000); + const ok = frame?.error?.code === -32003; + out.push({ + fixture: `transport-rules/${method}`, transport: 'ws', ok, + detail: ok ? 'refused with -32003 over the WebSocket, as required' + : `expected -32003, got ${frame ? JSON.stringify(frame.error ?? frame.result).slice(0, 80) : 'no reply'}`, + }); + } + return out; +} + +// ---------------------------------------------------------------------- main + +async function main(): Promise { + const argv = process.argv.slice(2); + const arg = (name: string): string | undefined => { + const i = argv.indexOf(name); + return i === -1 ? undefined : argv[i + 1]; + }; + + // --only narrows the run to fixtures whose path contains a substring, and + // --include-requires replays the ones needing a condition the harness has arranged + // (a slow daemon, a hostile origin server). run.sh uses both to prove capture.offer + // fails open, which cannot be shown against a healthy server. + const only = arg('--only'); + const includeRequires = argv.includes('--include-requires'); + const all = loadFixtures(); + const fixtures = only === undefined ? all : all.filter((f) => f.file.includes(only)); + if (fixtures.length === 0) { + process.stderr.write(`conformance: --only ${String(only)} matched no fixtures\n`); + process.exit(2); + } + const results: Outcome[] = [...staticChecks(fixtures)]; + + const udsPath = arg('--uds'); + const wsPort = arg('--ws-port'); + + if (udsPath) { + const conn = await connectUds(udsPath); + await conn.call('session.hello', { clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0' }); + results.push(...(await replay(conn, fixtures, await setupBindings(conn), includeRequires))); + conn.close(); + } + if (wsPort) { + const conn = await connectWs(Number(wsPort)); + const paired = await conn.request( + 'session.pair', + { clientName: 'conformance', extensionId: '11111111-2222-3333-4444-555555555555' }, + 5000, + ); + const token = (paired?.result as { token?: string } | undefined)?.token; + if (token === undefined) throw new Error('pairing failed: no token issued'); + await conn.request('session.hello', + { clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0', token }, 5000); + results.push(...(await replay(conn, fixtures, await setupBindings(conn), includeRequires))); + results.push(...(await privilegeChecks(conn))); + conn.close(); + } + if (!udsPath && !wsPort) { + process.stdout.write('no --uds or --ws-port given: ran static checks only\n'); + } + + const failed = results.filter((r) => !r.ok); + for (const r of failed) { + process.stdout.write(`FAIL [${r.transport}] ${r.fixture}\n ${r.detail}\n`); + } + const byTransport = new Map(); + for (const r of results) byTransport.set(r.transport, (byTransport.get(r.transport) ?? 0) + 1); + const summary = [...byTransport].map(([k, v]) => `${k}:${v}`).join(' '); + process.stdout.write(`\n${results.length - failed.length}/${results.length} checks passed (${summary})\n`); + process.exit(failed.length === 0 ? 0 : 1); +} + +main().catch((err: unknown) => { + process.stderr.write(`conformance: ${String(err)}\n`); + process.exit(2); +}); diff --git a/tests/conformance/ts/tsconfig.json b/tests/conformance/ts/tsconfig.json new file mode 100644 index 0000000..fe7fdf0 --- /dev/null +++ b/tests/conformance/ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["*.ts", "../../../extension/src/shared/protocol/**/*.ts"] +} diff --git a/tools/mockd/README.md b/tools/mockd/README.md new file mode 100644 index 0000000..59a32b7 --- /dev/null +++ b/tools/mockd/README.md @@ -0,0 +1,61 @@ +# tools/mockd — a fake `veloxd` + +Serves `contracts/fixtures` over **both** transports, keeps just enough state that adding +and pausing a download does something visible, and fakes progress events at 4 Hz. + +The GUI and extension lanes develop against this from day one and never wait for the real +daemon. Its unhappy-path flags exist so those lanes can test the cases that are hard to +arrange on purpose — a slow daemon, a flaky one, a dropped socket, a refused pairing. + +```sh +cd tools/mockd +npm install +npm start -- --help +npm start # both transports, default paths +``` + +Defaults: `$XDG_RUNTIME_DIR/velox/velox.sock` and `ws://127.0.0.1:52000`. + +## Flags + +| Flag | Effect | +|---|---| +| `--uds ` / `--no-uds` | Unix socket path, or don't listen | +| `--ws-port ` / `--no-ws` | loopback WebSocket port, or don't listen | +| `--progress-hz ` | progress event rate (default 4, the contract's ceiling) | +| `--speed ` | synthetic per-task speed | +| `--slow ` | delay every reply. **Past 750 ms `capture.offer` must fail open.** | +| `--flaky <0..1>` | answer this fraction of calls with `-32603` | +| `--drop-connection ` | terminate every connection every N seconds | +| `--refuse-pairing` | `session.pair` fails, as if the user clicked Deny | +| `--lockout` | `session.pair` answers `-32014`, as if the brute-force lockout tripped | +| `--allowed-root ` | add a root that `download.add`'s `saveDir` may resolve inside | +| `--allow-any-origin` | skip the `moz-extension://` Origin check (debugging only) | +| `--no-validate` | stop validating params (to see what a client actually sends) | + +## What is real and what is faked + +**Real**, because a client's correctness depends on it: + +* transport and privilege rules, taken from the *generated* `METHODS` table — so a + privileged method is refused with `-32003` over the WebSocket exactly as `veloxd` must; +* param validation, through the *generated* validators, including range and length checks; +* `saveDir` canonicalization against the allowed roots, so `-32011` is reachable; +* the `capture.offer` decision — monitored types, minimum size, excluded hosts — so both + the take and the ignore paths get exercised; +* pairing: `session.hello` accepts only a token this process actually issued; +* task state, so add / pause / resume / cancel / remove do what a client expects to see. + +**Faked**: bytes advance on a clock, not from a socket. There is no network, no disk, and +no engine. Anything not listed above is answered from its golden fixture. + +## Why it imports the generated protocol code + +`mockd` uses `extension/src/shared/protocol/` — the generated TypeScript — rather than +types of its own. A mock with hand-written types is a third source of truth, and it drifts. +This way a schema change that breaks a client breaks `mockd` in the same commit. + +It imports the individual generated modules (`types.js`, `methods.js`, …) rather than +`index.js`, because the `extension/` tree has no `package.json` of its own for Node to +resolve a star re-export through. That is a quirk of running from outside that package, not +a problem with the generated code; the extension's own bundler is unaffected. diff --git a/tools/mockd/package-lock.json b/tools/mockd/package-lock.json new file mode 100644 index 0000000..3d3b87f --- /dev/null +++ b/tools/mockd/package-lock.json @@ -0,0 +1,573 @@ +{ + "name": "@velox/mockd", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@velox/mockd", + "version": "1.0.0", + "dependencies": { + "ws": "^8.18.0" + }, + "bin": { + "mockd": "src/index.ts" + }, + "devDependencies": { + "@types/node": "^22.7.0", + "@types/ws": "^8.5.12", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/tools/mockd/package.json b/tools/mockd/package.json new file mode 100644 index 0000000..2b3c27d --- /dev/null +++ b/tools/mockd/package.json @@ -0,0 +1,22 @@ +{ + "name": "@velox/mockd", + "version": "1.0.0", + "private": true, + "description": "Mock veloxd. Serves contracts/fixtures over both transports so the GUI and extension lanes never wait for the real daemon.", + "type": "module", + "bin": { "mockd": "./src/index.ts" }, + "scripts": { + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.7.0", + "@types/ws": "^8.5.12", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + }, + "engines": { "node": ">=20" } +} diff --git a/tools/mockd/src/dispatch.ts b/tools/mockd/src/dispatch.ts new file mode 100644 index 0000000..725a2d6 --- /dev/null +++ b/tools/mockd/src/dispatch.ts @@ -0,0 +1,331 @@ +/** + * Turning a request into a reply. + * + * Two sources of truth, in order: + * 1. A handler here, for the methods a client needs to *behave* (add a task and watch it + * appear, pause it and watch it stop). + * 2. The golden fixture for that method, otherwise. + * + * Transport and privilege rules come from the generated METHODS table, not from a list + * retyped here — so mockd refuses exactly what the real daemon must refuse, and the EXT + * lane can test its -32003 handling before veloxd exists. + */ + +// Imported from the individual generated modules rather than index.js: the extension +// tree has no package.json of its own, so Node cannot re-export * through it from here. +import { ErrorCode } from '../../../extension/src/shared/protocol/types.js'; +import { + METHODS, + isMethodName, + type MethodName, + type Transport, +} from '../../../extension/src/shared/protocol/methods.js'; +import { validateParams } from '../../../extension/src/shared/protocol/validate.js'; +import { resolve as resolvePath } from 'node:path'; +import { resolvePlaceholders, type Fixture } from './fixtures.js'; +import type { MockState } from './state.js'; + +export interface Session { + readonly transport: Transport; + paired: boolean; + subscribed: Set; + sessionId: string; +} + +export interface Options { + readonly protocolVersion: string; + readonly daemonVersion: string; + readonly refusePairing: boolean; + readonly flaky: number; + readonly validate: boolean; + /** Every write target must canonicalize inside one of these. */ + readonly allowedRoots: readonly string[]; + /** Answer session.pair with -32014, as if the brute-force lockout had tripped. */ + readonly lockout: boolean; +} + +type Json = Record; + +export function rpcError(id: unknown, code: number, message: string, data?: Json): Json { + const error: Json = { code, message }; + if (data) error['data'] = data; + return { jsonrpc: '2.0', id: id ?? null, error }; +} + +function rpcResult(id: unknown, result: unknown): Json { + return { jsonrpc: '2.0', id: id ?? null, result }; +} + +export class Dispatcher { + /** Tokens this mockd has issued. A token it never minted is not a valid token. */ + private readonly issuedTokens = new Set(); + + constructor( + private readonly state: MockState, + private readonly fixtures: Map, + private readonly opts: Options, + ) {} + + handle(session: Session, frame: unknown): Json | null { + if (typeof frame !== 'object' || frame === null || Array.isArray(frame)) { + return rpcError(null, ErrorCode.InvalidRequest, 'not a JSON-RPC 2.0 request'); + } + const req = frame as Json; + const id = req['id']; + if (req['jsonrpc'] !== '2.0' || typeof req['method'] !== 'string') { + return rpcError(id, ErrorCode.InvalidRequest, 'not a JSON-RPC 2.0 request'); + } + + const method = req['method']; + if (!isMethodName(method)) { + return rpcError(id, ErrorCode.MethodNotFound, 'no such method'); + } + if (!(METHODS[method].transports as readonly string[]).includes(session.transport)) { + return rpcError(id, ErrorCode.TransportForbidden, 'method is not permitted on this transport'); + } + if (session.transport === 'ws' && !session.paired && method !== 'session.pair' && method !== 'session.hello') { + return rpcError(id, ErrorCode.NotPaired, 'not paired: call session.pair first'); + } + + const params = (req['params'] ?? {}) as unknown; + if (this.opts.validate) { + const v = validateParams(method, params); + if (!v.ok) { + return rpcError(id, ErrorCode.InvalidParams, `${v.path}: ${v.message}`, { path: v.path }); + } + } + + // --flaky turns a fraction of otherwise-good calls into internal errors, so clients + // exercise their retry and error paths without a hostile server. + if (this.opts.flaky > 0 && Math.random() < this.opts.flaky && method !== 'session.hello') { + return rpcError(id, ErrorCode.InternalError, 'synthetic failure (--flaky)'); + } + + const result = this.route(session, method, params as Json); + if (result && typeof result === 'object' && 'code' in result && 'message' in result) { + return { jsonrpc: '2.0', id: id ?? null, error: result }; + } + return rpcResult(id, result); + } + + /** Returns a result, or an error object ({code, message}) to be wrapped by the caller. */ + private route(session: Session, method: MethodName, params: Json): unknown { + const state = this.state; + + switch (method) { + case 'session.hello': { + if (session.transport === 'ws') { + const token = params['token']; + // The reply does not distinguish absent from malformed from merely wrong: an + // unpaired caller learns nothing it could use to guess. + if (typeof token !== 'string' || !this.issuedTokens.has(token)) { + return { code: ErrorCode.NotPaired, message: 'not paired: call session.pair first' }; + } + session.paired = true; + } + const requested = String(params['protocolVersion'] ?? ''); + if (requested.split('.')[0] !== this.opts.protocolVersion.split('.')[0]) { + return { + code: ErrorCode.VersionMismatch, + message: `protocol major version mismatch: daemon speaks ${this.opts.protocolVersion.split('.')[0]}.x, client speaks ${requested.split('.')[0]}.x`, + data: { expected: this.opts.protocolVersion, actual: requested }, + }; + } + return { + daemonVersion: this.opts.daemonVersion, + protocolVersion: this.opts.protocolVersion, + capabilities: ['media', 'grabber', 'secretservice'], + sessionId: session.sessionId, + transport: session.transport, + }; + } + + case 'session.pair': { + if (this.opts.lockout) { + return { code: ErrorCode.RateLimited, message: 'too many pairing attempts; try again later', + data: { retryAfterSec: 60 } }; + } + if (this.opts.refusePairing) { + return { code: ErrorCode.NotPaired, message: 'pairing was declined by the user' }; + } + session.paired = true; + const token = Buffer.from(session.sessionId + session.sessionId).toString('base64url'); + this.issuedTokens.add(token); + return { token, expiresAt: null }; + } + + case 'session.subscribe': { + const events = (params['events'] as string[] | undefined) ?? []; + session.subscribed = new Set(events); + return { ok: true, events }; + } + + case 'download.list': { + const filter = (params['filter'] ?? null) as Json | null; + let items = state.list(); + const states = filter?.['states'] as string[] | undefined; + if (states) items = items.filter((t) => states.includes(t.state)); + const categoryId = filter?.['categoryId'] as string | undefined; + if (categoryId) items = items.filter((t) => t.categoryId === categoryId); + const total = items.length; + const offset = Number(params['offset'] ?? 0); + const limit = Number(params['limit'] ?? 500); + return { total, items: items.slice(offset, offset + limit) }; + } + + case 'download.get': { + const task = state.tasks.get(String(params['taskId'])); + if (!task) return notFound(String(params['taskId'])); + const fixture = this.fixtureResult('download.get') as Json; + return { ...fixture, summary: task }; + } + + case 'download.add': { + // Canonicalize before checking, so ../ traversal cannot smuggle a write out of the + // allowed roots. This is intrinsic to the request, so mockd answers it exactly as + // veloxd must, and the -32011 fixture is replayable against both. + const requested = params['saveDir'] as string | null | undefined; + if (typeof requested === 'string' && !this.withinAllowedRoots(requested)) { + return { code: ErrorCode.InvalidPath, message: 'destination is outside the allowed roots', + data: { path: requested } }; + } + const task = state.add({ + url: String(params['url']), + filename: (params['filename'] as string | null) ?? undefined, + saveDir: (params['saveDir'] as string | null) ?? undefined, + categoryId: (params['categoryId'] as string | null) ?? undefined, + segments: (params['segments'] as number | null) ?? undefined, + state: params['startMode'] === 'later' ? 'paused' + : params['startMode'] === 'queue' ? 'queued' : 'connecting', + }); + return { taskId: task.taskId, state: task.state, duplicate: null }; + } + + case 'download.start': + case 'download.pause': + case 'download.resume': + case 'download.cancel': { + const target = { 'download.start': 'connecting', 'download.pause': 'paused', + 'download.resume': 'connecting', 'download.cancel': 'cancelled' }[method] as + 'connecting' | 'paused' | 'cancelled'; + const updated: Json[] = []; + const failed: Json[] = []; + for (const raw of (params['taskIds'] as string[]) ?? []) { + const r = state.transition(raw, target); + if (!r) failed.push({ taskId: raw, code: ErrorCode.TaskNotFound, message: 'no such task' }); + else updated.push({ taskId: raw, state: r.task.state, changed: r.changed }); + } + return { updated, failed }; + } + + case 'download.remove': { + const removed: string[] = []; + const failed: Json[] = []; + for (const raw of (params['taskIds'] as string[]) ?? []) { + if (state.remove(raw)) removed.push(raw); + else failed.push({ taskId: raw, code: ErrorCode.TaskNotFound, message: 'no such task' }); + } + return { removed, failed }; + } + + case 'download.update': { + const task = state.tasks.get(String(params['taskId'])); + if (!task) return notFound(String(params['taskId'])); + const patch = (params['patch'] ?? {}) as Json; + for (const key of ['filename', 'saveDir', 'categoryId', 'queueId', 'description', 'segments'] as const) { + if (key in patch) (task as unknown as Json)[key] = patch[key]; + } + return task; + } + + case 'capture.offer': { + // A real decision rather than a canned reply, so the extension's take and ignore + // paths both get exercised. The daemon owns this policy; capture.getRules is how + // the extension mirrors it. + const rules = this.fixtureResult('capture.getRules') as { + enabled: boolean; monitoredExtensions: string[]; monitoredMimeTypes: string[]; + minSizeBytes: number; excludedHosts: string[]; + }; + const url = String(params['url'] ?? ''); + const length = params['contentLength'] as number | null | undefined; + const contentType = (params['contentType'] as string | null) ?? ''; + const disposition = (params['contentDisposition'] as string | null) ?? ''; + const ext = url.split('?')[0]?.split('.').pop()?.toLowerCase() ?? ''; + let host = ''; + try { host = new URL(url).hostname; } catch { host = ''; } + + const excluded = rules.excludedHosts.some( + (pattern) => pattern.startsWith('*.') + ? host.endsWith(pattern.slice(1)) || host === pattern.slice(2) + : host === pattern); + + const ignore = (reason: string): Json => ({ action: 'ignore', taskId: null, reason }); + if (!rules.enabled) return ignore('capture_disabled'); + if (excluded) return ignore('excluded_host'); + + const monitored = rules.monitoredExtensions.includes(ext) + || rules.monitoredMimeTypes.includes(contentType) + || disposition.startsWith('attachment'); + if (!monitored) return ignore('type_not_monitored'); + if (typeof length === 'number' && length < rules.minSizeBytes) return ignore('below_min_size'); + + const task = state.add({ + url, + filename: (params['filename'] as string | null) ?? undefined, + sizeBytes: typeof length === 'number' ? length : undefined, + state: 'connecting', + }); + return { action: 'take', taskId: task.taskId, reason: null }; + } + + case 'limiter.get': + return state.limiter; + + case 'limiter.set': { + state.limiter = { + enabled: Boolean(params['enabled']), + globalBps: Number(params['globalBps'] ?? state.limiter.globalBps), + applyToRunning: Boolean(params['applyToRunning']), + }; + return state.limiter; + } + + case 'settings.get': { + const base = this.fixtureResult('settings.get') as { values: Record }; + const values = { ...base.values, ...state.settings }; + const keys = params['keys'] as string[] | null | undefined; + if (!keys) return { values }; + const picked: Record = {}; + for (const k of keys) if (k in values) picked[k] = values[k]; + return { values: picked }; + } + + case 'settings.set': { + const values = (params['values'] ?? {}) as Record; + Object.assign(state.settings, values); + return { values, changed: Object.keys(values) }; + } + + default: + return this.fixtureResult(method); + } + } + + private withinAllowedRoots(dir: string): boolean { + const resolved = resolvePath(dir); + return this.opts.allowedRoots.some((root) => resolved === root || resolved.startsWith(root + '/')); + } + + /** The golden result for a method, with placeholders resolved fresh each time. */ + fixtureResult(method: string): unknown { + const fixture = this.fixtures.get(method); + if (!fixture || !fixture.response || !('result' in fixture.response)) { + return { code: ErrorCode.InternalError, message: `mockd has no fixture for ${method}` }; + } + return resolvePlaceholders(fixture.response.result); + } +} + +function notFound(taskId: string): Json { + return { code: ErrorCode.TaskNotFound, message: 'no such task', data: { taskId } }; +} diff --git a/tools/mockd/src/fixtures.ts b/tools/mockd/src/fixtures.ts new file mode 100644 index 0000000..ebcff00 --- /dev/null +++ b/tools/mockd/src/fixtures.ts @@ -0,0 +1,89 @@ +/** + * Loading and replaying contracts/fixtures. + * + * mockd answers from the golden files rather than from hand-written mock objects, so a + * GUI or extension built against it is built against the same bytes the conformance suite + * replays at the real daemon. If a fixture is wrong, everyone finds out at once. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export interface Fixture { + readonly file: string; + readonly name: string; + readonly description: string; + readonly kind?: 'timeout'; + readonly transport?: 'uds' | 'ws'; + readonly request?: { jsonrpc: '2.0'; id: number | string; method: string; params?: unknown }; + readonly notification?: { jsonrpc: '2.0'; method: string; params: unknown }; + readonly response?: { jsonrpc: '2.0'; id: number | string; result?: unknown; error?: unknown } | null; + readonly assertions?: readonly string[]; +} + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (entry.endsWith('.json')) out.push(full); + } + return out; +} + +export function loadFixtures(root: string): Fixture[] { + return walk(root).map((file) => ({ + ...(JSON.parse(readFileSync(file, 'utf8')) as Omit), + file: relative(root, file), + })); +} + +/** + * Placeholders stand for values a golden file cannot pin: a fresh uuid, the current time, + * an opaque token. Resolving them here is what lets one fixture be replayed forever. + */ +export function resolvePlaceholders(value: unknown): unknown { + if (typeof value === 'string') { + switch (value) { + case '$uuid': + return randomUUID(); + case '$isoDate': + return new Date().toISOString(); + case '$opaque': + return Buffer.from(randomUUID() + randomUUID()).toString('base64url'); + case '$any': + return 'placeholder'; + default: + return value; + } + } + if (Array.isArray(value)) return value.map(resolvePlaceholders); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = resolvePlaceholders(v); + } + return out; + } + return value; +} + +/** Success fixtures indexed by method, so a request can be answered from a golden file. */ +export function indexByMethod(fixtures: readonly Fixture[]): Map { + const out = new Map(); + for (const f of fixtures) { + const method = f.request?.method; + if (!method || !f.response || !('result' in f.response)) continue; + if (!out.has(method)) out.set(method, f); + } + return out; +} + +export function eventFixtures(fixtures: readonly Fixture[]): Map { + const out = new Map(); + for (const f of fixtures) { + if (f.notification) out.set(f.notification.method, f); + } + return out; +} diff --git a/tools/mockd/src/index.ts b/tools/mockd/src/index.ts new file mode 100644 index 0000000..8ef0090 --- /dev/null +++ b/tools/mockd/src/index.ts @@ -0,0 +1,218 @@ +#!/usr/bin/env -S npx tsx +/** + * mockd — a fake veloxd that is good enough to build a GUI and an extension against. + * + * It serves contracts/fixtures over both transports, keeps just enough state that adding + * and pausing a download does something visible, and fakes progress events at 4 Hz. Its + * unhappy-path flags exist so the GUI and EXT lanes can test the cases that are hard to + * arrange on purpose — a slow daemon, a flaky one, a dropped socket, a refused pairing — + * long before the real daemon exists. + * + * npm start -- --help + */ + +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { rmSync } from 'node:fs'; + +import { Dispatcher, type Session } from './dispatch.js'; +import { indexByMethod, loadFixtures, resolvePlaceholders } from './fixtures.js'; +import { MockState } from './state.js'; +import { startUds, type Connection } from './transport/uds.js'; +import { startWs } from './transport/ws.js'; +import type { TaskSummary } from '../../../extension/src/shared/protocol/types.js'; +import { PROTOCOL_VERSION } from '../../../extension/src/shared/protocol/types.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const REPO = resolve(HERE, '..', '..', '..'); +const FIXTURE_DIR = resolve(REPO, 'contracts', 'fixtures'); + +const USAGE = `mockd — mock veloxd, serving contracts/fixtures over both transports + + --uds Unix socket path + (default: $XDG_RUNTIME_DIR/velox/velox.sock) + --ws-port loopback WebSocket port (default: 52000) + --no-uds do not listen on the Unix socket + --no-ws do not listen on the WebSocket + --progress-hz progress event rate (default: 4, the contract's maximum) + --speed synthetic per-task speed in bytes/sec (default: 8388608) + +Unhappy paths, for the GUI and EXT lanes: + --slow delay every reply by . Past 750 ms, capture.offer must + fail open and let Firefox take the download. + --flaky <0..1> answer this fraction of calls with -32603 + --drop-connection terminate every connection every seconds + --refuse-pairing session.pair always fails, as if the user clicked Deny + --lockout session.pair answers -32014, as if the brute-force lockout tripped + --allowed-root add a root that download.add's saveDir may resolve inside + --allow-any-origin skip the moz-extension:// Origin check (debugging only) + --no-validate do not validate params against the generated validators + + -h, --help this message +`; + +interface Args { + uds: string | null; + wsPort: number | null; + progressHz: number; + speed: number; + slow: number; + flaky: number; + dropEverySec: number; + refusePairing: boolean; + allowAnyOrigin: boolean; + validate: boolean; + lockout: boolean; + allowedRoots: string[]; +} + +function parseArgs(argv: readonly string[]): Args { + const runtime = process.env['XDG_RUNTIME_DIR'] ?? `/run/user/${process.getuid?.() ?? 1000}`; + const args: Args = { + uds: resolve(runtime, 'velox', 'velox.sock'), + wsPort: 52000, + progressHz: 4, + speed: 8 * 1024 * 1024, + slow: 0, + flaky: 0, + dropEverySec: 0, + refusePairing: false, + allowAnyOrigin: false, + validate: true, + lockout: false, + allowedRoots: [resolve(process.env['HOME'] ?? '/home/sami', 'Downloads'), '/tmp'], + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = (): string => { + const v = argv[i + 1]; + if (v === undefined) throw new Error(`${arg} needs a value`); + i += 1; + return v; + }; + switch (arg) { + case '-h': case '--help': process.stdout.write(USAGE); process.exit(0); break; + case '--uds': args.uds = next(); break; + case '--no-uds': args.uds = null; break; + case '--ws-port': args.wsPort = Number(next()); break; + case '--no-ws': args.wsPort = null; break; + case '--progress-hz': args.progressHz = Number(next()); break; + case '--speed': args.speed = Number(next()); break; + case '--slow': args.slow = Number(next()); break; + case '--flaky': args.flaky = Number(next()); break; + case '--drop-connection': args.dropEverySec = Number(next()); break; + case '--refuse-pairing': args.refusePairing = true; break; + case '--lockout': args.lockout = true; break; + case '--allowed-root': args.allowedRoots.push(resolve(next())); break; + case '--allow-any-origin': args.allowAnyOrigin = true; break; + case '--no-validate': args.validate = false; break; + default: + process.stderr.write(`mockd: unknown option ${arg}\n\n${USAGE}`); + process.exit(2); + } + } + return args; +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + const log = (msg: string): void => { + process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`); + }; + + const fixtures = loadFixtures(FIXTURE_DIR); + const byMethod = indexByMethod(fixtures); + log(`loaded ${fixtures.length} fixtures covering ${byMethod.size} methods from contracts/fixtures`); + + // Seed the task list from the download.list fixture, so a client that connects before + // adding anything still has rows to draw. + const listFixture = byMethod.get('download.list'); + const seed = (resolvePlaceholders( + (listFixture?.response as { result?: { items?: unknown[] } } | undefined)?.result?.items ?? [], + ) as TaskSummary[]); + + const state = new MockState(seed, { progressHz: args.progressHz, speedBps: args.speed }); + const dispatcher = new Dispatcher(state, byMethod, { + protocolVersion: PROTOCOL_VERSION, + daemonVersion: '0.0.0-mockd', + refusePairing: args.refusePairing, + flaky: args.flaky, + validate: args.validate, + allowedRoots: args.allowedRoots, + lockout: args.lockout, + }); + + const connections = new Set(); + const broadcast = (method: string, params: unknown): void => { + const frame = { jsonrpc: '2.0', method, params }; + for (const conn of connections) { + if (conn.session.subscribed.has(method)) conn.send(frame); + } + }; + + if (args.uds !== null) { + startUds(args.uds, dispatcher, connections, log, args.slow); + const path = args.uds; + const cleanup = (): void => { + rmSync(path, { force: true }); + process.exit(0); + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + } + if (args.wsPort !== null) { + startWs({ port: args.wsPort, delayMs: args.slow, dropEverySec: args.dropEverySec, + allowAnyOrigin: args.allowAnyOrigin }, dispatcher, connections, log); + } + + // Progress at the contract's 4 Hz ceiling, as one batched array — never one + // notification per task. The GUI lane needs this shape to build its coalescing against. + setInterval(() => { + const { moved, completed } = state.tick(); + if (moved.length > 0) { + broadcast('event.task.progress', { + tasks: moved.map((t) => ({ + taskId: t.taskId, + downloadedBytes: t.downloadedBytes, + speedBps: t.speedBps, + etaSeconds: t.etaSeconds, + segments: Array.from({ length: Math.min(t.segments, 8) }, (_, i) => ({ + index: i, + downloadedBytes: Math.floor(t.downloadedBytes / t.segments), + speedBps: Math.floor(t.speedBps / t.segments), + })), + })), + at: new Date().toISOString(), + }); + } + for (const task of completed) { + broadcast('event.task.state', { + taskId: task.taskId, state: 'complete', previousState: 'downloading', + summary: task, error: null, + }); + broadcast('event.notify', { + level: 'success', title: 'Download complete', + body: `${task.filename} finished.`, taskId: task.taskId, sound: 'complete', + }); + } + }, Math.max(1, Math.round(1000 / args.progressHz))); + + setInterval(() => { + const speed = state.globalSpeed(); + broadcast('event.speed.global', { + ...speed, + limitBps: state.limiter.enabled ? state.limiter.globalBps : null, + }); + }, 1000); + + log(`protocol v${PROTOCOL_VERSION}; ${state.tasks.size} seeded task(s)`); + if (args.slow > 750) { + log(`WARNING --slow ${args.slow} exceeds capture.offer's 750 ms deadline: a correct extension will fail open`); + } +} + +main(); + +// Referenced so the Session type stays exported for transport implementations. +export type { Session }; diff --git a/tools/mockd/src/state.ts b/tools/mockd/src/state.ts new file mode 100644 index 0000000..c1c343e --- /dev/null +++ b/tools/mockd/src/state.ts @@ -0,0 +1,145 @@ +/** + * The little bit of real state mockd keeps. + * + * A pure fixture replayer would be useless to the GUI lane: adding a download and seeing + * nothing appear teaches you nothing about your table model. So mockd keeps a task list, + * moves tasks between states, and advances byte counters on a timer. Everything else is + * answered straight from a golden file. + * + * This is deliberately not a download engine. Bytes advance on a clock, not from a socket. + */ + +import { randomUUID } from 'node:crypto'; +import type { TaskState, TaskSummary } from '../../../extension/src/shared/protocol/types.js'; + +export interface MockOptions { + readonly progressHz: number; + readonly speedBps: number; +} + +const ACTIVE: readonly TaskState[] = ['connecting', 'downloading']; + +export class MockState { + readonly tasks = new Map(); + limiter = { enabled: false, globalBps: 2097152, applyToRunning: false }; + settings: Record = {}; + private readonly opts: MockOptions; + + constructor(seed: readonly TaskSummary[], opts: MockOptions) { + this.opts = opts; + for (const t of seed) this.tasks.set(t.taskId, { ...t }); + } + + list(): TaskSummary[] { + return [...this.tasks.values()]; + } + + add(partial: Partial & { url: string }): TaskSummary { + const now = new Date().toISOString(); + const task: TaskSummary = { + taskId: randomUUID(), + filename: partial.filename ?? filenameFromUrl(partial.url), + saveDir: partial.saveDir ?? '/home/sami/Downloads', + url: partial.url, + effectiveUrl: partial.url, + sizeBytes: partial.sizeBytes ?? 734003200, + downloadedBytes: 0, + state: partial.state ?? 'connecting', + speedBps: 0, + etaSeconds: null, + resumable: true, + segments: partial.segments ?? 8, + categoryId: partial.categoryId ?? null, + queueId: partial.queueId ?? null, + queuePosition: partial.queuePosition ?? null, + description: partial.description ?? null, + createdAt: now, + lastTryAt: now, + completedAt: null, + error: null, + }; + this.tasks.set(task.taskId, task); + return task; + } + + transition(taskId: string, state: TaskState): { changed: boolean; task: TaskSummary } | null { + const task = this.tasks.get(taskId); + if (!task) return null; + const changed = task.state !== state; + task.state = state; + task.speedBps = ACTIVE.includes(state) ? this.opts.speedBps : 0; + if (!ACTIVE.includes(state)) task.etaSeconds = null; + if (state === 'complete') { + task.downloadedBytes = task.sizeBytes ?? task.downloadedBytes; + task.completedAt = new Date().toISOString(); + } + return { changed, task }; + } + + remove(taskId: string): boolean { + return this.tasks.delete(taskId); + } + + /** + * Advance one progress tick. Returns the tasks that moved and any that just finished, + * so the caller can emit event.task.progress and event.task.state from one place. + */ + tick(): { moved: TaskSummary[]; completed: TaskSummary[] } { + const moved: TaskSummary[] = []; + const completed: TaskSummary[] = []; + const perTick = Math.floor(this.opts.speedBps / this.opts.progressHz); + + for (const task of this.tasks.values()) { + if (task.state === 'connecting') { + task.state = 'downloading'; + moved.push(task); + } + if (task.state !== 'downloading') continue; + + task.speedBps = jitter(perTick * this.opts.progressHz); + task.downloadedBytes += perTick; + const size = task.sizeBytes; + if (size !== null && size !== undefined && task.downloadedBytes >= size) { + task.downloadedBytes = size; + task.state = 'complete'; + task.speedBps = 0; + task.etaSeconds = null; + task.completedAt = new Date().toISOString(); + completed.push(task); + } else if (size !== null && size !== undefined && task.speedBps > 0) { + task.etaSeconds = Math.ceil((size - task.downloadedBytes) / task.speedBps); + } + moved.push(task); + } + return { moved, completed }; + } + + globalSpeed(): { downBps: number; activeCount: number; queuedCount: number } { + let downBps = 0; + let activeCount = 0; + let queuedCount = 0; + for (const t of this.tasks.values()) { + if (ACTIVE.includes(t.state)) { + downBps += t.speedBps; + activeCount += 1; + } else if (t.state === 'queued') { + queuedCount += 1; + } + } + return { downBps, activeCount, queuedCount }; + } +} + +function jitter(bps: number): number { + return Math.max(0, Math.round(bps * (0.85 + Math.random() * 0.3))); +} + +function filenameFromUrl(url: string): string { + try { + const path = new URL(url).pathname; + const last = path.split('/').filter(Boolean).pop(); + return last && last.length > 0 ? decodeURIComponent(last) : 'download.bin'; + } catch { + return 'download.bin'; + } +} diff --git a/tools/mockd/src/transport/uds.ts b/tools/mockd/src/transport/uds.ts new file mode 100644 index 0000000..1f1bec6 --- /dev/null +++ b/tools/mockd/src/transport/uds.ts @@ -0,0 +1,85 @@ +/** + * Unix socket transport: newline-delimited JSON, one request per line. + * + * This is what the GUI, the CLI and velox-nmhost speak. The real daemon checks + * SO_PEERCRED here and needs no token; mockd does the same by simply trusting the socket, + * because anything that can open it is already the same user. + */ + +import { createServer, type Server, type Socket } from 'node:net'; +import { mkdirSync, rmSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { Dispatcher, Session } from '../dispatch.js'; + +export interface Connection { + send(frame: unknown): void; + readonly session: Session; +} + +export function startUds( + path: string, + dispatcher: Dispatcher, + connections: Set, + log: (msg: string) => void, + delayMs: number, +): Server { + mkdirSync(dirname(path), { recursive: true }); + rmSync(path, { force: true }); + + const server = createServer((socket: 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)`); + + let buffer = ''; + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + let nl = buffer.indexOf('\n'); + while (nl !== -1) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + nl = buffer.indexOf('\n'); + if (line.length === 0) continue; + handleLine(line, conn, dispatcher, log, delayMs); + } + }); + + socket.on('error', (err) => log(`uds: socket error: ${err.message}`)); + socket.on('close', () => { + connections.delete(conn); + log(`uds: client disconnected (${connections.size} open)`); + }); + }); + + server.listen(path, () => log(`uds: listening on ${path}`)); + return server; +} + +function handleLine( + line: string, + conn: Connection, + dispatcher: Dispatcher, + log: (msg: string) => void, + delayMs: number, +): void { + let frame: unknown; + try { + frame = JSON.parse(line); + } catch { + conn.send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }); + return; + } + const reply = dispatcher.handle(conn.session, frame); + if (!reply) return; + const method = (frame as { method?: string }).method ?? '?'; + log(`uds: ${method} -> ${'error' in reply ? `error ${(reply['error'] as { code: number }).code}` : 'ok'}`); + if (delayMs > 0) setTimeout(() => conn.send(reply), delayMs); + else conn.send(reply); +} diff --git a/tools/mockd/src/transport/ws.ts b/tools/mockd/src/transport/ws.ts new file mode 100644 index 0000000..4e8f7d7 --- /dev/null +++ b/tools/mockd/src/transport/ws.ts @@ -0,0 +1,88 @@ +/** + * Loopback WebSocket transport: one JSON message per text frame. + * + * The extension's fallback for snap-confined Firefox, and the reason the security rules in + * docs/05 exist: this port is reachable by every process on the machine. mockd enforces + * the two that a client can actually observe — bind 127.0.0.1 only, and check the Origin — + * so the EXT lane finds out here rather than against the real daemon. + */ + +import { WebSocketServer, type WebSocket } from 'ws'; +import { randomUUID } from 'node:crypto'; +import type { Dispatcher, Session } from '../dispatch.js'; +import type { Connection } from './uds.js'; + +export interface WsOptions { + readonly port: number; + readonly delayMs: number; + /** Drop every connection every N seconds, to exercise reconnect logic. */ + readonly dropEverySec: number; + readonly allowAnyOrigin: boolean; +} + +export function startWs( + opts: WsOptions, + dispatcher: Dispatcher, + connections: Set, + log: (msg: string) => void, +): WebSocketServer { + const server = new WebSocketServer({ + host: '127.0.0.1', // never 0.0.0.0 — see docs/05-extension-spec.md §4 + port: opts.port, + verifyClient: ({ origin }, done) => { + const ok = opts.allowAnyOrigin || origin === undefined || origin.startsWith('moz-extension://'); + if (!ok) log(`ws: refused connection from origin ${origin}`); + done(ok, 403, 'origin not allowed'); + }, + }); + + server.on('listening', () => log(`ws: listening on ws://127.0.0.1:${opts.port}`)); + + server.on('connection', (socket: WebSocket) => { + const session: Session = { + transport: 'ws', + paired: false, // the extension must pair or present a token first + subscribed: new Set(), + sessionId: randomUUID(), + }; + const conn: Connection = { + session, + send: (frame) => { + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(frame)); + }, + }; + connections.add(conn); + log(`ws: client connected (${connections.size} open)`); + + socket.on('message', (data) => { + let frame: unknown; + try { + frame = JSON.parse(data.toString()); + } catch { + conn.send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }); + return; + } + const reply = dispatcher.handle(session, frame); + if (!reply) return; + const method = (frame as { method?: string }).method ?? '?'; + log(`ws: ${method} -> ${'error' in reply ? `error ${(reply['error'] as { code: number }).code}` : 'ok'}`); + if (opts.delayMs > 0) setTimeout(() => conn.send(reply), opts.delayMs); + else conn.send(reply); + }); + + socket.on('close', () => { + connections.delete(conn); + log(`ws: client disconnected (${connections.size} open)`); + }); + socket.on('error', (err) => log(`ws: socket error: ${err.message}`)); + }); + + if (opts.dropEverySec > 0) { + setInterval(() => { + log(`ws: dropping ${server.clients.size} connection(s) (--drop-connection)`); + for (const client of server.clients) client.terminate(); + }, opts.dropEverySec * 1000).unref(); + } + + return server; +} diff --git a/tools/mockd/tsconfig.json b/tools/mockd/tsconfig.json new file mode 100644 index 0000000..638f60c --- /dev/null +++ b/tools/mockd/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitOverride": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "../../extension/src/shared/protocol/**/*.ts"] +}