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:
Bohdan Triapitsyn
2026-09-07 20:38:00 +03:00
parent c82aa7879b
commit f9d6b5c479
6 changed files with 229 additions and 85 deletions
@@ -368,6 +368,16 @@ before starting managed OpenCode. The managed custom tool therefore receives
an authoritative loopback callback URL even when OpenChamber binds port `0`.
## Public exports (openchamber-routes.js)
Browser completion checks use `appType=web&updateStatus=true` to stay on the
Desktop Host's native updater. A rejected native restart is retained in the
server process and returned to these polls as `DESKTOP_UPDATE_RESTART_FAILED`;
ordinary availability checks remain usable so a browser reload can offer a
retry. Starting another installation clears the previous restart error.
The shared UI's `lib/web-update.ts` parses install/check responses and waits
for the installed native target version, rather than treating absence of a
newer release as installation success. Poll requests have individual deadlines
within a ten-minute overall deadline.
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
- `GET /api/openchamber/update-check`
- `POST /api/openchamber/update-install`
@@ -32,6 +32,8 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
desktopUpdater,
} = dependencies;
let desktopRestartError = null;
app.get('/api/openchamber/update-check', async (req, res) => {
try {
const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
@@ -61,6 +63,12 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
};
let updateInfo;
if (process.env.OPENCHAMBER_RUNTIME === 'desktop' && updateRequest.appType === 'web') {
if (desktopRestartError && req.query.updateStatus === 'true') {
return res.status(503).json({
code: 'DESKTOP_UPDATE_RESTART_FAILED',
error: desktopRestartError,
});
}
if (typeof desktopUpdater?.check !== 'function') {
return res.status(503).json({
available: false,
@@ -97,6 +105,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
});
}
desktopRestartError = null;
const updateInfo = await desktopUpdater.install();
if (!updateInfo?.available) {
return res.status(400).json({ error: 'No update available' });
@@ -115,7 +124,10 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
setImmediate(() => {
Promise.resolve()
.then(() => desktopUpdater.restart())
.catch((error) => console.error('Failed to restart after desktop update:', error));
.catch((error) => {
desktopRestartError = error instanceof Error ? error.message : 'Failed to restart after desktop update';
console.error('Failed to restart after desktop update:', error);
});
});
return;
}
@@ -71,6 +71,43 @@ afterEach(() => {
});
describe('OpenChamber desktop host update route', () => {
it('reports a restart rejection until the user retries installation', async () => {
const desktopUpdater = {
check: vi.fn(async () => ({ available: true, currentVersion: '1.17.0', version: '1.17.1' })),
install: vi.fn(async () => ({ available: true, version: '1.17.1' })),
restart: vi.fn().mockRejectedValueOnce(new Error('Signature rejected')).mockResolvedValue(undefined),
};
const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' }, desktopUpdater });
const logError = vi.spyOn(console, 'error').mockImplementation(() => {});
await request(app).post('/api/openchamber/update-install').expect(200);
await new Promise(resolve => setImmediate(resolve));
await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true').expect(503, {
code: 'DESKTOP_UPDATE_RESTART_FAILED', error: 'Signature rejected',
});
expect(desktopUpdater.check).not.toHaveBeenCalled();
expect(logError).toHaveBeenCalledOnce();
// Availability remains reachable after a browser reload, so users can retry.
await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
await request(app).post('/api/openchamber/update-install').expect(200);
await new Promise(resolve => setImmediate(resolve));
const response = await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
expect(response.body.currentVersion).toBe('1.17.0');
expect(response.body.updateOwner).toBe('electron-updater');
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
});
it('rejects native checks without a bridge and preserves explicit non-web checks', async () => {
const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' } });
await request(app).get('/api/openchamber/update-check?appType=web').expect(503, {
available: false, code: 'DESKTOP_UPDATER_UNAVAILABLE', error: 'The desktop updater is not available.',
});
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
await request(app).get('/api/openchamber/update-check?appType=desktop-electron').expect(200);
expect(packageManager.checkForUpdates).toHaveBeenCalledOnce();
});
it('uses electron-updater to check for Web client updates', async () => {
const desktopUpdater = {
check: vi.fn(async () => ({