mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-09-01 14:52:19 +08:00
fix: improve startup and restore global profiles
This commit is contained in:
@@ -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;"
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": "更新源",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user