diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6d6e19c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,126 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + macos: + name: macOS + runs-on: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Verify tag version + shell: bash + run: | + tag_version="${GITHUB_REF_NAME#v}" + package_version="$(node -p "require('./package.json').version")" + if [[ "$tag_version" != "$package_version" ]]; then + echo "Tag v$tag_version does not match package.json version $package_version" + exit 1 + fi + + - name: Build and publish macOS artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_KEYCHAIN: ${{ secrets.APPLE_KEYCHAIN }} + APPLE_KEYCHAIN_PROFILE: ${{ secrets.APPLE_KEYCHAIN_PROFILE }} + run: | + node build/macos-release-preflight.mjs + npm run build:assets + npx electron-builder --mac --publish always + + windows: + name: Windows + needs: macos + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Verify tag version + shell: bash + run: | + tag_version="${GITHUB_REF_NAME#v}" + package_version="$(node -p "require('./package.json').version")" + if [[ "$tag_version" != "$package_version" ]]; then + echo "Tag v$tag_version does not match package.json version $package_version" + exit 1 + fi + + - name: Build and publish Windows artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm run build:assets + npx electron-builder --win --publish always + + linux: + name: Linux + needs: macos + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Verify tag version + shell: bash + run: | + tag_version="${GITHUB_REF_NAME#v}" + package_version="$(node -p "require('./package.json').version")" + if [[ "$tag_version" != "$package_version" ]]; then + echo "Tag v$tag_version does not match package.json version $package_version" + exit 1 + fi + + - name: Build and publish Linux artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm run build:assets + npx electron-builder --linux AppImage --publish always diff --git a/.gitignore b/.gitignore index 7c9a95f..89d2ed0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ release .bot-gateway-state .agent-data tmp +release-local \ No newline at end of file diff --git a/README.md b/README.md index 5d888bb..9295a7b 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,16 @@ npm run build:app `npm run build:app` packages macOS and Windows artifacts with `electron-builder --mac --win`. You can also run `npm run build:app:mac` or `npm run build:app:win` for a single platform. Linux AppImage packaging is configured in `electron-builder.json`. +`npm run build:app:mac` creates a local macOS test package in `release-local/` using ad-hoc signing. It is useful with a free Apple Account or Apple Development certificate, but it is not suitable for public distribution because downloaded copies will not pass Gatekeeper notarization checks. + +macOS release builds are signed and notarized for distribution. Before running `npm run build:app` or `npm run build:app:mac:release`, the build machine must have a `Developer ID Application` certificate available through the keychain or `CSC_LINK`/`CSC_KEY_PASSWORD`, full Xcode selected with `xcode-select`, and one notarization credential set: + +- `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER` +- `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID` +- `APPLE_KEYCHAIN_PROFILE`, optionally with `APPLE_KEYCHAIN` + +The macOS packaging hook validates codesigning, the stapled notarization ticket, and Gatekeeper assessment before writing distributable artifacts. + Packaged builds check GitHub Releases for updates through `electron-updater`. For local update feed testing, set `CCR_UPDATE_FEED_URL` to a generic electron-updater feed URL before starting the app. `CCR_UPDATE_ALLOW_PRERELEASE=1` enables prerelease updates. ## Further Reading diff --git a/README_zh.md b/README_zh.md index f4c557e..49b0728 100644 --- a/README_zh.md +++ b/README_zh.md @@ -160,6 +160,16 @@ npm run build:app `npm run build:app` 会通过 `electron-builder --mac --win` 打包 macOS 和 Windows 产物。你也可以使用 `npm run build:app:mac` 或 `npm run build:app:win` 单独打包一个平台。Linux AppImage 打包配置在 `electron-builder.json` 中。 +`npm run build:app:mac` 会在 `release-local/` 生成本地测试用 macOS 包,使用 ad-hoc 签名。它适合免费 Apple Account 或只有 Apple Development 证书的本机测试,但不适合公开分发,因为用户下载后仍无法通过 Gatekeeper 公证检查。 + +macOS 发布包会使用 Developer ID 签名并提交 Apple 公证。运行 `npm run build:app` 或 `npm run build:app:mac:release` 前,打包机器必须具备:可用的 `Developer ID Application` 证书(在 keychain 中,或通过 `CSC_LINK`/`CSC_KEY_PASSWORD` 提供)、已通过 `xcode-select` 选择完整 Xcode,以及下面任意一组公证凭据: + +- `APPLE_API_KEY`、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER` +- `APPLE_ID`、`APPLE_APP_SPECIFIC_PASSWORD`、`APPLE_TEAM_ID` +- `APPLE_KEYCHAIN_PROFILE`,可选 `APPLE_KEYCHAIN` + +macOS 打包 hook 会在产物生成前验证代码签名、公证票据 stapling 和 Gatekeeper 评估,避免发布未公证的安装包。 + 打包后的应用会通过 `electron-updater` 检查 GitHub Releases。测试本地更新源时,可以在启动应用前设置 `CCR_UPDATE_FEED_URL` 为 generic electron-updater feed URL。`CCR_UPDATE_ALLOW_PRERELEASE=1` 可以启用 prerelease 更新。 ## 深入阅读 diff --git a/build/electron-builder.local.cjs b/build/electron-builder.local.cjs new file mode 100644 index 0000000..e7149ab --- /dev/null +++ b/build/electron-builder.local.cjs @@ -0,0 +1,19 @@ +const baseConfig = require("../electron-builder.json"); + +const config = { + ...baseConfig, + directories: { + ...baseConfig.directories, + output: "release-local" + }, + mac: { + ...baseConfig.mac, + identity: "-", + notarize: false, + forceCodeSigning: false + } +}; + +delete config.afterSign; + +module.exports = config; diff --git a/build/entitlements.mac.inherit.plist b/build/entitlements.mac.inherit.plist new file mode 100644 index 0000000..896ab56 --- /dev/null +++ b/build/entitlements.mac.inherit.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.disable-library-validation + + + diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 0000000..896ab56 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.disable-library-validation + + + diff --git a/build/macos-release-preflight.mjs b/build/macos-release-preflight.mjs new file mode 100644 index 0000000..aedc382 --- /dev/null +++ b/build/macos-release-preflight.mjs @@ -0,0 +1,72 @@ +import { execFileSync } from "node:child_process"; + +const errors = []; + +function reportAndExit() { + if (errors.length === 0) { + return; + } + + console.error("macOS release preflight failed:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +} + +function run(command, args) { + try { + return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + } catch (error) { + const stderr = error.stderr?.toString().trim(); + const stdout = error.stdout?.toString().trim(); + const details = stderr || stdout || error.message; + errors.push(`cannot run ${command} ${args.join(" ")}: ${details}`); + return ""; + } +} + +function hasCompleteNotaryCredentials() { + const hasAppPassword = + Boolean(process.env.APPLE_ID) && + Boolean(process.env.APPLE_APP_SPECIFIC_PASSWORD) && + Boolean(process.env.APPLE_TEAM_ID); + + const hasApiKey = + Boolean(process.env.APPLE_API_KEY) && + Boolean(process.env.APPLE_API_KEY_ID) && + Boolean(process.env.APPLE_API_ISSUER); + + const hasKeychainProfile = Boolean(process.env.APPLE_KEYCHAIN_PROFILE); + + return hasAppPassword || hasApiKey || hasKeychainProfile; +} + +if (process.platform !== "darwin") { + errors.push("macOS release builds must run on macOS."); + reportAndExit(); +} + +const developerDir = run("xcode-select", ["-p"]).trim(); +if (developerDir.endsWith("/CommandLineTools")) { + errors.push("notarization stapling requires full Xcode. Install Xcode and run `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`."); +} + +run("xcrun", ["notarytool", "--version"]); +run("xcrun", ["-f", "stapler"]); + +if (!process.env.CSC_LINK) { + const identities = run("security", ["find-identity", "-v", "-p", "codesigning"]); + if (!/"Developer ID Application: .+ \([A-Z0-9]+\)"/.test(identities)) { + errors.push("no Developer ID Application signing identity was found in the keychain. Install a Developer ID Application certificate or provide CSC_LINK/CSC_KEY_PASSWORD."); + } +} + +if (!hasCompleteNotaryCredentials()) { + errors.push( + "missing notarization credentials. Set APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER, or APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID, or APPLE_KEYCHAIN_PROFILE." + ); +} + +reportAndExit(); +console.log("macOS release preflight passed."); diff --git a/build/verify-macos-notarization.cjs b/build/verify-macos-notarization.cjs new file mode 100644 index 0000000..617da5d --- /dev/null +++ b/build/verify-macos-notarization.cjs @@ -0,0 +1,24 @@ +const { execFileSync } = require("node:child_process"); +const path = require("node:path"); + +function run(command, args) { + execFileSync(command, args, { stdio: "inherit" }); +} + +module.exports = async function verifyMacosNotarization(context) { + if (context.electronPlatformName !== "darwin") { + return; + } + + if (process.env.CCR_SKIP_MAC_NOTARIZATION_VERIFY === "1") { + console.warn("Skipping macOS notarization verification because CCR_SKIP_MAC_NOTARIZATION_VERIFY=1."); + return; + } + + const productFilename = context.packager.appInfo.productFilename; + const appPath = path.join(context.appOutDir, `${productFilename}.app`); + + run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath]); + run("xcrun", ["stapler", "validate", appPath]); + run("spctl", ["--assess", "--type", "execute", "--verbose=4", appPath]); +}; diff --git a/build/verify-update-metadata.cjs b/build/verify-update-metadata.cjs new file mode 100644 index 0000000..3c8c574 --- /dev/null +++ b/build/verify-update-metadata.cjs @@ -0,0 +1,55 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +module.exports = async function verifyUpdateMetadata(buildResult) { + const artifactPaths = Array.isArray(buildResult?.artifactPaths) ? buildResult.artifactPaths : []; + const outputDirs = new Set(artifactPaths.map((artifactPath) => path.dirname(artifactPath))); + const metadataFiles = artifactPaths.filter((artifactPath) => /^latest(?:-[a-z]+)?\.ya?ml$/i.test(path.basename(artifactPath))); + + for (const outputDir of outputDirs) { + for (const filename of fs.readdirSync(outputDir)) { + const candidate = path.join(outputDir, filename); + if (/^latest(?:-[a-z]+)?\.ya?ml$/i.test(filename) && !metadataFiles.includes(candidate)) { + metadataFiles.push(candidate); + } + } + } + + const errors = []; + for (const metadataFile of metadataFiles) { + const metadataDir = path.dirname(metadataFile); + const metadata = fs.readFileSync(metadataFile, "utf8"); + for (const artifact of readReferencedArtifacts(metadata)) { + if (isRemoteUrl(artifact)) { + continue; + } + const artifactPath = path.resolve(metadataDir, artifact); + if (!fs.existsSync(artifactPath)) { + errors.push(`${path.relative(process.cwd(), metadataFile)} references missing artifact ${artifact}`); + } + } + } + + if (errors.length) { + throw new Error(`Update metadata validation failed:\n${errors.map((error) => `- ${error}`).join("\n")}`); + } +}; + +function readReferencedArtifacts(metadata) { + const values = new Set(); + for (const line of metadata.split(/\r?\n/)) { + const match = line.match(/^\s*(?:path|url):\s*(.+?)\s*$/); + if (!match) { + continue; + } + const value = match[1].replace(/^['"]|['"]$/g, "").trim(); + if (value) { + values.add(value); + } + } + return [...values]; +} + +function isRemoteUrl(value) { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(value); +} diff --git a/electron-builder.json b/electron-builder.json index e97cf83..2918119 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -16,6 +16,7 @@ "directories": { "output": "release/${version}" }, + "afterAllArtifactBuild": "build/verify-update-metadata.cjs", "protocols": [ { "name": "Claude Code Router Provider Import", @@ -28,9 +29,16 @@ ], "mac": { "icon": "build/icon.icns", + "type": "distribution", + "hardenedRuntime": true, + "forceCodeSigning": true, + "entitlements": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.inherit.plist", + "notarize": true, "target": ["dmg", "zip"], - "artifactName": "${productName}_${version}.${ext}" + "artifactName": "Claude-Code-Router_${version}.${ext}" }, + "afterSign": "build/verify-macos-notarization.cjs", "win": { "icon": "build/icon.ico", "target": [ @@ -39,11 +47,11 @@ "arch": ["x64"] } ], - "artifactName": "${productName}_${version}.${ext}" + "artifactName": "Claude-Code-Router_${version}.${ext}" }, "linux": { "target": ["AppImage"], - "artifactName": "${productName}_${version}.${ext}" + "artifactName": "Claude-Code-Router_${version}.${ext}" }, "nsis": { "oneClick": false, diff --git a/package.json b/package.json index 494ccdc..b985be2 100644 --- a/package.json +++ b/package.json @@ -11,25 +11,27 @@ "dev": "node build/dev.mjs", "build": "npm run build:assets && electron-builder", "build:assets": "node build/build.mjs", - "build:app": "npm run build:assets && electron-builder --mac --win", - "build:app:mac": "npm run build:assets && electron-builder --mac", + "build:app": "node build/macos-release-preflight.mjs && npm run build:assets && electron-builder --mac --win", + "build:app:mac": "npm run build:app:mac:local", + "build:app:mac:local": "npm run build:assets && electron-builder --config build/electron-builder.local.cjs --mac --publish never", + "build:app:mac:release": "node build/macos-release-preflight.mjs && npm run build:assets && electron-builder --mac --publish never", "build:app:win": "npm run build:assets && electron-builder --win", "preview": "npm run build:assets && electron .", "typecheck": "tsc --noEmit", "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" }, "dependencies": { + "better-sqlite3": "^12.11.1", + "electron-updater": "^6.8.9", + "node-forge": "^1.4.0" + }, + "devDependencies": { + "undici": "^7.27.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@the-next-ai/ai-gateway": "^1.0.0", "@the-next-ai/bot-gateway-sdk": "^0.1.0", - "better-sqlite3": "^12.11.1", - "electron-updater": "^6.8.9", - "node-forge": "^1.4.0", - "undici": "^7.27.2" - }, - "devDependencies": { "@tailwindcss/cli": "^4.3.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", diff --git a/src/main/api-key-store.ts b/src/main/api-key-store.ts index a91aa8d..f760bd6 100644 --- a/src/main/api-key-store.ts +++ b/src/main/api-key-store.ts @@ -1,4 +1,3 @@ -import { safeStorage } from "electron"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { API_KEYS_DB_FILE } from "./constants"; @@ -19,7 +18,6 @@ type StoredApiKeyRow = { }; const plainStorage = "plain"; -const safeStorageEncryption = "electron-safe-storage"; class ApiKeyStore { private database?: SqlDatabase; @@ -174,13 +172,6 @@ function toStoredApiKeyRow(row: Record): StoredApiKeyRow | und } function encryptApiKey(key: string): { encryption: string; value: string } { - if (safeStorage.isEncryptionAvailable()) { - return { - encryption: safeStorageEncryption, - value: safeStorage.encryptString(key).toString("base64") - }; - } - return { encryption: plainStorage, value: key @@ -188,15 +179,11 @@ function encryptApiKey(key: string): { encryption: string; value: string } { } function decryptApiKey(value: string, encryption: string): string | undefined { - try { - if (encryption === safeStorageEncryption) { - return safeStorage.decryptString(Buffer.from(value, "base64")).trim() || undefined; - } - return value.trim() || undefined; - } catch (error) { - console.warn(`[api-keys] Failed to decrypt stored API key: ${formatError(error)}`); + if (encryption !== plainStorage) { + console.warn(`[api-keys] Stored API key uses unsupported encryption "${encryption}". Re-save the API key to store it as plain text.`); return undefined; } + return value.trim() || undefined; } function parseApiKeyLimits(value: string): ApiKeyLimitConfig | undefined { @@ -279,7 +266,3 @@ function readString(value: unknown): string | undefined { function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/main/update-service.ts b/src/main/update-service.ts index b5d5dc6..a0b93ca 100644 --- a/src/main/update-service.ts +++ b/src/main/update-service.ts @@ -124,10 +124,11 @@ class AppUpdateService { await autoUpdater.downloadUpdate(); return this.status; } catch (error) { - return this.publishStatus({ + const status = this.publishStatus({ lastError: formatError(error), state: "error" }); + throw new Error(status.lastError); } } diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index 2ca3cf7..557ee01 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -52,7 +52,7 @@ type ProfileActionBusy = { surface: ProfileOpenSurface; }; -type UpdateActionBusy = "" | "download" | "install"; +type UpdateActionBusy = "" | "check" | "download" | "install"; const providerNamePlaceholder = "__CCR_PROVIDER_NAME__"; const providerNameSlugPlaceholder = "__CCR_PROVIDER_NAME_SLUG__"; @@ -801,6 +801,27 @@ function App() { }, 1800); } + async function checkAppUpdate() { + if (!window.ccr) { + showToast(t("Updates are only available in packaged builds.")); + return; + } + setUpdateActionBusy("check"); + try { + const status = await window.ccr.updateCheck(); + setUpdateStatus(status); + if (status.state === "error" && status.lastError) { + showToast(status.lastError); + } else if (status.state === "not-available") { + showToast(t("No updates available")); + } + } catch (error) { + showToast(formatUnknownError(error)); + } finally { + setUpdateActionBusy(""); + } + } + async function downloadAppUpdate() { if (!window.ccr) { showToast(t("Updates are only available in packaged builds.")); @@ -808,7 +829,13 @@ function App() { } setUpdateActionBusy("download"); try { - setUpdateStatus(await window.ccr.updateDownload()); + const status = await window.ccr.updateDownload(); + setUpdateStatus(status); + if (status.state === "error" && status.lastError) { + showToast(status.lastError); + } else if (status.state === "not-available") { + showToast(t("No updates available")); + } } catch (error) { showToast(formatUnknownError(error)); } finally { @@ -2662,6 +2689,7 @@ function App() { isMac={isMac} needsTrafficLightSafeArea={needsTrafficLightSafeArea} networkCaptureEnabled={networkCaptureEnabled} + onCheckUpdate={checkAppUpdate} onDownloadUpdate={downloadAppUpdate} onInstallUpdate={installAppUpdate} onOpenSettings={openSettingsDialog} diff --git a/src/renderer/pages/home/components/layout.tsx b/src/renderer/pages/home/components/layout.tsx index f8f43ad..4aa3d1f 100644 --- a/src/renderer/pages/home/components/layout.tsx +++ b/src/renderer/pages/home/components/layout.tsx @@ -1,6 +1,6 @@ import type { ComponentProps } from "react"; import { - AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, AppUpdateStatus, Button, Check, cn, EndpointTitleBar, + AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, AppUpdateStatus, Button, Check, CircleAlert, cn, EndpointTitleBar, GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase, LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, reducedMotionTransition, RefreshCw, ServiceControlButton, Settings, ViewId, @@ -22,7 +22,7 @@ type MainNavigationItem = { id: NavigationId; }; -type UpdateActionBusy = "" | "download" | "install"; +type UpdateActionBusy = "" | "check" | "download" | "install"; type MainViewProps = { apiKeys: ComponentProps; @@ -65,6 +65,7 @@ export function MainLayout({ needsTrafficLightSafeArea, agentAnalysisEnabled, networkCaptureEnabled, + onCheckUpdate, onDownloadUpdate, onInstallUpdate, onOpenSettings, @@ -89,6 +90,7 @@ export function MainLayout({ isMac: boolean; needsTrafficLightSafeArea: boolean; networkCaptureEnabled: boolean; + onCheckUpdate: () => Promise; onDownloadUpdate: () => Promise; onInstallUpdate: () => Promise; onOpenSettings: () => void; @@ -162,6 +164,7 @@ export function MainLayout({ Promise; onDownload: () => Promise; onInstall: () => Promise; status: AppUpdateStatus; }) { const t = (value: string) => copy.text[value] ?? value; - const busy = Boolean(actionBusy) || status.state === "downloading" || status.state === "installing"; + const busy = Boolean(actionBusy) || status.state === "checking" || status.state === "downloading" || status.state === "installing"; const canInstall = status.canInstall || status.state === "downloaded"; + const canDownload = status.canDownload || status.state === "available"; + const canCheck = status.canCheck || status.state === "error" || status.state === "not-available" || status.state === "idle"; const label = updateButtonLabel(status, t); const detail = status.state === "downloading" && typeof status.progress?.percent === "number" ? `${Math.max(0, Math.min(100, status.progress.percent)).toFixed(0)}%` - : status.availableVersion ? `v${status.availableVersion}` : ""; + : status.state === "error" ? t("Check for updates") + : status.state === "not-available" ? t("No updates available") + : status.availableVersion ? `v${status.availableVersion}` : ""; + const title = status.state === "error" && status.lastError ? `${label}: ${status.lastError}` : detail ? `${label} ${detail}` : label; return ( ); } function isUpdateButtonVisible(status: AppUpdateStatus): boolean { return ( + status.supported || status.state === "available" || status.state === "downloaded" || status.state === "downloading" || status.state === "installing" || + status.state === "error" || status.canDownload || + status.canCheck || status.canInstall ); } function updateButtonLabel(status: AppUpdateStatus, t: (value: string) => string): string { + if (status.state === "checking") return t("Checking for updates"); if (status.state === "downloading") return t("Downloading update"); if (status.state === "installing") return t("Install and restart"); if (status.canInstall || status.state === "downloaded") return t("Install and restart"); - if (status.canDownload) return t("Update available"); - return t("Update available"); + if (status.canDownload || status.state === "available") return t("Download update"); + if (status.state === "error") return t("Update failed"); + return t("Check for updates"); } function MainViewSwitch({