Add control center plugins page
This commit is contained in:
parent
3c119e209d
commit
ee524369a7
5 changed files with 532 additions and 13 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<UserSelectableAnimationState, { row: number; frames: number; durationMs: number; iterations?: number | "infinite" }> }; 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<Record<string, unknown>>; options?: Array<{ label: string; value: string }>; min?: number; max?: number; step?: number; maxLength?: number; maxItems?: number; itemSchema?: Record<string, PluginConfigField> };
|
||||
type PluginConfigSchema = Record<string, PluginConfigField>;
|
||||
type PluginConfig = Record<string, unknown>;
|
||||
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<StateSnapshot>;
|
||||
getSettingsState(): Promise<SettingsState>;
|
||||
|
|
@ -29,6 +43,16 @@ type ControlCenterApi = {
|
|||
checkForUpdates(): Promise<UpdateStatus>;
|
||||
openUpdateReleasePage(): Promise<void>;
|
||||
resetDefaultPetPosition(): Promise<SettingsState>;
|
||||
getPluginsSnapshot(): Promise<PluginServiceSnapshot>;
|
||||
getPluginCatalogSnapshot(refresh?: boolean): Promise<PluginCatalogSnapshot>;
|
||||
setPluginEnabled(id: string, enabled: boolean): Promise<PluginServiceResult>;
|
||||
savePluginConfig(id: string, config: PluginConfig): Promise<PluginServiceResult>;
|
||||
reloadPlugin(id: string): Promise<PluginServiceResult>;
|
||||
executePluginCommand(id: string, commandId: string): Promise<PluginServiceResult>;
|
||||
loadLocalPlugin(): Promise<PluginServiceResult>;
|
||||
installCatalogPlugin(id: string): Promise<PluginServiceResult>;
|
||||
updateCatalogPlugin(id: string): Promise<PluginServiceResult>;
|
||||
uninstallPlugin(id: string): Promise<PluginServiceResult>;
|
||||
getCatalog(): Promise<CatalogState>;
|
||||
getCatalogPage(page: number): Promise<CatalogState>;
|
||||
getCatalogSearch(): Promise<{ pets: SearchPetEntry[]; error?: string }>;
|
||||
|
|
@ -85,6 +109,43 @@ const RefreshIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
const ConfigureIcon = () => (
|
||||
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FolderPlusIcon = () => (
|
||||
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 10v6" />
|
||||
<path d="M9 13h6" />
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SaveIcon = () => (
|
||||
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8A2 2 0 0 1 21 8.8V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" />
|
||||
<path d="M17 21v-7H7v7" />
|
||||
<path d="M7 3v5h8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CloseIcon = () => (
|
||||
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PrevIcon = () => (
|
||||
<svg className="btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
|
|
@ -640,6 +701,365 @@ function SettingsView() {
|
|||
</div>;
|
||||
}
|
||||
|
||||
const pluginFilterLabels: Record<PluginFilter, string> = {
|
||||
all: "All",
|
||||
installed: "Installed",
|
||||
catalog: "Catalog",
|
||||
local: "Local / Dev",
|
||||
broken: "Broken",
|
||||
};
|
||||
|
||||
const pluginPermissionLabels: Record<PluginPermission, string> = {
|
||||
"pet:speak": "Speech",
|
||||
"pet:reaction": "Reactions",
|
||||
timer: "Timers",
|
||||
schedule: "Schedule",
|
||||
storage: "Storage",
|
||||
status: "Status",
|
||||
commands: "Commands",
|
||||
network: "Network",
|
||||
};
|
||||
|
||||
const pluginStatusTone: Record<NonNullable<PluginStatus["tone"]>, keyof typeof statusPillToneClass> = {
|
||||
info: "blue",
|
||||
success: "green",
|
||||
warning: "orange",
|
||||
error: "red",
|
||||
};
|
||||
|
||||
function PluginGlyph({ className = "plugin-glyph" }: { className?: string }) {
|
||||
return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2 3 6.5l9 4.5 9-4.5z" />
|
||||
<path d="m3 12 9 4.5 9-4.5" />
|
||||
<path d="m3 17.5 9 4.5 9-4.5" />
|
||||
</svg>;
|
||||
}
|
||||
|
||||
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<string, PluginEntry>();
|
||||
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<string, unknown> = {}): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = {};
|
||||
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<string, unknown> => 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 className="plugin-config-row plugin-config-row-boolean">
|
||||
<span><strong>{label}</strong>{description && <small>{description}</small>}</span>
|
||||
<input className="settings-toggle" type="checkbox" checked={Boolean(value)} onChange={(event) => onChange(event.target.checked)} />
|
||||
</label>;
|
||||
}
|
||||
|
||||
if (field.type === "list" && field.itemSchema) {
|
||||
const items = Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object" && !Array.isArray(item)) : [];
|
||||
const maxed = typeof field.maxItems === "number" && items.length >= field.maxItems;
|
||||
return <div className="plugin-config-row">
|
||||
<span><strong>{label}</strong>{description && <small>{description}</small>}</span>
|
||||
<div className="plugin-list-editor">
|
||||
{items.map((item, index) => (
|
||||
<div className="plugin-list-item" key={index}>
|
||||
<div className="plugin-list-item-header"><span>Item {index + 1}</span><Button variant="danger" size="compact" onClick={() => onChange(items.filter((_, itemIndex) => itemIndex !== index))}>Remove</Button></div>
|
||||
{Object.entries(field.itemSchema ?? {}).map(([childKey, childField]) => (
|
||||
<ConfigFieldEditor key={childKey} fieldKey={childKey} field={childField} value={item[childKey] ?? initialConfigValue(childField)} onChange={(nextValue) => onChange(items.map((existing, itemIndex) => itemIndex === index ? { ...existing, [childKey]: nextValue } : existing))} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Button variant="secondary" size="compact" disabled={maxed} onClick={() => onChange([...items, materializeListItemDefaults(field.itemSchema ?? {})])}>Add Item</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <label className="plugin-config-row">
|
||||
<span><strong>{label}</strong>{description && <small>{description}</small>}</span>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea className="plugin-input plugin-textarea" value={textValue} maxLength={field.maxLength} onChange={(event) => onChange(event.target.value)} />
|
||||
) : field.type === "select" ? (
|
||||
<select className="settings-select plugin-select" value={textValue} onChange={(event) => onChange(event.target.value)}>
|
||||
{(field.options ?? []).map((option) => <option key={option.value} value={option.value}>{option.label || option.value}</option>)}
|
||||
</select>
|
||||
) : field.type === "multiSelect" ? (
|
||||
<span className="plugin-chip-list">
|
||||
{(field.options ?? []).map((option) => {
|
||||
const selected = Array.isArray(value) && value.includes(option.value);
|
||||
return <button type="button" key={option.value} className={`plugin-chip ${selected ? "active" : ""}`} onClick={() => {
|
||||
const current = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
onChange(selected ? current.filter((item) => item !== option.value) : [...current, option.value]);
|
||||
}}>{option.label || option.value}</button>;
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<input className="plugin-input" type={field.type === "number" ? "number" : field.type === "time" ? "time" : "text"} value={field.type === "number" && typeof value === "number" ? String(value) : textValue} min={field.min} max={field.max} step={field.step} maxLength={field.maxLength} onChange={(event) => onChange(field.type === "number" ? Number(event.target.value) : event.target.value)} />
|
||||
)}
|
||||
</label>;
|
||||
}
|
||||
|
||||
function PluginsView() {
|
||||
const [snapshot, setSnapshot] = useState<PluginServiceSnapshot | null>(null);
|
||||
const [catalog, setCatalog] = useState<PluginCatalogSnapshot | null>(null);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [filter, setFilter] = useState<PluginFilter>("all");
|
||||
const [busy, setBusy] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [configDraft, setConfigDraft] = useState<PluginConfig>({});
|
||||
|
||||
async function load(refreshCatalog = false, clearMessages = true) {
|
||||
if (clearMessages) setError("");
|
||||
const [nextSnapshot, nextCatalog] = await Promise.all([
|
||||
api.getPluginsSnapshot(),
|
||||
api.getPluginCatalogSnapshot(refreshCatalog).catch(() => ({ plugins: [] } as PluginCatalogSnapshot)),
|
||||
]);
|
||||
setSnapshot(nextSnapshot);
|
||||
setCatalog(nextCatalog);
|
||||
const entries = mergePluginEntries(nextSnapshot, nextCatalog);
|
||||
setSelectedId((current) => entries.some((entry) => entry.id === current) ? current : "");
|
||||
}
|
||||
|
||||
useEffect(() => { void load().catch((err) => setError(String(err?.message ?? err))); }, []);
|
||||
useEffect(() => {
|
||||
if (!message) return;
|
||||
const timeout = window.setTimeout(() => setMessage(""), 2200);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [message]);
|
||||
|
||||
const entries = useMemo(() => mergePluginEntries(snapshot, catalog), [snapshot, catalog]);
|
||||
const selected = entries.find((entry) => entry.id === selectedId);
|
||||
const installed = selected?.installed;
|
||||
const catalogPlugin = selected?.catalog;
|
||||
|
||||
useEffect(() => { setConfigDraft(materializeConfigDraft(installed?.configSchema, installed?.effectiveConfig)); }, [installed?.id, installed?.configSchema, installed?.effectiveConfig]);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
return entries.filter((entry) => {
|
||||
if (filter === "installed" && !entry.installed) return false;
|
||||
if (filter === "catalog" && entry.installed) return false;
|
||||
if (filter === "local" && entry.installed?.source !== "local") return false;
|
||||
if (filter === "broken" && !entry.installed?.brokenReason) return false;
|
||||
return true;
|
||||
});
|
||||
}, [entries, filter]);
|
||||
|
||||
async function run(label: string, fn: () => Promise<void>) {
|
||||
try { setBusy(label); setError(""); setMessage(""); await fn(); }
|
||||
catch (err) { setError(String((err as Error)?.message ?? err)); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
function applyResult(result: PluginServiceResult, success?: string) {
|
||||
setSnapshot(result.snapshot);
|
||||
if (!result.ok) { setError(result.error); return false; }
|
||||
if (success) setMessage(success);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateDraft(key: string, value: unknown) {
|
||||
setConfigDraft((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function installCatalogEntry(entry: PluginEntry) {
|
||||
const result = await api.installCatalogPlugin(entry.id);
|
||||
if (!applyResult(result)) return;
|
||||
const installedPlugin = result.snapshot.plugins.find((plugin) => plugin.id === entry.id);
|
||||
if (!installedPlugin) { setMessage("No plugin installed."); return; }
|
||||
await load(false, false);
|
||||
setMessage("Plugin installed.");
|
||||
}
|
||||
|
||||
async function updateCatalogEntry(plugin: SafePluginRecord) {
|
||||
const previousVersion = plugin.version;
|
||||
const result = await api.updateCatalogPlugin(plugin.id);
|
||||
if (!applyResult(result)) return;
|
||||
const updatedPlugin = result.snapshot.plugins.find((nextPlugin) => nextPlugin.id === plugin.id);
|
||||
setMessage(updatedPlugin && updatedPlugin.version !== previousVersion ? "Plugin updated." : "No plugin update applied.");
|
||||
}
|
||||
|
||||
return <div className="plugins-layout">
|
||||
{error && <div className="error settings-message">{error}</div>}
|
||||
{message && <div className="settings-success settings-message">{message}</div>}
|
||||
<GlassCard className="plugins-hub">
|
||||
<div className="filters">
|
||||
{(["all", "installed", "catalog", "local", "broken"] as PluginFilter[]).map((nextFilter) => (
|
||||
<button key={nextFilter} className={`filter ${filter === nextFilter ? "active" : ""}`} onClick={() => setFilter(nextFilter)}>{pluginFilterLabels[nextFilter]}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="plugin-grid">
|
||||
{filteredEntries.map((entry) => (
|
||||
<article key={entry.id} className={`plugin-card ${entry.installed?.brokenReason ? "broken" : ""}`}>
|
||||
<div className="plugin-card-body">
|
||||
<span className="plugin-card-icon"><PluginGlyph /></span>
|
||||
<div className="plugin-card-content">
|
||||
<strong>{pluginName(entry)}</strong>
|
||||
<small>{pluginDescription(entry)}</small>
|
||||
<div className="badges mt-1">
|
||||
<StatusPill tone={pluginPrimaryTone(entry)}>{pluginPrimaryLabel(entry)}</StatusPill>
|
||||
{entry.installed?.source === "local" && <StatusPill tone="orange">Local</StatusPill>}
|
||||
{entry.installed?.runtime === "javascript" || entry.catalog?.runtime === "javascript" ? <StatusPill tone="purple">JS</StatusPill> : <StatusPill tone="slate">Declarative</StatusPill>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plugin-card-footer">
|
||||
<div className="plugin-card-meta">
|
||||
<span className="text-[10px] font-bold text-slatecopy/50 uppercase tracking-tight">v{entry.installed?.version || entry.catalog?.version}</span>
|
||||
</div>
|
||||
|
||||
<div className="plugin-card-actions">
|
||||
{entry.installed && (
|
||||
<div className="plugin-card-toggle-zone">
|
||||
<span className="plugin-card-toggle-label">{entry.installed.enabled ? "Active" : "Off"}</span>
|
||||
<input
|
||||
className="settings-toggle plugin-card-toggle"
|
||||
type="checkbox"
|
||||
checked={entry.installed.enabled}
|
||||
disabled={!!busy || entry.installed.catalogDisabled || Boolean(entry.installed.brokenReason)}
|
||||
onChange={(event) => void run("Saving", async () => {
|
||||
applyResult(await api.setPluginEnabled(entry.id, event.target.checked), event.target.checked ? "Plugin enabled." : "Plugin disabled.");
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.installed ? (
|
||||
<Button variant="secondary" size="compact" icon={<ConfigureIcon />} disabled={!!busy} onClick={() => setSelectedId(entry.id)}>Configure</Button>
|
||||
) : (
|
||||
<Button variant="primary" size="compact" icon={<InstallIcon />} disabled={!!busy || entry.catalog?.deprecated} onClick={() => void run("Installing", async () => { await installCatalogEntry(entry); })}>Install Plugin</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!filteredEntries.length && <div className="plugin-empty"><PluginGlyph /><strong>No plugins found</strong><small>Try a different filter, refresh the catalog, or load a local plugin folder.</small></div>}
|
||||
</div>
|
||||
<div className="plugin-hub-footer">
|
||||
<span><strong>{snapshot?.plugins.length ?? 0}</strong> installed · <strong>{catalog?.plugins.length ?? 0}</strong> catalog</span>
|
||||
<span className="plugin-hub-actions">
|
||||
<Button variant="secondary" size="compact" disabled={!!busy} icon={<RefreshIcon />} onClick={() => void run("Refreshing", async () => { await load(true); setMessage("Plugin catalog refreshed."); })}>Refresh</Button>
|
||||
<Button variant="secondary" size="compact" icon={<FolderPlusIcon />} disabled={!!busy} onClick={() => void run("Loading", async () => {
|
||||
const beforeIds = new Set(snapshot?.plugins.map((plugin) => plugin.id) ?? []);
|
||||
const result = await api.loadLocalPlugin();
|
||||
if (!applyResult(result)) return;
|
||||
const loadedPlugin = result.snapshot.plugins.find((plugin) => plugin.source === "local" && !beforeIds.has(plugin.id));
|
||||
setMessage(loadedPlugin ? "Local plugin loaded." : "No local plugin loaded.");
|
||||
})}>Load Local Plugin</Button>
|
||||
</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
{selected && <div className="plugin-config-overlay" role="dialog" aria-modal="true" aria-label={`${pluginName(selected)} configuration`}>
|
||||
<button className="plugin-config-backdrop" type="button" aria-label="Close plugin configuration" onClick={() => setSelectedId("")} />
|
||||
<GlassCard className="plugin-inspector">
|
||||
{selected ? <>
|
||||
<div className="plugin-inspector-head">
|
||||
<span className="plugin-inspector-icon"><PluginGlyph /></span>
|
||||
<div className="flex-1 min-w-0"><p className="eyebrow">Plugin Configuration</p><h2>{pluginName(selected)}</h2><p className="desc">{pluginDescription(selected)}</p></div>
|
||||
<Button variant="secondary" size="compact" icon={<CloseIcon />} onClick={() => setSelectedId("")}>Close</Button>
|
||||
</div>
|
||||
<div className="meta">
|
||||
<StatusPill tone={pluginPrimaryTone(selected)}>{pluginPrimaryLabel(selected)}</StatusPill>
|
||||
<StatusPill tone="slate">v{installed?.version ?? catalogPlugin?.version}</StatusPill>
|
||||
{installed?.source === "local" && <StatusPill tone="orange">Local</StatusPill>}
|
||||
{(installed?.catalogDeprecated || catalogPlugin?.deprecated) && <StatusPill tone="orange">Deprecated</StatusPill>}
|
||||
</div>
|
||||
{(installed?.catalogStatusReason || catalogPlugin?.statusReason || installed?.status?.text) && <div className="plugin-status-strip">
|
||||
{installed?.status?.text && <StatusPill tone={installed.status.tone ? pluginStatusTone[installed.status.tone] : "blue"}>{installed.status.text}</StatusPill>}
|
||||
<span>{installed?.catalogStatusReason || catalogPlugin?.statusReason}</span>
|
||||
</div>}
|
||||
{installed ? <>
|
||||
<section className="plugin-section">
|
||||
<div className="plugin-section-title"><small>Runtime</small><strong>State & permissions</strong></div>
|
||||
<label className="settings-row plugin-toggle-row">
|
||||
<div className="settings-row-info"><strong>{installed.enabled ? "Enabled" : "Disabled"}</strong><small>{installed.brokenReason || (installed.catalogDisabled ? "This plugin is disabled by the catalog." : "Toggle this plugin without leaving the Control Center.")}</small></div>
|
||||
<input className="settings-toggle" type="checkbox" checked={installed.enabled} disabled={!!busy || installed.catalogDisabled} onChange={(event) => void run("Saving", async () => { applyResult(await api.setPluginEnabled(installed.id, event.target.checked), event.target.checked ? "Plugin enabled." : "Plugin disabled."); })} />
|
||||
</label>
|
||||
<div className="badges plugin-permissions">{installed.approvedPermissions.length ? installed.approvedPermissions.map((permission) => <StatusPill key={permission} tone={permission === "network" ? "orange" : "blue"}>{pluginPermissionLabels[permission]}</StatusPill>) : <StatusPill tone="slate">No permissions</StatusPill>}</div>
|
||||
</section>
|
||||
{!!installed.configErrors?.length && <section className="plugin-section plugin-section-danger"><div className="plugin-section-title"><small>Configuration</small><strong>Needs attention</strong></div><ul>{installed.configErrors.map((configError, index) => <li key={index}>{configError.message || String(configError)}</li>)}</ul></section>}
|
||||
{installed.configSchema && <section className="plugin-section">
|
||||
<div className="plugin-section-title"><small>Settings</small><strong>Configuration</strong></div>
|
||||
<div className="plugin-config-form">{Object.entries(installed.configSchema).map(([key, field]) => <ConfigFieldEditor key={key} fieldKey={key} field={field} value={configDraft[key] ?? initialConfigValue(field)} onChange={(value) => updateDraft(key, value)} />)}</div>
|
||||
<Button variant="primary" fullWidth icon={<SaveIcon />} disabled={!!busy} onClick={() => void run("Saving", async () => { applyResult(await api.savePluginConfig(installed.id, configDraft), "Plugin configuration saved."); })}>Save Configuration</Button>
|
||||
</section>}
|
||||
{!!installed.commands?.length && <section className="plugin-section"><div className="plugin-section-title"><small>Commands</small><strong>Quick actions</strong></div><div className="plugin-command-list">{installed.commands.map((command) => <Button key={command.id} variant="secondary" size="compact" disabled={!!busy} onClick={() => void run("Running", async () => { applyResult(await api.executePluginCommand(installed.id, command.id), "Plugin command ran."); })}>{command.title}</Button>)}</div></section>}
|
||||
<section className="plugin-section plugin-actions-section">
|
||||
<Button variant="secondary" disabled={!!busy} icon={<RefreshIcon />} onClick={() => void run("Reloading", async () => { applyResult(await api.reloadPlugin(installed.id), "Plugin reloaded."); })}>Reload</Button>
|
||||
{installed.source === "catalog" && catalogPlugin && catalogPlugin.version !== installed.version && <Button variant="primary" icon={<InstallIcon />} disabled={!!busy} onClick={() => void run("Updating", async () => { await updateCatalogEntry(installed); })}>Update</Button>}
|
||||
<Button variant="danger" icon={<RemoveIcon />} disabled={!!busy} onClick={() => { if (window.confirm(`Uninstall ${pluginName(selected)}?`)) void run("Uninstalling", async () => { if (applyResult(await api.uninstallPlugin(installed.id), "Plugin uninstalled.")) setSelectedId(""); }); }}>Uninstall</Button>
|
||||
</section>
|
||||
</> : <section className="plugin-section">
|
||||
<div className="plugin-section-title"><small>Catalog</small><strong>Ready to install</strong></div>
|
||||
<p className="desc">Install this plugin to approve its permissions and make it available in your desktop companion.</p>
|
||||
<div className="badges plugin-permissions">{catalogPlugin?.permissions.map((permission) => <StatusPill key={permission} tone={permission === "network" ? "orange" : "blue"}>{pluginPermissionLabels[permission]}</StatusPill>)}</div>
|
||||
<Button variant="primary" fullWidth icon={<InstallIcon />} disabled={!!busy || catalogPlugin?.deprecated} onClick={() => void run("Installing", async () => { await installCatalogEntry(selected); })}>Install Plugin</Button>
|
||||
</section>}
|
||||
</> : <div className="plugin-empty plugin-empty-detail"><PluginGlyph /><strong>No plugin selected</strong><small>Install a catalog plugin or load a local folder to begin.</small></div>}
|
||||
</GlassCard>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [currentRoute, setCurrentRoute] = useState<Route>("pets");
|
||||
const [state, setState] = useState<StateSnapshot | null>(null);
|
||||
|
|
@ -850,6 +1270,8 @@ function App() {
|
|||
|
||||
{currentRoute === "settings" ? (
|
||||
<SettingsView />
|
||||
) : currentRoute === "plugins" ? (
|
||||
<PluginsView />
|
||||
) : currentRoute !== "pets" ? (
|
||||
<PlaceholderView route={currentRoute} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@
|
|||
.settings-panel h2 { @apply m-0 font-monoDisplay text-2xl font-black text-navy; }
|
||||
.settings-wide { @apply w-full; }
|
||||
.settings-actions { @apply flex flex-wrap gap-3 mt-2; }
|
||||
.settings-message { @apply fixed bottom-8 left-1/2 z-20 w-[min(360px,calc(100vw-3rem))] -translate-x-1/2 text-center shadow-lg; }
|
||||
.settings-message { @apply fixed bottom-8 left-1/2 z-40 w-[min(360px,calc(100vw-3rem))] -translate-x-1/2 text-center shadow-lg; }
|
||||
.settings-success { @apply mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-emerald-700; }
|
||||
.update-card-content { @apply p-4 rounded-2xl bg-brand/5 border border-brand/10; }
|
||||
.update-card-eyebrow { @apply text-xs text-brand font-bold uppercase tracking-wider mb-2; }
|
||||
|
|
@ -134,4 +134,74 @@
|
|||
.reaction-header { @apply flex items-center justify-between mb-2; }
|
||||
.reaction-desc { @apply desc mb-2; }
|
||||
|
||||
.plugins-layout { @apply relative flex min-h-0 flex-1; }
|
||||
.plugins-hub { @apply flex min-h-0 flex-col overflow-hidden; }
|
||||
.plugins-layout .plugins-hub { @apply w-full; }
|
||||
.plugin-grid { @apply grid min-h-0 flex-1 grid-cols-2 gap-4 overflow-y-auto p-1.5 pr-2.5 pb-3 max-[850px]:grid-cols-1; overscroll-behavior: contain; }
|
||||
.plugin-card { @apply flex w-full flex-col rounded-[24px] border border-slate-200 bg-white/80 p-0 text-left shadow-sm transition-[border-color,background-color,box-shadow]; }
|
||||
.plugin-card:hover { @apply border-brand/40 bg-white; }
|
||||
.plugin-card.broken { @apply border-red-200 bg-red-50/60; }
|
||||
|
||||
.plugin-card-body { @apply flex items-start gap-4 p-4; }
|
||||
.plugin-card-icon { @apply grid h-14 w-14 shrink-0 place-items-center rounded-2xl border border-blue-100/60 bg-gradient-to-br from-white to-blue-100 text-brand shadow-sm; }
|
||||
.plugin-glyph { @apply h-7 w-7 stroke-[2.5]; }
|
||||
|
||||
.plugin-card-content { @apply flex min-w-0 flex-1 flex-col gap-1; }
|
||||
.plugin-card-content strong { @apply truncate text-base font-black text-navy tracking-tight; }
|
||||
.plugin-card-content small { @apply line-clamp-2 text-xs leading-relaxed text-slatecopy; }
|
||||
|
||||
.plugin-card-footer { @apply flex items-center justify-between border-t border-blue-50/50 bg-blue-50/10 px-4 py-3 rounded-b-[24px]; }
|
||||
.plugin-card-meta { @apply flex items-center gap-2; }
|
||||
|
||||
.plugin-card-actions { @apply flex items-center gap-3; }
|
||||
.plugin-card-toggle-zone { @apply flex items-center gap-2 border-r border-blue-100/50 pr-3; }
|
||||
.plugin-card-toggle-label { @apply font-monoDisplay text-[10px] font-black uppercase tracking-wider text-slatecopy/60; }
|
||||
.plugin-card-toggle { @apply h-5 w-9; }
|
||||
|
||||
.plugin-hub-footer { @apply mt-3 flex items-center justify-between gap-3 rounded-xl border border-blue-200/40 bg-blue-50/20 p-2 text-xs font-bold text-slatecopy; }
|
||||
.plugin-hub-actions { @apply flex flex-wrap items-center justify-end gap-2; }
|
||||
.plugin-empty { @apply flex min-h-48 flex-col items-center justify-center gap-2 rounded-3xl border border-dashed border-blue-200 bg-blue-50/30 p-8 text-center text-slatecopy; }
|
||||
.plugin-empty strong { @apply text-sm font-black text-navy; }
|
||||
.plugin-empty small { @apply max-w-xs text-xs leading-relaxed; }
|
||||
.plugin-empty-detail { @apply min-h-full; }
|
||||
|
||||
.plugin-config-overlay { @apply fixed inset-0 z-30 flex items-center justify-center p-6; animation: plugin-fade-in 0.2s ease-out; }
|
||||
.plugin-config-backdrop { @apply absolute inset-0 cursor-default border-0 bg-navy/[0.12] p-0 backdrop-blur-[4px]; }
|
||||
.plugin-inspector { @apply relative z-10 flex max-h-[calc(100vh-4rem)] w-[min(760px,calc(100vw-3rem))] flex-col gap-4 overflow-y-auto pr-3 shadow-[0_32px_80px_rgba(16,33,73,0.25)]; overscroll-behavior: contain; scrollbar-gutter: stable; animation: plugin-pop-in 0.25s cubic-bezier(0.16, 1, 0.3, 1); }
|
||||
@keyframes plugin-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes plugin-pop-in { from { opacity: 0; transform: scale(0.96) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
|
||||
.plugin-inspector-head { @apply flex items-center gap-4; }
|
||||
.plugin-inspector-icon { @apply grid h-16 w-16 shrink-0 place-items-center rounded-[22px] border border-blue-100/70 bg-gradient-to-br from-white to-blue-100 text-brand shadow-sm; }
|
||||
.plugin-inspector h2 { @apply m-0 font-monoDisplay text-3xl font-black text-navy leading-tight; }
|
||||
.plugin-status-strip { @apply flex items-center gap-2 rounded-2xl border border-blue-100/60 bg-blue-50/60 p-3 text-xs font-semibold text-slatecopy shadow-inner; }
|
||||
.plugin-section { @apply flex flex-col gap-3 rounded-[24px] border border-blue-100/70 bg-white/65 p-5 shadow-sm; }
|
||||
.plugin-section-danger { @apply border-red-200 bg-red-50/70 text-red-700; }
|
||||
.plugin-section-danger ul { @apply m-0 pl-5 text-sm; }
|
||||
.plugin-section-title { @apply flex flex-col gap-0.5; }
|
||||
.plugin-section-title small { @apply font-monoDisplay text-xs font-black uppercase tracking-[.16em] text-brand; }
|
||||
.plugin-section-title strong { @apply text-base font-black text-navy; }
|
||||
.plugin-toggle-row { @apply rounded-2xl border-b-0 bg-white/65; }
|
||||
.plugin-permissions { @apply mt-0; }
|
||||
.plugin-config-form { @apply flex flex-col gap-3; }
|
||||
.plugin-config-row { @apply flex flex-col gap-2 rounded-2xl border border-blue-50 bg-white/60 p-3; }
|
||||
.plugin-config-row > span:first-child { @apply flex flex-col gap-0.5; }
|
||||
.plugin-config-row strong { @apply text-sm font-bold text-navy; }
|
||||
.plugin-config-row small { @apply text-xs leading-relaxed text-slatecopy; }
|
||||
.plugin-config-row-boolean { @apply flex-row items-center justify-between; }
|
||||
.plugin-input { @apply min-h-10 w-full rounded-xl border border-blue-200/80 bg-white/80 px-3 py-2 text-sm font-semibold text-navy outline-none shadow-inner focus:border-brand focus:ring-4 focus:ring-brand/15; }
|
||||
.plugin-textarea { @apply min-h-24 resize-y font-sans; }
|
||||
.plugin-select { @apply w-full; }
|
||||
.plugin-chip-list { @apply flex flex-wrap gap-2; }
|
||||
.plugin-chip { @apply min-h-10 rounded-xl border border-blue-200/60 bg-white/80 px-3 py-1.5 text-xs font-bold text-slatecopy shadow-sm transition-[transform,background-color,border-color,box-shadow] active:scale-[0.96]; }
|
||||
.plugin-chip.active { @apply border-brand bg-blue-50 text-brand ring-2 ring-blue-100; }
|
||||
.plugin-list-editor { @apply flex flex-col gap-3; }
|
||||
.plugin-list-item { @apply flex flex-col gap-3 rounded-2xl border border-blue-100/70 bg-blue-50/25 p-3; }
|
||||
.plugin-list-item-header { @apply flex items-center justify-between text-xs font-bold uppercase tracking-wider text-slatecopy; }
|
||||
.plugin-command-list { @apply flex flex-wrap gap-2; }
|
||||
.plugin-actions-section { @apply grid grid-cols-3 gap-2; }
|
||||
.plugin-actions-section .btn-secondary { @apply bg-blue-50/70; }
|
||||
.plugin-actions-section .btn-primary { @apply min-w-[96px]; }
|
||||
.plugin-actions-section .btn-danger { @apply min-w-[112px]; }
|
||||
@media (max-width: 900px) { .plugins-layout, .plugins-hub, .plugin-grid { @apply overflow-visible; } .plugin-actions-section { @apply grid-cols-1; } }
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,58 +159,58 @@ export function installInternalUiHandlers(): void {
|
|||
});
|
||||
|
||||
ipcMain.handle("openpets:plugins-snapshot", async (event) => {
|
||||
assertAllowedSender(event, ["plugins"]);
|
||||
assertAllowedSender(event, ["plugins", "control-center"]);
|
||||
return getPluginService().getSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:plugins-set-enabled", async (event, id: unknown, enabled: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(event, ["plugins"]);
|
||||
assertAllowedSender(event, ["plugins", "control-center"]);
|
||||
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id) || typeof enabled !== "boolean") return pluginUiError("Invalid plugin enable request.");
|
||||
return getPluginService().setEnabled(id, enabled);
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:plugins-save-config", async (event, id: unknown, config: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(event, ["plugins"]);
|
||||
assertAllowedSender(event, ["plugins", "control-center"]);
|
||||
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id) || !isPlainObject(config)) return pluginUiError("Invalid plugin config request.");
|
||||
return getPluginService().saveConfig(id, config);
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:plugins-reload", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(event, ["plugins"]);
|
||||
assertAllowedSender(event, ["plugins", "control-center"]);
|
||||
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id)) return pluginUiError("Invalid plugin reload request.");
|
||||
return getPluginService().reload(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:plugins-execute-command", async (event, id: unknown, commandId: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(event, ["plugins"]);
|
||||
assertAllowedSender(event, ["plugins", "control-center"]);
|
||||
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id) || typeof commandId !== "string" || !/^[A-Za-z0-9._:-]{1,64}$/.test(commandId)) return pluginUiError("Invalid plugin command request.");
|
||||
return getPluginService().executeCommand(id, commandId);
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:plugins-load-local", async (event): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(event, ["plugins"]);
|
||||
assertAllowedSender(event, ["plugins", "control-center"]);
|
||||
return getPluginService().loadLocal();
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:plugins-catalog-snapshot", async (event, refresh: unknown) => {
|
||||
assertAllowedSender(event, ["plugins"]);
|
||||
assertAllowedSender(event, ["plugins", "control-center"]);
|
||||
return getPluginService().getCatalogSnapshot(refresh === true);
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:plugins-install-catalog", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(event, ["plugins"]);
|
||||
assertAllowedSender(event, ["plugins", "control-center"]);
|
||||
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id)) return pluginUiError("Invalid plugin install request.");
|
||||
return getPluginService().installCatalog(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:plugins-update-catalog", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(event, ["plugins"]);
|
||||
assertAllowedSender(event, ["plugins", "control-center"]);
|
||||
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id)) return pluginUiError("Invalid plugin update request.");
|
||||
return getPluginService().updateCatalog(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:plugins-uninstall", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(event, ["plugins"]);
|
||||
assertAllowedSender(event, ["plugins", "control-center"]);
|
||||
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id)) return pluginUiError("Invalid plugin uninstall request.");
|
||||
return getPluginService().uninstall(id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import { fileURLToPath } from "node:url";
|
|||
const desktopRoot = process.env.OPENPETS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const windowsSource = readFileSync(resolve(desktopRoot, "src/windows.ts"), "utf8");
|
||||
const preloadSource = readFileSync(resolve(desktopRoot, "preload.cjs"), "utf8");
|
||||
const controlCenterPreloadSource = readFileSync(resolve(desktopRoot, "control-center-preload.cjs"), "utf8");
|
||||
const controlCenterRendererSource = readFileSync(resolve(desktopRoot, "src/renderer/src/main.tsx"), "utf8");
|
||||
const jsHostSource = readFileSync(resolve(desktopRoot, "src/plugin-js-host.ts"), "utf8");
|
||||
const pluginSdkPreloadSource = readFileSync(resolve(desktopRoot, "plugin-sdk-preload.cjs"), "utf8");
|
||||
|
||||
assert.match(windowsSource, /kind === "plugins"[\s\S]*preload: getPreloadPath\(\)/);
|
||||
assert.match(windowsSource, /assertAllowedSender\(event, \["plugins"\]\)/);
|
||||
assert.match(windowsSource, /assertAllowedSender\(event, \["plugins", "control-center"\]\)/);
|
||||
assert.match(windowsSource, /openpets:plugins-snapshot/);
|
||||
assert.match(windowsSource, /openpets:plugins-save-config/);
|
||||
assert.match(windowsSource, /openpets:plugins-load-local/);
|
||||
|
|
@ -18,7 +20,7 @@ assert.match(windowsSource, /openpets:plugins-catalog-snapshot/);
|
|||
assert.match(windowsSource, /openpets:plugins-install-catalog/);
|
||||
assert.match(windowsSource, /openpets:plugins-update-catalog/);
|
||||
assert.match(windowsSource, /openpets:plugins-uninstall/);
|
||||
assert.match(windowsSource, /openpets:plugins-load-local[\s\S]*assertAllowedSender\(event, \["plugins"\]\)/);
|
||||
assert.match(windowsSource, /openpets:plugins-load-local[\s\S]*assertAllowedSender\(event, \["plugins", "control-center"\]\)/);
|
||||
assert.match(preloadSource, /contextBridge\.exposeInMainWorld\("openpetsPlugins", pluginsApi\)/);
|
||||
assert.match(preloadSource, /snapshot: \(\) => ipcRenderer\.invoke\("openpets:plugins-snapshot"\)/);
|
||||
assert.match(preloadSource, /loadLocal: \(\) => ipcRenderer\.invoke\("openpets:plugins-load-local"\)/);
|
||||
|
|
@ -26,9 +28,24 @@ assert.match(preloadSource, /catalogSnapshot: \(refresh\) => ipcRenderer\.invoke
|
|||
assert.match(preloadSource, /installCatalog: \(id\) => ipcRenderer\.invoke\("openpets:plugins-install-catalog", id\)/);
|
||||
assert.match(preloadSource, /updateCatalog: \(id\) => ipcRenderer\.invoke\("openpets:plugins-update-catalog", id\)/);
|
||||
assert.match(preloadSource, /uninstall: \(id\) => ipcRenderer\.invoke\("openpets:plugins-uninstall", id\)/);
|
||||
assert.match(controlCenterPreloadSource, /getPluginsSnapshot: \(\) => ipcRenderer\.invoke\("openpets:plugins-snapshot"\)/);
|
||||
assert.match(controlCenterPreloadSource, /getPluginCatalogSnapshot: \(refresh\) => ipcRenderer\.invoke\("openpets:plugins-catalog-snapshot", refresh\)/);
|
||||
assert.match(controlCenterPreloadSource, /setPluginEnabled: \(id, enabled\) => ipcRenderer\.invoke\("openpets:plugins-set-enabled", id, enabled\)/);
|
||||
assert.match(controlCenterPreloadSource, /savePluginConfig: \(id, config\) => ipcRenderer\.invoke\("openpets:plugins-save-config", id, config\)/);
|
||||
assert.match(controlCenterPreloadSource, /loadLocalPlugin: \(\) => ipcRenderer\.invoke\("openpets:plugins-load-local"\)/);
|
||||
assert.match(controlCenterPreloadSource, /installCatalogPlugin: \(id\) => ipcRenderer\.invoke\("openpets:plugins-install-catalog", id\)/);
|
||||
assert.match(controlCenterPreloadSource, /uninstallPlugin: \(id\) => ipcRenderer\.invoke\("openpets:plugins-uninstall", id\)/);
|
||||
assert.match(controlCenterRendererSource, /function PluginsView\(\)/);
|
||||
assert.match(controlCenterRendererSource, /currentRoute === "plugins"[\s\S]*<PluginsView \/>/);
|
||||
assert.match(controlCenterRendererSource, /materializeListItemDefaults/);
|
||||
assert.match(controlCenterRendererSource, /updateCatalogEntry[\s\S]*api\.updateCatalogPlugin/);
|
||||
assert.match(controlCenterRendererSource, /installed\.source === "catalog"[\s\S]*updateCatalogEntry/);
|
||||
assert.doesNotMatch(preloadSource, /plugins-load-local",\s*[^)]/);
|
||||
assert.doesNotMatch(controlCenterPreloadSource, /plugins-load-local",\s*[^)]/);
|
||||
assert.doesNotMatch(preloadSource, /manifestPath/);
|
||||
assert.doesNotMatch(preloadSource, /installPath/);
|
||||
assert.doesNotMatch(controlCenterPreloadSource, /manifestPath/);
|
||||
assert.doesNotMatch(controlCenterPreloadSource, /installPath/);
|
||||
assert.doesNotMatch(preloadSource, /openpets:plugins-install-catalog",\s*[^)]+,\s*[^)]/);
|
||||
|
||||
// Ensure no plugin tabs remain
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue