fix: improve startup and restore global profiles

This commit is contained in:
jesieleo
2026-07-11 11:52:53 +08:00
parent faa721fed8
commit 57b1169fff
8 changed files with 383 additions and 50 deletions
+10 -6
View File
@@ -32,12 +32,7 @@ export function broadcastWindowsEnvironmentChanged(): void {
return;
}
const script = [
"$signature = '[DllImport(\"user32.dll\", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);';",
"Add-Type -MemberDefinition $signature -Namespace Win32 -Name NativeMethods;",
"$result = [UIntPtr]::Zero;",
"[Win32.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 0x2, 5000, [ref]$result) | Out-Null;"
].join(" ");
const script = windowsEnvironmentChangedPowerShellLines().join(" ");
spawnSync(windowsSystemCommand("powershell.exe"), [
"-NoProfile",
@@ -51,3 +46,12 @@ export function broadcastWindowsEnvironmentChanged(): void {
windowsHide: true
});
}
export function windowsEnvironmentChangedPowerShellLines(): string[] {
return [
"$signature = '[DllImport(\"user32.dll\", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);';",
"Add-Type -MemberDefinition $signature -Namespace Win32 -Name NativeMethods;",
"$result = [UIntPtr]::Zero;",
"[Win32.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 0x2, 5000, [ref]$result) | Out-Null;"
];
}
+46 -7
View File
@@ -14,7 +14,7 @@ import { gatewayService } from "@ccr/core/gateway/service";
import { TOOL_HUB_MCP_RUNTIME_FILE_NAME, bundledToolHubMcpEntryPathCandidates } from "@ccr/core/mcp/toolhub-config";
import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, profileOpenSurfaces, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core";
import { applyProfileConfig, cleanupGeneratedBinBackups } from "@ccr/core/profiles/service";
import { broadcastWindowsEnvironmentChanged, windowsSystemCommand } from "@ccr/core/platform/windows-system";
import { windowsEnvironmentChangedPowerShellLines, windowsSystemCommand } from "@ccr/core/platform/windows-system";
const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>";
const ccrPathBlockEnd = "# <<< Claude Code Router CLI <<<";
@@ -29,6 +29,15 @@ type ProfileOpenCommandOptions = {
ensureLauncher?: boolean;
};
export type CcrCliLauncherPreparation = {
binDir: string;
persistentPathRequired: boolean;
};
type EnsureCcrCliLauncherOptions = {
persistPath?: boolean;
};
type ProfileAppLaunchResult = {
child: ChildProcess;
claudeDesignProxy?: boolean;
@@ -1090,8 +1099,9 @@ function commandProfileRef(config: AppConfig, profile: ReturnType<typeof findPro
return duplicateName ? profile.id : name;
}
export function ensureCcrCliLauncher(config?: AppConfig): string {
export function prepareCcrCliLauncherRuntime(): CcrCliLauncherPreparation {
const binDir = path.join(CONFIGDIR, "bin");
const persistentPathRequired = !processPathIncludes(binDir);
mkdirSync(binDir, { recursive: true });
cleanupGeneratedBinBackups();
cleanupLegacyCcrCliLauncher(binDir);
@@ -1101,6 +1111,22 @@ export function ensureCcrCliLauncher(config?: AppConfig): string {
writeFileIfChanged(runtimeFile, readFileSync(runtimeSource, "utf8"));
chmodSafe(runtimeFile);
ensureBundledToolHubMcpRuntime(path.join(binDir, TOOL_HUB_MCP_RUNTIME_FILE_NAME));
prependProcessPath(binDir);
return { binDir, persistentPathRequired };
}
export function persistPreparedCcrCliPath(preparation: CcrCliLauncherPreparation): void {
if (!preparation.persistentPathRequired) {
return;
}
persistCcrBinOnPath(preparation.binDir);
}
export function ensureCcrCliLauncher(config?: AppConfig, options: EnsureCcrCliLauncherOptions = {}): string {
const preparation = prepareCcrCliLauncherRuntime();
const { binDir } = preparation;
const runtimeFile = path.join(binDir, desktopCliRuntimeFileName);
const launcherFile = path.join(binDir, process.platform === "win32" ? `${desktopCliCommandName}.cmd` : desktopCliCommandName);
const launcherContent = process.platform === "win32"
@@ -1108,7 +1134,9 @@ export function ensureCcrCliLauncher(config?: AppConfig): string {
: posixCcrLauncher(runtimeFile);
writeFileIfChanged(launcherFile, launcherContent);
chmodSafe(launcherFile);
ensureCcrBinOnPath(binDir);
if (options.persistPath !== false) {
persistPreparedCcrCliPath(preparation);
}
return launcherFile;
}
@@ -1283,8 +1311,7 @@ function writeFileIfChanged(file: string, content: string): void {
writeFileSync(file, content, "utf8");
}
function ensureCcrBinOnPath(binDir: string): void {
prependProcessPath(binDir);
function persistCcrBinOnPath(binDir: string): void {
try {
if (process.platform === "win32") {
ensureWindowsUserPath(binDir);
@@ -1296,6 +1323,13 @@ function ensureCcrBinOnPath(binDir: string): void {
}
}
function processPathIncludes(binDir: string): boolean {
const pathKey = process.platform === "win32"
? Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "Path"
: "PATH";
return pathSegmentsInclude((process.env[pathKey] || "").split(path.delimiter).filter(Boolean), binDir);
}
function prependProcessPath(binDir: string): void {
const pathKey = process.platform === "win32"
? Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "Path"
@@ -1309,7 +1343,8 @@ function prependProcessPath(binDir: string): void {
process.env[pathKey] = [binDir, ...segments].join(delimiter);
}
function ensureWindowsUserPath(binDir: string): void {
function ensureWindowsUserPath(binDir: string): boolean {
const broadcastLines = windowsEnvironmentChangedPowerShellLines().map((line) => ` ${line}`);
const script = [
"$ErrorActionPreference = 'Stop'",
`$bin = ${powershellString(binDir)}`,
@@ -1322,6 +1357,10 @@ function ensureWindowsUserPath(binDir: string): void {
"$expandedSegments = $segments | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\\\\') }",
"if ($expandedSegments -notcontains $expandedBin) {",
" [Environment]::SetEnvironmentVariable('Path', ((@($bin) + $segments) -join ';'), 'User')",
...broadcastLines,
" Write-Output 'CHANGED'",
"} else {",
" Write-Output 'UNCHANGED'",
"}"
].join("\n");
const result = spawnSync(windowsSystemCommand("powershell.exe"), [
@@ -1341,7 +1380,7 @@ function ensureWindowsUserPath(binDir: string): void {
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout || `powershell.exe exited with ${result.status}`).trim());
}
broadcastWindowsEnvironmentChanged();
return result.stdout.trim().split(/\r?\n/).includes("CHANGED");
}
function ensurePosixShellPath(binDir: string): void {
+196 -21
View File
@@ -34,11 +34,23 @@ const managedToolHubMcpStart = "# BEGIN CCR managed ToolHub MCP";
const managedToolHubMcpEnd = "# END CCR managed ToolHub MCP";
const originalBackupSuffix = ".ccr-original";
const originalMissingSuffix = ".ccr-original-missing";
const globalProfileTakeoverFile = path.join(CONFIGDIR, "global-profile-takeover.json");
const fallbackClientToken = "ccr-local";
const privateDirMode = 0o700;
const privateExecutableMode = 0o700;
const privateFileMode = 0o600;
const publicExecutableMode = 0o755;
let ownedGlobalProfileTakeovers: GlobalProfileTakeoverRecord[] | undefined;
type GlobalProfileTakeoverRecord = {
agent: ProfileClientKind;
codexHome?: string;
configFile?: string;
id: string;
name: string;
providerId?: string;
settingsFile?: string;
};
export async function applyProfileConfig(config: AppConfig): Promise<ProfileApplyResult> {
cleanupGeneratedBinBackups();
@@ -49,9 +61,15 @@ export async function applyProfileConfig(config: AppConfig): Promise<ProfileAppl
clients: [],
enabled: profiles.some((profile) => profile.enabled)
};
const takeoverStatuses = synchronizeGlobalProfileTakeovers(
profiles,
result.enabled && hasAvailableGatewayModels(config)
);
if (!result.enabled) {
result.clients = profiles.map(disabledProfileStatus);
result.clients.push(...takeoverStatuses);
result.clients.push(...restoreInactiveGlobalProfileConfigs(profiles));
return result;
}
@@ -76,6 +94,8 @@ export async function applyProfileConfig(config: AppConfig): Promise<ProfileAppl
}
: status;
});
result.clients.push(...takeoverStatuses);
result.clients.push(...restoreInactiveGlobalProfileConfigs(profiles));
return result;
}
@@ -91,6 +111,7 @@ export async function applyProfileConfig(config: AppConfig): Promise<ProfileAppl
: applyCodexProfile(config, profile, token, appliedAt)
);
}
result.clients.push(...takeoverStatuses);
cleanupManagedClaudeCodeToolHubArtifacts(profiles, { includeActive: false });
result.clients.push(...restoreInactiveGlobalProfileConfigs(profiles));
return result;
@@ -1404,9 +1425,160 @@ export function restoreInactiveGlobalProfileConfigs(profiles: ProfileConfig[]):
}
}
}
const codexProfiles = profiles.filter((profile) => profile.agent === "codex");
if (codexProfiles.length > 0 && !codexProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) {
for (const file of uniqueResolvedPaths([
...codexProfiles.map(globalCodexConfigCandidate)
])) {
const restoreResult = restoreGlobalConfigFile(file, {
isManagedContent: (content) => isManagedCodexConfigContent(content, "claude-code-router"),
mode: privateFileMode
});
if (restoreResult.changed || restoreResult.missingBackup) {
statuses.push(inactiveGlobalCleanupStatus("codex", file, restoreResult));
}
}
}
const zcodeProfiles = profiles.filter((profile) => profile.agent === "zcode");
if (zcodeProfiles.length > 0 && !zcodeProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) {
const providerIds = [...new Set([
"claude-code-router",
...zcodeProfiles.map((profile) => sanitizeCodexProviderId(profile.providerId || "")).filter(Boolean)
])];
const configFiles = uniqueResolvedPaths([
...zcodeProfiles.map((profile) => resolveZcodeConfigFile(profile))
]);
for (const configFile of configFiles) {
const storageRoot = zcodeHomeFromConfigFile(configFile);
for (const file of [
configFile,
path.join(storageRoot, "v2", "config.json"),
path.join(storageRoot, "v2", "bots-model-cache.v2.json")
]) {
const restoreResult = restoreGlobalConfigFile(file, {
isManagedContent: (content) => providerIds.some((providerId) => isManagedZcodeConfigContent(content, providerId)),
mode: privateFileMode
});
if (restoreResult.changed || restoreResult.missingBackup) {
statuses.push(inactiveGlobalCleanupStatus("zcode", file, restoreResult));
}
}
}
}
return statuses;
}
function globalCodexConfigCandidate(profile: ProfileConfig): string {
const codexHome = profile.codexHome?.trim();
if (codexHome) {
return path.join(resolveUserPath(codexHome), "config.toml");
}
return profile.configFile || "~/.codex/config.toml";
}
export function restoreGlobalProfileConfigsOnExit(
profiles: ProfileConfig[],
options: { manageMarker?: boolean } = {}
): ProfileClientApplyStatus[] {
const manageMarker = options.manageMarker !== false;
const records = dedupeGlobalProfileTakeovers([
...(manageMarker ? ownedGlobalProfileTakeovers ?? readGlobalProfileTakeoverMarker() : []),
...globalProfileTakeoverRecords(profiles)
]);
const statuses = restoreGlobalProfileTakeoverRecords(records);
if (manageMarker && statuses.every((status) => status.ok)) {
clearGlobalProfileTakeoverMarker();
ownedGlobalProfileTakeovers = [];
}
return statuses;
}
function synchronizeGlobalProfileTakeovers(profiles: ProfileConfig[], canTakeOver: boolean): ProfileClientApplyStatus[] {
const next = canTakeOver ? globalProfileTakeoverRecords(profiles) : [];
const previous = ownedGlobalProfileTakeovers ?? readGlobalProfileTakeoverMarker();
if (ownedGlobalProfileTakeovers !== undefined && JSON.stringify(previous) === JSON.stringify(next)) {
return [];
}
const statuses = previous.length > 0 ? restoreGlobalProfileTakeoverRecords(previous) : [];
const markerRecords = statuses.every((status) => status.ok)
? next
: dedupeGlobalProfileTakeovers([...previous, ...next]);
if (markerRecords.length > 0) {
writeGlobalProfileTakeoverMarker(markerRecords);
} else {
clearGlobalProfileTakeoverMarker();
}
ownedGlobalProfileTakeovers = markerRecords;
return statuses;
}
function globalProfileTakeoverRecords(profiles: ProfileConfig[]): GlobalProfileTakeoverRecord[] {
return dedupeGlobalProfileTakeovers(profiles
.filter((profile) => profile.enabled && isGlobalProfile(profile))
.map((profile) => ({
agent: profile.agent,
codexHome: profile.codexHome?.trim() || undefined,
configFile: profile.configFile?.trim() || undefined,
id: profile.id,
name: profile.name,
providerId: profile.providerId?.trim() || undefined,
settingsFile: profile.settingsFile?.trim() || undefined
})));
}
function restoreGlobalProfileTakeoverRecords(records: GlobalProfileTakeoverRecord[]): ProfileClientApplyStatus[] {
return records.map((record) => disabledProfileStatus({
...record,
enabled: false,
env: {},
model: "",
scope: "global",
surface: "auto"
}));
}
function dedupeGlobalProfileTakeovers(records: GlobalProfileTakeoverRecord[]): GlobalProfileTakeoverRecord[] {
const seen = new Set<string>();
return records.filter((record) => {
const key = JSON.stringify(record);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function readGlobalProfileTakeoverMarker(): GlobalProfileTakeoverRecord[] {
try {
const parsed = JSON.parse(readFileSync(globalProfileTakeoverFile, "utf8")) as { profiles?: unknown };
if (!Array.isArray(parsed.profiles)) {
return [];
}
return parsed.profiles.filter((value): value is GlobalProfileTakeoverRecord =>
isRecord(value) &&
(value.agent === "claude-code" || value.agent === "codex" || value.agent === "zcode") &&
typeof value.id === "string" &&
typeof value.name === "string"
);
} catch {
return [];
}
}
function writeGlobalProfileTakeoverMarker(records: GlobalProfileTakeoverRecord[]): void {
mkdirSync(path.dirname(globalProfileTakeoverFile), { recursive: true });
writeFileSync(globalProfileTakeoverFile, `${JSON.stringify({ profiles: records, version: 1 }, null, 2)}\n`, {
encoding: "utf8",
mode: privateFileMode
});
}
function clearGlobalProfileTakeoverMarker(): void {
rmSync(globalProfileTakeoverFile, { force: true });
}
function inactiveGlobalCleanupStatus(
client: ProfileClientKind,
file: string,
@@ -1513,6 +1685,20 @@ function restoreGlobalConfigFile(
return { changed: false, file, missingBackup: false, restored: false };
}
const snapshot = originalSnapshotCandidate(file, options.isManagedContent);
if (snapshot) {
if (current === snapshot.content) {
chmodFileIfRequested(file, options.mode);
return { changed: false, file, missingBackup: false, restored: true };
}
const backupFile = current === undefined ? undefined : backupCurrentConfigFile(file, options.mode);
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, snapshot.content, options.mode === undefined ? "utf8" : { encoding: "utf8", mode: options.mode });
chmodFileIfRequested(file, options.mode);
return { backupFile, changed: true, file, missingBackup: false, restored: true };
}
if (existsSync(originalMissingFilePath(file))) {
if (currentManaged) {
const backupFile = backupCurrentConfigFile(file, options.mode);
@@ -1522,33 +1708,22 @@ function restoreGlobalConfigFile(
return { changed: false, file, missingBackup: false, restored: current === undefined };
}
const snapshot = originalSnapshotCandidate(file, options.isManagedContent);
if (!snapshot) {
return {
changed: false,
file,
missingBackup: Boolean(currentManaged),
restored: false
};
}
if (current === snapshot.content) {
chmodFileIfRequested(file, options.mode);
return { changed: false, file, missingBackup: false, restored: true };
}
const backupFile = current === undefined ? undefined : backupCurrentConfigFile(file, options.mode);
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, snapshot.content, options.mode === undefined ? "utf8" : { encoding: "utf8", mode: options.mode });
chmodFileIfRequested(file, options.mode);
return { backupFile, changed: true, file, missingBackup: false, restored: true };
return {
changed: false,
file,
missingBackup: Boolean(currentManaged),
restored: false
};
}
function originalSnapshotCandidate(
file: string,
isManagedContent: (content: string) => boolean
): { content: string; file: string } | undefined {
for (const candidate of [originalBackupFilePath(file), ...backupFiles(file)]) {
// Prefer the most recent non-CCR snapshot captured immediately before the
// latest takeover. The permanent .ccr-original file can be stale when the
// user changes the agent config between separate CCR sessions.
for (const candidate of [...backupFiles(file).reverse(), originalBackupFilePath(file)]) {
if (!existsSync(candidate)) {
continue;
}
+33 -6
View File
@@ -5,8 +5,8 @@ import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "@ccr/
import { deepLinkService } from "./deep-link";
import { gatewayService } from "@ccr/core/gateway/service";
import "./ipc";
import { applyProfileConfig } from "@ccr/core/profiles/service";
import { ensureCcrCliLauncher } from "@ccr/core/profiles/launch-service";
import { applyProfileConfig, restoreGlobalProfileConfigsOnExit } from "@ccr/core/profiles/service";
import { ensureCcrCliLauncher, persistPreparedCcrCliPath, prepareCcrCliLauncherRuntime, type CcrCliLauncherPreparation } from "@ccr/core/profiles/launch-service";
import { syncLaunchAtLogin } from "./launch-at-login";
import { proxyService } from "@ccr/core/proxy/service";
import trayController from "./tray-controller";
@@ -43,13 +43,25 @@ function startPrimaryInstance(): void {
void app.whenReady().then(() => {
configureProxyDesktopIntegration();
let ccrLauncherPreparation: CcrCliLauncherPreparation | undefined;
try {
ensureCcrCliLauncher();
ccrLauncherPreparation = prepareCcrCliLauncherRuntime();
} catch (error) {
console.error(`Failed to install ccr CLI launcher: ${formatError(error)}`);
console.error(`Failed to prepare ccr CLI runtime: ${formatError(error)}`);
}
setupApplicationMenu();
windowsManager.createMainWindow();
const mainWindow = windowsManager.createMainWindow();
if (ccrLauncherPreparation?.persistentPathRequired) {
mainWindow.once("ready-to-show", () => {
setTimeout(() => {
try {
persistPreparedCcrCliPath(ccrLauncherPreparation);
} catch (error) {
console.error(`Failed to persist ccr CLI PATH: ${formatError(error)}`);
}
}, 0);
});
}
trayController.start();
appUpdateService.start();
appUpdateService.setInstallPreparation(prepareForUpdateInstall);
@@ -164,7 +176,17 @@ function stopServicesForQuit(): Promise<void> {
.catch((error) => {
console.error(`Failed to stop services before quit: ${formatError(error)}`);
})
.finally(() => {
.finally(async () => {
try {
const config = await loadAppConfig();
for (const status of restoreGlobalProfileConfigsOnExit(config.profile.profiles)) {
if (!status.ok) {
console.error(`Failed to restore ${status.client} global profile config before quit: ${status.message}`);
}
}
} catch (error) {
console.error(`Failed to restore global profile configs before quit: ${formatError(error)}`);
}
try {
restoreClaudeAppGatewayConfig();
} catch (error) {
@@ -185,6 +207,11 @@ function startConfiguredServices(reason: string): Promise<void> {
} catch (error) {
console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`);
}
try {
ensureCcrCliLauncher(config, { persistPath: false });
} catch (error) {
console.error(`Failed to install ccr CLI launcher during ${reason}: ${formatError(error)}`);
}
try {
syncLaunchAtLogin(config);
} catch (error) {
+1 -1
View File
@@ -10,7 +10,7 @@ type UpdateCheckOptions = {
};
const startupCheckDelayMs = 12_000;
const defaultUpdateSource = "GitHub Releases (musistudio/claude-code-router)";
const defaultUpdateSource = "GitHub Releases";
class AppUpdateService {
private activeSilentCheckFailureRestoreStatus?: AppUpdateStatus;
@@ -46,12 +46,13 @@ export function UpdateDialog({
<DialogBody className="grid gap-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-foreground">{updateStateLabel(status, t)}</div>
<div className="mt-1 text-[12px] leading-5 text-muted-foreground">{updateStateDescription(status, t)}</div>
<div className="min-w-0 flex-1">
{updateStateDescription(status, t) ? (
<div className="text-[12px] leading-5 text-muted-foreground">{updateStateDescription(status, t)}</div>
) : null}
</div>
<UpdateStateBadge
label={status.state === "not-available" ? t("Update check complete") : updateStateLabel(status, t)}
label={updateStateLabel(status, t)}
status={status}
/>
</div>
@@ -59,8 +60,8 @@ export function UpdateDialog({
<div className="grid grid-cols-2 gap-2 max-[520px]:grid-cols-1">
<UpdateInfoRow label={t("Current version")} value={status.currentVersion} />
<UpdateInfoRow
label={t("Available version")}
value={status.availableVersion || (status.state === "not-available" ? t("No updates available") : "-")}
label={t("Latest version")}
value={status.availableVersion || (status.state === "not-available" ? status.currentVersion : "-")}
/>
<UpdateInfoRow label={t("Last checked")} value={formatUpdateDate(status.lastCheckedAt) || "-"} />
<UpdateInfoRow label={t("Feed URL")} scroll value={status.feedUrl || "-"} />
@@ -156,8 +157,10 @@ function UpdateStateBadge({ label, status }: { label: string; status: AppUpdateS
"shrink-0 rounded-full border px-2 py-1 text-[11px] font-medium",
status.state === "error"
? "border-destructive/25 bg-destructive/10 text-destructive"
: status.state === "not-available"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: status.state === "available" || status.state === "downloaded" || status.state === "downloading"
? "border-primary/25 bg-primary/10 text-primary"
? "border-amber-200 bg-amber-50 text-amber-700"
: "border-border bg-muted/40 text-muted-foreground"
)}>
{label}
@@ -181,7 +184,7 @@ function updateStateDescription(status: AppUpdateStatus, t: (value: string) => s
if (!status.supported) return t("Updates are only available in packaged builds.");
if (status.state === "available" && status.availableVersion) return `${t("Available version")}: ${status.availableVersion}`;
if (status.state === "downloaded") return t("Update downloaded");
if (status.state === "not-available") return t("No updates available");
if (status.state === "not-available") return "";
if (status.state === "downloading") return t("Downloading update");
return t("Online updates");
}
@@ -1679,6 +1679,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"System status": "系统状态",
"System Proxy": "系统代理",
"Available version": "可用版本",
"Latest version": "最新版本",
"Download update": "下载更新",
"Downloading update": "正在下载更新",
"Feed URL": "更新源",
+85 -1
View File
@@ -5,7 +5,7 @@ import path from "node:path";
import test from "node:test";
import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts";
import { CONFIGDIR } from "../../packages/core/src/config/constants.ts";
import { applyProfileConfig, cleanupGeneratedBinBackups, restoreInactiveGlobalProfileConfigs } from "../../packages/core/src/profiles/service.ts";
import { applyProfileConfig, cleanupGeneratedBinBackups, restoreGlobalProfileConfigsOnExit, restoreInactiveGlobalProfileConfigs } from "../../packages/core/src/profiles/service.ts";
test("profile service cleans stale generated bin backups only", () => {
const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-generated-bin-cleanup-"));
@@ -386,3 +386,87 @@ test("profile service keeps managed global Claude settings when a global Claude
rmSync(home, { force: true, recursive: true });
}
});
test("profile service restores global agent configs on exit", () => {
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-global-profile-exit-"));
try {
const claudeFile = path.join(root, "claude", "settings.json");
const codexFile = path.join(root, "codex", "config.toml");
const zcodeFile = path.join(root, "zcode", "cli", "config.json");
const zcodeRoot = path.dirname(path.dirname(zcodeFile));
const zcodeV2File = path.join(zcodeRoot, "v2", "config.json");
const zcodeCacheFile = path.join(zcodeRoot, "v2", "bots-model-cache.v2.json");
const files = [claudeFile, codexFile, zcodeFile, zcodeV2File, zcodeCacheFile];
const originals = new Map(files.map((file, index) => [file, `original-${index}\n`]));
const latestSnapshots = new Map(files.map((file, index) => [file, `latest-${index}\n`]));
for (const [file, original] of originals) {
mkdirSync(path.dirname(file), { recursive: true });
if (file === zcodeCacheFile) {
writeFileSync(`${file}.ccr-original-missing`, "");
} else {
writeFileSync(`${file}.ccr-original`, original);
}
writeFileSync(`${file}.ccr-backup-2026-07-11T00-00-00-000Z`, latestSnapshots.get(file));
}
writeFileSync(claudeFile, `${JSON.stringify({
apiKeyHelper: "ccr-claude-code-api-key-test",
env: {
ANTHROPIC_API_BASE_URL: "http://127.0.0.1:3456",
ANTHROPIC_BASE_URL: "http://127.0.0.1:3456",
CLAUDE_AGENT_API_BASE_URL: "http://127.0.0.1:3456"
}
})}\n`);
writeFileSync(codexFile, "# BEGIN CCR managed profile\nmodel = \"test\"\n# END CCR managed profile\n");
for (const file of [zcodeFile, zcodeV2File]) {
writeFileSync(file, `${JSON.stringify({ provider: { "claude-code-router": {} } })}\n`);
}
writeFileSync(zcodeCacheFile, `${JSON.stringify({ providers: [{ id: "claude-code-router" }] })}\n`);
const statuses = restoreGlobalProfileConfigsOnExit([
{
agent: "claude-code", enabled: true, env: {}, id: "claude", model: "test", name: "Claude",
scope: "global", settingsFile: claudeFile, smallFastModel: "", surface: "cli"
},
{
agent: "codex", configFile: codexFile, enabled: true, env: {}, id: "codex", model: "test", name: "Codex",
providerId: "claude-code-router", scope: "global", surface: "cli"
},
{
agent: "zcode", configFile: zcodeFile, enabled: true, env: {}, id: "zcode", model: "test", name: "ZCode",
providerId: "claude-code-router", scope: "global", surface: "app"
}
], { manageMarker: false });
assert.equal(statuses.length, 3);
assert.equal(statuses.every((status) => status.ok), true);
for (const [file, latest] of latestSnapshots) {
assert.equal(readFileSync(file, "utf8"), latest);
}
writeFileSync(codexFile, "# BEGIN CCR managed profile\nmodel = \"test\"\n# END CCR managed profile\n");
for (const file of [zcodeFile, zcodeV2File]) {
writeFileSync(file, `${JSON.stringify({ provider: { "claude-code-router": {} } })}\n`);
}
writeFileSync(zcodeCacheFile, `${JSON.stringify({ providers: [{ id: "claude-code-router" }] })}\n`);
const inactiveStatuses = restoreInactiveGlobalProfileConfigs([
{
agent: "codex", configFile: codexFile, enabled: false, env: {}, id: "codex", model: "test", name: "Codex",
providerId: "claude-code-router", scope: "ccr", surface: "cli"
},
{
agent: "zcode", configFile: zcodeFile, enabled: false, env: {}, id: "zcode", model: "test", name: "ZCode",
providerId: "claude-code-router", scope: "ccr", surface: "app"
}
]);
assert.equal(inactiveStatuses.filter((status) => status.client === "codex").length, 1);
assert.equal(inactiveStatuses.filter((status) => status.client === "zcode").length, 3);
for (const [file, latest] of latestSnapshots) {
if (file !== claudeFile) {
assert.equal(readFileSync(file, "utf8"), latest);
}
}
} finally {
rmSync(root, { force: true, recursive: true });
}
});