diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index f20542db..2a6676d4 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -13,6 +13,7 @@ files: - control-center-preload.cjs - pet-preload.cjs - plugin-sdk-preload.cjs + - plugin-command-form-preload.cjs - assets/** - package.json diff --git a/apps/desktop/plugin-command-form-preload.cjs b/apps/desktop/plugin-command-form-preload.cjs new file mode 100644 index 00000000..ac0b888f --- /dev/null +++ b/apps/desktop/plugin-command-form-preload.cjs @@ -0,0 +1,6 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("openPetsCommandForm", { + submit: (channel, values) => ipcRenderer.invoke(String(channel), values && typeof values === "object" ? values : {}), + close: () => window.close(), +}); diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 1355ba74..98f02801 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -48,6 +48,7 @@ assert.match(builderConfig, /dist\/\*\*/); assert.match(builderConfig, /control-center-preload\.cjs/); assert.match(builderConfig, /pet-preload\.cjs/); assert.match(builderConfig, /plugin-sdk-preload\.cjs/); +assert.match(builderConfig, /plugin-command-form-preload\.cjs/); assert.match(builderConfig, /assets\/\*\*/); assert.match(builderConfig, /extraResources:[\s\S]*from:\s*\.\.\/\.\.\/plugins\/official[\s\S]*to:\s*plugins\/official/, "desktop packages must include bundled official plugins as extra resources."); assert.match(builderConfig, /icon:\s*assets\/app-icon\.icns/); @@ -55,6 +56,7 @@ assert.match(builderConfig, /icon:\s*assets\/app-icon\.icns/); assert.ok(existsSync(join(appDir, "control-center-preload.cjs")), "control-center-preload.cjs must exist for Control Center IPC."); assert.ok(existsSync(join(appDir, "pet-preload.cjs")), "pet-preload.cjs must exist for pet window motion state updates."); assert.ok(existsSync(join(appDir, "plugin-sdk-preload.cjs")), "plugin-sdk-preload.cjs must exist for JavaScript plugin SDK hosting."); +assert.ok(existsSync(join(appDir, "plugin-command-form-preload.cjs")), "plugin-command-form-preload.cjs must exist for plugin command forms."); assert.ok(existsSync(join(appDir, "assets", "tray-icon.png")), "tray icon must exist for packaging."); assert.ok(existsSync(join(appDir, "assets", "app-icon.icns")), "app icon must exist for packaging."); assert.ok(existsSync(join(appDir, "assets", "app-icon.ico")), "Windows app icon must exist for packaging."); @@ -354,7 +356,7 @@ function assertNonEmptyFile(path: string, message: string): void { } function assertBundledOfficialPlugins(resourceDir: string): void { - for (const id of ["openpets.ambient-companion", "openpets.break-buddy", "openpets.pet-pal", "openpets.focus-buddy", "openpets.wander-buddy", "openpets.github-notifications"]) { + for (const id of ["openpets.ambient-companion", "openpets.break-buddy", "openpets.pet-pal", "openpets.focus-buddy", "openpets.wander-buddy", "openpets.quick-reminders", "openpets.github-notifications"]) { const dir = join(resourceDir, "plugins", "official", id); const manifestPath = join(dir, "openpets.plugin.json"); assertNonEmptyFile(manifestPath, `packaged bundled plugin manifest is missing: ${id}`); diff --git a/apps/desktop/src/pet-window.ts b/apps/desktop/src/pet-window.ts index e6bf96e8..05b95032 100644 --- a/apps/desktop/src/pet-window.ts +++ b/apps/desktop/src/pet-window.ts @@ -11,6 +11,7 @@ import type { OpenPetsReaction } from "./local-ipc-protocol.js"; import { pickReactionMessage } from "./reaction-messages.js"; import { debug, error as logError, info } from "./logger.js"; import { executeDefaultPetPluginCommand, getDefaultPetPluginCommands } from "./plugin-service.js"; +import type { PluginCommandForm } from "./plugin-sdk-bridge.js"; import { defaultPetSprite, motionToSpriteState, resolveReactionSpriteState, type PetMotionState, type UniversalSpriteState } from "./reaction-animation-mapping.js"; export interface DefaultPetWindowOptions { @@ -128,7 +129,7 @@ async function buildPetContextMenuTemplate(action: { readonly label: string; rea const plugins = new Map(); for (const command of commands) { const group = plugins.get(command.pluginId) ?? { name: command.pluginName, commands: [] }; - group.commands.push({ label: command.commandTitle, click: () => { executeDefaultPetPluginCommand(command.pluginId, command.commandId).catch((error) => logError("pet.window", "plugin command failed", error)); } }); + group.commands.push({ label: command.commandTitle, click: () => { if (command.form) openPluginCommandForm(command).catch((error) => logError("pet.window", "plugin command form failed", error)); else executeDefaultPetPluginCommand(command.pluginId, command.commandId).catch((error) => logError("pet.window", "plugin command failed", error)); } }); plugins.set(command.pluginId, group); } const template: Electron.MenuItemConstructorOptions[] = []; @@ -137,6 +138,50 @@ async function buildPetContextMenuTemplate(action: { readonly label: string; rea return template; } +async function openPluginCommandForm(command: { readonly pluginId: string; readonly commandId: string; readonly commandTitle: string; readonly form?: PluginCommandForm }): Promise { + if (!command.form) return; + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) ?? screen.getPrimaryDisplay(); + const width = 380; + const height = Math.min(420, 150 + command.form.fields.length * 72); + const window = new BrowserWindow({ + title: command.commandTitle, + width, + height, + x: Math.round(display.workArea.x + (display.workArea.width - width) / 2), + y: Math.round(display.workArea.y + (display.workArea.height - height) / 2), + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + parent: BrowserWindow.getFocusedWindow() ?? undefined, + modal: false, + show: false, + webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, preload: `${app.getAppPath()}/plugin-command-form-preload.cjs` }, + }); + window.setMenu(null); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event) => event.preventDefault()); + const token = `plugin-command-form-${window.id}`; + ipcMain.handle(token, async (event, values: unknown) => { + if (event.sender !== window.webContents) throw new Error("Invalid command form sender."); + const result = await executeDefaultPetPluginCommand(command.pluginId, command.commandId, isRecord(values) ? values : {}); + if (!window.isDestroyed()) window.close(); + return result; + }); + window.once("closed", () => ipcMain.removeHandler(token)); + await window.loadURL(buildPluginCommandFormUrl(command.commandTitle, command.form, token)); + window.show(); +} + +function buildPluginCommandFormUrl(title: string, form: PluginCommandForm, channel: string): string { + const csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'"; + const data = JSON.stringify({ title, form, channel }).replace(/${escapeHtml(title)}

`; + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} + +function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed?: (dismissToken: string) => void): void { let dragging: { readonly startScreenX: number; readonly startScreenY: number; readonly startWindowX: number; readonly startWindowY: number } | null = null; let rendererReady = false; diff --git a/apps/desktop/src/plugin-js-host.ts b/apps/desktop/src/plugin-js-host.ts index 43988570..06998490 100644 --- a/apps/desktop/src/plugin-js-host.ts +++ b/apps/desktop/src/plugin-js-host.ts @@ -94,7 +94,7 @@ function installSdkHandler(channel: string, contents: WebContents, sdk: PluginSd } async function dispatchSdkCall(contents: WebContents, sdk: PluginSdkApi, path: string, args: unknown[]): Promise { - const runCallback = (id: unknown) => typeof id === "string" ? () => contents.executeJavaScript(`globalThis.__openPetsRunCallback(${JSON.stringify(id)}, [])`, true) : undefined; + const runCallback = (id: unknown) => typeof id === "string" ? (...callbackArgs: unknown[]) => contents.executeJavaScript(`globalThis.__openPetsRunCallback(${JSON.stringify(id)}, ${JSON.stringify(callbackArgs)})`, true) : undefined; switch (path) { case "pet.speak": return sdk.pet.speak(String(args[0])); case "pet.react": return sdk.pet.react(args[0] as never); diff --git a/apps/desktop/src/plugin-runtime.ts b/apps/desktop/src/plugin-runtime.ts index be24c2a2..e010c67d 100644 --- a/apps/desktop/src/plugin-runtime.ts +++ b/apps/desktop/src/plugin-runtime.ts @@ -70,7 +70,7 @@ export class PluginRuntime { } getPluginState(id: string): PluginRuntimePublicState { return this.#sdkBridge.getPublicState(id); } - executeCommand(id: string, commandId: string): Promise { return this.#sdkBridge.executeCommand(id, commandId); } + executeCommand(id: string, commandId: string, args?: Record): Promise { return this.#sdkBridge.executeCommand(id, commandId, args); } notifyConfigChanged(id: string): void { this.#sdkBridge.notifyConfigChanged(id); } async reloadAll(): Promise { diff --git a/apps/desktop/src/plugin-sdk-bridge.ts b/apps/desktop/src/plugin-sdk-bridge.ts index debbabb2..dbcba0b8 100644 --- a/apps/desktop/src/plugin-sdk-bridge.ts +++ b/apps/desktop/src/plugin-sdk-bridge.ts @@ -11,7 +11,9 @@ import type { PluginPetApi } from "./plugin-pet-api.js"; import type { PluginRuntimeScheduler, PluginTimerHandle } from "./plugin-runtime.js"; import type { PluginStateRecord, PluginStateStore } from "./plugin-state.js"; -export type PluginCommand = { id: string; title: string; description?: string }; +export type PluginCommandFormField = { id: string; type: "text" | "textarea" | "number"; label: string; default?: string | number; min?: number; max?: number; maxLength?: number; required?: boolean }; +export type PluginCommandForm = { fields: readonly PluginCommandFormField[]; submitLabel?: string }; +export type PluginCommand = { id: string; title: string; description?: string; form?: PluginCommandForm }; export type PluginStatus = { text: string; tone?: "info" | "success" | "warning" | "error" }; export type PluginRuntimePublicState = { commands: readonly PluginCommand[]; status?: PluginStatus }; export type PluginLogLevel = "debug" | "info" | "warn" | "error"; @@ -48,7 +50,7 @@ export class PluginSdkBridge { readonly #storage: PluginStorageStore; readonly #onError: (id: string, reason: string) => void; readonly #logger: PluginRuntimeLogger; - readonly #states = new Map unknown | Promise }>; status?: PluginStatus; schedules: Map; configListeners: Set<(config: PluginConfig) => void>; petWindow: WindowCounter; logWindow: WindowCounter; httpWindow: WindowCounter }>(); + readonly #states = new Map) => unknown | Promise }>; status?: PluginStatus; schedules: Map; configListeners: Set<(config: PluginConfig) => void>; petWindow: WindowCounter; logWindow: WindowCounter; httpWindow: WindowCounter }>(); constructor(options: { stateStore: PluginStateStore; petApi: PluginPetApi; scheduler: PluginRuntimeScheduler; storage?: PluginStorageStore; onError?: (id: string, reason: string) => void; logger?: PluginRuntimeLogger }) { this.#stateStore = options.stateStore; this.#petApi = options.petApi; this.#scheduler = options.scheduler; this.#storage = options.storage ?? new MemoryPluginStorageStore(); this.#onError = options.onError ?? (() => undefined); this.#logger = options.logger ?? (() => undefined); @@ -83,7 +85,7 @@ export class PluginSdkBridge { }, config: { get: getConfig, onChange: (listener: (config: PluginConfig) => void) => { state.configListeners.add(listener); return () => state.configListeners.delete(listener); } }, commands: { - register: (command: PluginCommand, handler: () => unknown) => { requirePermission("commands"); const meta = validateCommand(command); check(state.commands.size < quotas.commands || state.commands.has(meta.id), "Plugin command quota exceeded."); state.commands.set(meta.id, { meta, handler }); }, + register: (command: PluginCommand, handler: (values?: Record) => unknown) => { requirePermission("commands"); const meta = validateCommand(command); check(state.commands.size < quotas.commands || state.commands.has(meta.id), "Plugin command quota exceeded."); state.commands.set(meta.id, { meta, handler }); }, unregister: (id: string) => { state.commands.delete(String(id)); }, }, status: { set: (status: PluginStatus | string) => { requirePermission("status"); state.status = validateStatus(status); }, clear: () => { state.status = undefined; } }, @@ -93,7 +95,7 @@ export class PluginSdkBridge { } getPublicState(id: string): PluginRuntimePublicState { const state = this.#pluginState(id); return { commands: [...state.commands.values()].map((entry) => entry.meta), status: state.status }; } - async executeCommand(id: string, commandId: string, timeoutMs = 5_000): Promise { const command = this.#pluginState(id).commands.get(commandId); if (!command) throw new Error("Plugin command is not registered."); await withTimeout(Promise.resolve().then(() => command.handler()), timeoutMs); } + async executeCommand(id: string, commandId: string, args?: Record, timeoutMs = 5_000): Promise { const command = this.#pluginState(id).commands.get(commandId); if (!command) throw new Error("Plugin command is not registered."); const values = command.meta.form ? validateCommandFormValues(command.meta.form, args) : undefined; await withTimeout(Promise.resolve().then(() => command.handler(values)), timeoutMs); } notifyConfigChanged(id: string): void { const state = this.#pluginState(id); const config = { ...(this.#stateStore.getRecord(id)?.config ?? {}) } as PluginConfig; for (const listener of state.configListeners) { try { listener(config); } catch (error) { this.#onError(id, safeError(error)); } } } clearPlugin(id: string): void { const state = this.#pluginState(id); for (const handle of state.schedules.values()) handle.cancel(); state.schedules.clear(); state.commands.clear(); state.status = undefined; state.configListeners.clear(); state.petWindow.reset(); state.logWindow.reset(); state.httpWindow.reset(); } #pluginState(id: string) { let state = this.#states.get(id); if (!state) { state = { commands: new Map(), schedules: new Map(), configListeners: new Set(), petWindow: new WindowCounter(), logWindow: new WindowCounter(), httpWindow: new WindowCounter() }; this.#states.set(id, state); } return state; } @@ -123,7 +125,9 @@ class WindowCounter { count = 0; started = Date.now(); tick(max: number, label: function check(ok: boolean, message: string): void { if (!ok) throw new Error(message); } function validateStorageKey(key: string): string { if (!/^[A-Za-z0-9._:-]{1,128}$/.test(String(key))) throw new Error("Invalid plugin storage key."); return String(key); } function validateSchedule(id: string, delayMs: number, min: number): void { if (!scheduleIdPattern.test(id)) throw new Error("Invalid plugin schedule id."); if (!Number.isFinite(delayMs) || delayMs < min) throw new Error("Invalid plugin schedule delay."); } -function validateCommand(command: PluginCommand): PluginCommand { if (!command || !commandIdPattern.test(command.id)) throw new Error("Invalid plugin command id."); if (typeof command.title !== "string" || command.title.trim() === "" || command.title.length > 80) throw new Error("Invalid plugin command title."); if (command.description !== undefined && (typeof command.description !== "string" || command.description.length > 240)) throw new Error("Invalid plugin command description."); return { id: command.id, title: command.title, description: command.description }; } +function validateCommand(command: PluginCommand): PluginCommand { if (!command || !commandIdPattern.test(command.id)) throw new Error("Invalid plugin command id."); if (typeof command.title !== "string" || command.title.trim() === "" || command.title.length > 80) throw new Error("Invalid plugin command title."); if (command.description !== undefined && (typeof command.description !== "string" || command.description.length > 240)) throw new Error("Invalid plugin command description."); return { id: command.id, title: command.title, description: command.description, form: validateCommandForm(command.form) }; } +function validateCommandForm(form: unknown): PluginCommandForm | undefined { if (form === undefined) return undefined; if (!isRecord(form) || !Array.isArray(form.fields) || form.fields.length < 1 || form.fields.length > 8) throw new Error("Invalid plugin command form."); const seen = new Set(); const fields = form.fields.map((field) => { if (!isRecord(field) || typeof field.id !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(field.id) || seen.has(field.id)) throw new Error("Invalid plugin command form field id."); seen.add(field.id); if (!['text','textarea','number'].includes(String(field.type))) throw new Error("Invalid plugin command form field type."); if (typeof field.label !== "string" || field.label.trim() === "" || field.label.length > 80) throw new Error("Invalid plugin command form label."); const out: PluginCommandFormField = { id: field.id, type: field.type as PluginCommandFormField['type'], label: field.label, required: field.required === true || undefined }; if (out.type === "number") { if (field.default !== undefined && !Number.isFinite(Number(field.default))) throw new Error("Invalid plugin command form default."); if (field.min !== undefined && !Number.isFinite(Number(field.min))) throw new Error("Invalid plugin command form min."); if (field.max !== undefined && !Number.isFinite(Number(field.max))) throw new Error("Invalid plugin command form max."); if (field.min !== undefined) out.min = Number(field.min); if (field.max !== undefined) out.max = Number(field.max); if (out.min !== undefined && out.max !== undefined && out.min > out.max) throw new Error("Invalid plugin command form range."); if (field.default !== undefined) out.default = Number(field.default); } else { if (field.default !== undefined && typeof field.default !== "string") throw new Error("Invalid plugin command form default."); if (field.maxLength !== undefined && (!Number.isInteger(Number(field.maxLength)) || Number(field.maxLength) < 1 || Number(field.maxLength) > 1000)) throw new Error("Invalid plugin command form maxLength."); if (field.default !== undefined) out.default = field.default; if (field.maxLength !== undefined) out.maxLength = Number(field.maxLength); } return out; }); const submitLabel = typeof form.submitLabel === "string" && form.submitLabel.trim() && form.submitLabel.length <= 40 ? form.submitLabel : undefined; return { fields, submitLabel }; } +function validateCommandFormValues(form: PluginCommandForm, args: unknown): Record { const input = isRecord(args) ? args : {}; const out: Record = {}; for (const field of form.fields) { if (field.type === "number") { const n = Number(input[field.id] ?? field.default ?? 0); if (!Number.isFinite(n)) throw new Error(`${field.label} must be a number.`); if (field.min !== undefined && n < field.min) throw new Error(`${field.label} is too small.`); if (field.max !== undefined && n > field.max) throw new Error(`${field.label} is too large.`); out[field.id] = n; } else { const text = String(input[field.id] ?? field.default ?? "").trim(); if (field.required && !text) throw new Error(`${field.label} is required.`); if (field.maxLength !== undefined && text.length > field.maxLength) throw new Error(`${field.label} is too long.`); out[field.id] = text; } } return out; } function validateStatus(status: PluginStatus | string): PluginStatus { const value = typeof status === "string" ? { text: status } : status; if (!value || typeof value.text !== "string" || value.text.trim() === "" || value.text.length > 120) throw new Error("Invalid plugin status text."); if (value.tone !== undefined && !["info", "success", "warning", "error"].includes(value.tone)) throw new Error("Invalid plugin status tone."); return { text: value.text, tone: value.tone }; } function validateMoveBy(value: unknown): { x: number; y: number; durationMs?: number } { if (!isRecord(value)) throw new Error("Invalid pet movement options."); const x = Number(value.x); const y = Number(value.y); if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Invalid pet movement distance."); return { x, y, durationMs: value.durationMs === undefined ? undefined : Number(value.durationMs) }; } function validateWander(value: unknown): { distance?: number; durationMs?: number } { const options = isRecord(value) ? value : {}; return { distance: options.distance === undefined ? undefined : Number(options.distance), durationMs: options.durationMs === undefined ? undefined : Number(options.durationMs) }; } diff --git a/apps/desktop/src/plugin-service.ts b/apps/desktop/src/plugin-service.ts index be32f34a..d2684afb 100644 --- a/apps/desktop/src/plugin-service.ts +++ b/apps/desktop/src/plugin-service.ts @@ -65,8 +65,8 @@ export type PluginServiceOptions = { readonly bundledPluginSourceDirs?: readonly string[]; }; -export const bundledOfficialPluginIds = ["openpets.ambient-companion", "openpets.break-buddy", "openpets.pet-pal", "openpets.focus-buddy", "openpets.wander-buddy", "openpets.github-notifications"] as const; -const bundledEnabledByDefault = new Set(["openpets.ambient-companion", "openpets.break-buddy", "openpets.pet-pal", "openpets.focus-buddy", "openpets.wander-buddy"]); +export const bundledOfficialPluginIds = ["openpets.ambient-companion", "openpets.break-buddy", "openpets.pet-pal", "openpets.focus-buddy", "openpets.wander-buddy", "openpets.quick-reminders", "openpets.github-notifications"] as const; +const bundledEnabledByDefault = new Set(["openpets.ambient-companion", "openpets.break-buddy", "openpets.pet-pal", "openpets.focus-buddy", "openpets.wander-buddy", "openpets.quick-reminders"]); const staleBundledPluginIds = ["openpets.daily-reminders", "openpets.pomodoro"] as const; export class PluginService { @@ -180,10 +180,10 @@ export class PluginService { return { ok: true, snapshot: await this.getSnapshot() }; } - async executeCommand(id: string, commandId: string): Promise { + async executeCommand(id: string, commandId: string, args?: Record): Promise { const record = this.stateStore.getRecord(id); if (!record) return this.#error("Plugin is not installed."); - try { await this.runtime.executeCommand(id, commandId); } + try { await this.runtime.executeCommand(id, commandId, args); } catch (error) { return this.#error(safeError(error)); } return { ok: true, snapshot: await this.getSnapshot() }; } @@ -441,18 +441,18 @@ export function initializePluginService(userDataPath: string, petApi: PluginPetA return appPluginService; } -export type PluginCommandMenuItem = { readonly pluginId: string; readonly pluginName: string; readonly commandId: string; readonly commandTitle: string }; +export type PluginCommandMenuItem = { readonly pluginId: string; readonly pluginName: string; readonly commandId: string; readonly commandTitle: string; readonly form?: PluginCommand["form"] }; export async function getDefaultPetPluginCommands(maxPlugins = 8, maxCommandsPerPlugin = 8): Promise { if (!appPluginService) return []; const snapshot = await appPluginService.getSnapshot(); return snapshot.plugins.filter((plugin) => plugin.enabled && !plugin.brokenReason && plugin.commands && plugin.commands.length > 0) .sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id) || a.id.localeCompare(b.id)).slice(0, maxPlugins) - .flatMap((plugin) => [...(plugin.commands ?? [])].sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id)).slice(0, maxCommandsPerPlugin).map((command) => ({ pluginId: plugin.id, pluginName: plugin.name ?? plugin.id, commandId: command.id, commandTitle: command.title }))); + .flatMap((plugin) => [...(plugin.commands ?? [])].slice(0, maxCommandsPerPlugin).map((command) => ({ pluginId: plugin.id, pluginName: plugin.name ?? plugin.id, commandId: command.id, commandTitle: command.title, form: command.form }))); } -export async function executeDefaultPetPluginCommand(pluginId: string, commandId: string): Promise { +export async function executeDefaultPetPluginCommand(pluginId: string, commandId: string, args?: Record): Promise { if (!appPluginService) return; - await appPluginService.executeCommand(pluginId, commandId); + await appPluginService.executeCommand(pluginId, commandId, args); } export function stopPluginService(): void { diff --git a/apps/desktop/tests/plugin-service.test.ts b/apps/desktop/tests/plugin-service.test.ts index e76bebb8..61408961 100644 --- a/apps/desktop/tests/plugin-service.test.ts +++ b/apps/desktop/tests/plugin-service.test.ts @@ -477,7 +477,7 @@ await scenario("right-click command helper groups caps and ignores stale command stop() {}, } as unknown as PluginService); const commands = await getDefaultPetPluginCommands(2, 2); - assert.deepEqual(commands.map((command) => `${command.pluginId}:${command.commandId}`), ["alpha:run", "zeta:a", "zeta:b"]); + assert.deepEqual(commands.map((command) => `${command.pluginId}:${command.commandId}`), ["alpha:run", "zeta:b", "zeta:a"]); setPluginServiceForTests({ getSnapshot: async () => ({ plugins: [{ id: "alpha", name: "Alpha", version: "1.0.0", source: "catalog", enabled: true, approvedPermissions: [], commands: [{ id: "run", title: "Run" }] }, { id: "zeta", name: "Zeta", version: "1.0.0", source: "catalog", enabled: true, approvedPermissions: [], commands: [] }] }), executeCommand: async (pluginId: string, commandId: string) => { runtime.executed.push({ pluginId, commandId }); }, stop() {} } as unknown as PluginService); assert.deepEqual((await getDefaultPetPluginCommands()).map((command) => command.pluginId), ["alpha"]); await executeDefaultPetPluginCommand("alpha", "run"); diff --git a/docs/plugins.md b/docs/plugins.md index 6cec8ea1..b7188405 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -16,15 +16,16 @@ The current implementation includes: - Catalog v2 support at `https://openpets.dev/plugins/catalog.v2.json`, with v1 fallback for older/declarative catalog support. - Local developer plugin loading through explicit environment variables. - Host-rendered plugin configuration and command UI in the desktop Plugins window. -- Six launch-current first-party JavaScript plugins under `plugins/official`: +- Seven launch-current first-party JavaScript plugins under `plugins/official`: - `openpets.ambient-companion` - `openpets.break-buddy` - `openpets.pet-pal` - `openpets.focus-buddy` - `openpets.wander-buddy` + - `openpets.quick-reminders` - `openpets.github-notifications` -Ambient Companion, Break Buddy, Pet Pal, Focus Buddy, and Wander Buddy form the companion-first default bundle. GitHub Notifications remains available as a Developer/Advanced plugin and is not part of the regular-user default experience. +Ambient Companion, Break Buddy, Pet Pal, Focus Buddy, Wander Buddy, and Quick Reminders form the companion-first default bundle. GitHub Notifications remains available as a Developer/Advanced plugin and is not part of the regular-user default experience. ## Non-goals for the current release @@ -181,7 +182,7 @@ type OpenPetsPluginContext = { onChange(handler: (config: T) => void | Promise): () => void } commands: { - register(command: { id: string; title: string; description?: string }, handler: () => void | Promise): Promise + register(command: { id: string; title: string; description?: string; form?: { submitLabel?: string; fields: Array<{ id: string; type: "text" | "textarea" | "number"; label: string; default?: string | number; min?: number; max?: number; maxLength?: number; required?: boolean }> } }, handler: (values?: Record) => void | Promise): Promise unregister(id: string): Promise } status: { @@ -213,6 +214,7 @@ The main-process SDK bridge validates and limits plugin behavior: - Pet messages/reactions go through normal OpenPets validation. - Pet movement only affects the default pet, never resizes windows, clamps to the primary work area, caps each move to about 160px, uses 250-1500ms stepped animation, skips while hidden/paused/dragging/busy, and saves the final position. - Schedule ids and command ids must be short safe identifiers. +- Commands may include a tiny host-rendered form schema. The pet context menu opens these forms in a dedicated dialog and passes validated values to the command handler. - Interval schedules have a minimum delay. - Daily schedules require `HH:mm` and optional weekdays `0-6`. - Storage keys are restricted and plugin storage has a size quota. @@ -378,6 +380,12 @@ Purpose: quiet companion movement so the default pet occasionally takes small sa Uses `pet:move`, `schedule`, `storage`, `commands`, and `status`. It is bundled and enabled by default with conservative Subtle/Rare defaults, respects quiet hours, and exposes right-click commands for “Take a little walk”, “Return home”, and “Stay still for now”. +### Quick Reminders + +Purpose: local one-shot reminders set from the pet context menu. + +Uses `commands`, command forms, `schedule`, `storage`, `status`, `pet:speak`, and `pet:reaction`. It is bundled and enabled by default, but passive until the user sets a reminder. + ### GitHub Notifications Purpose: public repository release/workflow notifications for developers. diff --git a/docs/release.md b/docs/release.md index e3456549..b132de68 100644 --- a/docs/release.md +++ b/docs/release.md @@ -82,6 +82,7 @@ Release goals: - Pet Pal (`openpets.pet-pal`) - Focus Buddy (`openpets.focus-buddy`) - Wander Buddy (`openpets.wander-buddy`) + - Quick Reminders (`openpets.quick-reminders`) - GitHub Notifications (`openpets.github-notifications`) 3. Remove legacy sample plugins from public discovery: - Break Reminder @@ -136,7 +137,7 @@ For explicit local plugin development, run `pnpm dev:desktop:plugins` separately Web release includes: - `plugins/official/**` source plugins. -- `web/public/plugins/catalog.v2.json` with the six official plugins. +- `web/public/plugins/catalog.v2.json` with the seven official plugins. - `web/public/plugins/catalog.v1.json` with an empty plugin list. - Removal of legacy sample plugin manifests. - Updated `web/docs/plugin-publishing.md`. @@ -158,7 +159,7 @@ Publishing sequence: pnpm plugins:check pnpm plugins:package ``` -2. Confirm `web/public/plugins/catalog.v2.json` has only the six launch-current official plugins. +2. Confirm `web/public/plugins/catalog.v2.json` has only the seven launch-current official plugins. 3. Confirm `web/public/plugins/catalog.v1.json` has `plugins: []`. 4. Upload plugin ZIPs to R2 and regenerate catalogs: ```bash diff --git a/plugins/official/openpets.quick-reminders/index.js b/plugins/official/openpets.quick-reminders/index.js new file mode 100644 index 00000000..2fcb7e33 --- /dev/null +++ b/plugins/official/openpets.quick-reminders/index.js @@ -0,0 +1,88 @@ +export const MAX_REMINDERS = 10; +export const MAX_MESSAGE_LENGTH = 140; +export const MAX_DELAY_MS = 24 * 60 * 60 * 1000; + +export function cleanMessage(value, fallback = "Reminder time.") { + const text = typeof value === "string" ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : ""; + return (text || fallback).slice(0, MAX_MESSAGE_LENGTH).trim() || fallback; +} + +export function durationMs(values = {}) { + const hours = Math.max(0, Math.min(23, Math.round(Number(values.hours ?? 0)))); + const minutes = Math.max(0, Math.min(59, Math.round(Number(values.minutes ?? 0)))); + const ms = (hours * 60 + minutes) * 60_000; + if (ms < 60_000 || ms > MAX_DELAY_MS) throw new Error("Reminder duration must be 1 minute to 24 hours."); + return ms; +} + +export async function getReminders(ctx) { + const reminders = await ctx.storage.get("reminders"); + return Array.isArray(reminders) ? reminders.filter((r) => r && typeof r.id === "string" && typeof r.dueAt === "number" && typeof r.message === "string").slice(0, MAX_REMINDERS) : []; +} + +async function saveReminders(ctx, reminders) { + await ctx.storage.set("reminders", reminders.slice(0, MAX_REMINDERS)); + await ctx.status.set(reminders.length ? { text: `${reminders.length} reminder${reminders.length === 1 ? "" : "s"} active`, tone: "info" } : { text: "No active reminders", tone: "info" }); +} + +export async function fireReminder(ctx, id) { + const reminders = await getReminders(ctx); + const item = reminders.find((r) => r.id === id); + await saveReminders(ctx, reminders.filter((r) => r.id !== id)); + if (!item) return false; + await ctx.pet.speak(item.message); + await ctx.pet.react("waving"); + return true; +} + +export async function scheduleReminder(ctx, reminder) { + const delay = Math.max(1_000, reminder.dueAt - Date.now()); + await ctx.schedule.once(reminder.id, delay, () => fireReminder(ctx, reminder.id)); +} + +export async function addReminder(ctx, message, delayMs) { + const reminders = (await getReminders(ctx)).filter((r) => r.dueAt > Date.now()); + if (reminders.length >= MAX_REMINDERS) throw new Error("Quick Reminders can keep up to 10 active reminders."); + const reminder = { id: `reminder-${Date.now().toString(36)}`.slice(0, 64), message: cleanMessage(message), dueAt: Date.now() + delayMs }; + reminders.push(reminder); + await saveReminders(ctx, reminders); + await scheduleReminder(ctx, reminder); + await ctx.pet.speak("Reminder set."); + await ctx.pet.react("success"); + return reminder; +} + +export async function reconcile(ctx) { + await ctx.schedule.cancelAll(); + const now = Date.now(); + const reminders = await getReminders(ctx); + const future = reminders.filter((r) => r.dueAt > now); + const overdue = reminders.filter((r) => r.dueAt <= now); + await saveReminders(ctx, future); + for (const item of future) await scheduleReminder(ctx, item); + if (overdue.length) await ctx.pet.speak(`${overdue.length} reminder${overdue.length === 1 ? "" : "s"} missed while OpenPets was closed.`); +} + +export function summary(reminders, now = Date.now()) { + if (!reminders.length) return "No active reminders."; + return reminders.slice(0, 5).map((r) => `${Math.max(1, Math.ceil((r.dueAt - now) / 60_000))} min: ${r.message}`).join("; "); +} + +export function register(OpenPetsPlugin) { + OpenPetsPlugin.register({ + async start(ctx) { + await reconcile(ctx); + await ctx.commands.register({ id: "set-reminder", title: "Set reminder...", description: "Create a quick local reminder.", form: { submitLabel: "Set Reminder", fields: [ + { id: "message", type: "textarea", label: "Message", required: true, maxLength: MAX_MESSAGE_LENGTH, default: "Reminder time." }, + { id: "hours", type: "number", label: "Hours", default: 0, min: 0, max: 23 }, + { id: "minutes", type: "number", label: "Minutes", default: 15, min: 0, max: 59 } + ] } }, async (values) => addReminder(ctx, values.message, durationMs(values))); + await ctx.commands.register({ id: "reminder-15", title: "15 min reminder", description: "Set a default reminder for 15 minutes." }, () => addReminder(ctx, "Reminder time.", 15 * 60_000)); + await ctx.commands.register({ id: "reminder-30", title: "30 min reminder", description: "Set a default reminder for 30 minutes." }, () => addReminder(ctx, "Reminder time.", 30 * 60_000)); + await ctx.commands.register({ id: "reminder-60", title: "1 hour reminder", description: "Set a default reminder for 1 hour." }, () => addReminder(ctx, "Reminder time.", 60 * 60_000)); + await ctx.commands.register({ id: "view-reminders", title: "View reminders", description: "Speak active reminders." }, async () => ctx.pet.speak(summary(await getReminders(ctx)))); + await ctx.commands.register({ id: "clear-reminders", title: "Clear reminders", description: "Cancel all active reminders." }, async () => { await ctx.schedule.cancelAll(); await saveReminders(ctx, []); await ctx.pet.speak("Reminders cleared."); }); + }, + async stop() {} + }); +} diff --git a/plugins/official/openpets.quick-reminders/openpets.plugin.json b/plugins/official/openpets.quick-reminders/openpets.plugin.json new file mode 100644 index 00000000..14ec1276 --- /dev/null +++ b/plugins/official/openpets.quick-reminders/openpets.plugin.json @@ -0,0 +1,12 @@ +{ + "manifestVersion": 2, + "id": "openpets.quick-reminders", + "name": "Quick Reminders", + "description": "Set short local reminders from the pet menu.", + "version": "1.0.0", + "runtime": "javascript", + "icon": "bell", + "sdkVersion": "1.0.0", + "entry": "index.js", + "permissions": ["pet:speak", "pet:reaction", "schedule", "storage", "commands", "status"] +} diff --git a/plugins/official/openpets.quick-reminders/test.js b/plugins/official/openpets.quick-reminders/test.js new file mode 100644 index 00000000..c1591dfd --- /dev/null +++ b/plugins/official/openpets.quick-reminders/test.js @@ -0,0 +1,7 @@ +import assert from "node:assert/strict"; +import { cleanMessage, durationMs, summary } from "./index.js"; + +assert.equal(cleanMessage(" hello\nthere "), "hello there"); +assert.equal(durationMs({ hours: 1, minutes: 30 }), 90 * 60_000); +assert.throws(() => durationMs({ hours: 0, minutes: 0 })); +assert.equal(summary([]), "No active reminders.");