Refactor plugin marketplace loading and browser app URLs

This commit is contained in:
musistudio 2026-07-08 15:42:50 +08:00
parent bc872a29b3
commit a400111f8a
13 changed files with 2584 additions and 71 deletions

View file

@ -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() {

View file

@ -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

View file

@ -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
View 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"
}
]
}

File diff suppressed because it is too large Load diff

View 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"
}
]
}

View 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);
}

View file

@ -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;
}

View file

@ -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> {

View file

@ -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);
}

View file

@ -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) {

View file

@ -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);
}

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}