From 78ce75ef62fd2159ad4aead797ead644e4d9408e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:54:24 -0400 Subject: [PATCH] Handle WebTorrent fallback without service worker --- js/app.js | 19 +++++ js/utils/serviceWorkerFallbackMessages.js | 58 +++++++++++++ js/webtorrent.js | 82 +++++++++++++++---- .../service-worker-fallback-message.test.mjs | 57 +++++++++++++ 4 files changed, 198 insertions(+), 18 deletions(-) create mode 100644 js/utils/serviceWorkerFallbackMessages.js create mode 100644 tests/torrent/service-worker-fallback-message.test.mjs diff --git a/js/app.js b/js/app.js index 090d06e7..069c29ee 100644 --- a/js/app.js +++ b/js/app.js @@ -81,6 +81,7 @@ import { pointerArrayToKey } from "./utils/pointer.js"; import { resolveVideoPointer } from "./utils/videoPointer.js"; import { isValidMagnetUri } from "./utils/magnetValidators.js"; import { dedupeToNewestByRoot } from "./utils/videoDeduper.js"; +import { buildServiceWorkerFallbackStatus } from "./utils/serviceWorkerFallbackMessages.js"; import { getVideoRootIdentifier, applyRootTimestampToVideosMap, @@ -5635,6 +5636,24 @@ class Application { this.modalVideo, { urlList: sanitizedUrlList } ); + + if (torrentClient.isServiceWorkerUnavailable()) { + const swError = torrentClient.getServiceWorkerInitError(); + const statusMessage = buildServiceWorkerFallbackStatus(swError); + this.log( + "[playViaWebTorrent] Service worker unavailable; streaming directly via WebTorrent.", + swError + ); + if (swError) { + userLogger.warn( + "[playViaWebTorrent] Service worker unavailable; direct streaming engaged.", + swError + ); + } + if (this.videoModal) { + this.videoModal.updateStatus(statusMessage); + } + } if (torrentInstance && torrentInstance.ready) { // Some browsers delay `playing` events for MediaSource-backed torrents. // Clearing the poster here prevents the historic "GIF stuck over the diff --git a/js/utils/serviceWorkerFallbackMessages.js b/js/utils/serviceWorkerFallbackMessages.js new file mode 100644 index 00000000..6110b92c --- /dev/null +++ b/js/utils/serviceWorkerFallbackMessages.js @@ -0,0 +1,58 @@ +// js/utils/serviceWorkerFallbackMessages.js + +const BASE_STATUS_MESSAGE = "Streaming via WebTorrent"; + +function normalizeMessage(error) { + if (!error) { + return ""; + } + if (error instanceof Error && typeof error.message === "string") { + return error.message; + } + if (typeof error === "string") { + return error; + } + try { + return String(error); + } catch (_err) { + return ""; + } +} + +export function buildServiceWorkerFallbackStatus(error) { + const rawMessage = normalizeMessage(error); + const normalized = rawMessage.toLowerCase(); + + if (!normalized) { + return `${BASE_STATUS_MESSAGE} (service worker unavailable)`; + } + + if (normalized.includes("https or localhost required")) { + return `${BASE_STATUS_MESSAGE} (serve over HTTPS to enable service worker)`; + } + + if (normalized.includes("not supported") || normalized.includes("disabled")) { + return `${BASE_STATUS_MESSAGE} (browser disabled service workers)`; + } + + if (normalized.includes("brave shield")) { + return `${BASE_STATUS_MESSAGE} (Brave Shields blocked the service worker)`; + } + + if ( + normalized.includes("failed to register") || + normalized.includes("failed to fetch") || + normalized.includes("blocked") || + normalized.includes("net::err_blocked_by_client") + ) { + return `${BASE_STATUS_MESSAGE} (service worker blocked by browser or extension)`; + } + + if (normalized.includes("controller claim timeout")) { + return `${BASE_STATUS_MESSAGE} (waiting for service worker control)`; + } + + return `${BASE_STATUS_MESSAGE} (service worker unavailable)`; +} + +export default buildServiceWorkerFallbackStatus; diff --git a/js/webtorrent.js b/js/webtorrent.js index 35315765..25efa6f5 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -125,7 +125,9 @@ function toError(err) { } export class TorrentClient { - constructor() { + constructor({ webTorrentClass } = {}) { + this.WebTorrentClass = + typeof webTorrentClass === "function" ? webTorrentClass : WebTorrent; // Reusable objects and flags this.client = null; this.currentTorrent = null; @@ -135,13 +137,16 @@ export class TorrentClient { this.swRegistration = null; this.serverCreated = false; // Indicates if we've called createServer on this.client + this.serviceWorkerDisabled = false; + this.serviceWorkerError = null; + // Timeout for SW operations this.TIMEOUT_DURATION = 60000; } ensureClientForProbe() { if (!this.probeClient) { - this.probeClient = new WebTorrent(); + this.probeClient = new this.WebTorrentClass(); } return this.probeClient; } @@ -288,8 +293,16 @@ export class TorrentClient { }); } - log(msg) { - devLogger.log(msg); + log(...args) { + devLogger.log(...args); + } + + isServiceWorkerUnavailable() { + return this.serviceWorkerDisabled; + } + + getServiceWorkerInitError() { + return this.serviceWorkerError || null; } async isBrave() { @@ -307,21 +320,52 @@ export class TorrentClient { * Called once from streamVideo. */ async init() { - // 1) If the client doesn't exist, create it if (!this.client) { - this.client = new WebTorrent(); + this.client = new this.WebTorrentClass(); } - // 2) If we haven’t registered the service worker yet, do it now - if (!this.swRegistration) { - this.swRegistration = await this.setupServiceWorker(); - } else { - // Even with an existing registration we still wait for control so that a - // transient controller drop (e.g. Chrome devtools unregister/reload) does - // not resurrect the grey-screen regression mentioned in - // waitForActiveController(). - this.requestClientsClaim(this.swRegistration); - await this.waitForActiveController(this.swRegistration); + if (this.serviceWorkerDisabled) { + return { + serviceWorkerReady: false, + registration: null, + error: this.serviceWorkerError, + }; + } + + try { + if (!this.swRegistration) { + this.swRegistration = await this.setupServiceWorker(); + } else { + // Even with an existing registration we still wait for control so that a + // transient controller drop (e.g. Chrome devtools unregister/reload) does + // not resurrect the grey-screen regression mentioned in + // waitForActiveController(). + this.requestClientsClaim(this.swRegistration); + await this.waitForActiveController(this.swRegistration); + } + + return { + serviceWorkerReady: !!this.swRegistration, + registration: this.swRegistration, + }; + } catch (error) { + const normalizedError = toError(error); + this.log( + "[WebTorrent] Service worker setup failed; continuing without it:", + normalizedError + ); + userLogger.warn( + "[WebTorrent] Service worker unavailable; falling back to direct streaming.", + normalizedError + ); + this.serviceWorkerDisabled = true; + this.serviceWorkerError = normalizedError; + this.swRegistration = null; + return { + serviceWorkerReady: false, + registration: null, + error: normalizedError, + }; } } @@ -777,10 +821,12 @@ export class TorrentClient { async streamVideo(magnetURI, videoElement, opts = {}) { try { // 1) Make sure we have a WebTorrent client and a valid SW registration. - await this.init(); + const initResult = await this.init(); + const serviceWorkerReady = + !!(initResult?.serviceWorkerReady && this.swRegistration); // 2) Create the server once if not already created. - if (!this.serverCreated) { + if (serviceWorkerReady && !this.serverCreated) { this.client.createServer({ controller: this.swRegistration, pathPrefix: location.origin + "/webtorrent", diff --git a/tests/torrent/service-worker-fallback-message.test.mjs b/tests/torrent/service-worker-fallback-message.test.mjs new file mode 100644 index 00000000..e8294036 --- /dev/null +++ b/tests/torrent/service-worker-fallback-message.test.mjs @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildServiceWorkerFallbackStatus, +} from "../../js/utils/serviceWorkerFallbackMessages.js"; + +const BASE = "Streaming via WebTorrent"; + +test("returns generic message when error missing", () => { + assert.strictEqual( + buildServiceWorkerFallbackStatus(null), + `${BASE} (service worker unavailable)` + ); +}); + +test("identifies HTTPS requirement errors", () => { + const error = new Error("HTTPS or localhost required"); + assert.strictEqual( + buildServiceWorkerFallbackStatus(error), + `${BASE} (serve over HTTPS to enable service worker)` + ); +}); + +test("identifies disabled service worker errors", () => { + const error = new Error("Service Worker not supported or disabled"); + assert.strictEqual( + buildServiceWorkerFallbackStatus(error), + `${BASE} (browser disabled service workers)` + ); +}); + +test("identifies Brave specific guidance", () => { + const error = new Error("Please enable Service Workers in Brave Shield settings"); + assert.strictEqual( + buildServiceWorkerFallbackStatus(error), + `${BASE} (Brave Shields blocked the service worker)` + ); +}); + +test("identifies blocked script errors", () => { + const error = new Error( + "Failed to register ServiceWorker: TypeError: Failed to fetch" + ); + assert.strictEqual( + buildServiceWorkerFallbackStatus(error), + `${BASE} (service worker blocked by browser or extension)` + ); +}); + +test("identifies controller claim timeout", () => { + const error = new Error("Service worker controller claim timeout"); + assert.strictEqual( + buildServiceWorkerFallbackStatus(error), + `${BASE} (waiting for service worker control)` + ); +});