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:
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user