desktop: harden bridge contracts and test coverage

This commit is contained in:
OpenPets Dev 2026-06-18 01:18:08 +00:00
parent 7290d6c26a
commit bb915ddc55
16 changed files with 176 additions and 31 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<T extends { readonly preferences: Record<string, unknown> }>(state: T): T {
return {
...state,

View file

@ -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<FamiliarOSStateV1["preferences"]>):
};
}
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));

View file

@ -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(/</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>*{box-sizing:border-box}html,body{margin:0;min-width:0}body{font:14px system-ui,"Hiragino Sans","Yu Gothic","Malgun Gothic","Apple SD Gothic Neo","PingFang SC","PingFang TC","Microsoft YaHei","Microsoft JhengHei","Noto Sans CJK JP","Noto Sans CJK KR","Noto Sans CJK SC","Noto Sans CJK TC",sans-serif;background:#fff;color:#161616;overflow:hidden}.wrap{padding:24px}h1{font-size:20px;line-height:1.2;margin:0 0 18px}.field{margin-top:14px}label{display:block;font-weight:700;margin:0 0 7px}.hint{display:block;color:#64748b;font-size:12px;line-height:1.35;margin-top:5px}input,textarea,select{width:100%;border:1px solid #aeb8c8;border-radius:10px;padding:11px 12px;font:inherit;outline:none;background:white;color:#161616}input:focus,textarea:focus,select:focus{border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.16)}textarea{min-height:148px;resize:vertical}.check{display:flex;align-items:center;gap:10px;font-weight:700}.check input{width:auto}.error{color:#b00020;min-height:20px;margin-top:10px}.buttons{display:flex;justify-content:flex-end;gap:10px;margin-top:18px}button{border:0;border-radius:10px;padding:10px 14px;font:inherit;font-weight:700}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">${escapeHtml(t("common.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';const values={};function resize(){requestAnimationFrame(()=>{const root=document.documentElement;api.resize(data.resizeChannel,{width:Math.ceil(Math.max(root.scrollWidth,document.body.scrollWidth)+2),height:Math.ceil(Math.max(root.scrollHeight,document.body.scrollHeight)+2)});});}function addOption(select,option){const el=document.createElement('option');el.value=option.value;el.textContent=option.label||option.value;select.appendChild(el);}for(const f of data.form.fields){const box=document.createElement('div');box.className='field';const label=document.createElement('label');label.textContent=f.label;label.htmlFor=f.id;let input;if(f.type==='textarea'){input=document.createElement('textarea');}else if(f.type==='select'){input=document.createElement('select');for(const option of f.options||[])addOption(input,option);}else if(f.type==='boolean'){label.className='check';input=document.createElement('input');input.type='checkbox';label.prepend(input);}else{input=document.createElement('input');if(f.type==='number')input.type='number';else if(f.type==='time')input.type='time';else if(f.type==='date')input.type='date';else input.type='text';}input.id=f.id;input.name=f.id;if(f.default!==undefined){if(input.type==='checkbox')input.checked=Boolean(f.default);else 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;if(f.type==='boolean'){box.append(label);}else{box.append(label,input);}fields.append(box);input.addEventListener('input',resize);}new ResizeObserver(resize).observe(document.body);resize();form.addEventListener('submit',async(event)=>{event.preventDefault();err.textContent='';for(const f of data.form.fields){const el=form.elements[f.id];values[f.id]=el.type==='number'?Number(el.value):el.type==='checkbox'?Boolean(el.checked):el.value;}try{await api.submit(data.channel,values);}catch(error){err.textContent=String(error&&error.message||error);resize();}});document.getElementById('cancel').addEventListener('click',()=>api.close());</script></body></html>`;
const html = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><title>${escapeHtml(title)}</title><style>*{box-sizing:border-box}html,body{margin:0;min-width:0}body{font:14px system-ui,"Hiragino Sans","Yu Gothic","Malgun Gothic","Apple SD Gothic Neo","PingFang SC","PingFang TC","Microsoft YaHei","Microsoft JhengHei","Noto Sans CJK JP","Noto Sans CJK KR","Noto Sans CJK SC","Noto Sans CJK TC",sans-serif;background:#fff;color:#161616;overflow:hidden}.wrap{padding:24px}h1{font-size:20px;line-height:1.2;margin:0 0 18px}.field{margin-top:14px}label{display:block;font-weight:700;margin:0 0 7px}.hint{display:block;color:#64748b;font-size:12px;line-height:1.35;margin-top:5px}input,textarea,select{width:100%;border:1px solid #aeb8c8;border-radius:10px;padding:11px 12px;font:inherit;outline:none;background:white;color:#161616}input:focus,textarea:focus,select:focus{border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.16)}textarea{min-height:148px;resize:vertical}.check{display:flex;align-items:center;gap:10px;font-weight:700}.check input{width:auto}.error{color:#b00020;min-height:20px;margin-top:10px}.buttons{display:flex;justify-content:flex-end;gap:10px;margin-top:18px}button{border:0;border-radius:10px;padding:10px 14px;font:inherit;font-weight:700}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">${escapeHtml(t("common.cancel"))}</button><button class="primary" type="submit"></button></div></form><script>const data=${data};const api=window.familiarOSCommandForm||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';const values={};function resize(){requestAnimationFrame(()=>{const root=document.documentElement;api.resize(data.resizeChannel,{width:Math.ceil(Math.max(root.scrollWidth,document.body.scrollWidth)+2),height:Math.ceil(Math.max(root.scrollHeight,document.body.scrollHeight)+2)});});}function addOption(select,option){const el=document.createElement('option');el.value=option.value;el.textContent=option.label||option.value;select.appendChild(el);}for(const f of data.form.fields){const box=document.createElement('div');box.className='field';const label=document.createElement('label');label.textContent=f.label;label.htmlFor=f.id;let input;if(f.type==='textarea'){input=document.createElement('textarea');}else if(f.type==='select'){input=document.createElement('select');for(const option of f.options||[])addOption(input,option);}else if(f.type==='boolean'){label.className='check';input=document.createElement('input');input.type='checkbox';label.prepend(input);}else{input=document.createElement('input');if(f.type==='number')input.type='number';else if(f.type==='time')input.type='time';else if(f.type==='date')input.type='date';else input.type='text';}input.id=f.id;input.name=f.id;if(f.default!==undefined){if(input.type==='checkbox')input.checked=Boolean(f.default);else 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;if(f.type==='boolean'){box.append(label);}else{box.append(label,input);}fields.append(box);input.addEventListener('input',resize);}new ResizeObserver(resize).observe(document.body);resize();form.addEventListener('submit',async(event)=>{event.preventDefault();err.textContent='';for(const f of data.form.fields){const el=form.elements[f.id];values[f.id]=el.type==='number'?Number(el.value):el.type==='checkbox'?Boolean(el.checked):el.value;}try{await api.submit(data.channel,values);}catch(error){err.textContent=String(error&&error.message||error);resize();}});document.getElementById('cancel').addEventListener('click',()=>api.close());</script></body></html>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}

View file

@ -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<PluginSdkRoute, SdkCallHandler> = {
// 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<PluginSdkRoute, SdkCallHandler> = {
"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<unknown> {
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<unknown> {
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);
}

View file

@ -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}}
</style></head><body><div class="shell" id="shell"><div class="editor-shell" id="editorShell" hidden><div id="conversations" class="conversations-list" hidden aria-label="Conversations"></div><div id="history" class="history-list" aria-label="Conversation history"></div></div><div class="toolbar"><div class="toolbar-actions"><button class="icon-button" id="editor" type="button" aria-label="Toggle editor" title="Editor"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 9h8"/><path d="M8 13h5"/></svg></button><button class="icon-button" id="newChat" type="button" aria-label="Start new chat" title="New chat"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button><button class="icon-button" id="historyButton" type="button" aria-label="Open history" title="History"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/><path d="M12 7v6l4 2"/></svg></button><button class="icon-button" id="storeKnowledge" type="button" aria-label="Store in Knowledge" title="Store in Knowledge"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg></button><button class="icon-button" id="settings" type="button" aria-label="Open settings" title="Settings"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1 1 0 0 0 .2 1.1l.1.1a1.2 1.2 0 0 1 0 1.7l-1.2 1.2a1.2 1.2 0 0 1-1.7 0l-.1-.1a1 1 0 0 0-1.1-.2 1 1 0 0 0-.6.9V20a1.2 1.2 0 0 1-1.2 1.2h-1.7A1.2 1.2 0 0 1 10.9 20v-.1a1 1 0 0 0-.6-.9 1 1 0 0 0-1.1.2l-.1.1a1.2 1.2 0 0 1-1.7 0l-1.2-1.2a1.2 1.2 0 0 1 0-1.7l.1-.1a1 1 0 0 0 .2-1.1 1 1 0 0 0-.9-.6H4A1.2 1.2 0 0 1 2.8 13v-2A1.2 1.2 0 0 1 4 9.8h.1a1 1 0 0 0 .9-.6 1 1 0 0 0-.2-1.1l-.1-.1a1.2 1.2 0 0 1 0-1.7l1.2-1.2a1.2 1.2 0 0 1 1.7 0l.1.1a1 1 0 0 0 1.1.2 1 1 0 0 0 .6-.9V4A1.2 1.2 0 0 1 10.6 2.8h1.7A1.2 1.2 0 0 1 13.5 4v.1a1 1 0 0 0 .6.9 1 1 0 0 0 1.1-.2l.1-.1a1.2 1.2 0 0 1 1.7 0l1.2 1.2a1.2 1.2 0 0 1 0 1.7l-.1.1a1 1 0 0 0-.2 1.1 1 1 0 0 0 .9.6H20a1.2 1.2 0 0 1 1.2 1.2v2A1.2 1.2 0 0 1 20 14.2h-.1a1 1 0 0 0-.9.8z"/></svg></button><button class="icon-button" id="close" type="button" aria-label="Close chat window" title="Close"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M6 6l12 12"/><path d="M18 6L6 18"/></svg></button></div></div><div class="composer"><input type="file" id="fileInput" multiple><div class="attachments" id="attachments" hidden></div><div class="prompt-shell" id="promptShell"><button class="attach-button" id="attach" type="button" aria-label="Attach file" title="Attach file"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button><div class="input-wrap"><textarea id="prompt" class="prompt-input" placeholder="Ask anything." aria-label="Prompt input"></textarea><div class="feedback" id="feedback" aria-live="polite"></div></div><button class="send-button" id="send" type="button" aria-label="Send prompt" title="Send"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h12"/><path d="M13 6l6 6-6 6"/></svg></button></div></div><div class="resize-grip" id="resizeGrip" aria-hidden="true"></div></div></div><script>
const shellEl = document.getElementById('shell');
const api = window.openPetsPromptWindow;
const api = window.familiarOSPromptWindow || window.openPetsPromptWindow;
const editorShellEl = document.getElementById('editorShell');
const historyEl = document.getElementById('history');
const conversationsEl = document.getElementById('conversations');

View file

@ -12,7 +12,7 @@ React/Tailwind source for the Control Center management UI. This renderer presen
- **Integrations**: Card-first setup UI for Claude Code, OpenCode, Cursor, and Pi guidance, including command mode/path controls and preview/action flows.
- **Plugins**: Gallery-first plugin hub for installed/catalog/local/broken filters, catalog refresh, local load, install/update/uninstall, enable/disable, config modal, command execution, runtime/status display, and broken-state feedback.
- **Settings**: Startup, launch-at-login, familiar scale, reaction-animation mapping, update check, default-familiar position reset, and familiar reaction previews.
- **Bridge Contract**: All data and actions go through `window.openPetsControlCenter`; page snapshots intentionally omit raw install paths and unrelated app state.
- **Bridge Contract**: All data and actions go through `window.familiarOSControlCenter` with `window.openPetsControlCenter` retained as a legacy alias; page snapshots intentionally omit raw install paths and unrelated app state.
## Key Files

View file

@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { normalizeFamiliarName } from "../src/app-state.js";
import { normalizeFamiliarName } from "../src/app-state-core.js";
assert.equal(normalizeFamiliarName("Mochi"), "Mochi");
assert.equal(normalizeFamiliarName(" Mochi "), "Mochi");

View file

@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
const controlCenterPreloadSource = readFileSync(resolve(desktopRoot, "control-center-preload.cjs"), "utf8");
const promptWindowPreloadSource = readFileSync(resolve(desktopRoot, "prompt-window-preload.cjs"), "utf8");
const windowsSource = readFileSync(resolve(desktopRoot, "src/windows.ts"), "utf8");
const promptWindowSource = readFileSync(resolve(desktopRoot, "src/prompt-window.ts"), "utf8");
const familiarWindowSource = readFileSync(resolve(desktopRoot, "src/familiar-window.ts"), "utf8");
const ttsServiceSource = readFileSync(resolve(desktopRoot, "src/tts-service.ts"), "utf8");
assertSetEqual(
"Control Center invoke routes",
extractLiteralFirstArgs(controlCenterPreloadSource, "ipcRenderer.invoke"),
extractLiteralFirstArgs(windowsSource, "ipcMain.handle"),
);
assertSetEqual(
"Control Center event routes",
extractLiteralFirstArgs(controlCenterPreloadSource, "ipcRenderer.on"),
extractLiteralFirstArgs(windowsSource, "webContents.send"),
);
assertSetEqual(
"Prompt window invoke routes",
extractLiteralFirstArgs(promptWindowPreloadSource, "ipcRenderer.invoke"),
extractLiteralFirstArgs(promptWindowSource, "ipcMain.handle"),
);
assertSetEqual(
"Prompt window TTS event routes",
extractPrefixedRoutes(promptWindowPreloadSource, "ipcRenderer.on", "familiaros:tts-"),
new Set([
...extractPrefixedRoutes(familiarWindowSource, "window.webContents.send", "familiaros:tts-"),
...extractPrefixedRoutes(ttsServiceSource, "window.webContents.send", "familiaros:tts-"),
]),
);
assert.deepEqual(
[...extractLiteralFirstArgs(promptWindowPreloadSource, "ipcRenderer.send")],
[],
"Prompt window preload should not expose unhandled fire-and-forget IPC routes.",
);
console.error("Internal UI route conformance validation passed.");
function extractPrefixedRoutes(source: string, callee: string, prefix: string): Set<string> {
return new Set([...extractLiteralFirstArgs(source, callee)].filter((route) => route.startsWith(prefix)));
}
function extractLiteralFirstArgs(source: string, callee: string): Set<string> {
const routes = new Set<string>();
const pattern = new RegExp(`${escapeRegex(callee)}\\(\\s*"([^"]+)"`, "g");
for (let match = pattern.exec(source); match; match = pattern.exec(source)) {
routes.add(match[1] ?? "");
}
return routes;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function assertSetEqual(label: string, actual: Set<string>, expected: Set<string>): void {
const missing = [...expected].filter((route) => !actual.has(route)).sort();
const extra = [...actual].filter((route) => !expected.has(route)).sort();
assert.deepEqual(
{ missing, extra },
{ missing: [], extra: [] },
`${label} drift detected.`,
);
}

View file

@ -94,7 +94,7 @@ await scenario("hud bubble spec validation is enforced", async ({ store, bridge
const record = store.getRecord("plug")!;
const updatedRecord = {
...record,
approvedPermissions: [...record.approvedPermissions, "familiar:pin" as const],
approvedPermissions: [...record.approvedPermissions, "familiar:speak" as const, "familiar:pin" as const],
};
store.upsertRecord(updatedRecord);
@ -133,7 +133,7 @@ await scenario("hud bubble spec validation is enforced", async ({ store, bridge
],
},
}),
/Plugin bubble HUD cannot be combined with text, markdown, body media, or indicator\./,
/Plugin bubble HUD cannot be combined with text(?: or markdown|, markdown, body media, or indicator)\./,
);
// Should reject if items contains more than 4 items

View file

@ -7,8 +7,13 @@ const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileU
const windowsSource = readFileSync(resolve(desktopRoot, "src/windows.ts"), "utf8");
const controlCenterPreloadSource = readFileSync(resolve(desktopRoot, "control-center-preload.cjs"), "utf8");
const controlCenterRendererSource = readFileSync(resolve(desktopRoot, "src/renderer/src/main.tsx"), "utf8");
const familiarWindowSource = readFileSync(resolve(desktopRoot, "src/familiar-window.ts"), "utf8");
const jsHostSource = readFileSync(resolve(desktopRoot, "src/plugin-js-host.ts"), "utf8");
const panelPreloadSource = readFileSync(resolve(desktopRoot, "panel-preload.cjs"), "utf8");
const pluginCommandFormPreloadSource = readFileSync(resolve(desktopRoot, "plugin-command-form-preload.cjs"), "utf8");
const pluginSdkPreloadSource = readFileSync(resolve(desktopRoot, "plugin-sdk-preload.cjs"), "utf8");
const promptWindowPreloadSource = readFileSync(resolve(desktopRoot, "prompt-window-preload.cjs"), "utf8");
const promptWindowSource = readFileSync(resolve(desktopRoot, "src/prompt-window.ts"), "utf8");
assert.doesNotMatch(windowsSource, /openTaskWindow|TaskWindowKind|createPluginsHtml|getPreloadPath|"preload\.cjs"/);
assert.match(windowsSource, /assertAllowedSender\(event, \["control-center"\]\)/);
@ -40,12 +45,28 @@ assert.doesNotMatch(controlCenterRendererSource, /OnboardingView|currentRoute ==
assert.match(controlCenterRendererSource, /materializeListItemDefaults/);
assert.match(controlCenterRendererSource, /updateCatalogEntry[\s\S]*api\.updateCatalogPlugin/);
assert.match(controlCenterRendererSource, /installed\.source === "catalog"[\s\S]*updateCatalogEntry/);
assert.match(controlCenterRendererSource, /familiarOSControlCenter \?\? apiBridge\.openPetsControlCenter/);
assert.match(jsHostSource, /FamiliarOSPlugin[\s\S]*register/);
assert.match(jsHostSource, /start\(sdk\)/);
assert.match(jsHostSource, /__openPetsRegisteredPlugin[\s\S]*stop/);
assert.match(jsHostSource, /__familiarOSRegisteredPlugin/);
assert.match(jsHostSource, /__openPetsRegisteredPlugin/);
assert.match(jsHostSource, /__familiarOSError/);
assert.match(jsHostSource, /__familiarOSRunCallback \?\? globalThis\.__openPetsRunCallback/);
assert.match(jsHostSource, /preload: getPluginSdkPreloadPath\(\)/);
assert.match(panelPreloadSource, /contextBridge\.exposeInMainWorld\("familiarOSPanel", api\)/);
assert.match(panelPreloadSource, /contextBridge\.exposeInMainWorld\("openPetsPanel", api\)/);
assert.match(pluginCommandFormPreloadSource, /contextBridge\.exposeInMainWorld\("familiarOSCommandForm", api\)/);
assert.match(pluginCommandFormPreloadSource, /contextBridge\.exposeInMainWorld\("openPetsCommandForm", api\)/);
assert.match(promptWindowPreloadSource, /contextBridge\.exposeInMainWorld\("familiarOSPromptWindow", api\)/);
assert.match(promptWindowPreloadSource, /contextBridge\.exposeInMainWorld\("openPetsPromptWindow", api\)/);
assert.match(promptWindowSource, /window\.familiarOSPromptWindow \|\| window\.openPetsPromptWindow/);
assert.match(familiarWindowSource, /window\.familiarOSCommandForm\|\|window\.openPetsCommandForm/);
assert.match(pluginSdkPreloadSource, /__familiarOSError/);
assert.match(pluginSdkPreloadSource, /contextBridge\.exposeInMainWorld\("__familiarOSSdk", sdk\)/);
assert.match(pluginSdkPreloadSource, /contextBridge\.exposeInMainWorld\("__openPetsSdk", sdk\)/);
assert.match(pluginSdkPreloadSource, /contextBridge\.exposeInMainWorld\("__familiarOSRunCallback", runCallback\)/);
assert.match(pluginSdkPreloadSource, /contextBridge\.exposeInMainWorld\("__openPetsRunCallback", runCallback\)/);
assert.match(pluginSdkPreloadSource, /speak: \(spec\) => call\("familiar\.speak", \[petId, spec\]\)/);
assert.match(pluginSdkPreloadSource, /register: \(command, handler\) => call\("commands\.register"/);
// SDK v3 namespaces are exposed to the plugin sandbox.

View file

@ -32,6 +32,12 @@ descriptors validated in the bridge and rendered by `familiar-window.ts`. Plugin
HTML runs only inside the sandboxed *panel* window (`ui:panel`), never in a familiar
window.
Bridge naming note: the desktop sandbox now exposes FamiliarOS-first globals
(`window.familiarOSPanel`, `window.familiarOSCommandForm`,
`window.familiarOSPromptWindow`, `globalThis.__familiarOSSdk`,
`globalThis.__familiarOSRunCallback`) while retaining the historical
OpenPets names as compatibility aliases during the rebrand transition.
## Manifest
`familiaros.plugin.json`, validated by `apps/desktop/src/plugin-manifest.ts`.