Files
Recordly/src/lib/extensions/extensionHost.ts
T

1128 lines
31 KiB
TypeScript

/**
* Extension Host — Renderer Process
*
* Manages the lifecycle of extensions in the renderer. Loads extension
* modules, provides the permission-gated host API, and coordinates render hooks.
*/
import { createExtensionModuleUrl, resolveExtensionRelativeFileUrl } from "./fileUrls";
import * as PhosphorIcons from "@phosphor-icons/react";
import type {
ContributedCursorStyle,
ContributedFrame,
ContributedWallpaper,
CursorEffectContext,
CursorEffectFn,
ExtensionEvent,
ExtensionEventHandler,
ExtensionEventType,
ExtensionInfo,
ExtensionSettingsPanel,
FrameInstance,
RecordlyExtensionAPI,
RecordlyExtensionModule,
RenderHookContext,
RenderHookFn,
RenderHookPhase,
} from "./types";
const EXTENSION_SETTINGS_STORAGE_KEY = "recordly.extension-settings.v1";
// ---------------------------------------------------------------------------
// Security: Hide electronAPI from extension code
// ---------------------------------------------------------------------------
// Extensions run via dynamic import() in the renderer's main world. Since
// contextBridge.exposeInMainWorld puts electronAPI on window in the same
// world, extensions could abuse it to read arbitrary files, open URLs, etc.
// We replace the global with a Proxy that blocks access while extension code
// is executing (import + activate). The reference is stashed so that only
// app code (which runs outside of extension activation) can reach it.
// ---------------------------------------------------------------------------
let _extensionActivationDepth = 0;
let _realElectronAPI: typeof window.electronAPI | undefined;
function installElectronAPIGuard(): void {
if (typeof window === "undefined" || _realElectronAPI !== undefined) return;
const real = window.electronAPI;
if (!real) return;
_realElectronAPI = real;
const proxy = new Proxy(real, {
get(target, prop, receiver) {
if (_extensionActivationDepth > 0) {
console.warn(
`[extensions] Blocked extension access to electronAPI.${String(prop)}`,
);
return undefined;
}
return Reflect.get(target, prop, receiver);
},
});
// contextBridge.exposeInMainWorld creates a non-configurable property on
// window. Attempting Object.defineProperty on it throws "Cannot redefine
// property: electronAPI" which crashes the renderer (and makes the
// transparent HUD window invisible). Only redefine when the descriptor
// allows it; otherwise the proxy is still used internally via
// _realElectronAPI so app code keeps working.
const desc = Object.getOwnPropertyDescriptor(window, "electronAPI");
if (!desc || desc.configurable) {
Object.defineProperty(window, "electronAPI", {
value: proxy,
writable: false,
configurable: false,
});
}
}
installElectronAPIGuard();
interface RegisteredRenderHook {
extensionId: string;
phase: RenderHookPhase;
hook: RenderHookFn;
}
interface RegisteredCursorEffect {
extensionId: string;
effect: CursorEffectFn;
}
interface RegisteredSettingsPanel {
extensionId: string;
panel: ExtensionSettingsPanel;
}
interface RegisteredWallpaper {
id: string;
extensionId: string;
wallpaper: ContributedWallpaper;
/** Resolved absolute URL to the wallpaper file */
resolvedUrl: string;
/** Resolved absolute URL to the thumbnail (or resolvedUrl if absent) */
resolvedThumbnailUrl: string;
}
interface RegisteredCursorStyle {
id: string;
extensionId: string;
cursorStyle: ContributedCursorStyle;
/** Resolved absolute URL to the default cursor image */
resolvedDefaultUrl: string;
/** Resolved absolute URL to the click image (if provided) */
resolvedClickUrl?: string;
}
interface ActiveExtension {
info: ExtensionInfo;
module: RecordlyExtensionModule;
disposables: (() => void)[];
}
/**
* The Extension Host manages all loaded extensions and provides
* access to their registered hooks, effects, and settings.
*/
export class ExtensionHost {
private activeExtensions = new Map<string, ActiveExtension>();
private renderHooks: RegisteredRenderHook[] = [];
private cursorEffects: RegisteredCursorEffect[] = [];
private frames: FrameInstance[] = [];
private eventHandlers = new Map<
ExtensionEventType,
{ extensionId: string; handler: ExtensionEventHandler }[]
>();
private settingsPanels: RegisteredSettingsPanel[] = [];
private wallpapers: RegisteredWallpaper[] = [];
private cursorStyles: RegisteredCursorStyle[] = [];
private extensionSettings = new Map<string, Record<string, unknown>>();
private settingChangeCallbacks = new Map<
string,
Set<(settingId: string, value: unknown) => void>
>();
private listeners = new Set<() => void>();
private fullSettingsStore: Record<string, Record<string, unknown>> | null = null;
private persistTimeout: any = null;
private iconPathCache = new Map<string, Path2D>();
// Shared playback/project state — set by the app, queried by extensions
private _videoInfo: { width: number; height: number; durationMs: number; fps: number } | null =
null;
private _videoLayout: {
maskRect: { x: number; y: number; width: number; height: number };
canvasWidth: number;
canvasHeight: number;
borderRadius: number;
padding: number | { top: number; right: number; bottom: number; left: number };
} | null = null;
private _zoomState: { scale: number; focusX: number; focusY: number; progress: number } | null =
null;
private _shadowConfig: { enabled: boolean; intensity: number } = {
enabled: false,
intensity: 0,
};
private _cursorTelemetry: Array<{
timeMs: number;
cx: number;
cy: number;
interactionType?: string;
pressure?: number;
}> = [];
private _smoothedCursor: {
timeMs: number;
cx: number;
cy: number;
trail: Array<{ cx: number; cy: number }>;
} | null = null;
private _keystrokeEvents: Array<{ timeMs: number; key: string; modifiers: string[] }> = [];
private _activeFrame: string | null = null;
private _playbackState: {
currentTimeMs: number;
durationMs: number;
isPlaying: boolean;
} | null = null;
/**
* Activate an extension given its info and resolved module URL.
*/
async activateExtension(info: ExtensionInfo, moduleUrl: string): Promise<void> {
if (this.activeExtensions.has(info.manifest.id)) {
// Deactivate stale instance first so reinstall/reload works
await this.deactivateExtension(info.manifest.id);
}
const disposables: (() => void)[] = [];
let mod: RecordlyExtensionModule | null = null;
try {
this.ensureExtensionSettingsLoaded(info.manifest.id);
// Block electronAPI access while extension code executes
_extensionActivationDepth++;
try {
const loaded: RecordlyExtensionModule = await import(/* @vite-ignore */ moduleUrl);
mod = loaded;
const api = this.createAPI(
info.manifest.id,
info.path,
info.manifest.permissions ?? [],
disposables,
);
await loaded.activate(api);
} finally {
_extensionActivationDepth--;
}
if (!mod) {
throw new Error("Extension module failed to load");
}
this.activeExtensions.set(info.manifest.id, {
info,
module: mod,
disposables,
});
this.notifyListeners();
console.log(`[extensions] Activated: ${info.manifest.name} v${info.manifest.version}`);
} catch (err) {
for (const dispose of disposables.reverse()) {
try {
dispose();
} catch {
/* ignore */
}
}
if (mod) {
try {
await mod.deactivate?.();
} catch {
/* ignore */
}
}
this.notifyListeners();
console.error(`[extensions] Failed to activate ${info.manifest.id}:`, err);
throw err;
}
}
/**
* Deactivate an extension by ID.
*/
async deactivateExtension(extensionId: string): Promise<void> {
const active = this.activeExtensions.get(extensionId);
if (!active) return;
try {
await active.module.deactivate?.();
} catch (err) {
console.warn(`[extensions] Error during deactivate of ${extensionId}:`, err);
}
// Clean up all disposables (unregister hooks, effects, handlers)
for (const dispose of active.disposables) {
try {
dispose();
} catch {
/* ignore */
}
}
this.activeExtensions.delete(extensionId);
this.notifyListeners();
console.log(`[extensions] Deactivated: ${extensionId}`);
}
/**
* Deactivate all extensions.
*/
async deactivateAll(): Promise<void> {
const ids = Array.from(this.activeExtensions.keys());
for (const id of ids) {
await this.deactivateExtension(id);
}
}
// ---------------------------------------------------------------------------
// Render Pipeline Integration
// ---------------------------------------------------------------------------
/**
* Execute all render hooks for a given phase.
*/
executeRenderHooks(phase: RenderHookPhase, context: RenderHookContext): void {
const hooks = this.renderHooks.filter((h) => h.phase === phase);
for (const hook of hooks) {
context.ctx.save();
try {
hook.hook(context);
} catch (err) {
console.warn(
`[extensions] Render hook error (${hook.extensionId}, ${phase}):`,
err,
);
} finally {
context.ctx.restore();
}
}
}
/**
* Execute all cursor effects. Returns true if any effect is still animating.
*/
executeCursorEffects(context: CursorEffectContext): boolean {
let anyActive = false;
for (const effect of this.cursorEffects) {
context.ctx.save();
try {
const stillActive = effect.effect(context);
if (stillActive) anyActive = true;
} catch (err) {
console.warn(`[extensions] Cursor effect error (${effect.extensionId}):`, err);
} finally {
context.ctx.restore();
}
}
return anyActive;
}
/**
* Emit an event to all registered handlers.
*/
emitEvent(event: ExtensionEvent): void {
const handlers = this.eventHandlers.get(event.type);
if (!handlers) return;
for (const { handler } of handlers) {
try {
handler(event);
} catch (err) {
console.warn(`[extensions] Event handler error (${event.type}):`, err);
}
}
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
getActiveExtensions(): ExtensionInfo[] {
return Array.from(this.activeExtensions.values()).map((a) => a.info);
}
/** Quick snapshot of video info for callers that need durationMs etc. */
getVideoInfoSnapshot(): {
width: number;
height: number;
durationMs: number;
fps: number;
} | null {
return this._videoInfo;
}
getSettingsPanels(): RegisteredSettingsPanel[] {
return [...this.settingsPanels];
}
hasRenderHooks(phase: RenderHookPhase): boolean {
return this.renderHooks.some((h) => h.phase === phase);
}
hasCursorEffects(): boolean {
return this.cursorEffects.length > 0;
}
getExtensionSetting(extensionId: string, settingId: string): unknown {
this.ensureExtensionSettingsLoaded(extensionId);
return this.extensionSettings.get(extensionId)?.[settingId];
}
setExtensionSetting(extensionId: string, settingId: string, value: unknown): void {
this.ensureExtensionSettingsLoaded(extensionId);
this.extensionSettings.get(extensionId)![settingId] = value;
this.persistExtensionSettings(extensionId);
// Notify per-extension setting change listeners
const cbs = this.settingChangeCallbacks.get(extensionId);
if (cbs) {
for (const cb of cbs) {
try {
cb(settingId, value);
} catch {
/* ignore */
}
}
}
this.notifyListeners();
}
/**
* Get all registered device frames from active extensions.
*/
getFrames(): FrameInstance[] {
return [...this.frames];
}
/**
* Get all contributed wallpapers from active extensions.
*/
getContributedWallpapers(): RegisteredWallpaper[] {
return [...this.wallpapers];
}
/**
* Get all contributed cursor styles from active extensions.
*/
getContributedCursorStyles(): RegisteredCursorStyle[] {
return [...this.cursorStyles];
}
/**
* Subscribe to changes in extensions (activation/deactivation).
*/
onChange(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
// ---------------------------------------------------------------------------
// Shared State — set by the app, read by extensions via API
// ---------------------------------------------------------------------------
setVideoInfo(
info: { width: number; height: number; durationMs: number; fps: number } | null,
): void {
this._videoInfo = info;
}
setVideoLayout(
layout: {
maskRect: { x: number; y: number; width: number; height: number };
canvasWidth: number;
canvasHeight: number;
borderRadius: number;
padding: number | { top: number; right: number; bottom: number; left: number };
} | null,
): void {
if (!layout) {
this._videoLayout = null;
return;
}
// Normalize and deep clone padding to exclude UI-only fields like 'linked'
const p = layout.padding;
const normalizedPadding =
typeof p === "number"
? p
: {
top: Number(p.top) || 0,
right: Number(p.right) || 0,
bottom: Number(p.bottom) || 0,
left: Number(p.left) || 0,
};
this._videoLayout = {
maskRect: { ...layout.maskRect },
canvasWidth: layout.canvasWidth,
canvasHeight: layout.canvasHeight,
borderRadius: layout.borderRadius,
padding: normalizedPadding,
};
}
setZoomState(
state: { scale: number; focusX: number; focusY: number; progress: number } | null,
): void {
this._zoomState = state;
}
setShadowConfig(config: { enabled: boolean; intensity: number }): void {
this._shadowConfig = config;
}
setCursorTelemetry(
telemetry: Array<{
timeMs: number;
cx: number;
cy: number;
interactionType?: string;
pressure?: number;
}>,
): void {
this._cursorTelemetry = telemetry;
}
setSmoothedCursor(
cursor: {
timeMs: number;
cx: number;
cy: number;
trail: Array<{ cx: number; cy: number }>;
} | null,
): void {
this._smoothedCursor = cursor
? {
timeMs: cursor.timeMs,
cx: cursor.cx,
cy: cursor.cy,
trail: cursor.trail.map((point) => ({ ...point })),
}
: null;
}
setKeystrokeEvents(events: Array<{ timeMs: number; key: string; modifiers: string[] }>): void {
this._keystrokeEvents = events;
}
setActiveFrame(frameId: string | null): void {
this._activeFrame = frameId;
}
setPlaybackState(
state: { currentTimeMs: number; durationMs: number; isPlaying: boolean } | null,
): void {
this._playbackState = state;
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
private notifyListeners(): void {
for (const listener of this.listeners) {
try {
listener();
} catch {
/* ignore */
}
}
}
private readPersistedSettingsStore(): Record<string, Record<string, unknown>> {
if (typeof window === "undefined" || !window.localStorage) {
return {};
}
try {
const raw = window.localStorage.getItem(EXTENSION_SETTINGS_STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
return parsed as Record<string, Record<string, unknown>>;
} catch {
return {};
}
}
private writePersistedSettingsStore(store: Record<string, Record<string, unknown>>): void {
if (typeof window === "undefined" || !window.localStorage) {
return;
}
try {
window.localStorage.setItem(EXTENSION_SETTINGS_STORAGE_KEY, JSON.stringify(store));
} catch {
// Ignore storage quota / privacy mode failures.
}
}
private getFullSettingsStore(): Record<string, Record<string, unknown>> {
if (this.fullSettingsStore) {
return this.fullSettingsStore;
}
this.fullSettingsStore = this.readPersistedSettingsStore();
return this.fullSettingsStore;
}
private ensureExtensionSettingsLoaded(extensionId: string): void {
if (this.extensionSettings.has(extensionId)) {
return;
}
const store = this.getFullSettingsStore();
const persisted = store[extensionId];
const normalized =
persisted && typeof persisted === "object" && !Array.isArray(persisted)
? { ...persisted }
: {};
this.extensionSettings.set(extensionId, normalized);
}
private persistExtensionSettings(extensionId: string): void {
const store = this.getFullSettingsStore();
const settings = this.extensionSettings.get(extensionId) ?? {};
if (Object.keys(settings).length === 0) {
delete store[extensionId];
} else {
store[extensionId] = { ...settings };
}
// Debounce the actual write to localStorage to avoid blocking the UI thread during rapid changes
if (this.persistTimeout) {
clearTimeout(this.persistTimeout);
}
this.persistTimeout = setTimeout(() => {
this.writePersistedSettingsStore(store);
this.persistTimeout = null;
}, 500);
}
/**
* Create the permission-gated API object for an extension.
*/
private createAPI(
extensionId: string,
extensionPath: string,
permissions: string[],
disposables: (() => void)[],
): RecordlyExtensionAPI {
const host = this;
const perms = new Set(permissions);
function requirePermission(perm: string, method: string): void {
if (!perms.has(perm)) {
throw new Error(
`Extension '${extensionId}' lacks '${perm}' permission required for ${method}()`,
);
}
}
function getEventPermission(
event: ExtensionEventType,
): "cursor" | "timeline" | "export" | null {
if (event.startsWith("cursor:")) {
return "cursor";
}
if (event.startsWith("playback:") || event.startsWith("timeline:")) {
return "timeline";
}
if (event.startsWith("export:")) {
return "export";
}
return null;
}
return {
registerRenderHook(phase: RenderHookPhase, hook: RenderHookFn): () => void {
requirePermission("render", "registerRenderHook");
const entry: RegisteredRenderHook = { extensionId, phase, hook };
host.renderHooks.push(entry);
const dispose = () => {
const index = host.renderHooks.indexOf(entry);
if (index >= 0) host.renderHooks.splice(index, 1);
};
disposables.push(dispose);
return dispose;
},
registerCursorEffect(effect: CursorEffectFn): () => void {
requirePermission("cursor", "registerCursorEffect");
const entry: RegisteredCursorEffect = { extensionId, effect };
host.cursorEffects.push(entry);
const dispose = () => {
const index = host.cursorEffects.indexOf(entry);
if (index >= 0) host.cursorEffects.splice(index, 1);
};
disposables.push(dispose);
return dispose;
},
registerFrame(frame: ContributedFrame): () => void {
requirePermission("ui", "registerFrame");
const resolveFramePath = (relativePath: string): string =>
resolveExtensionRelativeFileUrl(extensionPath, relativePath);
let filePath: string;
if (frame.draw) {
// Generate a small thumbnail for the picker UI
const thumbW = 192;
const thumbH = 108;
const c = document.createElement("canvas");
c.width = thumbW;
c.height = thumbH;
const ctx = c.getContext("2d");
if (ctx) frame.draw(ctx, thumbW, thumbH);
filePath = c.toDataURL("image/png");
} else if (frame.dataUrl) {
filePath = frame.dataUrl;
} else if (frame.file) {
filePath = resolveFramePath(frame.file);
} else {
throw new Error("Device frame must provide either draw, file, or dataUrl");
}
let thumbnailPath = filePath;
if (frame.thumbnail) {
thumbnailPath = resolveFramePath(frame.thumbnail);
}
const instance: FrameInstance = {
id: `${extensionId}/${frame.id}`,
extensionId,
label: frame.label,
category: frame.category,
filePath,
thumbnailPath,
screenInsets: frame.screenInsets,
appearance: frame.appearance,
draw: frame.draw,
};
host.frames.push(instance);
host.notifyListeners();
const dispose = () => {
const index = host.frames.indexOf(instance);
if (index >= 0) host.frames.splice(index, 1);
host.notifyListeners();
};
disposables.push(dispose);
return dispose;
},
registerWallpaper(wallpaper: ContributedWallpaper): () => void {
requirePermission("assets", "registerWallpaper");
const resolvedUrl = resolveExtensionRelativeFileUrl(extensionPath, wallpaper.file);
const resolvedThumbnailUrl = wallpaper.thumbnail
? resolveExtensionRelativeFileUrl(extensionPath, wallpaper.thumbnail)
: resolvedUrl;
const entry: RegisteredWallpaper = {
id: `${extensionId}/${wallpaper.id}`,
extensionId,
wallpaper,
resolvedUrl,
resolvedThumbnailUrl,
};
host.wallpapers.push(entry);
host.notifyListeners();
const dispose = () => {
const index = host.wallpapers.indexOf(entry);
if (index >= 0) host.wallpapers.splice(index, 1);
host.notifyListeners();
};
disposables.push(dispose);
return dispose;
},
registerCursorStyle(cursorStyle: ContributedCursorStyle): () => void {
requirePermission("assets", "registerCursorStyle");
const resolvedDefaultUrl = resolveExtensionRelativeFileUrl(
extensionPath,
cursorStyle.defaultImage,
);
const resolvedClickUrl = cursorStyle.clickImage
? resolveExtensionRelativeFileUrl(extensionPath, cursorStyle.clickImage)
: undefined;
const entry: RegisteredCursorStyle = {
id: `${extensionId}/${cursorStyle.id}`,
extensionId,
cursorStyle,
resolvedDefaultUrl,
resolvedClickUrl,
};
host.cursorStyles.push(entry);
host.notifyListeners();
const dispose = () => {
const index = host.cursorStyles.indexOf(entry);
if (index >= 0) host.cursorStyles.splice(index, 1);
host.notifyListeners();
};
disposables.push(dispose);
return dispose;
},
on(event: ExtensionEventType, handler: ExtensionEventHandler): () => void {
const requiredPermission = getEventPermission(event);
if (requiredPermission) {
requirePermission(requiredPermission, `on(${event})`);
}
if (!host.eventHandlers.has(event)) {
host.eventHandlers.set(event, []);
}
const entry = { extensionId, handler };
host.eventHandlers.get(event)!.push(entry);
const dispose = () => {
const list = host.eventHandlers.get(event);
if (!list) return;
const index = list.indexOf(entry);
if (index >= 0) list.splice(index, 1);
};
disposables.push(dispose);
return dispose;
},
registerSettingsPanel(panel: ExtensionSettingsPanel): () => void {
requirePermission("ui", "registerSettingsPanel");
const entry: RegisteredSettingsPanel = { extensionId, panel };
host.settingsPanels.push(entry);
host.notifyListeners();
const dispose = () => {
const index = host.settingsPanels.indexOf(entry);
if (index >= 0) host.settingsPanels.splice(index, 1);
host.notifyListeners();
};
disposables.push(dispose);
return dispose;
},
getSetting(settingId: string): unknown {
host.ensureExtensionSettingsLoaded(extensionId);
return host.extensionSettings.get(extensionId)?.[settingId];
},
setSetting(settingId: string, value: unknown): void {
host.ensureExtensionSettingsLoaded(extensionId);
host.extensionSettings.get(extensionId)![settingId] = value;
host.persistExtensionSettings(extensionId);
// Notify per-extension setting change listeners
const cbs = host.settingChangeCallbacks.get(extensionId);
if (cbs) {
for (const cb of cbs) {
try {
cb(settingId, value);
} catch {
/* ignore */
}
}
}
host.notifyListeners();
},
resolveAsset(relativePath: string): string {
requirePermission("assets", "resolveAsset");
return resolveExtensionRelativeFileUrl(extensionPath, relativePath);
},
playSound(relativePath: string, options?: { volume?: number }): () => void {
requirePermission("audio", "playSound");
const audio = new Audio(
resolveExtensionRelativeFileUrl(extensionPath, relativePath),
);
audio.volume = Math.max(0, Math.min(1, options?.volume ?? 1));
audio.play().catch((err) => {
console.warn(`[ext:${extensionId}] Failed to play sound:`, err);
});
return () => {
audio.pause();
audio.src = "";
};
},
log(message: string, ...args: unknown[]): void {
console.log(`[ext:${extensionId}]`, message, ...args);
},
// ----------------------------------------------------------------
// Query APIs
// ----------------------------------------------------------------
getVideoInfo() {
return host._videoInfo ? { ...host._videoInfo } : null;
},
getVideoLayout() {
if (!host._videoLayout) return null;
const p = host._videoLayout.padding;
return {
maskRect: { ...host._videoLayout.maskRect },
canvasWidth: host._videoLayout.canvasWidth,
canvasHeight: host._videoLayout.canvasHeight,
borderRadius: host._videoLayout.borderRadius,
padding: typeof p === "number" ? p : { ...p },
};
},
getCursorAt(timeMs: number) {
const t = host._cursorTelemetry;
if (!t || t.length === 0) return null;
if (timeMs <= t[0].timeMs) return { ...t[0], timeMs };
if (timeMs >= t[t.length - 1].timeMs) return { ...t[t.length - 1], timeMs };
let lo = 0;
let hi = t.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (t[mid].timeMs <= timeMs) {
lo = mid;
} else {
hi = mid;
}
}
const a = t[lo];
const b = t[hi];
const span = b.timeMs - a.timeMs;
const frac = span > 0 ? (timeMs - a.timeMs) / span : 0;
return {
...a,
cx: a.cx + (b.cx - a.cx) * frac,
cy: a.cy + (b.cy - a.cy) * frac,
timeMs,
};
},
getSmoothedCursor() {
if (!host._smoothedCursor) {
return null;
}
return {
timeMs: host._smoothedCursor.timeMs,
cx: host._smoothedCursor.cx,
cy: host._smoothedCursor.cy,
trail: host._smoothedCursor.trail.map((point) => ({ ...point })),
};
},
getZoomState() {
return host._zoomState ? { ...host._zoomState } : null;
},
getShadowConfig() {
return { ...host._shadowConfig };
},
getKeystrokesInRange(startMs: number, endMs: number) {
return host._keystrokeEvents
.filter((e) => e.timeMs >= startMs && e.timeMs <= endMs)
.map((e) => ({ ...e }));
},
getAspectRatio() {
if (!host._videoLayout) return null;
return host._videoLayout.canvasWidth / host._videoLayout.canvasHeight;
},
getActiveFrame() {
return host._activeFrame;
},
isExtensionActive(extId: string) {
return host.activeExtensions.has(extId);
},
getPlaybackState() {
return host._playbackState ? { ...host._playbackState } : null;
},
getCanvasDimensions() {
if (!host._videoLayout) return null;
return {
width: host._videoLayout.canvasWidth,
height: host._videoLayout.canvasHeight,
};
},
drawIcon(
ctx: CanvasRenderingContext2D,
name: string,
x: number,
y: number,
size: number,
color: string,
weight: "thin" | "light" | "regular" | "bold" | "fill" = "regular",
): void {
const cacheKey = `${name}:${weight}`;
let path = host.iconPathCache.get(cacheKey);
if (!path) {
// 1. Get the Icon component from the project's library
const Icon = (PhosphorIcons as any)[name];
if (!Icon) return;
try {
// 2. Extract path data by dry-running the component
const Icon = (PhosphorIcons as any)[name];
if (!Icon) {
console.warn(`[extensions] Icon ${name} not found in Phosphor library`);
return;
}
const element = (Icon as any).render?.({ weight }, null);
const weights = element?.props?.weights;
const definition = weights?.get(weight);
const children = definition?.props?.children;
// Handle both single path and array of paths
let pathElement = children;
if (Array.isArray(children)) {
pathElement = children.find((c: any) => c?.type === 'path' || c?.props?.d);
}
const pathData = pathElement?.props?.d;
if (pathData) {
path = new Path2D(pathData);
host.iconPathCache.set(cacheKey, path);
} else {
console.warn(`[extensions] No path data found for ${name}:${weight}`, { element, children });
}
} catch (err) {
console.error(`[extensions] Failed to extract path for icon ${name}:`, err);
return;
}
}
if (path) {
ctx.save();
ctx.translate(x, y);
const scale = size / 256; // Phosphor icons use a 256x256 grid
ctx.scale(scale, scale);
ctx.translate(-128, -128); // Center the icon
ctx.fillStyle = color;
ctx.fill(path);
ctx.restore();
}
},
onSettingChange(callback: (settingId: string, value: unknown) => void): () => void {
if (!host.settingChangeCallbacks.has(extensionId)) {
host.settingChangeCallbacks.set(extensionId, new Set());
}
host.settingChangeCallbacks.get(extensionId)!.add(callback);
const dispose = () => {
const cbs = host.settingChangeCallbacks.get(extensionId);
if (cbs) {
cbs.delete(callback);
if (cbs.size === 0) host.settingChangeCallbacks.delete(extensionId);
}
};
disposables.push(dispose);
return dispose;
},
getAllSettings(): Record<string, unknown> {
host.ensureExtensionSettingsLoaded(extensionId);
return { ...(host.extensionSettings.get(extensionId) ?? {}) };
},
};
}
async syncConfiguredExtensions(discovered: ExtensionInfo[]): Promise<void> {
const desired = new Map(
discovered
.filter((ext) => ext.status === "active")
.map((ext) => [ext.manifest.id, ext]),
);
for (const activeId of Array.from(this.activeExtensions.keys())) {
if (!desired.has(activeId)) {
await this.deactivateExtension(activeId);
}
}
for (const ext of discovered) {
if (ext.status !== "active" || this.activeExtensions.has(ext.manifest.id)) {
continue;
}
try {
const moduleUrl = createExtensionModuleUrl(ext.path, ext.manifest.main);
await this.activateExtension(ext, moduleUrl);
} catch (err) {
console.error(
`[extensions] Failed to activate configured extension ${ext.manifest.id}:`,
err,
);
}
}
}
// ---------------------------------------------------------------------------
// Auto-Activation (idempotent — safe to call from multiple places)
// ---------------------------------------------------------------------------
private _autoActivatePromise: Promise<void> | null = null;
/**
* Discover and activate all builtin extensions. Idempotent — only runs
* the discovery/activation sequence once no matter how many callers invoke it.
*/
autoActivateBuiltins(): Promise<void> {
if (this._autoActivatePromise) return this._autoActivatePromise;
this._autoActivatePromise = (async () => {
// Use the real (unproxied) reference — this is app code, not extension code
const api = _realElectronAPI ?? window.electronAPI;
if (!api?.extensionsDiscover) return;
try {
const discovered: ExtensionInfo[] = await api.extensionsDiscover();
await this.syncConfiguredExtensions(discovered);
} catch (err) {
console.error("[extensions] Failed to discover extensions:", err);
}
})();
return this._autoActivatePromise;
}
}
/**
* Singleton extension host instance for the renderer process.
*/
export const extensionHost = new ExtensionHost();