fix(windows): restore taskbar identity and shared downloads

This commit is contained in:
Sucukdeluxe
2026-08-12 03:10:23 +02:00
parent be5d60a0ca
commit a472f5dac9
12 changed files with 420 additions and 47 deletions
+90 -2
View File
@@ -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');
});
});
+53
View File
@@ -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);
});
});
+36 -1
View File
@@ -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');
});
});
+120 -26
View File
@@ -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();
}
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);
}