Files
openchamber/packages/ui/src/lib/projectMeta.ts
T
Bohdan Triapitsyn 2031e3b4a8 Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
2026-06-02 00:43:05 +03:00

236 lines
7.8 KiB
TypeScript

import React from 'react';
import type { ProjectEntry } from '@/lib/api/types';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import type { IconName } from "@/components/icon/icons";
type ThemeVariant = 'light' | 'dark';
export type ProjectIconImageOptions = { themeVariant?: ThemeVariant; iconColor?: string };
const PROJECT_ICON_OBJECT_URL_CACHE_LIMIT = 200;
type ProjectIconObjectUrlCacheEntry = {
url?: string;
promise?: Promise<string | null>;
};
type ProjectIconObjectUrlRequest = {
cacheKey: string;
projectId: string;
query: URLSearchParams;
};
const projectIconObjectUrlCache = new Map<string, ProjectIconObjectUrlCacheEntry>();
export const PROJECT_ICONS: Array<{ key: string; Icon: IconName; label: string }> = [
{ key: 'code', Icon: 'code-box', label: 'Code' },
{ key: 'terminal', Icon: 'terminal-box', label: 'Terminal' },
{ key: 'rocket', Icon: 'rocket', label: 'Rocket' },
{ key: 'flask', Icon: 'flask', label: 'Lab' },
{ key: 'gamepad', Icon: 'gamepad', label: 'Game' },
{ key: 'briefcase', Icon: 'briefcase', label: 'Work' },
{ key: 'home', Icon: 'home', label: 'Home' },
{ key: 'globe', Icon: 'global', label: 'Web' },
{ key: 'leaf', Icon: 'leaf', label: 'Nature' },
{ key: 'shield', Icon: 'shield', label: 'Security' },
{ key: 'palette', Icon: 'palette', label: 'Design' },
{ key: 'server', Icon: 'server', label: 'Server' },
{ key: 'phone', Icon: 'smartphone', label: 'Mobile' },
{ key: 'database', Icon: 'database-2', label: 'Data' },
{ key: 'lightbulb', Icon: 'lightbulb', label: 'Idea' },
{ key: 'music', Icon: 'music', label: 'Music' },
{ key: 'camera', Icon: 'camera', label: 'Media' },
{ key: 'book', Icon: 'book-open', label: 'Docs' },
{ key: 'heart', Icon: 'heart', label: 'Favorite' },
];
export const PROJECT_ICON_MAP: Record<string, IconName> = Object.fromEntries(
PROJECT_ICONS.map((i) => [i.key, i.Icon])
);
export const PROJECT_COLORS: Array<{ key: string; label: string; cssVar: string }> = [
{ key: 'keyword', label: 'Purple', cssVar: 'var(--syntax-keyword)' },
{ key: 'string', label: 'Green', cssVar: 'var(--syntax-string)' },
{ key: 'number', label: 'Pink', cssVar: 'var(--syntax-number)' },
{ key: 'type', label: 'Gold', cssVar: 'var(--syntax-type)' },
{ key: 'constant', label: 'Cyan', cssVar: 'var(--syntax-constant)' },
{ key: 'comment', label: 'Muted', cssVar: 'var(--syntax-comment)' },
{ key: 'error', label: 'Red', cssVar: 'var(--status-error)' },
{ key: 'primary', label: 'Blue', cssVar: 'var(--primary)' },
{ key: 'success', label: 'Green', cssVar: 'var(--status-success)' },
];
export const PROJECT_COLOR_MAP: Record<string, string> = Object.fromEntries(
PROJECT_COLORS.map((c) => [c.key, c.cssVar])
);
const buildProjectIconQuery = (updatedAt: number | null | undefined, options?: ProjectIconImageOptions): URLSearchParams | null => {
if (typeof updatedAt !== 'number' || updatedAt <= 0) {
return null;
}
const params = new URLSearchParams({ v: String(updatedAt) });
if (typeof options?.iconColor === 'string' && options.iconColor.trim()) {
params.set('iconColor', options.iconColor.trim());
}
if (options?.themeVariant === 'light' || options?.themeVariant === 'dark') {
params.set('theme', options.themeVariant);
}
return params;
};
const buildProjectIconObjectUrlCacheKey = (
projectId: string,
query: URLSearchParams,
): string | null => {
if (!projectId) return null;
return [
getRuntimeApiBaseUrl() || 'same-origin',
projectId,
query.toString(),
].join('|');
};
const buildProjectIconObjectUrlRequest = (
projectId: string,
updatedAt: number | null | undefined,
options?: ProjectIconImageOptions,
): ProjectIconObjectUrlRequest | null => {
const query = buildProjectIconQuery(updatedAt, options);
if (!query) return null;
const cacheKey = buildProjectIconObjectUrlCacheKey(projectId, query);
if (!cacheKey) return null;
return { cacheKey, projectId, query };
};
const trimProjectIconObjectUrlCache = (): void => {
while (projectIconObjectUrlCache.size > PROJECT_ICON_OBJECT_URL_CACHE_LIMIT) {
const firstKey = projectIconObjectUrlCache.keys().next().value;
if (typeof firstKey !== 'string') return;
const entry = projectIconObjectUrlCache.get(firstKey);
if (entry?.url && typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') {
URL.revokeObjectURL(entry.url);
}
projectIconObjectUrlCache.delete(firstKey);
}
};
const loadProjectIconObjectUrl = (
request: ProjectIconObjectUrlRequest,
): Promise<string | null> => {
const cached = projectIconObjectUrlCache.get(request.cacheKey);
if (cached?.url) return Promise.resolve(cached.url);
if (cached?.promise) return cached.promise;
const promise = runtimeFetch(`/api/projects/${encodeURIComponent(request.projectId)}/icon`, {
method: 'GET',
headers: { Accept: 'image/*' },
query: request.query,
})
.then(async (response) => {
if (!response.ok) return null;
if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return null;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
projectIconObjectUrlCache.set(request.cacheKey, { url });
trimProjectIconObjectUrlCache();
return url;
})
.catch(() => null)
.finally(() => {
const entry = projectIconObjectUrlCache.get(request.cacheKey);
if (entry?.promise === promise && !entry.url) {
projectIconObjectUrlCache.delete(request.cacheKey);
}
});
projectIconObjectUrlCache.set(request.cacheKey, { promise });
return promise;
};
export const useProjectIconImageObjectUrl = (
project: Pick<ProjectEntry, 'id' | 'iconImage'>,
options?: ProjectIconImageOptions,
): string | null => {
const projectId = project.id;
const updatedAt = project.iconImage?.updatedAt;
const themeVariant = options?.themeVariant;
const iconColor = options?.iconColor;
const request = React.useMemo(() => buildProjectIconObjectUrlRequest(projectId, updatedAt, { themeVariant, iconColor }), [
projectId,
updatedAt,
themeVariant,
iconColor,
]);
const [url, setUrl] = React.useState(() => {
return request ? projectIconObjectUrlCache.get(request.cacheKey)?.url ?? null : null;
});
React.useEffect(() => {
if (!request) {
setUrl(null);
return;
}
const cached = projectIconObjectUrlCache.get(request.cacheKey)?.url;
if (cached) {
setUrl(cached);
return;
}
let cancelled = false;
setUrl(null);
void loadProjectIconObjectUrl(request).then((nextUrl) => {
if (!cancelled) setUrl(nextUrl);
});
return () => {
cancelled = true;
};
}, [request]);
return url;
};
export type ProjectIconImageProps = {
project: Pick<ProjectEntry, 'id' | 'iconImage'>;
options?: ProjectIconImageOptions;
className?: string;
alt?: string;
draggable?: boolean;
fallback?: React.ReactNode;
onError?: React.ReactEventHandler<HTMLImageElement>;
};
export const ProjectIconImage: React.FC<ProjectIconImageProps> = ({
project,
options,
className,
alt = '',
draggable = false,
fallback = null,
onError,
}) => {
const src = useProjectIconImageObjectUrl(project, options);
const [failed, setFailed] = React.useState(false);
React.useEffect(() => {
setFailed(false);
}, [src, project.id, project.iconImage?.updatedAt]);
if (!src || failed) return React.createElement(React.Fragment, null, fallback);
return React.createElement('img', {
src,
alt,
className,
draggable,
onError: (event: React.SyntheticEvent<HTMLImageElement>) => {
setFailed(true);
onError?.(event);
},
});
};