Add Quick Reminders plugin
This commit is contained in:
parent
fed1bc4b3a
commit
33327afe90
14 changed files with 197 additions and 23 deletions
|
|
@ -13,6 +13,7 @@ files:
|
|||
- control-center-preload.cjs
|
||||
- pet-preload.cjs
|
||||
- plugin-sdk-preload.cjs
|
||||
- plugin-command-form-preload.cjs
|
||||
- assets/**
|
||||
- package.json
|
||||
|
||||
|
|
|
|||
6
apps/desktop/plugin-command-form-preload.cjs
Normal file
6
apps/desktop/plugin-command-form-preload.cjs
Normal 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(),
|
||||
});
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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) }; }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
88
plugins/official/openpets.quick-reminders/index.js
Normal file
88
plugins/official/openpets.quick-reminders/index.js
Normal 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() {}
|
||||
});
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
7
plugins/official/openpets.quick-reminders/test.js
Normal file
7
plugins/official/openpets.quick-reminders/test.js
Normal 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.");
|
||||
Loading…
Add table
Reference in a new issue