export interface UpdateAsset {
download_url: string;
zip_url?: string;
}
export interface UpdateInfo {
name: string;
tag_name: string;
release_notes: string;
assets: {
linux?: {
deb?: UpdateAsset;
rpm?: UpdateAsset;
};
macos?: {
x64?: UpdateAsset;
arm64?: UpdateAsset;
};
win32?: {
x64?: UpdateAsset;
};
};
}
export type UpdateStatus = {type: 'idle'}
| {type: 'checking'}
| {type: 'update-available', updateInfo: UpdateInfo}
| {type: 'downloading', progress: number}
| {type: 'restarting'}
| {type: 'error', error: string}
| {type: 'idle'}
export class AutoUpdater {
private checkInterval: NodeJS.Timeout | null = null
private updateUrl: string
private checkIntervalMs: number
private currentUpdateInfo: UpdateInfo | null = null
private status: UpdateStatus = {type: 'idle'}
constructor(updateUrl: string, checkIntervalMs: number = 3600000) {
this.updateUrl = updateUrl
this.checkIntervalMs = checkIntervalMs
this.currentUpdateInfo = null
this.status = {type: 'idle'}
ipcMain.on('auto-update:download-and-install', () => {
log.info('[AUTO-UPDATE] Received download and install request')
if (this.currentUpdateInfo) {
const asset = this.getAssetForCurrentPlatform(this.currentUpdateInfo)
if (asset?.download_url) {
this.downloadAndInstall(asset.download_url)
} else {
log.error('[AUTO-UPDATE] No compatible update found for download')
}
} else {
log.error('[AUTO-UPDATE] No update info available for download')
}
})
ipcMain.on('auto-update:set-status', (_, status: UpdateStatus) => {
this.status = status
const win = BrowserWindow.getFocusedWindow()
if (win) {
win.webContents.send('auto-update:status', this.status)
}
})
ipcMain.on('auto-update:release-notes', () => {
log.info('[AUTO-UPDATE] Received release notes request')
if (this.currentUpdateInfo) {
this.showReleaseNotes()
}
})
}
private showReleaseNotes() {
log.info('[AUTO-UPDATE] Showing release notes')
if (this.currentUpdateInfo?.release_notes) {
dialog.showMessageBoxSync({
type: 'info',
title: 'Update Available',
message: `${this.currentUpdateInfo?.name}`,
detail:
this.currentUpdateInfo.release_notes || 'No release notes available',
buttons: ['OK'],
})
} else {
log.info('[AUTO-UPDATE] No release notes available')
}
}
async checkForUpdates(): Promise<void> {
log.info('[AUTO-UPDATE] Checking for updates...')
const win = BrowserWindow.getFocusedWindow()
if (!win) {
log.error('[AUTO-UPDATE] No window found')
return
}
this.status = {type: 'checking'}
win.webContents.send('auto-update:status', this.status)
try {
const response = await fetch(this.updateUrl)
const updateInfo: UpdateInfo = await response.json()
log.info(
`[AUTO-UPDATE] Current version: ${app.getVersion()}, Latest version: ${
updateInfo.name
}`,
)
if (this.shouldUpdate(updateInfo.name)) {
log.info(
'[AUTO-UPDATE] New version available, initiating update process',
)
this.status = {type: 'update-available', updateInfo: updateInfo}
win.webContents.send('auto-update:status', this.status)
this.currentUpdateInfo = updateInfo
await this.handleUpdate(updateInfo)
} else {
log.info('[AUTO-UPDATE] Application is up to date')
this.status = {type: 'idle'}
win.webContents.send('auto-update:status', this.status)
this.currentUpdateInfo = null
}
} catch (error) {
log.error(`[AUTO-UPDATE] Error checking for updates: ${error}`)
this.status = {type: 'error', error: JSON.stringify(error)}
win.webContents.send('auto-update:status', this.status)
this.currentUpdateInfo = null
}
}
private shouldUpdate(newVersion: string): boolean {
const currentVersion = app.getVersion()
const shouldUpdate = this.compareVersions(newVersion, currentVersion) > 0
log.info(`[AUTO-UPDATE] Update needed: ${shouldUpdate}`)
return shouldUpdate
}
private compareVersions(v1: string, v2: string): number {
log.info(`[AUTO-UPDATE] Comparing versions: ${v1} vs ${v2}`)
const [v1Base, v1Dev] = v1.split('-dev.')
const [v2Base, v2Dev] = v2.split('-dev.')
const v1Parts = v1Base.split('.').map(Number)
const v2Parts = v2Base.split('.').map(Number)
for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) {
const v1Part = v1Parts[i] || 0
const v2Part = v2Parts[i] || 0
if (v1Part > v2Part) return 1
if (v1Part < v2Part) return -1
}
if (v1Base === v2Base) {
if (!v1Dev && v2Dev) return 1
if (v1Dev && !v2Dev) return -1
if (v1Dev && v2Dev) {
const v1DevNum = parseInt(v1Dev)
const v2DevNum = parseInt(v2Dev)
return v1DevNum - v2DevNum
}
return 0
}
return 0
}
private async handleUpdate(updateInfo: UpdateInfo): Promise<void> {
log.info('[AUTO-UPDATE] Handling update process')
const asset = this.getAssetForCurrentPlatform(updateInfo)
if (!asset?.download_url) {
log.error('[AUTO-UPDATE] No compatible update found')
return
}
log.info('[AUTO-UPDATE] Sending event to renderer')
const win = BrowserWindow.getFocusedWindow()
if (!win) {
log.error('[AUTO-UPDATE] No window found')
return
}
this.status = {type: 'update-available', updateInfo: updateInfo}
win.webContents.send('auto-update:status', this.status)
}
private getAssetForCurrentPlatform(
updateInfo: UpdateInfo,
): UpdateAsset | null {
log.info(`[AUTO-UPDATE] Getting asset for platform: ${process.platform}`)
if (process.platform === 'linux') {
const isRpm = fs.existsSync('/etc/redhat-release')
log.info(`[AUTO-UPDATE] Linux package type: ${isRpm ? 'RPM' : 'DEB'}`)
return isRpm
? updateInfo.assets.linux?.rpm || null
: updateInfo.assets.linux?.deb || null
} else if (process.platform === 'darwin') {
log.info('[AUTO-UPDATE] Platform: macOS')
log.info(`[AUTO-UPDATE] Architecture: ${process.arch}`)
return updateInfo.assets.macos?.[process.arch as 'x64' | 'arm64'] || null
}
log.warn('[AUTO-UPDATE] Platform not supported')
return null
}
private async downloadAndInstall(downloadUrl: string): Promise<void> {
log.info(`[AUTO-UPDATE] Starting download from: ${downloadUrl}`)
const tempPath = path.join(app.getPath('temp'), 'update')
if (!fs.existsSync(tempPath)) {
fs.mkdirSync(tempPath, {recursive: true})
}
console.log(`== [AUTO-UPDATE] downloadAndInstall ~ tempPath:`, tempPath)
const win = BrowserWindow.getFocusedWindow()
console.log(`== [AUTO-UPDATE] downloadAndInstall ~ win:`, win?.id)
if (!win) return
try {
log.info('[AUTO-UPDATE] Downloading update...')
this.status = {type: 'downloading', progress: 0}
win.webContents.send('auto-update:status', this.status)
session.defaultSession.downloadURL(downloadUrl)
session.defaultSession.on('will-download', (event: any, item: any) => {
const filePath = path.join(app.getPath('downloads'), item.getFilename())
item.setSavePath(filePath)
item.on('updated', (_event: any, state: any) => {
if (state === 'progressing') {
if (item.isPaused()) {
log.info('[AUTO-UPDATE] Download paused')
} else {
const received = item.getReceivedBytes()
const total = item.getTotalBytes()
const progress = Math.round((received / total) * 100)
log.info(`[AUTO-UPDATE] Download progress: ${progress}%`)
this.status = {type: 'downloading', progress: progress}
win.webContents.send('auto-update:status', this.status)
}
}
})
item.once('done', async (event: any, state: any) => {
if (state === 'completed') {
this.status = {type: 'restarting'}
win.webContents.send('auto-update:status', this.status)
log.info(`[AUTO-UPDATE] Download successfully saved to ${filePath}`)
if (process.platform === 'darwin') {
const {exec} = require('child_process')
const util = require('util')
const execPromise = util.promisify(exec)
const fs = require('fs/promises')
const volumePath = '/Volumes/Seed'
const appName = IS_PROD_DEV ? 'SeedDev.app' : 'Seed.app'
const tempPath = path.join(app.getPath('temp'), 'SeedUpdate')
try {
log.info(
`[AUTO-UPDATE] Creating temp directory at: ${tempPath}`,
)
try {
await fs.mkdir(tempPath, {recursive: true})
} catch (err) {
log.error(
`[AUTO-UPDATE] Error creating temp directory: ${err}`,
)
throw err
}
log.info('[AUTO-UPDATE] Mounting DMG...')
await execPromise(`hdiutil attach "${filePath}"`)
const scriptPath = path.join(tempPath, 'update.sh')
log.info(
`[AUTO-UPDATE] Creating update script at: ${scriptPath}`,
)
const scriptContent = `#!/bin/bash
sleep 2
# rm -rf "/Applications/${appName}"
# cp -R "${tempPath}/${appName}" "/Applications/"
# rm -rf "${tempPath}"
open "/Applications/${appName}"
`
try {
await fs.writeFile(scriptPath, scriptContent, {mode: 0o755})
log.info('[AUTO-UPDATE] Update script created successfully')
} catch (err) {
log.error(
`[AUTO-UPDATE] Error creating update script: ${err}`,
)
throw err
}
log.info('[AUTO-UPDATE] Executing update script...')
exec(`"${scriptPath}"`, {detached: true, stdio: 'ignore'})
app.quit()
} catch (error) {
log.error(`[AUTO-UPDATE] Installation error: ${error}`)
try {
await execPromise(`hdiutil detach "${volumePath}" || true`)
} catch (cleanupError) {
log.error(`[AUTO-UPDATE] Cleanup error: ${cleanupError}`)
}
}
} else if (process.platform === 'linux') {
try {
const {exec} = require('child_process')
const util = require('util')
const execPromise = util.promisify(exec)
const fs = require('fs/promises')
const isRpm = filePath.endsWith('.rpm')
const packageName = IS_PROD_DEV ? 'seed-dev' : 'seed'
const removeCmd = isRpm ? 'rpm -e' : 'dpkg -r'
const installCmd = isRpm ? 'rpm -U' : 'dpkg -i'
const appName = IS_PROD_DEV ? 'seed-dev' : 'seed'
const tempPath = path.join(app.getPath('temp'), 'SeedUpdate')
await fs.mkdir(tempPath, {recursive: true})
const scriptPath = path.join(tempPath, 'update.sh')
log.info(
`[AUTO-UPDATE] Creating update script at: ${scriptPath}`,
)
const scriptContent = `#!/bin/bash
sleep 2
# Remove existing package
pkexec ${removeCmd} ${packageName}
# Install new package
pkexec ${installCmd} "${filePath}"
# Clean up
rm -rf "${tempPath}"
rm -f "${filePath}"
# Start the new version
${appName}
`
try {
await fs.writeFile(scriptPath, scriptContent, {mode: 0o755})
log.info('[AUTO-UPDATE] Update script created successfully')
} catch (err) {
log.error(
`[AUTO-UPDATE] Error creating update script: ${err}`,
)
throw err
}
log.info('[AUTO-UPDATE] Executing update script...')
exec(`"${scriptPath}"`, {detached: true, stdio: 'ignore'})
app.quit()
} catch (error) {
log.error(`[AUTO-UPDATE] Installation error: ${error}`)
this.status = {type: 'error', error: 'Installation error'}
win.webContents.send('auto-update:status', this.status)
}
}
log.info(`[AUTO-UPDATE] Download failed: ${state}`)
this.status = {type: 'error', error: 'Download failed'}
win.webContents.send('auto-update:status', this.status)
}
})
})
} catch (error) {
this.status = {type: 'error', error: 'Download error'}
win.webContents.send('auto-update:status', this.status)
log.error(`[AUTO-UPDATE] Download error: ${error}`)
}
}
startAutoCheck(): void {
log.info(
`[AUTO-UPDATE] Starting auto-check with interval: ${this.checkIntervalMs}ms`,
)
this.checkInterval = setInterval(() => {
this.checkForUpdates()
}, this.checkIntervalMs)
}
stopAutoCheck(): void {
log.info('[AUTO-UPDATE] Stopping auto-check')
if (this.checkInterval) {
clearInterval(this.checkInterval)
this.checkInterval = null
}
}
setCheckInterval(ms: number): void {
log.info(`[AUTO-UPDATE] Setting new check interval: ${ms}ms`)
this.checkIntervalMs = ms
if (this.checkInterval) {
this.stopAutoCheck()
this.startAutoCheck()
}
}
}