mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Refactor plugin marketplace loading and browser app URLs
This commit is contained in:
parent
bc872a29b3
commit
a400111f8a
13 changed files with 2584 additions and 71 deletions
|
|
@ -147,14 +147,6 @@ export function copyBrowserRendererHtml() {
|
|||
|
||||
export function copyMarketplacePlugins() {
|
||||
ensureDist();
|
||||
for (const filename of ["claude-design-plugin.cjs", "cursor-proxy-plugin.cjs"]) {
|
||||
const source = path.join(projectRoot, "examples", "plugins", filename);
|
||||
if (existsSync(source)) {
|
||||
cpSync(source, path.join(cliMarketplacePluginsDir, filename));
|
||||
cpSync(source, path.join(coreMarketplacePluginsDir, filename));
|
||||
cpSync(source, path.join(electronMarketplacePluginsDir, filename));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function syncUiRendererToRuntimeDists() {
|
||||
|
|
|
|||
|
|
@ -21,11 +21,13 @@ Most custom extensions should start as a Wrapper plugin. It receives CCR config,
|
|||
When the gateway starts, CCR reads the `plugins` array and processes each plugin whose `enabled !== false`:
|
||||
|
||||
1. It first applies `apps`, `proxy.routes`, `coreGateway.providerPlugins`, `coreGateway.virtualModelProfiles`, and `coreGateway.config` declared in config.
|
||||
2. It then loads the plugin module. `module` can be an absolute path, a `~/` path, a `./...` path relative to the CCR config directory, or a Node-resolvable package name.
|
||||
3. If `module` is missing, CCR tries to match the plugin `id` with built-in marketplace plugins such as `claude-design` and `cursor-proxy`.
|
||||
2. It then loads the plugin module. `module` must resolve to an explicit local JavaScript file path, such as an absolute path, a `~/` path, or a `./...` path relative to the CCR config directory.
|
||||
3. If `module` is missing, CCR does not load a built-in fallback. Marketplace installs download the selected plugin from the GitHub marketplace manifest into CCR's data directory and then write that local cached module path into config.
|
||||
4. A module can export a function or an object with `setup(ctx)` or `activate(ctx)`.
|
||||
5. On stop, CCR runs `stop` and `onStop` hooks in reverse order, then closes HTTP backends and SQLite stores registered by the plugin.
|
||||
|
||||
The extension marketplace is fetched from GitHub at startup/use time. The default manifest URL is `https://raw.githubusercontent.com/musistudio/claude-code-router/main/marketplace/plugins.json`; set `CCR_PLUGIN_MARKETPLACE_URL` to point CCR at another compatible GitHub-hosted manifest.
|
||||
|
||||
Common module shape:
|
||||
|
||||
```js
|
||||
|
|
|
|||
|
|
@ -21,11 +21,13 @@ CCR 的扩展分为两层:
|
|||
启动网关时,CCR 会读取配置里的 `plugins` 数组,并按顺序处理每个 `enabled !== false` 的扩展:
|
||||
|
||||
1. 先应用配置中声明的 `apps`、`proxy.routes`、`coreGateway.providerPlugins`、`coreGateway.virtualModelProfiles` 和 `coreGateway.config`。
|
||||
2. 再加载扩展模块。`module` 可以是绝对路径、`~/` 开头路径、相对配置目录的 `./...` 路径,或 Node 可以解析到的包名。
|
||||
3. 如果没有配置 `module`,CCR 会尝试用扩展 `id` 匹配内置市场扩展,例如 `claude-design` 和 `cursor-proxy`。
|
||||
2. 再加载扩展模块。`module` 必须解析到明确的本地 JavaScript 文件路径,例如绝对路径、`~/` 开头路径,或相对 CCR 配置目录的 `./...` 路径。
|
||||
3. 如果没有配置 `module`,CCR 不会再加载内置兜底扩展。插件市场安装会从 GitHub 市场 manifest 下载选中的插件到 CCR 数据目录,并把本地缓存后的模块路径写入配置。
|
||||
4. 模块可以导出函数,也可以导出包含 `setup(ctx)` 或 `activate(ctx)` 的对象。
|
||||
5. 扩展停止时,CCR 会反向执行 `stop`、`onStop` 钩子,并关闭该扩展注册的 HTTP 后端和 SQLite store。
|
||||
|
||||
扩展市场会从 GitHub 获取。默认 manifest 地址是 `https://raw.githubusercontent.com/musistudio/claude-code-router/main/marketplace/plugins.json`;可以通过 `CCR_PLUGIN_MARKETPLACE_URL` 指向另一个兼容的 GitHub-hosted manifest。
|
||||
|
||||
扩展模块常见导出形式:
|
||||
|
||||
```js
|
||||
|
|
|
|||
35
marketplace/plugins.json
Normal file
35
marketplace/plugins.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"plugins": [
|
||||
{
|
||||
"id": "agent-console",
|
||||
"name": "Agent Console",
|
||||
"description": "Launches the local Agent Console Electron renderer as a CCR-hosted app and routes Codex/Claude agents through the CCR gateway.",
|
||||
"capabilities": ["Wrapper runtime", "PWA bridge", "System launcher", "Agent Console", "Model routing"],
|
||||
"module": "plugins/agent-console/index.cjs",
|
||||
"apps": [
|
||||
{
|
||||
"id": "agent-console",
|
||||
"name": "Agent Console",
|
||||
"description": "Open Agent Console inside CCR.",
|
||||
"icon": "terminal-square",
|
||||
"url": "/plugins/agent-console/pages/home/?mode=main"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "claude-design",
|
||||
"name": "Claude Design",
|
||||
"description": "Routes Claude App Design traffic through the local CCR wrapper backend with configurable model routing.",
|
||||
"capabilities": ["Wrapper runtime", "Claude App proxy", "Claude Design", "Model routing"],
|
||||
"module": "../examples/plugins/claude-design-plugin.cjs"
|
||||
},
|
||||
{
|
||||
"id": "cursor-proxy",
|
||||
"name": "Cursor Proxy",
|
||||
"description": "Routes Cursor-compatible LLM traffic captured by proxy mode into the local CCR gateway.",
|
||||
"capabilities": ["Wrapper runtime", "Proxy mode", "Cursor", "Model routing", "OpenAI/Anthropic/Gemini forwarding"],
|
||||
"module": "../examples/plugins/cursor-proxy-plugin.cjs"
|
||||
}
|
||||
]
|
||||
}
|
||||
1464
marketplace/plugins/agent-console/index.cjs
Normal file
1464
marketplace/plugins/agent-console/index.cjs
Normal file
File diff suppressed because it is too large
Load diff
14
marketplace/plugins/agent-console/plugin.json
Normal file
14
marketplace/plugins/agent-console/plugin.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"id": "agent-console",
|
||||
"name": "Agent Console",
|
||||
"module": "index.cjs",
|
||||
"apps": [
|
||||
{
|
||||
"id": "agent-console",
|
||||
"name": "Agent Console",
|
||||
"description": "Open Agent Console inside CCR.",
|
||||
"icon": "terminal-square",
|
||||
"url": "/plugins/agent-console/pages/home/?mode=main"
|
||||
}
|
||||
]
|
||||
}
|
||||
355
packages/core/src/plugins/marketplace.ts
Normal file
355
packages/core/src/plugins/marketplace.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATADIR } from "@ccr/core/config/constants";
|
||||
import type { GatewayPluginAppConfig, PluginDependency, PluginMarketplaceEntry } from "@ccr/core/contracts/app";
|
||||
import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
|
||||
|
||||
type MarketplaceCache = {
|
||||
entries: PluginMarketplaceEntry[];
|
||||
expiresAt: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const defaultMarketplaceUrl = "https://raw.githubusercontent.com/musistudio/claude-code-router/main/marketplace/plugins.json";
|
||||
const marketplaceCacheDir = path.join(DATADIR, "plugin-marketplace");
|
||||
const marketplaceModuleCacheDir = path.join(marketplaceCacheDir, "modules");
|
||||
const marketplaceManifestCacheFile = path.join(marketplaceCacheDir, "plugins.json");
|
||||
const marketplaceFetchTimeoutMs = 10_000;
|
||||
const marketplaceCacheTtlMs = 5 * 60 * 1000;
|
||||
const maxMarketplaceManifestBytes = 1024 * 1024;
|
||||
const maxMarketplaceModuleBytes = 8 * 1024 * 1024;
|
||||
|
||||
let marketplaceCache: MarketplaceCache | undefined;
|
||||
let marketplaceRequest: Promise<PluginMarketplaceEntry[]> | undefined;
|
||||
|
||||
export async function getPluginMarketplace(): Promise<PluginMarketplaceEntry[]> {
|
||||
const url = marketplaceUrl();
|
||||
const now = Date.now();
|
||||
if (marketplaceCache && marketplaceCache.url === url && marketplaceCache.expiresAt > now) {
|
||||
return marketplaceCache.entries.map(cloneMarketplaceEntry);
|
||||
}
|
||||
|
||||
if (!marketplaceRequest) {
|
||||
marketplaceRequest = fetchPluginMarketplace(url)
|
||||
.then((entries) => {
|
||||
marketplaceCache = {
|
||||
entries,
|
||||
expiresAt: Date.now() + marketplaceCacheTtlMs,
|
||||
url
|
||||
};
|
||||
return entries;
|
||||
})
|
||||
.finally(() => {
|
||||
marketplaceRequest = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
const entries = await marketplaceRequest;
|
||||
return entries.map(cloneMarketplaceEntry);
|
||||
}
|
||||
|
||||
function marketplaceUrl(): string {
|
||||
return process.env.CCR_PLUGIN_MARKETPLACE_URL?.trim() || defaultMarketplaceUrl;
|
||||
}
|
||||
|
||||
async function fetchPluginMarketplace(url: string): Promise<PluginMarketplaceEntry[]> {
|
||||
try {
|
||||
const source = await fetchText(url, maxMarketplaceManifestBytes);
|
||||
ensureMarketplaceCacheDir();
|
||||
writeFileSync(marketplaceManifestCacheFile, source, "utf8");
|
||||
return normalizeMarketplaceManifest(JSON.parse(source) as unknown, url);
|
||||
} catch (error) {
|
||||
console.warn(`[plugin-marketplace] Failed to fetch marketplace from ${url}: ${formatError(error)}`);
|
||||
return readCachedMarketplace(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function readCachedMarketplace(url: string): Promise<PluginMarketplaceEntry[]> {
|
||||
if (!existsSync(marketplaceManifestCacheFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const source = readFileSync(marketplaceManifestCacheFile, "utf8");
|
||||
return normalizeMarketplaceManifest(JSON.parse(source) as unknown, url, { offline: true });
|
||||
} catch (error) {
|
||||
console.warn(`[plugin-marketplace] Failed to read cached marketplace: ${formatError(error)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeMarketplaceManifest(
|
||||
value: unknown,
|
||||
manifestUrl: string,
|
||||
options: { offline?: boolean } = {}
|
||||
): Promise<PluginMarketplaceEntry[]> {
|
||||
const items = marketplaceItems(value);
|
||||
const entries: PluginMarketplaceEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
let entry: PluginMarketplaceEntry | undefined;
|
||||
try {
|
||||
entry = await normalizeMarketplaceEntry(item, manifestUrl, options);
|
||||
} catch (error) {
|
||||
console.warn(`[plugin-marketplace] Failed to load marketplace entry: ${formatError(error)}`);
|
||||
continue;
|
||||
}
|
||||
if (!entry || seen.has(entry.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(entry.id);
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function marketplaceItems(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
for (const key of ["plugins", "entries", "marketplace"]) {
|
||||
const items = value[key];
|
||||
if (Array.isArray(items)) {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function normalizeMarketplaceEntry(
|
||||
value: unknown,
|
||||
manifestUrl: string,
|
||||
options: { offline?: boolean }
|
||||
): Promise<PluginMarketplaceEntry | undefined> {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const id = pluginIdValue(readString(value.id) || readString(value.key));
|
||||
const name = readString(value.name) || readString(value.title) || id;
|
||||
const description = readString(value.description) || "";
|
||||
const moduleValue = readString(value.moduleUrl) || readString(value.module) || readString(value.modulePath) || readString(value.path);
|
||||
if (!id || !name || !moduleValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const moduleUrl = resolveRemoteMarketplaceUrl(moduleValue, manifestUrl);
|
||||
const modulePath = await cachedMarketplaceModulePath(id, moduleUrl, options);
|
||||
if (!modulePath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
apps: parsePluginApps(value.apps),
|
||||
capabilities: readStringArray(value.capabilities),
|
||||
dependencies: await parsePluginDependencies(value.dependencies ?? value.pluginDependencies, manifestUrl, options),
|
||||
description,
|
||||
id,
|
||||
modulePath,
|
||||
name
|
||||
};
|
||||
}
|
||||
|
||||
async function parsePluginDependencies(
|
||||
value: unknown,
|
||||
manifestUrl: string,
|
||||
options: { offline?: boolean }
|
||||
): Promise<PluginDependency[]> {
|
||||
const rawItems = Array.isArray(value)
|
||||
? value
|
||||
: isRecord(value)
|
||||
? Object.entries(value).map(([id, item]) => isRecord(item) ? { id, ...item } : { id, module: item })
|
||||
: [];
|
||||
const dependencies: PluginDependency[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const item of rawItems) {
|
||||
const dependency = await parsePluginDependency(item, manifestUrl, options);
|
||||
if (!dependency || seen.has(dependency.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(dependency.id);
|
||||
dependencies.push(dependency);
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
async function parsePluginDependency(
|
||||
value: unknown,
|
||||
manifestUrl: string,
|
||||
options: { offline?: boolean }
|
||||
): Promise<PluginDependency | undefined> {
|
||||
if (typeof value === "string") {
|
||||
const id = pluginIdValue(value);
|
||||
return id ? { id } : undefined;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const id = pluginIdValue(readString(value.id) || readString(value.key) || readString(value.name));
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const moduleValue = readString(value.moduleUrl) || readString(value.module) || readString(value.modulePath) || readString(value.path);
|
||||
const modulePath = moduleValue
|
||||
? await cachedMarketplaceModulePath(id, resolveRemoteMarketplaceUrl(moduleValue, manifestUrl), options)
|
||||
: undefined;
|
||||
const name = readString(value.name);
|
||||
return {
|
||||
id,
|
||||
...(modulePath ? { modulePath } : {}),
|
||||
...(name ? { name } : {})
|
||||
};
|
||||
}
|
||||
|
||||
async function cachedMarketplaceModulePath(
|
||||
id: string,
|
||||
moduleUrl: string,
|
||||
options: { offline?: boolean }
|
||||
): Promise<string | undefined> {
|
||||
const url = new URL(moduleUrl);
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
throw new Error(`Marketplace module must be an HTTP(S) URL: ${moduleUrl}`);
|
||||
}
|
||||
|
||||
const extension = path.extname(url.pathname).toLowerCase();
|
||||
if (![".cjs", ".js", ".mjs"].includes(extension)) {
|
||||
throw new Error(`Marketplace module must be a JavaScript file: ${moduleUrl}`);
|
||||
}
|
||||
|
||||
const file = path.join(marketplaceModuleCacheDir, `${sanitizeFileSegment(id)}-${hashString(moduleUrl)}${extension}`);
|
||||
if (existsSync(file)) {
|
||||
return file;
|
||||
}
|
||||
if (options.offline) {
|
||||
console.warn(`[plugin-marketplace] Cached module is missing while offline: ${moduleUrl}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const source = await fetchText(moduleUrl, maxMarketplaceModuleBytes);
|
||||
ensureMarketplaceCacheDir();
|
||||
writeFileSync(file, source, "utf8");
|
||||
return file;
|
||||
}
|
||||
|
||||
async function fetchText(url: string, maxBytes: number): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), marketplaceFetchTimeoutMs);
|
||||
try {
|
||||
const response = await fetchWithSystemProxy(url, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
accept: "application/json,text/plain,*/*",
|
||||
"user-agent": "ClaudeCodeRouter Plugin Marketplace"
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`);
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
throw new Error(`Response is too large: ${contentLength} bytes`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text, "utf8") > maxBytes) {
|
||||
throw new Error(`Response is too large: ${Buffer.byteLength(text, "utf8")} bytes`);
|
||||
}
|
||||
return text;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRemoteMarketplaceUrl(value: string, manifestUrl: string): string {
|
||||
try {
|
||||
return new URL(value).toString();
|
||||
} catch {
|
||||
return new URL(value, manifestUrl).toString();
|
||||
}
|
||||
}
|
||||
|
||||
function parsePluginApps(value: unknown): GatewayPluginAppConfig[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const apps = value.map(parsePluginApp).filter((item): item is GatewayPluginAppConfig => Boolean(item));
|
||||
return apps.length ? apps : undefined;
|
||||
}
|
||||
|
||||
function parsePluginApp(value: unknown): GatewayPluginAppConfig | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const name = readString(value.name) || readString(value.title);
|
||||
const url = readString(value.url) || readString(value.href) || readString(value.target);
|
||||
if (!name || !url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const description = readString(value.description);
|
||||
const icon = readString(value.icon);
|
||||
const id = pluginIdValue(readString(value.id) || name);
|
||||
return {
|
||||
...(description ? { description } : {}),
|
||||
...(icon ? { icon } : {}),
|
||||
...(id ? { id } : {}),
|
||||
name,
|
||||
url
|
||||
};
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.map(readString).filter((item): item is string => Boolean(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function pluginIdValue(value: string | undefined): string {
|
||||
return value?.toLowerCase().replace(/^@/, "").replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "";
|
||||
}
|
||||
|
||||
function sanitizeFileSegment(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "plugin";
|
||||
}
|
||||
|
||||
function hashString(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function cloneMarketplaceEntry(entry: PluginMarketplaceEntry): PluginMarketplaceEntry {
|
||||
return {
|
||||
...entry,
|
||||
apps: entry.apps?.map((app) => ({ ...app })),
|
||||
capabilities: [...entry.capabilities],
|
||||
dependencies: entry.dependencies.map((dependency) => ({ ...dependency }))
|
||||
};
|
||||
}
|
||||
|
||||
function ensureMarketplaceCacheDir(): void {
|
||||
mkdirSync(marketplaceModuleCacheDir, { recursive: true });
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -145,10 +145,6 @@ type LoadedPlugin = {
|
|||
};
|
||||
|
||||
const requireFromHere = createRequire(__filename);
|
||||
const builtInMarketplacePluginModules = new Map<string, string>([
|
||||
["claude-design", path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs")],
|
||||
["cursor-proxy", path.join(__dirname, "..", "marketplace", "plugins", "cursor-proxy-plugin.cjs")]
|
||||
]);
|
||||
|
||||
class GatewayPluginService {
|
||||
private config?: AppConfig;
|
||||
|
|
@ -297,7 +293,7 @@ class GatewayPluginService {
|
|||
this.registerProxyRoute(pluginConfig.id, route);
|
||||
}
|
||||
|
||||
const modulePath = pluginConfig.module || builtInMarketplacePluginModules.get(pluginConfig.id);
|
||||
const modulePath = pluginConfig.module;
|
||||
if (!modulePath) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { getProviderPresets } from "@ccr/core/providers/presets/index";
|
|||
import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "@ccr/core/providers/probe";
|
||||
import { applyProfileConfig } from "@ccr/core/profiles/service";
|
||||
import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "@ccr/core/profiles/launch-service";
|
||||
import { getPluginMarketplace } from "@ccr/core/plugins/marketplace";
|
||||
import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates";
|
||||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery";
|
||||
|
|
@ -49,7 +50,6 @@ import type {
|
|||
LocalAgentProviderImportRequest,
|
||||
PluginDependency,
|
||||
PluginDirectorySelection,
|
||||
PluginMarketplaceEntry,
|
||||
ProfileApplyResult,
|
||||
ProfileOpenRequest,
|
||||
ProviderAccountResetRequest,
|
||||
|
|
@ -103,24 +103,6 @@ const homeHtmlFile = path.join(staticRoot, "pages", "home", "index.html");
|
|||
const rendererAssetsRoot = path.join(staticRoot, "assets");
|
||||
const webBridgeScriptTag = ' <script src="../../assets/web-client-bridge.js"></script>';
|
||||
|
||||
const pluginMarketplace: PluginMarketplaceEntry[] = [
|
||||
{
|
||||
capabilities: ["Wrapper runtime", "Claude App proxy", "Claude Design", "Model routing"],
|
||||
dependencies: [],
|
||||
description: "Routes Claude App Design traffic through the local CCR wrapper backend with configurable model routing.",
|
||||
id: "claude-design",
|
||||
modulePath: path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs"),
|
||||
name: "Claude Design"
|
||||
},
|
||||
{
|
||||
capabilities: ["Wrapper runtime", "Proxy mode", "Cursor", "Model routing", "OpenAI/Anthropic/Gemini forwarding"],
|
||||
dependencies: [],
|
||||
description: "Routes Cursor-compatible LLM traffic captured by proxy mode into the local CCR gateway.",
|
||||
id: "cursor-proxy",
|
||||
modulePath: path.join(__dirname, "..", "marketplace", "plugins", "cursor-proxy-plugin.cjs"),
|
||||
name: "Cursor Proxy"
|
||||
}
|
||||
];
|
||||
|
||||
export async function startWebManagementServer(options: WebManagementServerOptions = {}): Promise<WebManagementServerRuntime> {
|
||||
const host = options.host?.trim() || readEnvString("CCR_WEB_HOST") || defaultWebHost;
|
||||
|
|
@ -286,7 +268,7 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
|||
}),
|
||||
getLocalAgentProviderCandidates: () => getLocalAgentProviderCandidates(),
|
||||
getOnboardingFinished: async () => Boolean(readString(await loadPersistedAppSetting(onboardingFinishedAtSettingKey)) || existsSync(ONBOARDING_FINISHED_FILE)),
|
||||
getPluginMarketplace: () => pluginMarketplace,
|
||||
getPluginMarketplace,
|
||||
getProfileOpenCommand: async (request) => getProfileOpenCommand(await loadAppConfig(), request as ProfileOpenRequest),
|
||||
getProfileRuntimeStatus: () => getProfileRuntimeStatus(),
|
||||
getProviderAccountSnapshots: (provider, options) => getProviderAccountSnapshots(provider as string | undefined, options as ProviderAccountSnapshotRequestOptions | undefined),
|
||||
|
|
@ -1010,14 +992,38 @@ function firstConfiguredBrowserAppUrl(config: AppConfig): string | undefined {
|
|||
if (plugin.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
const app = plugin.apps?.find((candidate) => readString(candidate.url));
|
||||
const app = configuredBrowserAppsForPlugin(plugin.id, plugin.apps)?.find((candidate) => readString(candidate.url));
|
||||
if (app?.url) {
|
||||
return app.url;
|
||||
return resolveGatewayBrowserAppUrl(config, app.url);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function configuredBrowserAppsForPlugin(_pluginId: string, apps: GatewayPluginAppConfig[] | undefined): GatewayPluginAppConfig[] {
|
||||
if (apps?.length) {
|
||||
return apps;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function resolveGatewayBrowserAppUrl(config: AppConfig, url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (/^https?:\/\//i.test(trimmed) || trimmed === "about:blank") {
|
||||
return trimmed;
|
||||
}
|
||||
const path = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
const host = normalizeGatewayBrowserAppHost(config.gateway?.host || config.HOST || "127.0.0.1");
|
||||
const port = config.gateway?.port || config.PORT || 3456;
|
||||
return `http://${host}:${port}${path}`;
|
||||
}
|
||||
|
||||
function normalizeGatewayBrowserAppHost(host: string): string {
|
||||
if (!host || host === "0.0.0.0") return "127.0.0.1";
|
||||
if (host === "::" || host === "[::]") return "[::1]";
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
async function revealFile(file: string): Promise<void> {
|
||||
if (process.platform === "darwin") {
|
||||
await execDetached("/usr/bin/open", ["-R", file]);
|
||||
|
|
@ -1031,7 +1037,7 @@ async function revealFile(file: string): Promise<void> {
|
|||
}
|
||||
|
||||
export function openSystemExternal(target: string): Promise<void> {
|
||||
const url = normalizeExternalHttpTarget(target);
|
||||
const url = normalizeExternalTarget(target);
|
||||
if (!url) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
|
@ -1045,6 +1051,14 @@ export function openSystemExternal(target: string): Promise<void> {
|
|||
}
|
||||
|
||||
export function normalizeExternalHttpTarget(target: unknown): string | undefined {
|
||||
const url = normalizeExternalTarget(target);
|
||||
if (!url?.startsWith("http://") && !url?.startsWith("https://")) {
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function normalizeExternalTarget(target: unknown): string | undefined {
|
||||
const trimmed = typeof target === "string" ? target.trim() : "";
|
||||
if (!trimmed || trimmed === "about:blank") {
|
||||
return undefined;
|
||||
|
|
@ -1055,10 +1069,13 @@ export function normalizeExternalHttpTarget(target: unknown): string | undefined
|
|||
} catch {
|
||||
throw new Error("External URL must be a valid absolute URL.");
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("Only http and https URLs can be opened.");
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
return url.toString();
|
||||
}
|
||||
return url.toString();
|
||||
if (url.protocol === "ccr:" && url.hostname.toLowerCase() === "plugin") {
|
||||
return url.toString();
|
||||
}
|
||||
throw new Error("Only http, https, and CCR plugin URLs can be opened.");
|
||||
}
|
||||
|
||||
function execDetached(command: string, args: string[]): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -965,8 +965,8 @@ function resolveInstalledBrowserApps(config: AppConfig, runtimeApps: InstalledBr
|
|||
if (plugin.id === "claude-design") {
|
||||
continue;
|
||||
}
|
||||
for (const app of plugin.apps ?? []) {
|
||||
const normalized = normalizeConfiguredBrowserApp(plugin.id, app, apps.size + 1);
|
||||
for (const app of configuredBrowserAppsForPlugin(plugin.id, plugin.apps)) {
|
||||
const normalized = normalizeConfiguredBrowserApp(config, plugin.id, app, apps.size + 1);
|
||||
if (normalized) {
|
||||
apps.set(`${normalized.pluginId}:${normalized.id}`, normalized);
|
||||
}
|
||||
|
|
@ -981,7 +981,14 @@ function resolveInstalledBrowserApps(config: AppConfig, runtimeApps: InstalledBr
|
|||
return [...apps.values()];
|
||||
}
|
||||
|
||||
function normalizeConfiguredBrowserApp(pluginId: string, app: GatewayPluginAppConfig, index: number): InstalledBrowserApp | undefined {
|
||||
function configuredBrowserAppsForPlugin(_pluginId: string, apps: GatewayPluginAppConfig[] | undefined): GatewayPluginAppConfig[] {
|
||||
if (apps?.length) {
|
||||
return apps;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeConfiguredBrowserApp(config: AppConfig, pluginId: string, app: GatewayPluginAppConfig, index: number): InstalledBrowserApp | undefined {
|
||||
const name = app.name?.trim();
|
||||
const url = app.url?.trim();
|
||||
if (!name || !url) {
|
||||
|
|
@ -994,10 +1001,27 @@ function normalizeConfiguredBrowserApp(pluginId: string, app: GatewayPluginAppCo
|
|||
id: app.id?.trim() || sanitizeBrowserAppId(`${name}-${url}`) || `app-${index}`,
|
||||
name,
|
||||
pluginId,
|
||||
url
|
||||
url: resolveGatewayBrowserAppUrl(config, url)
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGatewayBrowserAppUrl(config: AppConfig, url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (/^https?:\/\//i.test(trimmed) || trimmed === "about:blank") {
|
||||
return trimmed;
|
||||
}
|
||||
const path = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
const host = normalizeGatewayBrowserAppHost(config.gateway?.host || config.HOST || "127.0.0.1");
|
||||
const port = config.gateway?.port || config.PORT || 3456;
|
||||
return `http://${host}:${port}${path}`;
|
||||
}
|
||||
|
||||
function normalizeGatewayBrowserAppHost(host: string): string {
|
||||
if (!host || host === "0.0.0.0") return "127.0.0.1";
|
||||
if (host === "::" || host === "[::]") return "[::1]";
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,29 @@
|
|||
import { app } from "electron";
|
||||
import { app, dialog } from "electron";
|
||||
import path from "node:path";
|
||||
import { appDeepLinkProtocol, createProviderDeepLinkRequest as createSharedProviderDeepLinkRequest, isAppDeepLinkUrl } from "@ccr/core/contracts/deep-link";
|
||||
import type { ProviderDeepLinkRequest } from "@ccr/core/contracts/app";
|
||||
import type { AppConfig, GatewayPluginAppConfig, ProviderDeepLinkRequest } from "@ccr/core/contracts/app";
|
||||
import { IPC_CHANNELS } from "@ccr/core/config/constants";
|
||||
import { loadAppConfig } from "@ccr/core/config/config";
|
||||
import { syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
|
||||
import { gatewayService } from "@ccr/core/gateway/service";
|
||||
import { providerIdentitySafetyIssue } from "@ccr/core/providers/presets/index";
|
||||
import windowsManager from "./windows";
|
||||
|
||||
type PluginDeepLinkRequest = {
|
||||
appId?: string;
|
||||
pluginId: string;
|
||||
rawUrl: string;
|
||||
};
|
||||
|
||||
const pluginAppExistingGatewayProbeTimeoutMs = 2_000;
|
||||
const pluginAppStartupProbeTimeoutMs = 12_000;
|
||||
const pluginAppProbeIntervalMs = 250;
|
||||
const pluginAppProbeRequestTimeoutMs = 1_200;
|
||||
|
||||
class DeepLinkService {
|
||||
private pendingProviderRequests: ProviderDeepLinkRequest[] = [];
|
||||
private pendingPluginRequests: PluginDeepLinkRequest[] = [];
|
||||
private openingPluginRequests = new Set<string>();
|
||||
|
||||
register(): void {
|
||||
this.registerProtocolClient();
|
||||
|
|
@ -16,6 +32,8 @@ class DeepLinkService {
|
|||
event.preventDefault();
|
||||
this.handleUrl(url);
|
||||
});
|
||||
|
||||
void app.whenReady().then(() => this.flushPendingPluginRequests());
|
||||
}
|
||||
|
||||
consumePendingProviderRequests(): ProviderDeepLinkRequest[] {
|
||||
|
|
@ -33,6 +51,26 @@ class DeepLinkService {
|
|||
}
|
||||
|
||||
handleUrl(url: string): void {
|
||||
let pluginRequest: PluginDeepLinkRequest | undefined;
|
||||
try {
|
||||
pluginRequest = parsePluginDeepLinkRequest(url);
|
||||
} catch (error) {
|
||||
const detail = formatError(error);
|
||||
console.error(`[deep-link] Invalid plugin link ${url}: ${detail}`);
|
||||
if (app.isReady()) {
|
||||
try {
|
||||
dialog.showErrorBox("Invalid CCR plugin link", detail);
|
||||
} catch {
|
||||
// The console error above remains available when the dialog API is unavailable.
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pluginRequest) {
|
||||
this.handlePluginRequest(pluginRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = createProviderDeepLinkRequest(url);
|
||||
this.pendingProviderRequests.push(request);
|
||||
if (this.pendingProviderRequests.length > 20) {
|
||||
|
|
@ -47,6 +85,75 @@ class DeepLinkService {
|
|||
windowsManager.broadcast(IPC_CHANNELS.appProviderDeepLink, request);
|
||||
}
|
||||
|
||||
openPluginApp(pluginId: string, appId?: string): Promise<void> {
|
||||
return this.openPluginRequest({
|
||||
...(appId ? { appId } : {}),
|
||||
pluginId,
|
||||
rawUrl: `ccr://plugin/${encodeURIComponent(pluginId)}/open`
|
||||
});
|
||||
}
|
||||
|
||||
private handlePluginRequest(request: PluginDeepLinkRequest): void {
|
||||
if (!app.isReady()) {
|
||||
this.pendingPluginRequests.push(request);
|
||||
this.pendingPluginRequests = this.pendingPluginRequests.slice(-20);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.openPluginRequest(request);
|
||||
}
|
||||
|
||||
private flushPendingPluginRequests(): void {
|
||||
const requests = [...this.pendingPluginRequests];
|
||||
this.pendingPluginRequests = [];
|
||||
for (const request of requests) {
|
||||
void this.openPluginRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
private async openPluginRequest(request: PluginDeepLinkRequest): Promise<void> {
|
||||
const requestKey = `${request.pluginId}:${request.appId || ""}`;
|
||||
if (this.openingPluginRequests.has(requestKey)) {
|
||||
return;
|
||||
}
|
||||
this.openingPluginRequests.add(requestKey);
|
||||
|
||||
try {
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const pluginApp = resolvePluginApp(config, request);
|
||||
if (!pluginApp) {
|
||||
throw new Error(`Plugin app is not configured or enabled: ${request.pluginId}`);
|
||||
}
|
||||
|
||||
const currentStatus = gatewayService.getStatus();
|
||||
const startedGateway = currentStatus.state !== "running";
|
||||
const status = startedGateway ? await gatewayService.start(config) : currentStatus;
|
||||
if (status.state !== "running") {
|
||||
throw new Error(status.lastError || "CCR gateway did not start.");
|
||||
}
|
||||
const appUrl = resolveGatewayPluginAppUrl(config, pluginApp.url);
|
||||
await ensurePluginAppUrlAvailable(config, appUrl, startedGateway);
|
||||
|
||||
windowsManager.openPluginAppWindow({
|
||||
id: `${request.pluginId}:${pluginApp.id || pluginApp.name}`,
|
||||
title: pluginApp.name,
|
||||
url: appUrl
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = formatError(error);
|
||||
console.error(`[deep-link] Failed to open plugin app from ${request.rawUrl}: ${detail}`);
|
||||
try {
|
||||
dialog.showErrorBox("Failed to open CCR plugin app", detail);
|
||||
} catch {
|
||||
// The console error above remains available when the dialog API is unavailable.
|
||||
}
|
||||
windowsManager.showMainWindow();
|
||||
} finally {
|
||||
this.openingPluginRequests.delete(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
private registerProtocolClient(): void {
|
||||
try {
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
|
|
@ -60,6 +167,170 @@ class DeepLinkService {
|
|||
}
|
||||
}
|
||||
|
||||
async function ensurePluginAppUrlAvailable(config: AppConfig, appUrl: string, startedGateway: boolean): Promise<void> {
|
||||
try {
|
||||
await waitForPluginAppUrl(
|
||||
appUrl,
|
||||
startedGateway ? pluginAppStartupProbeTimeoutMs : pluginAppExistingGatewayProbeTimeoutMs
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (startedGateway) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(`[deep-link] Plugin app URL was not reachable on the running gateway; restarting gateway once. ${formatError(error)}`);
|
||||
}
|
||||
|
||||
const restartedStatus = await gatewayService.start(config);
|
||||
if (restartedStatus.state !== "running") {
|
||||
throw new Error(restartedStatus.lastError || "CCR gateway did not restart.");
|
||||
}
|
||||
await waitForPluginAppUrl(appUrl, pluginAppStartupProbeTimeoutMs);
|
||||
}
|
||||
|
||||
async function waitForPluginAppUrl(appUrl: string, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastProbe = "timeout";
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
const probe = await probePluginAppUrl(appUrl);
|
||||
if (probe.ok) {
|
||||
return;
|
||||
}
|
||||
lastProbe = probe.detail;
|
||||
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
await delay(Math.min(pluginAppProbeIntervalMs, remainingMs));
|
||||
}
|
||||
|
||||
throw new Error(`Plugin app did not become available at ${appUrl}. Last probe: ${lastProbe}`);
|
||||
}
|
||||
|
||||
async function probePluginAppUrl(appUrl: string): Promise<{ ok: true } | { detail: string; ok: false }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), pluginAppProbeRequestTimeoutMs);
|
||||
try {
|
||||
const response = await fetch(appUrl, {
|
||||
cache: "no-store",
|
||||
method: "HEAD",
|
||||
signal: controller.signal
|
||||
});
|
||||
if (response.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {
|
||||
detail: `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`,
|
||||
ok: false
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
detail: formatError(error),
|
||||
ok: false
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parsePluginDeepLinkRequest(rawUrl: string): PluginDeepLinkRequest | undefined {
|
||||
const url = new URL(rawUrl.trim());
|
||||
if (url.protocol !== `${appDeepLinkProtocol}:`) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const host = url.hostname.toLowerCase();
|
||||
const pathSegments = url.pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment));
|
||||
const isPluginHost = host === "plugin";
|
||||
const isPluginPath = pathSegments[0]?.toLowerCase() === "plugin";
|
||||
if (!isPluginHost && !isPluginPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pluginId = boundedPluginId(
|
||||
url.searchParams.get("plugin") ||
|
||||
url.searchParams.get("pluginId") ||
|
||||
(isPluginHost ? pathSegments[0] : pathSegments[1])
|
||||
);
|
||||
const action = (isPluginHost ? pathSegments[1] : pathSegments[2])?.toLowerCase();
|
||||
if (action && action !== "open") {
|
||||
throw new Error(`Unsupported plugin link action: ${action}`);
|
||||
}
|
||||
|
||||
const appId = boundedPluginId(url.searchParams.get("app") || url.searchParams.get("appId"), true);
|
||||
return {
|
||||
...(appId ? { appId } : {}),
|
||||
pluginId,
|
||||
rawUrl
|
||||
};
|
||||
}
|
||||
|
||||
function boundedPluginId(value: string | null | undefined, optional = false): string {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) {
|
||||
if (optional) {
|
||||
return "";
|
||||
}
|
||||
throw new Error("Plugin id is required.");
|
||||
}
|
||||
if (normalized.length > 120 || !/^[a-z0-9][a-z0-9._-]*$/i.test(normalized)) {
|
||||
throw new Error(`Invalid plugin id: ${normalized}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolvePluginApp(config: AppConfig, request: PluginDeepLinkRequest): GatewayPluginAppConfig | undefined {
|
||||
const plugin = config.plugins.find((candidate) => candidate.enabled !== false && candidate.id === request.pluginId);
|
||||
if (!plugin) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const apps = configuredPluginApps(plugin.id, plugin.apps);
|
||||
if (!apps.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (request.appId) {
|
||||
return apps.find((app) => (app.id || app.name) === request.appId);
|
||||
}
|
||||
return apps[0];
|
||||
}
|
||||
|
||||
function configuredPluginApps(_pluginId: string, apps: GatewayPluginAppConfig[] | undefined): GatewayPluginAppConfig[] {
|
||||
if (apps?.length) {
|
||||
return apps;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function resolveGatewayPluginAppUrl(config: AppConfig, url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
const urlPath = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
const host = normalizeGatewayPluginAppHost(config.gateway?.host || config.HOST || "127.0.0.1");
|
||||
const port = config.gateway?.port || config.PORT || 3456;
|
||||
return `http://${host}:${port}${urlPath}`;
|
||||
}
|
||||
|
||||
function normalizeGatewayPluginAppHost(host: string): string {
|
||||
const trimmed = host.trim();
|
||||
if (!trimmed || trimmed === "0.0.0.0" || trimmed === "::") {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
if (trimmed.includes(":") && !trimmed.startsWith("[")) {
|
||||
return `[${trimmed}]`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function createProviderDeepLinkRequest(rawUrl: string): ProviderDeepLinkRequest {
|
||||
const request = createSharedProviderDeepLinkRequest(rawUrl);
|
||||
if (!request.provider) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { getProviderPresets } from "@ccr/core/providers/presets/index";
|
|||
import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "@ccr/core/providers/probe";
|
||||
import { applyProfileConfig } from "@ccr/core/profiles/service";
|
||||
import { desktopCliCommandName, getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "@ccr/core/profiles/launch-service";
|
||||
import { getPluginMarketplace } from "@ccr/core/plugins/marketplace";
|
||||
import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates";
|
||||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery";
|
||||
|
|
@ -32,26 +33,7 @@ import trayController from "./tray-controller";
|
|||
import { appUpdateService } from "./update-service";
|
||||
import { getUsageStats } from "@ccr/core/usage/store";
|
||||
import windowsManager from "./windows";
|
||||
import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppCaptureElementPngRequest, AppCaptureElementPngResult, AppConfig, AppDataExportResult, AppImageExportTargetRequest, AppImageExportTargetResult, AppInfo, AppRenderHtmlPngRequest, AppRenderHtmlPngResult, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountResetRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "@ccr/core/contracts/app";
|
||||
|
||||
const pluginMarketplace: PluginMarketplaceEntry[] = [
|
||||
{
|
||||
capabilities: ["Wrapper runtime", "Claude App proxy", "Claude Design", "Model routing"],
|
||||
dependencies: [],
|
||||
description: "Routes Claude App Design traffic through the local CCR wrapper backend with configurable model routing.",
|
||||
id: "claude-design",
|
||||
modulePath: path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs"),
|
||||
name: "Claude Design"
|
||||
},
|
||||
{
|
||||
capabilities: ["Wrapper runtime", "Proxy mode", "Cursor", "Model routing", "OpenAI/Anthropic/Gemini forwarding"],
|
||||
dependencies: [],
|
||||
description: "Routes Cursor-compatible LLM traffic captured by proxy mode into the local CCR gateway.",
|
||||
id: "cursor-proxy",
|
||||
modulePath: path.join(__dirname, "..", "marketplace", "plugins", "cursor-proxy-plugin.cjs"),
|
||||
name: "Cursor Proxy"
|
||||
}
|
||||
];
|
||||
import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppCaptureElementPngRequest, AppCaptureElementPngResult, AppConfig, AppDataExportResult, AppImageExportTargetRequest, AppImageExportTargetResult, AppInfo, AppRenderHtmlPngRequest, AppRenderHtmlPngResult, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, ProfileApplyResult, ProfileOpenRequest, ProviderAccountResetRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "@ccr/core/contracts/app";
|
||||
const onboardingFinishedAtSettingKey = "onboardingFinishedAt";
|
||||
const imageExportTargets = new Map<string, string>();
|
||||
|
||||
|
|
@ -110,7 +92,7 @@ ipcMain.handle(IPC_CHANNELS.appGetGatewayStatus, () => gatewayService.getStatus(
|
|||
ipcMain.handle(IPC_CHANNELS.appGetProxyCertificateStatus, () => proxyService.getCertificateStatus());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetProxyNetworkCaptures, () => proxyService.getNetworkCaptures());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetProxyStatus, () => proxyService.getStatus());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetPluginMarketplace, () => pluginMarketplace);
|
||||
ipcMain.handle(IPC_CHANNELS.appGetPluginMarketplace, () => getPluginMarketplace());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetRequestLogDetail, (_event, request) => getRequestLogDetail(request));
|
||||
ipcMain.handle(IPC_CHANNELS.appGetRequestLogs, (_event, filter?: RequestLogListFilter) => getRequestLogs(filter));
|
||||
ipcMain.handle(IPC_CHANNELS.appGetUpdateStatus, () => appUpdateService.getStatus());
|
||||
|
|
@ -132,6 +114,10 @@ ipcMain.handle(IPC_CHANNELS.appListMcpServerTools, async (_event, serverName: st
|
|||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appOpenBuiltInBrowser, async () => {
|
||||
const config = await loadAppConfig();
|
||||
if (hasEnabledPluginApp(config, "agent-console")) {
|
||||
await deepLinkService.openPluginApp("agent-console");
|
||||
return;
|
||||
}
|
||||
await builtInBrowserService.open(config);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appCloseTray, () => {
|
||||
|
|
@ -1171,6 +1157,14 @@ function isFile(file: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function hasEnabledPluginApp(config: AppConfig, pluginId: string): boolean {
|
||||
return config.plugins.some((plugin) =>
|
||||
plugin.id === pluginId &&
|
||||
plugin.enabled !== false &&
|
||||
plugin.apps?.some((app) => typeof app.url === "string" && app.url.trim())
|
||||
);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { app, BrowserWindow, screen } from "electron";
|
||||
import { app, BrowserWindow, screen, shell } from "electron";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
|
@ -6,6 +6,11 @@ import { APP_NAME, IPC_CHANNELS, ONBOARDING_FINISHED_FILE } from "@ccr/core/conf
|
|||
|
||||
type WindowName = "main" | string;
|
||||
type WindowBounds = { height: number; width: number; x?: number; y?: number };
|
||||
type PluginAppWindowOptions = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const titleBarHeight = 46;
|
||||
const mainWindowDefaultHeight = 760;
|
||||
|
|
@ -13,6 +18,14 @@ const mainWindowDefaultWidth = 1180;
|
|||
const mainWindowMargin = 48;
|
||||
const mainWindowMinHeight = 420;
|
||||
const mainWindowMinWidth = 360;
|
||||
const pluginAppWindowDefaultHeight = 760;
|
||||
const pluginAppWindowDefaultWidth = 1180;
|
||||
const pluginAppWindowMinHeight = 520;
|
||||
const pluginAppWindowMinWidth = 760;
|
||||
const pluginSmallWindowDefaultHeight = 640;
|
||||
const pluginSmallWindowDefaultWidth = 420;
|
||||
const pluginSmallWindowMinHeight = 460;
|
||||
const pluginSmallWindowMinWidth = 360;
|
||||
|
||||
class WindowsManager {
|
||||
private windows = new Map<WindowName, BrowserWindow>();
|
||||
|
|
@ -88,6 +101,135 @@ class WindowsManager {
|
|||
return window;
|
||||
}
|
||||
|
||||
openPluginAppWindow(options: PluginAppWindowOptions): BrowserWindow {
|
||||
const windowName = `plugin-app:${options.id}`;
|
||||
const existing = this.getWindow(windowName);
|
||||
if (existing) {
|
||||
existing.setMinimumSize(pluginAppWindowMinWidth, pluginAppWindowMinHeight);
|
||||
const [width, height] = existing.getSize();
|
||||
if (width < pluginAppWindowMinWidth || height < pluginAppWindowMinHeight) {
|
||||
existing.setBounds(getPluginAppWindowInitialBounds());
|
||||
}
|
||||
if (existing.isMinimized()) {
|
||||
existing.restore();
|
||||
}
|
||||
existing.show();
|
||||
existing.focus();
|
||||
if (existing.webContents.getURL() !== options.url) {
|
||||
void existing.loadURL(options.url);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const bounds = getPluginAppWindowInitialBounds();
|
||||
const window = new BrowserWindow({
|
||||
...bounds,
|
||||
minHeight: pluginAppWindowMinHeight,
|
||||
minWidth: pluginAppWindowMinWidth,
|
||||
show: false,
|
||||
title: options.title,
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hiddenInset" as const,
|
||||
trafficLightPosition: {
|
||||
x: 16,
|
||||
y: Math.round((titleBarHeight - 14) / 2)
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
}
|
||||
});
|
||||
|
||||
this.windows.set(windowName, window);
|
||||
|
||||
let loadingFailurePage = false;
|
||||
window.once("ready-to-show", () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.show();
|
||||
}
|
||||
});
|
||||
window.on("closed", () => this.windows.delete(windowName));
|
||||
window.webContents.on("page-title-updated", (_event, title) => {
|
||||
window.setTitle(title || options.title);
|
||||
});
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (isPluginSmallWindowUrl(options.url, url)) {
|
||||
return {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
...getPluginSmallWindowBounds(window),
|
||||
alwaysOnTop: false,
|
||||
backgroundColor: "#00000000",
|
||||
frame: false,
|
||||
fullscreenable: false,
|
||||
maximizable: false,
|
||||
minHeight: pluginSmallWindowMinHeight,
|
||||
minWidth: pluginSmallWindowMinWidth,
|
||||
resizable: true,
|
||||
title: `${options.title} Chat`,
|
||||
transparent: true,
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
vibrancy: "fullscreen-ui" as const,
|
||||
visualEffectState: "active" as const
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
void shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
window.webContents.on("did-create-window", (childWindow, details) => {
|
||||
configurePluginChildWindow(childWindow, {
|
||||
parentUrl: options.url,
|
||||
title: details.options.title || options.title,
|
||||
url: details.url
|
||||
});
|
||||
});
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
if (loadingFailurePage && url.startsWith("data:text/html")) {
|
||||
return;
|
||||
}
|
||||
if (isSameOrigin(options.url, url)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void shell.openExternal(url);
|
||||
});
|
||||
window.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
if (isMainFrame === false || window.isDestroyed() || loadingFailurePage) {
|
||||
return;
|
||||
}
|
||||
loadingFailurePage = true;
|
||||
void window.loadURL(pluginAppLoadFailurePageUrl({
|
||||
errorCode,
|
||||
errorDescription,
|
||||
title: options.title,
|
||||
url: validatedURL || options.url
|
||||
})).finally(() => {
|
||||
loadingFailurePage = false;
|
||||
if (!window.isDestroyed()) {
|
||||
window.show();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
void window.loadURL(options.url);
|
||||
return window;
|
||||
}
|
||||
|
||||
resizeMainWindowToScreenSize(): void {
|
||||
const window = this.getWindow("main");
|
||||
if (!window) {
|
||||
|
|
@ -160,3 +302,208 @@ function getMainWindowScreenBounds(): Required<WindowBounds> {
|
|||
y: workArea.y
|
||||
};
|
||||
}
|
||||
|
||||
function getPluginAppWindowInitialBounds(): WindowBounds {
|
||||
const { height: availableHeight, width: availableWidth } = screen.getPrimaryDisplay().workAreaSize;
|
||||
return {
|
||||
height: fitWindowSize(pluginAppWindowDefaultHeight, pluginAppWindowMinHeight, availableHeight - mainWindowMargin),
|
||||
width: fitWindowSize(pluginAppWindowDefaultWidth, pluginAppWindowMinWidth, availableWidth - mainWindowMargin)
|
||||
};
|
||||
}
|
||||
|
||||
function getPluginSmallWindowBounds(parentWindow: BrowserWindow): Required<WindowBounds> {
|
||||
const parentBounds = parentWindow.getBounds();
|
||||
const { workArea } = screen.getDisplayMatching(parentBounds);
|
||||
const width = fitWindowSize(pluginSmallWindowDefaultWidth, pluginSmallWindowMinWidth, workArea.width - mainWindowMargin);
|
||||
const height = fitWindowSize(pluginSmallWindowDefaultHeight, pluginSmallWindowMinHeight, workArea.height - mainWindowMargin);
|
||||
const x = clampNumber(parentBounds.x + parentBounds.width - width - 32, workArea.x + 8, workArea.x + workArea.width - width - 8);
|
||||
const y = clampNumber(parentBounds.y + 72, workArea.y + 8, workArea.y + workArea.height - height - 8);
|
||||
return { height, width, x, y };
|
||||
}
|
||||
|
||||
function configurePluginChildWindow(
|
||||
window: BrowserWindow,
|
||||
options: {
|
||||
parentUrl: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
): void {
|
||||
window.webContents.on("page-title-updated", (_event, title) => {
|
||||
window.setTitle(title || options.title);
|
||||
});
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (handlePluginChildWindowControl(window, url)) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
void shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
if (isSameOrigin(options.parentUrl, url) && isSamePluginRoute(options.parentUrl, url)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void shell.openExternal(url);
|
||||
});
|
||||
}
|
||||
|
||||
function isPluginSmallWindowUrl(parentUrl: string, targetUrl: string): boolean {
|
||||
try {
|
||||
const target = new URL(targetUrl);
|
||||
return (
|
||||
isSameOrigin(parentUrl, targetUrl) &&
|
||||
isSamePluginRoute(parentUrl, targetUrl) &&
|
||||
target.searchParams.get("mode") === "small-chat"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePluginChildWindowControl(window: BrowserWindow, targetUrl: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(targetUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol !== "ccr-plugin-window:") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.hostname === "set-pinned") {
|
||||
const pinned = url.searchParams.get("pinned") === "1" || url.searchParams.get("pinned") === "true";
|
||||
window.setAlwaysOnTop(pinned, pinned ? "floating" : "normal");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.hostname === "close") {
|
||||
window.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSamePluginRoute(parentUrl: string, targetUrl: string): boolean {
|
||||
const routePrefix = pluginRoutePrefix(parentUrl);
|
||||
if (!routePrefix) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(targetUrl).pathname.startsWith(routePrefix);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pluginRoutePrefix(url: string): string | undefined {
|
||||
try {
|
||||
const segments = new URL(url).pathname.split("/").filter(Boolean);
|
||||
if (segments[0] !== "plugins" || !segments[1]) {
|
||||
return undefined;
|
||||
}
|
||||
return `/${segments[0]}/${segments[1]}/`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isSameOrigin(baseUrl: string, targetUrl: string): boolean {
|
||||
try {
|
||||
const base = new URL(baseUrl);
|
||||
const target = new URL(targetUrl);
|
||||
return base.protocol === target.protocol && base.host === target.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
|
||||
function pluginAppLoadFailurePageUrl(options: {
|
||||
errorCode: number;
|
||||
errorDescription: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}): string {
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(options.title)} failed to load</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
body {
|
||||
align-items: center;
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
max-width: 360px;
|
||||
padding: 28px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
p {
|
||||
color: color-mix(in srgb, CanvasText 72%, transparent);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
code {
|
||||
background: color-mix(in srgb, CanvasText 8%, transparent);
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
margin: 0 0 18px;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
a {
|
||||
color: LinkText;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${escapeHtml(options.title)} could not load</h1>
|
||||
<p>CCR could not reach the plugin page. Check that CCR is running and the plugin is enabled, then retry.</p>
|
||||
<code>${escapeHtml(options.errorDescription || `Load failed with code ${options.errorCode}`)}<br>${escapeHtml(options.url)}</code>
|
||||
<a href="${escapeHtml(options.url)}">Retry</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue