Split app packaging commands by platform

This commit is contained in:
musistudio 2026-06-23 16:27:35 +08:00
parent 77208a6da9
commit 585ff6ddd3
12 changed files with 232 additions and 208 deletions

View file

@ -151,18 +151,19 @@ npm install
npm run dev
npm run typecheck
npm run build:assets
npm run build:app
npm run build:app:mac
npm run build:app:win
```
`npm run build:assets` compiles the Electron main process and renderer assets into `dist/`.
`npm run build` packages the app for the current platform and writes installer artifacts to `release/`.
`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` and `npm run build:app:win` package platform-specific app artifacts. 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:
macOS release builds are signed and notarized for distribution. Before running `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`

View file

@ -151,18 +151,19 @@ npm install
npm run dev
npm run typecheck
npm run build:assets
npm run build:app
npm run build:app:mac
npm run build:app:win
```
`npm run build:assets` 会把 Electron main process 和 renderer assets 编译到 `dist/`。
`npm run build` 会为当前平台打包应用,并把安装包写入 `release/`。
`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` 和 `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,以及下面任意一组公证凭据:
macOS 发布包会使用 Developer ID 签名并提交 Apple 公证。运行 `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`

View file

@ -11,7 +11,6 @@
"dev": "node build/dev.mjs",
"build": "npm run build:assets && electron-builder",
"build:assets": "node build/build.mjs",
"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",
@ -21,17 +20,17 @@
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
},
"dependencies": {
"@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"
"node-forge": "^1.4.0",
"undici": "^7.27.2"
},
"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",
"@tailwindcss/cli": "^4.3.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.10.2",

View file

@ -1,6 +1,8 @@
import { app, dialog, Menu, type BrowserWindow, type MenuItemConstructorOptions } from "electron";
import { APP_NAME, IPC_CHANNELS } from "./constants";
import { appUpdateService } from "./update-service";
import windowsManager from "./windows";
import type { AppUpdateStatus } from "../shared/app";
export function setupApplicationMenu(): void {
Menu.setApplicationMenu(Menu.buildFromTemplate(createMenuTemplate()));
@ -16,6 +18,7 @@ function createMenuTemplate(): MenuItemConstructorOptions[] {
{ label: `About ${APP_NAME}`, click: showAboutPanel },
{ type: "separator" },
{ label: "Settings...", accelerator: "CmdOrCtrl+,", click: openSettings },
{ label: "Check for Updates...", click: checkForUpdatesFromMenu },
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
@ -102,6 +105,8 @@ function createMenuTemplate(): MenuItemConstructorOptions[] {
template.push({
label: "Help",
submenu: [
{ label: "Check for Updates...", click: checkForUpdatesFromMenu },
{ type: "separator" },
{ label: `About ${APP_NAME}`, click: showAboutPanel }
]
});
@ -126,6 +131,125 @@ function showAboutPanel(): void {
void (window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options));
}
function checkForUpdatesFromMenu(): void {
const window = windowsManager.showMainWindow();
void checkForUpdatesWithDialog(window);
}
async function checkForUpdatesWithDialog(window: BrowserWindow): Promise<void> {
try {
await presentUpdateStatus(window, await appUpdateService.checkForUpdates());
} catch (error) {
await showUpdateDialog(window, {
detail: formatError(error),
message: "Update check failed.",
type: "error"
});
}
}
async function presentUpdateStatus(window: BrowserWindow, status: AppUpdateStatus): Promise<void> {
if (status.state === "not-available") {
await showUpdateDialog(window, {
detail: `Version ${status.currentVersion} is currently installed.`,
message: `${APP_NAME} is up to date.`,
type: "info"
});
return;
}
if (status.state === "available") {
const result = await showUpdateDialog(window, {
buttons: ["Download", "Later"],
cancelId: 1,
detail: status.availableVersion
? `Version ${status.availableVersion} is available.`
: "An update is available.",
message: "Update available.",
type: "info"
});
if (result.response === 0) {
await downloadUpdateFromMenu(window);
}
return;
}
if (status.state === "downloaded") {
await promptInstallUpdate(window);
return;
}
if (status.state === "error") {
await showUpdateDialog(window, {
detail: status.lastError || "Unable to check for updates.",
message: "Update check failed.",
type: "error"
});
}
}
async function downloadUpdateFromMenu(window: BrowserWindow): Promise<void> {
try {
const status = await appUpdateService.downloadUpdate();
if (status.state === "downloaded" || status.canInstall) {
await promptInstallUpdate(window);
return;
}
if (status.state === "error") {
await showUpdateDialog(window, {
detail: status.lastError || "Unable to download the update.",
message: "Update download failed.",
type: "error"
});
}
} catch (error) {
await showUpdateDialog(window, {
detail: formatError(error),
message: "Update download failed.",
type: "error"
});
}
}
async function promptInstallUpdate(window: BrowserWindow): Promise<void> {
const result = await showUpdateDialog(window, {
buttons: ["Install and Restart", "Later"],
cancelId: 1,
detail: "The update has been downloaded and is ready to install.",
message: "Update ready to install.",
type: "info"
});
if (result.response !== 0) {
return;
}
try {
await appUpdateService.installUpdate();
} catch (error) {
await showUpdateDialog(window, {
detail: formatError(error),
message: "Update install failed.",
type: "error"
});
}
}
function showUpdateDialog(
window: BrowserWindow,
options: { buttons?: string[]; cancelId?: number; detail: string; message: string; type: "error" | "info" }
): Promise<Electron.MessageBoxReturnValue> {
return dialog.showMessageBox(window, {
buttons: options.buttons ?? ["OK"],
cancelId: options.cancelId,
defaultId: 0,
detail: options.detail,
message: options.message,
title: "Software Update",
type: options.type
});
}
function sendWhenReady(window: BrowserWindow, channel: string): void {
if (window.isDestroyed() || window.webContents.isDestroyed()) {
return;
@ -144,3 +268,7 @@ function sendWhenReady(window: BrowserWindow, channel: string): void {
send();
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -255,7 +255,7 @@ function resolveUserPath(value: string): string {
if (trimmed === "~") {
return os.homedir();
}
if (trimmed.startsWith("~/")) {
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
return path.join(os.homedir(), trimmed.slice(2));
}
return path.resolve(trimmed || ".");

View file

@ -6,7 +6,7 @@ import path from "node:path";
import type { AppConfig, ProfileOpenSurface } from "../shared/app";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { launchCodexAppProfile, launchZcodeAppProfile } from "./codex-app-launch";
import { buildProfileLaunchPlan, findProfileForOpen, resolveProfileOpenSurface } from "./profile-launch-core";
import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "./profile-launch-core";
type CliOptions = {
agentArgs: string[];
@ -55,7 +55,8 @@ async function main(): Promise<void> {
};
delete childEnv.ELECTRON_RUN_AS_NODE;
const child = spawn(plan.command, plan.args, {
const launch = profileLaunchSpawnCommand(plan);
const child = spawn(launch.command, launch.args, {
env: childEnv,
stdio: "inherit"
});

View file

@ -10,6 +10,11 @@ export type ProfileLaunchPlan = {
surface: ProfileOpenSurface;
};
export type ProfileLaunchSpawnCommand = {
args: string[];
command: string;
};
export function findProfileForOpen(config: Pick<AppConfig, "profile">, profileRef: string): ProfileConfig {
const needle = profileRef.trim();
if (!needle) {
@ -86,6 +91,25 @@ export function buildProfileLaunchPlan(
return buildClaudeCodeLaunchPlan(configDir, profile, resolvedSurface, extraArgs);
}
export function profileLaunchSpawnCommand(plan: Pick<ProfileLaunchPlan, "args" | "command">): ProfileLaunchSpawnCommand {
if (!isWindowsCommandScript(plan.command)) {
return {
args: plan.args,
command: plan.command
};
}
return {
args: [
"/d",
"/s",
"/v:off",
"/c",
windowsCommandScriptInvocation(plan.command, plan.args)
],
command: process.env.ComSpec || process.env.COMSPEC || "cmd.exe"
};
}
export function ccrManagedProfileDir(configDir: string, profile: ProfileConfig): string {
const slug = sanitizePathSegment(profile.id || profile.name || profile.agent);
const baseDir = path.join(configDir, "profiles", slug || "profile");
@ -194,7 +218,7 @@ function resolveUserPath(value: string): string {
if (trimmed === "~") {
return homeDir();
}
if (trimmed.startsWith("~/")) {
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
return path.join(homeDir(), trimmed.slice(2));
}
return path.resolve(trimmed || ".");
@ -228,3 +252,23 @@ function windowsCommandQuote(value: string): string {
? normalized
: `"${normalized.replace(/"/g, '\\"')}"`;
}
function isWindowsCommandScript(command: string): boolean {
return process.platform === "win32" && /\.(?:bat|cmd)$/i.test(path.basename(command));
}
function windowsCommandScriptInvocation(command: string, args: string[]): string {
return [
"call",
windowsCommandInvocationArg(command),
...args.map(windowsCommandInvocationArg)
].join(" ");
}
function windowsCommandInvocationArg(value: string): string {
const normalized = value.replace(/\r?\n/g, " ");
if (!normalized) {
return "\"\"";
}
return `"${normalized.replace(/[\^"%&|<>()]/g, "^$&")}"`;
}

View file

@ -10,7 +10,7 @@ import { launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleApp
import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime";
import { CONFIGDIR } from "./constants";
import { gatewayService } from "../server/gateway/service";
import { buildProfileLaunchPlan, findProfileForOpen, profileOpenCommand, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "./profile-launch-core";
import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "./profile-launch-core";
import { applyProfileConfig } from "./profile-service";
const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>";
@ -64,7 +64,8 @@ export async function openProfileFromCcr(config: AppConfig, request: ProfileOpen
throw new Error(`Profile launcher was not found: ${plan.command}. Re-save the profile and try again.`);
}
const child = spawn(plan.command, plan.args, {
const launch = profileLaunchSpawnCommand(plan);
const child = spawn(launch.command, launch.args, {
detached: true,
env: {
...process.env,

View file

@ -961,7 +961,7 @@ function resolveUserPath(value: string): string {
if (trimmed === "~") {
return os.homedir();
}
if (trimmed.startsWith("~/")) {
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
return path.join(os.homedir(), trimmed.slice(2));
}
return path.resolve(trimmed || ".");

View file

@ -5,10 +5,14 @@ import windowsManager from "./windows";
import type { AppUpdateStatus } from "../shared/app";
type InstallPreparation = () => Promise<void>;
type UpdateCheckOptions = {
silentFailure?: boolean;
};
const startupCheckDelayMs = 12_000;
class AppUpdateService {
private activeSilentCheckFailureRestoreStatus?: AppUpdateStatus;
private initialized = false;
private installingUpdate = false;
private prepareInstall?: InstallPreparation;
@ -49,7 +53,7 @@ class AppUpdateService {
return this.installingUpdate;
}
async checkForUpdates(): Promise<AppUpdateStatus> {
async checkForUpdates(options: UpdateCheckOptions = {}): Promise<AppUpdateStatus> {
this.start();
if (!this.isUpdaterSupported()) {
return this.publishStatus({
@ -58,6 +62,8 @@ class AppUpdateService {
});
}
const restoreStatus = options.silentFailure ? this.status : undefined;
this.activeSilentCheckFailureRestoreStatus = restoreStatus;
this.publishStatus({
lastError: undefined,
progress: undefined,
@ -96,11 +102,18 @@ class AppUpdateService {
progress: undefined
});
} catch (error) {
if (options.silentFailure && restoreStatus) {
return this.publishSilentCheckFailure(error, restoreStatus);
}
return this.publishStatus({
lastError: formatError(error),
progress: undefined,
state: "error"
});
} finally {
if (this.activeSilentCheckFailureRestoreStatus === restoreStatus) {
this.activeSilentCheckFailureRestoreStatus = undefined;
}
}
}
@ -232,6 +245,10 @@ class AppUpdateService {
});
});
autoUpdater.on("error", (error) => {
if (this.activeSilentCheckFailureRestoreStatus) {
this.publishSilentCheckFailure(error, this.activeSilentCheckFailureRestoreStatus);
return;
}
this.publishStatus({
lastError: formatError(error),
progress: undefined,
@ -246,11 +263,26 @@ class AppUpdateService {
}
this.startupCheckTimer = setTimeout(() => {
this.startupCheckTimer = undefined;
void this.checkForUpdates();
void this.checkForUpdates({ silentFailure: true });
}, readEnvNumber("CCR_UPDATE_STARTUP_DELAY_MS") ?? startupCheckDelayMs);
this.startupCheckTimer.unref?.();
}
private publishSilentCheckFailure(error: unknown, restoreStatus: AppUpdateStatus): AppUpdateStatus {
const state = restoreStatus.state === "checking" || restoreStatus.state === "error" ? "idle" : restoreStatus.state;
return this.publishStatus({
availableVersion: restoreStatus.availableVersion,
downloadedAt: restoreStatus.downloadedAt,
lastCheckedAt: restoreStatus.lastCheckedAt,
lastError: formatError(error),
progress: restoreStatus.progress,
releaseDate: restoreStatus.releaseDate,
releaseName: restoreStatus.releaseName,
releaseNotes: restoreStatus.releaseNotes,
state
});
}
private publishUpdateInfo(
state: AppUpdateStatus["state"],
info: UpdateInfo | UpdateDownloadedEvent,

View file

@ -1,6 +1,6 @@
import {
AddApiKeyDraft, AddProfileDraft, AddProviderDraft, AddRoutingRuleDraft, AgentAnalysisSessionSelection, AgentAnalysisSnapshot, AgentFilterValue,
ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo, AppUpdateStatus,
ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo,
AppLanguagePreference, applyProviderProbeResult, AppToast, BotGatewaySavedConfig, buildExtensionList, claudeDesignRoutingConfigFromDraft,
buildRouterConditionPath,
ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, cloneConfig, createApiKeyDraft, createApiKeyEditDraft,
@ -11,7 +11,7 @@ import {
enforceSingleEnabledGlobalProfilePerAgent,
ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig,
fallbackGatewayStatus, fallbackInfo, fallbackProxyCertificateStatus, fallbackProxyNetworkSnapshot, fallbackProxyStatus, fallbackRequestLogPage,
fallbackUpdateStatus, fallbackUsageStats, formatJson, formatProxyCertificateInstallMessage, GatewayProviderConfig,
fallbackUsageStats, formatJson, formatProxyCertificateInstallMessage, GatewayProviderConfig,
fusionCustomMcpServerFromDraft, fusionCustomToolConfigFromProfile,
GatewayProviderProbeResult, gatewayServiceMessage, GatewayStatus, getDefaultOnboardingStep, isClaudeDesignPluginConfig, isClaudeDesignRoutingDraftValid,
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
@ -52,8 +52,6 @@ type ProfileActionBusy = {
surface: ProfileOpenSurface;
};
type UpdateActionBusy = "" | "check" | "download" | "install";
const providerNamePlaceholder = "__CCR_PROVIDER_NAME__";
const providerNameSlugPlaceholder = "__CCR_PROVIDER_NAME_SLUG__";
const providerInternalNamePlaceholder = "__CCR_PROVIDER_INTERNAL_NAME__";
@ -162,8 +160,6 @@ function App() {
const [proxyCertificateStatus, setProxyCertificateStatus] = useState<ProxyCertificateStatus>(fallbackProxyCertificateStatus);
const [proxyNetworkSnapshot, setProxyNetworkSnapshot] = useState<ProxyNetworkSnapshot>(fallbackProxyNetworkSnapshot);
const [proxyStatus, setProxyStatus] = useState<ProxyStatus>(fallbackProxyStatus);
const [updateStatus, setUpdateStatus] = useState<AppUpdateStatus>(fallbackUpdateStatus);
const [updateActionBusy, setUpdateActionBusy] = useState<UpdateActionBusy>("");
const [actionBusy, setActionBusy] = useState<ServerActionBusy>("");
const [gatewayActionBusy, setGatewayActionBusy] = useState(false);
const [actionMessage, setActionMessage] = useState("");
@ -308,9 +304,7 @@ function App() {
.finally(() => setOnboardingStatusLoaded(true));
void window.ccr.getPluginMarketplace().then(setPluginMarketplace).catch(() => setPluginMarketplace([]));
void window.ccr.getProxyCertificateStatus().then(setProxyCertificateStatus);
void window.ccr.getUpdateStatus().then(setUpdateStatus).catch(() => setUpdateStatus(fallbackUpdateStatus));
const unsubscribeOpenSettings = window.ccr.onOpenSettingsRequest(openSettingsDialog);
const unsubscribeUpdateStatus = window.ccr.onUpdateStatusChanged(setUpdateStatus);
const refreshRuntimeStatus = () => {
void window.ccr?.getGatewayStatus().then(setGatewayStatus);
void window.ccr?.getProxyStatus().then(setProxyStatus);
@ -321,7 +315,6 @@ function App() {
return () => {
window.clearInterval(timer);
unsubscribeOpenSettings();
unsubscribeUpdateStatus();
};
}, []);
@ -801,62 +794,6 @@ 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."));
return;
}
setUpdateActionBusy("download");
try {
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 {
setUpdateActionBusy("");
}
}
async function installAppUpdate() {
if (!window.ccr) {
showToast(t("Updates are only available in packaged builds."));
return;
}
setUpdateActionBusy("install");
try {
await window.ccr.updateInstall();
} catch (error) {
showToast(formatUnknownError(error));
setUpdateActionBusy("");
}
}
function updateConfig(mutator: (config: AppConfig) => AppConfig) {
setDraftConfig((current) => {
const next = normalizeConfig(mutator(cloneConfig(current)));
@ -2689,9 +2626,6 @@ function App() {
isMac={isMac}
needsTrafficLightSafeArea={needsTrafficLightSafeArea}
networkCaptureEnabled={networkCaptureEnabled}
onCheckUpdate={checkAppUpdate}
onDownloadUpdate={downloadAppUpdate}
onInstallUpdate={installAppUpdate}
onOpenSettings={openSettingsDialog}
onSelectNavigationItem={selectNavigationItem}
onToggleSidebar={() => setSidebarOpen((current) => !current)}
@ -2699,8 +2633,6 @@ function App() {
shouldReduceMotion={shouldReduceMotion}
sidebarOpen={sidebarOpen}
toggleGatewayService={toggleGatewayService}
updateActionBusy={updateActionBusy}
updateStatus={updateStatus}
visibleNavigation={visibleNavigation}
viewProps={{
apiKeys: {

View file

@ -1,9 +1,9 @@
import type { ComponentProps } from "react";
import {
AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, AppUpdateStatus, Button, Check, CircleAlert, cn, EndpointTitleBar,
AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, cn, EndpointTitleBar,
GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase,
LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen,
reducedMotionTransition, RefreshCw, ServiceControlButton, Settings, ViewId,
NavigationId, PanelLeftClose, PanelLeftOpen,
reducedMotionTransition, ServiceControlButton, Settings, ViewId,
ViewMotionShell, viewUsesInternalScroll
} from "../shared";
import { ApiKeysView } from "./api-keys";
@ -22,8 +22,6 @@ type MainNavigationItem = {
id: NavigationId;
};
type UpdateActionBusy = "" | "check" | "download" | "install";
type MainViewProps = {
apiKeys: ComponentProps<typeof ApiKeysView>;
extensions: ComponentProps<typeof ExtensionsView>;
@ -65,17 +63,12 @@ export function MainLayout({
needsTrafficLightSafeArea,
agentAnalysisEnabled,
networkCaptureEnabled,
onCheckUpdate,
onDownloadUpdate,
onInstallUpdate,
onOpenSettings,
onSelectNavigationItem,
onToggleSidebar,
shouldReduceMotion,
sidebarOpen,
toggleGatewayService,
updateActionBusy,
updateStatus,
viewProps,
requestLogsEnabled,
visibleNavigation
@ -90,23 +83,17 @@ export function MainLayout({
isMac: boolean;
needsTrafficLightSafeArea: boolean;
networkCaptureEnabled: boolean;
onCheckUpdate: () => Promise<void>;
onDownloadUpdate: () => Promise<void>;
onInstallUpdate: () => Promise<void>;
onOpenSettings: () => void;
onSelectNavigationItem: (id: NavigationId) => void;
onToggleSidebar: () => void;
shouldReduceMotion: boolean | null;
sidebarOpen: boolean;
toggleGatewayService: () => void;
updateActionBusy: UpdateActionBusy;
updateStatus: AppUpdateStatus;
viewProps: MainViewProps;
requestLogsEnabled: boolean;
visibleNavigation: MainNavigationItem[];
}) {
const windowControlSafeAreaWidth = isMac ? 152 : 88;
const showUpdateButton = isUpdateButtonVisible(updateStatus);
return (
<>
@ -159,19 +146,6 @@ export function MainLayout({
<div className="app-drag min-w-0 flex-1" />
</div>
{showUpdateButton ? (
<div className="shrink-0 px-2 pb-2">
<SidebarUpdateButton
actionBusy={updateActionBusy}
copy={copy}
onCheck={onCheckUpdate}
onDownload={onDownloadUpdate}
onInstall={onInstallUpdate}
status={updateStatus}
/>
</div>
) : null}
<nav className="flex min-h-0 flex-1 flex-col gap-1 px-2 py-3 max-[720px]:flex-none max-[720px]:flex-row max-[720px]:overflow-x-auto max-[720px]:py-2" aria-label={copy.sidebar.primaryNavigation}>
{visibleNavigation.map((item) => (
<Button
@ -256,95 +230,6 @@ 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 === "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.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={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;
}
if (canDownload) {
void onDownload();
return;
}
void onCheck();
}}
title={title}
type="button"
unstyled
>
<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-[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 || status.state === "available") return t("Download update");
if (status.state === "error") return t("Update failed");
return t("Check for updates");
}
function MainViewSwitch({
activeView,
agentAnalysisEnabled,