Add configurable plugin actions

This commit is contained in:
alvinreal 2026-05-19 10:51:25 +01:00
parent ab1cbe5480
commit 8a393f24d7
11 changed files with 167 additions and 36 deletions

View file

@ -15,7 +15,8 @@ const validManifest = {
mood: {
type: "select",
label: "Mood",
options: [{ label: "Celebrate", value: "celebrate" }],
default: "celebrating",
options: [{ label: "Celebrate", value: "celebrating" }],
},
},
triggers: [
@ -24,13 +25,20 @@ const validManifest = {
everyMinutes: { config: "intervalMinutes" },
actions: [
{ type: "pet.speak", message: "Time to stretch!" },
{ type: "pet.react", reaction: "celebrate" },
{ type: "pet.react", reaction: "celebrating" },
],
},
],
};
assertValid(validManifest);
assertValid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "message" } }, { type: "pet.react", reaction: { config: "mood" } }] }] });
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "message", extra: true } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "missing" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.speak", message: { config: "mood" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.react", reaction: { config: "message" } }] }] }, "invalid_config_reference");
assertInvalid({ ...validManifest, configSchema: { mood: { type: "select", default: "celebrate", options: [{ label: "Celebrate", value: "celebrate" }] } }, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.react", reaction: { config: "mood" } }] }] }, "invalid_reaction_config_reference");
assertInvalid({ ...validManifest, configSchema: { mood: { type: "select", options: [{ label: "Celebrate", value: "celebrating" }] } }, triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.react", reaction: { config: "mood" } }] }] }, "invalid_reaction_config_reference");
assertInvalid({ ...validManifest, extra: true }, "unknown_field");
assertInvalid(
{

View file

@ -1,6 +1,6 @@
{
"name": "@open-pets/desktop",
"version": "2.0.9",
"version": "2.0.10",
"private": true,
"description": "OpenPets tray-first desktop companion app.",
"license": "MIT",
@ -18,10 +18,11 @@
"package": "pnpm build && node scripts/clean-package-output.cjs && electron-builder",
"package:dir": "pnpm build && node scripts/clean-package-output.cjs && electron-builder --dir && node dist/check-packaging-contract.js --output",
"test": "node scripts/run-tests.mjs",
"test:build": "tsc -p tsconfig.tests.json",
"build:deps": "pnpm --filter @open-pets/desktop^... build",
"test:build": "pnpm build:deps && tsc -p tsconfig.tests.json",
"check": "pnpm typecheck && pnpm build && pnpm test",
"typecheck": "tsc --noEmit",
"build": "tsc"
"typecheck": "pnpm build:deps && tsc --noEmit",
"build": "pnpm build:deps && tsc"
},
"devDependencies": {
"@types/node": "^25.6.2",

View file

@ -43,7 +43,7 @@ if (!gotSingleInstanceLock) {
}
initializeAppState();
await initializePluginService(app.getPath("userData"), defaultPluginPetApi).start();
await initializePluginService(app.getPath("userData"), defaultPluginPetApi, app.getVersion()).start();
installInternalUiProtocol();
installInternalUiHandlers();
createAppTray();

View file

@ -23,7 +23,8 @@ export function getEffectivePluginConfig(manifest: OpenPetsPluginManifest, persi
}
export function resolvePluginNumericConfig(manifest: OpenPetsPluginManifest, persisted: unknown, fieldName: string, options: { min?: number } = {}): number {
const field = manifest.configSchema?.[fieldName];
const schema = manifest.configSchema;
const field = schema && Object.prototype.hasOwnProperty.call(schema, fieldName) ? schema[fieldName] : undefined;
if (!field || field.type !== "number") throw new Error(`Plugin numeric config ${fieldName} must reference a number config field.`);
const result = getEffectivePluginConfig(manifest, persisted);
if (!result.ok) throw new Error(`Plugin numeric config ${fieldName} is invalid: ${result.errors.map((error) => error.message).join("; ")}`);
@ -34,6 +35,18 @@ export function resolvePluginNumericConfig(manifest: OpenPetsPluginManifest, per
return value;
}
export function resolvePluginStringConfig(manifest: OpenPetsPluginManifest, persisted: unknown, fieldName: string, allowedType: "text" | "select"): string {
const schema = manifest.configSchema;
const field = schema && Object.prototype.hasOwnProperty.call(schema, fieldName) ? schema[fieldName] : undefined;
if (!field || field.type !== allowedType) throw new Error(`Plugin string config reference must point to a ${allowedType} config field.`);
const result = getEffectivePluginConfig(manifest, persisted);
if (!result.ok) throw new Error("Plugin string config is invalid.");
if (!Object.prototype.hasOwnProperty.call(result.config, fieldName)) throw new Error("Plugin string config must resolve to a value.");
const value = result.config[fieldName];
if (typeof value !== "string") throw new Error("Plugin string config must resolve to a string.");
return value;
}
function validateConfigObject(manifest: OpenPetsPluginManifest, value: unknown, options: { rejectUnknown: boolean; applyDefaults: boolean }): PluginConfigValidationResult {
const errors: PluginConfigValidationError[] = [];
if (!isPlainRecord(value)) return { ok: false, errors: [{ path: "$", code: "invalid_config", message: "Plugin config must be a plain object." }] };
@ -41,7 +54,7 @@ function validateConfigObject(manifest: OpenPetsPluginManifest, value: unknown,
const schema = manifest.configSchema ?? {};
for (const key of Object.keys(value).sort((a, b) => a.localeCompare(b))) {
const field = schema[key];
const field = Object.prototype.hasOwnProperty.call(schema, key) ? schema[key] : undefined;
if (!field) {
if (options.rejectUnknown) errors.push({ path: `$.${key}`, code: "unknown_config_key", message: `Unknown config key ${key}.` });
continue;

View file

@ -1,3 +1,5 @@
import { allowedReactions } from "./local-ipc-protocol.js";
export const OPENPETS_PLUGIN_MANIFEST_FILENAME = "openpets.plugin.json";
export const openPetsPluginManifestFilename = OPENPETS_PLUGIN_MANIFEST_FILENAME;
@ -14,7 +16,8 @@ export type PluginConfigField = {
options?: Array<{ label: string; value: string }>;
};
export type PluginAction = { type: "pet.speak"; message: string } | { type: "pet.react"; reaction: string };
export type PluginStringConfigRef = { config: string };
export type PluginAction = { type: "pet.speak"; message: string | PluginStringConfigRef } | { type: "pet.react"; reaction: string | PluginStringConfigRef };
export type PluginTimerEveryMinutes = number | { config: string };
export type PluginTrigger = { on: "timer"; everyMinutes: PluginTimerEveryMinutes; actions: PluginAction[] };
@ -81,8 +84,8 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
}
const permissions = validatePermissions(input.permissions, errors);
const numberConfigFields = validateConfigSchema(input.configSchema, errors);
validateTriggers(input.triggers, permissions, numberConfigFields, errors);
const configFields = validateConfigSchema(input.configSchema, errors);
validateTriggers(input.triggers, permissions, configFields, errors);
if (errors.length > 0) return { ok: false, errors };
return { ok: true, manifest: input as OpenPetsPluginManifest, errors: [] };
@ -108,12 +111,14 @@ function validatePermissions(value: unknown, errors: PluginManifestValidationErr
return permissions;
}
function validateConfigSchema(value: unknown, errors: PluginManifestValidationError[]): Set<string> {
const numberFields = new Set<string>();
if (value === undefined) return numberFields;
type ConfigFieldSets = { text: Set<string>; select: Set<string>; number: Set<string>; schema: Record<string, unknown> };
function validateConfigSchema(value: unknown, errors: PluginManifestValidationError[]): ConfigFieldSets {
const fields: ConfigFieldSets = { text: new Set(), select: new Set(), number: new Set(), schema: isRecord(value) ? value : {} };
if (value === undefined) return fields;
if (!isRecord(value)) {
addError(errors, "$.configSchema", "invalid_config_schema", "configSchema must be an object.");
return numberFields;
return fields;
}
for (const [key, field] of Object.entries(value)) {
const path = `$.configSchema.${key}`;
@ -124,17 +129,19 @@ function validateConfigSchema(value: unknown, errors: PluginManifestValidationEr
}
rejectUnknownFields(field, configFieldFields, path, errors);
for (const feature of deferredConfigFeatures) {
if (feature in field) addError(errors, `${path}.${feature}`, "deferred_config_feature", `${feature} is deferred and unsupported in v1.`);
if (Object.prototype.hasOwnProperty.call(field, feature)) addError(errors, `${path}.${feature}`, "deferred_config_feature", `${feature} is deferred and unsupported in v1.`);
}
if (typeof field.type !== "string" || !supportedConfigTypes.has(field.type)) {
const code = typeof field.type === "string" && deferredConfigTypes.has(field.type) ? "deferred_config_type" : "invalid_config_type";
addError(errors, `${path}.type`, code, "Config field type must be text, textarea, number, boolean, or select.");
} else {
validateConfigFieldSemantics(field, path, errors);
if (field.type === "number") numberFields.add(key);
if (field.type === "number") fields.number.add(key);
if (field.type === "text") fields.text.add(key);
if (field.type === "select") fields.select.add(key);
}
}
return numberFields;
return fields;
}
function validateConfigFieldSemantics(field: Record<string, unknown>, path: string, errors: PluginManifestValidationError[]): void {
@ -179,7 +186,7 @@ function validateOptions(value: unknown, path: string, errors: PluginManifestVal
return values;
}
function validateTriggers(value: unknown, permissions: Set<string>, numberConfigFields: Set<string>, errors: PluginManifestValidationError[]): void {
function validateTriggers(value: unknown, permissions: Set<string>, configFields: ConfigFieldSets, errors: PluginManifestValidationError[]): void {
if (!Array.isArray(value)) return addError(errors, "$.triggers", "invalid_triggers", "triggers must be an array.");
value.forEach((trigger, index) => {
const path = `$.triggers[${index}]`;
@ -187,9 +194,9 @@ function validateTriggers(value: unknown, permissions: Set<string>, numberConfig
rejectUnknownFields(trigger, triggerFields, path, errors);
if (trigger.on !== "timer") addError(errors, `${path}.on`, "invalid_trigger", 'Only timer triggers are supported in v1.');
requirePermission(permissions, "timer", path, errors);
validateEveryMinutes(trigger.everyMinutes, numberConfigFields, `${path}.everyMinutes`, errors);
validateEveryMinutes(trigger.everyMinutes, configFields.number, `${path}.everyMinutes`, errors);
if (!Array.isArray(trigger.actions)) return addError(errors, `${path}.actions`, "invalid_actions", "actions must be an array.");
trigger.actions.forEach((action, actionIndex) => validateAction(action, permissions, `${path}.actions[${actionIndex}]`, errors));
trigger.actions.forEach((action, actionIndex) => validateAction(action, permissions, configFields, `${path}.actions[${actionIndex}]`, errors));
});
}
@ -198,22 +205,47 @@ function validateEveryMinutes(value: unknown, numberConfigFields: Set<string>, p
if (!Number.isInteger(value) || value < 5) addError(errors, path, "invalid_timer_interval", "Timer interval must be an integer of at least 5 minutes.");
return;
}
if (!isRecord(value) || Object.keys(value).length !== 1 || typeof value.config !== "string") {
if (!isRecord(value) || Object.keys(value).length !== 1 || !Object.prototype.hasOwnProperty.call(value, "config") || typeof value.config !== "string") {
addError(errors, path, "invalid_timer_interval", "Timer interval must be an integer or { config: string }.");
return;
}
if (!numberConfigFields.has(value.config)) addError(errors, `${path}.config`, "invalid_timer_config_reference", "Timer config reference must point to a number config field.");
}
function validateAction(value: unknown, permissions: Set<string>, path: string, errors: PluginManifestValidationError[]): void {
function validateStringOrConfigRef(value: unknown, allowedFields: Set<string>, path: string, label: string, fieldType: string, errors: PluginManifestValidationError[]): void {
if (typeof value === "string") return validateString(value, path, label, errors);
if (!isRecord(value) || Object.keys(value).length !== 1 || !Object.prototype.hasOwnProperty.call(value, "config") || typeof value.config !== "string") {
addError(errors, path, "invalid_config_reference", `${label} must be a non-empty string or { config: string }.`);
return;
}
if (!allowedFields.has(value.config)) addError(errors, `${path}.config`, "invalid_config_reference", `${label} config reference must point to a ${fieldType} config field.`);
}
function validateReactionSelectReference(schema: Record<string, unknown>, fieldName: string, path: string, errors: PluginManifestValidationError[]): void {
if (!Object.prototype.hasOwnProperty.call(schema, fieldName)) return;
const field = schema[fieldName];
if (!isRecord(field) || field.type !== "select") return;
const reactions = new Set<string>(allowedReactions);
if (!Object.prototype.hasOwnProperty.call(field, "default") || typeof field.default !== "string" || !reactions.has(field.default)) {
addError(errors, `${path}.config`, "invalid_reaction_config_reference", "Reaction select config must have a valid OpenPets reaction default.");
}
if (Array.isArray(field.options)) {
for (const option of field.options) {
if (isRecord(option) && typeof option.value === "string" && !reactions.has(option.value)) addError(errors, `${path}.config`, "invalid_reaction_config_reference", "Reaction select options must be valid OpenPets reactions.");
}
}
}
function validateAction(value: unknown, permissions: Set<string>, configFields: ConfigFieldSets, path: string, errors: PluginManifestValidationError[]): void {
if (!isRecord(value)) return addError(errors, path, "invalid_action", "Action must be an object.");
if (value.type === "pet.speak") {
rejectUnknownFields(value, speakActionFields, path, errors);
validateString(value.message, `${path}.message`, "message", errors);
validateStringOrConfigRef(value.message, configFields.text, `${path}.message`, "message", "text", errors);
requirePermission(permissions, "pet:speak", path, errors);
} else if (value.type === "pet.react") {
rejectUnknownFields(value, reactActionFields, path, errors);
validateString(value.reaction, `${path}.reaction`, "reaction", errors);
validateStringOrConfigRef(value.reaction, configFields.select, `${path}.reaction`, "reaction", "select", errors);
if (isRecord(value.reaction) && typeof value.reaction.config === "string" && configFields.select.has(value.reaction.config)) validateReactionSelectReference(configFields.schema, value.reaction.config, `${path}.reaction`, errors);
requirePermission(permissions, "pet:reaction", path, errors);
} else {
addError(errors, `${path}.type`, "invalid_action", "Action type must be pet.speak or pet.react.");

View file

@ -1,5 +1,5 @@
import { validateReaction, validateSayMessage, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { resolvePluginNumericConfig } from "./plugin-config.js";
import { resolvePluginNumericConfig, resolvePluginStringConfig } from "./plugin-config.js";
import { defaultMaxPluginManifestBytes, readSafePluginManifest } from "./plugin-manifest-reader.js";
import { type OpenPetsPluginManifest, type PluginAction } from "./plugin-manifest.js";
import type { PluginPetApi } from "./plugin-pet-api.js";
@ -94,7 +94,7 @@ export class PluginRuntime {
return manifest.triggers.map((trigger, index) => {
if (!approved.has("timer")) throw new Error("Plugin timer permission is not approved.");
const interval = resolveTimerInterval(record, manifest, trigger.everyMinutes, index);
const actions = trigger.actions.map((action) => compileAction(action, approved));
const actions = trigger.actions.map((action) => compileAction(record, manifest, action, approved));
return { intervalMs: interval * 60_000, actions };
});
}
@ -145,13 +145,15 @@ export class PluginRuntime {
}
}
function compileAction(action: PluginAction, approved: Set<string>): CompiledAction {
function compileAction(record: PluginStateRecord, manifest: OpenPetsPluginManifest, action: PluginAction, approved: Set<string>): CompiledAction {
if (action.type === "pet.speak") {
if (!approved.has("pet:speak")) throw new Error("Plugin speak permission is not approved.");
return { type: "pet.speak", message: validateSayMessage(action.message) };
const message = typeof action.message === "string" ? action.message : resolvePluginStringConfig(manifest, record.config, action.message.config, "text");
return { type: "pet.speak", message: validateSayMessage(message) };
}
if (!approved.has("pet:reaction")) throw new Error("Plugin reaction permission is not approved.");
return { type: "pet.react", reaction: validateReaction(action.reaction) };
const reaction = typeof action.reaction === "string" ? action.reaction : resolvePluginStringConfig(manifest, record.config, action.reaction.config, "select");
return { type: "pet.react", reaction: validateReaction(reaction) };
}
function resolveTimerInterval(record: PluginStateRecord, manifest: OpenPetsPluginManifest, value: number | { config: string }, triggerIndex: number): number {

View file

@ -43,6 +43,7 @@ export type PluginServiceOptions = {
readonly confirmPermissions?: PluginPermissionDialog;
readonly catalogOptions?: PluginCatalogOptions;
readonly fetchImpl?: typeof fetch;
readonly currentAppVersion?: string;
};
export class PluginService {
@ -55,6 +56,7 @@ export class PluginService {
readonly #confirmPermissions?: PluginPermissionDialog;
readonly #catalogOptions?: PluginCatalogOptions;
readonly #fetchImpl?: typeof fetch;
readonly #currentAppVersion: string;
constructor(options: PluginServiceOptions) {
if (!options.stateStore && !options.userDataPath) throw new Error("Plugin service requires userDataPath or stateStore.");
@ -65,6 +67,7 @@ export class PluginService {
this.#confirmPermissions = options.confirmPermissions;
this.#catalogOptions = options.catalogOptions;
this.#fetchImpl = options.fetchImpl;
this.#currentAppVersion = options.currentAppVersion ?? "0.0.0";
this.stateStore = options.stateStore ?? new PluginStateStore({ userDataPath: options.userDataPath ?? "" });
if (options.runtime) {
this.runtime = options.runtime;
@ -123,7 +126,7 @@ export class PluginService {
async getCatalogSnapshot(refresh = false): Promise<PluginCatalogSnapshot> {
try {
const catalog = await getPluginCatalog({ ...this.#catalogOptions, fetchImpl: this.#fetchImpl ?? this.#catalogOptions?.fetchImpl, refresh });
return { plugins: catalog.plugins.map((entry) => ({ id: entry.id, name: entry.name, version: entry.version, description: entry.description, runtime: entry.runtime, permissions: entry.permissions, installed: this.stateStore.getRecord(entry.id)?.source === "catalog" })) };
return { plugins: catalog.plugins.filter((entry) => isCatalogEntryCompatible(entry.minOpenPetsVersion, this.#currentAppVersion)).map((entry) => ({ id: entry.id, name: entry.name, version: entry.version, description: entry.description, runtime: entry.runtime, permissions: entry.permissions, installed: this.stateStore.getRecord(entry.id)?.source === "catalog" })) };
} catch {
return { plugins: [] };
}
@ -201,6 +204,7 @@ export class PluginService {
const confirm = this.#confirmPermissions ?? defaultConfirmPermissions;
try {
const entry = await getCatalogPlugin(id, { ...this.#catalogOptions, fetchImpl: this.#fetchImpl ?? this.#catalogOptions?.fetchImpl, refresh: update });
if (!isCatalogEntryCompatible(entry.minOpenPetsVersion, this.#currentAppVersion)) throw new Error("Plugin requires a newer OpenPets version.");
const zip = await downloadCatalogPluginZip(entry, this.#fetchImpl ?? this.#catalogOptions?.fetchImpl ?? fetch);
const preview = await readCatalogPluginManifestFromZip({ catalogEntry: entry, zip, maxManifestBytes: this.#maxManifestBytes });
const permissionsChanged = existing ? !isPermissionSubset(preview.manifest.permissions, existing.approvedPermissions) : true;
@ -246,8 +250,8 @@ export class PluginService {
let appPluginService: PluginService | null = null;
export function initializePluginService(userDataPath: string, petApi: PluginPetApi): PluginService {
appPluginService = new PluginService({ userDataPath, petApi });
export function initializePluginService(userDataPath: string, petApi: PluginPetApi, currentAppVersion = "0.0.0"): PluginService {
appPluginService = new PluginService({ userDataPath, petApi, currentAppVersion });
return appPluginService;
}
@ -268,6 +272,7 @@ function safeError(error: unknown): string {
if (/outside install/i.test(message)) return "Plugin manifest path is outside install path.";
if (/path is invalid/i.test(message)) return "Plugin manifest path is invalid.";
if (/id\/version/i.test(message)) return "Plugin manifest id/version does not match installed state.";
if (/newer OpenPets version/i.test(message)) return "Plugin requires a newer OpenPets version.";
return "Plugin manifest is unavailable.";
}
@ -286,6 +291,22 @@ function isPermissionSubset(next: readonly PluginPermission[], approved: readonl
return next.every((permission) => approvedSet.has(permission));
}
function isCatalogEntryCompatible(minOpenPetsVersion: string | undefined, currentAppVersion: string): boolean {
if (!minOpenPetsVersion) return true;
return compareSemver(currentAppVersion, minOpenPetsVersion) >= 0;
}
function compareSemver(a: string, b: string): number {
const pa = parseCoreVersion(a); const pb = parseCoreVersion(b);
for (let i = 0; i < 3; i += 1) if (pa[i] !== pb[i]) return pa[i] > pb[i] ? 1 : -1;
return 0;
}
function parseCoreVersion(version: string): [number, number, number] {
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [0, 0, 0];
}
async function defaultOpenDialog(): Promise<{ canceled: boolean; filePaths: string[] }> {
const { dialog } = await import("electron");
return dialog.showOpenDialog({ properties: ["openDirectory"] });

View file

@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { getEffectivePluginConfig, getPluginDefaultConfig, resolvePluginNumericConfig, validatePluginConfigReplacement } from "../src/plugin-config.js";
import { getEffectivePluginConfig, getPluginDefaultConfig, resolvePluginNumericConfig, resolvePluginStringConfig, validatePluginConfigReplacement } from "../src/plugin-config.js";
import type { OpenPetsPluginManifest } from "../src/plugin-manifest.js";
const base = manifest();
@ -37,6 +37,13 @@ assert.throws(() => resolvePluginNumericConfig(base, { intervalMinutes: 4 }, "in
assert.throws(() => resolvePluginNumericConfig(manifest({ configSchema: { intervalMinutes: { type: "number" } } }), {}, "intervalMinutes", { min: 5 }), /must resolve to an integer/);
assert.throws(() => resolvePluginNumericConfig(base, { intervalMinutes: 6.5 }, "intervalMinutes", { min: 5 }), /must resolve to an integer/);
assert.equal(resolvePluginStringConfig(base, {}, "message", "text"), "Stretch");
assert.equal(resolvePluginStringConfig(base, { message: "Move" }, "message", "text"), "Move");
assert.equal(resolvePluginStringConfig(base, {}, "mood", "select"), "calm");
assert.throws(() => resolvePluginStringConfig(base, { message: 1 }, "message", "text"), /invalid/);
assert.throws(() => resolvePluginStringConfig(manifest({ configSchema: { message: { type: "text" } } }), {}, "message", "text"), /resolve to a value/);
assert.throws(() => resolvePluginStringConfig(base, {}, "intervalMinutes", "text"), /text config field/);
console.error("Plugin config validation passed.");
function assertInvalidReplacement(config: unknown, code: string): void {

View file

@ -60,6 +60,28 @@ await scenario("valid timer schedules and fires", async ({ store, scheduler, pet
assert.deepEqual(petApi.events, ["speak:Stretch", "react:celebrating"]);
});
await scenario("config speak and reaction refs execute", async ({ store, scheduler, petApi }) => {
addPlugin(store, { config: { message: "Hello", reaction: "celebrating" } }, manifest({ permissions: ["timer", "pet:speak", "pet:reaction"], configSchema: { message: { type: "text", default: "Stretch" }, reaction: { type: "select", default: "idle", options: [{ label: "Idle", value: "idle" }, { label: "Celebrate", value: "celebrating" }] } }, actions: [{ type: "pet.speak", message: { config: "message" } }, { type: "pet.react", reaction: { config: "reaction" } }] }));
await runtime(store, scheduler, petApi).start();
scheduler.fire(0);
await Promise.resolve();
assert.deepEqual(petApi.events, ["speak:Hello", "react:celebrating"]);
});
await scenario("invalid persisted config refs mark broken without api call", async ({ store, scheduler, petApi }) => {
addPlugin(store, { config: { message: 42 } }, manifest({ configSchema: { message: { type: "text", default: "Stretch" } }, actions: [{ type: "pet.speak", message: { config: "message" } }] }));
await runtime(store, scheduler, petApi).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /invalid/);
assert.equal(scheduler.activeCount(), 0);
assert.deepEqual(petApi.events, []);
});
await scenario("final config message and reaction validation applies", async ({ store }) => {
addPlugin(store, { config: { message: "https://example.test" } }, manifest({ configSchema: { message: { type: "text", default: "Stretch" } }, actions: [{ type: "pet.speak", message: { config: "message" } }] }));
await runtime(store, new FakeScheduler()).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /URL/);
});
await scenario("unapproved permission broken", async ({ store, scheduler }) => {
addPlugin(store, { approvedPermissions: ["timer"] }, manifest({ permissions: ["timer", "pet:speak"] }));
await runtime(store, scheduler).start();

View file

@ -202,7 +202,7 @@ await localScenario("loadLocal rejects destination symlink before write", async
});
await localScenario("loadLocal permission change disables", async ({ service, store, source, userData }) => {
writeManifest(source, manifest({ id: "perm-plug", permissions: ["timer", "pet:speak", "pet:reaction"], triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.react", reaction: "celebrate" }] }] }));
writeManifest(source, manifest({ id: "perm-plug", permissions: ["timer", "pet:speak", "pet:reaction"], triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "pet.react", reaction: "celebrating" }] }] }));
const install = join(userData, "plugins-dev", "perm-plug");
const manifestPath = writeManifest(install, manifest({ id: "perm-plug", permissions: ["timer", "pet:speak"] }));
store.upsertRecord({ id: "perm-plug", version: "1.0.0", installPath: install, manifestPath, source: "local", enabled: true, approvedPermissions: ["timer", "pet:speak"], config: {} });
@ -273,6 +273,14 @@ await catalogRollbackScenario("catalog update rolls back manifest if state write
assert.deepEqual(runtime.reloads, []);
});
await catalogCompatibilityScenario("catalog filters and blocks incompatible plugins", async ({ service }) => {
const snapshot = await service.getCatalogSnapshot(true);
assert.deepEqual(snapshot.plugins.map((plugin) => plugin.id), ["compatible-plug"]);
const result = await service.installCatalog("future-plug");
assert.equal(result.ok, false);
assert.match(result.error, /newer OpenPets/);
});
console.error("Plugin service validation passed.");
async function scenario(name: string, fn: (ctx: { root: string; userData: string; store: PluginStateStore; service: PluginService; runtime: FakeRuntime }) => Promise<void>): Promise<void> {
@ -324,6 +332,20 @@ async function catalogRollbackScenario(name: string, fn: (ctx: { root: string; u
try { await fn({ root, userData, store, service, runtime }); } catch (error) { throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); }
}
async function catalogCompatibilityScenario(name: string, fn: (ctx: { service: PluginService }) => Promise<void>): Promise<void> {
const userData = mkdtempSync(join(tmpdir(), "openpets-plugin-compat-user-"));
const store = new PluginStateStore({ statePath: join(userData, "state.json") });
store.initialize();
const catalog = { version: 1, generatedAt: new Date().toISOString(), plugins: [catalogEntry("compatible-plug", "1.0.0"), catalogEntry("future-plug", "9.0.0")] };
const fetchImpl = async (): Promise<Response> => new Response(JSON.stringify(catalog), { status: 200 });
const service = new PluginService({ userDataPath: userData, stateStore: store, runtime: new FakeRuntime() as never, fetchImpl, currentAppVersion: "2.0.0", confirmPermissions: async () => true });
try { await fn({ service }); } catch (error) { throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); }
}
function catalogEntry(id: string, minOpenPetsVersion: string): object {
return { id, name: id, version: "1.0.0", description: "Test", runtime: "declarative", permissions: ["timer", "pet:speak"], downloadUrl: `https://zip.openpets.dev/plugins/${id}.zip`, sha256: "0".repeat(64), minOpenPetsVersion };
}
function addPlugin(store: PluginStateStore, patch: Partial<PluginStateRecord> = {}, data: unknown = manifest()): void {
const id = patch.id ?? "plug";
const installPath = patch.installPath ?? join(currentRootFromStore(store), id);

View file

@ -12,5 +12,8 @@ supportedArchitectures:
libc:
- glibc
allowBuilds:
'@google/genai': true
electron-winstaller: false
koffi: true
protobufjs: true
sharp: true