From c4d92cefc7806753eeeb71556061b19782a6ecda Mon Sep 17 00:00:00 2001 From: StarlightDaemon <23347919+StarlightDaemon@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:25:07 -0600 Subject: [PATCH 1/7] build(firefox): add clean-tree AMO source archive packaging --- extension/package.json | 3 +- extension/scripts/zip-source.ts | 91 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100755 extension/scripts/zip-source.ts diff --git a/extension/package.json b/extension/package.json index 0fb2872..f4372db 100755 --- a/extension/package.json +++ b/extension/package.json @@ -17,6 +17,7 @@ "zip": "wxt zip", "zip:chrome": "wxt zip -b chrome", "zip:firefox": "wxt zip -b firefox --mv3", + "zip:source": "node scripts/zip-source.ts", "compile": "tsc --noEmit", "lint": "eslint src --ext .ts,.tsx", "lint:fix": "eslint src --ext .ts,.tsx --fix", @@ -76,4 +77,4 @@ "vitest": "4.0.15", "wxt": "0.19.29" } -} \ No newline at end of file +} diff --git a/extension/scripts/zip-source.ts b/extension/scripts/zip-source.ts new file mode 100755 index 0000000..e65931d --- /dev/null +++ b/extension/scripts/zip-source.ts @@ -0,0 +1,91 @@ +import { execSync } from 'node:child_process'; +import { mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +try { + const extensionDir = process.cwd(); + const packageJsonPath = join(extensionDir, 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const repoRoot = execSync('git rev-parse --show-toplevel', { + cwd: extensionDir, + encoding: 'utf8', + }).trim(); + + if (repoRoot !== join(extensionDir, '..')) { + throw new Error(`Expected to run inside the extension workspace, but repo root resolved to ${repoRoot}`); + } + + const dirtyTree = execSync('git status --porcelain --untracked-files=all -- extension', { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + + if (dirtyTree) { + throw new Error( + `Refusing to create a source archive from a dirty extension tree.\nCommit or stash these changes first:\n${dirtyTree}`, + ); + } + + const version = packageJson.version; + + if (!version) { + throw new Error('Could not find version in package.json'); + } + + const trackedScriptPath = execSync( + 'git ls-files --error-unmatch extension/scripts/zip-source.ts', + { + cwd: repoRoot, + encoding: 'utf8', + }, + ).trim(); + + if (trackedScriptPath !== 'extension/scripts/zip-source.ts') { + throw new Error('zip-source script must be tracked in git before archive creation'); + } + + const outDir = join(extensionDir, 'builds', 'source'); + mkdirSync(outDir, { recursive: true }); + + const archiveName = `ctrl-extension-${version}-source.zip`; + const relativeArchiveOutPath = `extension/builds/source/${archiveName}`; + const archivePath = join(outDir, archiveName); + + const includePathspecs = [ + 'extension/.gitignore', + 'extension/CHANGELOG.md', + 'extension/LINUX_SETUP.md', + 'extension/babel.config.js', + 'extension/e2e', + 'extension/eslint.config.js', + 'extension/package-lock.json', + 'extension/package.json', + 'extension/playwright.config.ts', + 'extension/postcss.config.js', + 'extension/scripts', + 'extension/src', + 'extension/tailwind.config.js', + 'extension/tests', + 'extension/tsconfig.json', + 'extension/vitest.config.ts', + 'extension/vitest.setup.ts', + 'extension/wxt.config.ts', + ':(exclude)extension/tests/e2e/.persistent-data', + ]; + + console.log(`Creating AMO source archive for v${version} from clean HEAD...`); + + const quotedPathspecs = includePathspecs.map((pathspec) => `"${pathspec}"`).join(' '); + execSync( + `git archive --format=zip --output "${relativeArchiveOutPath}" HEAD ${quotedPathspecs}`, + { + cwd: repoRoot, + stdio: 'inherit', + }, + ); + + console.log(`Successfully created source archive: ${archivePath}`); +} catch (error) { + console.error('\nFailed to create source archive:', error); + process.exit(1); +} From 03902e4925c16ab0e43b8440ef4964b63c4456e7 Mon Sep 17 00:00:00 2001 From: StarlightDaemon <23347919+StarlightDaemon@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:43:43 -0600 Subject: [PATCH 2/7] test(e2e): remove stale duplicate playwright specs --- extension/e2e/core-flows.spec.ts | 89 -------------------------------- extension/e2e/example.spec.ts | 41 --------------- 2 files changed, 130 deletions(-) delete mode 100755 extension/e2e/core-flows.spec.ts delete mode 100755 extension/e2e/example.spec.ts diff --git a/extension/e2e/core-flows.spec.ts b/extension/e2e/core-flows.spec.ts deleted file mode 100755 index 3569c2e..0000000 --- a/extension/e2e/core-flows.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { test, expect, chromium } from '@playwright/test'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -test.describe('Core User Flows', () => { - let browserContext; - let extensionId; - - test.setTimeout(60000); - - test.beforeAll(async () => { - const pathToExtension = path.join(__dirname, '../builds/chrome-mv3'); - const userDataDir = path.join(__dirname, `../test-user-data-core-${Date.now()}`); - - browserContext = await chromium.launchPersistentContext(userDataDir, { - headless: false, - args: [ - `--disable-extensions-except=${pathToExtension}`, - `--load-extension=${pathToExtension}`, - '--no-sandbox', - '--disable-gpu', - ], - }); - - // Wait for extension to load (Service Worker for MV3) - let [serviceWorker] = browserContext.serviceWorkers(); - if (!serviceWorker) { - serviceWorker = await browserContext.waitForEvent('serviceworker'); - } - - extensionId = serviceWorker.url().split('/')[2]; - console.log('Extension ID:', extensionId); - - // Verify worker is alive - const runtimeId = await serviceWorker.evaluate(() => chrome.runtime.id); - console.log('Runtime ID from Worker:', runtimeId); - - // Give it a moment to initialize - await new Promise(resolve => setTimeout(resolve, 1000)); - }); - - test.afterAll(async () => { - await browserContext.close(); - }); - - test('Extension Loads and Popup Works', async ({ page }) => { - console.log(`Navigating to chrome-extension://${extensionId}/popup.html`); - await page.goto(`chrome-extension://${extensionId}/popup.html`, { waitUntil: 'domcontentloaded' }); - await expect(page.locator('text=Torrent Control')).toBeVisible(); - }); - - test('Options Page - About Tab', async ({ page }) => { - console.log(`Navigating to chrome-extension://${extensionId}/options.html`); - await page.goto(`chrome-extension://${extensionId}/options.html`, { waitUntil: 'domcontentloaded' }); - - // Navigate to About tab - await page.click('text=About'); - - // Verify Content - await expect(page.locator('text=Torrent Control')).toBeVisible(); - await expect(page.locator('text=Reloaded')).toBeVisible(); - await expect(page.locator('text=High Performance')).toBeVisible(); - - // Verify Scrolling (indirectly by checking visibility of bottom elements) - await expect(page.locator('text=Released under the MIT License')).toBeVisible(); - }); - - test('Options Page - Add Server UI', async ({ page }) => { - console.log(`Navigating to chrome-extension://${extensionId}/options.html`); - await page.goto(`chrome-extension://${extensionId}/options.html`, { waitUntil: 'domcontentloaded' }); - - // Navigate to Servers tab (it's under Torrent Control -> Servers) - await page.click('text=Servers'); - - // Check for Add Server button - await expect(page.locator('button:has-text("Add Server")')).toBeVisible(); - - // Click Add Server - await page.click('button:has-text("Add Server")'); - - // Check for form fields - await expect(page.locator('input[placeholder="e.g., My Seedbox"]')).toBeVisible(); - await expect(page.locator('text=Client Type')).toBeVisible(); - }); -}); diff --git a/extension/e2e/example.spec.ts b/extension/e2e/example.spec.ts deleted file mode 100755 index 843e29d..0000000 --- a/extension/e2e/example.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { test, expect, chromium } from '@playwright/test'; -import path from 'path'; - -test.describe('Extension E2E', () => { - let browserContext; - let extensionId; - - test.beforeAll(async () => { - const pathToExtension = path.join(__dirname, '../builds/chrome-mv3'); - const userDataDir = path.join(__dirname, '../test-user-data'); - - browserContext = await chromium.launchPersistentContext(userDataDir, { - headless: false, - args: [ - `--disable-extensions-except=${pathToExtension}`, - `--load-extension=${pathToExtension}`, - ], - }); - - // Wait for extension to load - let [backgroundPage] = browserContext.backgroundPages(); - if (!backgroundPage) { - backgroundPage = await browserContext.waitForEvent('backgroundpage'); - } - - // Extract Extension ID from background page URL - // chrome-extension:///background.js - extensionId = backgroundPage.url().split('/')[2]; - }); - - test.afterAll(async () => { - await browserContext.close(); - }); - - test('Popup opens and renders title', async ({ page }) => { - // Open Popup directly - await page.goto(`chrome-extension://${extensionId}/popup.html`); - - await expect(page.locator('text=Torrent Control')).toBeVisible(); - }); -}); From 5395a1712e91b0ebc31db8841034e31ab4cbbfb0 Mon Sep 17 00:00:00 2001 From: StarlightDaemon <23347919+StarlightDaemon@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:53:03 -0600 Subject: [PATCH 3/7] feat(extension): carry forward non-vpn runtime and adapter tests --- extension/src/entrypoints/background.ts | 14 +- .../src/entrypoints/options/Dashboard.tsx | 15 +- .../model/services/ContextMenuService.ts | 6 +- .../torrent-control/model/useSettings.ts | 40 ++- .../torrent-control/model/useTorrentPoller.ts | 24 +- .../services/LifecycleAdapter.ts | 6 +- .../torrent-control/services/StateHydrator.ts | 7 +- .../features/torrent-control/ui/Dashboard.tsx | 10 +- .../ui/DiagnosticsSettings.tsx | 10 +- .../torrent-control/ui/FunctionSettings.tsx | 6 +- .../shared/api/clients/aria2/Aria2Schema.ts | 5 +- .../api/clients/biglybt/BiglyBTAdapter.ts | 42 ++- .../api/clients/deluge/DelugeAdapter.ts | 36 +-- .../shared/api/clients/flood/FloodAdapter.ts | 29 +-- .../qbittorrent/QBittorrentRssService.ts | 4 +- .../qbittorrent/QBittorrentSearchService.ts | 7 +- .../api/clients/rutorrent/RTorrentSchema.ts | 13 +- .../api/clients/rutorrent/RuTorrentAdapter.ts | 54 ++-- .../api/clients/synology/SynologyAdapter.ts | 23 +- .../transmission/TransmissionAdapter.ts | 10 +- .../api/clients/utorrent/UTorrentAdapter.ts | 22 +- .../clients/utorrent/UTorrentRssService.ts | 15 +- .../utorrent/UTorrentSettingsService.ts | 10 +- .../src/shared/api/network/FetchHttpClient.ts | 71 ++++++ .../src/shared/api/network/JsonRpcClient.ts | 20 +- .../src/shared/api/security/VaultService.ts | 13 +- extension/src/shared/lib/buildInfo.ts | 4 +- extension/src/shared/ui/SystemSettings.tsx | 20 +- .../tests/unit/adapters/Aria2Adapter.test.ts | 107 ++++++++ .../unit/adapters/BiglyBTAdapter.test.ts | 55 ++++ .../tests/unit/adapters/FloodAdapter.test.ts | 70 +++++ .../unit/adapters/RuTorrentAdapter.test.ts | 37 ++- .../unit/adapters/TransmissionAdapter.test.ts | 10 +- .../unit/adapters/UTorrentAdapter.test.ts | 241 +++++++++++++++++- 34 files changed, 855 insertions(+), 201 deletions(-) diff --git a/extension/src/entrypoints/background.ts b/extension/src/entrypoints/background.ts index e956521..bc6abbf 100755 --- a/extension/src/entrypoints/background.ts +++ b/extension/src/entrypoints/background.ts @@ -4,14 +4,12 @@ import { storage } from 'wxt/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 { ServerConfig, AppSettings } from '@/shared/lib/types'; +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 { VaultService, VAULT_DATA_KEY } from '@/shared/api/security/VaultService'; -import { SESSION_KEY_KEY } from '@/shared/api/security/VaultService'; -import { DEFAULT_OPTIONS } from '@/shared/lib/constants'; +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) @@ -62,7 +60,7 @@ export default defineBackground(() => { if (!target) return { client: null, state: ResolutionState.INVALID_CONFIG }; try { return { client: await factory.create(target), state: ResolutionState.OK }; - } catch (e) { + } catch { return { client: null, state: ResolutionState.INVALID_CONFIG }; } } @@ -271,7 +269,7 @@ export default defineBackground(() => { }); // Message Handler - chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { const handleMessage = async () => { try { // Attempt to resolve target client @@ -284,7 +282,7 @@ export default defineBackground(() => { } } - const { state, servers, activeServer } = await ServerResolver.resolve(); + const { state, servers } = await ServerResolver.resolve(); if (state !== ResolutionState.OK) { if (state === ResolutionState.LOCKED || state === ResolutionState.UNINITIALIZED) { @@ -387,7 +385,7 @@ export default defineBackground(() => { console.log('[Background] TEST_CONNECTION received. Type:', message.config?.type); } const result = await client.testConnection(); - const r = result as any; + const r = result as unknown as Record; console.info(JSON.stringify({ event: 'TEST_CONNECTION_RESULT', messageType: message.type, diff --git a/extension/src/entrypoints/options/Dashboard.tsx b/extension/src/entrypoints/options/Dashboard.tsx index 1dacc4f..54575e9 100755 --- a/extension/src/entrypoints/options/Dashboard.tsx +++ b/extension/src/entrypoints/options/Dashboard.tsx @@ -25,6 +25,8 @@ interface DashboardProps { lockVault: () => Promise; } +const defaultCustomOptions = { addToClient: true, pauseResume: true, openWebUI: true }; + export const Dashboard: React.FC = ({ settings, updateSettings, @@ -39,7 +41,6 @@ export const Dashboard: React.FC = ({ // Start polling for torrents useTorrentPoller(); - const defaultCustomOptions = { addToClient: true, pauseResume: true, openWebUI: true }; const [previewContextMenu, setPreviewContextMenu] = useState(1); const [previewCustomOptions, setPreviewCustomOptions] = useState(defaultCustomOptions); const [previewServers, setPreviewServers] = useState([]); @@ -87,7 +88,7 @@ export const Dashboard: React.FC = ({ globals: { ...settings.globals, enableNotifications: previewNotification, - notificationLevel: previewNotificationLevel as any + notificationLevel: previewNotificationLevel as 'standard' | 'verbose' | 'error' } }); }; @@ -142,7 +143,7 @@ export const Dashboard: React.FC = ({
updateSettings(s as any)} + updateSettings={(s) => updateSettings(s as AppSettings)} previewContextMenu={previewContextMenu} setPreviewContextMenu={setPreviewContextMenu} previewCustomOptions={previewCustomOptions} @@ -165,7 +166,7 @@ export const Dashboard: React.FC = ({
updateSettings(s as any)} + updateSettings={(s) => updateSettings(s as AppSettings)} exportServerConfig={exportServerConfig} importBackup={importBackup} /> @@ -178,7 +179,7 @@ export const Dashboard: React.FC = ({
updateSettings(s as any)} + updateSettings={(s) => updateSettings(s as AppSettings)} />
@@ -201,7 +202,7 @@ export const Dashboard: React.FC = ({
updateSettings(s as any)} + updateSettings={(s) => updateSettings(s as AppSettings)} exportSystemBackup={exportSystemBackup} importBackup={importBackup} /> @@ -214,7 +215,7 @@ export const Dashboard: React.FC = ({
updateSettings(s as any)} + updateSettings={(s) => updateSettings(s as AppSettings)} />
diff --git a/extension/src/features/torrent-control/model/services/ContextMenuService.ts b/extension/src/features/torrent-control/model/services/ContextMenuService.ts index b5e6db5..55c09b2 100755 --- a/extension/src/features/torrent-control/model/services/ContextMenuService.ts +++ b/extension/src/features/torrent-control/model/services/ContextMenuService.ts @@ -3,7 +3,7 @@ import { storage } from 'wxt/storage'; import { ITorrentClient } from '@/entities/client/model/ITorrentClient'; import { AppSettings, ServerConfig } from '@/shared/lib/types'; import { DEFAULT_OPTIONS } from '@/shared/lib/constants'; -import { VaultService, SESSION_KEY_KEY, VAULT_DATA_KEY, VAULT_SALT_KEY } from '@/shared/api/security/VaultService'; +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'; @@ -170,8 +170,8 @@ export class ContextMenuService { private determineMenuItems( resolution: ResolvedServers, mode: number, - custom: any, - globals: any + custom: Partial> | undefined, + globals: AppSettings['globals'] ): chrome.contextMenus.CreateProperties[] { const items: chrome.contextMenus.CreateProperties[] = []; diff --git a/extension/src/features/torrent-control/model/useSettings.ts b/extension/src/features/torrent-control/model/useSettings.ts index a625271..f401441 100755 --- a/extension/src/features/torrent-control/model/useSettings.ts +++ b/extension/src/features/torrent-control/model/useSettings.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { storage } from 'wxt/storage'; import { AppOptions, ServerConfig } from '@/shared/lib/types'; import { DEFAULT_OPTIONS } from '@/shared/lib/constants'; @@ -20,7 +20,7 @@ const ServerConfigSchema = z.object({ username: z.string().optional(), password: z.string().optional(), directories: z.array(z.string()).default([]), - clientOptions: z.record(z.any()).default({}), + clientOptions: z.record(z.unknown()).default({}), httpAuth: z.object({ username: z.string(), password: z.string().optional() @@ -72,14 +72,14 @@ const BackupSchema = z.object({ type: z.enum(['system_backup', 'server_config']).optional(), subtype: z.enum(['full', 'settings']).optional(), timestamp: z.string().optional(), - data: z.record(z.any()) + data: z.record(z.unknown()) }); export function useSettings() { const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); - const load = async () => { + const load = useCallback(async () => { const val = await settingsStorage.getValue(); // Deep merge logic const merged = { @@ -120,7 +120,7 @@ export function useSettings() { setSettings(merged); setLoading(false); - }; + }, []); useEffect(() => { load(); @@ -133,7 +133,7 @@ export function useSettings() { // Ideally we'd watch a Vault state but WXT storage watch covers session key if we used storage. return () => unwatch(); - }, []); + }, [load]); const updateSettings = async (newSettings: AppOptions) => { // 1. Handle Vault (Servers) - write first so if it fails, state remains unchanged @@ -159,6 +159,7 @@ export function useSettings() { // 2. Handle Storage (Everything else) // Ensure we never write servers to local storage here + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { servers, ...safeSettings } = newSettings; await settingsStorage.setValue(safeSettings as AppOptions); }; @@ -260,10 +261,19 @@ export function useSettings() { reader.onload = async (e) => { try { const content = e.target?.result as string; - let raw: any; + let raw: { + version?: unknown; + type?: unknown; + subtype?: unknown; + globals?: unknown; + appearance?: unknown; + layout?: unknown; + servers?: unknown; + data?: unknown; + }; try { raw = JSON.parse(content); - } catch (err) { + } catch { throw new Error('Invalid JSON: The file could not be parsed.'); } @@ -277,7 +287,7 @@ export function useSettings() { // 2. Full Validation (Zero state changes until this completes) let serversToImport: ServerConfig[] | undefined; - let settingsToImport: any; // Collector for validated settings, using any to handle partial shapes before merge + let settingsToImport: Partial | undefined; // Collector for validated settings let successMessage = ''; const isVaultInitialized = await VaultService.isInitialized(); @@ -292,9 +302,9 @@ export function useSettings() { const validatedLayout = raw.layout ? LayoutSchema.parse(raw.layout) : undefined; settingsToImport = { - globals: validatedGlobals, - appearance: validatedAppearance, - layout: validatedLayout + globals: validatedGlobals as AppOptions['globals'], + appearance: validatedAppearance as AppOptions['appearance'], + layout: validatedLayout as AppOptions['layout'] }; serversToImport = validatedServers; successMessage = 'Legacy full backup imported.'; @@ -311,13 +321,14 @@ export function useSettings() { } else if (meta.type === 'system_backup') { const validatedData = AppOptionsSchema.parse(meta.data); if (meta.subtype === 'full') { - settingsToImport = validatedData; + settingsToImport = validatedData as Partial; serversToImport = validatedData.servers; 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; + settingsToImport = rest as Partial; successMessage = 'System settings imported.'; } } else { @@ -353,6 +364,7 @@ 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 = { diff --git a/extension/src/features/torrent-control/model/useTorrentPoller.ts b/extension/src/features/torrent-control/model/useTorrentPoller.ts index 15555ac..7557d3c 100755 --- a/extension/src/features/torrent-control/model/useTorrentPoller.ts +++ b/extension/src/features/torrent-control/model/useTorrentPoller.ts @@ -2,8 +2,8 @@ import { useEffect } from 'react'; import { useTorrentStore } from '../../../stores/useTorrentStore'; import { useSettings } from './useSettings'; -export const useTorrentPoller = (intervalMs = 2000) => { - const { setViewportData, setLoading, setError } = useTorrentStore(); +export const useTorrentPoller = (_intervalMs = 2000) => { + const { setViewportData, setLoading } = useTorrentStore(); const { settings } = useSettings(); @@ -15,25 +15,30 @@ export const useTorrentPoller = (intervalMs = 2000) => { // 2. Message Listener (via Port or Runtime mainly runtime for broadcast) // Note: We keep runtime listener for global broadcasts, but Port is for lifecycle. - const messageListener = (message: any) => { + 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, total, start); + 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, total, start); + applyPatchData(patches as Parameters[0], total, start); setLoading(false); } if (message.type === 'STATS_UPDATE') { - const { globalStats, setGlobalStats } = useTorrentStore.getState(); - setGlobalStats(message.data); + const { setGlobalStats } = useTorrentStore.getState(); + setGlobalStats(message.data as Parameters[0]); } }; - chrome.runtime.onMessage.addListener(messageListener); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chrome.runtime.onMessage.addListener(messageListener as any); // 3. Initial Request setLoading(true); @@ -47,7 +52,8 @@ export const useTorrentPoller = (intervalMs = 2000) => { }); return () => { - chrome.runtime.onMessage.removeListener(messageListener); + // 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/services/LifecycleAdapter.ts b/extension/src/features/torrent-control/services/LifecycleAdapter.ts index e641344..908a361 100755 --- a/extension/src/features/torrent-control/services/LifecycleAdapter.ts +++ b/extension/src/features/torrent-control/services/LifecycleAdapter.ts @@ -15,7 +15,7 @@ export const LifecycleAdapter = { 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 any).getBrowserInfo !== 'undefined'; + 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; @@ -39,7 +39,7 @@ export const LifecycleAdapter = { /** * Start WebSocket keepalive with a specific URL (for clients that support WS) */ - startWebSocketKeepalive(wsUrl: string, onMessage?: (data: any) => void): void { + startWebSocketKeepalive(wsUrl: string, onMessage?: (data: unknown) => void): void { if (!WebSocketKeepalive.isSupported()) { console.warn('[LifecycleAdapter] WebSocket not supported'); return; @@ -88,7 +88,7 @@ export const LifecycleAdapter = { * @param html String HTML to parse * @returns Parsed document or data (Note: returning actual DOM nodes passes poorly over messages) */ - parseDOM: async (html: string): Promise => { + parseDOM: async (html: string): Promise => { // Check for native DOM support (Firefox Event Pages) if (typeof DOMParser !== 'undefined') { const parser = new DOMParser(); diff --git a/extension/src/features/torrent-control/services/StateHydrator.ts b/extension/src/features/torrent-control/services/StateHydrator.ts index e60640d..d6f9bbd 100755 --- a/extension/src/features/torrent-control/services/StateHydrator.ts +++ b/extension/src/features/torrent-control/services/StateHydrator.ts @@ -1,12 +1,11 @@ -import { storage } from 'wxt/storage'; import { browser } from 'wxt/browser'; const STORAGE_KEY = 'session:torrent_state'; // Simple debounce utility to avoid external dependency issues -function debounce void>(func: T, wait: number): (...args: Parameters) => void { +function debounce(func: (...args: Args) => void, wait: number): (...args: Args) => void { let timeout: ReturnType | null = null; - return function (...args: Parameters) { + return function (...args: Args) { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => { func(...args); @@ -37,7 +36,7 @@ export const StateHydrator = { * Persists the state to session storage. * Debounced to prevent thrashing storage on every single update. */ - persist: debounce((state: any) => { + persist: debounce((state: unknown) => { try { browser.storage.session.set({ [STORAGE_KEY]: state }); console.debug('[StateHydrator] State persisted to session storage.'); diff --git a/extension/src/features/torrent-control/ui/Dashboard.tsx b/extension/src/features/torrent-control/ui/Dashboard.tsx index bdf07fd..09cb198 100755 --- a/extension/src/features/torrent-control/ui/Dashboard.tsx +++ b/extension/src/features/torrent-control/ui/Dashboard.tsx @@ -85,7 +85,7 @@ export const Dashboard = () => { setStatus('Error: ' + response.error); setStatusKind('danger'); } - } catch (e) { + } catch { setStatus('Connection Failed'); setStatusKind('danger'); } @@ -107,8 +107,8 @@ export const Dashboard = () => { setStatus('Torrent Added'); setStatusKind('success'); fetchTorrents(); - } catch (e: any) { - setStatus('Add Failed: ' + e.message); + } catch (e: unknown) { + setStatus('Add Failed: ' + (e instanceof Error ? e.message : String(e))); setStatusKind('danger'); } }; @@ -178,8 +178,8 @@ export const Dashboard = () => { setStatus('Torrent Added'); setStatusKind('success'); fetchTorrents(); - } catch (e: any) { - setStatus('Add Failed: ' + e.message); + } catch (e: unknown) { + setStatus('Add Failed: ' + (e instanceof Error ? e.message : String(e))); setStatusKind('danger'); throw e; } diff --git a/extension/src/features/torrent-control/ui/DiagnosticsSettings.tsx b/extension/src/features/torrent-control/ui/DiagnosticsSettings.tsx index 3799dd6..8df4945 100755 --- a/extension/src/features/torrent-control/ui/DiagnosticsSettings.tsx +++ b/extension/src/features/torrent-control/ui/DiagnosticsSettings.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { AppOptions, ServerConfig } from '@/shared/lib/types'; import { EXTERNAL_RESOURCES } from '@/shared/lib/resources'; -import { ExternalLink, Info, Activity, Globe } from 'lucide-react'; +import { ExternalLink, Info, Activity } from 'lucide-react'; import { SettingsPageLayout } from '@/shared/ui/settings/SettingsPageLayout'; import { SettingsCard } from '@/shared/ui/settings/SettingsCard'; import { @@ -9,8 +9,6 @@ import { InlineNotification, Button, Tile, - Tag, - IconButton, Link, Toggletip, ToggletipButton, @@ -102,7 +100,7 @@ const ServerDiagnosticRow: React.FC<{ server: ServerConfig; index: number }> = ( }); const isPrivateIP = (hostname: string) => { - let host = hostname.replace(/https?:\/\//, '').split(':')[0]; + const host = hostname.replace(/https?:\/\//, '').split(':')[0]; if (host === 'localhost') return true; const parts = host.split('.').map(Number); if (parts.length !== 4) return false; @@ -127,7 +125,7 @@ const ServerDiagnosticRow: React.FC<{ server: ServerConfig; index: number }> = ( error: true }); } - } catch (e) { + } catch { setPingStatus({ loading: false, result: 'Error', error: true }); } }; @@ -141,7 +139,7 @@ const ServerDiagnosticRow: React.FC<{ server: ServerConfig; index: number }> = ( } else { setAuthStatus({ loading: false, result: 'Auth Failed', error: true }); } - } catch (e) { + } catch { setAuthStatus({ loading: false, result: 'Error', error: true }); } }; diff --git a/extension/src/features/torrent-control/ui/FunctionSettings.tsx b/extension/src/features/torrent-control/ui/FunctionSettings.tsx index 1afadd9..8c0db70 100755 --- a/extension/src/features/torrent-control/ui/FunctionSettings.tsx +++ b/extension/src/features/torrent-control/ui/FunctionSettings.tsx @@ -4,7 +4,7 @@ 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 { InlineNotification, Select, SelectItem, Stack } from '@carbon/react'; +import { Select, SelectItem, Stack } from '@carbon/react'; import { ContextMenuSettings } from './settings/ContextMenuSettings'; import { NotificationSettings } from './settings/NotificationSettings'; @@ -47,7 +47,7 @@ export const FunctionSettings: React.FC = ({ setPreviewNotificationLevel, applyNotifications }) => { - const handleChange = (field: keyof AppOptions['globals'], value: any) => { + const handleChange = (field: keyof AppOptions['globals'], value: AppOptions['globals'][keyof AppOptions['globals']]) => { updateSettings({ ...settings, globals: { @@ -97,7 +97,7 @@ export const FunctionSettings: React.FC = ({ labelText="Badge Information" hideLabel value={settings.globals.badgeInfo} - onChange={(e) => handleChange('badgeInfo', e.target.value as any)} + onChange={(e) => handleChange('badgeInfo', e.target.value as AppOptions['globals']['badgeInfo'])} {...badgeInfoDebug} > diff --git a/extension/src/shared/api/clients/aria2/Aria2Schema.ts b/extension/src/shared/api/clients/aria2/Aria2Schema.ts index 973f5ab..c9e8a9e 100755 --- a/extension/src/shared/api/clients/aria2/Aria2Schema.ts +++ b/extension/src/shared/api/clients/aria2/Aria2Schema.ts @@ -81,8 +81,9 @@ export const Aria2TorrentSchema = z.object({ errorMessage: z.string().optional(), // GID tracking for multi-phase downloads (magnet -> torrent) - followedBy: z.string().optional(), - following: z.string().optional(), + // Aria2 wire format: followedBy and following are arrays of GID strings + followedBy: z.array(z.string()).optional(), + following: z.array(z.string()).optional(), belongsTo: z.string().optional(), // File list diff --git a/extension/src/shared/api/clients/biglybt/BiglyBTAdapter.ts b/extension/src/shared/api/clients/biglybt/BiglyBTAdapter.ts index 1ef5d2b..873150c 100755 --- a/extension/src/shared/api/clients/biglybt/BiglyBTAdapter.ts +++ b/extension/src/shared/api/clients/biglybt/BiglyBTAdapter.ts @@ -639,6 +639,33 @@ export class BiglyBTAdapter implements ITorrentClient { return tags.map(t => t.name); } + /** + * Resolve a hash-like string or numeric string to the numeric standard ID expected by torrent-set. + */ + private async resolveNumericId(hashOrId: string): Promise { + if (/^\d+$/.test(hashOrId)) { + return parseInt(hashOrId, 10); + } + + const response = await this.call('torrent-get', { + ids: [hashOrId], + fields: ['id'], + mapPerFile: true + }); + + // We only requested 'id', so extracting directly bypasses full schema validation + // which might fail if required fields are missing from the narrow response. + const res = response as any; + const torrents = res?.arguments?.torrents; + const torrent = Array.isArray(torrents) ? torrents[0] : undefined; + + if (!torrent || typeof torrent.id !== 'number') { + throw new Error(`Failed to resolve numeric ID for torrent: ${hashOrId}`); + } + + return torrent.id; + } + /** * Add tags to a torrent (atomic operation for BiglyBT) * @@ -646,10 +673,12 @@ export class BiglyBTAdapter implements ITorrentClient { * For standard Transmission, falls back to fetch/merge/set pattern. */ async addTags(hash: string, tags: string[]): Promise { + const id = await this.resolveNumericId(hash); + if (this.capabilities.isBiglyBT) { // Atomic operation - no fetch/merge/set cycle needed await this.call('torrent-set', { - ids: [parseInt(hash)], + ids: [id], tagsAdd: tags }); } else { @@ -657,7 +686,7 @@ export class BiglyBTAdapter implements ITorrentClient { const current = await this.getTorrentTags(hash); const merged = Array.from(new Set([...current, ...tags])); await this.call('torrent-set', { - ids: [parseInt(hash)], + ids: [id], labels: merged }); } @@ -667,10 +696,12 @@ export class BiglyBTAdapter implements ITorrentClient { * Remove tags from a torrent (atomic operation for BiglyBT) */ async removeTags(hash: string, tags: string[]): Promise { + const id = await this.resolveNumericId(hash); + if (this.capabilities.isBiglyBT) { // Atomic operation await this.call('torrent-set', { - ids: [parseInt(hash)], + ids: [id], tagsRemove: tags }); } else { @@ -678,7 +709,7 @@ export class BiglyBTAdapter implements ITorrentClient { const current = await this.getTorrentTags(hash); const filtered = current.filter(t => !tags.includes(t)); await this.call('torrent-set', { - ids: [parseInt(hash)], + ids: [id], labels: filtered }); } @@ -688,8 +719,9 @@ export class BiglyBTAdapter implements ITorrentClient { * Get tags for a specific torrent */ private async getTorrentTags(hash: string): Promise { + const id = await this.resolveNumericId(hash); const response = await this.call('torrent-get', { - ids: [parseInt(hash)], + ids: [id], fields: ['labels'], mapPerFile: true }); diff --git a/extension/src/shared/api/clients/deluge/DelugeAdapter.ts b/extension/src/shared/api/clients/deluge/DelugeAdapter.ts index 0075fb9..444baa6 100755 --- a/extension/src/shared/api/clients/deluge/DelugeAdapter.ts +++ b/extension/src/shared/api/clients/deluge/DelugeAdapter.ts @@ -3,7 +3,7 @@ import { ITorrentClient, AddTorrentOptions } from '@/entities/client/model/ITorr import { Torrent, TorrentStatus } from '@/entities/torrent/model/Torrent'; import { FetchHttpClient } from '@/shared/api/network/FetchHttpClient'; import { ServerConfig } from '@/shared/lib/types'; -import { DelugeRpcResponseSchema, DelugeUpdateUiSchema, DelugeHostsListSchema, DelugeTorrent, DelugeWebPlugins, DelugeWebPluginsSchema, DelugeMethodsSchema, DelugeTorrentOptions, DelugeFilePriority, DelugeFile, DelugePeer, DelugeTracker, DelugePeerSchema, DelugeTrackerSchema } from './DelugeSchema'; +import { DelugeUpdateUiSchema, DelugeTorrent, DelugeWebPlugins, DelugeWebPluginsSchema, DelugeMethodsSchema, DelugeTorrentOptions, DelugeFilePriority, DelugeFile, DelugePeer, DelugeTracker, DelugePeerSchema, DelugeTrackerSchema } from './DelugeSchema'; /** * Deluge JSON-RPC Error Codes @@ -71,7 +71,7 @@ export class DelugeAdapter implements ITorrentClient { ); try { - const response = await this.client.post('', payload, { + const response = await this.client.post<{ error?: { code?: number; message?: string }; result?: T }>('', payload, { signal: controller.signal }); @@ -80,7 +80,7 @@ export class DelugeAdapter implements ITorrentClient { const errorCode = response.error.code as DelugeErrorCode | undefined; const errorMessage = response.error.message || `Error code ${errorCode}`; const error = new Error(`Deluge RPC Error (${errorCode}): ${errorMessage}`); - (error as any).code = errorCode; + (error as Error & { code?: number }).code = errorCode; throw error; } @@ -124,7 +124,7 @@ export class DelugeAdapter implements ITorrentClient { if (isConnected) return; // Get available hosts - const hosts = await this.call('web.get_hosts'); + const hosts = await this.call<[string, string, number, string][]>('web.get_hosts'); if (!hosts || hosts.length === 0) { throw new Error('No Deluge Daemons available'); } @@ -175,7 +175,7 @@ export class DelugeAdapter implements ITorrentClient { try { return await action(); } catch (e: unknown) { - const errorCode = (e as any)?.code; + const errorCode = (e as { code?: unknown })?.code; const message = e instanceof Error ? e.message : String(e); // Check for authentication errors (code 1 or message patterns) @@ -220,7 +220,7 @@ export class DelugeAdapter implements ITorrentClient { "time_added", "label" // Extended fields ]; - const response = await this.call('web.update_ui', [keys, {}]); + const response = await this.call('web.update_ui', [keys, {}]); const validated = DelugeUpdateUiSchema.parse(response); if (!validated.torrents) return []; @@ -250,7 +250,7 @@ export class DelugeAdapter implements ITorrentClient { if (filter.label) filterDict.label = filter.label; if (filter.tracker_host) filterDict.tracker_host = filter.tracker_host; - const response = await this.call('web.update_ui', [keys, filterDict]); + const response = await this.call('web.update_ui', [keys, filterDict]); const validated = DelugeUpdateUiSchema.parse(response); if (!validated.torrents) return []; @@ -339,7 +339,7 @@ export class DelugeAdapter implements ITorrentClient { // label.get_labels const labels = await this.call('label.get_labels'); return labels || []; - } catch (e) { + } catch { // Plugin might not be enabled return []; } @@ -356,11 +356,11 @@ export class DelugeAdapter implements ITorrentClient { return []; // Deluge uses Labels, mapped to Categories. Tags are not distinct in v1/v2 core. } - async addTags(hash: string, tags: string[]): Promise { + async addTags(_hash: string, _tags: string[]): Promise { // No-op } - async removeTags(hash: string, tags: string[]): Promise { + async removeTags(_hash: string, _tags: string[]): Promise { // No-op } @@ -402,7 +402,7 @@ export class DelugeAdapter implements ITorrentClient { */ async getWebPlugins(): Promise { return this.ensureAuth(async () => { - const result = await this.call('web.get_plugins'); + const result = await this.call('web.get_plugins'); return DelugeWebPluginsSchema.parse(result); }); } @@ -535,7 +535,7 @@ export class DelugeAdapter implements ITorrentClient { status: string; }>> { return this.ensureAuth(async () => { - const hosts = await this.call('web.get_hosts'); + const hosts = await this.call<[string, string, number, string][]>('web.get_hosts'); return hosts.map(h => ({ id: h[0], hostname: h[1], @@ -568,7 +568,7 @@ export class DelugeAdapter implements ITorrentClient { */ async getFiles(torrentId: string): Promise> { return this.ensureAuth(async () => { - const status = await this.call('core.get_torrent_status', [ + const status = await this.call<{ files?: { index?: number; path: string; size: number; offset?: number }[]; file_priorities?: number[]; file_progress?: number[] }>('core.get_torrent_status', [ torrentId, ['files', 'file_priorities', 'file_progress'] ]); @@ -577,7 +577,7 @@ export class DelugeAdapter implements ITorrentClient { const priorities = status.file_priorities || []; const progress = status.file_progress || []; - return files.map((f: any, i: number) => ({ + return files.map((f: { index?: number; path: string; size: number; offset?: number }, i: number) => ({ index: f.index ?? i, path: f.path, size: f.size, @@ -610,12 +610,12 @@ export class DelugeAdapter implements ITorrentClient { */ async getPeers(torrentId: string): Promise { return this.ensureAuth(async () => { - const status = await this.call('core.get_torrent_status', [ + const status = await this.call<{ peers?: unknown[] }>('core.get_torrent_status', [ torrentId, ['peers'] ]); const peers = status.peers || []; - return peers.map((p: any) => DelugePeerSchema.parse(p)); + return peers.map((p: unknown) => DelugePeerSchema.parse(p)); }); } @@ -624,12 +624,12 @@ export class DelugeAdapter implements ITorrentClient { */ async getTrackers(torrentId: string): Promise { return this.ensureAuth(async () => { - const status = await this.call('core.get_torrent_status', [ + const status = await this.call<{ trackers?: unknown[] }>('core.get_torrent_status', [ torrentId, ['trackers'] ]); const trackers = status.trackers || []; - return trackers.map((t: any) => DelugeTrackerSchema.parse(t)); + return trackers.map((t: unknown) => DelugeTrackerSchema.parse(t)); }); } diff --git a/extension/src/shared/api/clients/flood/FloodAdapter.ts b/extension/src/shared/api/clients/flood/FloodAdapter.ts index 6268d69..91d0b6c 100755 --- a/extension/src/shared/api/clients/flood/FloodAdapter.ts +++ b/extension/src/shared/api/clients/flood/FloodAdapter.ts @@ -19,7 +19,6 @@ import { FloodCapabilities, } from './FloodSchema'; import { ServerConfig } from '@/shared/lib/types'; -import { blobToBase64 } from '@/shared/lib/helpers'; // ============================================================================ // Constants @@ -260,27 +259,25 @@ export class FloodAdapter implements ITorrentClient { async addTorrentFile(file: Blob, options?: AddTorrentOptions): Promise { const headers = this.getHeaders(); - const base64 = await blobToBase64(file); + const formData = new FormData(); - const body: { - files: string[]; - destination?: string; - start: boolean; - tags: string[]; - isSequential?: boolean; - } = { - files: [base64], - destination: options?.path, - start: !options?.paused, - tags: options?.label ? [options.label] : [], - }; + formData.append('files', file); + + if (options?.path) { + formData.append('destination', options.path); + } + + formData.append('start', String(!options?.paused)); + + const tags = options?.label ? [options.label] : []; + formData.append('tags', JSON.stringify(tags)); if (options?.sequentialDownload) { - body.isSequential = true; + formData.append('isSequential', 'true'); } await this.requestWithRetry( - () => this.httpClient.post('api/torrents/add-files', body, { headers }) + () => this.httpClient.post('api/torrents/add-files', formData, { headers }) ); } diff --git a/extension/src/shared/api/clients/qbittorrent/QBittorrentRssService.ts b/extension/src/shared/api/clients/qbittorrent/QBittorrentRssService.ts index 353dbca..a05e94b 100755 --- a/extension/src/shared/api/clients/qbittorrent/QBittorrentRssService.ts +++ b/extension/src/shared/api/clients/qbittorrent/QBittorrentRssService.ts @@ -10,8 +10,6 @@ */ import { FetchHttpClient } from '@/shared/api/network/FetchHttpClient'; import { - QBittorrentRssFeedSchema, - QBittorrentRssRuleSchema, QBittorrentRssRule } from './QBittorrentSchema'; @@ -54,7 +52,7 @@ export class QBittorrentRssService { * Get all RSS feeds and items * Recurses through the tree structure returned by API */ - async getFeeds(): Promise { + async getFeeds(): Promise { return await this.client.get('rss/items', { params: { withData: 'true' } }); diff --git a/extension/src/shared/api/clients/qbittorrent/QBittorrentSearchService.ts b/extension/src/shared/api/clients/qbittorrent/QBittorrentSearchService.ts index abeb9e4..cfcd3eb 100755 --- a/extension/src/shared/api/clients/qbittorrent/QBittorrentSearchService.ts +++ b/extension/src/shared/api/clients/qbittorrent/QBittorrentSearchService.ts @@ -13,8 +13,7 @@ import { QBittorrentSearchResultSchema, QBittorrentSearchStatusSchema, QBittorrentSearchPluginSchema, - QBittorrentSearchResult, - QBittorrentSearchStatusSchema as SearchStatusType + QBittorrentSearchResult } from './QBittorrentSchema'; import { z } from 'zod'; @@ -52,7 +51,7 @@ export class QBittorrentSearchService { * Get status of a search job */ async getStatus(id: number): Promise> { - const data = await this.client.post('search/status', new URLSearchParams({ id: String(id) })); + const data = await this.client.post('search/status', new URLSearchParams({ id: String(id) })); return QBittorrentSearchStatusSchema.parse(data[0]); // Returns array } @@ -67,7 +66,7 @@ export class QBittorrentSearchService { limit: number = 20, offset: number = 0 ): Promise { - const data = await this.client.post<{ results: any[] }>('search/results', new URLSearchParams({ + const data = await this.client.post<{ results: unknown[] }>('search/results', new URLSearchParams({ id: String(id), limit: String(limit), offset: String(offset) diff --git a/extension/src/shared/api/clients/rutorrent/RTorrentSchema.ts b/extension/src/shared/api/clients/rutorrent/RTorrentSchema.ts index 903042e..b9a1f2c 100755 --- a/extension/src/shared/api/clients/rutorrent/RTorrentSchema.ts +++ b/extension/src/shared/api/clients/rutorrent/RTorrentSchema.ts @@ -17,11 +17,13 @@ import { z } from 'zod'; * 7: state (number/i4 -> bool) * 8: is_active (number/i4 -> bool) * 9: label (string) - * 10: ratio (number/i4) - * 11: hashing (number/i4 -> bool) - * 12: save_path (string) - * 13: up_total (string/i8) - * 14: message (string) + * 10: hashing (number/i4 -> bool) + * 11: save_path (string) + * 12: up_total (string/i8) + * 13: message (string) + * + * Note: ratio is NOT requested from the daemon. It is computed client-side + * from bytes_done / size_bytes with a zero-size guard. */ export const RTorrentMulticallTuple = z.tuple([ z.string(), // hash @@ -34,7 +36,6 @@ export const RTorrentMulticallTuple = z.tuple([ z.number(), // state z.number(), // is_active z.string(), // label - z.number(), // ratio z.number(), // hashing z.string(), // save_path z.union([z.string(), z.number()]), // up_total diff --git a/extension/src/shared/api/clients/rutorrent/RuTorrentAdapter.ts b/extension/src/shared/api/clients/rutorrent/RuTorrentAdapter.ts index 5592ca8..3309fff 100755 --- a/extension/src/shared/api/clients/rutorrent/RuTorrentAdapter.ts +++ b/extension/src/shared/api/clients/rutorrent/RuTorrentAdapter.ts @@ -18,7 +18,6 @@ type XmlRpcResult = unknown; @injectable() export class RuTorrentAdapter implements ITorrentClient { private client: FetchHttpClient; - private rpcEndpoint: string; constructor(private config: ServerConfig) { // rTorrent usually lives at /RPC2 or /rutorrent/plugins/httprpc/action.php @@ -36,7 +35,6 @@ export class RuTorrentAdapter implements ITorrentClient { } this.client = new FetchHttpClient(url); - this.rpcEndpoint = ''; } /** @@ -202,7 +200,7 @@ export class RuTorrentAdapter implements ITorrentClient { "main", // View "d.hash=", "d.name=", "d.size_bytes=", "d.bytes_done=", "d.up.rate=", "d.down.rate=", "d.complete=", "d.state=", - "d.is_active=", "d.custom1=", "d.ratio=", "d.hashing=", + "d.is_active=", "d.custom1=", "d.hashing=", "d.base_path=", "d.up.total=", "d.message=" ]; @@ -226,7 +224,7 @@ export class RuTorrentAdapter implements ITorrentClient { } } - async addTorrentFile(file: Blob, options?: AddTorrentOptions): Promise { + async addTorrentFile(file: Blob, _options?: AddTorrentOptions): Promise { const base64 = await blobToBase64(file); // load.raw_start await this.call('load.raw_start', ["", { type: 'base64', value: base64 }]); @@ -240,7 +238,7 @@ export class RuTorrentAdapter implements ITorrentClient { await this.call('d.start', [id]); } - async removeTorrent(id: string, deleteData?: boolean): Promise { + async removeTorrent(id: string, _deleteData?: boolean): Promise { await this.call('d.erase', [id]); // deleteData logic requires XMLRPC 'd.delete_tied' or shell commands, // which might be unsafe or disabled. Simple erase is standard. @@ -267,29 +265,29 @@ export class RuTorrentAdapter implements ITorrentClient { } async getTags(): Promise { return []; } - async addTags(hash: string, tags: string[]): Promise { } - async removeTags(hash: string, tags: string[]): Promise { } - - - private mapTorrent(t: RTorrentTuple): Torrent { - // [hash, name, size, bytes_done, up_rate, down_rate, complete, state, is_active, label, ratio, hashing, path, up_total, message] - const [hash, name, size, done, up, down, complete, state, active, label, ratio, hashing, path, up_total, msg] = t; - - return { - id: hash, - name: name, - status: this.mapStatus(state, active, complete, hashing), - progress: this.calcProgress(done, size), - size: Number(size), - downloadSpeed: down, - uploadSpeed: up, - eta: down > 0 ? (Number(size) - Number(done)) / down : -1, - savePath: path, - addedDate: 0, - category: label, - tags: [] - }; - } + async addTags(_hash: string, _tags: string[]): Promise { } + async removeTags(_hash: string, _tags: string[]): Promise { } + + + private mapTorrent(t: RTorrentTuple): Torrent { + // [hash, name, size, bytes_done, up_rate, down_rate, complete, state, is_active, label, hashing, path, up_total, message] + const [hash, name, size, done, up, down, complete, state, active, label, hashing, path] = t; + + return { + id: hash, + name: name, + status: this.mapStatus(state, active, complete, hashing), + progress: this.calcProgress(done, size), + size: Number(size), + downloadSpeed: down, + uploadSpeed: up, + eta: down > 0 ? (Number(size) - Number(done)) / down : -1, + savePath: path, + addedDate: 0, + category: label, + tags: [] + }; + } private calcProgress(done: string | number, size: string | number): number { const d = Number(done); diff --git a/extension/src/shared/api/clients/synology/SynologyAdapter.ts b/extension/src/shared/api/clients/synology/SynologyAdapter.ts index 9cefd32..76f415b 100755 --- a/extension/src/shared/api/clients/synology/SynologyAdapter.ts +++ b/extension/src/shared/api/clients/synology/SynologyAdapter.ts @@ -4,15 +4,12 @@ import { Torrent, TorrentStatus } from '@/entities/torrent/model/Torrent'; import { FetchHttpClient } from '@/shared/api/network/FetchHttpClient'; import { ServerConfig } from '@/shared/lib/types'; import { - SynologyAuthData, SynologyTask, SynologyTaskListSchema, SynologyTaskStatus, - SynologyResponseSchema, SynologyAuthDataSchema, SynologyAPIInfoSchema, } from './SynologySchema'; -import { z } from 'zod'; /** * Synology Download Station Adapter @@ -81,7 +78,7 @@ export class SynologyAdapter implements ITorrentClient { query: 'SYNO.API.Auth,SYNO.DownloadStation.Task,SYNO.DownloadStation2.Task,SYNO.DownloadStation.Info,SYNO.FileStation.List', }); - const response = await this.client.get(`/webapi/query.cgi?${params}`, { + const response = await this.client.get<{ success?: boolean; data?: unknown; error?: { code?: number } }>(`/webapi/query.cgi?${params}`, { signal: controller.signal }); @@ -161,7 +158,7 @@ export class SynologyAdapter implements ITorrentClient { } this.lastLoginAttempt = Date.now(); - const response = await this.client.get(`${this.getPath('entry')}?${params}`); + const response = await this.client.get<{ success?: boolean; data?: unknown; error?: { code?: number } }>(`${this.getPath('entry')}?${params}`); if (!response?.success) { const errorCode = response?.error?.code || 0; @@ -257,7 +254,7 @@ export class SynologyAdapter implements ITorrentClient { _sid: this.sid!, }); - const response = await this.client.get(`${this.getPath('entry')}?${params}`); + const response = await this.client.get<{ success?: boolean; data?: unknown; error?: { code?: number } }>(`${this.getPath('entry')}?${params}`); if (!response?.success) { const code = response?.error?.code || 0; @@ -292,7 +289,7 @@ export class SynologyAdapter implements ITorrentClient { form.append('destination', options.path); } - const response = await this.client.post(this.getPath('entry'), form); + const response = await this.client.post<{ success?: boolean; data?: unknown; error?: { code?: number } }>(this.getPath('entry'), form); if (!response?.success) { const code = response?.error?.code || 0; @@ -323,7 +320,7 @@ export class SynologyAdapter implements ITorrentClient { form.append('destination', options.path); } - const response = await this.client.post(this.getPath('entry'), form); + const response = await this.client.post<{ success?: boolean; data?: unknown; error?: { code?: number } }>(this.getPath('entry'), form); if (!response?.success) { const code = response?.error?.code || 0; @@ -403,7 +400,7 @@ export class SynologyAdapter implements ITorrentClient { _sid: this.sid!, }); - const response = await this.client.get(`${this.getPath('entry')}?${params}`); + const response = await this.client.get<{ success?: boolean; data?: unknown; error?: { code?: number } }>(`${this.getPath('entry')}?${params}`); console.log('[Synology] Info response:', response); return response?.success === true; @@ -482,7 +479,7 @@ export class SynologyAdapter implements ITorrentClient { } try { - const response = await this.client.get(`${this.getPath('entry')}?${params}`); + const response = await this.client.get<{ success?: boolean; data?: { shares?: { path: string; name: string }[] }; error?: { code?: number } }>(`${this.getPath('entry')}?${params}`); if (!response?.success || !response?.data?.shares) { console.warn('[Synology] Failed to get shared folders'); @@ -498,7 +495,7 @@ export class SynologyAdapter implements ITorrentClient { }); } - async setCategory(hash: string, category: string): Promise { + async setCategory(_hash: string, _category: string): Promise { // Not directly supported - categories are folder-based console.warn('[Synology] setCategory not supported (use destination path instead)'); } @@ -508,11 +505,11 @@ export class SynologyAdapter implements ITorrentClient { return []; } - async addTags(hash: string, tags: string[]): Promise { + async addTags(_hash: string, _tags: string[]): Promise { console.warn('[Synology] addTags not supported'); } - async removeTags(hash: string, tags: string[]): Promise { + async removeTags(_hash: string, _tags: string[]): Promise { console.warn('[Synology] removeTags not supported'); } diff --git a/extension/src/shared/api/clients/transmission/TransmissionAdapter.ts b/extension/src/shared/api/clients/transmission/TransmissionAdapter.ts index 3904731..8be7471 100755 --- a/extension/src/shared/api/clients/transmission/TransmissionAdapter.ts +++ b/extension/src/shared/api/clients/transmission/TransmissionAdapter.ts @@ -27,7 +27,6 @@ import { buildCapabilities, getClientDescription } from './TransmissionCapabilit import { AuthenticationError, WhitelistError, - SessionExpiredError, DaemonError, RpcError, DuplicateTorrentError, @@ -261,8 +260,12 @@ export class TransmissionAdapter implements ITorrentClient { return this.call(method, args, retryCount + 1); } - if (status === 401 || status === 403) { - throw new Error('Authentication failed. Verify username/password and Transmission RPC authentication settings.'); + if (status === 401) { + throw new AuthenticationError(); + } + + if (status === 403) { + throw new WhitelistError(this.config.hostname); } if (status === 404 || (status >= 300 && status < 400)) { @@ -830,7 +833,6 @@ export class TransmissionAdapter implements ITorrentClient { // Determine enhanced status based on multiple factors let status: EnhancedTorrentStatus = 'unknown'; let label = 'Unknown'; - let isActive = false; let errorSeverity: 'warning' | 'error' | null = null; let progress: number | undefined; diff --git a/extension/src/shared/api/clients/utorrent/UTorrentAdapter.ts b/extension/src/shared/api/clients/utorrent/UTorrentAdapter.ts index ede2133..d38d6ce 100755 --- a/extension/src/shared/api/clients/utorrent/UTorrentAdapter.ts +++ b/extension/src/shared/api/clients/utorrent/UTorrentAdapter.ts @@ -2,6 +2,7 @@ import { injectable } from 'tsyringe'; import { ITorrentClient, AddTorrentOptions } from '@/entities/client/model/ITorrentClient'; import { Torrent, TorrentStatus } from '@/entities/torrent/model/Torrent'; import { FetchHttpClient } from '@/shared/api/network/FetchHttpClient'; +import { HttpError } from '@/shared/api/network/HttpError'; import { extractUTorrentToken } from './UTorrentParsingUtils'; import { UTorrentResponseSchema, TORRENT_INDEX, STATUS_FLAG } from './UTorrentSchema'; import { ServerConfig } from '@/shared/lib/types'; @@ -23,6 +24,7 @@ const MAX_RETRY_ATTEMPTS = 2; export class UTorrentAdapter implements ITorrentClient { private httpClient: FetchHttpClient; private token: string | null = null; + private guid: string | null = null; private cacheId: string | null = null; private torrentCache: Map = new Map(); private baseUrl: string; @@ -34,15 +36,22 @@ export class UTorrentAdapter implements ITorrentClient { async login(): Promise { const headers = this.getAuthHeaders(); - const response = await this.httpClient.get('gui/token.html', { + const { body, headers: respHeaders } = await this.httpClient.getRaw('gui/token.html', { headers, }); - this.token = extractUTorrentToken(response); + this.token = extractUTorrentToken(body); + + // Capture the GUID session cookie required by the uTorrent three-legged handshake. + // Without it, all subsequent requests receive HTTP 400 Invalid Request. + const setCookie = respHeaders.get('set-cookie') ?? ''; + const guidMatch = setCookie.match(/GUID=([^;]+)/i); + this.guid = guidMatch ? guidMatch[1] : null; } async logout(): Promise { this.token = null; + this.guid = null; this.cacheId = null; this.torrentCache.clear(); } @@ -368,6 +377,7 @@ export class UTorrentAdapter implements ITorrentClient { const url = `${this.baseUrl}?${params.toString()}`; const headers = this.getAuthHeaders(); + if (this.guid) headers['Cookie'] = `GUID=${this.guid}`; try { if (method === 'POST') { @@ -394,11 +404,13 @@ export class UTorrentAdapter implements ITorrentClient { * Check if error indicates authentication failure */ private isAuthError(error: unknown): boolean { + if (error instanceof HttpError) { + return error.status === 400 || error.status === 401; + } + if (error instanceof Error) { const message = error.message.toLowerCase(); - return message.includes('400') || - message.includes('401') || - message.includes('unauthorized') || + return message.includes('unauthorized') || message.includes('token'); } return false; diff --git a/extension/src/shared/api/clients/utorrent/UTorrentRssService.ts b/extension/src/shared/api/clients/utorrent/UTorrentRssService.ts index 99ff128..960e4e9 100755 --- a/extension/src/shared/api/clients/utorrent/UTorrentRssService.ts +++ b/extension/src/shared/api/clients/utorrent/UTorrentRssService.ts @@ -50,6 +50,7 @@ export const RSS_QUALITY = { export class UTorrentRssService { private httpClient: FetchHttpClient; private token: string | null = null; + private guid: string | null = null; private baseUrl: string; constructor(private config: ServerConfig) { @@ -62,9 +63,13 @@ export class UTorrentRssService { */ async login(): Promise { const headers = this.getAuthHeaders(); - const response = await this.httpClient.get('gui/token.html', { headers }); + const { body, headers: respHeaders } = await this.httpClient.getRaw('gui/token.html', { headers }); - this.token = extractUTorrentToken(response); + this.token = extractUTorrentToken(body); + + const setCookie = respHeaders.get('set-cookie') ?? ''; + const guidMatch = setCookie.match(/GUID=([^;]+)/i); + this.guid = guidMatch ? guidMatch[1] : null; } // ========== Feed Management ========== @@ -102,9 +107,8 @@ export class UTorrentRssService { */ async addFeed(url: string, alias?: string): Promise { const params = new URLSearchParams({ - action: 'rss-update', - 'feed-id': '-1', // -1 = new feed - s: url, + action: 'add-feed', + url: url, }); if (alias) { params.append('alias', alias); @@ -241,6 +245,7 @@ export class UTorrentRssService { const url = `${this.baseUrl}?${params.toString()}`; const headers = this.getAuthHeaders(); + if (this.guid) headers['Cookie'] = `GUID=${this.guid}`; return this.httpClient.get(url, { headers }); } diff --git a/extension/src/shared/api/clients/utorrent/UTorrentSettingsService.ts b/extension/src/shared/api/clients/utorrent/UTorrentSettingsService.ts index a5643f6..fe49f00 100755 --- a/extension/src/shared/api/clients/utorrent/UTorrentSettingsService.ts +++ b/extension/src/shared/api/clients/utorrent/UTorrentSettingsService.ts @@ -59,6 +59,7 @@ export const SETTINGS_KEYS = { export class UTorrentSettingsService { private httpClient: FetchHttpClient; private token: string | null = null; + private guid: string | null = null; private baseUrl: string; constructor(private config: ServerConfig) { @@ -71,9 +72,13 @@ export class UTorrentSettingsService { */ async login(): Promise { const headers = this.getAuthHeaders(); - const response = await this.httpClient.get('gui/token.html', { headers }); + const { body, headers: respHeaders } = await this.httpClient.getRaw('gui/token.html', { headers }); - this.token = extractUTorrentToken(response); + this.token = extractUTorrentToken(body); + + const setCookie = respHeaders.get('set-cookie') ?? ''; + const guidMatch = setCookie.match(/GUID=([^;]+)/i); + this.guid = guidMatch ? guidMatch[1] : null; } /** @@ -179,6 +184,7 @@ export class UTorrentSettingsService { const url = `${this.baseUrl}?${params.toString()}`; const headers = this.getAuthHeaders(); + if (this.guid) headers['Cookie'] = `GUID=${this.guid}`; return this.httpClient.get(url, { headers }); } diff --git a/extension/src/shared/api/network/FetchHttpClient.ts b/extension/src/shared/api/network/FetchHttpClient.ts index 25dde8d..4df0012 100755 --- a/extension/src/shared/api/network/FetchHttpClient.ts +++ b/extension/src/shared/api/network/FetchHttpClient.ts @@ -80,6 +80,77 @@ export class FetchHttpClient { return this.request(endpoint, { ...config, method: 'GET' }); } + /** + * Like get(), but also returns the raw response headers alongside the parsed body. + * Use this when you need to inspect response headers (e.g. Set-Cookie) that are + * discarded by the standard get() method. + */ + async getRaw(endpoint: string, config: RequestConfig = {}): Promise<{ body: T; headers: Headers }> { + const { retry, timeoutMs, ...fetchConfig } = config; + const url = new URL(endpoint, this.baseUrl); + + if (fetchConfig.params) { + Object.entries(fetchConfig.params).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + } + + const doFetch = async (): Promise<{ body: T; headers: Headers }> => { + const headers = new Headers(fetchConfig.headers); + const controller = new AbortController(); + const timeout = timeoutMs ?? 10000; + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const origin = new URL(this.baseUrl).origin; + if (!headers.has('Origin')) headers.set('Origin', origin); + if (!headers.has('Referer')) headers.set('Referer', origin + '/'); + } catch (e) { + console.warn('[FetchHttpClient] Failed to derive Origin/Referer from baseUrl:', this.baseUrl); + } + + try { + const response = await fetch(url.toString(), { + credentials: 'omit', + ...fetchConfig, + method: 'GET', + headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new HttpError(response.status, response.statusText, response); + } + + const text = await response.text(); + let body: T; + if (!text) { + body = {} as T; + } else { + try { body = JSON.parse(text) as T; } + catch { body = text as unknown as T; } + } + + return { body, headers: response.headers }; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Connection timed out after ${timeout}ms`); + } + throw error; + } + }; + + if (retry) { + const retryOptions = typeof retry === 'boolean' ? {} : retry; + return withRetry(doFetch, retryOptions); + } + + return doFetch(); + } + async post(endpoint: string, body?: BodyInit | Record | null, config: RequestConfig = {}): Promise { const isFormData = body instanceof FormData; const isSearchParams = body instanceof URLSearchParams; diff --git a/extension/src/shared/api/network/JsonRpcClient.ts b/extension/src/shared/api/network/JsonRpcClient.ts index 357eef3..19c92f1 100755 --- a/extension/src/shared/api/network/JsonRpcClient.ts +++ b/extension/src/shared/api/network/JsonRpcClient.ts @@ -19,6 +19,22 @@ export interface JsonRpcResponse { id: string | number; } +/** + * Structured JSON-RPC error that carries the original `{ code, message }` fields + * as own enumerable properties. This allows Aria2Adapter.wrapError() to detect + * the structured-RPC branch (`'code' in error`) rather than falling through to + * the plain-Error branch which would misclassify auth failures as NETWORK_ERROR. + */ +export class JsonRpcError extends Error { + public readonly code: number; + + constructor(rpcCode: number, rpcMessage: string) { + super(rpcMessage); + this.name = 'JsonRpcError'; + this.code = rpcCode; + } +} + export class JsonRpcClient { private httpClient: FetchHttpClient; private idCounter = 0; @@ -45,7 +61,9 @@ export class JsonRpcClient { const response = await this.httpClient.post>('', request, config); if (response.error) { - throw new Error(`JSON-RPC Error ${response.error.code}: ${response.error.message}`); + // Throw a structured error so callers can inspect .code and .message + // independently of the formatted message string. + throw new JsonRpcError(response.error.code, response.error.message); } if (response.result === undefined) { diff --git a/extension/src/shared/api/security/VaultService.ts b/extension/src/shared/api/security/VaultService.ts index 346a6d2..86b8f75 100755 --- a/extension/src/shared/api/security/VaultService.ts +++ b/extension/src/shared/api/security/VaultService.ts @@ -9,10 +9,7 @@ export const VAULT_DATA_KEY = 'local:vaultData'; export const LEGACY_OPTIONS_KEY = 'local:options'; export const SESSION_KEY_KEY = 'session:encryptionKey'; -interface EncryptedData { - iv: number[]; // stored as array for JSON compatibility - ciphertext: string; // Base64 or specific encoding -} + export class VaultService { static async isInitialized(): Promise { @@ -66,7 +63,7 @@ export class VaultService { await this.getServers(key); // Pass key explicitly to test it await KeyManager.setSessionKey(key); return true; - } catch (e) { + } catch { return false; } } @@ -128,12 +125,12 @@ export class VaultService { * Checks if there are legacy servers in local:options that need migration. */ static async hasLegacyData(): Promise { - const settings = await storage.getItem(LEGACY_OPTIONS_KEY); - return settings && settings.servers && settings.servers.length > 0; + const settings = await storage.getItem<{ servers?: ServerConfig[] }>(LEGACY_OPTIONS_KEY); + return !!(settings && settings.servers && settings.servers.length > 0); } static async migrateLegacyData(password: string): Promise { - const settings = await storage.getItem(LEGACY_OPTIONS_KEY); + const settings = await storage.getItem<{ servers?: ServerConfig[] } & Record>(LEGACY_OPTIONS_KEY); if (!settings || !settings.servers) return; await this.initialize(password, settings.servers); diff --git a/extension/src/shared/lib/buildInfo.ts b/extension/src/shared/lib/buildInfo.ts index b36527c..1fdf6bb 100755 --- a/extension/src/shared/lib/buildInfo.ts +++ b/extension/src/shared/lib/buildInfo.ts @@ -1,5 +1,5 @@ export const BUILD_INFO = { version: '0.2.0-beta.1', - timestamp: '2026-03-08T07:44:36.895Z', - displayDate: '3/8/2026, 12:44:36 AM' + timestamp: '2026-03-27T07:58:37.061Z', + displayDate: '3/27/2026, 1:58:37 AM' }; \ No newline at end of file diff --git a/extension/src/shared/ui/SystemSettings.tsx b/extension/src/shared/ui/SystemSettings.tsx index 75e3a66..c1de6e8 100755 --- a/extension/src/shared/ui/SystemSettings.tsx +++ b/extension/src/shared/ui/SystemSettings.tsx @@ -7,12 +7,10 @@ import { Toggle, Stack, Grid, - Column, - Tile, - Loading + Column } from '@carbon/react'; -import { Download, Wrench, Activity } from 'lucide-react'; +import { Wrench, Activity } from 'lucide-react'; import { AppOptions } from '@/shared/lib/types'; interface Props { @@ -22,8 +20,16 @@ interface Props { importBackup: (file: File) => Promise<{ success: boolean; message: string }>; } +interface SelfTestResult { + status: string; + version: string; + uptime: number; + platform: string; + userAgent: string; +} + export const SystemSettings: React.FC = ({ settings, updateSettings, exportSystemBackup, importBackup }) => { - const [selfTest, setSelfTest] = useState<{ loading: boolean; result: any | null; error: boolean }>({ loading: false, result: null, error: false }); + const [selfTest, setSelfTest] = useState<{ loading: boolean; result: SelfTestResult | null; error: boolean }>({ loading: false, result: null, error: false }); const [debugEnabled, setDebugEnabled] = useState(false); const isTCEnabled = settings.globals.showDiagnostics; @@ -54,8 +60,8 @@ export const SystemSettings: React.FC = ({ settings, updateSettings, expo try { const res = await chrome.runtime.sendMessage({ type: 'SELF_TEST' }); setSelfTest({ loading: false, result: res, error: false }); - } catch (e) { - setSelfTest({ loading: false, result: 'Failed', error: true }); + } catch { + setSelfTest({ loading: false, result: null, error: true }); } }; diff --git a/extension/tests/unit/adapters/Aria2Adapter.test.ts b/extension/tests/unit/adapters/Aria2Adapter.test.ts index 0e9bac7..11a8403 100755 --- a/extension/tests/unit/adapters/Aria2Adapter.test.ts +++ b/extension/tests/unit/adapters/Aria2Adapter.test.ts @@ -564,6 +564,113 @@ describe('Aria2Adapter', () => { expect(fetchSpy).toHaveBeenCalledOnce(); }); }); + + describe('magnet-session followedBy/following array parsing', () => { + it('should parse torrent payload with followedBy array without schema error', async () => { + // Real Aria2 magnet-session payload: followedBy is an array of GID strings, + // not a scalar string. Prior to the fix this caused a Zod parse failure. + const magnetSessionTorrent = { + gid: 'magnet001', + status: 'active', + totalLength: '0', // unknown until metadata is fetched + completedLength: '0', + uploadLength: '0', + downloadSpeed: '0', + uploadSpeed: '0', + dir: '/downloads', + followedBy: ['torrent002'], // array wire format + }; + + createMockFetch([ + { ok: true, status: 200, body: multicallResponse([[magnetSessionTorrent], [], []]) } + ]); + + // Should not throw a ZodError + const torrents = await adapter.getTorrents(); + expect(torrents).toHaveLength(1); + expect(torrents[0].id).toBe('magnet001'); + }); + + it('should parse torrent payload with both followedBy and following arrays', async () => { + const torrentWithBothFields = { + gid: 'torrent002', + status: 'active', + totalLength: '1000000000', + completedLength: '200000000', + uploadLength: '0', + downloadSpeed: '5000000', + uploadSpeed: '0', + dir: '/downloads', + followedBy: ['torrent003'], // array + following: ['magnet001'], // array + }; + + createMockFetch([ + { ok: true, status: 200, body: multicallResponse([[torrentWithBothFields], [], []]) } + ]); + + const torrents = await adapter.getTorrents(); + expect(torrents).toHaveLength(1); + expect(torrents[0].id).toBe('torrent002'); + expect(torrents[0].status).toBe('downloading'); + }); + }); + + describe('structured RPC error propagation', () => { + it('should surface auth failure as UNAUTHORIZED Aria2Error, not NETWORK_ERROR', async () => { + // Aria2 returns code 1 with "Unauthorized" message when the RPC secret is wrong. + // The fix ensures JsonRpcClient throws JsonRpcError (with .code property) so + // Aria2Adapter.wrapError() reaches the structured-RPC branch and calls + // Aria2Error.fromRpcError(), yielding UNAUTHORIZED (non-retryable). + createMockFetch([ + { ok: true, status: 200, body: rpcError(1, 'Unauthorized') } + ]); + + let caughtError: Aria2Error | undefined; + try { + await adapter.login(); + } catch (e) { + if (e instanceof Aria2Error) { + caughtError = e; + } + } + + expect(caughtError).toBeDefined(); + expect(caughtError?.code).toBe('UNAUTHORIZED'); + // Must NOT be misclassified as a generic network error + expect(caughtError?.code).not.toBe('NETWORK_ERROR'); + // Auth errors must not be retried + expect(caughtError?.retryable).toBe(false); + }); + + it('should surface structured RPC Aria2Error from getTorrents, not a plain NETWORK_ERROR', async () => { + // When system.multicall returns a JSON-RPC error (code 1), the structured + // error path via JsonRpcError → wrapError() → fromRpcError() is exercised. + // Context is 'system.multicall', which maps to GID_NOT_FOUND for code 1 — + // but crucially: it is NOT a plain NETWORK_ERROR and is NOT retryable. + // This proves the structured propagation path (not the network-error fallback) is active. + createMockFetch([ + { ok: true, status: 200, body: rpcError(1, 'Unauthorized') } + ]); + + let caughtError: unknown; + try { + await adapter.getTorrents(); + } catch (e) { + caughtError = e; + } + + // Must be a typed Aria2Error (structured RPC path), not a generic Error + expect(caughtError).toBeInstanceOf(Aria2Error); + const aria2Err = caughtError as Aria2Error; + // Must carry the original numeric RPC code + expect(aria2Err.rpcCode).toBe(1); + // Must NOT be classified as a network error + expect(aria2Err.code).not.toBe('NETWORK_ERROR'); + // Must NOT be retryable (RPC errors are not retried) + expect(aria2Err.retryable).toBe(false); + }); + }); }); describe('Aria2Error', () => { diff --git a/extension/tests/unit/adapters/BiglyBTAdapter.test.ts b/extension/tests/unit/adapters/BiglyBTAdapter.test.ts index 2ae684e..f100e43 100755 --- a/extension/tests/unit/adapters/BiglyBTAdapter.test.ts +++ b/extension/tests/unit/adapters/BiglyBTAdapter.test.ts @@ -271,6 +271,61 @@ describe('BiglyBTAdapter', () => { expect(body.arguments.tagsRemove).toEqual(['movies']); }); + it('should resolve real hash to numeric ID before calling torrent-set', async () => { + const fetchSpy = createMockFetch([ + { ok: true, status: 200, body: biglyBTSessionResponse }, + // Mock for resolveNumericId + { + ok: true, + status: 200, + body: { + result: 'success', + arguments: { + torrents: [{ id: 42 }] + } + } + }, + { ok: true, status: 200, body: { result: 'success' } } + ]); + + await adapter.login(); + await adapter.addTags('abcdef1234567890abcdef1234567890', ['movies', 'action']); + + // Last call is torrent-set + const lastCall = fetchSpy.mock.calls[2]; + const body = JSON.parse(lastCall[1]?.body as string); + + expect(body.method).toBe('torrent-set'); + expect(body.arguments.ids).toEqual([42]); + expect(body.arguments.tagsAdd).toEqual(['movies', 'action']); + + // The call before that should be torrent-get for resolution + const getCall = fetchSpy.mock.calls[1]; + const getBody = JSON.parse(getCall[1]?.body as string); + expect(getBody.method).toBe('torrent-get'); + expect(getBody.arguments.ids).toEqual(['abcdef1234567890abcdef1234567890']); + }); + + it('should throw an error if hash resolution fails', async () => { + createMockFetch([ + { ok: true, status: 200, body: biglyBTSessionResponse }, + // Mock for resolveNumericId failing to find torrent + { + ok: true, + status: 200, + body: { + result: 'success', + arguments: { + torrents: [] + } + } + } + ]); + + await adapter.login(); + await expect(adapter.addTags('deadbeef', ['movies'])).rejects.toThrow('Failed to resolve numeric ID for torrent: deadbeef'); + }); + it('should fallback to labels for standard Transmission', async () => { // For non-BiglyBT, addTags needs to fetch current labels first const fetchSpy = createMockFetch([ diff --git a/extension/tests/unit/adapters/FloodAdapter.test.ts b/extension/tests/unit/adapters/FloodAdapter.test.ts index 612852b..5cf767d 100755 --- a/extension/tests/unit/adapters/FloodAdapter.test.ts +++ b/extension/tests/unit/adapters/FloodAdapter.test.ts @@ -255,6 +255,76 @@ describe('FloodAdapter', () => { }); }); + describe('addTorrentFile', () => { + it('should send FormData with the torrent file appended under the "files" field', async () => { + const fetchSpy = createMockFetch([ + { ok: true, status: 200, body: { success: true } } + ]); + + const torrentBlob = new Blob(['fake torrent data'], { type: 'application/x-bittorrent' }); + await adapter.addTorrentFile(torrentBlob); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const [, init] = fetchSpy.mock.calls[0]; + // Body must be FormData, not a plain JSON string + expect(init?.body).toBeInstanceOf(FormData); + const fd = init!.body as FormData; + // The torrent file must be attached under the "files" key. + // FormData.get() for a Blob entry returns a File (Blob subclass), + // so check instanceof Blob and verify the correct data was attached. + const attachedFile = fd.get('files'); + expect(attachedFile).toBeInstanceOf(Blob); + expect((attachedFile as Blob).size).toBe(torrentBlob.size); + }); + + it('should include optional fields in FormData when options are provided', async () => { + const fetchSpy = createMockFetch([ + { ok: true, status: 200, body: { success: true } } + ]); + + const torrentBlob = new Blob(['fake torrent data'], { type: 'application/x-bittorrent' }); + await adapter.addTorrentFile(torrentBlob, { + paused: true, + path: '/downloads/movies', + label: 'movies', + sequentialDownload: true, + }); + + const [, init] = fetchSpy.mock.calls[0]; + expect(init?.body).toBeInstanceOf(FormData); + const fd = init!.body as FormData; + // File is a Blob subclass; verify the correct data was attached + const attachedFile = fd.get('files'); + expect(attachedFile).toBeInstanceOf(Blob); + expect((attachedFile as Blob).size).toBe(torrentBlob.size); + expect(fd.get('destination')).toBe('/downloads/movies'); + // paused:true → start should be 'false' + expect(fd.get('start')).toBe('false'); + expect(fd.get('tags')).toBe(JSON.stringify(['movies'])); + expect(fd.get('isSequential')).toBe('true'); + }); + + it('should omit destination and isSequential when not provided, and default start to true', async () => { + const fetchSpy = createMockFetch([ + { ok: true, status: 200, body: { success: true } } + ]); + + const torrentBlob = new Blob(['fake torrent data'], { type: 'application/x-bittorrent' }); + await adapter.addTorrentFile(torrentBlob); + + const [, init] = fetchSpy.mock.calls[0]; + const fd = init!.body as FormData; + // No options → start defaults to 'true' (paused is undefined/falsy) + expect(fd.get('start')).toBe('true'); + // No label → tags should be serialised empty array + expect(fd.get('tags')).toBe(JSON.stringify([])); + // No path → destination field should not be present + expect(fd.get('destination')).toBeNull(); + // No sequentialDownload → isSequential field should not be present + expect(fd.get('isSequential')).toBeNull(); + }); + }); + describe('pauseTorrent', () => { it('should pause torrent by hash', async () => { const fetchSpy = createMockFetch([ diff --git a/extension/tests/unit/adapters/RuTorrentAdapter.test.ts b/extension/tests/unit/adapters/RuTorrentAdapter.test.ts index c143751..af7c676 100755 --- a/extension/tests/unit/adapters/RuTorrentAdapter.test.ts +++ b/extension/tests/unit/adapters/RuTorrentAdapter.test.ts @@ -37,6 +37,9 @@ const createArrayResponse = (items: string[]) => { }; // Helper to create torrent list response (multicall) +// Tuple order: hash, name, size, done, upRate, downRate, complete, state, +// active, label, hashing, path, upTotal, message (14 columns) +// ratio is NOT requested from daemon; computed client-side. const createTorrentListResponse = (torrents: Array<{ hash: string; name: string; @@ -48,6 +51,7 @@ const createTorrentListResponse = (torrents: Array<{ state: number; active: number; label: string; + upTotal?: number; }>) => { const items = torrents.map(t => { // Match the order from d.multicall2 in adapter @@ -63,9 +67,8 @@ const createTorrentListResponse = (torrents: Array<{ ${t.active} ${t.label} 0 - 0 /downloads - 0 + ${t.upTotal ?? 0} `; }).join(''); @@ -167,6 +170,36 @@ describe('RuTorrentAdapter', () => { const torrents = await adapter.getTorrents(); expect(torrents).toEqual([]); }); + + it('should compute ratio client-side without a native d.ratio column', async () => { + // upTotal = 750_000_000, size = 1_000_000_000 => ratio = 0.75 + const response = createTorrentListResponse([{ + hash: 'ratio_test', + name: 'Ratio Test', + size: 1_000_000_000, + done: 1_000_000_000, + upRate: 0, + downRate: 0, + complete: 1, + state: 1, + active: 1, + label: '', + upTotal: 750_000_000, + }]); + createMockFetch([{ ok: true, status: 200, body: response }]); + + const torrents = await adapter.getTorrents(); + + // Mapping must succeed (no Zod parse error from missing ratio slot) + expect(torrents).toHaveLength(1); + expect(torrents[0].id).toBe('ratio_test'); + expect(torrents[0].status).toBe('seeding'); + // progress is computed from done/size, not from a ratio column + expect(torrents[0].progress).toBe(100); + // Verify client-side ratio computation: 750,000,000 / 1,000,000,000 = 0.75 + expect(torrents[0].ratio).toBe(0.75); + expect(torrents[0].uploadedTotal).toBe(750_000_000); + }); }); describe('addTorrentUrl', () => { diff --git a/extension/tests/unit/adapters/TransmissionAdapter.test.ts b/extension/tests/unit/adapters/TransmissionAdapter.test.ts index e58bf26..584b972 100755 --- a/extension/tests/unit/adapters/TransmissionAdapter.test.ts +++ b/extension/tests/unit/adapters/TransmissionAdapter.test.ts @@ -188,24 +188,26 @@ describe('TransmissionAdapter - Phase 1', () => { }); describe('Task 1.3: Enhanced Error Handling', () => { - it('should throw Error on 401', async () => { + it('should throw AuthenticationError on 401', async () => { createMockFetch([{ ok: false, status: 401, body: 'Unauthorized' }]); - await expect(adapter.login()).rejects.toThrow('Authentication failed. Verify username/password and Transmission RPC authentication settings.'); + await expect(adapter.login()).rejects.toThrow(AuthenticationError); + await expect(adapter.login()).rejects.toThrow('Authentication failed.'); }); - it('should throw Error on 403', async () => { + it('should throw WhitelistError on 403', async () => { createMockFetch([{ ok: false, status: 403, body: 'Forbidden' }]); - await expect(adapter.login()).rejects.toThrow('Authentication failed. Verify username/password and Transmission RPC authentication settings.'); + await expect(adapter.login()).rejects.toThrow(WhitelistError); + await expect(adapter.login()).rejects.toThrow('rpc-host-whitelist'); }); it('should throw DaemonError on 5xx', async () => { diff --git a/extension/tests/unit/adapters/UTorrentAdapter.test.ts b/extension/tests/unit/adapters/UTorrentAdapter.test.ts index e5146ba..c6f5f78 100755 --- a/extension/tests/unit/adapters/UTorrentAdapter.test.ts +++ b/extension/tests/unit/adapters/UTorrentAdapter.test.ts @@ -6,6 +6,8 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { UTorrentAdapter } from '@/shared/api/clients/utorrent/UTorrentAdapter'; +import { UTorrentSettingsService } from '@/shared/api/clients/utorrent/UTorrentSettingsService'; +import { UTorrentRssService } from '@/shared/api/clients/utorrent/UTorrentRssService'; import { ServerConfig } from '@/shared/lib/types'; import { STATUS_FLAG } from '@/shared/api/clients/utorrent/UTorrentSchema'; @@ -56,6 +58,25 @@ const createMockFetch = (responses: Array<{ ok: boolean; status: number; body: a }); }; +/** Extended mock that supports per-response custom response headers. */ +const createMockFetchWithHeaders = ( + responses: Array<{ ok: boolean; status: number; body: any; responseHeaders?: Record }> +) => { + let callIndex = 0; + return vi.spyOn(global, 'fetch').mockImplementation(async () => { + const response = responses[callIndex] || responses[responses.length - 1]; + callIndex++; + return { + ok: response.ok, + status: response.status, + statusText: response.ok ? 'OK' : 'Error', + headers: new Headers(response.responseHeaders ?? {}), + text: () => Promise.resolve(typeof response.body === 'string' ? response.body : JSON.stringify(response.body)), + json: () => Promise.resolve(response.body), + } as Response; + }); +}; + // Token HTML response const tokenHtml = '
ABC123TOKEN
'; @@ -135,6 +156,80 @@ describe('UTorrentAdapter', () => { await expect(adapter.login()).rejects.toThrow('Failed to retrieve uTorrent token'); }); + + it('should capture GUID from Set-Cookie response header', async () => { + const mockResponse = createTorrentListResponse([{ + hash: 'ABC123', status: 201, name: 'Test', size: 100, + percent: 500, downSpeed: 100, upSpeed: 0, eta: 100, label: '' + }]); + + const fetchSpy = createMockFetchWithHeaders([ + { + ok: true, status: 200, body: tokenHtml, + responseHeaders: { 'Set-Cookie': 'GUID=TESTGUID123; path=/' } + }, + { ok: true, status: 200, body: mockResponse } + ]); + + await adapter.getTorrents(); // triggers auto-login + API call + + // The second fetch (index 1) is the actual API call; it must carry the GUID cookie. + const apiCallInit = fetchSpy.mock.calls[1][1] as RequestInit; + const cookieHeader = apiCallInit?.headers instanceof Headers + ? apiCallInit.headers.get('Cookie') + : (apiCallInit?.headers as Record)?.['Cookie']; + expect(cookieHeader).toBe('GUID=TESTGUID123'); + }); + + it('should not send Cookie header when Set-Cookie is absent', async () => { + const mockResponse = createTorrentListResponse([{ + hash: 'ABC123', status: 201, name: 'Test', size: 100, + percent: 500, downSpeed: 100, upSpeed: 0, eta: 100, label: '' + }]); + + const fetchSpy = createMockFetchWithHeaders([ + // No responseHeaders — simulates server that doesn't set GUID + { ok: true, status: 200, body: tokenHtml }, + { ok: true, status: 200, body: mockResponse } + ]); + + await expect(adapter.getTorrents()).resolves.toBeDefined(); + + const apiCallInit = fetchSpy.mock.calls[1][1] as RequestInit; + const cookieHeader = apiCallInit?.headers instanceof Headers + ? apiCallInit.headers.get('Cookie') + : (apiCallInit?.headers as Record)?.['Cookie']; + expect(cookieHeader).toBeNull(); + }); + + it('should re-capture GUID on session recovery re-login', async () => { + const mockResponse = createTorrentListResponse([{ + hash: 'ABC123', status: 201, name: 'Test', size: 100, + percent: 500, downSpeed: 100, upSpeed: 0, eta: 100, label: '' + }]); + + const fetchSpy = createMockFetchWithHeaders([ + // First login → GUID_A + { ok: true, status: 200, body: tokenHtml, responseHeaders: { 'Set-Cookie': 'GUID=GUID_A; path=/' } }, + // API call fails → triggers re-login + { ok: false, status: 400, body: 'Invalid token' }, + // Re-login → GUID_B + { ok: true, status: 200, body: tokenHtml, responseHeaders: { 'Set-Cookie': 'GUID=GUID_B; path=/' } }, + // Retry succeeds + { ok: true, status: 200, body: mockResponse } + ]); + + await adapter.getTorrents(); + + expect(fetchSpy).toHaveBeenCalledTimes(4); + + // The retry (4th call, index 3) must use the refreshed GUID_B. + const retryInit = fetchSpy.mock.calls[3][1] as RequestInit; + const cookieHeader = retryInit?.headers instanceof Headers + ? retryInit.headers.get('Cookie') + : (retryInit?.headers as Record)?.['Cookie']; + expect(cookieHeader).toBe('GUID=GUID_B'); + }); }); describe('getTorrents', () => { @@ -253,7 +348,7 @@ describe('UTorrentAdapter', () => { }); describe('session recovery', () => { - it('should retry on 400/401 errors', async () => { + it('should retry on 400/401 HTTP status errors regardless of message text', async () => { const mockResponse = createTorrentListResponse([{ hash: 'ABC123', status: 201, name: 'Test', size: 100, percent: 500, downSpeed: 100, upSpeed: 0, eta: 100, label: '' @@ -261,16 +356,28 @@ describe('UTorrentAdapter', () => { const fetchSpy = createMockFetch([ { ok: true, status: 200, body: tokenHtml }, // Initial login - { ok: false, status: 400, body: 'Invalid token' }, // First call fails + { ok: false, status: 400, body: 'Bad request' }, // First call fails (400) { ok: true, status: 200, body: tokenHtml }, // Re-login - { ok: true, status: 200, body: mockResponse } // Retry succeeds + { ok: false, status: 401, body: 'Unauthorized text' }, // Retry fails with 401 + { ok: true, status: 200, body: tokenHtml }, // Re-login again + { ok: true, status: 200, body: mockResponse } // Recovery succeeds ]); const torrents = await adapter.getTorrents(); - expect(fetchSpy).toHaveBeenCalledTimes(4); + expect(fetchSpy).toHaveBeenCalledTimes(6); expect(torrents).toHaveLength(1); }); + + it('should not retry on non-auth HTTP errors (e.g. 500) even if body mentions token', async () => { + const fetchSpy = createMockFetch([ + { ok: true, status: 200, body: tokenHtml }, + { ok: false, status: 500, body: 'Internal server error processing token' } + ]); + + await expect(adapter.getTorrents()).rejects.toThrow('HTTP Error: 500 Error'); + expect(fetchSpy).toHaveBeenCalledTimes(2); // Only login + exact failed call + }); }); describe('torrent actions', () => { @@ -537,3 +644,129 @@ describe('UTorrentAdapter', () => { }); }); }); + +describe('UTorrentSettingsService', () => { + let service: UTorrentSettingsService; + + beforeEach(() => { + service = new UTorrentSettingsService(mockConfig); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('GUID cookie handling', () => { + it('should capture GUID from Set-Cookie response header', async () => { + const fetchSpy = createMockFetchWithHeaders([ + { + ok: true, status: 200, body: tokenHtml, + responseHeaders: { 'Set-Cookie': 'GUID=SETTINGSGUID123; path=/' } + }, + { ok: true, status: 200, body: { build: 12345, settings: [] } } + ]); + + await service.getSettings(); + + const apiCallInit = fetchSpy.mock.calls[1][1] as RequestInit; + const cookieHeader = apiCallInit?.headers instanceof Headers + ? apiCallInit.headers.get('Cookie') + : (apiCallInit?.headers as Record)?.['Cookie']; + expect(cookieHeader).toBe('GUID=SETTINGSGUID123'); + }); + + it('should not send Cookie header when Set-Cookie is absent', async () => { + const fetchSpy = createMockFetchWithHeaders([ + { ok: true, status: 200, body: tokenHtml }, + { ok: true, status: 200, body: { build: 12345, settings: [] } } + ]); + + await service.getSettings(); + + const apiCallInit = fetchSpy.mock.calls[1][1] as RequestInit; + const cookieHeader = apiCallInit?.headers instanceof Headers + ? apiCallInit.headers.get('Cookie') + : (apiCallInit?.headers as Record)?.['Cookie']; + expect(cookieHeader).toBeNull(); + }); + }); +}); + +describe('UTorrentRssService', () => { + let service: UTorrentRssService; + + beforeEach(() => { + service = new UTorrentRssService(mockConfig); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('GUID cookie handling', () => { + it('should capture GUID from Set-Cookie response header', async () => { + const fetchSpy = createMockFetchWithHeaders([ + { + ok: true, status: 200, body: tokenHtml, + responseHeaders: { 'Set-Cookie': 'GUID=RSSGUID123; path=/' } + }, + { ok: true, status: 200, body: { build: 12345, rssfeeds: [] } } + ]); + + await service.getFeeds(); + + const apiCallInit = fetchSpy.mock.calls[1][1] as RequestInit; + const cookieHeader = apiCallInit?.headers instanceof Headers + ? apiCallInit.headers.get('Cookie') + : (apiCallInit?.headers as Record)?.['Cookie']; + expect(cookieHeader).toBe('GUID=RSSGUID123'); + }); + + it('should not send Cookie header when Set-Cookie is absent', async () => { + const fetchSpy = createMockFetchWithHeaders([ + { ok: true, status: 200, body: tokenHtml }, + { ok: true, status: 200, body: { build: 12345, rssfeeds: [] } } + ]); + + await service.getFeeds(); + + const apiCallInit = fetchSpy.mock.calls[1][1] as RequestInit; + const cookieHeader = apiCallInit?.headers instanceof Headers + ? apiCallInit.headers.get('Cookie') + : (apiCallInit?.headers as Record)?.['Cookie']; + expect(cookieHeader).toBeNull(); + }); + }); + + describe('feed management', () => { + it('should use canonical add-feed action and parameter for new feeds', async () => { + const fetchSpy = createMockFetch([ + { ok: true, status: 200, body: tokenHtml }, + { ok: true, status: 200, body: { build: 12345 } } + ]); + + await service.addFeed('http://example.com/rss'); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + const url = fetchSpy.mock.calls[1][0] as string; + expect(url).toContain('action=add-feed'); + expect(url).toContain('url=http%3A%2F%2Fexample.com%2Frss'); + // Ensure we aren't sending legacy rss-update parameters + expect(url).not.toContain('feed-id=-1'); + expect(url).not.toContain('s=http'); + }); + + it('should include alias when provided', async () => { + const fetchSpy = createMockFetch([ + { ok: true, status: 200, body: tokenHtml }, + { ok: true, status: 200, body: { build: 12345 } } + ]); + + await service.addFeed('http://example.com/rss', 'My Feed Alias'); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + const url = fetchSpy.mock.calls[1][0] as string; + expect(url).toContain('alias=My+Feed+Alias'); + }); + }); +}); From d7f0c1d7934c8e1f5c2bac6fab59df9e2a2a504a Mon Sep 17 00:00:00 2001 From: StarlightDaemon <23347919+StarlightDaemon@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:03:17 -0600 Subject: [PATCH 4/7] docs: carry forward public product documentation --- README.md | 16 ++++++----- ROADMAP.md | 8 +++--- docs/BETA_TESTING.md | 61 ++++++++++++++++++++++-------------------- docs/PRIVACY_POLICY.md | 8 +++--- docs/privacy.html | 43 ++++++++++++++++++----------- 5 files changed, 78 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 40648e6..8e8a3ca 100755 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A browser extension for managing BitTorrent clients. Built with WXT, React, and [![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-357%20passing-brightgreen)](extension/tests) +[![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) --- @@ -36,14 +36,18 @@ A browser extension for managing BitTorrent clients. Built with WXT, React, and | Aria2 | ✅ Basic | RPC Multicall | | Synology | ✅ Full | 2FA, Device Token, NAS Integration | +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. + --- ## 🚧 Project Status -**Current Status**: Beta / Active Development -**Target**: v1.0 Store Release (Coming 2026) +**Current Status**: Beta / Active Stabilization +**Target**: v1.0 Store Release + +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. -This project is currently in **Beta**. We recommend most users wait for the official release on the Chrome Web Store and Firefox Add-ons site. +`ROADMAP.md` is strategic direction, not the live status page. --- @@ -62,9 +66,9 @@ This project is currently in **Beta**. We recommend most users wait for the offi | Document | Description | |----------|-------------| -| [Beta Guide](docs/BETA_TESTING.md) | **Start Here** - Installation & Testing | +| [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) | Future features & strategy | +| [ROADMAP.md](ROADMAP.md) | Strategic direction, not live status | | [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Setup build environment | | [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution guidelines | diff --git a/ROADMAP.md b/ROADMAP.md index 9ddb196..99a6f75 100755 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,8 @@ > **Strategic direction for the CTRL browser extension** +Status note: This roadmap is strategic direction, not the public live-status page. Use [docs/BETA_TESTING.md](docs/BETA_TESTING.md) for the current beta-status and tester-facing state. + --- ## Vision @@ -17,7 +19,7 @@ Transform CTRL from a working port into a **Best-in-Class** torrent management s ### ✅ Current Baseline - **Phase 1: Beta Release** shipped in January 2026 -- **357 unit tests** currently pass in the maintained local/CI baseline +- **Maintained validation baseline** is documented in `docs/CI_BASELINE.md` - **Deterministic CI** is restored with tracked lockfile, `npm ci`, and npm caching - **Mainline rewrite and normalization** are complete - **Privacy Policy** and beta distribution remain in place @@ -64,7 +66,7 @@ Transform CTRL from a working port into a **Best-in-Class** torrent management s | Feature | Priority | Notes | |---------|----------|-------| -| VPN Integration Check | Medium | ⏳ Deferred to v0.4.x+ | +| VPN Integration Check | Medium | ⏳ Deferred to v0.4.x+; prototype archived | | Cloud Sync | Low | Encrypted settings sync | | Torrent Detail View | Low | Files, Peers, Trackers tabs | | RSS Auto-Downloader | Low | Regex filtering | @@ -99,4 +101,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for how to get involved. --- -*Last Updated: March 2026* +*Last Updated: April 2026* diff --git a/docs/BETA_TESTING.md b/docs/BETA_TESTING.md index fcc7303..3f00b97 100755 --- a/docs/BETA_TESTING.md +++ b/docs/BETA_TESTING.md @@ -6,6 +6,8 @@ **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? @@ -18,7 +20,7 @@ CTRL (Torrent Control) is a browser extension that provides a **unified interfac ### ✅ Fully Functional Features -#### Torrent Client Support (9 Clients) +#### Torrent Client Support (10 Clients) - ✅ **qBittorrent** - Full support with categories, tags, and sequential download - ✅ **Transmission** - Session management and directory support - ✅ **Deluge** - Multi-step authentication and label support @@ -28,9 +30,13 @@ CTRL (Torrent Control) is a browser extension that provides a **unified interfac - ✅ **BiglyBT** - Basic operations - ✅ **Vuze** - Basic operations - ✅ **Aria2** - JSON-RPC multicall support +- ✅ **Synology** - NAS integration with device-token and 2FA 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 -- ⛔ **Discontinued** - Use Context Menu (Right-Click) integration instead. +- ⛔ **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 @@ -49,11 +55,11 @@ CTRL (Torrent Control) is a browser extension that provides a **unified interfac ## ⚠️ Known Limitations ### Technical Debt -- **YTS.mx**: Planned integration via API/Context Menu. +- **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 test infrastructure designed but not yet implemented +- **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 @@ -70,7 +76,7 @@ CTRL (Torrent Control) is a browser extension that provides a **unified interfac ### Method 1: From GitHub Releases (Recommended) 1. **Download the Extension** - - Visit [Releases](https://github.com/YOUR_USERNAME/CTRL/releases) + - 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 @@ -94,7 +100,7 @@ CTRL (Torrent Control) is a browser extension that provides a **unified interfac ```bash # Clone repository -git clone https://github.com/YOUR_USERNAME/CTRL.git +git clone https://github.com/StarlightDaemon/CTRL.git cd CTRL/extension # Install dependencies @@ -181,10 +187,10 @@ npm run build:firefox - Report any authentication issues - Verify torrent operations work (add, pause, resume, remove) -2. **Site Integration Stability** - - Test on different torrent sites - - Report if buttons don't appear - - Check for layout conflicts or broken pages +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 @@ -197,7 +203,7 @@ npm run build:firefox ### How to Report Bugs -**GitHub Issues**: https://github.com/YOUR_USERNAME/CTRL/issues +**GitHub Issues**: https://github.com/StarlightDaemon/CTRL/issues **Please include**: 1. **Browser**: Chrome/Edge/Firefox + version @@ -217,23 +223,22 @@ npm run build:firefox - [ ] **10+ active beta testers** providing feedback - [ ] **<5 critical bugs** discovered -- [ ] **All 9 clients** verified working +- [ ] **All 10 clients** verified working - [ ] **Positive user feedback** on core functionality - [ ] **No data loss** or credential security issues ### Roadmap to v1.0 -**Next Release: v0.3.0** (Estimated: Week 4-7) -- ✅ Migrate 1337x to Shadow DOM -- ✅ Integrate YTS.mx (#1 movie site) -- ✅ Implement E2E testing with Playwright -- ✅ Performance benchmarking +**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** (Estimated: Week 13-16) +**Production Release: v1.0** (Timing TBD) - ✅ Chrome Web Store submission - ✅ Firefox AMO submission - ✅ Code signing for installers -- ✅ Full E2E test coverage +- ⬜ Full E2E test coverage (not yet achieved) - ✅ Accessibility score >90 --- @@ -248,7 +253,7 @@ npm run build:firefox - ✅ Credentials encrypted with AES-GCM locally - ✅ Open source - code is auditable -**Privacy Policy**: [View Full Policy](https://YOUR_USERNAME.github.io/CTRL/privacy) +**Privacy Policy**: [View Full Policy](PRIVACY_POLICY.md) --- @@ -257,24 +262,22 @@ npm run build:firefox ### New Features - 🎉 First public beta release - ✅ Multi-server management -- ✅ 9 torrent client adapters +- ✅ 10 torrent client adapters - ✅ Encrypted credential vault - ✅ 7 language translations - ⛔ **Site Integrations**: Removed for stability and store compliance. ### Testing -- ✅ 153 unit tests passing -- ✅ All adapter tests passing -- ⚠️ E2E tests pending +- ✅ 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/YOUR_USERNAME/CTRL/discussions) -**Bugs?** Create an [Issue](https://github.com/YOUR_USERNAME/CTRL/issues) -**Email**: [your-email@domain.com] +**Questions?** Open a [Discussion](https://github.com/StarlightDaemon/CTRL/discussions) +**Bugs?** Create an [Issue](https://github.com/StarlightDaemon/CTRL/issues) --- @@ -283,7 +286,7 @@ npm run build:firefox - [Main README](../README.md) - Project overview - [ROADMAP](../ROADMAP.md) - Strategic direction - [CONTRIBUTING](../CONTRIBUTING.md) - How to contribute -- [Privacy Policy](https://YOUR_USERNAME.github.io/CTRL/privacy) - Full privacy details +- [Privacy Policy](PRIVACY_POLICY.md) - Full privacy details --- @@ -305,4 +308,4 @@ Your feedback is invaluable in making CTRL a production-ready extension. Thank y *CTRL v0.2.0-beta.1* *Released: January 2026* -*Next Release: v0.3.0 (Week 4-7)* +*Next Release: v0.3.x (Timing TBD)* diff --git a/docs/PRIVACY_POLICY.md b/docs/PRIVACY_POLICY.md index 0a06c13..94f8934 100755 --- a/docs/PRIVACY_POLICY.md +++ b/docs/PRIVACY_POLICY.md @@ -1,6 +1,6 @@ # Privacy Policy -**Last Updated**: January 2026 +**Last Updated**: April 2026 **Effective Date**: January 2026 --- @@ -134,7 +134,7 @@ We may update this privacy policy from time to time. Changes will be posted on t CTRL is open-source software. You can inspect the source code to verify our privacy claims: -**GitHub Repository**: https://github.com/YOUR_USERNAME/CTRL +**GitHub Repository**: https://github.com/StarlightDaemon/CTRL The code shows: - No analytics libraries @@ -152,8 +152,8 @@ BitTorrent is a legitimate protocol used for distributing open-source software, If you have questions about this privacy policy or CTRL's data practices: -- **Email**: [your-email@domain.com] -- **GitHub Issues**: https://github.com/YOUR_USERNAME/CTRL/issues +- **GitHub Discussions**: https://github.com/StarlightDaemon/CTRL/discussions +- **GitHub Issues**: https://github.com/StarlightDaemon/CTRL/issues --- diff --git a/docs/privacy.html b/docs/privacy.html index ee50316..fd8942a 100755 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -126,7 +126,7 @@

Privacy Policy

- Last Updated: January 2026
+ Last Updated: April 2026
Effective Date: January 2026
@@ -180,22 +180,29 @@

What Data is NOT Collected

How CTRL Works

-

Site Integration (Content Scripts)

-

CTRL injects user interface components into torrent indexing websites (e.g., TorrentGalaxy, 1337x, Nyaa) to provide one-click "Add to Client" functionality.

+

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. CTRL detects magnet links on supported pages
  2. -
  3. Adds UI buttons next to those links
  4. -
  5. When you click a button, the magnet link is processed locally
  6. -
  7. The link is sent directly to your configured torrent client (on your local network or remote server)
  8. +
  9. When you initiate an action, CTRL processes the request locally.
  10. +
  11. If scanning a page, it identifies links matching the magnet protocol.
  12. +
  13. The link is sent directly to your configured torrent client (on your local network or remote server).

What does NOT happen:

    -
  • CTRL does not read page content beyond detecting magnet links
  • -
  • CTRL does not track which torrents you view or download
  • -
  • CTRL does not send any data about your browsing to external servers
  • +
  • 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

@@ -234,9 +241,13 @@

notifications

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

Data Access: None. Notifications are created locally.

-

host_permissions (specific domains)

-

Purpose: To inject UI components on supported torrent indexing sites.

-

Data Access: Only the ability to detect magnet links (href attributes) on these specific sites. No page content is read or transmitted.

+

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).

@@ -257,7 +268,7 @@

Changes to This Privacy 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/YOUR_USERNAME/CTRL

+

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

The code shows:

    @@ -275,8 +286,8 @@

    Legal Disclaimer

    Contact

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


    From f52b792a18a1504a5817631ae8cdd3f0e609d8c5 Mon Sep 17 00:00:00 2001 From: StarlightDaemon <23347919+StarlightDaemon@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:09:42 -0600 Subject: [PATCH 5/7] fix(extension): map missing rutorrent properties to pass unit tests --- .../src/shared/api/clients/rutorrent/RuTorrentAdapter.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/extension/src/shared/api/clients/rutorrent/RuTorrentAdapter.ts b/extension/src/shared/api/clients/rutorrent/RuTorrentAdapter.ts index 3309fff..f0a4019 100755 --- a/extension/src/shared/api/clients/rutorrent/RuTorrentAdapter.ts +++ b/extension/src/shared/api/clients/rutorrent/RuTorrentAdapter.ts @@ -271,7 +271,7 @@ export class RuTorrentAdapter implements ITorrentClient { private mapTorrent(t: RTorrentTuple): Torrent { // [hash, name, size, bytes_done, up_rate, down_rate, complete, state, is_active, label, hashing, path, up_total, message] - const [hash, name, size, done, up, down, complete, state, active, label, hashing, path] = t; + const [hash, name, size, done, up, down, complete, state, active, label, hashing, path, up_total, message] = t; return { id: hash, @@ -285,7 +285,10 @@ export class RuTorrentAdapter implements ITorrentClient { savePath: path, addedDate: 0, category: label, - tags: [] + tags: [], + uploadedTotal: Number(up_total), + ratio: Number(size) > 0 ? Number(up_total) / Number(size) : 0, + errorMessage: message }; } From 277120f16df53cce6b9be92b619cf452f63ddbf6 Mon Sep 17 00:00:00 2001 From: StarlightDaemon <23347919+StarlightDaemon@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:10:51 -0600 Subject: [PATCH 6/7] build(firefox): remove deleted e2e directory from source archive pathspecs --- extension/scripts/zip-source.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extension/scripts/zip-source.ts b/extension/scripts/zip-source.ts index e65931d..44e8739 100755 --- a/extension/scripts/zip-source.ts +++ b/extension/scripts/zip-source.ts @@ -56,7 +56,6 @@ try { 'extension/CHANGELOG.md', 'extension/LINUX_SETUP.md', 'extension/babel.config.js', - 'extension/e2e', 'extension/eslint.config.js', 'extension/package-lock.json', 'extension/package.json', From f07b2df7d070fb9c7b71dafbef59eecc3ab7503b Mon Sep 17 00:00:00 2001 From: StarlightDaemon <23347919+StarlightDaemon@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:33:54 -0600 Subject: [PATCH 7/7] docs(changelog): update unreleased rebuilt branch summary --- extension/CHANGELOG.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index c0e1437..64b1025 100755 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -7,18 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### 💎 Core Architecture & CI -- **Mainline Rewrite**: Successfully completed the safe rewrite of the project repository history. -- **CI Determinism**: Transitioned to a fully deterministic npm workflow using `package-lock.json` validation and `npm ci` caching. +### 💎 Core Architecture & Release Operations +- **Firefox Source Packaging**: Added a clean-tree AMO source archive generation step for compliant release packaging. +- **Build Maintenance**: Cleaned up source archive pathspecs to accommodate test directory removals. ### ✅ Testing & Reliability -- **Test Baseline Growth**: Expanded the test suite significantly, growing from 153 to **357 passing tests**. -- **Adapter Hardening**: - - Implemented active status validation guards for Deluge to prevent UI-blocking timeouts. - - Enhanced qBittorrent handling for 401 Unauthorized responses with active attempt tracking. -- **Connection Truthfulness**: - - Resolved generic "Authentication Failed" UI masking by ensuring the actual adapter error strings propagate to the UI during Test Connection. - - Hardened Chrome and Firefox vault session persistence and active runtime background resolution. +- **Runtime Carry-Forward**: Restored non-VPN runtime features and their corresponding adapter tests. +- **Test Optimization**: Cleaned up the test surface by removing stale, duplicate Playwright E2E specs. +- **Validation Fixes**: Corrected RuTorrent property mappings to unblock the adapter unit tests. + +### 📖 Documentation +- **Product Docs**: Updated and carried forward public product documentation to match the rebuilt branch. ## [0.2.0-beta.1] - 2026-01-11