From 2cb1959bffc974e573771670e8dd106ae294fc24 Mon Sep 17 00:00:00 2001 From: sami Date: Fri, 11 Sep 2026 12:25:19 +0400 Subject: [PATCH 1/4] ext: no-download-logic ESLint gate in the extension-lint CI job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md §3's rule was prose only for extension/ (GUI already has gui_no_download_logic as a ctest). eslint.config.mjs adds a no-restricted-syntax/no-restricted-globals rule banning fetch/XHR/Request, ReadableStream.getReader, Range/Content-Range header construction, and IndexedDB in src/**/*.ts. Verified red on a planted violation (fetch + Range header + stream reader) and green on ordinary code; that check is now a permanent regression test (tests/lint/no-download-logic.test.ts) rather than a one-off manual run. Wired into the existing extension-lint job in .github/workflows/ci.yml, ahead of web-ext lint. Generated protocol code (src/shared/protocol/**) is excluded from lint entirely — it must never be hand-edited, so flagging it as fixable would be a lie. Answers gui/docs/ext-requests-m1.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB --- .github/workflows/ci.yml | 7 +- extension/eslint.config.mjs | 90 ++ extension/package-lock.json | 1080 ++++++++++++++--- extension/package.json | 7 +- .../tests/lint/no-download-logic.test.ts | 40 + 5 files changed, 1062 insertions(+), 162 deletions(-) create mode 100644 extension/eslint.config.mjs create mode 100644 extension/tests/lint/no-download-logic.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04446fc..079e67a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,11 +105,16 @@ jobs: if: steps.check.outputs.present == 'true' with: node-version: '22' - - name: web-ext lint + - name: eslint (no-download-logic gate + general rules) if: steps.check.outputs.present == 'true' working-directory: extension run: | npm ci + npx eslint . + - name: web-ext lint + if: steps.check.outputs.present == 'true' + working-directory: extension + run: | npx web-ext lint --source-dir . # --- build + test matrix ---------------------------------------------------------- diff --git a/extension/eslint.config.mjs b/extension/eslint.config.mjs new file mode 100644 index 0000000..43912fe --- /dev/null +++ b/extension/eslint.config.mjs @@ -0,0 +1,90 @@ +// ESLint config for the extension. +// +// The "no download logic in extension/" rule (CLAUDE.md §3) used to be prose only. +// GUI turned its half into a ctest (gui/tests/no_download_logic.cmake); this is EXT's +// equivalent — a build-failing gate instead of something a reviewer has to remember to +// look for. See gui/docs/ext-requests-m1.md for the request this answers. +// +// The extension's whole job is: collect URL + headers + cookies, hand them to veloxd, +// render what comes back. It must never fetch bytes, assemble a Range request, or read +// a response body itself — that is download logic, and it belongs in core/daemon only. +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +const noDownloadLogic = { + name: 'velox/no-download-logic', + files: ['src/**/*.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: "NewExpression[callee.name='XMLHttpRequest']", + message: + 'No XMLHttpRequest in extension/ — the extension hands URLs to veloxd, it never fetches bytes itself (CLAUDE.md §3).', + }, + { + selector: "NewExpression[callee.name='Request']", + message: + 'No `new Request(...)` in extension/ — that is download-side plumbing. Hand the URL to veloxd instead (CLAUDE.md §3).', + }, + { + selector: "CallExpression[callee.name='fetch']", + message: + 'No fetch() in extension/ — the extension never retrieves download bytes itself (CLAUDE.md §3). Talking to veloxd goes through transport/, not fetch.', + }, + { + selector: "MemberExpression[property.name='getReader']", + message: + 'No ReadableStream reader in extension/ — reading a response body here is download logic (CLAUDE.md §3); the daemon owns transfer bytes.', + }, + { + selector: + "Property[key.name='Range'], Property[key.name='range'], Property[key.name='Content-Range'], Property[key.name='content-range']", + message: + 'No Range/Content-Range header construction in extension/ — resumption is the daemon\'s job (CLAUDE.md §3, docs/05).', + }, + { + selector: "NewExpression[callee.object.name='indexedDB'], CallExpression[callee.object.name='indexedDB']", + message: 'No IndexedDB in extension/ for moving bytes — hand off to veloxd instead (CLAUDE.md §3).', + }, + ], + 'no-restricted-globals': [ + 'error', + { name: 'fetch', message: 'No fetch() in extension/ — see CLAUDE.md §3.' }, + { name: 'XMLHttpRequest', message: 'No XMLHttpRequest in extension/ — see CLAUDE.md §3.' }, + { name: 'indexedDB', message: 'No IndexedDB in extension/ — see CLAUDE.md §3.' }, + ], + }, +}; + +export default tseslint.config( + { + // src/shared/protocol/** is generated (contracts/codegen/gen_ts.py) and must never + // be hand-edited — linting it as if we could fix a finding would be a lie. + ignores: ['dist/**', 'node_modules/**', 'scripts/**', 'src/shared/protocol/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['src/**/*.ts', 'tests/**/*.ts'], + languageOptions: { + parserOptions: { + project: false, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + }, + noDownloadLogic, + { + // Tests legitimately construct fake Requests/fetch mocks to exercise transport code + // against a fake server; the rule protects src/, not the harness that pokes at it. + files: ['tests/**/*.ts'], + rules: { + 'no-restricted-syntax': 'off', + 'no-restricted-globals': 'off', + }, + }, +); diff --git a/extension/package-lock.json b/extension/package-lock.json index 225a50e..7950d84 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -11,7 +11,10 @@ "@types/firefox-webext-browser": "^120.0.4", "@types/ws": "^8.5.12", "esbuild": "^0.24.0", + "eslint": "^9.39.5", + "happy-dom": "^15.11.7", "typescript": "^5.6.0", + "typescript-eslint": "^8.70.0", "vitest": "^2.1.0", "web-ext": "^8.3.0", "ws": "^8.18.0" @@ -537,23 +540,62 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, - "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "dependencies": { + "ajv": "^6.14.0", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -561,8 +603,9 @@ }, "node_modules/@eslint/eslintrc/node_modules/ajv": { "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -574,42 +617,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/espree": { - "version": "9.6.1", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" }, @@ -618,11 +636,37 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.1", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, - "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@fluent/syntax": { @@ -642,10 +686,47 @@ "node": ">= 0.10.0" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", "dev": true, - "license": "Apache-2.0", "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -669,8 +750,23 @@ }, "node_modules/@humanwhocodes/object-schema": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "BSD-3-Clause" + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", @@ -699,8 +795,9 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -711,16 +808,18 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, - "license": "MIT", "engines": { "node": ">= 8" } }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -801,6 +900,12 @@ "integrity": "sha512-imn8ecga0HQWcOSvxy9i0lD+7vpHgkj1NVLvXS1lNHqHt03Z4QwazeEdoWe+C9JXpmVyKiRanb+z9D/w001tRg==", "dev": true }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, "node_modules/@types/minimatch": { "version": "3.0.5", "dev": true, @@ -831,10 +936,290 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.4.0", - "dev": true, - "license": "ISC" + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true }, "node_modules/@vitest/expect": { "version": "2.1.9", @@ -992,6 +1377,259 @@ "node": ">=18.0.0" } }, + "node_modules/addons-linter/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/addons-linter/node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/addons-linter/node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/eslint/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/addons-linter/node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/addons-linter/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/addons-linter/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/addons-linter/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/addons-moz-compare": { "version": "1.3.0", "dev": true, @@ -1111,8 +1749,9 @@ }, "node_modules/argparse": { "version": "2.0.1", - "dev": true, - "license": "Python-2.0" + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/array-differ": { "version": "4.0.0", @@ -1270,8 +1909,9 @@ }, "node_modules/callsites": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -1693,8 +2333,9 @@ }, "node_modules/doctrine": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, - "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -1883,57 +2524,63 @@ } }, "node_modules/eslint": { - "version": "8.57.1", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-plugin-no-unsanitized": { @@ -1945,15 +2592,16 @@ } }, "node_modules/eslint-scope": { - "version": "7.2.2", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -1985,33 +2633,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/espree": { - "version": "9.6.1", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "dev": true, @@ -2058,8 +2679,9 @@ }, "node_modules/esrecurse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -2085,8 +2707,9 @@ }, "node_modules/esutils": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } @@ -2144,8 +2767,9 @@ }, "node_modules/fastq": { "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, - "license": "ISC", "dependencies": { "reusify": "^1.0.4" } @@ -2158,15 +2782,33 @@ "pend": "~1.2.0" } }, - "node_modules/file-entry-cache": { - "version": "6.0.1", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, - "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/find-up": { @@ -2211,22 +2853,23 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, - "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { "version": "3.4.4", - "dev": true, - "license": "ISC" + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true }, "node_modules/fs-extra": { "version": "11.4.0", @@ -2243,8 +2886,9 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", - "dev": true, - "license": "ISC" + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -2325,8 +2969,10 @@ }, "node_modules/glob": { "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -2381,14 +3027,12 @@ } }, "node_modules/globals": { - "version": "13.24.0", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2406,14 +3050,29 @@ }, "node_modules/graphemer": { "version": "1.4.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true }, "node_modules/growly": { "version": "1.3.0", "dev": true, "license": "MIT" }, + "node_modules/happy-dom": { + "version": "15.11.7", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.7.tgz", + "integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==", + "dev": true, + "dependencies": { + "entities": "^4.5.0", + "webidl-conversions": "^7.0.0", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "dev": true, @@ -2454,8 +3113,9 @@ }, "node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } @@ -2478,8 +3138,9 @@ }, "node_modules/import-fresh": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2501,8 +3162,10 @@ }, "node_modules/inflight": { "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, - "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -2662,8 +3325,9 @@ }, "node_modules/is-path-inside": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -2716,6 +3380,8 @@ }, "node_modules/js-yaml": { "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -2727,7 +3393,6 @@ "url": "https://github.com/sponsors/nodeca" } ], - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -2737,8 +3402,9 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true }, "node_modules/json-merge-patch": { "version": "1.0.2", @@ -2790,8 +3456,9 @@ }, "node_modules/keyv": { "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -3005,8 +3672,9 @@ }, "node_modules/once": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, - "license": "ISC", "dependencies": { "wrappy": "1" } @@ -3103,8 +3771,9 @@ }, "node_modules/parent-module": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -3185,8 +3854,9 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -3222,6 +3892,18 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pino": { "version": "9.9.5", "dev": true, @@ -3351,6 +4033,8 @@ }, "node_modules/queue-microtask": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ { @@ -3365,8 +4049,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "license": "MIT" + ] }, "node_modules/quick-format-unescaped": { "version": "4.0.4", @@ -3465,16 +4148,18 @@ }, "node_modules/resolve-from": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/reusify": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, - "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -3482,8 +4167,10 @@ }, "node_modules/rimraf": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -3551,6 +4238,8 @@ }, "node_modules/run-parallel": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { @@ -3566,7 +4255,6 @@ "url": "https://feross.org/support" } ], - "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -3845,8 +4533,9 @@ }, "node_modules/text-table": { "version": "0.2.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true }, "node_modules/thread-stream": { "version": "3.2.0", @@ -3871,6 +4560,22 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tinypool": { "version": "1.1.1", "dev": true, @@ -3903,6 +4608,18 @@ "node": ">=14.14" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -3916,8 +4633,9 @@ }, "node_modules/type-fest": { "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -3942,6 +4660,29 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "dev": true, @@ -4629,6 +5370,24 @@ "npm": ">=8.0.0" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/when": { "version": "3.7.7", "dev": true, @@ -4749,8 +5508,9 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "dev": true, - "license": "ISC" + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true }, "node_modules/ws": { "version": "8.21.3", diff --git a/extension/package.json b/extension/package.json index fc0024c..8433b28 100644 --- a/extension/package.json +++ b/extension/package.json @@ -10,13 +10,18 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "lint": "web-ext lint --source-dir ." + "lint": "npm run lint:eslint && npm run lint:webext", + "lint:eslint": "eslint .", + "lint:webext": "web-ext lint --source-dir ." }, "devDependencies": { "@types/firefox-webext-browser": "^120.0.4", "@types/ws": "^8.5.12", "esbuild": "^0.24.0", + "eslint": "^9.39.5", + "happy-dom": "^15.11.7", "typescript": "^5.6.0", + "typescript-eslint": "^8.70.0", "vitest": "^2.1.0", "web-ext": "^8.3.0", "ws": "^8.18.0" diff --git a/extension/tests/lint/no-download-logic.test.ts b/extension/tests/lint/no-download-logic.test.ts new file mode 100644 index 0000000..0007dfb --- /dev/null +++ b/extension/tests/lint/no-download-logic.test.ts @@ -0,0 +1,40 @@ +// Regression test for the "no download logic in extension/" ESLint gate +// (eslint.config.mjs, answering gui/docs/ext-requests-m1.md). Runs ESLint's Node API +// directly against fixture source so a future edit to the rule set can't silently stop +// catching the patterns it was written for. +import { ESLint } from 'eslint'; +import { describe, expect, it } from 'vitest'; + +async function lint(code: string): Promise { + const eslint = new ESLint({ cwd: new URL('../..', import.meta.url).pathname }); + // Path only needs to match the `files: ['src/**/*.ts']` glob in eslint.config.mjs. + const [result] = await eslint.lintText(code, { filePath: 'src/background/__fixture.ts' }); + return result.messages.filter((m) => m.severity === 2).length; +} + +describe('no-download-logic ESLint gate', () => { + it('goes red on fetch() + a hand-built Range header + a stream reader', async () => { + const errors = await lint(` + export async function grabBytes(url: string) { + const res = await fetch(url, { headers: { Range: 'bytes=0-1023' } }); + const reader = res.body!.getReader(); + return reader.read(); + } + `); + expect(errors).toBeGreaterThan(0); + }); + + it('goes red on XMLHttpRequest', async () => { + const errors = await lint(`const x = new XMLHttpRequest();`); + expect(errors).toBeGreaterThan(0); + }); + + it('stays green on ordinary transport/RPC code', async () => { + const errors = await lint(` + export function greet(name: string): string { + return \`hello \${name}\`; + } + `); + expect(errors).toBe(0); + }); +}); From 25c171f74295b341208db742970059133a5a6ed5 Mon Sep 17 00:00:00 2001 From: sami Date: Fri, 11 Sep 2026 12:25:27 +0400 Subject: [PATCH 2/4] ext: pairing-restart test + AMO permission justification tests/transport/storage.test.ts covers the storage half of "the pairing token survives a browser restart" (round-trip, unpair-clears, corrupted override falls back to auto). websocket.test.ts adds the transport half: a fresh WebSocketTransport instance over the same backing store reuses the persisted token with no re-pairing, plus pairWithCode/unpair coverage. "Wrong token rejected and rate-limited" was already covered (websocket.test.ts's NotPaired/RateLimited cases). docs/amo-permissions.md is the submission-ready permission justification for AMO's Notes to Reviewer field, covering every permission in manifest.json plus what was deliberately not requested and how cookie/ header data is handled. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB --- extension/docs/amo-permissions.md | 49 ++++++++++++++++ extension/src/background/transport/types.ts | 5 ++ .../src/background/transport/websocket.ts | 27 +++++++++ extension/tests/transport/storage.test.ts | 56 +++++++++++++++++++ extension/tests/transport/websocket.test.ts | 53 ++++++++++++++++++ 5 files changed, 190 insertions(+) create mode 100644 extension/docs/amo-permissions.md create mode 100644 extension/tests/transport/storage.test.ts diff --git a/extension/docs/amo-permissions.md b/extension/docs/amo-permissions.md new file mode 100644 index 0000000..636d086 --- /dev/null +++ b/extension/docs/amo-permissions.md @@ -0,0 +1,49 @@ +# AMO permission justification + +Submitted with every version bump in the AMO "Notes to Reviewer" field. Kept here so the +justification is reviewed and versioned alongside the permission list itself +(`manifest.json`), instead of living only in a web form. docs/05 §7 is the design-time +version of this; this file is the submission-ready copy. + +## What Velox is + +A download manager. It intercepts a response Firefox is about to download, hands the +URL, request headers, and cookies to a companion native application (`veloxd`), and lets +that application fetch the file with resumable, multi-connection transfers. The +extension itself never stores or transfers file bytes — see `CLAUDE.md` §3 and the +`no-download-logic` ESLint gate (`eslint.config.mjs`) that fails CI if it ever does. + +## Permissions requested + +| Permission | Why | Narrowest alternative considered | +|---|---|---| +| `webRequest` + `webRequestBlocking` | The whole feature: inspect response headers on `onHeadersReceived` to decide whether to intercept a download, and `{cancel: true}` before Firefox starts its own download. This is the one thing MV3 Chrome removed and MV3 Firefox kept — it's why the extension can exist as designed (docs/05 §1). | None. Without blocking `webRequest` there is no way to stop Firefox's own download before it starts; polling `downloads.onCreated` alone (which we also use, see below) only catches what already started. | +| `downloads` | Belt-and-braces safety net (`capture/downloads-api.ts`): some downloads (form POSTs, service-worker blobs) never reach `onHeadersReceived` in a way we can act on and only surface via `downloads.onCreated`. Also used to `cancel`/`erase` a download we're taking over so Firefox doesn't keep two copies. | Drop the safety net and accept that those cases silently bypass Velox. Rejected — docs/05 §2 calls this out explicitly as a known gap the safety net exists to close. | +| `cookies` | A file behind a login (private CDN links, forum attachments) needs its session cookies handed to `veloxd`, or the daemon's fetch gets a 403 the browser's own request wouldn't have. Read via `cookies.getAll(url)` only for a URL we are about to offer to the daemon — never harvested in bulk or logged. | Skip cookies and only support anonymous URLs. Rejected — it's a top user-facing IDM-parity feature and the reason people leave Chrome download managers behind. | +| `contextMenus` | "Download with Velox" on a link/image/video, and "Download all links…" (docs/05 §3). Table-stakes UI for a download manager extension. | None smaller — there's no partial grant for context menus. | +| `storage` | `browser.storage.local` holds only extension-local state: the WebSocket pairing token, the manual transport override, the last-good WS port, and the user's default-category preference (`transport/storage.ts`, `options/prefs.ts`). No browsing data. | None — some persistence is required for pairing to survive a restart (M1 DoD), which is the point of the token existing at all. | +| `notifications` | Tells the user when the daemon can't be reached for a download that fell back to Firefox, and (native-messaging path) surfaces pairing prompts if the GUI isn't running. | Silent failure. Rejected — capture fails open by design (CLAUDE.md §4) and a silent fallback with no notification would look like a bug. | +| `nativeMessaging` | Opportunistic transport to `veloxd` over a Unix socket, for installs where it works (docs/adr/0003). Not the default path — WebSocket is — but shipped because it avoids the WebSocket port-scan on installs where the native host manifest is reachable. | Drop native messaging and use WebSocket exclusively. Considered and rejected in ADR 0003: keeping both means the extension keeps working across deb/snap/flatpak Firefox without per-flavour capture-logic forks. | +| `` (host permission) | Downloads happen from every site on the web; `webRequest`'s header inspection and `cookies.getAll` both need to run against whatever site the user is on. This is the item AMO reviewers push back on hardest for extensions of this shape. | A fixed list of "known download sites" — unworkable for a general-purpose download manager, and defeats the point of an IDM-style interceptor. **Mitigation, not a narrower permission:** the exclusion list in Options is front-and-center (`options.html` → "Capture policy") so a user can scope capture down to nothing on sites they don't want Velox touching, and the bypass modifier (default Alt) lets a single click skip capture without changing settings. | + +## What is explicitly *not* requested + +- No `` XHR/fetch use — `webRequest`/`cookies` read metadata about a request + Firefox is already making; the extension never issues its own network request for + file bytes (enforced by the ESLint gate above). +- No `identity`, `history`, `bookmarks`, `tabs` beyond what `contextMenus`/`commands` + already imply, `management`, or any permission unrelated to capturing and handing off + a download. +- No remote code: the manifest ships no CDN scripts and no `eval`; `web-ext lint` fails + the build otherwise (CI's `extension-lint` job). + +## Data handling + +- Cookies and headers are held in memory only long enough to answer one + `capture.offer` call to the local daemon (`capture/headers.ts`'s ring buffer, 5-minute + TTL) — never written to disk by the extension and never sent anywhere but + `127.0.0.1`. +- The daemon connection is local-only: `WebSocketTransport` connects to + `ws://127.0.0.1:`, never a remote host (docs/05 §4, conformance-tested). +- Nothing is sent to Anthropic, Mozilla, or any third party beyond the user's own local + `veloxd` process. diff --git a/extension/src/background/transport/types.ts b/extension/src/background/transport/types.ts index 562af61..20fa0bc 100644 --- a/extension/src/background/transport/types.ts +++ b/extension/src/background/transport/types.ts @@ -62,6 +62,11 @@ export interface VeloxTransport { /** Fires on every state change. Returns an unsubscribe. */ onStateChange(cb: (status: TransportStatus) => void): () => void; + + /** WebSocket transport only (pairing has no meaning over native messaging's uds + * socket, which has no token). Options renders these controls only when present. */ + pairWithCode?(code: string): Promise; + unpair?(): Promise; } // --- errors --------------------------------------------------------------------------- diff --git a/extension/src/background/transport/websocket.ts b/extension/src/background/transport/websocket.ts index 1676d3b..a46085f 100644 --- a/extension/src/background/transport/websocket.ts +++ b/extension/src/background/transport/websocket.ts @@ -97,6 +97,9 @@ export class WebSocketTransport implements VeloxTransport { private stopped = false; private connectPromise: Promise | null = null; private reconnectTimer: ReturnType | null = null; + /** Set only for the duration of pairWithCode(); consumed by pair(). docs/05 §4: "the + * user clicks Allow (or types the code in the extension options)." */ + private pendingPairCode: string | null = null; private readonly listeners = new Map>(); private readonly stateListeners = new Set<(status: TransportStatus) => void>(); @@ -139,6 +142,29 @@ export class WebSocketTransport implements VeloxTransport { return this.connectPromise; } + /** + * Options → "Pair" with a code typed from the daemon's dialog, for when the GUI isn't + * running to click Allow (docs/05 §4). Drops any stored token first so the handshake + * takes the pairing branch, then reconnects with the code attached. + */ + async pairWithCode(code: string): Promise { + this.disconnect(); + await this.deps.setToken(null); // drop any stale token so the handshake takes the pairing branch + this.pendingPairCode = code; + try { + await this.connect(); + } finally { + this.pendingPairCode = null; + } + } + + /** Options → "Unpair": revoke the local token. The daemon's own record of it is + * cleaned up on its side; this only ever forgets our copy. */ + async unpair(): Promise { + await this.deps.setToken(null); + this.disconnect(); + } + disconnect(): void { this.stopped = true; if (this.reconnectTimer) { @@ -322,6 +348,7 @@ export class WebSocketTransport implements VeloxTransport { const params: SessionPairParams = { clientName: this.clientName, extensionId: this.deps.extensionId, + code: this.pendingPairCode, }; try { const res = (await rpc.request( diff --git a/extension/tests/transport/storage.test.ts b/extension/tests/transport/storage.test.ts new file mode 100644 index 0000000..ae1ec06 --- /dev/null +++ b/extension/tests/transport/storage.test.ts @@ -0,0 +1,56 @@ +// browser.storage.local is backed by the real add-on profile on disk, so anything +// written through transport/storage.ts is what "survives a browser restart" means in +// practice — this covers the storage half of that DoD item; websocket.test.ts's +// "survives a browser restart" case covers the transport half (a fresh transport +// instance reusing a persisted token with no re-pairing). +import { beforeEach, describe, expect, it } from 'vitest'; + +import * as storage from '../../src/background/transport/storage.js'; + +describe('transport/storage', () => { + beforeEach(async () => { + await browser.storage.local.clear(); + }); + + it('round-trips the pairing token', async () => { + expect(await storage.getToken()).toBeNull(); + await storage.setToken('tok-abc'); + expect(await storage.getToken()).toBe('tok-abc'); + }); + + it('clears the token on unpair (null)', async () => { + await storage.setToken('tok-abc'); + await storage.setToken(null); + expect(await storage.getToken()).toBeNull(); + }); + + it('a fresh read after a simulated restart still sees the persisted token', async () => { + await storage.setToken('tok-survives'); + // Nothing here recreates browser.storage.local — that's the point: it is the one + // thing in the extension that outlives the background page's lifetime, restart + // included. A second, independent read call stands in for "the page reloaded". + expect(await storage.getToken()).toBe('tok-survives'); + expect(await storage.getToken()).toBe('tok-survives'); + }); + + it('round-trips the transport override, defaulting to auto', async () => { + expect(await storage.getOverride()).toBe('auto'); + await storage.setOverride('uds'); + expect(await storage.getOverride()).toBe('uds'); + await storage.setOverride('ws'); + expect(await storage.getOverride()).toBe('ws'); + }); + + it('ignores a corrupted override value and falls back to auto', async () => { + await browser.storage.local.set({ 'velox.transportOverride': 'not-a-transport' }); + expect(await storage.getOverride()).toBe('auto'); + }); + + it('round-trips the cached WebSocket port', async () => { + expect(await storage.getCachedWsPort()).toBeNull(); + await storage.setCachedWsPort(52003); + expect(await storage.getCachedWsPort()).toBe(52003); + await storage.setCachedWsPort(null); + expect(await storage.getCachedWsPort()).toBeNull(); + }); +}); diff --git a/extension/tests/transport/websocket.test.ts b/extension/tests/transport/websocket.test.ts index 1b94b5d..03e4954 100644 --- a/extension/tests/transport/websocket.test.ts +++ b/extension/tests/transport/websocket.test.ts @@ -93,6 +93,59 @@ describe('WebSocketTransport', () => { expect(lastHello?.token).toBe('tok-issued-1'); }); + it('the pairing token survives a browser restart: a fresh transport instance over the same storage reuses it, no re-pairing', async () => { + const port = nextPort(); + daemon = await FakeDaemon.start({ port, acceptToken: null }); + const h = memDeps(); // stands in for browser.storage.local, which outlives the page + transport = makeTransport(port, h); + + await transport.connect(); + expect(daemon.pairCount).toBe(1); + expect(h.store.token).toBe('tok-issued-1'); + transport.disconnect(); + + // Simulate "the browser restarted": a brand new transport instance, same backing + // store (in reality, the same on-disk profile), no in-memory state carried over. + const restarted = makeTransport(port, h); + await restarted.connect(); + try { + expect(restarted.state).toBe('connected'); + expect(daemon.pairCount).toBe(1); // still just the one pairing, ever + const lastHello = [...daemon.seen].reverse().find((s) => s.method === 'session.hello'); + expect(lastHello?.token).toBe('tok-issued-1'); + } finally { + restarted.disconnect(); + } + }); + + it('pairWithCode drops any stale token and pairs fresh with the typed code', async () => { + const port = nextPort(); + daemon = await FakeDaemon.start({ port, acceptToken: 'stale' }); + const h = memDeps({ token: 'stale' }); + transport = makeTransport(port, h); + + await transport.pairWithCode('4821'); + + expect(transport.state).toBe('connected'); + expect(daemon.pairCount).toBe(1); + const pairCall = daemon.seen.find((s) => s.method === 'session.pair'); + expect((pairCall?.params as { code?: string }).code).toBe('4821'); + expect(h.store.token).toBe('tok-issued-1'); + }); + + it('unpair clears the stored token and disconnects', async () => { + const port = nextPort(); + daemon = await FakeDaemon.start({ port, acceptToken: 'good-token' }); + const h = memDeps({ token: 'good-token' }); + transport = makeTransport(port, h); + await transport.connect(); + + await transport.unpair(); + + expect(h.store.token).toBeNull(); + expect(transport.state).toBe('disconnected'); + }); + it('with autoPair off, a wrong token surfaces needsPairing and does NOT retry', async () => { const port = nextPort(); daemon = await FakeDaemon.start({ port, acceptToken: 'the-real-one' }); From 55932a2e11c3996cfbe9366ee5ab88dc9e9ea860 Mon Sep 17 00:00:00 2001 From: sami Date: Fri, 11 Sep 2026 12:26:10 +0400 Subject: [PATCH 3/4] ext: popup, options, and the panel bridge (build order step 6) Popup and Options are separate documents from the background page and can't reach its live VeloxTransport directly, so background/bridge.ts relays it over one browser.runtime.connect port per document (call/subscribe/getStatus/reconnect/pair/unpair/setOverride in; result/event/status/pairError out). shared/panel-client.ts is the client side both surfaces use. Popup (src/popup/): status dot + text, active-downloads list driven by event.task.progress/added/state/removed (repaints ride the event's own <=4 Hz cap rather than adding a second timer), pause/resume buttons, "Start it" wired to a reconnect request. State lives in a DOM-free store.ts for unit testing; rendering uses createElement, not innerHTML (web-ext lint flags the latter). Options (src/options/): transport override select, pairing (code entry + pair/unpair, backed by two new WebSocketTransport methods, pairWithCode/unpair), and the daemon's capture policy mirrored via capture.getRules. The capture-policy form is editable only when the active transport is native messaging (uds) -- settings.set and rules.upsert are privileged, uds-only methods per shared/protocol METHODS, so WebSocket can't write them no matter what the page shows; CLAUDE.md section 2 rules out working around that locally. The bridge's status payload adds a kind field (which transport is live) for this to key off. Default category is the one piece of state that's genuinely the extension's own, not the daemon's, and lives in browser.storage.local via options/prefs.ts. Pure decisions (statusLine, pairingAvailable, captureRulesEditable) are split into view.ts for unit testing without a DOM. manifest.json registers the popup action and options_ui page (and, in the same edit, the content_scripts entry the next commit's media detection needs -- split by file, not by manifest line). build.mjs gains popup/options as further esbuild entry points, plus copying their static HTML/CSS into dist/. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB --- extension/manifest.json | 19 ++ extension/scripts/build.mjs | 41 ++++- extension/src/background/bridge.ts | 196 +++++++++++++++++++++ extension/src/background/index.ts | 31 +++- extension/src/options/options.css | 56 ++++++ extension/src/options/options.html | 63 +++++++ extension/src/options/options.ts | 133 ++++++++++++++ extension/src/options/prefs.ts | 25 +++ extension/src/options/view.ts | 39 ++++ extension/src/popup/popup.css | 89 ++++++++++ extension/src/popup/popup.html | 18 ++ extension/src/popup/popup.ts | 143 +++++++++++++++ extension/src/popup/store.ts | 115 ++++++++++++ extension/src/shared/panel-client.ts | 117 ++++++++++++ extension/tests/background/bridge.test.ts | 205 ++++++++++++++++++++++ extension/tests/options/prefs.test.ts | 24 +++ extension/tests/options/view.test.ts | 71 ++++++++ extension/tests/popup/store.test.ts | 114 ++++++++++++ 18 files changed, 1485 insertions(+), 14 deletions(-) create mode 100644 extension/src/background/bridge.ts create mode 100644 extension/src/options/options.css create mode 100644 extension/src/options/options.html create mode 100644 extension/src/options/options.ts create mode 100644 extension/src/options/prefs.ts create mode 100644 extension/src/options/view.ts create mode 100644 extension/src/popup/popup.css create mode 100644 extension/src/popup/popup.html create mode 100644 extension/src/popup/popup.ts create mode 100644 extension/src/popup/store.ts create mode 100644 extension/src/shared/panel-client.ts create mode 100644 extension/tests/background/bridge.test.ts create mode 100644 extension/tests/options/prefs.test.ts create mode 100644 extension/tests/options/view.test.ts create mode 100644 extension/tests/popup/store.test.ts diff --git a/extension/manifest.json b/extension/manifest.json index 9f9097a..46feedd 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -19,6 +19,25 @@ "type": "module" }, + "action": { + "default_popup": "dist/popup.html", + "default_title": "Velox" + }, + + "options_ui": { + "page": "dist/options.html", + "open_in_tab": true + }, + + "content_scripts": [ + { + "matches": [""], + "js": ["dist/content.js"], + "run_at": "document_idle", + "all_frames": true + } + ], + "permissions": [ "webRequest", "webRequestBlocking", diff --git a/extension/scripts/build.mjs b/extension/scripts/build.mjs index 03c06b3..272c6b3 100644 --- a/extension/scripts/build.mjs +++ b/extension/scripts/build.mjs @@ -5,29 +5,46 @@ // build step. Firefox-only: no polyfill, native ESM, `browser.*` is a global. import { build } from 'esbuild'; -import { rm, mkdir } from 'node:fs/promises'; +import { rm, mkdir, copyFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const outdir = resolve(root, 'dist'); -// One entry per manifest surface. Add popup/options/content here as they land. -const entryPoints = { +// The background page and the popup/options documents load as native ESM (manifest.json +// declares "type": "module" for the background script; the popup/options HTML load +// their script with type="module"). Content scripts registered via manifest.json's +// content_scripts have no such declaration and run as classic scripts, so content.js is +// built as an IIFE instead — an `export` left in by the esm build would be a syntax +// error there. +const esmEntryPoints = { background: resolve(root, 'src/background/index.ts'), + popup: resolve(root, 'src/popup/popup.ts'), + options: resolve(root, 'src/options/options.ts'), }; +const iifeEntryPoints = { + content: resolve(root, 'src/content/index.ts'), +}; + +// Static HTML/CSS esbuild doesn't touch — copied straight to dist/ alongside their JS. +const staticFiles = [ + ['src/popup/popup.html', 'popup.html'], + ['src/popup/popup.css', 'popup.css'], + ['src/options/options.html', 'options.html'], + ['src/options/options.css', 'options.css'], +]; await rm(outdir, { recursive: true, force: true }); await mkdir(outdir, { recursive: true }); +await Promise.all(staticFiles.map(([src, dest]) => copyFile(resolve(root, src), resolve(outdir, dest)))); const watch = process.argv.includes('--watch'); const dev = watch || process.argv.includes('--dev'); -const options = { - entryPoints, +const shared = { outdir, bundle: true, - format: 'esm', target: ['firefox128'], platform: 'browser', sourcemap: dev ? 'inline' : 'linked', @@ -37,10 +54,16 @@ const options = { external: [], }; +const buildConfigs = [ + { ...shared, entryPoints: esmEntryPoints, format: 'esm' }, + { ...shared, entryPoints: iifeEntryPoints, format: 'iife' }, +]; + if (watch) { - const ctx = await (await import('esbuild')).context(options); - await ctx.watch(); + const { context } = await import('esbuild'); + const contexts = await Promise.all(buildConfigs.map((cfg) => context(cfg))); + await Promise.all(contexts.map((ctx) => ctx.watch())); console.log('esbuild: watching'); } else { - await build(options); + await Promise.all(buildConfigs.map((cfg) => build(cfg))); } diff --git a/extension/src/background/bridge.ts b/extension/src/background/bridge.ts new file mode 100644 index 0000000..f38a8a1 --- /dev/null +++ b/extension/src/background/bridge.ts @@ -0,0 +1,196 @@ +// Relays the background page's one VeloxTransport to the popup and options documents, +// which run as separate contexts and cannot import background/index.ts directly. +// +// Wire protocol over a `browser.runtime.connect` port (see docs/05 §3: "live progress +// via event.task.progress relayed over the transport"): +// client -> bg { type: 'call', id, method, params } +// client -> bg { type: 'subscribe', events: string[] } (replaces prior selection) +// client -> bg { type: 'getStatus' } +// bg -> client { type: 'result', id, ok: true, result } | { type: 'result', id, ok: false, error } +// bg -> client { type: 'event', event, payload } +// bg -> client { type: 'status', status } +// +// This is plain message relaying, not download logic: no bytes, no URLs fetched here, +// just RPC forwarding to the transport the background page already owns. + +import type { EventName, MethodName } from '../shared/protocol/index.js'; +import type { TransportKind, TransportStatus, VeloxTransport } from './transport/index.js'; + +/** TransportStatus plus which implementation is live — Options needs `kind` to know + * whether pairing controls and privileged settings.set are meaningful right now. */ +export type PanelStatus = TransportStatus & { kind: TransportKind | null }; + +export type PanelRequest = + | { type: 'call'; id: number; method: MethodName; params: unknown } + | { type: 'subscribe'; events: EventName[] } + | { type: 'getStatus' } + | { type: 'reconnect' } + | { type: 'pair'; code: string } + | { type: 'unpair' } + | { type: 'setOverride'; override: 'auto' | 'ws' | 'uds' }; + +export type PanelResponse = + | { type: 'result'; id: number; ok: true; result: unknown } + | { type: 'result'; id: number; ok: false; error: { code?: number; message: string } } + | { type: 'event'; event: string; payload: unknown } + | { type: 'status'; status: PanelStatus } + | { type: 'pairError'; error: { code?: number; message: string } }; + +export interface PortLike { + name: string; + postMessage(message: PanelResponse): void; + onMessage: { addListener(cb: (msg: PanelRequest) => void): void }; + onDisconnect: { addListener(cb: () => void): void }; +} + +export interface RuntimeOnConnectLike { + addListener(cb: (port: PortLike) => void): void; +} + +const DISCONNECTED_STATUS: PanelStatus = { + state: 'disconnected', + needsPairing: false, + fatal: null, + retryAfterSec: null, + sessionId: null, + daemonVersion: null, + capabilities: [], + kind: null, +}; + +function panelStatus(t: VeloxTransport | undefined): PanelStatus { + return t ? { ...t.status, kind: t.kind } : DISCONNECTED_STATUS; +} + +function errorOf(e: unknown): { code?: number; message: string } { + if (e && typeof e === 'object') { + const code = 'code' in e && typeof (e as { code: unknown }).code === 'number' ? (e as { code: number }).code : undefined; + const message = e instanceof Error ? e.message : String(e); + return code === undefined ? { message } : { code, message }; + } + return { message: String(e) }; +} + +export interface PanelBridgeDeps { + getTransport(): VeloxTransport | undefined; + /** Rebuilds the transport for a new manual override ('auto' lets the runtime picker + * decide again) and swaps it in. Needed because switching kind means constructing a + * different Transport implementation, not a method on the existing one. */ + setOverride(override: 'auto' | 'ws' | 'uds'): Promise; +} + +export class PanelBridge { + constructor(private readonly deps: PanelBridgeDeps) {} + + private getTransport(): VeloxTransport | undefined { + return this.deps.getTransport(); + } + + attach(onConnect: RuntimeOnConnectLike): void { + onConnect.addListener((port) => this.handleConnect(port)); + } + + private handleConnect(port: PortLike): void { + const unsubscribe: Array<() => void> = []; + let disposed = false; + + const post = (msg: PanelResponse): void => { + if (!disposed) port.postMessage(msg); + }; + + // The transport may not exist yet (background just woke up). Retry briefly rather + // than leaving the panel stuck on "connecting" forever. + const attachStatus = (attemptsLeft: number): void => { + const t = this.getTransport(); + if (t) { + post({ type: 'status', status: panelStatus(t) }); + const off = t.onStateChange(() => post({ type: 'status', status: panelStatus(this.getTransport()) })); + unsubscribe.push(off); + return; + } + post({ type: 'status', status: DISCONNECTED_STATUS }); + if (attemptsLeft > 0 && !disposed) { + const timer = setTimeout(() => attachStatus(attemptsLeft - 1), 300); + unsubscribe.push(() => clearTimeout(timer)); + } + }; + attachStatus(10); + + port.onMessage.addListener((msg) => void this.handleMessage(msg, post, unsubscribe)); + port.onDisconnect.addListener(() => { + disposed = true; + for (const u of unsubscribe) u(); + unsubscribe.length = 0; + }); + } + + private async handleMessage( + msg: PanelRequest, + post: (m: PanelResponse) => void, + unsubscribe: Array<() => void>, + ): Promise { + if (msg.type === 'getStatus') { + post({ type: 'status', status: panelStatus(this.getTransport()) }); + return; + } + + if (msg.type === 'reconnect') { + // "Start it" in the popup (docs/05 §5) — never a fresh call the panel builds + // params for itself; it just asks the transport it already owns to try again. + this.getTransport() + ?.connect() + .catch(() => undefined); + return; + } + + if (msg.type === 'pair') { + const t = this.getTransport(); + try { + if (!t?.pairWithCode) throw new Error('pairing by code is only available on the WebSocket transport'); + await t.pairWithCode(msg.code); + post({ type: 'status', status: panelStatus(t) }); + } catch (e) { + post({ type: 'status', status: panelStatus(t) }); + post({ type: 'pairError', error: errorOf(e) }); + } + return; + } + + if (msg.type === 'unpair') { + const t = this.getTransport(); + await t?.unpair?.(); + post({ type: 'status', status: panelStatus(this.getTransport()) }); + return; + } + + if (msg.type === 'setOverride') { + await this.deps.setOverride(msg.override); + post({ type: 'status', status: panelStatus(this.getTransport()) }); + return; + } + + if (msg.type === 'subscribe') { + const t = this.getTransport(); + if (!t) return; + for (const event of msg.events) { + const cb = (payload: unknown) => post({ type: 'event', event, payload }); + t.on(event, cb); + unsubscribe.push(() => t.off(event, cb)); + } + return; + } + + // msg.type === 'call' + const t = this.getTransport(); + if (!t) { + post({ type: 'result', id: msg.id, ok: false, error: { message: 'transport not ready' } }); + return; + } + try { + const result = await t.call(msg.method, msg.params as never); + post({ type: 'result', id: msg.id, ok: true, result }); + } catch (e) { + post({ type: 'result', id: msg.id, ok: false, error: errorOf(e) }); + } + } +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index ed7df75..9820628 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -4,9 +4,11 @@ // paths: the blocking onHeadersReceived hook and the downloads.onCreated safety net. The // popup relay and context menus attach here in later steps of the build order. +import { PanelBridge } from './bridge.js'; import { DownloadsSafetyNet, type DownloadsApiLike } from './capture/downloads-api.js'; import { HeaderStash, type WebRequestLike } from './capture/headers.js'; import { CaptureHook, type HeadersReceivedWebRequest } from './capture/index.js'; +import { MediaWatcher, type MediaWebRequest } from './capture/media.js'; import { OfferedUrls } from './capture/offered-urls.js'; import { DEFAULT_CAPTURE_RULES } from './capture/rules.js'; import { @@ -15,7 +17,8 @@ import { type MenusLike, type TabsLike, } from './context-menus.js'; -import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js'; +import { MediaBridge, notifyTab } from './media-bridge.js'; +import { createTransport, transportStorage, type TransportStatus, type VeloxTransport } from './transport/index.js'; import type { CaptureOfferParams, CaptureRules, DownloadSpec } from '../shared/protocol/index.js'; let transport: VeloxTransport | undefined; @@ -78,18 +81,36 @@ function onTransportState(status: TransportStatus): void { if (status.state === 'connected') void refreshRules(); } +async function setOverride(override: 'auto' | 'ws' | 'uds'): Promise { + await transportStorage.setOverride(override); + transport?.disconnect(); + transport = await createTransport({ override }); + transport.onStateChange(onTransportState); + transport.on('event.settings.changed', onSettingsChanged); + onTransportState(transport.status); +} + +const bridge = new PanelBridge({ getTransport: () => transport, setOverride }); +const mediaBridge = new MediaBridge(() => transport); +const mediaWatcher = new MediaWatcher((detected) => notifyTab(browser.tabs, detected)); + +function onSettingsChanged(payload: unknown): void { + const keys = (payload as { keys?: string[] }).keys ?? []; + if (keys.some((k) => k.startsWith('capture.'))) void refreshRules(); +} + async function start(): Promise { stash.attach(browser.webRequest as unknown as WebRequestLike); hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest); safetyNet.attach(browser.downloads as unknown as DownloadsApiLike); void contextMenus.register(); + bridge.attach(browser.runtime.onConnect); + mediaBridge.attach(browser.runtime.onMessage); + mediaWatcher.attach(browser.webRequest as unknown as MediaWebRequest); transport = await createTransport(); transport.onStateChange(onTransportState); - transport.on('event.settings.changed', (payload) => { - const keys = (payload as { keys?: string[] }).keys ?? []; - if (keys.some((k) => k.startsWith('capture.'))) void refreshRules(); - }); + transport.on('event.settings.changed', onSettingsChanged); onTransportState(transport.status); } diff --git a/extension/src/options/options.css b/extension/src/options/options.css new file mode 100644 index 0000000..78fe824 --- /dev/null +++ b/extension/src/options/options.css @@ -0,0 +1,56 @@ +body { + max-width: 560px; + margin: 0 auto; + padding: 24px 16px; + font: 14px -apple-system, system-ui, sans-serif; + color: #1a1a1a; + background: #fff; +} + +h1 { margin: 0 0 16px; } + +section { + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 1px solid #eee; +} +section:last-of-type { border-bottom: none; } + +h2 { + font-size: 15px; + margin: 0 0 10px; +} + +label { + display: block; + margin: 8px 0; + font-weight: 600; + font-size: 13px; +} + +select, input, textarea { + display: block; + margin-top: 4px; + font: inherit; + padding: 5px 7px; + width: 100%; + max-width: 320px; + box-sizing: border-box; +} + +button { + font: inherit; + padding: 6px 12px; + margin-top: 8px; + margin-right: 8px; +} + +#rules-locked { + color: #888; + font-style: italic; +} + +#pair-message { + min-height: 1.2em; + color: #555; +} diff --git a/extension/src/options/options.html b/extension/src/options/options.html new file mode 100644 index 0000000..9d429ca --- /dev/null +++ b/extension/src/options/options.html @@ -0,0 +1,63 @@ + + + + + Velox Options + + + +

Velox

+ +
+

Transport

+ +

Connecting…

+
+ +
+

Pairing

+

If the Velox app isn't running to show an Allow prompt, type the 4-digit code it displayed:

+ + + +

+
+ +
+

Capture policy

+

Loading…

+

These are set by the Velox app or CLI. Connect via native messaging (or edit them there) to change them from here.

+ +
+ +
+

Default category

+

Applied to downloads sent from the right-click menu and the keyboard shortcut.

+ +
+ + + + diff --git a/extension/src/options/options.ts b/extension/src/options/options.ts new file mode 100644 index 0000000..335cdfe --- /dev/null +++ b/extension/src/options/options.ts @@ -0,0 +1,133 @@ +// Options page: transport & pairing, the daemon's capture policy (mirrored, editable +// only over the privileged uds transport), min size / exclusions / bypass modifier +// (same privileged path), and the one setting that is genuinely the extension's own — +// the default category for extension-initiated downloads (prefs.ts). docs/05 §3. + +import { PanelClient } from '../shared/panel-client.js'; +import type { PanelStatus } from '../background/bridge.js'; +import type { Category, CaptureRules } from '../shared/protocol/index.js'; +import { getDefaultCategoryId, setDefaultCategoryId } from './prefs.js'; +import { captureRulesEditable, pairingAvailable, statusLine, unpairAvailable } from './view.js'; + +const client = new PanelClient(); + +const $transportSelect = document.querySelector('#transport-override')!; +const $statusText = document.querySelector('#transport-status')!; +const $pairCode = document.querySelector('#pair-code')!; +const $pairButton = document.querySelector('#pair-button')!; +const $unpairButton = document.querySelector('#unpair-button')!; +const $pairMessage = document.querySelector('#pair-message')!; +const $rulesSummary = document.querySelector('#rules-summary')!; +const $rulesForm = document.querySelector('#rules-form')!; +const $rulesLocked = document.querySelector('#rules-locked')!; +const $minSize = document.querySelector('#min-size')!; +const $excludedHosts = document.querySelector('#excluded-hosts')!; +const $bypassModifier = document.querySelector('#bypass-modifier')!; +const $defaultCategory = document.querySelector('#default-category')!; + +let latestRules: CaptureRules | null = null; + +function renderStatus(status: PanelStatus): void { + $transportSelect.value = status.kind ?? 'auto'; + $statusText.textContent = statusLine(status); + + $pairButton.disabled = !pairingAvailable(status); + $pairCode.disabled = !pairingAvailable(status); + $unpairButton.disabled = !unpairAvailable(status); + + const canEdit = captureRulesEditable(status); + $rulesForm.hidden = !canEdit; + $rulesLocked.hidden = canEdit; +} + +function renderRulesSummary(rules: CaptureRules): void { + latestRules = rules; + $rulesSummary.textContent = rules.enabled + ? `Capturing ${rules.monitoredExtensions.length} extension(s) and ${rules.monitoredMimeTypes.length} MIME type(s), min size ${rules.minSizeBytes} bytes.` + : 'Capture is disabled on the daemon.'; + $minSize.value = String(rules.minSizeBytes); + $excludedHosts.value = rules.excludedHosts.join('\n'); + $bypassModifier.value = rules.bypassModifier ?? 'alt'; +} + +async function refreshRules(): Promise { + try { + const rules = await client.call('capture.getRules', {}); + renderRulesSummary(rules); + } catch { + $rulesSummary.textContent = 'Could not read the daemon’s capture policy.'; + } +} + +function makeOption(value: string, label: string): HTMLOptionElement { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + return opt; +} + +async function refreshCategories(): Promise { + try { + const { items } = await client.call('category.list', {}); + const current = await getDefaultCategoryId(); + $defaultCategory.replaceChildren( + makeOption('', '(none — daemon default)'), + ...items.map((c: Category) => makeOption(c.categoryId, c.name)), + ); + $defaultCategory.value = current ?? ''; + } catch { + $defaultCategory.replaceChildren(makeOption('', '(unavailable — not connected)')); + } +} + +$transportSelect.addEventListener('change', () => { + client.setOverride($transportSelect.value as 'auto' | 'ws' | 'uds'); +}); + +$pairButton.addEventListener('click', () => { + const code = $pairCode.value.trim(); + if (!code) return; + $pairMessage.textContent = 'Pairing…'; + client.pair(code); +}); + +$unpairButton.addEventListener('click', () => { + client.unpair(); + $pairMessage.textContent = 'Unpaired.'; +}); + +$defaultCategory.addEventListener('change', () => { + void setDefaultCategoryId($defaultCategory.value || null); +}); + +$rulesForm.addEventListener('submit', (e) => { + e.preventDefault(); + if (!latestRules) return; + const values = { + 'capture.minSizeBytes': Number($minSize.value) || 0, + 'capture.excludedHosts': $excludedHosts.value + .split('\n') + .map((s) => s.trim()) + .filter(Boolean), + 'capture.bypassModifier': $bypassModifier.value as CaptureRules['bypassModifier'], + }; + client + .call('settings.set', { values }) + .then(() => refreshRules()) + .catch(() => { + $rulesSummary.textContent = 'Failed to save — is Velox still connected via native messaging?'; + }); +}); + +async function start(): Promise { + client.onStatus(renderStatus); + client.onPairError((err) => { + $pairMessage.textContent = `Pairing failed: ${err.message}`; + }); + client.on('event.settings.changed', (payload) => { + if (payload.keys.some((k) => k.startsWith('capture.'))) void refreshRules(); + }); + await Promise.all([refreshRules(), refreshCategories()]); +} + +void start(); diff --git a/extension/src/options/prefs.ts b/extension/src/options/prefs.ts new file mode 100644 index 0000000..25a7031 --- /dev/null +++ b/extension/src/options/prefs.ts @@ -0,0 +1,25 @@ +// Extension-local preferences: state that belongs to this browser install, not to the +// daemon's Settings bag. The protocol draws a hard line here — settings.set and +// rules.upsert are privileged, uds-only methods (METHODS in shared/protocol) — so an +// Options page reachable only over the WebSocket transport cannot write the daemon's +// capture policy no matter what the UI looks like. Rather than inventing a protocol +// field to work around that (CLAUDE.md §2 forbids exactly this), the extension: +// - mirrors the daemon's capture policy read-only via capture.getRules (works on +// both transports, and is what shouldCapture() itself already trusts), and +// - keeps the one piece of "options" state that genuinely is the extension's own — +// which category new captures/context-menu downloads default to — in +// browser.storage.local, exactly like the pairing token and transport override. +// Editing the mirrored capture policy is only offered when connected via NativeTransport, +// where settings.set is allowed; see options.ts. + +const KEY = { defaultCategoryId: 'velox.defaultCategoryId' } as const; + +export async function getDefaultCategoryId(): Promise { + const bag = await browser.storage.local.get(KEY.defaultCategoryId); + return (bag[KEY.defaultCategoryId] as string | undefined) ?? null; +} + +export async function setDefaultCategoryId(categoryId: string | null): Promise { + if (categoryId === null) await browser.storage.local.remove(KEY.defaultCategoryId); + else await browser.storage.local.set({ [KEY.defaultCategoryId]: categoryId }); +} diff --git a/extension/src/options/view.ts b/extension/src/options/view.ts new file mode 100644 index 0000000..aa416b6 --- /dev/null +++ b/extension/src/options/view.ts @@ -0,0 +1,39 @@ +// Pure view-model helpers for options.ts, kept separate from the DOM so the decisions +// that matter (when pairing controls are enabled, when the capture-policy form is +// editable, what the status line says) are unit-testable without a document. + +import type { PanelStatus } from '../background/bridge.js'; + +export function statusLine(status: PanelStatus): string { + const parts: string[] = [status.state]; + if (status.daemonVersion) parts.push(`v${status.daemonVersion}`); + if (status.kind) parts.push(`via ${status.kind === 'uds' ? 'native messaging' : 'WebSocket'}`); + if (status.needsPairing) { + parts.push(status.retryAfterSec ? `pairing locked (${status.retryAfterSec}s)` : 'needs pairing'); + } + if (status.fatal) parts.push(status.fatal); + return parts.join(' · '); +} + +/** Pairing is a WebSocket-transport concept; native messaging has no token. Also true + * before the transport kind is known yet, so the control isn't stuck disabled forever + * on first paint. */ +export function pairingAvailable(status: PanelStatus): boolean { + return status.kind === 'ws' || status.kind === null; +} + +export function unpairAvailable(status: PanelStatus): boolean { + return status.kind === 'ws'; +} + +/** + * settings.set and rules.upsert are privileged, uds-only methods (shared/protocol + * METHODS) — the daemon refuses them over WebSocket with -32003 regardless of what this + * page renders. So the capture-policy form is only ever offered as editable when the + * active transport is native messaging; on WebSocket it is a read-only mirror, per + * CLAUDE.md §2 ("working around a wrong contract locally" is not an option here — this + * boundary is deliberate, not wrong). + */ +export function captureRulesEditable(status: PanelStatus): boolean { + return status.kind === 'uds'; +} diff --git a/extension/src/popup/popup.css b/extension/src/popup/popup.css new file mode 100644 index 0000000..34b6138 --- /dev/null +++ b/extension/src/popup/popup.css @@ -0,0 +1,89 @@ +body { + width: 320px; + margin: 0; + font: 13px -apple-system, system-ui, sans-serif; + color: #1a1a1a; + background: #fff; +} + +.topbar { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid #e2e2e2; +} + +.dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex: 0 0 auto; + background: #999; +} +.dot-connected { background: #2ea043; } +.dot-connecting { background: #d4a72c; } +.dot-disconnected { background: #d1242f; } + +#status-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#start-daemon { + font-size: 12px; +} + +#task-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 360px; + overflow-y: auto; +} + +.task { + padding: 8px 12px; + border-bottom: 1px solid #f0f0f0; +} + +.task-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} + +.task-bar { + height: 4px; + background: #eee; + border-radius: 2px; + margin: 4px 0; + overflow: hidden; +} +.task-bar-fill { + height: 100%; + background: #2f6fed; +} + +.task-meta { + display: flex; + gap: 8px; + align-items: center; + color: #666; + font-size: 12px; +} +.task-state { text-transform: capitalize; } +.task-actions { margin-left: auto; } +.task-actions button { + font-size: 11px; + padding: 2px 6px; +} + +#empty { + padding: 24px 12px; + text-align: center; + color: #888; +} diff --git a/extension/src/popup/popup.html b/extension/src/popup/popup.html new file mode 100644 index 0000000..e6080c7 --- /dev/null +++ b/extension/src/popup/popup.html @@ -0,0 +1,18 @@ + + + + + Velox + + + +
+ + Connecting… + +
+
    +

    No active downloads.

    + + + diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts new file mode 100644 index 0000000..1768f1d --- /dev/null +++ b/extension/src/popup/popup.ts @@ -0,0 +1,143 @@ +// Toolbar popup: daemon status dot, active downloads with live progress, pause/resume. +// docs/05 §3. No download logic — this only renders state and forwards pause/resume +// intent over the panel bridge (background/bridge.ts) to the transport the background +// page owns. + +import { PanelClient } from '../shared/panel-client.js'; +import type { PanelStatus } from '../background/bridge.js'; +import { formatEta, formatSpeed, PopupStore, progressPercent, type TaskRow } from './store.js'; + +const client = new PanelClient(); +const store = new PopupStore(); + +const $status = document.querySelector('#status-dot')!; +const $statusText = document.querySelector('#status-text')!; +const $list = document.querySelector('#task-list')!; +const $empty = document.querySelector('#empty')!; +const $startButton = document.querySelector('#start-daemon')!; + +function renderStatus(status: PanelStatus): void { + $status.className = `dot dot-${status.state}`; + if (status.fatal) { + $statusText.textContent = status.fatal; + } else if (status.needsPairing) { + $statusText.textContent = status.retryAfterSec + ? `Pairing locked — retry in ${status.retryAfterSec}s` + : 'Velox needs pairing — open Options'; + } else if (status.state === 'connected') { + $statusText.textContent = status.daemonVersion ? `Connected · v${status.daemonVersion}` : 'Connected'; + } else if (status.state === 'connecting') { + $statusText.textContent = 'Connecting…'; + } else { + $statusText.textContent = "Velox isn't running."; + } + $startButton.hidden = status.state === 'connected'; +} + +// Built with createElement rather than innerHTML: AMO's linter flags dynamic innerHTML +// on sight, and building nodes directly means a filename can never be parsed as markup. +function buildRow(row: TaskRow): HTMLLIElement { + const pct = progressPercent(row); + const canPause = row.state === 'downloading' || row.state === 'connecting' || row.state === 'queued'; + const canResume = row.state === 'paused' || row.state === 'retry_wait' || row.state === 'failed'; + + const li = document.createElement('li'); + li.className = 'task'; + li.dataset['taskId'] = row.taskId; + + const name = document.createElement('div'); + name.className = 'task-name'; + name.title = row.filename; + name.textContent = row.filename; + + const bar = document.createElement('div'); + bar.className = 'task-bar'; + const barFill = document.createElement('div'); + barFill.className = 'task-bar-fill'; + barFill.style.width = `${pct ?? 0}%`; + bar.appendChild(barFill); + + const meta = document.createElement('div'); + meta.className = 'task-meta'; + const state = document.createElement('span'); + state.className = 'task-state'; + state.textContent = row.state; + const speed = document.createElement('span'); + speed.className = 'task-speed'; + speed.textContent = formatSpeed(row.speedBps); + const eta = document.createElement('span'); + eta.className = 'task-eta'; + eta.textContent = formatEta(row.etaSeconds); + const actions = document.createElement('span'); + actions.className = 'task-actions'; + if (canPause) actions.appendChild(makeActionButton('pause', row.taskId, 'Pause')); + if (canResume) actions.appendChild(makeActionButton('resume', row.taskId, 'Resume')); + meta.append(state, speed, eta, actions); + + li.append(name, bar, meta); + return li; +} + +function makeActionButton(action: 'pause' | 'resume', taskId: string, label: string): HTMLButtonElement { + const button = document.createElement('button'); + button.dataset['action'] = action; + button.dataset['taskId'] = taskId; + button.textContent = label; + return button; +} + +function render(): void { + const rows = store.rows(); + $empty.hidden = rows.length > 0; + $list.replaceChildren(...rows.map(buildRow)); +} + +$list.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const action = target.dataset['action']; + const taskId = target.dataset['taskId']; + if (!action || !taskId) return; + if (action === 'pause') void client.call('download.pause', { taskIds: [taskId] }); + else if (action === 'resume') void client.call('download.resume', { taskIds: [taskId] }); +}); + +$startButton.addEventListener('click', () => { + // The transport reconnects on its own with backoff; this just gives the user + // something to click rather than staring at a red dot (docs/05 §5). + client.reconnect(); +}); + +async function start(): Promise { + client.onStatus(renderStatus); + client.on('event.task.added', (evt) => { + store.onAdded(evt); + render(); + }); + // event.task.progress arrives at up to 4 Hz (EVENTS contract) — this repaint rides + // that rate directly rather than adding a second timer, so the popup never exceeds it. + client.on('event.task.progress', (evt) => { + store.onProgress(evt); + render(); + }); + client.on('event.task.state', (evt) => { + store.onState(evt); + render(); + }); + client.on('event.task.removed', (evt) => { + store.onRemoved(evt); + render(); + }); + + try { + const list = await client.call('download.list', { + filter: { states: ['queued', 'connecting', 'downloading', 'paused', 'retry_wait', 'assembling', 'verifying'] }, + limit: 100, + }); + store.setInitial(list.items); + } catch { + // Daemon unreachable — status dot already shows red; the list just stays empty. + } + render(); +} + +void start(); diff --git a/extension/src/popup/store.ts b/extension/src/popup/store.ts new file mode 100644 index 0000000..2c97a7b --- /dev/null +++ b/extension/src/popup/store.ts @@ -0,0 +1,115 @@ +// Pure state for the popup's task list — no DOM, so it is unit-testable without a +// browser. Fed by download.list (initial) and event.task.{added,progress,state,removed} +// relayed over the panel bridge (docs/05 §3: "live progress via event.task.progress"). + +import type { + TaskAddedEvent, + TaskProgressEvent, + TaskRemovedEvent, + TaskState, + TaskStateEvent, + TaskSummary, +} from '../shared/protocol/index.js'; + +export interface TaskRow { + taskId: string; + filename: string; + state: TaskState; + downloadedBytes: number; + sizeBytes: number | null; + speedBps: number; + etaSeconds: number | null; +} + +const ACTIVE_STATES: readonly TaskState[] = ['connecting', 'downloading', 'assembling', 'verifying', 'probing', 'queued']; + +function fromSummary(s: TaskSummary): TaskRow { + return { + taskId: s.taskId, + filename: s.filename, + state: s.state, + downloadedBytes: s.downloadedBytes, + sizeBytes: s.sizeBytes ?? null, + speedBps: s.speedBps, + etaSeconds: s.etaSeconds ?? null, + }; +} + +export class PopupStore { + private rowsById = new Map(); + + setInitial(items: TaskSummary[]): void { + this.rowsById.clear(); + for (const s of items) this.rowsById.set(s.taskId, fromSummary(s)); + } + + onAdded(evt: TaskAddedEvent): void { + this.rowsById.set(evt.taskId, fromSummary(evt.summary)); + } + + /** event.task.progress is a patch, never a rebuild (docs/05 §3 / EVENTS contract). */ + onProgress(evt: TaskProgressEvent): void { + for (const t of evt.tasks) { + const row = this.rowsById.get(t.taskId); + if (!row) continue; // a progress tick for a task we haven't seen added yet — ignore + row.downloadedBytes = t.downloadedBytes; + row.speedBps = t.speedBps; + row.etaSeconds = t.etaSeconds ?? null; + } + } + + onState(evt: TaskStateEvent): void { + const row = this.rowsById.get(evt.taskId); + if (evt.summary) { + this.rowsById.set(evt.taskId, fromSummary(evt.summary)); + } else if (row) { + row.state = evt.state; + } + } + + onRemoved(evt: TaskRemovedEvent): void { + this.rowsById.delete(evt.taskId); + } + + /** Active tasks first (what the user opened the popup to watch), then by filename. */ + rows(): TaskRow[] { + const isActive = (r: TaskRow): boolean => ACTIVE_STATES.includes(r.state) || r.state === 'paused'; + return [...this.rowsById.values()].sort((a, b) => { + const activeDiff = Number(isActive(b)) - Number(isActive(a)); + if (activeDiff !== 0) return activeDiff; + return a.filename.localeCompare(b.filename); + }); + } + + get size(): number { + return this.rowsById.size; + } +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let v = n / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; +} + +export function formatSpeed(bps: number): string { + return bps > 0 ? `${formatBytes(bps)}/s` : ''; +} + +export function formatEta(seconds: number | null): string { + if (seconds === null || seconds < 0 || !Number.isFinite(seconds)) return ''; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} + +export function progressPercent(row: TaskRow): number | null { + if (!row.sizeBytes || row.sizeBytes <= 0) return null; + return Math.min(100, Math.round((row.downloadedBytes / row.sizeBytes) * 100)); +} diff --git a/extension/src/shared/panel-client.ts b/extension/src/shared/panel-client.ts new file mode 100644 index 0000000..9b47bf2 --- /dev/null +++ b/extension/src/shared/panel-client.ts @@ -0,0 +1,117 @@ +// Client side of background/bridge.ts, used by popup/ and options/ — the two documents +// that cannot import the background page's live VeloxTransport directly and instead +// talk to it over a `browser.runtime.connect` port. + +import type { EventName, EventPayload, MethodName, Params, Result } from '../shared/protocol/index.js'; +import type { PanelStatus } from '../background/bridge.js'; + +const PORT_NAME = 'velox-panel'; + +export class RpcCallError extends Error { + constructor( + readonly code: number | undefined, + message: string, + ) { + super(message); + this.name = 'RpcCallError'; + } +} + +type Pending = { resolve: (v: unknown) => void; reject: (e: unknown) => void }; + +/** Thin promise-based RPC client plus event fan-out, over one long-lived port. */ +export class PanelClient { + private port: browser.runtime.Port; + private nextId = 1; + private pending = new Map(); + private eventListeners = new Map void>>(); + private statusListeners = new Set<(status: PanelStatus) => void>(); + private pairErrorListeners = new Set<(error: { code?: number; message: string }) => void>(); + private subscribed = new Set(); + + constructor(connect: () => browser.runtime.Port = () => browser.runtime.connect({ name: PORT_NAME })) { + this.port = connect(); + this.port.onMessage.addListener((raw) => this.onMessage(raw as Record)); + } + + private onMessage(msg: Record): void { + if (msg['type'] === 'result') { + const id = msg['id'] as number; + const p = this.pending.get(id); + if (!p) return; + this.pending.delete(id); + if (msg['ok']) p.resolve(msg['result']); + else { + const err = msg['error'] as { code?: number; message: string }; + p.reject(new RpcCallError(err.code, err.message)); + } + } else if (msg['type'] === 'event') { + const listeners = this.eventListeners.get(msg['event'] as string); + if (listeners) for (const cb of listeners) cb(msg['payload']); + } else if (msg['type'] === 'status') { + for (const cb of this.statusListeners) cb(msg['status'] as PanelStatus); + } else if (msg['type'] === 'pairError') { + for (const cb of this.pairErrorListeners) cb(msg['error'] as { code?: number; message: string }); + } + } + + call(method: M, params: Params): Promise> { + const id = this.nextId++; + return new Promise>((resolve, reject) => { + this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject }); + this.port.postMessage({ type: 'call', id, method, params }); + }); + } + + /** Adds `event` to the set this panel receives. Safe to call repeatedly. */ + private ensureSubscribed(event: EventName): void { + if (this.subscribed.has(event)) return; + this.subscribed.add(event); + this.port.postMessage({ type: 'subscribe', events: [...this.subscribed] }); + } + + on(event: E, cb: (payload: EventPayload) => void): () => void { + let set = this.eventListeners.get(event); + if (!set) { + set = new Set(); + this.eventListeners.set(event, set); + } + set.add(cb as (payload: unknown) => void); + this.ensureSubscribed(event); + return () => set!.delete(cb as (payload: unknown) => void); + } + + /** Asks the background page's transport to retry now, instead of waiting on backoff. */ + reconnect(): void { + this.port.postMessage({ type: 'reconnect' }); + } + + onStatus(cb: (status: PanelStatus) => void): () => void { + this.statusListeners.add(cb); + this.port.postMessage({ type: 'getStatus' }); + return () => this.statusListeners.delete(cb); + } + + onPairError(cb: (error: { code?: number; message: string }) => void): () => void { + this.pairErrorListeners.add(cb); + return () => this.pairErrorListeners.delete(cb); + } + + /** Options → "Pair" with a code typed from the daemon's dialog. */ + pair(code: string): void { + this.port.postMessage({ type: 'pair', code }); + } + + /** Options → "Unpair": revoke the locally stored token. */ + unpair(): void { + this.port.postMessage({ type: 'unpair' }); + } + + setOverride(override: 'auto' | 'ws' | 'uds'): void { + this.port.postMessage({ type: 'setOverride', override }); + } + + dispose(): void { + this.port.disconnect(); + } +} diff --git a/extension/tests/background/bridge.test.ts b/extension/tests/background/bridge.test.ts new file mode 100644 index 0000000..de072bc --- /dev/null +++ b/extension/tests/background/bridge.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { PanelBridge, type PanelRequest, type PanelResponse, type PortLike } from '../../src/background/bridge.js'; +import type { TransportStatus, VeloxTransport } from '../../src/background/transport/index.js'; + +function fakePort(): PortLike & { received: PanelResponse[]; emit(msg: PanelRequest): void; close(): void } { + const msgListeners = new Set<(msg: PanelRequest) => void>(); + const discListeners = new Set<() => void>(); + const received: PanelResponse[] = []; + return { + name: 'velox-panel', + received, + postMessage: (m) => received.push(m), + onMessage: { addListener: (cb) => msgListeners.add(cb) }, + onDisconnect: { addListener: (cb) => discListeners.add(cb) }, + emit: (msg) => msgListeners.forEach((cb) => cb(msg)), + close: () => discListeners.forEach((cb) => cb()), + }; +} + +function fakeTransport(status: TransportStatus): VeloxTransport & { fireStatus(s: TransportStatus): void; calls: unknown[] } { + const stateListeners = new Set<(s: TransportStatus) => void>(); + const eventListeners = new Map void>>(); + const calls: unknown[] = []; + return { + kind: 'ws', + state: status.state, + status, + calls, + connect: async () => undefined, + disconnect: () => undefined, + call: (async (method: string, params: unknown) => { + calls.push({ method, params }); + if (method === 'boom') throw Object.assign(new Error('nope'), { code: -32010 }); + return { echoed: params }; + }) as VeloxTransport['call'], + on: ((event: string, cb: (p: unknown) => void) => { + let s = eventListeners.get(event); + if (!s) eventListeners.set(event, (s = new Set())); + s.add(cb); + }) as VeloxTransport['on'], + off: (event: string, cb: (p: unknown) => void) => { + eventListeners.get(event)?.delete(cb); + }, + onStateChange: (cb: (s: TransportStatus) => void) => { + stateListeners.add(cb); + return () => stateListeners.delete(cb); + }, + fireStatus: (s: TransportStatus) => stateListeners.forEach((cb) => cb(s)), + } as unknown as VeloxTransport & { fireStatus(s: TransportStatus): void; calls: unknown[] }; +} + +const CONNECTED: TransportStatus = { + state: 'connected', + needsPairing: false, + fatal: null, + retryAfterSec: null, + sessionId: 's1', + daemonVersion: '1.0.0', + capabilities: [], +}; + +describe('PanelBridge', () => { + it('sends the current status on connect', () => { + const t = fakeTransport(CONNECTED); + const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined }); + const port = fakePort(); + bridge.attach({ addListener: (cb) => cb(port) }); + + expect(port.received[0]).toEqual({ type: 'status', status: { ...CONNECTED, kind: 'ws' } }); + }); + + it('forwards a call and relays the result', async () => { + const t = fakeTransport(CONNECTED); + const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined }); + const port = fakePort(); + bridge.attach({ addListener: (cb) => cb(port) }); + + port.emit({ type: 'call', id: 7, method: 'download.list', params: {} }); + await vi.waitFor(() => expect(port.received.some((m) => m.type === 'result')).toBe(true)); + + const result = port.received.find((m) => m.type === 'result'); + expect(result).toEqual({ type: 'result', id: 7, ok: true, result: { echoed: {} } }); + }); + + it('relays a call error with its code', async () => { + const t = fakeTransport(CONNECTED); + const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined }); + const port = fakePort(); + bridge.attach({ addListener: (cb) => cb(port) }); + + port.emit({ type: 'call', id: 1, method: 'boom' as never, params: {} }); + await vi.waitFor(() => expect(port.received.some((m) => m.type === 'result')).toBe(true)); + + const result = port.received.find((m) => m.type === 'result'); + expect(result).toEqual({ type: 'result', id: 1, ok: false, error: { code: -32010, message: 'nope' } }); + }); + + it('relays a call error when there is no transport yet', async () => { + const bridge = new PanelBridge({ getTransport: () => undefined, setOverride: async () => undefined }); + const port = fakePort(); + bridge.attach({ addListener: (cb) => cb(port) }); + + port.emit({ type: 'call', id: 2, method: 'download.list', params: {} }); + await vi.waitFor(() => expect(port.received.some((m) => m.type === 'result')).toBe(true)); + + const result = port.received.find((m) => m.type === 'result'); + expect(result).toEqual({ type: 'result', id: 2, ok: false, error: { message: 'transport not ready' } }); + }); + + it('a disconnected port stops receiving after disconnect', () => { + const t = fakeTransport(CONNECTED); + const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined }); + const port = fakePort(); + bridge.attach({ addListener: (cb) => cb(port) }); + + port.close(); + expect(() => port.emit({ type: 'getStatus' })).not.toThrow(); + }); + + it('relays event.speed.global to the port after subscribing', () => { + const stateListeners = new Set<(s: TransportStatus) => void>(); + const eventCbs = new Map void>(); + const t: VeloxTransport = { + kind: 'ws', + state: 'connected', + status: CONNECTED, + connect: async () => undefined, + disconnect: () => undefined, + call: (async () => ({})) as VeloxTransport['call'], + on: ((event: string, cb: (p: unknown) => void) => { + eventCbs.set(event, cb); + }) as VeloxTransport['on'], + off: () => undefined, + onStateChange: (cb) => { + stateListeners.add(cb); + return () => stateListeners.delete(cb); + }, + }; + const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined }); + const port = fakePort(); + bridge.attach({ addListener: (cb) => cb(port) }); + + port.emit({ type: 'subscribe', events: ['event.speed.global'] }); + eventCbs.get('event.speed.global')?.({ bytesPerSec: 4096 }); + + expect(port.received).toContainEqual({ type: 'event', event: 'event.speed.global', payload: { bytesPerSec: 4096 } }); + }); + + it('pairs with a code and reports the resulting status', async () => { + const pairWithCode = vi.fn(async () => undefined); + const t = { ...fakeTransport(CONNECTED), pairWithCode }; + const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined }); + const port = fakePort(); + bridge.attach({ addListener: (cb) => cb(port) }); + + port.emit({ type: 'pair', code: '4821' }); + await vi.waitFor(() => expect(pairWithCode).toHaveBeenCalledWith('4821')); + }); + + it('reports a pairError when the transport has no pairWithCode (e.g. native transport)', async () => { + const t = fakeTransport(CONNECTED); // no pairWithCode + const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined }); + const port = fakePort(); + bridge.attach({ addListener: (cb) => cb(port) }); + + port.emit({ type: 'pair', code: '4821' }); + await vi.waitFor(() => expect(port.received.some((m) => m.type === 'pairError')).toBe(true)); + }); + + it('unpairs via the transport and reports status', async () => { + const unpair = vi.fn(async () => undefined); + const t = { ...fakeTransport(CONNECTED), unpair }; + const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined }); + const port = fakePort(); + bridge.attach({ addListener: (cb) => cb(port) }); + + port.emit({ type: 'unpair' }); + await vi.waitFor(() => expect(unpair).toHaveBeenCalled()); + }); + + it('delegates setOverride to the deps and reports the new status', async () => { + const setOverride = vi.fn(async () => undefined); + const t = fakeTransport(CONNECTED); + const bridge = new PanelBridge({ getTransport: () => t, setOverride }); + const port = fakePort(); + bridge.attach({ addListener: (cb) => cb(port) }); + + port.emit({ type: 'setOverride', override: 'uds' }); + await vi.waitFor(() => expect(setOverride).toHaveBeenCalledWith('uds')); + }); + + it('getStatus answers with the disconnected sentinel when there is no transport', () => { + const bridge = new PanelBridge({ getTransport: () => undefined, setOverride: async () => undefined }); + const port = fakePort(); + bridge.attach({ addListener: (cb) => cb(port) }); + port.received.length = 0; + + port.emit({ type: 'getStatus' }); + expect(port.received[0]).toEqual({ + type: 'status', + status: { state: 'disconnected', needsPairing: false, fatal: null, retryAfterSec: null, sessionId: null, daemonVersion: null, capabilities: [], kind: null }, + }); + }); +}); diff --git a/extension/tests/options/prefs.test.ts b/extension/tests/options/prefs.test.ts new file mode 100644 index 0000000..95ca8d4 --- /dev/null +++ b/extension/tests/options/prefs.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { getDefaultCategoryId, setDefaultCategoryId } from '../../src/options/prefs.js'; + +describe('options/prefs', () => { + beforeEach(async () => { + await browser.storage.local.clear(); + }); + + it('defaults to null (no preference set)', async () => { + expect(await getDefaultCategoryId()).toBeNull(); + }); + + it('round-trips a chosen category', async () => { + await setDefaultCategoryId('cat-videos'); + expect(await getDefaultCategoryId()).toBe('cat-videos'); + }); + + it('clears back to null', async () => { + await setDefaultCategoryId('cat-videos'); + await setDefaultCategoryId(null); + expect(await getDefaultCategoryId()).toBeNull(); + }); +}); diff --git a/extension/tests/options/view.test.ts b/extension/tests/options/view.test.ts new file mode 100644 index 0000000..c6b4c44 --- /dev/null +++ b/extension/tests/options/view.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { captureRulesEditable, pairingAvailable, statusLine, unpairAvailable } from '../../src/options/view.js'; +import type { PanelStatus } from '../../src/background/bridge.js'; + +function status(over: Partial = {}): PanelStatus { + return { + state: 'connected', + needsPairing: false, + fatal: null, + retryAfterSec: null, + sessionId: null, + daemonVersion: null, + capabilities: [], + kind: 'ws', + ...over, + }; +} + +describe('statusLine', () => { + it('names the state, version, and transport kind', () => { + expect(statusLine(status({ daemonVersion: '1.2.3' }))).toBe('connected · v1.2.3 · via WebSocket'); + }); + + it('calls out native messaging', () => { + expect(statusLine(status({ kind: 'uds' }))).toBe('connected · via native messaging'); + }); + + it('surfaces "needs pairing" with no retry hint', () => { + expect(statusLine(status({ state: 'disconnected', needsPairing: true, retryAfterSec: null, kind: null }))).toBe( + 'disconnected · needs pairing', + ); + }); + + it('surfaces a pairing lockout with the retry hint', () => { + expect( + statusLine(status({ state: 'disconnected', needsPairing: true, retryAfterSec: 45, kind: 'ws' })), + ).toBe('disconnected · via WebSocket · pairing locked (45s)'); + }); + + it('surfaces a fatal condition', () => { + expect(statusLine(status({ fatal: 'protocol mismatch' }))).toContain('protocol mismatch'); + }); +}); + +describe('pairingAvailable', () => { + it('is available on ws and before the kind is known', () => { + expect(pairingAvailable(status({ kind: 'ws' }))).toBe(true); + expect(pairingAvailable(status({ kind: null }))).toBe(true); + }); + + it('is not available on native messaging (no token concept there)', () => { + expect(pairingAvailable(status({ kind: 'uds' }))).toBe(false); + }); +}); + +describe('unpairAvailable', () => { + it('only on ws, never before the kind is known (nothing to revoke yet)', () => { + expect(unpairAvailable(status({ kind: 'ws' }))).toBe(true); + expect(unpairAvailable(status({ kind: 'uds' }))).toBe(false); + expect(unpairAvailable(status({ kind: null }))).toBe(false); + }); +}); + +describe('captureRulesEditable', () => { + it('only true over native messaging, matching METHODS privileged/uds-only', () => { + expect(captureRulesEditable(status({ kind: 'uds' }))).toBe(true); + expect(captureRulesEditable(status({ kind: 'ws' }))).toBe(false); + expect(captureRulesEditable(status({ kind: null }))).toBe(false); + }); +}); diff --git a/extension/tests/popup/store.test.ts b/extension/tests/popup/store.test.ts new file mode 100644 index 0000000..d7fad6f --- /dev/null +++ b/extension/tests/popup/store.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { formatBytes, formatEta, formatSpeed, PopupStore, progressPercent } from '../../src/popup/store.js'; +import type { TaskSummary } from '../../src/shared/protocol/index.js'; + +function summary(over: Partial = {}): TaskSummary { + return { + taskId: 't1', + filename: 'file.zip', + saveDir: '/home/u/Downloads', + url: 'https://example.com/file.zip', + downloadedBytes: 0, + state: 'downloading', + speedBps: 0, + resumable: true, + segments: 1, + createdAt: '2026-01-01T00:00:00Z', + ...over, + }; +} + +describe('PopupStore', () => { + it('starts empty', () => { + const s = new PopupStore(); + expect(s.rows()).toEqual([]); + expect(s.size).toBe(0); + }); + + it('seeds from download.list', () => { + const s = new PopupStore(); + s.setInitial([summary({ taskId: 'a' }), summary({ taskId: 'b', filename: 'b.zip' })]); + expect(s.size).toBe(2); + }); + + it('adds a row on event.task.added', () => { + const s = new PopupStore(); + s.onAdded({ taskId: 't1', summary: summary() }); + expect(s.rows()).toHaveLength(1); + expect(s.rows()[0]!.filename).toBe('file.zip'); + }); + + it('applies event.task.progress as a patch, not a rebuild', () => { + const s = new PopupStore(); + s.setInitial([summary({ taskId: 't1', sizeBytes: 1000 })]); + s.onProgress({ at: '2026-01-01T00:00:00Z', tasks: [{ taskId: 't1', downloadedBytes: 500, speedBps: 2048, etaSeconds: 4 }] }); + const row = s.rows()[0]!; + expect(row.downloadedBytes).toBe(500); + expect(row.speedBps).toBe(2048); + expect(row.etaSeconds).toBe(4); + expect(row.filename).toBe('file.zip'); // untouched by the patch + }); + + it('ignores a progress tick for an unknown task rather than inserting a partial row', () => { + const s = new PopupStore(); + s.onProgress({ at: '2026-01-01T00:00:00Z', tasks: [{ taskId: 'ghost', downloadedBytes: 1, speedBps: 1 }] }); + expect(s.size).toBe(0); + }); + + it('replaces the row on event.task.state when a summary is attached', () => { + const s = new PopupStore(); + s.setInitial([summary({ taskId: 't1', state: 'downloading' })]); + s.onState({ taskId: 't1', state: 'paused', summary: summary({ taskId: 't1', state: 'paused' }) }); + expect(s.rows()[0]!.state).toBe('paused'); + }); + + it('updates just the state when event.task.state has no summary', () => { + const s = new PopupStore(); + s.setInitial([summary({ taskId: 't1', state: 'downloading' })]); + s.onState({ taskId: 't1', state: 'failed' }); + expect(s.rows()[0]!.state).toBe('failed'); + }); + + it('drops the row on event.task.removed', () => { + const s = new PopupStore(); + s.setInitial([summary({ taskId: 't1' })]); + s.onRemoved({ taskId: 't1', deletedFile: false }); + expect(s.size).toBe(0); + }); + + it('sorts active tasks before paused/finished ones, then by filename', () => { + const s = new PopupStore(); + s.setInitial([ + summary({ taskId: 'z', filename: 'z-paused.zip', state: 'paused' }), + summary({ taskId: 'b', filename: 'b-active.zip', state: 'downloading' }), + summary({ taskId: 'a', filename: 'a-active.zip', state: 'downloading' }), + ]); + expect(s.rows().map((r) => r.taskId)).toEqual(['a', 'b', 'z']); + }); +}); + +describe('format helpers', () => { + it('formats bytes', () => { + expect(formatBytes(500)).toBe('500 B'); + expect(formatBytes(2048)).toBe('2.0 KB'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB'); + }); + + it('formats speed, blank when idle', () => { + expect(formatSpeed(0)).toBe(''); + expect(formatSpeed(1024)).toBe('1.0 KB/s'); + }); + + it('formats eta, blank when unknown', () => { + expect(formatEta(null)).toBe(''); + expect(formatEta(-1)).toBe(''); + expect(formatEta(45)).toBe('45s'); + expect(formatEta(125)).toBe('2m 5s'); + }); + + it('computes a progress percent, null when size is unknown', () => { + expect(progressPercent({ taskId: 't', filename: 'f', state: 'downloading', downloadedBytes: 50, sizeBytes: 100, speedBps: 0, etaSeconds: null })).toBe(50); + expect(progressPercent({ taskId: 't', filename: 'f', state: 'downloading', downloadedBytes: 50, sizeBytes: null, speedBps: 0, etaSeconds: null })).toBeNull(); + }); +}); From 03b6253b5a56933ebc773bb65f82894a17439b1a Mon Sep 17 00:00:00 2001 From: sami Date: Fri, 11 Sep 2026 12:26:48 +0400 Subject: [PATCH 4/4] ext: content/ media detection (build order step 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent signals feed one panel, per docs/05 §3: - capture/media.ts (background): webRequest-based, recognizes .m3u8/.mpd URLs and the HLS/DASH content types, deduped per (tab, url) so an HLS live-refresh doesn't re-fire. media-bridge.ts relays a hit to the tab's content script over runtime.sendMessage, and separately answers the content script's media.listVariants/media.addVariant calls by forwarding them to the background page's transport. - content/media-observer.ts: watches the page's own