Add Claude App profile opening and HTML PNG export

This commit is contained in:
musistudio
2026-06-30 17:45:19 +08:00
parent 83ea5d8b5b
commit 98375b7f4e
10 changed files with 302 additions and 44 deletions
+45 -13
View File
@@ -2,14 +2,16 @@
import { spawn, spawnSync } from "node:child_process";
import { accessSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { assertAvailableGatewayModels, type ProfileOpenSurface } from "../shared/app";
import { assertAvailableGatewayModels, type ProfileConfig, type ProfileOpenSurface } from "../shared/app";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service";
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch";
import { launchCodexAppProfile, launchZcodeAppProfile } from "./codex-app-launch";
import { loadAppConfig } from "./config";
import { CONFIGDIR } from "./constants";
import { applyProfileConfig, applyProfileRuntimeConfig } from "./profile-service";
import { ensureProfileGateway } from "./profile-launch-service";
import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "./profile-launch-core";
import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "./profile-launch-core";
import { startWebManagementServer } from "./web-management-server";
type ProfileCliOptions = {
@@ -93,30 +95,50 @@ async function main(): Promise<void> {
assertAvailableGatewayModels(config);
await applyProfileConfig(config);
const profile = findProfileForOpen(config, profileOptions.profileRef);
const surface = profileOptions.surface ?? (profile.agent === "zcode" || profile.surface === "app" ? "app" : "cli");
const surface = profileOptions.surface ?? defaultProfileOpenSurface(profile);
const resolvedSurface = resolveProfileOpenSurface(profile, surface);
const launchConfig = resolvedSurface === "cli"
? await ensureProfileGateway(config, profile, profile.name || profile.id || "profile", { reuseExisting: true, startIfMissing: false })
: config;
if (profile.agent === "zcode" && profileOptions.agentArgs.length > 0) {
throw new Error("ZCode profiles can only open the app; agent arguments are not supported.");
}
if (profile.agent === "claude-code" && resolvedSurface === "app" && profileOptions.agentArgs.length > 0) {
throw new Error("Claude App profiles do not support agent arguments.");
}
const launchConfig = await ensureProfileGateway(config, profile, resolvedSurface === "app" ? profileAppName(profile) : profile.name || profile.id || "profile", {
reuseExisting: true,
startIfMissing: false
});
if (resolvedSurface === "cli") {
const runtimeResult = applyProfileRuntimeConfig(launchConfig, profile, launchConfig.APIKEY);
if (!runtimeResult.ok) {
throw new Error(runtimeResult.message);
}
}
if (profile.agent === "zcode" && profileOptions.agentArgs.length > 0) {
throw new Error("ZCode profiles can only open the app; agent arguments are not supported.");
if (profile.agent === "claude-code" && resolvedSurface === "app") {
applyClaudeAppGatewayConfig(launchConfig);
applyClaudeAppGatewayConfig(launchConfig, {
backup: false,
dataDir: resolveClaudeAppProfileUserDataDir(configDir, profile),
refreshModelDiscoveryCache: true
});
const launch = await launchClaudeAppProfile(configDir, profile, launchConfig);
const spawnError = await waitForImmediateSpawnError(launch.child, 500);
if (spawnError) {
throw new Error(`Failed to open Claude App: ${spawnError}`);
}
process.stdout.write(`Opened Claude App with ${profile.name || profile.id}.\n`);
return;
}
if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) {
if (profile.agent === "zcode") {
const launch = launchZcodeAppProfile(configDir, profile, config);
const launch = launchZcodeAppProfile(configDir, profile, launchConfig);
const spawnError = await waitForImmediateSpawnError(launch.child, 500);
if (spawnError) {
throw new Error(`Failed to open ZCode App: ${spawnError}`);
}
process.stdout.write(`Opened ZCode App with ${profile.name || profile.id}.\n`);
} else {
const launch = launchCodexAppProfile(configDir, profile, config);
const launch = launchCodexAppProfile(configDir, profile, launchConfig);
const spawnError = await waitForImmediateSpawnError(launch.child, 500);
if (spawnError) {
throw new Error(`Failed to open Codex App: ${spawnError}`);
@@ -197,6 +219,16 @@ function parseArgs(args: string[]): CliOptions {
return options;
}
function profileAppName(profile: Pick<ProfileConfig, "agent">): string {
if (profile.agent === "claude-code") {
return "Claude App";
}
if (profile.agent === "zcode") {
return "ZCode App";
}
return "Codex App";
}
function parseStopArgs(args: string[]): StopCliOptions {
const options: StopCliOptions = {
command: "stop",
@@ -376,13 +408,13 @@ function printHelp(exitCode: number): void {
"Usage:",
" ccr start [--host <host>] [--port <port>] [--open] [--no-gateway]",
" ccr stop",
" ccr <profile-name-or-id> <cli|app> [-- <agent args>]",
" ccr <profile-name-or-id> [cli|app] [-- <agent args>]",
"",
"Examples:",
" ccr start",
" ccr stop",
" ccr Codex cli",
" ccr default-codex cli -- --model gpt-5-codex",
" ccr Codex",
" ccr default-codex -- --model gpt-5-codex",
" ccr default-codex app"
].join("\n");
const stream = exitCode === 0 ? process.stdout : process.stderr;
+124 -8
View File
@@ -1,6 +1,6 @@
import { app, BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions, type Rectangle, type SaveDialogOptions } from "electron";
import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell, type OpenDialogOptions, type Rectangle, type SaveDialogOptions } from "electron";
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import net from "node:net";
import path from "node:path";
import { deflateSync, inflateSync } from "node:zlib";
@@ -31,7 +31,7 @@ import trayController from "./tray-controller";
import { appUpdateService } from "./update-service";
import { getUsageStats } from "./usage-store";
import windowsManager from "./windows";
import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppCaptureElementPngRequest, AppCaptureElementPngResult, AppConfig, AppDataExportResult, AppImageExportTargetRequest, AppImageExportTargetResult, AppInfo, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
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, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
const pluginMarketplace: PluginMarketplaceEntry[] = [
{
@@ -79,6 +79,9 @@ ipcMain.handle(IPC_CHANNELS.appCaptureElementPng, async (event, request: AppCapt
ipcMain.handle(IPC_CHANNELS.appPrepareImageExportTarget, async (event, request: AppImageExportTargetRequest): Promise<AppImageExportTargetResult> => {
return prepareImageExportTarget(BrowserWindow.fromWebContents(event.sender), request);
});
ipcMain.handle(IPC_CHANNELS.appRenderHtmlPng, async (event, request: AppRenderHtmlPngRequest): Promise<AppRenderHtmlPngResult> => {
return renderHtmlPng(BrowserWindow.fromWebContents(event.sender), request);
});
ipcMain.handle(IPC_CHANNELS.appGetConfig, () => loadAppConfig());
ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, async () => {
@@ -554,10 +557,7 @@ async function captureElementPng(window: BrowserWindow | null, request: AppCaptu
}
const rect = sanitizeCaptureRect(request.rect);
const targetFile = request.exportId ? consumeImageExportTarget(request.exportId) : undefined;
const result = targetFile
? { canceled: false, filePath: targetFile }
: await dialog.showSaveDialog(window, shareCardSaveDialogOptions(request.fileName));
const result = await imageExportFile(window, request.fileName, request.exportId);
if (result.canceled || !result.filePath) {
return { canceled: true };
}
@@ -568,6 +568,62 @@ async function captureElementPng(window: BrowserWindow | null, request: AppCaptu
return { canceled: false, file: result.filePath };
}
async function renderHtmlPng(window: BrowserWindow | null, request: AppRenderHtmlPngRequest): Promise<AppRenderHtmlPngResult> {
const html = typeof request.html === "string" ? request.html : "";
if (!html.trim()) {
throw new Error("Export HTML is empty.");
}
const size = sanitizeRenderSize(request.size);
const result = await imageExportFile(window, request.fileName, request.exportId);
if (result.canceled || !result.filePath) {
return { canceled: true };
}
const renderWindow = new BrowserWindow({
backgroundColor: "#00000000",
frame: false,
height: size.height,
paintWhenInitiallyHidden: true,
resizable: false,
show: false,
skipTaskbar: true,
transparent: true,
useContentSize: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
nodeIntegration: false,
offscreen: true,
sandbox: true
},
width: size.width
});
const tempDir = mkdtempSync(path.join(app.getPath("temp"), "ccr-export-"));
const tempHtmlFile = path.join(tempDir, "export.html");
writeFileSync(tempHtmlFile, html, { encoding: "utf8", mode: 0o600 });
try {
await renderWindow.loadFile(tempHtmlFile);
await waitForExportWindowPaint(renderWindow);
const image = await renderWindow.webContents.capturePage({
height: size.height,
width: size.width,
x: 0,
y: 0
});
const png = image.toPNG();
writeFileSync(result.filePath, pngWithExportProcessing(png, request, size.width), { mode: 0o600 });
return { canceled: false, file: result.filePath };
} finally {
if (!renderWindow.isDestroyed()) {
renderWindow.destroy();
}
rmSync(tempDir, { force: true, recursive: true });
}
}
async function prepareImageExportTarget(window: BrowserWindow | null, request: AppImageExportTargetRequest): Promise<AppImageExportTargetResult> {
const result = window
? await dialog.showSaveDialog(window, shareCardSaveDialogOptions(request.fileName))
@@ -585,6 +641,15 @@ async function prepareImageExportTarget(window: BrowserWindow | null, request: A
};
}
async function imageExportFile(window: BrowserWindow | null, fileName: string, exportId?: string): Promise<{ canceled: boolean; filePath?: string }> {
const targetFile = exportId ? consumeImageExportTarget(exportId) : undefined;
return targetFile
? { canceled: false, filePath: targetFile }
: window
? await dialog.showSaveDialog(window, shareCardSaveDialogOptions(fileName))
: await dialog.showSaveDialog(shareCardSaveDialogOptions(fileName));
}
function dataExportSaveDialogOptions(exportedAt: string): SaveDialogOptions {
return {
buttonLabel: "Export",
@@ -623,6 +688,29 @@ function sanitizeCaptureRect(rect: AppCaptureElementPngRequest["rect"]): Rectang
};
}
function sanitizeRenderSize(size: AppRenderHtmlPngRequest["size"]): { height: number; width: number } {
const width = finiteNumber(size?.width, "render width");
const height = finiteNumber(size?.height, "render height");
if (width <= 0 || height <= 0 || width > 4096 || height > 4096) {
throw new Error("Render size is out of range.");
}
return {
height: Math.ceil(height),
width: Math.ceil(width)
};
}
async function waitForExportWindowPaint(window: BrowserWindow): Promise<void> {
await window.webContents.executeJavaScript(`
new Promise((resolve) => {
const fontsReady = document.fonts && document.fonts.ready ? document.fonts.ready.catch(() => undefined) : Promise.resolve();
fontsReady.then(() => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)));
});
});
`);
}
function finiteNumber(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`Invalid ${label}.`);
@@ -647,6 +735,14 @@ function consumeImageExportTarget(exportId: string): string {
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
type PngExportProcessingRequest = {
borderRadius?: number;
output?: {
height: number;
width: number;
};
};
type DecodedPngPixels = {
bitDepth: number;
colorType: 2 | 6;
@@ -655,7 +751,7 @@ type DecodedPngPixels = {
width: number;
};
function pngWithExportProcessing(png: Buffer, request: AppCaptureElementPngRequest, cssWidth: number): Buffer {
function pngWithExportProcessing(png: Buffer, request: PngExportProcessingRequest, cssWidth: number): Buffer {
const radius = typeof request.borderRadius === "number" && Number.isFinite(request.borderRadius) ? Math.max(0, request.borderRadius) : 0;
const outputWidth = sanitizePngOutputDimension(request.output?.width);
const outputHeight = sanitizePngOutputDimension(request.output?.height);
@@ -680,10 +776,30 @@ function pngWithExportProcessing(png: Buffer, request: AppCaptureElementPngReque
return encodeRgbaPng(width, height, rgba);
} catch (error) {
console.warn(`[export] Failed to process exported PNG: ${formatError(error)}`);
const resized = resizePngWithNativeImage(png, outputWidth, outputHeight);
if (resized) {
return resized;
}
return png;
}
}
function resizePngWithNativeImage(png: Buffer, width?: number, height?: number): Buffer | undefined {
if (!width || !height) {
return undefined;
}
try {
const image = nativeImage.createFromBuffer(png);
if (image.isEmpty()) {
return undefined;
}
return image.resize({ height, width }).toPNG();
} catch (error) {
console.warn(`[export] Failed to resize exported PNG fallback: ${formatError(error)}`);
return undefined;
}
}
function sanitizePngOutputDimension(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
+3
View File
@@ -13,6 +13,8 @@ import type {
AppInfo,
AppImageExportTargetRequest,
AppImageExportTargetResult,
AppRenderHtmlPngRequest,
AppRenderHtmlPngResult,
AppSaveConfigOptions,
AppUpdateStatus,
ApiKeyConfig,
@@ -131,6 +133,7 @@ contextBridge.exposeInMainWorld("ccr", {
probeProvider: (request: GatewayProviderProbeRequest) => invoke(IPC_CHANNELS.appProbeProvider, request) as Promise<GatewayProviderProbeResult>,
quitApp: () => invoke(IPC_CHANNELS.appQuit) as Promise<void>,
revealProxyCertificate: () => invoke(IPC_CHANNELS.appRevealProxyCertificate) as Promise<void>,
renderHtmlPng: (request: AppRenderHtmlPngRequest) => invoke(IPC_CHANNELS.appRenderHtmlPng, request) as Promise<AppRenderHtmlPngResult>,
restartGateway: () => invoke(IPC_CHANNELS.appRestartGateway) as Promise<GatewayStatus>,
restartProxy: () => invoke(IPC_CHANNELS.appRestartProxy) as Promise<ProxyStatus>,
saveApiKeys: (apiKeys: ApiKeyConfig[]) => invoke(IPC_CHANNELS.appSaveApiKeys, apiKeys) as Promise<AppConfig>,
+10 -2
View File
@@ -69,14 +69,22 @@ export function resolveProfileOpenSurface(profile: ProfileConfig, surface?: Prof
return surfaces[0];
}
export function defaultProfileOpenSurface(profile: Pick<ProfileConfig, "agent">): ProfileOpenSurface {
return profile.agent === "zcode" ? "app" : "cli";
}
export function profileOpenCommand(
profile: ProfileConfig,
surface: ProfileOpenSurface = profile.agent === "zcode" ? "app" : "cli",
surface: ProfileOpenSurface = defaultProfileOpenSurface(profile),
command = "ccr",
profileRef = profile.name?.trim() || profile.id
): string {
const quote = process.platform === "win32" ? windowsCommandQuote : shellQuote;
return [quote(command), quote(profileRef), surface].join(" ");
const parts = [quote(command), quote(profileRef)];
if (surface === "app") {
parts.push(surface);
}
return parts.join(" ");
}
export function buildProfileLaunchPlan(
@@ -29,9 +29,9 @@ type ShareCardPreparedExportTarget = {
};
const shareCardExportCssWidth = 540;
const shareCardExportCssHeight = 960;
const shareCardExportCssHeight = 675;
const shareCardExportPixelWidth = 1080;
const shareCardExportPixelHeight = 1920;
const shareCardExportPixelHeight = 1350;
const shareCardTones: Record<ShareCardTone, { accent: string; background: string; border: string; glow: string; muted: string; text: string }> = {
amber: {
@@ -159,7 +159,7 @@ function ShareCardShell({
setExporting(true);
await nextFrame();
await nextFrame();
const target = exportCardRef.current ?? cardRef.current;
const target = exportCardRef.current;
if (!target) {
throw new Error("Export card is unavailable.");
}
@@ -185,9 +185,16 @@ function ShareCardShell({
<div className="min-w-0 truncate text-[12px] font-semibold text-muted-foreground">{title}</div>
<div className="flex shrink-0 items-center gap-2">
{status === "error" ? <span className="text-[11px] font-medium text-destructive">{t("Export failed")}</span> : null}
<Button disabled={status === "saving"} size="sm" type="button" variant="outline" onClick={() => void saveImage()}>
<Button
aria-label={status === "saving" ? t("Saving") : t("Save image")}
className="inline-flex items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25 disabled:pointer-events-none disabled:opacity-45"
disabled={status === "saving"}
title={status === "saving" ? t("Saving") : t("Save image")}
type="button"
unstyled
onClick={() => void saveImage()}
>
{status === "saving" ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
{status === "saving" ? t("Saving") : t("Save image")}
</Button>
</div>
</div>
@@ -197,10 +204,11 @@ function ShareCardShell({
{exporting ? (
<div
aria-hidden="true"
className="pointer-events-none fixed left-0 top-0 z-[9999]"
className="pointer-events-none fixed top-0 z-[9999]"
ref={exportCardRef}
style={{
height: `${shareCardExportCssHeight}px`,
left: "-10000px",
width: `${shareCardExportCssWidth}px`
}}
>
@@ -637,6 +645,10 @@ function calendarColor(intensity: TokenActivityCell["intensity"], inRange: boole
}
async function saveElementAsPng(element: HTMLElement, fileName: string, options: ShareCardPngExportOptions = {}): Promise<void> {
if (window.ccr?.renderHtmlPng) {
await nativeRenderElementAsPng(element, fileName, options);
return;
}
if (window.ccr?.captureElementPng) {
await nativeCaptureElementAsPng(element, fileName, options);
return;
@@ -644,6 +656,21 @@ async function saveElementAsPng(element: HTMLElement, fileName: string, options:
await browserExportElementAsPng(element, fileName);
}
async function nativeRenderElementAsPng(element: HTMLElement, fileName: string, options: ShareCardPngExportOptions): Promise<void> {
const size = exportElementSize(element);
const result = await window.ccr?.renderHtmlPng?.({
borderRadius: exportBorderRadius(element),
exportId: options.exportId,
fileName,
html: exportElementHtmlDocument(element, size.width, size.height),
output: options.output,
size
});
if (!result || result.canceled) {
return;
}
}
async function nativeCaptureElementAsPng(element: HTMLElement, fileName: string, options: ShareCardPngExportOptions): Promise<void> {
element.scrollIntoView({ block: "nearest", inline: "nearest" });
await nextFrame();
@@ -665,6 +692,16 @@ async function nativeCaptureElementAsPng(element: HTMLElement, fileName: string,
}
}
function exportElementSize(element: HTMLElement): { height: number; width: number } {
const rect = element.getBoundingClientRect();
const width = Math.ceil(rect.width);
const height = Math.ceil(rect.height);
if (width <= 0 || height <= 0) {
throw new Error("Cannot export an empty element.");
}
return { height, width };
}
function exportBorderRadius(element: HTMLElement): number | undefined {
const target = element.firstElementChild instanceof HTMLElement ? element.firstElementChild : element;
const styles = window.getComputedStyle(target);
@@ -685,18 +722,8 @@ function nextFrame(): Promise<void> {
}
async function browserExportElementAsPng(element: HTMLElement, fileName: string): Promise<void> {
const rect = element.getBoundingClientRect();
const width = Math.ceil(rect.width);
const height = Math.ceil(rect.height);
if (width <= 0 || height <= 0) {
throw new Error("Cannot export an empty element.");
}
const clone = element.cloneNode(true) as HTMLElement;
clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
clone.style.width = `${width}px`;
clone.style.height = `${height}px`;
inlineComputedStyles(element, clone);
const { height, width } = exportElementSize(element);
const clone = cloneElementForExport(element, width, height);
const serialized = new XMLSerializer().serializeToString(clone);
const svg = [
@@ -735,6 +762,41 @@ async function browserExportElementAsPng(element: HTMLElement, fileName: string)
}
}
function exportElementHtmlDocument(element: HTMLElement, width: number, height: number): string {
const clone = cloneElementForExport(element, width, height);
const serialized = new XMLSerializer().serializeToString(clone);
return [
"<!doctype html>",
"<html>",
"<head>",
"<meta charset=\"utf-8\">",
"<style>",
`html,body{margin:0;width:${width}px;height:${height}px;overflow:hidden;background:transparent;}`,
"body{display:block;}",
"*,*::before,*::after{box-sizing:border-box;}",
"</style>",
"</head>",
`<body>${serialized}</body>`,
"</html>"
].join("");
}
function cloneElementForExport(element: HTMLElement, width: number, height: number): HTMLElement {
const clone = element.cloneNode(true) as HTMLElement;
clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
inlineComputedStyles(element, clone);
clone.style.bottom = "auto";
clone.style.height = `${height}px`;
clone.style.left = "0";
clone.style.margin = "0";
clone.style.position = "relative";
clone.style.right = "auto";
clone.style.top = "0";
clone.style.transform = "none";
clone.style.width = `${width}px`;
return clone;
}
function inlineComputedStyles(source: Element, target: Element): void {
if (target instanceof HTMLElement || target instanceof SVGElement) {
const computed = window.getComputedStyle(source);
+1 -1
View File
@@ -1609,7 +1609,7 @@ export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[
export function profileOpenCommandFallback(profile: ProfileConfig, surface: ProfileOpenSurface = profile.agent === "zcode" ? "app" : "cli"): string {
const profileRef = profile.name.trim() || profile.id;
return ["ccr", shellCommandQuote(profileRef), ...(surface === "app" ? ["--app"] : [])].join(" ");
return ["ccr", shellCommandQuote(profileRef), ...(surface === "app" ? ["app"] : [])].join(" ");
}
function shellCommandQuote(value: string): string {
+3
View File
@@ -12,6 +12,8 @@ import type {
AppInfo,
AppImageExportTargetRequest,
AppImageExportTargetResult,
AppRenderHtmlPngRequest,
AppRenderHtmlPngResult,
AppSaveConfigOptions,
AppUpdateStatus,
ApiKeyConfig,
@@ -118,6 +120,7 @@ declare global {
probeProvider: (request: GatewayProviderProbeRequest) => Promise<GatewayProviderProbeResult>;
quitApp: () => Promise<void>;
revealProxyCertificate: () => Promise<void>;
renderHtmlPng?: (request: AppRenderHtmlPngRequest) => Promise<AppRenderHtmlPngResult>;
restartGateway: () => Promise<GatewayStatus>;
restartProxy: () => Promise<ProxyStatus>;
saveApiKeys: (apiKeys: ApiKeyConfig[]) => Promise<AppConfig>;
+20
View File
@@ -49,6 +49,26 @@ export type AppImageExportTargetResult = {
file?: string;
};
export type AppRenderHtmlPngRequest = {
borderRadius?: number;
exportId?: string;
fileName: string;
html: string;
output?: {
height: number;
width: number;
};
size: {
height: number;
width: number;
};
};
export type AppRenderHtmlPngResult = {
canceled: boolean;
file?: string;
};
export type AppUpdateState =
| "idle"
| "checking"
+1
View File
@@ -50,6 +50,7 @@ export const IPC_CHANNELS = {
appPrepareImageExportTarget: "ccr:app:prepare-image-export-target",
appQuit: "ccr:app:quit",
appRevealProxyCertificate: "ccr:app:reveal-proxy-certificate",
appRenderHtmlPng: "ccr:app:render-html-png",
appRestartProxy: "ccr:app:restart-proxy",
appRestartGateway: "ccr:app:restart-gateway",
appSaveApiKeys: "ccr:app:save-api-keys",
+15 -2
View File
@@ -4,6 +4,7 @@ import test from "node:test";
import {
buildProfileLaunchPlan,
ccrManagedProfileDir,
defaultProfileOpenSurface,
findProfileForOpen,
profileOpenCommand,
profileOpenSurfaces,
@@ -59,6 +60,13 @@ test("profile open surfaces enforce agent capabilities", () => {
assert.throws(() => resolveProfileOpenSurface({ ...claudeProfile, surface: "cli" }, "app"), /does not support APP/);
});
test("default profile command surface is CLI unless the agent is app-only", () => {
assert.equal(defaultProfileOpenSurface(claudeProfile), "cli");
assert.equal(defaultProfileOpenSurface(codexProfile), "cli");
assert.equal(defaultProfileOpenSurface({ ...codexProfile, surface: "app" }), "cli");
assert.equal(defaultProfileOpenSurface({ ...codexProfile, agent: "zcode" }), "app");
});
test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => {
const configDir = path.join(path.sep, "tmp", "ccr-config");
const codexPlan = buildProfileLaunchPlan(configDir, codexProfile, "app");
@@ -99,6 +107,11 @@ test("profile config paths honor CCR, custom, and global scopes", () => {
});
test("profileOpenCommand quotes profile references for shell usage", () => {
assert.match(profileOpenCommand(claudeProfile, "cli", "ccr", "Claude Main"), /Claude/);
assert.match(profileOpenCommand(claudeProfile, "cli", "ccr", "Claude Main"), /Main/);
const cliCommand = profileOpenCommand(claudeProfile, "cli", "ccr", "Claude Main");
const appCommand = profileOpenCommand(codexProfile, "app", "ccr", "Codex Main");
assert.match(cliCommand, /Claude/);
assert.match(cliCommand, /Main/);
assert.equal(cliCommand.endsWith(" cli"), false);
assert.match(appCommand, / app$/);
});