openpencil/apps/desktop/auto-updater.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

264 lines
7.2 KiB
TypeScript

import { app, BrowserWindow } from 'electron';
import { autoUpdater } from 'electron-updater';
import type { NsisUpdater } from 'electron-updater';
import { GitHubProvider } from 'electron-updater/out/providers/GitHubProvider';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { GITHUB_OWNER, GITHUB_REPO } from './constants';
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type UpdaterStatus =
| 'disabled'
| 'idle'
| 'checking'
| 'available'
| 'downloading'
| 'downloaded'
| 'not-available'
| 'error';
export interface UpdaterState {
status: UpdaterStatus;
currentVersion: string;
latestVersion?: string;
downloadProgress?: number;
releaseDate?: string;
error?: string;
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const isDev = !app.isPackaged;
let updaterState: UpdaterState = {
status: isDev ? 'disabled' : 'idle',
currentVersion: app.getVersion(),
};
let autoUpdateEnabled = true;
let updateCheckTimer: ReturnType<typeof setInterval> | null = null;
let lastUpdateCheckAt = 0;
const MacGitHubUpdateProvider = class {
constructor(options: unknown, updater: unknown, runtimeOptions: unknown) {
const provider = new (GitHubProvider as any)(options, updater, runtimeOptions) as any;
if (process.platform === 'darwin') {
provider.getDefaultChannelName = () =>
process.arch === 'arm64' ? 'latest-mac-arm64' : 'latest-mac';
provider.getCustomChannelName = (channel: string) => channel;
}
return provider;
}
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function getUpdaterState(): UpdaterState {
return updaterState;
}
export function getAutoUpdateEnabled(): boolean {
return autoUpdateEnabled;
}
export function setAutoUpdateEnabled(enabled: boolean): void {
autoUpdateEnabled = enabled;
}
export function broadcastUpdaterState(): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('updater:state', updaterState);
}
}
}
export function setUpdaterState(next: Partial<UpdaterState>): void {
updaterState = {
...updaterState,
...next,
currentVersion: app.getVersion(),
};
broadcastUpdaterState();
}
export async function checkForAppUpdates(force = false): Promise<void> {
if (isDev) return;
const now = Date.now();
if (!force && now - lastUpdateCheckAt < 60 * 1000) {
return;
}
lastUpdateCheckAt = now;
try {
await autoUpdater.checkForUpdates();
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
setUpdaterState({ status: 'error', error });
}
}
export function clearUpdateTimer(): void {
if (updateCheckTimer) {
clearInterval(updateCheckTimer);
updateCheckTimer = null;
}
}
export function startUpdateTimer(): void {
if (updateCheckTimer) return;
updateCheckTimer = setInterval(
() => {
void checkForAppUpdates(false);
},
60 * 60 * 1000,
);
updateCheckTimer.unref();
}
export function quitAndInstall(): boolean {
if (!isDev && updaterState.status === 'downloaded') {
autoUpdater.quitAndInstall();
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
export function setupAutoUpdater(): void {
if (isDev) return;
if (process.platform === 'darwin') {
// macOS needs a custom provider to select arm64 vs x64 channel
autoUpdater.setFeedURL({
provider: 'custom',
updateProvider: MacGitHubUpdateProvider as any,
owner: GITHUB_OWNER,
repo: GITHUB_REPO,
releaseType: 'release',
} as any);
} else {
// Windows/Linux: use standard GitHub provider (reads from electron-builder.yml publish config)
autoUpdater.setFeedURL({
provider: 'github',
owner: GITHUB_OWNER,
repo: GITHUB_REPO,
releaseType: 'release',
});
}
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.allowPrerelease = true;
// Windows: custom signature verification for self-signed certificate.
// The default verifier requires the cert to be in the Windows trusted root
// store, which self-signed certs are not. This custom function still verifies
// the publisher name from the Authenticode signature — it just skips the
// trust chain check. This is NOT disabling verification.
if (process.platform === 'win32') {
const nsisUpdater = autoUpdater as NsisUpdater;
nsisUpdater.verifyUpdateCodeSignature = async (
publisherNames: string[],
tempUpdateFile: string,
): Promise<string | null> => {
try {
const { stdout } = await execFileAsync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-AuthenticodeSignature '${tempUpdateFile.replace(/'/g, "''")}').SignerCertificate.Subject`,
],
{ timeout: 30_000 },
);
const subject = stdout.trim();
if (!subject) {
return 'The update file is not signed.';
}
for (const name of publisherNames) {
if (subject.includes(name)) {
return null; // Publisher name matches — verification passed
}
}
return `Publisher mismatch. Expected: ${publisherNames.join(', ')}. Got: ${subject}`;
} catch (err) {
return `Signature verification failed: ${err instanceof Error ? err.message : String(err)}`;
}
};
}
autoUpdater.on('checking-for-update', () => {
setUpdaterState({ status: 'checking', error: undefined, downloadProgress: undefined });
});
autoUpdater.on('update-available', (info) => {
setUpdaterState({
status: 'available',
latestVersion: info.version,
releaseDate: info.releaseDate,
error: undefined,
});
});
autoUpdater.on('download-progress', (progress) => {
setUpdaterState({
status: 'downloading',
downloadProgress: Math.round(progress.percent),
error: undefined,
});
});
autoUpdater.on('update-downloaded', (info) => {
setUpdaterState({
status: 'downloaded',
latestVersion: info.version,
releaseDate: info.releaseDate,
downloadProgress: 100,
error: undefined,
});
});
autoUpdater.on('update-not-available', (info) => {
setUpdaterState({
status: 'not-available',
latestVersion: info.version,
downloadProgress: undefined,
error: undefined,
});
});
autoUpdater.on('error', (err) => {
setUpdaterState({
status: 'error',
error: err?.message ?? String(err),
});
});
if (autoUpdateEnabled) {
// Delay first check until app startup work is done.
setTimeout(() => {
void checkForAppUpdates(true);
}, 5000);
startUpdateTimer();
}
}