diff --git a/main/src/auto-update.ts b/main/src/auto-update.ts index ffd9d780..075b216e 100644 --- a/main/src/auto-update.ts +++ b/main/src/auto-update.ts @@ -1,5 +1,6 @@ import { app, autoUpdater, dialog, ipcMain, type BrowserWindow } from 'electron' import { updateElectronApp } from 'update-electron-app' +import * as Sentry from '@sentry/electron/main' import { stopAllServers } from './graceful-exit' import { stopToolhive, @@ -12,26 +13,8 @@ import { getAppVersion, pollWindowReady } from './util' import { delay } from '../../utils/delay' import log from './logger' import { setQuittingState, setTearingDownState } from './app-state' -import { - isCurrentVersionOlder, - normalizeVersion, -} from '../../utils/parse-release-version' import Store from 'electron-store' - -interface ReleaseAsset { - name: string - url: string - size: number - sha256: string -} - -export interface ReleaseInfo { - tag: string - prerelease: boolean - published_at: string - base_url: string - assets: ReleaseAsset[] -} +import { fetchLatestRelease } from './utils/toolhive-version' const store = new Store<{ isAutoUpdateEnabled: boolean @@ -45,40 +28,6 @@ let updateState: | 'installing' | 'none' = 'none' -/** - * Gets all download assets for the current platform - * @param releaseInfo - The release information from the API - * @returns The tag of the release - */ -function getAssetsTagByPlatform(releaseInfo: ReleaseInfo): string | undefined { - const platform = process.platform - - // Map platform to asset name patterns - const assetPatterns: Record = { - darwin: ['darwin-arm64', 'darwin-x64'], - win32: ['win32-x64', 'Setup.exe'], - linux: ['linux-x64', 'amd64'], - } - - const patterns = assetPatterns[platform] - if (!patterns) { - log.error(`[update] Unsupported platform: ${platform}`) - return - } - - const assets = releaseInfo.assets.filter((asset) => { - const assetName = asset.name.toLowerCase() - return patterns.some((pattern) => assetName.includes(pattern.toLowerCase())) - }) - - if (assets.length > 0) { - return releaseInfo.tag - } else { - log.error(`[update] No assets found for patterns: ${patterns.join(', ')}`) - return - } -} - async function safeServerShutdown(): Promise { try { const port = getToolhivePort() @@ -97,233 +46,563 @@ async function safeServerShutdown(): Promise { } } -async function performUpdateInstallation(releaseName: string | null) { +async function installUpdateAndRestart() { if (updateState === 'installing') { - log.warn('[update] Update installation already in progress') - return + log.warn('[update] Update installation already in progress via IPC') + return { success: false, error: 'Update already in progress' } } - log.info(`[update] installing update to version: ${releaseName || 'unknown'}`) - updateState = 'installing' + log.info( + `[update] installing update and restarting application via IPC ${pendingUpdateVersion || 'unknown'}` + ) try { - // Remove event listeners to avoid interference - log.info('[update] removing quit listeners to avoid interference') - app.removeAllListeners('before-quit') - app.removeAllListeners('will-quit') + await performUpdateInstallation({ + releaseName: pendingUpdateVersion, + installByNotification: true, + }) + return { success: true } + } catch (error) { + log.error('[update] IPC update installation failed: ', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } +} - setQuittingState(true) - setTearingDownState(true) +function handleUpdateError({ + message, + rootSpan, + rootFinish, +}: { + message: Error + rootSpan: Sentry.Span + rootFinish: () => void +}) { + Sentry.startSpan( + { + name: 'Auto-update error', + op: 'update.error', + parentSpan: rootSpan, + attributes: { + 'analytics.source': 'tracking', + 'analytics.type': 'event', + update_flow: 'true', + update_state: updateState, + toolhive_running: `${isToolhiveRunning()}`, + }, + }, + (startSpan) => { + try { + log.error( + '[update] there was a problem updating the application: ', + message + ) + log.info('[update] update state: ', updateState) + log.info('[update] toolhive binary is running: ', isToolhiveRunning()) - log.info('[update] starting graceful shutdown before update...') + startSpan.setStatus({ + code: 2, + message: message instanceof Error ? message.message : String(message), + }) + updateState = 'none' - // Notify renderer of graceful exit - const mainWindow = mainWindowGetter?.() - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('graceful-exit') - // Give renderer time to handle the event - await delay(500) + // Finish init span with error when update fails + rootSpan.setStatus({ + code: 2, + message: 'Update failed', + }) + rootFinish() + } catch (error) { + log.error('[update] error during auto-update error:', error) + startSpan.setStatus({ + code: 2, + message: error instanceof Error ? error.message : 'Unknown error', + }) + } } + ) +} + +function handleUpdateNotAvailable({ + rootSpan, + rootFinish, +}: { + rootSpan: Sentry.Span + rootFinish: () => void +}) { + Sentry.startSpan( + { + name: 'Update not available', + op: 'update.not_available', + parentSpan: rootSpan, + attributes: { + update_flow: 'true', + 'analytics.source': 'tracking', + 'analytics.type': 'event', + current_version: getAppVersion(), + update_state: updateState, + toolhive_running: `${isToolhiveRunning()}`, + }, + }, + () => { + if (updateState === 'downloading') { + log.warn( + '[update] update became unavailable during download - ignoring' + ) + return + } + log.info('[update] no update available') + updateState = 'none' - const shutdownSuccess = await safeServerShutdown() - if (!shutdownSuccess) { - log.warn('[update] Server shutdown failed, proceeding with update anyway') + rootSpan.setStatus({ code: 1 }) + rootFinish() } + ) +} - try { - stopToolhive() - log.info('[update] ToolHive stopped') - } catch (error) { - log.error('[update] Error stopping ToolHive: ', error) +function handleUpdateAvailable(rootSpan: Sentry.Span) { + Sentry.startSpan( + { + name: 'Update available', + op: 'update.available', + parentSpan: rootSpan, + attributes: { + update_flow: 'true', + 'analytics.source': 'tracking', + 'analytics.type': 'event', + current_version: getAppVersion(), + update_state: updateState, + toolhive_running: `${isToolhiveRunning()}`, + }, + }, + () => { + log.info('[update] update is available, starting download...') + updateState = 'downloading' } + ) +} - safeTrayDestroy() +function handleUpdateChecking(rootSpan: Sentry.Span) { + Sentry.startSpan( + { + name: 'Checking for update', + op: 'update.checking', + parentSpan: rootSpan, + attributes: { + update_flow: 'true', + 'analytics.source': 'tracking', + 'analytics.type': 'event', + current_version: getAppVersion(), + toolhive_running: `${isToolhiveRunning()}`, + update_state: updateState, + }, + }, + () => { + log.info('[update] checking for updates...') + updateState = 'checking' + } + ) +} - log.info('[update] all cleaned up, calling autoUpdater.quitAndInstall()...') - autoUpdater.quitAndInstall() +async function handleUpdateDownloaded({ + rootSpan, + rootFinish, + releaseName, + isAutoUpdateEnabled, +}: { + rootSpan: Sentry.Span + rootFinish: () => void + releaseName: string | null + isAutoUpdateEnabled: boolean +}) { + // Phase 1: Prepare dialog (fast, technical operation) + const mainWindow = await Sentry.startSpanManual( + { + name: 'Prepare update dialog', + op: 'update.downloaded', + parentSpan: rootSpan, + attributes: { + 'analytics.source': 'tracking', + 'analytics.type': 'event', + update_flow: 'true', + release_name: releaseName || 'unknown', + update_state: updateState, + toolhive_running: `${isToolhiveRunning()}`, + is_auto_update_enabled: `${isAutoUpdateEnabled}`, + }, + }, + async (span, finish): Promise => { + try { + if (updateState === 'installing') { + log.warn( + '[update] Update already in progress, ignoring duplicate event' + ) + span.setStatus({ + code: 2, + message: 'Update already in progress', + }) + finish() + return null + } + + log.info('[update] downloaded - preparing dialog') + pendingUpdateVersion = releaseName + updateState = 'downloaded' + + let window = mainWindowGetter?.() + + // check if destroyed is important for not crashing the app + if (!window || window.isDestroyed()) { + log.warn( + '[update] MainWindow not available, recreating for update dialog' + ) + if (!windowCreator) { + log.error('[update] Window creator not initialized') + span.setStatus({ + code: 2, + message: 'Window creator not initialized', + }) + finish() + return null + } + window = await windowCreator() + await pollWindowReady(window) + } + + if (window.isMinimized() && !window.isDestroyed()) { + window.restore() + } + + span.setStatus({ code: 1 }) + finish() + return window + } catch (error) { + log.error('[update] Failed to prepare update dialog:', error) + span.setStatus({ + code: 2, + message: + error instanceof Error + ? `Failed to prepare dialog: ${error.message}` + : 'Failed to prepare dialog', + }) + finish() + return null + } + } + ) + + if (!mainWindow || mainWindow.isDestroyed()) { + // Fallback: send notification to renderer + const fallbackWindow = mainWindowGetter?.() + if (fallbackWindow && !fallbackWindow.isDestroyed()) { + fallbackWindow.webContents.send('update-downloaded') + } + return + } + + // Phase 2: Show dialog and wait for user decision (user interaction time) + const dialogOpts = { + type: 'info' as const, + buttons: ['Restart', 'Later'], + cancelId: 1, + defaultId: 0, + title: `Release ${releaseName}`, + message: + process.platform === 'darwin' + ? `Release ${releaseName}` + : 'A new version has been downloaded.\nThe update will be applied on the next application restart.', + detail: + process.platform === 'darwin' + ? 'A new version has been downloaded.\nThe update will be applied on the next application restart.' + : `Ready to install ${releaseName}`, + icon: undefined, + } + + let userChoice: 'restart' | 'later' | 'error' = 'error' + + try { + const returnValue = await dialog.showMessageBox(mainWindow, dialogOpts) + userChoice = returnValue.response === 0 ? 'restart' : 'later' } catch (error) { - log.error('[update] error during update installation:', error) - updateState = 'none' + log.error('[update] Dialog error in update-downloaded handler:', error) + userChoice = 'error' + // Fallback: send notification + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('update-downloaded') + } + } - // Attempt recovery + // Track user decision with timing (separate span, not blocking) + Sentry.startSpan( + { + name: `User update decision ${userChoice}`, + op: `update.user_decision`, + parentSpan: rootSpan, + attributes: { + 'analytics.source': 'tracking', + 'analytics.type': 'event', + update_flow: 'true', + release_name: releaseName || 'unknown', + user_choice: userChoice, + update_state: updateState, + toolhive_running: `${isToolhiveRunning()}`, + is_auto_update_enabled: `${isAutoUpdateEnabled}`, + }, + }, + () => { + log.info(`[update] User decision: ${userChoice}`) + } + ) + + // Phase 3: Handle user choice + if (userChoice === 'restart') { + rootSpan.setStatus({ code: 1 }) + rootFinish() try { - safeTrayDestroy() - log.info('[update] attempting app relaunch after update failure') - // this creates a new app instance - app.relaunch() - // this quits the current instance - app.quit() - } catch (recoveryError) { - log.error('[update] recovery failed: ', recoveryError) - // Force quit as last resort - process.exit(1) + await performUpdateInstallation({ + releaseName: releaseName || 'unknown', + rootSpan, + rootFinish, + }) + } catch (error) { + // Error already logged and recovery attempted in performUpdateInstallation + log.error('[update] Installation failed after recovery attempt:', error) + } + } else { + updateState = 'none' + log.info( + '[update] user deferred update installation - showing toast notification' + ) + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('update-downloaded') } - throw error + rootSpan.setStatus({ code: 1 }) + rootFinish() } } -let mainWindowGetter: (() => BrowserWindow | null) | null = null -let windowCreator: (() => Promise) | null = null - -export function initAutoUpdate({ - isManualUpdate = false, - mainWindowGetter: getterParam, - windowCreator: creatorParam, +async function performUpdateInstallation({ + releaseName, + installByNotification = false, + rootSpan, + rootFinish, }: { - isManualUpdate?: boolean - mainWindowGetter: () => BrowserWindow | null - windowCreator: () => Promise + releaseName: string | null + installByNotification?: boolean + rootSpan?: Sentry.Span + rootFinish?: () => void }) { - // Always save references first, so manualUpdate() can work even if auto-update is disabled - mainWindowGetter = getterParam - windowCreator = creatorParam - - const isAutoUpdateEnabled = store.get('isAutoUpdateEnabled') - if (!isAutoUpdateEnabled && !isManualUpdate) { - log.info('[update] Auto update is disabled, skipping initialization') - return - } + return Sentry.startSpanManual( + { + name: 'Auto-update installation', + op: 'update.install', + ...(rootSpan ? { parentSpan: rootSpan } : {}), + attributes: { + 'analytics.source': 'tracking', + 'analytics.type': 'event', + update_flow: 'true', + release_name: releaseName || 'unknown', + update_state: updateState, + is_installing_by_ui_notification: `${installByNotification}`, + toolhive_running: `${isToolhiveRunning()}`, + }, + }, + async (span, finish) => { + if (updateState === 'installing') { + log.warn('[update] Update installation already in progress') + span.setStatus({ code: 2, message: 'Already in progress' }) + finish() + if (rootFinish) { + rootFinish() + } + return + } - resetAllUpdateState() + log.info( + `[update] installing update to version: ${releaseName || 'unknown'}` + ) + updateState = 'installing' - // Remove any existing listeners to prevent duplicates - autoUpdater.removeAllListeners() - ipcMain.removeHandler('install-update-and-restart') + try { + // Remove event listeners to avoid interference + log.info('[update] removing quit listeners to avoid interference') + app.removeAllListeners('before-quit') + app.removeAllListeners('will-quit') - updateElectronApp({ logger: log, notifyUser: false }) + setQuittingState(true) + setTearingDownState(true) - autoUpdater.on('update-downloaded', async (_, __, releaseName) => { - if (updateState === 'installing') { - log.warn('[update] Update already in progress, ignoring duplicate event') - return - } + log.info('[update] starting graceful shutdown before update...') - log.info('[update] downloaded - showing dialog') - pendingUpdateVersion = releaseName - updateState = 'downloaded' + // Notify renderer of graceful exit + const mainWindow = mainWindowGetter?.() + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('graceful-exit') + // Give renderer time to handle the event + await delay(500) + } - try { - let mainWindow = mainWindowGetter?.() + const shutdownSuccess = await safeServerShutdown() + if (!shutdownSuccess) { + log.warn( + '[update] Server shutdown failed, proceeding with update anyway' + ) + } - // check if destroyed is important for not crashing the app - if (!mainWindow || mainWindow.isDestroyed()) { - log.warn( - '[update] MainWindow not available, recreating for update dialog' - ) try { - if (!windowCreator) { - log.error('[update] Window creator not initialized') - return - } - mainWindow = await windowCreator() - await pollWindowReady(mainWindow) + stopToolhive() + log.info('[update] ToolHive stopped') } catch (error) { - log.error( - '[update] Failed to create window for update dialog: ', - error - ) - // Fallback: send notification to existing renderer if available - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('update-downloaded') - } - return + log.error('[update] Error stopping ToolHive: ', error) } - } - if (mainWindow.isMinimized() && !mainWindow.isDestroyed()) { - mainWindow.restore() - } + safeTrayDestroy() - const dialogOpts = { - type: 'info' as const, - buttons: ['Restart', 'Later'], - cancelId: 1, - defaultId: 0, - title: `Release ${releaseName}`, - message: - process.platform === 'darwin' - ? `Release ${releaseName}` - : 'A new version has been downloaded.\nThe update will be applied on the next application restart.', - detail: - process.platform === 'darwin' - ? 'A new version has been downloaded.\nThe update will be applied on the next application restart.' - : `Ready to install ${releaseName}`, - icon: undefined, - } + log.info('[update] all cleaned up, preparing for quit...') + + span.setStatus({ code: 1 }) + finish() + if (rootFinish) { + rootFinish() + } - const returnValue = await dialog.showMessageBox(mainWindow, dialogOpts) + try { + const flushResult = await Sentry.flush(2000) // Wait max 2 seconds for Sentry to send data + log.info(`[update] Sentry flush completed: ${flushResult}`) + } catch (error) { + log.warn('[update] Sentry flush error:', error) + } - if (returnValue.response === 0) { - await performUpdateInstallation(releaseName) - } else { + log.info('[update] calling autoUpdater.quitAndInstall()...') + autoUpdater.quitAndInstall() + } catch (error) { + log.error('[update] error during update installation:', error) + span.setStatus({ + code: 2, + message: + error instanceof Error + ? `[update] error during update installation, ${error?.name} - ${error?.message}` + : `[update] error during update installation ${JSON.stringify(error)}`, + }) + finish() + if (rootFinish) { + rootFinish() + } updateState = 'none' - log.info( - '[update] user deferred update installation - showing toast notification' - ) - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('update-downloaded') + + // Attempt recovery + try { + safeTrayDestroy() + log.info('[update] attempting app relaunch after update failure') + // this creates a new app instance + app.relaunch() + // this quits the current instance + app.quit() + } catch (recoveryError) { + log.error('[update] recovery failed: ', recoveryError) + // Force quit as last resort + process.exit(1) } - } - } catch (error) { - log.error('[update] error in update-downloaded handler:', error) - updateState = 'none' - // Fallback: send notification to renderer - const mainWindow = mainWindowGetter?.() - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('update-downloaded') + + throw error } } - }) + ) +} - autoUpdater.on('checking-for-update', () => { - log.info('[update] checking for updates...') - updateState = 'checking' - }) +let mainWindowGetter: (() => BrowserWindow | null) | null = null +let windowCreator: (() => Promise) | null = null - autoUpdater.on('update-available', () => { - updateState = 'downloading' - }) +export function initAutoUpdate({ + isManualUpdate = false, + mainWindowGetter: getterParam, + windowCreator: creatorParam, +}: { + isManualUpdate?: boolean + mainWindowGetter: () => BrowserWindow | null + windowCreator: () => Promise +}) { + const isAutoUpdateEnabled = store.get('isAutoUpdateEnabled') + return Sentry.startSpanManual( + { + name: 'Auto-update initialization', + op: 'update.init', + attributes: { + 'analytics.source': 'tracking', + 'analytics.type': 'event', + update_flow: 'true', + is_manual_update: isManualUpdate, + update_state: updateState, + toolhive_running: `${isToolhiveRunning()}`, + is_auto_update_enabled: `${isAutoUpdateEnabled}`, + }, + }, + async (rootSpan, rootFinish) => { + try { + // Always save references first, so manualUpdate() can work even if auto-update is disabled + mainWindowGetter = getterParam + windowCreator = creatorParam + + if (!isAutoUpdateEnabled && !isManualUpdate) { + log.info('[update] Auto update is disabled, skipping initialization') + rootSpan.setStatus({ code: 1 }) + rootFinish() + return + } - autoUpdater.on('update-not-available', () => { - if (updateState === 'downloading') { - log.warn('[update] update became unavailable during download - ignoring') - return - } - updateState = 'none' - }) + resetAllUpdateState() - autoUpdater.on('error', (message) => { - log.error( - '[update] there was a problem updating the application: ', - message - ) - log.info('[update] update state: ', updateState) - log.info('[update] toolhive binary is running: ', isToolhiveRunning()) - updateState = 'none' - }) + // Remove any existing listeners to prevent duplicates + autoUpdater.removeAllListeners() + ipcMain.removeHandler('install-update-and-restart') - ipcMain.handle('install-update-and-restart', async () => { - if (updateState === 'installing') { - log.warn('[update] Update installation already in progress via IPC') - return { success: false, error: 'Update already in progress' } - } + updateElectronApp({ logger: log, notifyUser: false }) - log.info( - `[update] installing update and restarting application via IPC ${pendingUpdateVersion || 'unknown'}` - ) + autoUpdater.on('update-downloaded', async (_, __, releaseName) => { + handleUpdateDownloaded({ + rootSpan, + releaseName, + isAutoUpdateEnabled, + rootFinish, + }) + }) - try { - await performUpdateInstallation(pendingUpdateVersion) - return { success: true } - } catch (error) { - log.error('[update] IPC update installation failed: ', error) - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', + autoUpdater.on('checking-for-update', () => + handleUpdateChecking(rootSpan) + ) + + autoUpdater.on('update-available', () => + handleUpdateAvailable(rootSpan) + ) + + autoUpdater.on('update-not-available', () => + handleUpdateNotAvailable({ rootSpan, rootFinish }) + ) + + autoUpdater.on('error', (message) => + handleUpdateError({ message, rootSpan, rootFinish }) + ) + + ipcMain.handle('install-update-and-restart', async () => + installUpdateAndRestart() + ) + + rootSpan.setStatus({ code: 1 }) + } catch (error) { + log.error('[update] error during initialization:', error) + rootSpan.setStatus({ + code: 2, + message: error instanceof Error ? error.message : 'Unknown error', + }) + rootFinish() + + throw error } } - }) + ) } ipcMain.handle('is-update-in-progress', () => { @@ -386,78 +665,97 @@ export function getIsAutoUpdateEnabled() { return store.get('isAutoUpdateEnabled') } -function isCurrentVersionPrerelease( - currentVersion: string, - releaseData: ReleaseInfo -): boolean { - return ( - currentVersion.includes('-beta') || - currentVersion.includes('-alpha') || - currentVersion.includes('-rc') || - releaseData.prerelease === true - ) -} - export async function getLatestAvailableVersion() { - const currentVersion = getAppVersion() - try { - const response = await fetch( - 'https://stacklok.github.io/toolhive-studio/latest', - { - headers: { - 'Content-Type': 'application/json', - }, - } - ) - if (!response.ok) { - log.error( - '[update] Failed to check for ToolHive update: ', - response.statusText - ) - return { - currentVersion: currentVersion, - latestVersion: undefined, - isNewVersionAvailable: false, + const isAutoUpdateEnabled = store.get('isAutoUpdateEnabled') + return Sentry.startSpanManual( + { + name: 'Check for latest version', + op: 'update.get_latest_version', + attributes: { + 'analytics.source': 'tracking', + 'analytics.type': 'event', + update_flow: 'true', + current_version: getAppVersion(), + update_state: updateState, + toolhive_running: `${isToolhiveRunning()}`, + is_auto_update_enabled: `${isAutoUpdateEnabled}`, + }, + }, + async (span, finish) => { + const currentVersion = getAppVersion() + try { + const { latestVersion, isNewVersionAvailable, currentVersion } = + await fetchLatestRelease(span) + + return { + currentVersion, + latestVersion, + isNewVersionAvailable, + } + } catch (error) { + log.error('[update] Failed to check for ToolHive update: ', error) + span.setStatus({ + code: 2, + message: error instanceof Error ? error.message : 'Unknown error', + }) + return { + currentVersion: currentVersion, + latestVersion: undefined, + isNewVersionAvailable: false, + } + } finally { + finish() } } - const data = await response.json() - const latestTag = getAssetsTagByPlatform(data) - - // If current version is a prerelease (contains -beta, -alpha, -rc) OR data.prerelease is true, - // always consider it as older than ANY latest version - // This allows testing updates even when running a prerelease build - const currentIsPrerelease = isCurrentVersionPrerelease(currentVersion, data) - - const isNewVersion = currentIsPrerelease - ? latestTag !== undefined // If prerelease, any latest tag is considered new - : isCurrentVersionOlder(currentVersion, normalizeVersion(latestTag ?? '')) - - return { - currentVersion: currentVersion, - latestVersion: latestTag, - isNewVersionAvailable: isNewVersion, - } - } catch (error) { - log.error('[update] Failed to check for ToolHive update: ', error) - return { - currentVersion: currentVersion, - latestVersion: undefined, - isNewVersionAvailable: false, - } - } + ) } export function manualUpdate() { - if (!mainWindowGetter || !windowCreator) { - log.error( - '[update] Cannot perform manual update: initAutoUpdate was not called first' - ) - return - } + const isAutoUpdateEnabled = store.get('isAutoUpdateEnabled') + return Sentry.startSpanManual( + { + name: 'Manual update triggered', + op: 'update.manual', + attributes: { + 'analytics.source': 'tracking', + 'analytics.type': 'event', + update_flow: 'true', + is_manual_update: 'true', + update_state: updateState, + toolhive_running: `${isToolhiveRunning()}`, + is_auto_update_enabled: `${isAutoUpdateEnabled}`, + }, + }, + async (span, finish) => { + try { + if (!mainWindowGetter || !windowCreator) { + log.error( + '[update] Cannot perform manual update: initAutoUpdate was not called first' + ) + span.setStatus({ code: 2, message: 'References not initialized' }) + finish() + return + } - initAutoUpdate({ - isManualUpdate: true, - mainWindowGetter, - windowCreator, - }) + initAutoUpdate({ + isManualUpdate: true, + mainWindowGetter, + windowCreator, + }) + span.setStatus({ code: 1 }) + } catch (error) { + log.error('[update] error during manual update:', error) + span.setStatus({ + code: 2, + message: + error instanceof Error + ? error.message + : `Unknown error during manual update ${JSON.stringify(error)}`, + }) + throw error + } finally { + finish() + } + } + ) } diff --git a/main/src/main-window.ts b/main/src/main-window.ts index f724ac1a..e7e42e35 100644 --- a/main/src/main-window.ts +++ b/main/src/main-window.ts @@ -77,7 +77,6 @@ async function loadWindowContent(window: BrowserWindow): Promise { `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html` ) await window.loadFile(filePath) - log.info('Loaded production HTML file') } } catch (error) { logError('Failed to load window content', error) @@ -206,8 +205,6 @@ export function focusMainWindow(): void { mainWindow!.show() mainWindow!.focus() - - log.info('Main window focused successfully') } catch (error) { logError('Failed to focus main window', error) } diff --git a/main/src/tests/auto-update.test.ts b/main/src/tests/auto-update.test.ts index fb177955..fb583b62 100644 --- a/main/src/tests/auto-update.test.ts +++ b/main/src/tests/auto-update.test.ts @@ -11,7 +11,6 @@ import { setAutoUpdateEnabled, manualUpdate, getLatestAvailableVersion, - type ReleaseInfo, } from '../auto-update' vi.mock('electron', () => { @@ -56,6 +55,42 @@ vi.mock('electron', () => { } }) +vi.mock('@sentry/electron/main', () => ({ + captureMessage: vi.fn(), + captureException: vi.fn(), + flush: vi.fn(() => Promise.resolve(true)), + spanToJSON: vi.fn(() => ({ + span_id: 'mock-span-id', + trace_id: 'mock-trace-id', + parent_span_id: undefined, + })), + startSpan: vi.fn((_, callback) => { + const mockSpan = { + setStatus: vi.fn(), + setAttribute: vi.fn(), + addLink: vi.fn(), + spanContext: vi.fn(() => ({ + spanId: 'mock-span-id', + traceId: 'mock-trace-id', + })), + } + return callback(mockSpan) + }), + startSpanManual: vi.fn((_, callback) => { + const mockSpan = { + setStatus: vi.fn(), + setAttribute: vi.fn(), + addLink: vi.fn(), + spanContext: vi.fn(() => ({ + spanId: 'mock-span-id', + traceId: 'mock-trace-id', + })), + } + const mockFinish = vi.fn() + return callback(mockSpan, mockFinish) + }), +})) + // Mock update-electron-app vi.mock('update-electron-app', () => ({ updateElectronApp: vi.fn(), @@ -365,7 +400,7 @@ describe('auto-update', () => { await vi.runAllTimersAsync() expect(vi.mocked(log).error).toHaveBeenCalledWith( - '[update] Failed to create window for update dialog: ', + '[update] Failed to prepare update dialog:', expect.any(Error) ) }) @@ -618,7 +653,7 @@ describe('auto-update', () => { await vi.runAllTimersAsync() expect(vi.mocked(log).error).toHaveBeenCalledWith( - '[update] error in update-downloaded handler:', + '[update] Dialog error in update-downloaded handler:', expect.any(Error) ) }) @@ -952,7 +987,7 @@ describe('auto-update', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { value: 'darwin' }) - const mockResponse: ReleaseInfo = { + const mockResponse = { tag: 'v1.5.0', prerelease: false, published_at: '2024-01-01', @@ -979,7 +1014,7 @@ describe('auto-update', () => { }) it('returns no update when latest version is not available for platform', async () => { - const mockResponse: ReleaseInfo = { + const mockResponse = { tag: 'v1.5.0', prerelease: false, published_at: '2024-01-01', diff --git a/main/src/utils/toolhive-version.ts b/main/src/utils/toolhive-version.ts new file mode 100644 index 00000000..f202941a --- /dev/null +++ b/main/src/utils/toolhive-version.ts @@ -0,0 +1,118 @@ +import { + isCurrentVersionOlder, + normalizeVersion, +} from '../../../utils/parse-release-version' +import { getAppVersion } from '../util' +import log from '../logger' +import * as Sentry from '@sentry/electron/main' + +interface ReleaseAsset { + name: string + url: string + size: number + sha256: string +} + +interface ReleaseInfo { + tag: string + prerelease: boolean + published_at: string + base_url: string + assets: ReleaseAsset[] +} + +/** + * Gets all download assets for the current platform + * @param releaseInfo - The release information from the API + * @returns The tag of the release + */ +function getAssetsTagByPlatform(releaseInfo: ReleaseInfo): string | undefined { + const platform = process.platform + + // Map platform to asset name patterns + const assetPatterns: Record = { + darwin: ['darwin-arm64', 'darwin-x64'], + win32: ['win32-x64', 'Setup.exe'], + linux: ['linux-x64', 'amd64'], + } + + const patterns = assetPatterns[platform] + if (!patterns) { + log.error(`[update] Unsupported platform: ${platform}`) + return + } + + const assets = releaseInfo.assets.filter((asset) => { + const assetName = asset.name.toLowerCase() + return patterns.some((pattern) => assetName.includes(pattern.toLowerCase())) + }) + + if (assets.length > 0) { + return releaseInfo.tag + } else { + log.error(`[update] No assets found for patterns: ${patterns.join(', ')}`) + return + } +} + +function isCurrentVersionPrerelease( + currentVersion: string, + releaseData: ReleaseInfo +): boolean { + return ( + currentVersion.includes('-beta') || + currentVersion.includes('-alpha') || + currentVersion.includes('-rc') || + releaseData.prerelease === true + ) +} + +export async function fetchLatestRelease(span: Sentry.Span) { + const currentVersion = getAppVersion() + log.info('[update] checking github pages for ToolHive update...') + const response = await fetch( + 'https://stacklok.github.io/toolhive-studio/latest', + { + headers: { + 'Content-Type': 'application/json', + }, + } + ) + if (!response.ok) { + log.error( + '[update] Failed to check for ToolHive update: ', + response.statusText + ) + span.setStatus({ + code: 2, + message: `HTTP ${response.status}: ${response.statusText}`, + }) + return { + currentVersion: currentVersion, + latestVersion: undefined, + isNewVersionAvailable: false, + } + } + + const data = await response.json() + const latestTag = getAssetsTagByPlatform(data) + + // If current version is a prerelease (contains -beta, -alpha, -rc) OR data.prerelease is true, + // always consider it as older than ANY latest version + // This allows testing updates even when running a prerelease build + const currentIsPrerelease = isCurrentVersionPrerelease(currentVersion, data) + + const isNewVersion = currentIsPrerelease + ? latestTag !== undefined // If prerelease, any latest tag is considered new + : isCurrentVersionOlder(currentVersion, normalizeVersion(latestTag ?? '')) + + span.setAttribute('latest_version', latestTag ?? 'unknown') + span.setAttribute('is_new_version_available', isNewVersion) + span.setStatus({ code: 1 }) + + return { + currentVersion: currentVersion, + latestVersion: latestTag, + isNewVersionAvailable: isNewVersion, + } +} diff --git a/renderer/src/common/hooks/use-mcp-secrets.ts b/renderer/src/common/hooks/use-mcp-secrets.ts index 1c5c1fb3..631859fb 100644 --- a/renderer/src/common/hooks/use-mcp-secrets.ts +++ b/renderer/src/common/hooks/use-mcp-secrets.ts @@ -66,12 +66,7 @@ export async function saveMCPSecrets( // confusion when many secrets are being created in quick succession. // The delay is between 100 and 500ms await new Promise((resolve) => - setTimeout( - resolve, - process.env.NODE_ENV === 'test' - ? 0 - : Math.floor(Math.random() * 401) + 100 - ) + setTimeout(resolve, Math.floor(Math.random() * 401) + 100) ) createdSecrets.push({ /** The name of the secret in the secret store */