merge: resolve conflicts with origin/main

Keep both features:
- App focus event (HEAD)
- Auto-update functionality (origin/main)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-01-16 18:03:18 +08:00
12 changed files with 363 additions and 5 deletions

3
dev-app-update.yml Normal file
View File

@@ -0,0 +1,3 @@
provider: github
owner: multica-ai
repo: multica

View File

@@ -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

View File

@@ -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",

35
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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()

122
src/main/updater/index.ts Normal file
View File

@@ -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<void> {
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<void> {
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)
}

View File

@@ -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<typeof callback>[0])
ipcRenderer.on(IPC_CHANNELS.UPDATE_STATUS, listener)
return () => ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_STATUS, listener)
}
}

View File

@@ -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 */}
<Toaster position="bottom-right" />
{/* Update notification */}
<UpdateNotification />
</div>
)
}

View File

@@ -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<UpdateStatus | null>(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 (
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 fade-in duration-300">
<div className="flex items-center gap-3 rounded-lg border bg-card p-3 shadow-lg">
{/* Icon */}
<div
className={`flex h-8 w-8 items-center justify-center rounded-full ${isError ? 'bg-destructive/10' : 'bg-primary/10'}`}
>
{isError ? (
<AlertCircle className="h-4 w-4 text-destructive" />
) : updateStatus.status === 'downloaded' ? (
<CheckCircle className="h-4 w-4 text-primary" />
) : updateStatus.status === 'downloading' ? (
<RefreshCw className="h-4 w-4 text-primary animate-spin" />
) : (
<Download className="h-4 w-4 text-primary" />
)}
</div>
{/* Content */}
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{isError
? 'Update failed'
: updateStatus.status === 'downloaded'
? 'Update ready'
: updateStatus.status === 'downloading'
? 'Downloading update...'
: 'Update available'}
</span>
<span className="text-xs text-muted-foreground">
{isError
? 'Please download manually from GitHub'
: updateStatus.status === 'downloading' && updateStatus.progress
? `${Math.round(updateStatus.progress.percent)}%`
: version
? `Version ${version}`
: 'New version available'}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-1 ml-2">
{updateStatus.status === 'available' && (
<Button size="sm" variant="default" onClick={handleDownload}>
Download
</Button>
)}
{updateStatus.status === 'downloaded' && (
<Button size="sm" variant="default" onClick={handleInstall}>
Restart
</Button>
)}
{isError && (
<Button
size="sm"
variant="outline"
onClick={() =>
window.open('https://github.com/multica-ai/multica/releases', '_blank')
}
>
View Releases
</Button>
)}
{updateStatus.status !== 'downloading' && (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={handleDismiss}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
)
}

View File

@@ -4,3 +4,4 @@ export { ChatView } from './ChatView'
export { MessageInput } from './MessageInput'
export { StatusBar } from './StatusBar'
export { Settings } from './Settings'
export { UpdateNotification } from './UpdateNotification'

View File

@@ -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<RunningSessionsStatus>
@@ -180,6 +201,12 @@ export interface ElectronAPI {
// App lifecycle
onAppFocus(callback: () => void): () => void
// Auto-update
checkForUpdates(): Promise<void>
downloadUpdate(): Promise<void>
installUpdate(): Promise<void>
onUpdateStatus(callback: (status: UpdateStatus) => void): () => void
}
declare global {

View File

@@ -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]