diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8f0d2e7e..41bc3a102 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -206,10 +206,12 @@ jobs: # Job: Upload to Alibaba Cloud OSS # Uploads all release artifacts to OSS for: # - Official website downloads (via release-info.json) - # - electron-updater auto-update (via latest-*.yml) + # - electron-updater auto-update (via {channel}-*.yml) # - # Directory structure on OSS: - # latest/ → always overwritten with the newest version + # Directory structure on OSS (channel-separated): + # latest/ → stable releases (latest.yml, latest-mac.yml, …) + # alpha/ → alpha releases (alpha.yml, alpha-mac.yml, …) + # beta/ → beta releases (beta.yml, beta-mac.yml, …) # releases/vX.Y.Z/ → permanent archive, never deleted # ────────────────────────────────────────────────────────────── upload-oss: @@ -225,7 +227,7 @@ jobs: with: path: release-artifacts - - name: Extract version + - name: Extract version and channel id: version run: | if [[ "${{ github.ref }}" == refs/tags/v* ]]; then @@ -233,43 +235,88 @@ jobs: else VERSION="${{ github.event.inputs.version }}" fi + + # Detect channel from semver prerelease tag + # e.g. 0.1.8-alpha.0 → alpha, 1.0.0-beta.1 → beta, 1.0.0 → latest + if [[ "$VERSION" =~ -([a-zA-Z]+) ]]; then + CHANNEL="${BASH_REMATCH[1]}" + else + CHANNEL="latest" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "tag=v${VERSION}" >> $GITHUB_OUTPUT - echo "Detected version: ${VERSION}" + echo "channel=${CHANNEL}" >> $GITHUB_OUTPUT + echo "Detected version: ${VERSION}, channel: ${CHANNEL}" - name: Prepare upload directories run: | VERSION="${{ steps.version.outputs.version }}" TAG="${{ steps.version.outputs.tag }}" + CHANNEL="${{ steps.version.outputs.channel }}" - mkdir -p staging/latest + mkdir -p staging/${CHANNEL} mkdir -p staging/releases/${TAG} # Flatten all platform artifacts into staging directories find release-artifacts/ -type f | while read file; do filename=$(basename "$file") - cp "$file" "staging/latest/${filename}" + cp "$file" "staging/${CHANNEL}/${filename}" cp "$file" "staging/releases/${TAG}/${filename}" done - echo "=== staging/latest/ ===" - ls -lh staging/latest/ + echo "=== staging/${CHANNEL}/ ===" + ls -lh staging/${CHANNEL}/ echo "" echo "=== staging/releases/${TAG}/ ===" ls -lh staging/releases/${TAG}/ + - name: Rename yml files for channel + run: | + CHANNEL="${{ steps.version.outputs.channel }}" + + # electron-builder always generates latest*.yml. + # For non-stable channels, rename them so electron-updater can find + # e.g. alpha.yml, alpha-mac.yml, alpha-linux.yml + if [[ "$CHANNEL" != "latest" ]]; then + cd staging/${CHANNEL} + for f in latest*.yml; do + [ -f "$f" ] || continue + newname="${f/latest/$CHANNEL}" + echo "Renaming $f → $newname" + mv "$f" "$newname" + done + cd - + + # Also rename in the archive directory + cd staging/releases/${{ steps.version.outputs.tag }} + for f in latest*.yml; do + [ -f "$f" ] || continue + newname="${f/latest/$CHANNEL}" + echo "Renaming (archive) $f → $newname" + mv "$f" "$newname" + done + cd - + fi + + echo "=== Final staging/$CHANNEL/ ===" + ls -lh staging/${CHANNEL}/ + - name: Generate release-info.json run: | VERSION="${{ steps.version.outputs.version }}" - BASE_URL="https://oss.intelli-spectrum.com/latest" + CHANNEL="${{ steps.version.outputs.channel }}" + BASE_URL="https://oss.intelli-spectrum.com/${CHANNEL}" jq -n \ --arg version "$VERSION" \ + --arg channel "$CHANNEL" \ --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg base "$BASE_URL" \ --arg changelog "https://github.com/${{ github.repository }}/releases/tag/v${VERSION}" \ '{ version: $version, + channel: $channel, releaseDate: $date, downloads: { mac: { @@ -289,10 +336,10 @@ jobs: } }, changelog: $changelog - }' > staging/latest/release-info.json + }' > staging/${CHANNEL}/release-info.json echo "=== release-info.json ===" - cat staging/latest/release-info.json + cat staging/${CHANNEL}/release-info.json - name: Install and configure ossutil env: @@ -312,18 +359,20 @@ jobs: ossutil --version - - name: "Upload to OSS: latest/ (overwrite)" + - name: "Upload to OSS: {channel}/ (overwrite)" run: | - # Clean old latest/ to remove stale version files - ossutil rm -r -f oss://valuecell-clawx/latest/ || true + CHANNEL="${{ steps.version.outputs.channel }}" + + # Only clean the current channel's directory — never touch other channels + ossutil rm -r -f oss://valuecell-clawx/${CHANNEL}/ || true # Upload all files with no-cache so clients always get the freshest version ossutil cp -r -f \ --meta="Cache-Control:no-cache,no-store,must-revalidate" \ - staging/latest/ \ - oss://valuecell-clawx/latest/ + staging/${CHANNEL}/ \ + oss://valuecell-clawx/${CHANNEL}/ - echo "Uploaded to latest/" + echo "Uploaded to ${CHANNEL}/" - name: "Upload to OSS: releases/vX.Y.Z/ (archive)" run: | @@ -340,9 +389,10 @@ jobs: - name: Verify OSS upload run: | TAG="${{ steps.version.outputs.tag }}" + CHANNEL="${{ steps.version.outputs.channel }}" - echo "=== latest/ ===" - ossutil ls oss://valuecell-clawx/latest/ --short + echo "=== ${CHANNEL}/ ===" + ossutil ls oss://valuecell-clawx/${CHANNEL}/ --short echo "" echo "=== releases/${TAG}/ ===" @@ -350,8 +400,18 @@ jobs: echo "" echo "=== Verify release-info.json ===" - ossutil cp oss://valuecell-clawx/latest/release-info.json /tmp/release-info.json -f + ossutil cp oss://valuecell-clawx/${CHANNEL}/release-info.json /tmp/release-info.json -f jq . /tmp/release-info.json + + echo "" + echo "=== Verify update yml ===" + if [[ "$CHANNEL" == "latest" ]]; then + YML_PREFIX="latest" + else + YML_PREFIX="$CHANNEL" + fi + echo "Looking for ${YML_PREFIX}*.yml files:" + ossutil ls oss://valuecell-clawx/${CHANNEL}/ --short | grep "${YML_PREFIX}.*\\.yml" || echo "(none found)" echo "" - echo "✅ All files uploaded and verified successfully!" + echo "All files uploaded and verified successfully!" diff --git a/electron/main/index.ts b/electron/main/index.ts index 8f6338d22..d8556cfc9 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -151,14 +151,8 @@ async function initialize(): Promise { // Register update handlers registerUpdateHandlers(appUpdater, mainWindow); - // Check for updates after a delay (only in production) - if (!process.env.VITE_DEV_SERVER_URL) { - setTimeout(() => { - appUpdater.checkForUpdates().catch((err) => { - console.error('Failed to check for updates:', err); - }); - }, 10000); - } + // Note: Auto-check for updates is driven by the renderer (update store init) + // so it respects the user's "Auto-check for updates" setting. // Handle window close mainWindow.on('closed', () => { diff --git a/electron/main/updater.ts b/electron/main/updater.ts index 080a598b3..45fc17399 100644 --- a/electron/main/updater.ts +++ b/electron/main/updater.ts @@ -3,12 +3,16 @@ * Handles automatic application updates using electron-updater * * Update providers are configured in electron-builder.yml (OSS primary, GitHub fallback). - * electron-updater handles provider resolution automatically. + * For prerelease channels (alpha, beta), the feed URL is overridden at runtime + * to point at the channel-specific OSS directory (e.g. /alpha/, /beta/). */ import { autoUpdater, UpdateInfo, ProgressInfo, UpdateDownloadedEvent } from 'electron-updater'; import { BrowserWindow, app, ipcMain } from 'electron'; import { EventEmitter } from 'events'; +/** Base CDN URL (without trailing channel path) */ +const OSS_BASE_URL = 'https://oss.intelli-spectrum.com'; + export interface UpdateStatus { status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; info?: UpdateInfo; @@ -26,6 +30,15 @@ export interface UpdaterEvents { 'error': (error: Error) => void; } +/** + * Detect the update channel from a semver version string. + * e.g. "0.1.8-alpha.0" → "alpha", "1.0.0-beta.1" → "beta", "1.0.0" → "latest" + */ +function detectChannel(version: string): string { + const match = version.match(/-([a-zA-Z]+)/); + return match ? match[1] : 'latest'; +} + export class AppUpdater extends EventEmitter { private mainWindow: BrowserWindow | null = null; private status: UpdateStatus = { status: 'idle' }; @@ -45,6 +58,20 @@ export class AppUpdater extends EventEmitter { debug: (msg: string) => console.debug('[Updater]', msg), }; + // Override feed URL for prerelease channels so that + // alpha -> /alpha/alpha-mac.yml, beta -> /beta/beta-mac.yml, etc. + const version = app.getVersion(); + const channel = detectChannel(version); + const feedUrl = `${OSS_BASE_URL}/${channel}`; + + console.log(`[Updater] Version: ${version}, channel: ${channel}, feedUrl: ${feedUrl}`); + + autoUpdater.setFeedURL({ + provider: 'generic', + url: feedUrl, + useMultipleRangeRequest: false, + }); + this.setupListeners(); } @@ -115,13 +142,34 @@ export class AppUpdater extends EventEmitter { } /** - * Check for updates - * electron-updater automatically tries providers defined in electron-builder.yml in order + * Check for updates. + * electron-updater automatically tries providers defined in electron-builder.yml in order. + * + * In dev mode (not packed), autoUpdater.checkForUpdates() silently returns + * null without emitting any events, so we must detect this and force a + * final status so the UI never gets stuck in 'checking'. */ async checkForUpdates(): Promise { try { const result = await autoUpdater.checkForUpdates(); - return result?.updateInfo || null; + + // In dev mode (app not packaged), autoUpdater silently returns null + // without emitting ANY events (not even checking-for-update). + // Detect this and force an error so the UI never stays silent. + if (result == null) { + this.updateStatus({ + status: 'error', + error: 'Update check skipped (dev mode – app is not packaged)', + }); + return null; + } + + // Safety net: if events somehow didn't fire, force a final state. + if (this.status.status === 'checking' || this.status.status === 'idle') { + this.updateStatus({ status: 'not-available' }); + } + + return result.updateInfo || null; } catch (error) { console.error('[Updater] Check for updates failed:', error); this.updateStatus({ status: 'error', error: (error as Error).message || String(error) }); @@ -189,13 +237,14 @@ export function registerUpdateHandlers( return updater.getCurrentVersion(); }); - // Check for updates + // Check for updates – always return final status so the renderer + // never gets stuck in 'checking' waiting for a push event. ipcMain.handle('update:check', async () => { try { - const info = await updater.checkForUpdates(); - return { success: true, info }; + await updater.checkForUpdates(); + return { success: true, status: updater.getStatus() }; } catch (error) { - return { success: false, error: String(error) }; + return { success: false, error: String(error), status: updater.getStatus() }; } }); @@ -227,42 +276,6 @@ export function registerUpdateHandlers( return { success: true }; }); - // Forward update events to renderer - updater.on('checking-for-update', () => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('update:checking'); - } - }); - - updater.on('update-available', (info) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('update:available', info); - } - }); - - updater.on('update-not-available', (info) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('update:not-available', info); - } - }); - - updater.on('download-progress', (progress) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('update:progress', progress); - } - }); - - updater.on('update-downloaded', (event) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('update:downloaded', event); - } - }); - - updater.on('error', (error) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('update:error', error.message); - } - }); } // Export singleton instance diff --git a/src/components/settings/UpdateSettings.tsx b/src/components/settings/UpdateSettings.tsx index 6facb37ef..f6f7da5da 100644 --- a/src/components/settings/UpdateSettings.tsx +++ b/src/components/settings/UpdateSettings.tsx @@ -3,7 +3,7 @@ * Displays update status and allows manual update checking/installation */ import { useEffect, useCallback } from 'react'; -import { Download, RefreshCw, CheckCircle2, AlertCircle, Loader2, Rocket } from 'lucide-react'; +import { Download, RefreshCw, Loader2, Rocket } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { useUpdateStore } from '@/stores/update'; @@ -41,25 +41,6 @@ export function UpdateSettings() { await checkForUpdates(); }, [checkForUpdates, clearError]); - const renderStatusIcon = () => { - switch (status) { - case 'checking': - return ; - case 'downloading': - return ; - case 'available': - return ; - case 'downloaded': - return ; - case 'error': - return ; - case 'not-available': - return ; - default: - return ; - } - }; - const renderStatusText = () => { switch (status) { case 'checking': @@ -71,7 +52,7 @@ export function UpdateSettings() { case 'downloaded': return `Ready to install: v${updateInfo?.version}`; case 'error': - return error || 'Update check failed'; + return 'Update check failed'; case 'not-available': return 'You have the latest version'; default: @@ -138,12 +119,9 @@ export function UpdateSettings() { return (
{/* Current Version */} -
-
-

Current Version

-

v{currentVersion}

-
- {renderStatusIcon()} +
+

Current Version

+

v{currentVersion}

{/* Status */} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index abf70170e..11f9e7437 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -50,6 +50,7 @@ export function Settings() { const { status: gatewayStatus, restart: restartGateway } = useGatewayStore(); const currentVersion = useUpdateStore((state) => state.currentVersion); + const updateSetAutoDownload = useUpdateStore((state) => state.setAutoDownload); const [controlUiInfo, setControlUiInfo] = useState(null); const [openclawCliCommand, setOpenclawCliCommand] = useState(''); const [openclawCliError, setOpenclawCliError] = useState(null); @@ -380,7 +381,10 @@ export function Settings() {
{ + setAutoDownloadUpdate(value); + updateSetAutoDownload(value); + }} />
diff --git a/src/stores/update.ts b/src/stores/update.ts index 9d4c3dd09..47b618989 100644 --- a/src/stores/update.ts +++ b/src/stores/update.ts @@ -3,6 +3,7 @@ * Manages application update state */ import { create } from 'zustand'; +import { useSettingsStore } from './settings'; export interface UpdateInfo { version: string; @@ -83,6 +84,8 @@ export const useUpdateStore = create((set, get) => ({ } // Listen for update events + // Single source of truth: listen only to update:status-changed + // (sent by AppUpdater.updateStatus() in the main process) window.electron.ipcRenderer.on('update:status-changed', (data) => { const status = data as { status: UpdateStatus; @@ -98,31 +101,22 @@ export const useUpdateStore = create((set, get) => ({ }); }); - window.electron.ipcRenderer.on('update:checking', () => { - set({ status: 'checking', error: null }); - }); - - window.electron.ipcRenderer.on('update:available', (info) => { - set({ status: 'available', updateInfo: info as UpdateInfo }); - }); - - window.electron.ipcRenderer.on('update:not-available', () => { - set({ status: 'not-available' }); - }); - - window.electron.ipcRenderer.on('update:progress', (progress) => { - set({ status: 'downloading', progress: progress as ProgressInfo }); - }); - - window.electron.ipcRenderer.on('update:downloaded', (info) => { - set({ status: 'downloaded', updateInfo: info as UpdateInfo, progress: null }); - }); - - window.electron.ipcRenderer.on('update:error', (error) => { - set({ status: 'error', error: error as string, progress: null }); - }); - set({ isInitialized: true }); + + // Apply persisted settings from the settings store + const { autoCheckUpdate, autoDownloadUpdate } = useSettingsStore.getState(); + + // Sync auto-download preference to the main process + if (autoDownloadUpdate) { + window.electron.ipcRenderer.invoke('update:setAutoDownload', true).catch(() => {}); + } + + // Auto-check for updates on startup (respects user toggle) + if (autoCheckUpdate) { + setTimeout(() => { + get().checkForUpdates().catch(() => {}); + }, 10000); + } }, checkForUpdates: async () => { @@ -134,15 +128,34 @@ export const useUpdateStore = create((set, get) => ({ new Promise((_, reject) => setTimeout(() => reject(new Error('Update check timed out')), 30000)) ]) as { success: boolean; - info?: UpdateInfo; error?: string; + status?: { + status: UpdateStatus; + info?: UpdateInfo; + progress?: ProgressInfo; + error?: string; + }; }; - if (!result.success) { + if (result.status) { + set({ + status: result.status.status, + updateInfo: result.status.info || null, + progress: result.status.progress || null, + error: result.status.error || null, + }); + } else if (!result.success) { set({ status: 'error', error: result.error || 'Failed to check for updates' }); } } catch (error) { set({ status: 'error', error: String(error) }); + } finally { + // In dev mode autoUpdater skips without emitting events, so the + // status may still be 'checking' or even 'idle'. Catch both. + const currentStatus = get().status; + if (currentStatus === 'checking' || currentStatus === 'idle') { + set({ status: 'error', error: 'Update check completed without a result. This usually means the app is running in dev mode.' }); + } } },