Harden macOS release builds and update handling

This commit is contained in:
musistudio 2026-06-23 15:40:27 +08:00
parent a8674287bc
commit 77208a6da9
16 changed files with 433 additions and 48 deletions

126
.github/workflows/release.yml vendored Normal file
View 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
View file

@ -11,3 +11,4 @@ release
.bot-gateway-state
.agent-data
tmp
release-local

View file

@ -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

View file

@ -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 更新。
## 深入阅读

View 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;

View 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>

View 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>

View 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.");

View 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]);
};

View 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);
}

View file

@ -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,

View file

@ -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",

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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}

View file

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