refactor desktop runtime control center seams

This commit is contained in:
OpenPets Dev 2026-06-18 05:53:10 +00:00
parent 1d7d2e2c6a
commit d89171c4be
23 changed files with 2549 additions and 1705 deletions

12
.sentrux/baseline.json Normal file
View file

@ -0,0 +1,12 @@
{
"timestamp": 1781761968.167321,
"quality_signal": 0.438779684727969,
"coupling_score": 0.35494021397105097,
"cycle_count": 3,
"god_file_count": 0,
"hotspot_count": 1,
"complex_fn_count": 143,
"max_depth": 17,
"total_import_edges": 1589,
"cross_module_edges": 824
}

View file

@ -83,6 +83,7 @@ const updateCheckerSource = readFileSync(join(appDir, "src", "update-checker.ts"
const traySource = readFileSync(join(appDir, "src", "tray.ts"), "utf8");
const enCatalogSource = readFileSync(join(appDir, "src", "i18n", "locales", "en.ts"), "utf8");
const windowsSource = readFileSync(join(appDir, "src", "windows.ts"), "utf8");
const controlCenterIpcSource = readFileSync(join(appDir, "src", "control-center-ipc.ts"), "utf8");
const promptWindowSource = readFileSync(join(appDir, "src", "prompt-window.ts"), "utf8");
const agentSetupSource = readFileSync(join(appDir, "src", "agent-setup.ts"), "utf8");
const loggerSource = readFileSync(join(appDir, "src", "logger.ts"), "utf8");
@ -93,6 +94,7 @@ const leaseManagerSource = readFileSync(join(appDir, "src", "lease-manager.ts"),
const defaultPetControllerSource = readFileSync(join(appDir, "src", "default-familiar-controller.ts"), "utf8");
const agentPetControllerSourceForLogging = readFileSync(join(appDir, "src", "agent-familiar-controller.ts"), "utf8");
const mappingDoc = readFileSync(join(repoRoot, "docs", "mapping.md"), "utf8");
const controlCenterInternalUiSource = `${windowsSource}\n${controlCenterIpcSource}`;
assert.match(loggerSource, /familiaros\.log/, "desktop logger must write a user-sendable familiaros.log file.");
assert.match(loggerSource, /familiaros\.previous\.log/, "desktop logger must retain a previous log file for bug reports.");
assert.match(loggerSource, /FAMILIAROS_LOG_LEVEL/, "desktop logger must support verbose dev logging via environment.");
@ -164,7 +166,7 @@ assert.match(petPreloadSource, /familiaros:familiar-drag-start/, "familiar prelo
assert.match(petPreloadSource, /familiaros:familiar-open-prompt/, "familiar preload must request the floating prompt window on double-click.");
assert.match(defaultPetControllerSource, /powerMonitor\.on\("resume", recoverDefaultPetWindowAfterResume\)/, "default familiar must recover mouse interop after Windows sleep or resume.");
assert.match(defaultPetControllerSource, /recoverDefaultPetMouseInterop\("display-change"\)/, "default familiar must recover mouse interop after monitor topology changes.");
assert.match(windowsSource, /recoverDefaultPetMouseInterop\("default-familiar-changed"\)/, "changing default familiar must recover mouse interop for dragging without app restart.");
assert.match(controlCenterInternalUiSource, /recoverDefaultPetMouseInterop\("default-familiar-changed"\)/, "changing default familiar must recover mouse interop for dragging without app restart.");
assert.match(petWindowSource, /function installPetContextMenu/, "familiar windows must install a native right-click context menu.");
assert.match(petWindowSource, /webContents\.on\("context-menu"/, "familiar context menu must be handled in the Electron main process.");
assert.match(petWindowSource, /Menu\.buildFromTemplate/, "familiar context menu must use a small native Electron menu.");
@ -183,11 +185,11 @@ assert.match(updateCheckerSource, /api\.github\.com\/repos\/\$\{githubRepository
assert.match(updateCheckerSource, /shell\.openExternal\(url\)/, "update action must open the GitHub release page externally.");
assert.match(traySource, /t\("tray\.updateAvailable"/, "tray menu must surface available updates.");
assert.match(enCatalogSource, /Update available:/, "English catalog must keep the update-available label.");
assert.match(windowsSource, /familiaros:check-for-updates/, "settings window must be able to trigger update checks.");
assert.match(windowsSource, /familiaros:get-reaction-animation-settings/, "settings window must be able to load reaction animation metadata.");
assert.match(windowsSource, /reactionAnimationOverrides/, "settings window must be able to persist reaction animation overrides.");
assert.match(windowsSource, /familiaros-familiar-preview/, "settings reaction preview must use a scoped internal familiar preview protocol.");
assert.match(windowsSource, /familiaros:open-update-release-page/, "settings window must be able to open the release page.");
assert.match(controlCenterInternalUiSource, /familiaros:check-for-updates/, "settings window must be able to trigger update checks.");
assert.match(controlCenterInternalUiSource, /familiaros:get-reaction-animation-settings/, "settings window must be able to load reaction animation metadata.");
assert.match(controlCenterInternalUiSource, /reactionAnimationOverrides/, "settings window must be able to persist reaction animation overrides.");
assert.match(controlCenterInternalUiSource, /familiaros-familiar-preview/, "settings reaction preview must use a scoped internal familiar preview protocol.");
assert.match(controlCenterInternalUiSource, /familiaros:open-update-release-page/, "settings window must be able to open the release page.");
assert.match(controlCenterPreloadSource, /checkForUpdates/, "Control Center preload must expose update checks.");
assert.match(controlCenterPreloadSource, /getReactionAnimationSettings/, "Control Center preload must expose reaction animation settings metadata.");
assert.match(controlCenterPreloadSource, /saveOpenApiCredential/, "Control Center preload must expose OpenAPI chat credential management.");
@ -233,7 +235,7 @@ assert.match(enCatalogSource, /OpenCode/, "Control Center integrations must incl
assert.match(enCatalogSource, /Cursor/, "Control Center integrations must include Cursor.");
assert.match(enCatalogSource, /Pi/, "Control Center integrations must include Pi.");
assert.doesNotMatch(agentSetupSource, /JSON\.parse\(prepared\.configWrite\.content\)/, "OpenCode desktop preview must parse JSONC planned config safely, not JSON.parse.");
assert.match(windowsSource, /refreshDefaultPetContent\(\);\s*refreshAgentPetContent\(\);/, "familiar scale preference changes must refresh default and agent familiar windows.");
assert.match(controlCenterInternalUiSource, /refreshDefaultPetContent\(\);\s*refreshAgentPetContent\(\);/, "familiar scale preference changes must refresh default and agent familiar windows.");
assert.ok(existsSync(join(appDir, "scripts", "clean-package-output.cjs")), "package output cleanup helper must exist.");
assert.ok(existsSync(join(distDir, "main.js")), "desktop main build output must exist before packaging checks run.");
assert.ok(existsSync(join(repoRoot, "packages", "claude", "dist", "index.js")), "@familiaros/claude must be built before packaging.");

View file

@ -0,0 +1,10 @@
export {
buildFamiliarOSMcpServerPreview,
getAgentSetupSnapshot,
runAgentSetupAction,
testFamiliarOSMcpServer,
updateAgentSetupCommandPaths,
} from "./agent-setup.js";
export { refreshAgentPetContent } from "./agent-familiar-controller.js";
export { getMcpChatClientManager, listMcpChatVanillaServerIds } from "./mcp-chat-client.js";
export { installPersistentToolkit, type McpToolkitPersistentTarget } from "./mcp-toolkit-installer.js";

View file

@ -0,0 +1,16 @@
export { getAppStateSnapshot, updatePreferences } from "./app-state.js";
export {
getDashboardSnapshot,
getI18nSnapshot,
getLaunchAtLoginState,
getPetsStateSnapshot,
getReactionAnimationSettingsSnapshot,
getSettingsStateSnapshot,
isPlainObject,
validateExternalUrl,
validatePreferencePatch,
} from "./control-center-state.js";
export { getCatalogPageUiState, getCatalogSearchUiState, getCatalogUiState } from "./catalog.js";
export { getActiveLocale, setLocaleFromPreference } from "./i18n/index.js";
export { debug, error as logError, warn } from "./logger.js";
export { checkForGitHubReleaseUpdate, getUpdateStatus, openUpdateReleasePage } from "./update-checker.js";

View file

@ -0,0 +1,23 @@
export {
forgetFamiliarOSMemory,
listFamiliarOSMemories,
searchFamiliarOSMemories,
storeFamiliarOSMemory,
updateFamiliarOSMemory,
} from "./familiaros-memory.js";
export {
addKnowledgeMemory,
deleteKnowledgeFile,
listKnowledgeFiles,
searchKnowledgeStore,
storeKnowledgeFile,
} from "./knowledge-store.js";
export {
clearOpenApiCredential,
getOpenApiChatSettingsSnapshot,
resetOpenApiConversationContext,
saveOpenApiCredential,
} from "./openapi-chat.js";
export { getPluginService, type PluginConfigSoundPickResult, type PluginServiceResult } from "./plugin-service.js";
export { clearTtsCredential, saveTtsCredential } from "./tts-credentials.js";
export { fetchTtsVoiceList, getTtsSettingsSnapshot, speakTts, stopTts, type TtsProviderId } from "./tts-service.js";

View file

@ -0,0 +1,696 @@
import { readFile, stat } from "node:fs/promises";
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell, type IpcMainInvokeEvent, type OpenDialogOptions } from "electron";
import {
buildFamiliarOSMcpServerPreview,
getAgentSetupSnapshot,
getMcpChatClientManager,
installPersistentToolkit,
listMcpChatVanillaServerIds,
refreshAgentPetContent,
runAgentSetupAction,
testFamiliarOSMcpServer,
type McpToolkitPersistentTarget,
updateAgentSetupCommandPaths,
} from "./control-center-agent-services.js";
import {
addKnowledgeMemory,
clearOpenApiCredential,
clearTtsCredential,
deleteKnowledgeFile,
fetchTtsVoiceList,
forgetFamiliarOSMemory,
getOpenApiChatSettingsSnapshot,
getPluginService,
getTtsSettingsSnapshot,
listFamiliarOSMemories,
listKnowledgeFiles,
resetOpenApiConversationContext,
saveOpenApiCredential,
saveTtsCredential,
searchFamiliarOSMemories,
searchKnowledgeStore,
speakTts,
stopTts,
storeFamiliarOSMemory,
storeKnowledgeFile,
type PluginConfigSoundPickResult,
type PluginServiceResult,
type TtsProviderId,
updateFamiliarOSMemory,
} from "./control-center-data-services.js";
import {
checkForGitHubReleaseUpdate,
debug,
getActiveLocale,
getAppStateSnapshot,
getCatalogPageUiState,
getCatalogSearchUiState,
getCatalogUiState,
getDashboardSnapshot,
getI18nSnapshot,
getLaunchAtLoginState,
getPetsStateSnapshot,
getReactionAnimationSettingsSnapshot,
getSettingsStateSnapshot,
getUpdateStatus,
isPlainObject,
logError,
openUpdateReleasePage,
setLocaleFromPreference,
updatePreferences,
validateExternalUrl,
validatePreferencePatch,
warn,
} from "./control-center-core-services.js";
import {
installPet,
installPetFromFolder,
installPetFromZipFile,
recoverDefaultPetMouseInterop,
refreshDefaultPetContent,
removePet,
resetDefaultPetToInitialPosition,
setDefaultInstalledPet,
} from "./control-center-pet-services.js";
type InternalUiWindowKind = "control-center";
type ControlCenterWindowGetter = () => BrowserWindow | null;
const pluginIdPattern = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/;
const pluginCommandIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
let internalUiHandlersInstalled = false;
async function loadCodexPetsModule() {
return import("./codex-familiars.js");
}
export function installInternalUiHandlers(getControlCenterWindow: ControlCenterWindowGetter): void {
if (internalUiHandlersInstalled) {
return;
}
internalUiHandlersInstalled = true;
ipcMain.handle("familiaros:get-familiars-state", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getPetsStateSnapshot();
});
ipcMain.handle("familiaros:get-settings-state", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getSettingsStateSnapshot();
});
ipcMain.handle("familiaros:get-i18n", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getI18nSnapshot();
});
ipcMain.handle("familiaros:get-dashboard-snapshot", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getDashboardSnapshot();
});
ipcMain.handle("familiaros:get-openapi-chat-settings", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getOpenApiChatSettingsSnapshot();
});
ipcMain.handle("familiaros:save-openapi-credential", (event, apiKey: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof apiKey !== "string") throw new Error("Invalid chat credential.");
return saveOpenApiCredential(apiKey);
});
ipcMain.handle("familiaros:clear-openapi-credential", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return clearOpenApiCredential();
});
ipcMain.handle("familiaros:install-mcp-toolkit", async (event, target: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (target !== "claude-user" && target !== "codex-global") {
throw new Error("Invalid MCP toolkit target.");
}
return installPersistentToolkit(target as McpToolkitPersistentTarget);
});
ipcMain.handle("familiaros:get-vanilla-chat-mcp-tools", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const state = getAppStateSnapshot();
return {
enabled: state.preferences.vanillaChatMcpTools ?? [],
available: listMcpChatVanillaServerIds(),
active: getMcpChatClientManager().getActiveServerIds(),
};
});
ipcMain.handle("familiaros:set-vanilla-chat-mcp-tools", async (event, toolIds: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!Array.isArray(toolIds) || !toolIds.every((id) => typeof id === "string")) {
throw new Error("Invalid vanilla chat MCP tool list.");
}
const validIds = new Set(listMcpChatVanillaServerIds());
const sanitized = toolIds.filter((id: string) => validIds.has(id));
updatePreferences({ vanillaChatMcpTools: sanitized });
await getMcpChatClientManager().startEnabledServers(sanitized);
return getAppStateSnapshot().preferences.vanillaChatMcpTools ?? [];
});
ipcMain.handle("familiaros:get-memories", async (event, query: unknown, limit: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const q = typeof query === "string" ? query.trim() : "";
const l = typeof limit === "number" && Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.round(limit))) : 50;
if (q) return searchFamiliarOSMemories(q, l).map((hit) => hit.entry);
return listFamiliarOSMemories(l);
});
ipcMain.handle("familiaros:store-memory", async (event, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
return storeFamiliarOSMemory({
text: text.trim(),
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
importance: typeof importance === "number" ? importance : undefined,
});
});
ipcMain.handle("familiaros:update-memory", async (event, id: unknown, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof id !== "string" || !id.trim()) throw new Error("Memory id is required.");
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
const result = updateFamiliarOSMemory(id.trim(), {
text: text.trim(),
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
importance: typeof importance === "number" ? importance : undefined,
});
if (!result) throw new Error("Memory not found.");
return result;
});
ipcMain.handle("familiaros:delete-memory", async (event, id: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof id !== "string" || !id.trim()) throw new Error("Memory id is required.");
const ok = forgetFamiliarOSMemory(id.trim());
if (!ok) throw new Error("Memory not found.");
return true;
});
ipcMain.handle("familiaros:knowledge-list", async (event, limit: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const l = typeof limit === "number" && Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.round(limit))) : 50;
return listKnowledgeFiles(l);
});
ipcMain.handle("familiaros:knowledge-search", async (event, query: unknown, limit: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const q = typeof query === "string" ? query.trim() : "";
const l = typeof limit === "number" && Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.round(limit))) : 10;
if (!q) {
return {
files: listKnowledgeFiles(l).map((file) => ({ file, score: 0 })),
memories: [],
};
}
return searchKnowledgeStore(q, l);
});
ipcMain.handle("familiaros:knowledge-delete-file", async (event, id: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof id !== "string" || !id.trim()) throw new Error("File id is required.");
const ok = await deleteKnowledgeFile(id.trim());
if (!ok) throw new Error("File not found.");
return true;
});
ipcMain.handle("familiaros:knowledge-add-memory", async (event, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
return addKnowledgeMemory({
text: text.trim(),
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
importance: typeof importance === "number" ? importance : undefined,
});
});
ipcMain.handle("familiaros:knowledge-store-file", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined;
const options: OpenDialogOptions = {
title: "Add file to Knowledge Store",
buttonLabel: "Add",
properties: ["openFile"],
filters: [{ name: "All files", extensions: ["*"] }],
};
const result = owner ? await dialog.showOpenDialog(owner, options) : await dialog.showOpenDialog(options);
if (result.canceled || !result.filePaths[0]) {
return null;
}
const filePath = result.filePaths[0];
const stats = await stat(filePath);
if (!stats.isFile()) {
throw new Error("Selected path is not a file.");
}
const data = await readFile(filePath);
const name = filePath.replace(/\\/g, "/").split("/").pop() ?? "unnamed";
return storeKnowledgeFile({ name, data });
});
ipcMain.handle("familiaros:get-reaction-animation-settings", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getReactionAnimationSettingsSnapshot();
});
ipcMain.handle("familiaros:plugins-snapshot", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getPluginService().getSnapshot();
});
ipcMain.handle("familiaros:plugins-set-enabled", async (event, id: unknown, enabled: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id) || typeof enabled !== "boolean") return pluginUiError("Invalid plugin enable request.");
return getPluginService().setEnabled(id, enabled);
});
ipcMain.handle("familiaros:plugins-save-config", async (event, id: unknown, config: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id) || !isPlainObject(config)) return pluginUiError("Invalid plugin config request.");
return getPluginService().saveConfig(id, config);
});
ipcMain.handle("familiaros:plugins-pick-config-sound", async (event, id: unknown): Promise<PluginConfigSoundPickResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) {
warn("ui", "Plugin sound pick invalid request.", { ok: false, reason: "invalid-plugin-id" });
return pluginUiSoundError("Invalid plugin sound request.");
}
debug("ui", "Plugin sound pick requested.", { pluginId: id });
try {
const result = await getPluginService().pickConfigSound(id);
if (result.ok && "sound" in result && result.sound.id) debug("ui", "Plugin sound pick succeeded.", { pluginId: id, ok: true, soundId: result.sound.id });
else if (result.ok) debug("ui", "Plugin sound pick canceled.", { pluginId: id, ok: true, canceled: true });
else warn("ui", "Plugin sound pick failed.", { pluginId: id, ok: false, reason: result.error });
return result;
} catch (error) {
logError("ui", "Plugin sound pick errored.", { pluginId: id, ok: false, reason: error instanceof Error ? error.message : "unknown" });
throw error;
}
});
ipcMain.handle("familiaros:plugins-reload", async (event, id: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin reload request.");
return getPluginService().reload(id);
});
ipcMain.handle("familiaros:plugins-execute-command", async (event, id: unknown, commandId: unknown, args: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id) || !isValidPluginCommandId(commandId) || (args !== undefined && !isPlainObject(args))) return pluginUiError("Invalid plugin command request.");
return getPluginService().executeCommand(id, commandId, isPlainObject(args) ? args as Record<string, unknown> : undefined);
});
ipcMain.handle("familiaros:plugins-load-local", async (event): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getPluginService().loadLocal();
});
ipcMain.handle("familiaros:plugins-catalog-snapshot", async (event, refresh: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getPluginService().getCatalogSnapshot(refresh === true);
});
ipcMain.handle("familiaros:plugins-install-catalog", async (event, id: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin install request.");
return getPluginService().installCatalog(id);
});
ipcMain.handle("familiaros:plugins-update-catalog", async (event, id: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin update request.");
return getPluginService().updateCatalog(id);
});
ipcMain.handle("familiaros:plugins-uninstall", async (event, id: unknown): Promise<PluginServiceResult> => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin uninstall request.");
return getPluginService().uninstall(id);
});
ipcMain.handle("familiaros:plugins-inspector", async (event, id: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isValidPluginId(id)) throw new Error("Invalid plugin inspector request.");
return getPluginService().runtime.getInspectorState(id);
});
ipcMain.handle("familiaros:plugin-platform-settings-get", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const { getPluginPlatformSettings } = await import("./plugin-platform-settings.js");
return getPluginPlatformSettings();
});
ipcMain.handle("familiaros:plugin-platform-settings-update", async (event, patch: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (!isPlainObject(patch)) throw new Error("Invalid plugin platform settings patch.");
const { updatePluginPlatformSettings } = await import("./plugin-platform-settings.js");
return updatePluginPlatformSettings(patch as never);
});
ipcMain.handle("familiaros:plugin-platform-ai-key-set", async (event, key: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const { getPluginHostCapabilitiesForUi } = await import("./plugin-host-capabilities.js");
const { hostSecretsOwner, hostAiApiKeySecret } = await import("./plugin-ai-gateway.js");
const capabilities = getPluginHostCapabilitiesForUi();
if (!capabilities) throw new Error("Plugin host capabilities are unavailable.");
if (key === null || key === "") {
await capabilities.secretsStore.delete(hostSecretsOwner, hostAiApiKeySecret);
return { ok: true, hasKey: false };
}
if (typeof key !== "string" || key.length > 4096) throw new Error("Invalid AI API key.");
await capabilities.secretsStore.set(hostSecretsOwner, hostAiApiKeySecret, key);
return { ok: true, hasKey: true };
});
ipcMain.handle("familiaros:plugin-platform-ai-key-status", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const { getPluginHostCapabilitiesForUi } = await import("./plugin-host-capabilities.js");
const { hostSecretsOwner, hostAiApiKeySecret } = await import("./plugin-ai-gateway.js");
const capabilities = getPluginHostCapabilitiesForUi();
if (!capabilities) return { hasKey: false };
return { hasKey: await capabilities.secretsStore.has(hostSecretsOwner, hostAiApiKeySecret) };
});
ipcMain.handle("familiaros:get-catalog", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getCatalogUiState();
});
ipcMain.handle("familiaros:get-catalog-page", async (event, page: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof page !== "number" || !Number.isInteger(page) || page < 0) throw new Error("Invalid catalog page.");
return getCatalogPageUiState(page);
});
ipcMain.handle("familiaros:get-catalog-search", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getCatalogSearchUiState();
});
ipcMain.handle("familiaros:get-codex-familiars", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return (await loadCodexPetsModule()).getCodexPetsUiState();
});
ipcMain.handle("familiaros:update-preferences", (event, patch: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const currentState = getAppStateSnapshot();
const previousScale = currentState.preferences.petScale;
const previousOverrides = JSON.stringify(currentState.preferences.reactionAnimationOverrides ?? {});
const previousLocale = getActiveLocale();
const state = updatePreferences(validatePreferencePatch(patch));
if (currentState.preferences.openApiChatModel !== state.preferences.openApiChatModel
|| currentState.preferences.openApiChatEndpoint !== state.preferences.openApiChatEndpoint) {
resetOpenApiConversationContext();
}
const nextOverrides = JSON.stringify(state.preferences.reactionAnimationOverrides ?? {});
if (state.preferences.petScale !== previousScale || nextOverrides !== previousOverrides) {
refreshDefaultPetContent();
refreshAgentPetContent();
}
const nextLocale = setLocaleFromPreference(state.preferences.locale);
if (nextLocale !== previousLocale || state.preferences.familiarName !== currentState.preferences.familiarName) {
void import("./tray.js").then(({ refreshTrayMenu }) => refreshTrayMenu());
if (nextLocale !== previousLocale) {
broadcastPluginRecordsRefresh(getControlCenterWindow);
}
}
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getSettingsStateSnapshot() : state;
});
ipcMain.handle("familiaros:get-launch-at-login", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getLaunchAtLoginState();
});
ipcMain.handle("familiaros:set-launch-at-login", (event, enabled: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof enabled !== "boolean") throw new Error("Invalid launch-at-login value.");
if (!isLaunchAtLoginSupported()) return getLaunchAtLoginState();
app.setLoginItemSettings({ openAtLogin: enabled, openAsHidden: true });
return getLaunchAtLoginState();
});
ipcMain.handle("familiaros:get-update-status", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getUpdateStatus();
});
ipcMain.handle("familiaros:check-for-updates", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const status = await checkForGitHubReleaseUpdate();
const { refreshTrayMenu } = await import("./tray.js");
refreshTrayMenu();
return status;
});
ipcMain.handle("familiaros:open-update-release-page", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
await openUpdateReleasePage();
});
ipcMain.handle("familiaros:copy-text", (event, text: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof text !== "string" || !text.trim()) throw new Error("Invalid copy payload.");
clipboard.writeText(text);
});
ipcMain.handle("familiaros:open-external-url", async (event, rawUrl: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const url = validateExternalUrl(rawUrl);
await shell.openExternal(url);
});
ipcMain.handle("familiaros:set-default-familiar", async (event, petId: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof petId !== "string") {
throw new Error("Invalid familiar id.");
}
const state = await setDefaultInstalledPet(petId);
refreshDefaultPetContent();
recoverDefaultPetMouseInterop("default-familiar-changed");
setTimeout(() => recoverDefaultPetMouseInterop("default-familiar-changed+500ms"), 500).unref?.();
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getPetsStateSnapshot() : state;
});
ipcMain.handle("familiaros:install-familiar", async (event, petId: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof petId !== "string") {
throw new Error("Invalid familiar id.");
}
const state = await installPet(petId);
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getPetsStateSnapshot() : state;
});
ipcMain.handle("familiaros:install-local-familiar", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined;
const importKind = await chooseLocalPetImportKind(owner);
if (!importKind) return getPetsStateSnapshot();
const options: OpenDialogOptions = importKind === "zip" ? {
title: "Install familiar from ZIP",
buttonLabel: "Install Familiar",
properties: ["openFile"],
filters: [{ name: "FamiliarOS ZIP", extensions: ["zip"] }],
} : {
title: "Install familiar from folder",
buttonLabel: "Install Familiar",
properties: ["openDirectory"],
};
const result = owner ? await dialog.showOpenDialog(owner, options) : await dialog.showOpenDialog(options);
if (result.canceled || !result.filePaths[0]) return getPetsStateSnapshot();
const selectedPath = result.filePaths[0];
try {
const selectedStats = await stat(selectedPath);
const state = selectedStats.isDirectory() ? await installPetFromFolder(selectedPath) : await installPetFromZipFile(selectedPath);
debug("ui", "local familiar import succeeded", { kind: selectedStats.isDirectory() ? "folder" : "zip" });
refreshDefaultPetContent();
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getPetsStateSnapshot() : state;
} catch (error) {
logError("ui", "local familiar import failed", { error: error instanceof Error ? error.message : String(error) });
throw error;
}
});
ipcMain.handle("familiaros:open-gallery", async (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
await shell.openExternal("https://familiaros.dev/gallery");
});
ipcMain.handle("familiaros:import-codex-familiar", async (event, petId: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof petId !== "string") {
throw new Error("Invalid familiar id.");
}
const state = await (await loadCodexPetsModule()).importCodexPet(petId);
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getPetsStateSnapshot() : state;
});
ipcMain.handle("familiaros:remove-familiar", async (event, petId: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof petId !== "string") {
throw new Error("Invalid familiar id.");
}
const state = await removePet(petId);
refreshDefaultPetContent();
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getPetsStateSnapshot() : state;
});
ipcMain.handle("familiaros:reset-default-familiar-position", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
resetDefaultPetToInitialPosition();
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getSettingsStateSnapshot() : getAppStateSnapshot();
});
ipcMain.handle("familiaros:agent-setup-snapshot", async (event, selectedPetId: unknown, commandMode: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getAgentSetupSnapshot(selectedPetId, commandMode);
});
ipcMain.handle("familiaros:agent-setup-action", async (event, action: unknown, selectedPetId: unknown, commandMode: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (action !== "configure" && action !== "replace" && action !== "remove" && action !== "install-memory" && action !== "doctor-hooks" && action !== "install-hooks" && action !== "uninstall-hooks" && action !== "opencode-install" && action !== "opencode-remove" && action !== "cursor-install" && action !== "cursor-replace" && action !== "cursor-remove") {
throw new Error("Invalid agent setup action.");
}
return runAgentSetupAction(action, selectedPetId, commandMode);
});
ipcMain.handle("familiaros:agent-setup-command-paths", (event, patch: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return updateAgentSetupCommandPaths(patch);
});
ipcMain.handle("familiaros:familiaros-mcp-server-preview", (event, selectedPetId: unknown, commandMode: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const petId = typeof selectedPetId === "string" && selectedPetId.trim() ? selectedPetId.trim() : undefined;
const mode = typeof commandMode === "string" && (commandMode === "published" || commandMode === "bundled" || commandMode === "local") ? commandMode : "published";
return buildFamiliarOSMcpServerPreview(petId, mode);
});
ipcMain.handle("familiaros:test-familiaros-mcp-server", async (event, selectedPetId: unknown, commandMode: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
const petId = typeof selectedPetId === "string" && selectedPetId.trim() ? selectedPetId.trim() : undefined;
const mode = typeof commandMode === "string" && (commandMode === "published" || commandMode === "bundled" || commandMode === "local") ? commandMode : "published";
return testFamiliarOSMcpServer(petId, mode);
});
ipcMain.handle("familiaros:get-tts-settings", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
return getTtsSettingsSnapshot();
});
ipcMain.handle("familiaros:get-tts-voices", async (event, provider: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof provider !== "string") throw new Error("Provider must be a string.");
return fetchTtsVoiceList(provider as TtsProviderId);
});
ipcMain.handle("familiaros:save-tts-credential", (event, provider: unknown, credential: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof provider !== "string") throw new Error("Provider must be a string.");
if (typeof credential !== "string") throw new Error("Credential must be a string.");
return saveTtsCredential(provider, credential);
});
ipcMain.handle("familiaros:clear-tts-credential", (event, provider: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof provider !== "string") throw new Error("Provider must be a string.");
return clearTtsCredential(provider);
});
ipcMain.handle("familiaros:test-tts", async (event, text: unknown) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
if (typeof text !== "string" || !text.trim()) throw new Error("Text must be a non-empty string.");
await speakTts(text.trim(), "familiar");
return { ok: true };
});
ipcMain.handle("familiaros:tts-stop", (event) => {
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
stopTts("familiar");
});
}
function assertAllowedSender(getControlCenterWindow: ControlCenterWindowGetter, event: IpcMainInvokeEvent, allowedKinds: readonly InternalUiWindowKind[]): void {
const actualKind = getInternalUiWindowKindForWebContents(getControlCenterWindow, event.sender.id);
if (!actualKind || !allowedKinds.includes(actualKind)) {
throw new Error("FamiliarOS internal UI request came from an unexpected window.");
}
}
function isControlCenterSender(getControlCenterWindow: ControlCenterWindowGetter, webContentsId: number): boolean {
return getInternalUiWindowKindForWebContents(getControlCenterWindow, webContentsId) === "control-center";
}
function getInternalUiWindowKindForWebContents(getControlCenterWindow: ControlCenterWindowGetter, webContentsId: number): InternalUiWindowKind | null {
const controlCenterWindow = getControlCenterWindow();
if (controlCenterWindow && !controlCenterWindow.isDestroyed() && controlCenterWindow.webContents.id === webContentsId) {
return "control-center";
}
return null;
}
function broadcastPluginRecordsRefresh(getControlCenterWindow: ControlCenterWindowGetter): void {
const controlCenterWindow = getControlCenterWindow();
if (controlCenterWindow && !controlCenterWindow.isDestroyed()) {
controlCenterWindow.webContents.send("familiaros:plugins-refresh");
}
}
function chooseLocalPetImportKind(owner: BrowserWindow | undefined): Promise<"zip" | "folder" | null> {
const options = {
type: "question" as const,
title: "Install familiar",
message: "Install familiar from ZIP or folder?",
detail: "Choose the source type before selecting the familiar package.",
buttons: ["ZIP", "Folder", "Cancel"],
defaultId: 0,
cancelId: 2,
noLink: true,
};
return (owner ? dialog.showMessageBox(owner, options) : dialog.showMessageBox(options)).then((result) => {
if (result.response === 0) return "zip";
if (result.response === 1) return "folder";
return null;
});
}
function pluginUiError(error: string): PluginServiceResult {
return { ok: false, error, snapshot: { plugins: [] } };
}
function pluginUiSoundError(error: string): PluginConfigSoundPickResult {
return { ok: false, error, snapshot: { plugins: [] } };
}
function isValidPluginId(value: unknown): value is string {
return typeof value === "string" && pluginIdPattern.test(value);
}
function isValidPluginCommandId(value: unknown): value is string {
return typeof value === "string" && pluginCommandIdPattern.test(value);
}
function isLaunchAtLoginSupported(): boolean {
return process.platform === "darwin" || process.platform === "win32";
}

View file

@ -0,0 +1,12 @@
export {
recoverDefaultPetMouseInterop,
refreshDefaultPetContent,
resetDefaultPetToInitialPosition,
} from "./default-familiar-controller.js";
export {
installPet,
installPetFromFolder,
installPetFromZipFile,
removePet,
setDefaultInstalledPet,
} from "./familiar-installation.js";

View file

@ -0,0 +1,363 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import { app } from "electron";
import { getAppStateSnapshot, normalizeFamiliarName, normalizeOpenApiChatEndpoint, normalizePetScale, petScaleOptions } from "./app-state.js";
import { getCatalogUiState } from "./catalog.js";
import { getInstalledPetDir } from "./familiar-paths.js";
import { getActiveLocale, getActiveMessages, isSupportedLocale, LOCALE_LABELS, SUPPORTED_LOCALES, t, type Locale, type LocalePreference } from "./i18n/index.js";
import { warn } from "./logger.js";
import { getOpenApiChatSettingsSnapshot, normalizeChatModel } from "./openapi-chat.js";
import { getPluginService } from "./plugin-service.js";
import { defaultPetSprite, reactionAnimationMetadata, selectableAnimationMetadata, validateReactionAnimationOverrides } from "./reaction-animation-mapping.js";
import { listTtsProviders } from "./tts-engine.js";
import { type TtsProviderId } from "./tts-service.js";
import { getUpdateStatus } from "./update-checker.js";
type ControlCenterSettingsPreferences = Pick<
ReturnType<typeof getAppStateSnapshot>["preferences"],
| "openDefaultPetOnLaunch"
| "locale"
| "petScale"
| "reactionAnimationOverrides"
| "openApiChatModel"
| "openApiChatSystemPrompt"
| "openApiChatEndpoint"
| "openApiChatTheme"
| "vanillaChatMcpTools"
| "openApiChatBaseInstructionsEnabled"
| "ttsProvider"
| "ttsVoice"
| "ttsSpeed"
| "ttsModel"
| "ttsEndpointPreset"
| "ttsEndpoint"
| "familiarName"
>;
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
export type ControlCenterPreferencesPatch = Partial<Mutable<ReturnType<typeof getAppStateSnapshot>["preferences"]>>;
export function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; familiars: ReturnType<typeof getAppStateSnapshot>["familiars"] } {
const state = getAppStateSnapshot();
return { preferences: { defaultPetId: state.preferences.defaultPetId }, familiars: state.familiars };
}
export function getSettingsStateSnapshot(): {
preferences: ControlCenterSettingsPreferences;
petScaleOptions: typeof petScaleOptions;
openApiChat: ReturnType<typeof getOpenApiChatSettingsSnapshot>;
} {
const state = getAppStateSnapshot();
return {
preferences: {
openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch,
locale: state.preferences.locale,
petScale: state.preferences.petScale,
reactionAnimationOverrides: state.preferences.reactionAnimationOverrides,
openApiChatModel: state.preferences.openApiChatModel,
openApiChatSystemPrompt: state.preferences.openApiChatSystemPrompt,
openApiChatEndpoint: state.preferences.openApiChatEndpoint,
openApiChatTheme: state.preferences.openApiChatTheme,
vanillaChatMcpTools: state.preferences.vanillaChatMcpTools,
openApiChatBaseInstructionsEnabled: state.preferences.openApiChatBaseInstructionsEnabled,
ttsProvider: state.preferences.ttsProvider,
ttsVoice: state.preferences.ttsVoice,
ttsSpeed: state.preferences.ttsSpeed,
ttsModel: state.preferences.ttsModel,
ttsEndpointPreset: state.preferences.ttsEndpointPreset,
ttsEndpoint: state.preferences.ttsEndpoint,
familiarName: state.preferences.familiarName,
},
petScaleOptions,
openApiChat: getOpenApiChatSettingsSnapshot(),
};
}
export function getI18nSnapshot(): {
locale: Locale;
localePreference: LocalePreference;
availableLocales: { value: Locale; label: string }[];
messages: ReturnType<typeof getActiveMessages>;
} {
return {
locale: getActiveLocale(),
localePreference: getAppStateSnapshot().preferences.locale,
availableLocales: SUPPORTED_LOCALES.map((value) => ({ value, label: LOCALE_LABELS[value] })),
messages: getActiveMessages(),
};
}
export async function getDashboardSnapshot(): Promise<{
readonly defaultPet: { readonly id: string; readonly displayName: string; readonly previewSpriteUrl: string };
readonly installedPetCount: number;
readonly catalog: { readonly source: string; readonly total?: number; readonly page?: number; readonly pageCount?: number; readonly error?: string };
readonly plugins: { readonly installed: number; readonly enabled: number; readonly broken: number };
readonly updateStatus: ReturnType<typeof getUpdateStatus>;
readonly activity: ReturnType<typeof getAppStateSnapshot>["analytics"];
}> {
const state = getAppStateSnapshot();
const defaultPet = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId && !familiar.broken) ?? state.familiars.installed[0];
const preview = await getDefaultPetPreviewSpriteInfo();
const catalog = await getCatalogUiState().catch((error: unknown) => ({ source: "error" as const, familiars: [], total: undefined, page: undefined, pageCount: undefined, error: error instanceof Error ? error.message : "Catalog unavailable." }));
const pluginSnapshot = await getPluginService().getSnapshot().catch((error: unknown) => {
warn("ui", "dashboard plugin snapshot unavailable", { error: error instanceof Error ? error.message : String(error) });
return { plugins: [] } as const;
});
const installedPlugins = pluginSnapshot.plugins.length;
const brokenPlugins = pluginSnapshot.plugins.filter((plugin) => Boolean(plugin.brokenReason)).length;
const enabledPlugins = pluginSnapshot.plugins.filter((plugin) => plugin.enabled && !plugin.brokenReason).length;
return {
defaultPet: {
id: defaultPet?.id ?? state.preferences.defaultPetId,
displayName: defaultPet?.displayName ?? "FamiliarOS",
previewSpriteUrl: `familiaros-familiar-preview://spritesheet/default?v=${encodeURIComponent(preview.version)}`,
},
installedPetCount: state.familiars.installed.length,
catalog: {
source: catalog.source,
total: catalog.total,
page: catalog.page,
pageCount: catalog.pageCount,
error: catalog.error,
},
plugins: {
installed: installedPlugins,
enabled: enabledPlugins,
broken: brokenPlugins,
},
updateStatus: getUpdateStatus(),
activity: state.analytics,
};
}
export async function getReactionAnimationSettingsSnapshot(): Promise<unknown> {
const state = getAppStateSnapshot();
const preview = await getDefaultPetPreviewSpriteInfo();
return {
reactions: reactionAnimationMetadata.map((reaction) => ({
...reaction,
label: t(`settings.reaction.${reaction.id}.label`),
description: t(`settings.reaction.${reaction.id}.description`),
})),
animations: selectableAnimationMetadata.map((animation) => ({
...animation,
label: t(`settings.animation.${animation.id}.label`),
description: t(`settings.animation.${animation.id}.description`),
})),
sprite: defaultPetSprite,
overrides: state.preferences.reactionAnimationOverrides ?? {},
previewSpriteUrl: `familiaros-familiar-preview://spritesheet/default?v=${encodeURIComponent(preview.version)}`,
};
}
export async function getDefaultPetPreviewSpriteInfo(): Promise<{ readonly path: string; readonly version: string }> {
const state = getAppStateSnapshot();
const selected = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId);
const builtInPath = join(app.getAppPath(), "assets", defaultPetSprite.fileName);
const candidatePath = selected && !selected.broken && !selected.builtIn
? join(getInstalledPetDir(selected.id), "spritesheet.webp")
: builtInPath;
try {
const spritesheet = await stat(candidatePath);
if (spritesheet.isFile() && spritesheet.size > 0 && spritesheet.size <= 100 * 1024 * 1024) {
return { path: candidatePath, version: `${selected?.id ?? "builtin"}-${Math.round(spritesheet.mtimeMs)}-${spritesheet.size}` };
}
} catch {
// Fall back to the bundled familiar if an installed default disappears while Settings is open.
}
const fallback = await stat(builtInPath);
return { path: builtInPath, version: `builtin-${Math.round(fallback.mtimeMs)}-${fallback.size}` };
}
export function validatePreferencePatch(value: unknown): ControlCenterPreferencesPatch {
if (!isRecord(value)) {
throw new Error("Invalid preferences patch.");
}
const patch: ControlCenterPreferencesPatch = {};
if ("openDefaultPetOnLaunch" in value) {
if (typeof value.openDefaultPetOnLaunch !== "boolean") throw new Error("Invalid open-on-launch value.");
patch.openDefaultPetOnLaunch = value.openDefaultPetOnLaunch;
}
if ("locale" in value) {
if (value.locale !== "system" && !isSupportedLocale(value.locale)) throw new Error("Invalid locale value.");
patch.locale = value.locale;
}
if ("petScale" in value) {
const scale = normalizePetScale(value.petScale);
if (scale !== value.petScale) throw new Error("Invalid familiar scale value.");
patch.petScale = scale;
}
if ("reactionAnimationOverrides" in value) {
patch.reactionAnimationOverrides = validateReactionAnimationOverrides(value.reactionAnimationOverrides);
}
if ("openApiChatModel" in value) {
if (value.openApiChatModel === undefined || value.openApiChatModel === null || value.openApiChatModel === "") {
patch.openApiChatModel = undefined;
} else {
const model = normalizeChatModel(value.openApiChatModel);
if (!model) throw new Error("Invalid model name.");
patch.openApiChatModel = model;
}
}
if ("openApiChatSystemPrompt" in value) {
if (value.openApiChatSystemPrompt === undefined || value.openApiChatSystemPrompt === null || value.openApiChatSystemPrompt === "") {
patch.openApiChatSystemPrompt = undefined;
} else if (typeof value.openApiChatSystemPrompt === "string") {
patch.openApiChatSystemPrompt = value.openApiChatSystemPrompt;
} else {
throw new Error("Invalid familiar character prompt.");
}
}
if ("openApiChatEndpoint" in value) {
if (value.openApiChatEndpoint === undefined || value.openApiChatEndpoint === null || value.openApiChatEndpoint === "") {
patch.openApiChatEndpoint = undefined;
} else {
const endpoint = normalizeOpenApiChatEndpoint(value.openApiChatEndpoint);
if (!endpoint) {
throw new Error("Invalid OpenAPI-compatible endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full /responses or /chat/completions URL.");
}
patch.openApiChatEndpoint = endpoint;
}
}
if ("openApiChatTheme" in value) {
if (value.openApiChatTheme !== "system" && value.openApiChatTheme !== "light" && value.openApiChatTheme !== "dark") {
throw new Error("Invalid theme mode.");
}
patch.openApiChatTheme = value.openApiChatTheme;
}
if ("openApiChatBaseInstructionsEnabled" in value) {
if (typeof value.openApiChatBaseInstructionsEnabled !== "boolean") {
throw new Error("Invalid base instructions value.");
}
patch.openApiChatBaseInstructionsEnabled = value.openApiChatBaseInstructionsEnabled;
}
if ("ttsProvider" in value) {
const validIds = listTtsProviders().map((provider) => provider.id);
if (!validIds.includes(value.ttsProvider as TtsProviderId)) {
throw new Error("Invalid TTS provider.");
}
patch.ttsProvider = value.ttsProvider as TtsProviderId;
}
if ("ttsVoice" in value) {
if (value.ttsVoice === undefined || value.ttsVoice === null || value.ttsVoice === "") {
patch.ttsVoice = undefined;
} else if (typeof value.ttsVoice === "string") {
patch.ttsVoice = value.ttsVoice;
} else {
throw new Error("Invalid TTS voice.");
}
}
if ("ttsSpeed" in value) {
const speed = Number(value.ttsSpeed);
if (!Number.isFinite(speed) || speed < 0.5 || speed > 2) {
throw new Error("Invalid TTS speed. Must be between 0.5 and 2.");
}
patch.ttsSpeed = Math.round(speed * 10) / 10;
}
if ("ttsModel" in value) {
if (value.ttsModel === undefined || value.ttsModel === null || value.ttsModel === "") {
patch.ttsModel = undefined;
} else if (typeof value.ttsModel === "string") {
const trimmed = value.ttsModel.trim();
if (trimmed.length > 120) throw new Error("Invalid TTS model name.");
patch.ttsModel = trimmed;
} else {
throw new Error("Invalid TTS model.");
}
}
if ("ttsEndpointPreset" in value) {
if (value.ttsEndpointPreset !== "openrouter" && value.ttsEndpointPreset !== "litellm" && value.ttsEndpointPreset !== "wavespeedai" && value.ttsEndpointPreset !== "custom") {
throw new Error("Invalid TTS endpoint preset.");
}
patch.ttsEndpointPreset = value.ttsEndpointPreset;
}
if ("ttsEndpoint" in value) {
if (value.ttsEndpoint === undefined || value.ttsEndpoint === null || value.ttsEndpoint === "") {
patch.ttsEndpoint = undefined;
} else {
const endpoint = normalizeOpenApiChatEndpoint(value.ttsEndpoint);
if (!endpoint) {
throw new Error("Invalid TTS endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full URL.");
}
patch.ttsEndpoint = endpoint;
}
}
if ("familiarName" in value) {
if (value.familiarName === undefined || value.familiarName === null || value.familiarName === "") {
patch.familiarName = undefined;
} else if (typeof value.familiarName === "string") {
const name = normalizeFamiliarName(value.familiarName);
if (name === undefined) {
throw new Error("Invalid Familiar name. Use up to 64 characters and avoid control characters.");
}
patch.familiarName = name;
} else {
throw new Error("Invalid Familiar name.");
}
}
return patch;
}
export function getLaunchAtLoginState(): { supported: boolean; enabled: boolean } {
if (!isLaunchAtLoginSupported()) return { supported: false, enabled: false };
return { supported: true, enabled: app.getLoginItemSettings().openAtLogin };
}
export function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value) as unknown;
return prototype === Object.prototype || prototype === null;
}
export function validateExternalUrl(value: unknown): string {
if (typeof value !== "string") {
throw new Error("Invalid URL.");
}
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error("Invalid URL.");
}
const isLocalHttp = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname);
if ((parsed.protocol !== "https:" && !isLocalHttp)
|| parsed.username
|| parsed.password
|| parsed.hash) {
throw new Error("Only https URLs, or localhost http URLs, are allowed.");
}
return parsed.toString();
}
function isLaunchAtLoginSupported(): boolean {
return process.platform === "darwin" || process.platform === "win32";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -0,0 +1,76 @@
import { promises as fs } from "node:fs";
import { basename, extname } from "node:path";
import { getDefaultPetWindowForPlugins } from "./default-familiar-controller.js";
import { playPetWindowAudio, stopPetWindowAudio } from "./familiar-window.js";
import { debug, warn } from "./logger.js";
import type { PluginHostCapabilities } from "./plugin-sdk-bridge.js";
import type { PluginPickedFileRegistry } from "./plugin-host-files.js";
import { maxUserSoundBytes, UserSoundStore, userSoundMimeByExtension } from "./plugin-user-sound-store.js";
export function createPluginHostAudioApi(userSounds: UserSoundStore, pickedFiles: PluginPickedFileRegistry): PluginHostCapabilities["audio"] {
return {
async play(spec, volume) {
const baseFields = {
kind: spec.kind,
pluginId: "pluginId" in spec ? spec.pluginId : undefined,
soundId: "id" in spec ? spec.id : undefined,
name: "name" in spec ? spec.name : undefined,
};
debug("plugin", "audio play requested", baseFields);
const window = getDefaultPetWindowForPlugins();
if (!window) {
debug("plugin", "audio play skipped", { ...baseFields, reason: "no-familiar-window" });
return;
}
if (spec.kind === "named") {
playPetWindowAudio(window, { kind: "named", name: spec.name, volume });
debug("plugin", "audio play started", baseFields);
return;
}
try {
const sourcePath = spec.kind === "user-sound" ? await userSounds.resolvePath(spec.pluginId, spec.id) : spec.path;
const stat = await fs.stat(sourcePath);
const ext = extname(sourcePath).toLowerCase();
if (!stat.isFile() || stat.size > maxUserSoundBytes) throw new Error("Plugin sound file is missing or too large.");
const mime = userSoundMimeByExtension[ext];
if (!mime) throw new Error("Plugin sound format is not supported.");
debug("plugin", "audio play file ready", { ...baseFields, ext, sizeBytes: stat.size });
const bytes = await fs.readFile(sourcePath);
playPetWindowAudio(window, {
kind: "data",
dataUrl: `data:${mime};base64,${bytes.toString("base64")}`,
volume,
});
debug("plugin", "audio play started", { ...baseFields, ext, sizeBytes: stat.size });
} catch (error) {
warn("plugin", "audio play failed", { ...baseFields, reason: error instanceof Error ? error.message : "unknown" });
throw error;
}
},
async importUserSound(pluginId, fileId, opts) {
const entry = pickedFiles.getEntry(fileId);
if (!entry) throw new Error("Plugin file handle is invalid.");
return userSounds.importFromPath(pluginId, entry.path, { name: opts?.name ?? entry.name });
},
async importUserSoundFromPath(pluginId, path, opts) {
const fields: Record<string, unknown> = { pluginId, basename: basename(path), ext: extname(path).toLowerCase() };
try {
fields.sizeBytes = (await fs.stat(path)).size;
} catch {
fields.reason = "stat-unavailable";
}
debug("plugin", "user sound import requested", fields);
const sound = await userSounds.importFromPath(pluginId, path, { name: opts?.name ?? basename(path) });
debug("plugin", "user sound import succeeded", { pluginId, soundId: sound.id, name: sound.name });
return sound;
},
async forgetUserSound(pluginId, ref) {
await userSounds.forget(pluginId, ref);
},
async stop() {
const window = getDefaultPetWindowForPlugins();
if (window) stopPetWindowAudio(window);
},
};
}

View file

@ -1,30 +1,33 @@
import { promises as fs } from "node:fs";
import * as os from "node:os";
import { basename, extname, join } from "node:path";
import { join } from "node:path";
import { app, clipboard, dialog, nativeTheme, net, Notification, shell } from "electron";
import { Notification } from "electron";
import { getDefaultPetWindowForPlugins } from "./default-familiar-controller.js";
import { getActiveLocaleLang } from "./i18n/index.js";
import { debug, warn } from "./logger.js";
import { playPetWindowAudio, stopPetWindowAudio } from "./familiar-window.js";
import { PluginAiGateway } from "./plugin-ai-gateway.js";
import { readDroppedFileText, startPluginEventSources, subscribePluginEvent } from "./plugin-events-source.js";
import { PluginOauthBroker } from "./plugin-oauth.js";
import { openPluginPanel } from "./plugin-panels.js";
import { getPluginPlatformSettings, isInQuietHours } from "./plugin-platform-settings.js";
import { createPluginHostAudioApi } from "./plugin-host-audio.js";
import { createPluginPickedFileRegistry } from "./plugin-host-files.js";
import {
classifyPluginError,
getPluginPlatformSettings,
isInQuietHours,
motionStop,
openPluginPanel,
PluginAiGateway,
PluginOauthBroker,
PluginSecretsStore,
pluginVoiceListen,
pluginVoiceSpeak,
showPluginToast,
startPluginEventSources,
subscribePluginEvent,
UserSoundStore,
warn,
} from "./plugin-host-runtime.js";
import { createPluginHostSystemApi } from "./plugin-host-system.js";
import {
setPluginPetStatusReaction, clearPluginPetsForPlugin, closeAllPluginPets, closePluginPet, getPluginPetArbiter, getPluginPetState, hidePluginPet, listPluginPets,
movePluginPetBy, movePluginPetTo, movePluginPetToHome, onPluginPetTick, onPluginPetsChange, reactPluginPet,
setPluginPetAnimation, setPluginPetFollowCursor, setPluginPetPhysics, setPluginPetScale, showPluginPet, spawnPluginPet, wanderPluginPet,
} from "./plugin-familiar-registry.js";
import { motionStop } from "./familiar-motion-engine.js";
import { PluginSecretsStore } from "./plugin-secrets.js";
import { showPluginToast } from "./plugin-toast.js";
import { pluginVoiceListen, pluginVoiceSpeak } from "./plugin-voice.js";
import type { PluginHostCapabilities, PluginPickedFileHost } from "./plugin-sdk-bridge.js";
import { maxUserSoundBytes, UserSoundStore, userSoundMimeByExtension } from "./plugin-user-sound-store.js";
import { classifyPluginError } from "./plugin-diagnostics.js";
import type { PluginHostCapabilities } from "./plugin-sdk-bridge.js";
/**
* The Electron implementation of every SDK v3 host capability. Built once at
@ -32,32 +35,6 @@ import { classifyPluginError } from "./plugin-diagnostics.js";
* permissions, and quotas this layer only does the side effects.
*/
const maxPickedFileBytes = 16 * 1024 * 1024;
type PickedFileEntry = { path: string; name: string; sizeBytes: number };
let cpuSample: { idle: number; total: number } | null = null;
function sampleCpus(): { idle: number; total: number } {
let idle = 0;
let total = 0;
for (const cpu of os.cpus()) {
idle += cpu.times.idle;
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
}
return { idle, total };
}
function cpuPercent(): number {
const current = sampleCpus();
const previous = cpuSample ?? current;
cpuSample = current;
const totalDelta = current.total - previous.total;
const idleDelta = current.idle - previous.idle;
if (totalDelta <= 0) return 0;
return Math.round(Math.min(100, Math.max(0, (1 - idleDelta / totalDelta) * 100)));
}
export type ElectronPluginHostCapabilities = PluginHostCapabilities & {
readonly secretsStore: PluginSecretsStore;
readonly aiGateway: PluginAiGateway;
@ -78,9 +55,8 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
const secretsStore = new PluginSecretsStore(userDataPath);
const aiGateway = new PluginAiGateway(secretsStore);
const oauthBroker = new PluginOauthBroker(secretsStore);
const pickedFiles = new Map<string, PickedFileEntry>();
const pickedFiles = createPluginPickedFileRegistry();
const userSounds = new UserSoundStore(join(userDataPath, "plugin-user-sounds"));
let nextPickedFileId = 0;
const capabilities: ElectronPluginHostCapabilities = {
secretsStore,
@ -90,53 +66,7 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
return getPluginPetArbiter(petId).show(pluginId, bubble, callbacks);
},
},
audio: {
async play(spec, volume) {
const baseFields = { kind: spec.kind, pluginId: "pluginId" in spec ? spec.pluginId : undefined, soundId: "id" in spec ? spec.id : undefined, name: "name" in spec ? spec.name : undefined };
debug("plugin", "audio play requested", baseFields);
const window = getDefaultPetWindowForPlugins();
if (!window) { debug("plugin", "audio play skipped", { ...baseFields, reason: "no-familiar-window" }); return; }
if (spec.kind === "named") {
playPetWindowAudio(window, { kind: "named", name: spec.name, volume });
debug("plugin", "audio play started", baseFields);
return;
}
try {
const sourcePath = spec.kind === "user-sound" ? await userSounds.resolvePath(spec.pluginId, spec.id) : spec.path;
const stat = await fs.stat(sourcePath);
const ext = extname(sourcePath).toLowerCase();
if (!stat.isFile() || stat.size > maxUserSoundBytes) throw new Error("Plugin sound file is missing or too large.");
const mime = userSoundMimeByExtension[ext];
if (!mime) throw new Error("Plugin sound format is not supported.");
debug("plugin", "audio play file ready", { ...baseFields, ext, sizeBytes: stat.size });
const bytes = await fs.readFile(sourcePath);
playPetWindowAudio(window, { kind: "data", dataUrl: `data:${mime};base64,${bytes.toString("base64")}`, volume });
debug("plugin", "audio play started", { ...baseFields, ext, sizeBytes: stat.size });
} catch (error) { warn("plugin", "audio play failed", { ...baseFields, reason: error instanceof Error ? error.message : "unknown" }); throw error; }
},
async importUserSound(pluginId, fileId, opts) {
const entry = pickedFiles.get(fileId);
if (!entry) throw new Error("Plugin file handle is invalid.");
return userSounds.importFromPath(pluginId, entry.path, { name: opts?.name ?? entry.name });
},
async importUserSoundFromPath(pluginId, path, opts) {
// Trusted Control Center plumbing only: plugins receive opaque refs and
// cannot pass arbitrary paths through the SDK.
const fields: Record<string, unknown> = { pluginId, basename: basename(path), ext: extname(path).toLowerCase() };
try { fields.sizeBytes = (await fs.stat(path)).size; } catch { fields.reason = "stat-unavailable"; }
debug("plugin", "user sound import requested", fields);
const sound = await userSounds.importFromPath(pluginId, path, { name: opts?.name ?? basename(path) });
debug("plugin", "user sound import succeeded", { pluginId, soundId: sound.id, name: sound.name });
return sound;
},
async forgetUserSound(pluginId, ref) {
await userSounds.forget(pluginId, ref);
},
async stop() {
const window = getDefaultPetWindowForPlugins();
if (window) stopPetWindowAudio(window);
},
},
audio: createPluginHostAudioApi(userSounds, pickedFiles),
events: {
subscribe(event, handler) {
return subscribePluginEvent(event, handler);
@ -191,74 +121,8 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
refresh: async (pluginId, provider) => { try { return await oauthBroker.refresh(pluginId, provider); } catch (error) { warn("plugin", "oauth refresh failed", { pluginId, provider, reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; } },
signOut: async (pluginId, provider) => { try { await oauthBroker.signOut(pluginId, provider); } catch (error) { warn("plugin", "oauth signout failed", { pluginId, provider, reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; } },
},
files: {
async pick(opts) {
const filters = opts.accept && opts.accept.length > 0
? [{ name: "Allowed files", extensions: opts.accept.map((ext) => ext.replace(/^[.]/, "")) }]
: undefined;
const result = await dialog.showOpenDialog({ properties: opts.multiple ? ["openFile", "multiSelections"] : ["openFile"], filters });
if (result.canceled) return [];
const out: PluginPickedFileHost[] = [];
let skipped = 0;
for (const path of result.filePaths.slice(0, 16)) {
try {
const stat = await fs.stat(path);
if (!stat.isFile()) { skipped++; continue; }
const fileId = `pick-${++nextPickedFileId}`;
pickedFiles.set(fileId, { path, name: basename(path), sizeBytes: stat.size });
out.push({ fileId, name: basename(path), sizeBytes: stat.size });
} catch { skipped++; }
}
debug("plugin", "files picked", { count: out.length, skipped });
return out;
},
async read(fileId, encoding) {
const dropped = readDroppedFileText(fileId);
if (dropped !== undefined) return encoding === "text" ? dropped : new TextEncoder().encode(dropped);
const entry = pickedFiles.get(fileId);
if (!entry) throw new Error("Plugin file handle is invalid.");
const stat = await fs.stat(entry.path);
if (stat.size > maxPickedFileBytes) throw new Error("Picked file is too large to read.");
debug("plugin", "file read", { basename: entry.name, ext: extname(entry.name).toLowerCase(), sizeBytes: stat.size });
const bytes = await fs.readFile(entry.path);
return encoding === "text" ? bytes.toString("utf8") : new Uint8Array(bytes);
},
async save(opts) {
const result = await dialog.showSaveDialog({ defaultPath: opts.suggestedName });
if (result.canceled || !result.filePath) return;
debug("plugin", "file saved", { basename: basename(result.filePath), ext: extname(result.filePath).toLowerCase(), sizeBytes: typeof opts.data === "string" ? Buffer.byteLength(opts.data) : opts.data.byteLength });
await fs.writeFile(result.filePath, typeof opts.data === "string" ? opts.data : Buffer.from(opts.data));
},
},
system: {
async info() {
return {
platform: process.platform === "darwin" ? "mac" as const : process.platform === "win32" ? "win" as const : "linux" as const,
locale: getActiveLocaleLang(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
theme: nativeTheme.shouldUseDarkColors ? "dark" as const : "light" as const,
appVersion: app.getVersion(),
online: net.online,
};
},
async metrics() {
const memory = process.getSystemMemoryInfo();
const memUsedPercent = memory.total > 0 ? Math.round(Math.min(100, Math.max(0, (1 - memory.free / memory.total) * 100))) : 0;
return { cpuPercent: cpuPercent(), memUsedPercent };
},
async openExternal(url) {
let host: string | undefined;
try { host = new URL(url).hostname; } catch { host = undefined; }
debug("plugin", "system openExternal", { host });
await shell.openExternal(url);
},
async readClipboardText() {
return clipboard.readText().slice(0, 64 * 1024);
},
async writeClipboardText(text) {
clipboard.writeText(text);
},
},
files: pickedFiles.files,
system: createPluginHostSystemApi(),
settings: {
audioAllowed: () => getPluginPlatformSettings().allowPluginAudio,
dynamicSpeechAllowed: () => getPluginPlatformSettings().allowDynamicSpeech,
@ -280,8 +144,6 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
closeAllPluginPets();
},
};
// Prime the CPU sampler so the first metrics() call has a delta to use.
cpuSample = sampleCpus();
activeCapabilities = capabilities;
return capabilities;
}

View file

@ -0,0 +1,85 @@
import { promises as fs } from "node:fs";
import { basename, extname } from "node:path";
import { dialog } from "electron";
import { readDroppedFileText } from "./plugin-events-source.js";
import { debug } from "./logger.js";
import type { PluginHostCapabilities, PluginPickedFileHost } from "./plugin-sdk-bridge.js";
const maxPickedFileBytes = 16 * 1024 * 1024;
export type PickedFileEntry = {
path: string;
name: string;
sizeBytes: number;
};
export type PluginPickedFileRegistry = {
readonly files: PluginHostCapabilities["files"];
getEntry(fileId: string): PickedFileEntry | undefined;
};
export function createPluginPickedFileRegistry(): PluginPickedFileRegistry {
const pickedFiles = new Map<string, PickedFileEntry>();
let nextPickedFileId = 0;
return {
files: {
async pick(opts) {
const filters = opts.accept && opts.accept.length > 0
? [{ name: "Allowed files", extensions: opts.accept.map((ext) => ext.replace(/^[.]/, "")) }]
: undefined;
const result = await dialog.showOpenDialog({
properties: opts.multiple ? ["openFile", "multiSelections"] : ["openFile"],
filters,
});
if (result.canceled) return [];
const out: PluginPickedFileHost[] = [];
let skipped = 0;
for (const path of result.filePaths.slice(0, 16)) {
try {
const stat = await fs.stat(path);
if (!stat.isFile()) {
skipped += 1;
continue;
}
const fileId = `pick-${++nextPickedFileId}`;
pickedFiles.set(fileId, { path, name: basename(path), sizeBytes: stat.size });
out.push({ fileId, name: basename(path), sizeBytes: stat.size });
} catch {
skipped += 1;
}
}
debug("plugin", "files picked", { count: out.length, skipped });
return out;
},
async read(fileId, encoding) {
const dropped = readDroppedFileText(fileId);
if (dropped !== undefined) {
return encoding === "text" ? dropped : new TextEncoder().encode(dropped);
}
const entry = pickedFiles.get(fileId);
if (!entry) throw new Error("Plugin file handle is invalid.");
const stat = await fs.stat(entry.path);
if (stat.size > maxPickedFileBytes) throw new Error("Picked file is too large to read.");
debug("plugin", "file read", { basename: entry.name, ext: extname(entry.name).toLowerCase(), sizeBytes: stat.size });
const bytes = await fs.readFile(entry.path);
return encoding === "text" ? bytes.toString("utf8") : new Uint8Array(bytes);
},
async save(opts) {
const result = await dialog.showSaveDialog({ defaultPath: opts.suggestedName });
if (result.canceled || !result.filePath) return;
debug("plugin", "file saved", {
basename: basename(result.filePath),
ext: extname(result.filePath).toLowerCase(),
sizeBytes: typeof opts.data === "string" ? Buffer.byteLength(opts.data) : opts.data.byteLength,
});
await fs.writeFile(result.filePath, typeof opts.data === "string" ? opts.data : Buffer.from(opts.data));
},
},
getEntry(fileId) {
return pickedFiles.get(fileId);
},
};
}

View file

@ -0,0 +1,12 @@
export { warn } from "./logger.js";
export { PluginAiGateway } from "./plugin-ai-gateway.js";
export { startPluginEventSources, subscribePluginEvent } from "./plugin-events-source.js";
export { motionStop } from "./familiar-motion-engine.js";
export { classifyPluginError } from "./plugin-diagnostics.js";
export { PluginOauthBroker } from "./plugin-oauth.js";
export { openPluginPanel } from "./plugin-panels.js";
export { getPluginPlatformSettings, isInQuietHours } from "./plugin-platform-settings.js";
export { PluginSecretsStore } from "./plugin-secrets.js";
export { showPluginToast } from "./plugin-toast.js";
export { UserSoundStore } from "./plugin-user-sound-store.js";
export { pluginVoiceListen, pluginVoiceSpeak } from "./plugin-voice.js";

View file

@ -0,0 +1,64 @@
import * as os from "node:os";
import { app, clipboard, nativeTheme, net, shell } from "electron";
import { getActiveLocaleLang } from "./i18n/index.js";
import { debug } from "./logger.js";
import type { PluginHostCapabilities } from "./plugin-sdk-bridge.js";
export function createPluginHostSystemApi(): PluginHostCapabilities["system"] {
let cpuSample = sampleCpus();
return {
async info() {
return {
platform: process.platform === "darwin" ? "mac" as const : process.platform === "win32" ? "win" as const : "linux" as const,
locale: getActiveLocaleLang(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
theme: nativeTheme.shouldUseDarkColors ? "dark" as const : "light" as const,
appVersion: app.getVersion(),
online: net.online,
};
},
async metrics() {
const memory = process.getSystemMemoryInfo();
const memUsedPercent = memory.total > 0 ? Math.round(Math.min(100, Math.max(0, (1 - memory.free / memory.total) * 100))) : 0;
return { cpuPercent: cpuPercent(cpuSample, (nextSample) => { cpuSample = nextSample; }), memUsedPercent };
},
async openExternal(url) {
let host: string | undefined;
try {
host = new URL(url).hostname;
} catch {
host = undefined;
}
debug("plugin", "system openExternal", { host });
await shell.openExternal(url);
},
async readClipboardText() {
return clipboard.readText().slice(0, 64 * 1024);
},
async writeClipboardText(text) {
clipboard.writeText(text);
},
};
}
function sampleCpus(): { idle: number; total: number } {
let idle = 0;
let total = 0;
for (const cpu of os.cpus()) {
idle += cpu.times.idle;
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
}
return { idle, total };
}
function cpuPercent(previous: { idle: number; total: number }, setCurrent: (current: { idle: number; total: number }) => void): number {
const current = sampleCpus();
setCurrent(current);
const totalDelta = current.total - previous.total;
const idleDelta = current.idle - previous.idle;
if (totalDelta <= 0) return 0;
return Math.round(Math.min(100, Math.max(0, (1 - idleDelta / totalDelta) * 100)));
}

View file

@ -1,28 +1,63 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { lookup } from "node:dns/promises";
import * as net from "node:net";
import { join } from "node:path";
import { getActiveLocaleLang } from "./i18n/index.js";
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
import { validateReaction, validateSayMessage } from "./local-ipc-protocol.js";
import { makePluginT } from "./plugin-i18n.js";
import { resolveDeclaredAssetPath, resolveDeclaredPanelPath } from "./plugin-assets.js";
import type { PluginConfig } from "./plugin-config.js";
import type { FamiliarOSJavascriptPluginManifest, PluginAssetKind, PluginPermission } from "./plugin-manifest.js";
import type { PluginPetApi } from "./plugin-familiar-api.js";
import type { PluginRuntimeScheduler } from "./plugin-runtime.js";
import { createPluginAudioApi } from "./plugin-sdk-audio.js";
import { createPluginBusApi, type PluginBusTopicEntry } from "./plugin-sdk-bus.js";
import { createPluginConfigApi } from "./plugin-sdk-config.js";
import { createPluginEventsApi } from "./plugin-sdk-events.js";
import { createDefaultPluginHostCapabilities } from "./plugin-sdk-default-capabilities.js";
import type { PluginConfig, FamiliarOSJavascriptPluginManifest, PluginAssetKind, PluginPermission, PluginPetApi, PluginRuntimeScheduler, PluginStateRecord, PluginStateStore } from "./plugin-sdk-contracts.js";
import {
allowedNetworkHosts,
safeHttpFetch,
safeHttpStream,
validateNetOptions,
} from "./plugin-sdk-network.js";
import { pluginSdkQuotas } from "./plugin-sdk-quotas.js";
import type { ScheduleSpec, PluginInspectorState } from "./plugin-sdk-state.js";
import { WindowCounter, type PluginRuntimeState } from "./plugin-sdk-state.js";
import { createPluginStorageApi } from "./plugin-sdk-storage.js";
import { createPluginUiApi } from "./plugin-sdk-ui.js";
import type { PluginStateRecord, PluginStateStore } from "./plugin-state.js";
import { classifyPluginError, logPluginDiagnostic } from "./plugin-diagnostics.js";
import {
createPluginAudioApi,
createPluginBusApi,
createPluginConfigApi,
createPluginEventsApi,
createPluginStorageApi,
createPluginUiApi,
type PluginBusTopicEntry,
} from "./plugin-sdk-surfaces.js";
import {
allowedAccents,
check,
clampNumber,
commandIdPattern,
isRecord,
namedHostIcons,
nextCronRunMs,
normalizeJson,
parseCronExpression,
renderLimitedMarkdown,
scheduleIdPattern,
screenStaticBubbleText,
validateAiRequest,
validateBubbleActions,
validateBubbleInput,
validateCommand,
validateCommandFormValues,
validateCssColor,
validateDynamicText,
validateMenuItems,
validateMoveBy,
validateMoveToOptions,
validateOauthConfig,
validatePetHandleId,
validatePinnedBubbleText,
validatePoint,
validateProviderName,
validateReactOptions,
validateStatus,
validateStorageKey,
validateWander,
} from "./plugin-sdk-validators.js";
import { classifyPluginError, getActiveLocaleLang, makePluginT, resolveDeclaredAssetPath, resolveDeclaredPanelPath, validateReaction, validateSayMessage, type FamiliarOSReaction } from "./plugin-sdk-support.js";
import { WindowCounter, type PluginRuntimeState, type ScheduleSpec, type PluginInspectorState } from "./plugin-sdk-state.js";
export { createDefaultPluginHostCapabilities } from "./plugin-sdk-default-capabilities.js";
export { assertPublicHost, isPrivateIp, safeHttpFetch, safeHttpStream } from "./plugin-sdk-network.js";
export { nextCronRunMs, normalizeJson, parseCronExpression, renderLimitedMarkdown, validateCommandFormValues, validateDynamicText, validatePinnedBubbleText } from "./plugin-sdk-validators.js";
// ---------------------------------------------------------------------------
// Public bridge types
@ -113,10 +148,6 @@ export interface PluginBubbleHostHandle {
unpin(): Promise<void>;
}
// ---------------------------------------------------------------------------
// Host capabilities — injected by the Electron layer, defaulted for tests
// ---------------------------------------------------------------------------
export type PluginPetInfo = { id: string; name: string; kind: "default" | "agent" | "plugin"; visible: boolean };
export type PluginPetState = { position: { x: number; y: number }; bounds: { x: number; y: number; width: number; height: number }; currentAnimation: string; visible: boolean; dragging: boolean };
export type PluginAnimationSpec = { kind: "reaction"; reaction: FamiliarOSReaction } | { kind: "sprite"; spritePath: string; loop: boolean; fps: number };
@ -209,84 +240,11 @@ export interface PluginHostCapabilities {
clearPlugin?(pluginId: string): void;
}
/**
* Capability defaults used when the Electron layer is not wired (contract
* tests, headless runs). Familiar speech/reactions fall back to the v2 familiar API;
* everything host-bound reports a clear, structured unavailability error.
*/
export function createDefaultPluginHostCapabilities(petApi: PluginPetApi): PluginHostCapabilities {
const unavailable = (feature: string) => async (): Promise<never> => { throw new Error(`Plugin host capability is unavailable: ${feature}`); };
return {
bubbles: {
async show({ bubble, callbacks }) {
if (bubble.text) await petApi.speak(bubble.text);
let dismissed = false;
return {
id: `bubble-${Math.random().toString(36).slice(2)}`,
update: async () => undefined,
dismiss: async () => { if (!dismissed) { dismissed = true; callbacks.onDismiss("manual"); } },
pin: async () => undefined,
unpin: async () => { if (!dismissed) { dismissed = true; callbacks.onDismiss("unpinned"); } },
};
},
},
audio: { play: async () => undefined, importUserSound: async (_pluginId, _fileId, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }), importUserSoundFromPath: async (_pluginId, _path, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }), forgetUserSound: async () => undefined, stop: async () => undefined },
events: { subscribe: () => () => undefined },
familiars: {
list: () => [{ id: "default", name: "Default familiar", kind: "default", visible: true }],
spawn: unavailable("familiars.spawn"),
close: unavailable("familiars.close"),
show: async () => undefined,
hide: async () => undefined,
react: async (_petId, reaction, options) => { await petApi.react(reaction, options); },
setAnimation: async (_petId, spec) => { if (spec.kind === "reaction") await petApi.react(spec.reaction); },
setScale: async () => undefined,
setStatusReaction: async () => undefined,
moveBy: async (_petId, opts) => { await petApi.moveBy(opts); },
wander: async (_petId, opts) => { await petApi.wander(opts); },
moveToHome: async () => { await petApi.moveToHome(); },
moveTo: unavailable("familiars.moveTo"),
followCursor: async () => undefined,
physics: async () => undefined,
getState: async () => ({ position: { x: 0, y: 0 }, bounds: { x: 0, y: 0, width: 0, height: 0 }, currentAnimation: "idle", visible: true, dragging: false }),
onTick: () => () => undefined,
onChange: () => () => undefined,
},
toast: async () => undefined,
notify: async () => undefined,
panels: { open: unavailable("panels.open") },
secrets: (() => {
const store = new Map<string, string>();
return {
get: async (pluginId: string, key: string) => store.get(`${pluginId}\0${key}`),
set: async (pluginId: string, key: string, value: string) => void store.set(`${pluginId}\0${key}`, value),
delete: async (pluginId: string, key: string) => void store.delete(`${pluginId}\0${key}`),
has: async (pluginId: string, key: string) => store.has(`${pluginId}\0${key}`),
};
})(),
ai: { available: async () => false, complete: unavailable("ai.complete"), stream: unavailable("ai.stream") },
voice: { speak: unavailable("voice.speak"), listen: unavailable("voice.listen") },
auth: { oauth: unavailable("auth.oauth"), refresh: unavailable("auth.refresh"), signOut: async () => undefined },
files: { pick: async () => [], read: unavailable("files.read"), save: unavailable("files.save") },
system: {
info: async () => ({ platform: process.platform === "darwin" ? "mac" : process.platform === "win32" ? "win" : "linux", locale: "en-US", timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC", theme: "light", appVersion: "0.0.0", online: true }),
metrics: async () => ({ cpuPercent: 0, memUsedPercent: 0 }),
openExternal: unavailable("system.openExternal"),
readClipboardText: unavailable("system.readClipboardText"),
writeClipboardText: unavailable("system.writeClipboardText"),
},
settings: { audioAllowed: () => true, dynamicSpeechAllowed: () => false, voiceAllowed: () => true, listenAllowed: () => false, inQuietHours: () => false },
};
}
// ---------------------------------------------------------------------------
// Quotas
// ---------------------------------------------------------------------------
const quotas = pluginSdkQuotas;
const commandIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
const scheduleIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
const allowedEventNames = new Set([
"familiar:clicked", "familiar:doubleClicked", "familiar:dragStart", "familiar:dragEnd", "familiar:hover", "familiar:drop",
"idle:enter", "idle:exit", "agent:activity", "config:changed",
@ -294,9 +252,6 @@ const allowedEventNames = new Set([
"display:changed", "online", "offline", "day:partChanged",
]);
export const pluginEventNames = allowedEventNames;
const allowedAccents = new Set(["blue", "purple", "green", "amber", "red", "pink", "slate"]);
const namedHostIcons = new Set(["info", "check", "alert", "heart", "star", "bell", "coffee", "timer", "droplet", "sparkles", "zap", "moon", "sun", "food", "play", "pause"]);
const safeCssColorPattern = /^(#[0-9a-fA-F]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)|hsla?\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\))$/;
// ---------------------------------------------------------------------------
// Storage stores
@ -857,407 +812,6 @@ export class PluginSdkBridge {
}
function countActiveBubbles(state: PluginRuntimeState): number { return state.bubbles.size; }
// ---------------------------------------------------------------------------
// Network
// ---------------------------------------------------------------------------
type SimpleHttpResponse = { status: number; ok: boolean; headers: Record<string, string>; text: string; json?: unknown };
type ValidatedNetOptions = { method: string; headers?: Record<string, string>; body?: string; timeoutMs?: number };
const forbiddenHeaderNames = new Set(["host", "cookie", "cookie2", "origin", "referer", "content-length", "connection", "transfer-encoding", "upgrade", "keep-alive", "te", "trailer", "expect", "via"]);
function validateNetOptions(options: unknown, approved: ReadonlySet<PluginPermission>): ValidatedNetOptions {
const opts = isRecord(options) ? options : {};
const method = String(opts.method ?? "GET").toUpperCase();
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) throw new Error("Plugin HTTP method is not allowed.");
if (method !== "GET" && !approved.has("network:write")) throw new Error("Plugin permission is not approved: network:write");
let body: string | undefined;
if (opts.body !== undefined) {
if (method === "GET") throw new Error("Plugin GET requests must not have a body.");
body = String(opts.body);
if (Buffer.byteLength(body) > quotas.httpRequestBodyBytes) throw new Error("Plugin HTTP request body is too large.");
}
return { method, headers: safeNetHeaders(opts.headers), body, timeoutMs: opts.timeoutMs === undefined ? undefined : Number(opts.timeoutMs) };
}
function safeNetHeaders(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
const out: Record<string, string> = {};
for (const [name, headerValue] of Object.entries(value).slice(0, 24)) {
const lower = name.toLowerCase();
if (!/^[a-z0-9-]{1,64}$/.test(lower) || forbiddenHeaderNames.has(lower) || lower.startsWith("proxy-") || lower.startsWith("sec-")) continue;
if (typeof headerValue !== "string" || headerValue.length > 4096 || /[\r\n\0]/.test(headerValue)) continue;
out[lower] = headerValue;
}
return out;
}
function allowedNetworkHosts(record: PluginStateRecord, manifest: FamiliarOSJavascriptPluginManifest): Set<string> { const manifestHosts = new Set((manifest.network?.hosts ?? []).map((h) => h.toLowerCase())); const approved = record.approvedNetworkHosts?.map((h) => h.toLowerCase()) ?? []; return new Set(approved.filter((h) => manifestHosts.has(h))); }
async function prepareSafeRequest(urlText: string, opts: ValidatedNetOptions, allowedHosts: Set<string>): Promise<{ url: URL; init: RequestInit; controller: AbortController; timeout: NodeJS.Timeout }> {
const url = new URL(urlText);
if (url.protocol !== "https:") throw new Error("Plugin HTTP fetch requires HTTPS.");
if (url.username || url.password) throw new Error("Plugin HTTP fetch credentials are not allowed.");
const host = url.hostname.toLowerCase();
if (!allowedHosts.has(host)) throw new Error("Plugin HTTP host is not approved.");
await assertPublicHost(host);
const controller = new AbortController();
const timeoutMs = Math.min(Math.max(Number(opts.timeoutMs ?? 10_000), 1_000), 120_000);
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const init: RequestInit = { method: opts.method, redirect: "manual", credentials: "omit", signal: controller.signal, headers: opts.headers ?? {}, ...(opts.body === undefined ? {} : { body: opts.body }) };
return { url, init, controller, timeout };
}
type NetworkDiagnostics = { logger?: PluginRuntimeLogger; pluginId?: string; route?: string };
export async function safeHttpFetch(urlText: string, options: ValidatedNetOptions | unknown, allowedHosts: Set<string>, diagnostics?: NetworkDiagnostics): Promise<SimpleHttpResponse> {
const opts: ValidatedNetOptions = isValidatedNetOptions(options) ? options : { method: "GET", headers: undefined, timeoutMs: undefined };
const started = Date.now();
let host = "";
try { host = new URL(urlText).hostname.toLowerCase(); } catch { host = "invalid"; }
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host, phase: "begin" });
let prepared: Awaited<ReturnType<typeof prepareSafeRequest>>;
try { prepared = await prepareSafeRequest(urlText, opts, allowedHosts); }
catch (error) { logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host, phase: "denied", reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error), durationMs: Date.now() - started }); throw error; }
const { url, init, timeout } = prepared;
try {
const response = await fetch(url, init);
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) throw new Error("Plugin HTTP redirects are not allowed.");
const text = await readCapped(response, quotas.httpResponseBytes);
const headers: Record<string, string> = {};
for (const key of ["content-type", "etag", "last-modified", "retry-after", "x-ratelimit-remaining"]) { const value = response.headers.get(key); if (value) headers[key] = value; }
let json: unknown;
if ((headers["content-type"] ?? "").includes("application/json")) { try { json = JSON.parse(text); } catch { json = undefined; } }
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host: url.hostname, phase: "success", status: response.status, sizeBytes: Buffer.byteLength(text), durationMs: Date.now() - started });
return { status: response.status, ok: response.ok, headers, text, ...(json === undefined ? {} : { json }) };
} catch (error) {
const mapped = error instanceof Error && error.name === "AbortError" ? new Error("Plugin HTTP fetch timed out.") : error;
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host: url.hostname, phase: "fail", reason: mapped instanceof Error ? mapped.message : String(mapped), errorCode: classifyPluginError(mapped), durationMs: Date.now() - started });
if (mapped instanceof Error) throw mapped;
throw error;
} finally { clearTimeout(timeout); }
}
export async function safeHttpStream(urlText: string, opts: ValidatedNetOptions, allowedHosts: Set<string>, onChunk: (chunk: string) => void, diagnostics?: NetworkDiagnostics): Promise<{ status: number; ok: boolean }> {
const started = Date.now();
let host = "";
try { host = new URL(urlText).hostname.toLowerCase(); } catch { host = "invalid"; }
let prepared: Awaited<ReturnType<typeof prepareSafeRequest>>;
try { prepared = await prepareSafeRequest(urlText, { ...opts, timeoutMs: opts.timeoutMs ?? 120_000 }, allowedHosts); }
catch (error) { logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.stream", method: opts.method, host, phase: "denied", reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error), durationMs: Date.now() - started }); throw error; }
const { url, init, timeout } = prepared;
try {
const response = await fetch(url, init);
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) throw new Error("Plugin HTTP redirects are not allowed.");
const reader = response.body?.getReader();
if (reader) {
const decoder = new TextDecoder();
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > quotas.streamResponseBytes) { await reader.cancel().catch(() => undefined); throw new Error("Plugin HTTP stream is too large."); }
const chunk = decoder.decode(value, { stream: true });
if (chunk.length > 0) onChunk(chunk);
}
const tail = decoder.decode();
if (tail.length > 0) onChunk(tail);
}
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.stream", method: opts.method, host: url.hostname, phase: "success", status: response.status, durationMs: Date.now() - started });
return { status: response.status, ok: response.ok };
} catch (error) {
const mapped = error instanceof Error && error.name === "AbortError" ? new Error("Plugin HTTP stream timed out.") : error;
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.stream", method: opts.method, host: url.hostname, phase: "fail", reason: mapped instanceof Error ? mapped.message : String(mapped), errorCode: classifyPluginError(mapped), durationMs: Date.now() - started });
if (mapped instanceof Error) throw mapped;
throw error;
} finally { clearTimeout(timeout); }
}
function isValidatedNetOptions(value: unknown): value is ValidatedNetOptions { return isRecord(value) && typeof value.method === "string"; }
async function readCapped(response: Response, cap: number): Promise<string> { const reader = response.body?.getReader(); if (!reader) return ""; const chunks: Uint8Array[] = []; let total = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > cap) throw new Error("Plugin HTTP response is too large."); chunks.push(value); } return Buffer.concat(chunks).toString("utf8"); }
export async function assertPublicHost(host: string): Promise<void> { if (["localhost", "metadata.google.internal"].includes(host) || host.endsWith(".localhost")) throw new Error("Plugin HTTP host is not public."); const results = await lookup(host, { all: true, verbatim: true }); if (results.length === 0 || results.some((r) => isPrivateIp(r.address))) throw new Error("Plugin HTTP host resolves to a restricted address."); }
export function isPrivateIp(address: string): boolean { if (net.isIPv4(address)) { const p = address.split(".").map(Number); return p[0] === 10 || p[0] === 127 || p[0] === 0 || (p[0] === 169 && p[1] === 254) || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168) || (p[0] === 100 && p[1] >= 64 && p[1] <= 127); } const v = address.toLowerCase(); return v === "::1" || v === "::" || v.startsWith("fc") || v.startsWith("fd") || v.startsWith("fe80:") || v.startsWith("::ffff:127.") || v.startsWith("::ffff:10.") || v.startsWith("::ffff:192.168."); }
// ---------------------------------------------------------------------------
// Validators
// ---------------------------------------------------------------------------
function check(ok: boolean, message: string): void { if (!ok) throw new Error(message); }
function clampNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.min(Math.max(value, min), max); }
function validateCssColor(value: unknown, message: string): string { const color = String(value).trim(); check(color.length <= 48 && safeCssColorPattern.test(color), message); return color; }
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 validatePetHandleId(value: unknown): string { const id = String(value); if (!/^[A-Za-z0-9._:-]{1,128}$/.test(id)) throw new Error("Invalid familiar handle id."); return id; }
function validateReactOptions(value: unknown): PluginReactOptions | undefined { if (value === undefined) return undefined; if (!isRecord(value)) throw new Error("Invalid familiar reaction options."); const keys = Object.keys(value); check(keys.every((key) => key === "showMessage"), "Invalid familiar reaction option."); if (value.showMessage !== undefined && typeof value.showMessage !== "boolean") throw new Error("Invalid familiar reaction showMessage option."); return value.showMessage === undefined ? {} : { showMessage: value.showMessage }; }
function validatePoint(value: unknown): { x: number; y: number } { if (!isRecord(value)) throw new Error("Invalid point."); const x = Number(value.x); const y = Number(value.y); if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Invalid point."); return { x, y }; }
function validateMoveToOptions(value: unknown): { durationMs?: number; easing?: string } { const opts = isRecord(value) ? value : {}; const durationMs = opts.durationMs === undefined ? undefined : clampNumber(Number(opts.durationMs), 100, 10_000); const easing = opts.easing === undefined ? undefined : (check(["linear", "ease-in", "ease-out", "ease-in-out"].includes(String(opts.easing)), "Invalid easing."), String(opts.easing)); return { durationMs, easing }; }
/** Relaxed screen for model-generated speech (§13.1): longer cap, secrets stripped. */
export function validateDynamicText(value: string): string {
const text = value.replace(/[\0-\x08\x0B\x0C\x0E-\x1F]/g, "").trim();
check(text.length >= 1, "Dynamic speech cannot be empty.");
check(text.length <= quotas.dynamicTextChars, "Dynamic speech is too long.");
// Strip obvious secret material even in dynamic mode.
return text
.replace(/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, "[redacted]")
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[redacted]")
.replace(/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g, "[redacted]")
.replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]");
}
export function validatePinnedBubbleText(value: string): string {
const text = value.trim().replace(/\r\n?/g, "\n");
check(text.length >= 1, "Pinned bubble text cannot be empty.");
check(text.length <= 140, "Pinned bubble text is too long.");
const lines = text.split("\n");
check(lines.length <= 4, "Pinned bubble text has too many lines.");
check(lines.every((line) => line.trim().length > 0), "Pinned bubble text cannot contain blank lines.");
return lines.map((line) => validateSayMessage(line)).join("\n");
}
/** Static (non-dynamic) bubble markdown still gets the ambient content screen. */
function screenStaticBubbleText(markdown: string): void {
check(!/```|<script|function\s+\w+\(|\b(import|export)\s/.test(markdown), "Bubble markdown looks like code.");
check(!/https?:\/\/|www\./.test(markdown), "Bubble markdown contains a URL.");
check(!/(api[_-]?key|secret|password|BEGIN [A-Z ]+PRIVATE KEY)/i.test(markdown), "Bubble markdown looks secret-like.");
}
/**
* Limited markdown -> safe HTML. Everything is HTML-escaped first; only
* bold/italic/inline-code/line-break syntax is re-introduced as markup.
*/
export function renderLimitedMarkdown(markdown: string): string {
const escaped = markdown
.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
return escaped
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
.replace(/\n/g, "<br>");
}
function validateBubbleActions(value: unknown): PluginBubbleAction[] {
check(Array.isArray(value) && value.length >= 1 && value.length <= 4, "Bubble actions must be 1-4 entries.");
const seen = new Set<string>();
return (value as unknown[]).map((entry) => {
if (!isRecord(entry) || typeof entry.id !== "string" || !commandIdPattern.test(entry.id) || seen.has(entry.id)) throw new Error("Invalid bubble action id.");
seen.add(entry.id);
if (typeof entry.label !== "string" || entry.label.trim() === "" || entry.label.length > 32) throw new Error("Invalid bubble action label.");
const style = entry.style === undefined ? "default" : String(entry.style);
check(["default", "primary", "danger"].includes(style), "Invalid bubble action style.");
const iconName = entry.icon === undefined ? undefined : (check(typeof entry.icon === "string" && namedHostIcons.has(entry.icon), "Invalid bubble action icon."), String(entry.icon));
return { id: entry.id, label: entry.label, style: style as PluginBubbleAction["style"], iconName, dismissesBubble: entry.dismissesBubble !== false };
});
}
function validateBubbleInput(value: unknown): PluginBubbleInput {
if (!isRecord(value) || typeof value.id !== "string" || !commandIdPattern.test(value.id)) throw new Error("Invalid bubble input id.");
const type = String(value.type);
check(["text", "number", "select"].includes(type), "Invalid bubble input type.");
const out: PluginBubbleInput = { id: value.id, type: type as PluginBubbleInput["type"] };
if (value.placeholder !== undefined) { check(typeof value.placeholder === "string" && value.placeholder.length <= 60, "Invalid bubble input placeholder."); out.placeholder = String(value.placeholder); }
if (value.submitLabel !== undefined) { check(typeof value.submitLabel === "string" && value.submitLabel.length <= 24 && value.submitLabel.trim() !== "", "Invalid bubble input submitLabel."); out.submitLabel = String(value.submitLabel); }
if (value.default !== undefined) { check(typeof value.default === "string" ? value.default.length <= 200 : Number.isFinite(Number(value.default)), "Invalid bubble input default."); out.default = typeof value.default === "string" ? value.default : Number(value.default); }
if (type === "select") {
check(Array.isArray(value.options) && value.options.length >= 1 && value.options.length <= 8, "Bubble select inputs need 1-8 options.");
out.options = (value.options as unknown[]).map((option) => { if (!isRecord(option) || typeof option.value !== "string" || option.value.length > 80 || typeof option.label !== "string" || option.label.length > 60) throw new Error("Invalid bubble input option."); return { value: option.value, label: option.label }; });
}
return out;
}
function validateMenuItems(value: unknown): PluginMenuItem[] {
check(Array.isArray(value) && value.length <= quotas.menuItems, "Invalid plugin menu items.");
const seen = new Set<string>();
return (value as unknown[]).map((entry) => {
if (!isRecord(entry) || typeof entry.id !== "string" || !commandIdPattern.test(entry.id) || seen.has(entry.id)) throw new Error("Invalid plugin menu item id.");
seen.add(entry.id);
if (typeof entry.title !== "string" || entry.title.trim() === "" || entry.title.length > 80) throw new Error("Invalid plugin menu item title.");
return { id: entry.id, title: entry.title, enabled: entry.enabled === false ? false : undefined, checked: entry.checked === true ? true : undefined };
});
}
function validateAiRequest(value: unknown): PluginAiRequest {
if (!isRecord(value) || !Array.isArray(value.messages)) throw new Error("Invalid AI request.");
check(value.messages.length >= 1 && value.messages.length <= 64, "AI request needs 1-64 messages.");
const messages = (value.messages as unknown[]).map((entry) => {
if (!isRecord(entry) || (entry.role !== "user" && entry.role !== "assistant") || typeof entry.content !== "string") throw new Error("Invalid AI message.");
check(entry.content.length <= 32 * 1024, "AI message content is too long.");
return { role: entry.role as "user" | "assistant", content: entry.content };
});
const out: PluginAiRequest = { messages };
if (value.system !== undefined) { check(typeof value.system === "string" && value.system.length <= 32 * 1024, "Invalid AI system prompt."); out.system = String(value.system); }
if (value.maxTokens !== undefined) out.maxTokens = clampNumber(Number(value.maxTokens), 1, 8192);
if (value.temperature !== undefined) out.temperature = clampNumber(Number(value.temperature), 0, 2);
if (value.tools !== undefined) {
check(Array.isArray(value.tools) && value.tools.length <= 16, "AI request allows at most 16 tools.");
out.tools = (value.tools as unknown[]).map((tool) => {
if (!isRecord(tool) || typeof tool.name !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(tool.name) || !isRecord(tool.inputSchema)) throw new Error("Invalid AI tool definition.");
const description = tool.description === undefined ? undefined : String(tool.description).slice(0, 1024);
return { name: tool.name, description, inputSchema: normalizeJson(tool.inputSchema, 16 * 1024, "AI tool schema") as Record<string, unknown> };
});
}
return out;
}
function validateOauthConfig(value: unknown): { provider: string; authorizationUrl: string; tokenUrl: string; clientId: string; scopes: string[]; pkce: boolean; redirect: "loopback" | "appProtocol" } {
if (!isRecord(value)) throw new Error("Invalid OAuth config.");
const authorizationUrl = new URL(String(value.authorizationUrl));
const tokenUrl = new URL(String(value.tokenUrl));
check(authorizationUrl.protocol === "https:" && tokenUrl.protocol === "https:", "OAuth URLs must be HTTPS.");
check(!authorizationUrl.username && !tokenUrl.username, "OAuth URLs must not carry credentials.");
const clientId = String(value.clientId ?? "");
check(clientId.length >= 1 && clientId.length <= 512 && !/[\s\0]/.test(clientId), "Invalid OAuth clientId.");
check(Array.isArray(value.scopes) && value.scopes.length <= 32, "Invalid OAuth scopes.");
const scopes = (value.scopes as unknown[]).map((scope) => { const text = String(scope); check(text.length >= 1 && text.length <= 256 && !/[\r\n\0]/.test(text), "Invalid OAuth scope."); return text; });
const provider = value.provider === undefined ? "generic" : validateProviderName(value.provider);
const redirect = value.redirect === undefined ? "loopback" : String(value.redirect);
check(redirect === "loopback" || redirect === "appProtocol", "Invalid OAuth redirect mode.");
return { provider, authorizationUrl: authorizationUrl.toString(), tokenUrl: tokenUrl.toString(), clientId, scopes, pkce: value.pkce !== false, redirect: redirect as "loopback" | "appProtocol" };
}
function validateProviderName(value: unknown): string { const provider = String(value); check(/^[a-z0-9][a-z0-9._-]{0,63}$/.test(provider), "Invalid OAuth provider name."); return provider; }
/** Clone-safe JSON normalization with a byte cap. */
export function normalizeJson(value: unknown, maxBytes: number, label: string): unknown {
let text: string;
try { text = JSON.stringify(value ?? null); } catch { throw new Error(`Plugin ${label} must be JSON-compatible.`); }
if (text === undefined) throw new Error(`Plugin ${label} must be JSON-compatible.`);
check(Buffer.byteLength(text) <= maxBytes, `Plugin ${label} is too large.`);
return JSON.parse(text) as unknown;
}
function validateCommand(command: PluginCommand, validateIconAssetRef: (ref: unknown) => { kind: PluginAssetKind; name: string; path: string }): 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.");
const placement = command.placement === undefined ? undefined : (check(command.placement === "top" || command.placement === "submenu", "Invalid plugin command placement."), command.placement);
const priority = command.priority === undefined ? undefined : (check(Number.isFinite(Number(command.priority)), "Invalid plugin command priority."), clampNumber(Number(command.priority), -1000, 1000));
const icon = command.icon === undefined ? undefined : validateCommandIcon(command.icon, validateIconAssetRef);
return { id: command.id, title: command.title, description: command.description, form: validateCommandForm(command.form), placement, priority, featured: command.featured === true || undefined, icon };
}
function validateCommandIcon(icon: unknown, validateIconAssetRef: (ref: unknown) => { kind: PluginAssetKind; name: string; path: string }): PluginCommandIcon {
if (typeof icon === "string") {
check(namedHostIcons.has(icon), "Invalid plugin command icon.");
return icon;
}
if (!isRecord(icon) || icon.kind !== "icon" || typeof icon.name !== "string") throw new Error("Invalid plugin command icon.");
validateIconAssetRef(icon);
return { kind: "icon", name: icon.name };
}
const commandFormFieldTypes = new Set(["text", "textarea", "number", "boolean", "select", "multiSelect", "time", "date", "list"]);
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 (!commandFormFieldTypes.has(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 (out.type === "boolean") {
if (field.default !== undefined && typeof field.default !== "boolean") throw new Error("Invalid plugin command form default.");
if (field.default !== undefined) out.default = field.default;
} else if (out.type === "select" || out.type === "multiSelect") {
if (!Array.isArray(field.options) || field.options.length < 1 || field.options.length > 24) throw new Error("Invalid plugin command form options.");
const values = new Set<string>();
out.options = field.options.map((option) => {
if (!isRecord(option) || typeof option.value !== "string" || option.value.length > 120 || typeof option.label !== "string" || option.label.trim() === "" || option.label.length > 80 || values.has(option.value)) throw new Error("Invalid plugin command form option.");
values.add(option.value);
return { label: option.label, value: option.value };
});
if (field.default !== undefined) {
if (out.type === "multiSelect") { if (!Array.isArray(field.default) || field.default.some((entry) => typeof entry !== "string" || !values.has(entry))) throw new Error("Invalid plugin command form default."); out.default = field.default as string[]; }
else { if (typeof field.default !== "string" || !values.has(field.default)) throw new Error("Invalid plugin command form default."); out.default = field.default; }
}
} else if (out.type === "time") {
if (field.default !== undefined && (typeof field.default !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(field.default))) throw new Error("Invalid plugin command form default.");
if (field.default !== undefined) out.default = field.default;
} else if (out.type === "date") {
if (field.default !== undefined && (typeof field.default !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(field.default))) throw new Error("Invalid plugin command form default.");
if (field.default !== undefined) out.default = field.default;
} else if (out.type === "list") {
if (field.default !== undefined && (!Array.isArray(field.default) || field.default.some((entry) => typeof entry !== "string" || entry.length > 200) || field.default.length > 32)) throw new Error("Invalid plugin command form default.");
if (field.default !== undefined) out.default = field.default as string[];
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.maxLength !== undefined) out.maxLength = Number(field.maxLength);
} 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 };
}
export 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) {
const raw = input[field.id];
if (field.type === "number") {
const value = Number(raw ?? field.default ?? 0);
if (!Number.isFinite(value)) throw new Error(`${field.label} must be a number.`);
if (field.min !== undefined && value < field.min) throw new Error(`${field.label} is too small.`);
if (field.max !== undefined && value > field.max) throw new Error(`${field.label} is too large.`);
out[field.id] = value;
} else if (field.type === "boolean") {
out[field.id] = raw === undefined ? field.default === true : raw === true || raw === "true";
} else if (field.type === "select") {
const value = String(raw ?? field.default ?? "");
if (field.required && !value) throw new Error(`${field.label} is required.`);
if (value && !(field.options ?? []).some((option) => option.value === value)) throw new Error(`${field.label} has an invalid value.`);
out[field.id] = value;
} else if (field.type === "multiSelect") {
const values = Array.isArray(raw) ? raw.map(String) : Array.isArray(field.default) ? field.default : [];
const allowed = new Set((field.options ?? []).map((option) => option.value));
if (values.some((value) => !allowed.has(value))) throw new Error(`${field.label} has an invalid value.`);
out[field.id] = values;
} else if (field.type === "time") {
const value = String(raw ?? field.default ?? "").trim();
if (field.required && !value) throw new Error(`${field.label} is required.`);
if (value && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error(`${field.label} must be HH:mm.`);
out[field.id] = value;
} else if (field.type === "date") {
const value = String(raw ?? field.default ?? "").trim();
if (field.required && !value) throw new Error(`${field.label} is required.`);
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error(`${field.label} must be YYYY-MM-DD.`);
out[field.id] = value;
} else if (field.type === "list") {
const values = Array.isArray(raw) ? raw.map((entry) => String(entry).trim()).filter(Boolean) : Array.isArray(field.default) ? field.default : [];
if (values.length > 32) throw new Error(`${field.label} has too many entries.`);
if (field.maxLength !== undefined && values.some((value) => value.length > field.maxLength!)) throw new Error(`${field.label} entries are too long.`);
out[field.id] = values;
} else {
const text = String(raw ?? 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 familiar movement options."); const x = Number(value.x); const y = Number(value.y); if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Invalid familiar 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) }; }
function parseDaily(spec: string | { time: string; days?: number[] }): { time: string; days?: number[] } { const value = typeof spec === "string" ? { time: spec } : spec; const m = /^(\d{2}):(\d{2})$/.exec(value.time); if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) throw new Error("Daily schedule time must be HH:mm between 00:00 and 23:59."); if (value.days && (!Array.isArray(value.days) || value.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) throw new Error("Daily schedule days must be weekdays 0-6."); return value; }
function msUntilDaily(spec: { time: string; days?: number[] }): number { const [hour, minute] = spec.time.split(":").map(Number); const now = new Date(); for (let add = 0; add <= 7; add += 1) { const next = new Date(now); next.setDate(now.getDate() + add); next.setHours(hour ?? 0, minute ?? 0, 0, 0); if (next > now && (!spec.days || spec.days.includes(next.getDay()))) return next.getTime() - now.getTime(); } return 24 * 60 * 60 * 1000; }
@ -1270,69 +824,5 @@ function nextScheduleDelayMs(spec: ScheduleSpec): number | null {
return next === null ? null : Math.max(1_000, next - Date.now());
}
// ---------------------------------------------------------------------------
// 5-field cron (m h dom mon dow)
// ---------------------------------------------------------------------------
type CronField = Set<number>;
type ParsedCron = { minutes: CronField; hours: CronField; daysOfMonth: CronField; months: CronField; daysOfWeek: CronField; domWildcard: boolean; dowWildcard: boolean };
export function parseCronExpression(expr: string): ParsedCron {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) throw new Error("Cron expressions must have 5 fields (m h dom mon dow).");
const [minutePart, hourPart, domPart, monthPart, dowPart] = parts as [string, string, string, string, string];
return {
minutes: parseCronField(minutePart, 0, 59),
hours: parseCronField(hourPart, 0, 23),
daysOfMonth: parseCronField(domPart, 1, 31),
months: parseCronField(monthPart, 1, 12),
daysOfWeek: parseCronField(dowPart, 0, 7, true),
domWildcard: domPart === "*",
dowWildcard: dowPart === "*",
};
}
function parseCronField(field: string, min: number, max: number, mapSevenToZero = false): CronField {
const values = new Set<number>();
if (field.length === 0 || field.length > 64) throw new Error("Invalid cron field.");
for (const part of field.split(",")) {
const stepMatch = /^(.+)\/(\d+)$/.exec(part);
const base = stepMatch ? stepMatch[1]! : part;
const step = stepMatch ? Number(stepMatch[2]) : 1;
if (!Number.isInteger(step) || step < 1 || step > max) throw new Error("Invalid cron step.");
let start = min; let end = max;
if (base !== "*") {
const rangeMatch = /^(\d+)-(\d+)$/.exec(base);
if (rangeMatch) { start = Number(rangeMatch[1]); end = Number(rangeMatch[2]); }
else { if (!/^\d+$/.test(base)) throw new Error("Invalid cron value."); start = Number(base); end = stepMatch ? max : start; }
}
if (start < min || end > max || start > end) throw new Error("Cron value out of range.");
for (let value = start; value <= end; value += step) values.add(mapSevenToZero && value === 7 ? 0 : value);
}
if (values.size === 0) throw new Error("Invalid cron field.");
return values;
}
/** Next run strictly after `fromMs`, or null if none within 4 years. */
export function nextCronRunMs(expr: string, fromMs: number): number | null {
const cron = parseCronExpression(expr);
const candidate = new Date(fromMs);
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
const limit = fromMs + 4 * 366 * 24 * 60 * 60 * 1000;
while (candidate.getTime() <= limit) {
if (!cron.months.has(candidate.getMonth() + 1)) { candidate.setMonth(candidate.getMonth() + 1, 1); candidate.setHours(0, 0, 0, 0); continue; }
const domMatch = cron.daysOfMonth.has(candidate.getDate());
const dowMatch = cron.daysOfWeek.has(candidate.getDay());
const dayMatch = cron.domWildcard && cron.dowWildcard ? true : cron.domWildcard ? dowMatch : cron.dowWildcard ? domMatch : domMatch || dowMatch;
if (!dayMatch) { candidate.setDate(candidate.getDate() + 1); candidate.setHours(0, 0, 0, 0); continue; }
if (!cron.hours.has(candidate.getHours())) { candidate.setHours(candidate.getHours() + 1, 0, 0, 0); continue; }
if (!cron.minutes.has(candidate.getMinutes())) { candidate.setMinutes(candidate.getMinutes() + 1, 0, 0); continue; }
return candidate.getTime();
}
return null;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error("Plugin command timed out.")), timeoutMs); promise.then((v) => { clearTimeout(timeout); resolve(v); }, (e) => { clearTimeout(timeout); reject(e); }); }); }
function safeError(error: unknown): string { return error instanceof Error ? error.message : "Plugin SDK callback failed."; }
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }

View file

@ -0,0 +1,5 @@
export type { PluginConfig } from "./plugin-config.js";
export type { PluginPetApi } from "./plugin-familiar-api.js";
export type { FamiliarOSJavascriptPluginManifest, PluginAssetKind, PluginPermission } from "./plugin-manifest.js";
export type { PluginRuntimeScheduler } from "./plugin-runtime.js";
export type { PluginStateRecord, PluginStateStore } from "./plugin-state.js";

View file

@ -0,0 +1,115 @@
import type { PluginHostCapabilities } from "./plugin-sdk-bridge.js";
import type { PluginPetApi } from "./plugin-familiar-api.js";
export function createDefaultPluginHostCapabilities(petApi: PluginPetApi): PluginHostCapabilities {
const unavailable = (feature: string) => async (): Promise<never> => {
throw new Error(`Plugin host capability is unavailable: ${feature}`);
};
return {
bubbles: {
async show({ bubble, callbacks }) {
if (bubble.text) await petApi.speak(bubble.text);
let dismissed = false;
return {
id: `bubble-${Math.random().toString(36).slice(2)}`,
update: async () => undefined,
dismiss: async () => {
if (!dismissed) {
dismissed = true;
callbacks.onDismiss("manual");
}
},
pin: async () => undefined,
unpin: async () => {
if (!dismissed) {
dismissed = true;
callbacks.onDismiss("unpinned");
}
},
};
},
},
audio: {
play: async () => undefined,
importUserSound: async (_pluginId, _fileId, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }),
importUserSoundFromPath: async (_pluginId, _path, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }),
forgetUserSound: async () => undefined,
stop: async () => undefined,
},
events: { subscribe: () => () => undefined },
familiars: {
list: () => [{ id: "default", name: "Default familiar", kind: "default", visible: true }],
spawn: unavailable("familiars.spawn"),
close: unavailable("familiars.close"),
show: async () => undefined,
hide: async () => undefined,
react: async (_petId, reaction, options) => {
await petApi.react(reaction, options);
},
setAnimation: async (_petId, spec) => {
if (spec.kind === "reaction") await petApi.react(spec.reaction);
},
setScale: async () => undefined,
setStatusReaction: async () => undefined,
moveBy: async (_petId, opts) => {
await petApi.moveBy(opts);
},
wander: async (_petId, opts) => {
await petApi.wander(opts);
},
moveToHome: async () => {
await petApi.moveToHome();
},
moveTo: unavailable("familiars.moveTo"),
followCursor: async () => undefined,
physics: async () => undefined,
getState: async () => ({
position: { x: 0, y: 0 },
bounds: { x: 0, y: 0, width: 0, height: 0 },
currentAnimation: "idle",
visible: true,
dragging: false,
}),
onTick: () => () => undefined,
onChange: () => () => undefined,
},
toast: async () => undefined,
notify: async () => undefined,
panels: { open: unavailable("panels.open") },
secrets: (() => {
const store = new Map<string, string>();
return {
get: async (pluginId: string, key: string) => store.get(`${pluginId}\0${key}`),
set: async (pluginId: string, key: string, value: string) => void store.set(`${pluginId}\0${key}`, value),
delete: async (pluginId: string, key: string) => void store.delete(`${pluginId}\0${key}`),
has: async (pluginId: string, key: string) => store.has(`${pluginId}\0${key}`),
};
})(),
ai: { available: async () => false, complete: unavailable("ai.complete"), stream: unavailable("ai.stream") },
voice: { speak: unavailable("voice.speak"), listen: unavailable("voice.listen") },
auth: { oauth: unavailable("auth.oauth"), refresh: unavailable("auth.refresh"), signOut: async () => undefined },
files: { pick: async () => [], read: unavailable("files.read"), save: unavailable("files.save") },
system: {
info: async () => ({
platform: process.platform === "darwin" ? "mac" : process.platform === "win32" ? "win" : "linux",
locale: "en-US",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
theme: "light",
appVersion: "0.0.0",
online: true,
}),
metrics: async () => ({ cpuPercent: 0, memUsedPercent: 0 }),
openExternal: unavailable("system.openExternal"),
readClipboardText: unavailable("system.readClipboardText"),
writeClipboardText: unavailable("system.writeClipboardText"),
},
settings: {
audioAllowed: () => true,
dynamicSpeechAllowed: () => false,
voiceAllowed: () => true,
listenAllowed: () => false,
inQuietHours: () => false,
},
};
}

View file

@ -0,0 +1,324 @@
import { lookup } from "node:dns/promises";
import * as net from "node:net";
import type { PluginRuntimeLogger } from "./plugin-sdk-bridge.js";
import { classifyPluginError, logPluginDiagnostic } from "./plugin-diagnostics.js";
import type { FamiliarOSJavascriptPluginManifest, PluginPermission } from "./plugin-manifest.js";
import { pluginSdkQuotas } from "./plugin-sdk-quotas.js";
import type { PluginStateRecord } from "./plugin-state.js";
export type SimpleHttpResponse = {
status: number;
ok: boolean;
headers: Record<string, string>;
text: string;
json?: unknown;
};
export type ValidatedNetOptions = {
method: string;
headers?: Record<string, string>;
body?: string;
timeoutMs?: number;
};
export type NetworkDiagnostics = {
logger?: PluginRuntimeLogger;
pluginId?: string;
route?: string;
};
const quotas = pluginSdkQuotas;
const forbiddenHeaderNames = new Set([
"host",
"cookie",
"cookie2",
"origin",
"referer",
"content-length",
"connection",
"transfer-encoding",
"upgrade",
"keep-alive",
"te",
"trailer",
"expect",
"via",
]);
export function validateNetOptions(options: unknown, approved: ReadonlySet<PluginPermission>): ValidatedNetOptions {
const opts = isRecord(options) ? options : {};
const method = String(opts.method ?? "GET").toUpperCase();
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) throw new Error("Plugin HTTP method is not allowed.");
if (method !== "GET" && !approved.has("network:write")) throw new Error("Plugin permission is not approved: network:write");
let body: string | undefined;
if (opts.body !== undefined) {
if (method === "GET") throw new Error("Plugin GET requests must not have a body.");
body = String(opts.body);
if (Buffer.byteLength(body) > quotas.httpRequestBodyBytes) throw new Error("Plugin HTTP request body is too large.");
}
return {
method,
headers: safeNetHeaders(opts.headers),
body,
timeoutMs: opts.timeoutMs === undefined ? undefined : Number(opts.timeoutMs),
};
}
export function allowedNetworkHosts(record: PluginStateRecord, manifest: FamiliarOSJavascriptPluginManifest): Set<string> {
const manifestHosts = new Set((manifest.network?.hosts ?? []).map((host) => host.toLowerCase()));
const approved = record.approvedNetworkHosts?.map((host) => host.toLowerCase()) ?? [];
return new Set(approved.filter((host) => manifestHosts.has(host)));
}
export async function safeHttpFetch(urlText: string, options: ValidatedNetOptions | unknown, allowedHosts: Set<string>, diagnostics?: NetworkDiagnostics): Promise<SimpleHttpResponse> {
const opts: ValidatedNetOptions = isValidatedNetOptions(options) ? options : { method: "GET", headers: undefined, timeoutMs: undefined };
const started = Date.now();
let host = "";
try {
host = new URL(urlText).hostname.toLowerCase();
} catch {
host = "invalid";
}
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", {
pluginId: diagnostics?.pluginId,
route: diagnostics?.route ?? "net.fetch",
method: opts.method,
host,
phase: "begin",
});
let prepared: Awaited<ReturnType<typeof prepareSafeRequest>>;
try {
prepared = await prepareSafeRequest(urlText, opts, allowedHosts);
} catch (error) {
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", {
pluginId: diagnostics?.pluginId,
route: diagnostics?.route ?? "net.fetch",
method: opts.method,
host,
phase: "denied",
reason: error instanceof Error ? error.message : String(error),
errorCode: classifyPluginError(error),
durationMs: Date.now() - started,
});
throw error;
}
const { url, init, timeout } = prepared;
try {
const response = await fetch(url, init);
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
throw new Error("Plugin HTTP redirects are not allowed.");
}
const text = await readCapped(response, quotas.httpResponseBytes);
const headers: Record<string, string> = {};
for (const key of ["content-type", "etag", "last-modified", "retry-after", "x-ratelimit-remaining"]) {
const value = response.headers.get(key);
if (value) headers[key] = value;
}
let json: unknown;
if ((headers["content-type"] ?? "").includes("application/json")) {
try {
json = JSON.parse(text);
} catch {
json = undefined;
}
}
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", {
pluginId: diagnostics?.pluginId,
route: diagnostics?.route ?? "net.fetch",
method: opts.method,
host: url.hostname,
phase: "success",
status: response.status,
sizeBytes: Buffer.byteLength(text),
durationMs: Date.now() - started,
});
return { status: response.status, ok: response.ok, headers, text, ...(json === undefined ? {} : { json }) };
} catch (error) {
const mapped = error instanceof Error && error.name === "AbortError" ? new Error("Plugin HTTP fetch timed out.") : error;
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", {
pluginId: diagnostics?.pluginId,
route: diagnostics?.route ?? "net.fetch",
method: opts.method,
host: url.hostname,
phase: "fail",
reason: mapped instanceof Error ? mapped.message : String(mapped),
errorCode: classifyPluginError(mapped),
durationMs: Date.now() - started,
});
if (mapped instanceof Error) throw mapped;
throw error;
} finally {
clearTimeout(timeout);
}
}
export async function safeHttpStream(urlText: string, opts: ValidatedNetOptions, allowedHosts: Set<string>, onChunk: (chunk: string) => void, diagnostics?: NetworkDiagnostics): Promise<{ status: number; ok: boolean }> {
const started = Date.now();
let host = "";
try {
host = new URL(urlText).hostname.toLowerCase();
} catch {
host = "invalid";
}
let prepared: Awaited<ReturnType<typeof prepareSafeRequest>>;
try {
prepared = await prepareSafeRequest(urlText, { ...opts, timeoutMs: opts.timeoutMs ?? 120_000 }, allowedHosts);
} catch (error) {
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", {
pluginId: diagnostics?.pluginId,
route: diagnostics?.route ?? "net.stream",
method: opts.method,
host,
phase: "denied",
reason: error instanceof Error ? error.message : String(error),
errorCode: classifyPluginError(error),
durationMs: Date.now() - started,
});
throw error;
}
const { url, init, timeout } = prepared;
try {
const response = await fetch(url, init);
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
throw new Error("Plugin HTTP redirects are not allowed.");
}
const reader = response.body?.getReader();
if (reader) {
const decoder = new TextDecoder();
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > quotas.streamResponseBytes) {
await reader.cancel().catch(() => undefined);
throw new Error("Plugin HTTP stream is too large.");
}
const chunk = decoder.decode(value, { stream: true });
if (chunk.length > 0) onChunk(chunk);
}
const tail = decoder.decode();
if (tail.length > 0) onChunk(tail);
}
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", {
pluginId: diagnostics?.pluginId,
route: diagnostics?.route ?? "net.stream",
method: opts.method,
host: url.hostname,
phase: "success",
status: response.status,
durationMs: Date.now() - started,
});
return { status: response.status, ok: response.ok };
} catch (error) {
const mapped = error instanceof Error && error.name === "AbortError" ? new Error("Plugin HTTP stream timed out.") : error;
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", {
pluginId: diagnostics?.pluginId,
route: diagnostics?.route ?? "net.stream",
method: opts.method,
host: url.hostname,
phase: "fail",
reason: mapped instanceof Error ? mapped.message : String(mapped),
errorCode: classifyPluginError(mapped),
durationMs: Date.now() - started,
});
if (mapped instanceof Error) throw mapped;
throw error;
} finally {
clearTimeout(timeout);
}
}
export async function assertPublicHost(host: string): Promise<void> {
if (["localhost", "metadata.google.internal"].includes(host) || host.endsWith(".localhost")) {
throw new Error("Plugin HTTP host is not public.");
}
const results = await lookup(host, { all: true, verbatim: true });
if (results.length === 0 || results.some((result) => isPrivateIp(result.address))) {
throw new Error("Plugin HTTP host resolves to a restricted address.");
}
}
export function isPrivateIp(address: string): boolean {
if (net.isIPv4(address)) {
const parts = address.split(".").map(Number);
return parts[0] === 10
|| parts[0] === 127
|| parts[0] === 0
|| (parts[0] === 169 && parts[1] === 254)
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|| (parts[0] === 192 && parts[1] === 168)
|| (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127);
}
const value = address.toLowerCase();
return value === "::1"
|| value === "::"
|| value.startsWith("fc")
|| value.startsWith("fd")
|| value.startsWith("fe80:")
|| value.startsWith("::ffff:127.")
|| value.startsWith("::ffff:10.")
|| value.startsWith("::ffff:192.168.");
}
function safeNetHeaders(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
const out: Record<string, string> = {};
for (const [name, headerValue] of Object.entries(value).slice(0, 24)) {
const lower = name.toLowerCase();
if (!/^[a-z0-9-]{1,64}$/.test(lower) || forbiddenHeaderNames.has(lower) || lower.startsWith("proxy-") || lower.startsWith("sec-")) continue;
if (typeof headerValue !== "string" || headerValue.length > 4096 || /[\r\n\0]/.test(headerValue)) continue;
out[lower] = headerValue;
}
return out;
}
async function prepareSafeRequest(urlText: string, opts: ValidatedNetOptions, allowedHosts: Set<string>): Promise<{ url: URL; init: RequestInit; controller: AbortController; timeout: NodeJS.Timeout }> {
const url = new URL(urlText);
if (url.protocol !== "https:") throw new Error("Plugin HTTP fetch requires HTTPS.");
if (url.username || url.password) throw new Error("Plugin HTTP fetch credentials are not allowed.");
const host = url.hostname.toLowerCase();
if (!allowedHosts.has(host)) throw new Error("Plugin HTTP host is not approved.");
await assertPublicHost(host);
const controller = new AbortController();
const timeoutMs = Math.min(Math.max(Number(opts.timeoutMs ?? 10_000), 1_000), 120_000);
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const init: RequestInit = {
method: opts.method,
redirect: "manual",
credentials: "omit",
signal: controller.signal,
headers: opts.headers ?? {},
...(opts.body === undefined ? {} : { body: opts.body }),
};
return { url, init, controller, timeout };
}
function isValidatedNetOptions(value: unknown): value is ValidatedNetOptions {
return isRecord(value) && typeof value.method === "string";
}
async function readCapped(response: Response, cap: number): Promise<string> {
const reader = response.body?.getReader();
if (!reader) return "";
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > cap) throw new Error("Plugin HTTP response is too large.");
chunks.push(value);
}
return Buffer.concat(chunks).toString("utf8");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -0,0 +1,5 @@
export { getActiveLocaleLang } from "./i18n/index.js";
export { classifyPluginError } from "./plugin-diagnostics.js";
export { resolveDeclaredAssetPath, resolveDeclaredPanelPath } from "./plugin-assets.js";
export { makePluginT } from "./plugin-i18n.js";
export { validateReaction, validateSayMessage, type FamiliarOSReaction } from "./local-ipc-protocol.js";

View file

@ -0,0 +1,6 @@
export { createPluginAudioApi } from "./plugin-sdk-audio.js";
export { createPluginBusApi, type PluginBusTopicEntry } from "./plugin-sdk-bus.js";
export { createPluginConfigApi } from "./plugin-sdk-config.js";
export { createPluginEventsApi } from "./plugin-sdk-events.js";
export { createPluginStorageApi } from "./plugin-sdk-storage.js";
export { createPluginUiApi } from "./plugin-sdk-ui.js";

View file

@ -0,0 +1,568 @@
import type {
PluginAiRequest,
PluginBubbleAction,
PluginBubbleInput,
PluginCommand,
PluginCommandForm,
PluginCommandFormField,
PluginCommandIcon,
PluginMenuItem,
PluginReactOptions,
PluginStatus,
} from "./plugin-sdk-bridge.js";
import { validateSayMessage } from "./local-ipc-protocol.js";
import type { PluginAssetKind } from "./plugin-manifest.js";
import { pluginSdkQuotas } from "./plugin-sdk-quotas.js";
const quotas = pluginSdkQuotas;
export const commandIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
export const scheduleIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
export const allowedAccents = new Set(["blue", "purple", "green", "amber", "red", "pink", "slate"]);
export const namedHostIcons = new Set(["info", "check", "alert", "heart", "star", "bell", "coffee", "timer", "droplet", "sparkles", "zap", "moon", "sun", "food", "play", "pause"]);
const safeCssColorPattern = /^(#[0-9a-fA-F]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)|hsla?\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\))$/;
const commandFormFieldTypes = new Set(["text", "textarea", "number", "boolean", "select", "multiSelect", "time", "date", "list"]);
type CronField = Set<number>;
type ParsedCron = {
minutes: CronField;
hours: CronField;
daysOfMonth: CronField;
months: CronField;
daysOfWeek: CronField;
domWildcard: boolean;
dowWildcard: boolean;
};
export function check(ok: boolean, message: string): void {
if (!ok) throw new Error(message);
}
export function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(Math.max(value, min), max);
}
export function validateCssColor(value: unknown, message: string): string {
const color = String(value).trim();
check(color.length <= 48 && safeCssColorPattern.test(color), message);
return color;
}
export 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);
}
export function validatePetHandleId(value: unknown): string {
const id = String(value);
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(id)) throw new Error("Invalid familiar handle id.");
return id;
}
export function validateReactOptions(value: unknown): PluginReactOptions | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) throw new Error("Invalid familiar reaction options.");
const keys = Object.keys(value);
check(keys.every((key) => key === "showMessage"), "Invalid familiar reaction option.");
if (value.showMessage !== undefined && typeof value.showMessage !== "boolean") {
throw new Error("Invalid familiar reaction showMessage option.");
}
return value.showMessage === undefined ? {} : { showMessage: value.showMessage };
}
export function validatePoint(value: unknown): { x: number; y: number } {
if (!isRecord(value)) throw new Error("Invalid point.");
const x = Number(value.x);
const y = Number(value.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Invalid point.");
return { x, y };
}
export function validateMoveToOptions(value: unknown): { durationMs?: number; easing?: string } {
const opts = isRecord(value) ? value : {};
const durationMs = opts.durationMs === undefined ? undefined : clampNumber(Number(opts.durationMs), 100, 10_000);
const easing = opts.easing === undefined
? undefined
: (check(["linear", "ease-in", "ease-out", "ease-in-out"].includes(String(opts.easing)), "Invalid easing."), String(opts.easing));
return { durationMs, easing };
}
export function validateDynamicText(value: string): string {
const text = value.replace(/[\0-\x08\x0B\x0C\x0E-\x1F]/g, "").trim();
check(text.length >= 1, "Dynamic speech cannot be empty.");
check(text.length <= quotas.dynamicTextChars, "Dynamic speech is too long.");
return text
.replace(/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, "[redacted]")
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[redacted]")
.replace(/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g, "[redacted]")
.replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]");
}
export function validatePinnedBubbleText(value: string): string {
const text = value.trim().replace(/\r\n?/g, "\n");
check(text.length >= 1, "Pinned bubble text cannot be empty.");
check(text.length <= 140, "Pinned bubble text is too long.");
const lines = text.split("\n");
check(lines.length <= 4, "Pinned bubble text has too many lines.");
check(lines.every((line) => line.trim().length > 0), "Pinned bubble text cannot contain blank lines.");
return lines.map((line) => validateSayMessage(line)).join("\n");
}
export function screenStaticBubbleText(markdown: string): void {
check(!/```|<script|function\s+\w+\(|\b(import|export)\s/.test(markdown), "Bubble markdown looks like code.");
check(!/https?:\/\/|www\./.test(markdown), "Bubble markdown contains a URL.");
check(!/(api[_-]?key|secret|password|BEGIN [A-Z ]+PRIVATE KEY)/i.test(markdown), "Bubble markdown looks secret-like.");
}
export function renderLimitedMarkdown(markdown: string): string {
const escaped = markdown
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
return escaped
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
.replace(/\n/g, "<br>");
}
export function validateBubbleActions(value: unknown): PluginBubbleAction[] {
check(Array.isArray(value) && value.length >= 1 && value.length <= 4, "Bubble actions must be 1-4 entries.");
const seen = new Set<string>();
return (value as unknown[]).map((entry) => {
if (!isRecord(entry) || typeof entry.id !== "string" || !commandIdPattern.test(entry.id) || seen.has(entry.id)) {
throw new Error("Invalid bubble action id.");
}
seen.add(entry.id);
if (typeof entry.label !== "string" || entry.label.trim() === "" || entry.label.length > 32) {
throw new Error("Invalid bubble action label.");
}
const style = entry.style === undefined ? "default" : String(entry.style);
check(["default", "primary", "danger"].includes(style), "Invalid bubble action style.");
const iconName = entry.icon === undefined
? undefined
: (check(typeof entry.icon === "string" && namedHostIcons.has(entry.icon), "Invalid bubble action icon."), String(entry.icon));
return {
id: entry.id,
label: entry.label,
style: style as PluginBubbleAction["style"],
iconName,
dismissesBubble: entry.dismissesBubble !== false,
};
});
}
export function validateBubbleInput(value: unknown): PluginBubbleInput {
if (!isRecord(value) || typeof value.id !== "string" || !commandIdPattern.test(value.id)) {
throw new Error("Invalid bubble input id.");
}
const type = String(value.type);
check(["text", "number", "select"].includes(type), "Invalid bubble input type.");
const out: PluginBubbleInput = { id: value.id, type: type as PluginBubbleInput["type"] };
if (value.placeholder !== undefined) {
check(typeof value.placeholder === "string" && value.placeholder.length <= 60, "Invalid bubble input placeholder.");
out.placeholder = String(value.placeholder);
}
if (value.submitLabel !== undefined) {
check(typeof value.submitLabel === "string" && value.submitLabel.length <= 24 && value.submitLabel.trim() !== "", "Invalid bubble input submitLabel.");
out.submitLabel = String(value.submitLabel);
}
if (value.default !== undefined) {
check(typeof value.default === "string" ? value.default.length <= 200 : Number.isFinite(Number(value.default)), "Invalid bubble input default.");
out.default = typeof value.default === "string" ? value.default : Number(value.default);
}
if (type === "select") {
check(Array.isArray(value.options) && value.options.length >= 1 && value.options.length <= 8, "Bubble select inputs need 1-8 options.");
out.options = (value.options as unknown[]).map((option) => {
if (!isRecord(option) || typeof option.value !== "string" || option.value.length > 80 || typeof option.label !== "string" || option.label.length > 60) {
throw new Error("Invalid bubble input option.");
}
return { value: option.value, label: option.label };
});
}
return out;
}
export function validateMenuItems(value: unknown): PluginMenuItem[] {
check(Array.isArray(value) && value.length <= quotas.menuItems, "Invalid plugin menu items.");
const seen = new Set<string>();
return (value as unknown[]).map((entry) => {
if (!isRecord(entry) || typeof entry.id !== "string" || !commandIdPattern.test(entry.id) || seen.has(entry.id)) {
throw new Error("Invalid plugin menu item id.");
}
seen.add(entry.id);
if (typeof entry.title !== "string" || entry.title.trim() === "" || entry.title.length > 80) {
throw new Error("Invalid plugin menu item title.");
}
return {
id: entry.id,
title: entry.title,
enabled: entry.enabled === false ? false : undefined,
checked: entry.checked === true ? true : undefined,
};
});
}
export function validateAiRequest(value: unknown): PluginAiRequest {
if (!isRecord(value) || !Array.isArray(value.messages)) throw new Error("Invalid AI request.");
check(value.messages.length >= 1 && value.messages.length <= 64, "AI request needs 1-64 messages.");
const messages = (value.messages as unknown[]).map((entry) => {
if (!isRecord(entry) || (entry.role !== "user" && entry.role !== "assistant") || typeof entry.content !== "string") {
throw new Error("Invalid AI message.");
}
check(entry.content.length <= 32 * 1024, "AI message content is too long.");
return { role: entry.role as "user" | "assistant", content: entry.content };
});
const out: PluginAiRequest = { messages };
if (value.system !== undefined) {
check(typeof value.system === "string" && value.system.length <= 32 * 1024, "Invalid AI system prompt.");
out.system = String(value.system);
}
if (value.maxTokens !== undefined) out.maxTokens = clampNumber(Number(value.maxTokens), 1, 8192);
if (value.temperature !== undefined) out.temperature = clampNumber(Number(value.temperature), 0, 2);
if (value.tools !== undefined) {
check(Array.isArray(value.tools) && value.tools.length <= 16, "AI request allows at most 16 tools.");
out.tools = (value.tools as unknown[]).map((tool) => {
if (!isRecord(tool) || typeof tool.name !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(tool.name) || !isRecord(tool.inputSchema)) {
throw new Error("Invalid AI tool definition.");
}
const description = tool.description === undefined ? undefined : String(tool.description).slice(0, 1024);
return {
name: tool.name,
description,
inputSchema: normalizeJson(tool.inputSchema, 16 * 1024, "AI tool schema") as Record<string, unknown>,
};
});
}
return out;
}
export function validateOauthConfig(value: unknown): { provider: string; authorizationUrl: string; tokenUrl: string; clientId: string; scopes: string[]; pkce: boolean; redirect: "loopback" | "appProtocol" } {
if (!isRecord(value)) throw new Error("Invalid OAuth config.");
const authorizationUrl = new URL(String(value.authorizationUrl));
const tokenUrl = new URL(String(value.tokenUrl));
check(authorizationUrl.protocol === "https:" && tokenUrl.protocol === "https:", "OAuth URLs must be HTTPS.");
check(!authorizationUrl.username && !tokenUrl.username, "OAuth URLs must not carry credentials.");
const clientId = String(value.clientId ?? "");
check(clientId.length >= 1 && clientId.length <= 512 && !/[\s\0]/.test(clientId), "Invalid OAuth clientId.");
check(Array.isArray(value.scopes) && value.scopes.length <= 32, "Invalid OAuth scopes.");
const scopes = (value.scopes as unknown[]).map((scope) => {
const text = String(scope);
check(text.length >= 1 && text.length <= 256 && !/[\r\n\0]/.test(text), "Invalid OAuth scope.");
return text;
});
const provider = value.provider === undefined ? "generic" : validateProviderName(value.provider);
const redirect = value.redirect === undefined ? "loopback" : String(value.redirect);
check(redirect === "loopback" || redirect === "appProtocol", "Invalid OAuth redirect mode.");
return {
provider,
authorizationUrl: authorizationUrl.toString(),
tokenUrl: tokenUrl.toString(),
clientId,
scopes,
pkce: value.pkce !== false,
redirect: redirect as "loopback" | "appProtocol",
};
}
export function normalizeJson(value: unknown, maxBytes: number, label: string): unknown {
let text: string;
try {
text = JSON.stringify(value ?? null);
} catch {
throw new Error(`Plugin ${label} must be JSON-compatible.`);
}
if (text === undefined) throw new Error(`Plugin ${label} must be JSON-compatible.`);
check(Buffer.byteLength(text) <= maxBytes, `Plugin ${label} is too large.`);
return JSON.parse(text) as unknown;
}
export function validateCommand(command: PluginCommand, validateIconAssetRef: (ref: unknown) => { kind: PluginAssetKind; name: string; path: string }): 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.");
}
const placement = command.placement === undefined
? undefined
: (check(command.placement === "top" || command.placement === "submenu", "Invalid plugin command placement."), command.placement);
const priority = command.priority === undefined
? undefined
: (check(Number.isFinite(Number(command.priority)), "Invalid plugin command priority."), clampNumber(Number(command.priority), -1000, 1000));
const icon = command.icon === undefined ? undefined : validateCommandIcon(command.icon, validateIconAssetRef);
return {
id: command.id,
title: command.title,
description: command.description,
form: validateCommandForm(command.form),
placement,
priority,
featured: command.featured === true || undefined,
icon,
};
}
export 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) {
const raw = input[field.id];
if (field.type === "number") {
const value = Number(raw ?? field.default ?? 0);
if (!Number.isFinite(value)) throw new Error(`${field.label} must be a number.`);
if (field.min !== undefined && value < field.min) throw new Error(`${field.label} is too small.`);
if (field.max !== undefined && value > field.max) throw new Error(`${field.label} is too large.`);
out[field.id] = value;
} else if (field.type === "boolean") {
out[field.id] = raw === undefined ? field.default === true : raw === true || raw === "true";
} else if (field.type === "select") {
const value = String(raw ?? field.default ?? "").trim();
if (field.required && !value) throw new Error(`${field.label} is required.`);
if (value && !(field.options ?? []).some((option) => option.value === value)) throw new Error(`${field.label} has an invalid value.`);
out[field.id] = value;
} else if (field.type === "multiSelect") {
const values = Array.isArray(raw) ? raw.map(String) : Array.isArray(field.default) ? field.default : [];
const allowed = new Set((field.options ?? []).map((option) => option.value));
if (values.some((value) => !allowed.has(value))) throw new Error(`${field.label} has an invalid value.`);
out[field.id] = values;
} else if (field.type === "time") {
const value = String(raw ?? field.default ?? "").trim();
if (field.required && !value) throw new Error(`${field.label} is required.`);
if (value && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error(`${field.label} must be HH:mm.`);
out[field.id] = value;
} else if (field.type === "date") {
const value = String(raw ?? field.default ?? "").trim();
if (field.required && !value) throw new Error(`${field.label} is required.`);
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error(`${field.label} must be YYYY-MM-DD.`);
out[field.id] = value;
} else if (field.type === "list") {
const values = Array.isArray(raw) ? raw.map((entry) => String(entry).trim()).filter(Boolean) : Array.isArray(field.default) ? field.default : [];
if (values.length > 32) throw new Error(`${field.label} has too many entries.`);
const maxLength = field.maxLength;
if (maxLength !== undefined && values.some((value) => value.length > maxLength)) throw new Error(`${field.label} entries are too long.`);
out[field.id] = values;
} else {
const text = String(raw ?? 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;
}
export 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 };
}
export function validateMoveBy(value: unknown): { x: number; y: number; durationMs?: number } {
if (!isRecord(value)) throw new Error("Invalid familiar movement options.");
const x = Number(value.x);
const y = Number(value.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Invalid familiar movement distance.");
return { x, y, durationMs: value.durationMs === undefined ? undefined : Number(value.durationMs) };
}
export 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),
};
}
export function parseCronExpression(expr: string): ParsedCron {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) throw new Error("Cron expressions must have 5 fields (m h dom mon dow).");
const [minutePart, hourPart, domPart, monthPart, dowPart] = parts as [string, string, string, string, string];
return {
minutes: parseCronField(minutePart, 0, 59),
hours: parseCronField(hourPart, 0, 23),
daysOfMonth: parseCronField(domPart, 1, 31),
months: parseCronField(monthPart, 1, 12),
daysOfWeek: parseCronField(dowPart, 0, 7, true),
domWildcard: domPart === "*",
dowWildcard: dowPart === "*",
};
}
export function nextCronRunMs(expr: string, fromMs: number): number | null {
const cron = parseCronExpression(expr);
const candidate = new Date(fromMs);
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
const limit = fromMs + 4 * 366 * 24 * 60 * 60 * 1000;
while (candidate.getTime() <= limit) {
if (!cron.months.has(candidate.getMonth() + 1)) {
candidate.setMonth(candidate.getMonth() + 1, 1);
candidate.setHours(0, 0, 0, 0);
continue;
}
const domMatch = cron.daysOfMonth.has(candidate.getDate());
const dowMatch = cron.daysOfWeek.has(candidate.getDay());
const dayMatch = cron.domWildcard && cron.dowWildcard ? true : cron.domWildcard ? dowMatch : cron.dowWildcard ? domMatch : domMatch || dowMatch;
if (!dayMatch) {
candidate.setDate(candidate.getDate() + 1);
candidate.setHours(0, 0, 0, 0);
continue;
}
if (!cron.hours.has(candidate.getHours())) {
candidate.setHours(candidate.getHours() + 1, 0, 0, 0);
continue;
}
if (!cron.minutes.has(candidate.getMinutes())) {
candidate.setMinutes(candidate.getMinutes() + 1, 0, 0);
continue;
}
return candidate.getTime();
}
return null;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function validateProviderName(value: unknown): string {
const provider = String(value);
check(/^[a-z0-9][a-z0-9._-]{0,63}$/.test(provider), "Invalid OAuth provider name.");
return provider;
}
function validateCommandIcon(icon: unknown, validateIconAssetRef: (ref: unknown) => { kind: PluginAssetKind; name: string; path: string }): PluginCommandIcon {
if (typeof icon === "string") {
check(namedHostIcons.has(icon), "Invalid plugin command icon.");
return icon;
}
if (!isRecord(icon) || icon.kind !== "icon" || typeof icon.name !== "string") throw new Error("Invalid plugin command icon.");
validateIconAssetRef(icon);
return { kind: "icon", name: icon.name };
}
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 (!commandFormFieldTypes.has(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 (out.type === "boolean") {
if (field.default !== undefined && typeof field.default !== "boolean") throw new Error("Invalid plugin command form default.");
if (field.default !== undefined) out.default = field.default;
} else if (out.type === "select" || out.type === "multiSelect") {
if (!Array.isArray(field.options) || field.options.length < 1 || field.options.length > 24) throw new Error("Invalid plugin command form options.");
const values = new Set<string>();
out.options = field.options.map((option) => {
if (!isRecord(option) || typeof option.value !== "string" || option.value.length > 120 || typeof option.label !== "string" || option.label.trim() === "" || option.label.length > 80 || values.has(option.value)) {
throw new Error("Invalid plugin command form option.");
}
values.add(option.value);
return { label: option.label, value: option.value };
});
if (field.default !== undefined) {
if (out.type === "multiSelect") {
if (!Array.isArray(field.default) || field.default.some((entry) => typeof entry !== "string" || !values.has(entry))) {
throw new Error("Invalid plugin command form default.");
}
out.default = field.default as string[];
} else {
if (typeof field.default !== "string" || !values.has(field.default)) throw new Error("Invalid plugin command form default.");
out.default = field.default;
}
}
} else if (out.type === "time") {
if (field.default !== undefined && (typeof field.default !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(field.default))) {
throw new Error("Invalid plugin command form default.");
}
if (field.default !== undefined) out.default = field.default;
} else if (out.type === "date") {
if (field.default !== undefined && (typeof field.default !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(field.default))) {
throw new Error("Invalid plugin command form default.");
}
if (field.default !== undefined) out.default = field.default;
} else if (out.type === "list") {
if (field.default !== undefined && (!Array.isArray(field.default) || field.default.some((entry) => typeof entry !== "string" || entry.length > 200) || field.default.length > 32)) {
throw new Error("Invalid plugin command form default.");
}
if (field.default !== undefined) out.default = field.default as string[];
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.maxLength !== undefined) out.maxLength = Number(field.maxLength);
} 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 parseCronField(field: string, min: number, max: number, mapSevenToZero = false): CronField {
const values = new Set<number>();
if (field.length === 0 || field.length > 64) throw new Error("Invalid cron field.");
for (const part of field.split(",")) {
const stepMatch = /^(.+)\/(\d+)$/.exec(part);
const base = stepMatch ? stepMatch[1]! : part;
const step = stepMatch ? Number(stepMatch[2]) : 1;
if (!Number.isInteger(step) || step < 1 || step > max) throw new Error("Invalid cron step.");
let start = min;
let end = max;
if (base !== "*") {
const rangeMatch = /^(\d+)-(\d+)$/.exec(base);
if (rangeMatch) {
start = Number(rangeMatch[1]);
end = Number(rangeMatch[2]);
} else {
if (!/^\d+$/.test(base)) throw new Error("Invalid cron value.");
start = Number(base);
end = stepMatch ? max : start;
}
}
if (start < min || end > max || start > end) throw new Error("Cron value out of range.");
for (let value = start; value <= end; value += step) {
values.add(mapSevenToZero && value === 7 ? 0 : value);
}
}
if (values.size === 0) throw new Error("Invalid cron field.");
return values;
}

File diff suppressed because it is too large Load diff

View file

@ -7,20 +7,22 @@ const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileU
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 controlCenterIpcSource = readFileSync(resolve(desktopRoot, "src/control-center-ipc.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");
const controlCenterInternalUiSource = `${windowsSource}\n${controlCenterIpcSource}`;
assertSetEqual(
"Control Center invoke routes",
extractLiteralFirstArgs(controlCenterPreloadSource, "ipcRenderer.invoke"),
extractLiteralFirstArgs(windowsSource, "ipcMain.handle"),
extractLiteralFirstArgs(controlCenterInternalUiSource, "ipcMain.handle"),
);
assertSetEqual(
"Control Center event routes",
extractLiteralFirstArgs(controlCenterPreloadSource, "ipcRenderer.on"),
extractLiteralFirstArgs(windowsSource, "webContents.send"),
extractLiteralFirstArgs(controlCenterInternalUiSource, "webContents.send"),
);
assertSetEqual(

View file

@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
const windowsSource = readFileSync(resolve(desktopRoot, "src/windows.ts"), "utf8");
const controlCenterIpcSource = readFileSync(resolve(desktopRoot, "src/control-center-ipc.ts"), "utf8");
const controlCenterPreloadSource = readFileSync(resolve(desktopRoot, "control-center-preload.cjs"), "utf8");
const controlCenterRendererSource = readFileSync(resolve(desktopRoot, "src/renderer/src/main.tsx"), "utf8");
const controlCenterSharedSource = readFileSync(resolve(desktopRoot, "src/renderer/src/control-center/shared.tsx"), "utf8");
@ -16,17 +17,18 @@ const pluginCommandFormPreloadSource = readFileSync(resolve(desktopRoot, "plugin
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");
const controlCenterInternalUiSource = `${windowsSource}\n${controlCenterIpcSource}`;
assert.doesNotMatch(windowsSource, /openTaskWindow|TaskWindowKind|createPluginsHtml|getPreloadPath|"preload\.cjs"/);
assert.match(windowsSource, /assertAllowedSender\(event, \["control-center"\]\)/);
assert.match(windowsSource, /familiaros:plugins-snapshot/);
assert.match(windowsSource, /familiaros:plugins-save-config/);
assert.match(windowsSource, /familiaros:plugins-load-local/);
assert.match(windowsSource, /familiaros:plugins-catalog-snapshot/);
assert.match(windowsSource, /familiaros:plugins-install-catalog/);
assert.match(windowsSource, /familiaros:plugins-update-catalog/);
assert.match(windowsSource, /familiaros:plugins-uninstall/);
assert.match(windowsSource, /familiaros:plugins-load-local[\s\S]*assertAllowedSender\(event, \["control-center"\]\)/);
assert.match(controlCenterInternalUiSource, /assertAllowedSender\(getControlCenterWindow, event, \["control-center"\]\)/);
assert.match(controlCenterInternalUiSource, /familiaros:plugins-snapshot/);
assert.match(controlCenterInternalUiSource, /familiaros:plugins-save-config/);
assert.match(controlCenterInternalUiSource, /familiaros:plugins-load-local/);
assert.match(controlCenterInternalUiSource, /familiaros:plugins-catalog-snapshot/);
assert.match(controlCenterInternalUiSource, /familiaros:plugins-install-catalog/);
assert.match(controlCenterInternalUiSource, /familiaros:plugins-update-catalog/);
assert.match(controlCenterInternalUiSource, /familiaros:plugins-uninstall/);
assert.match(controlCenterInternalUiSource, /familiaros:plugins-load-local[\s\S]*assertAllowedSender\(getControlCenterWindow, event, \["control-center"\]\)/);
assert.match(controlCenterPreloadSource, /getPluginsSnapshot: \(\) => ipcRenderer\.invoke\("familiaros:plugins-snapshot"\)/);
assert.match(controlCenterPreloadSource, /getPluginCatalogSnapshot: \(refresh\) => ipcRenderer\.invoke\("familiaros:plugins-catalog-snapshot", refresh\)/);
@ -80,8 +82,8 @@ assert.match(pluginSdkPreloadSource, /bubble: \(spec\) => call\("ui\.bubble", \[
assert.match(pluginSdkPreloadSource, /on: \(event, fn\) => subscription\("events\.on", "events\.off"/);
assert.match(pluginSdkPreloadSource, /publish: \(topic, payload\) => call\("bus\.publish", \[topic, payload\]\)/);
// Plugin platform settings are reachable from the Control Center.
assert.match(windowsSource, /familiaros:plugin-platform-settings-get/);
assert.match(windowsSource, /familiaros:plugins-inspector/);
assert.match(controlCenterInternalUiSource, /familiaros:plugin-platform-settings-get/);
assert.match(controlCenterInternalUiSource, /familiaros:plugins-inspector/);
assert.match(controlCenterPreloadSource, /getPluginPlatformSettings: \(\) => ipcRenderer\.invoke\("familiaros:plugin-platform-settings-get"\)/);
console.error("Plugin Control Center static validation passed.");