mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Harden macOS release builds and update handling
This commit is contained in:
parent
a8674287bc
commit
77208a6da9
16 changed files with 433 additions and 48 deletions
126
.github/workflows/release.yml
vendored
Normal file
126
.github/workflows/release.yml
vendored
Normal file
|
|
@ -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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,3 +11,4 @@ release
|
|||
.bot-gateway-state
|
||||
.agent-data
|
||||
tmp
|
||||
release-local
|
||||
10
README.md
10
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
|
||||
|
|
|
|||
10
README_zh.md
10
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 更新。
|
||||
|
||||
## 深入阅读
|
||||
|
|
|
|||
19
build/electron-builder.local.cjs
Normal file
19
build/electron-builder.local.cjs
Normal file
|
|
@ -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;
|
||||
10
build/entitlements.mac.inherit.plist
Normal file
10
build/entitlements.mac.inherit.plist
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
10
build/entitlements.mac.plist
Normal file
10
build/entitlements.mac.plist
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
72
build/macos-release-preflight.mjs
Normal file
72
build/macos-release-preflight.mjs
Normal file
|
|
@ -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.");
|
||||
24
build/verify-macos-notarization.cjs
Normal file
24
build/verify-macos-notarization.cjs
Normal file
|
|
@ -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]);
|
||||
};
|
||||
55
build/verify-update-metadata.cjs
Normal file
55
build/verify-update-metadata.cjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
18
package.json
18
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",
|
||||
|
|
|
|||
|
|
@ -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<string, SqlValue>): 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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<typeof ApiKeysView>;
|
||||
|
|
@ -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<void>;
|
||||
onDownloadUpdate: () => Promise<void>;
|
||||
onInstallUpdate: () => Promise<void>;
|
||||
onOpenSettings: () => void;
|
||||
|
|
@ -162,6 +164,7 @@ export function MainLayout({
|
|||
<SidebarUpdateButton
|
||||
actionBusy={updateActionBusy}
|
||||
copy={copy}
|
||||
onCheck={onCheckUpdate}
|
||||
onDownload={onDownloadUpdate}
|
||||
onInstall={onInstallUpdate}
|
||||
status={updateStatus}
|
||||
|
|
@ -256,67 +259,90 @@ export function MainLayout({
|
|||
function SidebarUpdateButton({
|
||||
actionBusy,
|
||||
copy,
|
||||
onCheck,
|
||||
onDownload,
|
||||
onInstall,
|
||||
status
|
||||
}: {
|
||||
actionBusy: UpdateActionBusy;
|
||||
copy: AppCopy;
|
||||
onCheck: () => Promise<void>;
|
||||
onDownload: () => Promise<void>;
|
||||
onInstall: () => Promise<void>;
|
||||
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 (
|
||||
<Button
|
||||
className="flex min-h-10 w-full min-w-0 items-center gap-2 rounded-md border border-primary/20 bg-primary/10 px-2 py-2 text-left text-[12px] font-medium text-primary transition-colors hover:bg-primary/15 disabled:cursor-default disabled:opacity-80"
|
||||
disabled={busy || (!status.canDownload && !canInstall)}
|
||||
className={cn(
|
||||
"flex min-h-10 w-full min-w-0 items-center gap-2 rounded-md border px-2 py-2 text-left text-[12px] font-medium transition-colors disabled:cursor-default disabled:opacity-80",
|
||||
status.state === "error"
|
||||
? "border-destructive/25 bg-destructive/10 text-destructive hover:bg-destructive/15"
|
||||
: status.state === "not-available" || status.state === "idle"
|
||||
? "border-border bg-muted/55 text-muted-foreground hover:bg-muted"
|
||||
: "border-primary/20 bg-primary/10 text-primary hover:bg-primary/15"
|
||||
)}
|
||||
disabled={busy || (!canDownload && !canInstall && !canCheck)}
|
||||
onClick={() => {
|
||||
if (canInstall) {
|
||||
void onInstall();
|
||||
return;
|
||||
}
|
||||
void onDownload();
|
||||
if (canDownload) {
|
||||
void onDownload();
|
||||
return;
|
||||
}
|
||||
void onCheck();
|
||||
}}
|
||||
title={detail ? `${label} ${detail}` : label}
|
||||
title={title}
|
||||
type="button"
|
||||
unstyled
|
||||
>
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-primary/10">
|
||||
<AnimatedIconSwap iconKey={busy ? "busy" : canInstall ? "install" : "refresh"}>
|
||||
{busy ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : canInstall ? <Check className="h-3.5 w-3.5" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-current/10">
|
||||
<AnimatedIconSwap iconKey={busy ? "busy" : status.state === "error" ? "error" : canInstall ? "install" : "refresh"}>
|
||||
{busy ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : status.state === "error" ? <CircleAlert className="h-3.5 w-3.5" /> : canInstall ? <Check className="h-3.5 w-3.5" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
</AnimatedIconSwap>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{detail ? <span className="max-w-[72px] shrink-0 truncate text-[11px] text-primary/75">{detail}</span> : null}
|
||||
{detail ? <span className="max-w-[92px] shrink-0 truncate text-[11px] opacity-75">{detail}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue