mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
Merge feat/auto-update: add auto-update functionality
This commit is contained in:
3
dev-app-update.yml
Normal file
3
dev-app-update.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
provider: github
|
||||
owner: multica-ai
|
||||
repo: multica
|
||||
@@ -39,5 +39,6 @@ appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
provider: github
|
||||
owner: multica-ai
|
||||
repo: 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",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
|
||||
9700
pnpm-lock.yaml
generated
9700
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,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({
|
||||
@@ -121,6 +123,32 @@ app.whenReady().then(async () => {
|
||||
|
||||
mainWindow = createWindow()
|
||||
|
||||
// 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
122
src/main/updater/index.ts
Normal 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)
|
||||
}
|
||||
@@ -103,7 +103,19 @@ const electronAPI: ElectronAPI = {
|
||||
},
|
||||
|
||||
// Terminal
|
||||
runInTerminal: (command: string) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RUN, command)
|
||||
runInTerminal: (command: string) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RUN, command),
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Expose API to renderer via contextBridge
|
||||
|
||||
@@ -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'
|
||||
@@ -170,6 +170,9 @@ function AppContent(): React.JSX.Element {
|
||||
|
||||
{/* Toast notifications */}
|
||||
<Toaster position="bottom-right" />
|
||||
|
||||
{/* Update notification */}
|
||||
<UpdateNotification />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
114
src/renderer/src/components/UpdateNotification.tsx
Normal file
114
src/renderer/src/components/UpdateNotification.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export { ChatView } from './ChatView'
|
||||
export { MessageInput } from './MessageInput'
|
||||
export { StatusBar } from './StatusBar'
|
||||
export { Settings } from './Settings'
|
||||
export { UpdateNotification } from './UpdateNotification'
|
||||
|
||||
27
src/shared/electron-api.d.ts
vendored
27
src/shared/electron-api.d.ts
vendored
@@ -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>
|
||||
@@ -177,6 +198,12 @@ export interface ElectronAPI {
|
||||
|
||||
// Terminal
|
||||
runInTerminal(command: string): Promise<void>
|
||||
|
||||
// Auto-update
|
||||
checkForUpdates(): Promise<void>
|
||||
downloadUpdate(): Promise<void>
|
||||
installUpdate(): Promise<void>
|
||||
onUpdateStatus(callback: (status: UpdateStatus) => void): () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -49,7 +49,13 @@ export const IPC_CHANNELS = {
|
||||
FS_OPEN_WITH: 'fs:open-with',
|
||||
|
||||
// Terminal
|
||||
TERMINAL_RUN: 'terminal:run'
|
||||
TERMINAL_RUN: 'terminal:run',
|
||||
|
||||
// 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]
|
||||
|
||||
Reference in New Issue
Block a user