From a472f5dac9ba8be750f1b3ef00fc606455a650a1 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:10:23 +0200 Subject: [PATCH] fix(windows): restore taskbar identity and shared downloads --- package.json | 2 +- scripts/dev.mjs | 23 ++- scripts/public-release-files.json | 1 + scripts/smoke-test-public-release-config.js | 7 +- src/main.ts | 45 +++++- src/main/domain/app-identity.test.ts | 92 ++++++++++- src/main/domain/app-identity.ts | 53 +++++++ .../download-policy-integration.test.ts | 10 +- .../domain/token-bucket-transform.test.ts | 37 ++++- src/main/domain/token-bucket-transform.ts | 146 ++++++++++++++---- src/renderer-settings-autosave.test.ts | 49 +++++- src/renderer-settings.ts | 2 + 12 files changed, 420 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 7b6e397..82a35e2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build": "tsc", "lint": "eslint .", "security:check": "node scripts/security-check.js && node scripts/smoke-test-public-release-config.js", - "start": "npm run build && electron .", + "start": "node scripts/dev.mjs --once", "dev": "node scripts/dev.mjs", "test:unit": "vitest run --passWithNoTests", "test:unit:watch": "vitest", diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 3b414cf..8a82b1d 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -4,7 +4,8 @@ import { pathToFileURL } from 'node:url'; import { watch } from 'node:fs'; import { dirname, resolve } from 'node:path'; -const rootDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const scriptPath = fileURLToPath(import.meta.url); +const rootDirectory = resolve(dirname(scriptPath), '..'); const typescriptCli = resolve(rootDirectory, 'node_modules', 'typescript', 'bin', 'tsc'); const electronSourceExecutable = process.platform === 'win32' ? resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'electron.exe') @@ -13,10 +14,14 @@ let electronExecutable = electronSourceExecutable; const outputDirectory = resolve(rootDirectory, 'dist'); const developmentProgramData = resolve(rootDirectory, '.dev-program-data'); const developmentUserData = resolve(rootDirectory, '.dev-user-data'); +const developmentRelaunchCommand = `"${process.execPath}" "${scriptPath}" --once`; +const runOnce = process.argv.includes('--once'); let electronProcess; let restarting = false; let restartTimer; +let compiler; +let outputWatcher; function run(command, args, options = {}) { return spawn(command, args, { cwd: rootDirectory, stdio: 'inherit', ...options }); @@ -35,11 +40,13 @@ function startElectron() { ...process.env, PROGRAMDATA: developmentProgramData, TWITCH_VOD_MANAGER_DEV: '1', + TWITCH_VOD_MANAGER_RELAUNCH_COMMAND: developmentRelaunchCommand, }, }); electronProcess.once('exit', () => { electronProcess = undefined; }); + return electronProcess; } function restartElectron() { @@ -90,16 +97,20 @@ if (process.platform === 'win32') { }); } -const compiler = run(process.execPath, [typescriptCli, '--watch', '--preserveWatchOutput']); -const outputWatcher = watch(outputDirectory, { recursive: true }, (_, fileName) => scheduleRestart(fileName)); -startElectron(); - for (const signal of ['SIGINT', 'SIGTERM']) { process.once(signal, () => { - outputWatcher.close(); + outputWatcher?.close(); clearTimeout(restartTimer); stop(compiler); stop(electronProcess); process.exit(); }); } + +if (runOnce) { + process.exitCode = await waitForExit(startElectron()); +} else { + compiler = run(process.execPath, [typescriptCli, '--watch', '--preserveWatchOutput']); + outputWatcher = watch(outputDirectory, { recursive: true }, (_, fileName) => scheduleRestart(fileName)); + startElectron(); +} diff --git a/scripts/public-release-files.json b/scripts/public-release-files.json index c35e54b..660e753 100644 --- a/scripts/public-release-files.json +++ b/scripts/public-release-files.json @@ -131,6 +131,7 @@ "src/renderer-archive.ts", "src/renderer-command-palette.ts", "src/renderer-cutter.ts", + "src/renderer-cutter.production-path.test.ts", "src/renderer-globals.d.ts", "src/renderer-locale-de.ts", "src/renderer-locale-en.ts", diff --git a/scripts/smoke-test-public-release-config.js b/scripts/smoke-test-public-release-config.js index c7a2133..434a733 100644 --- a/scripts/smoke-test-public-release-config.js +++ b/scripts/smoke-test-public-release-config.js @@ -38,7 +38,12 @@ check(fs.existsSync(path.join(root, 'build', 'icon.png')), 'application PNG icon check(fs.existsSync(path.join(root, 'build', 'icon.ico')), 'application ICO icon is missing'); check(mainSource.includes('app.setAppUserModelId(WINDOWS_APP_IDENTITY.appUserModelId)'), 'Windows AppUserModelID is not applied from the centralized identity'); check(mainSource.includes('app.setName(WINDOWS_APP_IDENTITY.name)'), 'Windows application name is not applied before startup'); -check(mainSource.includes("icon: path.join(__dirname, process.platform === 'win32' ? '../build/icon.ico' : '../build/icon.png')"), 'BrowserWindow does not use the platform application icon'); +const windowCreationIndex = mainSource.indexOf('mainWindow = new BrowserWindow'); +const taskbarDetailsIndex = mainSource.indexOf('mainWindow.setAppDetails', windowCreationIndex); +const windowShowIndex = mainSource.indexOf('mainWindow.show()', windowCreationIndex); +check(mainSource.includes('resolveWindowsAppIconPath') && mainSource.includes('createWindowsTaskbarDetails'), 'BrowserWindow does not use centralized Windows taskbar identity'); +check(mainSource.slice(windowCreationIndex, taskbarDetailsIndex).includes('show: false'), 'BrowserWindow is visible before Windows taskbar identity is applied'); +check(taskbarDetailsIndex > windowCreationIndex && windowShowIndex > taskbarDetailsIndex, 'Windows taskbar identity is not applied before the window is shown'); check(indexSource.includes('class="topbar-brand-mark" src="../build/icon.png"'), 'topbar does not use the application icon'); check(mainSource.includes('GITHUB_RELEASES_API_LATEST_URL'), 'GitHub releases API constant is missing'); check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases download constant is missing'); diff --git a/src/main.ts b/src/main.ts index e0f990a..68167aa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import * as fs from 'fs'; import { spawn, ChildProcess, execSync, spawnSync } from 'child_process'; import { connect as tlsConnect, TLSSocket } from 'node:tls'; import { pathToFileURL } from 'node:url'; +import type { Transform } from 'node:stream'; import axios from 'axios'; import { autoUpdater } from 'electron-updater'; import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils'; @@ -19,7 +20,7 @@ import { import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend'; import { watchRendererChanges } from './main/dev-reload'; import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output'; -import { createTokenBucketTransform } from './main/domain/token-bucket-transform'; +import { createTokenBucketBudget, createTokenBucketTransform } from './main/domain/token-bucket-transform'; import { decideDownloadStart, normalizeDownloadPolicy, type DownloadPolicy } from './main/domain/download-policy'; import { PartialDownloadRegistry } from './main/domain/partial-download'; import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './main/queue/process-registry'; @@ -40,7 +41,7 @@ import { } from './main/domain/config-normalize'; import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types'; import { buildVodPreviewFrameUrls } from './main/domain/vod-preview'; -import { getWindowsAppIdentity } from './main/domain/app-identity'; +import { createWindowsTaskbarDetails, getWindowsAppIdentity, resolveWindowsAppIconPath } from './main/domain/app-identity'; import { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange, type EditorCut } from './main/domain/video-editor'; import { calculateCutterExportProgress, @@ -87,9 +88,18 @@ import { // CONFIG & CONSTANTS // ========================================== const APP_VERSION = app.getVersion(); -const WINDOWS_APP_IDENTITY = getWindowsAppIdentity(process.env.TWITCH_VOD_MANAGER_DEV === '1'); +const IS_HOT_DEVELOPMENT = process.env.TWITCH_VOD_MANAGER_DEV === '1'; +const WINDOWS_APP_IDENTITY = getWindowsAppIdentity(IS_HOT_DEVELOPMENT); app.setName(WINDOWS_APP_IDENTITY.name); app.setAppUserModelId(WINDOWS_APP_IDENTITY.appUserModelId); +const WINDOWS_APP_ICON_PATH = process.platform === 'win32' + ? resolveWindowsAppIconPath({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + version: APP_VERSION, + }) + : null; const GITHUB_REPO_OWNER = 'Sucukdeluxe'; const GITHUB_REPO_NAME = 'Twitch-VOD-Manager'; const GITHUB_RELEASES_API_LATEST_URL = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'; @@ -419,6 +429,12 @@ function getStreamlinkStreamArg(): string { return `${choice},best`; } +function createDownloadThrottleTransform(): Transform | undefined { + const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond ?? null; + downloadThrottleBudget.setMaxBytesPerSecond(maxBytesPerSecond); + return maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond, undefined, downloadThrottleBudget) : undefined; +} + function normalizeConfigTemplates(input: Config): Config { // downloaded_vod_ids is bounded so a long-running app doesn't accumulate // an unbounded list across years of downloads. Latest entries kept. @@ -810,6 +826,7 @@ const activeDownloads = new Map(); const cancelledItemIds = new Set(); const queueProcessRegistry = new QueueProcessRegistry(); const queueRunLifecycle = new QueueRunLifecycle(queueProcessRegistry); +const downloadThrottleBudget = createTokenBucketBudget(null); let downloadPolicyWakeTimer: NodeJS.Timeout | null = null; let lastDownloadPolicyStatusFingerprint = ''; @@ -4049,11 +4066,10 @@ function downloadVODPart( resolve({ success: false, error: tBackend('unknownDownloadError') }); return; } - const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond; const output = createPausableOutput( proc.stdout, outputStream, - maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond) : undefined, + createDownloadThrottleTransform(), ); const outputFinished = output.finished.then(() => null, (error) => error); const processRegistration = queueProcessRegistry.register(itemId, 'streamlink', { @@ -7067,15 +7083,17 @@ async function processQueue(manualOverride = false): Promise { // ========================================== function createWindow(): void { nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark'; + const windowIconPath = WINDOWS_APP_ICON_PATH ?? path.join(__dirname, '../build/icon.png'); mainWindow = new BrowserWindow({ + show: false, width: 1400, height: 900, minWidth: 1200, minHeight: 700, title: `Twitch VOD Manager [v${APP_VERSION}]`, backgroundColor: '#0e0e10', - icon: path.join(__dirname, process.platform === 'win32' ? '../build/icon.ico' : '../build/icon.png'), + icon: windowIconPath, autoHideMenuBar: true, webPreferences: { nodeIntegration: false, @@ -7084,6 +7102,17 @@ function createWindow(): void { } }); + if (process.platform === 'win32') { + mainWindow.setAppDetails(createWindowsTaskbarDetails({ + identity: WINDOWS_APP_IDENTITY, + iconPath: windowIconPath, + executablePath: process.execPath, + developmentRelaunchCommand: process.env.TWITCH_VOD_MANAGER_RELAUNCH_COMMAND, + isDevelopment: IS_HOT_DEVELOPMENT, + })); + } + mainWindow.show(); + if (process.platform !== 'darwin') { mainWindow.removeMenu(); } @@ -7525,6 +7554,7 @@ ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability } const nextConfig = normalizeConfigTemplates({ ...config, ...acceptedConfig }); config = persistStateChange(config, () => nextConfig, saveConfig); + downloadThrottleBudget.setMaxBytesPerSecond(config.download_policy.throttle?.maxBytesPerSecond ?? null); if (JSON.stringify(config.download_policy) !== previousDownloadPolicy && !isDownloading && downloadQueue.some((item) => item.status === 'pending')) { scheduleQueueProcessing(); } else { @@ -8175,11 +8205,10 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () = resolve({ success: false, error: tBackend('unknownDownloadError') }); return; } - const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond; const output = createPausableOutput( proc.stdout, fs.createWriteStream(partialFilename, { flags: 'w' }), - maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond) : undefined, + createDownloadThrottleTransform(), ); const outputFinished = output.finished.then(() => null, (error) => error); diff --git a/src/main/domain/app-identity.test.ts b/src/main/domain/app-identity.test.ts index 2b06ea3..a019a41 100644 --- a/src/main/domain/app-identity.test.ts +++ b/src/main/domain/app-identity.test.ts @@ -1,5 +1,18 @@ -import { describe, expect, test } from 'vitest'; -import { getWindowsAppIdentity } from './app-identity'; +import { afterEach, describe, expect, test } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + createWindowsTaskbarDetails, + getWindowsAppIdentity, + resolveWindowsAppIconPath, +} from './app-identity'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true }); +}); describe('getWindowsAppIdentity', () => { test('trennt Hot-Dev von der veröffentlichten Windows-Identität', () => { @@ -13,3 +26,78 @@ describe('getWindowsAppIdentity', () => { }); }); }); + +describe('Windows taskbar identity', () => { + test('resolves an existing repository icon for development and the versioned resource for packaged builds', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-app-icon-')); + temporaryDirectories.push(root); + const appPath = path.join(root, 'app'); + const resourcesPath = path.join(root, 'resources'); + const developmentIcon = path.join(appPath, 'build', 'icon.ico'); + const packagedIcon = path.join(resourcesPath, 'app-icons', 'icon-1.0.5.ico'); + fs.mkdirSync(path.dirname(developmentIcon), { recursive: true }); + fs.mkdirSync(path.dirname(packagedIcon), { recursive: true }); + fs.writeFileSync(developmentIcon, 'development-icon'); + fs.writeFileSync(packagedIcon, 'packaged-icon'); + + expect(resolveWindowsAppIconPath({ isPackaged: false, appPath, resourcesPath, version: '1.0.5' })).toBe(developmentIcon); + expect(resolveWindowsAppIconPath({ isPackaged: true, appPath, resourcesPath, version: '1.0.5' })).toBe(packagedIcon); + }); + + test('rejects startup when the selected Windows icon resource is missing', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-app-icon-missing-')); + temporaryDirectories.push(root); + + expect(() => resolveWindowsAppIconPath({ + isPackaged: true, + appPath: path.join(root, 'app'), + resourcesPath: path.join(root, 'resources'), + version: '1.0.5', + })).toThrow('Windows application icon is missing'); + }); + + test('provides explicit taskbar relaunch properties for development and packaged windows', () => { + const developmentIdentity = getWindowsAppIdentity(true); + const packagedIdentity = getWindowsAppIdentity(false); + const developmentCommand = '"C:\\Program Files\\nodejs\\node.exe" "C:\\repo\\scripts\\dev.mjs" --once'; + + expect(createWindowsTaskbarDetails({ + identity: developmentIdentity, + iconPath: 'C:\\repo\\build\\icon.ico', + executablePath: 'C:\\repo\\Twitch VOD Manager.exe', + developmentRelaunchCommand: developmentCommand, + isDevelopment: true, + })).toEqual({ + appId: developmentIdentity.appUserModelId, + appIconPath: 'C:\\repo\\build\\icon.ico', + appIconIndex: 0, + relaunchCommand: developmentCommand, + relaunchDisplayName: developmentIdentity.name, + }); + + expect(createWindowsTaskbarDetails({ + identity: packagedIdentity, + iconPath: 'C:\\Program Files\\Twitch VOD Manager\\resources\\app-icons\\icon-1.0.5.ico', + executablePath: 'C:\\Program Files\\Twitch VOD Manager\\Twitch VOD Manager.exe', + isDevelopment: false, + }).relaunchCommand).toBe('"C:\\Program Files\\Twitch VOD Manager\\Twitch VOD Manager.exe"'); + }); + + test('wires taskbar properties before the initially hidden window is shown and routes start through the branded launcher', () => { + const root = path.resolve(__dirname, '..', '..', '..'); + const mainSource = fs.readFileSync(path.join(root, 'src', 'main.ts'), 'utf8'); + const devSource = fs.readFileSync(path.join(root, 'scripts', 'dev.mjs'), 'utf8'); + const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as { scripts?: Record }; + const windowCreation = mainSource.indexOf('mainWindow = new BrowserWindow'); + const appDetails = mainSource.indexOf('mainWindow.setAppDetails', windowCreation); + const windowShow = mainSource.indexOf('mainWindow.show()', windowCreation); + + expect(mainSource.slice(windowCreation, appDetails)).toContain('show: false'); + expect(appDetails).toBeGreaterThan(windowCreation); + expect(windowShow).toBeGreaterThan(appDetails); + expect(mainSource).toContain('resolveWindowsAppIconPath'); + expect(mainSource).toContain('createWindowsTaskbarDetails'); + expect(devSource).toContain('TWITCH_VOD_MANAGER_RELAUNCH_COMMAND'); + expect(packageJson.scripts?.start).toBe('node scripts/dev.mjs --once'); + }); +}); diff --git a/src/main/domain/app-identity.ts b/src/main/domain/app-identity.ts index d1d82bc..841532c 100644 --- a/src/main/domain/app-identity.ts +++ b/src/main/domain/app-identity.ts @@ -1,8 +1,34 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + export interface WindowsAppIdentity { name: string; appUserModelId: string; } +export interface WindowsAppIconPathOptions { + isPackaged: boolean; + appPath: string; + resourcesPath: string; + version: string; +} + +export interface WindowsTaskbarDetailsOptions { + identity: WindowsAppIdentity; + iconPath: string; + executablePath: string; + developmentRelaunchCommand?: string; + isDevelopment: boolean; +} + +export interface WindowsTaskbarDetails { + appId: string; + appIconPath: string; + appIconIndex: number; + relaunchCommand: string; + relaunchDisplayName: string; +} + export function getWindowsAppIdentity(isDevelopment: boolean): WindowsAppIdentity { return { name: 'Twitch VOD Manager', @@ -11,3 +37,30 @@ export function getWindowsAppIdentity(isDevelopment: boolean): WindowsAppIdentit : 'io.github.sucukdeluxe.twitch-vod-manager' }; } + +export function resolveWindowsAppIconPath(options: WindowsAppIconPathOptions): string { + const iconPath = options.isPackaged + ? path.join(options.resourcesPath, 'app-icons', `icon-${options.version}.ico`) + : path.join(options.appPath, 'build', 'icon.ico'); + if (!fs.existsSync(iconPath) || !fs.statSync(iconPath).isFile()) { + throw new Error(`Windows application icon is missing: ${iconPath}`); + } + return iconPath; +} + +function quoteWindowsCommandPath(value: string): string { + if (!value || /["\r\n]/.test(value)) throw new Error('Invalid Windows command path'); + return `"${value}"`; +} + +export function createWindowsTaskbarDetails(options: WindowsTaskbarDetailsOptions): WindowsTaskbarDetails { + const developmentCommand = options.developmentRelaunchCommand?.trim(); + if (options.isDevelopment && !developmentCommand) throw new Error('Development relaunch command is missing'); + return { + appId: options.identity.appUserModelId, + appIconPath: options.iconPath, + appIconIndex: 0, + relaunchCommand: options.isDevelopment ? developmentCommand! : quoteWindowsCommandPath(options.executablePath), + relaunchDisplayName: options.identity.name, + }; +} diff --git a/src/main/domain/download-policy-integration.test.ts b/src/main/domain/download-policy-integration.test.ts index 1c4300d..eb11ef7 100644 --- a/src/main/domain/download-policy-integration.test.ts +++ b/src/main/domain/download-policy-integration.test.ts @@ -52,8 +52,16 @@ describe('download policy integration contract', () => { const end = source.indexOf('const outputFinished = output.finished', start); const section = source.slice(start, end); - expect(section).toContain('createTokenBucketTransform'); + expect(section).toContain('createDownloadThrottleTransform()'); expect(section).toContain("const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '--stdout'];"); expect(section).not.toMatch(/args\.push\([^\n]*(?:bandwidth|rate-limit|max-rate|throttle)/i); }); + + it('routes queue and clip stdout through one app-wide token bucket budget', () => { + const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); + + expect(source).toContain('const downloadThrottleBudget = createTokenBucketBudget(null);'); + expect(source).toContain('function createDownloadThrottleTransform(): Transform | undefined'); + expect(source.match(/createDownloadThrottleTransform\(\)/g)).toHaveLength(3); + }); }); diff --git a/src/main/domain/token-bucket-transform.test.ts b/src/main/domain/token-bucket-transform.test.ts index 38d3430..b350087 100644 --- a/src/main/domain/token-bucket-transform.test.ts +++ b/src/main/domain/token-bucket-transform.test.ts @@ -1,6 +1,6 @@ import { PassThrough } from 'node:stream'; import { describe, expect, it } from 'vitest'; -import { createTokenBucketTransform, type TokenBucketClock } from './token-bucket-transform'; +import { createTokenBucketBudget, createTokenBucketTransform, type TokenBucketClock } from './token-bucket-transform'; class ManualClock implements TokenBucketClock { private nextTimerId = 0; @@ -75,4 +75,39 @@ describe('app-side token bucket transform', () => { expect(clock.timerCount).toBe(0); expect(Buffer.concat(output).toString()).toBe('a'); }); + + it('shares one byte budget across concurrent output transforms', () => { + const clock = new ManualClock(); + const budget = createTokenBucketBudget(2, clock); + const first = createTokenBucketTransform(2, clock, budget); + const second = createTokenBucketTransform(2, clock, budget); + const firstOutput: Buffer[] = []; + const secondOutput: Buffer[] = []; + first.on('data', (chunk: Buffer) => firstOutput.push(Buffer.from(chunk))); + second.on('data', (chunk: Buffer) => secondOutput.push(Buffer.from(chunk))); + + first.write(Buffer.from('ab')); + second.write(Buffer.from('cd')); + + expect(Buffer.concat(firstOutput).toString()).toBe('ab'); + expect(Buffer.concat(secondOutput).toString()).toBe(''); + expect(clock.timerCount).toBe(1); + + clock.advance(1_000); + + expect(Buffer.concat(secondOutput).toString()).toBe('cd'); + }); + + it('seeds an app-wide budget when throttling is enabled after startup', () => { + const clock = new ManualClock(); + const budget = createTokenBucketBudget(null, clock); + budget.setMaxBytesPerSecond(2); + const transform = createTokenBucketTransform(2, clock, budget); + const output: Buffer[] = []; + transform.on('data', (chunk: Buffer) => output.push(Buffer.from(chunk))); + + transform.write(Buffer.from('ab')); + + expect(Buffer.concat(output).toString()).toBe('ab'); + }); }); diff --git a/src/main/domain/token-bucket-transform.ts b/src/main/domain/token-bucket-transform.ts index 201687f..3cb73c5 100644 --- a/src/main/domain/token-bucket-transform.ts +++ b/src/main/domain/token-bucket-transform.ts @@ -6,53 +6,147 @@ export interface TokenBucketClock { clearTimeout(handle: ReturnType): void; } +export interface TokenBucketBudget { + reserve(bytes: number, release: () => void): () => void; + setMaxBytesPerSecond(maxBytesPerSecond: number | null): void; +} + +interface TokenBucketReservation { + bytes: number; + release: () => void; + cancelled: boolean; +} + const systemClock: TokenBucketClock = { now: () => Date.now(), setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), clearTimeout: (handle) => clearTimeout(handle), }; -class TokenBucketTransform extends Transform { +function assertRate(maxBytesPerSecond: number): void { + if (!Number.isSafeInteger(maxBytesPerSecond) || maxBytesPerSecond <= 0) throw new RangeError('maxBytesPerSecond must be a positive safe integer'); +} + +class SharedTokenBucketBudget implements TokenBucketBudget { private availableBytes: number; private lastRefillAt: number; private timer: ReturnType | null = null; + private draining = false; + private readonly reservations: TokenBucketReservation[] = []; - constructor(private readonly maxBytesPerSecond: number, private readonly clock: TokenBucketClock) { - super(); - this.availableBytes = maxBytesPerSecond; + constructor(private maxBytesPerSecond: number | null, private readonly clock: TokenBucketClock) { + if (maxBytesPerSecond !== null) assertRate(maxBytesPerSecond); + this.availableBytes = maxBytesPerSecond ?? 0; this.lastRefillAt = clock.now(); } + reserve(bytes: number, release: () => void): () => void { + const reservation: TokenBucketReservation = { bytes, release, cancelled: false }; + this.reservations.push(reservation); + this.drain(); + return () => { + if (reservation.cancelled) return; + reservation.cancelled = true; + this.drain(); + }; + } + + setMaxBytesPerSecond(maxBytesPerSecond: number | null): void { + if (maxBytesPerSecond !== null) assertRate(maxBytesPerSecond); + if (this.maxBytesPerSecond === maxBytesPerSecond) return; + const wasUnlimited = this.maxBytesPerSecond === null; + this.maxBytesPerSecond = maxBytesPerSecond; + this.availableBytes = maxBytesPerSecond === null ? 0 : wasUnlimited ? maxBytesPerSecond : Math.min(this.availableBytes, maxBytesPerSecond); + this.lastRefillAt = this.clock.now(); + this.drain(); + } + + private refill(capacity: number): void { + if (this.maxBytesPerSecond === null) return; + const now = this.clock.now(); + const elapsed = Math.max(0, now - this.lastRefillAt); + this.availableBytes = Math.min(capacity, this.availableBytes + (elapsed * this.maxBytesPerSecond) / 1000); + this.lastRefillAt = now; + } + + private clearTimer(): void { + if (!this.timer) return; + this.clock.clearTimeout(this.timer); + this.timer = null; + } + + private removeCancelledReservations(): void { + while (this.reservations[0]?.cancelled) this.reservations.shift(); + } + + private drain(): void { + if (this.draining) return; + this.draining = true; + try { + this.clearTimer(); + while (true) { + this.removeCancelledReservations(); + const reservation = this.reservations[0]; + if (!reservation) return; + if (this.maxBytesPerSecond === null) { + this.reservations.shift(); + reservation.release(); + continue; + } + const capacity = Math.max(this.maxBytesPerSecond, reservation.bytes); + this.refill(capacity); + if (this.availableBytes >= reservation.bytes) { + this.availableBytes -= reservation.bytes; + this.reservations.shift(); + reservation.release(); + continue; + } + const delayMs = Math.max(1, Math.ceil(((reservation.bytes - this.availableBytes) * 1000) / this.maxBytesPerSecond)); + this.timer = this.clock.setTimeout(() => { + this.timer = null; + this.drain(); + }, delayMs); + return; + } + } finally { + this.draining = false; + } + } +} + +class TokenBucketTransform extends Transform { + private cancelReservation: (() => void) | null = null; + + constructor(private readonly budget: TokenBucketBudget) { + super(); + } + override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { const output = Buffer.from(chunk); - const capacity = Math.max(this.maxBytesPerSecond, output.length); - const release = (): void => { - this.timer = null; + this.cancelReservation = this.budget.reserve(output.length, () => { + this.cancelReservation = null; if (this.destroyed) return; - const now = this.clock.now(); - const elapsed = Math.max(0, now - this.lastRefillAt); - this.availableBytes = Math.min(capacity, this.availableBytes + (elapsed * this.maxBytesPerSecond) / 1000); - this.lastRefillAt = now; - if (this.availableBytes >= output.length) { - this.availableBytes -= output.length; - this.push(output); - callback(); - return; - } - const delayMs = Math.max(1, Math.ceil(((output.length - this.availableBytes) * 1000) / this.maxBytesPerSecond)); - this.timer = this.clock.setTimeout(release, delayMs); - }; - release(); + this.push(output); + callback(); + }); } override _destroy(error: Error | null, callback: (error: Error | null) => void): void { - if (this.timer) this.clock.clearTimeout(this.timer); - this.timer = null; + this.cancelReservation?.(); + this.cancelReservation = null; callback(error); } } -export function createTokenBucketTransform(maxBytesPerSecond: number, clock: TokenBucketClock = systemClock): Transform { - if (!Number.isSafeInteger(maxBytesPerSecond) || maxBytesPerSecond <= 0) throw new RangeError('maxBytesPerSecond must be a positive safe integer'); - return new TokenBucketTransform(maxBytesPerSecond, clock); +export function createTokenBucketBudget(maxBytesPerSecond: number | null, clock: TokenBucketClock = systemClock): TokenBucketBudget { + return new SharedTokenBucketBudget(maxBytesPerSecond, clock); +} + +export function createTokenBucketTransform( + maxBytesPerSecond: number, + clock: TokenBucketClock = systemClock, + budget: TokenBucketBudget = createTokenBucketBudget(maxBytesPerSecond, clock), +): Transform { + assertRate(maxBytesPerSecond); + return new TokenBucketTransform(budget); } diff --git a/src/renderer-settings-autosave.test.ts b/src/renderer-settings-autosave.test.ts index e287d8d..76e5abb 100644 --- a/src/renderer-settings-autosave.test.ts +++ b/src/renderer-settings-autosave.test.ts @@ -17,7 +17,8 @@ const inputIds = [ 'deletePartsAfterMergeToggle', 'discordWebhookUrl', 'discordNotifyLiveStartToggle', 'discordNotifyLiveEndToggle', 'discordNotifyVodCompleteToggle', 'discordNotifyVodAutoQueuedToggle', 'autoVodPollMinutes', 'autoVodMaxAgeHours', 'autoCleanupEnabledToggle', 'autoCleanupDays', 'autoCleanupTarget', 'autoCleanupAction', 'streamlinkQuality', - 'metadataCacheMinutes', 'vodFilenameTemplate', 'partsFilenameTemplate', 'defaultClipFilenameTemplate' + 'metadataCacheMinutes', 'vodFilenameTemplate', 'partsFilenameTemplate', 'defaultClipFilenameTemplate', + 'downloadThrottleMiBps', 'downloadWindows', 'downloadPolicyValidation' ]; function createInput(value = '', checked = false): Input { @@ -25,6 +26,52 @@ function createInput(value = '', checked = false): Input { } describe('renderer settings autosave orchestration', () => { + it('persists a pure download policy change through the real autosave fingerprint', async () => { + const inputs = new Map(inputIds.map((id) => [id, createInput()])); + inputs.get('downloadThrottleMiBps')!.value = '1'; + inputs.get('downloadWindows')!.value = '22:00-06:00'; + const saveConfigCalls: Array> = []; + const window = { + api: { + setClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), + clearClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), + setDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), + clearDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), + saveConfig(payload: Record) { + saveConfigCalls.push(payload); + return Promise.resolve(payload); + }, + }, + }; + const sandbox = { + window, + config: { download_policy: { throttle: { maxBytesPerSecond: 1_048_576 }, windows: [{ start: '22:00', end: '06:00' }] } }, + UI_TEXT: { status: {}, static: {}, streamers: {} }, + byId: (id: string) => inputs.get(id) ?? createInput(), + collectUnknownTemplatePlaceholders: () => [], + document: { hidden: false, querySelector: () => null, getElementById: () => null }, + setTimeout, + clearTimeout, + console, + }; + const context = vm.createContext(sandbox); + const source = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer-settings.ts'), 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, + }).outputText; + vm.runInContext(compiled, context); + + vm.runInContext('lastPersistedSettingsFingerprint = getSettingsFingerprint(collectAutoSavePayload())', context); + inputs.get('downloadThrottleMiBps')!.value = '1.5'; + await (vm.runInContext('flushSettingsAutoSave(false)', context) as Promise); + + expect(saveConfigCalls).toHaveLength(1); + expect(saveConfigCalls[0].download_policy).toEqual({ + throttle: { maxBytesPerSecond: 1_572_864 }, + windows: [{ start: '22:00', end: '06:00' }] + }); + }); + it('persists a newer secret after an earlier asynchronous save settles', async () => { const inputs = new Map(inputIds.map((id) => [id, createInput()])); inputs.get('clientSecret')!.value = 'A'; diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 0b303bc..c8162c3 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -859,6 +859,8 @@ function getSettingsFingerprint(payload: Partial): string { effective.auto_cleanup_action ?? 'archive', effective.streamlink_quality ?? 'best', effective.metadata_cache_minutes ?? 10, + effective.download_policy?.throttle?.maxBytesPerSecond ?? null, + effective.download_policy?.windows ?? [], effective.filename_template_vod ?? '{title}.mp4', effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4', effective.filename_template_clip ?? '{date}_{part}.mp4'