Add configurable plugin actions
This commit is contained in:
parent
ab1cbe5480
commit
8a393f24d7
11 changed files with 167 additions and 36 deletions
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"] });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -12,5 +12,8 @@ supportedArchitectures:
|
|||
libc:
|
||||
- glibc
|
||||
allowBuilds:
|
||||
'@google/genai': true
|
||||
electron-winstaller: false
|
||||
koffi: true
|
||||
protobufjs: true
|
||||
sharp: true
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue