Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/electron/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ files:
extraMetadata:
main: dist/main.cjs

# Auto-update is disabled until we have an owned update server.
# Auto-update publish metadata is generated from the active brand config.

# Disable ASAR to avoid decompression overhead and click delays
asar: false
Expand Down
45 changes: 34 additions & 11 deletions apps/electron/src/main/auto-update.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* Auto-update module using electron-updater
*
* Auto-update is disabled for this fork until we have an update server we own.
* Keep the public API in place so renderer/main callers fail closed instead of
* accidentally reaching the upstream Craft update feed.
* Auto-update is enabled only for packaged builds whose active brand declares
* a brand-owned update source. Development builds keep the same public API but
* skip network update checks.
*
* Platform behavior:
* - macOS: Downloads zip, extracts and swaps app bundle atomically
Expand All @@ -21,6 +21,7 @@ import * as path from 'path'
import * as fs from 'fs'
import { mainLog } from './logger'
import { getAppVersion } from '@craft-agent/shared/version'
import { BRAND } from '@craft-agent/shared/branding'
import {
getDismissedUpdateVersion,
clearDismissedUpdateVersion,
Expand All @@ -33,7 +34,8 @@ import type { EventSink } from '@craft-agent/server-core/transport'
const PLATFORM = platform()
const IS_MAC = PLATFORM === 'darwin'
const IS_WINDOWS = PLATFORM === 'win32'
const AUTO_UPDATE_ENABLED = false
const UPDATE_SOURCE = BRAND.updates
const AUTO_UPDATE_ENABLED = app.isPackaged && !!UPDATE_SOURCE

// Get the update cache directory path (for file watcher fallback on macOS)
// electron-updater uses these paths:
Expand Down Expand Up @@ -112,11 +114,11 @@ function broadcastDownloadProgress(progress: number): void {

// ─── Configure electron-updater ───────────────────────────────────────────────

// Do not download or install updates until an owned update server is configured.
// Download updates only for packaged builds with a brand-owned update source.
autoUpdater.autoDownload = AUTO_UPDATE_ENABLED

// Prevent cached upstream downloads from being applied on quit.
autoUpdater.autoInstallOnAppQuit = AUTO_UPDATE_ENABLED
autoUpdater.allowPrerelease = false
autoUpdater.allowDowngrade = false

// Use the logger for electron-updater internal logging
autoUpdater.logger = {
Expand Down Expand Up @@ -316,6 +318,11 @@ function checkForExistingDownload(): { exists: boolean; version?: string } {
*/
export async function checkForUpdates(options: CheckOptions = {}): Promise<UpdateInfo> {
if (!AUTO_UPDATE_ENABLED) {
mainLog.info('[auto-update] Skipping update check', {
packaged: app.isPackaged,
brand: BRAND.id,
hasUpdateSource: !!UPDATE_SOURCE,
})
updateInfo = {
available: false,
currentVersion: getAppVersion(),
Expand All @@ -327,6 +334,13 @@ export async function checkForUpdates(options: CheckOptions = {}): Promise<Updat
}

const { autoDownload = true } = options
mainLog.info('[auto-update] Checking stable release feed', {
brand: BRAND.id,
provider: UPDATE_SOURCE.provider,
owner: UPDATE_SOURCE.owner,
repo: UPDATE_SOURCE.repo,
autoDownload,
})

// Temporarily override autoDownload for this check if needed
// (e.g., manual check from settings shouldn't auto-download on metered connections)
Expand Down Expand Up @@ -382,7 +396,7 @@ export async function checkForUpdates(options: CheckOptions = {}): Promise<Updat
*/
export async function installUpdate(): Promise<void> {
if (!AUTO_UPDATE_ENABLED) {
throw new Error('Auto-update is disabled')
throw new Error('Auto-update is not available for this build')
}

if (updateInfo.downloadState !== 'ready') {
Expand Down Expand Up @@ -430,11 +444,20 @@ export interface UpdateOnLaunchResult {
*/
export async function checkForUpdatesOnLaunch(): Promise<UpdateOnLaunchResult> {
if (!AUTO_UPDATE_ENABLED) {
mainLog.info('[auto-update] Skipping auto-update; disabled until an owned update server is configured')
return { action: 'skipped', reason: 'disabled' }
mainLog.info('[auto-update] Skipping launch update check', {
packaged: app.isPackaged,
brand: BRAND.id,
hasUpdateSource: !!UPDATE_SOURCE,
})
return { action: 'skipped', reason: app.isPackaged ? 'unconfigured' : 'development' }
}

mainLog.info('[auto-update] Checking for updates on launch...')
mainLog.info('[auto-update] Checking for updates on launch...', {
brand: BRAND.id,
provider: UPDATE_SOURCE.provider,
owner: UPDATE_SOURCE.owner,
repo: UPDATE_SOURCE.repo,
})

const info = await checkForUpdates({ autoDownload: true })

Expand Down
84 changes: 76 additions & 8 deletions apps/electron/src/renderer/hooks/useUpdateChecker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { APP_VERSION } from '@craft-agent/shared/branding'
import type { UpdateInfo } from '../../shared/types'

Expand All @@ -11,6 +11,8 @@ interface UseUpdateCheckerResult {
isDownloading: boolean
/** Whether update is ready to install */
isReadyToInstall: boolean
/** Whether a manual update check is running */
isChecking: boolean
/** Download progress (0-100) */
downloadProgress: number
/** Check for updates manually */
Expand All @@ -28,18 +30,84 @@ const DISABLED_UPDATE_INFO: UpdateInfo = {
}

export function useUpdateChecker(): UseUpdateCheckerResult {
const [updateInfo, setUpdateInfo] = useState<UpdateInfo>(DISABLED_UPDATE_INFO)
const [isChecking, setIsChecking] = useState(false)

useEffect(() => {
let cancelled = false

window.electronAPI.getUpdateInfo()
.then((info) => {
if (!cancelled) setUpdateInfo(info)
})
.catch((error) => {
if (cancelled) return
setUpdateInfo({
...DISABLED_UPDATE_INFO,
downloadState: 'error',
error: error instanceof Error ? error.message : 'Unable to read update state',
})
})

const unsubscribeInfo = window.electronAPI.onUpdateAvailable((info) => {
setUpdateInfo(info)
})

const unsubscribeProgress = window.electronAPI.onUpdateDownloadProgress((progress) => {
setUpdateInfo((current) => ({
...current,
downloadState: current.downloadState === 'ready' ? 'ready' : 'downloading',
downloadProgress: progress,
}))
})

return () => {
cancelled = true
unsubscribeInfo()
unsubscribeProgress()
}
}, [])

const installUpdate = useCallback(async () => {
throw new Error('Auto-update is disabled')
try {
await window.electronAPI.installUpdate()
} catch (error) {
setUpdateInfo((current) => ({
...current,
downloadState: 'error',
error: error instanceof Error ? error.message : 'Unable to install update',
}))
throw error
}
}, [])

const checkForUpdates = useCallback(async () => {
setIsChecking(true)
try {
const info = await window.electronAPI.checkForUpdates()
setUpdateInfo(info)
} catch (error) {
setUpdateInfo((current) => ({
...current,
downloadState: 'error',
error: error instanceof Error ? error.message : 'Unable to check for updates',
}))
} finally {
setIsChecking(false)
}
}, [])

const checkForUpdates = useCallback(async () => undefined, [])
const derived = useMemo(() => ({
updateAvailable: updateInfo.available,
isDownloading: updateInfo.downloadState === 'downloading',
isReadyToInstall: updateInfo.downloadState === 'ready',
downloadProgress: updateInfo.downloadProgress,
}), [updateInfo])

return {
updateInfo: DISABLED_UPDATE_INFO,
updateAvailable: false,
isDownloading: false,
isReadyToInstall: false,
downloadProgress: 0,
updateInfo,
...derived,
isChecking,
checkForUpdates,
installUpdate,
}
Expand Down
103 changes: 103 additions & 0 deletions apps/electron/src/renderer/pages/settings/AppSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Settings:
* - Notifications
* - Network (proxy)
* - Updates
* - About (version)
*
* Note: AI settings (connections, model, thinking) have been moved to AiSettingsPage.
Expand All @@ -19,6 +20,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { HeaderMenu } from '@/components/ui/HeaderMenu'
import { routes } from '@/lib/navigate'
import { useUpdateChecker } from '@/hooks/useUpdateChecker'
import { Spinner } from '@craft-agent/ui'
import { APP_VERSION } from '@craft-agent/shared/branding'
import type { DetailsPageMeta } from '@/lib/navigation-registry'
Expand Down Expand Up @@ -103,6 +105,15 @@ export default function AppSettingsPage() {

// Tools state
const [browserToolEnabled, setBrowserToolEnabled] = useState(true)
const {
updateInfo,
isChecking,
isDownloading,
isReadyToInstall,
downloadProgress,
checkForUpdates,
installUpdate,
} = useUpdateChecker()

// Proxy state
const [proxyForm, setProxyForm] = useState<ProxyFormState>(EMPTY_PROXY_FORM)
Expand Down Expand Up @@ -185,6 +196,48 @@ export default function AppSettingsPage() {
setProxyError(undefined)
}, [savedProxyForm])

const currentVersion = updateInfo?.currentVersion ?? APP_VERSION
const latestVersion = updateInfo?.latestVersion
const isInstallingUpdate = updateInfo?.downloadState === 'installing'
const updateActionDisabled = isChecking || isDownloading || isInstallingUpdate
const updateStatusDescription = (() => {
if (updateInfo?.downloadState === 'error') {
return updateInfo.error || t("settings.updates.errorDesc")
}
if (isInstallingUpdate) {
return t("settings.updates.installingDesc")
}
if (isReadyToInstall) {
return t("settings.updates.readyDesc", { version: latestVersion ?? '' })
}
if (isDownloading) {
return t("settings.updates.downloadingDesc", { progress: downloadProgress })
}
if (updateInfo?.available && latestVersion) {
return t("settings.updates.availableDesc", { version: latestVersion })
}
if (latestVersion) {
return t("settings.updates.upToDateDesc", { version: currentVersion })
}
return t("settings.updates.idleDesc")
})()

const handleUpdateAction = useCallback(async () => {
if (isReadyToInstall) {
await installUpdate()
return
}
await checkForUpdates()
}, [checkForUpdates, installUpdate, isReadyToInstall])

const updateActionLabel = (() => {
if (isInstallingUpdate) return t("settings.updates.installing")
if (isReadyToInstall) return t("settings.updates.restartToUpdate")
if (isDownloading) return t("settings.updates.downloading")
if (isChecking) return t("settings.updates.checking")
return t("settings.updates.check")
})()

return (
<div className="h-full flex flex-col">
<PanelHeader title={t("settings.app.title")} actions={<HeaderMenu route={routes.view.settings('app')} helpFeature="app-settings" />} />
Expand Down Expand Up @@ -294,6 +347,56 @@ export default function AppSettingsPage() {
</SettingsCard>
</SettingsSection>

{/* Updates */}
<SettingsSection title={t("settings.updates.title")}>
<SettingsCard>
<SettingsRow
label={t("settings.updates.currentVersion")}
description={updateStatusDescription}
action={
<Button
size="sm"
onClick={handleUpdateAction}
disabled={updateActionDisabled}
>
{(isChecking || isDownloading || isInstallingUpdate) && (
<Spinner className="mr-1.5" />
)}
{updateActionLabel}
</Button>
}
>
<span className="text-muted-foreground">{currentVersion}</span>
</SettingsRow>
{latestVersion && latestVersion !== currentVersion && (
<SettingsRow label={t("settings.updates.latestVersion")}>
<span className="text-muted-foreground">{latestVersion}</span>
</SettingsRow>
)}
{isDownloading && (
<SettingsRow label={t("settings.updates.downloadProgress")}>
<div className="flex items-center gap-2">
<div
className="h-1.5 w-28 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={downloadProgress}
>
<div
className="h-full rounded-full bg-primary transition-[width] duration-150"
style={{ width: `${Math.max(0, Math.min(100, downloadProgress))}%` }}
/>
</div>
<span className="w-10 text-right text-sm text-muted-foreground">
{downloadProgress}%
</span>
</div>
</SettingsRow>
)}
</SettingsCard>
</SettingsSection>

{/* About */}
<SettingsSection title={t("settings.about.title")}>
<SettingsCard>
Expand Down
Loading