import { devLogger, userLogger } from "../js/utils/logger.js"; import { createBeaconToast } from "./ui/toastService.js"; import { createTorrentTable } from "./ui/torrentTable.js"; const VERSION = "2.0"; const TRACKERS = [ "wss://tracker.btorrent.xyz", "wss://tracker.openwebtorrent.com", ]; const TORRENT_OPTIONS = { announce: TRACKERS }; const TRACKER_OPTIONS = { announce: TRACKERS }; // A magnet with no live seeders never fires WebTorrent's metadata-ready // callback, so the processing overlay would otherwise spin forever. After this // long we drop the overlay and tell the user it's still trying in the // background (the torrent stays in the table and keeps announcing). const PROCESSING_TIMEOUT_MS = 30000; function formatBytes(num, speed = false) { if (typeof num !== "number" || Number.isNaN(num)) { return ""; } if (num < 1) { return speed ? "" : "0 B"; } const units = ["B", "kB", "MB", "GB", "TB"]; const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), units.length - 1); const value = (num / Math.pow(1000, exponent)).toFixed(1); return `${value} ${units[exponent]}${speed ? "/s" : ""}`; } function formatProgress(value) { if (typeof value !== "number" || Number.isNaN(value)) { return ""; } return `${(value * 100).toFixed(1)}%`; } function formatRatio(value) { if (typeof value !== "number" || !Number.isFinite(value)) { return "0.00"; } return value.toFixed(2); } function torrentsMatch(a, b) { if (!a || !b) { return false; } if (a === b) { return true; } if (a.infoHash && b.infoHash && a.infoHash === b.infoHash) { return true; } if (a.magnetURI && b.magnetURI && a.magnetURI === b.magnetURI) { return true; } return false; } function prepareTorrentFiles(torrent) { if (!torrent || !Array.isArray(torrent.files)) { return; } torrent.files.forEach((file) => { if (typeof file.priority !== "string") { file.priority = "0"; } if (typeof file.getBlobURL === "function") { file.getBlobURL((error, url) => { if (error || !url) { return; } file.url = url; }); } }); if (torrent.torrentFile && typeof Blob === "function" && typeof URL !== "undefined") { try { const blob = new Blob([torrent.torrentFile], { type: "application/x-bittorrent", }); torrent.torrentFileBlobURL = URL.createObjectURL(blob); if (!torrent.fileName) { const name = torrent.name || "download"; torrent.fileName = `${name}.torrent`; } } catch (error) { devLogger.warn("[beacon] Failed to create torrent blob URL", error); } } } function setFilePriority(file, value) { if (!file) { return; } const priorityValue = typeof value === "string" ? value : String(value ?? "0"); file.priority = priorityValue; if (priorityValue === "-1") { if (typeof file.deselect === "function") { file.deselect(); } return; } const numericPriority = Number.parseInt(priorityValue, 10); if (!Number.isFinite(numericPriority)) { return; } if (typeof file.select === "function") { file.select(numericPriority); } } function selectNextTorrent(current, torrents) { if (!Array.isArray(torrents) || torrents.length === 0) { return null; } if (!current) { return torrents[0]; } const existing = torrents.find((torrent) => torrentsMatch(torrent, current)); if (existing) { return existing; } return torrents[0]; } export function createBeaconApp({ documentRef = typeof document !== "undefined" ? document : null, WebTorrentCtor, } = {}) { if (!documentRef) { throw new Error("createBeaconApp requires a document reference"); } const view = documentRef.defaultView || globalThis; const WebTorrentClass = typeof WebTorrentCtor === "function" ? WebTorrentCtor : typeof view?.WebTorrent === "function" ? view.WebTorrent : null; if (!WebTorrentClass) { throw new Error("createBeaconApp requires a WebTorrent constructor"); } const toast = createBeaconToast(documentRef); const client = new WebTorrentClass({ tracker: TRACKER_OPTIONS }); const elements = { form: documentRef.querySelector('[data-beacon="magnet-form"]'), magnetInput: documentRef.querySelector('[data-beacon="magnet-input"]'), seedButton: documentRef.querySelector('[data-beacon="seed-button"]'), seedInput: documentRef.querySelector('[data-beacon="seed-input"]'), tableRoot: documentRef.querySelector('[data-beacon="torrent-table"]'), selectedSection: documentRef.querySelector('[data-beacon="selected"]'), selectedName: documentRef.querySelector('[data-beacon="selected-name"]'), pauseButton: documentRef.querySelector('[data-beacon-action="pause"]'), resumeButton: documentRef.querySelector('[data-beacon-action="resume"]'), downloadAllButton: documentRef.querySelector('[data-beacon-action="download-all"]'), removeButton: documentRef.querySelector('[data-beacon-action="remove"]'), copyMagnetButton: documentRef.querySelector('[data-beacon-action="copy-magnet"]'), infoHashValue: documentRef.querySelector('[data-beacon="info-hash"]'), fileList: documentRef.querySelector('[data-beacon="file-list"]'), stats: documentRef.querySelector('[data-beacon="client-stats"]'), overlay: documentRef.querySelector('[data-beacon="processing-overlay"]'), }; const table = elements.tableRoot ? createTorrentTable({ documentRef, root: elements.tableRoot, formatters: { bytes: (value, speed = false) => formatBytes(value, speed), progress: (value) => formatProgress(value), ratio: (value) => formatRatio(value), integer: (value) => (typeof value === "number" && Number.isFinite(value) ? Math.floor(value).toString() : "0"), bytesPerSecond: (value) => formatBytes(value, true), }, onSelect(torrent) { state.selectedTorrent = torrent; renderSelectedTorrent(); }, }) : null; const state = { selectedTorrent: null, processing: false, }; const cleanupTasks = []; const intervalHandles = []; let processingTimeoutId = null; function addCleanup(task) { cleanupTasks.push(task); } function clearProcessingWatchdog() { if (processingTimeoutId !== null) { view.clearTimeout(processingTimeoutId); processingTimeoutId = null; } } // Show the processing overlay and arm a watchdog so it can never hang // forever: if the operation hasn't completed in time, drop the overlay and // warn instead of leaving a dead spinner. function beginProcessing() { setProcessing(true); clearProcessingWatchdog(); processingTimeoutId = view.setTimeout(() => { processingTimeoutId = null; if (!state.processing) { return; } setProcessing(false); toast?.warn( "Still searching for peers — this is taking a while. It'll keep trying in the background.", ); }, PROCESSING_TIMEOUT_MS); } function setProcessing(flag) { state.processing = Boolean(flag); if (!elements.overlay) { return; } const isActive = state.processing; elements.overlay.hidden = !isActive; elements.overlay.setAttribute("aria-hidden", isActive ? "false" : "true"); if (isActive) { elements.overlay.style.removeProperty("display"); } else { elements.overlay.style.display = "none"; } } function getTorrents() { return Array.isArray(client.torrents) ? [...client.torrents] : []; } function updateStats() { if (!elements.stats) { return; } const download = formatBytes(client.downloadSpeed, true) || "0 B/s"; const upload = formatBytes(client.uploadSpeed, true) || "0 B/s"; const ratio = formatRatio(client.ratio); elements.stats.textContent = `Client stats: ↓ ${download} · ↑ ${upload} · Ratio: ${ratio}`; } function renderFileList(torrent) { if (!elements.fileList) { return; } elements.fileList.innerHTML = ""; if (!torrent || !Array.isArray(torrent.files) || !torrent.files.length) { return; } torrent.files.forEach((file, index) => { const row = documentRef.createElement("tr"); row.className = "align-top"; const nameCell = documentRef.createElement("td"); nameCell.className = "px-3 py-2"; if (file.done && file.url) { const link = documentRef.createElement("a"); link.className = "text-info hover:text-info-strong"; link.href = file.url; link.download = file.name || `file-${index + 1}`; link.textContent = file.name || `File ${index + 1}`; nameCell.appendChild(link); } else { const span = documentRef.createElement("span"); span.textContent = file.name || `File ${index + 1}`; nameCell.appendChild(span); } row.appendChild(nameCell); const sizeCell = documentRef.createElement("td"); sizeCell.className = "px-3 py-2 text-muted-strong"; sizeCell.textContent = formatBytes(file.length) || "0 B"; row.appendChild(sizeCell); const priorityCell = documentRef.createElement("td"); priorityCell.className = "px-3 py-2"; const select = documentRef.createElement("select"); select.className = "select"; select.name = `${file.name || `file-${index + 1}`}-priority`; select.setAttribute("aria-label", `Set priority for ${file.name || `file ${index + 1}`}`); const options = [ { value: "1", label: "High priority" }, { value: "0", label: "Low priority" }, { value: "-1", label: "Don't download" }, ]; options.forEach((option) => { const optionEl = documentRef.createElement("option"); optionEl.value = option.value; optionEl.textContent = option.label; if (option.value === file.priority) { optionEl.selected = true; } select.appendChild(optionEl); }); select.value = file.priority ?? "0"; select.addEventListener("change", (event) => { setFilePriority(file, event.target.value); }); priorityCell.appendChild(select); row.appendChild(priorityCell); elements.fileList.appendChild(row); }); } function renderSelectedTorrent() { if (!elements.selectedSection) { return; } const torrents = getTorrents(); state.selectedTorrent = selectNextTorrent(state.selectedTorrent, torrents); const torrent = state.selectedTorrent; elements.selectedSection.hidden = !torrent; if (!torrent) { return; } if (elements.selectedName) { elements.selectedName.textContent = torrent.name || "Untitled torrent"; } if (elements.pauseButton) { elements.pauseButton.hidden = torrent.paused === true; elements.pauseButton.disabled = torrent.paused === true; } if (elements.resumeButton) { elements.resumeButton.hidden = torrent.paused !== true; elements.resumeButton.disabled = torrent.paused !== true; } if (elements.downloadAllButton) { elements.downloadAllButton.disabled = !(torrent.progress >= 1); } if (elements.infoHashValue) { elements.infoHashValue.textContent = torrent.infoHash || ""; } renderFileList(torrent); } function handleTorrentReady(torrent, { isSeed = false } = {}) { clearProcessingWatchdog(); setProcessing(false); prepareTorrentFiles(torrent); if (!isSeed) { toast.info(`Received ${torrent.name || "torrent"} metadata`); } if (!state.selectedTorrent) { state.selectedTorrent = torrent; } renderSelectedTorrent(); table?.render(getTorrents(), state.selectedTorrent); } function addMagnet(magnet) { const trimmed = typeof magnet === "string" ? magnet.trim() : ""; if (!trimmed) { return; } beginProcessing(); let torrent; try { torrent = client.add(trimmed, TORRENT_OPTIONS, (added) => { handleTorrentReady(added, { isSeed: false }); }); } catch (error) { clearProcessingWatchdog(); setProcessing(false); userLogger.error("[beacon] Failed to add magnet", error); toast?.error(error?.message || "Couldn't add that magnet."); return; } watchTorrentForFailure(torrent); } function seedFiles(files) { if (!Array.isArray(files) || files.length === 0) { return; } beginProcessing(); let torrent; try { torrent = client.seed(files, TORRENT_OPTIONS, (seeded) => { handleTorrentReady(seeded, { isSeed: true }); toast.success(`Seeding ${seeded.files.length} file(s)`); }); } catch (error) { clearProcessingWatchdog(); setProcessing(false); userLogger.error("[beacon] Failed to seed files", error); toast?.error(error?.message || "Couldn't seed those files."); return; } watchTorrentForFailure(torrent); } // A torrent can emit its own 'error' (bad/duplicate magnet) without the // client-level handler firing; surface it and drop the overlay so the add // fails visibly instead of spinning. function watchTorrentForFailure(torrent) { if (!torrent || typeof torrent.once !== "function") { return; } torrent.once("error", (error) => { clearProcessingWatchdog(); setProcessing(false); userLogger.error("[beacon] Torrent error", error); toast?.error(error?.message || "That torrent could not be loaded."); }); } function downloadAll(torrent) { if (!torrent) { return; } if (torrent.progress < 1) { toast.warn("Torrent is not finished downloading yet."); return; } torrent.files.forEach((file) => { if (typeof file.getBlob !== "function") { return; } file.getBlob((err, blob) => { if (err || !blob) { userLogger.error("[beacon] Failed to get blob for file", file?.name, err); toast.error("Failed to prepare file for download."); return; } const blobUrl = URL.createObjectURL(blob); const anchor = documentRef.createElement("a"); anchor.className = "beacon-hidden-download"; anchor.setAttribute("data-beacon-hidden-download", "true"); anchor.href = blobUrl; anchor.download = file.name || "download"; documentRef.body.appendChild(anchor); anchor.click(); documentRef.body.removeChild(anchor); URL.revokeObjectURL(blobUrl); }); }); } function copyMagnet(torrent) { if (!torrent?.magnetURI) { return; } const magnet = torrent.magnetURI; const clipboard = view?.navigator?.clipboard; if (clipboard && typeof clipboard.writeText === "function") { clipboard .writeText(magnet) .then(() => { toast.success("Magnet URI copied to clipboard!"); }) .catch((error) => { userLogger.error("[beacon] Clipboard error", error); toast.error("Failed to copy magnet URI"); }); return; } try { const textarea = documentRef.createElement("textarea"); textarea.value = magnet; textarea.className = "beacon-clipboard-offscreen"; textarea.setAttribute("data-beacon-clipboard-state", "offscreen"); documentRef.body.appendChild(textarea); textarea.select(); documentRef.execCommand?.("copy"); documentRef.body.removeChild(textarea); toast.success("Magnet URI copied (fallback)!"); } catch (error) { userLogger.error("[beacon] Clipboard fallback error", error); toast.error("Failed to copy magnet URI"); } } function removeTorrent(torrent) { if (!torrent) { return; } torrent.destroy((error) => { if (error) { userLogger.error("[beacon] Failed to destroy torrent", error); toast.error("Failed to remove torrent"); } const torrents = getTorrents(); state.selectedTorrent = selectNextTorrent(null, torrents); table?.render(torrents, state.selectedTorrent); renderSelectedTorrent(); }); } function mount() { devLogger.info(`[beacon] Starting beacon runtime v${VERSION}`); if (!toast) { devLogger.warn("[beacon] Toast service unavailable"); } if (!WebTorrentClass.WEBRTC_SUPPORT) { toast?.error("Please use a browser with WebRTC support.", { sticky: true }); } if (elements.form) { const submitHandler = (event) => { event.preventDefault(); addMagnet(elements.magnetInput?.value || ""); if (elements.magnetInput) { elements.magnetInput.value = ""; } }; elements.form.addEventListener("submit", submitHandler); addCleanup(() => elements.form.removeEventListener("submit", submitHandler)); } if (elements.seedButton && elements.seedInput) { const seedClickHandler = () => { elements.seedInput.click(); }; const seedChangeHandler = () => { const files = Array.from(elements.seedInput.files || []); elements.seedInput.value = ""; seedFiles(files); }; elements.seedButton.addEventListener("click", seedClickHandler); elements.seedInput.addEventListener("change", seedChangeHandler); addCleanup(() => { elements.seedButton.removeEventListener("click", seedClickHandler); elements.seedInput.removeEventListener("change", seedChangeHandler); }); } if (elements.pauseButton) { const handler = () => { if (state.selectedTorrent && typeof state.selectedTorrent.pause === "function") { state.selectedTorrent.pause(); renderSelectedTorrent(); } }; elements.pauseButton.addEventListener("click", handler); addCleanup(() => elements.pauseButton.removeEventListener("click", handler)); } if (elements.resumeButton) { const handler = () => { if (state.selectedTorrent && typeof state.selectedTorrent.resume === "function") { state.selectedTorrent.resume(); renderSelectedTorrent(); } }; elements.resumeButton.addEventListener("click", handler); addCleanup(() => elements.resumeButton.removeEventListener("click", handler)); } if (elements.downloadAllButton) { const handler = () => downloadAll(state.selectedTorrent); elements.downloadAllButton.addEventListener("click", handler); addCleanup(() => elements.downloadAllButton.removeEventListener("click", handler)); } if (elements.removeButton) { const handler = () => removeTorrent(state.selectedTorrent); elements.removeButton.addEventListener("click", handler); addCleanup(() => elements.removeButton.removeEventListener("click", handler)); } if (elements.copyMagnetButton) { const handler = () => copyMagnet(state.selectedTorrent); elements.copyMagnetButton.addEventListener("click", handler); addCleanup(() => elements.copyMagnetButton.removeEventListener("click", handler)); } const errorHandler = (error) => { userLogger.error("[beacon] Torrent client error", error); toast?.error(error?.message || String(error)); clearProcessingWatchdog(); setProcessing(false); }; client.on("error", errorHandler); addCleanup(() => client.removeListener("error", errorHandler)); const beforeUnloadHandler = (event) => { if (client.torrents && client.torrents.length > 0) { event.preventDefault(); event.returnValue = "Transfers are in progress. Are you sure you want to leave or refresh?"; return event.returnValue; } return undefined; }; if (view && typeof view.addEventListener === "function") { view.addEventListener("beforeunload", beforeUnloadHandler); addCleanup(() => view.removeEventListener("beforeunload", beforeUnloadHandler)); } table?.render(getTorrents(), state.selectedTorrent); renderSelectedTorrent(); updateStats(); setProcessing(false); const intervalId = view.setInterval(() => { table?.render(getTorrents(), state.selectedTorrent); renderSelectedTorrent(); updateStats(); }, 1000); intervalHandles.push(intervalId); const hash = view?.location?.hash; if (typeof hash === "string" && hash.length > 1) { addMagnet(hash.slice(1)); } } function destroy() { clearProcessingWatchdog(); intervalHandles.forEach((id) => view.clearInterval(id)); intervalHandles.length = 0; cleanupTasks.forEach((task) => { try { task(); } catch (error) { devLogger.warn("[beacon] Cleanup task failed", error); } }); cleanupTasks.length = 0; table?.destroy(); try { client.destroy(); } catch (error) { devLogger.warn("[beacon] Failed to destroy client", error); } } return { mount, destroy }; } export default createBeaconApp;