openpetswithchatandmcp/apps/desktop/familiar-preload.cjs
OpenPets Dev 6ab3bb64d8 feat(rebrand): rename OpenPets to FamiliarOS and pets to familiars
- 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.
2026-06-17 01:42:08 +00:00

560 lines
22 KiB
JavaScript

const { ipcRenderer } = require("electron");
const allowedMotionStates = new Set(["idle", "run-left", "run-right"]);
const allowedReactionStates = new Set(["idle", "running-right", "running-left", "waving", "jumping", "failed", "waiting", "running", "review"]);
let lastInteractiveHit = null;
let dragging = false;
let scaling = false;
let scaleStartY = 0;
let scaleStartValue = 0.56;
let scalePreviewTimer = null;
const dragThreshold = 4;
const dismissBubble = (event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
const target = event.target;
if (!(target instanceof Element)) return;
const bubble = target.closest(".bubble");
if (!bubble) return;
const dismissToken = bubble.dataset.dismissToken;
if (!dismissToken) return;
event.preventDefault();
event.stopPropagation();
bubble.remove();
const newTarget = document.elementFromPoint(event.clientX, event.clientY);
const stillInteractive = Boolean(newTarget && newTarget.closest(".familiar-hitbox, .familiar-shell, .bubble")) || dragging || scaling;
reportInteractiveHit(stillInteractive, "bubble-dismiss", true);
ipcRenderer.send("familiaros:bubble-dismissed", dismissToken);
};
const requestPromptWindow = (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest(".bubble")) return;
if (!target.closest(".familiar-hitbox, .familiar-shell")) return;
event.preventDefault();
event.stopPropagation();
ipcRenderer.send("familiaros:familiar-open-prompt");
};
ipcRenderer.on("familiaros:familiar-motion", (_event, state) => {
if (!allowedMotionStates.has(state)) {
return;
}
const apply = () => {
document.documentElement.dataset.motionState = state;
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", apply, { once: true });
} else {
apply();
}
});
ipcRenderer.on("familiaros:familiar-reaction-state", (_event, state) => {
if (!allowedReactionStates.has(state)) {
return;
}
const apply = () => {
document.documentElement.dataset.reactionState = state;
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", apply, { once: true });
} else {
apply();
}
});
ipcRenderer.on("familiaros:familiar-content-state", (_event, state) => {
if (!state || typeof state.bodyHtml !== "string" || state.bodyHtml.length > 64 * 1024 || !allowedReactionStates.has(state.reactionState)) {
return;
}
const apply = () => {
document.documentElement.dataset.reactionState = state.reactionState;
document.body.innerHTML = state.bodyHtml;
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", apply, { once: true });
} else {
apply();
}
});
const getInteractiveTarget = (event) => {
const target = document.elementFromPoint(event.clientX, event.clientY);
return target && target.closest(".familiar-hitbox, .familiar-shell, .bubble");
};
const reportInteractiveHit = (interactive, source, force = false) => {
if (!force && lastInteractiveHit === interactive) return;
lastInteractiveHit = interactive;
ipcRenderer.send("familiaros:familiar-hit-test", interactive, source);
};
const setInteractiveHit = (interactive, source = "mouse") => {
if (lastInteractiveHit === interactive) return;
reportInteractiveHit(interactive, source);
};
const updateInteractiveHit = (event) => {
setInteractiveHit(Boolean(getInteractiveTarget(event)) || dragging || scaling);
};
const getCurrentSpriteScale = () => {
const sprite = document.querySelector(".sprite, .installed-sprite");
if (!sprite) return 0.56;
const style = sprite.getAttribute("style") || "";
const match = style.match(/transform:\s*scale\(([\d.]+)\)/);
return match ? parseFloat(match[1]) : 0.56;
};
const applyScalePreview = (scale) => {
const clamped = Math.min(Math.max(scale, 0.16), 10);
const sprite = document.querySelector(".sprite, .installed-sprite");
if (sprite) sprite.style.transform = `scale(${clamped})`;
return clamped;
};
const sendScalePreview = (scale) => {
ipcRenderer.send("familiaros:familiar-scale-preview", { scale });
};
ipcRenderer.on("familiaros:familiar-probe-hit-test", (_event, point) => {
if (!point || typeof point.clientX !== "number" || typeof point.clientY !== "number" || !Number.isFinite(point.clientX) || !Number.isFinite(point.clientY)) return;
const clientX = point.clientX;
const clientY = point.clientY;
const target = document.elementFromPoint(clientX, clientY);
reportInteractiveHit(Boolean(target && target.closest(".familiar-hitbox, .familiar-shell, .bubble")) || dragging || scaling, typeof point.reason === "string" ? point.reason.slice(0, 80) : "probe", true);
});
// --- Plugin bubble interactions (actions, inline inputs) -------------------
const collectBubbleInputValues = (bubble) => {
const values = {};
for (const control of bubble.querySelectorAll(".bubble-input-control")) {
const id = control.dataset.inputId;
if (!id) continue;
values[id] = control.type === "number" ? Number(control.value) : String(control.value);
}
return values;
};
const handleBubbleInteraction = (event) => {
const target = event.target;
if (!(target instanceof Element)) return false;
const actionButton = target.closest("[data-bubble-action]");
if (actionButton) {
event.preventDefault();
event.stopPropagation();
ipcRenderer.send("familiaros:bubble-action", actionButton.dataset.bubbleToken, actionButton.dataset.bubbleAction);
return true;
}
const submitButton = target.closest("[data-bubble-submit]");
if (submitButton) {
event.preventDefault();
event.stopPropagation();
const bubble = submitButton.closest(".bubble");
ipcRenderer.send("familiaros:bubble-submit", submitButton.dataset.bubbleSubmit, bubble ? collectBubbleInputValues(bubble) : {});
return true;
}
if (target.closest(".bubble-input-control")) return true;
return false;
};
// --- Familiar senses: clicks, hover, drops ---------------------------------------
let lastHoverSentAt = 0;
let suppressClickUntil = 0;
const sendPetEvent = (name, payload) => {
ipcRenderer.send("familiaros:familiar-event", name, payload || {});
};
const installPetSenses = () => {
let clickCount = 0;
let clickTimer = null;
let firstClickEvent = null;
const resetClickTimer = () => {
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
}
};
const flushClicks = () => {
resetClickTimer();
if (clickCount === 1) {
sendPetEvent("familiar:clicked", {});
} else if (clickCount === 2 && firstClickEvent) {
sendPetEvent("familiar:doubleClicked", {});
requestPromptWindow(firstClickEvent);
}
clickCount = 0;
firstClickEvent = null;
};
document.addEventListener("click", (event) => {
if (event.button !== 0) return;
const target = event.target;
if (!(target instanceof Element)) return;
if (!target.closest(".familiar-hitbox, .familiar-shell")) return;
if (Date.now() < suppressClickUntil) return;
clickCount += 1;
if (clickCount === 1) {
firstClickEvent = event;
}
resetClickTimer();
clickTimer = setTimeout(flushClicks, 200);
});
document.addEventListener("mouseover", (event) => {
const target = event.target;
if (!(target instanceof Element) || !target.closest(".familiar-hitbox, .familiar-shell")) return;
const now = Date.now();
if (now - lastHoverSentAt < 2000) return;
lastHoverSentAt = now;
sendPetEvent("familiar:hover", {});
}, { passive: true });
const maxDropTextBytes = 256 * 1024;
const maxDropFileBytes = 5 * 1024 * 1024;
document.addEventListener("dragover", (event) => {
const target = event.target;
if (target instanceof Element && target.closest(".familiar-hitbox, .familiar-shell")) event.preventDefault();
});
document.addEventListener("drop", (event) => {
const target = event.target;
if (!(target instanceof Element) || !target.closest(".familiar-hitbox, .familiar-shell")) return;
event.preventDefault();
const transfer = event.dataTransfer;
if (!transfer) return;
const files = [...(transfer.files || [])].slice(0, 4);
if (files.length > 0) {
Promise.all(files.map(async (file) => ({
name: String(file.name).slice(0, 200),
sizeBytes: file.size,
text: file.size <= maxDropFileBytes ? await file.text().catch(() => "") : "",
truncated: file.size > maxDropFileBytes,
}))).then((read) => sendPetEvent("familiar:drop", { kind: "files", droppedFiles: read })).catch(() => undefined);
return;
}
const text = String(transfer.getData("text/plain") || "").slice(0, maxDropTextBytes);
if (text) sendPetEvent("familiar:drop", { kind: "text", text });
});
};
// --- Plugin sprite/scale overrides ------------------------------------------
let spriteOverrideElement = null;
ipcRenderer.on("familiaros:familiar-sprite-override", (_event, override) => {
const shell = document.querySelector(".familiar-shell");
if (!shell) return;
const base = shell.querySelector(".sprite, .installed-card");
if (spriteOverrideElement) { spriteOverrideElement.remove(); spriteOverrideElement = null; }
if (!override || typeof override.fileUrl !== "string" || !override.fileUrl.startsWith("file://")) {
if (base) base.style.visibility = "";
return;
}
const probe = new Image();
probe.onload = () => {
const frame = probe.naturalHeight;
const frames = Math.max(1, Math.floor(probe.naturalWidth / Math.max(1, frame)));
const fps = Math.min(30, Math.max(1, Number(override.fps) || 8));
const el = document.createElement("div");
el.className = "plugin-sprite-override";
el.style.cssText = `position:absolute;left:50%;bottom:0;transform:translateX(-50%);width:${frame}px;height:${frame}px;background-image:url("${override.fileUrl.replace(/"/g, "%22")}");background-repeat:no-repeat;background-size:${probe.naturalWidth}px ${frame}px;animation:plugin-sprite-frames ${(frames / fps).toFixed(3)}s steps(${frames}) ${override.loop === false ? "1" : "infinite"};pointer-events:none;`;
let style = document.getElementById("plugin-sprite-override-style");
if (!style) {
style = document.createElement("style");
style.id = "plugin-sprite-override-style";
document.head.appendChild(style);
}
style.textContent = `@keyframes plugin-sprite-frames { from { background-position: 0 0; } to { background-position: -${frames * frame}px 0; } }`;
if (base) base.style.visibility = "hidden";
shell.appendChild(el);
spriteOverrideElement = el;
};
probe.src = override.fileUrl;
});
ipcRenderer.on("familiaros:familiar-scale-override", (_event, scale) => {
const value = Number(scale);
if (!Number.isFinite(value) || value < 0.25 || value > 3) return;
const sprite = document.querySelector(".sprite, .installed-sprite");
if (sprite) sprite.style.transform = `scale(${value})`;
});
ipcRenderer.on("familiaros:familiar-bubble-layout", (_event, layout) => {
if (!layout || typeof layout !== "object") return;
const root = document.documentElement;
if (typeof layout.bubbleMaxHeight === "number") root.style.setProperty("--bubble-max-height", `${layout.bubbleMaxHeight}px`);
if (typeof layout.bubbleMaxHeightLong === "number") root.style.setProperty("--bubble-max-height-long", `${layout.bubbleMaxHeightLong}px`);
if (typeof layout.bubbleMaxHeightVeryLong === "number") root.style.setProperty("--bubble-max-height-very-long", `${layout.bubbleMaxHeightVeryLong}px`);
document.querySelectorAll(".bubble").forEach((bubble) => {
bubble.classList.toggle("is-below", layout.bubbleBelow === true);
});
});
// --- Plugin audio (named WebAudio recipes + bundled data URLs) ---------------
let audioContext = null;
let activeAudioNodes = [];
let activeAudioElements = [];
const audioLog = (level, message, fields) => {
try {
const safeFields = fields && Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
const line = `[familiaros:familiar-audio] ${message}`;
if (level === "warn") console.warn(line, safeFields || {});
else console.debug(line, safeFields || {});
} catch { /* diagnostics must never affect playback */ }
};
const getAudioContext = () => {
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
return audioContext;
};
const namedSoundRecipes = {
chime: [{ freq: 880, type: "sine", start: 0, duration: 0.35 }, { freq: 1318.5, type: "sine", start: 0.12, duration: 0.4 }],
pop: [{ freq: 420, type: "square", start: 0, duration: 0.08 }],
nom: [{ freq: 220, type: "triangle", start: 0, duration: 0.1 }, { freq: 180, type: "triangle", start: 0.12, duration: 0.1 }],
alert: [{ freq: 660, type: "sawtooth", start: 0, duration: 0.18 }, { freq: 660, type: "sawtooth", start: 0.26, duration: 0.18 }],
"level-up": [{ freq: 523.25, type: "sine", start: 0, duration: 0.12 }, { freq: 659.25, type: "sine", start: 0.12, duration: 0.12 }, { freq: 783.99, type: "sine", start: 0.24, duration: 0.22 }],
tick: [{ freq: 1000, type: "square", start: 0, duration: 0.03 }],
success: [{ freq: 587.33, type: "sine", start: 0, duration: 0.14 }, { freq: 880, type: "sine", start: 0.14, duration: 0.24 }],
error: [{ freq: 311.13, type: "sine", start: 0, duration: 0.18 }, { freq: 233.08, type: "sine", start: 0.2, duration: 0.28 }],
};
ipcRenderer.on("familiaros:play-audio", (_event, payload) => {
try {
if (!payload) return;
const volume = Math.min(1, Math.max(0, Number(payload.volume) || 0.6));
audioLog("debug", "play requested", { kind: payload.kind, volume });
if (payload.kind === "named") {
const recipe = namedSoundRecipes[payload.name];
if (!recipe) { audioLog("warn", "named sound skipped", { name: payload.name, reason: "unknown-sound" }); return; }
const ctxAudio = getAudioContext();
if (ctxAudio.state === "suspended") void ctxAudio.resume().catch((error) => audioLog("warn", "audio context resume failed", { reason: error && error.message ? error.message : String(error) }));
const now = ctxAudio.currentTime;
for (const note of recipe) {
const osc = ctxAudio.createOscillator();
const gain = ctxAudio.createGain();
osc.type = note.type;
osc.frequency.value = note.freq;
gain.gain.setValueAtTime(0, now + note.start);
gain.gain.linearRampToValueAtTime(volume * 0.35, now + note.start + 0.015);
gain.gain.exponentialRampToValueAtTime(0.001, now + note.start + note.duration);
osc.connect(gain).connect(ctxAudio.destination);
osc.start(now + note.start);
osc.stop(now + note.start + note.duration + 0.05);
activeAudioNodes.push(osc);
}
audioLog("debug", "named sound scheduled", { name: payload.name, notes: recipe.length, contextState: ctxAudio.state });
return;
}
if (payload.kind === "data" && typeof payload.dataUrl === "string" && payload.dataUrl.startsWith("data:audio/")) {
const element = new Audio(payload.dataUrl);
element.volume = volume;
activeAudioElements.push(element);
element.addEventListener("ended", () => { activeAudioElements = activeAudioElements.filter((entry) => entry !== element); audioLog("debug", "data sound ended", { remaining: activeAudioElements.length }); });
element.addEventListener("error", () => audioLog("warn", "data sound element error", { code: element.error ? element.error.code : undefined, message: element.error ? element.error.message : undefined }));
void element.play().then(() => audioLog("debug", "data sound playback started", { volume })).catch((error) => {
activeAudioElements = activeAudioElements.filter((entry) => entry !== element);
audioLog("warn", "data sound playback failed", { reason: error && error.message ? error.message : String(error), name: error && error.name ? error.name : undefined });
});
} else {
audioLog("warn", "play request ignored", { kind: payload.kind, reason: "invalid-payload" });
}
} catch (error) { audioLog("warn", "play request threw", { reason: error && error.message ? error.message : String(error) }); }
});
ipcRenderer.on("familiaros:stop-audio", () => {
audioLog("debug", "stop requested", { nodes: activeAudioNodes.length, elements: activeAudioElements.length });
for (const node of activeAudioNodes) { try { node.stop(); } catch { /* already stopped */ } }
activeAudioNodes = [];
for (const element of activeAudioElements) { try { element.pause(); } catch { /* noop */ } }
activeAudioElements = [];
});
// --- Plugin TTS ---------------------------------------------------------------
let currentTtsAudio = null;
ipcRenderer.on("familiaros:tts-speak", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || typeof payload.text !== "string" || !window.speechSynthesis) return;
const utterance = new SpeechSynthesisUtterance(payload.text.slice(0, 500));
if (typeof payload.rate === "number" && payload.rate >= 0.5 && payload.rate <= 2) utterance.rate = payload.rate;
if (typeof payload.voice === "string" && payload.voice) {
const match = window.speechSynthesis.getVoices().find((voice) => voice.name === payload.voice || voice.lang === payload.voice);
if (match) utterance.voice = match;
}
window.speechSynthesis.speak(utterance);
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("familiaros:tts-audio", (_event, payload) => {
try {
stopCurrentTtsAudio();
if (!payload || !payload.audio || !payload.mimeType) return;
const buffer = payload.audio instanceof Uint8Array ? payload.audio : new Uint8Array(payload.audio);
const blob = new Blob([buffer], { type: payload.mimeType });
const url = URL.createObjectURL(blob);
currentTtsAudio = new Audio(url);
currentTtsAudio.addEventListener("ended", () => {
URL.revokeObjectURL(url);
currentTtsAudio = null;
});
currentTtsAudio.play().catch(() => { /* audio is best-effort */ });
} catch { /* tts is best-effort */ }
});
ipcRenderer.on("familiaros:tts-stop", () => {
try {
stopCurrentTtsAudio();
if (window.speechSynthesis) window.speechSynthesis.cancel();
} catch { /* noop */ }
});
function stopCurrentTtsAudio() {
if (currentTtsAudio) {
try { currentTtsAudio.pause(); } catch { /* noop */ }
try { currentTtsAudio.src = ""; } catch { /* noop */ }
currentTtsAudio = null;
}
}
const installMouseInterop = () => {
lastInteractiveHit = null;
dragging = false;
scaling = false;
document.addEventListener("click", (event) => {
if (handleBubbleInteraction(event)) return;
dismissBubble(event);
});
installPetSenses();
let dragStartPoint = null;
let dragCandidate = null;
document.addEventListener("mousemove", (event) => {
updateInteractiveHit(event);
if (dragging) {
ipcRenderer.send("familiaros:familiar-drag-move", { screenX: event.screenX, screenY: event.screenY });
} else if (dragCandidate) {
const dx = event.screenX - dragCandidate.screenX;
const dy = event.screenY - dragCandidate.screenY;
if (Math.hypot(dx, dy) > dragThreshold) {
dragging = true;
dragStartPoint = { screenX: dragCandidate.screenX, screenY: dragCandidate.screenY };
dragCandidate = null;
setInteractiveHit(true);
ipcRenderer.send("familiaros:familiar-drag-start", { screenX: event.screenX, screenY: event.screenY });
}
}
if (scaling) {
const newScale = scaleStartValue + (event.screenY - scaleStartY) * 0.003;
const clamped = applyScalePreview(newScale);
sendScalePreview(clamped);
}
}, { passive: true });
document.addEventListener("mousedown", (event) => {
const target = getInteractiveTarget(event);
setInteractiveHit(Boolean(target));
const scaleHandle = target && target.closest(".scale-handle");
if (scaleHandle) {
if (event.button !== 0) return;
event.preventDefault();
scaling = true;
scaleStartY = event.screenY;
scaleStartValue = getCurrentSpriteScale();
setInteractiveHit(true);
ipcRenderer.send("familiaros:familiar-scale-start");
return;
}
if (event.button !== 0 || !target?.closest(".familiar-hitbox, .familiar-shell")) return;
event.preventDefault();
dragCandidate = { screenX: event.screenX, screenY: event.screenY };
dragStartPoint = null;
});
document.addEventListener("mouseup", (event) => {
if (dragging) {
dragging = false;
if (dragStartPoint && Math.hypot(event.screenX - dragStartPoint.screenX, event.screenY - dragStartPoint.screenY) > dragThreshold) {
suppressClickUntil = Date.now() + 300;
}
dragStartPoint = null;
dragCandidate = null;
ipcRenderer.send("familiaros:familiar-drag-end");
} else if (dragCandidate) {
dragCandidate = null;
}
if (scaling) {
scaling = false;
const sprite = document.querySelector(".sprite, .installed-sprite");
const finalScale = sprite ? getCurrentSpriteScale() : scaleStartValue;
ipcRenderer.send("familiaros:familiar-scale-end", { scale: finalScale });
}
});
document.addEventListener("mouseleave", () => {
if (!dragging && !scaling) setInteractiveHit(false);
}, { passive: true });
const scaleNudgeStep = 0.02;
let scaleNudgeCommitTimer = null;
const commitScaleNudge = () => {
if (scaleNudgeCommitTimer) {
clearTimeout(scaleNudgeCommitTimer);
scaleNudgeCommitTimer = null;
}
const finalScale = getCurrentSpriteScale();
ipcRenderer.send("familiaros:familiar-scale-end", { scale: finalScale });
};
window.addEventListener("keydown", (event) => {
if (event.ctrlKey || event.metaKey || event.altKey) return;
const target = event.target;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
if (event.key !== "ArrowUp" && event.key !== "ArrowDown" && event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const current = getCurrentSpriteScale();
const delta = event.key === "ArrowUp" || event.key === "ArrowRight" ? scaleNudgeStep : -scaleNudgeStep;
const next = applyScalePreview(current + delta);
ipcRenderer.send("familiaros:familiar-scale-preview", { scale: next });
if (scaleNudgeCommitTimer) clearTimeout(scaleNudgeCommitTimer);
scaleNudgeCommitTimer = setTimeout(commitScaleNudge, 250);
});
setInteractiveHit(false, "ready");
ipcRenderer.send("familiaros:familiar-ready");
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", installMouseInterop, { once: true });
} else {
installMouseInterop();
}