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({