release: publish Twitch VOD Manager 1.0.3

Polish navigation, settings, streamer and queue workflows; add safe pause and partial-file lifecycle handling; apply the product identity across Windows surfaces; and replace the public README with a complete English product guide and isolated screenshot.
This commit is contained in:
Sucukdeluxe
2026-08-10 19:25:08 +02:00
parent f9e415da88
commit b3c2a8b9fd
48 changed files with 4035 additions and 826 deletions
+101
View File
@@ -0,0 +1,101 @@
const { _electron: electron } = require('playwright');
const fs = require('fs');
const path = require('path');
const {
createE2eEnvironment,
getElectronLaunchOptions,
verifyE2eIsolation,
installOfflineFixtures,
cleanupE2eEnvironment
} = require('./e2e-test-environment');
async function run() {
const environment = createE2eEnvironment('readme-screenshot', {
language: 'en',
theme: 'twitch',
sidebar_split_view: true
});
const outputPath = path.resolve('docs', 'images', 'twitch-vod-manager-overview.png');
let app;
try {
app = await electron.launch(getElectronLaunchOptions(environment));
const win = await app.firstWindow();
await verifyE2eIsolation(app, win, environment);
await installOfflineFixtures(app);
await app.evaluate(({ ipcMain }) => {
const image = (label, from, to) => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="640" height="360" fill="url(#g)"/><circle cx="520" cy="80" r="110" fill="rgba(255,255,255,.09)"/><circle cx="100" cy="320" r="170" fill="rgba(0,0,0,.12)"/><text x="34" y="300" fill="white" font-family="Segoe UI,Arial" font-size="34" font-weight="700">${label}</text></svg>`;
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
};
const vods = [
['Ranked highlights and community games', '5h13m20s', 155331, '#6441a5', '#1f7ae0'],
['Late night challenge run', '7h39m0s', 241394, '#172554', '#7c3aed'],
['Tournament watch party', '5h59m10s', 183557, '#0f766e', '#2563eb'],
['Creative stream and Q&A', '3h40m3s', 94821, '#7c2d12', '#db2777'],
['Weekend co-op session', '4h50m0s', 127644, '#1e3a8a', '#0891b2'],
['Best moments from the week', '2h54m23s', 119663, '#581c87', '#be123c']
].map(([title, duration, viewCount, from, to], index) => ({
id: `demo-vod-${index + 1}`,
title,
created_at: `2026-08-${String(9 - index).padStart(2, '0')}T18:00:00Z`,
duration,
thumbnail_url: image(`DEMO VOD ${index + 1}`, from, to),
url: `https://www.twitch.tv/videos/demo-${index + 1}`,
view_count: viewCount,
stream_id: `demo-stream-${index + 1}`
}));
ipcMain.removeHandler('get-user-id');
ipcMain.handle('get-user-id', async () => 'demo-user');
ipcMain.removeHandler('get-vods');
ipcMain.handle('get-vods', async () => vods);
ipcMain.removeHandler('get-streamer-profile');
ipcMain.handle('get-streamer-profile', async () => ({
login: 'demo_channel',
displayName: 'DemoChannel',
avatarUrl: image('TVM', '#7c3aed', '#0ea5e9'),
bannerUrl: '',
description: 'Example profile for the public product screenshot',
broadcasterType: 'partner',
followerCount: 1250000,
vodCount: vods.length,
lastStreamAt: '2026-08-09T18:00:00Z',
isLive: false,
currentTitle: null,
currentGame: null,
currentStreamPreviewUrl: '',
currentStreamViewers: null,
twitchUrl: 'https://www.twitch.tv/demo_channel',
fetchedAt: Date.now()
}));
ipcMain.removeHandler('get-streamer-display-names');
ipcMain.handle('get-streamer-display-names', async () => ({ demo_channel: 'DemoChannel' }));
});
await win.waitForFunction(() => typeof window.showTab === 'function');
await win.setViewportSize({ width: 1600, height: 900 });
await win.evaluate(async () => {
window.changeLanguage('en');
showTab('vods');
isConnected = true;
config.streamers = ['demo_channel'];
config.streamer_display_names = { demo_channel: 'DemoChannel' };
config.sidebar_split_view = true;
streamerVodCache.clear();
streamerProfileCache.clear();
await window.hydrateStreamerDisplayNames();
await selectStreamer('demo_channel');
});
await win.waitForTimeout(700);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
await win.screenshot({ path: outputPath, type: 'png' });
process.stdout.write(`${outputPath}\n`);
} finally {
if (app) await app.close();
cleanupE2eEnvironment(environment);
}
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+102
View File
@@ -0,0 +1,102 @@
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
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 typescriptCli = resolve(rootDirectory, 'node_modules', 'typescript', 'bin', 'tsc');
const electronSourceExecutable = process.platform === 'win32'
? resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'electron.exe')
: resolve(rootDirectory, 'node_modules', '.bin', 'electron');
let electronExecutable = electronSourceExecutable;
const outputDirectory = resolve(rootDirectory, 'dist');
const developmentProgramData = resolve(rootDirectory, '.dev-program-data');
const developmentUserData = resolve(rootDirectory, '.dev-user-data');
let electronProcess;
let restarting = false;
let restartTimer;
function run(command, args, options = {}) {
return spawn(command, args, { cwd: rootDirectory, stdio: 'inherit', ...options });
}
function waitForExit(child) {
return new Promise((resolveExit, reject) => {
child.once('error', reject);
child.once('exit', (code) => resolveExit(code ?? 1));
});
}
function startElectron() {
electronProcess = run(electronExecutable, [`--user-data-dir=${developmentUserData}`, '.'], {
env: {
...process.env,
PROGRAMDATA: developmentProgramData,
TWITCH_VOD_MANAGER_DEV: '1',
},
});
electronProcess.once('exit', () => {
electronProcess = undefined;
});
}
function restartElectron() {
if (restarting) return;
restarting = true;
if (electronProcess) {
electronProcess.once('exit', () => {
restarting = false;
startElectron();
});
electronProcess.kill();
return;
}
restarting = false;
startElectron();
}
function isElectronRestartTarget(fileName) {
const baseName = fileName.replaceAll('\\', '/').split('/').at(-1) ?? '';
return !/^renderer(?:[-.].+)?\.js$/.test(baseName);
}
function scheduleRestart(fileName) {
if (!fileName || !isElectronRestartTarget(fileName.toString())) return;
clearTimeout(restartTimer);
restartTimer = setTimeout(restartElectron, 200);
}
function stop(child) {
if (child && !child.killed) child.kill();
}
const initialCompile = run(process.execPath, [typescriptCli]);
const initialExitCode = await waitForExit(initialCompile);
if (initialExitCode !== 0) process.exit(initialExitCode);
if (process.platform === 'win32') {
const helperPath = pathToFileURL(resolve(outputDirectory, 'main', 'dev-executable.js')).href;
const { prepareWindowsDevExecutable } = await import(helperPath);
electronExecutable = await prepareWindowsDevExecutable({
sourcePath: electronSourceExecutable,
destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'),
iconPath: resolve(rootDirectory, 'build', 'icon.ico'),
version: '1.0.3',
});
}
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();
clearTimeout(restartTimer);
stop(compiler);
stop(electronProcess);
process.exit();
});
}
+17
View File
@@ -5,10 +5,15 @@
"LICENSE",
"README.md",
"build/installer.nsh",
"build/icon.ico",
"build/icon.png",
"docs/images/twitch-vod-manager-overview.png",
"eslint.config.mjs",
"package-lock.json",
"package.json",
"scripts/e2e-test-environment.js",
"scripts/capture-readme-screenshot.js",
"scripts/dev.mjs",
"scripts/public-release-files.json",
"scripts/smoke-test-e2e-isolation-contract.js",
"scripts/smoke-test-full.js",
@@ -26,10 +31,18 @@
"src/main/domain/chunk-index-store.ts",
"src/main/domain/config-normalize.test.ts",
"src/main/domain/config-normalize.ts",
"src/main/domain/app-identity.test.ts",
"src/main/domain/app-identity.ts",
"src/main/domain/i18n-backend.test.ts",
"src/main/domain/i18n-backend.ts",
"src/main/domain/partial-download.test.ts",
"src/main/domain/partial-download.ts",
"src/main/domain/pausable-output.test.ts",
"src/main/domain/pausable-output.ts",
"src/main/domain/integrity-check.test.ts",
"src/main/domain/integrity-check.ts",
"src/main/domain/vod-preview.test.ts",
"src/main/domain/vod-preview.ts",
"src/main/domain/migrator.test.ts",
"src/main/domain/migrator.ts",
"src/main/domain/pkce.test.ts",
@@ -59,6 +72,10 @@
"src/main/infra/secure-storage.test.ts",
"src/main/infra/secure-storage.ts",
"src/main.ts",
"src/main/dev-executable.test.ts",
"src/main/dev-executable.ts",
"src/main/dev-reload.test.ts",
"src/main/dev-reload.ts",
"src/preload.ts",
"src/renderer-archive.ts",
"src/renderer-command-palette.ts",
+6
View File
@@ -189,6 +189,9 @@ async function run() {
retry: (document.getElementById('btnRetryFailed')?.textContent || '').trim(),
enText: (document.getElementById('languageEnText')?.textContent || '').trim(),
enIcon: !!document.querySelector('#langOptionEn .flag-icon.flag-en'),
enIconTag: document.querySelector('#langOptionEn .flag-icon.flag-en')?.tagName || '',
enIconOpacity: getComputedStyle(document.querySelector('#langOptionEn .flag-icon.flag-en')).opacity,
enIconSize: document.querySelector('#langOptionEn .flag-icon.flag-en')?.getBoundingClientRect().toJSON() || null,
enActive: !!document.getElementById('langOptionEn')?.classList.contains('active')
};
@@ -197,6 +200,9 @@ async function run() {
assert(enState.nav.includes('Settings'), 'English language switch failed');
assert(deState.deIcon, 'German flag icon missing');
assert(enState.enIcon, 'English flag icon missing');
assert(enState.enIconTag === 'svg', 'English flag must use crisp vector geometry');
assert(enState.enIconOpacity === '1', 'English flag must render fully opaque');
assert(enState.enIconSize?.width === 18 && enState.enIconSize?.height === 12, 'English flag must render at 18x12 pixels');
assert(deState.deActive, 'German language button did not activate');
assert(enState.enActive, 'English language button did not activate');
+16 -5
View File
@@ -13,19 +13,30 @@ function check(condition, message) {
if (!condition) failures.push(message);
}
check(packageJson.version === '1.0.2', `package version is ${packageJson.version}`);
check(packageLock.version === '1.0.2', `lockfile version is ${packageLock.version}`);
check(packageLock.packages?.['']?.version === '1.0.2', `lockfile root package version is ${packageLock.packages?.['']?.version}`);
check(packageJson.version === '1.0.3', `package version is ${packageJson.version}`);
check(packageLock.version === '1.0.3', `lockfile version is ${packageLock.version}`);
check(packageLock.packages?.['']?.version === '1.0.3', `lockfile root package version is ${packageLock.packages?.['']?.version}`);
check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`);
check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`);
check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`);
check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', 'src/index.html', 'src/styles.css', 'src/workspace.css', 'package.json']), 'packaged file list is not restricted');
check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', 'src/index.html', 'src/styles.css', 'src/workspace.css', 'build/icon.png', 'package.json']), 'packaged file list is not restricted');
check(packageJson.build?.win?.icon === 'build/icon.ico', `Windows icon is ${packageJson.build?.win?.icon}`);
check(packageJson.build?.nsis?.installerIcon === 'build/icon.ico', `installer icon is ${packageJson.build?.nsis?.installerIcon}`);
check(packageJson.build?.nsis?.uninstallerIcon === 'build/icon.ico', `uninstaller icon is ${packageJson.build?.nsis?.uninstallerIcon}`);
check(packageJson.build?.win?.signAndEditExecutable !== false, 'Windows executable resource editing is enabled');
check(packageJson.build?.win?.signExecutable === false, 'Windows code signing remains disabled without suppressing icon resources');
check(fs.existsSync(path.join(root, 'build', 'icon.png')), 'application PNG icon is missing');
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');
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');
check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing');
check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing');
check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source');
check(indexSource.includes('Version: v1.0.2'), 'initial version label is not 1.0.2');
check(indexSource.includes('Version: v1.0.3'), 'initial version label is not 1.0.3');
check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present');
check(fs.existsSync(manifestPath), 'public release manifest is missing');
+3
View File
@@ -41,6 +41,9 @@ async function run() {
});
await win.waitForTimeout(200);
await win.click('[data-context-for="settings"] [data-settings-pane="downloads"]');
await win.waitForTimeout(180);
await win.click('#settingsTemplateGuideBtn');
await win.waitForTimeout(180);
File diff suppressed because it is too large Load Diff