fix(updater): verify native host version and report restart failures
Complete #3227 by keeping browser completion polls on the native updater, checking the requested version, and preserving retry access after a failed restart.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { installWebUpdate, waitForUpdateApplied } from './web-update';
|
||||
|
||||
describe('browser host updates', () => {
|
||||
test('keeps the native target version returned by the install request', async () => {
|
||||
const result = await installWebUpdate(async () => Response.json({
|
||||
success: true, updateOwner: 'electron-updater', version: '1.22.3', autoRestart: true,
|
||||
}));
|
||||
expect(result).toEqual({ success: true, autoRestart: true, target: { owner: 'electron', version: '1.22.3' } });
|
||||
});
|
||||
|
||||
test('rejects a native success response without its target version', async () => {
|
||||
expect(await installWebUpdate(async () => Response.json({
|
||||
success: true, updateOwner: 'electron-updater',
|
||||
}))).toEqual({ success: false });
|
||||
});
|
||||
|
||||
test('rejects malformed success instead of starting the reconnect loop', async () => {
|
||||
expect(await installWebUpdate(async () => Response.json({}))).toEqual({ success: false });
|
||||
});
|
||||
|
||||
test('keeps native polling on the web host route until the target is installed', async () => {
|
||||
let requests = 0;
|
||||
const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', {
|
||||
intervalMs: 1, maxWaitMs: 1000,
|
||||
fetchUpdate: async (url, init) => {
|
||||
expect(url).toBe('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true');
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
requests += 1;
|
||||
// The old package feed can say no update while native installation is
|
||||
// still pending. That does not prove the requested version is running.
|
||||
return Response.json({ available: false, currentVersion: requests === 1 ? '1.22.2' : '1.22.3' });
|
||||
},
|
||||
});
|
||||
expect(requests).toBe(2);
|
||||
expect(result).toEqual({ status: 'applied' });
|
||||
});
|
||||
|
||||
test('reports a rejected native restart without treating it as a transient disconnect', async () => {
|
||||
const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', {
|
||||
fetchUpdate: async () => Response.json({
|
||||
code: 'DESKTOP_UPDATE_RESTART_FAILED', error: 'Signature rejected',
|
||||
}, { status: 503 }),
|
||||
});
|
||||
expect(result).toEqual({ status: 'failed', error: 'Signature rejected' });
|
||||
});
|
||||
|
||||
test('does not accept authentication loss as native upgrade completion', async () => {
|
||||
const requests: string[] = [];
|
||||
const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', {
|
||||
intervalMs: 1, maxWaitMs: 20,
|
||||
fetchUpdate: async (url) => {
|
||||
requests.push(String(url));
|
||||
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: 'timeout' });
|
||||
expect(requests).not.toContain('/health');
|
||||
});
|
||||
|
||||
test('a blocked poll cannot extend the overall deadline', async () => {
|
||||
const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', {
|
||||
maxWaitMs: 20,
|
||||
fetchUpdate: async (_url, init) => new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('Timed out')), { once: true });
|
||||
}),
|
||||
});
|
||||
expect(result).toEqual({ status: 'timeout' });
|
||||
});
|
||||
|
||||
test('preserves package-manager completion after a version change', async () => {
|
||||
const result = await waitForUpdateApplied({ owner: 'package-manager' }, '1.22.2', {
|
||||
fetchUpdate: async () => Response.json({ available: false, currentVersion: '1.22.3' }),
|
||||
});
|
||||
expect(result).toEqual({ status: 'applied' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { z } from 'zod';
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
|
||||
const installResponse = z.object({
|
||||
success: z.literal(true),
|
||||
autoRestart: z.boolean().optional(),
|
||||
updateOwner: z.string().optional(),
|
||||
version: z.string().min(1).optional(),
|
||||
});
|
||||
const checkResponse = z.object({
|
||||
available: z.boolean(),
|
||||
currentVersion: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
const errorResponse = z.object({ error: z.string(), code: z.string().optional() });
|
||||
|
||||
type UpdateTarget = { owner: 'electron'; version: string } | { owner: 'package-manager' };
|
||||
type InstallResult =
|
||||
| { success: true; autoRestart: boolean; target: UpdateTarget }
|
||||
| { success: false; error?: string };
|
||||
type AppliedResult = { status: 'applied' } | { status: 'timeout' } | { status: 'failed'; error: string };
|
||||
|
||||
export async function installWebUpdate(fetchUpdate = runtimeFetch): Promise<InstallResult> {
|
||||
try {
|
||||
const response = await fetchUpdate('/api/openchamber/update-install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = errorResponse.safeParse(payload);
|
||||
return { success: false, error: error.success ? error.data.error : undefined };
|
||||
}
|
||||
const parsed = installResponse.safeParse(payload);
|
||||
if (!parsed.success) return { success: false };
|
||||
const data = parsed.data;
|
||||
if (data.updateOwner === 'electron-updater') {
|
||||
if (!data.version) return { success: false };
|
||||
return { success: true, autoRestart: data.autoRestart !== false, target: { owner: 'electron', version: data.version } };
|
||||
}
|
||||
return { success: true, autoRestart: data.autoRestart !== false, target: { owner: 'package-manager' } };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : undefined };
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForUpdateApplied(
|
||||
target: UpdateTarget,
|
||||
previousVersion?: string,
|
||||
{ fetchUpdate = runtimeFetch, maxWaitMs = 10 * 60 * 1000, intervalMs = 2000 } = {},
|
||||
): Promise<AppliedResult> {
|
||||
const deadline = Date.now() + maxWaitMs;
|
||||
while (Date.now() < deadline) {
|
||||
const signal = AbortSignal.timeout(Math.max(1, Math.min(10_000, deadline - Date.now())));
|
||||
try {
|
||||
const response = await fetchUpdate('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true', {
|
||||
method: 'GET', headers: { Accept: 'application/json' }, signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
const parsed = checkResponse.safeParse(await response.json());
|
||||
if (parsed.success && !parsed.data.error) {
|
||||
const data = parsed.data;
|
||||
const applied = target.owner === 'electron'
|
||||
? data.currentVersion === target.version
|
||||
: data.available === false || (previousVersion !== undefined && data.currentVersion !== undefined && data.currentVersion !== previousVersion);
|
||||
if (applied) return { status: 'applied' };
|
||||
}
|
||||
} else {
|
||||
const parsed = errorResponse.safeParse(await response.json().catch(() => null));
|
||||
if (parsed.success && parsed.data.code === 'DESKTOP_UPDATE_RESTART_FAILED') {
|
||||
return { status: 'failed', error: parsed.data.error };
|
||||
}
|
||||
// Package-manager restarts can replace the browser session. A native
|
||||
// update must still prove its target version after authentication.
|
||||
if (target.owner === 'package-manager' && (response.status === 401 || response.status === 403)) {
|
||||
const health = await fetchUpdate('/health', { headers: { Accept: 'application/json' }, signal });
|
||||
if (health.ok) return { status: 'applied' };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A restarting host can disconnect or time out; retry within the deadline.
|
||||
}
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, Math.min(intervalMs, remaining)));
|
||||
}
|
||||
return { status: 'timeout' };
|
||||
}
|
||||
Reference in New Issue
Block a user