From 8a393f24d7a70ab8bf8c97a8e0081854e5dc1b09 Mon Sep 17 00:00:00 2001 From: alvinreal Date: Tue, 19 May 2026 10:51:25 +0100 Subject: [PATCH] Add configurable plugin actions --- .../contracts/plugin-manifest.contract.ts | 12 +++- apps/desktop/package.json | 9 +-- apps/desktop/src/main.ts | 2 +- apps/desktop/src/plugin-config.ts | 17 ++++- apps/desktop/src/plugin-manifest.ts | 66 ++++++++++++++----- apps/desktop/src/plugin-runtime.ts | 12 ++-- apps/desktop/src/plugin-service.ts | 27 +++++++- apps/desktop/tests/plugin-config.test.ts | 9 ++- apps/desktop/tests/plugin-runtime.test.ts | 22 +++++++ apps/desktop/tests/plugin-service.test.ts | 24 ++++++- pnpm-workspace.yaml | 3 + 11 files changed, 167 insertions(+), 36 deletions(-) diff --git a/apps/desktop/contracts/plugin-manifest.contract.ts b/apps/desktop/contracts/plugin-manifest.contract.ts index 693b0acb..b02ae733 100644 --- a/apps/desktop/contracts/plugin-manifest.contract.ts +++ b/apps/desktop/contracts/plugin-manifest.contract.ts @@ -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( { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index df9bc31f..47726a9f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index fcb21d88..4066bd99 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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(); diff --git a/apps/desktop/src/plugin-config.ts b/apps/desktop/src/plugin-config.ts index 13d3998d..914d0993 100644 --- a/apps/desktop/src/plugin-config.ts +++ b/apps/desktop/src/plugin-config.ts @@ -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; diff --git a/apps/desktop/src/plugin-manifest.ts b/apps/desktop/src/plugin-manifest.ts index e9234872..8f6b56b2 100644 --- a/apps/desktop/src/plugin-manifest.ts +++ b/apps/desktop/src/plugin-manifest.ts @@ -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 { - const numberFields = new Set(); - if (value === undefined) return numberFields; +type ConfigFieldSets = { text: Set; select: Set; number: Set; schema: Record }; + +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, 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, numberConfigFields: Set, errors: PluginManifestValidationError[]): void { +function validateTriggers(value: unknown, permissions: Set, 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, 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, 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, path: string, errors: PluginManifestValidationError[]): void { +function validateStringOrConfigRef(value: unknown, allowedFields: Set, 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, 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(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, 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."); diff --git a/apps/desktop/src/plugin-runtime.ts b/apps/desktop/src/plugin-runtime.ts index 9f54a7b2..76d41b50 100644 --- a/apps/desktop/src/plugin-runtime.ts +++ b/apps/desktop/src/plugin-runtime.ts @@ -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): CompiledAction { +function compileAction(record: PluginStateRecord, manifest: OpenPetsPluginManifest, action: PluginAction, approved: Set): 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 { diff --git a/apps/desktop/src/plugin-service.ts b/apps/desktop/src/plugin-service.ts index 5216ec18..151fa29a 100644 --- a/apps/desktop/src/plugin-service.ts +++ b/apps/desktop/src/plugin-service.ts @@ -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 { 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"] }); diff --git a/apps/desktop/tests/plugin-config.test.ts b/apps/desktop/tests/plugin-config.test.ts index c4e84706..d83e8504 100644 --- a/apps/desktop/tests/plugin-config.test.ts +++ b/apps/desktop/tests/plugin-config.test.ts @@ -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 { diff --git a/apps/desktop/tests/plugin-runtime.test.ts b/apps/desktop/tests/plugin-runtime.test.ts index 9dc76ace..2be6ef50 100644 --- a/apps/desktop/tests/plugin-runtime.test.ts +++ b/apps/desktop/tests/plugin-runtime.test.ts @@ -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(); diff --git a/apps/desktop/tests/plugin-service.test.ts b/apps/desktop/tests/plugin-service.test.ts index ab0bac14..74d694ee 100644 --- a/apps/desktop/tests/plugin-service.test.ts +++ b/apps/desktop/tests/plugin-service.test.ts @@ -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): Promise { @@ -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): Promise { + 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 => 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 = {}, data: unknown = manifest()): void { const id = patch.id ?? "plug"; const installPath = patch.installPath ?? join(currentRootFromStore(store), id); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6cc7c76a..86fd4218 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,5 +12,8 @@ supportedArchitectures: libc: - glibc allowBuilds: + '@google/genai': true electron-winstaller: false + koffi: true + protobufjs: true sharp: true