Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ dist-electron
dist-remote
release
coverage
# Local auto-update test server payload (build artifacts, not source).
update-test/
# Runtime data dir written by the app (MCP coordinator state, etc.)
.parallel-code/
.worktrees
Expand Down
7 changes: 7 additions & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ export enum IPC {
// Logging
LogFromRenderer = 'log_from_renderer',

// Auto-update
CheckForUpdates = 'check_for_updates',
DownloadUpdate = 'download_update',
QuitAndInstallUpdate = 'quit_and_install_update',
GetUpdateStatus = 'get_update_status',
UpdateStatusChanged = 'update_status_changed',

// MCP / Coordinating agent
SetCoordinatorModeEnabled = 'set_coordinator_mode_enabled',
StartMCPServer = 'start_mcp_server',
Expand Down
26 changes: 15 additions & 11 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ import {
deleteCustomThemeFile,
} from './persistence.js';
import { loadKeybindings, saveKeybindings } from './keybindings.js';
import {
initAutoUpdater,
getUpdateStatus,
checkForUpdates,
downloadUpdate,
quitAndInstallUpdate,
} from './updater.js';
import { spawn } from 'child_process';
import { askAboutCode, cancelAskAboutCode } from './ask-code.js';
import { setMinimaxApiKey } from './ask-code-minimax.js';
Expand All @@ -86,7 +93,7 @@ import {
assertOptionalBoolean,
} from './validate.js';
import { validateBranchName as sharedValidateBranchName, validateUUID } from '../mcp/validation.js';
import { warn as logWarn } from '../log.js';
import { warn as logWarn, errMessage } from '../log.js';
import { getMCPRemoteServerUrl, detectStaleDockerMCPUrl } from '../mcp/config.js';
import { redactServerUrl } from '../remote/server.js';

Expand Down Expand Up @@ -157,16 +164,6 @@ async function startRemoteServerOnFreePort(
throw new Error(`No free port found in range ${start}–${end}`);
}

function errMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
try {
return JSON.stringify(err);
} catch {
return String(err);
}
}

/** Reject paths that are non-absolute or attempt directory traversal. */
function validatePath(p: unknown, label: string): void {
if (typeof p !== 'string') throw new Error(`${label} must be a string`);
Expand Down Expand Up @@ -906,6 +903,13 @@ export function registerAllHandlers(win: BrowserWindow): void {
// --- System ---
ipcMain.handle(IPC.GetSystemFonts, () => getSystemMonospaceFonts());

// --- Auto-update ---
initAutoUpdater(win);
ipcMain.handle(IPC.GetUpdateStatus, () => getUpdateStatus());
ipcMain.handle(IPC.CheckForUpdates, () => checkForUpdates());
ipcMain.handle(IPC.DownloadUpdate, () => downloadUpdate());
ipcMain.handle(IPC.QuitAndInstallUpdate, () => quitAndInstallUpdate());

// --- Notifications (fire-and-forget via ipcMain.on) ---
const activeNotifications = new Set<Notification>();
ipcMain.handle(IPC.ShowNotification, (_e, args) => {
Expand Down
12 changes: 1 addition & 11 deletions electron/ipc/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// their args; all others omit them by default (default-deny).

import type { IpcMain, IpcMainInvokeEvent } from 'electron';
import { debug, getMinLevel, warn } from '../log.js';
import { debug, getMinLevel, warn, errMessage } from '../log.js';
import { IPC } from './channels.js';

/**
Expand Down Expand Up @@ -52,16 +52,6 @@ for (const ch of NEVER_SAFE) {
}
}

function errMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
try {
return JSON.stringify(err);
} catch {
return String(err);
}
}

/**
* Patch `ipcMain.handle` to trace every dispatch. Idempotent: calling
* twice is a no-op. Call once, before any handler is registered.
Expand Down
198 changes: 198 additions & 0 deletions electron/ipc/updater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Auto-update — wraps electron-updater's `autoUpdater` so the renderer can
// check GitHub Releases for a newer version, download it, and relaunch onto
// it without a manual reinstall.
//
// Auto-update only works for packaged builds that have an in-place update
// channel: macOS (signed) and the Linux AppImage. A dev run or the Linux
// `deb` target has no channel, so we report `unsupported` rather than letting
// electron-updater throw.

import { app, type BrowserWindow } from 'electron';
import electronUpdater from 'electron-updater';
import type { UpdateInfo, ProgressInfo, AppUpdater } from 'electron-updater';
import { IPC } from './channels.js';
import { debug, info, warn, error as logError, errMessage } from '../log.js';

// `electronUpdater.autoUpdater` is a lazy getter that instantiates a
// platform updater (and touches `app`) on first access. Resolve it lazily so
// importing this module never triggers that — important for unit tests where
// `electron.app` is not a real instance.
function getAutoUpdater(): AppUpdater {
return electronUpdater.autoUpdater;
}

const LOG = 'updater';

export type UpdatePhase =
| 'unsupported'
| 'idle'
| 'checking'
| 'up-to-date'
| 'available'
| 'downloading'
| 'downloaded'
| 'error';

export interface UpdateStatus {
phase: UpdatePhase;
/** Version this app is currently running. */
currentVersion: string;
/** Version offered by the latest check, when newer than `currentVersion`. */
latestVersion: string | null;
/** 0–100 while `phase` is `downloading`. */
downloadPercent: number;
/** Human-readable message when `phase` is `error`. */
error: string | null;
}

// The Linux AppImage runtime sets APPIMAGE to the mounted image path. Its
// absence on Linux means a non-updatable target (e.g. an installed `.deb`).
// `app` is undefined when this module is loaded outside an Electron runtime
// (e.g. a unit test), so guard every access.
function isAutoUpdateSupported(): boolean {
if (!app?.isPackaged) return false;
if (process.platform === 'darwin') return true;
if (process.platform === 'linux') return !!process.env.APPIMAGE;
return false;
}

const status: UpdateStatus = {
phase: 'idle',
currentVersion: '',
latestVersion: null,
downloadPercent: 0,
error: null,
};

let mainWindow: BrowserWindow | null = null;
let wired = false;
let startupCheckTimer: ReturnType<typeof setTimeout> | null = null;

function broadcast(): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IPC.UpdateStatusChanged, { ...status });
}
}

function setPhase(phase: UpdatePhase, patch: Partial<UpdateStatus> = {}): void {
status.phase = phase;
Object.assign(status, patch);
broadcast();
}

function wireUpdaterEvents(): void {
if (wired) return;
wired = true;

const autoUpdater = getAutoUpdater();

// Downloads are user-initiated; a finished download installs on next quit.
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;

autoUpdater.on('checking-for-update', () => {
setPhase('checking', { error: null });
});

autoUpdater.on('update-available', (infoEvt: UpdateInfo) => {
info(LOG, 'update available', { version: infoEvt.version });
setPhase('available', { latestVersion: infoEvt.version, error: null });
});

autoUpdater.on('update-not-available', () => {
debug(LOG, 'no update available');
setPhase('up-to-date', { latestVersion: null, error: null });
});

autoUpdater.on('download-progress', (progress: ProgressInfo) => {
// Fires many times per second; only broadcast when the rounded percent
// (the only value the UI shows) actually moves.
const percent = Math.round(progress.percent);
if (status.phase === 'downloading' && status.downloadPercent === percent) return;
setPhase('downloading', { downloadPercent: percent });
});

autoUpdater.on('update-downloaded', (infoEvt: UpdateInfo) => {
info(LOG, 'update downloaded', { version: infoEvt.version });
setPhase('downloaded', { latestVersion: infoEvt.version, downloadPercent: 100 });
});

autoUpdater.on('error', (err: Error) => {
const message = errMessage(err);
warn(LOG, 'updater error', { error: message });
setPhase('error', { error: message });
});
}

/**
* Wire the updater to a window and run one silent check shortly after launch.
* Safe to call when auto-update is unsupported — it becomes a no-op.
*/
export function initAutoUpdater(win: BrowserWindow): void {
mainWindow = win;
status.currentVersion = app?.getVersion?.() ?? '';
if (!isAutoUpdateSupported()) {
setPhase('unsupported');
debug(LOG, 'auto-update unsupported in this build');
return;
}
wireUpdaterEvents();
// Delay the first check so it does not compete with app startup work.
startupCheckTimer = setTimeout(() => {
startupCheckTimer = null;
void checkForUpdates();
}, 10_000);
win.once('closed', () => {
if (startupCheckTimer) {
clearTimeout(startupCheckTimer);
startupCheckTimer = null;
}
// Detach event handlers so a re-created window re-wires from a clean
// slate. `removeAllListeners` is safe because this module is the sole
// consumer of the `autoUpdater` singleton.
getAutoUpdater().removeAllListeners();
wired = false;
mainWindow = null;
});
}

/** Check GitHub Releases for a newer version. Resolves with the latest status. */
export async function checkForUpdates(): Promise<UpdateStatus> {
if (!isAutoUpdateSupported()) return { ...status };
// A check while downloading/downloaded would clobber that progress.
if (status.phase === 'downloading' || status.phase === 'downloaded') return { ...status };
try {
setPhase('checking', { error: null });
await getAutoUpdater().checkForUpdates();
} catch (err) {
setPhase('error', { error: errMessage(err) });
}
return { ...status };
}

/** Download the available update; progress is reported via the status event. */
export async function downloadUpdate(): Promise<UpdateStatus> {
if (status.phase !== 'available') return { ...status };
try {
setPhase('downloading', { downloadPercent: 0, error: null });
await getAutoUpdater().downloadUpdate();
} catch (err) {
setPhase('error', { error: errMessage(err) });
}
return { ...status };
}

/** Relaunch onto a downloaded update. No-op unless an update is downloaded. */
export function quitAndInstallUpdate(): void {
if (status.phase !== 'downloaded') return;
try {
getAutoUpdater().quitAndInstall();
} catch (err) {
logError(LOG, 'quitAndInstall failed', err);
setPhase('error', { error: errMessage(err) });
}
}

export function getUpdateStatus(): UpdateStatus {
return { ...status };
}
11 changes: 11 additions & 0 deletions electron/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ export function error(category: string, msg: string, err: unknown, ctx?: LogCont
emit('error', category, msg, ctx, err);
}

/** Reduce an unknown thrown value to a human-readable string. */
export function errMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
try {
return JSON.stringify(err);
} catch {
return String(err);
}
}

function emit(
level: LogLevel,
category: string,
Expand Down
6 changes: 6 additions & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ const ALLOWED_CHANNELS = new Set([
'pr_checks_update',
// Logging
'log_from_renderer',
// Auto-update
'check_for_updates',
'download_update',
'quit_and_install_update',
'get_update_status',
'update_status_changed',
// MCP / Coordinating agent
'set_coordinator_mode_enabled',
'start_mcp_server',
Expand Down
45 changes: 45 additions & 0 deletions openspec/changes/add-auto-update/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Add In-App Auto-Update

## Why

Parallel Code ships as a macOS DMG and a Linux AppImage/deb. Today a user who
wants a newer version must notice a release exists, download the installer by
hand, and reinstall over the running app. There is no in-app signal that an
update is available and no way to apply one without that manual reinstall
(issue #91).

## What changes

- The app checks GitHub Releases for a newer published version: silently once
shortly after launch, and on demand from a new **Updates** section in
Settings → Diagnostics.
- When a newer version exists the Updates section reports it and offers a
**Download update** action; download progress is shown.
- An update control also appears in the **sidebar header** whenever an update
is available, downloading, or downloaded — so it is discoverable without
opening Settings. It is hidden when the app is up to date, unsupported, or
the last check failed.
- Once downloaded, a **Restart & install** action relaunches the app onto the
new version — no manual reinstall. A deferred install is also applied
automatically the next time the app quits.
- The Updates section always shows the current version and the outcome of the
last check (up to date / available / error), so the feature is discoverable
and its state legible.
- Auto-update is a packaged-build capability. In dev runs and for the Linux
`deb` target (which has no in-place update channel) the section reports that
updates are unavailable rather than failing.

## Impact

- New capability `updates`.
- Adds `electron-updater` and a GitHub `publish` target to the
`electron-builder` config; adds a `zip` artifact to the macOS build so
`electron-updater` can apply macOS updates.
- New backend module `electron/ipc/updater.ts`; new IPC channels
(`check_for_updates`, `download_update`, `quit_and_install_update`,
`get_update_status`, `update_status_changed`) wired through
`channels.ts`, `preload.cjs`, and `register.ts`.
- New renderer store slice `src/store/updates.ts`, an Updates section in
`src/components/SettingsDialog.tsx`, and a new `UpdateButton` component in
the `Sidebar` header; subscription wired in `src/App.tsx`.
- No persisted-state or schema change — update status is transient.
Loading
Loading