feat(updater): add GitHub releases desktop updater

Adds the GitHub Releases desktop updater with release notes, startup notification, packaging metadata, tests, and CI fixes for Electron E2E.
This commit is contained in:
4gray authored and GitHub committed 2026-06-28 22:21:20 +02:00
1 parent 3cb228d931
commit b1119189e2
54 files changed
+3569 -134

No files matched your search

@@ -40,6 +40,10 @@ export const defaultStalkerPortalName = 'Mock Stalker Portal';
export const defaultXtreamUsername = 'user1';
export const defaultXtreamPassword = 'pass1';
export const defaultStalkerMacAddress = '00:1A:79:00:00:01';
const electronAppCloseTimeoutMs = Number(
process.env['IPTVNATOR_E2E_CLOSE_TIMEOUT_MS'] ?? '10000'
);
const electronAppKillWaitMs = 2000;
export type PortalProvider = 'stalker' | 'xtream';
@@ -153,6 +157,7 @@ export async function launchElectronApp(
NODE_ENV: 'test',
},
});
attachElectronProcessDiagnostics(electronApp);
const mainWindow = await findMainWindow(electronApp);
await waitForAppReady(mainWindow);
@@ -166,16 +171,83 @@ export async function launchElectronApp(
};
}
function attachElectronProcessDiagnostics(electronApp: ElectronApplication): void {
if (!process.env['CI']) {
return;
}
const childProcess = electronApp.process();
childProcess.stdout?.on('data', (chunk: Buffer) => {
console.log(`[electron stdout] ${chunk.toString().trimEnd()}`);
});
childProcess.stderr?.on('data', (chunk: Buffer) => {
console.error(`[electron stderr] ${chunk.toString().trimEnd()}`);
});
childProcess.once('exit', (code, signal) => {
console.log(
`[electron process exit] code=${code ?? '<null>'} signal=${
signal ?? '<null>'
}`
);
});
}
export async function closeElectronApp(
app: LaunchedElectronApp
): Promise<void> {
try {
await app.electronApp.close();
const closePromise = app.electronApp.close();
const closed = await waitForPromiseWithTimeout(
closePromise,
electronAppCloseTimeoutMs
);
if (closed) {
return;
}
console.warn(
`Electron app did not close within ${electronAppCloseTimeoutMs}ms; killing process`
);
const childProcess = app.electronApp.process();
if (!childProcess.killed) {
childProcess.kill();
}
await waitForPromiseWithTimeout(
closePromise.catch(() => undefined),
electronAppKillWaitMs
);
} catch (error) {
console.warn('Failed to close Electron app cleanly:', error);
}
}
async function waitForPromiseWithTimeout(
promise: Promise<unknown>,
timeoutMs: number
): Promise<boolean> {
let timeoutId: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise.then(() => true),
new Promise<boolean>((resolvePromise) => {
timeoutId = setTimeout(
() => resolvePromise(false),
timeoutMs
);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function assertPackagedRendererBuildIsElectronSafe(): void {
if (!existsSync(packagedRendererIndexPath)) {
throw new Error(
@@ -41,6 +41,30 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
`;
test.describe('Electron Settings', () => {
test('@settings @electron shows manual app update fallback when self-update is unavailable', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await expect(app.mainWindow.getByTestId('app-update-status')).toBeVisible();
await expect(app.mainWindow.getByTestId('app-update-check')).toBeVisible();
await expect(
app.mainWindow.getByTestId('app-update-open-release')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('app-update-download')
).toHaveCount(0);
await expect(
app.mainWindow.getByTestId('app-update-install')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@settings @electron gates external MPV playback behind double-click when enabled', async ({
dataDir,
}) => {