diff --git a/packages/ui/src/components/ui/UpdateDialog.tsx b/packages/ui/src/components/ui/UpdateDialog.tsx index bc21e211..38ac83a5 100644 --- a/packages/ui/src/components/ui/UpdateDialog.tsx +++ b/packages/ui/src/components/ui/UpdateDialog.tsx @@ -13,7 +13,7 @@ import type { UpdateInfo, UpdateProgress } from '@/lib/desktop'; import { copyTextToClipboard } from '@/lib/clipboard'; import { openExternalUrl } from '@/lib/url'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; -import { runtimeFetch } from '@/lib/runtime-fetch'; +import { installWebUpdate, waitForUpdateApplied } from '@/lib/web-update'; type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error'; @@ -111,85 +111,6 @@ function parseChangelogSections(body: string): ChangelogSection[] { }); } -type InstallWebUpdateResult = { - success: boolean; - error?: string; - autoRestart?: boolean; -}; - -const WEB_UPDATE_POLL_INTERVAL_MS = 2000; -const WEB_UPDATE_MAX_WAIT_MS = 10 * 60 * 1000; - -async function installWebUpdate(): Promise { - try { - const response = await runtimeFetch('/api/openchamber/update-install', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - return { success: false, error: data.error || `Server error: ${response.status}` }; - } - - const data = await response.json().catch(() => ({})); - return { - success: true, - autoRestart: data.autoRestart !== false, - }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : undefined }; - } -} - -async function isServerReachable(): Promise { - try { - const response = await runtimeFetch('/health', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - return response.ok; - } catch { - return false; - } -} - -async function waitForUpdateApplied( - previousVersion?: string, - maxAttempts = Math.ceil(WEB_UPDATE_MAX_WAIT_MS / WEB_UPDATE_POLL_INTERVAL_MS), - intervalMs = WEB_UPDATE_POLL_INTERVAL_MS, -): Promise { - for (let i = 0; i < maxAttempts; i++) { - try { - // Status-only poll while waiting for the update to apply; not a usage report. - const response = await runtimeFetch('/api/openchamber/update-check?reportUsage=false', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (response.ok) { - const data = await response.json().catch(() => null); - if (data && data.available === false) { - return true; - } - if ( - data && - typeof data.currentVersion === 'string' && - typeof previousVersion === 'string' && - data.currentVersion !== previousVersion - ) { - return true; - } - } else if ((response.status === 401 || response.status === 403) && await isServerReachable()) { - return true; - } - } catch { - // Server may be restarting - } - await new Promise(resolve => setTimeout(resolve, intervalMs)); - } - return false; -} - export const UpdateDialog: React.FC = ({ open, onOpenChange, @@ -258,13 +179,13 @@ export const UpdateDialog: React.FC = ({ setWebUpdateState('reconnecting'); - const applied = await waitForUpdateApplied(info?.currentVersion); + const applied = await waitForUpdateApplied(result.target, info?.currentVersion); - if (applied) { + if (applied.status === 'applied') { window.location.reload(); } else { setWebUpdateState('error'); - setWebError(t('updateDialog.error.takingLonger')); + setWebError(applied.status === 'failed' ? applied.error : t('updateDialog.error.takingLonger')); } }, [info?.currentVersion, t]); @@ -414,7 +335,7 @@ export const UpdateDialog: React.FC = ({ )} {/* Web runtime fallback command */} - {isWebRuntime && webUpdateState === 'error' && ( + {isWebRuntime && info?.packageManager !== 'electron' && webUpdateState === 'error' && (
diff --git a/packages/ui/src/lib/web-update.test.ts b/packages/ui/src/lib/web-update.test.ts new file mode 100644 index 00000000..13ff0183 --- /dev/null +++ b/packages/ui/src/lib/web-update.test.ts @@ -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((_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' }); + }); +}); diff --git a/packages/ui/src/lib/web-update.ts b/packages/ui/src/lib/web-update.ts new file mode 100644 index 00000000..6d72f48f --- /dev/null +++ b/packages/ui/src/lib/web-update.ts @@ -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 { + 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 { + 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' }; +} diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index bf618692..185a218a 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -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` diff --git a/packages/web/server/lib/opencode/openchamber-routes.js b/packages/web/server/lib/opencode/openchamber-routes.js index 4742cad7..008758d2 100644 --- a/packages/web/server/lib/opencode/openchamber-routes.js +++ b/packages/web/server/lib/opencode/openchamber-routes.js @@ -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; } diff --git a/packages/web/server/lib/opencode/openchamber-routes.test.js b/packages/web/server/lib/opencode/openchamber-routes.test.js index b821370e..eaba9aa6 100644 --- a/packages/web/server/lib/opencode/openchamber-routes.test.js +++ b/packages/web/server/lib/opencode/openchamber-routes.test.js @@ -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 () => ({