diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9723afe --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Files copied verbatim into the extension package must not depend on the checkout platform: +# reviewers rebuild from the git archive (LF), so the working tree must match it byte for byte. +extension/src/public/**/*.json text eol=lf +*.png binary diff --git a/.github/workflows/auto-localize.yml b/.github/workflows/auto-localize.yml deleted file mode 100755 index a0c68b1..0000000 --- a/.github/workflows/auto-localize.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Zero-Touch Localization - -on: - push: - paths: - - 'extension/src/public/_locales/en/messages.json' # Only trigger when source changes - branches: - - main - -jobs: - translate: - runs-on: ubuntu-latest - permissions: - contents: write # Required to push translations back to repo - - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Sync Locale Placeholders - run: | - # This script executes the diffing, API calls, and file writing - node extension/scripts/translator/index.js - - - name: Commit and Push Changes - uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 (current target of the v5 tag) - with: - commit_message: "chore(i18n): auto-generate translations" - file_pattern: 'extension/src/public/_locales/**/messages.json' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a16ffc..1fbc968 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,147 +2,176 @@ name: CI on: push: - branches: [main, master] + branches: [main] pull_request: - branches: [main, master] + branches: [main] + +# Every job runs in the extension workspace from the tracked lockfile. +# Release-gate jobs (package) check what the store submissions depend on: +# a clean, deterministic, remote-free package with the expected version and +# recorded checksums. Store submission itself is never automated. + +env: + NODE_VERSION: '24' jobs: - # Job 1: Lint lint: runs-on: ubuntu-latest - defaults: run: working-directory: extension - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ env.NODE_VERSION }} cache: 'npm' cache-dependency-path: extension/package-lock.json + - run: npm ci + - run: npm run lint - - name: Install dependencies - run: npm ci - - - name: Run ESLint - run: npm run lint - - # Job 2: Type Check, Unit Tests test: runs-on: ubuntu-latest needs: lint - defaults: run: working-directory: extension - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ env.NODE_VERSION }} cache: 'npm' cache-dependency-path: extension/package-lock.json - - - name: Install dependencies - run: npm ci - + - run: npm ci - name: Type check run: npm run compile + - name: Unit and component tests + run: npm test - - name: Run unit tests - run: npm run test - - # Job 2: Build Extensions - build: + package: runs-on: ubuntu-latest needs: test - defaults: run: working-directory: extension - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ env.NODE_VERSION }} cache: 'npm' cache-dependency-path: extension/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Build Chrome extension - run: npm run build:chrome - - - name: Build Firefox extension - run: npm run build:firefox - - - name: Upload Chrome build - uses: actions/upload-artifact@v4 - with: - name: chrome-extension - path: extension/builds/chrome-mv3/ - retention-days: 7 - - - name: Upload Firefox build - uses: actions/upload-artifact@v4 + - run: npm ci + + - name: Build Chrome and Firefox packages + run: | + npm run zip:chrome + npm run zip:firefox + + - name: Build twice and require byte-identical output + run: | + set -e + (cd builds/chrome-mv3 && find . -type f | sort | xargs sha256sum) > /tmp/chrome-1.txt + (cd builds/firefox-mv3 && find . -type f | sort | xargs sha256sum) > /tmp/firefox-1.txt + npm run build:chrome + npm run build:firefox + (cd builds/chrome-mv3 && find . -type f | sort | xargs sha256sum) > /tmp/chrome-2.txt + (cd builds/firefox-mv3 && find . -type f | sort | xargs sha256sum) > /tmp/firefox-2.txt + diff /tmp/chrome-1.txt /tmp/chrome-2.txt + diff /tmp/firefox-1.txt /tmp/firefox-2.txt + echo "deterministic: $(wc -l < /tmp/firefox-1.txt) files" + + - name: No tracked source mutated by the build + run: | + cd "$(git rev-parse --show-toplevel)" + git status --porcelain + test -z "$(git status --porcelain)" + + - name: Mozilla validator (addons-linter, errors fail the build) + run: npx --yes addons-linter builds/firefox-mv3 + + - name: Package content gates + run: | + set -e + for dir in builds/chrome-mv3 builds/firefox-mv3; do + echo "== $dir" + # Only the English locale is shipped. + test "$(ls $dir/_locales | tr '\n' ' ' | xargs)" = "en" + # No bundled fonts and no remote font or CDN references. + test "$(find $dir -iname '*.woff*' -o -iname '*.ttf' -o -iname '*.otf' | wc -l)" = "0" + ! grep -rEl 's81c\.com|fonts\.googleapis|fonts\.gstatic|@font-face' "$dir" + # Strings from removed features must not reappear. + ! grep -rEl 'iknowwhatyoudownload|btcache|torrage|itorrents|openwebtorrent' "$dir" + # Only the expected set of files. + test "$(find $dir -type f | wc -l)" = "14" + done + + - name: Version consistency + run: | + set -e + PKG=$(node -p "require('./package.json').version") + MAN_CHROME=$(node -p "require('./builds/chrome-mv3/manifest.json').version") + MAN_FIREFOX=$(node -p "require('./builds/firefox-mv3/manifest.json').version") + # Same rule as toManifestVersion() in wxt.config.ts: a pre-release tag becomes a fourth integer. + EXPECTED=$(node -p "const [b,p]=process.argv[1].split('-'); if(!p) b; else { const s=p.split('.').pop(); /^\d+$/.test(s)? b+'.'+s : b+'.0' }" "$PKG") + echo "package $PKG → manifest $EXPECTED; chrome $MAN_CHROME; firefox $MAN_FIREFOX" + test "$MAN_CHROME" = "$EXPECTED" + test "$MAN_FIREFOX" = "$EXPECTED" + test "$(node -p "require('./builds/chrome-mv3/manifest.json').version_name")" = "$PKG" + ls builds/ctrl-extension-$PKG-chrome.zip builds/ctrl-extension-$EXPECTED-firefox.zip + # Permissions must stay the reviewed set. + for m in builds/chrome-mv3/manifest.json builds/firefox-mv3/manifest.json; do + test "$(node -p "JSON.stringify(require('./$m').permissions)")" = '["storage","contextMenus","notifications","alarms","declarativeNetRequestWithHostAccess"]' + test "$(node -p "JSON.stringify(require('./$m').optional_host_permissions)")" = '["http://*/*","https://*/*"]' + done + + - name: Package size (internal regression threshold) + run: | + set -e + for z in builds/*.zip; do + SIZE=$(stat -c %s "$z"); echo "$z: $SIZE bytes" + # Well above the current ~327 KB; a jump past this means something unintended was bundled. + test "$SIZE" -lt 600000 + done + + - name: Checksums + run: | + (cd builds && sha256sum *.zip | tee SHA256SUMS.txt) + + - uses: actions/upload-artifact@v4 with: - name: firefox-extension - path: extension/builds/firefox-mv3/ - retention-days: 7 + name: packages + path: | + extension/builds/*.zip + extension/builds/SHA256SUMS.txt + extension/builds/chrome-mv3/ + extension/builds/firefox-mv3/ + retention-days: 14 - # Job 3: E2E Tests (Chrome only) e2e: runs-on: ubuntu-latest - needs: build - + needs: test defaults: run: working-directory: extension - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ env.NODE_VERSION }} cache: 'npm' cache-dependency-path: extension/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Install Playwright browsers - run: npx playwright install chromium --with-deps - - - name: Build Chrome extension - run: npm run build:chrome - - - name: Run E2E tests + - run: npm ci + - run: npx playwright install chromium --with-deps + - run: npm run build:chrome + - name: E2E smoke (Playwright Chromium) run: npm run test:e2e -- --grep-invert "@integration" env: CI: true - - - name: Upload Playwright report - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: extension/playwright-report/ retention-days: 7 - diff --git a/.gitignore b/.gitignore index b7cdbbf..3416549 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,6 @@ extension/tests/e2e/.persistent-data/ *.pma *.db *.db-journal + +# Live-verification screenshots (regenerated by extension/tests/live/verify.mjs) +docs/release/v1/evidence/screens/ diff --git a/.nvmrc b/.nvmrc index 2bd5a0a..a45fd52 100755 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +24 diff --git a/.raiden/state/CURRENT_STATE.md b/.raiden/state/CURRENT_STATE.md index 7f43440..540b257 100644 --- a/.raiden/state/CURRENT_STATE.md +++ b/.raiden/state/CURRENT_STATE.md @@ -1,7 +1,7 @@ # Current State -**Active branch:** `main` @ `247f7b0` (PR #4 merged `next/main-rebuild`; in sync with `origin/main`, 0 ahead / 0 behind) -**CI:** passing on `main` (GitHub CI: lint, test, build, e2e) +**Active branch:** `main` (PR #4 merged `next/main-rebuild`). As of 2026-09-10 the v1 release lineage (see `docs/release/v1/EXECUTION_STATE.md`) is committed locally on top of `origin/main` and has **not been published**; the publication candidate is the local branch `release/v1-publication-candidate` (the release lineage plus the state reconciliation that records this entry). Publication and sync status are deliberately not recorded here — verify them from live git evidence (`git fetch origin`, then `git rev-list --left-right --count origin/main...` and `git ls-remote origin`) before acting. This file is not authoritative for ahead/behind counts. +**CI:** last verified passing on the published `origin/main` (GitHub CI run `29472393892` for `f088c5f`; see OPEN_LOOPS.md OL-010). Remote CI has **not** run against the unpublished v1 release lineage; release Gate H stays PARTIAL until the first remote run of `lint`, `test`, `package` and `e2e` completes green. --- @@ -32,7 +32,8 @@ CTRL is a browser extension for managing BitTorrent clients. Built with WXT, Rea - External repository audit performed 2026-07-02 (report at `.audits/CTRL_AUDIT_2026-07-02.md`, untracked); remediation pass conducted: dependency vulnerability fixes (3196b3c), extension attack-surface hardening (0e90490), CI Node version alignment and third-party action pinning (4adf272), governance/license-year sync (e909644). - LifecycleAdapter `parseDOM` serialization fixed and validated (2026-07-03, commit aaa99b3). - Synology Download Station support removed — breaking change (2026-07-03, commit f2e4a62); research documents archived (247f7b0). -- `main` HEAD = `247f7b0`, in sync with `origin/main`. Full unit suite re-verified 2026-07-03: **512 passed / 0 failed** (15 test files). +- As of 2026-07-03, `main` HEAD was `247f7b0` and in sync with `origin/main` (historical; for current sync status see the header). Full unit suite re-verified 2026-07-03: **512 passed / 0 failed** (15 test files). +- v1 release program (2026-09-09): release lineage prepared locally — not published, not released, no store submission (version remains `0.2.0-beta.1`). Program state, release gates and evidence: `docs/release/v1/EXECUTION_STATE.md`, `docs/release/v1/CLIENT_VERIFICATION.md`. Gate H (remote CI) is PARTIAL and Gate K (operator acceptance) is not yet evaluated. OL-012, OL-013, OL-014 and OL-015 are closed against that lineage's evidence; OL-011 remains open (see OPEN_LOOPS.md). ## In Progress - 2026-06-13 — hook exec-bit fixed, .gitignore e2e noise cleared. @@ -42,6 +43,7 @@ CTRL is a browser extension for managing BitTorrent clients. Built with WXT, Rea ## Non-Blocking Open Items - Secret scanning alert #1: RESOLVED (2026-06-16) — see Confirmed Current State. Flagged value was the public Chromium omnibox key in committed e2e cache, not a real credential; GitHub alert already resolved-as-revoked; stale Dependabot branch carrying the only reachable copy was deleted. Sole residual is GitHub's immutable `refs/pull/3/head` (PR #3), which cannot be removed client-side — acceptable: the key is public and the alert is already closed. No further action available or needed. +- Superseded in part (2026-07-26): the "acceptable … no further action" judgement above predates DECISIONS.md D-005, which records that `refs/pull/3/head` is publicly reachable because the repository is PUBLIC. Provider-side revocation remains unverified and is tracked as OPEN_LOOPS.md OL-011 (Open, external gate). ## Not Yet Done diff --git a/.raiden/state/DECISIONS.md b/.raiden/state/DECISIONS.md index 6c1e57e..a21da16 100644 --- a/.raiden/state/DECISIONS.md +++ b/.raiden/state/DECISIONS.md @@ -22,3 +22,19 @@ ## D-004 | D-004 | 2026-06-14 | Maintenance pass: hook exec-bit fixed, .persistent-data/ untracked and ignored, .raiden/ exec-bit drift normalized, npm audit fix applied (vite 7.3.3→7.3.5, esbuild 0.27.2→0.27.7, shell-quote 1.8.3→1.8.4), stale VPN doc references removed. | + +## D-005 + +- Date: 2026-07-26 +- Status: Active +- Decision: record as established fact that the exposed Google/Chromium API key is reachable from the **public** repository, not only from the local clone. The 2026-07-26 audit's first pass characterized the exposure as local-only, resting on the claim that the `pre-dependabot-delete-backup` tag was never pushed to `origin`. That claim is technically true — `git ls-remote --tags origin` returns no tags — but materially incomplete: `git ls-remote origin` resolves `refs/pull/3/head` to `15ffee9534d732e44727ad370458217340798122`, the identical commit object as the local tag, and `gh repo view` reports the repository visibility is PUBLIC. The audit's adversarial second pass established this and corrected the finding in place. +- Rationale: GitHub retains `refs/pull//head` permanently and immutably, independent of whether the source branch was deleted. Because the remote ref names the same commit object as the local tag, it carries the same tree and the same 16 cache blobs under `extension/tests/e2e/.persistent-data/Default/Cache/Cache_Data/`. Two consequences follow and are the reason this is recorded rather than left in the audit report alone: deleting the local tag remediates nothing, and no client-side git operation can remove the remote copy. The blast radius on record is therefore internet-exposed, not single-machine. +- Note: this entry records a fact established by audit, not a remediation decision. No decision has been taken on verifying provider-side rotation or on pursuing a GitHub Support purge of the PR-3 ref; both are tracked as open items in OPEN_LOOPS.md OL-011. Mitigating context recorded but not adjudicated: the flagged value is documented as the public Chromium omnibox suggest key, and D-003 declares it revoked on 2026-06-14 — a declared date, which no repository-bound audit can verify. Related: the tracked and publicly readable `.raiden/state/CURRENT_STATE.md` records the key fragment, its exact in-tree path, the commit, and the residual ref. + +## D-006 + +- Date: 2026-07-26 +- Status: Active +- Decision: record as a correction to the record that the technical justification documented for the `@vitejs/plugin-react` hold (OPEN_LOOPS.md OL-006, written 2026-07-04) was factually inaccurate as of this date. The 2026-07-26 audit checked its premises against installed and live upstream state; three failed. It recorded the installed vite as "4.x" when the installed version is `7.3.5`. It named a conflicting `@wxt-dev/module-react@1.1.5` peer range of `^4.4.1 || ^5.0.0` when that package declares `{wxt: '>=0.19.16'}` and no vite peer at all, so the named conflict did not exist. And it stated the WXT ecosystem had not moved to support vite 8 when `@wxt-dev/module-react@1.2.2` declares `vite: ^5.4.19 || ^6.3.4 || ^7.0.0 || ^8.0.0-0` and the installed `wxt@0.20.27`'s own vite dependency range already includes `^8.0.0-0`. +- Rationale: the hold's conclusion remains correct — `@vitejs/plugin-react@6.0.4` requires `vite@^8.0.0` plus new `@rolldown/plugin-babel` and `babel-plugin-react-compiler` peers, while the project runs vite `7.3.5` — but the recorded reason pointed at an upstream gap that has since been satisfied. A planner reading OL-006 as originally written would wait for something that has already happened, and would be working from a vite version three majors out of date. The corrected justification, and the gate change from `upstream` to `local`, are recorded in OL-006 itself. +- Note: this entry records a correction to the record, not a decision to act. OL-006 remains Open and no decision has been taken on performing the vite 7 → 8 migration. By contrast the Babel 8 hold (OL-005) was re-verified in the same pass as fully accurate and required no correction. diff --git a/.raiden/state/OPEN_LOOPS.md b/.raiden/state/OPEN_LOOPS.md index 66ed71c..c06eac1 100644 --- a/.raiden/state/OPEN_LOOPS.md +++ b/.raiden/state/OPEN_LOOPS.md @@ -43,6 +43,13 @@ - Routing through Anthropic's own Claude Code GitHub Action using a subscription-tied OAuth token was identified as the one path that would stay inside the existing subscription, but its reliability for this specific unattended CI use case is unproven and was not pursued. - The pipeline currently writes a visible bracketed placeholder (`[locale] `) for any new untranslated key rather than a real translation, by design, until this is revisited. - Closed by: `extract-i18n` --out-file corrected to `src/public/_locales/en/messages.json`; `LOCALES_DIR` corrected to `extension/src/public/_locales`; `TARGET_LOCALES` set to `['de', 'es', 'fi', 'fr', 'ru', 'zh_CN']`; zombie-key removal and new-key detection left intact; new-key branch restored to the original placeholder behavior (no OpenAI/Anthropic calls); workflow trigger path, dependency-install step removal, and direct-commit-via-`stefanzweifel/git-auto-commit-action` (pinned `b863ae1933cb653a53c021fe36dbb774e1fb9403`) all corrected to match. +- Correction (2026-07-26): the end-state recorded in the third 2026-07-03 decision above — "the pipeline currently writes a visible bracketed placeholder" — is no longer an accurate description of the repository. Per the 2026-07-26 audit (F5, F27): + - Zero `[locale]` bracketed placeholders exist in any locale file. `grep -c '\[de\]'`, `'\[fr\]'`, and `'\[zh_CN\]'` against their respective `messages.json` files all return 0. + - The six non-English locales instead carry real human-language translations, but at roughly 21% coverage: `en` has 152 message entries against `fi` 63, `ru` 61, and `de`/`es`/`fr`/`zh_CN` 32 each. + - The visibility mechanism this loop closed on is therefore not in effect. A key present in English and absent from a target locale falls back silently to the English default via `browser.i18n.getMessage()` rather than rendering a `[locale] ` marker, so missing coverage is invisible in the UI. + - Provenance of the real translations is unexplained and unresolved. The in-repo pipeline writes only bracketed placeholders, and `gh run list --workflow=auto-localize.yml` returns empty — the workflow has never executed once — so the translations cannot have come from it. No governance record states where they did come from. This provenance question is not covered by the closure above and remains open. + - The pipeline's live-risk aspect (armed in CI, never run, would inject 89–120 placeholders per locale on next trigger) is tracked separately as OL-015. +- Superseded (2026-09-10): `9191c15` removed `.github/workflows/auto-localize.yml`, `extension/scripts/translator/index.js`, the `extract-i18n` script and all six non-English locales as part of the English-only v1 scope (`docs/release/v1/V1_SCOPE.md`). The pipeline this loop describes no longer exists in the tree; the translation-provenance question above was never answered and no longer bears on the shipped tree. Status unchanged. ## OL-005 @@ -53,13 +60,162 @@ - Held at: `7.29.7` — the newest release still within the `7.x` line (above the prior `7.28.0` pin, below the `8.0.2` major) — so the package stays current without forcing the toolchain migration. - Success condition (future): migrate to Babel 8, including verifying whether legacy-mode decorators (as used by tsyringe) are still supported by the new decorators plugin under `@babel/core@8`, then bump `@babel/plugin-proposal-decorators` to the 8.x line as its own distinct change. - Decisions (2026-07-04): treated as a distinct future decision, not part of this dependency-currency pass. No workaround (`--force`/`--legacy-peer-deps`) applied; the version was held back deliberately instead. +- Re-verified (2026-07-26): the 2026-07-26 audit re-derived this hold against installed and live upstream state and found it fully accurate — no correction needed. `extension/package.json` pins `@babel/plugin-proposal-decorators` at exactly `7.29.7`; installed `@babel/core` is `7.29.7`; `npm view @babel/plugin-proposal-decorators@8.0.2 peerDependencies` returns `{'@babel/core': '^8.0.0'}`. `wxt.config.ts` still applies the plugin in `{ legacy: true }` mode for tsyringe, so the open migration question above remains genuinely open. The declared blocker is real and unchanged. +- Update (2026-09-10): the premise above no longer holds at the v1 publication candidate. `d16b7df` removed `@babel/plugin-proposal-decorators`, `tsyringe` and `reflect-metadata` from `extension/package.json` and the legacy-decorators Babel block from `wxt.config.ts`; nothing in `extension/src` references `tsyringe`. The held package is no longer a dependency, so the success condition as written cannot be met literally. Status left Open pending an explicit decision to close it as superseded; no status transition is inferred here. ## OL-006 -- Title: Vite 8 support blocked upstream — @vitejs/plugin-react held back during dependency-currency pass +- Title: `@vitejs/plugin-react` held back — bump requires an in-repo vite 7 → 8 migration - Status: Open -- Gate: upstream -- Why it matters: during the F4 dependency-currency pass, `@vitejs/plugin-react` was found six major versions behind (current latest `6.0.3`). Bumping it broke `npm install` — that release requires `vite@^8.0.0` as a peer, which conflicts with `@wxt-dev/module-react@1.1.5`'s peer range (`^4.4.1 || ^5.0.0`). The blocker is not this plugin itself but that the WXT ecosystem has not yet moved to support vite 8. -- Held at: `5.2.0` — the newest `5.x` release, which satisfies both the currently installed vite (4.x) and `@wxt-dev/module-react`'s peer constraint. -- Success condition (future): once `@wxt-dev/module-react` and the broader WXT ecosystem are updated to support vite 8 (tracked separately as general WXT-adjacent currency debt from the original audit), revisit this package and bump it alongside that change, not independently before it. +- Gate: local +- Why it matters: during the F4 dependency-currency pass, `@vitejs/plugin-react` was found several major versions behind (current latest `6.0.4`). Bumping it breaks `npm install` — that release requires `vite@^8.0.0` as a peer, and it additionally introduces `@rolldown/plugin-babel` and `babel-plugin-react-compiler@^1.0.0` as new peers. The installed vite is `7.3.5`, so taking the bump means performing a vite 7 → 8 major migration in this repository and re-validating the WXT build against it — not a routine version bump. +- Held at: `5.2.0` — its peer range is `vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0`, which the installed vite `7.3.5` satisfies, so the pin holds without forcing the vite 8 migration. +- Success condition (future): migrate this repository from vite 7 to vite 8 and confirm the WXT build plus the unit and e2e suites pass under it, then bump `@vitejs/plugin-react` to the 6.x line together with its new `@rolldown/plugin-babel` and `babel-plugin-react-compiler` peers as one coordinated change. - Decisions (2026-07-04): treated as a blocked-upstream dependency, not a direct version constraint. The hold-back is deliberate pending WXT ecosystem updates, not a workaround. +- Correction (2026-07-26): the justification recorded on 2026-07-04 was factually inaccurate. The 2026-07-26 audit (F4) checked three of its premises against installed and live upstream state; all three failed: + - "the currently installed vite (4.x)" — the installed vite is `7.3.5`, three majors off the recorded value. + - "conflicts with `@wxt-dev/module-react@1.1.5`'s peer range (`^4.4.1 || ^5.0.0`)" — the installed `1.1.5` declares peers `{wxt: '>=0.19.16'}` and no vite peer at all. The named conflict did not exist. + - "the WXT ecosystem has not yet moved to support vite 8" — `npm view @wxt-dev/module-react@1.2.2 peerDependencies` returns `{vite: '^5.4.19 || ^6.3.4 || ^7.0.0 || ^8.0.0-0', wxt: '>=0.19.16'}`, and the installed `wxt@0.20.27`'s own vite dependency range is already `^5.4.19 || ^6.3.4 || ^7.0.0 || ^8.0.0-0`. WXT supports vite 8. + - Only the fourth premise held: `npm view @vitejs/plugin-react@6.0.4 peerDependencies` confirms `vite: ^8.0.0`. + - Effect: the hold itself remains correct and stays Open, but for a different reason than recorded. The blocker is the in-repo vite 7 → 8 migration, not an upstream gap — the upstream gate this loop was waiting on has already been satisfied. Title, Why it matters, Held at, Success condition, and Gate (`upstream` → `local`) corrected accordingly; the original 2026-07-04 decision line is retained above as the historical record of what was decided at the time. See DECISIONS.md D-006. + +## OL-007 + +- Title: AppleDouble ghost files break the local unit-test invocation +- Status: Closed (2026-07-26) +- Severity: High +- Why it matters: 3,153 `._*` AppleDouble sidecar files copied into the working tree were matched by Vitest's test glob, so ghost twins of 15 real test files were parsed as source and failed on binary AppleDouble header bytes. `npm run test` exited 1 even though all 512 real tests passed. Raised as F2 by the 2026-07-15 audit but never tracked as a loop. +- Success condition: `npm run test` exits 0 with no phantom test files. +- Closed by: confirmed resolved by the 2026-07-26 audit, by execution rather than inspection. `timeout 600 npm test` was run under that audit's sanctioned execution gate and returned **512 passed / 512, 15 test files, exit 0** in 9.96s. The root cause was independently confirmed gone: `find` for `._*` and for `.DS_Store` across the entire working tree both return 0 files (previously 3,153 and 4). Cross-ref: 2026-07-15 audit F2; 2026-07-26 audit §13.4. Root-cause fix tracked as OL-008. + +## OL-008 + +- Title: Root `.gitignore` carried no `._*` pattern +- Status: Closed (2026-07-26) +- Severity: Medium +- Why it matters: the committed root `.gitignore` listed `.DS_Store` but no `._*` pattern anywhere. This was the root cause of OL-007 and of roughly 683 untracked-noise files surfacing in tracked source paths on every `git status` call. +- Files: + - `.gitignore` +- Success condition: `._*` present in the committed root `.gitignore`, not merely as a pending working-tree edit. +- Closed by: confirmed resolved by the 2026-07-26 audit — `._*` is present at root `.gitignore` line 52 and landed in commit `f088c5f` (`chore: ignore AppleDouble and .DS_Store files`), i.e. it is committed state rather than an uncommitted edit. One residual noted but not blocking: `.DS_Store` is now listed twice (lines 34 and 53), a harmless redundancy that survived the fix (2026-07-26 audit F17). Cross-ref: 2026-07-15 audit F3. + +## OL-009 + +- Title: `git fetch` failed with host key verification error +- Status: Closed (2026-07-26) +- Severity: Low +- Why it matters: `git fetch --all --prune` failed with `Host key verification failed` — an SSH host-key trust gap on the workstation rather than a repository defect. The consequence was that any ahead/behind reading against `origin` reflected the last successful fetch rather than a freshly confirmed one, so branch sync state could not be trusted. +- Success condition: `git fetch --all --prune` completes successfully and branch sync state can be confirmed against a fresh fetch. +- Closed by: confirmed resolved by the 2026-07-26 audit — `git fetch --all --prune` ran to exit 0, and `main` was then confirmed in sync with `origin/main` against that fresh fetch rather than a stale one. Environmental resolution, not a repository change. Cross-ref: 2026-07-15 audit F16. + +## OL-010 + +- Title: Live CI run status could not be verified +- Status: Closed (2026-07-26) +- Severity: Low +- Why it matters: the `gh` CLI was not installed on the workstation, so CI pipeline *definitions* could be reviewed but live Actions run status could not be queried at all. A green local tree masking a failing remote pipeline would have gone unnoticed. +- Success condition: live run status for `main` can be queried and confirmed against the declared "CI: passing" state. +- Closed by: confirmed resolved by the 2026-07-26 audit — `gh` is installed and authenticated. `gh run list --limit 10` returned 10 of 10 runs completed successfully, and the most recent CI run on `main` (`29472393892`, for commit `f088c5f`) succeeded in 4m09s. The "CI: passing on `main`" line in CURRENT_STATE.md is now independently verified rather than asserted. Cross-ref: 2026-07-15 audit F13. + +## OL-011 + +- Title: Verify provider-side revocation of the exposed Google/Chromium API key +- Status: Open +- Gate: external +- Severity: Critical +- Why it matters: 2026-07-26 audit F1. The committed key remains reachable in current git state, and the blast radius previously on record was wrong. It is not confined to this machine: + - `git ls-remote origin` resolves `refs/pull/3/head` to `15ffee9534d732e44727ad370458217340798122` — the identical commit object as the local `pre-dependabot-delete-backup` tag — and `gh repo view` reports the repository visibility is PUBLIC. Any unauthenticated party can fetch that ref and recover the value. + - `git ls-remote --tags origin` returns no tags, so the prior record that the tag was never pushed is literally true but does not mean the secret is absent from the remote. + - GitHub retains `refs/pull//head` permanently and immutably. Deleting the local tag remediates nothing, and no client-side git operation can remove the remote copy. + - `git grep -l 'AIzaSy' pre-dependabot-delete-backup` returns 16 blobs under `extension/tests/e2e/.persistent-data/Default/Cache/Cache_Data/`; `main` and every branch remain clean. + - The tracked and publicly readable `.raiden/state/CURRENT_STATE.md` itself records the key fragment, its exact in-tree path, the commit, and the residual PR ref — a complete public map to the artifact. +- Why this loop is scoped to verification: no repository-bound audit can confirm provider-side revocation; that is outside the repository boundary by definition. DECISIONS.md D-003 declares the key revoked in the Google Cloud Console on 2026-06-14. That is a declared date, not a verified one, and this loop exists to confirm it against the provider. +- Files: + - `refs/pull/3/head` (remote, public, immutable — not removable client-side) + - `refs/tags/pre-dependabot-delete-backup` → `15ffee9` (local) + - `extension/tests/e2e/.persistent-data/Default/Cache/Cache_Data/` (16 blobs within that commit's tree) +- Success condition: revocation confirmed directly against the Google Cloud Console — or the key's absence from the project's credential list recorded — with the confirmation date and method written back into this entry. Separately, a decision recorded on whether to pursue a GitHub Support purge of the PR-3 ref. +- Mitigating context (recorded by the audit, not adjudicated by it): the flagged value is documented as the public Chromium omnibox suggest key (`client=chrome-omni&sugkey=`), a value embedded in every Chromium build and public by design, so realistic harm may be nil. The audit reports presence and reachability only and takes no position on provider-side status. See DECISIONS.md D-005. + +## OL-012 + +- Title: Firefox vault-key fallback writes the AES key to `chrome.storage.local` in plaintext +- Status: Closed (2026-09-10) +- Severity: High +- Why it matters: 2026-07-26 audit F2. `KeyManager.setSessionKey()` exports the derived AES-GCM vault key to JWK and, on Firefox only, additionally writes it to `local:session_encryptionKey` in `chrome.storage.local` — which is plaintext on disk and unencrypted at rest on every platform. Anyone with read access to the extension's profile directory can recover the key and decrypt every stored torrent-client credential without knowing the master password, bypassing the PBKDF2-300k work factor entirely. `SecurityService.deriveKey` is called with `extractable = true` specifically to enable this export. The fallback is a deliberate workaround for Firefox MV3 session storage not surviving background-script restarts, and `background.ts:25-33` clears the key on `chrome.runtime.onStartup`, which bounds exposure to between browser sessions but not during them — and not against offline disk access if a crash prevented the clear. +- Files: + - `extension/src/shared/api/security/KeyManager.ts` (write path lines 33-36, read path lines 50-53) + - `extension/src/shared/api/security/SecurityService.ts` (`extractable = true`) + - `extension/src/entrypoints/background.ts` (startup clear, lines 25-33) +- Success condition: the vault key is no longer persisted in plaintext at rest on any target, or the residual risk is explicitly accepted and recorded here with its rationale and the compensating controls relied upon. +- Note on coverage: the same audit found this path has no direct test coverage at all (F12) — no test file imports `KeyManager`, `VaultService`, or `SecurityService` as a subject, so a regression here would not fail CI. +- Closed by: `50e54c4` (`fix(security): keep the vault session key out of disk-backed storage (OL-012)`), verified against the first branch of the success condition — the key is no longer persisted at rest on any target: + - `KeyManager` reads and writes the session key through `storage.session` (in-memory, browser-session scoped) only, on every browser; the Firefox `storage.local` fallback write and read paths are gone. A search of `extension/src` for `session_encryptionKey` / `local:session` finds only the legacy-key constant retained for scrubbing. + - `KeyManager.purgeLegacyFallbackKey()` runs on every background wake — not only `onStartup`, which does not fire on extension update — so a key left on disk by an older build is removed. + - `extension/tests/unit/KeyManager.test.ts` (real WebCrypto, `@webext-core/fake-browser` storage areas) asserts on Chrome-like and Firefox-like user agents that the raw key material never reaches `local`, `sync` or `managed` storage, including across repeated unlocks, and that a legacy plaintext key is ignored and purged. It gives `KeyManager` the direct coverage F12 found missing, and passed in the 2026-09-10 publication-candidate validation. + - Residual context, not a reopening: `SecurityService.deriveKey` still derives with `extractable = true`, now used only to export the key into in-memory `storage.session`; Firefox lock-on-browser-restart was not exercised live (`docs/release/v1/CLIENT_VERIFICATION.md` caveat B). + +## OL-013 + +- Title: CSRF-bypass headers (Origin, Referer, Cookie) are silently dropped by the Fetch API +- Status: Closed (2026-09-10) +- Severity: High +- Why it matters: 2026-07-26 audit F25. `Origin`, `Referer`, and `Cookie` are forbidden header names under the Fetch Standard — when a `Headers` object is used to construct a `Request`, the request guard filters them out silently, with no exception and no warning. The extension sets all three and relies on them reaching the torrent client: + - `extension/src/shared/api/network/FetchHttpClient.ts:32-33` and `:106-107` set `Origin` and `Referer` for every adapter that routes through the shared client. + - `extension/src/shared/api/clients/qbittorrent/QBittorrentAdapter.ts:327-330` sets them again directly, under the comment "Inject CSRF bypass headers - critical for browser extensions". + - `extension/src/shared/api/clients/utorrent/UTorrentAdapter.ts:394` and the uTorrent RSS/Settings services set `Cookie` for the GUID session; `FetchHttpClient` also passes `credentials: 'omit'`, so the browser will not supply that cookie either. + - The mechanism that handled this correctly — Declarative Net Request dynamic rules — was removed during the 2026-07-02 hardening pass along with the `declarativeNetRequest` permission. `shared/api/network/HeaderRewriter.ts` survives as a set of logging no-ops whose own comments state that DNR was "the standard, safe way to handle this in MV3". There is currently no working mechanism for these headers. + - Expected impact if confirmed: qBittorrent enforces Origin/Referer CSRF validation by default and the browser supplies `Origin: chrome-extension://` on these cross-origin requests, which is precisely the mismatch the code was written to prevent. Transmission is unaffected because `X-Transmission-Session-Id` is not a forbidden header name. +- Verification status — NOT CONFIRMED IN EITHER DIRECTION: this finding is spec-derived and corroborated from four independent places (the source, the removed DNR mechanism, the unit test, and the localhost-only testing history), but it was **not runtime-verified**. Observing the actual wire requires launching a browser against a live torrent client, which the audit's sanctioned execution gate did not permit. It must not be treated as a confirmed defect, nor dismissed, until a live-browser test is run. Two facts bear on why it could have gone unnoticed for this long: + - `extension/tests/unit/adapters/QBittorrentAdapter.test.ts:126-142` ("should inject CSRF headers (Origin and Referer)") asserts on the standalone `Headers` object, whose guard is `"none"` and where `set()` genuinely succeeds. The filtering happens later, at `Request` construction inside `fetch()`, which the test never reaches — so the test passes regardless of what is actually transmitted. + - qBittorrent relaxes CSRF and host-header checks for localhost by default, and prior connection testing was performed against `localhost:8080` / `localhost:9091`. The failure would not appear in the environment the project tested in, while affecting LAN and remote hosts. +- Files: + - `extension/src/shared/api/network/FetchHttpClient.ts` + - `extension/src/shared/api/clients/qbittorrent/QBittorrentAdapter.ts` + - `extension/src/shared/api/clients/utorrent/UTorrentAdapter.ts`, `UTorrentRssService.ts`, `UTorrentSettingsService.ts` + - `extension/src/shared/api/network/HeaderRewriter.ts` (dead, no-op) +- Success condition: a live-browser test against a non-localhost qBittorrent instance establishes whether these headers reach the server. Treat the finding as confirmed or refuted only on that result, and decide remediation from there. +- Closed by: the live-browser test this loop required was run in the v1 release program, and it **confirmed** the finding. `docs/release/v1/CLIENT_VERIFICATION.md` §Defects #2: against qBittorrent 5.2.3 at the non-loopback LAN address `192.168.1.235`, with default CSRF protection on, every request from both Chrome 152 and Firefox 155 was rejected ("Origin header & Target origin mismatch") because the browser stamps the extension origin and `Origin`/`Referer` are forbidden request headers. Remediation was decided and implemented in `a8f489a`: a `declarativeNetRequestWithHostAccess` session rule per configured qBittorrent origin sets `Origin`/`Referer` to that origin (`HeaderRewriter.ts`, no longer a no-op), installed by the controller before a client is created or tested; asking users to disable CSRF protection was rejected. After the fix qBittorrent passed 16/16 live scenarios in both browsers with CSRF protection on (`docs/release/v1/evidence/live-qbittorrent-{chrome,firefox}.md`). Not covered by this closure: the µTorrent `Cookie` path, which was not live-tested; µTorrent is hidden/experimental in v1. + +## OL-014 + +- Title: Export sanitizer denylist misses `clientOptions.simpleApiKey`, leaking the BiglyBT API key +- Status: Closed (2026-09-10) +- Severity: High +- Why it matters: 2026-07-26 audit F26. Both sanitizing export paths in `useSettings.ts` clear exactly two fields — `password` and `httpAuth.password` — while spreading the rest of the `ServerConfig` through verbatim. `ServerConfig.clientOptions` is typed `Record` and demonstrably holds a real credential: `BiglyBTSchema.ts` `parseSimpleApiConfig()` reads `clientOptions.simpleApiKey`, documented in-source as the API key the user copies from BiglyBT's own Simple API plugin settings. The `...s` spread copies it into the export untouched. A user who has configured BiglyBT's Simple API and then exports a "safe" server config — for backup, for sharing, or to attach to a bug report — ships a live credential in cleartext inside a file named `ctrl-servers-safe-.json`, whose name asserts the opposite. The structural problem outlasts this one key: a denylist applied over an open `Record` cannot be correct, so any future secret placed in `clientOptions` leaks identically. +- Files: + - `extension/src/features/torrent-control/model/useSettings.ts` (lines 192-196 for `exportSystemBackup`, lines 222-228 for `exportServerConfig`) + - `extension/src/shared/api/clients/biglybt/BiglyBTSchema.ts` (lines 350-364, `parseSimpleApiConfig`) + - `extension/src/entities/server/model/types.ts` (`ServerConfig.clientOptions`) +- Success condition: an export marked sanitized provably contains no credential from any field of `ServerConfig`, including `clientOptions`, verified against a config that has every credential-bearing field populated. +- Related: the same key is also placed in the query string of every Simple API request (`?apikey=…`), so it reaches the BiglyBT server's access logs — 2026-07-26 audit F33. +- Closed by: `d13123f` replaced the denylist with an allowlist (`extension/src/features/torrent-control/model/exportSanitizer.ts`), which also removes the structural problem above. A safe export copies only named non-secret `ServerConfig` fields, strips userinfo from `hostname`, keeps `httpAuth.username` only, and keeps only primitive `clientOptions` values whose keys the client's `CLIENT_LIST` definition declares as user-visible settings (none of the declared keys is a credential). Everything else is dropped, including `clientOptions.simpleApiKey` and any future unknown key. Both sanitizing paths in `useSettings.ts` (`exportSystemBackup` and `exportServerConfig` with `sanitize`) route through it. `extension/tests/unit/exportSanitizer.test.ts` populates every credential-bearing field (`password`, `httpAuth.password`, URL userinfo in `hostname`, and in `clientOptions` the BiglyBT `simpleApiKey`, a token, a nested secret and an unknown future key) and asserts that none appears in the serialized export; it passed in the 2026-09-10 publication-candidate validation. Runtime corroboration: `safe-export-has-no-secrets` passed in both browsers (`docs/release/v1/evidence/state-vault-{chrome,firefox}.md`; password fields only). Not covered by this closure: F33 (the Simple API key in the request query string reaches BiglyBT access logs) is unaddressed; BiglyBT is hidden/experimental in v1. + +## OL-015 + +- Title: Locale-injection script is armed in CI with `contents: write` and has never executed +- Status: Closed (2026-09-10) +- Severity: Medium +- Why it matters: 2026-07-26 audit F27. `.github/workflows/auto-localize.yml` runs `node extension/scripts/translator/index.js` with `permissions: contents: write` and auto-commits the result directly to `main` via `stefanzweifel/git-auto-commit-action` (SHA-pinned `b863ae1933cb653a53c021fe36dbb774e1fb9403`). Three facts combine into a live risk: + - `gh run list --workflow=auto-localize.yml` returns empty — the workflow has never executed once, so its real behaviour has never been observed. + - It fires on any push to `main` that touches `extension/src/public/_locales/en/messages.json`, and the script writes `[locale] ` for every key present in English and missing from a target locale (`scripts/translator/index.js:53-57`). + - Current key counts are `en` 152 against `fi` 63, `ru` 61, and `de`/`es`/`fr`/`zh_CN` 32 each. The next trigger would inject roughly 89 to 120 bracketed placeholder strings per locale across all six files and push them to `main` unattended and unreviewed — over the real translations currently in the tree (see OL-004). + - Compounding: this script is covered by neither the typecheck nor the lint gate (OL-016), so it is the one piece of repository code that runs in CI with write access and is checked by nothing. +- Files: + - `.github/workflows/auto-localize.yml` + - `extension/scripts/translator/index.js` + - `extension/src/public/_locales/*/messages.json` +- Success condition: a decision recorded on whether this pipeline should be able to fire at all in its current form, and either the workflow disarmed or gated, or its placeholder behaviour reconciled with the real translations now present in the tree. +- Closed by: `9191c15` (`refactor(product): reduce CTRL to the v1 public surface`) removed `.github/workflows/auto-localize.yml`, `extension/scripts/translator/index.js`, the `extract-i18n` script and all six non-English locales. The decision is recorded in `docs/release/v1/V1_SCOPE.md`: the multi-language claim is DELETE (v1), with an English-only listing, the partial locales removed, the `auto-localize` workflow removed, and proper localisation deferred to after launch. The pipeline is disarmed by removal. At the v1 publication candidate `.github/workflows/` contains only `ci.yml`, which declares no `permissions:` block and has no commit or push step, and the CI `package` job asserts that each package ships the `en` locale only. Any future localisation pipeline is a new decision, not a reopening of this one. + +## OL-016 + +- Title: Typecheck and lint are scoped to `src/` only — `scripts/` and `tests/` are covered by neither gate +- Status: Open +- Severity: Medium +- Why it matters: 2026-07-26 audit F28. `extension/tsconfig.json` sets `"include": ["src/**/*", ".wxt/types/**/*"]`, so `npm run compile` (`tsc --noEmit`, the CI test job's typecheck step) never typechecks `tests/`, `scripts/`, `wxt.config.ts`, `vitest.config.ts`, or `playwright.config.ts`. `extension/package.json` sets `"lint": "eslint src --ext .ts,.tsx"`, so the CI lint job never lints `tests/` or `scripts/` either. The sharp edge is that `extension/scripts/translator/index.js` runs in CI with `contents: write` and pushes commits to `main` (OL-015), and it is the one piece of repository code subject to neither gate. `tsconfig.json` additionally excludes a `"legacy"` directory that does not exist in the tree. Strictness for the code that *is* covered is not in question — all seven `strict*` flags are enabled and only `react/react-in-jsx-scope` is disabled repo-wide, which is correct for the modern JSX transform. +- Files: + - `extension/tsconfig.json` + - `extension/package.json` +- Success condition: CI-executed code and test code are covered by typecheck, by lint, or by an explicit recorded decision that they are intentionally excluded and why. +- Update (2026-09-10): `9191c15` removed `extension/scripts/translator/index.js` and the `auto-localize` workflow, so the sharp edge above (an unchecked script running in CI with write access) no longer exists (OL-015 closed). The scope gap itself is unchanged at the v1 publication candidate: `extension/tsconfig.json` `include` is still `src/**/*` and `.wxt/types/**/*`, `lint` is still `eslint src --ext .ts,.tsx`, and CI still executes `tests/` through `npm test`. Status remains Open. diff --git a/README.md b/README.md index 670e331..ea24fc3 100755 --- a/README.md +++ b/README.md @@ -1,90 +1,102 @@ # CTRL -> **Control torrent clients from your browser** +> **Send magnet links to your own BitTorrent client and control its queue from the browser.** -A browser extension for managing BitTorrent clients. Built with WXT, React, and TypeScript. +CTRL is a browser extension for Chrome and Firefox. It talks directly to a +torrent client you already run — on your machine, your NAS or a server you +control — and shows and controls that client's queue from the toolbar. -[![Chrome](https://img.shields.io/badge/Chrome-Coming_Soon-lightgrey?logo=googlechrome)](https://github.com/StarlightDaemon/CTRL/releases) -[![Firefox](https://img.shields.io/badge/Firefox-Coming_Soon-lightgrey?logo=firefox)](https://github.com/StarlightDaemon/CTRL/releases) [![CI](https://github.com/StarlightDaemon/CTRL/actions/workflows/ci.yml/badge.svg)](https://github.com/StarlightDaemon/CTRL/actions/workflows/ci.yml) -[![Tests](https://img.shields.io/badge/Tests-See%20CI-blue)](https://github.com/StarlightDaemon/CTRL/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) --- -## ✨ Features +## What it does -- **Universal Client Support** - Unified interface for all major BitTorrent clients (see list below). -- **Secure Vault** - Industry-standard AES-GCM encryption for your credentials. -- **Privacy First** - Zero telemetry, zero analytics, strictly local data storage. -- **Modern UI** - Native dark mode, context menus, and responsive design. -- **Multilingual** - Translated into 7 languages. -- **Open Source** - MIT licensed and completely auditable code. +- **Add** magnet links and torrent URLs from the toolbar popup or the right-click menu, optionally paused, with a label and a download folder. +- **See and control** the client's queue: pause, resume, remove (files are kept), live transfer speeds, a toolbar badge with the active count or download speed. +- **Several servers**: configure more than one client and switch between them. +- **Encrypted vault**: server addresses and logins are encrypted on your device with a master password (PBKDF2 + AES-GCM) and sent only to the client you configured. Nothing is sent to the CTRL developer; there is no telemetry. +- **Backup**: export your server list with or without passwords (clearly labelled) and import it elsewhere. -### 📦 Supported Clients +## Supported clients -| Client | Status | Key Features | -|--------|--------|--------------| -| qBittorrent | ✅ Full | Categories, Tags, Sequential Download | -| Transmission | ✅ Full | Labels, Directories | -| Deluge | ✅ Full | Labels, Auth | -| Flood | ✅ Full | Tags, JWT | -| ruTorrent | ✅ Full | Labels, Fast Resume | -| uTorrent | ✅ Full | Token Auth | -| BiglyBT | ✅ Full | Basic Control | -| Vuze | ✅ Full | Basic Control | -| Aria2 | ✅ Basic | RPC Multicall | +Support means the whole workflow — configure, grant access, sign in, list, add, add paused, pause, resume, remove, wrong credentials, server outage and restart — has been verified against a real server from both Chrome and Firefox. Details and evidence: [docs/release/v1/CLIENT_VERIFICATION.md](docs/release/v1/CLIENT_VERIFICATION.md). -This table is the public support matrix for implemented adapter types. Internal audit or stabilization priorities may temporarily focus on a subset of adapters without implying that the others were removed from the product. +| Client | Verified versions | Notes | +|---|---|---| +| **Transmission** (daemon / RPC) | 4.1.3 | Basic authentication | +| **qBittorrent** (Web UI) | 5.2.3 (Web API 2.11) | Works with qBittorrent's default CSRF protection left on | +| **aria2** (JSON-RPC) | 1.37.0 | RPC secret token; also used by Motrix | ---- +Adapters for Deluge, Flood, ruTorrent, µTorrent, BiglyBT and Vuze exist in the code but are **not offered** in this release: they are not verified. An existing configuration of one of them keeps working and is marked "experimental" in the server list. -## 🚧 Project Status +## Browsers -**Current Status**: Beta / Active Stabilization -**Target**: v1.0 Store Release +| Browser | Minimum | Verified on | +|---|---|---| +| Chrome (desktop) | 120 | 152 | +| Firefox (desktop) | 140 | 155 | -This project is currently in **Beta**. Use [docs/BETA_TESTING.md](docs/BETA_TESTING.md) as the public source of truth for current beta status, tester guidance, and validation-scope notes. +The extension is English-only in this release. -`ROADMAP.md` is strategic direction, not the live status page. +## Installation ---- +Store listings are being prepared. Until then, install a release package from [GitHub Releases](https://github.com/StarlightDaemon/CTRL/releases): -## 📥 Installation +- **Chrome**: unzip, open `chrome://extensions`, enable *Developer mode*, *Load unpacked*, select the unzipped folder. +- **Firefox**: open `about:debugging#/runtime/this-firefox`, *Load Temporary Add-on*, select the `.zip`. Temporary add-ons are removed when Firefox restarts. -> **Beta Release Available**: Download from [GitHub Releases](https://github.com/StarlightDaemon/CTRL/releases) - See [Beta Guide](docs/BETA_TESTING.md) for instructions. +## First use -1. **Download**: Get the latest `.zip` release. -2. **Install**: - - **Chrome**: Load unpacked in Developer Mode. - - **Firefox**: Load as Temporary Add-on. +1. Choose a master password. It encrypts your server logins on this device and cannot be recovered; forgetting it means resetting the vault (Settings → System). +2. Add your client: name, client type, the full address of its web interface (for example `http://192.168.1.10:9091/` or `https://nas.example/qbt/`), username and password. CTRL asks the browser for permission to contact that address. +3. *Test connection*, then *Save*. The toolbar popup now shows the queue; right-click a magnet link to add it. ---- +Plain `http://` to a server outside your local network is allowed but CTRL warns you: anyone on the path can read or alter the login and the commands. Prefer `https://` where the client or a reverse proxy offers it. + +## Permissions + +| Permission | Why | +|---|---| +| `storage` | your settings and the encrypted vault (local); the session key and queue snapshot (session storage) | +| `contextMenus` | the right-click "Add to CTRL" entries | +| `notifications` | optional "torrent added / failed" notifications (can be switched off) | +| `alarms` | a one-minute badge refresh while no CTRL window is open (only when the badge is enabled) | +| `declarativeNetRequestWithHostAccess` | a rule, limited to the qBittorrent server you configured, that sets the `Origin`/`Referer` headers of CTRL's own requests to that server so qBittorrent's default cross-site protection accepts them — without asking you to weaken your server. It affects no other site | +| host access (`http://*/*`, `https://*/*`, **optional**) | requested per server address, only when you add a server | + +The full data-handling description is in the [privacy policy](docs/PRIVACY_POLICY.md). -## 📖 Documentation +## Documentation | Document | Description | -|----------|-------------| -| [Beta Guide](docs/BETA_TESTING.md) | **Start Here** - Public beta status, installation, and testing guidance | -| [E2E Troubleshooting](docs/E2E_TROUBLESHOOTING.md) | Diagnose Playwright/Environment issues | -| [ROADMAP.md](ROADMAP.md) | Strategic direction, not live status | -| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Setup build environment | +|---|---| +| [docs/PRIVACY_POLICY.md](docs/PRIVACY_POLICY.md) | What is stored, what is transmitted, and to whom | +| [docs/release/v1/CLIENT_VERIFICATION.md](docs/release/v1/CLIENT_VERIFICATION.md) | Verified clients, evidence, known limits | +| [extension/BUILD.md](extension/BUILD.md) | Reproducible build instructions (also used for store source review) | +| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Developer setup, tests, live verification harness | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | How the pieces fit together | +| [extension/CHANGELOG.md](extension/CHANGELOG.md) | Changes per release | | [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution guidelines | ---- - -## 🛠️ Development +## Development -We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) to get started. +```bash +cd extension +npm ci +npm run dev # Chrome dev build with reload +npm run dev:firefox +npm test # unit and component tests +npm run build:chrome && npm run build:firefox +``` ---- +See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md), including how to run the live verification against real clients. -## 📄 License +## License MIT © CTRL Contributors ---- - -## 🙏 Acknowledgments +## Acknowledgments -Inspired by [Torrent Control](https://github.com/AthanasiusBrainworx/torrent-control). CTRL is a complete rewrite built from the ground up with modern technologies. +Inspired by [Torrent Control](https://github.com/AthanasiusBrainworx/torrent-control). CTRL is a complete rewrite. diff --git a/docs/API.md b/docs/API.md index 53a9f98..16ad5fc 100755 --- a/docs/API.md +++ b/docs/API.md @@ -78,6 +78,8 @@ type TorrentStatus = ## Supported Clients +> **v1 note (2026-09-09):** this table describes every adapter present in the code. Only **Transmission, qBittorrent and aria2** are offered in the v1 release and verified against real servers (`docs/release/v1/CLIENT_VERIFICATION.md`); the others are hidden and unverified. + | Adapter | Protocol | Auth | Categories | Tags | |---------|----------|------|------------|------| | **qBittorrent** | REST | Cookie | ✅ | ✅ | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0699809..5386934 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,74 +2,118 @@ ## Overview -CTRL is a cross-browser extension (Chrome/Firefox MV3) for managing torrent clients from the browser. - -## High-Level Architecture +CTRL is a Manifest V3 extension for Chrome and Firefox. One codebase (WXT + +React + TypeScript) produces both packages; the manifest differs only in +what each browser requires (Chrome: `minimum_chrome_version`; Firefox: the +`gecko` block with the permanent add-on id, `strict_min_version` and +`data_collection_permissions`). ``` -┌─────────────────────────────────────────────────────────────┐ -│ Browser Extension │ -├─────────────┬──────────────┬──────────────┬─────────────────┤ -│ Popup │ Options │ Content │ Background │ -│ (React) │ (React) │ Scripts │ (Service │ -│ │ │ (7 sites) │ Worker) │ -└──────┬──────┴──────┬───────┴──────┬───────┴────────┬────────┘ - │ │ │ │ - └─────────────┴──────────────┴────────────────┘ - │ - chrome.runtime.sendMessage - │ - ┌─────────────┴─────────────┐ - │ Background Service │ - │ - Client Factory │ - │ - Vault (Encryption) │ - │ - Context Menus │ - └─────────────┬─────────────┘ - │ - ┌─────────────────┼─────────────────┐ - │ │ │ - ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ - │qBittorrent│ │Transmission│ │ Deluge │ - │ Adapter │ │ Adapter │ │ Adapter │ - └───────────┘ └────────────┘ └───────────┘ +┌──────────────────────────── extension ────────────────────────────┐ +│ popup.html options.html background │ +│ (React, Carbon) (React, Carbon) (service worker / │ +│ │ │ event page) │ +│ └──── runtime port ───┴──── messages ──────► TorrentController│ +│ │ ├ ClientFactory ─► adapter (Transmission | qBittorrent | aria2 | hidden ones) +│ │ ├ ServerResolver ─► VaultService (encrypted servers) + settings +│ │ ├ HeaderRewriter ─► declarativeNetRequest session rule (qBittorrent only) +│ │ └ StateHydrator ─► storage.session snapshot +│ └ badge, context menus, alarms +└────────────────────────────────────────────────────────────────────┘ + │ fetch (only to the configured client address, after the host grant) + ▼ + the user's torrent client ``` -## Key Patterns +There are no content scripts, no web-accessible resources, no remote code +and no third-party network requests. -### Feature-Sliced Design (FSD) +## Background: `TorrentController` -``` -entrypoints/ → features/ → entities/ → shared/ - ↓ ↓ ↓ ↓ - UI Entry Business Domain Utilities - Points Features Models & APIs -``` +The controller is the single owner of queue state. Invariants (all +runtime-verified in `docs/release/v1/evidence/state-vault-*.md`): -### Client Adapter Pattern +- **One poll in flight**; additional refresh requests coalesce. +- **Generation checks**: every poll captures the generation it started in. + A server switch, vault lock or settings change bumps it, and a result from + an older generation is discarded — a slow reply from server A is never + shown as server B's queue. +- **Id-keyed snapshots**: the UI addresses rows by torrent id, never by + position, so reordering, insertion and removal on the server cannot make a + row show another torrent's data. +- **Commands carry the server id** of the row they came from and are routed + to that server's client, whatever the active server is; a command for a + server that no longer exists fails closed. +- **Truthful connection state** (`ConnectionState`): `uninitialized`, + `locked`, `vault_corrupted`, `no_servers`, `invalid_config`, + `permission_missing` (with a `PERMISSION_REVOKED` discriminant when the + browser removed the grant), `connecting`, `connected`, `stale`, + `unavailable`, `auth_failed`. Settled outcomes stay on screen while a + retry runs; after `auth_failed` automatic polling stops until the settings + change or the user asks, so a wrong password fails once instead of + hammering (and getting banned by) the server. +- Each subscriber (popup, every options window) opens a port, declares the + viewport it renders and receives id-keyed snapshots; holding a port + switches the background to fast polling; with no UI open a one-minute + alarm refreshes the badge only if the badge is enabled. -All torrent clients implement `ITorrentClient` interface: -- `login()`, `logout()` -- `getTorrents()`, `addTorrentUrl()`, `removeTorrent()` -- `pauseTorrent()`, `resumeTorrent()` +## Adapters and transport -### Vault Security +Every client implements `ITorrentClient` (`login`, `getTorrents`, +`addTorrentUrl`, `pauseTorrent`, `resumeTorrent`, `removeTorrent`, +`testConnection`, `classifyError`, …). Adapters map their protocol's errors +to typed `AdapterError`s so the controller can tell an authentication +failure from an outage. -Credentials encrypted at rest using: -- PBKDF2 key derivation -- AES-GCM encryption -- Session-based key storage +- `FetchHttpClient` is the browser-correct transport: forbidden headers + (`Origin`, `Referer`, `Cookie`) are never set, cookie sessions use + `credentials: 'include'` (qBittorrent) and everything else `omit`. +- `HeaderRewriter` installs a `declarativeNetRequestWithHostAccess` session + rule for each configured qBittorrent origin that sets `Origin`/`Referer` + to that origin, because qBittorrent's default cross-site check rejects the + extension origin the browser stamps on requests. The rule is scoped to + that one origin and to hosts the user granted. +- Host permissions are optional and granted per server address. Chrome gets + `http://host:port/*`; Firefox `http://host/*` (its match patterns have no + port support and a port-qualified grant does not exempt fetches from CORS). -## Tech Stack +The v1 adapters offered in the UI are Transmission, qBittorrent and aria2 +(`CLIENT_LIST` in `shared/lib/constants.ts`, `v1Status: 'candidate'`); the +other adapters remain loadable for existing configurations but are hidden. -| Layer | Technology | -|-------|------------| -| Framework | WXT | -| UI | React 18, TypeScript | -| Styling | Tailwind CSS | -| State | Zustand, React Query | -| Validation | Zod | -| Testing | Vitest, Playwright | +## Vault + +Servers (address, username, password, options) live in one authenticated +envelope in `storage.local` (`vault`: version, PBKDF2 parameters, salt, IV, +ciphertext, revision). The key is derived from the master password +(PBKDF2-SHA256, 300,000 iterations) and kept only in `storage.session` +while unlocked, so a browser restart locks the vault. Writes carry a revision +so a stale window cannot silently overwrite a newer one. Incomplete or +malformed material is reported as corrupted and never unlocks; the only way +forward is an explicit, acknowledged reset. The pre-envelope format +(`vaultSalt` + `vaultData`) is migrated on the first successful unlock. -## Directory Structure +## UI -See [PROJECT_SOP.md](PROJECT_SOP.md) for complete structure. +Popup and options pages are React with IBM Carbon components (Tailwind only +for layout tokens). The server form is a single complete-URL field backed by +`parseEndpoint` (IPv6, ports and reverse-proxy sub-paths round-trip), with +labels, stable ids, live-region results, the credential disclosure and the +plain-HTTP warning. No native `alert`/`confirm` dialogs are used. + +## Packaging + +`wxt.config.ts` generates both manifests; PostCSS strips remote `@font-face` +rules so the package makes no third-party requests; builds are deterministic +(verified by CI building twice). `scripts/zip-source.ts` produces the +reviewer source archive from a clean git tree. See `extension/BUILD.md`. + +## Tech stack + +| Layer | Technology | +|---|---| +| Framework | WXT 0.20 | +| UI | React 18, TypeScript, IBM Carbon, Tailwind (layout) | +| State | Zustand (UI mirror of the background snapshot) | +| Validation | Zod | +| Tests | Vitest + React Testing Library; Playwright smoke; live harness (puppeteer-core for Chrome, selenium-webdriver for Firefox) | diff --git a/docs/BETA_TESTING.md b/docs/BETA_TESTING.md index 1bac508..49fe4ae 100755 --- a/docs/BETA_TESTING.md +++ b/docs/BETA_TESTING.md @@ -1,310 +1,80 @@ -# 🧪 CTRL Beta Program - -⚠️ **BETA SOFTWARE** - This is pre-release software for testing purposes. - -**Version**: v0.2.0-beta.1 -**Release Date**: January 2026 -**Status**: Public Beta Testing - -Authority note: This document is the public source of truth for current beta status, tester guidance, and validation-scope notes. `README.md` is the overview page, and `ROADMAP.md` is strategic direction rather than live status. - ---- - -## What is CTRL? - -CTRL (Torrent Control) is a browser extension that provides a **unified interface for managing BitTorrent clients** directly from your browser. Send magnet links to your torrent client with one click, monitor downloads, and manage your queue without leaving your browser. - ---- - -## 🎯 What's Working in Beta - -### ✅ Fully Functional Features - -#### Torrent Client Support (9 Clients) -- ✅ **qBittorrent** - Full support with categories, tags, and sequential download -- ✅ **Transmission** - Session management and directory support -- ✅ **Deluge** - Multi-step authentication and label support -- ✅ **Flood** - JWT authentication and tag management -- ✅ **ruTorrent** - XML-RPC support with fast resume -- ✅ **µTorrent** - Token-based auth with bitmask status -- ✅ **BiglyBT** - Basic operations -- ✅ **Vuze** - Basic operations -- ✅ **Aria2** - JSON-RPC multicall support - -This list mirrors the public adapter matrix in `README.md`. Internal audit or stabilization priorities may focus on a subset of adapters without changing the product-level support matrix shown here. - -#### Site Integrations -- ⛔ **Removed from CTRL** - Site-specific integrations were moved to a separate extension to keep CTRL focused and avoid a "kitchen sink" product scope. -- ✅ **Supported alternative in CTRL** - Use Context Menu (Right-Click) integration instead. - - -#### Core Features -- ✅ **Multi-Server Support** - Manage multiple clients, switch instantly -- ✅ **Secure Vault** - AES-GCM encrypted credential storage -- ✅ **Queue Management** - Pause, resume, remove torrents -- ✅ **Download Monitoring** - Real-time speed and progress -- ✅ **Context Menu Integration** - Right-click to add magnets -- ✅ **Notifications** - Completion alerts -- ✅ **Theming** - Dark Mode, Light Mode, Low Power, Cyberpunk, Linux, Glass, Linear -- ✅ **Internationalization** - 7 languages (en, de, es, fi, fr, ru, zh_CN) -- ✅ **Import/Export** - Backup and restore settings - ---- - -## ⚠️ Known Limitations - -### Technical Debt -- **No site-specific integrations in CTRL**: Site-integration work now belongs to the separate extension, not this repository's product scope. - - -### Not Implemented Yet -- **E2E Testing**: Playwright-based end-to-end tests exist under `./tests/e2e`. CI runs non-`@integration` tests only (`npm run test:e2e -- --grep-invert "@integration"`). Full E2E coverage is not yet claimed. -- **Performance Optimization**: Diffing engine for large torrent lists (>5,000 torrents) not yet implemented -- **Advanced i18n**: Build-time transformation pipeline planned - -### Browser Support -- ✅ **Chrome/Edge**: Manifest V3, fully tested -- ✅ **Firefox**: Manifest V3, fully tested -- ❌ **Safari**: Not supported (requires native app) -- ❌ **Mobile**: Desktop browsers only (mobile browsers have limited extension support) - ---- - -## 📦 How to Install (Beta) - -### Method 1: From GitHub Releases (Recommended) - -1. **Download the Extension** - - Visit [Releases](https://github.com/StarlightDaemon/CTRL/releases) - - Download `ctrl-chrome-v0.2.0-beta.1.zip` (for Chrome/Edge) - - OR download `ctrl-firefox-v0.2.0-beta.1.zip` (for Firefox) - - Extract the ZIP file - -2. **Install in Chrome/Edge** - - Open `chrome://extensions/` (or `edge://extensions/`) - - Enable "Developer mode" (toggle in top-right) - - Click "Load unpacked" - - Select the extracted folder - - ✅ Extension will appear in your toolbar - -3. **Install in Firefox** - - Open `about:debugging#/runtime/this-firefox` - - Click "Load Temporary Add-on" - - Navigate to extracted folder - - Select `manifest.json` - - ✅ Extension will appear in your toolbar - - **Note**: Temporary add-ons in Firefox are removed when browser restarts - -### Method 2: Build from Source - -```bash -# Clone repository -git clone https://github.com/StarlightDaemon/CTRL.git -cd CTRL/extension - -# Install dependencies -npm install - -# Build for Chrome -npm run build:chrome - -# OR build for Firefox -npm run build:firefox - -# Load from builds/chrome-mv3/ or builds/firefox-mv3/ -``` - ---- - -## 🚀 Quick Start Guide - -### Step 1: Configure Your First Client - -1. Click the CTRL icon in your toolbar -2. Click **"Add Server"** or go to Options -3. Enter your torrent client details: - - **Name**: "Home qBittorrent" (or whatever you prefer) - - **Type**: Select your client (qBittorrent, Transmission, etc.) - - **URL**: Your client's WebUI URL (e.g., `http://localhost:8080`) - - **Username**: Your WebUI username - - **Password**: Your WebUI password -4. Click **"Test Connection"** -5. If successful, click **"Save"** - -### Step 2: Add a Torrent (Example with Ubuntu ISO) - -**Option A: From a Torrent Site** -1. Visit any torrent site -2. Find a magnet link -3. Right-click the link -> "Add to [Client]" -4. ✅ Torrent starts downloading! - - -**Option B: Right-Click Context Menu** -1. Right-click any magnet link on any webpage -2. Select **"Add to qBittorrent"** (or your configured client) -3. ✅ Torrent starts downloading! - -### Step 3: Monitor Downloads - -1. Click the CTRL icon -2. See your active torrents, speeds, and progress -3. Use controls to pause, resume, or remove torrents - ---- - -## 🐛 Known Issues & Workarounds - -### Issue: "Connection Failed" Error -**Cause**: Client WebUI not accessible or wrong URL -**Fix**: -1. Verify your client's WebUI is running -2. Check URL format: `http://192.168.1.100:8080` (include `http://`) -3. Try accessing the WebUI URL directly in your browser -4. Check firewall/network settings - -### Issue: "Authentication Failed" Error -**Cause**: Wrong username/password -**Fix**: -1. Verify credentials by logging into WebUI manually -2. Re-enter credentials in CTRL settings -3. For qBittorrent: Ensure "Bypass authentication for localhost" is OFF - -### Issue: Extension Icon Greyed Out -**Cause**: No active server configured -**Fix**: Add and test a server in Options - - ---- - -## 🧪 What We Need from Beta Testers - -### Critical Testing Areas - -1. **Multi-Client Compatibility** - - Test with your specific torrent client version - - Report any authentication issues - - Verify torrent operations work (add, pause, resume, remove) - -2. **Context Menu Reliability** - - Test adding magnet links through the right-click context menu - - Report missing menu entries or wrong client targeting - - Check behavior across different sites and link types - -3. **Cross-Browser Testing** - - Test on Chrome, Edge, and Firefox - - Report browser-specific bugs - -4. **Edge Cases** - - Large torrent lists (>100 torrents) - - Slow/unreliable network connections - - Multiple servers switching rapidly - -### How to Report Bugs - -**GitHub Issues**: https://github.com/StarlightDaemon/CTRL/issues - -**Please include**: -1. **Browser**: Chrome/Edge/Firefox + version -2. **Extension Version**: v0.2.0-beta.1 -3. **Torrent Client**: Type + version (e.g., "qBittorrent 4.6.2") -4. **Steps to Reproduce**: Detailed steps to trigger the bug -5. **Expected Behavior**: What should happen -6. **Actual Behavior**: What actually happens -7. **Screenshots**: If applicable -8. **Console Errors**: Open DevTools, check for red errors - ---- - -## 📊 Beta Testing Goals - -### Success Criteria for v1.0 Release - -- [ ] **10+ active beta testers** providing feedback -- [ ] **<5 critical bugs** discovered -- [ ] **All 9 clients** verified working -- [ ] **Positive user feedback** on core functionality -- [ ] **No data loss** or credential security issues - -### Roadmap to v1.0 - -**Next Release: v0.3.x** (Timing TBD) -- ✅ Continue post-beta stabilization and adapter hardening -- ✅ Maintain Playwright E2E infrastructure (CI runs non-@integration subset) -- ✅ Continue performance benchmarking and tuning - -**Production Release: v1.0** (Timing TBD) -- ✅ Chrome Web Store submission -- ✅ Firefox AMO submission -- ✅ Code signing for installers -- ⬜ Full E2E test coverage (not yet achieved) -- ✅ Accessibility score >90 - ---- - -## 🔒 Privacy & Security - -**CTRL does NOT collect any data.** - -- ✅ No analytics or tracking -- ✅ No telemetry -- ✅ No external servers (except your torrent clients) -- ✅ Credentials encrypted with AES-GCM locally -- ✅ Open source - code is auditable - -**Privacy Policy**: [View Full Policy](PRIVACY_POLICY.md) - ---- - -## 📝 Changelog (v0.2.0-beta.1) - -### New Features -- 🎉 First public beta release -- ✅ Multi-server management -- ✅ 9 torrent client adapters -- ✅ Encrypted credential vault -- ✅ 7 language translations -- ⛔ **Site Integrations**: Removed for stability and store compliance. - -### Testing -- ✅ Unit and adapter test suites are part of the maintained validation baseline -- ✅ Playwright E2E tests configured (CI runs non-@integration subset) - - ---- - -## 💬 Community & Support - -**Questions?** Open a [Discussion](https://github.com/StarlightDaemon/CTRL/discussions) -**Bugs?** Create an [Issue](https://github.com/StarlightDaemon/CTRL/issues) - ---- - -## 📚 Additional Resources - -- [Main README](../README.md) - Project overview -- [ROADMAP](../ROADMAP.md) - Strategic direction -- [CONTRIBUTING](../CONTRIBUTING.md) - How to contribute -- [Privacy Policy](PRIVACY_POLICY.md) - Full privacy details - ---- - -## ⚖️ Legal Disclaimer - -CTRL is a **remote control utility** for BitTorrent clients. It does not provide, host, index, or distribute any files, media, or content. Users are solely responsible for the content they choose to transfer using their local torrent clients. - -BitTorrent is a legitimate protocol used for distributing open-source software, public domain content, and other legal files. CTRL developers do not endorse or encourage copyright infringement. - ---- - -## ❤️ Thank You, Beta Testers! - -Your feedback is invaluable in making CTRL a production-ready extension. Thank you for being part of the beta program! - -**Happy Testing! 🚀** - ---- - -*CTRL v0.2.0-beta.1* -*Released: January 2026* -*Next Release: v0.3.x (Timing TBD)* +# CTRL — testing guide (pre-release) + +This page is the public status of CTRL before its store release: what it +supports, what has been verified, how to install a release package, and how +to report problems. It replaces earlier beta notes that described features +no longer in the product. + +## Status + +- Version: `0.2.0-beta.1` (manifest `0.2.0.1`) +- Target: v1.0 on the Chrome Web Store and Firefox Add-ons; not yet submitted +- Clients offered: **Transmission**, **qBittorrent**, **aria2** — each verified end-to-end in Chrome 152 and Firefox 155 against a real server (see [release/v1/CLIENT_VERIFICATION.md](release/v1/CLIENT_VERIFICATION.md)) +- Browsers: Chrome 120+ (desktop), Firefox 140+ (desktop) +- Language: English + +Not in this release: Deluge, Flood, ruTorrent, µTorrent, BiglyBT and Vuze +(adapters exist but are unverified and hidden; an existing configuration +keeps working and is labelled experimental), page scanning for magnet links, +themes, download-completion notifications, translations. + +## Install a release package + +1. Download the package for your browser from [GitHub Releases](https://github.com/StarlightDaemon/CTRL/releases). +2. Chrome: unzip; `chrome://extensions` → Developer mode → *Load unpacked* → select the folder. +3. Firefox: `about:debugging#/runtime/this-firefox` → *Load Temporary Add-on* → select the `.zip`. Temporary add-ons disappear when Firefox restarts (this is a Firefox rule for unsigned add-ons; the store version will not have it). + +## Set up + +1. Open the toolbar popup or the options page and choose a **master + password**. It encrypts your server logins on this device. It cannot be + recovered; if you forget it, reset the vault from Settings → System (this + deletes the saved servers). +2. Settings → Servers → **Add server**: name, client type, the full address + of the client's web interface (for example `http://192.168.1.10:9091/`, + `http://nas.local:8080/`, `https://home.example/qbt/`), username and + password (aria2: put the RPC secret in the password field). +3. **Grant access** when asked — the browser needs your permission to let + CTRL contact that address. +4. **Test connection**, then **Save**. + +Client-side notes: + +- qBittorrent: leave *CSRF protection* and *Host header validation* at their + defaults; CTRL works with them on. Make sure the Web UI is enabled. +- Transmission: enable the RPC interface and authentication; CTRL uses the + standard `…/transmission/rpc` path under the address you enter. +- aria2: start it with `--enable-rpc` and, if you use `--rpc-secret`, enter + that token as the password. +- Plain `http://` to an address outside your local network is allowed but + CTRL warns you that logins and commands can be read in transit. + +## What to try + +- Add a magnet link from the popup and from the right-click menu; with + "Add torrents paused" on, check it arrives paused. +- Pause, resume and remove torrents from the options dashboard. +- Enter a wrong password: Test connection must fail and the dashboard must + say *Authentication failed* rather than pretend to be connected. +- Stop the client: the dashboard must say *Connection lost*; start it again + and CTRL must reconnect on its own. +- Lock CTRL, restart the browser: it must ask for the master password. +- Export the server list (safe export) and confirm the file contains no + password; the full export says it contains secrets. + +## Reporting a problem + +Open a [GitHub issue](https://github.com/StarlightDaemon/CTRL/issues) with: + +1. browser and version; +2. client and version, and how CTRL is pointed at it (address form, HTTP or HTTPS, reverse proxy or not); +3. what you did, what you expected, what happened (the status text CTRL showed); +4. anything from the client's log around that time. + +Never paste passwords, tokens or a full export. + +## Legal + +CTRL is a remote control for BitTorrent clients. It does not provide, host, +index or distribute files. Users are responsible for the content they +transfer with their own clients. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index fc5783e..5cd5141 100755 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -2,87 +2,105 @@ ## Prerequisites -- Node.js 20+ (see `.nvmrc`) -- npm 10+ +- Node.js 24 (see `.nvmrc`; 22 also works) and npm 11 +- Chrome and/or Firefox for manual testing +- For the live verification harness (optional): Windows, 7-Zip, and the + installed release browsers — see `extension/tests/live/README.md` -## Quick Start +## Quick start ```bash -# Clone and install cd extension -npm install - -# Development server (Chrome) -npm run dev - -# Development server (Firefox) -npm run dev:firefox +npm ci # exact dependencies from package-lock.json +npm run dev # WXT dev build for Chrome with reload +npm run dev:firefox # same for Firefox ``` ## Commands | Command | Description | -|---------|-------------| -| `npm run dev` | Start dev server (Chrome) | -| `npm run dev:firefox` | Start dev server (Firefox) | -| `npm run build` | Build both browsers | -| `npm run build:chrome` | Build Chrome extension | -| `npm run build:firefox` | Build Firefox extension | +|---|---| | `npm run compile` | TypeScript type check | -| `npm run test` | Run unit tests | -| `npm run test:e2e` | Run E2E tests | +| `npm run lint` | ESLint over `src` | +| `npm test` | Vitest unit and component tests (jsdom, React Testing Library) | +| `npm run build:chrome` / `build:firefox` | Production builds into `builds/chrome-mv3` and `builds/firefox-mv3` | +| `npm run zip:chrome` / `zip:firefox` | Build and zip a package | +| `npm run build-for-amo` | The Firefox package exactly as submitted (used by Mozilla's rebuild) | +| `npm run zip:source` | Reviewer source archive from a clean git tree (`builds/source/`) | +| `npm run test:e2e` | Playwright smoke tests (Chromium) | +| `npm run live:env …` | Disposable torrent-client environment (see below) | +| `npm run live:verify …` | Live client × browser verification matrix | + +Reproducible-build details for reviewers: [`extension/BUILD.md`](../extension/BUILD.md). -## Project Structure +## Project structure ``` extension/src/ -├── app/ # App configuration -├── entrypoints/ # WXT entry points -├── features/ # Feature modules (FSD) -├── entities/ # Domain models (FSD) -└── shared/ # Shared utilities (FSD) +├── app/styles # single stylesheet (Carbon + Tailwind tokens; remote fonts stripped at build time) +├── entrypoints/ # background (controller, badge, context menus), options, popup +├── features/ # torrent-control: UI (server form, dashboard, settings), model (settings, vault, forms), services (TorrentController) +├── entities/ # domain models: client factory, server identity, torrent +└── shared/ # adapters (api/clients), transport (api/network), vault (api/security), messaging protocol, lib ``` -See [PROJECT_SOP.md](./PROJECT_SOP.md) for complete structure. +Key modules: -## Development Workflow +- `features/torrent-control/services/TorrentController.ts` — the single owner of background state: polling with generation checks, id-keyed snapshots, per-server command routing, connection state. +- `shared/api/messaging/protocol.ts` — the port and message protocol between background and UI. +- `shared/api/security/VaultService.ts` — encrypted server vault (PBKDF2 + AES-GCM envelope), legacy migration, fail-closed handling. +- `shared/lib/endpoint.ts` and `features/torrent-control/model/serverForm.ts` — address parsing and the server form model. +- `shared/lib/permissions.ts` — optional host permission patterns (Chrome keeps the port, Firefox cannot). +- `shared/api/network/HeaderRewriter.ts` — the per-server `Origin`/`Referer` rule qBittorrent needs. +- `shared/lib/constants.ts` — `CLIENT_LIST` with the v1 status of each adapter (`candidate` = offered, `experimental` = hidden but loadable). -1. Create feature branch: `git checkout -b feature/my-feature` -2. Make changes following FSD patterns -3. Run `npm run compile` to check types -4. Run `npm run test` to verify tests -5. Build and test: `npm run build:chrome` -6. Submit PR with conventional commit messages +## Loading a build manually -## Loading the Extension +- **Chrome**: `chrome://extensions` → Developer mode → Load unpacked → `extension/builds/chrome-mv3`. +- **Firefox**: `about:debugging#/runtime/this-firefox` → Load Temporary Add-on → `extension/builds/firefox-mv3/manifest.json`. -### Chrome -1. Navigate to `chrome://extensions` -2. Enable "Developer mode" -3. Click "Load unpacked" -4. Select `extension/builds/chrome-mv3` +Debugging: background service worker from `chrome://extensions` (Chrome) or `about:debugging` (Firefox); popup via right-click → Inspect. -### Firefox -1. Navigate to `about:debugging` -2. Click "This Firefox" -3. Click "Load Temporary Add-on" -4. Select `extension/builds/firefox-mv3/manifest.json` +## Tests + +### Unit and component tests + +```bash +cd extension +npm test +``` -## Debugging +Vitest with jsdom and `@webext-core/fake-browser`. Component tests use React +Testing Library. Notes: Carbon `TextInput` renders an empty `role="alert"` +announcer (query notifications by text), Carbon danger buttons carry a +hidden "danger" prefix in their accessible name, and `chrome.permissions` / +`runtime.sendMessage` are stubbed per test (`tests/unit/ui/browserStubs.ts`). -- **Background Script**: Chrome DevTools → Extensions → Inspect service worker -- **Popup**: Right-click popup → Inspect -- **Content Scripts**: Open DevTools on the target page +### Live verification (real browsers, real clients) -## Testing +`extension/tests/live/` contains a disposable environment (upstream +Transmission, qBittorrent and aria2 binaries, pinned by hash, run with +temporary configuration) and two runners that drive the built extension in +stock Chrome and Firefox: ```bash -# Unit tests (Vitest) -npm run test +cd extension +npm run build:chrome && npm run build:firefox +node tests/live/env.mjs fetch && node tests/live/env.mjs extract && node tests/live/env.mjs start all +node tests/live/verify.mjs --client transmission --browser chrome # client matrix (16 scenarios) +node tests/live/verify-state.mjs --browser firefox --headless # state/vault invariants (17 scenarios) +node tests/live/env.mjs clean +``` -# Watch mode -npm run test -- --watch +Evidence lands in `docs/release/v1/evidence/`. See `extension/tests/live/README.md` +for provenance, ports, credentials, the browser harness design and its limits. -# E2E tests (Playwright) -npm run test:e2e -``` +## Workflow + +1. Branch from `main`. +2. Keep changes narrow; add or update tests with the change. +3. `npm run compile && npm run lint && npm test`, then build both targets. +4. If adapter, transport or controller code changed, re-run the affected live + matrix cells and refresh the evidence. +5. Conventional commit messages; no `Co-Authored-By` trailers (enforced by the + repository's commit hook). diff --git a/docs/PRIVACY_POLICY.md b/docs/PRIVACY_POLICY.md index 94f8934..dc12bc1 100755 --- a/docs/PRIVACY_POLICY.md +++ b/docs/PRIVACY_POLICY.md @@ -1,173 +1,136 @@ -# Privacy Policy +# CTRL Privacy Policy + +**Last updated**: September 2026 (applies to CTRL 0.2.0-beta.1 and later) + +CTRL is a browser extension that sends magnet links and torrent URLs to a +BitTorrent client you run yourself and shows and controls that client's +queue. This policy describes exactly what CTRL stores, what it transmits, to +whom, and what it never does. It is written to match the extension's +behaviour as verified in the release program; the source is public. + +## The short version + +- CTRL has **no servers of its own**, no analytics, no telemetry, no crash + reporting and no advertising. The CTRL developer receives nothing from the + extension. +- The only network destination is the **torrent client address you + configured**. CTRL sends that client your login (to sign in) and the + torrent links and commands you issue, and reads the queue back. +- Your server addresses, usernames and passwords are **encrypted on your + device** with a master password before being stored, and are only ever + sent to the client they belong to. + +## What CTRL stores on your device + +All storage uses the browser's extension storage; nothing leaves the device +by being stored. -**Last Updated**: April 2026 -**Effective Date**: January 2026 +| Data | Where | Encrypted | Lifetime | +|---|---|---|---| +| Server list: name, client type, address, username, password, per-server options | `storage.local`, key `vault` | Yes — AES-GCM with a key derived from your master password (PBKDF2-SHA256, 300,000 iterations, random salt). The master password itself is never stored. | Until you remove the server, reset the vault or uninstall | +| Preferences: context-menu mode, add-paused default, advanced-add dialog, notifications on/off, labels, default server, badge mode | `storage.local` | No (they contain no secrets) | Until changed or uninstall | +| Session key (the derived encryption key while the vault is unlocked) | `storage.session` (memory-backed, cleared when the browser closes) | Held by the browser in memory | Until you lock CTRL or the browser closes — after a browser restart CTRL is locked and asks for the master password | +| Queue snapshot: the last torrent list received from your client (names, sizes, progress, speeds, ids) | `storage.session` | No | Browser session; discarded when the active server changes | ---- +Locking CTRL (toolbar or settings) discards the session key in every window +at once. A damaged vault is never "half-opened": CTRL refuses to unlock and +offers a reset that deletes the stored servers. + +## What CTRL transmits, and to whom + +CTRL makes network requests **only to the torrent client addresses you +configured**, and only after you granted the browser's host permission for +that address. + +| What | When | To | +|---|---|---| +| Username and password (Transmission: HTTP Basic; qBittorrent: login form; aria2: RPC secret) | When signing in, testing a connection, or re-authenticating after a session expired | The configured client only | +| Magnet links / torrent URLs you add, with the options you chose (paused, label, folder) | When you add a torrent | The configured client only | +| Queue commands (pause, resume, remove) and queue polling requests | While a CTRL window is open (every few seconds) and, if the toolbar badge is enabled, about once a minute in the background | The configured client only | + +Nothing is sent to the CTRL developer or to any third party. CTRL does not +read web pages, does not inject anything into websites, and does not react to +page loads; the right-click menu acts only on the link you right-clicked. + +### Unencrypted connections + +If you configure a client with `http://` rather than `https://`, the login +and the commands travel unencrypted. That is a normal choice on a private +network and CTRL allows it, but it warns you when the address is outside +loopback and private ranges, because anyone on the network path could read or +alter that traffic. Use `https://` wherever the client or a reverse proxy +offers it. + +### qBittorrent and the `Origin` header + +Browsers label every request an extension makes with the extension's own +origin, which qBittorrent's default cross-site protection rejects. To work +with qBittorrent without asking you to switch that protection off, CTRL +installs a browser rule (`declarativeNetRequestWithHostAccess`) that applies +**only to the qBittorrent server address you configured** and sets the +`Origin` and `Referer` headers of CTRL's own requests to that server's +address. The rule touches no other site and reads no traffic. It exists for +the current browser session and only for hosts you granted access to. + +## Backups and exports + +- **Safe export** (default) contains server names, types, addresses and + options — never passwords — and is marked `containsSecrets: false`. +- **Full export** contains passwords in plain text so it can be imported + elsewhere. It is marked `containsSecrets: true` and labelled as such in the + interface. Keep such a file private. +- Exports are files saved by your browser; CTRL does not upload them. + +## Permissions + +| Permission | Purpose | +|---|---| +| `storage` | the encrypted vault, preferences, the session key and the queue snapshot | +| `contextMenus` | the right-click entries | +| `notifications` | optional local notifications when a torrent was added or adding failed (switchable in Settings) | +| `alarms` | the one-minute background badge refresh (only while the badge is enabled) | +| `declarativeNetRequestWithHostAccess` | the per-server header rule described above | +| Optional host access (`http://*/*`, `https://*/*`) | granted by you per server address when you add a server; used only to reach that client | + +Firefox shows this in the add-on's data-collection consent as +*authentication information*: CTRL sends your client login to the client you +configured (Mozilla's taxonomy counts any transmission outside the extension, +even to your own server). + +## What CTRL never does + +- collect or transmit browsing history, page content, IP addresses or any + personal information; +- send torrent links, hashes or queue contents anywhere other than your + configured client; +- use analytics, telemetry, crash reporting or advertising; +- load remote code or remote resources (fonts are the system's; the package + makes no third-party requests). + +## Your control + +- Change or remove servers at any time in Settings → Servers. +- Lock CTRL to discard the session key immediately. +- Reset the vault (Settings → System) to delete every stored server and the + master password from the device. +- Uninstalling the extension removes all its stored data. + +## Children -## Introduction +CTRL does not knowingly collect any information from anyone. -CTRL (Torrent Control) is a browser extension that provides a unified interface for managing BitTorrent clients directly from your browser. This privacy policy explains how CTRL handles your data. +## Changes -## Data Collection +Changes to this policy are published here with a new "last updated" date. + +## Contact and source -**CTRL does not collect, store, or transmit any personal data to external servers.** +- Source code: https://github.com/StarlightDaemon/CTRL +- Questions: https://github.com/StarlightDaemon/CTRL/discussions +- Issues: https://github.com/StarlightDaemon/CTRL/issues -We believe in privacy by design. All data processing occurs locally on your device, and nothing is sent to us or any third-party services. +## Legal note -## What Data is Stored Locally - -CTRL stores the following data **locally** on your device using your browser's built-in storage API (`chrome.storage.local` or `browser.storage.local`): - -### 1. Torrent Client Credentials -- **What**: Server URLs, usernames, and passwords for your torrent clients -- **Purpose**: To connect to and manage your torrent clients (qBittorrent, Transmission, Deluge, etc.) -- **Security**: All credentials are encrypted using AES-GCM encryption before being stored locally -- **Location**: Browser's local storage on your device only -- **Transmission**: Credentials are never transmitted outside your device - -### 2. User Preferences -- **What**: Your settings and preferences (theme, language, notification settings, etc.) -- **Purpose**: To provide a personalized experience -- **Location**: Browser's local storage on your device only - -### 3. Extension State -- **What**: Active server selection, UI state, feature toggles -- **Purpose**: To maintain your workspace across browser sessions -- **Location**: Browser's local storage on your device only - -## What Data is NOT Collected - -CTRL explicitly does **NOT** collect, store, or transmit: - -- ❌ Browsing history -- ❌ Personal information (name, email, address) -- ❌ Torrent content or metadata -- ❌ Magnet links or torrent hashes to external servers -- ❌ Usage analytics or telemetry -- ❌ Crash reports -- ❌ IP addresses -- ❌ Any data to advertising networks - -## How CTRL Works - -### User-Initiated Actions -CTRL operates only when you explicitly interact with it. It does not automatically modify web pages or background-monitor your browsing activity. - -**How you interact with CTRL**: -1. **Context Menus**: You can right-click on a magnet link or a page to send torrents directly to your client. -2. **Scan Page**: You can manually trigger a "Scan Page for Magnets" action from the context menu. This generically scans the current page for magnet links (`magnet:?xt=...`) and adds them to your selected client. -3. **Manual Addition**: You can add torrents by pasting URLs or magnet links directly into the extension popup. - -**What happens**: -1. When you initiate an action, CTRL processes the request **locally**. -2. If scanning a page, it identifies links matching the magnet protocol. -3. The link is sent directly to **your configured torrent client** (on your local network or remote server). - -**What does NOT happen**: -- CTRL does not automatically inject UI components or buttons into websites. -- CTRL does not detect magnet links on page load; it only scans when you tell it to. -- CTRL does not track which torrents you view or download. -- CTRL does not send any data about your browsing to external servers. - -### Protocol Handling -CTRL is **content-agnostic**. It processes magnet URI hashes (strings) without knowledge of what content they represent. The extension does not know if a magnet link points to a Linux ISO, open-source software, or any other type of file. - -### Communication with Torrent Clients -When you initiate a download: -1. CTRL sends the magnet link to **your configured torrent client** using HTTP/HTTPS requests -2. Communication is **direct** between your browser and your client (localhost or your specified server) -3. No intermediary servers are involved -4. No data is sent to CTRL developers or third parties - -## Third-Party Services - -CTRL does **NOT** use any third-party services for: -- Analytics (e.g., Google Analytics) -- Crash reporting (e.g., Sentry) -- Advertising networks -- Cloud storage or sync - -## Permissions Explained - -CTRL requests the following browser permissions: - -### `storage` -**Purpose**: To store your encrypted credentials and preferences locally on your device. -**Data Access**: Only data created by CTRL (your settings and server configurations). - -### `contextMenus` -**Purpose**: To add right-click menu options for sending magnet links to your torrent clients. -**Data Access**: Only the text/URL you right-clicked on. - -### `notifications` -**Purpose**: To notify you when downloads complete or errors occur. -**Data Access**: None. Notifications are created locally. - -### `activeTab` -**Purpose**: To generically scan the current page for magnet links when you manually select the "Scan Page" option from the context menu. -**Data Access**: Only the ability to identify magnet link URLs (`href` attributes) on the page you are currently viewing and have interacted with. - -### `optional_host_permissions` -**Purpose**: To allow communication with your self-hosted torrent client (e.g., qBittorrent, Transmission). -**Data Access**: Permissions are only requested for the specific URL of your torrent client. No data from other websites is accessed using these permissions. - -## Your Rights - -Since CTRL does not collect any personal data, there is no data for you to request, correct, or delete from our servers (because we don't have servers). - -However, you have full control over locally stored data: -- **View**: Inspect your browser's extension storage using developer tools -- **Delete**: Uninstall the extension to remove all local data -- **Export**: Use CTRL's built-in export feature to backup your settings -- **Modify**: Change settings at any time in the Options page - -## Children's Privacy - -CTRL does not knowingly collect any information from anyone, including children under the age of 13. - -## Changes to This Privacy Policy - -We may update this privacy policy from time to time. Changes will be posted on this page with an updated "Last Updated" date. Continued use of CTRL after changes constitutes acceptance of the updated policy. - -## Open Source Transparency - -CTRL is open-source software. You can inspect the source code to verify our privacy claims: - -**GitHub Repository**: https://github.com/StarlightDaemon/CTRL - -The code shows: -- No analytics libraries -- No network requests to external servers (except to your configured torrent clients) -- All storage operations use local browser APIs -- Encryption implementation for credential storage - -## Legal Disclaimer - -CTRL is a remote control utility for BitTorrent clients. The extension does not provide, host, index, or distribute any files, media, or content. Users are solely responsible for the content they choose to transfer using their local torrent clients. - -BitTorrent is a legitimate protocol used for distributing open-source software, public domain content, and other legal files. CTRL developers do not endorse or encourage copyright infringement. - -## Contact - -If you have questions about this privacy policy or CTRL's data practices: - -- **GitHub Discussions**: https://github.com/StarlightDaemon/CTRL/discussions -- **GitHub Issues**: https://github.com/StarlightDaemon/CTRL/issues - ---- - -## Summary (TL;DR) - -✅ **Zero data collection** - Nothing is sent to external servers -✅ **Local encryption** - Credentials encrypted with AES-GCM -✅ **No tracking** - No analytics, no telemetry -✅ **Open source** - Code is publicly auditable -✅ **Content agnostic** - Extension doesn't know what you download -✅ **Direct communication** - Browser → Your Client (no middleman) - -**Your privacy is our priority.** - ---- - -*This privacy policy is effective as of January 2026 and applies to CTRL version 0.2.0 and later.* +CTRL is a remote control for BitTorrent clients. It does not provide, host, +index or distribute files. Users are responsible for the content they +transfer with their own clients. diff --git a/docs/privacy.html b/docs/privacy.html index fd8942a..bebbde5 100755 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -3,311 +3,119 @@ - CTRL - Privacy Policy + CTRL Privacy Policy -
-

Privacy Policy

-
- Last Updated: April 2026
- Effective Date: January 2026 -
+

CTRL Privacy Policy

+

Last updated: September 2026 (applies to CTRL 0.2.0-beta.1 and later)

-

Introduction

-

CTRL (Torrent Control) is a browser extension that provides a unified interface for managing BitTorrent clients directly from your browser. This privacy policy explains how CTRL handles your data.

+

CTRL is a browser extension that sends magnet links and torrent URLs to a BitTorrent client you run yourself and shows and controls that client's queue. This policy describes exactly what CTRL stores, what it transmits, to whom, and what it never does. It matches the extension's verified behaviour; the source is public.

-

Data Collection

-
- CTRL does not collect, store, or transmit any personal data to external servers. -
-

We believe in privacy by design. All data processing occurs locally on your device, and nothing is sent to us or any third-party services.

- -

What Data is Stored Locally

-

CTRL stores the following data locally on your device using your browser's built-in storage API (chrome.storage.local or browser.storage.local):

- -

1. Torrent Client Credentials

-
    -
  • What: Server URLs, usernames, and passwords for your torrent clients
  • -
  • Purpose: To connect to and manage your torrent clients (qBittorrent, Transmission, Deluge, etc.)
  • -
  • Security: All credentials are encrypted using AES-GCM encryption before being stored locally
  • -
  • Location: Browser's local storage on your device only
  • -
  • Transmission: Credentials are never transmitted outside your device
  • -
- -

2. User Preferences

-
    -
  • What: Your settings and preferences (theme, language, notification settings, etc.)
  • -
  • Purpose: To provide a personalized experience
  • -
  • Location: Browser's local storage on your device only
  • -
- -

3. Extension State

-
    -
  • What: Active server selection, UI state, feature toggles
  • -
  • Purpose: To maintain your workspace across browser sessions
  • -
  • Location: Browser's local storage on your device only
  • -
- -

What Data is NOT Collected

-

CTRL explicitly does NOT collect, store, or transmit:

+
+

The short version

    -
  • ❌ Browsing history
  • -
  • ❌ Personal information (name, email, address)
  • -
  • ❌ Torrent content or metadata
  • -
  • ❌ Magnet links or torrent hashes to external servers
  • -
  • ❌ Usage analytics or telemetry
  • -
  • ❌ Crash reports
  • -
  • ❌ IP addresses
  • -
  • ❌ Any data to advertising networks
  • +
  • CTRL has no servers of its own, no analytics, no telemetry, no crash reporting and no advertising. The CTRL developer receives nothing from the extension.
  • +
  • The only network destination is the torrent client address you configured. CTRL sends that client your login (to sign in) and the torrent links and commands you issue, and reads the queue back.
  • +
  • Your server addresses, usernames and passwords are encrypted on your device with a master password before being stored, and are only ever sent to the client they belong to.
- -

How CTRL Works

- -

User-Initiated Actions

-

CTRL operates only when you explicitly interact with it. It does not automatically modify web pages or background-monitor your browsing activity.

- -

How you interact with CTRL:

-
    -
  1. Context Menus: You can right-click on a magnet link or a page to send torrents directly to your client.
  2. -
  3. Scan Page: You can manually trigger a "Scan Page for Magnets" action from the context menu. This generically scans the current page for magnet links (magnet:?xt=...) and adds them to your selected client.
  4. -
  5. Manual Addition: You can add torrents by pasting URLs or magnet links directly into the extension popup.
  6. -
- -

What happens:

-
    -
  1. When you initiate an action, CTRL processes the request locally.
  2. -
  3. If scanning a page, it identifies links matching the magnet protocol.
  4. -
  5. The link is sent directly to your configured torrent client (on your local network or remote server).
  6. -
- -

What does NOT happen:

-
    -
  • CTRL does not automatically inject UI components or buttons into websites.
  • -
  • CTRL does not detect magnet links on page load; it only scans when you tell it to.
  • -
  • CTRL does not track which torrents you view or download.
  • -
  • CTRL does not send any data about your browsing to external servers.
  • -
- -

Protocol Handling

-

CTRL is content-agnostic. It processes magnet URI hashes (strings) without knowledge of what content they represent. The extension does not know if a magnet link points to a Linux ISO, open-source software, or any other type of file.

- -

Communication with Torrent Clients

-

When you initiate a download:

-
    -
  1. CTRL sends the magnet link to your configured torrent client using HTTP/HTTPS requests
  2. -
  3. Communication is direct between your browser and your client (localhost or your specified server)
  4. -
  5. No intermediary servers are involved
  6. -
  7. No data is sent to CTRL developers or third parties
  8. -
- -

Third-Party Services

-

CTRL does NOT use any third-party services for:

-
    -
  • Analytics (e.g., Google Analytics)
  • -
  • Crash reporting (e.g., Sentry)
  • -
  • Advertising networks
  • -
  • Cloud storage or sync
  • -
- -

Permissions Explained

-

CTRL requests the following browser permissions:

- -

storage

-

Purpose: To store your encrypted credentials and preferences locally on your device.

-

Data Access: Only data created by CTRL (your settings and server configurations).

- -

contextMenus

-

Purpose: To add right-click menu options for sending magnet links to your torrent clients.

-

Data Access: Only the text/URL you right-clicked on.

- -

notifications

-

Purpose: To notify you when downloads complete or errors occur.

-

Data Access: None. Notifications are created locally.

- -

activeTab

-

Purpose: To generically scan the current page for magnet links when you manually select the "Scan Page" option from the context menu.

-

Data Access: Only the ability to identify magnet link URLs (href attributes) on the page you are currently viewing and have interacted with.

- -

optional_host_permissions

-

Purpose: To allow communication with your self-hosted torrent client (for example qBittorrent or Transmission).

-

Data Access: Permissions are only requested for the specific URL of your torrent client. No data from other websites is accessed using these permissions.

- -

Your Rights

-

Since CTRL does not collect any personal data, there is no data for you to request, correct, or delete from our servers (because we don't have servers).

- -

However, you have full control over locally stored data:

-
    -
  • View: Inspect your browser's extension storage using developer tools
  • -
  • Delete: Uninstall the extension to remove all local data
  • -
  • Export: Use CTRL's built-in export feature to backup your settings
  • -
  • Modify: Change settings at any time in the Options page
  • -
- -

Children's Privacy

-

CTRL does not knowingly collect any information from anyone, including children under the age of 13.

- -

Changes to This Privacy Policy

-

We may update this privacy policy from time to time. Changes will be posted on this page with an updated "Last Updated" date. Continued use of CTRL after changes constitutes acceptance of the updated policy.

- -

Open Source Transparency

-

CTRL is open-source software. You can inspect the source code to verify our privacy claims:

-

GitHub Repository: https://github.com/StarlightDaemon/CTRL

- -

The code shows:

-
    -
  • No analytics libraries
  • -
  • No network requests to external servers (except to your configured torrent clients)
  • -
  • All storage operations use local browser APIs
  • -
  • Encryption implementation for credential storage
  • -
- -

Legal Disclaimer

-

CTRL is a remote control utility for BitTorrent clients. The extension does not provide, host, index, or distribute any files, media, or content. Users are solely responsible for the content they choose to transfer using their local torrent clients.

- -

BitTorrent is a legitimate protocol used for distributing open-source software, public domain content, and other legal files. CTRL developers do not endorse or encourage copyright infringement.

- -

Contact

-

If you have questions about this privacy policy or CTRL's data practices:

- - -
- -
-

Summary (TL;DR)

-
    -
  • ✅ Zero data collection - Nothing is sent to external servers
  • -
  • ✅ Local encryption - Credentials encrypted with AES-GCM
  • -
  • ✅ No tracking - No analytics, no telemetry
  • -
  • ✅ Open source - Code is publicly auditable
  • -
  • ✅ Content agnostic - Extension doesn't know what you download
  • -
  • ✅ Direct communication - Browser → Your Client (no middleman)
  • -
-

Your privacy is our priority.

-
- -

- This privacy policy is effective as of January 2026 and applies to CTRL version 0.2.0 and later. -

+ +

What CTRL stores on your device

+

All storage uses the browser's extension storage; nothing leaves the device by being stored.

+ + + + + + + + +
DataWhereEncryptedLifetime
Server list: name, client type, address, username, password, per-server optionsstorage.local, key vaultYes — AES-GCM with a key derived from your master password (PBKDF2-SHA256, 300,000 iterations, random salt). The master password itself is never stored.Until you remove the server, reset the vault or uninstall
Preferences: context-menu mode, add-paused default, advanced-add dialog, notifications on/off, labels, default server, badge modestorage.localNo (no secrets)Until changed or uninstall
Session key (the derived encryption key while the vault is unlocked)storage.session (memory-backed, cleared when the browser closes)Held by the browser in memoryUntil you lock CTRL or the browser closes — after a restart CTRL asks for the master password
Queue snapshot: the last torrent list received from your client (names, sizes, progress, speeds, ids)storage.sessionNoBrowser session; discarded when the active server changes
+

Locking CTRL discards the session key in every window at once. A damaged vault is never "half-opened": CTRL refuses to unlock and offers a reset that deletes the stored servers.

+ +

What CTRL transmits, and to whom

+

CTRL makes network requests only to the torrent client addresses you configured, and only after you granted the browser's host permission for that address.

+ + + + + + + +
WhatWhenTo
Username and password (Transmission: HTTP Basic; qBittorrent: login form; aria2: RPC secret)When signing in, testing a connection, or re-authenticating after a session expiredThe configured client only
Magnet links / torrent URLs you add, with the options you chose (paused, label, folder)When you add a torrentThe configured client only
Queue commands (pause, resume, remove) and queue polling requestsWhile a CTRL window is open (every few seconds) and, if the toolbar badge is enabled, about once a minute in the backgroundThe configured client only
+

Nothing is sent to the CTRL developer or to any third party. CTRL does not read web pages, does not inject anything into websites, and does not react to page loads; the right-click menu acts only on the link you right-clicked.

+ +

Unencrypted connections

+

If you configure a client with http:// rather than https://, the login and the commands travel unencrypted. That is a normal choice on a private network and CTRL allows it, but it warns you when the address is outside loopback and private ranges, because anyone on the network path could read or alter that traffic. Use https:// wherever the client or a reverse proxy offers it.

+ +

qBittorrent and the Origin header

+

Browsers label every request an extension makes with the extension's own origin, which qBittorrent's default cross-site protection rejects. To work with qBittorrent without asking you to switch that protection off, CTRL installs a browser rule (declarativeNetRequestWithHostAccess) that applies only to the qBittorrent server address you configured and sets the Origin and Referer headers of CTRL's own requests to that server's address. The rule touches no other site and reads no traffic. It exists for the current browser session and only for hosts you granted access to.

+ +

Backups and exports

+
    +
  • Safe export (default) contains server names, types, addresses and options — never passwords — and is marked containsSecrets: false.
  • +
  • Full export contains passwords in plain text so it can be imported elsewhere. It is marked containsSecrets: true and labelled as such in the interface. Keep such a file private.
  • +
  • Exports are files saved by your browser; CTRL does not upload them.
  • +
+ +

Permissions

+ + + + + + + + + + +
PermissionPurpose
storagethe encrypted vault, preferences, the session key and the queue snapshot
contextMenusthe right-click entries
notificationsoptional local notifications when a torrent was added or adding failed (switchable in Settings)
alarmsthe one-minute background badge refresh (only while the badge is enabled)
declarativeNetRequestWithHostAccessthe per-server header rule described above
Optional host access (http://*/*, https://*/*)granted by you per server address when you add a server; used only to reach that client
+

Firefox shows this in the add-on's data-collection consent as authentication information: CTRL sends your client login to the client you configured (Mozilla's taxonomy counts any transmission outside the extension, even to your own server).

+ +

What CTRL never does

+
    +
  • collect or transmit browsing history, page content, IP addresses or any personal information;
  • +
  • send torrent links, hashes or queue contents anywhere other than your configured client;
  • +
  • use analytics, telemetry, crash reporting or advertising;
  • +
  • load remote code or remote resources (fonts are the system's; the package makes no third-party requests).
  • +
+ +

Your control

+
    +
  • Change or remove servers at any time in Settings → Servers.
  • +
  • Lock CTRL to discard the session key immediately.
  • +
  • Reset the vault (Settings → System) to delete every stored server and the master password from the device.
  • +
  • Uninstalling the extension removes all its stored data.
  • +
+ +

Children

+

CTRL does not knowingly collect any information from anyone.

+ +

Changes

+

Changes to this policy are published here with a new "last updated" date.

+ +

Contact and source

+ + +

Legal note

+

CTRL is a remote control for BitTorrent clients. It does not provide, host, index or distribute files. Users are responsible for the content they transfer with their own clients.

diff --git a/docs/release/v1/CLIENT_VERIFICATION.md b/docs/release/v1/CLIENT_VERIFICATION.md new file mode 100644 index 0000000..61136f4 --- /dev/null +++ b/docs/release/v1/CLIENT_VERIFICATION.md @@ -0,0 +1,95 @@ +# CTRL v1 client verification + +Status: live-verified 2026-09-09 (Phase C/D of the release program). This is +the authoritative support classification for v1; `V1_SCOPE.md` §3 defers to it. + +Classification rule (from `V1_SCOPE.md` §3): a client is **VERIFIED FOR V1** +only after the full operation set passes against a real server from both +Chrome and Firefox on a non-loopback address. Passing unit tests is not +evidence. Every claim below was checked against the server's own API by the +harness (`extension/tests/live/oracles.mjs`), not taken from the extension. + +## Result + +| Client | Version tested | Chrome 152.0.7977.83 | Firefox 155.0.1 | Classification | +|---|---|---|---|---| +| Transmission (daemon, RPC 19) | 4.1.3 | 16 / 16 | 16 / 16 | **VERIFIED FOR V1** | +| qBittorrent (Web API 2.11) | 5.2.3, CSRF protection **on** (default), Host-header validation on | 16 / 16 | 16 / 16 | **VERIFIED FOR V1** | +| aria2 (JSON-RPC, `--rpc-secret`) | 1.37.0 | 16 / 16 | 16 / 16 | **VERIFIED FOR V1** | +| Deluge, Flood, ruTorrent, µTorrent, BiglyBT, Vuze | — | not run | not run | EXPERIMENTAL / HIDDEN (unchanged) | + +Evidence per cell (sanitized, generated by the harness, includes the +server-side log excerpt): `evidence/live--.md` and `.json`. +Server address in every run: `http://192.168.1.235:/…` (the host's LAN +address; non-loopback; plain HTTP). Extension build: the working tree at the +checkpoint commit (`0.2.0-beta.1`). + +## Scenario set (identical for every cell) + +1. clean profile → master password created (vault setup) +2. server configured through the single-URL form; credential disclosure shown; plain-HTTP warning logic exercised (private host → no warning) +3. optional host permission granted (Chrome: native bubble accepted; Firefox: test pref) +4. Test connection → success +5. Save → stored address shown verbatim +6. Dashboard live (`LIVE` / connected) +7. Add magnet from the popup → server lists the torrent (info-hash match) +8. Dashboard lists it (row name/status) +9. Add-paused default on → second magnet arrives paused on the server (Transmission status 0, qBittorrent `stoppedDL`, aria2 `paused`) +10. Pause from the dashboard → server paused +11. Resume → server active again +12. Remove (keeping files) → gone on the server; the other torrent untouched +13. Wrong credentials: Test connection fails; after Save the dashboard shows **Authentication failed**; restoring the credentials reconnects +14. Server stopped → **Connection lost** (stale data shown) +15. Server restarted → reconnects (Transmission/qBittorrent keep their torrents across the restart; aria2 keeps none without `--save-session`, which is aria2 behaviour) +16. Browser restart with the same profile → vault locked → unlock → connected (Chrome; see caveat A) + +## Defects found live and repaired (all in `8959576`…checkpoint commits) + +| # | Finding | Root cause | Repair | +|---|---|---|---| +| 1 | Firefox: every client "cannot be reached" although requests arrived at the server | Firefox accepts a port-qualified origin pattern for `permissions.request` but does not use it to exempt fetches from CORS (preflights were sent and blocked); Firefox match patterns have no port support | `toMatchPattern()` requests `http://host/*` on Firefox (its narrowest grant) and keeps `http://host:port/*` on Chrome (`shared/lib/permissions.ts`) | +| 2 | qBittorrent rejects every request in both browsers with default security | Browsers stamp extension requests with `Origin: chrome-extension://…` / `moz-extension://…`; qBittorrent's CSRF check compares it with its host ("Origin header & Target origin mismatch"); `Origin`/`Referer` are forbidden request headers | `declarativeNetRequestWithHostAccess` session rule per configured qBittorrent origin setting `Origin`/`Referer` to that origin (`shared/api/network/HeaderRewriter.ts`), installed by the controller before a client is created or tested. Verified in both browsers with CSRF protection **on**. Asking users to switch CSRF protection off was rejected: it would weaken their server for every website | +| 3 | qBittorrent 5.x ignores "add paused" | Web API 2.11 renamed the `paused` add parameter to `stopped` | both parameters are sent | +| 4 | A wrong qBittorrent password led to an IP ban within seconds | the 2 s poll loop repeated the failing login; qBittorrent bans after 5 failures | adapter latches a rejected login until the settings change (one failed login per configuration); controller stops automatic polling after `auth_failed` | +| 5 | qBittorrent "Test connection" reported success with a wrong password | the browser still held a valid session cookie and qBittorrent answers `auth/login` with Ok for an authenticated session | test-connection ends the existing session (`auth/logout`) before logging in | +| 6 | aria2 wrong token shown as "connection lost" / "cannot reach" | aria2 answers single calls with HTTP 400 + JSON-RPC error and `system.multicall` with bare `{code, message}` faults; both were classified as network errors | JSON-RPC errors are parsed from non-2xx bodies (`JsonRpcClient`); multicall faults are mapped; "Unauthorized" maps to `UNAUTHORIZED` in any context | +| 7 | Authentication failure invisible in Chrome (dashboard stuck on "Connecting…") | aria2 takes ~2 s to reject a wrong secret; the controller flipped to `connecting` at the start of every 2 s retry, so `auth_failed` was visible only for microseconds | settled outcomes (`connected`, `stale`, `auth_failed`, `unavailable`) stay on screen during a retry; `auth_failed` stops automatic polling until the settings change or the user forces a refresh | + +Behaviour observed and documented rather than changed: qBittorrent keeps an +existing session valid after the stored password changes (the server, not +CTRL, decides when a session ends); a changed password is enforced at the +next login (session expiry, client restart, or Test connection). + +## Caveats and limits of the evidence + +- **A. Browser restart in Chrome:** the harness reloads the unpacked build on + every launch (CDP `loadUnpacked`), which Chrome treats as a reinstall and + which drops the optional host grant; the scenario therefore re-granted + through the in-product recovery flow (Servers → Grant access) and then + reconnected. A store-installed extension is not reinstalled on restart. The + vault lock/unlock part of the scenario is genuine. +- **B. Browser restart in Firefox:** not exercised — a temporarily installed + add-on is removed when Firefox restarts. Lock-on-restart in Firefox is + covered by the session-storage design only. +- **C.** Permission prompts were automated (Chrome: Windows UI Automation + click on the real "Allow" button; Firefox: `extensions.webextOptionalPermissionPrompts=false`). +- **D.** HTTPS, reverse-proxy sub-paths and DNS names were not exercised live + (unit-tested only); the live servers were IPv4 + port over plain HTTP. +- **E.** Windows host only; one machine; each cell ran at least once on the + final build (several cells ran repeatedly during the repair loop). + +## Reproduce + +```bash +cd extension +npm ci && npm run build:chrome && npm run build:firefox +node tests/live/env.mjs fetch && node tests/live/env.mjs extract +node tests/live/env.mjs start all +node tests/live/verify.mjs --client transmission --browser chrome +node tests/live/verify.mjs --client transmission --browser firefox --headless +# … qbittorrent, aria2 +node tests/live/env.mjs clean +``` + +See `extension/tests/live/README.md` for provenance, ports, credentials and +the browser harness design. diff --git a/docs/release/v1/EXECUTION_STATE.md b/docs/release/v1/EXECUTION_STATE.md new file mode 100644 index 0000000..8a0c470 --- /dev/null +++ b/docs/release/v1/EXECUTION_STATE.md @@ -0,0 +1,212 @@ +# CTRL v1 Execution State + +## Checkpoint + +- timestamp: 2026-09-09 (pre-push correction wave, after the read-only pre-push reconciliation returned NOT SAFE TO PUSH) +- repository: `E:\Citadel\CTRL` +- branch: `main` (tracks `origin/main` at `f088c5f`; 27 local commits ahead after this checkpoint, nothing pushed) +- ending HEAD: the correction commit that also updates this file, child of `06c8c65` (see Local Commits) +- version: `0.2.0-beta.1` (unchanged; manifest `version` `0.2.0.1`; not eligible for 1.0.0 — gates not all passed) +- working tree at checkpoint: only the pre-existing operator files under `.raiden/` and `.serena/` remain modified/untracked (11 modified or deleted, 2 untracked). No staged changes. Stash `stash@{0}` (obsolete buildInfo.ts stamp) untouched. +- toolchain: Node v24.18.0, npm 11.16.0 (`.nvmrc` 24; CI 24), addons-linter 10.11.0, Chrome 152.0.7977.83, Firefox 155.0.1 +- disposable environment: the session scratchpad `live/` directory held Transmission 4.1.3, qBittorrent 5.2.3, aria2 1.37.0 and geckodriver 0.37.1; all client processes and harness browsers were **stopped at the end of the batch** (binaries and downloads remain only in the session scratchpad). Reproducible from scratch with `node tests/live/env.mjs fetch && extract && start all` (default root `%LOCALAPPDATA%\Temp\ctrl-live`). + +## Pre-Push Correction Wave — COMPLETE — COMMITTED (child of `06c8c65`) + +The read-only pre-push reconciliation of `06c8c65` found the outbound range coherent, scoped, secret-free, free of operator state and non-publishing, but returned **NOT SAFE TO PUSH** because the retained CI `e2e` job still asserted UI removed by the product-surface reduction. The `e2e` job had not been considered by the batch (Playwright's bundled Chromium does not start on this host) and would have failed on the first remote run. Corrections, kept to the smallest set: + +| Item | Change | +|---|---| +| `extension/tests/e2e/popup.spec.ts` | The test expected the removed `dashboardSetupNow` / `dashboardEmptyState` strings ("Setup Now", "Extension not configured."). It now asserts the current first-run prompt by accessible role and name: heading "Set up CTRL", the master-password sentence, button "Set up now", and that no add-torrent field renders before a vault exists. | +| `extension/tests/e2e/options.spec.ts` | "should display version in footer/header" expected a `v\d+.\d+.\d+` string on the fresh (uninitialized-vault) profile; it came from the deleted `VersionOverlay`. Replaced by "should show the version on the About page once the vault exists": creates the vault through the real `SetupVault` form (labels "Master Password" / "Confirm Password", button "Create Vault", waits for the "Dashboard" tab), opens the "About" secondary-nav button and asserts the manifest version tag. The other three tests were re-checked against the current UI and left unchanged (load, navigate-with-skip-when-locked, unconfigured-state). | +| `extension/src/entities/server/lib/serverIdentity.ts` | The legacy-id field separator was a raw NUL byte in the source, so git and text tools treated the file as binary. It is now the `'\0'` escape: same string value, same hash. Golden regression added to `tests/unit/serverIdentity.test.ts` with five ids recorded from the original bytes before the change (`legacy-f15fb539`, `legacy-bdac00b4`, `legacy-a05f7431`, `legacy-e9a57f78`, `legacy-babcbca8`). The compiled package already emitted `join("\0")` and contains no raw NUL byte, so packaged output is unaffected. | +| `extension/BUILD.md` | Node row no longer claims CI builds on Node 22; it states 24.x (`.nvmrc` 24, CI 24). | +| `extension/.gitignore` (pre-existing, unchanged) | Line 2 reads `web-ext-ar# Build outputs` and the file ends with a stray `ea/`, both from the initial commit. The intended entry is presumably `web-ext-artifacts/` (which the root `.gitignore` already carries), but the `ea/` fragment cannot be established unambiguously, and neither line matches anything in the tree. Left unchanged; recorded as a non-blocking pre-existing issue. | +| this file | Gate H wording corrected (remote CI pending, no "expected green"); addons-linter warning count reconciled. | + +Verification on the corrected tree (Node 24.18 / npm 11.16, Windows host): + +| Check | Result | +|---|---| +| `npx tsc --noEmit` | clean | +| `npx eslint src --ext .ts,.tsx` | 0 errors, 13 warnings (unchanged) | +| `npx vitest run` | **32 files, 717 tests passed** (716 + the golden regression) | +| `npm run zip:chrome` / `zip:firefox` | success; 14 files each; `_locales/en` only; 0 fonts; 0 remote font/CDN refs; 0 removed-feature strings; manifests `0.2.0.1` (Chrome `version_name` `0.2.0-beta.1`), reviewed permission set | +| Determinism | both targets byte-identical across two consecutive builds (14/14) | +| Build side effects | none (only the five intended edits in `git status`) | +| addons-linter 10.11.0 | 0 errors, 3 warnings: 1 × `KEY_FIREFOX_ANDROID_UNSUPPORTED_BY_MIN_VERSION` (manifest `data_collection_permissions` vs. Firefox for Android min version; desktop-only listing) + 2 × `UNSAFE_VAR_ASSIGNMENT` (React DOM production bundle) | +| Playwright non-integration e2e | **not runtime-verified locally**: `browserType.launchPersistentContext: spawn UNKNOWN` — Playwright's bundled Chromium (`chromium-1200`) cannot start on this host, the limitation already recorded in `tests/live/README.md`. The corrected assertions were verified statically against `Dashboard.tsx` (popup prompt), `SetupVault.tsx` (labels, button), `OptionsLayout.tsx` (Carbon `Tab` role, secondary-nav ` - ) : ( - - )} + )} +
{/* Progress Bar */} -
-
+
+
- {torrent.status === 'downloading' && ( -
- )} -
+ className={`h-full transition-all duration-500 motion-reduce:transition-none ${barColor}`} + style={{ width: `${progress}%` }} + />
diff --git a/extension/src/entrypoints/background.ts b/extension/src/entrypoints/background.ts index 1a48104..343feb6 100755 --- a/extension/src/entrypoints/background.ts +++ b/extension/src/entrypoints/background.ts @@ -1,445 +1,206 @@ -import 'reflect-metadata'; -import { defineBackground } from 'wxt/utils/define-background'; -import { storage } from 'wxt/utils/storage'; -import { ClientFactory } from '@/entities/client/lib/ClientFactory'; // New Dynamic Factory -import { ContextMenuService } from '../features/torrent-control/model/services/ContextMenuService'; -import { ITorrentClient } from '@/entities/client/model/ITorrentClient'; // New Interface -import { AppSettings } from '@/shared/lib/types'; -import { LifecycleAdapter } from '../features/torrent-control/services/LifecycleAdapter'; -import { StateHydrator } from '../features/torrent-control/services/StateHydrator'; -import { ViewportManager } from '../features/torrent-control/services/ViewportManager'; -import { Torrent } from '../entities/torrent/model/Torrent'; -import { SESSION_KEY_KEY, VAULT_DATA_KEY } from '@/shared/api/security/VaultService'; -import { ServerResolver, ResolutionState } from '@/shared/api/server/ServerResolver'; - -// HeaderRewriter import removed (DNR Dependency Elimination) - -export default defineBackground(() => { - console.log('Torrent Control: Background Service Worker Initialized (Phase 2 w/ Persistence & Vault)'); - - // 1. Initialize Persistence (Cross-Browser) - LifecycleAdapter.initKeepAlive(); - - // [FF MV3 Fix] Clear session fallback on browser startup to maintain "session" semantics - // Also rebuild context menus — Firefox MV3 does not persist them across restarts. - chrome.runtime.onStartup.addListener(async () => { - if (navigator.userAgent.includes('Firefox')) { - await storage.removeItem('local:session_encryptionKey'); - console.log('[Background] Firefox session fallback cleared on startup.'); - } - // Rebuild context menus on every cold start (required for Firefox MV3, harmless on Chrome) - contextMenuService.ensureMenus(); - console.log('[Background] onStartup: context menus rebuild triggered.'); - }); - - // DNR dependency removed - - const factory = new ClientFactory(); - const contextMenuService = new ContextMenuService(); - const viewportManager = new ViewportManager(); - let activeClient: ITorrentClient | null = null; - - // 2. Initialize Hydration (Restore state immediately on wake) - StateHydrator.hydrate().then(data => { - if (data && data.length > 0) { - console.log(`[Hydration] Restored ${data.length} torrents from session storage.`); - viewportManager.updateTorrents(data); - } - }); - - // Helper to get client with structured result (Soft-fail) - const getClientResult = async (serverIndex?: number): Promise<{ client: ITorrentClient | null, state: ResolutionState }> => { - const { state, servers, activeServer } = await ServerResolver.resolve(); - - if (state !== ResolutionState.OK && serverIndex === undefined) { - return { client: null, state }; - } - - if (serverIndex !== undefined) { - const target = servers[serverIndex]; - if (!target) return { client: null, state: ResolutionState.INVALID_CONFIG }; - try { - return { client: await factory.create(target), state: ResolutionState.OK }; - } catch { - return { client: null, state: ResolutionState.INVALID_CONFIG }; - } - } - - if (!activeServer) { - return { client: null, state: ResolutionState.NO_ACTIVE_SERVER }; - } - - // If activeClient already exists, we should still ensure it's not null before proceeding. - // However, we MUST NOT blindly return it if the activeServer has changed. - // For simplicity and correctness, we rely on the storage watchers below to clear activeClient. - if (activeClient) { - return { client: activeClient, state: ResolutionState.OK }; - } - - try { - activeClient = await factory.create(activeServer); - console.log('Background: Client created successfully'); - return { client: activeClient, state: ResolutionState.OK }; - } catch (e) { - console.error('Background: Factory failed to create client', e); - return { client: null, state: ResolutionState.INVALID_CONFIG }; - } - }; - - // Initialize Services - contextMenuService.initialize(getClientResult); - - // Badge Update Logic - const updateBadge = async (torrents?: Torrent[]) => { - try { - const settings = await storage.getItem('local:options'); - if (!settings || settings.globals.badgeInfo === 'none') { - chrome.action.setBadgeText({ text: '' }); - return; - } - - // If no data passed, we might skip to avoid double fetch in this architecture, - // or fetch if called outside the loop. - if (!torrents && activeClient) { - try { - torrents = await activeClient.getTorrents(); - } catch { return; } - } - - if (torrents) { - if (settings.globals.badgeInfo === 'count') { - const activeCount = torrents.filter(t => (t.status as string) === 'downloading' || (t.status as string) === 'seeding').length; - chrome.action.setBadgeText({ text: activeCount > 0 ? activeCount.toString() : '' }); - chrome.action.setBadgeBackgroundColor({ color: '#3B82F6' }); // Blue - } else if (settings.globals.badgeInfo === 'speed') { - const totalSpeed = torrents.reduce((acc, t) => acc + t.downloadSpeed, 0); - if (totalSpeed > 0) { - let speedText = ''; - if (totalSpeed < 1024) speedText = `${totalSpeed}B`; - else if (totalSpeed < 1024 * 1024) speedText = `${(totalSpeed / 1024).toFixed(0)}K`; - else speedText = `${(totalSpeed / (1024 * 1024)).toFixed(1)}M`; - chrome.action.setBadgeText({ text: speedText }); - chrome.action.setBadgeBackgroundColor({ color: '#10B981' }); // Green - } else { - chrome.action.setBadgeText({ text: '' }); - } - } - } - } catch (e: unknown) { - const message = e instanceof Error ? e.message : String(e); - if (message === 'No configuration found' || message === 'Vault is locked') { - chrome.action.setBadgeText({ text: 'Lock' }); // Indicator - chrome.action.setBadgeBackgroundColor({ color: '#EF4444' }); - return; - } - console.error('Failed to update badge:', e); - chrome.action.setBadgeText({ text: '!' }); - chrome.action.setBadgeBackgroundColor({ color: '#EF4444' }); - } - }; - - // 3. Lifecycle & Polling Logic (Lite Architecture) - // ------------------------------------------------ - let pollingInterval: ReturnType | null = null; - let activePorts = 0; - - const performCheck = async () => { - try { - // Resolve current state - const { state, activeServer } = await ServerResolver.resolve(); - - if (state !== ResolutionState.OK) { - if (state === ResolutionState.LOCKED) { - updateBadge(); - } - activeClient = null; - return; - } - - // Ensure connection - if (!activeClient && activeServer) { - try { - activeClient = await factory.create(activeServer); - } catch (e) { - console.error('Background: Failed to initialize active client:', e); - return; - } - } - - if (activeClient) { - const torrents = await activeClient.getTorrents(); - viewportManager.updateTorrents(torrents); - updateBadge(torrents); - - if (torrents) { - const totalDl = torrents.reduce((acc, t) => acc + t.downloadSpeed, 0); - const totalUl = torrents.reduce((acc, t) => acc + t.uploadSpeed, 0); - const active = torrents.filter(t => (t.status as string) === 'downloading' || (t.status as string) === 'seeding').length; - - chrome.runtime.sendMessage({ - type: 'STATS_UPDATE', - data: { downloadSpeed: totalDl, uploadSpeed: totalUl, activeCount: active } - }).catch(() => { }); - } - } - } catch (e) { - console.error('Check error:', e); - } - }; - - // Fast Polling (Foreground) - const startFastPolling = () => { - if (pollingInterval) return; // Already running - console.log('Background: Starting Fast Polling (Active Session)'); - - // Immediate check - performCheck(); - - pollingInterval = setInterval(performCheck, 2000); - }; - - const stopFastPolling = () => { - if (pollingInterval) { - console.log('Background: Stopping Fast Polling (Idle)'); - clearInterval(pollingInterval); - pollingInterval = null; - } - }; - - // Sender validation: only this extension's own contexts (popup/options) may - // reach the privileged handlers below. chrome.runtime.id works for both - // Chrome and Firefox MV3 (Firefox reports the add-on ID in both places). - const isTrustedSender = (sender?: chrome.runtime.MessageSender): boolean => - sender?.id === chrome.runtime.id; - - // Port Listener (The "Switch") - chrome.runtime.onConnect.addListener((port) => { - if (!isTrustedSender(port.sender)) { - console.warn('[Background] Rejected port connection from untrusted sender:', port.sender?.id); - port.disconnect(); - return; - } - if (port.name === 'ctrl-active-session') { - activePorts++; - startFastPolling(); - - port.onDisconnect.addListener(() => { - activePorts--; - if (activePorts <= 0) { - activePorts = 0; - stopFastPolling(); - } - }); - } - }); - - // Alarm Listener (Background "Heartbeat") - chrome.alarms.create('packet_beat', { periodInMinutes: 1 }); - chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === 'packet_beat') { - // Only run if NOT fast polling (avoid double fetch) - if (activePorts === 0) { - console.log('Background: Alarm Beat'); - performCheck(); - } - } - }); - - // Initial check on load (in case of event wake) - if (activePorts > 0) startFastPolling(); - - // ------------------------------------------------ - - // Watch for Unlock & Vault changes - try { - storage.watch(SESSION_KEY_KEY, (newValue) => { - if (newValue) { - activeClient = null; - // If UI is open, this will trigger fast poll next tick - if (activePorts > 0) startFastPolling(); - else performCheck(); - } else { - activeClient = null; - updateBadge(); - } - }); - - storage.watch(VAULT_DATA_KEY, (newValue) => { - if (newValue) { - console.log('[Background] Vault data changed, clearing active client cache.'); - activeClient = null; - } - }); - } catch (e) { console.error('Watch error', e) } - - - // Reset loop on settings change - storage.watch('local:options', (_newValue) => { - activeClient = null; - if (activePorts > 0) startFastPolling(); - }); - - // Message Handler - chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (!isTrustedSender(sender)) { - console.warn('[Background] Rejected message from untrusted sender:', sender?.id); - return false; - } - const handleMessage = async () => { - try { - // Attempt to resolve target client - const getTargetClient = async (): Promise<{ client: ITorrentClient | null, error?: string }> => { - if (message.config) { - try { - return { client: await factory.create(message.config) }; - } catch (e) { - return { client: null, error: e instanceof Error ? e.message : 'Invalid config' }; - } - } - - const { state, servers } = await ServerResolver.resolve(); - - if (state !== ResolutionState.OK) { - if (state === ResolutionState.LOCKED || state === ResolutionState.UNINITIALIZED) { - if (message.type === 'ADD_TORRENT_URL') { - chrome.notifications.create({ - type: 'basic', - iconUrl: 'icon/default-64.png', - title: 'Vault Locked', - message: 'Please unlock CTRL to add this torrent.', - priority: 2 - }); - } - return { client: null, error: 'Vault is locked' }; - } - return { client: null, error: `Resolution failed: ${state}` }; - } - - if (typeof message.serverIndex === 'number') { - const target = servers[message.serverIndex]; - if (!target) return { client: null, error: `Server at index ${message.serverIndex} not found.` }; - return { client: await factory.create(target) }; - } - - // Default client - try { - const { client, state } = await getClientResult(); - return { client, error: client ? undefined : `Resolution failed: ${state}` }; - } catch (e) { - return { client: null, error: e instanceof Error ? e.message : 'Client creation failed' }; - } - }; - - // NEW: Viewport Control (Keep as is since it doesn't need client) - if (message.type === 'UPDATE_VIEWPORT') { - if (message.data && typeof message.data.start === 'number') { - const end = message.data.end || (message.data.start + 50); - viewportManager.setViewport(message.data.start, end); - } - return { success: true }; - } - - if (message.type === 'SELF_TEST') { - return { - status: 'ok', - version: chrome.runtime.getManifest().version, - uptime: performance.now(), - userAgent: navigator.userAgent, - platform: navigator.platform, - language: navigator.language - }; - } - - const { client, error } = await getTargetClient(); - if (error || !client) { - return { error }; - } - - switch (message.type) { - case 'GET_TORRENTS': - return await client.getTorrents(); - - case 'ADD_TORRENT_URL': { - const currentSettings = await storage.getItem('local:options'); - const globalAddPaused = currentSettings?.globals.addPaused ?? false; - const options = { - ...message.options, - paused: message.options?.paused ?? globalAddPaused - }; - - const result = await client.addTorrentUrl(message.url, options); - performCheck(); // Force refresh - return result; - } - - case 'PAUSE_TORRENT': { - const pResult = await client.pauseTorrent(message.id); - performCheck(); - return pResult; - } - - case 'RESUME_TORRENT': { - const rResult = await client.resumeTorrent(message.id); - performCheck(); - return rResult; - } - - case 'REMOVE_TORRENT': { - const dResult = await client.removeTorrent(message.id, message.deleteData); - performCheck(); - return dResult; - } - - case 'FORCE_REFRESH': - await performCheck(); - break; - - case 'TEST_CONNECTION': - case 'TEST_CONNECTION_SERVER': { - if (typeof __UI_DEBUG_MODE__ !== 'undefined' && __UI_DEBUG_MODE__) { - console.log('[Background] TEST_CONNECTION received. Type:', message.config?.type); - } - const result = await client.testConnection(); - // AdapterError instances cannot cross the message channel with - // their methods intact, so serialize to a wire-safe shape: - // { connected, error? } where error is the user-facing string. - const response = { - connected: result.connected, - error: result.error?.toUserMessage(), - }; - console.info(JSON.stringify({ - event: 'TEST_CONNECTION_RESULT', - messageType: message.type, - hostnameTested: message.config?.hostname || 'unknown_persisted', - configSource: message.config ? 'message.config' : 'ServerResolver', - adapterType: client.constructor.name, - connected: result.connected, - errorType: result.error?.type ?? null, - errorMessage: response.error ?? null - })); - return response; - } - - case 'PING': - case 'PING_SERVER': - return await client.ping(); - - default: - throw new Error(`Unknown message type: ${message.type}`); - } - } catch (e: unknown) { - const errorMessage = e instanceof Error ? e.message : String(e); - console.error('Background Error:', e); - return { error: errorMessage }; - } finally { - // DNR dependency removed - } - }; - - handleMessage().then(sendResponse); - return true; - }); - - - -}); +import { defineBackground } from 'wxt/utils/define-background'; +import { storage } from 'wxt/utils/storage'; +import { ClientFactory } from '@/entities/client/lib/ClientFactory'; +import { ContextMenuService } from '../features/torrent-control/model/services/ContextMenuService'; +import { AppSettings } from '@/shared/lib/types'; +import { StateHydrator } from '../features/torrent-control/services/StateHydrator'; +import { TorrentController, type ControllerStateEvent } from '../features/torrent-control/services/TorrentController'; +import { SESSION_KEY_KEY, VAULT_DATA_KEY, VAULT_SALT_KEY } from '@/shared/api/security/VaultService'; +import { KeyManager } from '@/shared/api/security/KeyManager'; +import { ServerResolver } from '@/shared/api/server/ServerResolver'; +import { checkHostPermission } from '@/shared/lib/permissions'; +import { HeaderRewriter } from '@/shared/api/network/HeaderRewriter'; +import { ACTIVE_SESSION_PORT, type RuntimeRequest } from '@/shared/api/messaging/protocol'; + +const FAST_POLL_INTERVAL_MS = 2000; +const HEARTBEAT_ALARM = 'packet_beat'; + +export default defineBackground(() => { + // [Security] Scrub the plaintext vault key that older Firefox builds mirrored + // into storage.local. This runs on every wake rather than only on startup: + // onStartup does not fire on extension update, which is exactly how existing + // installs reach this build, and a stale key left on disk is the whole defect. + KeyManager.purgeLegacyFallbackKey().catch((err) => { + console.warn('[Background] Legacy session key purge failed:', err); + }); + + const factory = new ClientFactory(); + const contextMenuService = new ContextMenuService(); + + const controller = new TorrentController({ + resolve: () => ServerResolver.resolve(), + createClient: (config) => factory.create(config), + hasHostPermission: (url) => checkHostPermission(url), + prepareTransport: (config) => HeaderRewriter.prepare(config), + getSettings: () => storage.getItem('local:options'), + persist: (snapshot) => StateHydrator.persist(snapshot), + log: (message, ...rest) => { + if (typeof __UI_DEBUG_MODE__ !== 'undefined' && __UI_DEBUG_MODE__) console.debug(message, ...rest); + }, + }); + + // Restore the last snapshot from the previous background instance. The + // controller only uses it once the active server is known to match. + StateHydrator.hydrate().then((persisted) => controller.hydrate(persisted)); + + // Rebuild context menus on browser startup — Firefox MV3 does not persist + // them across restarts. + chrome.runtime.onStartup.addListener(async () => { + await KeyManager.purgeLegacyFallbackKey(); + contextMenuService.ensureMenus(); + }); + + contextMenuService.initialize({ + addTorrent: (request) => controller.addTorrent(request), + }); + + // ------------------------------------------------------------------ + // Badge + // ------------------------------------------------------------------ + + const applyBadge = async (event: ControllerStateEvent) => { + try { + const settings = await storage.getItem('local:options'); + const badgeInfo = settings?.globals?.badgeInfo ?? 'count'; + const { connection, stats } = event; + + if (badgeInfo === 'none') { + await chrome.action.setBadgeText({ text: '' }); + return; + } + + switch (connection.status) { + case 'connected': + case 'stale': + if (badgeInfo === 'count') { + await chrome.action.setBadgeText({ text: stats.activeCount > 0 ? String(stats.activeCount) : '' }); + await chrome.action.setBadgeBackgroundColor({ color: '#3B82F6' }); + } else { + const speed = stats.downloadSpeed; + let text = ''; + if (speed >= 1024 * 1024) text = `${(speed / (1024 * 1024)).toFixed(1)}M`; + else if (speed >= 1024) text = `${(speed / 1024).toFixed(0)}K`; + else if (speed > 0) text = `${speed}B`; + await chrome.action.setBadgeText({ text }); + await chrome.action.setBadgeBackgroundColor({ color: '#10B981' }); + } + return; + case 'locked': + await chrome.action.setBadgeText({ text: 'Lock' }); + await chrome.action.setBadgeBackgroundColor({ color: '#EF4444' }); + return; + case 'unavailable': + case 'auth_failed': + case 'permission_missing': + case 'invalid_config': + case 'vault_corrupted': + await chrome.action.setBadgeText({ text: '!' }); + await chrome.action.setBadgeBackgroundColor({ color: '#EF4444' }); + return; + default: + await chrome.action.setBadgeText({ text: '' }); + } + } catch (e) { + console.error('[Background] Failed to update badge:', e); + } + }; + controller.onStateChange((event) => { void applyBadge(event); }); + + // ------------------------------------------------------------------ + // Polling cadence + // ------------------------------------------------------------------ + + let fastPolling: ReturnType | null = null; + + const updateCadence = () => { + const wantFast = controller.subscriberCount() > 0; + if (wantFast && !fastPolling) { + void controller.refresh(); + fastPolling = setInterval(() => { void controller.refresh(); }, FAST_POLL_INTERVAL_MS); + } else if (!wantFast && fastPolling) { + clearInterval(fastPolling); + fastPolling = null; + } + }; + + // Sender validation: only this extension's own contexts (popup/options) may + // reach the privileged handlers below. chrome.runtime.id works for both + // Chrome and Firefox MV3 (Firefox reports the add-on ID in both places). + const isTrustedSender = (sender?: chrome.runtime.MessageSender): boolean => + sender?.id === chrome.runtime.id; + + chrome.runtime.onConnect.addListener((port) => { + if (!isTrustedSender(port.sender)) { + console.warn('[Background] Rejected port connection from untrusted sender:', port.sender?.id); + port.disconnect(); + return; + } + if (port.name !== ACTIVE_SESSION_PORT) return; + + controller.attachPort(port); + port.onDisconnect.addListener(() => updateCadence()); + updateCadence(); + }); + + // Background heartbeat: keeps the toolbar badge current while no UI is + // open. Skipped entirely when the badge is disabled so an idle browser + // does not contact the server for nothing. + chrome.alarms.create(HEARTBEAT_ALARM, { periodInMinutes: 1 }); + chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== HEARTBEAT_ALARM) return; + if (controller.subscriberCount() > 0) return; // fast polling already running + const settings = await storage.getItem('local:options'); + if ((settings?.globals?.badgeInfo ?? 'count') === 'none') return; + void controller.refresh(); + }); + + // ------------------------------------------------------------------ + // Invalidation sources + // ------------------------------------------------------------------ + + const invalidate = (reason: string) => { + controller.invalidate(reason); + void controller.refresh(); + }; + + try { + storage.watch(SESSION_KEY_KEY, () => invalidate('session-key')); + storage.watch(VAULT_DATA_KEY, () => invalidate('vault-data')); + storage.watch(VAULT_SALT_KEY, () => invalidate('vault-salt')); + storage.watch('local:options', () => invalidate('options')); + } catch (e) { + console.error('[Background] Failed to register storage watchers', e); + } + + if (chrome.permissions?.onRemoved) { + chrome.permissions.onRemoved.addListener(() => { + controller.notePermissionRemoved(); + invalidate('permission-removed'); + }); + } + if (chrome.permissions?.onAdded) { + chrome.permissions.onAdded.addListener(() => invalidate('permission-added')); + } + + // ------------------------------------------------------------------ + // Request handler + // ------------------------------------------------------------------ + + chrome.runtime.onMessage.addListener((message: RuntimeRequest, sender, sendResponse) => { + if (!isTrustedSender(sender)) { + console.warn('[Background] Rejected message from untrusted sender:', sender?.id); + return false; + } + controller + .handleRequest(message) + .then(sendResponse) + .catch((error: unknown) => { + console.error('[Background] Request failed:', error); + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }); + }); + return true; + }); + + // Initial state for the badge (and to warm the client) on wake. + void controller.refresh(); +}); diff --git a/extension/src/entrypoints/options/App.tsx b/extension/src/entrypoints/options/App.tsx index 6ba9b0a..7f5ae64 100755 --- a/extension/src/entrypoints/options/App.tsx +++ b/extension/src/entrypoints/options/App.tsx @@ -1,7 +1,6 @@ import React, { useMemo } from 'react'; import { useSettings } from '../../features/torrent-control/model/useSettings'; import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'; -import { VersionOverlay } from '@/shared/ui/VersionOverlay'; import { VaultGuard } from '@/shared/ui/security/VaultGuard'; import { Dashboard } from './Dashboard'; import { AppSettings, ServerConfig } from '@/shared/lib/types'; @@ -12,6 +11,7 @@ const SecureContent: React.FC<{ vaultServers: ServerConfig[]; saveServers: (servers: ServerConfig[]) => Promise; lock: () => Promise; + reset: () => Promise; settings: AppSettings | null; updateSettings: (settings: AppSettings) => Promise; loading: boolean; @@ -22,6 +22,7 @@ const SecureContent: React.FC<{ vaultServers, saveServers, lock, + reset, settings, updateSettings, loading, @@ -63,6 +64,7 @@ const SecureContent: React.FC<{ exportServerConfig={(sanitize) => exportServerConfig(sanitize, mergedSettings?.servers)} importBackup={importBackup} lockVault={lock} + resetVault={reset} /> ); }; @@ -80,11 +82,12 @@ const App = () => { return ( - {({ servers: vaultServers, saveServers, lock }) => ( + {({ servers: vaultServers, saveServers, lock, reset }) => ( { /> )} - ); }; diff --git a/extension/src/entrypoints/options/Dashboard.tsx b/extension/src/entrypoints/options/Dashboard.tsx index 54575e9..45cc795 100755 --- a/extension/src/entrypoints/options/Dashboard.tsx +++ b/extension/src/entrypoints/options/Dashboard.tsx @@ -1,248 +1,189 @@ -import React, { useState, useEffect } from 'react'; -import { OptionsLayout } from './OptionsLayout'; -import { ServerConfigPanel } from '../../features/torrent-control/ui/ServerConfigPanel'; -import { FunctionSettings } from '../../features/torrent-control/ui/FunctionSettings'; -import { AboutTab } from '../../features/torrent-control/ui/AboutTab'; -import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'; -import { TorrentDashboard } from '../../features/torrent-control/ui/TorrentDashboard'; -import { CommandPalette } from '@/shared/ui/ui/CommandPalette'; -import { Lock } from 'lucide-react'; -import { useTorrentPoller } from '../../features/torrent-control/model/useTorrentPoller'; -import { Utilities } from '../../features/torrent-control/ui/Utilities'; -import { AppearanceSettings } from '../../features/torrent-control/ui/AppearanceSettings'; -import { SystemSettings } from '@/shared/ui/SystemSettings'; -import { AppSettings } from '@/shared/lib/types'; -import { Loading } from '@carbon/react'; -import { browser } from 'wxt/browser'; - -interface DashboardProps { - settings: AppSettings | null; - updateSettings: (settings: AppSettings) => Promise; - loading: boolean; - exportSystemBackup: (type?: 'full' | 'settings', sanitize?: boolean) => void; - exportServerConfig: (sanitize?: boolean) => void; - importBackup: (file: File) => Promise<{ success: boolean; message: string }>; - lockVault: () => Promise; -} - -const defaultCustomOptions = { addToClient: true, pauseResume: true, openWebUI: true }; - -export const Dashboard: React.FC = ({ - settings, - updateSettings, - loading, - exportSystemBackup, - exportServerConfig, - importBackup, - lockVault -}) => { - const [activeView, setActiveView] = useState('dashboard'); - - // Start polling for torrents - useTorrentPoller(); - - const [previewContextMenu, setPreviewContextMenu] = useState(1); - const [previewCustomOptions, setPreviewCustomOptions] = useState(defaultCustomOptions); - const [previewServers, setPreviewServers] = useState([]); - - // Notification Preview State - const [previewNotification, setPreviewNotification] = useState(false); - const [previewNotificationLevel, setPreviewNotificationLevel] = useState('standard'); - - useEffect(() => { - if (settings) { - setPreviewContextMenu(settings.globals.contextMenu); - setPreviewCustomOptions(settings.globals.contextMenuCustomOptions || defaultCustomOptions); - setPreviewServers(settings.servers || []); - setPreviewNotification(settings.globals.enableNotifications); - setPreviewNotificationLevel(settings.globals.notificationLevel); - } - }, [settings]); - - // Handle Lock Action - useEffect(() => { - if (activeView === 'lock') { - lockVault().then(() => { - setActiveView('dashboard'); - }); - } - }, [activeView, lockVault]); - - const applyContextMenu = async () => { - if (!settings) return; - await updateSettings({ - ...settings, - globals: { - ...settings.globals, - contextMenu: previewContextMenu, - contextMenuCustomOptions: previewCustomOptions - }, - servers: previewServers - }); - }; - - const applyNotifications = async () => { - if (!settings) return; - await updateSettings({ - ...settings, - globals: { - ...settings.globals, - enableNotifications: previewNotification, - notificationLevel: previewNotificationLevel as 'standard' | 'verbose' | 'error' - } - }); - }; - - const primaryNavItems = [ - { id: 'dashboard', label: browser.i18n.getMessage('navDashboard') }, - { id: 'servers', label: browser.i18n.getMessage('navServers') }, - { id: 'settings', label: browser.i18n.getMessage('navSettings') }, - ]; - - const secondaryNavItems = [ - { id: 'appearance', label: browser.i18n.getMessage('navAppearance') }, - { id: 'utilities', label: browser.i18n.getMessage('navUtilities') }, - { id: 'system', label: browser.i18n.getMessage('navSystem') }, - { id: 'about', label: browser.i18n.getMessage('navAbout') }, - ]; - - const LockButton = ( - - ); - - const renderContent = () => { - if (loading || !settings) { - return ( -
- -
- ); - } - - switch (activeView) { - case 'dashboard': - return ( -
-
-
- -
-
-
- ); - case 'settings': - return ( -
-
- updateSettings(s as AppSettings)} - previewContextMenu={previewContextMenu} - setPreviewContextMenu={setPreviewContextMenu} - previewCustomOptions={previewCustomOptions} - setPreviewCustomOptions={setPreviewCustomOptions} - applyContextMenu={applyContextMenu} - previewServers={previewServers} - setPreviewServers={setPreviewServers} - previewNotification={previewNotification} - setPreviewNotification={setPreviewNotification} - previewNotificationLevel={previewNotificationLevel} - setPreviewNotificationLevel={setPreviewNotificationLevel} - applyNotifications={applyNotifications} - /> -
-
- ); - case 'servers': - return ( -
-
- updateSettings(s as AppSettings)} - exportServerConfig={exportServerConfig} - importBackup={importBackup} - /> -
-
- ); - case 'appearance': - return ( -
-
- updateSettings(s as AppSettings)} - /> -
-
- ); - case 'utilities': - return ( -
-
- -
-
- ); - case 'system': - return ( -
-
- updateSettings(s as AppSettings)} - exportSystemBackup={exportSystemBackup} - importBackup={importBackup} - /> -
-
- ); - case 'about': - return ( -
-
- updateSettings(s as AppSettings)} - /> -
-
- ); - default: - return ( -
-

Module not found

-
- ); - } - }; - - return ( - - <> - - - {renderContent()} - - - - ); -}; +import React, { useState, useEffect } from 'react'; +import { OptionsLayout } from './OptionsLayout'; +import { ServerConfigPanel } from '../../features/torrent-control/ui/ServerConfigPanel'; +import { FunctionSettings } from '../../features/torrent-control/ui/FunctionSettings'; +import { AboutTab } from '../../features/torrent-control/ui/AboutTab'; +import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'; +import { TorrentDashboard } from '../../features/torrent-control/ui/TorrentDashboard'; +import { Lock } from 'lucide-react'; +import { useTorrentSubscription } from '../../features/torrent-control/model/useTorrentSubscription'; +import { SystemSettings } from '@/shared/ui/SystemSettings'; +import { AppSettings, ContextMenuMode } from '@/shared/lib/types'; +import { Loading } from '@carbon/react'; +import { browser } from 'wxt/browser'; + +interface DashboardProps { + settings: AppSettings | null; + updateSettings: (settings: AppSettings) => Promise; + loading: boolean; + exportSystemBackup: (type?: 'full' | 'settings', sanitize?: boolean) => void; + exportServerConfig: (sanitize?: boolean) => void; + importBackup: (file: File) => Promise<{ success: boolean; message: string }>; + lockVault: () => Promise; + resetVault: () => Promise; +} + +export const Dashboard: React.FC = ({ + settings, + updateSettings, + loading, + exportSystemBackup, + exportServerConfig, + importBackup, + lockVault, + resetVault +}) => { + const [activeView, setActiveView] = useState('dashboard'); + + // Live queue subscription for this window (own viewport, auto-reconnect) + const subscription = useTorrentSubscription(); + + const [previewContextMenu, setPreviewContextMenu] = useState(1); + const [previewServers, setPreviewServers] = useState([]); + + useEffect(() => { + if (settings) { + setPreviewContextMenu(settings.globals.contextMenu); + setPreviewServers(settings.servers || []); + } + }, [settings]); + + // Handle Lock Action + useEffect(() => { + if (activeView === 'lock') { + lockVault().then(() => { + setActiveView('dashboard'); + }); + } + }, [activeView, lockVault]); + + const applyContextMenu = async () => { + if (!settings) return; + await updateSettings({ + ...settings, + globals: { + ...settings.globals, + contextMenu: previewContextMenu, + }, + servers: previewServers + }); + }; + + const primaryNavItems = [ + { id: 'dashboard', label: browser.i18n.getMessage('navDashboard') }, + { id: 'servers', label: browser.i18n.getMessage('navServers') }, + { id: 'settings', label: browser.i18n.getMessage('navSettings') }, + ]; + + const secondaryNavItems = [ + { id: 'system', label: browser.i18n.getMessage('navSystem') }, + { id: 'about', label: browser.i18n.getMessage('navAbout') }, + ]; + + const LockButton = ( + + ); + + const renderContent = () => { + if (loading || !settings) { + return ( +
+ +
+ ); + } + + switch (activeView) { + case 'dashboard': + return ( +
+
+
+ +
+
+
+ ); + case 'settings': + return ( +
+
+ updateSettings(s as AppSettings)} + previewContextMenu={previewContextMenu} + setPreviewContextMenu={setPreviewContextMenu} + applyContextMenu={applyContextMenu} + previewServers={previewServers} + setPreviewServers={setPreviewServers} + /> +
+
+ ); + case 'servers': + return ( +
+
+ updateSettings(s as AppSettings)} + exportServerConfig={exportServerConfig} + importBackup={importBackup} + /> +
+
+ ); + case 'system': + return ( +
+
+ +
+
+ ); + case 'about': + return ( +
+
+ updateSettings(s as AppSettings)} + /> +
+
+ ); + default: + return ( +
+

Module not found

+
+ ); + } + }; + + return ( + + + {renderContent()} + + + ); +}; diff --git a/extension/src/entrypoints/options/index.html b/extension/src/entrypoints/options/index.html index 38ecdbe..28ac95d 100755 --- a/extension/src/entrypoints/options/index.html +++ b/extension/src/entrypoints/options/index.html @@ -4,7 +4,8 @@ - Torrent Control Settings + CTRL Settings + @@ -12,4 +13,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/extension/src/entrypoints/options/main.tsx b/extension/src/entrypoints/options/main.tsx index 565d602..f2f8ec9 100755 --- a/extension/src/entrypoints/options/main.tsx +++ b/extension/src/entrypoints/options/main.tsx @@ -1,24 +1,15 @@ -import React, { Suspense } from 'react'; +import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; -import '../style.css'; -import '@/app/styles/global.css'; // New Global CSS +import '@/app/styles/index.css'; import { Theme } from '@carbon/react'; - -const DebugOverlay = __UI_DEBUG_MODE__ - ? React.lazy(() => import('@/shared/ui/debug/DebugOverlay').then(module => ({ default: module.DebugOverlay }))) - : () => null; + ReactDOM.createRoot(document.getElementById('root')!).render( - {__UI_DEBUG_MODE__ && ( - - - - )} , ); diff --git a/extension/src/entrypoints/popup/Popup.tsx b/extension/src/entrypoints/popup/Popup.tsx index 40bfcc1..67ed17a 100755 --- a/extension/src/entrypoints/popup/Popup.tsx +++ b/extension/src/entrypoints/popup/Popup.tsx @@ -1,93 +1,18 @@ -import React, { useState } from 'react'; -import { MainLayout } from '@/shared/ui/layout/MainLayout'; -import { Dashboard } from '../../features/torrent-control/ui/Dashboard'; -import { Settings, Bug, Activity } from 'lucide-react'; -import { VersionOverlay } from '@/shared/ui/VersionOverlay'; -import { Tabs, TabList, Tab } from '@carbon/react'; -import { browser } from 'wxt/browser'; - -type ViewType = 'torrents' | 'settings' | 'debug'; - -const Popup = () => { - const [activeView, setActiveView] = useState('torrents'); - - const handleTabChange = ({ selectedIndex }: { selectedIndex: number }) => { - const views: ViewType[] = ['torrents', 'settings', 'debug']; - setActiveView(views[selectedIndex]); - }; - - const renderContent = () => { - switch (activeView) { - case 'torrents': - return ; - case 'settings': - return ( -
-
- -

{browser.i18n.getMessage('popupGlobalSettings')}

-

{browser.i18n.getMessage('popupDescription')}

- -
-
- ); - case 'debug': - return ( -
-
- -

{browser.i18n.getMessage('popupDebugTools')}

-
- -
-

{browser.i18n.getMessage('popupUiInspection')}

- -
-
- ); - default: - return null; - } - }; - - - return ( - <> - - -
- - - {browser.i18n.getMessage('popupTabControl')} - {browser.i18n.getMessage('popupTabSettings')} - {browser.i18n.getMessage('popupTabDebug')} - - -
-
- {renderContent()} -
-
- - ); -}; - -export default Popup; - +import React from 'react'; +import { MainLayout } from '@/shared/ui/layout/MainLayout'; +import { Dashboard } from '../../features/torrent-control/ui/Dashboard'; +import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'; + +const Popup = () => { + return ( + + +
+ +
+
+
+ ); +}; + +export default Popup; diff --git a/extension/src/entrypoints/popup/index.html b/extension/src/entrypoints/popup/index.html index e9b56fc..d0a9ee6 100755 --- a/extension/src/entrypoints/popup/index.html +++ b/extension/src/entrypoints/popup/index.html @@ -4,7 +4,7 @@ - Torrent Control + CTRL @@ -12,4 +12,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/extension/src/entrypoints/popup/main.tsx b/extension/src/entrypoints/popup/main.tsx index 2bae2be..ad22714 100755 --- a/extension/src/entrypoints/popup/main.tsx +++ b/extension/src/entrypoints/popup/main.tsx @@ -1,25 +1,16 @@ -import React, { Suspense } from 'react'; +import React from 'react'; import ReactDOM from 'react-dom/client'; import Popup from './Popup'; -import '../style.css'; -import '@/app/styles/global.css'; // New Global CSS +import '@/app/styles/index.css'; import { Theme } from '@carbon/react'; import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'; - -const DebugOverlay = __UI_DEBUG_MODE__ - ? React.lazy(() => import('@/shared/ui/debug/DebugOverlay').then(module => ({ default: module.DebugOverlay }))) - : () => null; + ReactDOM.createRoot(document.getElementById('root')!).render( - {__UI_DEBUG_MODE__ && ( - - - - )} , ); diff --git a/extension/src/entrypoints/style.css b/extension/src/entrypoints/style.css deleted file mode 100755 index 02e2570..0000000 --- a/extension/src/entrypoints/style.css +++ /dev/null @@ -1,92 +0,0 @@ -@import '@carbon/styles/css/styles.css'; -@import '@ibm/plex/css/ibm-plex.css'; -@import '../app/styles/global.css'; -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* Legacy theme blocks removed to allow Carbon tokens to take precedence */ - -/* Performance Mode Overrides */ -/* Performance Mode Overrides */ -[data-performance='fancy'] { - --shadow-glow: 0 0 15px rgba(59, 130, 246, 0.5); -} - -[data-performance='fancy'] button, -[data-performance='fancy'] .bg-card, -[data-performance='fancy'] input, -[data-performance='fancy'] select { - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -[data-performance='fancy'] button:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-glow); -} - -[data-performance='fancy'] .bg-card { - backdrop-filter: blur(12px); - background-color: color-mix(in srgb, var(--cds-layer-02), transparent 10%); - border: 1px solid color-mix(in srgb, var(--cds-border-subtle), transparent 80%); - border-radius: 0.5rem; -} - -[data-performance='low'] .bg-card, -[data-performance='low'] .bg-primary, -[data-performance='low'] .bg-secondary, -[data-performance='low'] button, -[data-performance='low'] input, -[data-performance='low'] select { - background-image: none !important; -} - -body { - background-color: var(--cds-background, #161616); - /* Fallback to g100 background */ - color: var(--cds-text-primary, #f4f4f4); -} - -html, -body, -#root { - height: 100%; - width: 100%; -} - -/* Low Performance / Accessible Toggle */ -[data-performance='low'] .ctrl-toggle-track { - background-color: transparent !important; - border: 2px solid var(--cds-text-secondary); - border-radius: 4px !important; - width: 48px !important; - height: 24px !important; - position: relative; -} - -[data-performance='low'] .ctrl-toggle-track::after { - display: none !important; -} - -[data-performance='low'] .ctrl-toggle-track::before { - content: "OFF"; - display: block; - color: var(--cds-text-secondary); - font-size: 11px; - font-family: monospace; - font-weight: bold; - text-align: center; - line-height: 20px; - width: 100%; -} - -[data-performance='low'] input:checked+.ctrl-toggle-track { - background-color: var(--cds-link-primary) !important; - border-color: var(--cds-link-primary) !important; -} - -[data-performance='low'] input:checked+.ctrl-toggle-track::before { - content: "ON"; - color: #ffffff; - /* Always white for contrast on accent */ -} \ No newline at end of file diff --git a/extension/src/features/torrent-control/model/exportSanitizer.ts b/extension/src/features/torrent-control/model/exportSanitizer.ts new file mode 100644 index 0000000..e417aa8 --- /dev/null +++ b/extension/src/features/torrent-control/model/exportSanitizer.ts @@ -0,0 +1,79 @@ +import type { ServerConfig } from '@/shared/lib/types'; +import { CLIENT_LIST } from '@/shared/lib/constants'; + +/** + * Safe-export sanitisation. + * + * A "safe" export must never contain a credential. Instead of listing the + * fields to strip (a denylist cannot be correct over an open + * `clientOptions` record), this builds the export from an explicit allowlist: + * only the named non-secret fields are copied, `httpAuth` keeps its username + * only, userinfo is removed from the address, and `clientOptions` keeps only + * the option keys that the client definition in `CLIENT_LIST` declares as + * user-visible settings. Anything else is dropped. + */ + +/** Non-secret top-level fields that survive a safe export. */ +export const SAFE_SERVER_FIELDS = [ + 'id', + 'name', + 'application', + 'type', + 'hostname', + 'username', + 'directories', + 'defaultDirectory', + 'defaultLabel', + 'showInContextMenu', +] as const; + +export type SafeServerConfig = Pick & { + httpAuth?: { username: string }; + clientOptions: Record; +}; + +function stripUserinfo(hostname: string): string { + try { + const url = new URL(hostname); + url.username = ''; + url.password = ''; + return url.toString(); + } catch { + // Not a parseable URL: keep the literal but remove anything before an '@'. + return hostname.replace(/^([a-z]+:\/\/)?[^/@]*@/i, '$1'); + } +} + +function allowedClientOptionKeys(type: string): Set { + const definition = CLIENT_LIST.find((c) => c.id === type); + return new Set((definition?.clientOptions ?? []).map((o) => o.name)); +} + +export function sanitizeServerForExport(server: ServerConfig): SafeServerConfig { + const safe: Partial = {}; + for (const field of SAFE_SERVER_FIELDS) { + const value = server[field]; + if (value !== undefined) { + (safe as Record)[field] = Array.isArray(value) ? [...value] : value; + } + } + if (typeof server.hostname === 'string') { + safe.hostname = stripUserinfo(server.hostname); + } + if (server.httpAuth?.username) { + safe.httpAuth = { username: server.httpAuth.username }; + } + const allowed = allowedClientOptionKeys(server.type); + const clientOptions: Record = {}; + for (const [key, value] of Object.entries(server.clientOptions ?? {})) { + if (allowed.has(key) && (typeof value === 'boolean' || typeof value === 'string' || typeof value === 'number')) { + clientOptions[key] = value; + } + } + safe.clientOptions = clientOptions; + return safe as SafeServerConfig; +} + +export function sanitizeServersForExport(servers: ServerConfig[]): SafeServerConfig[] { + return servers.map(sanitizeServerForExport); +} diff --git a/extension/src/features/torrent-control/model/serverForm.ts b/extension/src/features/torrent-control/model/serverForm.ts new file mode 100644 index 0000000..8135187 --- /dev/null +++ b/extension/src/features/torrent-control/model/serverForm.ts @@ -0,0 +1,128 @@ +import type { ServerConfig } from '@/shared/lib/types'; +import { DEFAULT_CLIENT_ID, getClientCapability } from '@/shared/lib/constants'; +import { isPrivateHost, parseEndpoint } from '@/shared/lib/endpoint'; +import { newServerId } from '@/entities/server/lib/serverIdentity'; + +/** + * Form model for the server configuration workflow. + * + * The form holds the address as one complete URL string, exactly as the user + * typed it (or exactly as it was stored). Nothing is split into host/port + * fields, so IPv6 literals, non-default ports and reverse-proxy sub-paths + * survive the edit cycle. On save the address goes through `parseEndpoint` + * once, so what is stored is always an absolute http(s) URL ending in "/", + * and a stored value re-parses to itself (save → reload → edit → save is a + * fixed point). + */ +export interface ServerFormValues { + name: string; + clientId: string; + address: string; + username: string; + password: string; +} + +export type ServerFormField = keyof ServerFormValues; +export type ServerFormErrors = Partial>; + +export const MAX_SERVER_NAME_LENGTH = 64; + +export function emptyServerForm(clientId: string = DEFAULT_CLIENT_ID): ServerFormValues { + return { name: '', clientId, address: '', username: '', password: '' }; +} + +export function serverToForm(server: ServerConfig): ServerFormValues { + return { + name: server.name ?? '', + clientId: server.type || server.application || DEFAULT_CLIENT_ID, + address: server.hostname ?? '', + username: server.username ?? '', + password: server.password ?? '', + }; +} + +/** Example address shown in the empty address field for a client type. */ +export function addressPlaceholder(clientId: string): string { + return getClientCapability(clientId)?.addressPlaceholder ?? 'http://127.0.0.1:8080/'; +} + +export interface AddressAnalysis { + ok: boolean; + /** Canonical form that will be stored, or null when invalid. */ + normalized: string | null; + /** Origin used for the host-permission grant, or null when invalid. */ + origin: string | null; + hostname: string | null; + error: string | null; + /** Plain http:// to a host outside loopback/LAN: traffic is observable and modifiable in transit. */ + plainHttpRemote: boolean; +} + +export function analyzeAddress(address: string): AddressAnalysis { + const parsed = parseEndpoint(address); + if (!parsed.ok) { + return { ok: false, normalized: null, origin: null, hostname: null, error: parsed.error, plainHttpRemote: false }; + } + const isHttp = parsed.url.protocol === 'http:'; + return { + ok: true, + normalized: parsed.normalized, + origin: parsed.url.origin, + hostname: parsed.url.hostname, + error: null, + plainHttpRemote: isHttp && !isPrivateHost(parsed.url.hostname), + }; +} + +export function validateServerForm(values: ServerFormValues): ServerFormErrors { + const errors: ServerFormErrors = {}; + const name = values.name.trim(); + if (!name) { + errors.name = 'Enter a name for this server.'; + } else if (name.length > MAX_SERVER_NAME_LENGTH) { + errors.name = `Keep the name to ${MAX_SERVER_NAME_LENGTH} characters or fewer.`; + } + if (!getClientCapability(values.clientId)) { + errors.clientId = 'Choose a BitTorrent client.'; + } + const address = analyzeAddress(values.address); + if (!address.ok) { + errors.address = address.error ?? 'Enter the server address.'; + } + return errors; +} + +export function isServerFormValid(values: ServerFormValues): boolean { + return Object.keys(validateServerForm(values)).length === 0; +} + +/** + * Builds the configuration to persist. Fields the form does not edit + * (identity, directories, defaults, HTTP auth, context-menu visibility) are + * carried over from the existing entry. Client-specific options are kept only + * while the client type is unchanged. + * + * @throws Error with the first validation message when the values are invalid. + */ +export function formToServer(values: ServerFormValues, existing?: ServerConfig | null): ServerConfig { + const errors = validateServerForm(values); + const firstError = Object.values(errors)[0]; + if (firstError) throw new Error(firstError); + + const address = parseEndpoint(values.address); + if (!address.ok) throw new Error(address.error); + + const sameClient = !!existing && existing.type === values.clientId; + return { + ...(existing ?? {}), + id: existing?.id ?? newServerId(), + name: values.name.trim(), + application: values.clientId, + type: values.clientId, + hostname: address.normalized, + username: values.username, + password: values.password, + directories: existing?.directories ?? [], + clientOptions: sameClient ? (existing?.clientOptions ?? {}) : {}, + }; +} diff --git a/extension/src/features/torrent-control/model/services/ContextMenuService.ts b/extension/src/features/torrent-control/model/services/ContextMenuService.ts index 10f4964..cf14678 100755 --- a/extension/src/features/torrent-control/model/services/ContextMenuService.ts +++ b/extension/src/features/torrent-control/model/services/ContextMenuService.ts @@ -1,494 +1,418 @@ -import { singleton } from 'tsyringe'; -import { storage } from 'wxt/utils/storage'; -import { ITorrentClient } from '@/entities/client/model/ITorrentClient'; -import { AppSettings, ServerConfig } from '@/shared/lib/types'; -import { DEFAULT_OPTIONS } from '@/shared/lib/constants'; -import { SESSION_KEY_KEY, VAULT_DATA_KEY, VAULT_SALT_KEY } from '@/shared/api/security/VaultService'; -import { ServerResolver, ResolutionState, ResolvedServers } from '@/shared/api/server/ServerResolver'; - -const FALLBACK_SESSION_KEY = 'local:session_encryptionKey'; - -/** Debounce window (ms) — absorbs rapid-fire storage events into one rebuild. */ -const REBUILD_DEBOUNCE_MS = navigator.userAgent.includes('Firefox') ? 300 : 200; - -/** Last-known-good cache TTL (ms) — prevents transient NO_SERVERS from clearing menus. */ -const LAST_GOOD_TTL_MS = 3000; - -type VaultState = 'uninitialized' | 'locked' | 'unlocked'; - -@singleton() -export class ContextMenuService { - private clientProvider: (serverIndex?: number) => Promise<{ client: ITorrentClient | null, state: ResolutionState }>; - - // Coalescing state - private rebuildTimer: ReturnType | null = null; - private isRebuilding = false; - private pendingRebuild = false; - - // Last-known-good stabilization - private lastGoodResult: ResolvedServers | null = null; - private lastGoodTimestamp = 0; - private lastVaultState: VaultState = 'uninitialized'; - - constructor() { - this.clientProvider = async () => ({ client: null, state: ResolutionState.UNINITIALIZED }); - } - - initialize(clientProvider: (serverIndex?: number) => Promise<{ client: ITorrentClient | null, state: ResolutionState }>) { - this.clientProvider = clientProvider; - console.log('[ContextMenu] Initializing ContextMenuService'); - this.scheduleRebuild('initialize', true); - this.setupListeners(); - - // Watch for settings changes to rebuild menus - storage.watch('local:options', () => { - console.log('[ContextMenu] options changed, scheduling rebuild'); - this.scheduleRebuild('options'); - }); - - // Watch for Vault Data changes (e.g. servers updated) - storage.watch(VAULT_DATA_KEY, () => { - console.log('[ContextMenu] Vault data changed, scheduling rebuild'); - this.scheduleRebuild('vault_data'); - }); - - // Watch for Session Key (Unlock/Lock) - storage.watch(SESSION_KEY_KEY, () => { - console.log('[ContextMenu] Session key changed, scheduling rebuild'); - this.scheduleRebuild('session_key'); - }); - - // [FF Fix] Watch for fallback session key in Firefox - if (navigator.userAgent.includes('Firefox')) { - storage.watch(FALLBACK_SESSION_KEY, () => { - console.log('[ContextMenu] FF Fallback session key changed, scheduling rebuild'); - this.scheduleRebuild('ff_fallback_key'); - }); - } - - // [Fix] Watch for Vault Initialization - storage.watch(VAULT_SALT_KEY, () => { - console.log('[ContextMenu] Vault initialization state changed, scheduling rebuild'); - this.scheduleRebuild('vault_salt'); - }); - - // Ensure fresh setup on install/update - chrome.runtime.onInstalled.addListener(() => { - console.log('[ContextMenu] onInstalled triggered, scheduling rebuild'); - this.scheduleRebuild('onInstalled', true); - }); - } - - /** - * Public entry point for external callers (e.g. onStartup in background.ts). - * Forces an immediate rebuild (no debounce). - */ - ensureMenus() { - console.log('[ContextMenu] ensureMenus() called'); - this.scheduleRebuild('ensureMenus', true); - } - - /** - * Coalesces multiple rapid triggers into a single rebuild. - * If `immediate` is true, fires right away (for startup / onInstalled). - */ - private scheduleRebuild(source: string, immediate = false) { - console.debug(`[ContextMenu] scheduleRebuild source=${source} immediate=${immediate}`); - - if (immediate) { - // Cancel any pending debounced timer - if (this.rebuildTimer !== null) { - clearTimeout(this.rebuildTimer); - this.rebuildTimer = null; - } - this.doRebuild(source); - return; - } - - // Debounced — if timer already set, the pending trigger is absorbed - if (this.rebuildTimer !== null) { - console.debug(`[ContextMenu] Absorbing trigger '${source}' into pending debounce`); - return; - } - - this.rebuildTimer = setTimeout(() => { - this.rebuildTimer = null; - this.doRebuild(source); - }, REBUILD_DEBOUNCE_MS); - } - - /** - * Wrapper around chrome.contextMenus.create that checks runtime.lastError. - * Soft-fails: logs the error but does not throw. - */ - private safeCreate(props: chrome.contextMenus.CreateProperties) { - chrome.contextMenus.create(props, () => { - if (chrome.runtime.lastError) { - console.warn('[ContextMenu] create() error for', props.id, ':', chrome.runtime.lastError.message); - } - }); - } - - /** - * Enhanced stabilization: tracks vault state and applies last-known-good for transient failures. - */ - private stabilizeResolution(current: ResolvedServers): ResolvedServers { - const now = Date.now(); - - // Cache good states and track vault as unlocked - if (current.state === ResolutionState.OK) { - this.lastGoodResult = current; - this.lastGoodTimestamp = now; - this.lastVaultState = 'unlocked'; - return current; - } - - // Security states always override cache and update vault tracking - if (current.state === ResolutionState.LOCKED || current.state === ResolutionState.UNINITIALIZED) { - this.lastVaultState = current.state === ResolutionState.LOCKED ? 'locked' : 'uninitialized'; - // Reset cache on genuine vault state change to prevent showing stale OK menus - this.lastGoodResult = null; - return current; - } - - // Transient failure states: use cache if within TTL and vault is still unlocked - if ((current.state === ResolutionState.NO_SERVERS || current.state === ResolutionState.INVALID_CONFIG) && - this.lastGoodResult !== null && - (now - this.lastGoodTimestamp) < LAST_GOOD_TTL_MS && - this.lastVaultState === 'unlocked') { - console.debug(`[ContextMenu] Using last-known-good (transient ${current.state} within TTL, vault=${this.lastVaultState})`); - return this.lastGoodResult; - } - - return current; - } - - /** - * Pure function: determines which menu items should exist for a given resolution state. - * Returns empty array only for Hidden mode. - */ - private determineMenuItems( - resolution: ResolvedServers, - mode: number, - custom: Partial> | undefined, - globals: AppSettings['globals'] - ): chrome.contextMenus.CreateProperties[] { - const items: chrome.contextMenus.CreateProperties[] = []; - - // Hidden mode: return empty array - if (mode === 0) { - return items; - } - - // Non-OK states: single fallback item - if (resolution.state !== ResolutionState.OK) { - if (resolution.state === ResolutionState.LOCKED || resolution.state === ResolutionState.UNINITIALIZED) { - items.push({ - id: 'unlock-vault', - title: resolution.state === ResolutionState.LOCKED - ? 'Unlock CTRL to add torrents' - : 'Setup CTRL to add torrents', - contexts: ['link', 'selection', 'page'], - }); - } else { - // NO_SERVERS, INVALID_CONFIG, NO_ACTIVE_SERVER - items.push({ - id: 'open-ctrl', - title: 'Open CTRL to configure servers', - contexts: ['link', 'selection', 'page'], - }); - } - return items; - } - - // OK state: build full menu - const servers = resolution.servers; - const showAdd = mode === 1 || mode === 2 || (mode === 3 && custom?.addToClient); - const showPaused = mode === 1 || (mode === 3 && custom?.pauseResume); - - // 1. Add to Torrent Control (Default) - if (showAdd) { - items.push({ - id: 'add-torrent', - title: 'Add to Torrent Control', - contexts: ['link', 'selection'], - }); - - // 1.5 Scan Page - items.push({ - id: 'scan-page', - title: 'Scan Page for Magnets (CTRL)', - contexts: ['page', 'frame'], - }); - } - - // 2. Add Paused (if supported) - if (showPaused) { - items.push({ - id: 'add-torrent-paused', - title: 'Add Paused', - contexts: ['link'], - }); - } - - // 3. Server Selection (if multiple servers) - if (servers.length > 1) { - // 3a. Top Level Servers - servers.forEach((server: ServerConfig, index: number) => { - if (server.showInContextMenu) { - items.push({ - id: `add-torrent-server-${index}`, - title: `Add to ${server.name}`, - contexts: ['link'], - }); - } - }); - - // 3b. Submenu Servers (those NOT shown in top level) - const submenuServers = servers - .map((server: ServerConfig, index: number) => ({ ...server, originalIndex: index })) - .filter((server: ServerConfig & { showInContextMenu?: boolean }) => !server.showInContextMenu); - - if (submenuServers.length > 0) { - items.push({ - id: 'server-selection', - title: 'Add to Server...', - contexts: ['link'], - }); - - submenuServers.forEach((server: ServerConfig & { originalIndex: number }) => { - items.push({ - id: `add-torrent-server-${server.originalIndex}`, - parentId: 'server-selection', - title: server.name, - contexts: ['link'], - }); - }); - } - } - - // 4. Add with Label (Full Menu only) - if (mode === 1 && globals.labels && globals.labels.length > 0) { - items.push({ - id: 'label-selection', - title: 'Add with Label...', - contexts: ['link'], - }); - - globals.labels.forEach((label: string, index: number) => { - items.push({ - id: `add-torrent-label-${index}`, - parentId: 'label-selection', - title: label, - contexts: ['link'], - }); - }); - } - - // 5. Add to Path (Full Menu only) - const currentServer = servers[globals.currentServer || 0]; - if (mode === 1 && currentServer && currentServer.directories && currentServer.directories.length > 0) { - items.push({ - id: 'path-selection', - title: 'Add to Path...', - contexts: ['link'], - }); - - currentServer.directories.forEach((path: string, index: number) => { - items.push({ - id: `add-torrent-path-${index}`, - parentId: 'path-selection', - title: path, - contexts: ['link'], - }); - }); - } - - return items; - } - - /** - * Core rebuild — called once per coalesced trigger burst. - * Uses atomic menu replacement: determine full item set first, then replace. - */ - private async doRebuild(source: string) { - if (this.isRebuilding) { - console.debug(`[ContextMenu] Rebuild already running, marking pending (source=${source})`); - this.pendingRebuild = true; - return; - } - - this.isRebuilding = true; - this.pendingRebuild = false; - - try { - console.log(`[ContextMenu] doRebuild() started (source=${source})`); - const settings = await storage.getItem('local:options') || DEFAULT_OPTIONS; - const globals = settings?.globals || DEFAULT_OPTIONS.globals; - - // Carbon radio group can persist string values. Normalize mode so MV3 gating stays stable. - const parsedMode = Number(globals.contextMenu); - const mode = Number.isInteger(parsedMode) ? parsedMode : DEFAULT_OPTIONS.globals.contextMenu; - const custom = globals.contextMenuCustomOptions || DEFAULT_OPTIONS.globals.contextMenuCustomOptions; - - // ── Step 1: Resolver snapshot (single call per rebuild) ── - const rawResolution = await ServerResolver.resolve(); - console.debug(`[ContextMenu] Resolver snapshot: state=${rawResolution.state} servers=${rawResolution.servers.length}`); - - // ── Step 2: Apply stabilization ── - const resolution = this.stabilizeResolution(rawResolution); - console.debug(`[ContextMenu] Effective state=${resolution.state} servers=${resolution.servers.length} mode=${mode}`); - - // ── Step 3: Determine full menu set (pure function, no side effects) ── - const menuItems = this.determineMenuItems(resolution, mode, custom, globals); - console.debug(`[ContextMenu] Determined ${menuItems.length} items for state=${resolution.state}`); - - // ── Step 4: ATOMIC replacement ── - await chrome.contextMenus.removeAll(); - - if (menuItems.length > 0) { - console.debug('[ContextMenu] removeAll completed, creating menu items'); - for (const item of menuItems) { - this.safeCreate(item); - } - console.debug(`[ContextMenu] Menu rebuild complete — ${menuItems.length} items created`); - } else { - console.debug('[ContextMenu] Mode is Hidden — cleared all menus'); - } - } catch (e) { - console.error('[ContextMenu] Error in doRebuild:', e); - } finally { - this.isRebuilding = false; - if (this.pendingRebuild) { - console.debug('[ContextMenu] Processing pending rebuild'); - this.doRebuild('pending'); - } - } - } - - private setupListeners() { - chrome.contextMenus.onClicked.addListener(async (info, tab) => { - if (info.menuItemId === 'unlock-vault' || info.menuItemId === 'open-ctrl') { - chrome.runtime.openOptionsPage(); - return; - } - - // [FF MV3 Fix] Re-fetch canonical settings at click time to avoid transient hydration race. - const settings = await storage.getItem('local:options') || DEFAULT_OPTIONS; - const url = info.linkUrl || info.selectionText; - - try { - if (info.menuItemId === 'scan-page') { - if (tab && tab.id) { - chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: () => { - const magnets = Array.from(document.querySelectorAll('a[href^="magnet:"]')) - .map(a => a.getAttribute('href')) - .filter(href => href !== null) as string[]; - return magnets; - } - }, async (results) => { - const magnets = results?.[0]?.result; - if (magnets && magnets.length > 0) { - this.notify(true, `Found ${magnets.length} magnets. Adding...`); - const { client, state } = await this.clientProvider(); - if (!client) { - this.notify(false, `Failed to resolve client: ${state}`); - return; - } - try { - for (const magnet of magnets) { - await client.addTorrentUrl(magnet); - } - this.notify(true, `Added ${magnets.length} torrents.`); - } catch (e: unknown) { - const errMsg = e instanceof Error ? e.message : 'Failed to add torrents'; - this.notify(false, errMsg); - } - } else { - this.notify(false, 'No magnet links found on this page.'); - } - }); - } - return; - } - - if (!url) return; - - if (info.menuItemId === 'add-torrent' || info.menuItemId === 'add-torrent-paused') { - const { client, state } = await this.clientProvider(); - if (!client) { - this.notify(false, `Failed: ${state}`); - return; - } - const addOptions = info.menuItemId === 'add-torrent-paused' ? { paused: true } : {}; - await client.addTorrentUrl(url, addOptions); - this.notify(true, info.menuItemId === 'add-torrent-paused' ? 'Torrent added (paused)' : 'Torrent added successfully'); - } - else if (typeof info.menuItemId === 'string') { - if (info.menuItemId.startsWith('add-torrent-server-')) { - const serverIndex = parseInt(info.menuItemId.split('-').pop() || '0'); - const { client, state } = await this.clientProvider(serverIndex); - if (!client) { - this.notify(false, `Failed: ${state}`); - return; - } - await client.addTorrentUrl(url); - this.notify(true, `Torrent added to server`); - } - else if (info.menuItemId.startsWith('add-torrent-label-')) { - const labelIndex = parseInt(info.menuItemId.split('-').pop() || '0'); - const label = settings.globals.labels[labelIndex]; - - if (label) { - const { client, state } = await this.clientProvider(); - if (!client) { - this.notify(false, `Failed: ${state}`); - return; - } - await client.addTorrentUrl(url, { label }); - this.notify(true, `Torrent added with label: ${label}`); - } - } - else if (info.menuItemId.startsWith('add-torrent-path-')) { - const pathIndex = parseInt(info.menuItemId.split('-').pop() || '0'); - const { client, state } = await this.clientProvider(); - if (!client) { - this.notify(false, `Failed: ${state}`); - return; - } - - // We still need the server config for path lookup, get it from ServerResolver - const { servers } = await ServerResolver.resolve(); - const currentServer = servers[settings.globals.currentServer || 0]; - const path = currentServer?.directories[pathIndex]; - - if (path) { - await client.addTorrentUrl(url, { path }); - this.notify(true, `Torrent added to path: ${path}`); - } - } - } - } catch (e: unknown) { - const errMsg = e instanceof Error ? e.message : 'Unknown error'; - console.error('Context Menu Error:', e); - this.notify(false, `Failed to add torrent: ${errMsg}`); - } - }); - } - - private async notify(success: boolean, message: string) { - const settings = await storage.getItem('local:options'); - if (settings?.globals?.enableNotifications === false) { - return; - } - - chrome.notifications.create({ - type: 'basic', - iconUrl: 'icon/default-64.png', - title: success ? 'Torrent Control' : 'Error', - message: message, - }); - } -} +import { storage } from 'wxt/utils/storage'; +import { AppSettings, ContextMenuMode, ServerConfig } from '@/shared/lib/types'; +import { normalizeContextMenuMode } from '@/features/torrent-control/model/settingsSchema'; +import { DEFAULT_OPTIONS } from '@/shared/lib/constants'; +import { SESSION_KEY_KEY, VAULT_DATA_KEY, VAULT_SALT_KEY } from '@/shared/api/security/VaultService'; +import { ServerResolver, ResolutionState, ResolvedServers } from '@/shared/api/server/ServerResolver'; +import type { AddTorrentRequest, CommandResult } from '@/shared/api/messaging/protocol'; + +/** Debounce window (ms) — absorbs rapid-fire storage events into one rebuild. */ +const REBUILD_DEBOUNCE_MS = navigator.userAgent.includes('Firefox') ? 300 : 200; + +/** Last-known-good cache TTL (ms) — prevents transient NO_SERVERS from clearing menus. */ +const LAST_GOOD_TTL_MS = 3000; + +const SERVER_ITEM_PREFIX = 'add-torrent-server-'; +const LABEL_ITEM_PREFIX = 'add-torrent-label-'; +const PATH_ITEM_PREFIX = 'add-torrent-path-'; + +type VaultState = 'uninitialized' | 'locked' | 'unlocked'; + +/** + * Commands the menu can issue. Adds go through the background controller so + * the global add-paused default and server identity rules apply exactly as + * they do for the popup. + */ +export interface ContextMenuCommands { + addTorrent: (request: AddTorrentRequest) => Promise; +} + +export class ContextMenuService { + private commands: ContextMenuCommands; + + // Coalescing state + private rebuildTimer: ReturnType | null = null; + private isRebuilding = false; + private pendingRebuild = false; + + // Last-known-good stabilization + private lastGoodResult: ResolvedServers | null = null; + private lastGoodTimestamp = 0; + private lastVaultState: VaultState = 'uninitialized'; + + constructor() { + this.commands = { + addTorrent: async () => ({ ok: false, error: 'CTRL is still starting up.', errorType: 'NOT_READY' }), + }; + } + + initialize(commands: ContextMenuCommands) { + this.commands = commands; + this.scheduleRebuild('initialize', true); + this.setupListeners(); + + // Watch for settings changes to rebuild menus + storage.watch('local:options', () => { + this.scheduleRebuild('options'); + }); + + // Watch for Vault Data changes (e.g. servers updated) + storage.watch(VAULT_DATA_KEY, () => { + this.scheduleRebuild('vault_data'); + }); + + // Watch for Session Key (Unlock/Lock) + storage.watch(SESSION_KEY_KEY, () => { + this.scheduleRebuild('session_key'); + }); + + // Watch for Vault Initialization + storage.watch(VAULT_SALT_KEY, () => { + this.scheduleRebuild('vault_salt'); + }); + + // Ensure fresh setup on install/update + chrome.runtime.onInstalled.addListener(() => { + this.scheduleRebuild('onInstalled', true); + }); + } + + /** + * Public entry point for external callers (e.g. onStartup in background.ts). + * Forces an immediate rebuild (no debounce). + */ + ensureMenus() { + this.scheduleRebuild('ensureMenus', true); + } + + /** + * Coalesces multiple rapid triggers into a single rebuild. + * If `immediate` is true, fires right away (for startup / onInstalled). + */ + private scheduleRebuild(source: string, immediate = false) { + if (immediate) { + // Cancel any pending debounced timer + if (this.rebuildTimer !== null) { + clearTimeout(this.rebuildTimer); + this.rebuildTimer = null; + } + this.doRebuild(source); + return; + } + + // Debounced — if timer already set, the pending trigger is absorbed + if (this.rebuildTimer !== null) { + return; + } + + this.rebuildTimer = setTimeout(() => { + this.rebuildTimer = null; + this.doRebuild(source); + }, REBUILD_DEBOUNCE_MS); + } + + /** + * Wrapper around chrome.contextMenus.create that checks runtime.lastError. + * Soft-fails: logs the error but does not throw. + */ + private safeCreate(props: chrome.contextMenus.CreateProperties) { + chrome.contextMenus.create(props, () => { + if (chrome.runtime.lastError) { + console.warn('[ContextMenu] create() error for', props.id, ':', chrome.runtime.lastError.message); + } + }); + } + + /** + * Enhanced stabilization: tracks vault state and applies last-known-good for transient failures. + */ + private stabilizeResolution(current: ResolvedServers): ResolvedServers { + const now = Date.now(); + + // Cache good states and track vault as unlocked + if (current.state === ResolutionState.OK) { + this.lastGoodResult = current; + this.lastGoodTimestamp = now; + this.lastVaultState = 'unlocked'; + return current; + } + + // Security states always override cache and update vault tracking + if (current.state === ResolutionState.LOCKED || current.state === ResolutionState.UNINITIALIZED) { + this.lastVaultState = current.state === ResolutionState.LOCKED ? 'locked' : 'uninitialized'; + // Reset cache on genuine vault state change to prevent showing stale OK menus + this.lastGoodResult = null; + return current; + } + + // Transient failure states: use cache if within TTL and vault is still unlocked + if ((current.state === ResolutionState.NO_SERVERS || current.state === ResolutionState.INVALID_CONFIG) && + this.lastGoodResult !== null && + (now - this.lastGoodTimestamp) < LAST_GOOD_TTL_MS && + this.lastVaultState === 'unlocked') { + return this.lastGoodResult; + } + + return current; + } + + /** + * Pure function: determines which menu items should exist for a given resolution state. + * Returns empty array only for Hidden mode. + */ + private determineMenuItems( + resolution: ResolvedServers, + mode: ContextMenuMode, + globals: AppSettings['globals'] + ): chrome.contextMenus.CreateProperties[] { + const items: chrome.contextMenus.CreateProperties[] = []; + + // Hidden mode: return empty array + if (mode === 0) { + return items; + } + + // Non-OK states: single fallback item + if (resolution.state !== ResolutionState.OK) { + if (resolution.state === ResolutionState.LOCKED || resolution.state === ResolutionState.UNINITIALIZED) { + items.push({ + id: 'unlock-vault', + title: resolution.state === ResolutionState.LOCKED + ? 'Unlock CTRL to add torrents' + : 'Set up CTRL to add torrents', + contexts: ['link', 'selection', 'page'], + }); + } else { + // NO_SERVERS, INVALID_CONFIG, NO_ACTIVE_SERVER + items.push({ + id: 'open-ctrl', + title: 'Open CTRL to configure servers', + contexts: ['link', 'selection', 'page'], + }); + } + return items; + } + + // OK state: build full menu + const servers = resolution.servers; + const showAdd = mode === 1 || mode === 2; + const showPaused = mode === 1; + + // 1. Add to CTRL (default server, global add-paused default applies) + if (showAdd) { + items.push({ + id: 'add-torrent', + title: 'Add to CTRL', + contexts: ['link', 'selection'], + }); + } + + // 2. Add Paused (explicit override) + if (showPaused) { + items.push({ + id: 'add-torrent-paused', + title: 'Add to CTRL (paused)', + contexts: ['link'], + }); + } + + // 3. Server Selection (if multiple servers). Items are keyed by the + // server's stable id, never by its position in the list. + if (servers.length > 1) { + const withIds = servers.filter((s): s is ServerConfig & { id: string } => typeof s.id === 'string'); + + withIds.forEach((server) => { + if (server.showInContextMenu) { + items.push({ + id: `${SERVER_ITEM_PREFIX}${server.id}`, + title: `Add to ${server.name}`, + contexts: ['link'], + }); + } + }); + + const submenuServers = withIds.filter((server) => !server.showInContextMenu); + + if (submenuServers.length > 0) { + items.push({ + id: 'server-selection', + title: 'Add to server...', + contexts: ['link'], + }); + + submenuServers.forEach((server) => { + items.push({ + id: `${SERVER_ITEM_PREFIX}${server.id}`, + parentId: 'server-selection', + title: server.name, + contexts: ['link'], + }); + }); + } + } + + // 4. Add with Label (Full Menu only) + if (mode === 1 && globals.labels && globals.labels.length > 0) { + items.push({ + id: 'label-selection', + title: 'Add with label...', + contexts: ['link'], + }); + + globals.labels.forEach((label: string, index: number) => { + items.push({ + id: `${LABEL_ITEM_PREFIX}${index}`, + parentId: 'label-selection', + title: label, + contexts: ['link'], + }); + }); + } + + // 5. Add to Path (Full Menu only) + const currentServer = resolution.activeServer; + if (mode === 1 && currentServer && currentServer.directories && currentServer.directories.length > 0) { + items.push({ + id: 'path-selection', + title: 'Add to folder...', + contexts: ['link'], + }); + + currentServer.directories.forEach((path: string, index: number) => { + items.push({ + id: `${PATH_ITEM_PREFIX}${index}`, + parentId: 'path-selection', + title: path, + contexts: ['link'], + }); + }); + } + + return items; + } + + /** + * Core rebuild — called once per coalesced trigger burst. + * Uses atomic menu replacement: determine full item set first, then replace. + */ + private async doRebuild(source: string) { + if (this.isRebuilding) { + this.pendingRebuild = true; + return; + } + + this.isRebuilding = true; + this.pendingRebuild = false; + + try { + const settings = await storage.getItem('local:options') || DEFAULT_OPTIONS; + const globals = settings?.globals || DEFAULT_OPTIONS.globals; + + // Stored values may be strings (old radio groups) or the removed + // "custom" mode; normalise to the retained 0/1/2 set. + const mode = normalizeContextMenuMode(globals.contextMenu); + + // ── Step 1: Resolver snapshot (single call per rebuild) ── + const rawResolution = await ServerResolver.resolve(); + + // ── Step 2: Apply stabilization ── + const resolution = this.stabilizeResolution(rawResolution); + + // ── Step 3: Determine full menu set (pure function, no side effects) ── + const menuItems = this.determineMenuItems(resolution, mode, globals); + + // ── Step 4: ATOMIC replacement ── + await chrome.contextMenus.removeAll(); + + for (const item of menuItems) { + this.safeCreate(item); + } + } catch (e) { + console.error(`[ContextMenu] Error in doRebuild (source=${source}):`, e); + } finally { + this.isRebuilding = false; + if (this.pendingRebuild) { + this.doRebuild('pending'); + } + } + } + + private setupListeners() { + chrome.contextMenus.onClicked.addListener(async (info) => { + if (info.menuItemId === 'unlock-vault' || info.menuItemId === 'open-ctrl') { + chrome.runtime.openOptionsPage(); + return; + } + + const url = (info.linkUrl || info.selectionText || '').trim(); + if (!url) return; + + const menuItemId = String(info.menuItemId); + + try { + if (menuItemId === 'add-torrent') { + await this.submit({ type: 'ADD_TORRENT_URL', url }, 'Torrent added'); + return; + } + if (menuItemId === 'add-torrent-paused') { + await this.submit({ type: 'ADD_TORRENT_URL', url, options: { paused: true } }, 'Torrent added (paused)'); + return; + } + if (menuItemId.startsWith(SERVER_ITEM_PREFIX)) { + const serverId = menuItemId.slice(SERVER_ITEM_PREFIX.length); + await this.submit({ type: 'ADD_TORRENT_URL', url, serverId }, 'Torrent added'); + return; + } + if (menuItemId.startsWith(LABEL_ITEM_PREFIX)) { + // Re-read canonical settings at click time to avoid a stale label list. + const settings = await storage.getItem('local:options') || DEFAULT_OPTIONS; + const labelIndex = parseInt(menuItemId.slice(LABEL_ITEM_PREFIX.length), 10); + const label = settings.globals.labels?.[labelIndex]; + if (!label) { + this.notify(false, 'That label no longer exists. Open CTRL settings and try again.'); + return; + } + await this.submit({ type: 'ADD_TORRENT_URL', url, options: { label } }, `Torrent added with label: ${label}`); + return; + } + if (menuItemId.startsWith(PATH_ITEM_PREFIX)) { + const pathIndex = parseInt(menuItemId.slice(PATH_ITEM_PREFIX.length), 10); + const { activeServer } = await ServerResolver.resolve(); + const path = activeServer?.directories?.[pathIndex]; + if (!path || !activeServer?.id) { + this.notify(false, 'That folder no longer exists. Open CTRL settings and try again.'); + return; + } + await this.submit({ type: 'ADD_TORRENT_URL', url, serverId: activeServer.id, options: { path } }, `Torrent added to folder: ${path}`); + } + } catch (e: unknown) { + const errMsg = e instanceof Error ? e.message : 'Unknown error'; + console.error('[ContextMenu] add failed:', e); + this.notify(false, `Failed to add torrent: ${errMsg}`); + } + }); + } + + private async submit(request: AddTorrentRequest, successMessage: string): Promise { + const result = await this.commands.addTorrent(request); + if (result.ok) { + this.notify(true, successMessage); + } else { + this.notify(false, result.error ?? 'Failed to add torrent.'); + } + } + + private async notify(success: boolean, message: string) { + const settings = await storage.getItem('local:options'); + if (settings?.globals?.enableNotifications === false) { + return; + } + + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icon/default-64.png', + title: success ? 'CTRL' : 'CTRL — error', + message: message, + }); + } +} diff --git a/extension/src/features/torrent-control/model/settingsSchema.ts b/extension/src/features/torrent-control/model/settingsSchema.ts new file mode 100644 index 0000000..91ca267 --- /dev/null +++ b/extension/src/features/torrent-control/model/settingsSchema.ts @@ -0,0 +1,100 @@ +import { z } from 'zod'; +import { AppOptions, ContextMenuMode, GlobalOptions } from '@/shared/lib/types'; +import { DEFAULT_OPTIONS } from '@/shared/lib/constants'; + +/** + * Persisted-settings contract for v1. + * + * Only keys with a runtime consumer are retained. Anything else found in + * `local:options` or in an imported backup — the removed appearance, layout, + * notification level/style, debug, regex, diagnostics and custom + * context-menu keys, or keys from a future version — is dropped on load and + * on import so that stale preferences can never make configuration loading + * fail or resurrect removed behaviour. + */ + +/** Context-menu mode value that used to select the removed "custom" menu. */ +export const LEGACY_CUSTOM_CONTEXT_MENU_MODE = 3; + +export function normalizeContextMenuMode(value: unknown): ContextMenuMode { + const parsed = typeof value === 'number' ? value : Number(value); + if (parsed === 0 || parsed === 1 || parsed === 2) return parsed; + // The custom mode had no retained runtime behaviour beyond a subset of + // the full menu; users who had it now get the full menu. + if (parsed === LEGACY_CUSTOM_CONTEXT_MENU_MODE) return 1; + return DEFAULT_OPTIONS.globals.contextMenu; +} + +export const ServerConfigSchema = z.object({ + id: z.string().optional(), + name: z.string().default('New Server'), + application: z.string(), + type: z.string(), + hostname: z.string(), + username: z.string().optional(), + password: z.string().optional(), + directories: z.array(z.string()).default([]), + defaultDirectory: z.string().optional(), + defaultLabel: z.string().optional(), + clientOptions: z.record(z.unknown()).default({}), + httpAuth: z.object({ + username: z.string(), + password: z.string().optional() + }).optional(), + showInContextMenu: z.boolean().optional(), +}).passthrough(); + +/** + * Retained global options. Unknown keys are stripped (Zod default), and a + * legacy custom context-menu value is folded into the full menu. + */ +export const GlobalOptionsSchema = z.object({ + contextMenu: z.unknown().transform(normalizeContextMenuMode).optional(), + addPaused: z.boolean().optional(), + addAdvanced: z.boolean().optional(), + enableNotifications: z.boolean().optional(), + labels: z.array(z.string()).optional(), + currentServer: z.number().int().nonnegative().optional(), + badgeInfo: z.enum(['none', 'count', 'speed']).optional(), +}); + +export const AppOptionsSchema = z.object({ + globals: GlobalOptionsSchema.optional(), + servers: z.array(ServerConfigSchema).optional(), +}); + +export const BackupSchema = z.object({ + version: z.number().optional(), + type: z.enum(['system_backup', 'server_config']).optional(), + subtype: z.enum(['full', 'settings']).optional(), + timestamp: z.string().optional(), + data: z.record(z.unknown()) +}); + +/** + * Merge whatever is in storage with the defaults, keeping only retained keys. + * Never throws: an unreadable `globals` falls back to the defaults. + */ +export function normalizeSettings(raw: unknown): AppOptions { + const source = (raw && typeof raw === 'object') ? raw as Record : {}; + const parsedGlobals = GlobalOptionsSchema.safeParse(source.globals ?? {}); + const globals: GlobalOptions = { + ...DEFAULT_OPTIONS.globals, + ...(parsedGlobals.success ? stripUndefined(parsedGlobals.data) : {}), + }; + const servers = Array.isArray(source.servers) ? source.servers as AppOptions['servers'] : []; + return { globals, servers }; +} + +/** Merge validated incoming globals over the current ones, defaults filling gaps. */ +export function mergeGlobals(current: Partial | undefined, incoming: Partial | undefined): GlobalOptions { + return { + ...DEFAULT_OPTIONS.globals, + ...stripUndefined(current ?? {}), + ...stripUndefined(incoming ?? {}), + }; +} + +function stripUndefined(obj: T): Partial { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial; +} diff --git a/extension/src/features/torrent-control/model/torrentCommands.ts b/extension/src/features/torrent-control/model/torrentCommands.ts new file mode 100644 index 0000000..d54daa1 --- /dev/null +++ b/extension/src/features/torrent-control/model/torrentCommands.ts @@ -0,0 +1,46 @@ +import { useTorrentStore } from '../../../stores/useTorrentStore'; +import type { CommandResult, TorrentCommandRequest } from '@/shared/api/messaging/protocol'; + +type Action = 'pause' | 'resume' | 'remove'; + +const MESSAGE_TYPES: Record = { + pause: 'PAUSE_TORRENT', + resume: 'RESUME_TORRENT', + remove: 'REMOVE_TORRENT', +}; + +/** + * Sends a torrent command for the server the row belongs to and tracks its + * pending/failed state in the store. There is no optimistic status change: + * the row shows "pausing…" until the background confirms and the next + * snapshot reflects the server's real state. + */ +export async function runTorrentCommand(serverId: string, torrentId: string, action: Action): Promise { + const { setPending, setFailure } = useTorrentStore.getState(); + setPending(torrentId, action); + setFailure(torrentId, null); + try { + const request: TorrentCommandRequest = { + type: MESSAGE_TYPES[action], + serverId, + torrentId, + deleteData: false, + }; + const result = (await chrome.runtime.sendMessage(request)) as CommandResult | undefined; + if (!result || typeof result !== 'object') { + const failure = { ok: false, error: 'No response from the extension background.' }; + setFailure(torrentId, failure.error); + return failure; + } + if (!result.ok) { + setFailure(torrentId, result.error ?? 'The command failed.'); + } + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setFailure(torrentId, message); + return { ok: false, error: message }; + } finally { + setPending(torrentId, null); + } +} diff --git a/extension/src/features/torrent-control/model/types/ITorrentClient.ts b/extension/src/features/torrent-control/model/types/ITorrentClient.ts deleted file mode 100755 index a06a736..0000000 --- a/extension/src/features/torrent-control/model/types/ITorrentClient.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shim for FSD Migration - re-exports from canonical location -export type { ITorrentClient, AddTorrentOptions } from '@/entities/client/model/ITorrentClient'; diff --git a/extension/src/features/torrent-control/model/useHostPermission.ts b/extension/src/features/torrent-control/model/useHostPermission.ts new file mode 100644 index 0000000..e717cc5 --- /dev/null +++ b/extension/src/features/torrent-control/model/useHostPermission.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { checkHostPermission, requestHostPermission } from '@/shared/lib/permissions'; + +export type HostPermissionStatus = + /** No usable address yet. */ + | 'idle' + | 'checking' + | 'granted' + | 'missing'; + +/** + * Tracks whether the optional host permission for an origin is granted. + * + * Re-checks whenever the origin changes and whenever the browser reports a + * permission being added or removed (for example the user revoking site + * access from the browser's extension settings while the form is open), so + * the form never claims access it no longer has. + */ +export function useHostPermission(origin: string | null) { + const [status, setStatus] = useState('idle'); + const seq = useRef(0); + + const check = useCallback(async () => { + const id = ++seq.current; + if (!origin) { + setStatus('idle'); + return; + } + setStatus('checking'); + const granted = await checkHostPermission(origin); + if (id !== seq.current) return; // a newer check superseded this one + setStatus(granted ? 'granted' : 'missing'); + }, [origin]); + + useEffect(() => { + void check(); + }, [check]); + + useEffect(() => { + const onChange = () => { void check(); }; + const added = chrome.permissions?.onAdded; + const removed = chrome.permissions?.onRemoved; + added?.addListener(onChange); + removed?.addListener(onChange); + return () => { + added?.removeListener(onChange); + removed?.removeListener(onChange); + }; + }, [check]); + + /** + * Asks the browser for the permission. Must be called synchronously from + * a user gesture (a click handler); Firefox rejects the request otherwise. + */ + const request = useCallback((): Promise => { + if (!origin) return Promise.resolve(false); + return requestHostPermission(origin).then((granted) => { + setStatus(granted ? 'granted' : 'missing'); + return granted; + }); + }, [origin]); + + return { status, check, request }; +} diff --git a/extension/src/features/torrent-control/model/useSettings.ts b/extension/src/features/torrent-control/model/useSettings.ts index 3d18ef3..b4a2168 100755 --- a/extension/src/features/torrent-control/model/useSettings.ts +++ b/extension/src/features/torrent-control/model/useSettings.ts @@ -2,93 +2,30 @@ import { useState, useEffect, useCallback } from 'react'; import { storage } from 'wxt/utils/storage'; import { AppOptions, ServerConfig } from '@/shared/lib/types'; import { DEFAULT_OPTIONS } from '@/shared/lib/constants'; - -import { z } from 'zod'; // Add Zod import +import { z } from 'zod'; +import { VaultService } from '@/shared/api/security/VaultService'; +import { sanitizeServersForExport } from './exportSanitizer'; +import { + AppOptionsSchema, + BackupSchema, + GlobalOptionsSchema, + ServerConfigSchema, + mergeGlobals, + normalizeSettings, +} from './settingsSchema'; export const settingsStorage = storage.defineItem('local:options', { defaultValue: DEFAULT_OPTIONS, }); -import { VaultService } from '@/shared/api/security/VaultService'; - -// Zod Schemas for Validation -const ServerConfigSchema = z.object({ - name: z.string().default('New Server'), - application: z.string(), - type: z.string(), - hostname: z.string(), - username: z.string().optional(), - password: z.string().optional(), - directories: z.array(z.string()).default([]), - clientOptions: z.record(z.unknown()).default({}), - httpAuth: z.object({ - username: z.string(), - password: z.string().optional() - }).optional() -}).passthrough(); - -const GlobalOptionsSchema = z.object({ - contextMenu: z.number().optional(), - addPaused: z.boolean().optional(), - addAdvanced: z.boolean().optional(), - enableNotifications: z.boolean().optional(), - notificationLevel: z.enum(['standard', 'verbose', 'error']).optional(), - debugMode: z.boolean().optional(), - matchRegExp: z.array(z.string()).optional(), - labels: z.array(z.string()).optional(), - currentServer: z.number().optional(), - showDiagnostics: z.boolean().optional(), - badgeInfo: z.enum(['none', 'count', 'speed']).optional(), - notificationStyle: z.enum(['toast', 'banner', 'modal']).optional(), - contextMenuCustomOptions: z.object({ - addToClient: z.boolean(), - pauseResume: z.boolean(), - openWebUI: z.boolean(), - }).optional(), -}).passthrough(); - -const AppearanceSchema = z.object({ - theme: z.string().optional(), - performance: z.enum(['low', 'standard', 'fancy']).optional(), -}).passthrough(); - -const LayoutSchema = z.object({ - sidebar: z.array(z.object({ - id: z.string(), - visible: z.boolean(), - order: z.number(), - })).optional() -}).passthrough(); - -const AppOptionsSchema = z.object({ - globals: GlobalOptionsSchema.optional(), - appearance: AppearanceSchema.optional(), - layout: LayoutSchema.optional(), - servers: z.array(ServerConfigSchema).optional(), -}).passthrough(); - -const BackupSchema = z.object({ - version: z.number().optional(), - type: z.enum(['system_backup', 'server_config']).optional(), - subtype: z.enum(['full', 'settings']).optional(), - timestamp: z.string().optional(), - data: z.record(z.unknown()) -}); - export function useSettings() { const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const load = useCallback(async () => { const val = await settingsStorage.getValue(); - // Deep merge logic - const merged = { - ...DEFAULT_OPTIONS, - ...val, - globals: { ...DEFAULT_OPTIONS.globals, ...val?.globals }, - appearance: { ...DEFAULT_OPTIONS.appearance, ...val?.appearance }, - layout: { ...DEFAULT_OPTIONS.layout, ...val?.layout } - } as AppOptions; + // Retained keys over defaults; obsolete keys from older versions are dropped. + const merged = normalizeSettings(val); // Try to load servers from Vault try { @@ -129,9 +66,6 @@ export function useSettings() { load(); // Reload on change }); - // Listen for vault unlock (custom event or polling? For now, we rely on parent re-render or polling) - // Ideally we'd watch a Vault state but WXT storage watch covers session key if we used storage. - return () => unwatch(); }, [load]); @@ -172,35 +106,20 @@ export function useSettings() { type: 'system_backup', subtype: type, timestamp: new Date().toISOString(), + /** Marks whether credentials are present so the file is self-describing. */ + containsSecrets: type === 'full' && !sanitize, data: {} as Partial }; - const dataToExport = { ...settings }; - - // Remove servers from generic backup if sanitizing or if it's settings only - if (sanitize || type === 'settings') { - // For 'settings' type we might want to strip servers anyway, but lets be explicit - // Actually, 'settings' type usually implies no servers. - // If type is 'full' and sanitize is true, we should probably strip sensitive fields from servers or remove them entirely? - // The user wanted "clearly different parts". - // Let's decide: System Backup (Full) includes everything. Sanitize strips passwords. - } - if (type === 'full') { - exportData.data = dataToExport; + exportData.data = { ...settings }; if (sanitize && exportData.data.servers) { - exportData.data.servers = exportData.data.servers.map((s: ServerConfig) => ({ - ...s, - password: '', // Clear password - httpAuth: s.httpAuth ? { ...s.httpAuth, password: '' } : undefined - })); + // Allowlist-based: only known non-secret fields survive. + exportData.data.servers = sanitizeServersForExport(exportData.data.servers) as unknown as ServerConfig[]; } } else { - // Settings Only (Global + Appearance) - exportData.data = { - globals: settings.globals, - appearance: settings.appearance - }; + // Settings only: global preferences, never servers. + exportData.data = { globals: settings.globals }; } downloadJson(exportData, `ctrl-backup-${type}-${new Date().toISOString().split('T')[0]}.json`); @@ -217,20 +136,16 @@ export function useSettings() { return; } - let serversToExport = [...serversToUse]; - - if (sanitize) { - serversToExport = serversToExport.map(s => ({ - ...s, - password: '', // Clear main password - httpAuth: s.httpAuth ? { ...s.httpAuth, password: '' } : undefined - })); - } + const serversToExport: unknown[] = sanitize + ? sanitizeServersForExport(serversToUse) + : [...serversToUse]; const exportData = { version: 2, type: 'server_config', timestamp: new Date().toISOString(), + /** Marks whether credentials are present so the file is self-describing. */ + containsSecrets: !sanitize, data: { servers: serversToExport } @@ -266,8 +181,6 @@ export function useSettings() { type?: unknown; subtype?: unknown; globals?: unknown; - appearance?: unknown; - layout?: unknown; servers?: unknown; data?: unknown; }; @@ -285,50 +198,37 @@ export function useSettings() { throw new Error('Unrecognized or malformed backup format.'); } - // 2. Full Validation (Zero state changes until this completes) + // 2. Full Validation (Zero state changes until this completes). + // Obsolete preference keys (appearance, layout, notification + // level/style, ...) are stripped by the schemas rather than rejected. let serversToImport: ServerConfig[] | undefined; - let settingsToImport: Partial | undefined; // Collector for validated settings + let globalsToImport: Partial | undefined; let successMessage = ''; const isVaultInitialized = await VaultService.isInitialized(); const isVaultLocked = await VaultService.isLocked(); if (isLegacy) { - // Validate legacy payload - const validatedGlobals = GlobalOptionsSchema.parse(raw.globals); - const validatedServers = z.array(ServerConfigSchema).parse(raw.servers); - - const validatedAppearance = raw.appearance ? AppearanceSchema.parse(raw.appearance) : undefined; - const validatedLayout = raw.layout ? LayoutSchema.parse(raw.layout) : undefined; - - settingsToImport = { - globals: validatedGlobals as AppOptions['globals'], - appearance: validatedAppearance as AppOptions['appearance'], - layout: validatedLayout as AppOptions['layout'] - }; - serversToImport = validatedServers; + globalsToImport = GlobalOptionsSchema.parse(raw.globals); + serversToImport = z.array(ServerConfigSchema).parse(raw.servers) as ServerConfig[]; successMessage = 'Legacy full backup imported.'; } else { - // Validate modern payload const meta = BackupSchema.parse(raw); if (meta.type === 'server_config') { if (!meta.data || !Array.isArray(meta.data.servers)) { throw new Error('Invalid server config: missing servers data.'); } - serversToImport = z.array(ServerConfigSchema).parse(meta.data.servers); + serversToImport = z.array(ServerConfigSchema).parse(meta.data.servers) as ServerConfig[]; successMessage = 'Server configuration imported.'; } else if (meta.type === 'system_backup') { const validatedData = AppOptionsSchema.parse(meta.data); + globalsToImport = validatedData.globals; if (meta.subtype === 'full') { - settingsToImport = validatedData as Partial; - serversToImport = validatedData.servers; + serversToImport = validatedData.servers as ServerConfig[] | undefined; successMessage = 'System backup imported.'; } else { - // Settings only - explicitly ensure servers are NOT in the import payload - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { servers: _, ...rest } = validatedData; - settingsToImport = rest as Partial; + // Settings only - servers are never taken from this payload successMessage = 'System settings imported.'; } } else { @@ -347,9 +247,6 @@ export function useSettings() { } // 4. ATOMIC COMMIT (Mutation Phase) - // If we reach here, validation passed and state is ready for mutation. - - // Snapshot current state for rollback on partial failure const vaultSnapshot = isVaultInitialized && !isVaultLocked ? await VaultService.getServers() : []; @@ -362,20 +259,12 @@ export function useSettings() { } // B. Update settings in local storage - if (settingsToImport) { - const current = await settingsStorage.getValue() || DEFAULT_OPTIONS; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { servers: _, ...safeIncoming } = settingsToImport; - - const merged = { - ...current, - ...safeIncoming, - globals: safeIncoming.globals ? { ...current.globals, ...safeIncoming.globals } : current.globals, - appearance: safeIncoming.appearance ? { ...current.appearance, ...safeIncoming.appearance } : current.appearance, - layout: safeIncoming.layout ? { ...current.layout, ...safeIncoming.layout } : current.layout, + if (globalsToImport) { + const current = normalizeSettings(await settingsStorage.getValue()); + const merged: AppOptions = { + globals: mergeGlobals(current.globals, globalsToImport), servers: [] // Always empty in local storage - } as AppOptions; - + }; await settingsStorage.setValue(merged); } @@ -383,7 +272,6 @@ export function useSettings() { await load(); resolve({ success: true, message: successMessage }); } catch (importError) { - // Rollback on failure if (__UI_DEBUG_MODE__) { console.error('[Import] Atomic commit failed, rolling back:', importError); } diff --git a/extension/src/features/torrent-control/model/useTorrentPoller.ts b/extension/src/features/torrent-control/model/useTorrentPoller.ts deleted file mode 100755 index 7557d3c..0000000 --- a/extension/src/features/torrent-control/model/useTorrentPoller.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useEffect } from 'react'; -import { useTorrentStore } from '../../../stores/useTorrentStore'; -import { useSettings } from './useSettings'; - -export const useTorrentPoller = (_intervalMs = 2000) => { - const { setViewportData, setLoading } = useTorrentStore(); - const { settings } = useSettings(); - - - useEffect(() => { - if (!settings || (settings.servers || []).length === 0) return; - - // 1. Establish Active Session Port (Keeps SW Alive & Signals Foreground) - const port = chrome.runtime.connect({ name: 'ctrl-active-session' }); - - // 2. Message Listener (via Port or Runtime mainly runtime for broadcast) - // Note: We keep runtime listener for global broadcasts, but Port is for lifecycle. - type PollerMessage = - | { type: 'VIEWPORT_UPDATE'; data: { items: unknown[]; total: number; start: number } } - | { type: 'VIEWPORT_DIFF'; data: { patches: unknown[]; total: number; start: number } } - | { type: 'STATS_UPDATE'; data: unknown }; - const messageListener = (message: PollerMessage) => { - if (message.type === 'VIEWPORT_UPDATE') { - const { items, total, start } = message.data; - setViewportData(items as Parameters[0], total, start); - setLoading(false); - } - if (message.type === 'VIEWPORT_DIFF') { - const { patches, total, start } = message.data; - const { applyPatchData } = useTorrentStore.getState(); - applyPatchData(patches as Parameters[0], total, start); - setLoading(false); - } - if (message.type === 'STATS_UPDATE') { - const { setGlobalStats } = useTorrentStore.getState(); - setGlobalStats(message.data as Parameters[0]); - } - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - chrome.runtime.onMessage.addListener(messageListener as any); - - // 3. Initial Request - setLoading(true); - // We can send the force refresh via the port or runtime. Runtime is fine. - chrome.runtime.sendMessage({ type: 'FORCE_REFRESH' }).catch(() => { }); - - port.onDisconnect.addListener(() => { - if (__UI_DEBUG_MODE__) { - console.log('Poller: Port disconnected (SW died or Unloaded)'); - } - }); - - return () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - chrome.runtime.onMessage.removeListener(messageListener as any); - port.disconnect(); - }; - }, [settings, setViewportData, setLoading]); -}; diff --git a/extension/src/features/torrent-control/model/useTorrentSubscription.ts b/extension/src/features/torrent-control/model/useTorrentSubscription.ts new file mode 100644 index 0000000..f421648 --- /dev/null +++ b/extension/src/features/torrent-control/model/useTorrentSubscription.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useTorrentStore } from '../../../stores/useTorrentStore'; +import { + ACTIVE_SESSION_PORT, + DEFAULT_VIEWPORT_SIZE, + type PortClientMessage, + type PortServerMessage, +} from '@/shared/api/messaging/protocol'; + +const RECONNECT_DELAY_MS = 1000; + +export interface Viewport { + start: number; + end: number; +} + +/** + * Subscribes this UI context to live queue data from the background. + * + * Opens the active-session port, declares the viewport it renders, and feeds + * every snapshot/status message into the torrent store. Holding the port also + * tells the background to poll at the fast rate. If the port drops (the + * background service worker was restarted), it reconnects automatically and + * re-declares the viewport so the list keeps updating without a page reload. + */ +export function useTorrentSubscription(initialViewport: Viewport = { start: 0, end: DEFAULT_VIEWPORT_SIZE }) { + const applySnapshot = useTorrentStore((s) => s.applySnapshot); + const applyStatus = useTorrentStore((s) => s.applyStatus); + + const portRef = useRef(null); + const viewportRef = useRef(initialViewport); + const activeRef = useRef(true); + const reconnectTimer = useRef | null>(null); + + const post = useCallback((message: PortClientMessage) => { + try { + portRef.current?.postMessage(message); + } catch { + // Port is closed; the reconnect path will re-send the viewport. + } + }, []); + + useEffect(() => { + activeRef.current = true; + + const connect = () => { + if (!activeRef.current) return; + let port: chrome.runtime.Port; + try { + port = chrome.runtime.connect({ name: ACTIVE_SESSION_PORT }); + } catch (error) { + console.warn('[Subscription] connect failed, retrying', error); + scheduleReconnect(); + return; + } + portRef.current = port; + + port.onMessage.addListener((message: PortServerMessage) => { + if (!message || typeof message !== 'object') return; + if (message.type === 'SNAPSHOT') applySnapshot(message); + else if (message.type === 'STATUS') applyStatus(message); + }); + + port.onDisconnect.addListener(() => { + if (portRef.current === port) portRef.current = null; + scheduleReconnect(); + }); + + const { start, end } = viewportRef.current; + port.postMessage({ type: 'SET_VIEWPORT', start, end } satisfies PortClientMessage); + }; + + const scheduleReconnect = () => { + if (!activeRef.current || reconnectTimer.current) return; + reconnectTimer.current = setTimeout(() => { + reconnectTimer.current = null; + connect(); + }, RECONNECT_DELAY_MS); + }; + + connect(); + + return () => { + activeRef.current = false; + if (reconnectTimer.current) { + clearTimeout(reconnectTimer.current); + reconnectTimer.current = null; + } + const port = portRef.current; + portRef.current = null; + try { + port?.disconnect(); + } catch { + // already gone + } + }; + }, [applySnapshot, applyStatus]); + + const setViewport = useCallback((start: number, end: number) => { + const next = { start: Math.max(0, start), end: Math.max(start + 1, end) }; + const prev = viewportRef.current; + if (prev.start === next.start && prev.end === next.end) return; + viewportRef.current = next; + post({ type: 'SET_VIEWPORT', start: next.start, end: next.end }); + }, [post]); + + const refresh = useCallback(() => post({ type: 'REFRESH' }), [post]); + + return { setViewport, refresh }; +} diff --git a/extension/src/features/torrent-control/model/useVault.ts b/extension/src/features/torrent-control/model/useVault.ts index 39fe0dd..b2afd78 100755 --- a/extension/src/features/torrent-control/model/useVault.ts +++ b/extension/src/features/torrent-control/model/useVault.ts @@ -1,53 +1,86 @@ -import { useState, useEffect, useCallback } from 'react'; -import { VaultService } from '@/shared/api/security/VaultService'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { storage } from 'wxt/utils/storage'; +import { VaultService, SESSION_KEY_KEY, VAULT_SALT_KEY, VAULT_DATA_KEY } from '@/shared/api/security/VaultService'; import { ServerConfig } from '@/shared/lib/types'; -export type VaultStatus = 'loading' | 'uninitialized' | 'locked' | 'unlocked'; +export type VaultStatus = 'loading' | 'uninitialized' | 'locked' | 'unlocked' | 'corrupted'; +/** + * Vault state for one UI context. + * + * The vault itself lives in extension storage; this hook mirrors it. It + * watches the session key, salt and ciphertext so that locking in any window + * (or the browser discarding the session) immediately redacts every other + * window, and so that a save in one window refreshes the others. + */ export const useVault = () => { const [status, setStatus] = useState('loading'); const [servers, setServers] = useState([]); + const [error, setError] = useState(null); + const checkSeq = useRef(0); const checkStatus = useCallback(async () => { + const seq = ++checkSeq.current; try { - const initialized = await VaultService.isInitialized(); - if (!initialized) { - setStatus('uninitialized'); - return; - } - - const locked = await VaultService.isLocked(); - if (locked) { - setStatus('locked'); - } else { - // If unlocked, fetch the data - try { - const data = await VaultService.getServers(); - setServers(data); - setStatus('unlocked'); - } catch (e) { - // Fallback to locked if fetch fails (e.g. key expired/missing) - console.error('Failed to fetch servers despite unlocked check:', e); + const state = await VaultService.getState(); + if (seq !== checkSeq.current) return; // a newer check superseded this one + switch (state) { + case 'uninitialized': + setServers([]); + setStatus('uninitialized'); + setError(null); + return; + case 'locked': + setServers([]); setStatus('locked'); + setError(null); + return; + case 'corrupted': + setServers([]); + setStatus('corrupted'); + setError('The stored vault data is incomplete or damaged.'); + return; + case 'unlocked': { + try { + const data = await VaultService.getServers(); + if (seq !== checkSeq.current) return; + setServers(data); + setStatus('unlocked'); + setError(null); + } catch (e) { + if (seq !== checkSeq.current) return; + // The session key is present but cannot decrypt the data. + console.error('[useVault] Unlocked but decryption failed:', e); + setServers([]); + setStatus('locked'); + } + return; } } } catch (e) { - console.error('Vault status check failed', e); + if (seq !== checkSeq.current) return; + console.error('[useVault] Vault status check failed', e); + setServers([]); setStatus('locked'); } }, []); useEffect(() => { - checkStatus(); - - // Optional: Listen for storage changes to lock status? - // WXT storage.watch might help, but session storage isn't watchable in the same way across contexts easily. - // For now, relies on local component state + mount check. - // If we want auto-lock on timeout, we'd need a polling interval or message listener. + void checkStatus(); + const unwatchers = [ + storage.watch(SESSION_KEY_KEY, () => { void checkStatus(); }), + storage.watch(VAULT_SALT_KEY, () => { void checkStatus(); }), + storage.watch(VAULT_DATA_KEY, () => { void checkStatus(); }), + ]; + return () => { + for (const unwatch of unwatchers) { + try { unwatch(); } catch { /* already removed */ } + } + }; }, [checkStatus]); const setup = async (password: string) => { - await VaultService.initialize(password); // Logic inside handles migration if needed + await VaultService.initialize(password); await checkStatus(); }; @@ -71,12 +104,23 @@ export const useVault = () => { setServers(newServers); }; + /** + * Destroys the vault: every saved server and the master password. The + * caller must have obtained explicit confirmation from the user. + */ + const reset = async () => { + await VaultService.reset(); + await checkStatus(); + }; + return { status, servers, + error, setup, unlock, lock, + reset, saveServers, refresh: checkStatus }; diff --git a/extension/src/features/torrent-control/services/LifecycleAdapter.ts b/extension/src/features/torrent-control/services/LifecycleAdapter.ts deleted file mode 100755 index d3d9b1a..0000000 --- a/extension/src/features/torrent-control/services/LifecycleAdapter.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { browser } from 'wxt/browser'; -import { WebSocketKeepalive } from '@/shared/lib/websocket/WebSocketKeepalive'; - -/** - * Serializable representation of a parsed DOM subtree. - * DRAFT (unvalidated) — see parseDOM below. - */ -export interface ParsedDOMNode { - tag: string; - id: string; - attributes: Record; - text: string; - children: ParsedDOMNode[]; -} - -export interface ParsedDOMResult { - title: string; - text: string; - root: ParsedDOMNode | null; -} - -const MAX_SERIALIZE_DEPTH = 25; - -function serializeElement(el: Element, depth = 0): ParsedDOMNode { - const attributes: Record = {}; - for (const attr of Array.from(el.attributes)) { - attributes[attr.name] = attr.value; - } - return { - tag: el.tagName.toLowerCase(), - id: el.id, - attributes, - // Full descendant text (like `textContent`), not just this element's own - // direct text nodes — otherwise inline-formatted content (e.g. a - // highlighted search term wrapped in /, common on torrent - // index/search pages) is silently dropped from a link or row label. - // Matches the semantics already used for `ParsedDOMResult.text`. - text: el.textContent?.trim() ?? '', - children: depth < MAX_SERIALIZE_DEPTH - ? Array.from(el.children).map(child => serializeElement(child, depth + 1)) - : [], - }; -} - -/** - * Service to handle browser-specific lifecycle management. - * - * - Chrome 116+: Uses Native WebSocket Keep-Alive for persistent connections. - * - Firefox/Brave: Uses Alarms API "Heartbeat" to prevent idle suspension. - * - Safari: (Not fully supported yet, falls back to Hydration). - */ -export const LifecycleAdapter = { - wsKeepalive: null as WebSocketKeepalive | null, - - /** - * Initializes the appropriate Keep-Alive mechanism for the current browser. - */ - initKeepAlive: async () => { - // Feature detection for Firefox-like extensive environments vs Chrome-like restricted environments. - // 'browser.runtime.getBrowserInfo' is typically Firefox-only and not in the standard WebExtension types. - const isFirefox = typeof (browser.runtime as unknown as Record).getBrowserInfo !== 'undefined'; - // Check Chrome version for WebSocket support in SW (Chrome 116+) - const chromeVersion = LifecycleAdapter.getChromeVersion(); - const hasWebSocketInSW = chromeVersion >= 116; - - if (isFirefox) { - console.log('[LifecycleAdapter] Firefox detected. Using Alarms heartbeat.'); - // Firefox Event Pages handle lifecycle differently, alarms handled in background.ts - return; - } - - if (hasWebSocketInSW && WebSocketKeepalive.isSupported()) { - console.log('[LifecycleAdapter] Chrome 116+ detected. WebSocket keepalive available.'); - // Note: We don't automatically connect here - the actual WebSocket connection - // would be to a torrent client that supports it (e.g., qBittorrent WebSocket API). - // For now, we just log capability. Actual connection happens when needed. - return; - } - console.log('[LifecycleAdapter] No keepalive mechanism available. Relying on Alarms + Hydration.'); - }, - - /** - * Start WebSocket keepalive with a specific URL (for clients that support WS) - */ - startWebSocketKeepalive(wsUrl: string, onMessage?: (data: unknown) => void): void { - if (!WebSocketKeepalive.isSupported()) { - console.warn('[LifecycleAdapter] WebSocket not supported'); - return; - } - - LifecycleAdapter.stopWebSocketKeepalive(); - - LifecycleAdapter.wsKeepalive = new WebSocketKeepalive({ - url: wsUrl, - heartbeatInterval: 25000, - maxReconnectAttempts: 10, - onMessage: onMessage || (() => { }), - onStateChange: (state) => { - console.log(`[LifecycleAdapter] WebSocket state: ${state}`); - }, - }); - - LifecycleAdapter.wsKeepalive.connect(); - }, - - /** - * Stop the WebSocket keepalive connection - */ - stopWebSocketKeepalive(): void { - LifecycleAdapter.wsKeepalive?.disconnect(); - LifecycleAdapter.wsKeepalive = null; - }, - - /** - * Get Chrome major version number - */ - getChromeVersion(): number { - try { - const match = navigator.userAgent.match(/Chrome\/(\d+)/); - return match ? parseInt(match[1], 10) : 0; - } catch { - return 0; - } - }, - - /** - * Abstracted DOM Parser. - * - Firefox: Uses native DOMParser in background. - * - Chrome: Delegates to Offscreen Document (if implemented) or throws. - * - * DRAFT: this previously returned the raw `Document`, which is not - * structured-clone serializable and cannot cross the extension message - * boundary. It now returns a plain-object tree (`ParsedDOMResult`) following - * the same extract-to-serializable convention the adapters use - * (UTorrentParsingUtils string extraction, XmlRpcHelper txml objects). - * The output shape has been exercised in - * tests/unit/LifecycleAdapter.parseDOM.test.ts against representative - * torrent-client-style HTML (a uTorrent-style token fragment, a - * search-result listing with highlighted/nested text and magnet links) - * and against structured-clone safety and deep nesting — see that file - * for what was and wasn't checked. There are still zero real callers in - * this codebase, so it has not been validated against any actual client's - * live HTML; verify against the real protocol before relying on it. - * - * @param html String HTML to parse - * @returns Serializable simplified representation of the document - */ - parseDOM: async (html: string): Promise => { - // Check for native DOM support (Firefox Event Pages) - if (typeof DOMParser !== 'undefined') { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - return { - title: doc.title, - text: doc.body?.textContent?.trim() ?? '', - root: doc.body ? serializeElement(doc.body) : null, - }; - } - - // Chrome Offscreen Fallback would go here. - // For now, we assume this is only called where safe or Chrome uses a different path. - throw new Error('[LifecycleAdapter] Native DOM parsing not available.'); - } -}; diff --git a/extension/src/features/torrent-control/services/StateHydrator.ts b/extension/src/features/torrent-control/services/StateHydrator.ts index d6f9bbd..7dfacfc 100755 --- a/extension/src/features/torrent-control/services/StateHydrator.ts +++ b/extension/src/features/torrent-control/services/StateHydrator.ts @@ -1,47 +1,60 @@ -import { browser } from 'wxt/browser'; - -const STORAGE_KEY = 'session:torrent_state'; - -// Simple debounce utility to avoid external dependency issues -function debounce(func: (...args: Args) => void, wait: number): (...args: Args) => void { - let timeout: ReturnType | null = null; - return function (...args: Args) { - if (timeout) clearTimeout(timeout); - timeout = setTimeout(() => { - func(...args); - }, wait); - }; -} - -/** - * Service to handle "Write-Through Hydration". - * Ensures the extension state survives Service Worker termination (Safari/Firefox/Chrome). - */ -export const StateHydrator = { - /** - * Reads the last known state from session storage. - * Call this on Service Worker startup. - */ - hydrate: async (): Promise => { - try { - const data = await browser.storage.session.get(STORAGE_KEY); - return data[STORAGE_KEY] as T || null; - } catch (error) { - console.warn('[StateHydrator] Failed to hydrate:', error); - return null; - } - }, - - /** - * Persists the state to session storage. - * Debounced to prevent thrashing storage on every single update. - */ - persist: debounce((state: unknown) => { - try { - browser.storage.session.set({ [STORAGE_KEY]: state }); - console.debug('[StateHydrator] State persisted to session storage.'); - } catch (error) { - console.error('[StateHydrator] Failed to persist state:', error); - } - }, 1000) // 1 second debounce -}; +import { browser } from 'wxt/browser'; +import type { PersistedSnapshot } from '@/shared/api/messaging/protocol'; + +const STORAGE_KEY = 'session:torrent_state'; + +// Simple debounce utility to avoid external dependency issues +function debounce(func: (...args: Args) => void, wait: number): (...args: Args) => void { + let timeout: ReturnType | null = null; + return function (...args: Args) { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + func(...args); + }, wait); + }; +} + +function isPersistedSnapshot(value: unknown): value is PersistedSnapshot { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return typeof v.serverId === 'string' && Array.isArray(v.torrents) && typeof v.savedAt === 'number'; +} + +/** + * Write-through persistence of the last good queue snapshot to + * `storage.session`, so a restarted background can show recent data (marked + * stale) while the first fresh poll runs. The snapshot carries the server id + * it belongs to; the controller ignores it when the active server differs. + */ +export const StateHydrator = { + /** + * Reads the last known snapshot from session storage. Returns null when + * nothing usable is stored (including legacy shapes from older builds). + */ + hydrate: async (): Promise => { + try { + const data = await browser.storage.session.get(STORAGE_KEY); + const value = data[STORAGE_KEY]; + return isPersistedSnapshot(value) ? value : null; + } catch (error) { + console.warn('[StateHydrator] Failed to hydrate:', error); + return null; + } + }, + + /** + * Persists the snapshot (or clears it when null). Debounced to avoid + * thrashing storage on every poll. + */ + persist: debounce((snapshot: PersistedSnapshot | null) => { + try { + if (snapshot) { + void browser.storage.session.set({ [STORAGE_KEY]: snapshot }); + } else { + void browser.storage.session.remove(STORAGE_KEY); + } + } catch (error) { + console.error('[StateHydrator] Failed to persist state:', error); + } + }, 1000), +}; diff --git a/extension/src/features/torrent-control/services/TorrentController.ts b/extension/src/features/torrent-control/services/TorrentController.ts new file mode 100644 index 0000000..b022d51 --- /dev/null +++ b/extension/src/features/torrent-control/services/TorrentController.ts @@ -0,0 +1,743 @@ +import type { ITorrentClient } from '@/entities/client/model/ITorrentClient'; +import type { ServerConfig, AppSettings } from '@/shared/lib/types'; +import type { Torrent } from '@/entities/torrent/model/Torrent'; +import { ResolutionState, type ResolvedServers } from '@/shared/api/server/ServerResolver'; +import { AdapterError } from '@/shared/api/clients/shared/AdapterError'; +import { serverFingerprint } from '@/entities/server/lib/serverIdentity'; +import { + DEFAULT_VIEWPORT_SIZE, + computeStats, + emptyStats, + initialConnectionState, + type AddTorrentRequest, + type CommandResult, + type ConnectionState, + type ConnectionStatus, + type GlobalStats, + type PersistedSnapshot, + type PortClientMessage, + type PortServerMessage, + type RuntimeRequest, + type StateResponse, + type TestConnectionResponse, + type TorrentCommandRequest, +} from '@/shared/api/messaging/protocol'; + +/** + * Minimal port surface the controller needs. `chrome.runtime.Port` satisfies it; + * tests supply an in-memory implementation. + */ +export interface PortLike { + postMessage(message: PortServerMessage): void; + onMessage: { addListener(cb: (message: PortClientMessage) => void): void }; + onDisconnect: { addListener(cb: () => void): void }; +} + +export interface ControllerDeps { + /** Resolves vault + settings into the current server list and active server. */ + resolve: () => Promise; + /** Creates an adapter for a configuration. */ + createClient: (config: ServerConfig) => Promise; + /** Whether the per-origin host permission for this URL is granted. */ + hasHostPermission: (url: string) => Promise; + /** + * Prepares the transport for a server before a client is created or a + * connection is tested (e.g. installs the Origin/Referer rewrite rule a + * client with same-origin checks needs). Failures are logged, never fatal. + */ + prepareTransport?: (config: ServerConfig) => Promise; + /** Reads global settings (add-paused default etc). */ + getSettings: () => Promise; + /** Persists the latest snapshot for recovery after a background restart. */ + persist?: (snapshot: PersistedSnapshot | null) => void; + now?: () => number; + log?: (message: string, ...rest: unknown[]) => void; +} + +export interface ControllerStateEvent { + connection: ConnectionState; + stats: GlobalStats; + torrents: Torrent[] | null; +} + +interface Subscriber { + port: PortLike; + start: number; + end: number; + /** Revision of the last snapshot sent, so unchanged data is not re-sent. */ + sentRevision: number; + sentStart: number; + sentEnd: number; +} + +interface CachedClient { + fingerprint: string; + client: ITorrentClient; +} + +interface Snapshot { + serverId: string; + torrents: Torrent[]; +} + +/** Outcomes that stay on screen while a retry is in flight. */ +const SETTLED_STATUSES = new Set(['connected', 'stale', 'auth_failed', 'unavailable']); + +const AUTH_ERROR_TYPES = new Set([ + 'AUTH_FAILED', + 'UNAUTHORIZED', + 'IP_BANNED', + 'WHITELIST_BLOCKED', + 'INVALID_PARAMS', + 'INVALID_CREDENTIALS', +]); + +/** + * Owns all background state for the torrent queue. + * + * Invariants enforced here (see docs/release/v1/V1_SCOPE.md, Phase 1): + * + * - There is at most one poll in flight. Additional refresh requests coalesce. + * - Every poll captures the generation it started in. If the generation moved + * on (server switch, vault lock, settings change) before the result arrived, + * the result is discarded. Generation N data never mutates generation N+1. + * - Snapshots and status carry the server id they describe. + * - Commands that name a torrent must name its server. The command is routed + * to that server's client, whatever the active server is, and fails closed + * if the server no longer exists. + * - Each subscriber has its own viewport and receives an initial snapshot as + * soon as it attaches. Total-count changes reach every subscriber even when + * their visible slice is unchanged. + */ +export class TorrentController { + private generation = 0; + private revision = 0; + private activeServerId: string | null = null; + private snapshot: Snapshot | null = null; + private pendingHydration: PersistedSnapshot | null = null; + private connection: ConnectionState = initialConnectionState(); + private stats: GlobalStats = emptyStats(); + + /** Set when the browser reports an optional host permission being removed; cleared once access is confirmed again. */ + private permissionRevoked = false; + + private subscribers = new Set(); + private clients = new Map(); + private inFlight: Promise | null = null; + private refreshRequested = false; + private stateListeners = new Set<(event: ControllerStateEvent) => void>(); + + private readonly now: () => number; + private readonly log: (message: string, ...rest: unknown[]) => void; + + constructor(private readonly deps: ControllerDeps) { + this.now = deps.now ?? (() => Date.now()); + this.log = deps.log ?? (() => { }); + } + + // ------------------------------------------------------------------ + // Observation + // ------------------------------------------------------------------ + + getConnection(): ConnectionState { + return this.connection; + } + + getStats(): GlobalStats { + return this.stats; + } + + getGeneration(): number { + return this.generation; + } + + getSnapshotTorrents(): Torrent[] | null { + return this.snapshot?.torrents ?? null; + } + + subscriberCount(): number { + return this.subscribers.size; + } + + onStateChange(listener: (event: ControllerStateEvent) => void): () => void { + this.stateListeners.add(listener); + return () => this.stateListeners.delete(listener); + } + + // ------------------------------------------------------------------ + // Lifecycle + // ------------------------------------------------------------------ + + /** + * Offers a snapshot persisted by a previous background instance. It is only + * used once the active server has been resolved and matches; until a fresh + * poll succeeds the data is reported as `stale`. + */ + hydrate(persisted: PersistedSnapshot | null): void { + if (!persisted || !persisted.serverId || !Array.isArray(persisted.torrents)) return; + this.pendingHydration = persisted; + } + + /** + * Marks all current state as superseded. In-flight polls started before + * this call will be discarded when they complete. Cached clients are + * dropped so credentials/configuration changes take effect immediately. + */ + invalidate(reason: string): void { + this.generation++; + this.clients.clear(); + this.log(`[Controller] invalidate(${reason}) -> generation ${this.generation}`); + // A configuration change re-opens a settled outcome, including a rejected login. + if (this.connection.status === 'connected' || this.connection.status === 'stale' || this.connection.status === 'auth_failed') { + this.setConnection({ status: 'connecting', lastError: null, lastErrorType: null }); + } + } + + /** + * Records that the browser removed an optional host permission (the user + * revoked site access from the browser's extension settings). The next + * poll that finds the active server unreachable for lack of permission + * reports it as revoked rather than never granted, so the recovery step + * is named correctly. + */ + notePermissionRemoved(): void { + this.permissionRevoked = true; + } + + // ------------------------------------------------------------------ + // Subscriptions + // ------------------------------------------------------------------ + + attachPort(port: PortLike): void { + const subscriber: Subscriber = { + port, + start: 0, + end: DEFAULT_VIEWPORT_SIZE, + sentRevision: -1, + sentStart: -1, + sentEnd: -1, + }; + this.subscribers.add(subscriber); + + port.onMessage.addListener((message) => { + if (!message || typeof message !== 'object') return; + if (message.type === 'SET_VIEWPORT') { + const start = Math.max(0, Math.floor(Number(message.start) || 0)); + const requestedEnd = Math.floor(Number(message.end)); + const end = Number.isFinite(requestedEnd) && requestedEnd > start ? requestedEnd : start + DEFAULT_VIEWPORT_SIZE; + subscriber.start = start; + subscriber.end = end; + this.sendToSubscriber(subscriber); + } else if (message.type === 'REFRESH') { + void this.refresh({ force: true }); + } + }); + + port.onDisconnect.addListener(() => { + this.subscribers.delete(subscriber); + }); + + // Initial snapshot (or status) for the new subscriber. + this.sendToSubscriber(subscriber); + } + + // ------------------------------------------------------------------ + // Polling + // ------------------------------------------------------------------ + + /** + * Single-flight refresh. Concurrent callers share the in-flight poll. + * + * After the server rejected the configured credentials, automatic polling + * stops: repeating a failing login every few seconds hides the failure + * behind "connecting" (live-verified: aria2 takes ~2 s to reject a wrong + * secret) and gets the browser banned by clients such as qBittorrent. The + * state stays `auth_failed` until the settings change (`invalidate`) or the + * user asks explicitly (`force`). + */ + refresh(options: { force?: boolean } = {}): Promise { + if (!options.force && this.connection.status === 'auth_failed') { + return Promise.resolve(); + } + if (this.inFlight) { + this.refreshRequested = true; + return this.inFlight; + } + this.inFlight = this.pollOnce() + .catch((error) => { + this.log('[Controller] poll failed unexpectedly', error); + }) + .finally(() => { + this.inFlight = null; + if (this.refreshRequested) { + this.refreshRequested = false; + void this.refresh(); + } + }); + return this.inFlight; + } + + private async pollOnce(): Promise { + const startedGeneration = this.generation; + const resolved = await this.deps.resolve(); + if (startedGeneration !== this.generation) return; + + if (resolved.state !== ResolutionState.OK || !resolved.activeServer) { + this.applyNonOkResolution(resolved.state); + return; + } + + const server = resolved.activeServer; + const serverId = server.id; + if (!serverId) { + // Servers are normalised by the vault layer; a missing id means the + // resolver bypassed it. Fail closed rather than guess. + this.clearSnapshot(); + this.setConnection({ status: 'invalid_config', serverId: null, serverName: server.name, lastError: 'Server entry has no identity.' }); + this.broadcastStatus(true); + return; + } + + let generation = startedGeneration; + if (this.activeServerId !== serverId) { + // Active server changed since the last poll. Everything that belonged + // to the previous server is now stale by definition. + this.activeServerId = serverId; + this.generation++; + generation = this.generation; + this.clients.clear(); + this.clearSnapshot(); + this.setConnection({ status: 'connecting', serverId, serverName: server.name, lastError: null, lastErrorType: null }); + this.broadcastStatus(true); + } + + // Offer hydrated data as a stale placeholder while the first poll runs. + if (!this.snapshot && this.pendingHydration && this.pendingHydration.serverId === serverId) { + this.snapshot = { serverId, torrents: this.pendingHydration.torrents }; + this.revision++; + this.stats = computeStats(this.snapshot.torrents); + this.setConnection({ status: 'stale', serverId, serverName: server.name }); + this.broadcastSnapshot(); + } + this.pendingHydration = null; + + const permitted = await this.deps.hasHostPermission(server.hostname); + if (generation !== this.generation) return; + if (!permitted) { + const revoked = this.permissionRevoked; + this.clearSnapshot(); + this.setConnection({ + status: 'permission_missing', + serverId, + serverName: server.name, + lastAttemptAt: this.now(), + lastError: revoked + ? 'Access to this server address was removed in the browser. Grant it again to reconnect.' + : 'CTRL has not been granted access to this server address.', + lastErrorType: revoked ? 'PERMISSION_REVOKED' : 'PERMISSION_MISSING', + }); + this.broadcastStatus(true); + return; + } + this.permissionRevoked = false; + + let client: ITorrentClient; + try { + client = await this.getClient(server); + } catch (error) { + if (generation !== this.generation) return; + this.setConnection({ + status: 'invalid_config', + serverId, + serverName: server.name, + lastAttemptAt: this.now(), + lastError: error instanceof Error ? error.message : String(error), + lastErrorType: 'INVALID_CONFIG', + }); + this.broadcastStatus(true); + return; + } + if (generation !== this.generation) return; + + const attemptedAt = this.now(); + // Show "connecting" only while nothing more specific is known. A settled + // outcome (live data, stale data, a rejected login, an unreachable server) + // stays visible during the retry instead of flickering to "connecting" + // whenever the server is slow to answer. + if (!SETTLED_STATUSES.has(this.connection.status)) { + this.setConnection({ status: 'connecting', serverId, serverName: server.name, lastAttemptAt: attemptedAt }); + this.broadcastStatus(false); + } else { + this.setConnection({ lastAttemptAt: attemptedAt }); + } + + let torrents: Torrent[]; + try { + torrents = await client.getTorrents(); + } catch (error) { + if (generation !== this.generation) { + this.log('[Controller] discarding failed poll from superseded generation'); + return; + } + const classified = classifyFailure(error, client); + const hasData = this.snapshot !== null && this.snapshot.serverId === serverId; + let status: ConnectionStatus; + if (classified.isAuth) { + status = 'auth_failed'; + this.clearSnapshot(); + } else { + status = hasData ? 'stale' : 'unavailable'; + } + this.setConnection({ + status, + serverId, + serverName: server.name, + lastAttemptAt: attemptedAt, + lastError: classified.message, + lastErrorType: classified.type, + }); + this.broadcastStatus(!hasData || classified.isAuth); + return; + } + + if (generation !== this.generation) { + this.log('[Controller] discarding poll result from superseded generation'); + return; + } + + this.snapshot = { serverId, torrents: Array.isArray(torrents) ? torrents : [] }; + this.revision++; + this.stats = computeStats(this.snapshot.torrents); + this.setConnection({ + status: 'connected', + serverId, + serverName: server.name, + lastAttemptAt: attemptedAt, + lastSuccessAt: this.now(), + lastError: null, + lastErrorType: null, + }); + this.deps.persist?.({ serverId, torrents: this.snapshot.torrents, savedAt: this.now() }); + this.broadcastSnapshot(); + } + + private applyNonOkResolution(state: ResolutionState): void { + const status: ConnectionStatus = + state === ResolutionState.UNINITIALIZED ? 'uninitialized' + : state === ResolutionState.LOCKED ? 'locked' + : state === ResolutionState.CORRUPTED ? 'vault_corrupted' + : state === ResolutionState.NO_SERVERS ? 'no_servers' + : 'invalid_config'; + if (this.activeServerId !== null) { + this.activeServerId = null; + this.generation++; + this.clients.clear(); + } + this.clearSnapshot(); + this.setConnection({ status, serverId: null, serverName: null, lastError: null, lastErrorType: null }); + this.broadcastStatus(true); + } + + private clearSnapshot(): void { + if (this.snapshot) { + this.snapshot = null; + this.revision++; + this.deps.persist?.(null); + } + this.stats = emptyStats(); + } + + private setConnection(patch: Partial): void { + this.connection = { ...this.connection, ...patch, generation: this.generation }; + } + + // ------------------------------------------------------------------ + // Clients + // ------------------------------------------------------------------ + + private async getClient(server: ServerConfig): Promise { + const id = server.id; + if (!id) throw new Error('Server entry has no identity.'); + const fingerprint = serverFingerprint(server); + const cached = this.clients.get(id); + if (cached && cached.fingerprint === fingerprint) return cached.client; + await this.prepareTransport(server); + const client = await this.deps.createClient(server); + this.clients.set(id, { fingerprint, client }); + return client; + } + + private async prepareTransport(config: ServerConfig): Promise { + if (!this.deps.prepareTransport) return; + try { + await this.deps.prepareTransport(config); + } catch (error) { + this.log('[Controller] transport preparation failed', error); + } + } + + /** + * Finds a server by id in the current configuration and returns its client. + * Throws when the vault is unavailable or the server no longer exists. + */ + private async getClientById(serverId: string): Promise<{ client: ITorrentClient; server: ServerConfig }> { + const resolved = await this.deps.resolve(); + if (resolved.state === ResolutionState.LOCKED) throw new CommandError('Vault is locked.', 'LOCKED'); + if (resolved.state === ResolutionState.UNINITIALIZED) throw new CommandError('CTRL is not set up yet.', 'UNINITIALIZED'); + const server = resolved.servers.find((s) => s.id === serverId); + if (!server) throw new CommandError('The server this torrent belongs to no longer exists.', 'SERVER_NOT_FOUND'); + if (!(await this.deps.hasHostPermission(server.hostname))) { + throw new CommandError('CTRL has not been granted access to this server address.', 'PERMISSION_MISSING'); + } + return { client: await this.getClient(server), server }; + } + + // ------------------------------------------------------------------ + // Commands + // ------------------------------------------------------------------ + + async handleRequest(request: RuntimeRequest): Promise { + switch (request.type) { + case 'GET_STATE': + return this.getState(); + case 'FORCE_REFRESH': + await this.refresh({ force: true }); + return { ok: true } satisfies CommandResult; + case 'ADD_TORRENT_URL': + return this.addTorrent(request); + case 'PAUSE_TORRENT': + case 'RESUME_TORRENT': + case 'REMOVE_TORRENT': + return this.runTorrentCommand(request); + case 'TEST_CONNECTION': + return this.testConnection(request.config); + default: + return { ok: false, error: `Unknown request type: ${String((request as { type?: unknown }).type)}` } satisfies CommandResult; + } + } + + async getState(): Promise { + const resolved = await this.deps.resolve(); + const servers = resolved.servers + .filter((s) => typeof s.id === 'string') + .map((s) => ({ id: s.id as string, name: s.name, type: s.type })); + return { + connection: this.connection, + stats: this.stats, + servers, + activeServerId: resolved.activeServer?.id ?? null, + }; + } + + async addTorrent(request: AddTorrentRequest): Promise { + if (!request.url || typeof request.url !== 'string') { + return { ok: false, error: 'No torrent URL or magnet link was provided.', errorType: 'INVALID_INPUT' }; + } + try { + let serverId = request.serverId; + if (!serverId) { + const resolved = await this.deps.resolve(); + if (resolved.state === ResolutionState.LOCKED) throw new CommandError('Vault is locked.', 'LOCKED'); + if (resolved.state === ResolutionState.UNINITIALIZED) throw new CommandError('CTRL is not set up yet.', 'UNINITIALIZED'); + if (!resolved.activeServer?.id) throw new CommandError('No server is configured.', 'NO_SERVERS'); + serverId = resolved.activeServer.id; + } + const { client } = await this.getClientById(serverId); + const settings = await this.deps.getSettings(); + const globalAddPaused = settings?.globals?.addPaused ?? false; + const options = { + ...(request.options ?? {}), + paused: request.options?.paused ?? globalAddPaused, + }; + await client.addTorrentUrl(request.url, options); + void this.refresh(); + return { ok: true }; + } catch (error) { + return failureResult(error); + } + } + + async runTorrentCommand(request: TorrentCommandRequest): Promise { + if (!request.serverId || typeof request.serverId !== 'string') { + return { ok: false, error: 'Command is missing its server identity; refusing to guess a target.', errorType: 'MISSING_SERVER_ID' }; + } + if (!request.torrentId || typeof request.torrentId !== 'string') { + return { ok: false, error: 'Command is missing the torrent identity.', errorType: 'MISSING_TORRENT_ID' }; + } + try { + const { client } = await this.getClientById(request.serverId); + switch (request.type) { + case 'PAUSE_TORRENT': + await client.pauseTorrent(request.torrentId); + break; + case 'RESUME_TORRENT': + await client.resumeTorrent(request.torrentId); + break; + case 'REMOVE_TORRENT': + await client.removeTorrent(request.torrentId, request.deleteData === true); + break; + } + void this.refresh(); + return { ok: true }; + } catch (error) { + return failureResult(error); + } + } + + async testConnection(config: ServerConfig): Promise { + try { + await this.prepareTransport(config); + const client = await this.deps.createClient(config); + const result = await client.testConnection(); + return { + connected: result.connected, + error: result.error?.toUserMessage(), + errorType: result.error?.type, + }; + } catch (error) { + const failure = failureResult(error); + return { connected: false, error: failure.error, errorType: failure.errorType }; + } + } + + // ------------------------------------------------------------------ + // Broadcasting + // ------------------------------------------------------------------ + + private sendToSubscriber(subscriber: Subscriber): void { + if (!this.snapshot) { + subscriber.sentRevision = this.revision; + subscriber.sentStart = subscriber.start; + subscriber.sentEnd = subscriber.end; + this.safePost(subscriber, { type: 'STATUS', connection: this.connection, stats: this.stats, cleared: true }); + return; + } + const { torrents, serverId } = this.snapshot; + const start = Math.min(Math.max(0, subscriber.start), torrents.length); + const end = Math.min(Math.max(start, subscriber.end), torrents.length); + subscriber.sentRevision = this.revision; + subscriber.sentStart = subscriber.start; + subscriber.sentEnd = subscriber.end; + this.safePost(subscriber, { + type: 'SNAPSHOT', + serverId, + generation: this.generation, + revision: this.revision, + total: torrents.length, + start, + items: torrents.slice(start, end), + connection: this.connection, + stats: this.stats, + }); + } + + private broadcastSnapshot(): void { + for (const subscriber of this.subscribers) { + this.sendToSubscriber(subscriber); + } + this.emitState(); + } + + private broadcastStatus(cleared: boolean): void { + for (const subscriber of this.subscribers) { + if (cleared) { + this.sendToSubscriber(subscriber); + } else { + this.safePost(subscriber, { type: 'STATUS', connection: this.connection, stats: this.stats }); + } + } + this.emitState(); + } + + private emitState(): void { + const event: ControllerStateEvent = { + connection: this.connection, + stats: this.stats, + torrents: this.snapshot?.torrents ?? null, + }; + for (const listener of this.stateListeners) { + try { + listener(event); + } catch (error) { + this.log('[Controller] state listener failed', error); + } + } + } + + private safePost(subscriber: Subscriber, message: PortServerMessage): void { + try { + subscriber.port.postMessage(message); + } catch (error) { + // The port is gone; drop the subscriber. + this.log('[Controller] dropping unreachable subscriber', error); + this.subscribers.delete(subscriber); + } + } +} + +// ---------------------------------------------------------------------- +// Failure classification +// ---------------------------------------------------------------------- + +export class CommandError extends Error { + constructor(message: string, public readonly errorType: string) { + super(message); + this.name = 'CommandError'; + } +} + +interface ClassifiedFailure { + message: string; + type: string; + isAuth: boolean; +} + +/** + * Turns an arbitrary adapter failure into a user-facing message and a stable + * discriminant. Adapters that expose `classifyError` provide their own typed + * mapping; otherwise a conservative heuristic is applied. + */ +export function classifyFailure(error: unknown, client?: ITorrentClient): ClassifiedFailure { + let adapterError: AdapterError | null = null; + if (error instanceof AdapterError) { + adapterError = error; + } else if (client && typeof client.classifyError === 'function') { + try { + const classified = client.classifyError(error); + if (classified instanceof AdapterError) adapterError = classified; + } catch { + adapterError = null; + } + } + + if (adapterError) { + return { + message: adapterError.toUserMessage(), + type: adapterError.type, + isAuth: AUTH_ERROR_TYPES.has(adapterError.type), + }; + } + + if (error instanceof CommandError) { + return { message: error.message, type: error.errorType, isAuth: false }; + } + + const raw = error instanceof Error ? error.message : String(error); + const lower = raw.toLowerCase(); + const isAuth = /authentication|unauthori[sz]ed|forbidden|invalid credentials|401|403|fails\./.test(lower); + const isTimeout = /timed? ?out|abort/.test(lower); + const isNetwork = /failed to fetch|networkerror|network error|cannot reach|econnrefused|typeerror/.test(lower); + return { + message: raw || 'The server could not be reached.', + type: isAuth ? 'AUTH_FAILED' : isTimeout ? 'TIMEOUT' : isNetwork ? 'NETWORK_ERROR' : 'UNKNOWN', + isAuth, + }; +} + +function failureResult(error: unknown): CommandResult { + if (error instanceof CommandError) { + return { ok: false, error: error.message, errorType: error.errorType }; + } + const classified = classifyFailure(error); + return { ok: false, error: classified.message, errorType: classified.type }; +} diff --git a/extension/src/features/torrent-control/services/ViewportManager.ts b/extension/src/features/torrent-control/services/ViewportManager.ts deleted file mode 100755 index 06e2aa0..0000000 --- a/extension/src/features/torrent-control/services/ViewportManager.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Torrent } from '@/entities/torrent/model/Torrent'; -import { StateHydrator } from './StateHydrator'; -import { browser } from 'wxt/browser'; -import { computeTorrentDiff, JsonPatchOperation } from '@/shared/lib/diff/TorrentDiffer'; - -interface ViewportState { - start: number; - end: number; - enabled: boolean; -} - -export class ViewportManager { - private fullTorrents: Torrent[] = []; - private viewport: ViewportState = { start: 0, end: 50, enabled: false }; - private previousSlice: Torrent[] = []; - private previousStart: number = 0; - - constructor() { } - - /** - * Updates the full internal state with new data from the Torrent Client. - * Automatically broadcasts the new viewport slice and updates the badge. - */ - public updateTorrents(torrents: Torrent[]) { - this.fullTorrents = torrents; - this.broadcastViewport(); - // Persist full state for hydration safety (background crash recovery) - StateHydrator.persist(torrents); - } - - /** - * Sets the visible range for the UI. - * Called when the user scrolls the virtual list. - */ - public setViewport(start: number, end: number) { - const viewportChanged = this.viewport.start !== start || this.viewport.end !== end; - this.viewport = { start, end, enabled: true }; - - // If viewport range changed (scroll), send full update for new range - if (viewportChanged) { - this.previousSlice = []; // Force full update on scroll - } - this.broadcastViewport(); - } - - /** - * Sends the current visible slice to the UI. - * Uses diffing for incremental updates when possible. - */ - private broadcastViewport() { - if (!this.viewport.enabled) return; - - // Ensure bounds - const start = Math.max(0, this.viewport.start); - const end = Math.min(this.fullTorrents.length, this.viewport.end); - const sliced = this.fullTorrents.slice(start, end); - - // Check if we can use incremental diff - const canDiff = this.previousSlice.length > 0 && - this.previousStart === start && - Math.abs(this.previousSlice.length - sliced.length) <= 5; - - if (canDiff) { - // Compute and send diff - const { patches, hasChanges } = computeTorrentDiff( - this.previousSlice, - sliced, - start - ); - - if (hasChanges) { - browser.runtime.sendMessage({ - type: 'VIEWPORT_DIFF', - data: { - patches, - total: this.fullTorrents.length, - start - } - }).catch(() => { }); - } - // No changes = no message needed - } else { - // Send full update (first sync, scroll, or major change) - browser.runtime.sendMessage({ - type: 'VIEWPORT_UPDATE', - data: { - items: sliced, - total: this.fullTorrents.length, - start - } - }).catch(() => { }); - } - - // Store for next diff - this.previousSlice = sliced; - this.previousStart = start; - } - - public getSnapshot() { - return this.fullTorrents; - } -} - diff --git a/extension/src/features/torrent-control/ui/AddTorrentDialog.tsx b/extension/src/features/torrent-control/ui/AddTorrentDialog.tsx index 2a2f85f..1bdf38d 100755 --- a/extension/src/features/torrent-control/ui/AddTorrentDialog.tsx +++ b/extension/src/features/torrent-control/ui/AddTorrentDialog.tsx @@ -1,123 +1,130 @@ -import React, { useState, useEffect } from 'react'; -import { ServerConfig } from '@/shared/lib/types'; -import { - Modal, - TextInput, - Select, - SelectItem, - Checkbox, - InlineNotification, - Form, - Stack -} from '@carbon/react'; - -interface Props { - isOpen: boolean; - onClose: () => void; - onAdd: (url: string, options: { path?: string; label?: string; paused?: boolean }) => Promise; - initialUrl?: string; - server: ServerConfig; - labels: string[]; -} - -export const AddTorrentDialog: React.FC = ({ isOpen, onClose, onAdd, initialUrl = '', server, labels }) => { - const [url, setUrl] = useState(initialUrl); - const [path, setPath] = useState(server.defaultDirectory || ((server.directories || []).length > 0 ? (server.directories || [])[0] : '')); - const [label, setLabel] = useState(server.defaultLabel || ''); - const [paused, setPaused] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (isOpen) { - setUrl(initialUrl); - setPath(server.defaultDirectory || ((server.directories || []).length > 0 ? (server.directories || [])[0] : '')); - setLabel(server.defaultLabel || ''); - setPaused(false); - setError(null); - } - }, [isOpen, initialUrl, server]); - - const handleSubmit = async () => { - if (!url) return; - - setIsSubmitting(true); - setError(null); - try { - await onAdd(url, { path, label, paused }); - onClose(); - } catch (err: any) { - setError(err.message || 'Failed to add torrent'); - } finally { - setIsSubmitting(false); - } - }; - - return ( - -
- - setUrl(e.target.value)} - placeholder="magnet:?xt=urn:btih..." - autoFocus - /> - - {(server.directories || []).length > 0 && ( - - )} - - {labels.length > 0 && ( - - )} - - setPaused(checked)} - /> - - {error && ( - - )} - -
-
- ); -}; +import React, { useState, useEffect } from 'react'; +import { browser } from 'wxt/browser'; +import { ServerConfig } from '@/shared/lib/types'; +import { + Modal, + TextInput, + Select, + SelectItem, + Checkbox, + InlineNotification, + Form, + Stack +} from '@carbon/react'; + +interface Props { + isOpen: boolean; + onClose: () => void; + onAdd: (url: string, options: { path?: string; label?: string; paused?: boolean }) => Promise; + initialUrl?: string; + server: ServerConfig; + labels: string[]; + /** Global "add paused" default; the checkbox starts from it. */ + defaultPaused?: boolean; +} + +export const AddTorrentDialog: React.FC = ({ isOpen, onClose, onAdd, initialUrl = '', server, labels, defaultPaused = false }) => { + const [url, setUrl] = useState(initialUrl); + const [path, setPath] = useState(server.defaultDirectory || ((server.directories || []).length > 0 ? (server.directories || [])[0] : '')); + const [label, setLabel] = useState(server.defaultLabel || ''); + const [paused, setPaused] = useState(defaultPaused); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isOpen) { + setUrl(initialUrl); + setPath(server.defaultDirectory || ((server.directories || []).length > 0 ? (server.directories || [])[0] : '')); + setLabel(server.defaultLabel || ''); + setPaused(defaultPaused); + setError(null); + } + }, [isOpen, initialUrl, server, defaultPaused]); + + const handleSubmit = async () => { + if (!url) return; + + setIsSubmitting(true); + setError(null); + try { + await onAdd(url, { + path: path || undefined, + label: label || undefined, + paused, + }); + onClose(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to add torrent'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ + setUrl(e.target.value)} + placeholder="magnet:?xt=urn:btih..." + autoFocus + /> + + {(server.directories || []).length > 0 && ( + + )} + + {labels.length > 0 && ( + + )} + + setPaused(checked)} + /> + + {error && ( + + )} + +
+
+ ); +}; diff --git a/extension/src/features/torrent-control/ui/AppearanceSettings.tsx b/extension/src/features/torrent-control/ui/AppearanceSettings.tsx deleted file mode 100755 index 67f6e56..0000000 --- a/extension/src/features/torrent-control/ui/AppearanceSettings.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { AppOptions } from '@/shared/lib/types'; -import { ThemeSettings } from './settings/ThemeSettings'; -import { PerformanceSettings } from './settings/PerformanceSettings'; -import { SettingsPageLayout } from '@/shared/ui/settings/SettingsPageLayout'; -import { Palette } from 'lucide-react'; -import { LayoutSettings } from './LayoutSettings'; - -interface Props { - settings: AppOptions; - updateSettings: (newSettings: AppOptions) => void; -} - -export const AppearanceSettings: React.FC = ({ settings, updateSettings }) => { - // Local state for previews - const [previewTheme, setPreviewTheme] = useState(settings.appearance.theme); - - // Sync local state when settings change - useEffect(() => { - setPreviewTheme(settings.appearance.theme); - }, [settings.appearance.theme]); - - const applyTheme = () => { - updateSettings({ - ...settings, - appearance: { ...settings.appearance, theme: previewTheme } - }); - }; - - return ( - - void} - applyTheme={applyTheme} - /> - - {/* Sidebar Layout */} - - - - - ); -}; diff --git a/extension/src/features/torrent-control/ui/ConnectionBanner.tsx b/extension/src/features/torrent-control/ui/ConnectionBanner.tsx new file mode 100644 index 0000000..d7f0a1a --- /dev/null +++ b/extension/src/features/torrent-control/ui/ConnectionBanner.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { InlineNotification } from '@carbon/react'; +import type { ConnectionState } from '@/shared/api/messaging/protocol'; + +export interface ConnectionPresentation { + kind: 'success' | 'info' | 'warning' | 'error'; + title: string; + detail: string; +} + +/** + * Single source of truth for how a connection state is described to users. + * Shared by the popup, the options dashboard and the torrent list so the same + * situation never reads differently in two places. + */ +export function describeConnection(connection: ConnectionState): ConnectionPresentation { + const server = connection.serverName ?? 'the server'; + switch (connection.status) { + case 'uninitialized': + return { kind: 'info', title: 'Not set up', detail: 'Create a master password to start using CTRL.' }; + case 'locked': + return { kind: 'warning', title: 'Locked', detail: 'Unlock CTRL with your master password to see your torrents.' }; + case 'vault_corrupted': + return { kind: 'error', title: 'Vault damaged', detail: 'The stored vault data is incomplete or damaged and cannot be unlocked. Open CTRL settings to reset it.' }; + case 'no_servers': + return { kind: 'info', title: 'No server configured', detail: 'Add a torrent client in Settings → Servers.' }; + case 'invalid_config': + return { kind: 'error', title: 'Server configuration invalid', detail: connection.lastError ?? 'Check the server address and client type in Settings → Servers.' }; + case 'permission_missing': + if (connection.lastErrorType === 'PERMISSION_REVOKED') { + return { kind: 'warning', title: 'Access revoked', detail: `Access to ${server} was removed in the browser. Grant it again to reconnect.` }; + } + return { kind: 'warning', title: 'Access not granted', detail: `CTRL needs permission to contact ${server}. Grant access in Settings → Servers.` }; + case 'connecting': + return { kind: 'info', title: 'Connecting…', detail: `Contacting ${server}.` }; + case 'connected': + return { kind: 'success', title: 'Connected', detail: `Live data from ${server}.` }; + case 'stale': + return { kind: 'warning', title: 'Connection lost', detail: `Showing the last data received from ${server}. ${connection.lastError ?? ''}`.trim() }; + case 'unavailable': + return { kind: 'error', title: 'Server unavailable', detail: connection.lastError ?? `${server} cannot be reached.` }; + case 'auth_failed': + return { kind: 'error', title: 'Authentication failed', detail: connection.lastError ?? `${server} rejected the saved credentials.` }; + default: + return { kind: 'info', title: 'Unknown', detail: '' }; + } +} + +interface Props { + connection: ConnectionState; + /** Hide the banner when everything is fine. */ + hideWhenConnected?: boolean; +} + +export const ConnectionBanner: React.FC = ({ connection, hideWhenConnected = true }) => { + if (hideWhenConnected && connection.status === 'connected') return null; + if (connection.status === 'connecting' && hideWhenConnected) return null; + const presentation = describeConnection(connection); + return ( +
+ +
+ ); +}; diff --git a/extension/src/features/torrent-control/ui/Dashboard.tsx b/extension/src/features/torrent-control/ui/Dashboard.tsx index 043dfac..99b1442 100755 --- a/extension/src/features/torrent-control/ui/Dashboard.tsx +++ b/extension/src/features/torrent-control/ui/Dashboard.tsx @@ -1,405 +1,385 @@ -import React, { useState, useEffect } from 'react'; -import { - Button, - TextInput, - Select, - SelectItem, - ProgressBar, - Tile, - Stack, - Layer, - Loading, - Grid, - Column -} from '@carbon/react'; -import { Launch, Settings, Information, CheckmarkOutline, ErrorOutline, Add } from '@carbon/icons-react'; - -// Hooks -import { useSettings } from '@/features/torrent-control/model/useSettings'; - -// Entities -import { Torrent } from '@/entities/torrent/model/Torrent'; - -// Components -import { Logo } from '@/shared/ui/Logo'; -import { AddTorrentDialog } from './AddTorrentDialog'; -import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'; -import { VaultService } from '@/shared/api/security/VaultService'; - -import { useDebugId } from '@/shared/lib/hooks/useDebugId'; - -export const Dashboard = () => { - const { settings, updateSettings, loading } = useSettings(); - const [status, setStatus] = useState('Ready'); - const [statusKind, setStatusKind] = useState<'success' | 'danger' | 'warning' | 'info'>('success'); - const [torrents, setTorrents] = useState([]); - const [addUrl, setAddUrl] = useState(''); - const [isDialogOpen, setIsDialogOpen] = useState(false); - - // Debug IDs - const setupBtnDebug = useDebugId('dashboard', 'global', 'setup-button'); - const serverSelectDebug = useDebugId('dashboard', 'global', 'server-select'); - const addInputDebug = useDebugId('dashboard', 'add-torrent', 'url-input'); - const addBtnDebug = useDebugId('dashboard', 'add-torrent', 'add-button'); - const webUiBtnDebug = useDebugId('dashboard', 'actions', 'web-ui-button'); - const testBtnDebug = useDebugId('dashboard', 'actions', 'test-connection-button'); - const settingsBtnDebug = useDebugId('dashboard', 'actions', 'open-settings-button'); - - const [vaultStatus, setVaultStatus] = useState(''); - - useEffect(() => { - const checkVault = async () => { - try { - const isInit = await VaultService.isInitialized(); - if (!isInit) { - setVaultStatus('Vault: Uninitialized'); - return; - } - const isLocked = await VaultService.isLocked(); - setVaultStatus(isLocked ? 'Vault: Locked' : 'Vault: Unlocked'); - } catch (e) { - console.error('Failed to check vault status', e); - } - }; - checkVault(); - const interval = setInterval(checkVault, 2000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - if (settings && (settings.servers || []).length > 0) { - fetchTorrents(); - const interval = setInterval(fetchTorrents, 2000); - return () => clearInterval(interval); - } - }, [settings]); - - const fetchTorrents = async () => { - try { - const response = await chrome.runtime.sendMessage({ type: 'GET_TORRENTS' }); - if (response && !response.error) { - setTorrents(response); - setStatus('Online'); - setStatusKind('success'); - } else if (response && response.error) { - setStatus('Error: ' + response.error); - setStatusKind('danger'); - } - } catch { - setStatus('Connection Failed'); - setStatusKind('danger'); - } - }; - - const handleAddTorrent = async () => { - if (!addUrl) return; - setStatus('Adding...'); - setStatusKind('info'); - try { - const response = await chrome.runtime.sendMessage({ - type: 'ADD_TORRENT_URL', - url: addUrl - }); - if (response && response.error) { - throw new Error(response.error); - } - setAddUrl(''); - setStatus('Torrent Added'); - setStatusKind('success'); - fetchTorrents(); - } catch (e: unknown) { - setStatus('Add Failed: ' + (e instanceof Error ? e.message : String(e))); - setStatusKind('danger'); - } - }; - - if (loading || !settings) { - return ( -
- -
- ); - } - - const configured = (settings.servers || []).length > 0 && settings.servers[settings.globals.currentServer]?.hostname; - const currentServer = settings.servers[settings.globals.currentServer]; - - const handleServerChange = (index: number) => { - updateSettings({ - ...settings, - globals: { - ...settings.globals, - currentServer: index, - }, - }); - }; - - const openWebUI = () => { - if (currentServer?.hostname) { - let url = currentServer.hostname; - if (!url.match(/^http/)) url = 'http://' + url; - chrome.tabs.create({ url }); - } - }; - - const openOptions = () => { - chrome.runtime.openOptionsPage(); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleAddTorrent(); - } - }; - - const isAdding = status === 'Adding...'; - - const handleAddClick = () => { - if (settings.globals.addAdvanced) { - setIsDialogOpen(true); - } else { - handleAddTorrent(); - } - }; - - const handleDialogAdd = async (url: string, options: { path?: string; label?: string; paused?: boolean }) => { - setStatus('Adding...'); - setStatusKind('info'); - try { - const response = await chrome.runtime.sendMessage({ - type: 'ADD_TORRENT_URL', - url: url, - options: options - }); - if (response && response.error) { - throw new Error(response.error); - } - setAddUrl(''); - setStatus('Torrent Added'); - setStatusKind('success'); - fetchTorrents(); - } catch (e: unknown) { - setStatus('Add Failed: ' + (e instanceof Error ? e.message : String(e))); - setStatusKind('danger'); - throw e; - } - }; - - const getStatusIcon = () => { - switch (statusKind) { - case 'success': return ; - case 'danger': return ; - default: return ; - } - }; - - return ( - -
- {!configured ? ( - - - - -
- -

{browser.i18n.getMessage('dashboardTitle')}

-
- -

{browser.i18n.getMessage('dashboardEmptyState')}

- - -
-
-
-
- ) : ( - -
-

- - {browser.i18n.getMessage('dashboardTitle')} -

- {vaultStatus && ( -
- {vaultStatus} -
- )} -
- - - - - - - {(settings.servers || []).length > 1 ? ( - - ) : ( -
{currentServer?.name || 'Unknown Server'}
- )} - -
- {getStatusIcon()} - {status} -
-
-
- - - - -
-
- setAddUrl(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={browser.i18n.getMessage('dashboardMagnetPlaceholder')} - size="sm" - disabled={isAdding} - {...addInputDebug} - /> -
- -
-
-
- - -
-
- {browser.i18n.getMessage('dashboardActiveTorrents')} - {torrents?.length || 0} -
-
- {Array.isArray(torrents) && torrents.length > 0 ? ( - torrents.slice(0, 3).map(t => ( -
-
- {t.name || 'Unknown'} -
- -
- )) - ) : ( -
- No active torrents -
- )} -
-
-
- -
- - -
- -
- -
- - setIsDialogOpen(false)} - onAdd={handleDialogAdd} - initialUrl={addUrl} - server={currentServer} - labels={settings.globals.labels || []} - /> -
-
- )} -
-
- ); -}; +import React, { useState, useEffect, useCallback } from 'react'; +import { + Button, + TextInput, + Select, + SelectItem, + ProgressBar, + Tile, + Stack, + Layer, + Loading, + InlineNotification, +} from '@carbon/react'; +import { Launch, Settings, Add, Locked } from '@carbon/icons-react'; +import { browser } from 'wxt/browser'; + +import { useSettings } from '@/features/torrent-control/model/useSettings'; +import { useVault } from '@/features/torrent-control/model/useVault'; +import { useTorrentSubscription } from '@/features/torrent-control/model/useTorrentSubscription'; +import { useTorrentStore } from '@/stores/useTorrentStore'; +import { describeConnection } from './ConnectionBanner'; +import { formatSpeed } from '@/shared/lib/format'; +import { requestHostPermission } from '@/shared/lib/permissions'; +import type { AddTorrentRequest, CommandResult } from '@/shared/api/messaging/protocol'; + +import { Logo } from '@/shared/ui/Logo'; +import { UnlockVault } from '@/shared/ui/security/UnlockVault'; +import { AddTorrentDialog } from './AddTorrentDialog'; +import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'; + +const POPUP_ROWS = 5; + +type AddState = { kind: 'idle' } | { kind: 'adding' } | { kind: 'added' } | { kind: 'failed'; message: string }; + +/** + * Toolbar popup. + * + * Renders exactly one of: setup prompt, unlock form, corrupted-vault notice, + * "add a server" prompt, or the live dashboard. The dashboard subscribes to + * the background queue over the shared port, so the popup and the options + * page always show the same data and drive one poll loop. + */ +export const Dashboard = () => { + const vault = useVault(); + + if (vault.status === 'loading') { + return ( +
+ +
+ ); + } + + if (vault.status === 'uninitialized') { + return ( + chrome.runtime.openOptionsPage()} + /> + ); + } + + if (vault.status === 'corrupted') { + return ( + chrome.runtime.openOptionsPage()} + kind="error" + /> + ); + } + + if (vault.status === 'locked') { + return ( +
+ +
+ ); + } + + if (vault.servers.length === 0) { + return ( + chrome.runtime.openOptionsPage()} + /> + ); + } + + return ( + + + + ); +}; + +const Prompt: React.FC<{ + title: string; + body: string; + action: string; + icon: React.ComponentType; + onAction: () => void; + kind?: 'info' | 'error'; +}> = ({ title, body, action, icon, onAction, kind = 'info' }) => ( +
+ + +
+ +

{title}

+
+ {kind === 'error' ? ( + + ) : ( +

{body}

+ )} + +
+
+
+); + +const LiveDashboard: React.FC<{ onLock: () => Promise }> = ({ onLock }) => { + const { settings, updateSettings } = useSettings(); + useTorrentSubscription({ start: 0, end: POPUP_ROWS }); + + const connection = useTorrentStore((s) => s.connection); + const stats = useTorrentStore((s) => s.globalStats); + const ids = useTorrentStore((s) => s.ids); + const byId = useTorrentStore((s) => s.byId); + const totalCount = useTorrentStore((s) => s.totalCount); + + const [addUrl, setAddUrl] = useState(''); + const [addState, setAddState] = useState({ kind: 'idle' }); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + useEffect(() => { + if (addState.kind !== 'added') return; + const t = setTimeout(() => setAddState({ kind: 'idle' }), 3000); + return () => clearTimeout(t); + }, [addState]); + + const servers = settings?.servers ?? []; + const currentIndex = settings?.globals.currentServer ?? 0; + const currentServer = servers[currentIndex] ?? servers[0]; + + const submitAdd = useCallback(async (url: string, options?: AddTorrentRequest['options']) => { + const trimmed = url.trim(); + if (!trimmed) return; + setAddState({ kind: 'adding' }); + try { + const request: AddTorrentRequest = { type: 'ADD_TORRENT_URL', url: trimmed, options }; + const result = (await chrome.runtime.sendMessage(request)) as CommandResult | undefined; + if (!result?.ok) { + throw new Error(result?.error ?? 'The torrent could not be added.'); + } + setAddUrl(''); + setAddState({ kind: 'added' }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setAddState({ kind: 'failed', message }); + throw e; + } + }, []); + + const handleAddClick = () => { + if (settings?.globals.addAdvanced) { + setIsDialogOpen(true); + } else { + void submitAdd(addUrl).catch(() => { /* surfaced via addState */ }); + } + }; + + const handleServerChange = (index: number) => { + if (!settings) return; + void updateSettings({ + ...settings, + globals: { ...settings.globals, currentServer: index }, + }); + }; + + const openWebUI = () => { + if (!currentServer?.hostname) return; + try { + const url = new URL(currentServer.hostname); + if (url.protocol === 'http:' || url.protocol === 'https:') { + void chrome.tabs.create({ url: url.toString() }); + } + } catch { + // invalid hostname: nothing to open + } + }; + + const presentation = describeConnection(connection); + const isAdding = addState.kind === 'adding'; + const canAdd = connection.status !== 'locked' && connection.status !== 'uninitialized' && connection.status !== 'no_servers'; + + return ( +
+ +
+

+ + CTRL +

+
+
+
+ + + + + {servers.length > 1 ? ( + + ) : ( +
{currentServer?.name ?? 'Server'}
+ )} + +
+ +
+
{presentation.title}
+ {connection.status !== 'connected' && ( +
{presentation.detail}
+ )} + {connection.status === 'connected' && ( +
+ ↓ {formatSpeed(stats.downloadSpeed)} · ↑ {formatSpeed(stats.uploadSpeed)} +
+ )} +
+
+ {connection.status === 'permission_missing' && currentServer?.hostname && ( + + )} +
+
+ + + + +
+
+ setAddUrl(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleAddClick(); }} + placeholder="magnet:?xt=urn:btih:…" + size="sm" + disabled={isAdding || !canAdd} + /> +
+ +
+ {addState.kind === 'failed' && ( + + )} + {addState.kind === 'added' && ( + + )} + {settings?.globals.addPaused && ( +

New torrents start paused (change in Settings).

+ )} +
+
+ + +
+
+ Torrents + {totalCount} +
+
    + {ids.length > 0 ? ( + ids.map((id) => { + const t = byId[id]; + if (!t) return null; + const progress = Math.max(0, Math.min(100, Math.round(t.progress))); + return ( +
  • +
    {t.name}
    + = 100 ? 'finished' : t.status === 'error' ? 'error' : 'active'} + /> +
  • + ); + }) + ) : ( +
  • + {connection.status === 'connected' ? 'No torrents' : 'No data'} +
  • + )} +
+ {totalCount > ids.length && ( +
+ Showing {ids.length} of {totalCount}. Open settings for the full list. +
+ )} +
+
+ +
+ + +
+ + {currentServer && ( + setIsDialogOpen(false)} + onAdd={(url, options) => submitAdd(url, options)} + initialUrl={addUrl} + server={currentServer} + labels={settings?.globals.labels ?? []} + defaultPaused={settings?.globals.addPaused ?? false} + /> + )} +
+
+ ); +}; + +const StatusDot: React.FC<{ kind: 'success' | 'info' | 'warning' | 'error' }> = ({ kind }) => { + const color = + kind === 'success' ? 'var(--cds-support-success)' : + kind === 'warning' ? 'var(--cds-support-warning)' : + kind === 'error' ? 'var(--cds-support-error)' : 'var(--cds-support-info)'; + return
- - {/* Auth Control */} -
- -
- - {authStatus.result || -} - - {authStatus.error && ( - - - - - -

- Authentication failed. Verify your username and password are correct for this client. -

-
-
- )} -
-
-
- - ); -}; diff --git a/extension/src/features/torrent-control/ui/FunctionSettings.tsx b/extension/src/features/torrent-control/ui/FunctionSettings.tsx index 8c0db70..067839e 100755 --- a/extension/src/features/torrent-control/ui/FunctionSettings.tsx +++ b/extension/src/features/torrent-control/ui/FunctionSettings.tsx @@ -1,34 +1,22 @@ import React from 'react'; -import { AppOptions, ServerConfig } from '@/shared/lib/types'; +import { AppOptions, ContextMenuMode, ServerConfig } from '@/shared/lib/types'; import { SettingsPageLayout } from '@/shared/ui/settings/SettingsPageLayout'; import { SettingsCard } from '@/shared/ui/settings/SettingsCard'; import { SettingsToggle } from '@/shared/ui/settings/SettingsToggle'; -import { Settings as SettingsIcon, Download, Info } from 'lucide-react'; +import { Settings as SettingsIcon, Download, Info, Bell } from 'lucide-react'; import { Select, SelectItem, Stack } from '@carbon/react'; import { ContextMenuSettings } from './settings/ContextMenuSettings'; -import { NotificationSettings } from './settings/NotificationSettings'; import { useDebugId } from '@/shared/lib/hooks/useDebugId'; interface Props { settings: AppOptions; updateSettings: (newSettings: AppOptions) => Promise | void; - previewContextMenu: number; - setPreviewContextMenu: (value: number) => void; - previewCustomOptions: { - addToClient: boolean; - pauseResume: boolean; - openWebUI: boolean; - }; - setPreviewCustomOptions: (options: { addToClient: boolean; pauseResume: boolean; openWebUI: boolean }) => void; + previewContextMenu: ContextMenuMode; + setPreviewContextMenu: (value: ContextMenuMode) => void; applyContextMenu: () => Promise; previewServers: ServerConfig[]; setPreviewServers: (servers: ServerConfig[]) => void; - previewNotification: boolean; - setPreviewNotification: (enabled: boolean) => void; - previewNotificationLevel: string; - setPreviewNotificationLevel: (level: string) => void; - applyNotifications: () => Promise; } export const FunctionSettings: React.FC = ({ @@ -36,18 +24,11 @@ export const FunctionSettings: React.FC = ({ updateSettings, previewContextMenu, setPreviewContextMenu, - previewCustomOptions, - setPreviewCustomOptions, applyContextMenu, previewServers, setPreviewServers, - previewNotification, - setPreviewNotification, - previewNotificationLevel, - setPreviewNotificationLevel, - applyNotifications }) => { - const handleChange = (field: keyof AppOptions['globals'], value: AppOptions['globals'][keyof AppOptions['globals']]) => { + const handleChange = (field: K, value: AppOptions['globals'][K]) => { updateSettings({ ...settings, globals: { @@ -61,6 +42,7 @@ export const FunctionSettings: React.FC = ({ const addPausedDebug = useDebugId('settings', 'function', 'add-paused-toggle'); const addAdvancedDebug = useDebugId('settings', 'function', 'add-advanced-toggle'); const badgeInfoDebug = useDebugId('settings', 'function', 'badge-info-select'); + const notificationsDebug = useDebugId('settings', 'notifications', 'enable-toggle'); return ( = ({ handleChange('addPaused', !settings.globals.addPaused)} label="Add torrents paused" @@ -79,6 +62,7 @@ export const FunctionSettings: React.FC = ({ {...addPausedDebug} /> handleChange('addAdvanced', !settings.globals.addAdvanced)} label="Show advanced dialog when adding" @@ -91,7 +75,7 @@ export const FunctionSettings: React.FC = ({
-

Choose what information to display on the extension icon.

+

Choose what information to display on the extension icon. CTRL only polls your client in the background while the badge is enabled.

handleTempChange('name', e.target.value)} - /> -
- - {/* Application */} -
- - -
- - {/* Address */} -
- -
- - { - const host = e.target.value; - const protocol = tempServer.hostname.startsWith('https') ? 'https://' : 'http://'; - const portMatch = tempServer.hostname.match(/:(\d+)\/?$/); - const port = portMatch ? portMatch[1] : ''; - handleTempChange('hostname', `${protocol}${host}${port ? ':' + port : ''}/`); - }} - {...hostInputDebug} - /> - : - { - const match = tempServer.hostname.match(/:(\d+)\/?$/); - return match ? match[1] : ''; - })()} - onChange={(e) => { - const port = e.target.value; - const protocol = tempServer.hostname.startsWith('https') ? 'https://' : 'http://'; - const host = tempServer.hostname.replace(/^https?:\/\//, '').split(':')[0].replace(/\/$/, ''); - handleTempChange('hostname', `${protocol}${host}${port ? ':' + port : ''}/`); - }} - {...portInputDebug} - /> -
-
- - {/* Auth */} -
-
- - handleTempChange('username', e.target.value)} - /> -
-
- - handleTempChange('password', e.target.value)} - /> -
-
- - -
-
- - - {!hasPermission && ( -
- {isPrivateIP(tempServer.hostname) && ( -
- Local Network Access: Chrome restricts access to local IPs. You must explicitly grant permission. -
- )} - -
- )} - - {/* Result Area - Fixed Width/Position to prevent shifting */} -
- {testStatus.message ? ( - - {testStatus.message} - - ) : ( - Not tested yet - )} -
-
- -
- {saveError && ( - - {saveError} - - )} - {vaultStatus === 'Vault: Locked' && ( - - Unlock vault to save - - )} - -
-
-
+ setMode({ kind: 'list' })} + />
); } - // Render List + const removeTarget = pendingRemove !== null ? servers[pendingRemove] : undefined; + return ( - + Add Server - + } > -
- {settings.servers.length === 0 ? ( -
- No servers configured. Click "Add Server" to get started. -
+ + {notice && ( + setNotice(null)} + aria-label="Close notification" + /> + )} + + {servers.length === 0 ? ( + +

+ No server configured yet. Add your torrent client to start sending links to it. +

+
) : ( - settings.servers.map((server, index) => ( -
-
-
-

{server.name || `Server ${index + 1}`}

- {settings.globals.currentServer === index && ( - Default - )} -
-
- {CLIENT_LIST.find(c => c.id === server.application)?.name} • {server.hostname} -
-
-
- {settings.globals.currentServer !== index && ( - - )} - - -
-
- )) +
    + {servers.map((server, index) => { + const isDefault = index === currentIndex; + const client = getClientCapability(server.type || server.application); + const isActiveInBackground = !!server.id && connection.serverId === server.id; + const presentation = isActiveInBackground ? describeConnection(connection) : null; + const needsGrant = isActiveInBackground && connection.status === 'permission_missing'; + const label = server.name || `Server ${index + 1}`; + return ( +
  • +
    +
    +
    +

    {label}

    + {isDefault && Default} + {!isPublicClient(server.type || server.application) && ( + experimental, not verified + )} +
    +

    + {client?.name ?? server.type} · {server.hostname} +

    + {presentation && ( +

    + + {presentation.title} + {connection.status !== 'connected' && ( + — {presentation.detail} + )} +

    + )} +
    +
    + {needsGrant && ( + + )} + {!isDefault && ( + + )} + + +
    +
    +
  • + ); + })} +
)} -
- -

- Export your server configurations to a JSON file to transfer them to another device or browser. -
- Passwords are included in the export. Keep the file secure. + + +

+ The safe export leaves out usernames and passwords. The full export includes them in plain text; keep that file private. +

+
+ + + + { void handleImport(e); }} + /> +
+ +
+ + + { if (!removing) setPendingRemove(null); }} + onRequestSubmit={() => { void confirmRemove(); }} + size="sm" + > +

+ Remove {removeTarget?.name} ({removeTarget?.hostname}) from CTRL? Its saved address, username and + password are deleted from this browser. Nothing changes on the torrent client itself.

-
-
- - -
- -
- +
); }; + +const StatusDot: React.FC<{ kind: 'success' | 'info' | 'warning' | 'error' }> = ({ kind }) => { + const color = + kind === 'success' ? 'var(--cds-support-success)' : + kind === 'warning' ? 'var(--cds-support-warning)' : + kind === 'error' ? 'var(--cds-support-error)' : 'var(--cds-support-info)'; + return