mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-30 17:11:12 +08:00
Add Claude App profile opening and HTML PNG export
This commit is contained in:
+45
-13
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+3
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user