diff --git a/src/config/config.ts b/src/config/config.ts index c5d8b4c..2800b38 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -4,10 +4,12 @@ import { serializeWrites, writeJsonAtomic } from "../util/atomic"; export interface Config { downloadDir: string; + trackers: string[]; } export const defaultConfig: Config = { downloadDir: defaultDownloadDir, + trackers: [], }; export async function loadConfig(): Promise { @@ -15,17 +17,22 @@ export async function loadConfig(): Promise { try { raw = await fs.readFile(configFile, "utf8"); } catch { - return { ...defaultConfig }; + return { ...defaultConfig, trackers: [] }; } try { const parsed = JSON.parse(raw) as Partial; - const cfg = { ...defaultConfig, ...parsed }; - if (!cfg.downloadDir || typeof cfg.downloadDir !== "string") { - cfg.downloadDir = defaultDownloadDir; - } + const cfg: Config = { + downloadDir: + typeof parsed.downloadDir === "string" && parsed.downloadDir + ? parsed.downloadDir + : defaultDownloadDir, + trackers: Array.isArray(parsed.trackers) + ? parsed.trackers.filter((t): t is string => typeof t === "string" && t.length > 0) + : [], + }; return cfg; } catch { - return { ...defaultConfig }; + return { ...defaultConfig, trackers: [] }; } } diff --git a/src/config/trackers.test.ts b/src/config/trackers.test.ts new file mode 100644 index 0000000..f57baee --- /dev/null +++ b/src/config/trackers.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { formatTrackers, parseTrackers } from "./trackers"; + +describe("parseTrackers", () => { + it("returns empty for blank input", () => { + expect(parseTrackers("")).toEqual([]); + expect(parseTrackers(" \n\t ")).toEqual([]); + }); + + it("splits on commas, whitespace, and newlines", () => { + const input = + "udp://a.example:1337/announce, http://b.example/announce\nhttps://c.example/announce\tudp://d.example:80"; + expect(parseTrackers(input)).toEqual([ + "udp://a.example:1337/announce", + "http://b.example/announce", + "https://c.example/announce", + "udp://d.example:80", + ]); + }); + + it("dedupes exact duplicates", () => { + const input = "udp://a.example:1337, udp://a.example:1337 udp://a.example:1337"; + expect(parseTrackers(input)).toEqual(["udp://a.example:1337"]); + }); + + it("keeps only udp, http(s), ws(s) schemes", () => { + const input = + "udp://a.example:80 http://b.example ftp://c.example file:///etc/passwd hello ws://d.example wss://e.example"; + expect(parseTrackers(input)).toEqual([ + "udp://a.example:80", + "http://b.example", + "ws://d.example", + "wss://e.example", + ]); + }); + + it("preserves order", () => { + const input = "https://z.example, https://a.example, https://m.example"; + expect(parseTrackers(input)).toEqual([ + "https://z.example", + "https://a.example", + "https://m.example", + ]); + }); +}); + +describe("formatTrackers", () => { + it("joins with a comma and space", () => { + expect(formatTrackers(["a", "b", "c"])).toBe("a, b, c"); + }); + + it("returns empty string for empty array", () => { + expect(formatTrackers([])).toBe(""); + }); +}); diff --git a/src/config/trackers.ts b/src/config/trackers.ts new file mode 100644 index 0000000..f638147 --- /dev/null +++ b/src/config/trackers.ts @@ -0,0 +1,18 @@ +const VALID_SCHEME = /^(udp|https?|wss?):\/\//i; + +export function parseTrackers(input: string): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of input.split(/[\s,]+/)) { + const url = raw.trim(); + if (!url || !VALID_SCHEME.test(url)) continue; + if (seen.has(url)) continue; + seen.add(url); + out.push(url); + } + return out; +} + +export function formatTrackers(trackers: string[]): string { + return trackers.join(", "); +} diff --git a/src/download/engine.ts b/src/download/engine.ts index 8b5b061..a454e52 100644 --- a/src/download/engine.ts +++ b/src/download/engine.ts @@ -47,7 +47,15 @@ export class TorrentEngine { // `source` is a magnet URI, an infoHash, or a path to a .torrent file. Seeding // an existing file passes the stored .torrent path so webtorrent can verify it // locally instead of re-fetching metadata from the swarm. - add(id: string, source: string, dir: string, handlers: AddHandlers): void { + // `announce` supplements whatever trackers are already in the source URI; + // webtorrent dedupes internally. + add( + id: string, + source: string, + dir: string, + handlers: AddHandlers, + announce?: string[], + ): void { const client = this.ensureClient(); const existing = this.torrents.get(id); if (existing) { @@ -57,9 +65,10 @@ export class TorrentEngine { } catch {} } + const opts = announce && announce.length > 0 ? { path: dir, announce } : { path: dir }; let torrent: Torrent; try { - torrent = client.add(source, { path: dir }); + torrent = client.add(source, opts); } catch (e) { handlers.onError?.(message(e)); return; diff --git a/src/download/queue.ts b/src/download/queue.ts index 0b8249a..5d02d02 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -53,6 +53,14 @@ export class DownloadQueue extends EventEmitter { private seeds = new Map(); private strayHits = new Map(); private seedStartedAt = new Map(); + private trackers: string[] = []; + + // Extra announce URLs appended to every torrent added from now on. + // Existing running torrents aren't retro-updated — the change takes effect + // for the next add / resume / re-seed. + setTrackers(trackers: string[]): void { + this.trackers = trackers; + } getItems(): QueueItem[] { return [...this.items.values()].sort((a, b) => b.addedAt - a.addedAt); @@ -102,7 +110,7 @@ export class DownloadQueue extends EventEmitter { } private startEngine(item: QueueItem): void { - this.engine.add(item.id, item.magnet, item.dir, this.engineHandlers(item.id)); + this.engine.add(item.id, item.magnet, item.dir, this.engineHandlers(item.id), this.trackers); } // One torrent serves an item across its whole life (download -> seed -> @@ -372,7 +380,7 @@ export class DownloadQueue extends EventEmitter { // Seed from the stored .torrent metadata when we have it (verifies the local // file immediately, no swarm needed); fall back to the magnet otherwise. const source = torrentMetaExists(h.id) ? torrentMetaPath(h.id) : h.magnet; - this.engine.add(h.id, source, h.dir, this.engineHandlers(h.id)); + this.engine.add(h.id, source, h.dir, this.engineHandlers(h.id), this.trackers); this.ensurePoll(); this.changed(); void this.persistSeeds(); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index b9122b5..eb1e14e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -33,6 +33,7 @@ import { Spinner } from "./components/Spinner"; import { TabTitle } from "./components/TabTitle"; import { Splash } from "./views/Splash"; import { FolderPrompt } from "./components/FolderPrompt"; +import { TrackersPrompt } from "./components/TrackersPrompt"; import { footerHints } from "./keymap"; import { COLOR, ICON } from "./theme"; import { useMouseWheel } from "./hooks/useMouseWheel"; @@ -83,6 +84,7 @@ export function App({ const [seedFocus, setSeedFocus] = useState(null); const [showHelp, setShowHelp] = useState(false); const [editingFolder, setEditingFolder] = useState(false); + const [editingTrackers, setEditingTrackers] = useState(false); const [notice, setNotice] = useState(null); const booting = useRef(false); @@ -93,6 +95,7 @@ export function App({ void (async () => { const cfg = await loadConfig(); const q = new DownloadQueue(); + q.setTrackers(cfg.trackers); q.restore(reconcileQueue(await loadQueue())); q.restoreHistory(await loadHistory()); q.restoreSeeds(await loadSeeds()); @@ -148,15 +151,40 @@ export function App({ else exit(); }, [queue, onQuit, exit]); - const setConfig = useCallback((c: Config) => { - setConfigState(c); - void saveConfig(c); - }, []); + const setConfig = useCallback( + (c: Config) => { + setConfigState(c); + queue?.setTrackers(c.trackers); + void saveConfig(c); + }, + [queue], + ); const closeFolderPrompt = useCallback(() => { setEditingFolder(false); }, []); + const closeTrackersPrompt = useCallback(() => { + setEditingTrackers(false); + }, []); + + const setTrackers = useCallback( + (list: string[]) => { + closeTrackersPrompt(); + if (!config) return; + const same = + list.length === config.trackers.length && + list.every((t, i) => t === config.trackers[i]); + if (same) { + setNotice("Trackers unchanged."); + return; + } + setConfig({ ...config, trackers: list }); + setNotice(list.length === 0 ? "Cleared extra trackers." : `Saved ${list.length} tracker${list.length === 1 ? "" : "s"}.`); + }, + [config, setConfig, closeTrackersPrompt], + ); + const setDownloadDir = useCallback( (raw: string) => { closeFolderPrompt(); @@ -278,7 +306,7 @@ export function App({ submitQuery, section, setSection, - region: showHelp || editingFolder ? "help" : region, + region: showHelp || editingFolder || editingTrackers ? "help" : region, setRegion, captureMode, setCaptureMode, @@ -307,6 +335,7 @@ export function App({ region, showHelp, editingFolder, + editingTrackers, captureMode, downloadFocus, seedFocus, @@ -328,7 +357,7 @@ export function App({ quitAll(); return; } - if (editingFolder) return; // the folder prompt owns input (its own esc + enter) + if (editingFolder || editingTrackers) return; // the prompt owns input (its own esc + enter) if (captureMode === "text") return; if (showHelp) { setShowHelp(false); @@ -343,6 +372,11 @@ export function App({ setEditingFolder(true); return; } + if (input === "t") { + setShowHelp(false); + setEditingTrackers(true); + return; + } if (input === "m") { void pasteFromClipboard(); return; @@ -420,10 +454,21 @@ export function App({ ) : null} + {editingTrackers ? ( + + + + ) : null} + @@ -439,7 +484,7 @@ export function App({ {showFooter ? ( - +