From 22a7b746165145e521a2e683fac8072e36253f79 Mon Sep 17 00:00:00 2001 From: D3SOX Date: Wed, 12 Aug 2026 16:53:56 +0200 Subject: [PATCH 1/2] fix: cancel downloads when the userscript panel or tab closes Closing the panel/tab while Chromium was starting could miss cancel because page fetch is blocked by Private Network Access and mid-launch close was a no-op. --- src/browserLaunch.test.ts | 39 +++++++++++++++++++++++ src/browserLaunch.ts | 59 +++++++++++++++++++++++++++++++++++ src/downloadgater.ts | 7 +++-- src/droploud.ts | 7 +++-- src/gateCancellation.test.ts | 28 +++++++++++++++-- src/gaterush.ts | 7 +++-- src/hypeddit.ts | 10 ++++-- src/mypresskit.ts | 10 ++++-- src/pumpyoursound.ts | 10 ++++-- src/stillhype.ts | 7 +++-- userscript/sc-gate-dl.test.ts | 15 +++++++++ userscript/sc-gate-dl.user.js | 48 +++++++++++++++++++++------- webui/src/components/App.tsx | 13 ++++---- 13 files changed, 219 insertions(+), 41 deletions(-) diff --git a/src/browserLaunch.test.ts b/src/browserLaunch.test.ts index 419e3e6..14ba0dc 100644 --- a/src/browserLaunch.test.ts +++ b/src/browserLaunch.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { buildXvfbBrowserEnv, + CancellableBrowserLaunch, createXvfbManager, getGpuWorkaroundArgs, isXvfbSupported, @@ -118,3 +119,41 @@ describe('isXvfbSupported', () => { expect(isXvfbSupported('win32')).toBeFalse(); }); }); + +describe('CancellableBrowserLaunch', () => { + test('closes a browser that finishes launching after close()', async () => { + const events: string[] = []; + let resolveLaunch!: (browser: { close: () => Promise }) => void; + const launchPromise = new Promise<{ close: () => Promise }>( + (resolve) => { + resolveLaunch = resolve; + }, + ); + + const session = new CancellableBrowserLaunch( + () => launchPromise as Promise, + ); + const launching = session.launch(); + const closing = session.close(); + let closed = false; + resolveLaunch({ + close: async () => { + if (closed) return; + closed = true; + events.push('close'); + }, + }); + + await expect(launching).rejects.toThrow('Download cancelled'); + await closing; + expect(events).toEqual(['close']); + }); + + test('rejects a late launch after close()', async () => { + const session = new CancellableBrowserLaunch(async () => { + throw new Error('should not launch'); + }); + await session.close(); + await expect(session.launch()).rejects.toThrow('Download cancelled'); + }); +}); diff --git a/src/browserLaunch.ts b/src/browserLaunch.ts index edcb63b..a5ade24 100644 --- a/src/browserLaunch.ts +++ b/src/browserLaunch.ts @@ -274,3 +274,62 @@ export async function launchAppBrowser( throw error; } } + +/** + * Tracks an in-flight Chromium launch so cancel can close the browser even when + * `close()` races `initialize()` before `this.browser` is assigned. + */ +export class CancellableBrowserLaunch { + private browser: Browser | undefined; + private inflight: Promise | null = null; + private closed = false; + + constructor( + private readonly launchBrowser: ( + options?: AppBrowserLaunchOptions, + ) => Promise = launchAppBrowser, + ) {} + + async launch(options: AppBrowserLaunchOptions = {}): Promise { + if (this.closed) { + throw new Error('Download cancelled'); + } + const inflight = this.launchBrowser(options); + this.inflight = inflight; + try { + const browser = await inflight; + this.browser = browser; + if (this.closed) { + this.browser = undefined; + await browser.close().catch(() => {}); + throw new Error('Download cancelled'); + } + return browser; + } finally { + if (this.inflight === inflight) this.inflight = null; + } + } + + async launchConfigured(config: HypedditConfig): Promise { + return this.launch({ + ...browserModeToLaunchOptions(config.browserMode), + userDataDir: config.userDataDir ?? './browser-data', + }); + } + + async close(): Promise { + this.closed = true; + const inflight = this.inflight; + if (inflight) { + try { + const browser = await inflight; + await browser.close().catch(() => {}); + } catch { + // Launch failed or already closed after cancel. + } + } + const browser = this.browser; + this.browser = undefined; + await browser?.close().catch(() => {}); + } +} diff --git a/src/downloadgater.ts b/src/downloadgater.ts index 57d48e5..5232d86 100644 --- a/src/downloadgater.ts +++ b/src/downloadgater.ts @@ -1,6 +1,6 @@ import { Presets, SingleBar } from 'cli-progress'; import type { Browser, Page } from 'puppeteer'; -import { launchConfiguredBrowser } from './browserLaunch'; +import { CancellableBrowserLaunch } from './browserLaunch'; import type { ProgressCallback } from './hypeddit'; import Selectors from './selectors'; import { waitForSoundcloudLogin } from './soundcloudLogin'; @@ -9,6 +9,7 @@ import { loadCookies, timeout } from './utils'; export class DownloadgaterDownloader { private browser!: Browser; + private readonly browserLaunch = new CancellableBrowserLaunch(); private downloadFilename: string | null = null; private config: HypedditConfig; private progressCallback: ProgressCallback | null = null; @@ -32,7 +33,7 @@ export class DownloadgaterDownloader { } async initialize() { - this.browser = await launchConfiguredBrowser(this.config); + this.browser = await this.browserLaunch.launchConfigured(this.config); const browserContext = this.browser.defaultBrowserContext(); const soundCloudCookies = await loadCookies('soundcloud-cookies.json'); @@ -170,7 +171,7 @@ export class DownloadgaterDownloader { async close() { this.cancelPendingDownloadWait?.(); this.cancelPendingDownloadWait = null; - await this.browser?.close(); + await this.browserLaunch.close(); } private async clickFreeDownload(page: Page) { diff --git a/src/droploud.ts b/src/droploud.ts index d44185d..1054eff 100644 --- a/src/droploud.ts +++ b/src/droploud.ts @@ -1,6 +1,6 @@ import { Presets, SingleBar } from 'cli-progress'; import type { Browser, Page } from 'puppeteer'; -import { launchConfiguredBrowser } from './browserLaunch'; +import { CancellableBrowserLaunch } from './browserLaunch'; import type { ProgressCallback } from './hypeddit'; import Selectors from './selectors'; import { SoundcloudClient } from './soundcloud'; @@ -19,6 +19,7 @@ type PaneKind = export class DroploudDownloader { private browser!: Browser; + private readonly browserLaunch = new CancellableBrowserLaunch(); private downloadFilename: string | null = null; private config: HypedditConfig; private progressCallback: ProgressCallback | null = null; @@ -42,7 +43,7 @@ export class DroploudDownloader { } async initialize() { - this.browser = await launchConfiguredBrowser(this.config); + this.browser = await this.browserLaunch.launchConfigured(this.config); const browserContext = this.browser.defaultBrowserContext(); const soundCloudCookies = await loadCookies('soundcloud-cookies.json'); @@ -179,7 +180,7 @@ export class DroploudDownloader { async close() { this.cancelPendingDownloadWait?.(); this.cancelPendingDownloadWait = null; - await this.browser?.close(); + await this.browserLaunch.close(); } private async dismissCookieBanner(page: Page) { diff --git a/src/gateCancellation.test.ts b/src/gateCancellation.test.ts index 9241df5..eb34e68 100644 --- a/src/gateCancellation.test.ts +++ b/src/gateCancellation.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { DownloadgaterDownloader } from './downloadgater'; import { DroploudDownloader } from './droploud'; +import { GaterushDownloader } from './gaterush'; import { HypedditDownloader } from './hypeddit'; import { MypresskitDownloader } from './mypresskit'; +import { PumpyoursoundDownloader } from './pumpyoursound'; import { StillhypeDownloader } from './stillhype'; import type { HypedditConfig } from './types'; @@ -30,7 +32,7 @@ async function expectPendingWaitCancelled( events.push('cancel'); rejectPending(new Error('Download was canceled')); }, - browser: { + browserLaunch: { close: async () => { events.push('close'); }, @@ -46,6 +48,7 @@ async function expectPendingWaitCancelled( describe('browser gate cancellation', () => { for (const [name, create] of [ ['Droploud', () => new DroploudDownloader(config)], + ['GateRush', () => new GaterushDownloader(config)], ['DownloadGater', () => new DownloadgaterDownloader(config)], ['StillHype', () => new StillhypeDownloader(config)], ['Hypeddit', () => new HypedditDownloader(config)], @@ -64,7 +67,7 @@ describe('browser gate cancellation', () => { }); Object.assign(downloader, { downloadAbortController, - browser: { + browserLaunch: { close: async () => { events.push('close'); }, @@ -75,4 +78,25 @@ describe('browser gate cancellation', () => { expect(events).toEqual(['abort', 'close']); }); + + test('PumpYourSound closes its direct downloader before the browser', async () => { + const downloader = new PumpyoursoundDownloader(config); + const events: string[] = []; + Object.assign(downloader, { + directDownloader: { + close: async () => { + events.push('direct'); + }, + }, + browserLaunch: { + close: async () => { + events.push('close'); + }, + }, + }); + + await downloader.close(); + + expect(events).toEqual(['direct', 'close']); + }); }); diff --git a/src/gaterush.ts b/src/gaterush.ts index 0039112..60f0ab6 100644 --- a/src/gaterush.ts +++ b/src/gaterush.ts @@ -1,6 +1,6 @@ import { Presets, SingleBar } from 'cli-progress'; import type { Browser, Page } from 'puppeteer'; -import { launchConfiguredBrowser } from './browserLaunch'; +import { CancellableBrowserLaunch } from './browserLaunch'; import type { ProgressCallback } from './hypeddit'; import Selectors from './selectors'; import { @@ -12,6 +12,7 @@ import { loadCookies, timeout } from './utils'; export class GaterushDownloader { private browser!: Browser; + private readonly browserLaunch = new CancellableBrowserLaunch(); private downloadFilename: string | null = null; private config: HypedditConfig; private progressCallback: ProgressCallback | null = null; @@ -35,7 +36,7 @@ export class GaterushDownloader { } async initialize() { - this.browser = await launchConfiguredBrowser(this.config); + this.browser = await this.browserLaunch.launchConfigured(this.config); const browserContext = this.browser.defaultBrowserContext(); const soundCloudCookies = await loadCookies('soundcloud-cookies.json'); @@ -135,7 +136,7 @@ export class GaterushDownloader { async close() { this.cancelPendingDownloadWait?.(); this.cancelPendingDownloadWait = null; - await this.browser?.close(); + await this.browserLaunch.close(); } private async dismissCookieBanner(page: Page) { diff --git a/src/hypeddit.ts b/src/hypeddit.ts index 30fe9ac..a909120 100644 --- a/src/hypeddit.ts +++ b/src/hypeddit.ts @@ -1,6 +1,9 @@ import { Presets, SingleBar } from 'cli-progress'; import type { Browser, Page } from 'puppeteer'; -import { browserModeToLaunchOptions, launchAppBrowser } from './browserLaunch'; +import { + browserModeToLaunchOptions, + CancellableBrowserLaunch, +} from './browserLaunch'; import Selectors from './selectors'; import { waitForSoundcloudLogin } from './soundcloudLogin'; import type { HypedditConfig, JobProgress, JobStage } from './types'; @@ -22,6 +25,7 @@ interface GateDefinition { export class HypedditDownloader { private browser!: Browser; // null-asserted because it is initialized async and every call to it comes logically after the init + private readonly browserLaunch = new CancellableBrowserLaunch(); private downloadFilename: string | null = null; private config: HypedditConfig; private spotifyCookiesExists = false; @@ -102,7 +106,7 @@ export class HypedditDownloader { } async initialize() { - this.browser = await launchAppBrowser({ + this.browser = await this.browserLaunch.launch({ ...browserModeToLaunchOptions(this.config.browserMode), userDataDir: this.config.userDataDir ?? './browser-data', }); @@ -354,7 +358,7 @@ export class HypedditDownloader { async close() { this.cancelPendingDownloadWait?.(); this.cancelPendingDownloadWait = null; - await this.browser?.close(); + await this.browserLaunch.close(); } private async handleEmailSlide(page: Page) { diff --git a/src/mypresskit.ts b/src/mypresskit.ts index 3a1a1d2..c7096fc 100644 --- a/src/mypresskit.ts +++ b/src/mypresskit.ts @@ -2,7 +2,10 @@ import { mkdir, rm } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Presets, SingleBar } from 'cli-progress'; import type { Browser, Page } from 'puppeteer'; -import { browserModeToLaunchOptions, launchAppBrowser } from './browserLaunch'; +import { + browserModeToLaunchOptions, + CancellableBrowserLaunch, +} from './browserLaunch'; import type { ProgressCallback } from './hypeddit'; import { safeFetch } from './safeOutboundUrl'; import Selectors from './selectors'; @@ -63,6 +66,7 @@ function safePageUrl(page: Page): string { export class MypresskitDownloader { private browser!: Browser; + private readonly browserLaunch = new CancellableBrowserLaunch(); private config: HypedditConfig; private progressCallback: ProgressCallback | null = null; private readonly downloadAbortController = new AbortController(); @@ -85,7 +89,7 @@ export class MypresskitDownloader { } async initialize() { - this.browser = await launchAppBrowser({ + this.browser = await this.browserLaunch.launch({ ...browserModeToLaunchOptions(this.config.browserMode), userDataDir: this.config.userDataDir ?? './browser-data', // SC OAuth authorize popup shares session cookies more reliably with this. @@ -225,7 +229,7 @@ export class MypresskitDownloader { async close() { this.downloadAbortController.abort(); - await this.browser?.close(); + await this.browserLaunch.close(); } private async dismissCookies(page: Page) { diff --git a/src/pumpyoursound.ts b/src/pumpyoursound.ts index 6933c7b..eab4c9c 100644 --- a/src/pumpyoursound.ts +++ b/src/pumpyoursound.ts @@ -1,5 +1,8 @@ import type { Browser, Page } from 'puppeteer'; -import { browserModeToLaunchOptions, launchAppBrowser } from './browserLaunch'; +import { + browserModeToLaunchOptions, + CancellableBrowserLaunch, +} from './browserLaunch'; import { DirectDownloader } from './directDownload'; import type { ProgressCallback } from './hypeddit'; import Selectors from './selectors'; @@ -22,6 +25,7 @@ const SC_AUTHORIZE_RE = /secure\.soundcloud\.com\/authorize/i; */ export class PumpyoursoundDownloader { private browser!: Browser; + private readonly browserLaunch = new CancellableBrowserLaunch(); private directDownloader: DirectDownloader | null = null; private downloadFilename: string | null = null; private config: HypedditConfig; @@ -45,7 +49,7 @@ export class PumpyoursoundDownloader { } async initialize() { - this.browser = await launchAppBrowser({ + this.browser = await this.browserLaunch.launch({ ...browserModeToLaunchOptions(this.config.browserMode), userDataDir: this.config.userDataDir ?? './browser-data', // SC.connect authorize popup shares session cookies more reliably with this. @@ -142,7 +146,7 @@ export class PumpyoursoundDownloader { async close() { await this.directDownloader?.close(); - await this.browser?.close(); + await this.browserLaunch.close(); } private async dismissOverlays(page: Page) { diff --git a/src/stillhype.ts b/src/stillhype.ts index 3331401..a167669 100644 --- a/src/stillhype.ts +++ b/src/stillhype.ts @@ -2,7 +2,7 @@ import { mkdir, rm } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Presets, SingleBar } from 'cli-progress'; import type { Browser, HTTPResponse, Page } from 'puppeteer'; -import { launchConfiguredBrowser } from './browserLaunch'; +import { CancellableBrowserLaunch } from './browserLaunch'; import type { ProgressCallback } from './hypeddit'; import { safeFetch } from './safeOutboundUrl'; import Selectors from './selectors'; @@ -49,6 +49,7 @@ function safePageUrl(page: Page): string { export class StillhypeDownloader { private browser!: Browser; + private readonly browserLaunch = new CancellableBrowserLaunch(); private downloadFilename: string | null = null; private config: HypedditConfig; private progressCallback: ProgressCallback | null = null; @@ -73,7 +74,7 @@ export class StillhypeDownloader { } async initialize() { - this.browser = await launchConfiguredBrowser(this.config); + this.browser = await this.browserLaunch.launchConfigured(this.config); const browserContext = this.browser.defaultBrowserContext(); const soundCloudCookies = await loadCookies('soundcloud-cookies.json'); @@ -290,7 +291,7 @@ export class StillhypeDownloader { this.cancelPendingDownloadWait?.(); this.cancelPendingDownloadWait = null; this.downloadAbortController.abort(); - await this.browser?.close(); + await this.browserLaunch.close(); } private async safeEvaluate( diff --git a/userscript/sc-gate-dl.test.ts b/userscript/sc-gate-dl.test.ts index 65ecb6a..7d2c0a0 100644 --- a/userscript/sc-gate-dl.test.ts +++ b/userscript/sc-gate-dl.test.ts @@ -186,4 +186,19 @@ describe('Web UI preferences', () => { "window.addEventListener('pointerup', releaseRemotePointer, true)", ); }); + + test('cancels active jobs via GM bridge on panel close and host unload', () => { + expect(source).toContain('function requestJobCancel(jobId)'); + expect(source).toContain('gmXmlHttpRequest({'); + expect(source).toContain("method: 'POST'"); + expect(source).toContain( + "window.addEventListener('pagehide', cancelJobOnHostUnload)", + ); + expect(source).toContain('await cancellation'); + expect(source).toContain("iframe.src = 'about:blank'"); + const cancelIdx = source.indexOf('await cancellation'); + const blankIdx = source.indexOf("iframe.src = 'about:blank'"); + expect(cancelIdx).toBeGreaterThan(-1); + expect(blankIdx).toBeGreaterThan(cancelIdx); + }); }); diff --git a/userscript/sc-gate-dl.user.js b/userscript/sc-gate-dl.user.js index 3beffae..3602637 100644 --- a/userscript/sc-gate-dl.user.js +++ b/userscript/sc-gate-dl.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name sc-gate-dl // @namespace https://github.com/D3SOX/sc-gate-dl -// @version 1.11.3 +// @version 1.11.4 // @description Add sc-gate-dl download controls and remember your position in the SoundCloud feed // @author D3SOX // @match https://soundcloud.com/* @@ -1232,6 +1232,25 @@ }); } + /** Cancel via GM bridge — page fetch to localhost is blocked by PNA. */ + function requestJobCancel(jobId) { + const url = `${getApiBase()}/api/job/${encodeURIComponent(jobId)}/cancel`; + if (gmXmlHttpRequest) { + // Dispatch immediately; do not wait for teardown (browser close can take seconds). + gmXmlHttpRequest({ + method: 'POST', + url, + anonymous: true, + onload: () => {}, + onerror: () => {}, + ontimeout: () => {}, + onabort: () => {}, + }); + return Promise.resolve(); + } + return fetch(url, { method: 'POST', keepalive: true }).catch(() => {}); + } + async function isWebuiReachable(base) { const urls = [`${base}/`, `${apiOriginFromWebui(base)}/api/capabilities`]; const seen = new Set(); @@ -2337,30 +2356,35 @@ a[${STORE_SERVICE_ATTR}] > button::after { // ignore } } - try { - await fetch( - `${getApiBase()}/api/job/${encodeURIComponent(jobId)}/cancel`, - { - method: 'POST', - }, - ); - } catch { - // ignore — panel still closes - } + await requestJobCancel(jobId); delete panel.dataset.jobId; } + function cancelJobOnHostUnload() { + const panel = document.getElementById(PANEL_ID); + const jobId = panel?.dataset.jobId; + if (!jobId) return; + void requestJobCancel(jobId); + delete panel.dataset.jobId; + } + + window.addEventListener('pagehide', cancelJobOnHostUnload); + window.addEventListener('beforeunload', cancelJobOnHostUnload); + async function closePanel() { window.clearTimeout(autoCloseTimer); const panel = document.getElementById(PANEL_ID); if (!panel) return; + // Cancel via the GM bridge before tearing down the iframe — page fetch to + // localhost is blocked by Private Network Access, and blanking the iframe + // races the embedded WebUI's own unload cancel. const cancellation = cancelActiveJob(panel); + await cancellation; const iframe = panel.querySelector('iframe'); if (iframe) iframe.src = 'about:blank'; delete panel.dataset.trackUrl; delete panel.dataset.jobId; panel.hidden = true; - await cancellation; } function applyQueueGeom(el) { diff --git a/webui/src/components/App.tsx b/webui/src/components/App.tsx index 688d4bc..d663c9b 100644 --- a/webui/src/components/App.tsx +++ b/webui/src/components/App.tsx @@ -455,15 +455,16 @@ export default function App() { if (!jobId || !stage || !ACTIVE_JOB_STAGES.has(stage)) return; cancelRequestedRef.current = true; const url = `${API_BASE}/api/job/${encodeURIComponent(jobId)}/cancel`; - try { - if (navigator.sendBeacon(url)) return; - } catch { - // Fall back to a keepalive request below. - } + // Prefer keepalive fetch: sendBeacon is easy to drop for cross-origin + // POSTs, and pagehide is more reliable than beforeunload for iframes/tabs. void fetch(url, { method: 'POST', keepalive: true }).catch(() => {}); }; + window.addEventListener('pagehide', cancelJobOnUnload); window.addEventListener('beforeunload', cancelJobOnUnload); - return () => window.removeEventListener('beforeunload', cancelJobOnUnload); + return () => { + window.removeEventListener('pagehide', cancelJobOnUnload); + window.removeEventListener('beforeunload', cancelJobOnUnload); + }; }, []); const showCleanupSoundcloudToast = useCallback(() => { From a409ef7c821ee148c44715ff8d16bc81a1d335bb Mon Sep 17 00:00:00 2001 From: D3SOX Date: Wed, 12 Aug 2026 17:00:06 +0200 Subject: [PATCH 2/2] fix: harden userscript cancel against GM bridge failures Keep panel teardown moving if the cancel bridge throws, and assert both unload listeners in the regression test. --- userscript/sc-gate-dl.test.ts | 3 +++ userscript/sc-gate-dl.user.js | 26 +++++++++++++++----------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/userscript/sc-gate-dl.test.ts b/userscript/sc-gate-dl.test.ts index 7d2c0a0..f763b45 100644 --- a/userscript/sc-gate-dl.test.ts +++ b/userscript/sc-gate-dl.test.ts @@ -194,6 +194,9 @@ describe('Web UI preferences', () => { expect(source).toContain( "window.addEventListener('pagehide', cancelJobOnHostUnload)", ); + expect(source).toContain( + "window.addEventListener('beforeunload', cancelJobOnHostUnload)", + ); expect(source).toContain('await cancellation'); expect(source).toContain("iframe.src = 'about:blank'"); const cancelIdx = source.indexOf('await cancellation'); diff --git a/userscript/sc-gate-dl.user.js b/userscript/sc-gate-dl.user.js index 3602637..23977ff 100644 --- a/userscript/sc-gate-dl.user.js +++ b/userscript/sc-gate-dl.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name sc-gate-dl // @namespace https://github.com/D3SOX/sc-gate-dl -// @version 1.11.4 +// @version 1.11.5 // @description Add sc-gate-dl download controls and remember your position in the SoundCloud feed // @author D3SOX // @match https://soundcloud.com/* @@ -1236,16 +1236,20 @@ function requestJobCancel(jobId) { const url = `${getApiBase()}/api/job/${encodeURIComponent(jobId)}/cancel`; if (gmXmlHttpRequest) { - // Dispatch immediately; do not wait for teardown (browser close can take seconds). - gmXmlHttpRequest({ - method: 'POST', - url, - anonymous: true, - onload: () => {}, - onerror: () => {}, - ontimeout: () => {}, - onabort: () => {}, - }); + try { + // Dispatch immediately; do not wait for teardown (browser close can take seconds). + gmXmlHttpRequest({ + method: 'POST', + url, + anonymous: true, + onload: () => {}, + onerror: () => {}, + ontimeout: () => {}, + onabort: () => {}, + }); + } catch { + // Bridge may throw synchronously; panel cleanup must still continue. + } return Promise.resolve(); } return fetch(url, { method: 'POST', keepalive: true }).catch(() => {});