Files
paseo/packages/app/src/plugins/registry.ts
T
Mohamed Boudra 42806cdbf9 Let plugins play audio on every client (#5976)
* feat(plugins): play audio files on every client

* fix(audio): preserve voice output and isolate waiting cues

* fix(plugins): normalize audio MIME parameters

* refactor(audio): share PCM conversion and keep plugin playback file-only
2026-10-03 16:56:59 +01:00

208 lines
7.6 KiB
TypeScript

import type { AudioEngine } from "@/audio";
import { useMemo, useSyncExternalStore } from "react";
import { QueryClient } from "@tanstack/react-query";
import { createPaseoApi } from "@getpaseo/client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import { resolveAppVersion } from "@/utils/app-version";
import { createPluginClientRuntime } from "./client-runtime";
import { runPluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
type CatalogPlugin = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>[number];
export class PluginRegistry {
private readonly byHost = new Map<string, InstalledPlugin[]>();
private readonly listeners = new Set<() => void>();
private snapshot: InstalledPlugin[] = [];
private readonly disposed = new WeakSet<InstalledPlugin>();
private readonly evaluationErrors = new Map<string, string>();
constructor(
private readonly dependencies: {
version: string | null;
createRuntime: typeof createPluginClientRuntime;
},
) {}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): InstalledPlugin[] => this.snapshot;
getEvaluationError(serverId: string, pluginId: string): string | undefined {
return this.evaluationErrors.get(`${serverId}/${pluginId}`);
}
installCatalog(
serverId: string,
catalog: CatalogPlugin[],
options: {
replacePluginId?: string;
client: DaemonClient;
audio: Pick<AudioEngine, "play">;
},
): boolean {
const previous = this.byHost.get(serverId) ?? [];
const previousTimelineBundles = previous
.filter((plugin) => plugin.timelineTransformers.length > 0)
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
const preserved = catalog.flatMap((entry) => {
const existing = previous.find(
(plugin) =>
plugin.id !== options.replacePluginId &&
plugin.id === entry.id &&
plugin.clientBundle === entry.clientBundle &&
plugin.requirements?.paseo === entry.requirements?.paseo,
);
return existing ? [existing] : [];
});
const removed = previous.filter((plugin) => !preserved.includes(plugin));
if (removed.length > 0) {
this.byHost.set(serverId, preserved);
this.publish();
for (const plugin of removed) this.dispose(plugin);
}
const installed = catalog.flatMap((entry) => {
const key = `${serverId}/${entry.id}`;
let installation: InstalledPlugin | undefined;
try {
if (!entry.clientBundle) return [];
assertPluginCompatibility({ ...entry, version: this.dependencies.version, runtime: "app" });
const existing = preserved.find(
(plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle,
);
if (existing) {
this.evaluationErrors.delete(key);
return [existing];
}
const client = options.client;
installation = {
lifetime: new AbortController(),
paseo: createPaseoApi(client),
invoke: (method, input) => client.invokePluginRpc(entry.id, method, input),
id: entry.id,
serverId,
clientBundle: entry.clientBundle,
requirements: entry.requirements,
queryClient: new QueryClient(),
cleanup: () => undefined,
surfaces: [],
settingsScreens: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
timelineRenderers: [],
};
const runtime = this.dependencies.createRuntime(installation, options.audio);
const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () =>
this.publish(),
);
Object.assign(installation, evaluated);
const paseo = installation.paseo;
installation.cleanup = async () => {
const results = await Promise.allSettled([paseo.dispose(), evaluated.cleanup()]);
const failures = results.filter((result) => result.status === "rejected");
if (failures.length)
throw new AggregateError(
failures.map((result) => result.reason),
"Plugin cleanup failed",
);
};
this.evaluationErrors.delete(key);
return [installation];
} catch (error) {
installation?.lifetime.abort();
void installation?.paseo
.dispose()
.catch((failure) => console.warn(`[Plugins] API cleanup failed for ${key}`, failure));
this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error));
console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error);
return [];
}
});
const configuredIds = new Set(catalog.map((entry) => entry.id));
for (const key of this.evaluationErrors.keys()) {
if (key.startsWith(`${serverId}/`) && !configuredIds.has(key.slice(serverId.length + 1))) {
this.evaluationErrors.delete(key);
}
}
this.byHost.set(serverId, installed);
this.publish();
const installedTimelineBundles = installed
.filter((plugin) => plugin.timelineTransformers.length > 0)
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
return (
previousTimelineBundles.length !== installedTimelineBundles.length ||
previousTimelineBundles.some((bundle, index) => bundle !== installedTimelineBundles[index])
);
}
removeHost(serverId: string): void {
const installed = this.byHost.get(serverId);
if (!installed) return;
for (const plugin of installed) this.dispose(plugin);
for (const key of this.evaluationErrors.keys()) {
if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key);
}
this.byHost.delete(serverId);
this.publish();
}
private dispose(plugin: InstalledPlugin): void {
if (this.disposed.has(plugin)) return;
this.disposed.add(plugin);
plugin.lifetime.abort();
plugin.queryClient.clear();
try {
void Promise.resolve(plugin.cleanup()).catch((error) => {
console.warn(`[Plugins] Cleanup failed for ${plugin.serverId}/${plugin.id}`, error);
});
} catch (error) {
console.warn(`[Plugins] Cleanup failed for ${plugin.serverId}/${plugin.id}`, error);
}
}
private publish(): void {
this.snapshot = [...this.byHost.values()]
.flat()
.sort((left, right) =>
`${left.serverId}/${left.id}`.localeCompare(`${right.serverId}/${right.id}`),
);
for (const listener of this.listeners) listener();
}
}
export const pluginRegistry = new PluginRegistry({
version: resolveAppVersion(),
createRuntime: createPluginClientRuntime,
});
export function useInstalledPlugins(): InstalledPlugin[] {
return useSyncExternalStore(
pluginRegistry.subscribe,
pluginRegistry.getSnapshot,
pluginRegistry.getSnapshot,
);
}
export function useInstalledPlugin(serverId: string, pluginId: string): InstalledPlugin | null {
return (
useInstalledPlugins().find(
(plugin) => plugin.serverId === serverId && plugin.id === pluginId,
) ?? null
);
}
export function usePluginInstallations(pluginId: string): InstalledPlugin[] {
const installed = useInstalledPlugins();
return useMemo(() => installed.filter((plugin) => plugin.id === pluginId), [installed, pluginId]);
}