Add Quick Reminders plugin

This commit is contained in:
Alvin Unreal 2026-05-27 21:45:47 +02:00
parent fed1bc4b3a
commit 33327afe90
14 changed files with 197 additions and 23 deletions

View file

@ -13,6 +13,7 @@ files:
- control-center-preload.cjs
- pet-preload.cjs
- plugin-sdk-preload.cjs
- plugin-command-form-preload.cjs
- assets/**
- package.json

View file

@ -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(),
});

View file

@ -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}`);

View file

@ -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<string, { name: string; commands: Electron.MenuItemConstructorOptions[] }>();
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<void> {
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(/</g, "\\u003c");
const html = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><title>${escapeHtml(title)}</title><style>body{margin:0;font:13px system-ui,sans-serif;background:#fff;color:#161616}.wrap{padding:18px}h1{font-size:16px;margin:0 0 14px}label{display:block;font-weight:600;margin:10px 0 5px}input,textarea{box-sizing:border-box;width:100%;border:1px solid #bbb;border-radius:8px;padding:8px;font:inherit}textarea{min-height:74px;resize:vertical}.error{color:#b00020;min-height:18px;margin-top:8px}.buttons{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}button{border:0;border-radius:8px;padding:8px 12px;font:inherit}button.primary{background:#2563eb;color:white}</style></head><body><form class="wrap"><h1></h1><div id="fields"></div><div class="error" role="alert"></div><div class="buttons"><button type="button" id="cancel">Cancel</button><button class="primary" type="submit"></button></div></form><script>const data=${data};const api=window.openPetsCommandForm;const form=document.querySelector('form'),fields=document.getElementById('fields'),err=document.querySelector('.error');document.querySelector('h1').textContent=data.title;document.querySelector('.primary').textContent=data.form.submitLabel||'Set';for(const f of data.form.fields){const box=document.createElement('div');const label=document.createElement('label');label.textContent=f.label;label.htmlFor=f.id;let input=f.type==='textarea'?document.createElement('textarea'):document.createElement('input');input.id=f.id;input.name=f.id;if(f.type==='number')input.type='number';else input.type='text';if(f.default!==undefined)input.value=f.default;if(f.min!==undefined)input.min=f.min;if(f.max!==undefined)input.max=f.max;if(f.maxLength!==undefined)input.maxLength=f.maxLength;if(f.required)input.required=true;box.append(label,input);fields.append(box);}document.getElementById('cancel').onclick=()=>api.close();window.addEventListener('keydown',e=>{if(e.key==='Escape')api.close()});form.onsubmit=async e=>{e.preventDefault();err.textContent='';const values={};for(const f of data.form.fields){const el=form.elements[f.id];values[f.id]=f.type==='number'?Number(el.value):String(el.value||'').trim();}try{await api.submit(data.channel,values)}catch(error){err.textContent=(error&&error.message)||'Command failed.'}};</script></body></html>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
function isRecord(value: unknown): value is Record<string, unknown> { 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;

View file

@ -94,7 +94,7 @@ function installSdkHandler(channel: string, contents: WebContents, sdk: PluginSd
}
async function dispatchSdkCall(contents: WebContents, sdk: PluginSdkApi, path: string, args: unknown[]): Promise<unknown> {
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);

View file

@ -70,7 +70,7 @@ export class PluginRuntime {
}
getPluginState(id: string): PluginRuntimePublicState { return this.#sdkBridge.getPublicState(id); }
executeCommand(id: string, commandId: string): Promise<void> { return this.#sdkBridge.executeCommand(id, commandId); }
executeCommand(id: string, commandId: string, args?: Record<string, unknown>): Promise<void> { return this.#sdkBridge.executeCommand(id, commandId, args); }
notifyConfigChanged(id: string): void { this.#sdkBridge.notifyConfigChanged(id); }
async reloadAll(): Promise<void> {

View file

@ -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<string, { commands: Map<string, { meta: PluginCommand; handler: () => unknown | Promise<unknown> }>; status?: PluginStatus; schedules: Map<string, PluginTimerHandle>; configListeners: Set<(config: PluginConfig) => void>; petWindow: WindowCounter; logWindow: WindowCounter; httpWindow: WindowCounter }>();
readonly #states = new Map<string, { commands: Map<string, { meta: PluginCommand; handler: (values?: Record<string, unknown>) => unknown | Promise<unknown> }>; status?: PluginStatus; schedules: Map<string, PluginTimerHandle>; 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<string, unknown>) => 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<void> { 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<string, unknown>, timeoutMs = 5_000): Promise<void> { 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<string>(); 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<string, unknown> { const input = isRecord(args) ? args : {}; const out: Record<string, unknown> = {}; 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) }; }

View file

@ -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<string>(["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<string>(["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<PluginServiceResult> {
async executeCommand(id: string, commandId: string, args?: Record<string, unknown>): Promise<PluginServiceResult> {
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<PluginCommandMenuItem[]> {
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<void> {
export async function executeDefaultPetPluginCommand(pluginId: string, commandId: string, args?: Record<string, unknown>): Promise<void> {
if (!appPluginService) return;
await appPluginService.executeCommand(pluginId, commandId);
await appPluginService.executeCommand(pluginId, commandId, args);
}
export function stopPluginService(): void {

View file

@ -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");

View file

@ -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>): () => void
}
commands: {
register(command: { id: string; title: string; description?: string }, handler: () => void | Promise<void>): Promise<void>
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<string, unknown>) => void | Promise<void>): Promise<void>
unregister(id: string): Promise<void>
}
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.

View file

@ -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

View file

@ -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() {}
});
}

View file

@ -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"]
}

View file

@ -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.");