fix(windows): restore taskbar identity and shared downloads
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
+17
-6
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
+37
-8
@@ -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<string, ActiveDownloadTracking>();
|
||||
const cancelledItemIds = new Set<string>();
|
||||
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<void> {
|
||||
// ==========================================
|
||||
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<Config>, 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);
|
||||
|
||||
|
||||
@@ -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<string, string> };
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,53 +6,147 @@ export interface TokenBucketClock {
|
||||
clearTimeout(handle: ReturnType<typeof setTimeout>): 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<typeof setTimeout> | 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();
|
||||
}
|
||||
|
||||
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;
|
||||
if (this.destroyed) return;
|
||||
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;
|
||||
if (this.availableBytes >= output.length) {
|
||||
this.availableBytes -= output.length;
|
||||
this.push(output);
|
||||
callback();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const delayMs = Math.max(1, Math.ceil(((output.length - this.availableBytes) * 1000) / this.maxBytesPerSecond));
|
||||
this.timer = this.clock.setTimeout(release, delayMs);
|
||||
};
|
||||
release();
|
||||
} 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);
|
||||
this.cancelReservation = this.budget.reserve(output.length, () => {
|
||||
this.cancelReservation = null;
|
||||
if (this.destroyed) return;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
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<string, unknown>) {
|
||||
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<void>);
|
||||
|
||||
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';
|
||||
|
||||
@@ -859,6 +859,8 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): 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'
|
||||
|
||||
Reference in New Issue
Block a user