diff --git a/dev-app-update.yml b/dev-app-update.yml new file mode 100644 index 000000000..95ca79f94 --- /dev/null +++ b/dev-app-update.yml @@ -0,0 +1,3 @@ +provider: github +owner: multica-ai +repo: multica diff --git a/electron-builder.yml b/electron-builder.yml index 80c2ee8ff..86112010d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -19,6 +19,7 @@ nsis: uninstallDisplayName: ${productName} createDesktopShortcut: always mac: + artifactName: ${productName}-${version}-${arch}-mac.${ext} entitlementsInherit: build/entitlements.mac.plist extendInfo: - NSCameraUsageDescription: Application requests access to the device's camera. @@ -39,5 +40,6 @@ appImage: artifactName: ${name}-${version}.${ext} npmRebuild: false publish: - provider: generic - url: https://example.com/auto-updates + provider: github + owner: multica-ai + repo: multica diff --git a/package.json b/package.json index 13297eaf8..560ff6232 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multica", - "version": "0.1.1", + "version": "0.1.2", "description": "Multiplexed Information and Computing Agent - A GUI client that brings coding agent capabilities to everyone", "main": "./out/main/index.js", "author": "Multica", @@ -45,6 +45,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "electron-updater": "^6.7.3", "fix-path": "^5.0.0", "lucide-react": "^0.562.0", "react-markdown": "^10.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f0b1b9d0..f8216a027 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + electron-updater: + specifier: ^6.7.3 + version: 6.7.3 fix-path: specifier: ^5.0.0 version: 5.0.0 @@ -2073,6 +2076,9 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-updater@6.7.3: + resolution: {integrity: sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==} + electron-vite@5.0.0: resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2947,6 +2953,13 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3909,6 +3922,9 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -6193,6 +6209,19 @@ snapshots: electron-to-chromium@1.5.267: {} + electron-updater@6.7.3: + dependencies: + builder-util-runtime: 9.5.1 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.3 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron-vite@5.0.0(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@babel/core': 7.28.6 @@ -7254,6 +7283,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.escaperegexp@4.1.2: {} + + lodash.isequal@4.5.0: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -8547,6 +8580,8 @@ snapshots: dependencies: semver: 5.7.2 + tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} tinyexec@1.0.2: {} diff --git a/src/main/index.ts b/src/main/index.ts index a655fff92..892657d03 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,11 +13,13 @@ import { Conductor } from './conductor/Conductor' import { IPC_CHANNELS } from '../shared/ipc-channels' import type { PermissionResponse } from '../shared/electron-api' import { PermissionManager } from './permission' +import { createUpdater, AutoUpdater } from './updater' // Global instances let conductor: Conductor let mainWindow: BrowserWindow | null = null let permissionManager: PermissionManager +let updater: AutoUpdater function createWindow(): BrowserWindow { const window = new BrowserWindow({ @@ -134,6 +136,32 @@ app.whenReady().then(async () => { } }) + // Initialize auto-updater + // Set FORCE_DEV_UPDATE=true to test updates in dev mode + const forceDevUpdate = process.env.FORCE_DEV_UPDATE === 'true' + updater = createUpdater(forceDevUpdate) + updater.setMainWindow(() => mainWindow) + + // Auto-check for updates (in production or when forced) + if (!is.dev || forceDevUpdate) { + mainWindow.once('ready-to-show', () => { + updater.checkForUpdates() + }) + } + + // Register update IPC handlers + ipcMain.handle(IPC_CHANNELS.UPDATE_CHECK, async () => { + await updater.checkForUpdates() + }) + + ipcMain.handle(IPC_CHANNELS.UPDATE_DOWNLOAD, async () => { + await updater.downloadUpdate() + }) + + ipcMain.handle(IPC_CHANNELS.UPDATE_INSTALL, () => { + updater.quitAndInstall() + }) + app.on('activate', function () { // On macOS it's common to re-create a window when dock icon is clicked if (BrowserWindow.getAllWindows().length === 0) createWindow() diff --git a/src/main/updater/index.ts b/src/main/updater/index.ts new file mode 100644 index 000000000..7e4b5c01a --- /dev/null +++ b/src/main/updater/index.ts @@ -0,0 +1,122 @@ +/** + * Auto-updater module using electron-updater + * Checks for updates from GitHub releases and handles download/install + */ +import { autoUpdater, UpdateInfo, ProgressInfo } from 'electron-updater' +import { BrowserWindow } from 'electron' +import { IPC_CHANNELS } from '../../shared/ipc-channels' + +export interface UpdateStatus { + status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error' + info?: UpdateInfo + progress?: ProgressInfo + error?: string +} + +export class AutoUpdater { + private mainWindow: (() => BrowserWindow | null) | null = null + + constructor(forceDevUpdateConfig = false) { + // Configure auto-updater + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = true + + // Enable update checking in dev mode for testing + if (forceDevUpdateConfig) { + autoUpdater.forceDevUpdateConfig = true + console.log('[AutoUpdater] Force dev update config enabled') + } + + // Enable logging + autoUpdater.logger = { + info: (msg) => console.log('[AutoUpdater]', msg), + warn: (msg) => console.warn('[AutoUpdater]', msg), + error: (msg) => console.error('[AutoUpdater]', msg), + debug: (msg) => console.log('[AutoUpdater:debug]', msg) + } + + // Set up event handlers + autoUpdater.on('checking-for-update', () => { + this.sendStatus({ status: 'checking' }) + }) + + autoUpdater.on('update-available', (info: UpdateInfo) => { + this.sendStatus({ status: 'available', info }) + }) + + autoUpdater.on('update-not-available', (info: UpdateInfo) => { + this.sendStatus({ status: 'not-available', info }) + }) + + autoUpdater.on('download-progress', (progress: ProgressInfo) => { + this.sendStatus({ status: 'downloading', progress }) + }) + + autoUpdater.on('update-downloaded', (info: UpdateInfo) => { + this.sendStatus({ status: 'downloaded', info }) + }) + + autoUpdater.on('error', (err: Error) => { + this.sendStatus({ status: 'error', error: err.message }) + }) + } + + /** + * Set the main window reference for sending IPC messages + */ + setMainWindow(getWindow: () => BrowserWindow | null): void { + this.mainWindow = getWindow + } + + /** + * Check for updates + */ + async checkForUpdates(): Promise { + try { + await autoUpdater.checkForUpdates() + } catch (err) { + console.error('[AutoUpdater] Check for updates failed:', err) + this.sendStatus({ + status: 'error', + error: err instanceof Error ? err.message : 'Unknown error' + }) + } + } + + /** + * Download the available update + */ + async downloadUpdate(): Promise { + try { + await autoUpdater.downloadUpdate() + } catch (err) { + console.error('[AutoUpdater] Download update failed:', err) + this.sendStatus({ + status: 'error', + error: err instanceof Error ? err.message : 'Download failed' + }) + } + } + + /** + * Quit and install the downloaded update + */ + quitAndInstall(): void { + autoUpdater.quitAndInstall() + } + + /** + * Send update status to renderer + */ + private sendStatus(status: UpdateStatus): void { + const window = this.mainWindow?.() + if (window && !window.isDestroyed()) { + window.webContents.send(IPC_CHANNELS.UPDATE_STATUS, status) + } + } +} + +// Factory function to create updater with options +export function createUpdater(forceDevUpdateConfig = false): AutoUpdater { + return new AutoUpdater(forceDevUpdateConfig) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index e6e88a384..32134163a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -110,6 +110,18 @@ const electronAPI: ElectronAPI = { const listener = () => callback() ipcRenderer.on(IPC_CHANNELS.APP_FOCUS, listener) return () => ipcRenderer.removeListener(IPC_CHANNELS.APP_FOCUS, listener) + }, + + // Auto-update + checkForUpdates: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_CHECK), + downloadUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_DOWNLOAD), + installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_INSTALL), + + onUpdateStatus: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, status: unknown) => + callback(status as Parameters[0]) + ipcRenderer.on(IPC_CHANNELS.UPDATE_STATUS, listener) + return () => ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_STATUS, listener) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 941a2f93c..bfea9122c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,7 +3,7 @@ */ import { useState } from 'react' import { useApp } from './hooks/useApp' -import { ChatView, MessageInput, StatusBar } from './components' +import { ChatView, MessageInput, StatusBar, UpdateNotification } from './components' import { AppSidebar } from './components/AppSidebar' import { Modals } from './components/Modals' import { ThemeProvider } from './contexts/ThemeContext' @@ -181,6 +181,9 @@ function AppContent(): React.JSX.Element { {/* Toast notifications */} + + {/* Update notification */} + ) } diff --git a/src/renderer/src/components/UpdateNotification.tsx b/src/renderer/src/components/UpdateNotification.tsx new file mode 100644 index 000000000..3b37e746e --- /dev/null +++ b/src/renderer/src/components/UpdateNotification.tsx @@ -0,0 +1,118 @@ +/** + * Update notification component + * Shows when a new version is available and allows user to download/install + */ +import { useState, useEffect } from 'react' +import { Download, RefreshCw, X, CheckCircle, AlertCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { UpdateStatus } from '../../../shared/electron-api' + +export function UpdateNotification(): React.JSX.Element | null { + const [updateStatus, setUpdateStatus] = useState(null) + const [dismissed, setDismissed] = useState(false) + + useEffect(() => { + const unsubscribe = window.electronAPI.onUpdateStatus((status) => { + setUpdateStatus(status) + // Reset dismissed state when a new update becomes available + if (status.status === 'available') { + setDismissed(false) + } + }) + + return () => unsubscribe() + }, []) + + const handleDownload = async () => { + await window.electronAPI.downloadUpdate() + } + + const handleInstall = () => { + window.electronAPI.installUpdate() + } + + const handleDismiss = () => { + setDismissed(true) + } + + // Don't show if dismissed or no relevant status + if (dismissed) return null + if (!updateStatus) return null + if (updateStatus.status === 'checking' || updateStatus.status === 'not-available') return null + + const version = updateStatus.info?.version + const isError = updateStatus.status === 'error' + + return ( +
+
+ {/* Icon */} +
+ {isError ? ( + + ) : updateStatus.status === 'downloaded' ? ( + + ) : updateStatus.status === 'downloading' ? ( + + ) : ( + + )} +
+ + {/* Content */} +
+ + {isError + ? 'Update failed' + : updateStatus.status === 'downloaded' + ? 'Update ready' + : updateStatus.status === 'downloading' + ? 'Downloading update...' + : 'Update available'} + + + {isError + ? 'Please download manually from GitHub' + : updateStatus.status === 'downloading' && updateStatus.progress + ? `${Math.round(updateStatus.progress.percent)}%` + : version + ? `Version ${version}` + : 'New version available'} + +
+ + {/* Actions */} +
+ {updateStatus.status === 'available' && ( + + )} + {updateStatus.status === 'downloaded' && ( + + )} + {isError && ( + + )} + {updateStatus.status !== 'downloading' && ( + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts index 18eba1924..46e27f08e 100644 --- a/src/renderer/src/components/index.ts +++ b/src/renderer/src/components/index.ts @@ -4,3 +4,4 @@ export { ChatView } from './ChatView' export { MessageInput } from './MessageInput' export { StatusBar } from './StatusBar' export { Settings } from './Settings' +export { UpdateNotification } from './UpdateNotification' diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 65e8ac614..22092c56c 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -127,6 +127,27 @@ export interface InstallResult { error?: string } +// Auto-update types +export interface UpdateInfo { + version: string + releaseDate?: string + releaseNotes?: string | null +} + +export interface UpdateProgress { + percent: number + bytesPerSecond: number + total: number + transferred: number +} + +export interface UpdateStatus { + status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error' + info?: UpdateInfo + progress?: UpdateProgress + error?: string +} + export interface ElectronAPI { // Agent status (per-session agents) getAgentStatus(): Promise @@ -180,6 +201,12 @@ export interface ElectronAPI { // App lifecycle onAppFocus(callback: () => void): () => void + + // Auto-update + checkForUpdates(): Promise + downloadUpdate(): Promise + installUpdate(): Promise + onUpdateStatus(callback: (status: UpdateStatus) => void): () => void } declare global { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index cdca6454b..59d83c4d9 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -52,7 +52,13 @@ export const IPC_CHANNELS = { TERMINAL_RUN: 'terminal:run', // App lifecycle - APP_FOCUS: 'app:focus' + APP_FOCUS: 'app:focus', + + // Auto-update + UPDATE_CHECK: 'update:check', + UPDATE_DOWNLOAD: 'update:download', + UPDATE_INSTALL: 'update:install', + UPDATE_STATUS: 'update:status' } as const export type IPCChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]