diff --git a/packages/electron/README.md b/packages/electron/README.md index 6cc110f9..e9eabfb4 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -96,7 +96,7 @@ Running a packaged Linux AppImage requires FUSE (`libfuse.so.2`, typically `libf Desktop clears AppImage `ARGV0` from `process.env` before probing the login shell and starting the in-process server. Leaving it set makes zsh rewrite argv[0] for integrated-terminal and managed-OpenCode child commands to the AppImage path. -Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet). +Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. Authenticated Web clients connected to the embedded Desktop Host use this same `electron-updater` check, download, and restart flow rather than a package-manager command. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet). `desktop_restart` does not answer the renderer before the install is decided. On the apply-update path it calls `quitAndInstall()` and keeps the IPC call open until the app quits or `autoUpdater` emits `error`, which the platform installers do asynchronously (a rejected code signature, or a Squirrel session disabled by an earlier failure). A failed install rejects the IPC call so the update dialog can show it, and the quit/install flags are rolled back because the app is staying up. A still-running app after the grace period resolves the call. diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index fbec383b..fa027314 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1621,6 +1621,16 @@ const spawnLocalServer = async () => { apiBaseUrl: state.apiBaseUrl || '', requestHeaders: sanitizeRuntimeRequestHeaders(state.requestHeaders || {}), }), + desktopUpdater: { + check: () => handleInvoke(null, 'desktop_check_for_updates'), + install: async () => { + const updateInfo = await handleInvoke(null, 'desktop_check_for_updates'); + if (!updateInfo.available) return updateInfo; + await handleInvoke(null, 'desktop_download_and_install_update'); + return updateInfo; + }, + restart: () => handleInvoke(null, 'desktop_restart'), + }, }); const port = handle.getPort(); diff --git a/packages/web/server/index.d.ts b/packages/web/server/index.d.ts index e9141db8..076c3e7d 100644 --- a/packages/web/server/index.d.ts +++ b/packages/web/server/index.d.ts @@ -11,12 +11,27 @@ export interface WebUiServerController { stop: (options?: { exitProcess?: boolean }) => Promise; } +export interface DesktopUpdateInfo { + available: boolean; + currentVersion?: string; + version?: string | null; + body?: string | null; + date?: string | null; +} + +export interface DesktopUpdater { + check: () => Promise; + install: () => Promise; + restart: () => Promise | void; +} + export interface StartWebUiServerOptions { port?: number; host?: string; attachSignals?: boolean; exitOnShutdown?: boolean; uiPassword?: string | null; + desktopUpdater?: DesktopUpdater; } export declare function startWebUiServer( diff --git a/packages/web/server/index.js b/packages/web/server/index.js index a27eb88a..ef2e7d3f 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1658,6 +1658,12 @@ async function main(options = {}) { const getDesktopRuntimeConfig = typeof options.getDesktopRuntimeConfig === 'function' ? options.getDesktopRuntimeConfig : null; + const desktopUpdater = options.desktopUpdater + && typeof options.desktopUpdater.check === 'function' + && typeof options.desktopUpdater.install === 'function' + && typeof options.desktopUpdater.restart === 'function' + ? options.desktopUpdater + : null; console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`); @@ -1836,6 +1842,7 @@ async function main(options = {}) { getCachedZenModels, setAutoAcceptSession, agentToolRuntime, + desktopUpdater, }); uiAuthController = bootstrapResult.uiAuthController; realtimeProxyRuntime = attachRealtimeProxy({ diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 5e3b670d..bf618692 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -371,6 +371,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`. - `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints: - `GET /api/openchamber/update-check` - `POST /api/openchamber/update-install` + - Desktop-managed hosts delegate authenticated Web update requests to the Electron main process, which checks, downloads, and applies the update through `electron-updater` before restarting the host. - Foreground servers running under a systemd user unit queue installation in a separate transient unit and restart the configured service afterwards. `OPENCHAMBER_SYSTEMD_UNIT` overrides the default `openchamber.service`. diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js index f3d60a19..5e6ad65e 100644 --- a/packages/web/server/lib/opencode/bootstrap-runtime.js +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -63,6 +63,7 @@ export const createBootstrapRuntime = (dependencies) => { getCachedZenModels, setAutoAcceptSession, agentToolRuntime, + desktopUpdater, } = options; const uiAuthController = createUiAuth({ @@ -153,6 +154,7 @@ export const createBootstrapRuntime = (dependencies) => { readSettingsFromDiskMigrated, fetchFreeZenModels, getCachedZenModels, + desktopUpdater, }); return { diff --git a/packages/web/server/lib/opencode/openchamber-routes.js b/packages/web/server/lib/opencode/openchamber-routes.js index a618e731..4742cad7 100644 --- a/packages/web/server/lib/opencode/openchamber-routes.js +++ b/packages/web/server/lib/opencode/openchamber-routes.js @@ -29,11 +29,11 @@ export const registerOpenChamberRoutes = (app, dependencies) => { readSettingsFromDiskMigrated, fetchFreeZenModels, getCachedZenModels, + desktopUpdater, } = dependencies; app.get('/api/openchamber/update-check', async (req, res) => { try { - const { checkForUpdates } = await import('../package-manager.js'); const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined); const parseReportUsage = (value) => { if (typeof value !== 'string') return true; @@ -49,8 +49,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => { return 'desktop'; }; const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : ''; - - const updateInfo = await checkForUpdates({ + const updateRequest = { appType: parseString(req.query.appType), deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent), platform: parseString(req.query.platform), @@ -59,7 +58,25 @@ export const registerOpenChamberRoutes = (app, dependencies) => { currentVersion: parseString(req.query.currentVersion), installId: parseString(req.query.installId), reportUsage: parseReportUsage(parseString(req.query.reportUsage)), - }); + }; + let updateInfo; + if (process.env.OPENCHAMBER_RUNTIME === 'desktop' && updateRequest.appType === 'web') { + if (typeof desktopUpdater?.check !== 'function') { + return res.status(503).json({ + available: false, + code: 'DESKTOP_UPDATER_UNAVAILABLE', + error: 'The desktop updater is not available.', + }); + } + updateInfo = { + ...await desktopUpdater.check(), + packageManager: 'electron', + updateOwner: 'electron-updater', + }; + } else { + const { checkForUpdates } = await import('../package-manager.js'); + updateInfo = await checkForUpdates(updateRequest); + } res.json(updateInfo); } catch (error) { console.error('Failed to check for updates:', error); @@ -72,6 +89,37 @@ export const registerOpenChamberRoutes = (app, dependencies) => { app.post('/api/openchamber/update-install', async (_req, res) => { try { + if (process.env.OPENCHAMBER_RUNTIME === 'desktop') { + if (typeof desktopUpdater?.install !== 'function' || typeof desktopUpdater?.restart !== 'function') { + return res.status(503).json({ + code: 'DESKTOP_UPDATER_UNAVAILABLE', + error: 'The desktop updater is not available.', + }); + } + + const updateInfo = await desktopUpdater.install(); + if (!updateInfo?.available) { + return res.status(400).json({ error: 'No update available' }); + } + + res.json({ + success: true, + message: 'Desktop update downloaded, host will restart shortly', + version: updateInfo.version, + packageManager: 'electron', + updateOwner: 'electron-updater', + autoRestart: true, + restartManager: 'electron-updater', + }); + + setImmediate(() => { + Promise.resolve() + .then(() => desktopUpdater.restart()) + .catch((error) => console.error('Failed to restart after desktop update:', error)); + }); + return; + } + const { spawn: spawnChild, spawnSync } = await import('child_process'); const { checkForUpdates, diff --git a/packages/web/server/lib/opencode/openchamber-routes.test.js b/packages/web/server/lib/opencode/openchamber-routes.test.js index 6b5ac534..b821370e 100644 --- a/packages/web/server/lib/opencode/openchamber-routes.test.js +++ b/packages/web/server/lib/opencode/openchamber-routes.test.js @@ -18,7 +18,7 @@ const childProcess = await import('child_process'); const packageManager = await import('../package-manager.js'); const { registerOpenChamberRoutes } = await import('./openchamber-routes.js'); -const createApp = ({ environment = {}, storedOptions = {} } = {}) => { +const createApp = ({ environment = {}, storedOptions = {}, desktopUpdater } = {}) => { const app = express(); const dependencies = { fs: { @@ -47,6 +47,7 @@ const createApp = ({ environment = {}, storedOptions = {} } = {}) => { readSettingsFromDiskMigrated: vi.fn(), fetchFreeZenModels: vi.fn(), getCachedZenModels: vi.fn(), + desktopUpdater, }; registerOpenChamberRoutes(app, dependencies); @@ -69,6 +70,95 @@ afterEach(() => { vi.clearAllMocks(); }); +describe('OpenChamber desktop host update route', () => { + it('uses electron-updater to check for Web client updates', async () => { + const desktopUpdater = { + check: vi.fn(async () => ({ + available: true, + currentVersion: '1.17.0', + version: '1.17.1', + })), + install: vi.fn(), + restart: vi.fn(), + }; + const { app } = createApp({ + environment: { + OPENCHAMBER_RUNTIME: 'desktop', + }, + desktopUpdater, + }); + + await request(app) + .get('/api/openchamber/update-check?appType=web&reportUsage=false') + .expect(200, { + available: true, + currentVersion: '1.17.0', + version: '1.17.1', + packageManager: 'electron', + updateOwner: 'electron-updater', + }); + + expect(desktopUpdater.check).toHaveBeenCalledOnce(); + expect(packageManager.checkForUpdates).not.toHaveBeenCalled(); + }); + + it('installs through electron-updater and restarts after responding', async () => { + const desktopUpdater = { + check: vi.fn(), + install: vi.fn(async () => ({ + available: true, + version: '1.17.1', + })), + restart: vi.fn(), + }; + const { app } = createApp({ + environment: { + OPENCHAMBER_RUNTIME: 'desktop', + }, + desktopUpdater, + }); + + await request(app) + .post('/api/openchamber/update-install') + .expect(200, { + success: true, + message: 'Desktop update downloaded, host will restart shortly', + version: '1.17.1', + packageManager: 'electron', + updateOwner: 'electron-updater', + autoRestart: true, + restartManager: 'electron-updater', + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(desktopUpdater.install).toHaveBeenCalledOnce(); + expect(desktopUpdater.restart).toHaveBeenCalledOnce(); + expect(packageManager.checkForUpdates).not.toHaveBeenCalled(); + expect(packageManager.detectPackageManagerDetails).not.toHaveBeenCalled(); + expect(packageManager.getUpdateCommand).not.toHaveBeenCalled(); + expect(childProcess.spawn).not.toHaveBeenCalled(); + expect(childProcess.spawnSync).not.toHaveBeenCalled(); + }); + + it('fails safely when the Electron updater bridge is unavailable', async () => { + const { app } = createApp({ + environment: { + OPENCHAMBER_RUNTIME: 'desktop', + }, + }); + + await request(app) + .post('/api/openchamber/update-install') + .expect(503, { + code: 'DESKTOP_UPDATER_UNAVAILABLE', + error: 'The desktop updater is not available.', + }); + + expect(packageManager.checkForUpdates).not.toHaveBeenCalled(); + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); +}); + describe('OpenChamber foreground update route', () => { it('rejects a foreground update when the server is not owned by systemd', async () => { const { app } = createApp();