mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-31 01:35:16 +08:00
Improve Claude Design asset discovery and product runtime support
This commit is contained in:
@@ -2871,34 +2871,50 @@ function migratedClaudeShipPluginConfig(source: GatewayPluginConfig): GatewayPlu
|
||||
}
|
||||
|
||||
export function claudeDesignRuntimePluginConfig(): GatewayPluginConfig | undefined {
|
||||
return claudeProductRuntimePluginConfig(CLAUDE_DESIGN_PLUGIN_ID);
|
||||
}
|
||||
|
||||
export function claudeShipRuntimePluginConfig(): GatewayPluginConfig | undefined {
|
||||
return claudeProductRuntimePluginConfig(CLAUDE_SHIP_PLUGIN_ID);
|
||||
}
|
||||
|
||||
function claudeProductRuntimePluginConfig(pluginId: string): GatewayPluginConfig | undefined {
|
||||
if (!isDesktopAppRuntime()) {
|
||||
return undefined;
|
||||
}
|
||||
const modulePath = resolveBundledOrExternalizedPluginModule(CLAUDE_DESIGN_PLUGIN_ID, undefined);
|
||||
const modulePath = resolveBundledOrExternalizedPluginModule(pluginId, undefined);
|
||||
if (!modulePath) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
apps: knownGatewayPluginDefaultApps(CLAUDE_DESIGN_PLUGIN_ID),
|
||||
apps: knownGatewayPluginDefaultApps(pluginId),
|
||||
enabled: true,
|
||||
id: CLAUDE_DESIGN_PLUGIN_ID,
|
||||
id: pluginId,
|
||||
module: modulePath,
|
||||
permissions: knownGatewayPluginDefaultPermissions(CLAUDE_DESIGN_PLUGIN_ID),
|
||||
surfaces: knownGatewayPluginDefaultSurfaces(CLAUDE_DESIGN_PLUGIN_ID)
|
||||
permissions: knownGatewayPluginDefaultPermissions(pluginId),
|
||||
surfaces: knownGatewayPluginDefaultSurfaces(pluginId)
|
||||
};
|
||||
}
|
||||
|
||||
export function withClaudeDesignRuntimePluginConfig(config: AppConfig): AppConfig {
|
||||
const existingIndex = config.plugins.findIndex((plugin) => plugin.enabled !== false && plugin.id === CLAUDE_DESIGN_PLUGIN_ID);
|
||||
return withClaudeProductRuntimePluginConfig(config, CLAUDE_DESIGN_PLUGIN_ID, "Claude Design");
|
||||
}
|
||||
|
||||
export function withClaudeShipRuntimePluginConfig(config: AppConfig): AppConfig {
|
||||
return withClaudeProductRuntimePluginConfig(config, CLAUDE_SHIP_PLUGIN_ID, "Claude Ship");
|
||||
}
|
||||
|
||||
function withClaudeProductRuntimePluginConfig(config: AppConfig, pluginId: string, productName: string): AppConfig {
|
||||
const existingIndex = config.plugins.findIndex((plugin) => plugin.enabled !== false && plugin.id === pluginId);
|
||||
if (existingIndex >= 0 && config.plugins[existingIndex]?.module?.trim()) {
|
||||
return config;
|
||||
}
|
||||
if (!isDesktopAppRuntime()) {
|
||||
throw new Error("Claude Design is only available in CCR Desktop.");
|
||||
throw new Error(`${productName} is only available in CCR Desktop.`);
|
||||
}
|
||||
const plugin = claudeDesignRuntimePluginConfig();
|
||||
const plugin = claudeProductRuntimePluginConfig(pluginId);
|
||||
if (!plugin) {
|
||||
throw new Error("Claude Design runtime module was not found. Rebuild app assets so the bundled Claude Design plugin is copied into the Electron dist.");
|
||||
throw new Error(`${productName} runtime module was not found. Rebuild app assets so the bundled ${productName} plugin is copied into the Electron dist.`);
|
||||
}
|
||||
if (existingIndex >= 0) {
|
||||
const existing = config.plugins[existingIndex];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { claudeDesignRuntimePluginConfig, migrateKnownGatewayPluginConfigsForTest, withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config.ts";
|
||||
import { claudeDesignRuntimePluginConfig, claudeShipRuntimePluginConfig, migrateKnownGatewayPluginConfigsForTest, withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config.ts";
|
||||
import { CCR_DESKTOP_APP_ENV } from "@ccr/core/runtime/desktop-app.ts";
|
||||
|
||||
test("legacy combined Claude Design plugin config migrates to split Design and Ship plugins", () => {
|
||||
@@ -103,9 +103,13 @@ test("Claude Design runtime plugin config resolves from the bundled plugin in CC
|
||||
process.env[CCR_DESKTOP_APP_ENV] = "1";
|
||||
|
||||
const plugin = claudeDesignRuntimePluginConfig();
|
||||
const shipPlugin = claudeShipRuntimePluginConfig();
|
||||
assert.equal(plugin?.id, "claude-design");
|
||||
assert.equal(plugin?.module, bundledPluginModule("claude-design"));
|
||||
assert.deepEqual(plugin?.apps?.map((app) => app.id), ["claude-design"]);
|
||||
assert.equal(shipPlugin?.id, "claude-ship");
|
||||
assert.equal(shipPlugin?.module, bundledPluginModule("claude-ship"));
|
||||
assert.deepEqual(shipPlugin?.apps?.map((app) => app.id), ["claude-ship"]);
|
||||
|
||||
const config = { plugins: [] };
|
||||
const runtimeConfig = withClaudeDesignRuntimePluginConfig(config);
|
||||
|
||||
@@ -12,7 +12,13 @@ Claude Design and Claude Ship are separate plugins. Install `plugins/claude-ship
|
||||
|
||||
By default the Claude Design window loads `https://claude-design.ccrdesk.top/design` from the frontend embedded in the plugin code. Static files under `/design/*` are served by the Claude Design frontend host, while CCR still intercepts Design API paths such as `/v1/design`, `/design/v1/design`, Omelette RPC, bootstrap, and privacy consent probes.
|
||||
|
||||
Browser-saved Claude Design HTML and Claude app `ion-dist` assets are no longer auto-detected. The plugin uses the Cloudflare Pages frontend by default; for local development fixtures, set `assetDir` explicitly.
|
||||
Browser-saved Claude Design HTML and Claude app `ion-dist` assets are no longer auto-detected. The plugin uses the Cloudflare Pages frontend by default; for local development fixtures, set `frontendUrl`/`frontendAssetsOrigin` and `assetDir` explicitly. `assetDir` can point at any of these extracted roots:
|
||||
|
||||
- `/path/to/claude-design-assets/public`
|
||||
- `/path/to/claude-design-assets/public/design`
|
||||
- `/path/to/claude-design-assets/public/design/assets`
|
||||
|
||||
When `assetDir` contains a usable `design/index.html` or `index.html`, the plugin serves that saved HTML as the Design shell and does not mix in cached or remote-discovered entry bundles.
|
||||
|
||||
When the packaged app owns the `ccr://` protocol handler, the window can also be opened with:
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ const BOOTSTRAP_ROUTE_PATHS = ["/_bootstrap", "/api/bootstrap", "/edge-api/boots
|
||||
const TOKENIZED_PREVIEW_ROUTE_PATHS = ["/_t", "/design/_t"];
|
||||
const DESIGN_ONLINE_REQUIRED_ROUTE_PATHS = [CCR_RESOURCE_RUNTIME_PATH, "/design", CLAUDE_APP_LEGACY_DESIGN_PATH, OMELETTE_RPC_PATH_PREFIX, "/design/v1/design", "/v1/design", ...PRIVACY_CONSENT_ROUTE_PATHS, ...TOKENIZED_PREVIEW_ROUTE_PATHS, ...BOOTSTRAP_ROUTE_PATHS];
|
||||
const DESIGN_LOCAL_REQUIRED_ROUTE_PATHS = [CCR_RESOURCE_RUNTIME_PATH, OMELETTE_RPC_PATH_PREFIX, "/design/v1/design", "/v1/design", ...PRIVACY_CONSENT_ROUTE_PATHS, ...TOKENIZED_PREVIEW_ROUTE_PATHS, ...CLAUDE_APP_SPA_ROUTE_PATHS, ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS, ...AUTH_API_ROUTE_PATHS];
|
||||
const PARKED_MESSAGE_STATE_NOT_FOUND = 1;
|
||||
const ORGANIZATION_ROUTE_PATHS = ["/api/organizations", "/organizations"];
|
||||
const SHIP_API_ROUTE_PATHS = ["/api/billing/promotion/claude-ship", ...ORGANIZATION_ROUTE_PATHS];
|
||||
const SHIP_ONLINE_REQUIRED_ROUTE_PATHS = [CCR_RESOURCE_RUNTIME_PATH, "/v1/code", "/v1/sessions", ...SHIP_API_ROUTE_PATHS, ...PRIVACY_CONSENT_ROUTE_PATHS, ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS, ...AUTH_API_ROUTE_PATHS];
|
||||
@@ -513,6 +514,15 @@ const DEFAULT_ME = {
|
||||
|
||||
module.exports = createClaudeProductPlugin("design");
|
||||
module.exports.createClaudeProductPlugin = createClaudeProductPlugin;
|
||||
if (process.env.CCR_CLAUDE_DESIGN_PLUGIN_TEST_EXPORTS === "1") {
|
||||
module.exports.__test = {
|
||||
discoverLocalDesignIndexAssets,
|
||||
injectDesignMeIntoHtml,
|
||||
mergeDesignShellMe,
|
||||
readLocalAsset,
|
||||
routeMockRequest
|
||||
};
|
||||
}
|
||||
|
||||
function createClaudeProductPlugin(productName = "design") {
|
||||
const product = CLAUDE_PLUGIN_PRODUCTS[productName] || CLAUDE_PLUGIN_PRODUCTS.design;
|
||||
@@ -940,6 +950,10 @@ async function routeMockRequest(runtime, method, url, request, requestBody) {
|
||||
return serveAsset(runtime, path, request);
|
||||
}
|
||||
|
||||
if (method === "GET" && path.startsWith("/design/design-systems/")) {
|
||||
return serveAsset(runtime, path, request);
|
||||
}
|
||||
|
||||
if (method === "GET" && isClaudeAppStaticRoutePath(path)) {
|
||||
const claudeShipAsset = serveClaudeShipAsset(runtime, path);
|
||||
if (claudeShipAsset) {
|
||||
@@ -2229,6 +2243,7 @@ async function handleOmeletteConnectRpc(runtime, method, path, request, requestB
|
||||
|
||||
const rpcName = path.split("/").pop();
|
||||
const isConnectProtoRequest = headerIncludes(request.headers["content-type"], "application/connect+proto");
|
||||
const isJsonRpcRequest = headerIncludes(request.headers["content-type"], "application/json") && !isConnectProtoRequest;
|
||||
const rpcBody = rpcName === "Chat" || isConnectProtoRequest
|
||||
? decodeConnectEnvelope(requestBody)
|
||||
: requestBody;
|
||||
@@ -2341,6 +2356,16 @@ async function handleOmeletteConnectRpc(runtime, method, path, request, requestB
|
||||
return protoResponse(encodeListUserSkillsResponse(runtime));
|
||||
case "GetUsageStatus":
|
||||
return protoResponse(encodeUsageStatusResponse(runtime));
|
||||
case "GetPrepaidBalance":
|
||||
if (isJsonRpcRequest) {
|
||||
return jsonResponse(200, prepaidBalancePayload(runtime));
|
||||
}
|
||||
return protoResponse(encodePrepaidBalanceResponse(runtime));
|
||||
case "GetProjectPresence":
|
||||
if (isJsonRpcRequest) {
|
||||
return jsonResponse(200, projectPresencePayload(runtime));
|
||||
}
|
||||
return protoResponse(encodeProjectPresenceResponse(runtime, rpcBody));
|
||||
case "UpdateOrgSettings":
|
||||
updateOrgSettings(runtime, rpcBody);
|
||||
return protoResponse(Buffer.alloc(0));
|
||||
@@ -2357,6 +2382,16 @@ async function handleOmeletteConnectRpc(runtime, method, path, request, requestB
|
||||
return protoResponse(encodeTokenResponse());
|
||||
case "CountTokens":
|
||||
return protoResponse(await countGatewayTokens(runtime, rpcBody));
|
||||
case "GetParkedMessage":
|
||||
if (isJsonRpcRequest) {
|
||||
return jsonResponse(200, parkedMessagePayload());
|
||||
}
|
||||
return protoResponse(encodeParkedMessageResponse());
|
||||
case "CancelChat":
|
||||
if (isJsonRpcRequest) {
|
||||
return jsonResponse(200, {});
|
||||
}
|
||||
return protoResponse(Buffer.alloc(0));
|
||||
case "Chat":
|
||||
return await chatWithGateway(runtime, rpcBody);
|
||||
case "TrackEvent":
|
||||
@@ -2411,7 +2446,11 @@ async function handleOmeletteConnectRpc(runtime, method, path, request, requestB
|
||||
case "ExecuteExperienceAction":
|
||||
case "LintFiles":
|
||||
case "FigmaGetStatus":
|
||||
case "GoogleGetStatus":
|
||||
case "GithubGetStatus":
|
||||
if (isJsonRpcRequest) {
|
||||
return jsonResponse(200, integrationStatusPayload(rpcName));
|
||||
}
|
||||
return protoResponse(encodeIntegrationStatusResponse(rpcName));
|
||||
case "McpListConnected":
|
||||
case "McpListConnectors":
|
||||
@@ -2972,19 +3011,23 @@ async function resolveDesignIndexAssets(runtime, request, options = {}) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const fromRequests = discoverRequestedDesignIndexAssets(runtime.store);
|
||||
const fromLocal = discoverLocalDesignIndexAssets(runtime.assetDir);
|
||||
if (fromLocal) {
|
||||
return updateDesignIndexAssets(runtime, fromLocal, fromLocal.source || "local", { checkedAt: now });
|
||||
}
|
||||
|
||||
const fromRequests = discoverRequestedDesignIndexAssets(runtime.store);
|
||||
const fromCache = discoverCachedDesignIndexAssets(runtime.store);
|
||||
const fromRemote = await discoverRemoteDesignIndexAssets(runtime, request);
|
||||
const fromRemoteCache = await cacheRemoteDesignIndexAssets(runtime, fromRemote, request);
|
||||
const fallback = mergeDesignIndexAssetPartials(fromLocal, fromCache) || {};
|
||||
const fallback = mergeDesignIndexAssetPartials(fromCache) || {};
|
||||
const seeded = {
|
||||
html: isUsableDesignShellHtml(fromLocal?.html) ? fromLocal.html : current.html,
|
||||
html: current.html,
|
||||
scriptPath: fallback.scriptPath || current.scriptPath,
|
||||
source: fallback.source || current.source || "current",
|
||||
stylePath: fallback.stylePath || current.stylePath
|
||||
};
|
||||
const discovered = mergeDesignIndexAssets(seeded, fromRequests, fromRemoteCache || fromRemote, fromLocal);
|
||||
const discovered = mergeDesignIndexAssets(seeded, fromRequests, fromRemoteCache || fromRemote);
|
||||
const safeDiscovered = selectUsableDesignIndexAssets(runtime, discovered, fallback, current);
|
||||
return updateDesignIndexAssets(runtime, safeDiscovered, safeDiscovered.source || "discovered", { checkedAt: now });
|
||||
}
|
||||
@@ -3377,32 +3420,34 @@ function discoverLocalDesignIndexAssets(assetDir) {
|
||||
const assetRoot = pathModule.resolve(expandHomePath(assetDir));
|
||||
const scripts = [];
|
||||
const styles = [];
|
||||
for (const entry of listLocalAssetFiles(assetRoot)) {
|
||||
const requestPath = `/design/assets/${entry.relativePath}`;
|
||||
if (!isDesignIndexScriptPath(requestPath) && !isDesignIndexStylePath(requestPath)) {
|
||||
continue;
|
||||
for (const root of localDesignAssetRoots(assetRoot)) {
|
||||
for (const entry of listLocalAssetFiles(root)) {
|
||||
const requestPath = `/design/assets/${entry.relativePath}`;
|
||||
if (!isDesignIndexScriptPath(requestPath) && !isDesignIndexStylePath(requestPath)) {
|
||||
continue;
|
||||
}
|
||||
if (isDesignIndexStylePath(requestPath)) {
|
||||
styles.push({ mtimeMs: entry.stat.mtimeMs, path: requestPath, size: entry.stat.size });
|
||||
continue;
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(entry.file);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
scripts.push({
|
||||
mtimeMs: entry.stat.mtimeMs,
|
||||
path: requestPath,
|
||||
score: designEntryScriptScore(requestPath, body, entry.stat),
|
||||
size: entry.stat.size
|
||||
});
|
||||
}
|
||||
if (isDesignIndexStylePath(requestPath)) {
|
||||
styles.push({ mtimeMs: entry.stat.mtimeMs, path: requestPath, size: entry.stat.size });
|
||||
continue;
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(entry.file);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
scripts.push({
|
||||
mtimeMs: entry.stat.mtimeMs,
|
||||
path: requestPath,
|
||||
score: designEntryScriptScore(requestPath, body, entry.stat),
|
||||
size: entry.stat.size
|
||||
});
|
||||
}
|
||||
scripts.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs || b.size - a.size);
|
||||
styles.sort((a, b) => b.mtimeMs - a.mtimeMs || b.size - a.size);
|
||||
const assets = {
|
||||
html: "",
|
||||
html: readLocalDesignIndexHtml(assetRoot),
|
||||
scriptPath: scripts[0]?.path,
|
||||
source: "local",
|
||||
stylePath: styles[0]?.path
|
||||
@@ -3410,6 +3455,66 @@ function discoverLocalDesignIndexAssets(assetDir) {
|
||||
return assets.html || assets.scriptPath || assets.stylePath ? assets : undefined;
|
||||
}
|
||||
|
||||
function localDesignAssetRoots(assetRoot) {
|
||||
return uniqueExistingDirectories([
|
||||
assetRoot,
|
||||
pathModule.join(assetRoot, "assets"),
|
||||
pathModule.join(assetRoot, "design", "assets")
|
||||
]);
|
||||
}
|
||||
|
||||
function readLocalDesignIndexHtml(assetRoot) {
|
||||
for (const file of uniquePaths(localDesignIndexHtmlCandidates(assetRoot))) {
|
||||
try {
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
continue;
|
||||
}
|
||||
const html = fs.readFileSync(file, "utf8");
|
||||
if (isUsableDesignShellHtml(html)) {
|
||||
return html;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function localDesignIndexHtmlCandidates(assetRoot) {
|
||||
const candidates = [
|
||||
pathModule.join(assetRoot, "index.html"),
|
||||
pathModule.join(assetRoot, "design", "index.html")
|
||||
];
|
||||
if (pathModule.basename(assetRoot) === "assets") {
|
||||
candidates.push(pathModule.join(pathModule.dirname(assetRoot), "index.html"));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function uniqueExistingDirectories(paths) {
|
||||
return uniquePaths(paths).filter((directory) => {
|
||||
try {
|
||||
return fs.existsSync(directory) && fs.statSync(directory).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function uniquePaths(paths) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const file of paths) {
|
||||
const normalized = pathModule.resolve(file);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function localAssetDirExists(assetDir) {
|
||||
if (!assetDir) {
|
||||
return false;
|
||||
@@ -9564,10 +9669,63 @@ function encodeUsageStatusResponse() {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
function encodePrepaidBalanceResponse() {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
function encodeProjectPresenceResponse() {
|
||||
return Buffer.concat([
|
||||
protoInt32(1, 1)
|
||||
]);
|
||||
}
|
||||
|
||||
function encodeParkedMessageResponse() {
|
||||
return protoEnum(1, PARKED_MESSAGE_STATE_NOT_FOUND);
|
||||
}
|
||||
|
||||
function encodeIntegrationStatusResponse() {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
function projectPresencePayload(runtime) {
|
||||
return {
|
||||
accounts: [
|
||||
{
|
||||
accountUuid: runtime.me.accountUuid,
|
||||
displayName: runtime.me.displayName,
|
||||
email: runtime.me.email
|
||||
}
|
||||
],
|
||||
totalCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
function prepaidBalancePayload() {
|
||||
return {
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
prepaidBalance: 0
|
||||
};
|
||||
}
|
||||
|
||||
function parkedMessagePayload() {
|
||||
return {
|
||||
doneEvent: "",
|
||||
remainingMs: 0,
|
||||
state: "NOT_FOUND",
|
||||
turnEpoch: "0"
|
||||
};
|
||||
}
|
||||
|
||||
function integrationStatusPayload(rpcName) {
|
||||
const name = String(rpcName || "").replace(/GetStatus$/, "").toLowerCase() || "integration";
|
||||
return {
|
||||
connected: false,
|
||||
integration: name,
|
||||
status: "disconnected"
|
||||
};
|
||||
}
|
||||
|
||||
function encodeModelPreset(preset) {
|
||||
return Buffer.concat([
|
||||
protoString(1, preset.id),
|
||||
@@ -9704,6 +9862,11 @@ async function handleDesignRestApi(runtime, method, path, request, requestBody)
|
||||
}
|
||||
}
|
||||
|
||||
const projectEventsMatch = restPath.match(/^\/v1\/design\/projects\/([^/]+)\/events$/);
|
||||
if (method === "GET" && projectEventsMatch) {
|
||||
return designProjectEventsResponse(decodeURIComponent(projectEventsMatch[1]));
|
||||
}
|
||||
|
||||
if ((method === "GET" || method === "POST") && restPath === "/v1/design/files") {
|
||||
return handleDesignRestFiles(runtime, method, requestBody);
|
||||
}
|
||||
@@ -9767,6 +9930,9 @@ async function handleDesignRestApi(runtime, method, path, request, requestBody)
|
||||
const filePath = sanitizeProjectFilePath(decodeURIComponent(serveMatch[2]));
|
||||
const row = getProjectFileRow(runtime, projectId, filePath);
|
||||
if (!row) {
|
||||
if (filePath === DEFAULT_PROJECT_FILE_PATH) {
|
||||
return servePendingDesignPreviewResponse(runtime, projectId, filePath);
|
||||
}
|
||||
return jsonResponse(404, { error: { message: `File not found: ${filePath}` } });
|
||||
}
|
||||
if (method === "HEAD") {
|
||||
@@ -9948,6 +10114,62 @@ function isDesignRestApiRoutePath(path) {
|
||||
/^\/design\/_t\/[^/]+\/v1\/design\//.test(path);
|
||||
}
|
||||
|
||||
function designProjectEventsResponse(projectId) {
|
||||
return eventStreamResponse(200, async (response) => {
|
||||
await writeResponseChunk(response, Buffer.from("retry: 30000\n\n", "utf8"));
|
||||
await writeResponseChunk(response, Buffer.from(`event: presence\ndata: ${JSON.stringify({ accounts: [], projectId, totalCount: 0 })}\n\n`, "utf8"));
|
||||
await new Promise((resolve) => {
|
||||
let closed = false;
|
||||
let timer;
|
||||
const finish = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
response.on("close", finish);
|
||||
response.on("error", finish);
|
||||
timer = setInterval(() => {
|
||||
if (!closed) {
|
||||
response.write(": keep-alive\n\n");
|
||||
}
|
||||
}, 25_000);
|
||||
setTimeout(finish, 5 * 60 * 1000);
|
||||
});
|
||||
}, {
|
||||
"connection": "keep-alive",
|
||||
"x-accel-buffering": "no"
|
||||
});
|
||||
}
|
||||
|
||||
function servePendingDesignPreviewResponse(runtime, projectId, filePath) {
|
||||
const previewVersion = projectPreviewVersion(runtime, projectId);
|
||||
const html = injectOmelettePreviewScripts(renderClaudeDesignPendingPreviewHtml(), previewVersion, previewPollUrl(projectId, filePath));
|
||||
return textResponse(200, html, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
etag: `"ccr-preview-${previewVersion}"`,
|
||||
"x-ccr-preview-version": previewVersion
|
||||
});
|
||||
}
|
||||
|
||||
function renderClaudeDesignPendingPreviewHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Claude Design preview pending</title>
|
||||
<style>html,body{margin:0;min-height:100%;background:transparent;color:transparent}</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function serveProjectFileResponse(runtime, projectId, row, filePath) {
|
||||
const body = Buffer.from(row.body_base64 || "", "base64");
|
||||
const contentType = row.content_type || guessContentType(filePath);
|
||||
@@ -13885,17 +14107,61 @@ function readLocalAsset(assetDir, requestPath) {
|
||||
|
||||
function localAssetRelativePathCandidates(localRoot, requestPath) {
|
||||
const normalizedPath = normalizePath(requestPath);
|
||||
const relativePath = normalizedPath
|
||||
.replace(/^\/ship\//, "")
|
||||
.replace(/^\/design\/assets\//, "")
|
||||
.replace(/^\/assets\//, "")
|
||||
.replace(/^\/design\//, "");
|
||||
if (normalizedPath.startsWith("/design/assets/") && relativePath && !relativePath.includes("/")) {
|
||||
return [relativePath, `v1/${relativePath}`];
|
||||
if (normalizedPath.startsWith("/design/assets/")) {
|
||||
return localAssetPathCandidatesForAssetPath(normalizedPath.slice("/design/assets/".length), "design");
|
||||
}
|
||||
if (normalizedPath.startsWith("/ship/assets/")) {
|
||||
return localAssetPathCandidatesForAssetPath(normalizedPath.slice("/ship/assets/".length), "ship");
|
||||
}
|
||||
if (normalizedPath.startsWith("/assets/")) {
|
||||
const assetPath = normalizedPath.slice("/assets/".length);
|
||||
return uniqueRelativePathCandidates([
|
||||
...localAssetPathCandidatesForAssetPath(assetPath, "design"),
|
||||
...localAssetPathCandidatesForAssetPath(assetPath, "ship")
|
||||
]);
|
||||
}
|
||||
if (normalizedPath.startsWith("/ship/")) {
|
||||
const shipPath = normalizedPath.slice("/ship/".length);
|
||||
return uniqueRelativePathCandidates([
|
||||
`ship/${shipPath}`,
|
||||
shipPath
|
||||
]);
|
||||
}
|
||||
if (normalizedPath.startsWith("/design/")) {
|
||||
const designPath = normalizedPath.slice("/design/".length);
|
||||
return uniqueRelativePathCandidates([
|
||||
`design/${designPath}`,
|
||||
designPath
|
||||
]);
|
||||
}
|
||||
const relativePath = normalizedPath.replace(/^\//, "");
|
||||
return [relativePath];
|
||||
}
|
||||
|
||||
function localAssetPathCandidatesForAssetPath(assetPath, product) {
|
||||
const normalizedAssetPath = String(assetPath || "").replace(/^\/+/, "");
|
||||
if (!normalizedAssetPath) {
|
||||
return [];
|
||||
}
|
||||
const candidates = [
|
||||
`${product}/assets/${normalizedAssetPath}`,
|
||||
`assets/${normalizedAssetPath}`,
|
||||
normalizedAssetPath
|
||||
];
|
||||
if (!normalizedAssetPath.includes("/")) {
|
||||
candidates.push(
|
||||
`${product}/assets/v1/${normalizedAssetPath}`,
|
||||
"assets/v1/" + normalizedAssetPath,
|
||||
"v1/" + normalizedAssetPath
|
||||
);
|
||||
}
|
||||
return uniqueRelativePathCandidates(candidates);
|
||||
}
|
||||
|
||||
function uniqueRelativePathCandidates(candidates) {
|
||||
return Array.from(new Set(candidates.filter(Boolean)));
|
||||
}
|
||||
|
||||
function isClaudeAppStaticRoutePath(path) {
|
||||
return path === "/favicon.ico" ||
|
||||
path === "/manifest.json" ||
|
||||
@@ -14679,8 +14945,9 @@ function renderModulePreloadLinks(paths, lowPriority = false) {
|
||||
|
||||
function injectDesignMeIntoHtml(html, me) {
|
||||
let nextHtml = html;
|
||||
const meJsonScript = designMeJsonScript(me);
|
||||
const meJsonPattern = /<script\b(?=[^>]*\bid=["']omelette-me["'])[^>]*>[\s\S]*?<\/script>/i;
|
||||
const designMe = mergeDesignShellMe(readDesignMePayloadFromHtml(nextHtml), me);
|
||||
const meJsonScript = designMeJsonScript(designMe);
|
||||
if (meJsonPattern.test(nextHtml)) {
|
||||
nextHtml = nextHtml.replace(meJsonPattern, meJsonScript);
|
||||
}
|
||||
@@ -14697,10 +14964,10 @@ function injectDesignMeIntoHtml(html, me) {
|
||||
snippets.push(meJsonScript);
|
||||
}
|
||||
if (!nextHtml.includes("ccr-claude-design-model-reset")) {
|
||||
snippets.push(designModelPreferenceResetScript(me));
|
||||
snippets.push(designModelPreferenceResetScript(designMe));
|
||||
}
|
||||
if (!nextHtml.includes("__OMELETTE_ME__")) {
|
||||
snippets.push(designMeGlobalScript(me));
|
||||
snippets.push(designMeGlobalScript(designMe));
|
||||
}
|
||||
if (earlySnippets.length) {
|
||||
nextHtml = injectHtmlAfterHeadOpen(nextHtml, earlySnippets.join("\n "));
|
||||
@@ -14711,6 +14978,67 @@ function injectDesignMeIntoHtml(html, me) {
|
||||
return injectHtmlAfterBodyOpen(nextHtml, snippets.join("\n "));
|
||||
}
|
||||
|
||||
function readDesignMePayloadFromHtml(html) {
|
||||
const match = /<script\b(?=[^>]*\bid=["']omelette-me["'])[^>]*>([\s\S]*?)<\/script>/i.exec(String(html || ""));
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const text = decodeHtmlJsonText(match[1] || "").trim();
|
||||
const value = parseMaybeJson(text, undefined);
|
||||
return isRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function mergeDesignShellMe(shellMe, runtimeMe) {
|
||||
const merged = isRecord(runtimeMe) ? { ...runtimeMe } : {};
|
||||
if (!isRecord(shellMe)) {
|
||||
return merged;
|
||||
}
|
||||
if (typeof shellMe.hasProjects === "boolean" && merged.hasProjects === undefined) {
|
||||
merged.hasProjects = shellMe.hasProjects;
|
||||
}
|
||||
const shellGrowthbookPayload = stringValue(shellMe.growthbookPayload);
|
||||
const runtimeGrowthbookPayload = stringValue(merged.growthbookPayload);
|
||||
if (shellGrowthbookPayload) {
|
||||
merged.growthbookPayload = mergeGrowthbookPayloads(shellGrowthbookPayload, runtimeGrowthbookPayload);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeGrowthbookPayloads(shellPayload, runtimePayload) {
|
||||
const shell = parseMaybeJson(shellPayload, {});
|
||||
const runtime = parseMaybeJson(runtimePayload, {});
|
||||
if (!isRecord(shell)) {
|
||||
return stringValue(runtimePayload) || "{}";
|
||||
}
|
||||
if (!isRecord(runtime)) {
|
||||
return JSON.stringify(shell);
|
||||
}
|
||||
return JSON.stringify({
|
||||
...shell,
|
||||
...runtime,
|
||||
features: {
|
||||
...(isRecord(shell.features) ? shell.features : {}),
|
||||
...(isRecord(runtime.features) ? runtime.features : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function decodeHtmlJsonText(value) {
|
||||
return String(value || "")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/"/gi, "\"")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/</g, "<")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/>/gi, ">");
|
||||
}
|
||||
|
||||
function injectClaudeShipEntrypointIntoHtml(html) {
|
||||
let nextHtml = String(html || "");
|
||||
if (!nextHtml.includes("ccr-claude-ship-entrypoint")) {
|
||||
|
||||
@@ -16,8 +16,13 @@ export type ClaudeDesignWindowCdpOptions = {
|
||||
|
||||
type ClaudeDesignPluginStatus = {
|
||||
backend?: unknown;
|
||||
frontendAssetsHost?: unknown;
|
||||
frontendAssetsOrigin?: unknown;
|
||||
frontendUrl?: unknown;
|
||||
proxy?: {
|
||||
fallbackHosts?: unknown;
|
||||
frontendAssetsHost?: unknown;
|
||||
frontendAssetsOrigin?: unknown;
|
||||
host?: unknown;
|
||||
paths?: unknown;
|
||||
};
|
||||
@@ -89,6 +94,11 @@ export function claudeDesignCdpOptionsFromStatus(status: ClaudeDesignPluginStatu
|
||||
|
||||
const hosts = normalizeHostList([
|
||||
status.proxy?.host,
|
||||
status.frontendUrl,
|
||||
status.frontendAssetsHost,
|
||||
status.frontendAssetsOrigin,
|
||||
status.proxy?.frontendAssetsHost,
|
||||
status.proxy?.frontendAssetsOrigin,
|
||||
...(Array.isArray(status.proxy?.fallbackHosts) ? status.proxy.fallbackHosts : [])
|
||||
]);
|
||||
if (!hosts.length) {
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
import { withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config";
|
||||
import { CLAUDE_DESIGN_PLUGIN_ID, knownGatewayPluginDefaultApps, type AppConfig, type GatewayPluginAppConfig } from "@ccr/core/contracts/app";
|
||||
import { withClaudeDesignRuntimePluginConfig, withClaudeShipRuntimePluginConfig } from "@ccr/core/config/config";
|
||||
import { CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, knownGatewayPluginDefaultApps, type AppConfig, type GatewayPluginAppConfig, type GatewayPluginConfig } from "@ccr/core/contracts/app";
|
||||
|
||||
const DEFAULT_CLAUDE_DESIGN_FRONTEND_URL = "https://claude-design.ccrdesk.top/design";
|
||||
const DESIGN_FRONTEND_URL_ENV_KEYS = ["CCR_CLAUDE_DESIGN_FRONTEND_URL", "CCR_CLAUDE_DESIGN_WEB_URL"];
|
||||
const DESIGN_FRONTEND_ASSETS_ORIGIN_ENV_KEYS = ["CCR_CLAUDE_DESIGN_FRONTEND_ORIGIN", "CCR_CLAUDE_DESIGN_ASSETS_ORIGIN"];
|
||||
const SHIP_FRONTEND_URL_ENV_KEYS = ["CCR_CLAUDE_SHIP_FRONTEND_URL", "CCR_CLAUDE_SHIP_WEB_URL"];
|
||||
const SHIP_FRONTEND_ASSETS_ORIGIN_ENV_KEYS = ["CCR_CLAUDE_SHIP_FRONTEND_ORIGIN", "CCR_CLAUDE_SHIP_ASSETS_ORIGIN", "CCR_CLAUDE_DESIGN_ASSETS_ORIGIN"];
|
||||
|
||||
type ClaudeProductAppOpenConfig = {
|
||||
appPath: string;
|
||||
assetsOriginConfigKeys: string[];
|
||||
assetsOriginEnvKeys: string[];
|
||||
pluginId: string;
|
||||
urlConfigKeys: string[];
|
||||
urlEnvKeys: string[];
|
||||
};
|
||||
|
||||
const CLAUDE_PRODUCT_APP_OPEN_CONFIGS: Record<string, ClaudeProductAppOpenConfig> = {
|
||||
[CLAUDE_DESIGN_PLUGIN_ID]: {
|
||||
appPath: "/design",
|
||||
assetsOriginConfigKeys: ["designFrontendAssetsOrigin", "designFrontendOrigin", "designAssetsOrigin", "frontendAssetsOrigin", "frontendOrigin", "assetsOrigin", "staticAssetsOrigin"],
|
||||
assetsOriginEnvKeys: DESIGN_FRONTEND_ASSETS_ORIGIN_ENV_KEYS,
|
||||
pluginId: CLAUDE_DESIGN_PLUGIN_ID,
|
||||
urlConfigKeys: ["designFrontendUrl", "designWebUrl", "frontendUrl", "webUrl"],
|
||||
urlEnvKeys: DESIGN_FRONTEND_URL_ENV_KEYS
|
||||
},
|
||||
[CLAUDE_SHIP_PLUGIN_ID]: {
|
||||
appPath: "/claude-ship",
|
||||
assetsOriginConfigKeys: ["shipFrontendAssetsOrigin", "shipFrontendOrigin", "shipAssetsOrigin", "frontendAssetsOrigin", "frontendOrigin", "assetsOrigin", "staticAssetsOrigin"],
|
||||
assetsOriginEnvKeys: SHIP_FRONTEND_ASSETS_ORIGIN_ENV_KEYS,
|
||||
pluginId: CLAUDE_SHIP_PLUGIN_ID,
|
||||
urlConfigKeys: ["shipFrontendUrl", "shipWebUrl", "frontendUrl", "webUrl"],
|
||||
urlEnvKeys: SHIP_FRONTEND_URL_ENV_KEYS
|
||||
}
|
||||
};
|
||||
|
||||
export function pluginAppUrlForOpen(config: AppConfig, pluginId: string, appUrl: string): string {
|
||||
const frontendOverride = claudeProductFrontendUrlForOpen(config, pluginId);
|
||||
if (frontendOverride) {
|
||||
return frontendOverride;
|
||||
}
|
||||
|
||||
export function pluginAppUrlForOpen(_config: AppConfig, pluginId: string, appUrl: string): string {
|
||||
if (pluginId !== CLAUDE_DESIGN_PLUGIN_ID) {
|
||||
return appUrl;
|
||||
}
|
||||
@@ -26,9 +63,91 @@ export function builtInPluginAppForOpen(pluginId: string, appId?: string): Gatew
|
||||
}
|
||||
|
||||
export function configForPluginAppOpen(config: AppConfig, pluginId: string): AppConfig {
|
||||
return pluginId === CLAUDE_DESIGN_PLUGIN_ID
|
||||
? withClaudeDesignRuntimePluginConfig(config)
|
||||
: config;
|
||||
if (pluginId === CLAUDE_DESIGN_PLUGIN_ID) {
|
||||
return withClaudeDesignRuntimePluginConfig(config);
|
||||
}
|
||||
if (pluginId === CLAUDE_SHIP_PLUGIN_ID) {
|
||||
return withClaudeShipRuntimePluginConfig(config);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function claudeProductFrontendUrlForOpen(config: AppConfig, pluginId: string): string {
|
||||
const product = CLAUDE_PRODUCT_APP_OPEN_CONFIGS[pluginId];
|
||||
if (!product) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const pluginOptions = pluginConfigOptions(config, product.pluginId);
|
||||
const configuredUrl = normalizeHttpUrl(
|
||||
firstConfigValue(pluginOptions, product.urlConfigKeys) ||
|
||||
firstEnvValue(product.urlEnvKeys)
|
||||
);
|
||||
if (configuredUrl) {
|
||||
return configuredUrl;
|
||||
}
|
||||
|
||||
const configuredOrigin = normalizeHttpOrigin(
|
||||
firstConfigValue(pluginOptions, product.assetsOriginConfigKeys) ||
|
||||
firstEnvValue(product.assetsOriginEnvKeys)
|
||||
);
|
||||
return configuredOrigin ? new URL(product.appPath, configuredOrigin).toString() : "";
|
||||
}
|
||||
|
||||
function pluginConfigOptions(config: AppConfig, pluginId: string): Record<string, unknown> | undefined {
|
||||
const plugin = config.plugins.find((candidate: GatewayPluginConfig) => (
|
||||
candidate.enabled !== false &&
|
||||
candidate.id === pluginId
|
||||
));
|
||||
return isRecord(plugin?.config) ? plugin.config : undefined;
|
||||
}
|
||||
|
||||
function firstConfigValue(config: Record<string, unknown> | undefined, keys: string[]): string {
|
||||
if (!config) {
|
||||
return "";
|
||||
}
|
||||
for (const key of keys) {
|
||||
const value = stringValue(config[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function firstEnvValue(keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = stringValue(process.env[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value: string): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHttpOrigin(value: string): string {
|
||||
const normalized = normalizeHttpUrl(value);
|
||||
return normalized ? new URL(normalized).origin : "";
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isLegacyClaudeDesignUrl(value: string): boolean {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const previousTestExports = process.env.CCR_CLAUDE_DESIGN_PLUGIN_TEST_EXPORTS;
|
||||
process.env.CCR_CLAUDE_DESIGN_PLUGIN_TEST_EXPORTS = "1";
|
||||
const claudeDesignPlugin = require(path.resolve(
|
||||
process.cwd(),
|
||||
"packages",
|
||||
"electron",
|
||||
"bundled-plugins",
|
||||
"claude-design",
|
||||
"index.cjs"
|
||||
));
|
||||
if (previousTestExports === undefined) {
|
||||
delete process.env.CCR_CLAUDE_DESIGN_PLUGIN_TEST_EXPORTS;
|
||||
} else {
|
||||
process.env.CCR_CLAUDE_DESIGN_PLUGIN_TEST_EXPORTS = previousTestExports;
|
||||
}
|
||||
|
||||
test("Claude Design local asset discovery supports public, design, and assets roots", () => {
|
||||
const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-design-assets-"));
|
||||
try {
|
||||
const publicRoot = path.join(fixtureRoot, "public");
|
||||
const designRoot = path.join(publicRoot, "design");
|
||||
const assetsRoot = path.join(designRoot, "assets");
|
||||
const v1Root = path.join(assetsRoot, "v1");
|
||||
const designSystemsRoot = path.join(designRoot, "design-systems");
|
||||
mkdirSync(v1Root, { recursive: true });
|
||||
mkdirSync(designSystemsRoot, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(v1Root, "index-BLOCAL123.js"),
|
||||
"console.log('__OMELETTE_ME__ /v1/design createRoot ProjectPage');",
|
||||
"utf8"
|
||||
);
|
||||
writeFileSync(path.join(v1Root, "index-CLOCAL123.css"), "body{color:#111}", "utf8");
|
||||
writeFileSync(
|
||||
path.join(designRoot, "index.html"),
|
||||
[
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
"<script>globalThis.__OMELETTE_ME__={}</script>",
|
||||
'<script type="module" src="/design/assets/v1/index-BLOCAL123.js"></script>',
|
||||
'<link rel="stylesheet" href="/design/assets/v1/index-CLOCAL123.css">',
|
||||
"</head>",
|
||||
'<body><div id="root"></div></body>',
|
||||
"</html>"
|
||||
].join(""),
|
||||
"utf8"
|
||||
);
|
||||
writeFileSync(path.join(designSystemsRoot, "manifest.json"), JSON.stringify({ systems: [] }), "utf8");
|
||||
|
||||
for (const assetDir of [publicRoot, designRoot, assetsRoot]) {
|
||||
const discovered = claudeDesignPlugin.__test.discoverLocalDesignIndexAssets(assetDir);
|
||||
assert.equal(discovered?.source, "local");
|
||||
assert.equal(discovered?.scriptPath, "/design/assets/v1/index-BLOCAL123.js");
|
||||
assert.equal(discovered?.stylePath, "/design/assets/v1/index-CLOCAL123.css");
|
||||
assert.match(discovered?.html || "", /index-BLOCAL123\.js/);
|
||||
|
||||
const localScript = claudeDesignPlugin.__test.readLocalAsset(assetDir, "/design/assets/v1/index-BLOCAL123.js");
|
||||
assert.equal(localScript?.contentType, "application/javascript; charset=utf-8");
|
||||
assert.match(localScript?.body.toString("utf8") || "", /ProjectPage/);
|
||||
}
|
||||
|
||||
for (const assetDir of [publicRoot, designRoot]) {
|
||||
const manifest = claudeDesignPlugin.__test.readLocalAsset(assetDir, "/design/design-systems/manifest.json");
|
||||
assert.equal(manifest?.contentType, "application/json; charset=utf-8");
|
||||
assert.deepEqual(JSON.parse(manifest?.body.toString("utf8") || ""), { systems: [] });
|
||||
}
|
||||
} finally {
|
||||
rmSync(fixtureRoot, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude Design local shell keeps official UI flags while replacing account identity", () => {
|
||||
const shellMe = {
|
||||
accountUuid: "real-account-uuid",
|
||||
organizationUuid: "real-org-uuid",
|
||||
email: "real-user@example.com",
|
||||
displayName: "Real User",
|
||||
growthbookPayload: JSON.stringify({
|
||||
features: {
|
||||
"3252999076": {
|
||||
defaultValue: "template_tray"
|
||||
},
|
||||
"custom-shell-flag": {
|
||||
defaultValue: true
|
||||
}
|
||||
},
|
||||
hashing_algorithm: "djb2"
|
||||
}),
|
||||
hasProjects: true,
|
||||
modelPresets: [
|
||||
{
|
||||
id: "remote-only-model",
|
||||
label: "Remote Only"
|
||||
}
|
||||
]
|
||||
};
|
||||
const runtimeMe = {
|
||||
accountUuid: "00000000-0000-4000-8000-000000000001",
|
||||
organizationUuid: "00000000-0000-4000-8000-000000000002",
|
||||
email: "claude-code-router@example.local",
|
||||
displayName: "Claude Code Router",
|
||||
growthbookPayload: "{}",
|
||||
defaultModelId: "glm-5.2",
|
||||
modelPresets: [
|
||||
{
|
||||
id: "glm-5.2",
|
||||
label: "GLM 5.2"
|
||||
}
|
||||
]
|
||||
};
|
||||
const html = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head></head>",
|
||||
"<body>",
|
||||
`<script type="application/json" id="omelette-me">${JSON.stringify(shellMe)}</script>`,
|
||||
'<div id="root"></div>',
|
||||
"</body>",
|
||||
"</html>"
|
||||
].join("");
|
||||
|
||||
const rendered = claudeDesignPlugin.__test.injectDesignMeIntoHtml(html, runtimeMe);
|
||||
const match = /<script\b(?=[^>]*\bid=["']omelette-me["'])[^>]*>([\s\S]*?)<\/script>/i.exec(rendered);
|
||||
assert.ok(match, "expected rendered shell to include omelette-me JSON");
|
||||
const injectedMe = JSON.parse(match[1]);
|
||||
const injectedGrowthbook = JSON.parse(injectedMe.growthbookPayload);
|
||||
|
||||
assert.equal(injectedMe.accountUuid, runtimeMe.accountUuid);
|
||||
assert.equal(injectedMe.organizationUuid, runtimeMe.organizationUuid);
|
||||
assert.equal(injectedMe.email, runtimeMe.email);
|
||||
assert.equal(injectedMe.displayName, runtimeMe.displayName);
|
||||
assert.equal(injectedMe.hasProjects, true);
|
||||
assert.deepEqual(injectedMe.modelPresets, runtimeMe.modelPresets);
|
||||
assert.equal(injectedGrowthbook.features["3252999076"].defaultValue, "template_tray");
|
||||
assert.equal(injectedGrowthbook.features["custom-shell-flag"].defaultValue, true);
|
||||
assert.equal(injectedGrowthbook.hashing_algorithm, "djb2");
|
||||
assert.equal(rendered.includes("real-user@example.com"), false);
|
||||
assert.equal(rendered.includes("real-account-uuid"), false);
|
||||
});
|
||||
|
||||
test("Claude Design mock covers local frontend project runtime routes", async () => {
|
||||
const runtime = fakeClaudeDesignRuntime();
|
||||
const jsonHeaders = {
|
||||
"connect-protocol-version": "1",
|
||||
"content-type": "application/json"
|
||||
};
|
||||
|
||||
for (const rpcName of ["GetProjectPresence", "GetPrepaidBalance", "GoogleGetStatus", "GetParkedMessage", "CancelChat"]) {
|
||||
const response = await claudeDesignPlugin.__test.routeMockRequest(
|
||||
runtime,
|
||||
"POST",
|
||||
new URL(`http://127.0.0.1/design/anthropic.omelette.api.v1alpha.OmeletteService/${rpcName}`),
|
||||
{ headers: jsonHeaders },
|
||||
Buffer.from(JSON.stringify({ projectId: "project-local-test" }), "utf8")
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers["content-type"], /application\/json/);
|
||||
}
|
||||
|
||||
const parkedMessageProto = await claudeDesignPlugin.__test.routeMockRequest(
|
||||
runtime,
|
||||
"POST",
|
||||
new URL("http://127.0.0.1/design/anthropic.omelette.api.v1alpha.OmeletteService/GetParkedMessage"),
|
||||
{ headers: { "content-type": "application/proto" } },
|
||||
Buffer.alloc(0)
|
||||
);
|
||||
assert.equal(parkedMessageProto.status, 200);
|
||||
assert.deepEqual([...parkedMessageProto.body], [0x08, 0x01]);
|
||||
|
||||
const preview = await claudeDesignPlugin.__test.routeMockRequest(
|
||||
runtime,
|
||||
"GET",
|
||||
new URL("http://127.0.0.1/_t/local-preview-token/v1/design/projects/project-local-test/serve/index.html"),
|
||||
{ headers: {} },
|
||||
Buffer.alloc(0)
|
||||
);
|
||||
assert.equal(preview.status, 200);
|
||||
assert.match(preview.headers["content-type"], /text\/html/);
|
||||
assert.match(String(preview.body), /Claude Design preview pending/);
|
||||
|
||||
const events = await claudeDesignPlugin.__test.routeMockRequest(
|
||||
runtime,
|
||||
"GET",
|
||||
new URL("http://127.0.0.1/design/v1/design/projects/project-local-test/events?tab=tab-local-test"),
|
||||
{ headers: { accept: "text/event-stream" } },
|
||||
Buffer.alloc(0)
|
||||
);
|
||||
assert.equal(events.status, 200);
|
||||
assert.match(events.headers["content-type"], /text\/event-stream/);
|
||||
assert.equal(typeof events.stream, "function");
|
||||
});
|
||||
|
||||
function fakeClaudeDesignRuntime() {
|
||||
return {
|
||||
assetDir: "",
|
||||
frontendUrl: "http://127.0.0.1:6173/design",
|
||||
me: {
|
||||
accountUuid: "00000000-0000-4000-8000-000000000001",
|
||||
displayName: "Claude Code Router",
|
||||
email: "claude-code-router@example.local",
|
||||
organizationUuid: "00000000-0000-4000-8000-000000000002"
|
||||
},
|
||||
pluginId: "claude-design",
|
||||
store: {
|
||||
database: {
|
||||
prepare() {
|
||||
return {
|
||||
bind() {
|
||||
return this;
|
||||
},
|
||||
free() {},
|
||||
getAsObject() {
|
||||
return {};
|
||||
},
|
||||
step() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
},
|
||||
run() {}
|
||||
},
|
||||
persist() {}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -23,12 +23,13 @@ test("Claude Design window CDP options are derived from plugin status", () => {
|
||||
fallbackHosts: ["claude.com", "www.anthropic.com"],
|
||||
host: "claude.ai",
|
||||
paths: ["/design", "/v1/design", "/api"]
|
||||
}
|
||||
},
|
||||
frontendUrl: "http://127.0.0.1:6173/design"
|
||||
});
|
||||
|
||||
assert.deepEqual(options, {
|
||||
backendUrl: "http://127.0.0.1:45678/",
|
||||
hosts: ["claude.ai", "claude.com", "www.anthropic.com"],
|
||||
hosts: ["claude.ai", "127.0.0.1:6173", "claude.com", "www.anthropic.com"],
|
||||
paths: ["/design", "/v1/design", "/api"]
|
||||
});
|
||||
});
|
||||
@@ -55,6 +56,13 @@ test("Claude Design window CDP rewrites matching Claude requests to the local ba
|
||||
),
|
||||
"http://127.0.0.1:45678/_t/541cb539-11c0-4789-9d87-bfa6c87153aa/v1/design/projects/8964ff52-6e42-4b71-9247-1b7c18baee70/serve/index.html?srcmap=1"
|
||||
);
|
||||
assert.equal(
|
||||
claudeDesignRedirectUrlForRequest("http://127.0.0.1:6173/design/v1/design/projects/project-1/events?tab=tab-1", {
|
||||
...options,
|
||||
hosts: [...options.hosts, "127.0.0.1:6173"]
|
||||
}),
|
||||
"http://127.0.0.1:45678/design/v1/design/projects/project-1/events?tab=tab-1"
|
||||
);
|
||||
assert.equal(
|
||||
claudeDesignRedirectUrlForRequest("https://claude.ai/", options),
|
||||
"http://127.0.0.1:45678/"
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { CLAUDE_DESIGN_PLUGIN_ID } from "@ccr/core/contracts/app.ts";
|
||||
import { CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID } from "@ccr/core/contracts/app.ts";
|
||||
import { CCR_DESKTOP_APP_ENV } from "@ccr/core/runtime/desktop-app.ts";
|
||||
import { builtInPluginAppForOpen, configForPluginAppOpen, isLegacyClaudeDesignUrl, pluginAppUrlForOpen } from "@ccr/electron/main/plugin-app-url.ts";
|
||||
|
||||
@@ -56,6 +56,74 @@ test("Claude Design app opening keeps current and non-Design app URLs unchanged"
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude Design app opening uses configured local frontend URL", () => {
|
||||
const config = {
|
||||
plugins: [{
|
||||
config: {
|
||||
frontendUrl: "http://127.0.0.1:6173/design"
|
||||
},
|
||||
enabled: true,
|
||||
id: CLAUDE_DESIGN_PLUGIN_ID
|
||||
}]
|
||||
} as any;
|
||||
|
||||
assert.equal(
|
||||
pluginAppUrlForOpen(
|
||||
config,
|
||||
CLAUDE_DESIGN_PLUGIN_ID,
|
||||
"https://claude-design.ccrdesk.top/design"
|
||||
),
|
||||
"http://127.0.0.1:6173/design"
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude Design app opening derives local frontend URL from configured assets origin", () => {
|
||||
const config = {
|
||||
plugins: [{
|
||||
config: {
|
||||
frontendAssetsOrigin: "http://127.0.0.1:6173/"
|
||||
},
|
||||
enabled: true,
|
||||
id: CLAUDE_DESIGN_PLUGIN_ID
|
||||
}]
|
||||
} as any;
|
||||
|
||||
assert.equal(
|
||||
pluginAppUrlForOpen(
|
||||
config,
|
||||
CLAUDE_DESIGN_PLUGIN_ID,
|
||||
"https://claude-design.ccrdesk.top/design"
|
||||
),
|
||||
"http://127.0.0.1:6173/design"
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude Design app opening uses local frontend URL from environment", () => {
|
||||
withEnv("CCR_CLAUDE_DESIGN_FRONTEND_URL", "http://127.0.0.1:6173/design", () => {
|
||||
assert.equal(
|
||||
pluginAppUrlForOpen(
|
||||
configWithIgnoredSavedDesignHtml,
|
||||
CLAUDE_DESIGN_PLUGIN_ID,
|
||||
"https://claude-design.ccrdesk.top/design"
|
||||
),
|
||||
"http://127.0.0.1:6173/design"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("Claude Ship app opening derives local frontend URL from environment", () => {
|
||||
withEnv("CCR_CLAUDE_SHIP_FRONTEND_ORIGIN", "http://127.0.0.1:6173", () => {
|
||||
assert.equal(
|
||||
pluginAppUrlForOpen(
|
||||
{ plugins: [] } as any,
|
||||
CLAUDE_SHIP_PLUGIN_ID,
|
||||
"https://claude.ai/claude-ship"
|
||||
),
|
||||
"http://127.0.0.1:6173/claude-ship"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("Claude Design resolves to the built-in app without installed plugin config", () => {
|
||||
const pluginApp = builtInPluginAppForOpen(CLAUDE_DESIGN_PLUGIN_ID);
|
||||
|
||||
@@ -70,12 +138,16 @@ test("Claude Design app opening injects the runtime plugin config", () => {
|
||||
withDesktopRuntime(() => {
|
||||
const config = { plugins: [] } as any;
|
||||
const runtimeConfig = configForPluginAppOpen(config, CLAUDE_DESIGN_PLUGIN_ID);
|
||||
const shipConfig = configForPluginAppOpen(config, CLAUDE_SHIP_PLUGIN_ID);
|
||||
|
||||
assert.equal(config.plugins.length, 0);
|
||||
assert.equal(runtimeConfig.plugins.length, 1);
|
||||
assert.equal(runtimeConfig.plugins[0].id, CLAUDE_DESIGN_PLUGIN_ID);
|
||||
assert.equal(runtimeConfig.plugins[0].module, bundledPluginModule("claude-design"));
|
||||
assert.equal(configForPluginAppOpen(config, "claude-ship"), config);
|
||||
assert.equal(shipConfig.plugins.length, 1);
|
||||
assert.equal(shipConfig.plugins[0].id, CLAUDE_SHIP_PLUGIN_ID);
|
||||
assert.equal(shipConfig.plugins[0].module, bundledPluginModule("claude-ship"));
|
||||
assert.equal(configForPluginAppOpen(config, "unknown-plugin"), config);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,3 +190,17 @@ function withDesktopRuntime(run: () => void): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withEnv(name: string, value: string, run: () => void): void {
|
||||
const previousValue = process.env[name];
|
||||
try {
|
||||
process.env[name] = value;
|
||||
run();
|
||||
} finally {
|
||||
if (previousValue === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = previousValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user