fix(updater): use desktop host updater from web (#3227)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+15
@@ -11,12 +11,27 @@ export interface WebUiServerController {
|
||||
stop: (options?: { exitProcess?: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateInfo {
|
||||
available: boolean;
|
||||
currentVersion?: string;
|
||||
version?: string | null;
|
||||
body?: string | null;
|
||||
date?: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopUpdater {
|
||||
check: () => Promise<DesktopUpdateInfo>;
|
||||
install: () => Promise<DesktopUpdateInfo>;
|
||||
restart: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface StartWebUiServerOptions {
|
||||
port?: number;
|
||||
host?: string;
|
||||
attachSignals?: boolean;
|
||||
exitOnShutdown?: boolean;
|
||||
uiPassword?: string | null;
|
||||
desktopUpdater?: DesktopUpdater;
|
||||
}
|
||||
|
||||
export declare function startWebUiServer(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user