diff --git a/.sentrux/baseline.json b/.sentrux/baseline.json new file mode 100644 index 00000000..f7139116 --- /dev/null +++ b/.sentrux/baseline.json @@ -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 +} \ No newline at end of file diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 25820fec..e8b323f1 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -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."); diff --git a/apps/desktop/src/control-center-agent-services.ts b/apps/desktop/src/control-center-agent-services.ts new file mode 100644 index 00000000..9a12d3b4 --- /dev/null +++ b/apps/desktop/src/control-center-agent-services.ts @@ -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"; diff --git a/apps/desktop/src/control-center-core-services.ts b/apps/desktop/src/control-center-core-services.ts new file mode 100644 index 00000000..5fb01fcd --- /dev/null +++ b/apps/desktop/src/control-center-core-services.ts @@ -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"; diff --git a/apps/desktop/src/control-center-data-services.ts b/apps/desktop/src/control-center-data-services.ts new file mode 100644 index 00000000..d2a2d8f6 --- /dev/null +++ b/apps/desktop/src/control-center-data-services.ts @@ -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"; diff --git a/apps/desktop/src/control-center-ipc.ts b/apps/desktop/src/control-center-ipc.ts new file mode 100644 index 00000000..83866eed --- /dev/null +++ b/apps/desktop/src/control-center-ipc.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 : undefined); + }); + + ipcMain.handle("familiaros:plugins-load-local", async (event): Promise => { + 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 => { + 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 => { + 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 => { + 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"; +} diff --git a/apps/desktop/src/control-center-pet-services.ts b/apps/desktop/src/control-center-pet-services.ts new file mode 100644 index 00000000..406d61a0 --- /dev/null +++ b/apps/desktop/src/control-center-pet-services.ts @@ -0,0 +1,12 @@ +export { + recoverDefaultPetMouseInterop, + refreshDefaultPetContent, + resetDefaultPetToInitialPosition, +} from "./default-familiar-controller.js"; +export { + installPet, + installPetFromFolder, + installPetFromZipFile, + removePet, + setDefaultInstalledPet, +} from "./familiar-installation.js"; diff --git a/apps/desktop/src/control-center-state.ts b/apps/desktop/src/control-center-state.ts new file mode 100644 index 00000000..7e7739fe --- /dev/null +++ b/apps/desktop/src/control-center-state.ts @@ -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["preferences"], + | "openDefaultPetOnLaunch" + | "locale" + | "petScale" + | "reactionAnimationOverrides" + | "openApiChatModel" + | "openApiChatSystemPrompt" + | "openApiChatEndpoint" + | "openApiChatTheme" + | "vanillaChatMcpTools" + | "openApiChatBaseInstructionsEnabled" + | "ttsProvider" + | "ttsVoice" + | "ttsSpeed" + | "ttsModel" + | "ttsEndpointPreset" + | "ttsEndpoint" + | "familiarName" +>; + +type Mutable = { -readonly [K in keyof T]: T[K] }; + +export type ControlCenterPreferencesPatch = Partial["preferences"]>>; + +export function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; familiars: ReturnType["familiars"] } { + const state = getAppStateSnapshot(); + return { preferences: { defaultPetId: state.preferences.defaultPetId }, familiars: state.familiars }; +} + +export function getSettingsStateSnapshot(): { + preferences: ControlCenterSettingsPreferences; + petScaleOptions: typeof petScaleOptions; + openApiChat: ReturnType; +} { + 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; +} { + 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; + readonly activity: ReturnType["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 { + 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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/desktop/src/plugin-host-audio.ts b/apps/desktop/src/plugin-host-audio.ts new file mode 100644 index 00000000..ffe8fec1 --- /dev/null +++ b/apps/desktop/src/plugin-host-audio.ts @@ -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 = { 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); + }, + }; +} diff --git a/apps/desktop/src/plugin-host-capabilities.ts b/apps/desktop/src/plugin-host-capabilities.ts index 7bfbb328..3372a1f4 100644 --- a/apps/desktop/src/plugin-host-capabilities.ts +++ b/apps/desktop/src/plugin-host-capabilities.ts @@ -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(); + 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 = { 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; } diff --git a/apps/desktop/src/plugin-host-files.ts b/apps/desktop/src/plugin-host-files.ts new file mode 100644 index 00000000..e89b5921 --- /dev/null +++ b/apps/desktop/src/plugin-host-files.ts @@ -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(); + 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); + }, + }; +} diff --git a/apps/desktop/src/plugin-host-runtime.ts b/apps/desktop/src/plugin-host-runtime.ts new file mode 100644 index 00000000..3ca6beb6 --- /dev/null +++ b/apps/desktop/src/plugin-host-runtime.ts @@ -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"; diff --git a/apps/desktop/src/plugin-host-system.ts b/apps/desktop/src/plugin-host-system.ts new file mode 100644 index 00000000..af798604 --- /dev/null +++ b/apps/desktop/src/plugin-host-system.ts @@ -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))); +} diff --git a/apps/desktop/src/plugin-sdk-bridge.ts b/apps/desktop/src/plugin-sdk-bridge.ts index d8d1ef36..1e841527 100644 --- a/apps/desktop/src/plugin-sdk-bridge.ts +++ b/apps/desktop/src/plugin-sdk-bridge.ts @@ -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; } -// --------------------------------------------------------------------------- -// 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 => { 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(); - 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; text: string; json?: unknown }; -type ValidatedNetOptions = { method: string; headers?: Record; 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): 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 | undefined { - if (!isRecord(value)) return undefined; - const out: Record = {}; - 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 { 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): 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, diagnostics?: NetworkDiagnostics): Promise { - 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>; - 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 = {}; - 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, 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>; - 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 { 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 { 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(!/```| 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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); - return escaped - .replace(/\*\*([^*\n]+)\*\*/g, "$1") - .replace(/\*([^*\n]+)\*/g, "$1") - .replace(/`([^`\n]+)`/g, "$1") - .replace(/\n/g, "
"); -} - -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(); - 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(); - 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 }; - }); - } - 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(); - 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(); - 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 { - const input = isRecord(args) ? args : {}; - const out: Record = {}; - 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; -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(); - 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(promise: Promise, timeoutMs: number): Promise { 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 { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/apps/desktop/src/plugin-sdk-contracts.ts b/apps/desktop/src/plugin-sdk-contracts.ts new file mode 100644 index 00000000..03d9b259 --- /dev/null +++ b/apps/desktop/src/plugin-sdk-contracts.ts @@ -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"; diff --git a/apps/desktop/src/plugin-sdk-default-capabilities.ts b/apps/desktop/src/plugin-sdk-default-capabilities.ts new file mode 100644 index 00000000..e56d731f --- /dev/null +++ b/apps/desktop/src/plugin-sdk-default-capabilities.ts @@ -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 => { + 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(); + 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, + }, + }; +} diff --git a/apps/desktop/src/plugin-sdk-network.ts b/apps/desktop/src/plugin-sdk-network.ts new file mode 100644 index 00000000..b5761698 --- /dev/null +++ b/apps/desktop/src/plugin-sdk-network.ts @@ -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; + text: string; + json?: unknown; +}; + +export type ValidatedNetOptions = { + method: string; + headers?: Record; + 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): 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 { + 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, diagnostics?: NetworkDiagnostics): Promise { + 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>; + 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 = {}; + 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, 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>; + 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 { + 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 | undefined { + if (!isRecord(value)) return undefined; + const out: Record = {}; + 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): 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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/desktop/src/plugin-sdk-support.ts b/apps/desktop/src/plugin-sdk-support.ts new file mode 100644 index 00000000..ee00f89c --- /dev/null +++ b/apps/desktop/src/plugin-sdk-support.ts @@ -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"; diff --git a/apps/desktop/src/plugin-sdk-surfaces.ts b/apps/desktop/src/plugin-sdk-surfaces.ts new file mode 100644 index 00000000..7a6d1c1c --- /dev/null +++ b/apps/desktop/src/plugin-sdk-surfaces.ts @@ -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"; diff --git a/apps/desktop/src/plugin-sdk-validators.ts b/apps/desktop/src/plugin-sdk-validators.ts new file mode 100644 index 00000000..cad99668 --- /dev/null +++ b/apps/desktop/src/plugin-sdk-validators.ts @@ -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; +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(!/```|", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); + return escaped + .replace(/\*\*([^*\n]+)\*\*/g, "$1") + .replace(/\*([^*\n]+)\*/g, "$1") + .replace(/`([^`\n]+)`/g, "$1") + .replace(/\n/g, "
"); +} + +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(); + 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(); + 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, + }; + }); + } + 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 { + const input = isRecord(args) ? args : {}; + const out: Record = {}; + 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 { + 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(); + 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(); + 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(); + 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; +} diff --git a/apps/desktop/src/windows.ts b/apps/desktop/src/windows.ts index 657dcabc..eb368b80 100644 --- a/apps/desktop/src/windows.ts +++ b/apps/desktop/src/windows.ts @@ -1,720 +1,29 @@ import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, shell, type IpcMainInvokeEvent, type OpenDialogOptions } from "electron"; +import { app, BrowserWindow, protocol } from "electron"; -import { buildFamiliarOSMcpServerPreview, getAgentSetupSnapshot, runAgentSetupAction, testFamiliarOSMcpServer, updateAgentSetupCommandPaths } from "./agent-setup.js"; -import { refreshAgentPetContent } from "./agent-familiar-controller.js"; -import { getAppStateSnapshot, normalizeFamiliarName, normalizeOpenApiChatEndpoint, normalizePetScale, petScaleOptions, updatePreferences } from "./app-state.js"; +import { getAppStateSnapshot } from "./app-state.js"; import { createAppIcon } from "./assets.js"; -import { getCatalogPageUiState, getCatalogSearchUiState, getCatalogUiState } from "./catalog.js"; -import { getCodexPetsUiState, importCodexPet, readCodexPetSpritesheet } from "./codex-familiars.js"; -import { getActiveLocale, getActiveMessages, isSupportedLocale, LOCALE_LABELS, SUPPORTED_LOCALES, setLocaleFromPreference, t, type Locale, type LocalePreference } from "./i18n/index.js"; -import { recoverDefaultPetMouseInterop, refreshDefaultPetContent, resetDefaultPetToInitialPosition } from "./default-familiar-controller.js"; -import { getMcpChatClientManager, listMcpChatVanillaServerIds } from "./mcp-chat-client.js"; -import { installPersistentToolkit, type McpToolkitPersistentTarget } from "./mcp-toolkit-installer.js"; -import { clearOpenApiCredential, getOpenApiChatSettingsSnapshot, normalizeChatModel, resetOpenApiConversationContext, saveOpenApiCredential } from "./openapi-chat.js"; -import { clearTtsCredential, saveTtsCredential } from "./tts-credentials.js"; -import { fetchTtsVoiceList, getTtsSettingsSnapshot, speakTts, stopTts, type TtsProviderId } from "./tts-service.js"; -import { listTtsProviders } from "./tts-engine.js"; -import { addKnowledgeMemory, deleteKnowledgeFile, listKnowledgeFiles, searchKnowledgeStore, storeKnowledgeFile } from "./knowledge-store.js"; -import { forgetFamiliarOSMemory, listFamiliarOSMemories, searchFamiliarOSMemories, storeFamiliarOSMemory, updateFamiliarOSMemory } from "./familiaros-memory.js"; -import { installPet, installPetFromFolder, installPetFromZipFile, removePet, setDefaultInstalledPet } from "./familiar-installation.js"; +import { installInternalUiHandlers as installControlCenterIpcHandlers } from "./control-center-ipc.js"; +import { getDefaultPetPreviewSpriteInfo } from "./control-center-state.js"; +import { readCodexPetSpritesheet } from "./codex-familiars.js"; import { assertSafePetId, getInstalledPetDir } from "./familiar-paths.js"; import { debug, error as logError, warn } from "./logger.js"; -import { getPluginService, type PluginConfigSoundPickResult, type PluginServiceResult } from "./plugin-service.js"; -import { defaultPetSprite, reactionAnimationMetadata, selectableAnimationMetadata, validateReactionAnimationOverrides } from "./reaction-animation-mapping.js"; -import { checkForGitHubReleaseUpdate, getUpdateStatus, openUpdateReleasePage } from "./update-checker.js"; -type InternalUiWindowKind = "control-center"; export type ControlCenterRoute = "dashboard" | "familiars" | "settings" | "plugins" | "integrations"; const controlCenterRoutes = new Set(["dashboard", "familiars", "settings", "plugins", "integrations"]); + let controlCenterWindow: BrowserWindow | null = null; -let internalUiHandlersInstalled = false; let pendingControlCenterRoute: ControlCenterRoute | null = null; let pendingDockTimer: NodeJS.Timeout | null = null; let lastDockHideAt = 0; + const dockHideShowCooldownMs = 1100; -async function loadCodexPetsModule() { - return import("./codex-familiars.js"); -} - -function hasOpenInternalUiWindows(): boolean { - if (controlCenterWindow && !controlCenterWindow.isDestroyed()) return true; - return false; -} - -function syncDockVisibilityForInternalUi(): void { - if (process.platform !== "darwin") return; - const dock = app.dock; - if (!dock) return; - - if (pendingDockTimer) { - clearTimeout(pendingDockTimer); - pendingDockTimer = null; - } - - if (hasOpenInternalUiWindows()) { - const elapsedSinceHide = Date.now() - lastDockHideAt; - const delayMs = elapsedSinceHide < dockHideShowCooldownMs ? dockHideShowCooldownMs - elapsedSinceHide : 0; - pendingDockTimer = setTimeout(() => { - pendingDockTimer = null; - dock.setIcon(createAppIcon()); - dock.show(); - }, delayMs); - } else { - dock.hide(); - lastDockHideAt = Date.now(); - } -} - -function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; familiars: ReturnType["familiars"] } { - const state = getAppStateSnapshot(); - return { preferences: { defaultPetId: state.preferences.defaultPetId }, familiars: state.familiars }; -} - -function getSettingsStateSnapshot(): { - preferences: Pick["preferences"], "openDefaultPetOnLaunch" | "petScale" | "reactionAnimationOverrides" | "openApiChatModel" | "openApiChatSystemPrompt" | "openApiChatEndpoint" | "openApiChatTheme" | "vanillaChatMcpTools" | "openApiChatBaseInstructionsEnabled">; - petScaleOptions: typeof petScaleOptions; - openApiChat: ReturnType; -} { - const state = getAppStateSnapshot(); - return { - preferences: { - openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch, - 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, - }, - petScaleOptions, - openApiChat: getOpenApiChatSettingsSnapshot(), - }; -} - -function getI18nSnapshot(): { - locale: Locale; - localePreference: LocalePreference; - availableLocales: { value: Locale; label: string }[]; - messages: ReturnType; -} { - return { - locale: getActiveLocale(), - localePreference: getAppStateSnapshot().preferences.locale, - availableLocales: SUPPORTED_LOCALES.map((value) => ({ value, label: LOCALE_LABELS[value] })), - messages: getActiveMessages(), - }; -} - -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; - readonly activity: ReturnType["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 function installInternalUiHandlers(): void { - if (internalUiHandlersInstalled) { - return; - } - - internalUiHandlersInstalled = true; - - ipcMain.handle("familiaros:get-familiars-state", (event) => { - assertAllowedSender(event, ["control-center"]); - return getPetsStateSnapshot(); - }); - - ipcMain.handle("familiaros:get-settings-state", (event) => { - assertAllowedSender(event, ["control-center"]); - return getSettingsStateSnapshot(); - }); - - ipcMain.handle("familiaros:get-i18n", (event) => { - assertAllowedSender(event, ["control-center"]); - return getI18nSnapshot(); - }); - - ipcMain.handle("familiaros:get-dashboard-snapshot", async (event) => { - assertAllowedSender(event, ["control-center"]); - return getDashboardSnapshot(); - }); - - ipcMain.handle("familiaros:get-openapi-chat-settings", (event) => { - assertAllowedSender(event, ["control-center"]); - return getOpenApiChatSettingsSnapshot(); - }); - - ipcMain.handle("familiaros:save-openapi-credential", (event, apiKey: unknown) => { - assertAllowedSender(event, ["control-center"]); - if (typeof apiKey !== "string") throw new Error("Invalid chat credential."); - return saveOpenApiCredential(apiKey); - }); - - ipcMain.handle("familiaros:clear-openapi-credential", (event) => { - assertAllowedSender(event, ["control-center"]); - return clearOpenApiCredential(); - }); - - ipcMain.handle("familiaros:install-mcp-toolkit", async (event, target: unknown) => { - assertAllowedSender(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(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(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(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((h) => h.entry); - return listFamiliarOSMemories(l); - }); - - ipcMain.handle("familiaros:store-memory", async (event, text: unknown, kind: unknown, tags: unknown, importance: unknown) => { - assertAllowedSender(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((t): t is string => typeof t === "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(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((t): t is string => typeof t === "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(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(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(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(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(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((t): t is string => typeof t === "string") : undefined, - importance: typeof importance === "number" ? importance : undefined, - }); - }); - - ipcMain.handle("familiaros:knowledge-store-file", async (event) => { - assertAllowedSender(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(event, ["control-center"]); - return getReactionAnimationSettingsSnapshot(); - }); - - ipcMain.handle("familiaros:plugins-snapshot", async (event) => { - assertAllowedSender(event, ["control-center"]); - return getPluginService().getSnapshot(); - }); - - ipcMain.handle("familiaros:plugins-set-enabled", async (event, id: unknown, enabled: unknown): Promise => { - assertAllowedSender(event, ["control-center"]); - if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(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 => { - assertAllowedSender(event, ["control-center"]); - if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(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 => { - assertAllowedSender(event, ["control-center"]); - if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(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 => { - assertAllowedSender(event, ["control-center"]); - if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(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 => { - assertAllowedSender(event, ["control-center"]); - if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id) || typeof commandId !== "string" || !/^[A-Za-z0-9._:-]{1,64}$/.test(commandId) || (args !== undefined && !isPlainObject(args))) return pluginUiError("Invalid plugin command request."); - return getPluginService().executeCommand(id, commandId, isPlainObject(args) ? args as Record : undefined); - }); - - ipcMain.handle("familiaros:plugins-load-local", async (event): Promise => { - assertAllowedSender(event, ["control-center"]); - return getPluginService().loadLocal(); - }); - - ipcMain.handle("familiaros:plugins-catalog-snapshot", async (event, refresh: unknown) => { - assertAllowedSender(event, ["control-center"]); - return getPluginService().getCatalogSnapshot(refresh === true); - }); - - ipcMain.handle("familiaros:plugins-install-catalog", async (event, id: unknown): Promise => { - assertAllowedSender(event, ["control-center"]); - if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id)) return pluginUiError("Invalid plugin install request."); - return getPluginService().installCatalog(id); - }); - - ipcMain.handle("familiaros:plugins-update-catalog", async (event, id: unknown): Promise => { - assertAllowedSender(event, ["control-center"]); - if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id)) return pluginUiError("Invalid plugin update request."); - return getPluginService().updateCatalog(id); - }); - - ipcMain.handle("familiaros:plugins-uninstall", async (event, id: unknown): Promise => { - assertAllowedSender(event, ["control-center"]); - if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id)) return pluginUiError("Invalid plugin uninstall request."); - return getPluginService().uninstall(id); - }); - - ipcMain.handle("familiaros:plugins-inspector", async (event, id: unknown) => { - assertAllowedSender(event, ["control-center"]); - if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(id)) throw new Error("Invalid plugin inspector request."); - return getPluginService().runtime.getInspectorState(id); - }); - - ipcMain.handle("familiaros:plugin-platform-settings-get", async (event) => { - assertAllowedSender(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(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(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(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(event, ["control-center"]); - return getCatalogUiState(); - }); - - ipcMain.handle("familiaros:get-catalog-page", async (event, page: unknown) => { - assertAllowedSender(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(event, ["control-center"]); - return getCatalogSearchUiState(); - }); - - ipcMain.handle("familiaros:get-codex-familiars", async (event) => { - assertAllowedSender(event, ["control-center"]); - return (await loadCodexPetsModule()).getCodexPetsUiState(); - }); - - ipcMain.handle("familiaros:update-preferences", (event, patch: unknown) => { - assertAllowedSender(event, ["control-center"]); - const currentState = getAppStateSnapshot(); - const previousScale = getAppStateSnapshot().preferences.petScale; - const previousOverrides = JSON.stringify(getAppStateSnapshot().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(); - } - if (setLocaleFromPreference(state.preferences.locale) !== previousLocale - || state.preferences.familiarName !== currentState.preferences.familiarName) { - // Tray labels are rendered eagerly, so rebuild the menu when language or Familiar name changes. - void import("./tray.js").then(({ refreshTrayMenu }) => refreshTrayMenu()); - if (setLocaleFromPreference(state.preferences.locale) !== previousLocale) { - // Control Center plugin labels are resolved at display time; nudge it to re-fetch the - // SafePluginRecords so manifest/config labels re-render in the new language. - broadcastPluginRecordsRefresh(); - } - } - return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getSettingsStateSnapshot() : state; - }); - - ipcMain.handle("familiaros:get-launch-at-login", (event) => { - assertAllowedSender(event, ["control-center"]); - return getLaunchAtLoginState(); - }); - - ipcMain.handle("familiaros:set-launch-at-login", (event, enabled: unknown) => { - assertAllowedSender(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(event, ["control-center"]); - return getUpdateStatus(); - }); - - ipcMain.handle("familiaros:check-for-updates", async (event) => { - assertAllowedSender(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(event, ["control-center"]); - await openUpdateReleasePage(); - }); - - ipcMain.handle("familiaros:copy-text", (event, text: unknown) => { - assertAllowedSender(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(event, ["control-center"]); - const url = validateExternalUrl(rawUrl); - await shell.openExternal(url); - }); - - ipcMain.handle("familiaros:set-default-familiar", async (event, petId: unknown) => { - assertAllowedSender(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 getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getPetsStateSnapshot() : state; - }); - - ipcMain.handle("familiaros:install-familiar", async (event, petId: unknown) => { - assertAllowedSender(event, ["control-center"]); - if (typeof petId !== "string") { - throw new Error("Invalid familiar id."); - } - - const state = await installPet(petId); - return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getPetsStateSnapshot() : state; - }); - - ipcMain.handle("familiaros:install-local-familiar", async (event) => { - assertAllowedSender(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 getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? 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(event, ["control-center"]); - await shell.openExternal("https://familiaros.dev/gallery"); - }); - - ipcMain.handle("familiaros:import-codex-familiar", async (event, petId: unknown) => { - assertAllowedSender(event, ["control-center"]); - if (typeof petId !== "string") { - throw new Error("Invalid familiar id."); - } - - const state = await (await loadCodexPetsModule()).importCodexPet(petId); - return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getPetsStateSnapshot() : state; - }); - - ipcMain.handle("familiaros:remove-familiar", async (event, petId: unknown) => { - assertAllowedSender(event, ["control-center"]); - if (typeof petId !== "string") { - throw new Error("Invalid familiar id."); - } - - const state = await removePet(petId); - refreshDefaultPetContent(); - return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getPetsStateSnapshot() : state; - }); - - ipcMain.handle("familiaros:reset-default-familiar-position", (event) => { - assertAllowedSender(event, ["control-center"]); - resetDefaultPetToInitialPosition(); - return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getSettingsStateSnapshot() : getAppStateSnapshot(); - }); - - ipcMain.handle("familiaros:agent-setup-snapshot", async (event, selectedPetId: unknown, commandMode: unknown) => { - assertAllowedSender(event, ["control-center"]); - return getAgentSetupSnapshot(selectedPetId, commandMode); - }); - - ipcMain.handle("familiaros:agent-setup-action", async (event, action: unknown, selectedPetId: unknown, commandMode: unknown) => { - assertAllowedSender(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(event, ["control-center"]); - return updateAgentSetupCommandPaths(patch); - }); - - ipcMain.handle("familiaros:familiaros-mcp-server-preview", (event, selectedPetId: unknown, commandMode: unknown) => { - assertAllowedSender(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(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(event, ["control-center"]); - return getTtsSettingsSnapshot(); - }); - - ipcMain.handle("familiaros:get-tts-voices", async (event, provider: unknown) => { - assertAllowedSender(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(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(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(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(event, ["control-center"]); - stopTts("familiar"); - }); -} - -async 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, - }; - const result = owner ? await dialog.showMessageBox(owner, options) : await dialog.showMessageBox(options); - if (result.response === 0) return "zip"; - if (result.response === 1) return "folder"; - return null; + installControlCenterIpcHandlers(() => controlCenterWindow); } export function installInternalUiProtocol(): void { @@ -724,7 +33,7 @@ export function installInternalUiProtocol(): void { const url = new URL(request.url); if (url.hostname !== "spritesheet" || url.search || url.hash) return new Response(null, { status: 404 }); const petId = decodeURIComponent(url.pathname.replace(/^\//, "")); - const spritesheet = await (await loadCodexPetsModule()).readCodexPetSpritesheet(petId); + const spritesheet = await readCodexPetSpritesheet(petId); return new Response(spritesheet, { headers: { "Content-Type": "image/webp", @@ -829,13 +138,21 @@ export function openControlCenterWindow(route: ControlCenterRoute = "dashboard") console.error("Control Center renderer process gone.", details); logError("ui", "control center renderer gone", details); }); - window.on("closed", () => { controlCenterWindow = null; syncDockVisibilityForInternalUi(); }); - window.once("ready-to-show", () => { window.show(); window.focus(); }); + window.on("closed", () => { + controlCenterWindow = null; + syncDockVisibilityForInternalUi(); + }); + window.once("ready-to-show", () => { + window.show(); + window.focus(); + }); pendingControlCenterRoute = safeRoute; window.webContents.on("did-finish-load", () => flushPendingControlCenterRoute(window)); const devUrl = getSafeControlCenterDevUrl(); - const load = devUrl ? window.loadURL(withControlCenterRoute(devUrl, safeRoute)) : window.loadFile(join(app.getAppPath(), "dist", "renderer", "index.html"), { query: { route: safeRoute } }); + const load = devUrl + ? window.loadURL(withControlCenterRoute(devUrl, safeRoute)) + : window.loadFile(join(app.getAppPath(), "dist", "renderer", "index.html"), { query: { route: safeRoute } }); load.catch((error: unknown) => console.error("Failed to load Control Center.", error)); } @@ -848,6 +165,34 @@ export function focusOpenTaskWindows(): void { } } +function hasOpenInternalUiWindows(): boolean { + return Boolean(controlCenterWindow && !controlCenterWindow.isDestroyed()); +} + +function syncDockVisibilityForInternalUi(): void { + if (process.platform !== "darwin") return; + const dock = app.dock; + if (!dock) return; + + if (pendingDockTimer) { + clearTimeout(pendingDockTimer); + pendingDockTimer = null; + } + + if (hasOpenInternalUiWindows()) { + const elapsedSinceHide = Date.now() - lastDockHideAt; + const delayMs = elapsedSinceHide < dockHideShowCooldownMs ? dockHideShowCooldownMs - elapsedSinceHide : 0; + pendingDockTimer = setTimeout(() => { + pendingDockTimer = null; + dock.setIcon(createAppIcon()); + dock.show(); + }, delayMs); + } else { + dock.hide(); + lastDockHideAt = Date.now(); + } +} + function normalizeControlCenterRoute(route: unknown): ControlCenterRoute { return typeof route === "string" && controlCenterRoutes.has(route as ControlCenterRoute) ? route as ControlCenterRoute : "dashboard"; } @@ -857,13 +202,6 @@ function sendControlCenterRoute(window: BrowserWindow, route: ControlCenterRoute window.webContents.send("familiaros:control-center-route", route); } -/** Tell the open Control Center to re-fetch the plugin snapshot (e.g. after a locale change). */ -function broadcastPluginRecordsRefresh(): void { - if (controlCenterWindow && !controlCenterWindow.isDestroyed()) { - controlCenterWindow.webContents.send("familiaros:plugins-refresh"); - } -} - function routeControlCenterWindow(window: BrowserWindow, route: ControlCenterRoute): void { pendingControlCenterRoute = route; if (window.webContents.isLoading()) return; @@ -883,20 +221,6 @@ function withControlCenterRoute(rawUrl: string, route: ControlCenterRoute): stri return url.toString(); } -function pluginUiError(error: string): PluginServiceResult { - return { ok: false, error, snapshot: { plugins: [] } }; -} - -function pluginUiSoundError(error: string): PluginConfigSoundPickResult { - return { ok: false, error, snapshot: { plugins: [] } }; -} - -function isPlainObject(value: unknown): value is Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value) as unknown; - return prototype === Object.prototype || prototype === null; -} - function getControlCenterPreloadPath(): string { return join(app.getAppPath(), "control-center-preload.cjs"); } @@ -915,233 +239,3 @@ function getSafeControlCenterDevUrl(): string | null { } return null; } - -function assertAllowedSender(event: IpcMainInvokeEvent, allowedKinds: readonly InternalUiWindowKind[]): void { - const actualKind = getInternalUiWindowKindForWebContents(event.sender.id); - - if (!actualKind || !allowedKinds.includes(actualKind)) { - throw new Error("FamiliarOS internal UI request came from an unexpected window."); - } -} - -function getInternalUiWindowKindForWebContents(webContentsId: number): InternalUiWindowKind | null { - if (controlCenterWindow && !controlCenterWindow.isDestroyed() && controlCenterWindow.webContents.id === webContentsId) { - return "control-center"; - } - return null; -} - -async function getReactionAnimationSettingsSnapshot(): Promise { - 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)}`, - }; -} - -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}` }; -} - -function validatePreferencePatch(value: unknown): { openDefaultPetOnLaunch?: boolean; locale?: LocalePreference; petScale?: number; reactionAnimationOverrides?: ReturnType; openApiChatModel?: string | undefined; openApiChatSystemPrompt?: string | undefined; openApiChatEndpoint?: string | undefined; openApiChatTheme?: "system" | "light" | "dark"; ttsProvider?: TtsProviderId; ttsVoice?: string | undefined; ttsSpeed?: number; ttsModel?: string | undefined; ttsEndpointPreset?: "openrouter" | "litellm" | "wavespeedai" | "custom"; ttsEndpoint?: string | undefined; familiarName?: string | undefined } { - if (!isRecord(value)) { - throw new Error("Invalid preferences patch."); - } - - const patch: { openDefaultPetOnLaunch?: boolean; locale?: LocalePreference; petScale?: number; reactionAnimationOverrides?: ReturnType; openApiChatModel?: string | undefined; openApiChatSystemPrompt?: string | undefined; openApiChatEndpoint?: string | undefined; openApiChatTheme?: "system" | "light" | "dark"; ttsProvider?: TtsProviderId; ttsVoice?: string | undefined; ttsSpeed?: number; ttsModel?: string | undefined; ttsEndpointPreset?: "openrouter" | "litellm" | "wavespeedai" | "custom"; ttsEndpoint?: string | undefined; familiarName?: string | undefined } = {}; - - 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 ("ttsProvider" in value) { - const validIds = listTtsProviders().map((p) => p.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; -} - -function getLaunchAtLoginState(): { supported: boolean; enabled: boolean } { - if (!isLaunchAtLoginSupported()) return { supported: false, enabled: false }; - return { supported: true, enabled: app.getLoginItemSettings().openAtLogin }; -} - -function isLaunchAtLoginSupported(): boolean { - return process.platform === "darwin" || process.platform === "win32"; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -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(); -} diff --git a/apps/desktop/tests/internal-ui-route-conformance.test.ts b/apps/desktop/tests/internal-ui-route-conformance.test.ts index c7c83af7..7746c342 100644 --- a/apps/desktop/tests/internal-ui-route-conformance.test.ts +++ b/apps/desktop/tests/internal-ui-route-conformance.test.ts @@ -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( diff --git a/apps/desktop/tests/plugin-ui-static.test.ts b/apps/desktop/tests/plugin-ui-static.test.ts index 6ade7fe5..5fd74a11 100644 --- a/apps/desktop/tests/plugin-ui-static.test.ts +++ b/apps/desktop/tests/plugin-ui-static.test.ts @@ -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.");