From bb915ddc55f27ec6036763d8d8ed01ed55ec2ffe Mon Sep 17 00:00:00 2001 From: OpenPets Dev Date: Thu, 18 Jun 2026 01:18:08 +0000 Subject: [PATCH] desktop: harden bridge contracts and test coverage --- apps/desktop/panel-preload.cjs | 7 +- apps/desktop/plugin-command-form-preload.cjs | 7 +- apps/desktop/plugin-sdk-preload.cjs | 17 ++++- apps/desktop/prompt-window-preload.cjs | 2 +- apps/desktop/scripts/run-tests.mjs | 13 +++- apps/desktop/src/app-state-core.ts | 8 ++ apps/desktop/src/app-state.ts | 11 +-- apps/desktop/src/familiar-window.ts | 2 +- apps/desktop/src/plugin-js-host.ts | 27 +++++-- apps/desktop/src/prompt-window.ts | 2 +- apps/desktop/src/renderer/src/codemap.md | 2 +- .../tests/custom-familiar-name.test.ts | 2 +- .../internal-ui-route-conformance.test.ts | 74 +++++++++++++++++++ apps/desktop/tests/plugin-sdk-bridge.test.ts | 4 +- apps/desktop/tests/plugin-ui-static.test.ts | 23 +++++- docs/plugins.md | 6 ++ 16 files changed, 176 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/tests/internal-ui-route-conformance.test.ts diff --git a/apps/desktop/panel-preload.cjs b/apps/desktop/panel-preload.cjs index 19c810bc..39e6b53b 100644 --- a/apps/desktop/panel-preload.cjs +++ b/apps/desktop/panel-preload.cjs @@ -10,8 +10,11 @@ ipcRenderer.on(`${channel}:message`, (_event, msg) => { } }); -contextBridge.exposeInMainWorld("openPetsPanel", { +const api = { postMessage: (msg) => { if (channel) ipcRenderer.send(`${channel}:to-plugin`, msg); }, onMessage: (handler) => { if (typeof handler === "function") handlers.add(handler); return () => handlers.delete(handler); }, close: () => { if (channel) ipcRenderer.send(`${channel}:close`); }, -}); +}; + +contextBridge.exposeInMainWorld("familiarOSPanel", api); +contextBridge.exposeInMainWorld("openPetsPanel", api); diff --git a/apps/desktop/plugin-command-form-preload.cjs b/apps/desktop/plugin-command-form-preload.cjs index 61ee0266..130afa24 100644 --- a/apps/desktop/plugin-command-form-preload.cjs +++ b/apps/desktop/plugin-command-form-preload.cjs @@ -1,7 +1,10 @@ const { contextBridge, ipcRenderer } = require("electron"); -contextBridge.exposeInMainWorld("openPetsCommandForm", { +const api = { submit: (channel, values) => ipcRenderer.invoke(String(channel), values && typeof values === "object" ? values : {}), resize: (channel, size) => ipcRenderer.send(String(channel), size && typeof size === "object" ? size : {}), close: () => window.close(), -}); +}; + +contextBridge.exposeInMainWorld("familiarOSCommandForm", api); +contextBridge.exposeInMainWorld("openPetsCommandForm", api); diff --git a/apps/desktop/plugin-sdk-preload.cjs b/apps/desktop/plugin-sdk-preload.cjs index b6efff8c..950a73ff 100644 --- a/apps/desktop/plugin-sdk-preload.cjs +++ b/apps/desktop/plugin-sdk-preload.cjs @@ -17,7 +17,14 @@ async function call(path, args) { function callSync(path, args) { if (!channel) throw new Error("FamiliarOS plugin SDK is unavailable."); const result = ipcRenderer.sendSync(channel, path, normalizeForIpc(args)); - if (result && typeof result === "object" && typeof result.__openPetsError === "string") throw new Error(result.__openPetsError); + if (result && typeof result === "object") { + const errorMessage = typeof result.__familiarOSError === "string" + ? result.__familiarOSError + : typeof result.__openPetsError === "string" + ? result.__openPetsError + : ""; + if (errorMessage) throw new Error(errorMessage); + } return result; } @@ -258,9 +265,13 @@ Object.defineProperty(sdk, "locale", { get: () => callSync("i18n.locale", []), }); +contextBridge.exposeInMainWorld("__familiarOSSdk", sdk); contextBridge.exposeInMainWorld("__openPetsSdk", sdk); -contextBridge.exposeInMainWorld("__openPetsRunCallback", async (id, args) => { +const runCallback = async (id, args) => { const callback = callbacks.get(id); if (callback) return callback(...(Array.isArray(args) ? args : [])); return undefined; -}); +}; + +contextBridge.exposeInMainWorld("__familiarOSRunCallback", runCallback); +contextBridge.exposeInMainWorld("__openPetsRunCallback", runCallback); diff --git a/apps/desktop/prompt-window-preload.cjs b/apps/desktop/prompt-window-preload.cjs index dc141ddf..2fb91e73 100644 --- a/apps/desktop/prompt-window-preload.cjs +++ b/apps/desktop/prompt-window-preload.cjs @@ -11,9 +11,9 @@ const api = { resizeWindow: (bounds) => ipcRenderer.invoke("familiaros:prompt-window-resize", bounds), close: () => ipcRenderer.invoke("familiaros:prompt-window-close"), storeKnowledgeFile: (file) => ipcRenderer.invoke("familiaros:prompt-window-store-file", file), - log: (level, message) => ipcRenderer.send("familiaros:prompt-window-log", level, message), }; +contextBridge.exposeInMainWorld("familiarOSPromptWindow", api); contextBridge.exposeInMainWorld("openPetsPromptWindow", api); // --- TTS playback ------------------------------------------------------------- diff --git a/apps/desktop/scripts/run-tests.mjs b/apps/desktop/scripts/run-tests.mjs index 6b59d04a..034458b7 100644 --- a/apps/desktop/scripts/run-tests.mjs +++ b/apps/desktop/scripts/run-tests.mjs @@ -11,9 +11,18 @@ import { dirname, join } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, ".."); -const preloadChecks = ["control-center-preload.cjs", "familiar-preload.cjs", "plugin-sdk-preload.cjs", "panel-preload.cjs"]; +const preloadChecks = [ + "control-center-preload.cjs", + "familiar-preload.cjs", + "plugin-sdk-preload.cjs", + "panel-preload.cjs", + "plugin-command-form-preload.cjs", + "prompt-window-preload.cjs", +]; const behaviorTests = [ ".test-dist/tests/lease-manager.test.js", + ".test-dist/tests/custom-familiar-name.test.js", + ".test-dist/tests/internal-ui-route-conformance.test.js", ".test-dist/tests/default-familiar-external-show.test.js", ".test-dist/tests/onboarding-state.test.js", ".test-dist/tests/update-version.test.js", @@ -25,12 +34,14 @@ const behaviorTests = [ ".test-dist/tests/prompt-memory-extraction.test.js", ".test-dist/tests/knowledge-store.test.js", ".test-dist/tests/plugin-config.test.js", + ".test-dist/tests/plugin-sdk-bridge.test.js", ".test-dist/tests/plugin-state.test.js", ".test-dist/tests/plugin-runtime.test.js", ".test-dist/tests/plugin-catalog-validation.test.js", ".test-dist/tests/plugin-package.test.js", ".test-dist/tests/plugin-service.test.js", ".test-dist/tests/plugin-ui-static.test.js", + ".test-dist/tests/plugin-user-sound-store.test.js", ".test-dist/tests/plugin-bridge-fuzz.test.js", ]; const contractTests = [ diff --git a/apps/desktop/src/app-state-core.ts b/apps/desktop/src/app-state-core.ts index 5e9d4f53..4cad6e6c 100644 --- a/apps/desktop/src/app-state-core.ts +++ b/apps/desktop/src/app-state-core.ts @@ -29,6 +29,14 @@ export function normalizeOnboardingCompleted(value: OnboardingPreferenceLike): b return typeof value.onboardingCompleted === "boolean" ? value.onboardingCompleted : false; } +export function normalizeFamiliarName(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + if (/[\r\n\0\x01-\x1f\x7f]/.test(value)) return undefined; + const trimmed = value.trim().slice(0, 64); + if (!trimmed) return undefined; + return trimmed; +} + export function markOnboardingCompleted }>(state: T): T { return { ...state, diff --git a/apps/desktop/src/app-state.ts b/apps/desktop/src/app-state.ts index 1d5378ca..f15268d6 100644 --- a/apps/desktop/src/app-state.ts +++ b/apps/desktop/src/app-state.ts @@ -3,7 +3,7 @@ import { dirname, isAbsolute, join } from "node:path"; import { app } from "electron"; -import { defaultPetScale, markOnboardingCompleted, normalizeOnboardingCompleted, normalizePetScale, petScaleOptions, type PetScaleValue } from "./app-state-core.js"; +import { defaultPetScale, markOnboardingCompleted, normalizeFamiliarName, normalizeOnboardingCompleted, normalizePetScale, petScaleOptions, type PetScaleValue } from "./app-state-core.js"; import { builtInPet } from "./built-in-familiar.js"; import type { Point } from "./display.js"; import { isSupportedLocale, type LocalePreference } from "./i18n/catalog.js"; @@ -80,7 +80,7 @@ export type FamiliarOSActivityRecord = | { readonly kind: "say"; readonly reaction?: FamiliarOSReaction; readonly petId?: string } | { readonly kind: "react"; readonly reaction: FamiliarOSReaction; readonly petId?: string }; -export { defaultPetScale, normalizePetScale, petScaleOptions, type PetScaleValue }; +export { defaultPetScale, normalizeFamiliarName, normalizePetScale, petScaleOptions, type PetScaleValue }; export const defaultOpenApiChatEndpoint = "https://api.openai.com/v1/responses"; @@ -452,13 +452,6 @@ function normalizePreferences(value: Partial): }; } -export function normalizeFamiliarName(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const trimmed = value.trim().slice(0, 64); - if (!trimmed || /[\r\n\0\x01-\x1f\x7f]/.test(trimmed)) return undefined; - return trimmed; -} - function normalizeVanillaChatMcpTools(value: unknown): readonly string[] | undefined { if (!Array.isArray(value)) return undefined; const valid = value.filter((v): v is string => typeof v === "string" && /^[a-z0-9-]+$/.test(v)); diff --git a/apps/desktop/src/familiar-window.ts b/apps/desktop/src/familiar-window.ts index 73a7ecd6..641234dd 100644 --- a/apps/desktop/src/familiar-window.ts +++ b/apps/desktop/src/familiar-window.ts @@ -284,7 +284,7 @@ function clampNumber(value: number, min: number, max: number): number { function buildPluginCommandFormUrl(title: string, form: PluginCommandForm, channel: string, resizeChannel: 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, resizeChannel }).replace(/${escapeHtml(title)}

`; + const html = `${escapeHtml(title)}

`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } diff --git a/apps/desktop/src/plugin-js-host.ts b/apps/desktop/src/plugin-js-host.ts index e0e3d8fb..055cabdf 100644 --- a/apps/desktop/src/plugin-js-host.ts +++ b/apps/desktop/src/plugin-js-host.ts @@ -106,8 +106,9 @@ function installSdkHandler(channel: string, contents: WebContents, sdk: PluginSd if (!sdk || typeof path !== "string" || !isPluginSdkRoute(path) || !Array.isArray(args)) throw new Error("Invalid plugin SDK call."); event.returnValue = dispatchSyncSdkCall(sdk, path, args); } catch (error) { + const message = error instanceof Error ? error.message : String(error); logPluginDiagnostic(logger, "warn", "plugin sdk dispatch failed", { pluginId, route: typeof path === "string" ? path : "invalid", ok: false, reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error), durationMs: Date.now() - started }); - event.returnValue = { __openPetsError: error instanceof Error ? error.message : String(error) }; + event.returnValue = { __familiarOSError: message, __openPetsError: message }; } }; ipcMain.handle(channel, async (event: IpcMainInvokeEvent, path: unknown, args: unknown[]) => { @@ -131,6 +132,13 @@ type SdkCallHandler = (sdk: PluginSdkApi, args: unknown[], runCallback: RunCallb const noop = (): void => undefined; const callbackOf = (runCallback: RunCallback, id: unknown): ((...callbackArgs: unknown[]) => unknown) => runCallback(id) ?? noop; +function buildRunCallbackScript(id: string, callbackArgsSource: string): string { + return `(() => { + const callback = globalThis.__familiarOSRunCallback ?? globalThis.__openPetsRunCallback; + return typeof callback === "function" ? callback(${JSON.stringify(id)}, ${callbackArgsSource}) : undefined; + })()`; +} + export const sdkCallHandlers: Record = { // Familiar handles (first arg is the familiar handle id; "default" targets the default familiar). "familiar.speak": (sdk, args) => sdk.familiars.forPet(args[0]).speak(args[1]), @@ -206,7 +214,7 @@ export const sdkCallHandlers: Record = { "storage.unsubscribe": (sdk, args) => sdk.storage.unsubscribe(args[0]), // Config (special-cased disposers keyed by callback id). "config.get": (sdk) => sdk.config.get(), - "config.onChange": (sdk, args, _runCallback, contents) => { const id = String(args[0] ?? ""); if (!id) return { ok: false }; const disposer = sdk.config.onChange((config) => { void contents.executeJavaScript(`globalThis.__openPetsRunCallback(${JSON.stringify(id)}, [${JSON.stringify(config)}])`, true); }); let map = configDisposers.get(contents); if (!map) { map = new Map(); configDisposers.set(contents, map); } map.set(id, disposer); return { ok: true }; }, + "config.onChange": (sdk, args, _runCallback, contents) => { const id = String(args[0] ?? ""); if (!id) return { ok: false }; const disposer = sdk.config.onChange((config) => { void contents.executeJavaScript(buildRunCallbackScript(id, `[${JSON.stringify(config)}]`), true); }); let map = configDisposers.get(contents); if (!map) { map = new Map(); configDisposers.set(contents, map); } map.set(id, disposer); return { ok: true }; }, "config.offChange": (sdk, args, _runCallback, contents) => { const id = String(args[0] ?? ""); const disposer = configDisposers.get(contents)?.get(id); disposer?.(); configDisposers.get(contents)?.delete(id); return { ok: true }; }, // Network. "net.fetch": (sdk, args) => sdk.net.fetch(String(args[0]), args[1]), @@ -261,7 +269,7 @@ function dispatchSyncSdkCall(sdk: PluginSdkApi, path: PluginSdkRoute, args: unkn } async function dispatchSdkCall(contents: WebContents, sdk: PluginSdkApi, path: PluginSdkRoute, args: unknown[]): Promise { - const runCallback: RunCallback = (id) => typeof id === "string" ? (...callbackArgs: unknown[]) => contents.executeJavaScript(`globalThis.__openPetsRunCallback(${JSON.stringify(id)}, ${JSON.stringify(callbackArgs)})`, true) : undefined; + const runCallback: RunCallback = (id) => typeof id === "string" ? (...callbackArgs: unknown[]) => contents.executeJavaScript(buildRunCallbackScript(id, JSON.stringify(callbackArgs)), true) : undefined; const handler = sdkCallHandlers[path]; if (!handler) throw new Error("Unknown plugin SDK call."); return handler(sdk, args, runCallback, contents); @@ -316,8 +324,12 @@ export function buildPluginModuleUrl(source: string, sourceUrl: string): string export function buildPluginRegistrationHandshakeCode(entryUrl: string): string { return `(() => new Promise((resolve, reject) => { let done = false; - const sdk = globalThis.__openPetsSdk; - const finish = (value) => { if (done) return; done = true; globalThis.__openPetsRegisteredPlugin = value; Promise.resolve(value && typeof value.start === "function" ? value.start(sdk) : undefined).then(() => resolve(true), reject); }; + const sdk = globalThis.__familiarOSSdk ?? globalThis.__openPetsSdk; + const setRegisteredPlugin = (value) => { + globalThis.__familiarOSRegisteredPlugin = value; + globalThis.__openPetsRegisteredPlugin = value; + }; + const finish = (value) => { if (done) return; done = true; setRegisteredPlugin(value); Promise.resolve(value && typeof value.start === "function" ? value.start(sdk) : undefined).then(() => resolve(true), reject); }; Object.defineProperty(globalThis, "FamiliarOSPlugin", { configurable: false, enumerable: false, writable: false, value: Object.freeze({ register: finish }) }); import(${JSON.stringify(entryUrl)}).then((mod) => { if (mod && typeof mod.register === "function") Promise.resolve(mod.register(globalThis.FamiliarOSPlugin)).then(finish, reject); @@ -333,6 +345,9 @@ function runRegistrationHandshake(contents: WebContents, entryUrl: string, sdk: } function stopRegisteredPlugin(contents: WebContents): Promise { - const code = `Promise.resolve(globalThis.__openPetsRegisteredPlugin && typeof globalThis.__openPetsRegisteredPlugin.stop === "function" ? globalThis.__openPetsRegisteredPlugin.stop() : undefined).then(() => true)`; + const code = `(() => { + const plugin = globalThis.__familiarOSRegisteredPlugin ?? globalThis.__openPetsRegisteredPlugin; + return Promise.resolve(plugin && typeof plugin.stop === "function" ? plugin.stop() : undefined).then(() => true); + })()`; return contents.executeJavaScript(code, true); } diff --git a/apps/desktop/src/prompt-window.ts b/apps/desktop/src/prompt-window.ts index 223e4914..efbe987f 100644 --- a/apps/desktop/src/prompt-window.ts +++ b/apps/desktop/src/prompt-window.ts @@ -393,7 +393,7 @@ function buildPromptWindowUrl(): string { @media (max-width:460px){.shell{padding:7px 7px 5px}.history-list{min-height:72px}.conversations-list{min-height:72px}.prompt-input{min-height:46px}.send-button{width:38px;min-width:38px}}