diff --git a/.gitignore b/.gitignore index 860bc165..75962296 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ release .env .env.* !.env.example +.idea +.claude +.bot-gateway-state +.agent-data +tmp \ No newline at end of file diff --git a/src/main/bot-gateway-env.ts b/src/main/bot-gateway-env.ts index 72449b1a..72e8237b 100644 --- a/src/main/bot-gateway-env.ts +++ b/src/main/bot-gateway-env.ts @@ -1,13 +1,13 @@ import os from "node:os"; import { createRequire } from "node:module"; import path from "node:path"; -import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig } from "../shared/app"; +import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig, ProfileOpenSurface } from "../shared/app"; import { CONFIGDIR } from "./constants"; const requireFromHere = createRequire(__filename); -export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig): Record { - const bot = normalizeBotGatewayForWebSocket(resolveBotGatewayConfig(config, profile)); +export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig, surface?: ProfileOpenSurface): Record { + const bot = normalizeBotGatewayForWebSocket(resolveBotGatewayConfig(config, profile, surface)); if (!bot?.enabled || !bot.platform || bot.platform === "none") { return disabledBotGatewayEnv(); } @@ -29,7 +29,7 @@ export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig): CCR_BOT_GATEWAY_AUTO_START_INTEGRATION: boolEnv(bot.autoStartIntegration), CCR_BOT_GATEWAY_COMMAND: bot.command ?? "", CCR_BOT_GATEWAY_CONFIG_JSON: JSON.stringify(bot.integrationConfig ?? {}), - CCR_BOT_GATEWAY_CREATE_INTEGRATION: boolEnv(bot.createIntegration), + CCR_BOT_GATEWAY_CREATE_INTEGRATION: boolEnv(shouldCreateBotGatewayIntegration(bot)), CCR_BOT_GATEWAY_CREDENTIALS_JSON: JSON.stringify(bot.credentials ?? {}), CCR_BOT_GATEWAY_CWD: bot.cwd ?? "", CCR_BOT_GATEWAY_ENABLED: "true", @@ -38,7 +38,7 @@ export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig): CCR_BOT_GATEWAY_PLATFORM: bot.platform, CCR_BOT_GATEWAY_POLL_INTERVAL_MS: String(bot.pollIntervalMs ?? 2000), CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS: String(bot.requestTimeoutMs ?? 600000), - CCR_BOT_GATEWAY_SOURCE_DIR: bot.sourceDir ?? "", + CCR_BOT_GATEWAY_SOURCE_DIR: "", ...botGatewaySdkEnv(), CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS: String(bot.startupTimeoutMs ?? 10000), CCR_BOT_GATEWAY_STATE_DIR: stateDir, @@ -73,11 +73,51 @@ export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig): return env; } -function resolveBotGatewayConfig(config: AppConfig, profile: ProfileConfig): BotGatewayRuntimeConfig { +function resolveBotGatewayConfig(config: AppConfig, profile: ProfileConfig, surface?: ProfileOpenSurface): BotGatewayRuntimeConfig { + const runtimeSurface = surface ?? normalizeProfileSurface(profile.surface); + if (runtimeSurface !== "app") { + return { + ...config.botGateway, + enabled: false, + platform: "none" + }; + } const savedBot = profile.botConfigId ? (config.botConfigs ?? []).find((item) => item.id === profile.botConfigId) : undefined; - return savedBot?.botGateway ?? profile.botGateway ?? config.botGateway; + return mergeBotGatewayRuntimeConfig( + mergeBotGatewayRuntimeConfig(config.botGateway, savedBot?.botGateway), + profile.botGateway + ); +} + +function mergeBotGatewayRuntimeConfig( + base: BotGatewayRuntimeConfig, + override?: BotGatewayRuntimeConfig +): BotGatewayRuntimeConfig { + if (!override) { + return base; + } + return { + ...base, + ...override, + credentials: { + ...base.credentials, + ...override.credentials + }, + handoff: { + ...base.handoff, + ...override.handoff + }, + integrationConfig: { + ...base.integrationConfig, + ...override.integrationConfig + } + }; +} + +function normalizeProfileSurface(value: ProfileConfig["surface"]): "auto" | "cli" | "app" { + return value === "cli" || value === "app" ? value : "auto"; } function botGatewaySdkEnv(): Record { @@ -228,3 +268,10 @@ function sanitizePathSegment(value: string): string { function boolEnv(value: boolean): string { return value ? "true" : "false"; } + +function shouldCreateBotGatewayIntegration(bot: BotGatewayRuntimeConfig): boolean { + if (bot.authType === "qr_login") { + return false; + } + return bot.createIntegration; +} diff --git a/src/main/bot-gateway-qr-login-service.ts b/src/main/bot-gateway-qr-login-service.ts index 6e1cfa97..b44edcad 100644 --- a/src/main/bot-gateway-qr-login-service.ts +++ b/src/main/bot-gateway-qr-login-service.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -20,6 +20,7 @@ type BotGatewayClientWithRequest = { }; type BotGatewaySdkModule = { + bundledStdioPath?: () => string; createBotGatewayClient: (options?: unknown) => unknown; }; @@ -87,13 +88,18 @@ export async function startBotGatewayQrLogin( }); registered = true; + const qrCodeUrl = qrCodeUrlFromAuth(auth); + if (!qrCodeUrl) { + throw new Error("Bot Gateway QR start response missing qrCodeUrl."); + } + return { botConfigId: savedConfig.id, expiresAt: stringValue(auth.expiresAt), integrationId, message: stringValue(auth.message), platform: bot.platform, - qrCodeUrl: stringValue(auth.qrCodeUrl), + qrCodeUrl, sessionId, stateDir, tenantId: bot.tenantId @@ -165,6 +171,7 @@ export function cancelBotGatewayQrLogin( async function createQrClient(bot: BotGatewayRuntimeConfig, stateDir: string): Promise { const sdk = await loadBotGatewaySdk(); + const command = resolveBotGatewayCommand(sdk, bot); const client = sdk.createBotGatewayClient({ transport: "stdio", env: { @@ -172,13 +179,7 @@ async function createQrClient(bot: BotGatewayRuntimeConfig, stateDir: string): P BOT_GATEWAY_STATE_DIR: stateDir, CODEXL_HOME: CONFIGDIR }, - ...(bot.command - ? { - args: bot.args, - command: resolveUserPath(bot.command), - cwd: bot.cwd ? resolveUserPath(bot.cwd) : process.cwd() - } - : {}) + ...command }) as BotGatewayClientWithRequest; if (!client || typeof client.request !== "function" || typeof client.health !== "function") { throw new Error("Bot Gateway SDK client does not expose request()."); @@ -186,6 +187,53 @@ async function createQrClient(bot: BotGatewayRuntimeConfig, stateDir: string): P return client; } +function resolveBotGatewayCommand(sdk: BotGatewaySdkModule, bot: BotGatewayRuntimeConfig): { args?: string[]; command: string; cwd?: string } | undefined { + if (bot.command) { + return { + args: bot.args, + command: resolveUserPath(bot.command), + cwd: bot.cwd ? resolveUserPath(bot.cwd) : process.cwd() + }; + } + if (typeof sdk.bundledStdioPath !== "function") { + return undefined; + } + const bundledPath = sdk.bundledStdioPath(); + return { + args: [sanitizedBotGatewayStdioRunnerPath(bundledPath)], + command: process.execPath, + cwd: path.dirname(bundledPath) + }; +} + +function sanitizedBotGatewayStdioRunnerPath(sourcePath: string): string { + const source = readFileSync(sourcePath, "utf8"); + const normalized = normalizeDuplicateShebangs(source); + if (normalized === source) { + return sourcePath; + } + + const targetDir = path.join(CONFIGDIR, "bot-gateway", "runners"); + const targetPath = path.join(targetDir, "bot-gateway-stdio.mjs"); + mkdirSync(targetDir, { recursive: true }); + if (!existsSync(targetPath) || readFileSync(targetPath, "utf8") !== normalized) { + writeFileSync(targetPath, normalized); + } + return targetPath; +} + +function normalizeDuplicateShebangs(source: string): string { + const lines = source.split("\n"); + if (!lines[0]?.startsWith("#!")) { + return source; + } + let index = 1; + while (lines[index]?.startsWith("#!")) { + index += 1; + } + return [lines[0], ...lines.slice(index)].join("\n"); +} + async function loadBotGatewaySdk(): Promise { if (!sdkPromise) { sdkPromise = importBotGatewaySdk(); @@ -349,6 +397,28 @@ function unwrapGatewayResult(value: unknown): Record { return isRecord(result) ? result : value; } +function qrCodeUrlFromAuth(auth: Record): string { + const direct = stringValue(auth.qrCodeUrl) || + stringValue(auth.qrCodeURL) || + stringValue(auth.qrcodeUrl) || + stringValue(auth.qrcodeURL) || + stringValue(auth.qrcode_img_content) || + stringValue(auth.url); + if (direct) { + return direct; + } + const raw = auth.raw; + if (isRecord(raw)) { + return stringValue(raw.qrCodeUrl) || + stringValue(raw.qrCodeURL) || + stringValue(raw.qrcodeUrl) || + stringValue(raw.qrcodeURL) || + stringValue(raw.qrcode_img_content) || + stringValue(raw.url); + } + return ""; +} + function botGatewayClientRequest( client: BotGatewayClientWithRequest, method: string, diff --git a/src/main/bot-gateway-qr-window-service.ts b/src/main/bot-gateway-qr-window-service.ts new file mode 100644 index 00000000..d0201305 --- /dev/null +++ b/src/main/bot-gateway-qr-window-service.ts @@ -0,0 +1,126 @@ +import { BrowserWindow, shell } from "electron"; +import type { + BotGatewayQrWindowCloseRequest, + BotGatewayQrWindowCloseResult, + BotGatewayQrWindowOpenRequest, + BotGatewayQrWindowOpenResult +} from "../shared/app"; + +const qrWindows = new Map(); + +export async function openBotGatewayQrWindow( + request: BotGatewayQrWindowOpenRequest +): Promise { + const sessionId = request.sessionId.trim(); + if (!sessionId) { + throw new Error("QR window sessionId is required."); + } + + const url = parseQrWindowUrl(request.url); + const existing = qrWindows.get(sessionId); + if (existing && !existing.isDestroyed()) { + if (existing.webContents.getURL() !== url) { + await loadQrWindowUrl(existing, url, Boolean(request.waitForScan)); + } + existing.show(); + existing.focus(); + if (request.waitForScan) { + return { opened: true, ...await waitForQrWindowClose(existing) }; + } + return { opened: true }; + } + + const window = new BrowserWindow({ + height: 760, + minHeight: 560, + minWidth: 380, + show: true, + title: request.title?.trim() || "Weixin Login", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true + }, + width: 460 + }); + + qrWindows.set(sessionId, window); + window.on("closed", () => { + if (qrWindows.get(sessionId) === window) { + qrWindows.delete(sessionId); + } + }); + window.webContents.setWindowOpenHandler(({ url: targetUrl }) => { + if (isHttpUrl(targetUrl)) { + void shell.openExternal(targetUrl); + } + return { action: "deny" }; + }); + + window.show(); + window.focus(); + await loadQrWindowUrl(window, url, Boolean(request.waitForScan)); + if (!window.isDestroyed()) { + window.show(); + window.focus(); + } + if (request.waitForScan) { + return { opened: true, ...await waitForQrWindowClose(window) }; + } + return { opened: true }; +} + +export function closeBotGatewayQrWindow( + request: BotGatewayQrWindowCloseRequest +): BotGatewayQrWindowCloseResult { + const sessionId = request.sessionId.trim(); + const window = qrWindows.get(sessionId); + if (!window || window.isDestroyed()) { + qrWindows.delete(sessionId); + return { closed: false }; + } + qrWindows.delete(sessionId); + window.close(); + return { closed: true }; +} + +function parseQrWindowUrl(value: string): string { + const trimmed = value.trim(); + if (!isHttpUrl(trimmed)) { + throw new Error("Only http and https QR login URLs can be opened."); + } + return new URL(trimmed).toString(); +} + +async function loadQrWindowUrl(window: BrowserWindow, url: string, allowClosed: boolean) { + try { + await window.loadURL(url); + } catch (error) { + if (allowClosed && window.isDestroyed()) { + return; + } + throw error; + } +} + +async function waitForQrWindowClose( + window: BrowserWindow +): Promise> { + if (window.isDestroyed()) { + return { observed: true, reason: "closed" }; + } + return new Promise((resolve) => { + const onClosed = () => resolve({ observed: true, reason: "closed" }); + window.once("closed", onClosed); + }); +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/main/bot-handoff-scan-service.ts b/src/main/bot-handoff-scan-service.ts new file mode 100644 index 00000000..6201aa4b --- /dev/null +++ b/src/main/bot-handoff-scan-service.ts @@ -0,0 +1,293 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { BotHandoffScanTarget } from "../shared/app"; + +const execFileAsync = promisify(execFile); + +export async function scanBotHandoffWifiTargets(): Promise { + const output = await commandStdout("arp", ["-a"]); + if (!output.trim()) { + throw new Error("No Wi-Fi/LAN targets found."); + } + return parseArpScanTargets(output); +} + +export async function scanBotHandoffBluetoothTargets(): Promise { + const targets: BotHandoffScanTarget[] = []; + if (process.platform === "darwin") { + await collectBluetoothTargetsFromCommand(targets, "blueutil", ["--format", "json", "--connected"], "blueutil connected"); + await collectBluetoothTargetsFromCommand(targets, "blueutil", ["--format", "json", "--paired"], "blueutil paired"); + await collectBluetoothTargetsFromCommand(targets, "blueutil", ["--format", "json", "--recent"], "blueutil recent"); + await collectBluetoothTargetsFromCommand(targets, "/usr/sbin/system_profiler", ["SPBluetoothDataType", "-json"], "system_profiler bluetooth json"); + await collectBluetoothTargetsFromCommand(targets, "/usr/sbin/system_profiler", ["SPBluetoothDataType"], "system_profiler bluetooth"); + await collectBluetoothTargetsFromCommand(targets, "ioreg", ["-r", "-c", "IOBluetoothDevice", "-l"], "ioreg IOBluetoothDevice"); + } else if (process.platform === "win32") { + await collectBluetoothTargetsFromCommand(targets, "powershell.exe", [ + "-NoProfile", + "-Command", + "Get-PnpDevice -Class Bluetooth | Where-Object { $_.FriendlyName } | ForEach-Object { $_.FriendlyName }" + ], "Windows Bluetooth device"); + } + return uniqueTargets(targets); +} + +async function collectBluetoothTargetsFromCommand( + targets: BotHandoffScanTarget[], + command: string, + args: string[], + sourceDetail: string +) { + const output = await commandStdout(command, args).catch(() => ""); + if (!output.trim()) { + return; + } + for (const target of parseBluetoothScanTargets(output)) { + pushUniqueTarget(targets, { + ...target, + detail: target.detail || sourceDetail + }); + } +} + +async function commandStdout(command: string, args: string[]): Promise { + const { stdout } = await execFileAsync(command, args, { + maxBuffer: 4 * 1024 * 1024, + timeout: 12_000 + }); + return stdout; +} + +function parseArpScanTargets(output: string): BotHandoffScanTarget[] { + const targets: BotHandoffScanTarget[] = []; + for (const line of output.split(/\r?\n/)) { + const target = parseArpScanTarget(line); + if (target) { + pushUniqueTarget(targets, target); + } + } + return targets; +} + +function parseArpScanTarget(line: string): BotHandoffScanTarget | undefined { + const trimmed = line.trim(); + if (!trimmed || trimmed.includes("(incomplete)")) { + return undefined; + } + const windowsTarget = parseWindowsArpScanTarget(trimmed); + if (windowsTarget) { + return windowsTarget; + } + const open = trimmed.indexOf("("); + const close = open >= 0 ? trimmed.indexOf(")", open + 1) : -1; + if (open < 0 || close < 0) { + return undefined; + } + const host = trimmed.slice(0, open).trim().replace(/\.$/, ""); + const ip = trimmed.slice(open + 1, close).trim(); + const afterAt = trimmed.split(" at ")[1]?.trim() ?? ""; + const mac = afterAt.split(/\s+/)[0]?.replace(/,$/, "") ?? ""; + const networkInterface = trimmed.split(" on ")[1]?.split(/\s+/)[0] ?? ""; + const target = ip || mac; + if (!target) { + return undefined; + } + const detailParts = []; + if (mac && mac !== "(incomplete)") { + detailParts.push(`MAC ${mac}`); + } + if (networkInterface) { + detailParts.push(`interface ${networkInterface}`); + } + return { + detail: detailParts.join(" / "), + id: `wifi:${target}`, + label: host && host !== "?" ? `${host} (${target})` : target, + source: "wifi", + target + }; +} + +function parseWindowsArpScanTarget(line: string): BotHandoffScanTarget | undefined { + const [ip, mac] = line.split(/\s+/); + if (!looksLikeIpv4(ip) || !looksLikeMac(mac)) { + return undefined; + } + return { + detail: `MAC ${mac}`, + id: `wifi:${ip}`, + label: `${ip} (${mac})`, + source: "wifi", + target: ip + }; +} + +function parseBluetoothScanTargets(output: string): BotHandoffScanTarget[] { + const targets: BotHandoffScanTarget[] = []; + try { + collectBluetoothScanTargets(JSON.parse(output), targets); + } catch { + // Text output is parsed below. + } + if (targets.length === 0) { + collectBluetoothScanTargetsFromText(output, targets); + } + return uniqueTargets(targets); +} + +function collectBluetoothScanTargets(value: unknown, targets: BotHandoffScanTarget[]) { + if (Array.isArray(value)) { + for (const item of value) { + collectBluetoothScanTargets(item, targets); + } + return; + } + if (!isRecord(value)) { + return; + } + const target = bluetoothScanTargetFromObject(value); + if (target) { + pushUniqueTarget(targets, target); + } + for (const item of Object.values(value)) { + if (typeof item === "object" && item !== null) { + collectBluetoothScanTargets(item, targets); + } + } +} + +function bluetoothScanTargetFromObject(record: Record): BotHandoffScanTarget | undefined { + const name = firstStringField(record, [ + "device_name", + "device_title", + "name", + "_name", + "displayName", + "deviceName", + "DeviceName", + "localName", + "Product" + ])?.trim() ?? ""; + const address = firstStringField(record, [ + "device_address", + "address", + "bd_addr", + "macAddress", + "deviceAddress", + "BD_ADDR", + "BTAddress", + "DeviceAddress" + ])?.trim(); + const identifier = firstStringField(record, ["identifier", "id", "uuid", "UUID", "peripheralIdentifier"])?.trim(); + const hasDeviceMarker = Boolean(address || identifier || firstStringField(record, ["device_rssi", "rssi", "RSSI"])) || + Object.keys(record).some((key) => key.toLowerCase().includes("device")); + if (!hasDeviceMarker || name.toLowerCase() === "bluetooth" || name.toLowerCase() === "bluetooth-incoming-port") { + return undefined; + } + const target = address || identifier || name; + if (!target) { + return undefined; + } + const detailParts = []; + if (address) { + detailParts.push(`address ${address}`); + } + if (identifier && identifier !== address) { + detailParts.push(`id ${identifier}`); + } + const connected = firstStringField(record, ["device_connected", "connected"]); + if (connected) { + detailParts.push(`connected ${connected}`); + } + const rssi = firstStringField(record, ["device_rssi", "rssi", "RSSI"]); + if (rssi) { + detailParts.push(`RSSI ${rssi}`); + } + return { + detail: detailParts.join(" / "), + id: `bluetooth:${target}`, + label: name || bluetoothFallbackLabel(target), + source: "bluetooth", + target + }; +} + +function collectBluetoothScanTargetsFromText(output: string, targets: BotHandoffScanTarget[]) { + const blocks: Array> = []; + let current: Record = {}; + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const quoted = trimmed.match(/"([^"]+)"\s*=\s*"([^"]*)"/); + if (quoted) { + current[quoted[1]] = quoted[2]; + continue; + } + const keyValue = trimmed.match(/^([^:]+):\s*(.+)$/); + if (keyValue) { + current[keyValue[1].trim()] = keyValue[2].trim(); + continue; + } + const heading = trimmed.match(/^(.+):$/); + if (heading) { + if (Object.keys(current).length > 0) { + blocks.push(current); + } + current = { name: heading[1].trim() }; + } + } + if (Object.keys(current).length > 0) { + blocks.push(current); + } + for (const block of blocks) { + const target = bluetoothScanTargetFromObject(block); + if (target) { + pushUniqueTarget(targets, target); + } + } +} + +function firstStringField(record: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if ((typeof value === "number" || typeof value === "boolean") && String(value).trim()) { + return String(value); + } + } + return undefined; +} + +function bluetoothFallbackLabel(target: string): string { + const short = target.length > 12 ? `${target.slice(0, 8)}...${target.slice(-4)}` : target; + return `Bluetooth device ${short}`; +} + +function pushUniqueTarget(targets: BotHandoffScanTarget[], target: BotHandoffScanTarget) { + if (!targets.some((item) => item.id === target.id || (item.source === target.source && item.target === target.target))) { + targets.push(target); + } +} + +function uniqueTargets(targets: BotHandoffScanTarget[]): BotHandoffScanTarget[] { + const result: BotHandoffScanTarget[] = []; + for (const target of targets) { + pushUniqueTarget(result, target); + } + return result; +} + +function looksLikeIpv4(value: string | undefined): value is string { + return Boolean(value && /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value)); +} + +function looksLikeMac(value: string | undefined): value is string { + return Boolean(value && /^[0-9a-f]{2}(?:[:-][0-9a-f]{2}){5}$/i.test(value)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/main/claude-app-gateway-service.ts b/src/main/claude-app-gateway-service.ts index 17fe5719..cb7e95d3 100644 --- a/src/main/claude-app-gateway-service.ts +++ b/src/main/claude-app-gateway-service.ts @@ -5,7 +5,6 @@ import path from "node:path"; import { randomBytes, randomUUID } from "node:crypto"; import { saveAppConfig } from "./config"; import { CONFIGDIR } from "./constants"; -import { buildCodexModelCatalog } from "./codex-model-catalog"; import type { ApiKeyConfig, AppConfig, ClaudeAppGatewayApplyResult } from "../shared/app"; const CLAUDE_APP_CONFIG_ID = "8f69f2f1-3275-4ad8-9317-4aa7e972f311"; @@ -86,8 +85,8 @@ export function applyClaudeAppGatewayConfig(config: AppConfig, options: ClaudeAp const state = ensureClaudeAppGatewayState(config); const paths = getClaudeAppGatewayPaths(options.dataDir); const endpoint = gatewayEndpoint(state.config); - const model = inferClaudeAppGatewayModel(state.config); - const models = buildClaudeAppGatewayModels(state.config, model); + const model = inferClaudeAppGatewayModel(); + const models = buildClaudeAppGatewayModels(model); const gatewayConfig: ClaudeAppGatewayConfig = { inferenceCredentialKind: "static", inferenceGatewayApiKey: state.apiKey, @@ -290,47 +289,15 @@ function gatewayEndpoint(config: AppConfig): string { return `http://${formattedHost}:${port}`; } -function inferClaudeAppGatewayModel(config: AppConfig): string { - const routerModel = normalizeGatewayModelSelector(config.Router.default); - if (routerModel) { - return routerModel; - } - - for (const provider of Array.isArray(config.Providers) ? config.Providers : []) { - for (const model of Array.isArray(provider.models) ? provider.models : []) { - const modelName = stringValue(model); - if (modelName) { - return provider.name ? `${provider.name}/${modelName}` : modelName; - } - } - } - +function inferClaudeAppGatewayModel(): string { + // Claude App validates gateway model routes as Anthropic-looking model IDs. + // Keep the App picker schema-valid; CCR routes this placeholder to the + // profile's real provider/model through Router.default at request time. return CLAUDE_APP_FALLBACK_MODEL; } -function buildClaudeAppGatewayModels(config: AppConfig, selectedModel: string): string[] { - const models = buildCodexModelCatalog(config, selectedModel); - if (models.length > 0) { - return models; - } - return [CLAUDE_APP_FALLBACK_MODEL]; -} - -function normalizeGatewayModelSelector(value: unknown): string { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - const commaIndex = trimmed.indexOf(","); - if (commaIndex > 0 && commaIndex < trimmed.length - 1) { - const provider = trimmed.slice(0, commaIndex).trim(); - const model = trimmed.slice(commaIndex + 1).trim(); - return provider && model ? `${provider}/${model}` : ""; - } - return trimmed; +function buildClaudeAppGatewayModels(selectedModel: string): string[] { + return [selectedModel || CLAUDE_APP_FALLBACK_MODEL]; } function applyClaudeAppConfigMeta(metaFile: string): void { diff --git a/src/main/claude-app-launch.ts b/src/main/claude-app-launch.ts index c1b5f7c7..625e9763 100644 --- a/src/main/claude-app-launch.ts +++ b/src/main/claude-app-launch.ts @@ -2,7 +2,8 @@ import { spawn, spawnSync } from "node:child_process"; import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { ProfileConfig } from "../shared/app"; +import type { AppConfig, ProfileConfig } from "../shared/app"; +import { botGatewayProfileEnv } from "./bot-gateway-env"; import { resolveClaudeCodeSettingsFile } from "./profile-launch-core"; type ClaudeAppLookupResult = { @@ -20,7 +21,7 @@ const macClaudeAppNames = ["Claude.app", "Claude Desktop.app"]; const windowsClaudeAppDirs = ["Claude", "Claude Desktop", "ClaudeDesktop", "AnthropicClaude"]; const windowsClaudeExeNames = ["Claude.exe", "claude.exe", "Claude Desktop.exe"]; -export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig): ClaudeAppLaunchResult { +export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): ClaudeAppLaunchResult { const lookup = findInstalledClaudeAppExecutable(); if (!lookup.executable) { throw new Error([ @@ -37,6 +38,7 @@ export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig const env: NodeJS.ProcessEnv = { ...process.env, ...profileEnv(profile), + ...(config ? botGatewayProfileEnv(config, profile, "app") : {}), CLAUDE_CONFIG_DIR: settingsDir, CLAUDE_USER_DATA_DIR: userDataDir, CCR_CLAUDE_APP_USER_DATA_PATH: userDataDir, diff --git a/src/main/cli.ts b/src/main/cli.ts index 21eae72f..f9d28e82 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -43,7 +43,7 @@ async function main(): Promise { const childEnv = { ...process.env, ...plan.env, - ...botGatewayProfileEnv(config, profile) + ...botGatewayProfileEnv(config, profile, resolvedSurface) }; delete childEnv.ELECTRON_RUN_AS_NODE; diff --git a/src/main/codex-app-launch.ts b/src/main/codex-app-launch.ts index bd50b31a..66a36660 100644 --- a/src/main/codex-app-launch.ts +++ b/src/main/codex-app-launch.ts @@ -44,7 +44,7 @@ export function launchCodexAppProfile(configDir: string, profile: ProfileConfig, const env: NodeJS.ProcessEnv = { ...process.env, ...plan.env, - ...(config ? botGatewayProfileEnv(config, profile) : {}), + ...(config ? botGatewayProfileEnv(config, profile, "app") : {}), ...codexProfileEnv(profile), CODEX_CLI_PATH: plan.command, CODEX_ELECTRON_USER_DATA_PATH: userDataDir, diff --git a/src/main/codex-cli-middleware-runtime.ts b/src/main/codex-cli-middleware-runtime.ts index 261cc5bf..fad0dbec 100644 --- a/src/main/codex-cli-middleware-runtime.ts +++ b/src/main/codex-cli-middleware-runtime.ts @@ -17,10 +17,21 @@ const REQUEST_TIMEOUT_MS = numberEnv("CCR_CODEX_APP_REQUEST_TIMEOUT_MS", 10 * 60 const TURN_IDLE_TIMEOUT_MS = numberEnv("CCR_CODEX_CLAUDE_TURN_IDLE_TIMEOUT_MS", 10 * 60 * 1000); const CONFIG_DIR = path.join(os.homedir(), ".claude-code-router"); const LOG_PATH = process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG || path.join(CONFIG_DIR, "codex-cli-middleware.log"); -const BOT_BRIDGE = createBotGatewayBridge(); +let BOT_BRIDGE_INSTANCE = null; + +function botBridge() { + if (!BOT_BRIDGE_INSTANCE) { + BOT_BRIDGE_INSTANCE = createBotGatewayBridge(); + } + return BOT_BRIDGE_INSTANCE; +} async function main() { const args = process.argv.slice(2); + if (process.env.CCR_CLAUDE_CODE_BOT_WORKER === "1" || args[0] === "claude-bot-worker") { + await runClaudeCodeBotWorker(args); + return; + } if (process.env.CCR_CLAUDE_CODE_WRAPPER === "1") { await runClaudeCodeCliWrapper(args); return; @@ -49,12 +60,12 @@ async function runClaudeCodeCliWrapper(args) { const lines = pending.split(/\r?\n/g); pending = lines.pop() || ""; for (const line of lines) { - BOT_BRIDGE.handleClaudeCliLine(line); + botBridge().handleClaudeCliLine(line); } }); const code = await waitForChild(child); if (pending.trim()) { - BOT_BRIDGE.handleClaudeCliLine(pending); + botBridge().handleClaudeCliLine(pending); } log("claude_code_wrapper_exit", { code }); process.exitCode = code; @@ -104,7 +115,7 @@ async function runCodexCliMiddleware(args) { const stdoutRl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity, terminal: false }); stdoutRl.on("line", (line) => { const rewritten = rewriteCodexStdoutLine(line, requestMap); - BOT_BRIDGE.handleJsonRpcLine(rewritten); + botBridge().handleJsonRpcLine(rewritten); if (!shouldSuppressBotBridgeLine(rewritten)) { process.stdout.write(rewritten + "\n"); } @@ -276,6 +287,32 @@ async function runClaudeCodeAppServer(args) { await server.run(); } +async function runClaudeCodeBotWorker(args) { + const options = parseAppServerOptions(args); + const server = new ClaudeCodeAppServer(options); + server.ensureBotBridgeRegistered(); + log("claude_bot_worker_start", { workspaceName: options.workspaceName, pid: process.pid }); + await waitForTerminationSignal(); + await botBridge().stop(); + log("claude_bot_worker_stop", { pid: process.pid }); +} + +function waitForTerminationSignal() { + return new Promise((resolve) => { + const timer = setInterval(() => {}, 2147483647); + const done = () => { + clearInterval(timer); + process.off("SIGINT", done); + process.off("SIGTERM", done); + process.off("SIGHUP", done); + resolve(); + }; + process.once("SIGINT", done); + process.once("SIGTERM", done); + process.once("SIGHUP", done); + }); +} + function parseAppServerOptions(args) { let workspaceName = nonEmptyEnv("CCR_CODEX_WORKSPACE_NAME") || nonEmptyEnv("CODEXL_CODEX_WORKSPACE_NAME") || nonEmptyEnv("CODEXL_CODEX_INSTANCE_NAME") || "Claude Code"; for (let i = 0; i < args.length; i += 1) { @@ -299,7 +336,13 @@ class ClaudeCodeAppServer { this.threads = new Map(); this.active = new Map(); this.appResponses = new Map(); + this.botBridgeRegistered = false; + this.botSessionStore = { version: 1, conversations: {} }; + this.botSessionStoreLoaded = false; + this.botThreadKeys = new Map(); + this.botThreads = new Map(); this.configValues = {}; + this.pollingEvents = false; this.stdin = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false }); } @@ -364,6 +407,7 @@ class ClaudeCodeAppServer { platformFamily: process.platform === "win32" ? "windows" : "unix", platformOs: process.platform }); + this.ensureBotBridgeRegistered(); return undefined; case "thread/start": { const thread = this.createThread(params); @@ -551,6 +595,173 @@ class ClaudeCodeAppServer { } } + async handleBotInbound(event, _queued, eventId, bridge) { + const text = botEventText(event); + if (!text) { + log("bot_gateway_inbound_skip", { eventId, reason: "empty_text" }); + return; + } + const thread = this.botThreadForEvent(event, text); + const prepared = this.startTurn({ + cwd: thread.cwd, + input: [{ type: "text", text }], + threadId: thread.id + }); + for (const notification of prepared.notifications) writeRaw(notification); + bridge.suppressTurn(prepared.turn.id); + try { + await this.runTurn(prepared.work); + } finally { + bridge.unsuppressTurn(prepared.turn.id); + } + + const completed = thread.turns.find((turn) => turn.id === prepared.turn.id) || prepared.turn; + const responseText = completed.error + ? "Agent turn failed: " + completed.error + : (completed.agentText || "").trim() || "Claude Code completed the turn without a text response."; + await bridge.sendReplyToEvent(event, responseText, "ccr:claude-code:" + eventId + ":" + prepared.turn.id); + log("bot_gateway_inbound_replied", { eventId, threadId: thread.id, turnId: prepared.turn.id, textLen: responseText.length }); + } + + ensureBotBridgeRegistered() { + if (this.botBridgeRegistered) return; + this.botBridgeRegistered = true; + botBridge().setInboundHandler((event, queued, eventId, bridge) => this.handleBotInbound(event, queued, eventId, bridge)); + } + + botThreadForEvent(event, text) { + const key = botConversationKey(event); + const mappedThreadId = this.botThreads.get(key); + if (mappedThreadId && this.threads.has(mappedThreadId)) { + return this.threads.get(mappedThreadId); + } + const restoredThread = this.restoreBotThreadForConversation(key); + if (restoredThread) { + if (!restoredThread.preview) restoredThread.preview = text.slice(0, 160); + return restoredThread; + } + const appThread = this.inferBotThreadFromClaudeAppSession(key, text); + if (appThread) return appThread; + const thread = this.createThread({ cwd: process.cwd(), workspaceKind: "local" }); + if (!thread.preview) thread.preview = text.slice(0, 160); + this.botThreads.set(key, thread.id); + this.botThreadKeys.set(thread.id, key); + this.persistBotThread(thread.id); + return thread; + } + + restoreBotThreadForConversation(key) { + const entry = this.loadBotSessionStore().conversations[key]; + if (!entry || typeof entry !== "object") return null; + if (!entry.claudeSessionId && !entry.claudeAppSessionId) return null; + const thread = this.createThread({ + cwd: entry.cwd || process.cwd(), + model: entry.model || undefined, + workspaceKind: "local", + claudeConfigDir: entry.claudeConfigDir || null + }); + this.replaceThreadId(thread, entry.threadId || thread.id); + thread.sessionId = entry.sessionId || thread.id; + thread.claudeSessionId = entry.claudeSessionId || null; + thread.claudeConfigDir = entry.claudeConfigDir || null; + thread.claudeAppSessionId = entry.claudeAppSessionId || null; + thread.preview = entry.preview || ""; + thread.updatedAt = entry.updatedAtSeconds || nowSeconds(); + this.botThreads.set(key, thread.id); + this.botThreadKeys.set(thread.id, key); + log("bot_gateway_session_restored", { + conversationKeyPrefix: key.slice(0, 80), + threadId: thread.id, + claudeSessionIdPrefix: thread.claudeSessionId ? thread.claudeSessionId.slice(0, 8) : "" + }); + return thread; + } + + inferBotThreadFromClaudeAppSession(key, text) { + const session = latestClaudeAppLocalAgentSession(); + if (!session) return null; + const thread = this.createThread({ + cwd: session.cwd || process.cwd(), + model: session.model || undefined, + workspaceKind: "local", + claudeConfigDir: session.claudeConfigDir || null + }); + thread.sessionId = session.sessionId || thread.id; + thread.claudeSessionId = session.cliSessionId || null; + thread.claudeConfigDir = session.claudeConfigDir || null; + thread.claudeAppSessionId = session.sessionId || null; + thread.preview = session.title || session.initialMessage || text.slice(0, 160); + thread.name = session.title || this.workspaceName; + thread.updatedAt = Math.floor((session.lastActivityAt || Date.now()) / 1000); + this.botThreads.set(key, thread.id); + this.botThreadKeys.set(thread.id, key); + this.persistBotThread(thread.id); + log("bot_gateway_session_inferred", { + conversationKeyPrefix: key.slice(0, 80), + threadId: thread.id, + appSessionId: thread.claudeAppSessionId, + claudeSessionIdPrefix: thread.claudeSessionId ? thread.claudeSessionId.slice(0, 8) : "", + cwd: thread.cwd + }); + return thread; + } + + replaceThreadId(thread, id) { + const nextId = String(id || "").trim(); + if (!nextId || thread.id === nextId) return; + this.threads.delete(thread.id); + thread.id = nextId; + this.threads.set(thread.id, thread); + } + + loadBotSessionStore() { + if (this.botSessionStoreLoaded) return this.botSessionStore; + this.botSessionStoreLoaded = true; + try { + this.botSessionStore = normalizeBotSessionStore(JSON.parse(fs.readFileSync(botSessionStorePath(), "utf8"))); + } catch { + this.botSessionStore = { version: 1, conversations: {} }; + } + return this.botSessionStore; + } + + saveBotSessionStore() { + const file = botSessionStorePath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(this.botSessionStore, null, 2)); + } + + persistBotThread(threadId) { + const key = this.botThreadKeys.get(threadId); + if (!key) return; + const thread = this.threads.get(threadId); + if (!thread) return; + const store = this.loadBotSessionStore(); + store.conversations[key] = { + threadId: thread.id, + sessionId: thread.sessionId || thread.id, + claudeSessionId: thread.claudeSessionId || null, + claudeAppSessionId: thread.claudeAppSessionId || null, + claudeConfigDir: thread.claudeConfigDir || null, + cwd: thread.cwd || process.cwd(), + model: thread.model || "", + preview: thread.preview || "", + updatedAt: Date.now(), + updatedAtSeconds: thread.updatedAt || nowSeconds() + }; + this.saveBotSessionStore(); + } + + rememberClaudeSession(message, work) { + const sessionId = claudeSessionIdFromMessage(message); + if (!sessionId) return; + const thread = this.threads.get(work.threadId); + if (!thread || thread.claudeSessionId === sessionId) return; + thread.claudeSessionId = sessionId; + log("claude_session_remembered", { threadId: work.threadId, turnId: work.turnId, sessionIdPrefix: sessionId.slice(0, 8) }); + this.persistBotThread(work.threadId); + } + createThread(params) { const id = uuid(); const cwd = normalizeCwd(params.cwd); @@ -558,7 +769,9 @@ class ClaudeCodeAppServer { const thread = { id, sessionId: id, - claudeSessionId: id, + claudeSessionId: null, + claudeConfigDir: params.claudeConfigDir || null, + claudeAppSessionId: params.claudeAppSessionId || null, path: null, preview: "", cwd, @@ -656,7 +869,9 @@ class ClaudeCodeAppServer { cwd: thread.cwd, prompt, input, - resumeExisting: thread.turns.length > 1, + resumeExisting: Boolean(thread.claudeSessionId), + claudeSessionId: thread.claudeSessionId, + claudeConfigDir: thread.claudeConfigDir, model: thread.model }; const userItem = userItemJson(turn); @@ -691,7 +906,7 @@ class ClaudeCodeAppServer { this.active.set(key, { key, threadId: work.threadId, turnId: work.turnId, child }); try { child.stdin.write(JSON.stringify({ type: "control_request", request_id: uuid(), request: { subtype: "initialize" } }) + "\n"); - child.stdin.write(JSON.stringify(claudeInputMessage(work.input.length ? work.input : [{ type: "text", text: work.prompt }])) + "\n"); + child.stdin.write(JSON.stringify(claudeInputMessage(work.input.length ? work.input : [{ type: "text", text: work.prompt }], work.claudeSessionId || "")) + "\n"); } catch (error) { childSpawnError = error; log("claude_stdin_error", { threadId: work.threadId, turnId: work.turnId, error: formatError(error) }); @@ -742,6 +957,7 @@ class ClaudeCodeAppServer { turn.toolItems = Array.from(stream.tools.values()).map((tool) => toolItemJson(work.threadId, work.cwd, tool)); thread.updatedAt = turn.completedAt; thread.latestTokenUsageInfo = stream.latestUsage; + this.persistBotThread(work.threadId); if (!stream.agentStarted && text) { writeNotification("item/completed", { threadId: thread.id, @@ -762,6 +978,7 @@ class ClaudeCodeAppServer { } catch { return; } + this.rememberClaudeSession(message, work); rememberUsage(message, work, stream); if (message.type === "control_request") { this.handleControlRequest(message, work, child); @@ -858,6 +1075,22 @@ function handleClaudeToolResults(content, work, stream) { } } +function claudeSessionIdFromMessage(message) { + return ( + objectSessionId(message) || + objectSessionId(message && message.message) || + objectSessionId(message && message.event) || + objectSessionId(message && message.result) || + objectSessionId(message && message.response) || + "" + ); +} + +function objectSessionId(value) { + if (!value || typeof value !== "object") return ""; + return stringValue(value.session_id) || stringValue(value.sessionId); +} + function handleClaudeContentBlock(block, work, stream) { const type = block && block.type; if (type === "text" && typeof block.text === "string") { @@ -923,25 +1156,29 @@ function claudeCommand(work) { ]; const model = nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || work.model; if (model) args.push("--model", model); - if (work.resumeExisting) args.push("--resume", work.threadId); + if (work.resumeExisting && work.claudeSessionId) args.push("--resume", work.claudeSessionId); const extra = splitShellLike(nonEmptyEnv("CCR_CLAUDE_CODE_EXTRA_ARGS") || nonEmptyEnv("CODEXL_CLAUDE_CODE_EXTRA_ARGS") || ""); args.push(...extra); + const env = withoutKeys({ + ...process.env, + CODEX_SESSION_ID: work.threadId, + CODEX_THREAD_ID: work.threadId, + CODEX_TURN_ID: work.turnId + }, ["CCR_CLAUDE_CODE_BOT_WORKER", "ELECTRON_RUN_AS_NODE"]); + if (work.claudeConfigDir) { + env.CLAUDE_CONFIG_DIR = work.claudeConfigDir; + } return { command, args, - env: { - ...process.env, - CODEX_SESSION_ID: work.threadId, - CODEX_THREAD_ID: work.threadId, - CODEX_TURN_ID: work.turnId - } + env }; } -function claudeInputMessage(input) { +function claudeInputMessage(input, sessionId = "") { return { type: "user", - session_id: "", + session_id: sessionId || "", message: { role: "user", content: claudeContentFromInput(input) }, parent_tool_use_id: null }; @@ -1013,6 +1250,7 @@ function threadJson(thread, includeTurns) { threadId: thread.id, conversationId: thread.id, sessionId: thread.sessionId, + claudeSessionId: thread.claudeSessionId, path: thread.path, preview: thread.preview, cwd: thread.cwd, @@ -1415,7 +1653,7 @@ function writeNotification(method, params) { } function writeRaw(value) { - BOT_BRIDGE.handleJsonRpcValue(value); + botBridge().handleJsonRpcValue(value); writeLine(process.stdout, value); } @@ -1429,7 +1667,12 @@ function createBotGatewayBridge() { return { handleClaudeCliLine() {}, handleJsonRpcLine() {}, - handleJsonRpcValue() {} + handleJsonRpcValue() {}, + sendReplyToEvent: async () => {}, + setInboundHandler() {}, + stop: async () => {}, + suppressTurn() {}, + unsuppressTurn() {} }; } const bridge = new BotGatewayBridge(config); @@ -1539,14 +1782,43 @@ class BotGatewayBridge { this.child = null; this.client = null; this.forwarded = new Set(); + this.inboundHandler = null; + this.inboundEvents = new Set(); this.latestEvent = null; this.messageCounter = 0; this.pollTimer = null; this.startPromise = null; + this.suppressedTurnIds = new Set(); this.claudeCliCapture = { finalText: "", resultCount: 0, text: "" }; this.turnCaptures = new Map(); } + setInboundHandler(handler) { + this.inboundHandler = typeof handler === "function" ? handler : null; + if (this.inboundHandler) { + this.ensureStarted().catch((error) => this.logError("start_failed", error)); + } + } + + suppressTurn(turnId) { + if (turnId) this.suppressedTurnIds.add(String(turnId)); + } + + unsuppressTurn(turnId) { + if (turnId) this.suppressedTurnIds.delete(String(turnId)); + } + + async stop() { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + const client = this.client; + this.client = null; + this.startPromise = null; + await closeBotGatewayClient(client); + } + handleClaudeCliLine(line) { if (!line || !this.config.enabled) return; let message; @@ -1619,7 +1891,6 @@ class BotGatewayBridge { handleJsonRpcValue(value) { if (!this.config.enabled || !value || typeof value !== "object") return; - this.ensureStarted().catch((error) => this.logError("start_failed", error)); const method = typeof value.method === "string" ? value.method : ""; const params = value.params && typeof value.params === "object" ? value.params : {}; if (method === "item/completed") { @@ -1681,6 +1952,11 @@ class BotGatewayBridge { forwardAgentText(key, text, params) { if (this.forwarded.has(key)) return; + const turnId = params && (params.turnId || params.turn_id || (params.turn && params.turn.id)); + if (turnId && this.suppressedTurnIds.has(String(turnId))) { + log("bot_gateway_forward_skip", { key, reason: "bot_inbound_turn" }); + return; + } const decision = this.forwardDecision(); if (!decision.shouldForward) { log("bot_gateway_forward_skip", { key, reason: decision.reason }); @@ -1735,6 +2011,27 @@ class BotGatewayBridge { }); } + async sendReplyToEvent(event, text, key) { + if (!text || !String(text).trim()) return; + await this.ensureStarted(); + const conversationRef = conversationRefFromEvent(event) || this.config.conversationRef; + if (!conversationRef) { + throw new Error("No Bot Gateway conversationRef is available for inbound bot response."); + } + this.messageCounter += 1; + const outbound = { + tenantId: eventString(event, "tenantId") || this.config.tenantId || "ccr", + integrationId: eventString(event, "integrationId") || this.config.integrationId, + conversationRef, + intent: { + type: "text", + text + }, + idempotencyKey: key + ":" + this.messageCounter + }; + await withTimeout(this.client.send(outbound), this.config.requestTimeoutMs, "Bot Gateway request timed out: inbound outbound.send"); + } + resolveTenantId() { return eventString(this.latestEvent, "tenantId") || this.config.tenantId || "ccr"; } @@ -1746,14 +2043,7 @@ class BotGatewayBridge { resolveConversationRef() { if (this.config.conversationRef) return this.config.conversationRef; const event = this.latestEvent; - if (!event || !event.conversation || typeof event.conversation !== "object") return null; - const id = eventString(event.conversation, "id"); - if (!id) return null; - const type = ["dm", "group", "channel", "thread"].includes(event.conversation.type) ? event.conversation.type : "dm"; - const ref = { platformConversationId: id, type }; - const threadId = event.message && typeof event.message === "object" ? eventString(event.message, "threadId") : ""; - if (threadId) ref.threadId = threadId; - return ref; + return conversationRefFromEvent(event); } async ensureStarted() { @@ -1771,7 +2061,7 @@ class BotGatewayBridge { BOT_GATEWAY_STATE_DIR: this.config.stateDir || path.join(CONFIG_DIR, "bot-gateway", safePathSegment(this.config.profileId)), CODEXL_HOME: CONFIG_DIR }); - const clientOptions = botGatewaySdkClientOptions(this.config, env); + const clientOptions = botGatewaySdkClientOptions(this.config, env, sdk); this.client = sdk.createBotGatewayClient(clientOptions); await withTimeout(this.client.health(), this.config.startupTimeoutMs, "Bot Gateway health check timed out."); await this.ensureIntegration(); @@ -1784,7 +2074,7 @@ class BotGatewayBridge { async ensureIntegration() { if (!this.config.integrationId) return; - if (this.config.createIntegration) { + if (this.config.createIntegration && this.config.authType !== "qr_login") { await botGatewayClientRequest(this.client, "integrations.create", { id: this.config.integrationId, tenantId: this.config.tenantId, @@ -1805,24 +2095,48 @@ class BotGatewayBridge { async pollEvents() { if (!this.client) return; - const result = await withTimeout(this.client.events(20), this.config.requestTimeoutMs, "Bot Gateway request timed out: events.list"); - const events = Array.isArray(result && result.events) ? result.events : []; - for (const queued of events) { - const event = queued && queued.event && typeof queued.event === "object" ? queued.event : null; - if (!event || !this.matchesEvent(event)) continue; - if (event.actor && event.actor.isBot === true) continue; - this.latestEvent = event; - if (this.config.acknowledgeEvents) { - const eventId = eventString(queued, "id") || eventString(event, "id"); - if (eventId) { - await withTimeout(this.client.ackEvent(eventId), this.config.requestTimeoutMs, "Bot Gateway request timed out: events.ack").catch((error) => { - log("bot_gateway_ack_failed", { eventId, error: formatError(error) }); - }); + if (this.pollingEvents) return; + this.pollingEvents = true; + try { + const result = await withTimeout(this.client.events(20), this.config.requestTimeoutMs, "Bot Gateway request timed out: events.list"); + const events = Array.isArray(result && result.events) ? result.events : []; + for (const queued of events) { + const event = queued && queued.event && typeof queued.event === "object" ? queued.event : null; + if (!event || !this.matchesEvent(event)) continue; + if (event.actor && event.actor.isBot === true) continue; + this.latestEvent = event; + const eventId = eventIdFromQueued(queued, event); + if (this.inboundHandler) { + await this.dispatchInboundEvent(queued, event, eventId); + } else { + await this.ackEvent(eventId); } } + } finally { + this.pollingEvents = false; } } + async dispatchInboundEvent(queued, event, eventId) { + const key = eventId || botEventDedupeKey(event); + if (this.inboundEvents.has(key)) return; + this.inboundEvents.add(key); + try { + await this.inboundHandler(event, queued, eventId || key, this); + await this.ackEvent(eventId); + } catch (error) { + this.inboundEvents.delete(key); + throw error; + } + } + + async ackEvent(eventId) { + if (!this.config.acknowledgeEvents || !eventId) return; + await withTimeout(this.client.ackEvent(eventId), this.config.requestTimeoutMs, "Bot Gateway request timed out: events.ack").catch((error) => { + log("bot_gateway_ack_failed", { eventId, error: formatError(error) }); + }); + } + matchesEvent(event) { if (this.config.integrationId && event.integrationId !== this.config.integrationId) return false; if (this.config.platform && this.config.platform !== "none" && event.platform !== this.config.platform) return false; @@ -1885,8 +2199,8 @@ function botGatewaySdkImportSpecifier(value) { return trimmed; } -function botGatewaySdkClientOptions(config, env) { - const command = resolveBotGatewayCommand(config); +function botGatewaySdkClientOptions(config, env, sdk) { + const command = resolveBotGatewayCommand(config) || resolveBundledBotGatewayCommand(sdk); return { transport: "stdio", ...(command || {}), @@ -1902,23 +2216,49 @@ function resolveBotGatewayCommand(config) { cwd: config.cwd || process.cwd() }; } - const sourceDir = expandHome(config.sourceDir || ""); - const candidates = sourceDir ? [ - path.join(sourceDir, "dist-bundle", "stdio", "stdio.js"), - path.join(sourceDir, "dist", "src", "stdio.js") - ] : []; - for (const entry of candidates) { - if (fs.existsSync(entry)) { - return { - command: process.execPath, - args: [entry], - cwd: sourceDir - }; - } - } return undefined; } +function resolveBundledBotGatewayCommand(sdk) { + if (!sdk || typeof sdk.bundledStdioPath !== "function") { + return undefined; + } + const bundledPath = sdk.bundledStdioPath(); + return { + command: process.execPath, + args: [sanitizedBotGatewayStdioRunnerPath(bundledPath)], + cwd: path.dirname(bundledPath) + }; +} + +function sanitizedBotGatewayStdioRunnerPath(sourcePath) { + const source = fs.readFileSync(sourcePath, "utf8"); + const normalized = normalizeDuplicateShebangs(source); + if (normalized === source) { + return sourcePath; + } + + const targetDir = path.join(CONFIG_DIR, "bot-gateway", "runners"); + const targetPath = path.join(targetDir, "bot-gateway-stdio.mjs"); + fs.mkdirSync(targetDir, { recursive: true }); + if (!fs.existsSync(targetPath) || fs.readFileSync(targetPath, "utf8") !== normalized) { + fs.writeFileSync(targetPath, normalized); + } + return targetPath; +} + +function normalizeDuplicateShebangs(source) { + const lines = source.split("\n"); + if (!lines[0] || !lines[0].startsWith("#!")) { + return source; + } + let index = 1; + while (lines[index] && lines[index].startsWith("#!")) { + index += 1; + } + return [lines[0], ...lines.slice(index)].join("\n"); +} + function botGatewayClientRequest(client, method, params, timeoutMs) { if (!client || typeof client.request !== "function") { return Promise.reject(new Error("Bot Gateway SDK client does not expose request().")); @@ -1926,6 +2266,19 @@ function botGatewayClientRequest(client, method, params, timeoutMs) { return withTimeout(client.request(method, params), timeoutMs, "Bot Gateway request timed out: " + method); } +async function closeBotGatewayClient(client) { + if (!client || typeof client !== "object") return; + for (const method of ["close", "dispose", "stop"]) { + if (typeof client[method] !== "function") continue; + try { + await Promise.resolve(client[method]()); + } catch (error) { + log("bot_gateway_client_close_failed", { method, error: formatError(error) }); + } + return; + } +} + function withTimeout(promise, timeoutMs, message) { const timeout = Math.max(1000, timeoutMs || 30000); let timer = null; @@ -2039,6 +2392,201 @@ function turnErrorText(turn) { return ""; } +function botSessionStorePath() { + const stateDir = nonEmptyEnv("CCR_BOT_GATEWAY_STATE_DIR") || + nonEmptyEnv("CODEXL_BOT_GATEWAY_STATE_DIR") || + nonEmptyEnv("BOT_GATEWAY_STATE_DIR") || + path.join(CONFIG_DIR, "bot-gateway", safePathSegment(nonEmptyEnv("CCR_BOT_PROFILE_ID") || "default")); + return path.join(expandHome(stateDir), "claude-bot-sessions.json"); +} + +function normalizeBotSessionStore(value) { + const conversations = value && typeof value === "object" && value.conversations && typeof value.conversations === "object" + ? value.conversations + : {}; + return { version: 1, conversations }; +} + +function latestClaudeAppLocalAgentSession() { + const baseDir = nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR"); + if (!baseDir) return null; + const root = path.join(expandHome(baseDir), "local-agent-mode-sessions"); + const files = listClaudeAppSessionFiles(root, 6); + let latest = null; + for (const file of files) { + let value; + try { + value = JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + continue; + } + if (!value || typeof value !== "object" || value.isArchived === true || value.archived === true) continue; + const cliSessionId = stringValue(value.cliSessionId) || stringValue(value.cli_session_id); + if (!cliSessionId) continue; + const sessionId = stringValue(value.sessionId) || path.basename(file, ".json"); + const lastActivityAt = numberValue(value.lastActivityAt) || numberValue(value.updatedAt) || numberValue(value.createdAt) || fileMtimeMs(file); + const item = { + file, + sessionId, + cliSessionId, + cwd: stringValue(value.cwd) || process.cwd(), + model: stringValue(value.model) || "", + title: stringValue(value.title) || "", + initialMessage: stringValue(value.initialMessage) || "", + lastActivityAt, + claudeConfigDir: claudeAppSessionConfigDir(file, value) + }; + if (!latest || item.lastActivityAt > latest.lastActivityAt) latest = item; + } + return latest; +} + +function listClaudeAppSessionFiles(root, maxDepth) { + const files = []; + const visit = (dir, depth) => { + if (depth < 0) return; + let entries = []; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath, depth - 1); + } else if (entry.isFile() && entry.name.startsWith("local_") && entry.name.endsWith(".json")) { + files.push(fullPath); + } + } + }; + visit(root, maxDepth); + return files; +} + +function claudeAppSessionConfigDir(file, value) { + const candidates = []; + const cwd = stringValue(value && value.cwd); + if (cwd) candidates.push(path.join(path.dirname(expandHome(cwd)), ".claude")); + const sessionId = stringValue(value && value.sessionId) || path.basename(file, ".json"); + if (sessionId) candidates.push(path.join(path.dirname(file), sessionId, ".claude")); + candidates.push(path.join(path.dirname(file), ".claude")); + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) return candidate; + } + return ""; +} + +function fileMtimeMs(file) { + try { + return fs.statSync(file).mtimeMs; + } catch { + return 0; + } +} + +function numberValue(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function botEventText(event) { + const direct = valueStringAtPaths(event, [ + "/message/text", + "/message/content", + "/raw/message/text", + "/raw/message/content", + "/raw/text/content", + "/raw/content/text", + "/raw/content", + "/text", + "/content" + ]); + if (direct) return direct; + return valueStringAtPaths(event, [ + "/message/transcript", + "/message/transcription", + "/message/voiceText", + "/message/voice_text", + "/message/audioText", + "/message/audio_text", + "/raw/transcript", + "/raw/transcription", + "/raw/voiceText", + "/raw/voice_text", + "/raw/audioText", + "/raw/audio_text" + ]) || ""; +} + +function conversationRefFromEvent(event) { + if (!event || !event.conversation || typeof event.conversation !== "object") return null; + const conversation = event.conversation; + const platformConversationId = eventString(conversation, "id") || eventString(conversation, "platformConversationId"); + const gatewayConversationId = eventString(conversation, "gatewayConversationId"); + if (!platformConversationId && !gatewayConversationId) return null; + const rawType = eventString(conversation, "type"); + const type = ["dm", "group", "channel", "thread"].includes(rawType) ? rawType : "dm"; + const ref = { + ...(gatewayConversationId ? { gatewayConversationId } : {}), + ...(platformConversationId ? { platformConversationId } : {}), + type + }; + const threadId = event.message && typeof event.message === "object" ? eventString(event.message, "threadId") : ""; + if (threadId) ref.threadId = threadId; + const contextToken = valueStringAtPaths(event, ["/raw/context_token", "/raw/sessionWebhook", "/raw/contextToken"]); + if (contextToken) ref.contextToken = contextToken; + return ref; +} + +function eventIdFromQueued(queued, event) { + return eventString(queued, "id") || + eventString(event, "id") || + valueStringAtPaths(event, ["/message/id", "/message/messageId", "/raw/message/id", "/raw/messageId", "/raw/msgId"]); +} + +function botEventDedupeKey(event) { + const conversation = event && event.conversation && typeof event.conversation === "object" ? event.conversation : {}; + return [ + eventString(event, "tenantId"), + eventString(event, "integrationId"), + eventString(conversation, "id") || eventString(conversation, "gatewayConversationId"), + valueStringAtPaths(event, ["/message/id", "/message/messageId", "/raw/message/id", "/raw/messageId", "/raw/msgId"]), + botEventText(event), + valueStringAtPaths(event, ["/message/createdAt", "/message/timestamp", "/raw/createAt", "/raw/timestamp"]) + ].join(":"); +} + +function botConversationKey(event) { + const conversation = event && event.conversation && typeof event.conversation === "object" ? event.conversation : {}; + return [ + eventString(event, "tenantId"), + eventString(event, "integrationId"), + eventString(conversation, "id") || eventString(conversation, "gatewayConversationId") || "default", + event.message && typeof event.message === "object" ? eventString(event.message, "threadId") : "" + ].join(":"); +} + +function valueStringAtPaths(value, paths) { + for (const path of paths) { + const candidate = valueAtPointer(value, path); + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + if (Number.isFinite(candidate) || typeof candidate === "boolean") return String(candidate); + } + return ""; +} + +function valueAtPointer(value, pointer) { + if (!value || typeof pointer !== "string" || !pointer.startsWith("/")) return undefined; + let current = value; + for (const rawPart of pointer.slice(1).split("/")) { + if (current === null || current === undefined) return undefined; + const part = rawPart.replace(/~1/g, "/").replace(/~0/g, "~"); + current = current[part]; + } + return current; +} + function eventString(value, key) { return value && typeof value[key] === "string" ? value[key].trim() : ""; } @@ -2093,6 +2641,16 @@ function activeKey(threadId, turnId) { return String(threadId || "") + "\0" + String(turnId || ""); } +function latestThread(threads) { + let latest = null; + for (const thread of threads.values()) { + if (!latest || (thread.updatedAt || 0) > (latest.updatedAt || 0)) { + latest = thread; + } + } + return latest; +} + function findActiveForThread(active, threadId) { for (const [key, value] of active) { if (value.threadId === threadId) return { ...value, key }; diff --git a/src/main/config.ts b/src/main/config.ts index 863aaad8..d2669df1 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -27,6 +27,9 @@ import type { OverviewWidgetVariant, ProviderAccountConfig, ProviderAccountConnectorConfig, + ProviderCredentialConfig, + ProviderFailoverConfig, + ProviderFailoverStrategy, ProfileConfig, ProfileRuntimeConfig, ProxyRouteTarget, @@ -36,6 +39,7 @@ import type { RouterFallbackMode, RouterRule, RouterRuleType, + TrayBalanceProgressConfig, TrayComponentVariants, TrayIconPreference, TrayWidgetConfig, @@ -127,7 +131,7 @@ const DEFAULT_CONFIG: AppConfig = { platform: "none", pollIntervalMs: 2000, requestTimeoutMs: 600000, - sourceDir: "/Users/jinhuilee/products/bot-gateway", + sourceDir: "", startupTimeoutMs: 10000, stateDir: "", tenantId: "ccr" @@ -443,6 +447,18 @@ function assertProviderApiKeysAreSafe(config: AppConfig): void { throw new Error(issue.message); } assertProviderAccountApiKeyTargetsAreSafe(provider, apiKey, baseUrl); + for (const credential of provider.credentials ?? []) { + const credentialApiKey = providerCredentialApiKey(credential); + const credentialIssue = providerApiKeySafetyIssue({ + apiKey: credentialApiKey, + baseUrl, + name: provider.name + }); + if (credentialIssue) { + throw new Error(credentialIssue.message); + } + assertProviderCredentialAccountApiKeyTargetsAreSafe(provider, credential, credentialApiKey, baseUrl); + } } } @@ -468,6 +484,33 @@ function assertProviderAccountApiKeyTargetsAreSafe(provider: GatewayProviderConf } } +function assertProviderCredentialAccountApiKeyTargetsAreSafe( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + apiKey: string, + baseUrl: string +): void { + if (!apiKey || credential.account?.enabled === false) { + return; + } + + const presetId = findProviderPresetByBaseUrl(baseUrl)?.id; + for (const connector of credential.account?.connectors ?? []) { + const endpoints = providerAccountConnectorApiKeyEndpoints(connector); + for (const endpoint of endpoints) { + const issue = providerEndpointCanReceiveProviderApiKey({ + apiKey, + endpoint, + providerName: provider.name, + providerPresetId: presetId + }); + if (issue) { + throw new Error(issue.message); + } + } + } +} + function providerAccountConnectorApiKeyEndpoints(connector: ProviderAccountConnectorConfig): string[] { if ("auth" in connector && connector.auth === "none") { return []; @@ -492,6 +535,10 @@ function providerApiKey(provider: GatewayProviderConfig): string { return provider.api_key || provider.apiKey || provider.apikey || ""; } +function providerCredentialApiKey(credential: ProviderCredentialConfig): string { + return credential.api_key || credential.apiKey || credential.apikey || ""; +} + export async function saveApiKeysConfig(apiKeys: ApiKeyConfig[]): Promise { const normalized = normalizeApiKeys(apiKeys, undefined).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); await replacePersistedApiKeys(normalized); @@ -643,6 +690,12 @@ function pickConfig(value: Partial): LoadedAppConfig { if (trayIcon) { config.trayIcon = trayIcon; } + const trayBalanceProgress = parseTrayBalanceProgress((value as Record).trayBalanceProgress); + if (trayBalanceProgress) { + config.trayBalanceProgress = trayBalanceProgress; + } else if (config.trayIcon === "progress") { + config.trayIcon = "random"; + } const trayProgressTargetTokens = readNumber((value as Record).trayProgressTargetTokens); if (trayProgressTargetTokens && trayProgressTargetTokens > 0) { config.trayProgressTargetTokens = clampNumber(trayProgressTargetTokens, 1000, 1_000_000_000); @@ -782,6 +835,15 @@ function parseTrayIconPreference(value: unknown): TrayIconPreference | undefined return undefined; } +function parseTrayBalanceProgress(value: unknown): TrayBalanceProgressConfig | undefined { + if (!isObject(value)) { + return undefined; + } + const provider = readString(value.provider); + const meterId = readString(value.meterId); + return provider && meterId ? { meterId, provider } : undefined; +} + function parseTrayWindowModules(value: unknown): TrayWindowModuleId[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -944,8 +1006,10 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined { baseurl: readString(item.baseurl), billing: item.billing, capabilities: parseProviderCapabilities(item.capabilities), + credentials: parseProviderCredentials(item.credentials ?? item.keys ?? item.apiKeys), extraBody: item.extraBody, extraHeaders: item.extraHeaders, + failover: parseProviderFailover(item.failover ?? item.credentialFailover), icon: readString(item.icon), models, name, @@ -960,6 +1024,73 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined { return providers; } +function parseProviderCredentials(value: unknown): ProviderCredentialConfig[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const credentials = value + .map((item, index): ProviderCredentialConfig | undefined => { + if (!isObject(item)) { + return undefined; + } + + const apiKey = readString(item.api_key) || readString(item.apiKey) || readString(item.apikey) || readString(item.key) || readString(item.token); + if (!apiKey) { + return undefined; + } + + const id = readString(item.id) || readString(item.name) || readString(item.label) || `key-${index + 1}`; + const priority = readNumber(item.priority); + const weight = readNumber(item.weight); + return { + account: parseProviderAccount(item.account), + api_key: apiKey, + enabled: typeof item.enabled === "boolean" ? item.enabled : undefined, + id, + label: readString(item.label) || readString(item.name), + limits: parseApiKeyLimits(item.limits), + priority: priority !== undefined ? priority : undefined, + weight: weight !== undefined && weight > 0 ? weight : undefined + }; + }) + .filter((item): item is ProviderCredentialConfig => Boolean(item)); + + return credentials.length > 0 ? credentials : undefined; +} + +function parseProviderFailover(value: unknown): ProviderFailoverConfig | undefined { + if (!isObject(value)) { + return undefined; + } + + const strategy = parseProviderFailoverStrategy(readString(value.strategy) || readString(value.mode)); + const cooldownMs = readNumber(value.cooldownMs ?? value.cooldown); + const spilloverThreshold = readNumber(value.spilloverThreshold ?? value.threshold); + const failover: ProviderFailoverConfig = { + ...(strategy ? { strategy } : {}), + ...(cooldownMs !== undefined && cooldownMs > 0 ? { cooldownMs } : {}), + ...(spilloverThreshold !== undefined && spilloverThreshold > 0 ? { spilloverThreshold } : {}) + }; + return Object.keys(failover).length > 0 ? failover : undefined; +} + +function parseProviderFailoverStrategy(value: string | undefined): ProviderFailoverStrategy | undefined { + if (!value) { + return undefined; + } + const normalized = value.trim().toLowerCase().replace(/_/g, "-"); + if ( + normalized === "failover-only" || + normalized === "least-utilized" || + normalized === "priority-spillover" || + normalized === "weighted-round-robin" + ) { + return normalized; + } + return undefined; +} + function parseProviderAccount(value: unknown): ProviderAccountConfig | undefined { if (!isObject(value)) { return undefined; @@ -1809,9 +1940,12 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { const name = readString(item.name) || (agent === "claude-code" ? "Claude Code" : "Codex"); const model = readString(item.model) ?? ""; const env = parseStringRecord(item.env) ?? {}; - const botConfigId = readString(item.botConfigId) || readString(item.bot_config_id) || readString(item.savedBotConfigId) || readString(item.saved_bot_config_id); + const surface = parseProfileSurface(readString(item.surface) || readString(item.entry) || readString(item.frontend)) || "auto"; + const botConfigId = surface !== "cli" + ? readString(item.botConfigId) || readString(item.bot_config_id) || readString(item.savedBotConfigId) || readString(item.saved_bot_config_id) + : ""; const parsedBotGateway = parseBotGateway(item.botGateway ?? item.bot_gateway ?? item.bot); - const botGateway = parsedBotGateway ? completeBotGatewayConfig(parsedBotGateway) : undefined; + const botGateway = surface !== "cli" && parsedBotGateway ? completeBotGatewayConfig(parsedBotGateway) : undefined; if (agent === "claude-code") { return { @@ -1826,7 +1960,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { scope: parseProfileScope(readString(item.scope) || readString(item.applyScope) || readString(item.effectScope)) || "global", settingsFile: readString(item.settingsFile) || readString(item.configFile) || "~/.claude/settings.json", smallFastModel: readString(item.smallFastModel) || readString(item.smallModel) || "", - surface: parseProfileSurface(readString(item.surface) || readString(item.entry) || readString(item.frontend)) || "auto" + surface }; } @@ -1853,7 +1987,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { : typeof item.show_all_sessions === "boolean" ? item.show_all_sessions : false, - surface: parseProfileSurface(readString(item.surface) || readString(item.entry) || readString(item.frontend)) || "auto" + surface }; }) .filter((item): item is ProfileConfig => Boolean(item)); diff --git a/src/main/gateway/service.ts b/src/main/gateway/service.ts index 4a821464..54c8eee3 100644 --- a/src/main/gateway/service.ts +++ b/src/main/gateway/service.ts @@ -7,11 +7,14 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { dirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "node:path"; import type { ApiKeyConfig, + ApiKeyLimitConfig, AppConfig, GatewayMcpServerConfig, GatewayProviderCapability, GatewayProviderConfig, GatewayProviderProtocol, + ProviderCredentialConfig, + ProviderFailoverStrategy, GatewayStatus, RouterFallbackConfig, RouterFallbackMode, @@ -110,11 +113,16 @@ type CursorOpenAICompatPreparation = { type UpstreamAttempt = { body?: Buffer; + credentialChain?: string[]; + credentialProtocol?: GatewayProviderProtocol; + headers?: Record; index: number; + logicalProvider?: string; model?: string; }; type UpstreamFailedAttempt = { + credentialChain?: string[]; error?: string; model?: string; statusCode?: number; @@ -146,7 +154,10 @@ const localObservabilityHeaderNames = new Set([ "x-claude-design-project-id", "x-ccr-claude-model-discovery", "x-ccr-codex-model-rewrite", - "x-ccr-cursor-openai-compat" + "x-ccr-cursor-openai-compat", + "x-ccr-logical-provider", + "x-ccr-provider-credential-chain", + "x-ccr-provider-credential-saturated" ]); const proxyHeaderDenyList = new Set(["connection", "host", "upgrade"]); const responseHeaderDenyList = new Set(["connection", "content-encoding", "transfer-encoding"]); @@ -159,6 +170,7 @@ let warnedMissingCursorOpenAICompatContext = false; const rawTraceSyncPath = "/__ccr/raw-trace-sync"; const gatewayPackageCandidates = ["@the-next-ai/ai-gateway", "gateway"]; const apiKeyLimitCounters = new Map(); +const providerCredentialCooldowns = new Map(); class GatewayService { private child?: ChildProcess; @@ -577,9 +589,11 @@ class GatewayService { try { upstreamResult = await fetchUpstreamWithFallback({ body: bodyToForward, + config: this.config, fallback: routeFallback, headers, method, + path, routedModel, upstreamUrl }); @@ -614,6 +628,7 @@ class GatewayService { this.config ); const upstreamResponse = upstreamResult.response; + recordProviderCredentialOutcome(this.config, method, upstreamResult.attempt, upstreamResponse.status, responseHeaders); response.writeHead(upstreamResponse.status, Object.fromEntries(filteredResponseHeaders(responseHeaders))); if (!upstreamResponse.body) { if (shouldCaptureUsage) { @@ -1459,6 +1474,12 @@ function findProviderByPublicOrInternalName(config: AppConfig, name: string): Ga if (!normalized) { return undefined; } + const credentialInternalName = parseProviderCredentialInternalName(name); + if (credentialInternalName) { + return config.Providers.find((provider) => + provider.name.trim().toLowerCase() === credentialInternalName.providerName.toLowerCase() + ); + } return config.Providers.find((provider) => provider.name.trim().toLowerCase() === normalized || provider.provider?.trim().toLowerCase() === normalized || @@ -1473,6 +1494,20 @@ function rewriteCapabilityResponseHeaders(headers: Headers, config: AppConfig): if (!providerName) { return headers; } + const credentialInternalName = parseProviderCredentialInternalName(providerName); + if (credentialInternalName) { + const provider = findProviderByPublicOrInternalName(config, credentialInternalName.providerName); + if (!provider) { + return headers; + } + const credential = findProviderCredentialBySlug(provider, credentialInternalName.credentialSlug); + const rewritten = new Headers(headers); + rewritten.set("x-gateway-target-provider-name", provider.name); + rewritten.set("x-ccr-provider-protocol", credentialInternalName.protocol); + rewritten.set("x-ccr-provider-credential-provider", provider.name); + rewritten.set("x-ccr-provider-credential-id", credential?.id ?? credentialInternalName.credentialSlug); + return rewritten; + } const provider = findProviderByPublicOrInternalName(config, providerName); if (!provider || provider.name === providerName) { return headers; @@ -1490,9 +1525,11 @@ function rewriteCapabilityResponseHeaders(headers: Headers, config: AppConfig): async function fetchUpstreamWithFallback(input: { body?: Buffer; + config: AppConfig; fallback: RouterFallbackConfig; headers: Record; method: string; + path: string; routedModel?: string; upstreamUrl: string; }): Promise { @@ -1501,18 +1538,25 @@ async function fetchUpstreamWithFallback(input: { const failedAttempts: UpstreamFailedAttempt[] = []; for (let index = 0; index < attempts.length; index += 1) { - const attempt = attempts[index]; + const attempt = prepareUpstreamCredentialAttempt({ + attempt: attempts[index], + config: input.config, + headers: input.headers, + method: input.method, + path: input.path + }); const hasNextAttempt = index < attempts.length - 1; try { const response = await fetch(input.upstreamUrl, { body: shouldSendBody(input.method) ? attempt.body?.toString("utf8") : undefined, - headers: omitLocalObservabilityHeaders(input.headers), + headers: omitLocalObservabilityHeaders(attempt.headers ?? input.headers), method: input.method }); if (hasNextAttempt && shouldFallbackAfterStatus(response.status, fallbackMode)) { failedAttempts.push({ + credentialChain: attempt.credentialChain, model: attempt.model, statusCode: response.status }); @@ -1528,6 +1572,7 @@ async function fetchUpstreamWithFallback(input: { } catch (error) { const message = formatError(error); failedAttempts.push({ + credentialChain: attempt.credentialChain, error: message, model: attempt.model }); @@ -1547,6 +1592,221 @@ async function fetchUpstreamWithFallback(input: { }); } +function prepareUpstreamCredentialAttempt(input: { + attempt: UpstreamAttempt; + config: AppConfig; + headers: Record; + method: string; + path: string; +}): UpstreamAttempt { + const target = resolveProviderCredentialRoutingTarget(input.config, input.headers, input.path, input.attempt.body); + if (!target) { + return { + ...input.attempt, + headers: input.headers + }; + } + + const credentials = activeProviderCredentials(target.provider); + if (credentials.length === 0) { + return { + ...input.attempt, + headers: input.headers + }; + } + + const usage = estimateLimitUsage(input.method, input.attempt.body ?? Buffer.alloc(0)); + const selection = selectProviderCredentials(target.provider, target.protocol, credentials, usage); + if (selection.credentials.length === 0) { + return { + ...input.attempt, + headers: input.headers + }; + } + + const headers: Record = { + ...input.headers, + "x-target-providers": selection.credentials.map((candidate) => candidate.internalName).join(","), + "x-ccr-logical-provider": target.provider.name, + "x-ccr-provider-credential-chain": selection.credentials.map((candidate) => candidate.credential.id).join(",") + }; + delete headers["x-target-provider"]; + if (selection.saturated) { + headers["x-ccr-provider-credential-saturated"] = "true"; + } + + return { + ...input.attempt, + body: target.body ?? input.attempt.body, + credentialChain: selection.credentials.map((candidate) => candidate.internalName), + credentialProtocol: target.protocol, + headers, + logicalProvider: target.provider.name + }; +} + +function resolveProviderCredentialRoutingTarget( + config: AppConfig, + headers: Record, + path: string, + body: Buffer | undefined +): { body?: Buffer; model?: string; provider: GatewayProviderConfig; protocol: GatewayProviderProtocol } | undefined { + const protocol = requestProtocolForPath(path); + if (!protocol) { + return undefined; + } + + const parsedBody = parseJsonObjectSafe(body); + const bodyModel = stringValue(parsedBody?.model); + const parsedModel = parseProviderModelSelector(bodyModel); + if (parsedModel) { + const provider = findProviderByPublicOrInternalName(config, parsedModel.provider); + if (provider && activeProviderCredentials(provider).length > 0) { + return { + body: parsedBody ? serializeJsonBodyWithModel(parsedBody, parsedModel.model) : body, + model: parsedModel.model, + provider, + protocol + }; + } + } + + const targetProviderName = firstTargetProviderHeader(headers); + if (!targetProviderName) { + return undefined; + } + + const provider = findProviderByPublicOrInternalName(config, targetProviderName); + if (!provider || activeProviderCredentials(provider).length === 0) { + return undefined; + } + + return { + body, + model: bodyModel, + provider, + protocol + }; +} + +function parseProviderModelSelector(value: string | undefined): { model: string; provider: string } | undefined { + const normalized = normalizeRouteSelector(value); + if (!normalized) { + return undefined; + } + const separator = normalized.indexOf("/"); + if (separator <= 0 || separator >= normalized.length - 1) { + return undefined; + } + const provider = normalized.slice(0, separator).trim(); + const model = normalized.slice(separator + 1).trim(); + return provider && model ? { model, provider } : undefined; +} + +function firstTargetProviderHeader(headers: Record): string | undefined { + const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"]; + if (provider?.trim()) { + return provider.trim(); + } + const providers = headers["x-target-providers"]; + return providers + ?.split(",") + .map((item) => item.trim()) + .find(Boolean); +} + +function activeProviderCredentials(provider: GatewayProviderConfig): ProviderCredentialConfig[] { + return (provider.credentials ?? []).filter((credential) => + credential.enabled !== false && + Boolean(providerCredentialApiKey(credential)) + ); +} + +function selectProviderCredentials( + provider: GatewayProviderConfig, + protocol: GatewayProviderProtocol, + credentials: ProviderCredentialConfig[], + usage: ApiKeyLimitUsage +): { credentials: Array<{ credential: ProviderCredentialConfig; internalName: string }>; saturated: boolean } { + const candidates = credentials.map((credential, index) => { + const limitState = providerCredentialLimitState(provider, credential, usage); + const cooldown = readProviderCredentialCooldown(provider, credential); + return { + cooldown, + credential, + index, + internalName: providerCredentialInternalName(provider.name, protocol, credential), + limitState, + priority: providerCredentialPriority(credential, index), + weight: Math.max(1, credential.weight ?? 1) + }; + }); + const available = candidates.filter((candidate) => !candidate.cooldown && !candidate.limitState.blocked); + const sorted = sortProviderCredentialCandidates( + available.length > 0 ? available : candidates, + provider.failover?.strategy, + provider.failover?.spilloverThreshold + ); + return { + credentials: sorted.map((candidate) => ({ + credential: candidate.credential, + internalName: candidate.internalName + })), + saturated: available.length === 0 && candidates.length > 0 + }; +} + +function sortProviderCredentialCandidates( + candidates: T[], + strategy: ProviderFailoverStrategy | undefined, + spilloverThreshold: number | undefined +): T[] { + const normalizedStrategy = strategy ?? "least-utilized"; + if (normalizedStrategy === "priority-spillover") { + const prioritySorted = [...candidates].sort((left, right) => + left.priority - right.priority || + left.index - right.index + ); + const primary = prioritySorted[0]; + const threshold = Number.isFinite(spilloverThreshold) && spilloverThreshold !== undefined && spilloverThreshold > 0 + ? spilloverThreshold + : 0.8; + if (!primary || primary.limitState.utilization < threshold) { + return prioritySorted; + } + return prioritySorted.sort((left, right) => + left.limitState.utilization - right.limitState.utilization || + left.priority - right.priority || + right.weight - left.weight || + left.index - right.index + ); + } + return [...candidates].sort((left, right) => { + if (normalizedStrategy === "weighted-round-robin") { + return right.weight - left.weight || left.priority - right.priority || left.index - right.index; + } + if (normalizedStrategy === "least-utilized") { + return left.limitState.utilization - right.limitState.utilization || + left.priority - right.priority || + right.weight - left.weight || + left.index - right.index; + } + return left.priority - right.priority || + left.limitState.utilization - right.limitState.utilization || + right.weight - left.weight || + left.index - right.index; + }); +} + +function providerCredentialPriority(credential: ProviderCredentialConfig, index: number): number { + return Number.isFinite(credential.priority) ? Number(credential.priority) : index + 1; +} + function buildUpstreamAttempts(fallback: RouterFallbackConfig, method: string, body: Buffer | undefined, routedModel: string | undefined): UpstreamAttempt[] { const initialAttempt: UpstreamAttempt = { body, @@ -1749,18 +2009,33 @@ function appendNodeRequireOption(current: string | undefined, preloadFile: strin function toCoreGatewayProviders(provider: GatewayProviderConfig): CoreGatewayProvider[] { const capabilities = normalizedProviderCapabilities(provider); if (capabilities.length === 0) { - const coreProvider = toCoreGatewayProvider(provider); - return coreProvider ? [coreProvider] : []; + return toCoreGatewayProvidersForCapability(provider); } return capabilities - .map((capability) => toCoreGatewayProvider(provider, capability)) + .flatMap((capability) => toCoreGatewayProvidersForCapability(provider, capability)) + .filter((item): item is CoreGatewayProvider => Boolean(item)); +} + +function toCoreGatewayProvidersForCapability( + provider: GatewayProviderConfig, + capability?: GatewayProviderCapability +): CoreGatewayProvider[] { + const credentials = activeProviderCredentials(provider); + if (credentials.length === 0) { + const coreProvider = toCoreGatewayProvider(provider, capability); + return coreProvider ? [coreProvider] : []; + } + + return sortProviderCredentialsForConfig(credentials) + .map((credential) => toCoreGatewayProvider(provider, capability, credential)) .filter((item): item is CoreGatewayProvider => Boolean(item)); } function toCoreGatewayProvider( provider: GatewayProviderConfig, - capability?: GatewayProviderCapability + capability?: GatewayProviderCapability, + credential?: ProviderCredentialConfig ): CoreGatewayProvider | undefined { const type = capability?.type ?? @@ -1768,7 +2043,7 @@ function toCoreGatewayProvider( normalizeProviderProtocol(provider.provider) ?? inferProtocol(provider); const baseurl = normalizeProviderRuntimeBaseUrl(capability?.baseUrl ?? readBaseUrl(provider), type); - const apikey = provider.apikey || provider.apiKey || provider.api_key; + const apikey = credential ? providerCredentialApiKey(credential) : provider.apikey || provider.apiKey || provider.api_key; if (!provider.name || provider.models.length === 0) { return undefined; @@ -1789,11 +2064,22 @@ function toCoreGatewayProvider( extraBody: provider.extraBody, extraHeaders: provider.extraHeaders, models: provider.models, - name: capability ? providerCapabilityInternalName(provider.name, type) : provider.name, + name: credential + ? providerCredentialInternalName(provider.name, type, credential) + : capability + ? providerCapabilityInternalName(provider.name, type) + : provider.name, type }; } +function sortProviderCredentialsForConfig(credentials: ProviderCredentialConfig[]): ProviderCredentialConfig[] { + return [...credentials].sort((left, right) => + providerCredentialPriority(left, 0) - providerCredentialPriority(right, 0) || + providerCredentialSlug(left.id).localeCompare(providerCredentialSlug(right.id)) + ); +} + function normalizedProviderCapabilities(provider: GatewayProviderConfig): GatewayProviderCapability[] { const capabilities = Array.isArray(provider.capabilities) ? provider.capabilities : []; const normalized: GatewayProviderCapability[] = []; @@ -1822,6 +2108,54 @@ function providerCapabilityInternalName(providerName: string, protocol: GatewayP return `${providerName}::${protocol}`; } +function providerCredentialInternalName( + providerName: string, + protocol: GatewayProviderProtocol, + credential: ProviderCredentialConfig +): string { + return `${providerCapabilityInternalName(providerName, protocol)}::cred:${providerCredentialSlug(credential.id)}`; +} + +function parseProviderCredentialInternalName(value: string | undefined): { + credentialSlug: string; + providerName: string; + protocol: GatewayProviderProtocol; +} | undefined { + const marker = "::cred:"; + const markerIndex = value?.lastIndexOf(marker) ?? -1; + if (!value || markerIndex <= 0) { + return undefined; + } + const baseName = value.slice(0, markerIndex); + const credentialSlug = value.slice(markerIndex + marker.length).trim(); + const protocolSeparator = baseName.lastIndexOf("::"); + if (!credentialSlug || protocolSeparator <= 0) { + return undefined; + } + const protocol = normalizeProviderProtocol(baseName.slice(protocolSeparator + 2)); + const providerName = baseName.slice(0, protocolSeparator).trim(); + return protocol && providerName ? { credentialSlug, providerName, protocol } : undefined; +} + +function providerCredentialSlug(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") || "key"; +} + +function providerCredentialApiKey(credential: ProviderCredentialConfig): string { + return credential.api_key || credential.apiKey || credential.apikey || ""; +} + +function findProviderCredentialBySlug( + provider: GatewayProviderConfig, + credentialSlug: string +): ProviderCredentialConfig | undefined { + return (provider.credentials ?? []).find((credential) => providerCredentialSlug(credential.id) === credentialSlug); +} + function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined { if (typeof value !== "string") { return undefined; @@ -1865,6 +2199,10 @@ function resolveResponseProviderProtocol(headers: Headers, config: AppConfig | u if (!providerName) { return undefined; } + const credentialInternalName = parseProviderCredentialInternalName(providerName); + if (credentialInternalName) { + return credentialInternalName.protocol; + } const provider = config ? findProviderByPublicOrInternalName(config, providerName) : undefined; if (!provider) { return normalizeProviderProtocol(providerName); @@ -2162,7 +2500,10 @@ function reserveApiKeyLimits(apiKey: ApiKeyConfig | undefined, request: Incoming } function apiKeyLimitRules(apiKey: ApiKeyConfig, usage: ApiKeyLimitUsage): ApiKeyLimitRule[] { - const limits = apiKey.limits; + return limitRules(apiKey.limits, usage); +} + +function limitRules(limits: ApiKeyLimitConfig | undefined, usage: ApiKeyLimitUsage): ApiKeyLimitRule[] { if (!limits) { return []; } @@ -2181,6 +2522,134 @@ function apiKeyLimitRules(apiKey: ApiKeyConfig, usage: ApiKeyLimitUsage): ApiKey return rules; } +function providerCredentialLimitState( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + usage: ApiKeyLimitUsage +): { blocked: boolean; utilization: number } { + const rules = limitRules(credential.limits, usage); + if (rules.length === 0) { + return { + blocked: false, + utilization: 0 + }; + } + + const now = Date.now(); + let blocked = false; + let utilization = 0; + for (const rule of rules) { + const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; + const counter = readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart); + blocked = blocked || counter.value + rule.requested > rule.limit; + utilization = Math.max(utilization, (counter.value + rule.requested) / rule.limit); + } + + return { + blocked, + utilization + }; +} + +function recordProviderCredentialOutcome( + config: AppConfig, + method: string, + attempt: UpstreamAttempt, + statusCode: number, + responseHeaders: Headers +): void { + if (!attempt.logicalProvider || !attempt.credentialProtocol || !attempt.credentialChain?.length) { + return; + } + + const provider = findProviderByPublicOrInternalName(config, attempt.logicalProvider); + if (!provider) { + return; + } + + const responseCredentialId = responseHeaders.get("x-ccr-provider-credential-id")?.trim(); + const responseCredential = responseCredentialId + ? (provider.credentials ?? []).find((credential) => credential.id === responseCredentialId) + : undefined; + const fallbackCredential = providerCredentialFromInternalName(provider, attempt.credentialChain[0]); + const credential = responseCredential ?? fallbackCredential; + if (!credential) { + return; + } + + if (statusCode >= 200 && statusCode < 500 && statusCode !== 401 && statusCode !== 403 && statusCode !== 429) { + incrementProviderCredentialCounters(provider, credential, estimateLimitUsage(method, attempt.body ?? Buffer.alloc(0))); + clearProviderCredentialCooldown(provider, credential); + return; + } + + if (statusCode === 401 || statusCode === 403 || statusCode === 429 || statusCode >= 500) { + setProviderCredentialCooldown(provider, credential, providerCredentialCooldownMs(provider), `HTTP ${statusCode}`); + } +} + +function providerCredentialFromInternalName( + provider: GatewayProviderConfig, + internalName: string | undefined +): ProviderCredentialConfig | undefined { + const parsed = parseProviderCredentialInternalName(internalName); + return parsed ? findProviderCredentialBySlug(provider, parsed.credentialSlug) : undefined; +} + +function incrementProviderCredentialCounters( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + usage: ApiKeyLimitUsage +): void { + const rules = limitRules(credential.limits, usage); + const now = Date.now(); + for (const rule of rules) { + const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; + readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart).value += rule.requested; + } +} + +function providerCredentialCounterKey( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + rule: ApiKeyLimitRule, + windowStart: number +): string { + return ["provider-credential", provider.name, credential.id, rule.name, rule.metric, rule.windowMs, windowStart].join("|"); +} + +function readProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): { reason: string; until: number } | undefined { + const key = providerCredentialStateKey(provider, credential); + const cooldown = providerCredentialCooldowns.get(key); + if (!cooldown) { + return undefined; + } + if (cooldown.until > Date.now()) { + return cooldown; + } + providerCredentialCooldowns.delete(key); + return undefined; +} + +function setProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig, cooldownMs: number, reason: string): void { + providerCredentialCooldowns.set(providerCredentialStateKey(provider, credential), { + reason, + until: Date.now() + cooldownMs + }); +} + +function clearProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): void { + providerCredentialCooldowns.delete(providerCredentialStateKey(provider, credential)); +} + +function providerCredentialStateKey(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): string { + return `${provider.name}::${credential.id}`; +} + +function providerCredentialCooldownMs(provider: GatewayProviderConfig): number { + return clampNumber(provider.failover?.cooldownMs ?? 60_000, 1_000, 3_600_000); +} + function addApiKeyLimitRule( rules: ApiKeyLimitRule[], name: string, @@ -2212,7 +2681,11 @@ function readApiKeyWindowCounter(key: string, windowStart: number): ApiKeyWindow } function estimateApiKeyLimitUsage(request: IncomingMessage, requestBody: Buffer): ApiKeyLimitUsage { - if ((request.method ?? "GET").toUpperCase() !== "POST" || requestBody.byteLength === 0) { + return estimateLimitUsage(request.method ?? "GET", requestBody); +} + +function estimateLimitUsage(method: string, requestBody: Buffer): ApiKeyLimitUsage { + if (method.toUpperCase() !== "POST" || requestBody.byteLength === 0) { return { imageCount: 0, totalTokens: 0 diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d12aac98..347f5427 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -3,7 +3,9 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no import net from "node:net"; import path from "node:path"; import { builtInBrowserService } from "./built-in-browser"; +import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "./bot-handoff-scan-service"; import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "./bot-gateway-qr-login-service"; +import { closeBotGatewayQrWindow, openBotGatewayQrWindow } from "./bot-gateway-qr-window-service"; import { syncClaudeAppGatewayConfig } from "./claude-app-gateway-service"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "./config"; import { API_KEYS_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "./constants"; @@ -23,7 +25,7 @@ import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; import { getUsageStats } from "./usage-store"; import windowsManager from "./windows"; -import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountTestRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app"; +import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountTestRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app"; const pluginMarketplace: PluginMarketplaceEntry[] = [ { @@ -165,6 +167,18 @@ ipcMain.handle(IPC_CHANNELS.appBotGatewayQrLoginWait, (_event, request: BotGatew ipcMain.handle(IPC_CHANNELS.appBotGatewayQrLoginCancel, (_event, request: BotGatewayQrLoginCancelRequest) => { return cancelBotGatewayQrLogin(request); }); +ipcMain.handle(IPC_CHANNELS.appBotGatewayQrWindowOpen, (_event, request: BotGatewayQrWindowOpenRequest) => { + return openBotGatewayQrWindow(request); +}); +ipcMain.handle(IPC_CHANNELS.appBotGatewayQrWindowClose, (_event, request: BotGatewayQrWindowCloseRequest) => { + return closeBotGatewayQrWindow(request); +}); +ipcMain.handle(IPC_CHANNELS.appBotHandoffWifiTargetsScan, () => { + return scanBotHandoffWifiTargets(); +}); +ipcMain.handle(IPC_CHANNELS.appBotHandoffBluetoothTargetsScan, () => { + return scanBotHandoffBluetoothTargets(); +}); ipcMain.handle(IPC_CHANNELS.appApplyProfile, async () => { const config = await loadAppConfig(); return applyProfileConfig(config); diff --git a/src/main/preload.ts b/src/main/preload.ts index dfce2991..6fae816a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -13,6 +13,11 @@ import type { BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitRequest, BotGatewayQrLoginWaitResult, + BotGatewayQrWindowCloseRequest, + BotGatewayQrWindowCloseResult, + BotGatewayQrWindowOpenRequest, + BotGatewayQrWindowOpenResult, + BotHandoffScanTarget, ClaudeAppGatewayApplyResult, GatewayMcpServerConfig, GatewayMcpToolInfo, @@ -48,6 +53,7 @@ contextBridge.exposeInMainWorld("ccr", { applyClaudeAppGateway: (config?: AppConfig) => ipcRenderer.invoke(IPC_CHANNELS.appApplyClaudeAppGateway, config) as Promise, applyProfile: () => ipcRenderer.invoke(IPC_CHANNELS.appApplyProfile) as Promise, cancelBotGatewayQrLogin: (request: BotGatewayQrLoginCancelRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrLoginCancel, request) as Promise, + closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrWindowClose, request) as Promise, clearProxyNetworkCaptures: () => ipcRenderer.invoke(IPC_CHANNELS.appClearProxyNetworkCaptures) as Promise, closeTray: () => ipcRenderer.invoke(IPC_CHANNELS.appCloseTray) as Promise, detectProviderIcon: (request: ProviderIconDetectionRequest) => ipcRenderer.invoke(IPC_CHANNELS.appDetectProviderIcon, request) as Promise, @@ -70,6 +76,7 @@ contextBridge.exposeInMainWorld("ccr", { installProxyCertificate: () => ipcRenderer.invoke(IPC_CHANNELS.appInstallProxyCertificate) as Promise, listMcpServerTools: (server: GatewayMcpServerConfig) => ipcRenderer.invoke(IPC_CHANNELS.appListMcpServerTools, server) as Promise, openBuiltInBrowser: () => ipcRenderer.invoke(IPC_CHANNELS.appOpenBuiltInBrowser) as Promise, + openBotGatewayQrWindow: (request: BotGatewayQrWindowOpenRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrWindowOpen, request) as Promise, openExternal: (url: string) => ipcRenderer.invoke(IPC_CHANNELS.appOpenExternal, url) as Promise, openProfile: (request: ProfileOpenRequest) => ipcRenderer.invoke(IPC_CHANNELS.appOpenProfile, request) as Promise, probeProvider: (request: GatewayProviderProbeRequest) => ipcRenderer.invoke(IPC_CHANNELS.appProbeProvider, request) as Promise, @@ -87,6 +94,8 @@ contextBridge.exposeInMainWorld("ccr", { startGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStartGateway) as Promise, startBotGatewayQrLogin: (request: BotGatewayQrLoginStartRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrLoginStart, request) as Promise, stopGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStopGateway) as Promise, + scanBotHandoffBluetoothTargets: () => ipcRenderer.invoke(IPC_CHANNELS.appBotHandoffBluetoothTargetsScan) as Promise, + scanBotHandoffWifiTargets: () => ipcRenderer.invoke(IPC_CHANNELS.appBotHandoffWifiTargetsScan) as Promise, testProviderAccountConnector: (request: ProviderAccountTestRequest) => ipcRenderer.invoke(IPC_CHANNELS.appTestProviderAccountConnector, request) as Promise, updateCheck: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateCheck) as Promise, updateDownload: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateDownload) as Promise, diff --git a/src/main/profile-launch-service.ts b/src/main/profile-launch-service.ts index 9c47430e..981f40b3 100644 --- a/src/main/profile-launch-service.ts +++ b/src/main/profile-launch-service.ts @@ -1,4 +1,4 @@ -import { spawn, spawnSync } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -7,12 +7,17 @@ import { botGatewayProfileEnv } from "./bot-gateway-env"; import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service"; import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch"; import { launchCodexAppProfile } from "./codex-app-launch"; +import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime"; import { CONFIGDIR } from "./constants"; -import { buildProfileLaunchPlan, findProfileForOpen, profileOpenCommand, resolveProfileOpenSurface } from "./profile-launch-core"; +import { gatewayService } from "./gateway/service"; +import { buildProfileLaunchPlan, findProfileForOpen, profileOpenCommand, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "./profile-launch-core"; import { applyProfileConfig } from "./profile-service"; const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>"; const ccrPathBlockEnd = "# <<< Claude Code Router CLI <<<"; +let claudeAppBotWorker: ChildProcess | undefined; + +process.once("exit", stopClaudeAppBotWorker); export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest): Promise { await applyProfileConfig(config); @@ -28,6 +33,7 @@ export async function getProfileOpenCommand(config: AppConfig, request: ProfileO } export async function openProfileFromCcr(config: AppConfig, request: ProfileOpenRequest): Promise { + await applyProfileConfig(config); const profile = findProfileForOpen(config, request.profileId); const surface = resolveProfileOpenSurface(profile, request.surface); if (profile.agent === "claude-code" && surface === "app") { @@ -46,7 +52,7 @@ export async function openProfileFromCcr(config: AppConfig, request: ProfileOpen env: { ...process.env, ...plan.env, - ...botGatewayProfileEnv(config, profile) + ...botGatewayProfileEnv(config, profile, surface) }, stdio: "ignore" }); @@ -97,7 +103,17 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType): void { + const botEnv = botGatewayProfileEnv(config, profile, "app"); + stopClaudeAppBotWorker(); + if (botEnv.CCR_BOT_GATEWAY_ENABLED !== "true") { + return; + } + + const runtimeFile = path.join(CONFIGDIR, "bin", "ccr-codex-cli-middleware.js"); + ensureClaudeBotWorkerRuntime(runtimeFile); + + const settingsFile = resolveClaudeCodeSettingsFile(CONFIGDIR, profile); + const claudeAppUserDataDir = resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile); + const nodeLaunch = nodeRuntimeLaunch(); + const env: NodeJS.ProcessEnv = { + ...process.env, + ...stringRecord(profile.env), + ...botEnv, + ...(nodeLaunch.electronRunAsNode ? { ELECTRON_RUN_AS_NODE: "1" } : {}), + CLAUDE_CONFIG_DIR: path.dirname(settingsFile), + CLAUDE_USER_DATA_DIR: claudeAppUserDataDir, + CCR_CLAUDE_APP_USER_DATA_PATH: claudeAppUserDataDir, + CCR_CLAUDE_CODE_BOT_WORKER: "1", + CCR_CLAUDE_CODE_MODEL: profile.model.trim(), + CCR_CODEX_MODEL: profile.model.trim(), + CCR_CODEX_WORKSPACE_NAME: profile.name || profile.id, + CCR_PROFILE_SURFACE: "app", + CODEXL_CODEX_WORKSPACE_NAME: profile.name || profile.id, + CODEXL_PROFILE_SURFACE: "app" + }; + delete env.ELECTRON_NO_ATTACH_CONSOLE; + + const child = spawn(nodeLaunch.command, [runtimeFile, "claude-bot-worker", "--workspace-name", profile.name || profile.id], { + detached: false, + env, + stdio: ["ignore", "ignore", "pipe"], + windowsHide: true + }); + claudeAppBotWorker = child; + child.stderr?.on("data", (chunk) => { + console.warn(`[profile] Claude App bot worker stderr: ${chunk.toString("utf8").trim()}`); + }); + child.once("exit", (code, signal) => { + if (claudeAppBotWorker === child) { + claudeAppBotWorker = undefined; + } + if (code && code !== 0) { + console.warn(`[profile] Claude App bot worker exited: code=${code}${signal ? ` signal=${signal}` : ""}`); + } + }); + child.once("error", (error) => { + if (claudeAppBotWorker === child) { + claudeAppBotWorker = undefined; + } + console.warn(`[profile] Claude App bot worker failed: ${formatError(error)}`); + }); +} + +function ensureClaudeBotWorkerRuntime(runtimeFile: string): void { + const content = codexCliMiddlewareRuntimeScript(); + const existing = existsSync(runtimeFile) ? readFileSync(runtimeFile, "utf8") : ""; + if (existing !== content) { + mkdirSync(path.dirname(runtimeFile), { recursive: true }); + writeFileSync(runtimeFile, content); + if (process.platform !== "win32") { + chmodSync(runtimeFile, 0o755); + } + } + if (!content.includes("CCR_CLAUDE_CODE_BOT_WORKER") || !content.includes("claude-bot-worker")) { + throw new Error("Claude bot worker runtime does not contain the bot worker entrypoint."); + } +} + +function stopClaudeAppBotWorker(): void { + const child = claudeAppBotWorker; + claudeAppBotWorker = undefined; + if (!child || child.killed) { + return; + } + try { + child.kill("SIGTERM"); + } catch { + // The worker may have already exited. + } +} + +function nodeRuntimeLaunch(): { command: string; electronRunAsNode: boolean } { + const configured = process.env.CCR_NODE_BIN?.trim(); + if (configured) { + return { command: configured, electronRunAsNode: false }; + } + return { + command: process.execPath, + electronRunAsNode: Boolean(process.versions.electron) + }; +} + function commandProfileRef(config: AppConfig, profile: ReturnType): string { const name = profile.name?.trim(); if (!name) { @@ -407,6 +519,13 @@ function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function stringRecord(value: Record | undefined): Record { + if (!value || typeof value !== "object") { + return {}; + } + return Object.fromEntries(Object.entries(value).filter(([, item]) => typeof item === "string")); +} + function findProfileApiKey(config: AppConfig, profile: ReturnType): string { const keyId = profileApiKeyId(profile); const key = config.APIKEYS.find((apiKey) => apiKey.id === keyId)?.key.trim(); diff --git a/src/main/profile-service.ts b/src/main/profile-service.ts index 875b968a..f8547416 100644 --- a/src/main/profile-service.ts +++ b/src/main/profile-service.ts @@ -47,9 +47,8 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token const endpoint = gatewayEndpoint(config); const settings = readJsonObject(settingsFile); const env = { - ...Object.fromEntries(stringRecord(settings.env)), - ...profileEnv(profile), - ...botGatewayProfileEnv(config, profile) + ...withoutBotGatewayEnv(Object.fromEntries(stringRecord(settings.env))), + ...profileEnv(profile) }; env.ANTHROPIC_BASE_URL = endpoint; env.ANTHROPIC_API_BASE_URL = endpoint; @@ -421,15 +420,17 @@ function claudeCodeWrapperFilename(profile: ProfileConfig): string { function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string { const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; - const envExports = Object.entries({ - ...profileEnv(profile), - ...botGatewayProfileEnv(config, profile) - }) + const surface = normalizeProfileSurface(profile.surface); + const envExports = Object.entries(profileEnv(profile)) .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN") .map(([key, value]) => `export ${key}=${shellQuote(value)}`); + const botEnvExports = shellBotGatewayEnvExports(config, profile); return [ "#!/bin/sh", ...envExports, + `: "\${CCR_PROFILE_SURFACE:=${surface}}"`, + "export CCR_PROFILE_SURFACE", + ...botEnvExports, `export CCR_CLAUDE_CODE_WRAPPER=1`, `export CCR_REAL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`, `export CODEXL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`, @@ -441,15 +442,16 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string { const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; - const envExports = Object.entries({ - ...profileEnv(profile), - ...botGatewayProfileEnv(config, profile) - }) + const surface = normalizeProfileSurface(profile.surface); + const envExports = Object.entries(profileEnv(profile)) .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN") .map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`); + const botEnvExports = cmdBotGatewayEnvExports(config, profile); return [ "@echo off", ...envExports, + `if not defined CCR_PROFILE_SURFACE set "CCR_PROFILE_SURFACE=${surface}"`, + ...botEnvExports, `set "CCR_CLAUDE_CODE_WRAPPER=1"`, `set "CCR_REAL_CLAUDE_CODE_BIN=${realClaude.replace(/"/g, '\\"')}"`, `set "CODEXL_CLAUDE_CODE_BIN=${realClaude.replace(/"/g, '\\"')}"`, @@ -523,10 +525,8 @@ function codexMiddlewareShellScript( const codexHome = profile.codexHome?.trim() || path.dirname(values.configFile); const remoteFrontendMode = normalizeCodexRemoteFrontendMode(profile.remoteFrontendMode); const surface = normalizeProfileSurface(profile.surface); - const envExports = Object.entries({ - ...profileEnv(profile), - ...botGatewayProfileEnv(config, profile) - }).map(([key, value]) => `export ${key}=${shellQuote(value)}`); + const envExports = Object.entries(profileEnv(profile)).map(([key, value]) => `export ${key}=${shellQuote(value)}`); + const botEnvExports = shellBotGatewayEnvExports(config, profile); return [ "#!/bin/sh", ...envExports, @@ -541,6 +541,7 @@ function codexMiddlewareShellScript( `export CCR_PROFILE_SCOPE=${shellQuote(normalizeProfileScope(profile.scope))}`, `: "\${CCR_PROFILE_SURFACE:=${surface}}"`, "export CCR_PROFILE_SURFACE", + ...botEnvExports, `export CODEXL_REAL_CODEX_CLI_PATH=${shellQuote(codexCli)}`, `export CODEXL_CODEX_PROFILE=${shellQuote(values.providerId)}`, `export CODEXL_CODEX_MODEL_PROVIDER=${shellQuote(values.providerId)}`, @@ -573,10 +574,8 @@ function codexMiddlewareCmdScript( const surface = normalizeProfileSurface(profile.surface); const providerId = values.providerId.replace(/"/g, '\\"'); const workspaceName = (profile.name || values.providerId).replace(/"/g, '\\"'); - const envExports = Object.entries({ - ...profileEnv(profile), - ...botGatewayProfileEnv(config, profile) - }).map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`); + const envExports = Object.entries(profileEnv(profile)).map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`); + const botEnvExports = cmdBotGatewayEnvExports(config, profile); return [ "@echo off", ...envExports, @@ -590,6 +589,7 @@ function codexMiddlewareCmdScript( `set "CCR_CODEX_PROFILE_CONFIG_FORMAT=${values.configFormat}"`, `set "CCR_PROFILE_SCOPE=${normalizeProfileScope(profile.scope)}"`, `if not defined CCR_PROFILE_SURFACE set "CCR_PROFILE_SURFACE=${surface}"`, + ...botEnvExports, `set "CODEXL_REAL_CODEX_CLI_PATH=${codexCli.replace(/"/g, '\\"')}"`, `set "CODEXL_CODEX_PROFILE=${providerId}"`, `set "CODEXL_CODEX_MODEL_PROVIDER=${providerId}"`, @@ -608,6 +608,37 @@ function codexMiddlewareCmdScript( ].join("\r\n"); } +function shellBotGatewayEnvExports(config: AppConfig, profile: ProfileConfig): string[] { + return [ + 'if [ "$CCR_PROFILE_SURFACE" = "app" ]; then', + ...Object.entries(botGatewayProfileEnv(config, profile, "app")).map(([key, value]) => ` export ${key}=${shellQuote(value)}`), + "else", + ...Object.entries(botGatewayProfileEnv(config, profile, "cli")).map(([key, value]) => ` export ${key}=${shellQuote(value)}`), + "fi" + ]; +} + +function cmdBotGatewayEnvExports(config: AppConfig, profile: ProfileConfig): string[] { + return [ + `if /I "%CCR_PROFILE_SURFACE%"=="app" (`, + ...Object.entries(botGatewayProfileEnv(config, profile, "app")).map(([key, value]) => ` set "${key}=${value.replace(/"/g, '\\"')}"`), + ") else (", + ...Object.entries(botGatewayProfileEnv(config, profile, "cli")).map(([key, value]) => ` set "${key}=${value.replace(/"/g, '\\"')}"`), + ")" + ]; +} + +function withoutBotGatewayEnv(values: Record): Record { + return Object.fromEntries(Object.entries(values).filter(([key]) => !isBotGatewayEnvKey(key))); +} + +function isBotGatewayEnvKey(key: string): boolean { + return key === "BOT_GATEWAY_STATE_DIR" || + key.startsWith("CCR_BOT_") || + key.startsWith("CODEXL_BOT_") || + key === "CCR_BOT_GATEWAY_SDK_MODULE"; +} + function removeRootTomlKeys(source: string, keys: string[]): string { const keyPattern = keys.map(escapeRegExp).join("|"); const pattern = new RegExp(`^\\s*(?:${keyPattern})\\s*=.*(?:\\n|$)`, "gm"); diff --git a/src/main/tray-controller.ts b/src/main/tray-controller.ts index 3a402aae..32a26ce5 100644 --- a/src/main/tray-controller.ts +++ b/src/main/tray-controller.ts @@ -4,8 +4,9 @@ import { pathToFileURL } from "node:url"; import { deflateSync } from "node:zlib"; import { loadAppConfig } from "./config"; import { APP_NAME } from "./constants"; +import { getProviderAccountSnapshots } from "./provider-account-service"; import { getTodayUsageTotals, onUsageRecorded } from "./usage-store"; -import type { AppConfig, TrayIconPreference } from "../shared/app"; +import type { AppConfig, ProviderAccountMeter, TrayBalanceProgressConfig, TrayIconPreference } from "../shared/app"; const popoverMenuWidth = 420; const popoverPreferredHeight = 740; @@ -37,8 +38,8 @@ class TrayController { private resolvedRandomTrayIcon?: TrayMascotIconId; private refreshTimer?: NodeJS.Timeout; private tray?: Tray; + private trayBalanceProgress?: TrayBalanceProgressConfig; private trayIconPreference: TrayIconPreference = "random"; - private trayProgressTargetTokens = 100000; private trayTotalTokens = 0; private unsubscribeUsageUpdates?: () => void; @@ -113,9 +114,9 @@ class TrayController { this.resolvedRandomTrayIcon = undefined; } this.trayIconPreference = nextPreference; - this.trayProgressTargetTokens = normalizeTrayProgressTarget(nextConfig.trayProgressTargetTokens); - if (nextPreference === "progress") { - this.applyProgressTrayIcon(this.trayTotalTokens); + this.trayBalanceProgress = normalizeTrayBalanceProgressConfig(nextConfig.trayBalanceProgress); + if (nextPreference === "progress" && this.trayBalanceProgress) { + await this.refreshBalanceProgressTrayIcon(); return; } this.applyTrayIcon(this.resolveTrayIconId(nextPreference)); @@ -342,8 +343,8 @@ class TrayController { try { const totals = await getTodayUsageTotals(undefined, { includeProxy: true }); this.trayTotalTokens = Math.max(0, totals.totalTokens); - if (this.trayIconPreference === "progress") { - this.applyProgressTrayIcon(this.trayTotalTokens); + if (this.trayIconPreference === "progress" && this.trayBalanceProgress) { + await this.refreshBalanceProgressTrayIcon(); } this.tray.setTitle(formatTokenTitle(totals.totalTokens)); } catch { @@ -359,15 +360,28 @@ class TrayController { this.tray.setImage(icon.isEmpty() ? nativeImage.createEmpty() : icon); } - private applyProgressTrayIcon(totalTokens: number): void { + private applyProgressTrayIcon(progress: number): void { if (!this.tray) { return; } - const progress = calculateTrayProgress(totalTokens, this.trayProgressTargetTokens); const icon = createTrayProgressIcon(progress); this.tray.setImage(icon.isEmpty() ? nativeImage.createEmpty() : icon); } + private async refreshBalanceProgressTrayIcon(): Promise { + if (!this.trayBalanceProgress) { + return; + } + try { + const snapshots = await getProviderAccountSnapshots(this.trayBalanceProgress.provider); + const snapshot = snapshots.find((account) => account.provider === this.trayBalanceProgress?.provider) ?? snapshots[0]; + const meter = snapshot?.meters.find((candidate) => candidate.id === this.trayBalanceProgress?.meterId); + this.applyProgressTrayIcon(meter ? calculateTrayBalanceProgress(meter) : 0); + } catch { + this.applyProgressTrayIcon(0); + } + } + private refreshRandomTrayIconForCurrentDay(): void { if (this.trayIconPreference !== "random") { return; @@ -471,7 +485,7 @@ function createTrayIcon(iconId: TrayMascotIconId): Electron.NativeImage { function createTrayProgressIcon(progress: number): Electron.NativeImage { const clamped = Math.max(0, Math.min(1, progress)); - const image = nativeImage.createFromBuffer(createProgressRingPng(clamped)); + const image = nativeImage.createFromBuffer(createBalanceProgressBarPng(clamped)); if (image.isEmpty()) { return nativeImage.createEmpty(); } @@ -486,71 +500,92 @@ function normalizeTrayIconPreference(value: unknown): TrayIconPreference { : "random"; } -function normalizeTrayProgressTarget(value: unknown): number { - const target = Number(value); - if (!Number.isFinite(target) || target <= 0) { - return 100000; +function normalizeTrayBalanceProgressConfig(value: unknown): TrayBalanceProgressConfig | undefined { + if (!isRecord(value)) { + return undefined; } - return Math.min(1_000_000_000, Math.max(1000, Math.trunc(target))); + const provider = readRecordString(value, "provider"); + const meterId = readRecordString(value, "meterId"); + return provider && meterId ? { meterId, provider } : undefined; } -function calculateTrayProgress(totalTokens: number, targetTokens: number): number { - if (targetTokens <= 0) { - return 0; +function calculateTrayBalanceProgress(meter: ProviderAccountMeter): number { + if (meter.limit && meter.limit > 0) { + if (meter.remaining !== undefined) { + return Math.max(0, Math.min(1, meter.remaining / meter.limit)); + } + if (meter.used !== undefined) { + return Math.max(0, Math.min(1, 1 - meter.used / meter.limit)); + } } - return Math.max(0, Math.min(1, totalTokens / targetTokens)); + if (meter.unit === "%") { + if (meter.remaining !== undefined) { + return Math.max(0, Math.min(1, meter.remaining / 100)); + } + if (meter.used !== undefined) { + return Math.max(0, Math.min(1, 1 - meter.used / 100)); + } + } + const rawValue = meter.remaining ?? meter.limit ?? meter.used ?? 0; + return rawValue > 0 ? 1 : 0; } -function createProgressRingPng(progress: number): Buffer { +function createBalanceProgressBarPng(progress: number): Buffer { const size = 36; - const center = (size - 1) / 2; - const outerRadius = 15.2; - const ringRadius = 12.2; - const ringWidth = 4.2; const rgba = Buffer.alloc(size * size * 4); - const track = { a: 0.55, b: 184, g: 163, r: 148 }; - const fill = progress >= 0.99 - ? { a: 1, b: 153, g: 211, r: 52 } - : { a: 1, b: 250, g: 250, r: 248 }; + const clamped = Math.max(0, Math.min(1, progress)); + const track = { a: 0.48, b: 184, g: 163, r: 148 }; + const fill = clamped <= 0.05 + ? { a: 1, b: 68, g: 68, r: 248 } + : clamped <= 0.2 + ? { a: 1, b: 36, g: 191, r: 245 } + : { a: 1, b: 252, g: 250, r: 248 }; + const accent = { a: 0.95, b: 191, g: 212, r: 45 }; const background = { a: 0.92, b: 42, g: 23, r: 15 }; for (let y = 0; y < size; y += 1) { for (let x = 0; x < size; x += 1) { const px = x + 0.5; const py = y + 0.5; - const dx = px - center; - const dy = py - center; - const distance = Math.hypot(dx, dy); const index = (y * size + x) * 4; - blendPngPixel(rgba, index, background, edgeAlpha(outerRadius - distance)); - blendPngPixel(rgba, index, track, strokeAlpha(distance, ringRadius, ringWidth)); - if (progress > 0 && isProgressArcPoint(dx, dy, progress)) { - blendPngPixel(rgba, index, fill, strokeAlpha(distance, ringRadius, ringWidth)); - } - blendPngPixel(rgba, index, background, edgeAlpha(4.4 - distance)); + blendPngPixel(rgba, index, background, roundedRectAlpha(px, py, 3, 3, 30, 30, 8)); + blendPngPixel(rgba, index, { a: 0.74, b: 250, g: 250, r: 248 }, roundedRectAlpha(px, py, 7, 9, 12, 2.5, 1.25)); + blendPngPixel(rgba, index, accent, roundedRectAlpha(px, py, 7, 15, 18, 2.5, 1.25)); + blendPngPixel(rgba, index, track, roundedRectAlpha(px, py, 7, 22, 22, 5, 2.5)); + blendPngPixel(rgba, index, fill, roundedRectAlpha(px, py, 7, 22, Math.max(2, 22 * clamped), 5, 2.5)); } } return encodePngRgba(rgba, size, size); } -function isProgressArcPoint(dx: number, dy: number, progress: number): boolean { - if (progress >= 1) { - return true; - } - let angle = Math.atan2(dy, dx) + Math.PI / 2; - if (angle < 0) { - angle += Math.PI * 2; - } - return angle <= progress * Math.PI * 2; +function roundedRectAlpha( + px: number, + py: number, + x: number, + y: number, + width: number, + height: number, + radius: number +): number { + const halfWidth = Math.max(0, width / 2 - radius); + const halfHeight = Math.max(0, height / 2 - radius); + const centerX = x + width / 2; + const centerY = y + height / 2; + const dx = Math.abs(px - centerX) - halfWidth; + const dy = Math.abs(py - centerY) - halfHeight; + const outside = Math.hypot(Math.max(dx, 0), Math.max(dy, 0)); + const inside = Math.min(Math.max(dx, dy), 0); + return Math.max(0, Math.min(1, 0.5 - (outside + inside - radius))); } -function edgeAlpha(distance: number): number { - return Math.max(0, Math.min(1, distance + 0.5)); +function readRecordString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; } -function strokeAlpha(distance: number, radius: number, width: number): number { - return Math.max(0, Math.min(1, width / 2 + 0.5 - Math.abs(distance - radius))); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } function blendPngPixel( diff --git a/src/main/windows.ts b/src/main/windows.ts index 0538d72c..f326972d 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -42,8 +42,7 @@ class WindowsManager { nodeIntegration: false, preload: path.join(__dirname, "preload.js"), sandbox: true, - webSecurity: true, - webviewTag: true + webSecurity: true }, width }); diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index f0f9bb8d..bff87a75 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -16,8 +16,8 @@ import { isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady, LayoutGroup, mergeProviderCapabilities, mergeProviderModelLists, navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeOverviewWidgets, - normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayIconPreference, - normalizeTrayProgressTargetTokens, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingStepId, onboardingStepOrder, + normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference, + normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingStepId, onboardingStepOrder, OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft, persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft, probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, ProfileConfig, profileConfigFromDraft, providerAccountApiKeySafetyIssue, @@ -25,7 +25,7 @@ import { providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage, ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage, ResolvedTheme, resolvePluginInstallPlan, RouterRule, ServerActionBusy, - shouldAutoProbeProviderBaseUrl, splitLines, translateProxyCertificateMessage, translateText, TrayWidgetConfig, + shouldAutoProbeProviderBaseUrl, splitLines, translateProxyCertificateMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig, uniqueRoutingRuleId, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect, useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId, VirtualModelDraft, virtualModelProfileFromDraft @@ -969,11 +969,14 @@ function App() { probe?.capabilities ?? [], protocol && baseUrl ? [{ baseUrl, source: probe?.detectedProtocol ? "detected" : "preset", type: protocol }] : [] ); + const existingProvider = providerEditIndex === undefined ? undefined : draftConfig.Providers[providerEditIndex]; const provider: GatewayProviderConfig = { api_base_url: normalizeProviderBaseUrl(baseUrl, protocol), api_key: providerDraft.apiKey.trim(), capabilities: capabilities.length > 0 ? capabilities : undefined, account: accountConfig, + credentials: existingProvider?.credentials, + failover: existingProvider?.failover, icon: providerDraft.icon.trim() || undefined, models, name: providerName, @@ -1575,17 +1578,21 @@ function App() { function changeTrayIconPreference(value: string) { const trayIcon = normalizeTrayIconPreference(value); + if (trayIcon === "progress" && !normalizeTrayBalanceProgressConfig(draftConfig.trayBalanceProgress)) { + return; + } updateConfig((config) => ({ ...config, trayIcon })); } - function changeTrayProgressTargetTokens(value: string) { - const trayProgressTargetTokens = normalizeTrayProgressTargetTokens(value); - updateConfig((config) => ({ - ...config, - trayProgressTargetTokens + function changeTrayBalanceProgress(config: TrayBalanceProgressConfig) { + const trayBalanceProgress = normalizeTrayBalanceProgressConfig(config); + updateConfig((current) => ({ + ...current, + trayBalanceProgress, + trayIcon: trayBalanceProgress ? "progress" : current.trayIcon === "progress" ? "random" : current.trayIcon })); } @@ -1874,7 +1881,7 @@ function App() { function openProfileDialog(index: number) { const profile = draftConfig.profile.profiles[index]; - if (!profile?.enabled || normalizeProfileScope(profile.scope) !== "ccr") { + if (!profile?.enabled) { return; } setProfileActionError(""); @@ -2413,17 +2420,19 @@ function App() { onCheckUpdate: checkForAppUpdate, onChangeLanguage: changeLanguagePreference, onChangeTheme: changeThemePreference, + onChangeTrayBalanceProgress: changeTrayBalanceProgress, onChangeTrayIcon: changeTrayIconPreference, - onChangeTrayProgressTarget: changeTrayProgressTargetTokens, onChangeTrayWidgets: changeTrayWidgets, onClose: () => setSettingsOpen(false), onDownloadUpdate: downloadAppUpdate, onInstallUpdate: installAppUpdate, + profiles: draftConfig.profile.profiles, systemLanguage, systemTheme, themePreference: draftConfig.theme || "system", + providerAccountSnapshots, + trayBalanceProgress: normalizeTrayBalanceProgressConfig(draftConfig.trayBalanceProgress), trayIconPreference: draftConfig.trayIcon || "random", - trayProgressTargetTokens: draftConfig.trayProgressTargetTokens || 100000, trayWidgets: normalizeTrayWidgets(draftConfig.trayWidgets ?? DEFAULT_TRAY_WIDGETS, draftConfig.trayWindowModules, draftConfig.trayComponentVariants), updateActionBusy, updateActionError, diff --git a/src/renderer/pages/home/components/profiles.tsx b/src/renderer/pages/home/components/profiles.tsx index f73690a4..d5926c2d 100644 --- a/src/renderer/pages/home/components/profiles.tsx +++ b/src/renderer/pages/home/components/profiles.tsx @@ -1,14 +1,14 @@ import { - AddProfileDraft, AgentLogo, AnimatePresence, AppConfig, Badge, BotGatewaySavedConfig, botGatewaySavedConfigLabel, Button, + AddProfileDraft, AgentLogo, AnimatePresence, AppConfig, Badge, BotGatewaySavedConfig, botGatewaySavedConfigLabel, BotHandoffScanTarget, Button, Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, Copy, cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, - DialogTitle, Field, GatewayProviderConfig, Input, KeyValueRowsControl, LoaderCircle, motion, + DialogTitle, Field, GatewayProviderConfig, Info, Input, KeyValueRowsControl, LoaderCircle, motion, normalizeProfileScope, normalizeProfileSurface, parseProfileModelValue, Pencil, Plus, PopoverContent, profileAgentLabel, profileAgentOptions, ProfileConfig, profileModelDisplayValue, profileModelMatchesQuery, profileModelProviderMatchesQuery, profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions, - Play, Search, SelectControl, Toggle, translateOptions, Trash2, useAppText, type ProfileOpenSurface, type VirtualModelProfileConfig, + Play, RefreshCw, Search, Select, SelectControl, Toggle, translateOptions, Trash2, useAppText, type ProfileOpenSurface, type VirtualModelProfileConfig, copyTextToClipboard, - useEffect, useLayoutEffect, useMemo, useRef, useState, X + useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, X } from "../shared"; export function ProfileView({ addProfile, @@ -95,7 +95,7 @@ export function ProfileView({
updateProfileItem(index, { enabled })} /> - {profile.enabled && scope === "ccr" ? ( + {profile.enabled ? ( @@ -723,7 +723,17 @@ export function AddProfileForm({ onChange({ surface: normalizeProfileSurface(surface) })} + onChange={(surface) => { + const nextSurface = normalizeProfileSurface(surface); + onChange(nextSurface !== "cli" + ? { surface: nextSurface } + : { + botConfigId: "", + botConfigured: true, + botEnabled: false, + surface: nextSurface + }); + }} options={translateOptions(profileSurfaceOptions, t)} value={draft.surface} /> @@ -772,9 +782,11 @@ export function AddProfileForm({ )} -
- -
+ {draft.surface !== "cli" ? ( +
+ +
+ ) : null} config.id === selectedValue) + : undefined; + const [wifiScan, setWifiScan] = useState(emptyHandoffScanState); + const [bluetoothScan, setBluetoothScan] = useState(emptyHandoffScanState); + const autoHandoffScanRef = useRef(false); + + const scanHandoffTargets = useCallback(async (kind: "bluetooth" | "wifi") => { + const setScan = kind === "wifi" ? setWifiScan : setBluetoothScan; + const scanner = kind === "wifi" + ? window.ccr?.scanBotHandoffWifiTargets + : window.ccr?.scanBotHandoffBluetoothTargets; + if (!scanner) { + setScan({ + error: t("Handoff target scan is available in the Electron app."), + loading: false, + results: [] + }); + return; + } + setScan({ ...emptyHandoffScanState, loading: true }); + try { + const results = await scanner(); + setScan({ + error: "", + loading: false, + results + }); + } catch (error) { + setScan({ + error: error instanceof Error ? error.message : String(error), + loading: false, + results: [] + }); + } + }, [t]); + + useEffect(() => { + if (!draft.botEnabled || !draft.botHandoffEnabled || !selectedBot) { + autoHandoffScanRef.current = false; + return; + } + if (autoHandoffScanRef.current) { + return; + } + autoHandoffScanRef.current = true; + void scanHandoffTargets("wifi"); + void scanHandoffTargets("bluetooth"); + }, [draft.botEnabled, draft.botHandoffEnabled, scanHandoffTargets, selectedBot]); function updateEnabled(botEnabled: boolean) { if (!botEnabled) { onChange({ botConfigId: "", botConfigured: true, botEnabled: false }); return; } + const nextBotConfigId = draft.botConfigId || botConfigs[0]?.id || ""; + const nextBot = botConfigs.find((config) => config.id === nextBotConfigId); onChange({ - botConfigId: draft.botConfigId || botConfigs[0]?.id || "", + botConfigId: nextBotConfigId, botConfigured: true, - botEnabled: true + botEnabled: true, + botForwardAllAgentMessages: nextBot ? nextBot.botGateway.forwardAllAgentMessages !== false : draft.botForwardAllAgentMessages }); } @@ -834,26 +911,190 @@ function BotGatewaySelectForm({ onChange({ botConfigId: "", botConfigured: true, botEnabled: false }); return; } - onChange({ botConfigId: value, botConfigured: true, botEnabled: true }); + const nextBot = botConfigs.find((config) => config.id === value); + onChange({ + botConfigId: value, + botConfigured: true, + botEnabled: true, + botForwardAllAgentMessages: nextBot ? nextBot.botGateway.forwardAllAgentMessages !== false : draft.botForwardAllAgentMessages + }); } + const botScopeHint = t("Messages are forwarded only when using the corresponding app."); + return (
- {t("Bot")} + + {t("Bot")} + + +
{draft.botEnabled ? ( -
+
+ {selectedBot ? ( + <> +
+ {t("Forward agent messages")} + onChange({ botForwardAllAgentMessages })} /> +
+
+
+ {t("Handoff")} + onChange({ botHandoffEnabled })} /> +
+ {draft.botHandoffEnabled ? ( +
+ + onChange({ botHandoffIdleSeconds: event.target.value })} + /> + + void scanHandoffTargets("wifi")} + onSelect={(botHandoffPhoneWifiTargets) => onChange({ botHandoffPhoneWifiTargets })} + /> + void scanHandoffTargets("bluetooth")} + onSelect={(botHandoffPhoneBluetoothTargets) => onChange({ botHandoffPhoneBluetoothTargets })} + /> +
+ ) : null} +
+ + ) : null}
) : null}
); } +function HandoffTargetPicker({ + className, + label, + scan, + selectedTarget, + onRefresh, + onSelect +}: { + className?: string; + label: string; + scan: BotHandoffScanState; + selectedTarget: string; + onRefresh: () => void; + onSelect: (targetValue: string) => void; +}) { + const t = useAppText(); + const options = selectedTarget && !scan.results.some((target) => handoffTargetMatchesSavedValue(target, selectedTarget)) + ? [ + { + detail: "", + id: `selected:${selectedTarget}`, + label: selectedTarget, + source: "selected", + target: selectedTarget + }, + ...scan.results + ] + : scan.results; + const placeholderText = scan.loading + ? t("Scanning targets") + : options.length > 0 + ? t("Select a scanned target") + : t("No targets found"); + const selectedOption = options.find((target) => handoffTargetMatchesSavedValue(target, selectedTarget)); + const selectValue = selectedTarget || HANDOFF_TARGET_NONE_VALUE; + const selectOptions = [ + ...(selectedTarget ? [{ label: t("None"), value: HANDOFF_TARGET_NONE_VALUE }] : []), + ...(!selectedTarget ? [{ disabled: true, label: placeholderText, value: HANDOFF_TARGET_NONE_VALUE }] : []), + ...options.map((target) => ({ + label: handoffTargetSelectionText(target), + value: handoffTargetSavedValue(target) + })) + ]; + + return ( +
+ {label} +
+ update({ name: event.target.value })} /> @@ -629,93 +840,7 @@ function BotConfigDialog({ /> ))} - {qrLoginSupported ? ( -
-
-
-
{t("Weixin QR login")}
-
- {qrLogin.message || t("Scan with Weixin to connect this bot.")} -
-
- -
-
-
- -
-
-
-
- {qrLogin.loading ? ( - - ) : qrLogin.status === "confirmed" ? ( - - ) : qrLogin.error || isFailedBotQrLoginStatus(qrLogin.status) ? ( - - ) : ( - - )} - {t(botQrLoginStatusLabel(qrLogin.status))} -
- {qrLogin.error ? ( -
{qrLogin.error}
- ) : null} -
- {qrLogin.start?.expiresAt ? ( -
-
{t("Expires")}
-
{qrLogin.start.expiresAt}
-
- ) : null} - {qrLogin.display.kind === "frame" ? ( - - ) : null} -
-
-
- ) : null} -
- {t("Forward agent messages")} - update({ botForwardAllAgentMessages: checked === true })} /> -
-
-
- {t("Handoff")} - update({ botHandoffEnabled: checked === true })} /> -
- {draft.botHandoffEnabled ? ( -
- - update({ botHandoffIdleSeconds: event.target.value })} /> - - - update({ botHandoffPhoneWifiTargets: event.target.value })} /> - - - update({ botHandoffPhoneBluetoothTargets: event.target.value })} /> - -
- ) : null} -
-
+ {error ? (
{error} @@ -723,32 +848,17 @@ function BotConfigDialog({ ) : null} - - + + ); } -function BotQrPreview({ display, label }: { display: BotQrDisplay; label: string }) { - if (display.kind === "image") { - return {label}; - } - if (display.kind === "frame") { - return ( - - ); - } - return ; -} - function emptyBotQrLoginState(): BotQrLoginState { return { display: { kind: "empty", src: "" }, @@ -765,7 +875,7 @@ function normalizeBotQrDisplay(raw: string): BotQrDisplay { return { kind: "empty", src: "" }; } if (value.startsWith("http://") || value.startsWith("https://")) { - return { kind: "frame", src: value }; + return { kind: "window", src: value }; } if (value.startsWith("data:")) { return { kind: "image", src: value }; @@ -780,14 +890,10 @@ function isTerminalBotQrLoginStatus(status: string): boolean { return ["already_bound", "confirmed", "expired", "failed"].includes(status); } -function isFailedBotQrLoginStatus(status: string): boolean { - return ["already_bound", "expired", "failed"].includes(status); -} - function botQrLoginStatusLabel(status: string): string { switch (status) { case "starting": - return "Generating QR code"; + return "Waiting for QR code"; case "qr_pending": case "pending": return "Waiting for scan"; @@ -999,27 +1105,41 @@ function formatUpdateDate(value: string | undefined): string { function TraySettingsPage({ copy, + onChangeTrayBalanceProgress, onChangeTrayIcon, - onChangeTrayProgressTarget, onChangeTrayWidgets, + providerAccountSnapshots, + trayBalanceProgress, trayIconPreference, - trayProgressTargetTokens, trayWidgets }: { copy: AppCopy; + onChangeTrayBalanceProgress: (config: TrayBalanceProgressConfig) => void; onChangeTrayIcon: (value: string) => void; - onChangeTrayProgressTarget: (value: string) => void; onChangeTrayWidgets: (widgets: TrayWidgetConfig[]) => void; + providerAccountSnapshots: ProviderAccountSnapshot[]; + trayBalanceProgress?: TrayBalanceProgressConfig; trayIconPreference: AppConfig["trayIcon"]; - trayProgressTargetTokens: number; trayWidgets: TrayWidgetConfig[]; }) { const pageRef = useRef(null); const [selectedTrayWidgetId, setSelectedTrayWidgetId] = useState(); const [pendingScrollTrayWidgetId, setPendingScrollTrayWidgetId] = useState(); + const [progressSelectionActive, setProgressSelectionActive] = useState(false); + const [progressDraft, setProgressDraft] = useState>(trayBalanceProgress ?? {}); const widgets = useMemo(() => normalizeTrayWidgets(trayWidgets), [trayWidgets]); const selectedWidget = widgets.find((widget) => widget.id === selectedTrayWidgetId) ?? widgets[0]; const selectedWidgetIndex = selectedWidget ? widgets.findIndex((widget) => widget.id === selectedWidget.id) : -1; + const progressAccounts = useMemo(() => trayBalanceProgressAccounts(providerAccountSnapshots), [providerAccountSnapshots]); + const progressProvider = progressDraft.provider ?? ""; + const progressMeters = useMemo(() => trayBalanceProgressMeters(progressAccounts, progressProvider), [progressAccounts, progressProvider]); + const progressProviderValue = progressAccounts.some((snapshot) => snapshot.provider === progressProvider) ? progressProvider : ""; + const progressMeterValue = progressMeters.some((meter) => meter.id === progressDraft.meterId) ? progressDraft.meterId ?? "" : ""; + const progressBinding = trayBalanceProgressBindingFromDraft(progressDraft); + const progressPreviewBinding = progressBinding ?? trayBalanceProgress; + const progressPreviewValue = trayBalanceProgressValue(providerAccountSnapshots, progressPreviewBinding); + const effectiveTrayIconPreference: AppConfig["trayIcon"] = progressSelectionActive ? "progress" : trayIconPreference; + const progressEditorOpen = effectiveTrayIconPreference === "progress"; const trayIconOptions: Array<{ label: string; value: AppConfig["trayIcon"] }> = [ { label: copy.settings.trayIconRandom, value: "random" }, { label: copy.settings.trayIconViolet, value: "violet" }, @@ -1044,10 +1164,43 @@ function TraySettingsPage({ }) ); + useEffect(() => { + if (!progressSelectionActive) { + setProgressDraft(trayBalanceProgress ?? {}); + } + }, [progressSelectionActive, trayBalanceProgress?.meterId, trayBalanceProgress?.provider]); + function commitWidgets(nextWidgets: TrayWidgetConfig[]) { onChangeTrayWidgets(normalizeTrayWidgets(nextWidgets)); } + function changeTrayIcon(value: string) { + if (value === "progress") { + setProgressSelectionActive(true); + setProgressDraft(trayBalanceProgress ?? {}); + return; + } + setProgressSelectionActive(false); + onChangeTrayIcon(value); + } + + function changeProgressProvider(provider: string) { + setProgressSelectionActive(true); + setProgressDraft(provider ? { provider } : {}); + } + + function changeProgressMeter(meterId: string) { + const provider = progressDraft.provider?.trim(); + if (!provider || !meterId.trim()) { + setProgressDraft((current) => ({ ...current, meterId })); + return; + } + const nextProgress = { meterId: meterId.trim(), provider }; + setProgressDraft(nextProgress); + setProgressSelectionActive(false); + onChangeTrayBalanceProgress(nextProgress); + } + function addTrayWidget(template: TrayWidgetConfig) { const existingSingleton = isTraySingletonWidgetType(template.type) ? widgets.find((widget) => widget.type === template.type) @@ -1141,18 +1294,41 @@ function TraySettingsPage({

{copy.settings.tray}

- + - {trayIconPreference === "progress" ? ( - - onChangeTrayProgressTarget(event.target.value)} - /> - + {progressEditorOpen ? ( + progressAccounts.length > 0 ? ( + <> + + ({ label: trayBalanceProgressMeterLabel(meter, trayT), value: meter.id })) + ]} + value={progressMeterValue} + /> + +
+ {copy.settings.trayBalanceProgressRequired} +
+ + ) : ( +
+ {copy.settings.trayBalanceProgressNoData} +
+ ) ) : null}
@@ -1236,7 +1412,6 @@ function TraySettingsPage({
void; options: Array<{ label: string; value: AppConfig["trayIcon"] }>; + progress?: number; value: AppConfig["trayIcon"]; }) { return (
- +