From abd6a23949337c06e90e5c4d913067913b4d7b10 Mon Sep 17 00:00:00 2001 From: Biogenic Ooze Date: Sat, 7 Feb 2026 10:32:09 +0300 Subject: [PATCH 1/2] feat: implement auto-update functionality in electron-desktop app - Added auto-update support with new IPC handlers for checking, downloading, and installing updates. - Introduced UpdateBanner component to notify users of available updates. - Updated package.json to include electron-updater dependency and configured GitHub release settings. - Enhanced type definitions for update payloads in preload and env files. - Updated styles for the new UpdateBanner component. These changes aim to improve the user experience by providing seamless update notifications and management. --- .github/labeler.yml | 4 + .github/workflows/electron-desktop.yml | 156 ++++++++++++++++++ apps/electron-desktop/package.json | 10 +- apps/electron-desktop/renderer/src/env.d.ts | 30 ++++ apps/electron-desktop/renderer/src/main.tsx | 2 + .../renderer/src/ui/UpdateBanner.tsx | 141 ++++++++++++++++ .../renderer/src/ui/styles.css | 97 +++++++++++ ...der.afterAllArtifactBuild-notarize-dmg.cjs | 9 +- apps/electron-desktop/scripts/release.sh | 108 ++++++++++++ apps/electron-desktop/src/main.ts | 7 + .../electron-desktop/src/main/ipc/register.ts | 17 ++ apps/electron-desktop/src/main/updater.ts | 114 +++++++++++++ apps/electron-desktop/src/preload.ts | 60 +++++++ 13 files changed, 746 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/electron-desktop.yml create mode 100644 apps/electron-desktop/renderer/src/ui/UpdateBanner.tsx create mode 100755 apps/electron-desktop/scripts/release.sh create mode 100644 apps/electron-desktop/src/main/updater.ts diff --git a/.github/labeler.yml b/.github/labeler.yml index a1259f44aa436..dc820a0014ebc 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -100,6 +100,10 @@ - "extensions/zalouser/**" - "docs/channels/zalouser.md" +"app: electron-desktop": + - changed-files: + - any-glob-to-any-file: + - "apps/electron-desktop/**" "app: android": - changed-files: - any-glob-to-any-file: diff --git a/.github/workflows/electron-desktop.yml b/.github/workflows/electron-desktop.yml new file mode 100644 index 0000000000000..658d05202feb5 --- /dev/null +++ b/.github/workflows/electron-desktop.yml @@ -0,0 +1,156 @@ +name: Electron Desktop + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + publish: + description: "Publish to GitHub Releases" + required: false + default: false + type: boolean + +# Only allow one release build at a time to avoid race conditions on GitHub Releases. +concurrency: + group: electron-desktop-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-macos: + runs-on: macos-14 + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Checkout submodules (retry) + run: | + set -euo pipefail + git submodule sync --recursive + for attempt in 1 2 3 4 5; do + if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then + exit 0 + fi + echo "Submodule update failed (attempt $attempt/5). Retrying…" + sleep $((attempt * 10)) + done + exit 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + check-latest: true + + - name: Setup pnpm (corepack retry) + run: | + set -euo pipefail + corepack enable + for attempt in 1 2 3; do + if corepack prepare pnpm@10.23.0 --activate; then + pnpm -v + exit 0 + fi + echo "corepack prepare failed (attempt $attempt/3). Retrying..." + sleep $((attempt * 10)) + done + exit 1 + + - name: Runtime versions + run: | + node -v + npm -v + pnpm -v + + - name: Install root dependencies + run: pnpm install --frozen-lockfile + + - name: Install electron-desktop dependencies + working-directory: apps/electron-desktop + run: npm ci + + - name: Build main + renderer + working-directory: apps/electron-desktop + run: npm run build:all + + - name: Prepare OpenClaw bundle + working-directory: apps/electron-desktop + run: npm run prepare:openclaw + + - name: Prepare Node runtime + working-directory: apps/electron-desktop + run: npm run prepare:node + + - name: Prepare jq runtime + working-directory: apps/electron-desktop + run: npm run prepare:jq:all + + - name: Prepare gh runtime + working-directory: apps/electron-desktop + run: npm run prepare:gh:all + + # GOG credentials require a .env file with secrets; skip if not available. + - name: Prepare GOG runtime + if: env.GOG_CLIENT_EMAIL != '' + working-directory: apps/electron-desktop + run: npm run prepare:gog:all + env: + GOG_CLIENT_EMAIL: ${{ secrets.GOG_CLIENT_EMAIL }} + + - name: Prepare memo runtime + working-directory: apps/electron-desktop + run: npm run prepare:memo:all + + - name: Prepare remindctl runtime + working-directory: apps/electron-desktop + run: npm run prepare:remindctl:all + + - name: Prepare obsidian-cli runtime + working-directory: apps/electron-desktop + run: npm run prepare:obsidian-cli:all + + # Determine publish mode: --publish always for tags, --publish never otherwise. + - name: Determine publish mode + id: publish-mode + run: | + if [[ "${{ github.ref_type }}" == "tag" ]]; then + echo "flag=always" >> "$GITHUB_OUTPUT" + elif [[ "${{ inputs.publish }}" == "true" ]]; then + echo "flag=always" >> "$GITHUB_OUTPUT" + else + echo "flag=never" >> "$GITHUB_OUTPUT" + fi + + # Build + publish. When CSC_LINK is missing, electron-builder skips signing. + - name: Build with electron-builder + working-directory: apps/electron-desktop + run: npx electron-builder --publish ${{ steps.publish-mode.outputs.flag }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Code signing (optional — set these secrets to enable). + CSC_LINK: ${{ secrets.MACOS_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }} + # Notarization (optional — set these secrets to enable). + NOTARIZE: ${{ secrets.NOTARYTOOL_KEY != '' && '1' || '' }} + NOTARYTOOL_KEY: ${{ secrets.NOTARYTOOL_KEY }} + NOTARYTOOL_KEY_ID: ${{ secrets.NOTARYTOOL_KEY_ID }} + NOTARYTOOL_ISSUER: ${{ secrets.NOTARYTOOL_ISSUER }} + + # Upload artifacts for manual dispatch builds (when not publishing to Releases). + - name: Upload build artifacts + if: steps.publish-mode.outputs.flag == 'never' + uses: actions/upload-artifact@v4 + with: + name: electron-desktop-macos-${{ runner.arch }} + path: | + apps/electron-desktop/release/*.zip + apps/electron-desktop/release/*.dmg + apps/electron-desktop/release/*.blockmap + apps/electron-desktop/release/latest-mac.yml + if-no-files-found: error + retention-days: 14 diff --git a/apps/electron-desktop/package.json b/apps/electron-desktop/package.json index 187f92ed01a5f..30f9c2c812b46 100644 --- a/apps/electron-desktop/package.json +++ b/apps/electron-desktop/package.json @@ -43,7 +43,8 @@ "dist:local": "CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist -- --publish never", "dist": "npm run build:all && electron-builder", "dist:env": "npm run build:all && node --env-file=.env ./node_modules/electron-builder/out/cli/cli.js", - "dist:env:local": "CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist:env -- --publish never" + "dist:env:local": "CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist:env -- --publish never", + "release": "bash scripts/release.sh" }, "devDependencies": { "@reduxjs/toolkit": "^2.9.0", @@ -64,6 +65,7 @@ "prettier": "^3.4.2" }, "dependencies": { + "electron-updater": "^6.7.3", "json5": "^2.2.3", "react-hot-toast": "^2.6.0" }, @@ -139,6 +141,12 @@ "to": "gog-credentials" } ], + "publish": { + "provider": "github", + "owner": "AtomicBot-ai", + "repo": "atomicbot", + "releaseType": "draft" + }, "mac": { "icon": "assets/icon.icns", "category": "public.app-category.productivity", diff --git a/apps/electron-desktop/renderer/src/env.d.ts b/apps/electron-desktop/renderer/src/env.d.ts index 3cc33bf8f0e7b..a06eddc47d334 100644 --- a/apps/electron-desktop/renderer/src/env.d.ts +++ b/apps/electron-desktop/renderer/src/env.d.ts @@ -33,6 +33,26 @@ type GhExecResult = { resolvedPath: string | null; }; +type UpdateAvailablePayload = { + version: string; + releaseDate?: string; +}; + +type UpdateDownloadProgressPayload = { + percent: number; + bytesPerSecond: number; + transferred: number; + total: number; +}; + +type UpdateDownloadedPayload = { + version: string; +}; + +type UpdateErrorPayload = { + message: string; +}; + declare global { interface Window { openclawDesktop?: { @@ -70,6 +90,16 @@ declare global { ghAuthStatus: () => Promise; ghApiUser: () => Promise; onGatewayState: (cb: (state: GatewayState) => void) => () => void; + // Auto-updater + checkForUpdate: () => Promise; + downloadUpdate: () => Promise; + installUpdate: () => Promise; + onUpdateAvailable: (cb: (payload: UpdateAvailablePayload) => void) => () => void; + onUpdateDownloadProgress: ( + cb: (payload: UpdateDownloadProgressPayload) => void + ) => () => void; + onUpdateDownloaded: (cb: (payload: UpdateDownloadedPayload) => void) => () => void; + onUpdateError: (cb: (payload: UpdateErrorPayload) => void) => () => void; }; } } diff --git a/apps/electron-desktop/renderer/src/main.tsx b/apps/electron-desktop/renderer/src/main.tsx index 0e80476c8c589..fa27b20991eef 100644 --- a/apps/electron-desktop/renderer/src/main.tsx +++ b/apps/electron-desktop/renderer/src/main.tsx @@ -4,6 +4,7 @@ import { Provider } from "react-redux"; import { HashRouter } from "react-router-dom"; import { App } from "./ui/App"; import { Toaster } from "./ui/Toaster"; +import { UpdateBanner } from "./ui/UpdateBanner"; import { store } from "./store/store"; import "./ui/styles.css"; import "./ui/Sidebar.css"; @@ -18,6 +19,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( + diff --git a/apps/electron-desktop/renderer/src/ui/UpdateBanner.tsx b/apps/electron-desktop/renderer/src/ui/UpdateBanner.tsx new file mode 100644 index 0000000000000..f9f10a73c964c --- /dev/null +++ b/apps/electron-desktop/renderer/src/ui/UpdateBanner.tsx @@ -0,0 +1,141 @@ +import React from "react"; + +type UpdatePhase = + | { kind: "idle" } + | { kind: "available"; version: string } + | { kind: "downloading"; percent: number } + | { kind: "ready"; version: string } + | { kind: "error"; message: string }; + +/** + * Floating banner that shows when an app update is available, downloading, or ready to install. + * Subscribes to updater events from the main process via the preload bridge. + */ +export function UpdateBanner() { + const [phase, setPhase] = React.useState({ kind: "idle" }); + const [dismissed, setDismissed] = React.useState(false); + + React.useEffect(() => { + const api = window.openclawDesktop; + if (!api) { + return; + } + + const unsubs: Array<() => void> = []; + + unsubs.push( + api.onUpdateAvailable((payload) => { + setPhase({ kind: "available", version: payload.version }); + setDismissed(false); + }) + ); + + unsubs.push( + api.onUpdateDownloadProgress((payload) => { + setPhase({ kind: "downloading", percent: Math.round(payload.percent) }); + }) + ); + + unsubs.push( + api.onUpdateDownloaded((payload) => { + setPhase({ kind: "ready", version: payload.version }); + setDismissed(false); + }) + ); + + unsubs.push( + api.onUpdateError((payload) => { + // Only show error if we were in a downloading state; ignore background check errors. + setPhase((prev) => { + if (prev.kind === "downloading") { + return { kind: "error", message: payload.message }; + } + return prev; + }); + }) + ); + + return () => { + for (const unsub of unsubs) { + unsub(); + } + }; + }, []); + + if (phase.kind === "idle" || dismissed) { + return null; + } + + return ( +
+ {phase.kind === "available" && ( + <> + + Update available: v{phase.version} + + + + + )} + + {phase.kind === "downloading" && ( + <> + Downloading update... {phase.percent}% +
+
+
+ + )} + + {phase.kind === "ready" && ( + <> + + Update v{phase.version} ready! + + + + + )} + + {phase.kind === "error" && ( + <> + + Update failed: {phase.message} + + + + )} +
+ ); +} diff --git a/apps/electron-desktop/renderer/src/ui/styles.css b/apps/electron-desktop/renderer/src/ui/styles.css index ba0195d898664..997ffbc654ad5 100644 --- a/apps/electron-desktop/renderer/src/ui/styles.css +++ b/apps/electron-desktop/renderer/src/ui/styles.css @@ -2949,3 +2949,100 @@ pre { opacity: 0.4; cursor: default; } + +/* ── UpdateBanner ── */ + +.UpdateBanner { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 9999; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 10px; + background: rgba(30, 34, 42, 0.95); + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(12px); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + font-size: 13px; + color: rgba(230, 237, 243, 0.9); + max-width: 420px; + animation: updateBannerSlideIn 300ms ease-out; +} + +@keyframes updateBannerSlideIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.UpdateBanner-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.UpdateBanner-text--error { + color: rgba(248, 113, 113, 0.95); +} + +.UpdateBanner-btn { + appearance: none; + border: none; + border-radius: 6px; + padding: 5px 12px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: + background 120ms ease, + color 120ms ease; +} + +.UpdateBanner-btn--primary { + background: rgba(56, 139, 253, 0.85); + color: #fff; +} + +.UpdateBanner-btn--primary:hover { + background: rgba(56, 139, 253, 1); +} + +.UpdateBanner-btn--dismiss { + background: transparent; + color: rgba(230, 237, 243, 0.5); + font-size: 16px; + font-weight: 700; + line-height: 1; + padding: 2px 6px; +} + +.UpdateBanner-btn--dismiss:hover { + color: rgba(230, 237, 243, 0.9); +} + +.UpdateBanner-progress { + flex: 1; + min-width: 80px; + height: 6px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.1); + overflow: hidden; +} + +.UpdateBanner-progressBar { + height: 100%; + border-radius: 3px; + background: rgba(56, 139, 253, 0.85); + transition: width 200ms ease; +} diff --git a/apps/electron-desktop/scripts/electron-builder.afterAllArtifactBuild-notarize-dmg.cjs b/apps/electron-desktop/scripts/electron-builder.afterAllArtifactBuild-notarize-dmg.cjs index d123544d92cf5..a76cb87ed2e81 100644 --- a/apps/electron-desktop/scripts/electron-builder.afterAllArtifactBuild-notarize-dmg.cjs +++ b/apps/electron-desktop/scripts/electron-builder.afterAllArtifactBuild-notarize-dmg.cjs @@ -141,14 +141,7 @@ module.exports = async function afterAllArtifactBuild(context) { ); } - // If electron-builder previously created a .blockmap for a now-nonexistent DMG, delete it. - // We're not using electron-updater yet, so leaving a stale blockmap is more confusing than helpful. - const blockmapPath = `${dmgPath}.blockmap`; - try { - fs.rmSync(blockmapPath, { force: true }); - } catch { - // ignore - } + // Keep blockmap files — electron-updater uses them for efficient differential updates. const notarizeEnabled = String(process.env.NOTARIZE || "").trim() === "1"; if (!notarizeEnabled) { diff --git a/apps/electron-desktop/scripts/release.sh b/apps/electron-desktop/scripts/release.sh new file mode 100755 index 0000000000000..87de7746a3d62 --- /dev/null +++ b/apps/electron-desktop/scripts/release.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# Bump the electron-desktop version, commit, and create a git tag. +# +# Usage: +# ./scripts/release.sh patch # 0.0.7 → 0.0.8 +# ./scripts/release.sh minor # 0.0.7 → 0.1.0 +# ./scripts/release.sh major # 0.0.7 → 1.0.0 +# ./scripts/release.sh 1.2.3 # explicit version +# +# The script will: +# 1. Update version in apps/electron-desktop/package.json +# 2. Create a commit: "electron-desktop: release v" +# 3. Create an annotated git tag: v +# +# After running, push with: +# git push && git push --tags +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PKG="$APP_DIR/package.json" + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + exit 1 +fi + +BUMP="$1" + +# Read current version from package.json. +CURRENT=$(node -p "require('$PKG').version") +echo "Current version: $CURRENT" + +# Parse semver components. +IFS='.' read -r CUR_MAJOR CUR_MINOR CUR_PATCH <<< "$CURRENT" + +case "$BUMP" in + patch) + NEW_VERSION="$CUR_MAJOR.$CUR_MINOR.$((CUR_PATCH + 1))" + ;; + minor) + NEW_VERSION="$CUR_MAJOR.$((CUR_MINOR + 1)).0" + ;; + major) + NEW_VERSION="$((CUR_MAJOR + 1)).0.0" + ;; + *) + # Validate explicit version format (X.Y.Z). + if [[ ! "$BUMP" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be 'patch', 'minor', 'major', or a valid semver (X.Y.Z)" + exit 1 + fi + NEW_VERSION="$BUMP" + ;; +esac + +TAG="v$NEW_VERSION" + +# Check that the tag doesn't already exist. +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Error: tag $TAG already exists" + exit 1 +fi + +# Check for uncommitted changes (outside of package.json which we're about to modify). +if ! git diff --quiet -- ':!apps/electron-desktop/package.json'; then + echo "Error: you have uncommitted changes. Commit or stash them first." + exit 1 +fi + +echo "Bumping: $CURRENT → $NEW_VERSION (tag: $TAG)" + +# Update version in package.json using node (preserves formatting). +node -e " + const fs = require('fs'); + const path = '$PKG'; + const raw = fs.readFileSync(path, 'utf-8'); + const updated = raw.replace( + /\"version\":\s*\"[^\"]+\"/, + '\"version\": \"$NEW_VERSION\"' + ); + fs.writeFileSync(path, updated); +" + +# Verify the change. +VERIFY=$(node -p "require('$PKG').version") +if [[ "$VERIFY" != "$NEW_VERSION" ]]; then + echo "Error: version update failed (got $VERIFY, expected $NEW_VERSION)" + exit 1 +fi + +echo "Updated package.json → $NEW_VERSION" + +# Commit and tag. +git add "$PKG" +git commit -m "electron-desktop: release $TAG" +git tag -a "$TAG" -m "Electron Desktop $TAG" + +echo "" +echo "Done! Created commit and tag $TAG." +echo "" +echo "Next steps:" +echo " git push && git push --tags" +echo "" +echo "This will trigger the CI workflow to build and create a draft GitHub Release." +echo "After the build completes, go to GitHub Releases and publish the draft." diff --git a/apps/electron-desktop/src/main.ts b/apps/electron-desktop/src/main.ts index 6553297822d16..57da9872c2d68 100644 --- a/apps/electron-desktop/src/main.ts +++ b/apps/electron-desktop/src/main.ts @@ -7,6 +7,7 @@ import { registerIpcHandlers } from "./main/ipc/register"; import { DEFAULT_PORT } from "./main/constants"; import { ensureGatewayConfigFile, readGatewayTokenFromConfig } from "./main/gateway/config"; import { spawnGateway } from "./main/gateway/spawn"; +import { initAutoUpdater, disposeAutoUpdater } from "./main/updater"; import { resolveBundledGogBin, resolveBundledJqBin, @@ -180,6 +181,7 @@ app.on("activate", () => { }); app.on("before-quit", async () => { + disposeAutoUpdater(); await stopGatewayChild(); }); @@ -225,6 +227,11 @@ void app.whenReady().then(async () => { await ensureMainWindow(); ensureTray(); + // Initialize auto-updater in packaged builds only. + if (app.isPackaged) { + initAutoUpdater(() => mainWindow); + } + // Consent is stored in the same per-user state dir as the embedded gateway config. const consentPath = path.join(stateDir, "consent.json"); consentAccepted = readConsentAccepted(consentPath); diff --git a/apps/electron-desktop/src/main/ipc/register.ts b/apps/electron-desktop/src/main/ipc/register.ts index 5cf7e2b295d39..8c7eb80313309 100644 --- a/apps/electron-desktop/src/main/ipc/register.ts +++ b/apps/electron-desktop/src/main/ipc/register.ts @@ -8,6 +8,7 @@ import { upsertApiKeyProfile } from "../keys/apiKeys"; import { readAuthProfilesStore, resolveAuthProfilesPath } from "../keys/authProfilesStore"; import { registerGogIpcHandlers } from "../gog/ipc"; import { registerResetAndCloseIpcHandler } from "../reset/ipc"; +import { checkForUpdates, downloadUpdate, installUpdate } from "../updater"; import type { GatewayState } from "../types"; type ExecResult = { @@ -676,6 +677,22 @@ export function registerIpcHandlers(params: { }); }); + // Auto-updater IPC handlers. + ipcMain.handle("updater-check", async () => { + await checkForUpdates(); + return { ok: true } as const; + }); + + ipcMain.handle("updater-download", async () => { + await downloadUpdate(); + return { ok: true } as const; + }); + + ipcMain.handle("updater-install", async () => { + installUpdate(); + return { ok: true } as const; + }); + registerGogIpcHandlers({ gogBin: params.gogBin, openclawDir: params.openclawDir, diff --git a/apps/electron-desktop/src/main/updater.ts b/apps/electron-desktop/src/main/updater.ts new file mode 100644 index 0000000000000..2508080efb242 --- /dev/null +++ b/apps/electron-desktop/src/main/updater.ts @@ -0,0 +1,114 @@ +import { app, type BrowserWindow } from "electron"; +import { autoUpdater, type UpdateInfo } from "electron-updater"; + +// Interval between periodic update checks (4 hours). +const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; + +// Delay before the first check after startup (30 seconds). +const INITIAL_DELAY_MS = 30_000; + +let initialized = false; +let periodicTimer: ReturnType | null = null; + +/** + * Initialize the auto-updater. Must be called once after app is ready. + * + * Events are forwarded to the renderer via `webContents.send()` so the UI can + * display notifications and progress. + */ +export function initAutoUpdater(getMainWindow: () => BrowserWindow | null): void { + if (initialized) { + return; + } + initialized = true; + + // Don't auto-download; let the user decide when to download. + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + + autoUpdater.on("checking-for-update", () => { + sendToRenderer(getMainWindow(), "updater-checking", {}); + }); + + autoUpdater.on("update-available", (info: UpdateInfo) => { + sendToRenderer(getMainWindow(), "updater-available", { + version: info.version, + releaseDate: info.releaseDate, + }); + }); + + autoUpdater.on("update-not-available", (_info: UpdateInfo) => { + sendToRenderer(getMainWindow(), "updater-not-available", {}); + }); + + autoUpdater.on("download-progress", (progress) => { + sendToRenderer(getMainWindow(), "updater-download-progress", { + percent: progress.percent, + bytesPerSecond: progress.bytesPerSecond, + transferred: progress.transferred, + total: progress.total, + }); + }); + + autoUpdater.on("update-downloaded", (info: UpdateInfo) => { + sendToRenderer(getMainWindow(), "updater-downloaded", { + version: info.version, + }); + }); + + autoUpdater.on("error", (err) => { + sendToRenderer(getMainWindow(), "updater-error", { + message: String(err?.message ?? err), + }); + }); + + // Schedule first check after a short startup delay. + setTimeout(() => { + void checkForUpdates(); + }, INITIAL_DELAY_MS); + + // Periodic checks. + periodicTimer = setInterval(() => { + void checkForUpdates(); + }, CHECK_INTERVAL_MS); +} + +/** Manually trigger an update check. */ +export async function checkForUpdates(): Promise { + try { + await autoUpdater.checkForUpdates(); + } catch { + // Silently ignore network errors during background checks. + } +} + +/** Start downloading an available update. */ +export async function downloadUpdate(): Promise { + await autoUpdater.downloadUpdate(); +} + +/** Quit the app and install the downloaded update. */ +export function installUpdate(): void { + autoUpdater.quitAndInstall(); +} + +/** Get current app version for display. */ +export function getAppVersion(): string { + return app.getVersion(); +} + +function sendToRenderer(win: BrowserWindow | null, channel: string, payload: unknown): void { + try { + win?.webContents.send(channel, payload); + } catch { + // Window may be destroyed; ignore. + } +} + +/** Clean up timers (call on app quit). */ +export function disposeAutoUpdater(): void { + if (periodicTimer) { + clearInterval(periodicTimer); + periodicTimer = null; + } +} diff --git a/apps/electron-desktop/src/preload.ts b/apps/electron-desktop/src/preload.ts index 20c15e83ab016..e02e63ad73896 100644 --- a/apps/electron-desktop/src/preload.ts +++ b/apps/electron-desktop/src/preload.ts @@ -35,6 +35,26 @@ type GhExecResult = { resolvedPath: string | null; }; +type UpdateAvailablePayload = { + version: string; + releaseDate?: string; +}; + +type UpdateDownloadProgressPayload = { + percent: number; + bytesPerSecond: number; + transferred: number; + total: number; +}; + +type UpdateDownloadedPayload = { + version: string; +}; + +type UpdateErrorPayload = { + message: string; +}; + type OpenclawDesktopApi = { version: string; openLogs: () => Promise; @@ -70,6 +90,14 @@ type OpenclawDesktopApi = { ghAuthStatus: () => Promise; ghApiUser: () => Promise; onGatewayState: (cb: (state: GatewayState) => void) => () => void; + // Auto-updater + checkForUpdate: () => Promise; + downloadUpdate: () => Promise; + installUpdate: () => Promise; + onUpdateAvailable: (cb: (payload: UpdateAvailablePayload) => void) => () => void; + onUpdateDownloadProgress: (cb: (payload: UpdateDownloadProgressPayload) => void) => () => void; + onUpdateDownloaded: (cb: (payload: UpdateDownloadedPayload) => void) => () => void; + onUpdateError: (cb: (payload: UpdateErrorPayload) => void) => () => void; }; // Expose only the bare minimum to the renderer. The Control UI is served by the Gateway and @@ -113,6 +141,38 @@ const api: OpenclawDesktopApi = { ipcRenderer.removeListener("gateway-state", handler); }; }, + // Auto-updater + checkForUpdate: async () => ipcRenderer.invoke("updater-check"), + downloadUpdate: async () => ipcRenderer.invoke("updater-download"), + installUpdate: async () => ipcRenderer.invoke("updater-install"), + onUpdateAvailable: (cb: (payload: UpdateAvailablePayload) => void) => { + const handler = (_evt: unknown, payload: UpdateAvailablePayload) => cb(payload); + ipcRenderer.on("updater-available", handler); + return () => { + ipcRenderer.removeListener("updater-available", handler); + }; + }, + onUpdateDownloadProgress: (cb: (payload: UpdateDownloadProgressPayload) => void) => { + const handler = (_evt: unknown, payload: UpdateDownloadProgressPayload) => cb(payload); + ipcRenderer.on("updater-download-progress", handler); + return () => { + ipcRenderer.removeListener("updater-download-progress", handler); + }; + }, + onUpdateDownloaded: (cb: (payload: UpdateDownloadedPayload) => void) => { + const handler = (_evt: unknown, payload: UpdateDownloadedPayload) => cb(payload); + ipcRenderer.on("updater-downloaded", handler); + return () => { + ipcRenderer.removeListener("updater-downloaded", handler); + }; + }, + onUpdateError: (cb: (payload: UpdateErrorPayload) => void) => { + const handler = (_evt: unknown, payload: UpdateErrorPayload) => cb(payload); + ipcRenderer.on("updater-error", handler); + return () => { + ipcRenderer.removeListener("updater-error", handler); + }; + }, }; contextBridge.exposeInMainWorld("openclawDesktop", api); From e2c314630d37860bd5493d7f67b3d767d3d69a8d Mon Sep 17 00:00:00 2001 From: Biogenic Ooze Date: Sat, 7 Feb 2026 10:32:55 +0300 Subject: [PATCH 2/2] electron-desktop: release v0.0.8 --- apps/electron-desktop/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/electron-desktop/package.json b/apps/electron-desktop/package.json index 30f9c2c812b46..d8e3b6b280850 100644 --- a/apps/electron-desktop/package.json +++ b/apps/electron-desktop/package.json @@ -1,7 +1,7 @@ { "name": "atomicbot-desktop", "private": true, - "version": "0.0.7", + "version": "0.0.8", "description": "Atomic Bot makes things for you", "type": "commonjs", "main": "dist/main.js",