From ee524369a7aeb841b5ad64c750a9ae35b067a12f Mon Sep 17 00:00:00 2001 From: Alvin Unreal Date: Sat, 23 May 2026 23:44:23 +0200 Subject: [PATCH] Add control center plugins page --- apps/desktop/control-center-preload.cjs | 10 + apps/desktop/src/renderer/src/main.tsx | 422 ++++++++++++++++++++ apps/desktop/src/renderer/src/styles.css | 72 +++- apps/desktop/src/windows.ts | 20 +- apps/desktop/tests/plugin-ui-static.test.ts | 21 +- 5 files changed, 532 insertions(+), 13 deletions(-) diff --git a/apps/desktop/control-center-preload.cjs b/apps/desktop/control-center-preload.cjs index f4f969aa..d85530f9 100644 --- a/apps/desktop/control-center-preload.cjs +++ b/apps/desktop/control-center-preload.cjs @@ -11,6 +11,16 @@ const api = { checkForUpdates: () => ipcRenderer.invoke("openpets:check-for-updates"), openUpdateReleasePage: () => ipcRenderer.invoke("openpets:open-update-release-page"), resetDefaultPetPosition: () => ipcRenderer.invoke("openpets:reset-default-pet-position"), + getPluginsSnapshot: () => ipcRenderer.invoke("openpets:plugins-snapshot"), + getPluginCatalogSnapshot: (refresh) => ipcRenderer.invoke("openpets:plugins-catalog-snapshot", refresh), + setPluginEnabled: (id, enabled) => ipcRenderer.invoke("openpets:plugins-set-enabled", id, enabled), + savePluginConfig: (id, config) => ipcRenderer.invoke("openpets:plugins-save-config", id, config), + reloadPlugin: (id) => ipcRenderer.invoke("openpets:plugins-reload", id), + executePluginCommand: (id, commandId) => ipcRenderer.invoke("openpets:plugins-execute-command", id, commandId), + loadLocalPlugin: () => ipcRenderer.invoke("openpets:plugins-load-local"), + installCatalogPlugin: (id) => ipcRenderer.invoke("openpets:plugins-install-catalog", id), + updateCatalogPlugin: (id) => ipcRenderer.invoke("openpets:plugins-update-catalog", id), + uninstallPlugin: (id) => ipcRenderer.invoke("openpets:plugins-uninstall", id), getCatalog: () => ipcRenderer.invoke("openpets:get-catalog"), getCatalogPage: (page) => ipcRenderer.invoke("openpets:get-catalog-page", page), getCatalogSearch: () => ipcRenderer.invoke("openpets:get-catalog-search"), diff --git a/apps/desktop/src/renderer/src/main.tsx b/apps/desktop/src/renderer/src/main.tsx index c6b2ca19..6fc65530 100644 --- a/apps/desktop/src/renderer/src/main.tsx +++ b/apps/desktop/src/renderer/src/main.tsx @@ -18,6 +18,20 @@ type SettingsState = { preferences: { openDefaultPetOnLaunch: boolean; petScale: type LaunchAtLoginState = { supported: boolean; enabled: boolean }; type UpdateStatus = { state: "idle" | "checking" | "available" | "current" | "error"; currentVersion: string; latestVersion?: string; releaseUrl?: string; checkedAt?: number; error?: string }; type ReactionAnimationSettings = { reactions: { id: string; label: string; description: string; defaultAnimation: UserSelectableAnimationState }[]; animations: { id: UserSelectableAnimationState; label: string; description: string }[]; sprite: { frameWidth: number; frameHeight: number; columns: number; rows: number; states: Record }; overrides: ReactionAnimationOverrides; previewSpriteUrl: string }; +type PluginFilter = "all" | "installed" | "catalog" | "local" | "broken"; +type PluginPermission = "pet:speak" | "pet:reaction" | "timer" | "schedule" | "storage" | "status" | "commands" | "network"; +type PluginConfigField = { type: "text" | "textarea" | "number" | "boolean" | "select" | "time" | "multiSelect" | "list"; label?: string; description?: string; default?: string | number | boolean | string[] | Array>; options?: Array<{ label: string; value: string }>; min?: number; max?: number; step?: number; maxLength?: number; maxItems?: number; itemSchema?: Record }; +type PluginConfigSchema = Record; +type PluginConfig = Record; +type PluginCommand = { id: string; title: string; description?: string }; +type PluginStatus = { text: string; tone?: "info" | "success" | "warning" | "error" }; +type PluginConfigError = { path?: string; code?: string; message?: string }; +type SafePluginRecord = { id: string; name?: string; version: string; source: "catalog" | "local"; enabled: boolean; brokenReason?: string; approvedPermissions: PluginPermission[]; runtime?: "declarative" | "javascript"; sdkVersion?: string; catalogDisabled?: boolean; catalogDeprecated?: boolean; catalogStatusReason?: string; configSchema?: PluginConfigSchema; effectiveConfig?: PluginConfig; configErrors?: PluginConfigError[]; commands?: PluginCommand[]; status?: PluginStatus }; +type SafeCatalogPluginRecord = { id: string; name: string; version: string; description: string; runtime: "declarative" | "javascript"; sdkVersion?: string; permissions: PluginPermission[]; installed: boolean; deprecated?: boolean; statusReason?: string }; +type PluginServiceSnapshot = { plugins: SafePluginRecord[] }; +type PluginCatalogSnapshot = { plugins: SafeCatalogPluginRecord[] }; +type PluginServiceResult = { ok: true; snapshot: PluginServiceSnapshot } | { ok: false; error: string; snapshot: PluginServiceSnapshot }; +type PluginEntry = { id: string; installed?: SafePluginRecord; catalog?: SafeCatalogPluginRecord }; type ControlCenterApi = { getPetsState(): Promise; getSettingsState(): Promise; @@ -29,6 +43,16 @@ type ControlCenterApi = { checkForUpdates(): Promise; openUpdateReleasePage(): Promise; resetDefaultPetPosition(): Promise; + getPluginsSnapshot(): Promise; + getPluginCatalogSnapshot(refresh?: boolean): Promise; + setPluginEnabled(id: string, enabled: boolean): Promise; + savePluginConfig(id: string, config: PluginConfig): Promise; + reloadPlugin(id: string): Promise; + executePluginCommand(id: string, commandId: string): Promise; + loadLocalPlugin(): Promise; + installCatalogPlugin(id: string): Promise; + updateCatalogPlugin(id: string): Promise; + uninstallPlugin(id: string): Promise; getCatalog(): Promise; getCatalogPage(page: number): Promise; getCatalogSearch(): Promise<{ pets: SearchPetEntry[]; error?: string }>; @@ -85,6 +109,43 @@ const RefreshIcon = () => ( ); +const ConfigureIcon = () => ( + + + + + + + + + + + +); + +const FolderPlusIcon = () => ( + + + + + +); + +const SaveIcon = () => ( + + + + + +); + +const CloseIcon = () => ( + + + + +); + const PrevIcon = () => ( @@ -640,6 +701,365 @@ function SettingsView() { ; } +const pluginFilterLabels: Record = { + all: "All", + installed: "Installed", + catalog: "Catalog", + local: "Local / Dev", + broken: "Broken", +}; + +const pluginPermissionLabels: Record = { + "pet:speak": "Speech", + "pet:reaction": "Reactions", + timer: "Timers", + schedule: "Schedule", + storage: "Storage", + status: "Status", + commands: "Commands", + network: "Network", +}; + +const pluginStatusTone: Record, keyof typeof statusPillToneClass> = { + info: "blue", + success: "green", + warning: "orange", + error: "red", +}; + +function PluginGlyph({ className = "plugin-glyph" }: { className?: string }) { + return + + + + ; +} + +function pluginName(entry: PluginEntry): string { + return entry.installed?.name || entry.catalog?.name || entry.id; +} + +function pluginDescription(entry: PluginEntry): string { + if (entry.installed?.brokenReason) return entry.installed.brokenReason; + return entry.catalog?.description || (entry.installed ? "Installed plugin ready for configuration." : "Available from the plugin catalog."); +} + +function pluginPrimaryTone(entry: PluginEntry): keyof typeof statusPillToneClass { + if (entry.installed?.brokenReason) return "red"; + if (entry.installed?.catalogDisabled) return "orange"; + if (entry.installed?.enabled) return "green"; + if (entry.installed) return "slate"; + return "blue"; +} + +function pluginPrimaryLabel(entry: PluginEntry): string { + if (entry.installed?.brokenReason) return "Broken"; + if (entry.installed?.catalogDisabled) return "Catalog disabled"; + if (entry.installed?.enabled) return "Active"; + if (entry.installed) return "Disabled"; + return "Available"; +} + +function mergePluginEntries(snapshot: PluginServiceSnapshot | null, catalog: PluginCatalogSnapshot | null): PluginEntry[] { + const merged = new Map(); + for (const installed of snapshot?.plugins ?? []) merged.set(installed.id, { id: installed.id, installed }); + for (const catalogPlugin of catalog?.plugins ?? []) { + const current = merged.get(catalogPlugin.id) ?? { id: catalogPlugin.id }; + merged.set(catalogPlugin.id, { ...current, catalog: catalogPlugin }); + } + return [...merged.values()].sort((a, b) => { + const installedDelta = Number(Boolean(b.installed)) - Number(Boolean(a.installed)); + if (installedDelta) return installedDelta; + return pluginName(a).localeCompare(pluginName(b)); + }); +} + +function initialConfigValue(field: PluginConfigField): unknown { + if (field.default !== undefined) return field.default; + if (field.type === "boolean") return false; + if (field.type === "number") return field.min ?? 0; + if (field.type === "multiSelect" || field.type === "list") return []; + return ""; +} + +function materializeListItemDefaults(schema: PluginConfigSchema, value: Record = {}): Record { + const next: Record = {}; + for (const [key, field] of Object.entries(schema)) next[key] = materializeConfigValue(field, value[key]); + return next; +} + +function materializeConfigValue(field: PluginConfigField, value: unknown): unknown { + if (field.type === "list" && field.itemSchema) { + const items = Array.isArray(value) ? value.filter((item): item is Record => item !== null && typeof item === "object" && !Array.isArray(item)) : []; + return items.map((item) => materializeListItemDefaults(field.itemSchema ?? {}, item)); + } + return value ?? initialConfigValue(field); +} + +function materializeConfigDraft(schema: PluginConfigSchema | undefined, config: PluginConfig | undefined): PluginConfig { + const next: PluginConfig = {}; + for (const [key, field] of Object.entries(schema ?? {})) next[key] = materializeConfigValue(field, config?.[key]); + return next; +} + +function ConfigFieldEditor({ fieldKey, field, value, onChange }: { fieldKey: string; field: PluginConfigField; value: unknown; onChange: (value: unknown) => void }) { + const label = field.label || fieldKey; + const description = field.description; + const textValue = typeof value === "string" ? value : typeof field.default === "string" ? field.default : ""; + + if (field.type === "boolean") { + return {label}{description && {description}} + onChange(event.target.checked)} /> + ; + } + + if (field.type === "list" && field.itemSchema) { + const items = Array.isArray(value) ? value.filter((item): item is Record => item !== null && typeof item === "object" && !Array.isArray(item)) : []; + const maxed = typeof field.maxItems === "number" && items.length >= field.maxItems; + return
+ {label}{description && {description}} +
+ {items.map((item, index) => ( +
+
Item {index + 1}
+ {Object.entries(field.itemSchema ?? {}).map(([childKey, childField]) => ( + onChange(items.map((existing, itemIndex) => itemIndex === index ? { ...existing, [childKey]: nextValue } : existing))} /> + ))} +
+ ))} + +
+
; + } + + return