- Rename all user-facing and technical identifiers from OpenPets/Pet to FamiliarOS/Familiar. - Rename packages from @open-pets/* to @familiaros/*; rename install-pet/pet-format packages. - Rename plugin IDs and directories from openpets.* to familiaros.*. - Rename IPC namespace from openpets:* to familiaros:* and state filenames from openpets-* to familiaros-* with legacy migration. - Rename source files (pet-window, built-in-pet, default-pet-controller, etc.) to familiar equivalents. - Update locales (en, es-419, ja, ko, pt-BR, zh-Hans, zh-Hant) and tray/pet context menu strings. - Add Familiar naming feature: preference, settings input, tray menu display. - Update assets and packaging config; all desktop tests pass.
266 lines
11 KiB
JavaScript
266 lines
11 KiB
JavaScript
const { contextBridge, ipcRenderer } = require("electron");
|
|
|
|
// Keep SDK route strings in sync with src/plugin-sdk-routes.ts. The desktop
|
|
// conformance check extracts call()/callSync()/subscription() route literals
|
|
// from this preload and compares them to the canonical route table.
|
|
|
|
const tokenArg = process.argv.find((arg) => arg.startsWith("--familiaros-plugin-token="));
|
|
const channel = tokenArg ? `familiaros:plugin-sdk:${tokenArg.slice("--familiaros-plugin-token=".length)}` : "";
|
|
let callbackId = 0;
|
|
const callbacks = new Map();
|
|
|
|
async function call(path, args) {
|
|
if (!channel) throw new Error("FamiliarOS plugin SDK is unavailable.");
|
|
return ipcRenderer.invoke(channel, path, normalizeForIpc(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);
|
|
return result;
|
|
}
|
|
|
|
function normalizeForIpc(value, depth = 0) {
|
|
if (depth > 20) return null;
|
|
if (value === null || value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
if (typeof value === "bigint") return Number.isSafeInteger(Number(value)) ? Number(value) : String(value);
|
|
if (typeof value === "function" || typeof value === "symbol") return undefined;
|
|
if (value instanceof Uint8Array) return value;
|
|
if (value instanceof Error) return { name: value.name, message: value.message };
|
|
if (Array.isArray(value)) return value.slice(0, 100).map((item) => normalizeForIpc(item, depth + 1));
|
|
if (typeof value === "object") {
|
|
const output = {};
|
|
for (const [key, nested] of Object.entries(value).slice(0, 100)) {
|
|
const normalized = normalizeForIpc(nested, depth + 1);
|
|
if (normalized !== undefined) output[key] = normalized;
|
|
}
|
|
return output;
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function registerCallback(fn) {
|
|
if (typeof fn !== "function") return undefined;
|
|
const id = `cb-${++callbackId}`;
|
|
callbacks.set(id, fn);
|
|
return id;
|
|
}
|
|
|
|
// Subscription helper: fire-and-forget subscribe that resolves to a
|
|
// subscription id; returns a disposer that unsubscribes once resolved.
|
|
function subscription(onPath, offPath, args, fn) {
|
|
const id = registerCallback(fn);
|
|
const pending = call(onPath, [...args, id]).catch(() => undefined);
|
|
return () => {
|
|
callbacks.delete(id);
|
|
void pending.then((result) => {
|
|
const subscriptionId = result && result.subscriptionId;
|
|
if (subscriptionId) void call(offPath, [subscriptionId]).catch(() => undefined);
|
|
});
|
|
};
|
|
}
|
|
|
|
function makeBubbleHandle(result) {
|
|
const bubbleId = result && result.bubbleId;
|
|
return {
|
|
id: String(bubbleId),
|
|
update: (patch) => call("ui.bubbleUpdate", [bubbleId, patch]),
|
|
dismiss: () => call("ui.bubbleDismiss", [bubbleId]),
|
|
pin: () => call("ui.bubblePin", [bubbleId]),
|
|
unpin: () => call("ui.bubbleUnpin", [bubbleId]),
|
|
onAction: (fn) => { void call("ui.bubbleSubscribe", [bubbleId, "action", registerCallback(fn)]).catch(() => undefined); },
|
|
onSubmit: (fn) => { void call("ui.bubbleSubscribe", [bubbleId, "submit", registerCallback(fn)]).catch(() => undefined); },
|
|
onDismiss: (fn) => { void call("ui.bubbleSubscribe", [bubbleId, "dismiss", registerCallback(fn)]).catch(() => undefined); },
|
|
};
|
|
}
|
|
|
|
function makePanelHandle(result) {
|
|
const panelId = result && result.panelId;
|
|
return {
|
|
id: String(panelId),
|
|
show: () => call("ui.panelShow", [panelId]),
|
|
hide: () => call("ui.panelHide", [panelId]),
|
|
postMessage: (msg) => call("ui.panelPost", [panelId, msg]),
|
|
onMessage: (fn) => { void call("ui.panelOnMessage", [panelId, registerCallback(fn)]).catch(() => undefined); },
|
|
close: () => call("ui.panelClose", [panelId]),
|
|
};
|
|
}
|
|
|
|
function wrapPickedFile(file) {
|
|
return {
|
|
name: String(file.name),
|
|
sizeBytes: Number(file.sizeBytes),
|
|
readText: () => call("files.read", [file.fileId, "text"]),
|
|
readBytes: () => call("files.read", [file.fileId, "bytes"]),
|
|
};
|
|
}
|
|
|
|
function wrapEventPayload(event, payload) {
|
|
if (event !== "familiar:drop" || !payload || !Array.isArray(payload.files)) return payload;
|
|
return {
|
|
...payload,
|
|
files: payload.files.map((file) => ({
|
|
name: String(file.name),
|
|
sizeBytes: Number(file.sizeBytes),
|
|
readText: () => call("files.read", [file.fileId, "text"]),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function makePetHandle(petId) {
|
|
return {
|
|
id: String(petId),
|
|
speak: (spec) => call("familiar.speak", [petId, spec]).then(makeBubbleHandle),
|
|
react: (reaction, options) => call("familiar.react", [petId, reaction, options]),
|
|
setAnimation: (state) => call("familiar.setAnimation", [petId, state]),
|
|
setScale: (scale) => call("familiar.setScale", [petId, scale]),
|
|
setStatusReaction: (reaction) => call("familiar.setStatusReaction", [petId, reaction]),
|
|
moveBy: (options) => call("familiar.moveBy", [petId, options]),
|
|
wander: (options) => call("familiar.wander", [petId, options]),
|
|
moveToHome: () => call("familiar.moveToHome", [petId]),
|
|
moveTo: (point, opts) => call("familiar.moveTo", [petId, point, opts]),
|
|
followCursor: (opts) => call("familiar.followCursor", [petId, opts]),
|
|
physics: (opts) => call("familiar.physics", [petId, opts]),
|
|
onTick: (fn) => subscription("familiar.onTick", "familiar.offTick", [petId], fn),
|
|
getState: () => call("familiar.getState", [petId]),
|
|
show: () => call("familiar.show", [petId]),
|
|
hide: () => call("familiar.hide", [petId]),
|
|
close: () => call("familiar.close", [petId]),
|
|
};
|
|
}
|
|
|
|
const defaultPet = makePetHandle("default");
|
|
|
|
const sdk = {
|
|
familiar: defaultPet,
|
|
familiars: {
|
|
default: defaultPet,
|
|
list: () => call("familiars.list", []),
|
|
get: (petId) => makePetHandle(petId),
|
|
spawn: (spec) => call("familiars.spawn", [spec]).then((result) => makePetHandle(result && result.petHandleId)),
|
|
onChange: (fn) => subscription("familiars.onChange", "familiars.offChange", [], fn),
|
|
},
|
|
ui: {
|
|
bubble: (spec) => call("ui.bubble", [spec]).then(makeBubbleHandle),
|
|
alert: (spec) => call("ui.alert", [spec]).then((handle) => {
|
|
const bubble = makeBubbleHandle(handle);
|
|
bubble.acknowledge = () => call("ui.bubbleDismiss", [handle.bubbleId]);
|
|
return bubble;
|
|
}),
|
|
toast: (spec) => call("ui.toast", [spec]),
|
|
panel: (spec) => call("ui.panel", [spec]).then(makePanelHandle),
|
|
menu: {
|
|
setItems: (items) => call("ui.menuSetItems", [items]),
|
|
onSelect: (fn) => subscription("ui.menuOnSelect", "ui.menuOffSelect", [], fn),
|
|
},
|
|
},
|
|
audio: {
|
|
play: (sound, options) => call("audio.play", [sound, options]),
|
|
importUserSound: (file, opts) => call("audio.importUserSound", [file && file.fileId ? file.fileId : file, opts]),
|
|
forgetUserSound: (ref) => call("audio.forgetUserSound", [ref]),
|
|
stop: (handle) => call("audio.stop", [handle]),
|
|
},
|
|
events: {
|
|
on: (event, fn) => subscription("events.on", "events.off", [event], (payload) => fn(wrapEventPayload(event, payload))),
|
|
},
|
|
assets: {
|
|
icon: (name) => ({ kind: "icon", name: String(name) }),
|
|
image: (name) => ({ kind: "image", name: String(name) }),
|
|
svg: (name) => ({ kind: "svg", name: String(name) }),
|
|
sprite: (name) => ({ kind: "sprite", name: String(name) }),
|
|
sound: (name) => ({ kind: "sound", name: String(name) }),
|
|
},
|
|
bus: {
|
|
publish: (topic, payload) => call("bus.publish", [topic, payload]),
|
|
subscribe: (topic, fn) => subscription("bus.subscribe", "bus.unsubscribe", [topic], fn),
|
|
},
|
|
schedule: {
|
|
once: (id, delayMs, callback) => call("schedule.once", [id, delayMs, registerCallback(callback)]),
|
|
every: (id, intervalMs, callback) => call("schedule.every", [id, intervalMs, registerCallback(callback)]),
|
|
daily: (id, spec, callback) => call("schedule.daily", [id, spec, registerCallback(callback)]),
|
|
cron: (id, expr, callback) => call("schedule.cron", [id, expr, registerCallback(callback)]),
|
|
at: (id, isoTimestamp, callback) => call("schedule.at", [id, isoTimestamp, registerCallback(callback)]),
|
|
list: () => call("schedule.list", []),
|
|
cancel: (id) => call("schedule.cancel", [id]),
|
|
cancelAll: () => call("schedule.cancelAll", []),
|
|
},
|
|
storage: {
|
|
get: (key) => call("storage.get", [key]),
|
|
set: (key, value) => call("storage.set", [key, value]),
|
|
delete: (key) => call("storage.delete", [key]),
|
|
keys: () => call("storage.keys", []),
|
|
subscribe: (key, fn) => subscription("storage.subscribe", "storage.unsubscribe", [key], fn),
|
|
},
|
|
config: {
|
|
get: () => call("config.get", []),
|
|
onChange: (listener) => {
|
|
const id = registerCallback(listener);
|
|
void call("config.onChange", [id]);
|
|
return () => { callbacks.delete(id); void call("config.offChange", [id]); };
|
|
},
|
|
},
|
|
net: {
|
|
fetch: (url, options) => call("net.fetch", [url, options]),
|
|
stream: (url, options, onChunk) => call("net.stream", [url, options, registerCallback(onChunk)]),
|
|
},
|
|
notify: {
|
|
notify: (spec) => call("notify.notify", [spec]),
|
|
},
|
|
ai: {
|
|
available: () => call("ai.available", []),
|
|
complete: (req) => call("ai.complete", [req]),
|
|
stream: (req, onToken) => call("ai.stream", [req, registerCallback(onToken)]),
|
|
},
|
|
secrets: {
|
|
set: (key, value) => call("secrets.set", [key, value]),
|
|
get: (key) => call("secrets.get", [key]),
|
|
delete: (key) => call("secrets.delete", [key]),
|
|
has: (key) => call("secrets.has", [key]),
|
|
},
|
|
voice: {
|
|
speak: (text, opts) => call("voice.speak", [text, opts]),
|
|
listen: (opts) => call("voice.listen", [opts]),
|
|
},
|
|
auth: {
|
|
oauth: (config) => call("auth.oauth", [config]),
|
|
refresh: (provider) => call("auth.refresh", [provider]),
|
|
signOut: (provider) => call("auth.signOut", [provider]),
|
|
},
|
|
files: {
|
|
pick: (opts) => call("files.pick", [opts]).then((files) => (Array.isArray(files) ? files.map(wrapPickedFile) : [])),
|
|
save: (opts) => call("files.save", [opts]),
|
|
},
|
|
system: {
|
|
info: () => call("system.info", []),
|
|
metrics: () => call("system.metrics", []),
|
|
openExternal: (url) => call("system.openExternal", [url]),
|
|
readClipboardText: () => call("system.readClipboardText", []),
|
|
writeClipboardText: (text) => call("system.writeClipboardText", [text]),
|
|
},
|
|
commands: {
|
|
register: (command, handler) => call("commands.register", [command, registerCallback(handler)]),
|
|
unregister: (id) => call("commands.unregister", [id]),
|
|
},
|
|
status: {
|
|
set: (status) => call("status.set", [status]),
|
|
clear: () => call("status.clear", []),
|
|
},
|
|
http: {
|
|
fetch: (url, options) => call("http.fetch", [url, options]),
|
|
},
|
|
log: Object.fromEntries(["debug", "info", "warn", "error"].map((level) => [level, (...args) => call(`log.${level}`, args)])),
|
|
t: (key, vars) => callSync("i18n.t", [key, vars]),
|
|
};
|
|
|
|
Object.defineProperty(sdk, "locale", {
|
|
enumerable: true,
|
|
get: () => callSync("i18n.locale", []),
|
|
});
|
|
|
|
contextBridge.exposeInMainWorld("__openPetsSdk", sdk);
|
|
contextBridge.exposeInMainWorld("__openPetsRunCallback", async (id, args) => {
|
|
const callback = callbacks.get(id);
|
|
if (callback) return callback(...(Array.isArray(args) ? args : []));
|
|
return undefined;
|
|
});
|