mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Add Claude App design shell routing
This commit is contained in:
parent
56688e83d9
commit
e645bc5076
9 changed files with 558 additions and 50 deletions
|
|
@ -31,6 +31,7 @@ const BOOTSTRAP_ROUTE_PATHS = ["/_bootstrap", "/api/bootstrap", "/edge-api/boots
|
|||
const REQUIRED_ROUTE_PATHS = ["/", OMELETTE_RPC_PATH_PREFIX, "/v1/design", ...CLAUDE_APP_SPA_ROUTE_PATHS, ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS];
|
||||
const DEFAULT_ROUTE_PATHS = ["/design", "/v1/design", "/api", "/organizations", "/cdn-cgi", ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS];
|
||||
const FALLBACK_ROUTE_PATHS = ["/app-unavailable-in-region", "/cdn-cgi", "/design", ...AUTH_ESCAPE_ROUTE_PATHS];
|
||||
const DEFAULT_DIRECT_GATEWAY_ROUTE_PATHS = ["/design", "/v1/design", "/api", "/organizations", "/assets", "/cdn-cgi", "/app-unavailable-in-region", ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS];
|
||||
const DEFAULT_SCRIPT_PATH = "/design/assets/v1/index-C0BEUHEw.js";
|
||||
const DEFAULT_STYLE_PATH = "/design/assets/v1/index-CqhNJH1o.css";
|
||||
const DEFAULT_DESIGN_CRITICAL_MODULE_PRELOAD_PATHS = [
|
||||
|
|
@ -254,12 +255,15 @@ module.exports = {
|
|||
const routing = normalizeClaudeDesignRouting(options.routing, options);
|
||||
const upstreamOrigins = normalizeUpstreamOrigins(options.upstreamOrigins, upstreamOrigin);
|
||||
const me = normalizeMe(options.me, frontendDefaultModel, gatewayModelPresets);
|
||||
const browserAppUrl = claudeDesignBrowserAppUrl(routeHost, usingClaudeAppAssets);
|
||||
|
||||
ctx.registerApp({
|
||||
description: "Open Claude Design through the CCR browser proxy.",
|
||||
description: usingClaudeAppAssets
|
||||
? "Open Claude Design UI through the Claude App shell and CCR gateway."
|
||||
: "Open Claude Design through the CCR browser proxy.",
|
||||
id: "claude-design",
|
||||
name: "Claude Design",
|
||||
url: `https://${routeHost}/design`
|
||||
url: browserAppUrl
|
||||
});
|
||||
|
||||
const store = await ctx.openSqliteStore({
|
||||
|
|
@ -390,6 +394,20 @@ module.exports = {
|
|||
});
|
||||
}
|
||||
|
||||
if (options.directGatewayRoutes !== false) {
|
||||
for (const pathPrefix of normalizeDirectGatewayRoutePaths(options.directGatewayPaths)) {
|
||||
ctx.registerGatewayRoute({
|
||||
auth: "none",
|
||||
handler(request, response) {
|
||||
return handleMockRequest(runtime, request, response);
|
||||
},
|
||||
id: `claude-design-ui-${sanitizeRouteId(pathPrefix)}`,
|
||||
methods: ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"],
|
||||
pathPrefix
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.registerProxyRoute({
|
||||
host: routeHost,
|
||||
id: "claude-design-proxy",
|
||||
|
|
@ -1075,6 +1093,27 @@ function normalizeFallbackRouteHosts(value, primaryHost) {
|
|||
return hosts;
|
||||
}
|
||||
|
||||
function claudeDesignBrowserAppUrl(routeHost, usingClaudeAppAssets) {
|
||||
if (!usingClaudeAppAssets) {
|
||||
return `https://${routeHost}/design`;
|
||||
}
|
||||
const targetPath = withClaudeAppDesignIframeMarker("/design");
|
||||
return `https://${routeHost}${CLAUDE_APP_DESIGN_SHELL_PATH}?${CLAUDE_APP_DESIGN_PATH_QUERY}=${encodeURIComponent(targetPath)}`;
|
||||
}
|
||||
|
||||
function normalizeDirectGatewayRoutePaths(value) {
|
||||
const configured = stringArray(value);
|
||||
const paths = configured?.length ? configured : DEFAULT_DIRECT_GATEWAY_ROUTE_PATHS;
|
||||
return Array.from(new Set(paths.map(normalizePath).filter(Boolean).filter((path) => path !== "/")));
|
||||
}
|
||||
|
||||
function sanitizeRouteId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
|| "root";
|
||||
}
|
||||
|
||||
async function resolveDesignIndexAssets(runtime, request, options = {}) {
|
||||
const current = runtime.designIndexAssets || {
|
||||
checkedAt: 0,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@
|
|||
"assetPassthrough": false,
|
||||
"assetAutoUpdate": true,
|
||||
"autoAnswerQuestions": true,
|
||||
// Optional. Keep true so Claude App can iframe the local CCR-served
|
||||
// Claude Design UI at http://127.0.0.1:<gatewayPort>/design.
|
||||
"directGatewayRoutes": true,
|
||||
// Optional. When assetDir is omitted, the plugin will auto-detect Claude Desktop's
|
||||
// bundled ion-dist assets, e.g. /Applications/Claude.app/Contents/Resources/ion-dist.
|
||||
// Set claudeAppAssets to false to disable that auto-detection.
|
||||
|
|
|
|||
|
|
@ -575,16 +575,28 @@ function defaultBrowserAppsForPlugin(plugin: AppConfig["plugins"][number]): Gate
|
|||
}
|
||||
const config = isPlainRecord(plugin.config) ? plugin.config : {};
|
||||
const host = stringValue(config.host) || "claude.ai";
|
||||
const url = usesClaudeAppDesignShell(config)
|
||||
? claudeAppDesignShellUrl(host)
|
||||
: `https://${host}/design`;
|
||||
return [
|
||||
{
|
||||
description: "Open Claude Design through the CCR browser proxy.",
|
||||
id: "claude-design",
|
||||
name: "Claude Design",
|
||||
url: `https://${host}/design`
|
||||
url
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function usesClaudeAppDesignShell(config: Record<string, unknown>): boolean {
|
||||
return config.claudeAppAssets !== false && !stringValue(config.assetDir);
|
||||
}
|
||||
|
||||
function claudeAppDesignShellUrl(host: string): string {
|
||||
const path = encodeURIComponent("/design?__ccr_design_iframe=1");
|
||||
return `https://${host}/desktop-design?path=${path}`;
|
||||
}
|
||||
|
||||
function normalizeConfiguredBrowserApp(pluginId: string, app: GatewayPluginAppConfig, index: number): InstalledBrowserApp | undefined {
|
||||
const name = app.name?.trim();
|
||||
const url = app.url?.trim();
|
||||
|
|
|
|||
439
src/main/claude-app-cdp.ts
Normal file
439
src/main/claude-app-cdp.ts
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
import { rmSync } from "node:fs";
|
||||
import { createServer, type AddressInfo } from "node:net";
|
||||
import path from "node:path";
|
||||
import { WebSocket } from "undici";
|
||||
|
||||
type ClaudeAppCdpLogger = Pick<Console, "info" | "warn">;
|
||||
|
||||
type ClaudeAppDesignCdpOptions = {
|
||||
cdpPort?: number;
|
||||
designUrl?: string;
|
||||
logger?: ClaudeAppCdpLogger;
|
||||
};
|
||||
|
||||
type DevToolsTarget = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
url?: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
};
|
||||
|
||||
type CdpError = {
|
||||
code?: number;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type CdpMessage = {
|
||||
error?: CdpError;
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
type FetchRequestPausedParams = {
|
||||
request?: {
|
||||
url?: string;
|
||||
};
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
const claudeAppDevToolsActivePortFile = "DevToolsActivePort";
|
||||
const claudeAppDesignCdpConnectTimeoutMs = 15_000;
|
||||
const claudeAppDesignCdpKeepAliveMs = 45_000;
|
||||
const claudeAppDesignCdpPollIntervalMs = 250;
|
||||
|
||||
export async function reserveClaudeAppCdpPort(logger: ClaudeAppCdpLogger = console): Promise<number | undefined> {
|
||||
const configured = Number(process.env.CCR_CLAUDE_APP_CDP_PORT);
|
||||
if (Number.isInteger(configured) && configured > 0 && configured <= 65535) {
|
||||
return configured;
|
||||
}
|
||||
try {
|
||||
return await reserveLoopbackPort();
|
||||
} catch (error) {
|
||||
logger.warn(`[profile] Failed to reserve Claude App CDP port: ${nodeErrorMessage(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareClaudeAppCdpUserDataDir(userDataDir: string): void {
|
||||
rmSync(path.join(userDataDir, claudeAppDevToolsActivePortFile), { force: true });
|
||||
}
|
||||
|
||||
export function scheduleClaudeAppDesignCdp(options: ClaudeAppDesignCdpOptions): void {
|
||||
if (process.env.CCR_CLAUDE_APP_DESIGN_CDP?.trim().toLowerCase() === "false") {
|
||||
return;
|
||||
}
|
||||
const logger = options.logger || console;
|
||||
const designUrl = normalizeClaudeAppDesignUrl(options.designUrl);
|
||||
if (!options.cdpPort || !designUrl) {
|
||||
return;
|
||||
}
|
||||
void forceOpenClaudeAppDesignViaCdp({
|
||||
cdpPort: options.cdpPort,
|
||||
designUrl,
|
||||
logger
|
||||
}).catch((error) => {
|
||||
logger.warn(`[profile] Failed to force-open Claude Design via CDP: ${nodeErrorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function forceOpenClaudeAppDesignViaCdp(options: Required<ClaudeAppDesignCdpOptions>): Promise<void> {
|
||||
const target = await waitForClaudeAppPageTarget(options.cdpPort, claudeAppDesignCdpConnectTimeoutMs);
|
||||
if (!target.webSocketDebuggerUrl) {
|
||||
throw new Error(`Claude App CDP page target was not available on port ${options.cdpPort}.`);
|
||||
}
|
||||
|
||||
const client = await CdpClient.connect(target.webSocketDebuggerUrl);
|
||||
try {
|
||||
client.on("Fetch.requestPaused", (params) => {
|
||||
void handleFetchRequestPaused(client, params as FetchRequestPausedParams, options.logger);
|
||||
});
|
||||
|
||||
await client.send("Page.enable");
|
||||
await client.send("Runtime.enable");
|
||||
await client.send("Fetch.enable", {
|
||||
patterns: [
|
||||
{
|
||||
requestStage: "Request",
|
||||
urlPattern: "app://localhost/v1/privacy-consents*"
|
||||
}
|
||||
]
|
||||
});
|
||||
await client.send("Page.setBypassCSP", { enabled: true });
|
||||
await client.send("Page.addScriptToEvaluateOnNewDocument", {
|
||||
source: claudeAppDesignFeatureScript()
|
||||
});
|
||||
await client.send("Runtime.evaluate", {
|
||||
awaitPromise: false,
|
||||
expression: claudeAppDesignFeatureScript()
|
||||
});
|
||||
await client.send("Page.navigate", {
|
||||
url: claudeAppDesktopDesignUrl(options.designUrl)
|
||||
});
|
||||
await sleep(1_200);
|
||||
await client.send("Runtime.evaluate", {
|
||||
awaitPromise: false,
|
||||
expression: claudeAppDesignFrameScript(options.designUrl)
|
||||
});
|
||||
options.logger.info(`[profile] Force-opened Claude Design via CDP at ${options.designUrl}.`);
|
||||
await sleep(claudeAppDesignCdpKeepAliveMs);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchRequestPaused(client: CdpClient, params: FetchRequestPausedParams, logger: ClaudeAppCdpLogger): Promise<void> {
|
||||
const requestId = params.requestId;
|
||||
if (!requestId) {
|
||||
return;
|
||||
}
|
||||
const url = params.request?.url || "";
|
||||
try {
|
||||
if (url.startsWith("app://localhost/v1/privacy-consents")) {
|
||||
await client.send("Fetch.fulfillRequest", {
|
||||
body: Buffer.from(JSON.stringify({
|
||||
consents: {},
|
||||
ok: true,
|
||||
values: {}
|
||||
})).toString("base64"),
|
||||
responseCode: 200,
|
||||
responseHeaders: [
|
||||
{ name: "content-type", value: "application/json; charset=utf-8" },
|
||||
{ name: "cache-control", value: "no-store" }
|
||||
],
|
||||
requestId
|
||||
});
|
||||
return;
|
||||
}
|
||||
await client.send("Fetch.continueRequest", { requestId });
|
||||
} catch (error) {
|
||||
logger.warn(`[profile] Failed to handle Claude App CDP request ${url}: ${nodeErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForClaudeAppPageTarget(port: number, timeoutMs: number): Promise<DevToolsTarget> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const targets = await cdpJson<DevToolsTarget[]>(port, "/json/list");
|
||||
const target = targets.find(isClaudeAppPageTarget) ||
|
||||
targets.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl);
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(claudeAppDesignCdpPollIntervalMs);
|
||||
}
|
||||
throw new Error(`Claude App CDP page target was not available on port ${port}${lastError ? `: ${nodeErrorMessage(lastError)}` : ""}`);
|
||||
}
|
||||
|
||||
function isClaudeAppPageTarget(target: DevToolsTarget): boolean {
|
||||
if (target.type !== "page" || !target.webSocketDebuggerUrl) {
|
||||
return false;
|
||||
}
|
||||
const url = target.url || "";
|
||||
return url.startsWith("app://localhost/") || url.startsWith("app://-/") || /claude/i.test(target.title || "");
|
||||
}
|
||||
|
||||
async function cdpJson<T>(port: number, endpoint: string): Promise<T> {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${endpoint}`, {
|
||||
signal: AbortSignal.timeout(1_000)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`CDP ${endpoint} returned HTTP ${response.status}`);
|
||||
}
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
function claudeAppDesktopDesignUrl(designUrl: string): string {
|
||||
return `app://localhost/desktop-design?path=${encodeURIComponent(designUrl)}`;
|
||||
}
|
||||
|
||||
function claudeAppDesignFeatureScript(): string {
|
||||
return `(() => {
|
||||
const forced = { claudeDesignWindow: { status: "supported" } };
|
||||
function merge(value) {
|
||||
return Object.assign({}, value || {}, forced);
|
||||
}
|
||||
let bootFeatures = merge(globalThis.desktopBootFeatures);
|
||||
try {
|
||||
Object.defineProperty(globalThis, "desktopBootFeatures", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return bootFeatures;
|
||||
},
|
||||
set(value) {
|
||||
bootFeatures = merge(value);
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
globalThis.desktopBootFeatures = bootFeatures;
|
||||
}
|
||||
globalThis.claude = globalThis.claude || {};
|
||||
globalThis.claude.settings = globalThis.claude.settings || {};
|
||||
const existing = globalThis.claude.settings.AppFeatures || {};
|
||||
const previous = existing.getSupportedFeatures;
|
||||
globalThis.claude.settings.AppFeatures = Object.assign({}, existing, {
|
||||
__ccrClaudeDesignPatched: true,
|
||||
getSupportedFeatures() {
|
||||
if (typeof previous === "function") {
|
||||
return Promise.resolve(previous.call(existing)).then(merge, () => merge());
|
||||
}
|
||||
return Promise.resolve(merge());
|
||||
}
|
||||
});
|
||||
})();`;
|
||||
}
|
||||
|
||||
function claudeAppDesignFrameScript(designUrl: string): string {
|
||||
const target = JSON.stringify(designUrl);
|
||||
return `(() => {
|
||||
const target = ${target};
|
||||
function forceFrame() {
|
||||
const frames = Array.from(document.querySelectorAll("iframe"));
|
||||
const designFrame = frames.find((frame) => /design|desktop-design|omelette/i.test(frame.getAttribute("src") || frame.id || frame.className || ""));
|
||||
const frame = designFrame || frames[0];
|
||||
if (frame && frame.src !== target) {
|
||||
frame.src = target;
|
||||
}
|
||||
}
|
||||
forceFrame();
|
||||
globalThis.__ccrClaudeDesignFrameTimer = globalThis.__ccrClaudeDesignFrameTimer || setInterval(forceFrame, 500);
|
||||
})();`;
|
||||
}
|
||||
|
||||
function normalizeClaudeAppDesignUrl(value: string | undefined): string {
|
||||
const raw = value?.trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!isLocalHttpUrl(url)) {
|
||||
return "";
|
||||
}
|
||||
url.pathname = normalizeDesignPath(url.pathname);
|
||||
url.searchParams.set("__ccr_design_iframe", "1");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalHttpUrl(url: URL): boolean {
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
return hostname === "127.0.0.1" ||
|
||||
hostname === "localhost" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "[::1]";
|
||||
}
|
||||
|
||||
function normalizeDesignPath(value: string): string {
|
||||
if (!value || value === "/") {
|
||||
return "/design";
|
||||
}
|
||||
return value.startsWith("/") ? value : `/${value}`;
|
||||
}
|
||||
|
||||
function reserveLoopbackPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once("error", reject);
|
||||
server.listen({ host: "127.0.0.1", port: 0 }, () => {
|
||||
const address = server.address() as AddressInfo | null;
|
||||
const port = address?.port;
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
if (!port) {
|
||||
reject(new Error("No loopback port was assigned."));
|
||||
return;
|
||||
}
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
class CdpClient {
|
||||
private readonly handlers = new Map<string, Array<(params: unknown) => void>>();
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<number, {
|
||||
reject: (error: Error) => void;
|
||||
resolve: (value: unknown) => void;
|
||||
}>();
|
||||
|
||||
private constructor(private readonly ws: WebSocket) {
|
||||
ws.addEventListener("message", (event) => this.handleMessage(event.data));
|
||||
ws.addEventListener("close", () => this.rejectPending(new Error("CDP WebSocket closed.")));
|
||||
ws.addEventListener("error", () => this.rejectPending(new Error("CDP WebSocket failed.")));
|
||||
}
|
||||
|
||||
static connect(url: string): Promise<CdpClient> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// Ignore close failures during timeout cleanup.
|
||||
}
|
||||
reject(new Error("Timed out connecting to Claude App CDP WebSocket."));
|
||||
}, 5_000);
|
||||
ws.addEventListener("open", () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(new CdpClient(ws));
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Failed to connect to Claude App CDP WebSocket."));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.ws.readyState === 0 || this.ws.readyState === 1) {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
on(method: string, handler: (params: unknown) => void): void {
|
||||
const handlers = this.handlers.get(method) || [];
|
||||
handlers.push(handler);
|
||||
this.handlers.set(method, handlers);
|
||||
}
|
||||
|
||||
send(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||
if (this.ws.readyState !== 1) {
|
||||
return Promise.reject(new Error("CDP WebSocket is not open."));
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const payload = params === undefined ? { id, method } : { id, method, params };
|
||||
this.ws.send(JSON.stringify(payload));
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { reject, resolve });
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(data: unknown): void {
|
||||
let message: CdpMessage;
|
||||
try {
|
||||
message = JSON.parse(webSocketDataToString(data)) as CdpMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof message.id === "number") {
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message || `CDP command failed with code ${message.error.code || "unknown"}`));
|
||||
return;
|
||||
}
|
||||
pending.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
if (message.method) {
|
||||
for (const handler of this.handlers.get(message.method) || []) {
|
||||
handler(message.params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPending(error: Error): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function webSocketDataToString(data: unknown): string {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
if (Buffer.isBuffer(data)) {
|
||||
return data.toString("utf8");
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data).toString("utf8");
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
||||
}
|
||||
return String(data);
|
||||
}
|
||||
|
||||
function nodeErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import type { AppConfig, ProfileConfig } from "../shared/app";
|
||||
import { botGatewayProfileEnv } from "./bot-gateway-env";
|
||||
import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "./claude-app-cdp";
|
||||
import { resolveClaudeCodeSettingsFile } from "./profile-launch-core";
|
||||
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "./windows-app-discovery";
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ type ClaudeAppLookupResult = {
|
|||
export type ClaudeAppLaunchResult = {
|
||||
child: ChildProcess;
|
||||
command: string;
|
||||
cdpPort?: number;
|
||||
pid?: number;
|
||||
userDataDir: string;
|
||||
};
|
||||
|
|
@ -31,7 +33,7 @@ const windowsClaudeExeNames = [
|
|||
];
|
||||
const windowsClaudePackageKeywords = ["claude", "anthropic"];
|
||||
|
||||
export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): ClaudeAppLaunchResult {
|
||||
export async function launchClaudeAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): Promise<ClaudeAppLaunchResult> {
|
||||
const lookup = findInstalledClaudeAppExecutable();
|
||||
if (!lookup.executable) {
|
||||
throw new Error([
|
||||
|
|
@ -44,6 +46,8 @@ export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig
|
|||
const settingsDir = path.dirname(settingsFile);
|
||||
const userDataDir = resolveClaudeAppProfileUserDataDir(configDir, profile);
|
||||
mkdirSync(userDataDir, { recursive: true });
|
||||
prepareClaudeAppCdpUserDataDir(userDataDir);
|
||||
const cdpPort = await reserveClaudeAppCdpPort(console);
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
|
|
@ -57,16 +61,23 @@ export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig
|
|||
};
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
|
||||
const child = spawn(lookup.executable, claudeElectronArgs(userDataDir), {
|
||||
const designUrl = claudeAppDesignUrl(config);
|
||||
const child = spawn(lookup.executable, claudeElectronArgs(userDataDir, cdpPort), {
|
||||
detached: true,
|
||||
env,
|
||||
stdio: "ignore"
|
||||
});
|
||||
child.unref();
|
||||
scheduleClaudeAppDesignCdp({
|
||||
cdpPort,
|
||||
designUrl,
|
||||
logger: console
|
||||
});
|
||||
|
||||
return {
|
||||
child,
|
||||
command: lookup.executable,
|
||||
...(cdpPort ? { cdpPort } : {}),
|
||||
pid: child.pid,
|
||||
userDataDir
|
||||
};
|
||||
|
|
@ -77,8 +88,41 @@ export function resolveClaudeAppProfileUserDataDir(configDir: string, profile: P
|
|||
return claudeElectronUserDataDir(path.dirname(settingsFile), profile);
|
||||
}
|
||||
|
||||
function claudeElectronArgs(userDataDir: string): string[] {
|
||||
function claudeAppDesignUrl(config: AppConfig | undefined): string | undefined {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const port = Number.isInteger(config.gateway?.port) && config.gateway.port > 0
|
||||
? config.gateway.port
|
||||
: Number.isInteger(config.PORT) && config.PORT > 0
|
||||
? config.PORT
|
||||
: undefined;
|
||||
if (!port) {
|
||||
return undefined;
|
||||
}
|
||||
const host = formatLoopbackGatewayHost(config.gateway?.host || "127.0.0.1");
|
||||
return `http://${host}:${port}/design`;
|
||||
}
|
||||
|
||||
function formatLoopbackGatewayHost(host: string): string {
|
||||
const trimmed = host.trim();
|
||||
if (!trimmed || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
if (trimmed.includes(":") && !trimmed.startsWith("[")) {
|
||||
return `[${trimmed}]`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function claudeElectronArgs(userDataDir: string, cdpPort?: number): string[] {
|
||||
return [
|
||||
...(cdpPort
|
||||
? [
|
||||
`--remote-debugging-port=${cdpPort}`,
|
||||
"--remote-debugging-address=127.0.0.1"
|
||||
]
|
||||
: []),
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
"--remote-allow-origins=*",
|
||||
"--disable-renderer-backgrounding",
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType<typeo
|
|||
dataDir: resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile)
|
||||
});
|
||||
await ensureGatewayConfigRunning(profileGatewayConfig, "Claude App");
|
||||
activateProfileAppWindow(registerProfileApp(profile, "app", launchClaudeAppProfile(CONFIGDIR, profile, config)));
|
||||
activateProfileAppWindow(registerProfileApp(profile, "app", await launchClaudeAppProfile(CONFIGDIR, profile, profileGatewayConfig)));
|
||||
startClaudeAppBotWorker(config, profile);
|
||||
return {
|
||||
message: `Opened Claude App with ${profile.name || profile.id}.`,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
DialogHeader, DialogTitle, Field, findProviderPreset, formatProviderAccountMeterValue, GatewayProviderConfig,
|
||||
GatewayProviderProbeResult, getProviderPresets, Globe, inferProviderNameFromBaseUrl, Input, KeyValueRowsControl, Label,
|
||||
Layers3, LoaderCircle, localAgentProviderIconUrls, mergeProviderModelLists, modelCatalogItemMatchesQuery, motion,
|
||||
Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint, providerAccountBadgeVariant,
|
||||
Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint,
|
||||
providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot,
|
||||
providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
|
||||
ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft,
|
||||
|
|
@ -340,10 +340,9 @@ function ProviderAccountListCell({ provider, snapshots }: { provider: GatewayPro
|
|||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{snapshot.status === "ok" ? null : <Badge variant={providerAccountBadgeVariant(snapshot.status)}>{snapshot.status}</Badge>}
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[11px]">
|
||||
{meter ? <span className="min-w-0 truncate text-[11px] font-medium">{formatProviderAccountMeterValue(meter)}</span> : null}
|
||||
{sortedSnapshots.length > 1 ? <Badge variant="outline">{sortedSnapshots.length} {t("keys")}</Badge> : null}
|
||||
{sortedSnapshots.length > 1 ? <span className="shrink-0 text-muted-foreground">{sortedSnapshots.length} {t("keys")}</span> : null}
|
||||
</div>
|
||||
{providerAccountSnapshotCredentialLabel(snapshot) ? (
|
||||
<div className="mt-0.5 truncate text-[10px] font-semibold text-muted-foreground" title={providerAccountSnapshotLabel(snapshot)}>
|
||||
|
|
|
|||
|
|
@ -3197,19 +3197,19 @@ function prepareClaudeAppFallbackModelRequest(
|
|||
const parsedBody = parseJsonObjectSafe(body);
|
||||
const model = stringValue(parsedBody?.model);
|
||||
const normalizedModel = normalizeRouteSelector(model);
|
||||
if (
|
||||
!parsedBody ||
|
||||
!normalizedModel ||
|
||||
isConfiguredGatewayModelSelector(normalizedModel, config)
|
||||
) {
|
||||
if (!parsedBody || !normalizedModel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const routedModel = resolveClaudeAppGatewayRouteModel(normalizedModel, config, claudeAppGatewayModelRouteOptions) ??
|
||||
const routeModel = resolveClaudeAppGatewayRouteModel(normalizedModel, config, claudeAppGatewayModelRouteOptions);
|
||||
const routedModel = routeModel ??
|
||||
(normalizedModel.toLowerCase() === CLAUDE_APP_FALLBACK_MODEL ? inferClaudeAppGatewayTargetModel(config) : undefined);
|
||||
if (!routedModel || routedModel.toLowerCase() === normalizedModel.toLowerCase()) {
|
||||
return undefined;
|
||||
}
|
||||
if (isConfiguredGatewayModelSelector(normalizedModel, config) && !routeModel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
body: serializeJsonBodyWithModel(parsedBody, routedModel),
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { normalizeProfileScopeValue } from "./app";
|
|||
|
||||
export const CLAUDE_APP_FALLBACK_MODEL = "claude-sonnet-4-5";
|
||||
export const CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX = "[1m]";
|
||||
const CLAUDE_APP_CCR_ROUTE_PREFIX = "claude-ccr-";
|
||||
const CLAUDE_APP_CCR_ROUTE_SLUG_MAX_LENGTH = 72;
|
||||
|
||||
const CLAUDE_APP_ROUTE_NAMES = [
|
||||
"claude-sonnet-4-5",
|
||||
|
|
@ -42,15 +40,15 @@ export function buildClaudeAppGatewayModelRoutes(
|
|||
config: Pick<AppConfig, "Providers" | "Router" | "profile" | "virtualModelProfiles">,
|
||||
options: ClaudeAppGatewayModelRouteOptions = {}
|
||||
): ClaudeAppGatewayModelRoute[] {
|
||||
const targetModels = claudeAppGatewayTargetModels(config);
|
||||
const targetModels = claudeAppGatewayTargetModels(config).slice(0, CLAUDE_APP_ROUTE_NAMES.length);
|
||||
const displayNames = claudeAppGatewayDisplayNames(targetModels);
|
||||
return targetModels.map((rawTargetModel, index) => {
|
||||
const targetModel = stripClaudeAppGatewayOneMillionContextSuffix(rawTargetModel);
|
||||
const oneMillionContext = claudeAppGatewaySupportsOneMillionContext(rawTargetModel, options);
|
||||
const routeId = claudeAppGatewayRouteId(targetModel, index);
|
||||
const routeId = claudeAppGatewayRouteId(index);
|
||||
return {
|
||||
displayName: oneMillionContext ? `${displayNames[index]} (1M context)` : displayNames[index],
|
||||
id: oneMillionContext ? claudeAppGatewayOneMillionContextModelId(routeId) : routeId,
|
||||
id: routeId,
|
||||
oneMillionContext,
|
||||
targetModel
|
||||
};
|
||||
|
|
@ -155,34 +153,8 @@ function claudeAppGatewaySupportsOneMillionContext(
|
|||
Boolean(options.supportsOneMillionContext?.(baseModel));
|
||||
}
|
||||
|
||||
function claudeAppGatewayRouteId(targetModel: string, index: number): string {
|
||||
return CLAUDE_APP_ROUTE_NAMES[index] ??
|
||||
`${CLAUDE_APP_CCR_ROUTE_PREFIX}${claudeAppGatewayRouteSlug(targetModel)}-${claudeAppGatewayRouteHash(targetModel)}`;
|
||||
}
|
||||
|
||||
function claudeAppGatewayRouteSlug(value: string): string {
|
||||
const slug = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, CLAUDE_APP_CCR_ROUTE_SLUG_MAX_LENGTH)
|
||||
.replace(/-+$/g, "");
|
||||
return slug || "model";
|
||||
}
|
||||
|
||||
function claudeAppGatewayRouteHash(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const char of value.trim().toLowerCase()) {
|
||||
hash ^= char.charCodeAt(0);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(36).padStart(6, "0").slice(0, 6);
|
||||
}
|
||||
|
||||
function claudeAppGatewayOneMillionContextModelId(id: string): string {
|
||||
return hasClaudeAppGatewayOneMillionContextSuffix(id) ? id : `${id}${CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX}`;
|
||||
function claudeAppGatewayRouteId(index: number): string {
|
||||
return CLAUDE_APP_ROUTE_NAMES[index] ?? CLAUDE_APP_FALLBACK_MODEL;
|
||||
}
|
||||
|
||||
function claudeAppGatewayDisplayNames(models: string[]): string[] {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue