refactor desktop runtime control center seams
This commit is contained in:
parent
1d7d2e2c6a
commit
d89171c4be
23 changed files with 2549 additions and 1705 deletions
12
.sentrux/baseline.json
Normal file
12
.sentrux/baseline.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"timestamp": 1781761968.167321,
|
||||
"quality_signal": 0.438779684727969,
|
||||
"coupling_score": 0.35494021397105097,
|
||||
"cycle_count": 3,
|
||||
"god_file_count": 0,
|
||||
"hotspot_count": 1,
|
||||
"complex_fn_count": 143,
|
||||
"max_depth": 17,
|
||||
"total_import_edges": 1589,
|
||||
"cross_module_edges": 824
|
||||
}
|
||||
|
|
@ -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.");
|
||||
|
|
|
|||
10
apps/desktop/src/control-center-agent-services.ts
Normal file
10
apps/desktop/src/control-center-agent-services.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export {
|
||||
buildFamiliarOSMcpServerPreview,
|
||||
getAgentSetupSnapshot,
|
||||
runAgentSetupAction,
|
||||
testFamiliarOSMcpServer,
|
||||
updateAgentSetupCommandPaths,
|
||||
} from "./agent-setup.js";
|
||||
export { refreshAgentPetContent } from "./agent-familiar-controller.js";
|
||||
export { getMcpChatClientManager, listMcpChatVanillaServerIds } from "./mcp-chat-client.js";
|
||||
export { installPersistentToolkit, type McpToolkitPersistentTarget } from "./mcp-toolkit-installer.js";
|
||||
16
apps/desktop/src/control-center-core-services.ts
Normal file
16
apps/desktop/src/control-center-core-services.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export { getAppStateSnapshot, updatePreferences } from "./app-state.js";
|
||||
export {
|
||||
getDashboardSnapshot,
|
||||
getI18nSnapshot,
|
||||
getLaunchAtLoginState,
|
||||
getPetsStateSnapshot,
|
||||
getReactionAnimationSettingsSnapshot,
|
||||
getSettingsStateSnapshot,
|
||||
isPlainObject,
|
||||
validateExternalUrl,
|
||||
validatePreferencePatch,
|
||||
} from "./control-center-state.js";
|
||||
export { getCatalogPageUiState, getCatalogSearchUiState, getCatalogUiState } from "./catalog.js";
|
||||
export { getActiveLocale, setLocaleFromPreference } from "./i18n/index.js";
|
||||
export { debug, error as logError, warn } from "./logger.js";
|
||||
export { checkForGitHubReleaseUpdate, getUpdateStatus, openUpdateReleasePage } from "./update-checker.js";
|
||||
23
apps/desktop/src/control-center-data-services.ts
Normal file
23
apps/desktop/src/control-center-data-services.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export {
|
||||
forgetFamiliarOSMemory,
|
||||
listFamiliarOSMemories,
|
||||
searchFamiliarOSMemories,
|
||||
storeFamiliarOSMemory,
|
||||
updateFamiliarOSMemory,
|
||||
} from "./familiaros-memory.js";
|
||||
export {
|
||||
addKnowledgeMemory,
|
||||
deleteKnowledgeFile,
|
||||
listKnowledgeFiles,
|
||||
searchKnowledgeStore,
|
||||
storeKnowledgeFile,
|
||||
} from "./knowledge-store.js";
|
||||
export {
|
||||
clearOpenApiCredential,
|
||||
getOpenApiChatSettingsSnapshot,
|
||||
resetOpenApiConversationContext,
|
||||
saveOpenApiCredential,
|
||||
} from "./openapi-chat.js";
|
||||
export { getPluginService, type PluginConfigSoundPickResult, type PluginServiceResult } from "./plugin-service.js";
|
||||
export { clearTtsCredential, saveTtsCredential } from "./tts-credentials.js";
|
||||
export { fetchTtsVoiceList, getTtsSettingsSnapshot, speakTts, stopTts, type TtsProviderId } from "./tts-service.js";
|
||||
696
apps/desktop/src/control-center-ipc.ts
Normal file
696
apps/desktop/src/control-center-ipc.ts
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
import { readFile, stat } from "node:fs/promises";
|
||||
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell, type IpcMainInvokeEvent, type OpenDialogOptions } from "electron";
|
||||
|
||||
import {
|
||||
buildFamiliarOSMcpServerPreview,
|
||||
getAgentSetupSnapshot,
|
||||
getMcpChatClientManager,
|
||||
installPersistentToolkit,
|
||||
listMcpChatVanillaServerIds,
|
||||
refreshAgentPetContent,
|
||||
runAgentSetupAction,
|
||||
testFamiliarOSMcpServer,
|
||||
type McpToolkitPersistentTarget,
|
||||
updateAgentSetupCommandPaths,
|
||||
} from "./control-center-agent-services.js";
|
||||
import {
|
||||
addKnowledgeMemory,
|
||||
clearOpenApiCredential,
|
||||
clearTtsCredential,
|
||||
deleteKnowledgeFile,
|
||||
fetchTtsVoiceList,
|
||||
forgetFamiliarOSMemory,
|
||||
getOpenApiChatSettingsSnapshot,
|
||||
getPluginService,
|
||||
getTtsSettingsSnapshot,
|
||||
listFamiliarOSMemories,
|
||||
listKnowledgeFiles,
|
||||
resetOpenApiConversationContext,
|
||||
saveOpenApiCredential,
|
||||
saveTtsCredential,
|
||||
searchFamiliarOSMemories,
|
||||
searchKnowledgeStore,
|
||||
speakTts,
|
||||
stopTts,
|
||||
storeFamiliarOSMemory,
|
||||
storeKnowledgeFile,
|
||||
type PluginConfigSoundPickResult,
|
||||
type PluginServiceResult,
|
||||
type TtsProviderId,
|
||||
updateFamiliarOSMemory,
|
||||
} from "./control-center-data-services.js";
|
||||
import {
|
||||
checkForGitHubReleaseUpdate,
|
||||
debug,
|
||||
getActiveLocale,
|
||||
getAppStateSnapshot,
|
||||
getCatalogPageUiState,
|
||||
getCatalogSearchUiState,
|
||||
getCatalogUiState,
|
||||
getDashboardSnapshot,
|
||||
getI18nSnapshot,
|
||||
getLaunchAtLoginState,
|
||||
getPetsStateSnapshot,
|
||||
getReactionAnimationSettingsSnapshot,
|
||||
getSettingsStateSnapshot,
|
||||
getUpdateStatus,
|
||||
isPlainObject,
|
||||
logError,
|
||||
openUpdateReleasePage,
|
||||
setLocaleFromPreference,
|
||||
updatePreferences,
|
||||
validateExternalUrl,
|
||||
validatePreferencePatch,
|
||||
warn,
|
||||
} from "./control-center-core-services.js";
|
||||
import {
|
||||
installPet,
|
||||
installPetFromFolder,
|
||||
installPetFromZipFile,
|
||||
recoverDefaultPetMouseInterop,
|
||||
refreshDefaultPetContent,
|
||||
removePet,
|
||||
resetDefaultPetToInitialPosition,
|
||||
setDefaultInstalledPet,
|
||||
} from "./control-center-pet-services.js";
|
||||
|
||||
type InternalUiWindowKind = "control-center";
|
||||
type ControlCenterWindowGetter = () => BrowserWindow | null;
|
||||
|
||||
const pluginIdPattern = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/;
|
||||
const pluginCommandIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
|
||||
|
||||
let internalUiHandlersInstalled = false;
|
||||
|
||||
async function loadCodexPetsModule() {
|
||||
return import("./codex-familiars.js");
|
||||
}
|
||||
|
||||
export function installInternalUiHandlers(getControlCenterWindow: ControlCenterWindowGetter): void {
|
||||
if (internalUiHandlersInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
internalUiHandlersInstalled = true;
|
||||
|
||||
ipcMain.handle("familiaros:get-familiars-state", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getPetsStateSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-settings-state", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getSettingsStateSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-i18n", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getI18nSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-dashboard-snapshot", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getDashboardSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-openapi-chat-settings", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getOpenApiChatSettingsSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:save-openapi-credential", (event, apiKey: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof apiKey !== "string") throw new Error("Invalid chat credential.");
|
||||
return saveOpenApiCredential(apiKey);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:clear-openapi-credential", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return clearOpenApiCredential();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:install-mcp-toolkit", async (event, target: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (target !== "claude-user" && target !== "codex-global") {
|
||||
throw new Error("Invalid MCP toolkit target.");
|
||||
}
|
||||
return installPersistentToolkit(target as McpToolkitPersistentTarget);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-vanilla-chat-mcp-tools", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const state = getAppStateSnapshot();
|
||||
return {
|
||||
enabled: state.preferences.vanillaChatMcpTools ?? [],
|
||||
available: listMcpChatVanillaServerIds(),
|
||||
active: getMcpChatClientManager().getActiveServerIds(),
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:set-vanilla-chat-mcp-tools", async (event, toolIds: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!Array.isArray(toolIds) || !toolIds.every((id) => typeof id === "string")) {
|
||||
throw new Error("Invalid vanilla chat MCP tool list.");
|
||||
}
|
||||
const validIds = new Set(listMcpChatVanillaServerIds());
|
||||
const sanitized = toolIds.filter((id: string) => validIds.has(id));
|
||||
updatePreferences({ vanillaChatMcpTools: sanitized });
|
||||
await getMcpChatClientManager().startEnabledServers(sanitized);
|
||||
return getAppStateSnapshot().preferences.vanillaChatMcpTools ?? [];
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-memories", async (event, query: unknown, limit: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const q = typeof query === "string" ? query.trim() : "";
|
||||
const l = typeof limit === "number" && Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.round(limit))) : 50;
|
||||
if (q) return searchFamiliarOSMemories(q, l).map((hit) => hit.entry);
|
||||
return listFamiliarOSMemories(l);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:store-memory", async (event, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
|
||||
return storeFamiliarOSMemory({
|
||||
text: text.trim(),
|
||||
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
|
||||
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
|
||||
importance: typeof importance === "number" ? importance : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:update-memory", async (event, id: unknown, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof id !== "string" || !id.trim()) throw new Error("Memory id is required.");
|
||||
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
|
||||
const result = updateFamiliarOSMemory(id.trim(), {
|
||||
text: text.trim(),
|
||||
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
|
||||
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
|
||||
importance: typeof importance === "number" ? importance : undefined,
|
||||
});
|
||||
if (!result) throw new Error("Memory not found.");
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:delete-memory", async (event, id: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof id !== "string" || !id.trim()) throw new Error("Memory id is required.");
|
||||
const ok = forgetFamiliarOSMemory(id.trim());
|
||||
if (!ok) throw new Error("Memory not found.");
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:knowledge-list", async (event, limit: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const l = typeof limit === "number" && Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.round(limit))) : 50;
|
||||
return listKnowledgeFiles(l);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:knowledge-search", async (event, query: unknown, limit: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const q = typeof query === "string" ? query.trim() : "";
|
||||
const l = typeof limit === "number" && Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.round(limit))) : 10;
|
||||
if (!q) {
|
||||
return {
|
||||
files: listKnowledgeFiles(l).map((file) => ({ file, score: 0 })),
|
||||
memories: [],
|
||||
};
|
||||
}
|
||||
return searchKnowledgeStore(q, l);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:knowledge-delete-file", async (event, id: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof id !== "string" || !id.trim()) throw new Error("File id is required.");
|
||||
const ok = await deleteKnowledgeFile(id.trim());
|
||||
if (!ok) throw new Error("File not found.");
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:knowledge-add-memory", async (event, text: unknown, kind: unknown, tags: unknown, importance: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof text !== "string" || !text.trim()) throw new Error("Memory text is required.");
|
||||
return addKnowledgeMemory({
|
||||
text: text.trim(),
|
||||
kind: typeof kind === "string" ? kind as "identity" | "preference" | "fact" | "note" : undefined,
|
||||
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : undefined,
|
||||
importance: typeof importance === "number" ? importance : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:knowledge-store-file", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined;
|
||||
const options: OpenDialogOptions = {
|
||||
title: "Add file to Knowledge Store",
|
||||
buttonLabel: "Add",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "All files", extensions: ["*"] }],
|
||||
};
|
||||
const result = owner ? await dialog.showOpenDialog(owner, options) : await dialog.showOpenDialog(options);
|
||||
if (result.canceled || !result.filePaths[0]) {
|
||||
return null;
|
||||
}
|
||||
const filePath = result.filePaths[0];
|
||||
const stats = await stat(filePath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error("Selected path is not a file.");
|
||||
}
|
||||
const data = await readFile(filePath);
|
||||
const name = filePath.replace(/\\/g, "/").split("/").pop() ?? "unnamed";
|
||||
return storeKnowledgeFile({ name, data });
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-reaction-animation-settings", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getReactionAnimationSettingsSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-snapshot", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getPluginService().getSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-set-enabled", async (event, id: unknown, enabled: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id) || typeof enabled !== "boolean") return pluginUiError("Invalid plugin enable request.");
|
||||
return getPluginService().setEnabled(id, enabled);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-save-config", async (event, id: unknown, config: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id) || !isPlainObject(config)) return pluginUiError("Invalid plugin config request.");
|
||||
return getPluginService().saveConfig(id, config);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-pick-config-sound", async (event, id: unknown): Promise<PluginConfigSoundPickResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) {
|
||||
warn("ui", "Plugin sound pick invalid request.", { ok: false, reason: "invalid-plugin-id" });
|
||||
return pluginUiSoundError("Invalid plugin sound request.");
|
||||
}
|
||||
debug("ui", "Plugin sound pick requested.", { pluginId: id });
|
||||
try {
|
||||
const result = await getPluginService().pickConfigSound(id);
|
||||
if (result.ok && "sound" in result && result.sound.id) debug("ui", "Plugin sound pick succeeded.", { pluginId: id, ok: true, soundId: result.sound.id });
|
||||
else if (result.ok) debug("ui", "Plugin sound pick canceled.", { pluginId: id, ok: true, canceled: true });
|
||||
else warn("ui", "Plugin sound pick failed.", { pluginId: id, ok: false, reason: result.error });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logError("ui", "Plugin sound pick errored.", { pluginId: id, ok: false, reason: error instanceof Error ? error.message : "unknown" });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-reload", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin reload request.");
|
||||
return getPluginService().reload(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-execute-command", async (event, id: unknown, commandId: unknown, args: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id) || !isValidPluginCommandId(commandId) || (args !== undefined && !isPlainObject(args))) return pluginUiError("Invalid plugin command request.");
|
||||
return getPluginService().executeCommand(id, commandId, isPlainObject(args) ? args as Record<string, unknown> : undefined);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-load-local", async (event): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getPluginService().loadLocal();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-catalog-snapshot", async (event, refresh: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getPluginService().getCatalogSnapshot(refresh === true);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-install-catalog", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin install request.");
|
||||
return getPluginService().installCatalog(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-update-catalog", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin update request.");
|
||||
return getPluginService().updateCatalog(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-uninstall", async (event, id: unknown): Promise<PluginServiceResult> => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) return pluginUiError("Invalid plugin uninstall request.");
|
||||
return getPluginService().uninstall(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugins-inspector", async (event, id: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isValidPluginId(id)) throw new Error("Invalid plugin inspector request.");
|
||||
return getPluginService().runtime.getInspectorState(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugin-platform-settings-get", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const { getPluginPlatformSettings } = await import("./plugin-platform-settings.js");
|
||||
return getPluginPlatformSettings();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugin-platform-settings-update", async (event, patch: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (!isPlainObject(patch)) throw new Error("Invalid plugin platform settings patch.");
|
||||
const { updatePluginPlatformSettings } = await import("./plugin-platform-settings.js");
|
||||
return updatePluginPlatformSettings(patch as never);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugin-platform-ai-key-set", async (event, key: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const { getPluginHostCapabilitiesForUi } = await import("./plugin-host-capabilities.js");
|
||||
const { hostSecretsOwner, hostAiApiKeySecret } = await import("./plugin-ai-gateway.js");
|
||||
const capabilities = getPluginHostCapabilitiesForUi();
|
||||
if (!capabilities) throw new Error("Plugin host capabilities are unavailable.");
|
||||
if (key === null || key === "") {
|
||||
await capabilities.secretsStore.delete(hostSecretsOwner, hostAiApiKeySecret);
|
||||
return { ok: true, hasKey: false };
|
||||
}
|
||||
if (typeof key !== "string" || key.length > 4096) throw new Error("Invalid AI API key.");
|
||||
await capabilities.secretsStore.set(hostSecretsOwner, hostAiApiKeySecret, key);
|
||||
return { ok: true, hasKey: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:plugin-platform-ai-key-status", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const { getPluginHostCapabilitiesForUi } = await import("./plugin-host-capabilities.js");
|
||||
const { hostSecretsOwner, hostAiApiKeySecret } = await import("./plugin-ai-gateway.js");
|
||||
const capabilities = getPluginHostCapabilitiesForUi();
|
||||
if (!capabilities) return { hasKey: false };
|
||||
return { hasKey: await capabilities.secretsStore.has(hostSecretsOwner, hostAiApiKeySecret) };
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-catalog", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getCatalogUiState();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-catalog-page", async (event, page: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof page !== "number" || !Number.isInteger(page) || page < 0) throw new Error("Invalid catalog page.");
|
||||
return getCatalogPageUiState(page);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-catalog-search", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getCatalogSearchUiState();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-codex-familiars", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return (await loadCodexPetsModule()).getCodexPetsUiState();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:update-preferences", (event, patch: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const currentState = getAppStateSnapshot();
|
||||
const previousScale = currentState.preferences.petScale;
|
||||
const previousOverrides = JSON.stringify(currentState.preferences.reactionAnimationOverrides ?? {});
|
||||
const previousLocale = getActiveLocale();
|
||||
const state = updatePreferences(validatePreferencePatch(patch));
|
||||
if (currentState.preferences.openApiChatModel !== state.preferences.openApiChatModel
|
||||
|| currentState.preferences.openApiChatEndpoint !== state.preferences.openApiChatEndpoint) {
|
||||
resetOpenApiConversationContext();
|
||||
}
|
||||
const nextOverrides = JSON.stringify(state.preferences.reactionAnimationOverrides ?? {});
|
||||
if (state.preferences.petScale !== previousScale || nextOverrides !== previousOverrides) {
|
||||
refreshDefaultPetContent();
|
||||
refreshAgentPetContent();
|
||||
}
|
||||
const nextLocale = setLocaleFromPreference(state.preferences.locale);
|
||||
if (nextLocale !== previousLocale || state.preferences.familiarName !== currentState.preferences.familiarName) {
|
||||
void import("./tray.js").then(({ refreshTrayMenu }) => refreshTrayMenu());
|
||||
if (nextLocale !== previousLocale) {
|
||||
broadcastPluginRecordsRefresh(getControlCenterWindow);
|
||||
}
|
||||
}
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getSettingsStateSnapshot() : state;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-launch-at-login", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getLaunchAtLoginState();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:set-launch-at-login", (event, enabled: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof enabled !== "boolean") throw new Error("Invalid launch-at-login value.");
|
||||
if (!isLaunchAtLoginSupported()) return getLaunchAtLoginState();
|
||||
app.setLoginItemSettings({ openAtLogin: enabled, openAsHidden: true });
|
||||
return getLaunchAtLoginState();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-update-status", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getUpdateStatus();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:check-for-updates", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const status = await checkForGitHubReleaseUpdate();
|
||||
const { refreshTrayMenu } = await import("./tray.js");
|
||||
refreshTrayMenu();
|
||||
return status;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:open-update-release-page", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
await openUpdateReleasePage();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:copy-text", (event, text: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof text !== "string" || !text.trim()) throw new Error("Invalid copy payload.");
|
||||
clipboard.writeText(text);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:open-external-url", async (event, rawUrl: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const url = validateExternalUrl(rawUrl);
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:set-default-familiar", async (event, petId: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof petId !== "string") {
|
||||
throw new Error("Invalid familiar id.");
|
||||
}
|
||||
|
||||
const state = await setDefaultInstalledPet(petId);
|
||||
refreshDefaultPetContent();
|
||||
recoverDefaultPetMouseInterop("default-familiar-changed");
|
||||
setTimeout(() => recoverDefaultPetMouseInterop("default-familiar-changed+500ms"), 500).unref?.();
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getPetsStateSnapshot() : state;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:install-familiar", async (event, petId: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof petId !== "string") {
|
||||
throw new Error("Invalid familiar id.");
|
||||
}
|
||||
|
||||
const state = await installPet(petId);
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getPetsStateSnapshot() : state;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:install-local-familiar", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined;
|
||||
const importKind = await chooseLocalPetImportKind(owner);
|
||||
if (!importKind) return getPetsStateSnapshot();
|
||||
const options: OpenDialogOptions = importKind === "zip" ? {
|
||||
title: "Install familiar from ZIP",
|
||||
buttonLabel: "Install Familiar",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "FamiliarOS ZIP", extensions: ["zip"] }],
|
||||
} : {
|
||||
title: "Install familiar from folder",
|
||||
buttonLabel: "Install Familiar",
|
||||
properties: ["openDirectory"],
|
||||
};
|
||||
const result = owner ? await dialog.showOpenDialog(owner, options) : await dialog.showOpenDialog(options);
|
||||
if (result.canceled || !result.filePaths[0]) return getPetsStateSnapshot();
|
||||
const selectedPath = result.filePaths[0];
|
||||
try {
|
||||
const selectedStats = await stat(selectedPath);
|
||||
const state = selectedStats.isDirectory() ? await installPetFromFolder(selectedPath) : await installPetFromZipFile(selectedPath);
|
||||
debug("ui", "local familiar import succeeded", { kind: selectedStats.isDirectory() ? "folder" : "zip" });
|
||||
refreshDefaultPetContent();
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getPetsStateSnapshot() : state;
|
||||
} catch (error) {
|
||||
logError("ui", "local familiar import failed", { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:open-gallery", async (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
await shell.openExternal("https://familiaros.dev/gallery");
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:import-codex-familiar", async (event, petId: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof petId !== "string") {
|
||||
throw new Error("Invalid familiar id.");
|
||||
}
|
||||
|
||||
const state = await (await loadCodexPetsModule()).importCodexPet(petId);
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getPetsStateSnapshot() : state;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:remove-familiar", async (event, petId: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof petId !== "string") {
|
||||
throw new Error("Invalid familiar id.");
|
||||
}
|
||||
|
||||
const state = await removePet(petId);
|
||||
refreshDefaultPetContent();
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getPetsStateSnapshot() : state;
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:reset-default-familiar-position", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
resetDefaultPetToInitialPosition();
|
||||
return isControlCenterSender(getControlCenterWindow, event.sender.id) ? getSettingsStateSnapshot() : getAppStateSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:agent-setup-snapshot", async (event, selectedPetId: unknown, commandMode: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getAgentSetupSnapshot(selectedPetId, commandMode);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:agent-setup-action", async (event, action: unknown, selectedPetId: unknown, commandMode: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (action !== "configure" && action !== "replace" && action !== "remove" && action !== "install-memory" && action !== "doctor-hooks" && action !== "install-hooks" && action !== "uninstall-hooks" && action !== "opencode-install" && action !== "opencode-remove" && action !== "cursor-install" && action !== "cursor-replace" && action !== "cursor-remove") {
|
||||
throw new Error("Invalid agent setup action.");
|
||||
}
|
||||
|
||||
return runAgentSetupAction(action, selectedPetId, commandMode);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:agent-setup-command-paths", (event, patch: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return updateAgentSetupCommandPaths(patch);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:familiaros-mcp-server-preview", (event, selectedPetId: unknown, commandMode: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const petId = typeof selectedPetId === "string" && selectedPetId.trim() ? selectedPetId.trim() : undefined;
|
||||
const mode = typeof commandMode === "string" && (commandMode === "published" || commandMode === "bundled" || commandMode === "local") ? commandMode : "published";
|
||||
return buildFamiliarOSMcpServerPreview(petId, mode);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:test-familiaros-mcp-server", async (event, selectedPetId: unknown, commandMode: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
const petId = typeof selectedPetId === "string" && selectedPetId.trim() ? selectedPetId.trim() : undefined;
|
||||
const mode = typeof commandMode === "string" && (commandMode === "published" || commandMode === "bundled" || commandMode === "local") ? commandMode : "published";
|
||||
return testFamiliarOSMcpServer(petId, mode);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-tts-settings", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
return getTtsSettingsSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:get-tts-voices", async (event, provider: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof provider !== "string") throw new Error("Provider must be a string.");
|
||||
return fetchTtsVoiceList(provider as TtsProviderId);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:save-tts-credential", (event, provider: unknown, credential: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof provider !== "string") throw new Error("Provider must be a string.");
|
||||
if (typeof credential !== "string") throw new Error("Credential must be a string.");
|
||||
return saveTtsCredential(provider, credential);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:clear-tts-credential", (event, provider: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof provider !== "string") throw new Error("Provider must be a string.");
|
||||
return clearTtsCredential(provider);
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:test-tts", async (event, text: unknown) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
if (typeof text !== "string" || !text.trim()) throw new Error("Text must be a non-empty string.");
|
||||
await speakTts(text.trim(), "familiar");
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("familiaros:tts-stop", (event) => {
|
||||
assertAllowedSender(getControlCenterWindow, event, ["control-center"]);
|
||||
stopTts("familiar");
|
||||
});
|
||||
}
|
||||
|
||||
function assertAllowedSender(getControlCenterWindow: ControlCenterWindowGetter, event: IpcMainInvokeEvent, allowedKinds: readonly InternalUiWindowKind[]): void {
|
||||
const actualKind = getInternalUiWindowKindForWebContents(getControlCenterWindow, event.sender.id);
|
||||
if (!actualKind || !allowedKinds.includes(actualKind)) {
|
||||
throw new Error("FamiliarOS internal UI request came from an unexpected window.");
|
||||
}
|
||||
}
|
||||
|
||||
function isControlCenterSender(getControlCenterWindow: ControlCenterWindowGetter, webContentsId: number): boolean {
|
||||
return getInternalUiWindowKindForWebContents(getControlCenterWindow, webContentsId) === "control-center";
|
||||
}
|
||||
|
||||
function getInternalUiWindowKindForWebContents(getControlCenterWindow: ControlCenterWindowGetter, webContentsId: number): InternalUiWindowKind | null {
|
||||
const controlCenterWindow = getControlCenterWindow();
|
||||
if (controlCenterWindow && !controlCenterWindow.isDestroyed() && controlCenterWindow.webContents.id === webContentsId) {
|
||||
return "control-center";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function broadcastPluginRecordsRefresh(getControlCenterWindow: ControlCenterWindowGetter): void {
|
||||
const controlCenterWindow = getControlCenterWindow();
|
||||
if (controlCenterWindow && !controlCenterWindow.isDestroyed()) {
|
||||
controlCenterWindow.webContents.send("familiaros:plugins-refresh");
|
||||
}
|
||||
}
|
||||
|
||||
function chooseLocalPetImportKind(owner: BrowserWindow | undefined): Promise<"zip" | "folder" | null> {
|
||||
const options = {
|
||||
type: "question" as const,
|
||||
title: "Install familiar",
|
||||
message: "Install familiar from ZIP or folder?",
|
||||
detail: "Choose the source type before selecting the familiar package.",
|
||||
buttons: ["ZIP", "Folder", "Cancel"],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
noLink: true,
|
||||
};
|
||||
return (owner ? dialog.showMessageBox(owner, options) : dialog.showMessageBox(options)).then((result) => {
|
||||
if (result.response === 0) return "zip";
|
||||
if (result.response === 1) return "folder";
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function pluginUiError(error: string): PluginServiceResult {
|
||||
return { ok: false, error, snapshot: { plugins: [] } };
|
||||
}
|
||||
|
||||
function pluginUiSoundError(error: string): PluginConfigSoundPickResult {
|
||||
return { ok: false, error, snapshot: { plugins: [] } };
|
||||
}
|
||||
|
||||
function isValidPluginId(value: unknown): value is string {
|
||||
return typeof value === "string" && pluginIdPattern.test(value);
|
||||
}
|
||||
|
||||
function isValidPluginCommandId(value: unknown): value is string {
|
||||
return typeof value === "string" && pluginCommandIdPattern.test(value);
|
||||
}
|
||||
|
||||
function isLaunchAtLoginSupported(): boolean {
|
||||
return process.platform === "darwin" || process.platform === "win32";
|
||||
}
|
||||
12
apps/desktop/src/control-center-pet-services.ts
Normal file
12
apps/desktop/src/control-center-pet-services.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export {
|
||||
recoverDefaultPetMouseInterop,
|
||||
refreshDefaultPetContent,
|
||||
resetDefaultPetToInitialPosition,
|
||||
} from "./default-familiar-controller.js";
|
||||
export {
|
||||
installPet,
|
||||
installPetFromFolder,
|
||||
installPetFromZipFile,
|
||||
removePet,
|
||||
setDefaultInstalledPet,
|
||||
} from "./familiar-installation.js";
|
||||
363
apps/desktop/src/control-center-state.ts
Normal file
363
apps/desktop/src/control-center-state.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { app } from "electron";
|
||||
|
||||
import { getAppStateSnapshot, normalizeFamiliarName, normalizeOpenApiChatEndpoint, normalizePetScale, petScaleOptions } from "./app-state.js";
|
||||
import { getCatalogUiState } from "./catalog.js";
|
||||
import { getInstalledPetDir } from "./familiar-paths.js";
|
||||
import { getActiveLocale, getActiveMessages, isSupportedLocale, LOCALE_LABELS, SUPPORTED_LOCALES, t, type Locale, type LocalePreference } from "./i18n/index.js";
|
||||
import { warn } from "./logger.js";
|
||||
import { getOpenApiChatSettingsSnapshot, normalizeChatModel } from "./openapi-chat.js";
|
||||
import { getPluginService } from "./plugin-service.js";
|
||||
import { defaultPetSprite, reactionAnimationMetadata, selectableAnimationMetadata, validateReactionAnimationOverrides } from "./reaction-animation-mapping.js";
|
||||
import { listTtsProviders } from "./tts-engine.js";
|
||||
import { type TtsProviderId } from "./tts-service.js";
|
||||
import { getUpdateStatus } from "./update-checker.js";
|
||||
|
||||
type ControlCenterSettingsPreferences = Pick<
|
||||
ReturnType<typeof getAppStateSnapshot>["preferences"],
|
||||
| "openDefaultPetOnLaunch"
|
||||
| "locale"
|
||||
| "petScale"
|
||||
| "reactionAnimationOverrides"
|
||||
| "openApiChatModel"
|
||||
| "openApiChatSystemPrompt"
|
||||
| "openApiChatEndpoint"
|
||||
| "openApiChatTheme"
|
||||
| "vanillaChatMcpTools"
|
||||
| "openApiChatBaseInstructionsEnabled"
|
||||
| "ttsProvider"
|
||||
| "ttsVoice"
|
||||
| "ttsSpeed"
|
||||
| "ttsModel"
|
||||
| "ttsEndpointPreset"
|
||||
| "ttsEndpoint"
|
||||
| "familiarName"
|
||||
>;
|
||||
|
||||
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
||||
|
||||
export type ControlCenterPreferencesPatch = Partial<Mutable<ReturnType<typeof getAppStateSnapshot>["preferences"]>>;
|
||||
|
||||
export function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; familiars: ReturnType<typeof getAppStateSnapshot>["familiars"] } {
|
||||
const state = getAppStateSnapshot();
|
||||
return { preferences: { defaultPetId: state.preferences.defaultPetId }, familiars: state.familiars };
|
||||
}
|
||||
|
||||
export function getSettingsStateSnapshot(): {
|
||||
preferences: ControlCenterSettingsPreferences;
|
||||
petScaleOptions: typeof petScaleOptions;
|
||||
openApiChat: ReturnType<typeof getOpenApiChatSettingsSnapshot>;
|
||||
} {
|
||||
const state = getAppStateSnapshot();
|
||||
return {
|
||||
preferences: {
|
||||
openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch,
|
||||
locale: state.preferences.locale,
|
||||
petScale: state.preferences.petScale,
|
||||
reactionAnimationOverrides: state.preferences.reactionAnimationOverrides,
|
||||
openApiChatModel: state.preferences.openApiChatModel,
|
||||
openApiChatSystemPrompt: state.preferences.openApiChatSystemPrompt,
|
||||
openApiChatEndpoint: state.preferences.openApiChatEndpoint,
|
||||
openApiChatTheme: state.preferences.openApiChatTheme,
|
||||
vanillaChatMcpTools: state.preferences.vanillaChatMcpTools,
|
||||
openApiChatBaseInstructionsEnabled: state.preferences.openApiChatBaseInstructionsEnabled,
|
||||
ttsProvider: state.preferences.ttsProvider,
|
||||
ttsVoice: state.preferences.ttsVoice,
|
||||
ttsSpeed: state.preferences.ttsSpeed,
|
||||
ttsModel: state.preferences.ttsModel,
|
||||
ttsEndpointPreset: state.preferences.ttsEndpointPreset,
|
||||
ttsEndpoint: state.preferences.ttsEndpoint,
|
||||
familiarName: state.preferences.familiarName,
|
||||
},
|
||||
petScaleOptions,
|
||||
openApiChat: getOpenApiChatSettingsSnapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getI18nSnapshot(): {
|
||||
locale: Locale;
|
||||
localePreference: LocalePreference;
|
||||
availableLocales: { value: Locale; label: string }[];
|
||||
messages: ReturnType<typeof getActiveMessages>;
|
||||
} {
|
||||
return {
|
||||
locale: getActiveLocale(),
|
||||
localePreference: getAppStateSnapshot().preferences.locale,
|
||||
availableLocales: SUPPORTED_LOCALES.map((value) => ({ value, label: LOCALE_LABELS[value] })),
|
||||
messages: getActiveMessages(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardSnapshot(): Promise<{
|
||||
readonly defaultPet: { readonly id: string; readonly displayName: string; readonly previewSpriteUrl: string };
|
||||
readonly installedPetCount: number;
|
||||
readonly catalog: { readonly source: string; readonly total?: number; readonly page?: number; readonly pageCount?: number; readonly error?: string };
|
||||
readonly plugins: { readonly installed: number; readonly enabled: number; readonly broken: number };
|
||||
readonly updateStatus: ReturnType<typeof getUpdateStatus>;
|
||||
readonly activity: ReturnType<typeof getAppStateSnapshot>["analytics"];
|
||||
}> {
|
||||
const state = getAppStateSnapshot();
|
||||
const defaultPet = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId && !familiar.broken) ?? state.familiars.installed[0];
|
||||
const preview = await getDefaultPetPreviewSpriteInfo();
|
||||
const catalog = await getCatalogUiState().catch((error: unknown) => ({ source: "error" as const, familiars: [], total: undefined, page: undefined, pageCount: undefined, error: error instanceof Error ? error.message : "Catalog unavailable." }));
|
||||
const pluginSnapshot = await getPluginService().getSnapshot().catch((error: unknown) => {
|
||||
warn("ui", "dashboard plugin snapshot unavailable", { error: error instanceof Error ? error.message : String(error) });
|
||||
return { plugins: [] } as const;
|
||||
});
|
||||
const installedPlugins = pluginSnapshot.plugins.length;
|
||||
const brokenPlugins = pluginSnapshot.plugins.filter((plugin) => Boolean(plugin.brokenReason)).length;
|
||||
const enabledPlugins = pluginSnapshot.plugins.filter((plugin) => plugin.enabled && !plugin.brokenReason).length;
|
||||
|
||||
return {
|
||||
defaultPet: {
|
||||
id: defaultPet?.id ?? state.preferences.defaultPetId,
|
||||
displayName: defaultPet?.displayName ?? "FamiliarOS",
|
||||
previewSpriteUrl: `familiaros-familiar-preview://spritesheet/default?v=${encodeURIComponent(preview.version)}`,
|
||||
},
|
||||
installedPetCount: state.familiars.installed.length,
|
||||
catalog: {
|
||||
source: catalog.source,
|
||||
total: catalog.total,
|
||||
page: catalog.page,
|
||||
pageCount: catalog.pageCount,
|
||||
error: catalog.error,
|
||||
},
|
||||
plugins: {
|
||||
installed: installedPlugins,
|
||||
enabled: enabledPlugins,
|
||||
broken: brokenPlugins,
|
||||
},
|
||||
updateStatus: getUpdateStatus(),
|
||||
activity: state.analytics,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getReactionAnimationSettingsSnapshot(): Promise<unknown> {
|
||||
const state = getAppStateSnapshot();
|
||||
const preview = await getDefaultPetPreviewSpriteInfo();
|
||||
return {
|
||||
reactions: reactionAnimationMetadata.map((reaction) => ({
|
||||
...reaction,
|
||||
label: t(`settings.reaction.${reaction.id}.label`),
|
||||
description: t(`settings.reaction.${reaction.id}.description`),
|
||||
})),
|
||||
animations: selectableAnimationMetadata.map((animation) => ({
|
||||
...animation,
|
||||
label: t(`settings.animation.${animation.id}.label`),
|
||||
description: t(`settings.animation.${animation.id}.description`),
|
||||
})),
|
||||
sprite: defaultPetSprite,
|
||||
overrides: state.preferences.reactionAnimationOverrides ?? {},
|
||||
previewSpriteUrl: `familiaros-familiar-preview://spritesheet/default?v=${encodeURIComponent(preview.version)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDefaultPetPreviewSpriteInfo(): Promise<{ readonly path: string; readonly version: string }> {
|
||||
const state = getAppStateSnapshot();
|
||||
const selected = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId);
|
||||
const builtInPath = join(app.getAppPath(), "assets", defaultPetSprite.fileName);
|
||||
const candidatePath = selected && !selected.broken && !selected.builtIn
|
||||
? join(getInstalledPetDir(selected.id), "spritesheet.webp")
|
||||
: builtInPath;
|
||||
try {
|
||||
const spritesheet = await stat(candidatePath);
|
||||
if (spritesheet.isFile() && spritesheet.size > 0 && spritesheet.size <= 100 * 1024 * 1024) {
|
||||
return { path: candidatePath, version: `${selected?.id ?? "builtin"}-${Math.round(spritesheet.mtimeMs)}-${spritesheet.size}` };
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the bundled familiar if an installed default disappears while Settings is open.
|
||||
}
|
||||
const fallback = await stat(builtInPath);
|
||||
return { path: builtInPath, version: `builtin-${Math.round(fallback.mtimeMs)}-${fallback.size}` };
|
||||
}
|
||||
|
||||
export function validatePreferencePatch(value: unknown): ControlCenterPreferencesPatch {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid preferences patch.");
|
||||
}
|
||||
|
||||
const patch: ControlCenterPreferencesPatch = {};
|
||||
|
||||
if ("openDefaultPetOnLaunch" in value) {
|
||||
if (typeof value.openDefaultPetOnLaunch !== "boolean") throw new Error("Invalid open-on-launch value.");
|
||||
patch.openDefaultPetOnLaunch = value.openDefaultPetOnLaunch;
|
||||
}
|
||||
|
||||
if ("locale" in value) {
|
||||
if (value.locale !== "system" && !isSupportedLocale(value.locale)) throw new Error("Invalid locale value.");
|
||||
patch.locale = value.locale;
|
||||
}
|
||||
|
||||
if ("petScale" in value) {
|
||||
const scale = normalizePetScale(value.petScale);
|
||||
if (scale !== value.petScale) throw new Error("Invalid familiar scale value.");
|
||||
patch.petScale = scale;
|
||||
}
|
||||
|
||||
if ("reactionAnimationOverrides" in value) {
|
||||
patch.reactionAnimationOverrides = validateReactionAnimationOverrides(value.reactionAnimationOverrides);
|
||||
}
|
||||
|
||||
if ("openApiChatModel" in value) {
|
||||
if (value.openApiChatModel === undefined || value.openApiChatModel === null || value.openApiChatModel === "") {
|
||||
patch.openApiChatModel = undefined;
|
||||
} else {
|
||||
const model = normalizeChatModel(value.openApiChatModel);
|
||||
if (!model) throw new Error("Invalid model name.");
|
||||
patch.openApiChatModel = model;
|
||||
}
|
||||
}
|
||||
|
||||
if ("openApiChatSystemPrompt" in value) {
|
||||
if (value.openApiChatSystemPrompt === undefined || value.openApiChatSystemPrompt === null || value.openApiChatSystemPrompt === "") {
|
||||
patch.openApiChatSystemPrompt = undefined;
|
||||
} else if (typeof value.openApiChatSystemPrompt === "string") {
|
||||
patch.openApiChatSystemPrompt = value.openApiChatSystemPrompt;
|
||||
} else {
|
||||
throw new Error("Invalid familiar character prompt.");
|
||||
}
|
||||
}
|
||||
|
||||
if ("openApiChatEndpoint" in value) {
|
||||
if (value.openApiChatEndpoint === undefined || value.openApiChatEndpoint === null || value.openApiChatEndpoint === "") {
|
||||
patch.openApiChatEndpoint = undefined;
|
||||
} else {
|
||||
const endpoint = normalizeOpenApiChatEndpoint(value.openApiChatEndpoint);
|
||||
if (!endpoint) {
|
||||
throw new Error("Invalid OpenAPI-compatible endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full /responses or /chat/completions URL.");
|
||||
}
|
||||
patch.openApiChatEndpoint = endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
if ("openApiChatTheme" in value) {
|
||||
if (value.openApiChatTheme !== "system" && value.openApiChatTheme !== "light" && value.openApiChatTheme !== "dark") {
|
||||
throw new Error("Invalid theme mode.");
|
||||
}
|
||||
patch.openApiChatTheme = value.openApiChatTheme;
|
||||
}
|
||||
|
||||
if ("openApiChatBaseInstructionsEnabled" in value) {
|
||||
if (typeof value.openApiChatBaseInstructionsEnabled !== "boolean") {
|
||||
throw new Error("Invalid base instructions value.");
|
||||
}
|
||||
patch.openApiChatBaseInstructionsEnabled = value.openApiChatBaseInstructionsEnabled;
|
||||
}
|
||||
|
||||
if ("ttsProvider" in value) {
|
||||
const validIds = listTtsProviders().map((provider) => provider.id);
|
||||
if (!validIds.includes(value.ttsProvider as TtsProviderId)) {
|
||||
throw new Error("Invalid TTS provider.");
|
||||
}
|
||||
patch.ttsProvider = value.ttsProvider as TtsProviderId;
|
||||
}
|
||||
|
||||
if ("ttsVoice" in value) {
|
||||
if (value.ttsVoice === undefined || value.ttsVoice === null || value.ttsVoice === "") {
|
||||
patch.ttsVoice = undefined;
|
||||
} else if (typeof value.ttsVoice === "string") {
|
||||
patch.ttsVoice = value.ttsVoice;
|
||||
} else {
|
||||
throw new Error("Invalid TTS voice.");
|
||||
}
|
||||
}
|
||||
|
||||
if ("ttsSpeed" in value) {
|
||||
const speed = Number(value.ttsSpeed);
|
||||
if (!Number.isFinite(speed) || speed < 0.5 || speed > 2) {
|
||||
throw new Error("Invalid TTS speed. Must be between 0.5 and 2.");
|
||||
}
|
||||
patch.ttsSpeed = Math.round(speed * 10) / 10;
|
||||
}
|
||||
|
||||
if ("ttsModel" in value) {
|
||||
if (value.ttsModel === undefined || value.ttsModel === null || value.ttsModel === "") {
|
||||
patch.ttsModel = undefined;
|
||||
} else if (typeof value.ttsModel === "string") {
|
||||
const trimmed = value.ttsModel.trim();
|
||||
if (trimmed.length > 120) throw new Error("Invalid TTS model name.");
|
||||
patch.ttsModel = trimmed;
|
||||
} else {
|
||||
throw new Error("Invalid TTS model.");
|
||||
}
|
||||
}
|
||||
|
||||
if ("ttsEndpointPreset" in value) {
|
||||
if (value.ttsEndpointPreset !== "openrouter" && value.ttsEndpointPreset !== "litellm" && value.ttsEndpointPreset !== "wavespeedai" && value.ttsEndpointPreset !== "custom") {
|
||||
throw new Error("Invalid TTS endpoint preset.");
|
||||
}
|
||||
patch.ttsEndpointPreset = value.ttsEndpointPreset;
|
||||
}
|
||||
|
||||
if ("ttsEndpoint" in value) {
|
||||
if (value.ttsEndpoint === undefined || value.ttsEndpoint === null || value.ttsEndpoint === "") {
|
||||
patch.ttsEndpoint = undefined;
|
||||
} else {
|
||||
const endpoint = normalizeOpenApiChatEndpoint(value.ttsEndpoint);
|
||||
if (!endpoint) {
|
||||
throw new Error("Invalid TTS endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full URL.");
|
||||
}
|
||||
patch.ttsEndpoint = endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
if ("familiarName" in value) {
|
||||
if (value.familiarName === undefined || value.familiarName === null || value.familiarName === "") {
|
||||
patch.familiarName = undefined;
|
||||
} else if (typeof value.familiarName === "string") {
|
||||
const name = normalizeFamiliarName(value.familiarName);
|
||||
if (name === undefined) {
|
||||
throw new Error("Invalid Familiar name. Use up to 64 characters and avoid control characters.");
|
||||
}
|
||||
patch.familiarName = name;
|
||||
} else {
|
||||
throw new Error("Invalid Familiar name.");
|
||||
}
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function getLaunchAtLoginState(): { supported: boolean; enabled: boolean } {
|
||||
if (!isLaunchAtLoginSupported()) return { supported: false, enabled: false };
|
||||
return { supported: true, enabled: app.getLoginItemSettings().openAtLogin };
|
||||
}
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value) as unknown;
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
export function validateExternalUrl(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Invalid URL.");
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error("Invalid URL.");
|
||||
}
|
||||
|
||||
const isLocalHttp = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname);
|
||||
if ((parsed.protocol !== "https:" && !isLocalHttp)
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| parsed.hash) {
|
||||
throw new Error("Only https URLs, or localhost http URLs, are allowed.");
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function isLaunchAtLoginSupported(): boolean {
|
||||
return process.platform === "darwin" || process.platform === "win32";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
76
apps/desktop/src/plugin-host-audio.ts
Normal file
76
apps/desktop/src/plugin-host-audio.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { promises as fs } from "node:fs";
|
||||
import { basename, extname } from "node:path";
|
||||
|
||||
import { getDefaultPetWindowForPlugins } from "./default-familiar-controller.js";
|
||||
import { playPetWindowAudio, stopPetWindowAudio } from "./familiar-window.js";
|
||||
import { debug, warn } from "./logger.js";
|
||||
import type { PluginHostCapabilities } from "./plugin-sdk-bridge.js";
|
||||
import type { PluginPickedFileRegistry } from "./plugin-host-files.js";
|
||||
import { maxUserSoundBytes, UserSoundStore, userSoundMimeByExtension } from "./plugin-user-sound-store.js";
|
||||
|
||||
export function createPluginHostAudioApi(userSounds: UserSoundStore, pickedFiles: PluginPickedFileRegistry): PluginHostCapabilities["audio"] {
|
||||
return {
|
||||
async play(spec, volume) {
|
||||
const baseFields = {
|
||||
kind: spec.kind,
|
||||
pluginId: "pluginId" in spec ? spec.pluginId : undefined,
|
||||
soundId: "id" in spec ? spec.id : undefined,
|
||||
name: "name" in spec ? spec.name : undefined,
|
||||
};
|
||||
debug("plugin", "audio play requested", baseFields);
|
||||
const window = getDefaultPetWindowForPlugins();
|
||||
if (!window) {
|
||||
debug("plugin", "audio play skipped", { ...baseFields, reason: "no-familiar-window" });
|
||||
return;
|
||||
}
|
||||
if (spec.kind === "named") {
|
||||
playPetWindowAudio(window, { kind: "named", name: spec.name, volume });
|
||||
debug("plugin", "audio play started", baseFields);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sourcePath = spec.kind === "user-sound" ? await userSounds.resolvePath(spec.pluginId, spec.id) : spec.path;
|
||||
const stat = await fs.stat(sourcePath);
|
||||
const ext = extname(sourcePath).toLowerCase();
|
||||
if (!stat.isFile() || stat.size > maxUserSoundBytes) throw new Error("Plugin sound file is missing or too large.");
|
||||
const mime = userSoundMimeByExtension[ext];
|
||||
if (!mime) throw new Error("Plugin sound format is not supported.");
|
||||
debug("plugin", "audio play file ready", { ...baseFields, ext, sizeBytes: stat.size });
|
||||
const bytes = await fs.readFile(sourcePath);
|
||||
playPetWindowAudio(window, {
|
||||
kind: "data",
|
||||
dataUrl: `data:${mime};base64,${bytes.toString("base64")}`,
|
||||
volume,
|
||||
});
|
||||
debug("plugin", "audio play started", { ...baseFields, ext, sizeBytes: stat.size });
|
||||
} catch (error) {
|
||||
warn("plugin", "audio play failed", { ...baseFields, reason: error instanceof Error ? error.message : "unknown" });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async importUserSound(pluginId, fileId, opts) {
|
||||
const entry = pickedFiles.getEntry(fileId);
|
||||
if (!entry) throw new Error("Plugin file handle is invalid.");
|
||||
return userSounds.importFromPath(pluginId, entry.path, { name: opts?.name ?? entry.name });
|
||||
},
|
||||
async importUserSoundFromPath(pluginId, path, opts) {
|
||||
const fields: Record<string, unknown> = { pluginId, basename: basename(path), ext: extname(path).toLowerCase() };
|
||||
try {
|
||||
fields.sizeBytes = (await fs.stat(path)).size;
|
||||
} catch {
|
||||
fields.reason = "stat-unavailable";
|
||||
}
|
||||
debug("plugin", "user sound import requested", fields);
|
||||
const sound = await userSounds.importFromPath(pluginId, path, { name: opts?.name ?? basename(path) });
|
||||
debug("plugin", "user sound import succeeded", { pluginId, soundId: sound.id, name: sound.name });
|
||||
return sound;
|
||||
},
|
||||
async forgetUserSound(pluginId, ref) {
|
||||
await userSounds.forget(pluginId, ref);
|
||||
},
|
||||
async stop() {
|
||||
const window = getDefaultPetWindowForPlugins();
|
||||
if (window) stopPetWindowAudio(window);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,30 +1,33 @@
|
|||
import { promises as fs } from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { app, clipboard, dialog, nativeTheme, net, Notification, shell } from "electron";
|
||||
import { Notification } from "electron";
|
||||
|
||||
import { getDefaultPetWindowForPlugins } from "./default-familiar-controller.js";
|
||||
import { getActiveLocaleLang } from "./i18n/index.js";
|
||||
import { debug, warn } from "./logger.js";
|
||||
import { playPetWindowAudio, stopPetWindowAudio } from "./familiar-window.js";
|
||||
import { PluginAiGateway } from "./plugin-ai-gateway.js";
|
||||
import { readDroppedFileText, startPluginEventSources, subscribePluginEvent } from "./plugin-events-source.js";
|
||||
import { PluginOauthBroker } from "./plugin-oauth.js";
|
||||
import { openPluginPanel } from "./plugin-panels.js";
|
||||
import { getPluginPlatformSettings, isInQuietHours } from "./plugin-platform-settings.js";
|
||||
import { createPluginHostAudioApi } from "./plugin-host-audio.js";
|
||||
import { createPluginPickedFileRegistry } from "./plugin-host-files.js";
|
||||
import {
|
||||
classifyPluginError,
|
||||
getPluginPlatformSettings,
|
||||
isInQuietHours,
|
||||
motionStop,
|
||||
openPluginPanel,
|
||||
PluginAiGateway,
|
||||
PluginOauthBroker,
|
||||
PluginSecretsStore,
|
||||
pluginVoiceListen,
|
||||
pluginVoiceSpeak,
|
||||
showPluginToast,
|
||||
startPluginEventSources,
|
||||
subscribePluginEvent,
|
||||
UserSoundStore,
|
||||
warn,
|
||||
} from "./plugin-host-runtime.js";
|
||||
import { createPluginHostSystemApi } from "./plugin-host-system.js";
|
||||
import {
|
||||
setPluginPetStatusReaction, clearPluginPetsForPlugin, closeAllPluginPets, closePluginPet, getPluginPetArbiter, getPluginPetState, hidePluginPet, listPluginPets,
|
||||
movePluginPetBy, movePluginPetTo, movePluginPetToHome, onPluginPetTick, onPluginPetsChange, reactPluginPet,
|
||||
setPluginPetAnimation, setPluginPetFollowCursor, setPluginPetPhysics, setPluginPetScale, showPluginPet, spawnPluginPet, wanderPluginPet,
|
||||
} from "./plugin-familiar-registry.js";
|
||||
import { motionStop } from "./familiar-motion-engine.js";
|
||||
import { PluginSecretsStore } from "./plugin-secrets.js";
|
||||
import { showPluginToast } from "./plugin-toast.js";
|
||||
import { pluginVoiceListen, pluginVoiceSpeak } from "./plugin-voice.js";
|
||||
import type { PluginHostCapabilities, PluginPickedFileHost } from "./plugin-sdk-bridge.js";
|
||||
import { maxUserSoundBytes, UserSoundStore, userSoundMimeByExtension } from "./plugin-user-sound-store.js";
|
||||
import { classifyPluginError } from "./plugin-diagnostics.js";
|
||||
import type { PluginHostCapabilities } from "./plugin-sdk-bridge.js";
|
||||
|
||||
/**
|
||||
* The Electron implementation of every SDK v3 host capability. Built once at
|
||||
|
|
@ -32,32 +35,6 @@ import { classifyPluginError } from "./plugin-diagnostics.js";
|
|||
* permissions, and quotas — this layer only does the side effects.
|
||||
*/
|
||||
|
||||
const maxPickedFileBytes = 16 * 1024 * 1024;
|
||||
|
||||
type PickedFileEntry = { path: string; name: string; sizeBytes: number };
|
||||
|
||||
let cpuSample: { idle: number; total: number } | null = null;
|
||||
|
||||
function sampleCpus(): { idle: number; total: number } {
|
||||
let idle = 0;
|
||||
let total = 0;
|
||||
for (const cpu of os.cpus()) {
|
||||
idle += cpu.times.idle;
|
||||
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
|
||||
}
|
||||
return { idle, total };
|
||||
}
|
||||
|
||||
function cpuPercent(): number {
|
||||
const current = sampleCpus();
|
||||
const previous = cpuSample ?? current;
|
||||
cpuSample = current;
|
||||
const totalDelta = current.total - previous.total;
|
||||
const idleDelta = current.idle - previous.idle;
|
||||
if (totalDelta <= 0) return 0;
|
||||
return Math.round(Math.min(100, Math.max(0, (1 - idleDelta / totalDelta) * 100)));
|
||||
}
|
||||
|
||||
export type ElectronPluginHostCapabilities = PluginHostCapabilities & {
|
||||
readonly secretsStore: PluginSecretsStore;
|
||||
readonly aiGateway: PluginAiGateway;
|
||||
|
|
@ -78,9 +55,8 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
|
|||
const secretsStore = new PluginSecretsStore(userDataPath);
|
||||
const aiGateway = new PluginAiGateway(secretsStore);
|
||||
const oauthBroker = new PluginOauthBroker(secretsStore);
|
||||
const pickedFiles = new Map<string, PickedFileEntry>();
|
||||
const pickedFiles = createPluginPickedFileRegistry();
|
||||
const userSounds = new UserSoundStore(join(userDataPath, "plugin-user-sounds"));
|
||||
let nextPickedFileId = 0;
|
||||
|
||||
const capabilities: ElectronPluginHostCapabilities = {
|
||||
secretsStore,
|
||||
|
|
@ -90,53 +66,7 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
|
|||
return getPluginPetArbiter(petId).show(pluginId, bubble, callbacks);
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
async play(spec, volume) {
|
||||
const baseFields = { kind: spec.kind, pluginId: "pluginId" in spec ? spec.pluginId : undefined, soundId: "id" in spec ? spec.id : undefined, name: "name" in spec ? spec.name : undefined };
|
||||
debug("plugin", "audio play requested", baseFields);
|
||||
const window = getDefaultPetWindowForPlugins();
|
||||
if (!window) { debug("plugin", "audio play skipped", { ...baseFields, reason: "no-familiar-window" }); return; }
|
||||
if (spec.kind === "named") {
|
||||
playPetWindowAudio(window, { kind: "named", name: spec.name, volume });
|
||||
debug("plugin", "audio play started", baseFields);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sourcePath = spec.kind === "user-sound" ? await userSounds.resolvePath(spec.pluginId, spec.id) : spec.path;
|
||||
const stat = await fs.stat(sourcePath);
|
||||
const ext = extname(sourcePath).toLowerCase();
|
||||
if (!stat.isFile() || stat.size > maxUserSoundBytes) throw new Error("Plugin sound file is missing or too large.");
|
||||
const mime = userSoundMimeByExtension[ext];
|
||||
if (!mime) throw new Error("Plugin sound format is not supported.");
|
||||
debug("plugin", "audio play file ready", { ...baseFields, ext, sizeBytes: stat.size });
|
||||
const bytes = await fs.readFile(sourcePath);
|
||||
playPetWindowAudio(window, { kind: "data", dataUrl: `data:${mime};base64,${bytes.toString("base64")}`, volume });
|
||||
debug("plugin", "audio play started", { ...baseFields, ext, sizeBytes: stat.size });
|
||||
} catch (error) { warn("plugin", "audio play failed", { ...baseFields, reason: error instanceof Error ? error.message : "unknown" }); throw error; }
|
||||
},
|
||||
async importUserSound(pluginId, fileId, opts) {
|
||||
const entry = pickedFiles.get(fileId);
|
||||
if (!entry) throw new Error("Plugin file handle is invalid.");
|
||||
return userSounds.importFromPath(pluginId, entry.path, { name: opts?.name ?? entry.name });
|
||||
},
|
||||
async importUserSoundFromPath(pluginId, path, opts) {
|
||||
// Trusted Control Center plumbing only: plugins receive opaque refs and
|
||||
// cannot pass arbitrary paths through the SDK.
|
||||
const fields: Record<string, unknown> = { pluginId, basename: basename(path), ext: extname(path).toLowerCase() };
|
||||
try { fields.sizeBytes = (await fs.stat(path)).size; } catch { fields.reason = "stat-unavailable"; }
|
||||
debug("plugin", "user sound import requested", fields);
|
||||
const sound = await userSounds.importFromPath(pluginId, path, { name: opts?.name ?? basename(path) });
|
||||
debug("plugin", "user sound import succeeded", { pluginId, soundId: sound.id, name: sound.name });
|
||||
return sound;
|
||||
},
|
||||
async forgetUserSound(pluginId, ref) {
|
||||
await userSounds.forget(pluginId, ref);
|
||||
},
|
||||
async stop() {
|
||||
const window = getDefaultPetWindowForPlugins();
|
||||
if (window) stopPetWindowAudio(window);
|
||||
},
|
||||
},
|
||||
audio: createPluginHostAudioApi(userSounds, pickedFiles),
|
||||
events: {
|
||||
subscribe(event, handler) {
|
||||
return subscribePluginEvent(event, handler);
|
||||
|
|
@ -191,74 +121,8 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
|
|||
refresh: async (pluginId, provider) => { try { return await oauthBroker.refresh(pluginId, provider); } catch (error) { warn("plugin", "oauth refresh failed", { pluginId, provider, reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; } },
|
||||
signOut: async (pluginId, provider) => { try { await oauthBroker.signOut(pluginId, provider); } catch (error) { warn("plugin", "oauth signout failed", { pluginId, provider, reason: error instanceof Error ? error.message : "unknown", errorCode: classifyPluginError(error) }); throw error; } },
|
||||
},
|
||||
files: {
|
||||
async pick(opts) {
|
||||
const filters = opts.accept && opts.accept.length > 0
|
||||
? [{ name: "Allowed files", extensions: opts.accept.map((ext) => ext.replace(/^[.]/, "")) }]
|
||||
: undefined;
|
||||
const result = await dialog.showOpenDialog({ properties: opts.multiple ? ["openFile", "multiSelections"] : ["openFile"], filters });
|
||||
if (result.canceled) return [];
|
||||
const out: PluginPickedFileHost[] = [];
|
||||
let skipped = 0;
|
||||
for (const path of result.filePaths.slice(0, 16)) {
|
||||
try {
|
||||
const stat = await fs.stat(path);
|
||||
if (!stat.isFile()) { skipped++; continue; }
|
||||
const fileId = `pick-${++nextPickedFileId}`;
|
||||
pickedFiles.set(fileId, { path, name: basename(path), sizeBytes: stat.size });
|
||||
out.push({ fileId, name: basename(path), sizeBytes: stat.size });
|
||||
} catch { skipped++; }
|
||||
}
|
||||
debug("plugin", "files picked", { count: out.length, skipped });
|
||||
return out;
|
||||
},
|
||||
async read(fileId, encoding) {
|
||||
const dropped = readDroppedFileText(fileId);
|
||||
if (dropped !== undefined) return encoding === "text" ? dropped : new TextEncoder().encode(dropped);
|
||||
const entry = pickedFiles.get(fileId);
|
||||
if (!entry) throw new Error("Plugin file handle is invalid.");
|
||||
const stat = await fs.stat(entry.path);
|
||||
if (stat.size > maxPickedFileBytes) throw new Error("Picked file is too large to read.");
|
||||
debug("plugin", "file read", { basename: entry.name, ext: extname(entry.name).toLowerCase(), sizeBytes: stat.size });
|
||||
const bytes = await fs.readFile(entry.path);
|
||||
return encoding === "text" ? bytes.toString("utf8") : new Uint8Array(bytes);
|
||||
},
|
||||
async save(opts) {
|
||||
const result = await dialog.showSaveDialog({ defaultPath: opts.suggestedName });
|
||||
if (result.canceled || !result.filePath) return;
|
||||
debug("plugin", "file saved", { basename: basename(result.filePath), ext: extname(result.filePath).toLowerCase(), sizeBytes: typeof opts.data === "string" ? Buffer.byteLength(opts.data) : opts.data.byteLength });
|
||||
await fs.writeFile(result.filePath, typeof opts.data === "string" ? opts.data : Buffer.from(opts.data));
|
||||
},
|
||||
},
|
||||
system: {
|
||||
async info() {
|
||||
return {
|
||||
platform: process.platform === "darwin" ? "mac" as const : process.platform === "win32" ? "win" as const : "linux" as const,
|
||||
locale: getActiveLocaleLang(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
|
||||
theme: nativeTheme.shouldUseDarkColors ? "dark" as const : "light" as const,
|
||||
appVersion: app.getVersion(),
|
||||
online: net.online,
|
||||
};
|
||||
},
|
||||
async metrics() {
|
||||
const memory = process.getSystemMemoryInfo();
|
||||
const memUsedPercent = memory.total > 0 ? Math.round(Math.min(100, Math.max(0, (1 - memory.free / memory.total) * 100))) : 0;
|
||||
return { cpuPercent: cpuPercent(), memUsedPercent };
|
||||
},
|
||||
async openExternal(url) {
|
||||
let host: string | undefined;
|
||||
try { host = new URL(url).hostname; } catch { host = undefined; }
|
||||
debug("plugin", "system openExternal", { host });
|
||||
await shell.openExternal(url);
|
||||
},
|
||||
async readClipboardText() {
|
||||
return clipboard.readText().slice(0, 64 * 1024);
|
||||
},
|
||||
async writeClipboardText(text) {
|
||||
clipboard.writeText(text);
|
||||
},
|
||||
},
|
||||
files: pickedFiles.files,
|
||||
system: createPluginHostSystemApi(),
|
||||
settings: {
|
||||
audioAllowed: () => getPluginPlatformSettings().allowPluginAudio,
|
||||
dynamicSpeechAllowed: () => getPluginPlatformSettings().allowDynamicSpeech,
|
||||
|
|
@ -280,8 +144,6 @@ export function createElectronPluginHostCapabilities(userDataPath: string): Elec
|
|||
closeAllPluginPets();
|
||||
},
|
||||
};
|
||||
// Prime the CPU sampler so the first metrics() call has a delta to use.
|
||||
cpuSample = sampleCpus();
|
||||
activeCapabilities = capabilities;
|
||||
return capabilities;
|
||||
}
|
||||
|
|
|
|||
85
apps/desktop/src/plugin-host-files.ts
Normal file
85
apps/desktop/src/plugin-host-files.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { promises as fs } from "node:fs";
|
||||
import { basename, extname } from "node:path";
|
||||
|
||||
import { dialog } from "electron";
|
||||
|
||||
import { readDroppedFileText } from "./plugin-events-source.js";
|
||||
import { debug } from "./logger.js";
|
||||
import type { PluginHostCapabilities, PluginPickedFileHost } from "./plugin-sdk-bridge.js";
|
||||
|
||||
const maxPickedFileBytes = 16 * 1024 * 1024;
|
||||
|
||||
export type PickedFileEntry = {
|
||||
path: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type PluginPickedFileRegistry = {
|
||||
readonly files: PluginHostCapabilities["files"];
|
||||
getEntry(fileId: string): PickedFileEntry | undefined;
|
||||
};
|
||||
|
||||
export function createPluginPickedFileRegistry(): PluginPickedFileRegistry {
|
||||
const pickedFiles = new Map<string, PickedFileEntry>();
|
||||
let nextPickedFileId = 0;
|
||||
|
||||
return {
|
||||
files: {
|
||||
async pick(opts) {
|
||||
const filters = opts.accept && opts.accept.length > 0
|
||||
? [{ name: "Allowed files", extensions: opts.accept.map((ext) => ext.replace(/^[.]/, "")) }]
|
||||
: undefined;
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: opts.multiple ? ["openFile", "multiSelections"] : ["openFile"],
|
||||
filters,
|
||||
});
|
||||
if (result.canceled) return [];
|
||||
const out: PluginPickedFileHost[] = [];
|
||||
let skipped = 0;
|
||||
for (const path of result.filePaths.slice(0, 16)) {
|
||||
try {
|
||||
const stat = await fs.stat(path);
|
||||
if (!stat.isFile()) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const fileId = `pick-${++nextPickedFileId}`;
|
||||
pickedFiles.set(fileId, { path, name: basename(path), sizeBytes: stat.size });
|
||||
out.push({ fileId, name: basename(path), sizeBytes: stat.size });
|
||||
} catch {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
debug("plugin", "files picked", { count: out.length, skipped });
|
||||
return out;
|
||||
},
|
||||
async read(fileId, encoding) {
|
||||
const dropped = readDroppedFileText(fileId);
|
||||
if (dropped !== undefined) {
|
||||
return encoding === "text" ? dropped : new TextEncoder().encode(dropped);
|
||||
}
|
||||
const entry = pickedFiles.get(fileId);
|
||||
if (!entry) throw new Error("Plugin file handle is invalid.");
|
||||
const stat = await fs.stat(entry.path);
|
||||
if (stat.size > maxPickedFileBytes) throw new Error("Picked file is too large to read.");
|
||||
debug("plugin", "file read", { basename: entry.name, ext: extname(entry.name).toLowerCase(), sizeBytes: stat.size });
|
||||
const bytes = await fs.readFile(entry.path);
|
||||
return encoding === "text" ? bytes.toString("utf8") : new Uint8Array(bytes);
|
||||
},
|
||||
async save(opts) {
|
||||
const result = await dialog.showSaveDialog({ defaultPath: opts.suggestedName });
|
||||
if (result.canceled || !result.filePath) return;
|
||||
debug("plugin", "file saved", {
|
||||
basename: basename(result.filePath),
|
||||
ext: extname(result.filePath).toLowerCase(),
|
||||
sizeBytes: typeof opts.data === "string" ? Buffer.byteLength(opts.data) : opts.data.byteLength,
|
||||
});
|
||||
await fs.writeFile(result.filePath, typeof opts.data === "string" ? opts.data : Buffer.from(opts.data));
|
||||
},
|
||||
},
|
||||
getEntry(fileId) {
|
||||
return pickedFiles.get(fileId);
|
||||
},
|
||||
};
|
||||
}
|
||||
12
apps/desktop/src/plugin-host-runtime.ts
Normal file
12
apps/desktop/src/plugin-host-runtime.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { warn } from "./logger.js";
|
||||
export { PluginAiGateway } from "./plugin-ai-gateway.js";
|
||||
export { startPluginEventSources, subscribePluginEvent } from "./plugin-events-source.js";
|
||||
export { motionStop } from "./familiar-motion-engine.js";
|
||||
export { classifyPluginError } from "./plugin-diagnostics.js";
|
||||
export { PluginOauthBroker } from "./plugin-oauth.js";
|
||||
export { openPluginPanel } from "./plugin-panels.js";
|
||||
export { getPluginPlatformSettings, isInQuietHours } from "./plugin-platform-settings.js";
|
||||
export { PluginSecretsStore } from "./plugin-secrets.js";
|
||||
export { showPluginToast } from "./plugin-toast.js";
|
||||
export { UserSoundStore } from "./plugin-user-sound-store.js";
|
||||
export { pluginVoiceListen, pluginVoiceSpeak } from "./plugin-voice.js";
|
||||
64
apps/desktop/src/plugin-host-system.ts
Normal file
64
apps/desktop/src/plugin-host-system.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import * as os from "node:os";
|
||||
|
||||
import { app, clipboard, nativeTheme, net, shell } from "electron";
|
||||
|
||||
import { getActiveLocaleLang } from "./i18n/index.js";
|
||||
import { debug } from "./logger.js";
|
||||
import type { PluginHostCapabilities } from "./plugin-sdk-bridge.js";
|
||||
|
||||
export function createPluginHostSystemApi(): PluginHostCapabilities["system"] {
|
||||
let cpuSample = sampleCpus();
|
||||
|
||||
return {
|
||||
async info() {
|
||||
return {
|
||||
platform: process.platform === "darwin" ? "mac" as const : process.platform === "win32" ? "win" as const : "linux" as const,
|
||||
locale: getActiveLocaleLang(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
|
||||
theme: nativeTheme.shouldUseDarkColors ? "dark" as const : "light" as const,
|
||||
appVersion: app.getVersion(),
|
||||
online: net.online,
|
||||
};
|
||||
},
|
||||
async metrics() {
|
||||
const memory = process.getSystemMemoryInfo();
|
||||
const memUsedPercent = memory.total > 0 ? Math.round(Math.min(100, Math.max(0, (1 - memory.free / memory.total) * 100))) : 0;
|
||||
return { cpuPercent: cpuPercent(cpuSample, (nextSample) => { cpuSample = nextSample; }), memUsedPercent };
|
||||
},
|
||||
async openExternal(url) {
|
||||
let host: string | undefined;
|
||||
try {
|
||||
host = new URL(url).hostname;
|
||||
} catch {
|
||||
host = undefined;
|
||||
}
|
||||
debug("plugin", "system openExternal", { host });
|
||||
await shell.openExternal(url);
|
||||
},
|
||||
async readClipboardText() {
|
||||
return clipboard.readText().slice(0, 64 * 1024);
|
||||
},
|
||||
async writeClipboardText(text) {
|
||||
clipboard.writeText(text);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sampleCpus(): { idle: number; total: number } {
|
||||
let idle = 0;
|
||||
let total = 0;
|
||||
for (const cpu of os.cpus()) {
|
||||
idle += cpu.times.idle;
|
||||
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
|
||||
}
|
||||
return { idle, total };
|
||||
}
|
||||
|
||||
function cpuPercent(previous: { idle: number; total: number }, setCurrent: (current: { idle: number; total: number }) => void): number {
|
||||
const current = sampleCpus();
|
||||
setCurrent(current);
|
||||
const totalDelta = current.total - previous.total;
|
||||
const idleDelta = current.idle - previous.idle;
|
||||
if (totalDelta <= 0) return 0;
|
||||
return Math.round(Math.min(100, Math.max(0, (1 - idleDelta / totalDelta) * 100)));
|
||||
}
|
||||
|
|
@ -1,28 +1,63 @@
|
|||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { lookup } from "node:dns/promises";
|
||||
import * as net from "node:net";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getActiveLocaleLang } from "./i18n/index.js";
|
||||
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
|
||||
import { validateReaction, validateSayMessage } from "./local-ipc-protocol.js";
|
||||
import { makePluginT } from "./plugin-i18n.js";
|
||||
import { resolveDeclaredAssetPath, resolveDeclaredPanelPath } from "./plugin-assets.js";
|
||||
import type { PluginConfig } from "./plugin-config.js";
|
||||
import type { FamiliarOSJavascriptPluginManifest, PluginAssetKind, PluginPermission } from "./plugin-manifest.js";
|
||||
import type { PluginPetApi } from "./plugin-familiar-api.js";
|
||||
import type { PluginRuntimeScheduler } from "./plugin-runtime.js";
|
||||
import { createPluginAudioApi } from "./plugin-sdk-audio.js";
|
||||
import { createPluginBusApi, type PluginBusTopicEntry } from "./plugin-sdk-bus.js";
|
||||
import { createPluginConfigApi } from "./plugin-sdk-config.js";
|
||||
import { createPluginEventsApi } from "./plugin-sdk-events.js";
|
||||
import { createDefaultPluginHostCapabilities } from "./plugin-sdk-default-capabilities.js";
|
||||
import type { PluginConfig, FamiliarOSJavascriptPluginManifest, PluginAssetKind, PluginPermission, PluginPetApi, PluginRuntimeScheduler, PluginStateRecord, PluginStateStore } from "./plugin-sdk-contracts.js";
|
||||
import {
|
||||
allowedNetworkHosts,
|
||||
safeHttpFetch,
|
||||
safeHttpStream,
|
||||
validateNetOptions,
|
||||
} from "./plugin-sdk-network.js";
|
||||
import { pluginSdkQuotas } from "./plugin-sdk-quotas.js";
|
||||
import type { ScheduleSpec, PluginInspectorState } from "./plugin-sdk-state.js";
|
||||
import { WindowCounter, type PluginRuntimeState } from "./plugin-sdk-state.js";
|
||||
import { createPluginStorageApi } from "./plugin-sdk-storage.js";
|
||||
import { createPluginUiApi } from "./plugin-sdk-ui.js";
|
||||
import type { PluginStateRecord, PluginStateStore } from "./plugin-state.js";
|
||||
import { classifyPluginError, logPluginDiagnostic } from "./plugin-diagnostics.js";
|
||||
import {
|
||||
createPluginAudioApi,
|
||||
createPluginBusApi,
|
||||
createPluginConfigApi,
|
||||
createPluginEventsApi,
|
||||
createPluginStorageApi,
|
||||
createPluginUiApi,
|
||||
type PluginBusTopicEntry,
|
||||
} from "./plugin-sdk-surfaces.js";
|
||||
import {
|
||||
allowedAccents,
|
||||
check,
|
||||
clampNumber,
|
||||
commandIdPattern,
|
||||
isRecord,
|
||||
namedHostIcons,
|
||||
nextCronRunMs,
|
||||
normalizeJson,
|
||||
parseCronExpression,
|
||||
renderLimitedMarkdown,
|
||||
scheduleIdPattern,
|
||||
screenStaticBubbleText,
|
||||
validateAiRequest,
|
||||
validateBubbleActions,
|
||||
validateBubbleInput,
|
||||
validateCommand,
|
||||
validateCommandFormValues,
|
||||
validateCssColor,
|
||||
validateDynamicText,
|
||||
validateMenuItems,
|
||||
validateMoveBy,
|
||||
validateMoveToOptions,
|
||||
validateOauthConfig,
|
||||
validatePetHandleId,
|
||||
validatePinnedBubbleText,
|
||||
validatePoint,
|
||||
validateProviderName,
|
||||
validateReactOptions,
|
||||
validateStatus,
|
||||
validateStorageKey,
|
||||
validateWander,
|
||||
} from "./plugin-sdk-validators.js";
|
||||
import { classifyPluginError, getActiveLocaleLang, makePluginT, resolveDeclaredAssetPath, resolveDeclaredPanelPath, validateReaction, validateSayMessage, type FamiliarOSReaction } from "./plugin-sdk-support.js";
|
||||
import { WindowCounter, type PluginRuntimeState, type ScheduleSpec, type PluginInspectorState } from "./plugin-sdk-state.js";
|
||||
|
||||
export { createDefaultPluginHostCapabilities } from "./plugin-sdk-default-capabilities.js";
|
||||
export { assertPublicHost, isPrivateIp, safeHttpFetch, safeHttpStream } from "./plugin-sdk-network.js";
|
||||
export { nextCronRunMs, normalizeJson, parseCronExpression, renderLimitedMarkdown, validateCommandFormValues, validateDynamicText, validatePinnedBubbleText } from "./plugin-sdk-validators.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public bridge types
|
||||
|
|
@ -113,10 +148,6 @@ export interface PluginBubbleHostHandle {
|
|||
unpin(): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host capabilities — injected by the Electron layer, defaulted for tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PluginPetInfo = { id: string; name: string; kind: "default" | "agent" | "plugin"; visible: boolean };
|
||||
export type PluginPetState = { position: { x: number; y: number }; bounds: { x: number; y: number; width: number; height: number }; currentAnimation: string; visible: boolean; dragging: boolean };
|
||||
export type PluginAnimationSpec = { kind: "reaction"; reaction: FamiliarOSReaction } | { kind: "sprite"; spritePath: string; loop: boolean; fps: number };
|
||||
|
|
@ -209,84 +240,11 @@ export interface PluginHostCapabilities {
|
|||
clearPlugin?(pluginId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability defaults used when the Electron layer is not wired (contract
|
||||
* tests, headless runs). Familiar speech/reactions fall back to the v2 familiar API;
|
||||
* everything host-bound reports a clear, structured unavailability error.
|
||||
*/
|
||||
export function createDefaultPluginHostCapabilities(petApi: PluginPetApi): PluginHostCapabilities {
|
||||
const unavailable = (feature: string) => async (): Promise<never> => { throw new Error(`Plugin host capability is unavailable: ${feature}`); };
|
||||
return {
|
||||
bubbles: {
|
||||
async show({ bubble, callbacks }) {
|
||||
if (bubble.text) await petApi.speak(bubble.text);
|
||||
let dismissed = false;
|
||||
return {
|
||||
id: `bubble-${Math.random().toString(36).slice(2)}`,
|
||||
update: async () => undefined,
|
||||
dismiss: async () => { if (!dismissed) { dismissed = true; callbacks.onDismiss("manual"); } },
|
||||
pin: async () => undefined,
|
||||
unpin: async () => { if (!dismissed) { dismissed = true; callbacks.onDismiss("unpinned"); } },
|
||||
};
|
||||
},
|
||||
},
|
||||
audio: { play: async () => undefined, importUserSound: async (_pluginId, _fileId, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }), importUserSoundFromPath: async (_pluginId, _path, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }), forgetUserSound: async () => undefined, stop: async () => undefined },
|
||||
events: { subscribe: () => () => undefined },
|
||||
familiars: {
|
||||
list: () => [{ id: "default", name: "Default familiar", kind: "default", visible: true }],
|
||||
spawn: unavailable("familiars.spawn"),
|
||||
close: unavailable("familiars.close"),
|
||||
show: async () => undefined,
|
||||
hide: async () => undefined,
|
||||
react: async (_petId, reaction, options) => { await petApi.react(reaction, options); },
|
||||
setAnimation: async (_petId, spec) => { if (spec.kind === "reaction") await petApi.react(spec.reaction); },
|
||||
setScale: async () => undefined,
|
||||
setStatusReaction: async () => undefined,
|
||||
moveBy: async (_petId, opts) => { await petApi.moveBy(opts); },
|
||||
wander: async (_petId, opts) => { await petApi.wander(opts); },
|
||||
moveToHome: async () => { await petApi.moveToHome(); },
|
||||
moveTo: unavailable("familiars.moveTo"),
|
||||
followCursor: async () => undefined,
|
||||
physics: async () => undefined,
|
||||
getState: async () => ({ position: { x: 0, y: 0 }, bounds: { x: 0, y: 0, width: 0, height: 0 }, currentAnimation: "idle", visible: true, dragging: false }),
|
||||
onTick: () => () => undefined,
|
||||
onChange: () => () => undefined,
|
||||
},
|
||||
toast: async () => undefined,
|
||||
notify: async () => undefined,
|
||||
panels: { open: unavailable("panels.open") },
|
||||
secrets: (() => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get: async (pluginId: string, key: string) => store.get(`${pluginId}\0${key}`),
|
||||
set: async (pluginId: string, key: string, value: string) => void store.set(`${pluginId}\0${key}`, value),
|
||||
delete: async (pluginId: string, key: string) => void store.delete(`${pluginId}\0${key}`),
|
||||
has: async (pluginId: string, key: string) => store.has(`${pluginId}\0${key}`),
|
||||
};
|
||||
})(),
|
||||
ai: { available: async () => false, complete: unavailable("ai.complete"), stream: unavailable("ai.stream") },
|
||||
voice: { speak: unavailable("voice.speak"), listen: unavailable("voice.listen") },
|
||||
auth: { oauth: unavailable("auth.oauth"), refresh: unavailable("auth.refresh"), signOut: async () => undefined },
|
||||
files: { pick: async () => [], read: unavailable("files.read"), save: unavailable("files.save") },
|
||||
system: {
|
||||
info: async () => ({ platform: process.platform === "darwin" ? "mac" : process.platform === "win32" ? "win" : "linux", locale: "en-US", timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC", theme: "light", appVersion: "0.0.0", online: true }),
|
||||
metrics: async () => ({ cpuPercent: 0, memUsedPercent: 0 }),
|
||||
openExternal: unavailable("system.openExternal"),
|
||||
readClipboardText: unavailable("system.readClipboardText"),
|
||||
writeClipboardText: unavailable("system.writeClipboardText"),
|
||||
},
|
||||
settings: { audioAllowed: () => true, dynamicSpeechAllowed: () => false, voiceAllowed: () => true, listenAllowed: () => false, inQuietHours: () => false },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quotas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const quotas = pluginSdkQuotas;
|
||||
|
||||
const commandIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
|
||||
const scheduleIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
|
||||
const allowedEventNames = new Set([
|
||||
"familiar:clicked", "familiar:doubleClicked", "familiar:dragStart", "familiar:dragEnd", "familiar:hover", "familiar:drop",
|
||||
"idle:enter", "idle:exit", "agent:activity", "config:changed",
|
||||
|
|
@ -294,9 +252,6 @@ const allowedEventNames = new Set([
|
|||
"display:changed", "online", "offline", "day:partChanged",
|
||||
]);
|
||||
export const pluginEventNames = allowedEventNames;
|
||||
const allowedAccents = new Set(["blue", "purple", "green", "amber", "red", "pink", "slate"]);
|
||||
const namedHostIcons = new Set(["info", "check", "alert", "heart", "star", "bell", "coffee", "timer", "droplet", "sparkles", "zap", "moon", "sun", "food", "play", "pause"]);
|
||||
const safeCssColorPattern = /^(#[0-9a-fA-F]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)|hsla?\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\))$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Storage stores
|
||||
|
|
@ -857,407 +812,6 @@ export class PluginSdkBridge {
|
|||
}
|
||||
|
||||
function countActiveBubbles(state: PluginRuntimeState): number { return state.bubbles.size; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SimpleHttpResponse = { status: number; ok: boolean; headers: Record<string, string>; text: string; json?: unknown };
|
||||
type ValidatedNetOptions = { method: string; headers?: Record<string, string>; body?: string; timeoutMs?: number };
|
||||
|
||||
const forbiddenHeaderNames = new Set(["host", "cookie", "cookie2", "origin", "referer", "content-length", "connection", "transfer-encoding", "upgrade", "keep-alive", "te", "trailer", "expect", "via"]);
|
||||
|
||||
function validateNetOptions(options: unknown, approved: ReadonlySet<PluginPermission>): ValidatedNetOptions {
|
||||
const opts = isRecord(options) ? options : {};
|
||||
const method = String(opts.method ?? "GET").toUpperCase();
|
||||
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) throw new Error("Plugin HTTP method is not allowed.");
|
||||
if (method !== "GET" && !approved.has("network:write")) throw new Error("Plugin permission is not approved: network:write");
|
||||
let body: string | undefined;
|
||||
if (opts.body !== undefined) {
|
||||
if (method === "GET") throw new Error("Plugin GET requests must not have a body.");
|
||||
body = String(opts.body);
|
||||
if (Buffer.byteLength(body) > quotas.httpRequestBodyBytes) throw new Error("Plugin HTTP request body is too large.");
|
||||
}
|
||||
return { method, headers: safeNetHeaders(opts.headers), body, timeoutMs: opts.timeoutMs === undefined ? undefined : Number(opts.timeoutMs) };
|
||||
}
|
||||
|
||||
function safeNetHeaders(value: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const [name, headerValue] of Object.entries(value).slice(0, 24)) {
|
||||
const lower = name.toLowerCase();
|
||||
if (!/^[a-z0-9-]{1,64}$/.test(lower) || forbiddenHeaderNames.has(lower) || lower.startsWith("proxy-") || lower.startsWith("sec-")) continue;
|
||||
if (typeof headerValue !== "string" || headerValue.length > 4096 || /[\r\n\0]/.test(headerValue)) continue;
|
||||
out[lower] = headerValue;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function allowedNetworkHosts(record: PluginStateRecord, manifest: FamiliarOSJavascriptPluginManifest): Set<string> { const manifestHosts = new Set((manifest.network?.hosts ?? []).map((h) => h.toLowerCase())); const approved = record.approvedNetworkHosts?.map((h) => h.toLowerCase()) ?? []; return new Set(approved.filter((h) => manifestHosts.has(h))); }
|
||||
|
||||
async function prepareSafeRequest(urlText: string, opts: ValidatedNetOptions, allowedHosts: Set<string>): Promise<{ url: URL; init: RequestInit; controller: AbortController; timeout: NodeJS.Timeout }> {
|
||||
const url = new URL(urlText);
|
||||
if (url.protocol !== "https:") throw new Error("Plugin HTTP fetch requires HTTPS.");
|
||||
if (url.username || url.password) throw new Error("Plugin HTTP fetch credentials are not allowed.");
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (!allowedHosts.has(host)) throw new Error("Plugin HTTP host is not approved.");
|
||||
await assertPublicHost(host);
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = Math.min(Math.max(Number(opts.timeoutMs ?? 10_000), 1_000), 120_000);
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const init: RequestInit = { method: opts.method, redirect: "manual", credentials: "omit", signal: controller.signal, headers: opts.headers ?? {}, ...(opts.body === undefined ? {} : { body: opts.body }) };
|
||||
return { url, init, controller, timeout };
|
||||
}
|
||||
|
||||
type NetworkDiagnostics = { logger?: PluginRuntimeLogger; pluginId?: string; route?: string };
|
||||
|
||||
export async function safeHttpFetch(urlText: string, options: ValidatedNetOptions | unknown, allowedHosts: Set<string>, diagnostics?: NetworkDiagnostics): Promise<SimpleHttpResponse> {
|
||||
const opts: ValidatedNetOptions = isValidatedNetOptions(options) ? options : { method: "GET", headers: undefined, timeoutMs: undefined };
|
||||
const started = Date.now();
|
||||
let host = "";
|
||||
try { host = new URL(urlText).hostname.toLowerCase(); } catch { host = "invalid"; }
|
||||
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host, phase: "begin" });
|
||||
let prepared: Awaited<ReturnType<typeof prepareSafeRequest>>;
|
||||
try { prepared = await prepareSafeRequest(urlText, opts, allowedHosts); }
|
||||
catch (error) { logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host, phase: "denied", reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error), durationMs: Date.now() - started }); throw error; }
|
||||
const { url, init, timeout } = prepared;
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) throw new Error("Plugin HTTP redirects are not allowed.");
|
||||
const text = await readCapped(response, quotas.httpResponseBytes);
|
||||
const headers: Record<string, string> = {};
|
||||
for (const key of ["content-type", "etag", "last-modified", "retry-after", "x-ratelimit-remaining"]) { const value = response.headers.get(key); if (value) headers[key] = value; }
|
||||
let json: unknown;
|
||||
if ((headers["content-type"] ?? "").includes("application/json")) { try { json = JSON.parse(text); } catch { json = undefined; } }
|
||||
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host: url.hostname, phase: "success", status: response.status, sizeBytes: Buffer.byteLength(text), durationMs: Date.now() - started });
|
||||
return { status: response.status, ok: response.ok, headers, text, ...(json === undefined ? {} : { json }) };
|
||||
} catch (error) {
|
||||
const mapped = error instanceof Error && error.name === "AbortError" ? new Error("Plugin HTTP fetch timed out.") : error;
|
||||
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.fetch", method: opts.method, host: url.hostname, phase: "fail", reason: mapped instanceof Error ? mapped.message : String(mapped), errorCode: classifyPluginError(mapped), durationMs: Date.now() - started });
|
||||
if (mapped instanceof Error) throw mapped;
|
||||
throw error;
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
export async function safeHttpStream(urlText: string, opts: ValidatedNetOptions, allowedHosts: Set<string>, onChunk: (chunk: string) => void, diagnostics?: NetworkDiagnostics): Promise<{ status: number; ok: boolean }> {
|
||||
const started = Date.now();
|
||||
let host = "";
|
||||
try { host = new URL(urlText).hostname.toLowerCase(); } catch { host = "invalid"; }
|
||||
let prepared: Awaited<ReturnType<typeof prepareSafeRequest>>;
|
||||
try { prepared = await prepareSafeRequest(urlText, { ...opts, timeoutMs: opts.timeoutMs ?? 120_000 }, allowedHosts); }
|
||||
catch (error) { logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.stream", method: opts.method, host, phase: "denied", reason: error instanceof Error ? error.message : String(error), errorCode: classifyPluginError(error), durationMs: Date.now() - started }); throw error; }
|
||||
const { url, init, timeout } = prepared;
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) throw new Error("Plugin HTTP redirects are not allowed.");
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > quotas.streamResponseBytes) { await reader.cancel().catch(() => undefined); throw new Error("Plugin HTTP stream is too large."); }
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
if (chunk.length > 0) onChunk(chunk);
|
||||
}
|
||||
const tail = decoder.decode();
|
||||
if (tail.length > 0) onChunk(tail);
|
||||
}
|
||||
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.stream", method: opts.method, host: url.hostname, phase: "success", status: response.status, durationMs: Date.now() - started });
|
||||
return { status: response.status, ok: response.ok };
|
||||
} catch (error) {
|
||||
const mapped = error instanceof Error && error.name === "AbortError" ? new Error("Plugin HTTP stream timed out.") : error;
|
||||
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", { pluginId: diagnostics?.pluginId, route: diagnostics?.route ?? "net.stream", method: opts.method, host: url.hostname, phase: "fail", reason: mapped instanceof Error ? mapped.message : String(mapped), errorCode: classifyPluginError(mapped), durationMs: Date.now() - started });
|
||||
if (mapped instanceof Error) throw mapped;
|
||||
throw error;
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
function isValidatedNetOptions(value: unknown): value is ValidatedNetOptions { return isRecord(value) && typeof value.method === "string"; }
|
||||
|
||||
async function readCapped(response: Response, cap: number): Promise<string> { const reader = response.body?.getReader(); if (!reader) return ""; const chunks: Uint8Array[] = []; let total = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > cap) throw new Error("Plugin HTTP response is too large."); chunks.push(value); } return Buffer.concat(chunks).toString("utf8"); }
|
||||
export async function assertPublicHost(host: string): Promise<void> { if (["localhost", "metadata.google.internal"].includes(host) || host.endsWith(".localhost")) throw new Error("Plugin HTTP host is not public."); const results = await lookup(host, { all: true, verbatim: true }); if (results.length === 0 || results.some((r) => isPrivateIp(r.address))) throw new Error("Plugin HTTP host resolves to a restricted address."); }
|
||||
export function isPrivateIp(address: string): boolean { if (net.isIPv4(address)) { const p = address.split(".").map(Number); return p[0] === 10 || p[0] === 127 || p[0] === 0 || (p[0] === 169 && p[1] === 254) || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168) || (p[0] === 100 && p[1] >= 64 && p[1] <= 127); } const v = address.toLowerCase(); return v === "::1" || v === "::" || v.startsWith("fc") || v.startsWith("fd") || v.startsWith("fe80:") || v.startsWith("::ffff:127.") || v.startsWith("::ffff:10.") || v.startsWith("::ffff:192.168."); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function check(ok: boolean, message: string): void { if (!ok) throw new Error(message); }
|
||||
function clampNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.min(Math.max(value, min), max); }
|
||||
function validateCssColor(value: unknown, message: string): string { const color = String(value).trim(); check(color.length <= 48 && safeCssColorPattern.test(color), message); return color; }
|
||||
function validateStorageKey(key: string): string { if (!/^[A-Za-z0-9._:-]{1,128}$/.test(String(key))) throw new Error("Invalid plugin storage key."); return String(key); }
|
||||
function validatePetHandleId(value: unknown): string { const id = String(value); if (!/^[A-Za-z0-9._:-]{1,128}$/.test(id)) throw new Error("Invalid familiar handle id."); return id; }
|
||||
function validateReactOptions(value: unknown): PluginReactOptions | undefined { if (value === undefined) return undefined; if (!isRecord(value)) throw new Error("Invalid familiar reaction options."); const keys = Object.keys(value); check(keys.every((key) => key === "showMessage"), "Invalid familiar reaction option."); if (value.showMessage !== undefined && typeof value.showMessage !== "boolean") throw new Error("Invalid familiar reaction showMessage option."); return value.showMessage === undefined ? {} : { showMessage: value.showMessage }; }
|
||||
function validatePoint(value: unknown): { x: number; y: number } { if (!isRecord(value)) throw new Error("Invalid point."); const x = Number(value.x); const y = Number(value.y); if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Invalid point."); return { x, y }; }
|
||||
function validateMoveToOptions(value: unknown): { durationMs?: number; easing?: string } { const opts = isRecord(value) ? value : {}; const durationMs = opts.durationMs === undefined ? undefined : clampNumber(Number(opts.durationMs), 100, 10_000); const easing = opts.easing === undefined ? undefined : (check(["linear", "ease-in", "ease-out", "ease-in-out"].includes(String(opts.easing)), "Invalid easing."), String(opts.easing)); return { durationMs, easing }; }
|
||||
|
||||
/** Relaxed screen for model-generated speech (§13.1): longer cap, secrets stripped. */
|
||||
export function validateDynamicText(value: string): string {
|
||||
const text = value.replace(/[\0-\x08\x0B\x0C\x0E-\x1F]/g, "").trim();
|
||||
check(text.length >= 1, "Dynamic speech cannot be empty.");
|
||||
check(text.length <= quotas.dynamicTextChars, "Dynamic speech is too long.");
|
||||
// Strip obvious secret material even in dynamic mode.
|
||||
return text
|
||||
.replace(/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, "[redacted]")
|
||||
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
|
||||
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[redacted]")
|
||||
.replace(/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g, "[redacted]")
|
||||
.replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]");
|
||||
}
|
||||
|
||||
export function validatePinnedBubbleText(value: string): string {
|
||||
const text = value.trim().replace(/\r\n?/g, "\n");
|
||||
check(text.length >= 1, "Pinned bubble text cannot be empty.");
|
||||
check(text.length <= 140, "Pinned bubble text is too long.");
|
||||
const lines = text.split("\n");
|
||||
check(lines.length <= 4, "Pinned bubble text has too many lines.");
|
||||
check(lines.every((line) => line.trim().length > 0), "Pinned bubble text cannot contain blank lines.");
|
||||
return lines.map((line) => validateSayMessage(line)).join("\n");
|
||||
}
|
||||
|
||||
/** Static (non-dynamic) bubble markdown still gets the ambient content screen. */
|
||||
function screenStaticBubbleText(markdown: string): void {
|
||||
check(!/```|<script|function\s+\w+\(|\b(import|export)\s/.test(markdown), "Bubble markdown looks like code.");
|
||||
check(!/https?:\/\/|www\./.test(markdown), "Bubble markdown contains a URL.");
|
||||
check(!/(api[_-]?key|secret|password|BEGIN [A-Z ]+PRIVATE KEY)/i.test(markdown), "Bubble markdown looks secret-like.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Limited markdown -> safe HTML. Everything is HTML-escaped first; only
|
||||
* bold/italic/inline-code/line-break syntax is re-introduced as markup.
|
||||
*/
|
||||
export function renderLimitedMarkdown(markdown: string): string {
|
||||
const escaped = markdown
|
||||
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
return escaped
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
|
||||
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function validateBubbleActions(value: unknown): PluginBubbleAction[] {
|
||||
check(Array.isArray(value) && value.length >= 1 && value.length <= 4, "Bubble actions must be 1-4 entries.");
|
||||
const seen = new Set<string>();
|
||||
return (value as unknown[]).map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry.id !== "string" || !commandIdPattern.test(entry.id) || seen.has(entry.id)) throw new Error("Invalid bubble action id.");
|
||||
seen.add(entry.id);
|
||||
if (typeof entry.label !== "string" || entry.label.trim() === "" || entry.label.length > 32) throw new Error("Invalid bubble action label.");
|
||||
const style = entry.style === undefined ? "default" : String(entry.style);
|
||||
check(["default", "primary", "danger"].includes(style), "Invalid bubble action style.");
|
||||
const iconName = entry.icon === undefined ? undefined : (check(typeof entry.icon === "string" && namedHostIcons.has(entry.icon), "Invalid bubble action icon."), String(entry.icon));
|
||||
return { id: entry.id, label: entry.label, style: style as PluginBubbleAction["style"], iconName, dismissesBubble: entry.dismissesBubble !== false };
|
||||
});
|
||||
}
|
||||
|
||||
function validateBubbleInput(value: unknown): PluginBubbleInput {
|
||||
if (!isRecord(value) || typeof value.id !== "string" || !commandIdPattern.test(value.id)) throw new Error("Invalid bubble input id.");
|
||||
const type = String(value.type);
|
||||
check(["text", "number", "select"].includes(type), "Invalid bubble input type.");
|
||||
const out: PluginBubbleInput = { id: value.id, type: type as PluginBubbleInput["type"] };
|
||||
if (value.placeholder !== undefined) { check(typeof value.placeholder === "string" && value.placeholder.length <= 60, "Invalid bubble input placeholder."); out.placeholder = String(value.placeholder); }
|
||||
if (value.submitLabel !== undefined) { check(typeof value.submitLabel === "string" && value.submitLabel.length <= 24 && value.submitLabel.trim() !== "", "Invalid bubble input submitLabel."); out.submitLabel = String(value.submitLabel); }
|
||||
if (value.default !== undefined) { check(typeof value.default === "string" ? value.default.length <= 200 : Number.isFinite(Number(value.default)), "Invalid bubble input default."); out.default = typeof value.default === "string" ? value.default : Number(value.default); }
|
||||
if (type === "select") {
|
||||
check(Array.isArray(value.options) && value.options.length >= 1 && value.options.length <= 8, "Bubble select inputs need 1-8 options.");
|
||||
out.options = (value.options as unknown[]).map((option) => { if (!isRecord(option) || typeof option.value !== "string" || option.value.length > 80 || typeof option.label !== "string" || option.label.length > 60) throw new Error("Invalid bubble input option."); return { value: option.value, label: option.label }; });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function validateMenuItems(value: unknown): PluginMenuItem[] {
|
||||
check(Array.isArray(value) && value.length <= quotas.menuItems, "Invalid plugin menu items.");
|
||||
const seen = new Set<string>();
|
||||
return (value as unknown[]).map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry.id !== "string" || !commandIdPattern.test(entry.id) || seen.has(entry.id)) throw new Error("Invalid plugin menu item id.");
|
||||
seen.add(entry.id);
|
||||
if (typeof entry.title !== "string" || entry.title.trim() === "" || entry.title.length > 80) throw new Error("Invalid plugin menu item title.");
|
||||
return { id: entry.id, title: entry.title, enabled: entry.enabled === false ? false : undefined, checked: entry.checked === true ? true : undefined };
|
||||
});
|
||||
}
|
||||
|
||||
function validateAiRequest(value: unknown): PluginAiRequest {
|
||||
if (!isRecord(value) || !Array.isArray(value.messages)) throw new Error("Invalid AI request.");
|
||||
check(value.messages.length >= 1 && value.messages.length <= 64, "AI request needs 1-64 messages.");
|
||||
const messages = (value.messages as unknown[]).map((entry) => {
|
||||
if (!isRecord(entry) || (entry.role !== "user" && entry.role !== "assistant") || typeof entry.content !== "string") throw new Error("Invalid AI message.");
|
||||
check(entry.content.length <= 32 * 1024, "AI message content is too long.");
|
||||
return { role: entry.role as "user" | "assistant", content: entry.content };
|
||||
});
|
||||
const out: PluginAiRequest = { messages };
|
||||
if (value.system !== undefined) { check(typeof value.system === "string" && value.system.length <= 32 * 1024, "Invalid AI system prompt."); out.system = String(value.system); }
|
||||
if (value.maxTokens !== undefined) out.maxTokens = clampNumber(Number(value.maxTokens), 1, 8192);
|
||||
if (value.temperature !== undefined) out.temperature = clampNumber(Number(value.temperature), 0, 2);
|
||||
if (value.tools !== undefined) {
|
||||
check(Array.isArray(value.tools) && value.tools.length <= 16, "AI request allows at most 16 tools.");
|
||||
out.tools = (value.tools as unknown[]).map((tool) => {
|
||||
if (!isRecord(tool) || typeof tool.name !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(tool.name) || !isRecord(tool.inputSchema)) throw new Error("Invalid AI tool definition.");
|
||||
const description = tool.description === undefined ? undefined : String(tool.description).slice(0, 1024);
|
||||
return { name: tool.name, description, inputSchema: normalizeJson(tool.inputSchema, 16 * 1024, "AI tool schema") as Record<string, unknown> };
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function validateOauthConfig(value: unknown): { provider: string; authorizationUrl: string; tokenUrl: string; clientId: string; scopes: string[]; pkce: boolean; redirect: "loopback" | "appProtocol" } {
|
||||
if (!isRecord(value)) throw new Error("Invalid OAuth config.");
|
||||
const authorizationUrl = new URL(String(value.authorizationUrl));
|
||||
const tokenUrl = new URL(String(value.tokenUrl));
|
||||
check(authorizationUrl.protocol === "https:" && tokenUrl.protocol === "https:", "OAuth URLs must be HTTPS.");
|
||||
check(!authorizationUrl.username && !tokenUrl.username, "OAuth URLs must not carry credentials.");
|
||||
const clientId = String(value.clientId ?? "");
|
||||
check(clientId.length >= 1 && clientId.length <= 512 && !/[\s\0]/.test(clientId), "Invalid OAuth clientId.");
|
||||
check(Array.isArray(value.scopes) && value.scopes.length <= 32, "Invalid OAuth scopes.");
|
||||
const scopes = (value.scopes as unknown[]).map((scope) => { const text = String(scope); check(text.length >= 1 && text.length <= 256 && !/[\r\n\0]/.test(text), "Invalid OAuth scope."); return text; });
|
||||
const provider = value.provider === undefined ? "generic" : validateProviderName(value.provider);
|
||||
const redirect = value.redirect === undefined ? "loopback" : String(value.redirect);
|
||||
check(redirect === "loopback" || redirect === "appProtocol", "Invalid OAuth redirect mode.");
|
||||
return { provider, authorizationUrl: authorizationUrl.toString(), tokenUrl: tokenUrl.toString(), clientId, scopes, pkce: value.pkce !== false, redirect: redirect as "loopback" | "appProtocol" };
|
||||
}
|
||||
|
||||
function validateProviderName(value: unknown): string { const provider = String(value); check(/^[a-z0-9][a-z0-9._-]{0,63}$/.test(provider), "Invalid OAuth provider name."); return provider; }
|
||||
|
||||
/** Clone-safe JSON normalization with a byte cap. */
|
||||
export function normalizeJson(value: unknown, maxBytes: number, label: string): unknown {
|
||||
let text: string;
|
||||
try { text = JSON.stringify(value ?? null); } catch { throw new Error(`Plugin ${label} must be JSON-compatible.`); }
|
||||
if (text === undefined) throw new Error(`Plugin ${label} must be JSON-compatible.`);
|
||||
check(Buffer.byteLength(text) <= maxBytes, `Plugin ${label} is too large.`);
|
||||
return JSON.parse(text) as unknown;
|
||||
}
|
||||
|
||||
function validateCommand(command: PluginCommand, validateIconAssetRef: (ref: unknown) => { kind: PluginAssetKind; name: string; path: string }): PluginCommand {
|
||||
if (!command || !commandIdPattern.test(command.id)) throw new Error("Invalid plugin command id.");
|
||||
if (typeof command.title !== "string" || command.title.trim() === "" || command.title.length > 80) throw new Error("Invalid plugin command title.");
|
||||
if (command.description !== undefined && (typeof command.description !== "string" || command.description.length > 240)) throw new Error("Invalid plugin command description.");
|
||||
const placement = command.placement === undefined ? undefined : (check(command.placement === "top" || command.placement === "submenu", "Invalid plugin command placement."), command.placement);
|
||||
const priority = command.priority === undefined ? undefined : (check(Number.isFinite(Number(command.priority)), "Invalid plugin command priority."), clampNumber(Number(command.priority), -1000, 1000));
|
||||
const icon = command.icon === undefined ? undefined : validateCommandIcon(command.icon, validateIconAssetRef);
|
||||
return { id: command.id, title: command.title, description: command.description, form: validateCommandForm(command.form), placement, priority, featured: command.featured === true || undefined, icon };
|
||||
}
|
||||
|
||||
function validateCommandIcon(icon: unknown, validateIconAssetRef: (ref: unknown) => { kind: PluginAssetKind; name: string; path: string }): PluginCommandIcon {
|
||||
if (typeof icon === "string") {
|
||||
check(namedHostIcons.has(icon), "Invalid plugin command icon.");
|
||||
return icon;
|
||||
}
|
||||
if (!isRecord(icon) || icon.kind !== "icon" || typeof icon.name !== "string") throw new Error("Invalid plugin command icon.");
|
||||
validateIconAssetRef(icon);
|
||||
return { kind: "icon", name: icon.name };
|
||||
}
|
||||
|
||||
const commandFormFieldTypes = new Set(["text", "textarea", "number", "boolean", "select", "multiSelect", "time", "date", "list"]);
|
||||
|
||||
function validateCommandForm(form: unknown): PluginCommandForm | undefined {
|
||||
if (form === undefined) return undefined;
|
||||
if (!isRecord(form) || !Array.isArray(form.fields) || form.fields.length < 1 || form.fields.length > 8) throw new Error("Invalid plugin command form.");
|
||||
const seen = new Set<string>();
|
||||
const fields = form.fields.map((field) => {
|
||||
if (!isRecord(field) || typeof field.id !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(field.id) || seen.has(field.id)) throw new Error("Invalid plugin command form field id.");
|
||||
seen.add(field.id);
|
||||
if (!commandFormFieldTypes.has(String(field.type))) throw new Error("Invalid plugin command form field type.");
|
||||
if (typeof field.label !== "string" || field.label.trim() === "" || field.label.length > 80) throw new Error("Invalid plugin command form label.");
|
||||
const out: PluginCommandFormField = { id: field.id, type: field.type as PluginCommandFormField["type"], label: field.label, required: field.required === true || undefined };
|
||||
if (out.type === "number") {
|
||||
if (field.default !== undefined && !Number.isFinite(Number(field.default))) throw new Error("Invalid plugin command form default.");
|
||||
if (field.min !== undefined && !Number.isFinite(Number(field.min))) throw new Error("Invalid plugin command form min.");
|
||||
if (field.max !== undefined && !Number.isFinite(Number(field.max))) throw new Error("Invalid plugin command form max.");
|
||||
if (field.min !== undefined) out.min = Number(field.min);
|
||||
if (field.max !== undefined) out.max = Number(field.max);
|
||||
if (out.min !== undefined && out.max !== undefined && out.min > out.max) throw new Error("Invalid plugin command form range.");
|
||||
if (field.default !== undefined) out.default = Number(field.default);
|
||||
} else if (out.type === "boolean") {
|
||||
if (field.default !== undefined && typeof field.default !== "boolean") throw new Error("Invalid plugin command form default.");
|
||||
if (field.default !== undefined) out.default = field.default;
|
||||
} else if (out.type === "select" || out.type === "multiSelect") {
|
||||
if (!Array.isArray(field.options) || field.options.length < 1 || field.options.length > 24) throw new Error("Invalid plugin command form options.");
|
||||
const values = new Set<string>();
|
||||
out.options = field.options.map((option) => {
|
||||
if (!isRecord(option) || typeof option.value !== "string" || option.value.length > 120 || typeof option.label !== "string" || option.label.trim() === "" || option.label.length > 80 || values.has(option.value)) throw new Error("Invalid plugin command form option.");
|
||||
values.add(option.value);
|
||||
return { label: option.label, value: option.value };
|
||||
});
|
||||
if (field.default !== undefined) {
|
||||
if (out.type === "multiSelect") { if (!Array.isArray(field.default) || field.default.some((entry) => typeof entry !== "string" || !values.has(entry))) throw new Error("Invalid plugin command form default."); out.default = field.default as string[]; }
|
||||
else { if (typeof field.default !== "string" || !values.has(field.default)) throw new Error("Invalid plugin command form default."); out.default = field.default; }
|
||||
}
|
||||
} else if (out.type === "time") {
|
||||
if (field.default !== undefined && (typeof field.default !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(field.default))) throw new Error("Invalid plugin command form default.");
|
||||
if (field.default !== undefined) out.default = field.default;
|
||||
} else if (out.type === "date") {
|
||||
if (field.default !== undefined && (typeof field.default !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(field.default))) throw new Error("Invalid plugin command form default.");
|
||||
if (field.default !== undefined) out.default = field.default;
|
||||
} else if (out.type === "list") {
|
||||
if (field.default !== undefined && (!Array.isArray(field.default) || field.default.some((entry) => typeof entry !== "string" || entry.length > 200) || field.default.length > 32)) throw new Error("Invalid plugin command form default.");
|
||||
if (field.default !== undefined) out.default = field.default as string[];
|
||||
if (field.maxLength !== undefined && (!Number.isInteger(Number(field.maxLength)) || Number(field.maxLength) < 1 || Number(field.maxLength) > 1000)) throw new Error("Invalid plugin command form maxLength.");
|
||||
if (field.maxLength !== undefined) out.maxLength = Number(field.maxLength);
|
||||
} else {
|
||||
if (field.default !== undefined && typeof field.default !== "string") throw new Error("Invalid plugin command form default.");
|
||||
if (field.maxLength !== undefined && (!Number.isInteger(Number(field.maxLength)) || Number(field.maxLength) < 1 || Number(field.maxLength) > 1000)) throw new Error("Invalid plugin command form maxLength.");
|
||||
if (field.default !== undefined) out.default = field.default;
|
||||
if (field.maxLength !== undefined) out.maxLength = Number(field.maxLength);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const submitLabel = typeof form.submitLabel === "string" && form.submitLabel.trim() && form.submitLabel.length <= 40 ? form.submitLabel : undefined;
|
||||
return { fields, submitLabel };
|
||||
}
|
||||
|
||||
export function validateCommandFormValues(form: PluginCommandForm, args: unknown): Record<string, unknown> {
|
||||
const input = isRecord(args) ? args : {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of form.fields) {
|
||||
const raw = input[field.id];
|
||||
if (field.type === "number") {
|
||||
const value = Number(raw ?? field.default ?? 0);
|
||||
if (!Number.isFinite(value)) throw new Error(`${field.label} must be a number.`);
|
||||
if (field.min !== undefined && value < field.min) throw new Error(`${field.label} is too small.`);
|
||||
if (field.max !== undefined && value > field.max) throw new Error(`${field.label} is too large.`);
|
||||
out[field.id] = value;
|
||||
} else if (field.type === "boolean") {
|
||||
out[field.id] = raw === undefined ? field.default === true : raw === true || raw === "true";
|
||||
} else if (field.type === "select") {
|
||||
const value = String(raw ?? field.default ?? "");
|
||||
if (field.required && !value) throw new Error(`${field.label} is required.`);
|
||||
if (value && !(field.options ?? []).some((option) => option.value === value)) throw new Error(`${field.label} has an invalid value.`);
|
||||
out[field.id] = value;
|
||||
} else if (field.type === "multiSelect") {
|
||||
const values = Array.isArray(raw) ? raw.map(String) : Array.isArray(field.default) ? field.default : [];
|
||||
const allowed = new Set((field.options ?? []).map((option) => option.value));
|
||||
if (values.some((value) => !allowed.has(value))) throw new Error(`${field.label} has an invalid value.`);
|
||||
out[field.id] = values;
|
||||
} else if (field.type === "time") {
|
||||
const value = String(raw ?? field.default ?? "").trim();
|
||||
if (field.required && !value) throw new Error(`${field.label} is required.`);
|
||||
if (value && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error(`${field.label} must be HH:mm.`);
|
||||
out[field.id] = value;
|
||||
} else if (field.type === "date") {
|
||||
const value = String(raw ?? field.default ?? "").trim();
|
||||
if (field.required && !value) throw new Error(`${field.label} is required.`);
|
||||
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error(`${field.label} must be YYYY-MM-DD.`);
|
||||
out[field.id] = value;
|
||||
} else if (field.type === "list") {
|
||||
const values = Array.isArray(raw) ? raw.map((entry) => String(entry).trim()).filter(Boolean) : Array.isArray(field.default) ? field.default : [];
|
||||
if (values.length > 32) throw new Error(`${field.label} has too many entries.`);
|
||||
if (field.maxLength !== undefined && values.some((value) => value.length > field.maxLength!)) throw new Error(`${field.label} entries are too long.`);
|
||||
out[field.id] = values;
|
||||
} else {
|
||||
const text = String(raw ?? field.default ?? "").trim();
|
||||
if (field.required && !text) throw new Error(`${field.label} is required.`);
|
||||
if (field.maxLength !== undefined && text.length > field.maxLength) throw new Error(`${field.label} is too long.`);
|
||||
out[field.id] = text;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function validateStatus(status: PluginStatus | string): PluginStatus { const value = typeof status === "string" ? { text: status } : status; if (!value || typeof value.text !== "string" || value.text.trim() === "" || value.text.length > 120) throw new Error("Invalid plugin status text."); if (value.tone !== undefined && !["info", "success", "warning", "error"].includes(value.tone)) throw new Error("Invalid plugin status tone."); return { text: value.text, tone: value.tone }; }
|
||||
function validateMoveBy(value: unknown): { x: number; y: number; durationMs?: number } { if (!isRecord(value)) throw new Error("Invalid familiar movement options."); const x = Number(value.x); const y = Number(value.y); if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Invalid familiar movement distance."); return { x, y, durationMs: value.durationMs === undefined ? undefined : Number(value.durationMs) }; }
|
||||
function validateWander(value: unknown): { distance?: number; durationMs?: number } { const options = isRecord(value) ? value : {}; return { distance: options.distance === undefined ? undefined : Number(options.distance), durationMs: options.durationMs === undefined ? undefined : Number(options.durationMs) }; }
|
||||
function parseDaily(spec: string | { time: string; days?: number[] }): { time: string; days?: number[] } { const value = typeof spec === "string" ? { time: spec } : spec; const m = /^(\d{2}):(\d{2})$/.exec(value.time); if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) throw new Error("Daily schedule time must be HH:mm between 00:00 and 23:59."); if (value.days && (!Array.isArray(value.days) || value.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) throw new Error("Daily schedule days must be weekdays 0-6."); return value; }
|
||||
function msUntilDaily(spec: { time: string; days?: number[] }): number { const [hour, minute] = spec.time.split(":").map(Number); const now = new Date(); for (let add = 0; add <= 7; add += 1) { const next = new Date(now); next.setDate(now.getDate() + add); next.setHours(hour ?? 0, minute ?? 0, 0, 0); if (next > now && (!spec.days || spec.days.includes(next.getDay()))) return next.getTime() - now.getTime(); } return 24 * 60 * 60 * 1000; }
|
||||
|
||||
|
|
@ -1270,69 +824,5 @@ function nextScheduleDelayMs(spec: ScheduleSpec): number | null {
|
|||
return next === null ? null : Math.max(1_000, next - Date.now());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5-field cron (m h dom mon dow)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CronField = Set<number>;
|
||||
type ParsedCron = { minutes: CronField; hours: CronField; daysOfMonth: CronField; months: CronField; daysOfWeek: CronField; domWildcard: boolean; dowWildcard: boolean };
|
||||
|
||||
export function parseCronExpression(expr: string): ParsedCron {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) throw new Error("Cron expressions must have 5 fields (m h dom mon dow).");
|
||||
const [minutePart, hourPart, domPart, monthPart, dowPart] = parts as [string, string, string, string, string];
|
||||
return {
|
||||
minutes: parseCronField(minutePart, 0, 59),
|
||||
hours: parseCronField(hourPart, 0, 23),
|
||||
daysOfMonth: parseCronField(domPart, 1, 31),
|
||||
months: parseCronField(monthPart, 1, 12),
|
||||
daysOfWeek: parseCronField(dowPart, 0, 7, true),
|
||||
domWildcard: domPart === "*",
|
||||
dowWildcard: dowPart === "*",
|
||||
};
|
||||
}
|
||||
|
||||
function parseCronField(field: string, min: number, max: number, mapSevenToZero = false): CronField {
|
||||
const values = new Set<number>();
|
||||
if (field.length === 0 || field.length > 64) throw new Error("Invalid cron field.");
|
||||
for (const part of field.split(",")) {
|
||||
const stepMatch = /^(.+)\/(\d+)$/.exec(part);
|
||||
const base = stepMatch ? stepMatch[1]! : part;
|
||||
const step = stepMatch ? Number(stepMatch[2]) : 1;
|
||||
if (!Number.isInteger(step) || step < 1 || step > max) throw new Error("Invalid cron step.");
|
||||
let start = min; let end = max;
|
||||
if (base !== "*") {
|
||||
const rangeMatch = /^(\d+)-(\d+)$/.exec(base);
|
||||
if (rangeMatch) { start = Number(rangeMatch[1]); end = Number(rangeMatch[2]); }
|
||||
else { if (!/^\d+$/.test(base)) throw new Error("Invalid cron value."); start = Number(base); end = stepMatch ? max : start; }
|
||||
}
|
||||
if (start < min || end > max || start > end) throw new Error("Cron value out of range.");
|
||||
for (let value = start; value <= end; value += step) values.add(mapSevenToZero && value === 7 ? 0 : value);
|
||||
}
|
||||
if (values.size === 0) throw new Error("Invalid cron field.");
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Next run strictly after `fromMs`, or null if none within 4 years. */
|
||||
export function nextCronRunMs(expr: string, fromMs: number): number | null {
|
||||
const cron = parseCronExpression(expr);
|
||||
const candidate = new Date(fromMs);
|
||||
candidate.setSeconds(0, 0);
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
const limit = fromMs + 4 * 366 * 24 * 60 * 60 * 1000;
|
||||
while (candidate.getTime() <= limit) {
|
||||
if (!cron.months.has(candidate.getMonth() + 1)) { candidate.setMonth(candidate.getMonth() + 1, 1); candidate.setHours(0, 0, 0, 0); continue; }
|
||||
const domMatch = cron.daysOfMonth.has(candidate.getDate());
|
||||
const dowMatch = cron.daysOfWeek.has(candidate.getDay());
|
||||
const dayMatch = cron.domWildcard && cron.dowWildcard ? true : cron.domWildcard ? dowMatch : cron.dowWildcard ? domMatch : domMatch || dowMatch;
|
||||
if (!dayMatch) { candidate.setDate(candidate.getDate() + 1); candidate.setHours(0, 0, 0, 0); continue; }
|
||||
if (!cron.hours.has(candidate.getHours())) { candidate.setHours(candidate.getHours() + 1, 0, 0, 0); continue; }
|
||||
if (!cron.minutes.has(candidate.getMinutes())) { candidate.setMinutes(candidate.getMinutes() + 1, 0, 0); continue; }
|
||||
return candidate.getTime();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error("Plugin command timed out.")), timeoutMs); promise.then((v) => { clearTimeout(timeout); resolve(v); }, (e) => { clearTimeout(timeout); reject(e); }); }); }
|
||||
function safeError(error: unknown): string { return error instanceof Error ? error.message : "Plugin SDK callback failed."; }
|
||||
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
||||
|
|
|
|||
5
apps/desktop/src/plugin-sdk-contracts.ts
Normal file
5
apps/desktop/src/plugin-sdk-contracts.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type { PluginConfig } from "./plugin-config.js";
|
||||
export type { PluginPetApi } from "./plugin-familiar-api.js";
|
||||
export type { FamiliarOSJavascriptPluginManifest, PluginAssetKind, PluginPermission } from "./plugin-manifest.js";
|
||||
export type { PluginRuntimeScheduler } from "./plugin-runtime.js";
|
||||
export type { PluginStateRecord, PluginStateStore } from "./plugin-state.js";
|
||||
115
apps/desktop/src/plugin-sdk-default-capabilities.ts
Normal file
115
apps/desktop/src/plugin-sdk-default-capabilities.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import type { PluginHostCapabilities } from "./plugin-sdk-bridge.js";
|
||||
import type { PluginPetApi } from "./plugin-familiar-api.js";
|
||||
|
||||
export function createDefaultPluginHostCapabilities(petApi: PluginPetApi): PluginHostCapabilities {
|
||||
const unavailable = (feature: string) => async (): Promise<never> => {
|
||||
throw new Error(`Plugin host capability is unavailable: ${feature}`);
|
||||
};
|
||||
|
||||
return {
|
||||
bubbles: {
|
||||
async show({ bubble, callbacks }) {
|
||||
if (bubble.text) await petApi.speak(bubble.text);
|
||||
let dismissed = false;
|
||||
return {
|
||||
id: `bubble-${Math.random().toString(36).slice(2)}`,
|
||||
update: async () => undefined,
|
||||
dismiss: async () => {
|
||||
if (!dismissed) {
|
||||
dismissed = true;
|
||||
callbacks.onDismiss("manual");
|
||||
}
|
||||
},
|
||||
pin: async () => undefined,
|
||||
unpin: async () => {
|
||||
if (!dismissed) {
|
||||
dismissed = true;
|
||||
callbacks.onDismiss("unpinned");
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
play: async () => undefined,
|
||||
importUserSound: async (_pluginId, _fileId, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }),
|
||||
importUserSoundFromPath: async (_pluginId, _path, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }),
|
||||
forgetUserSound: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
},
|
||||
events: { subscribe: () => () => undefined },
|
||||
familiars: {
|
||||
list: () => [{ id: "default", name: "Default familiar", kind: "default", visible: true }],
|
||||
spawn: unavailable("familiars.spawn"),
|
||||
close: unavailable("familiars.close"),
|
||||
show: async () => undefined,
|
||||
hide: async () => undefined,
|
||||
react: async (_petId, reaction, options) => {
|
||||
await petApi.react(reaction, options);
|
||||
},
|
||||
setAnimation: async (_petId, spec) => {
|
||||
if (spec.kind === "reaction") await petApi.react(spec.reaction);
|
||||
},
|
||||
setScale: async () => undefined,
|
||||
setStatusReaction: async () => undefined,
|
||||
moveBy: async (_petId, opts) => {
|
||||
await petApi.moveBy(opts);
|
||||
},
|
||||
wander: async (_petId, opts) => {
|
||||
await petApi.wander(opts);
|
||||
},
|
||||
moveToHome: async () => {
|
||||
await petApi.moveToHome();
|
||||
},
|
||||
moveTo: unavailable("familiars.moveTo"),
|
||||
followCursor: async () => undefined,
|
||||
physics: async () => undefined,
|
||||
getState: async () => ({
|
||||
position: { x: 0, y: 0 },
|
||||
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
||||
currentAnimation: "idle",
|
||||
visible: true,
|
||||
dragging: false,
|
||||
}),
|
||||
onTick: () => () => undefined,
|
||||
onChange: () => () => undefined,
|
||||
},
|
||||
toast: async () => undefined,
|
||||
notify: async () => undefined,
|
||||
panels: { open: unavailable("panels.open") },
|
||||
secrets: (() => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get: async (pluginId: string, key: string) => store.get(`${pluginId}\0${key}`),
|
||||
set: async (pluginId: string, key: string, value: string) => void store.set(`${pluginId}\0${key}`, value),
|
||||
delete: async (pluginId: string, key: string) => void store.delete(`${pluginId}\0${key}`),
|
||||
has: async (pluginId: string, key: string) => store.has(`${pluginId}\0${key}`),
|
||||
};
|
||||
})(),
|
||||
ai: { available: async () => false, complete: unavailable("ai.complete"), stream: unavailable("ai.stream") },
|
||||
voice: { speak: unavailable("voice.speak"), listen: unavailable("voice.listen") },
|
||||
auth: { oauth: unavailable("auth.oauth"), refresh: unavailable("auth.refresh"), signOut: async () => undefined },
|
||||
files: { pick: async () => [], read: unavailable("files.read"), save: unavailable("files.save") },
|
||||
system: {
|
||||
info: async () => ({
|
||||
platform: process.platform === "darwin" ? "mac" : process.platform === "win32" ? "win" : "linux",
|
||||
locale: "en-US",
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
|
||||
theme: "light",
|
||||
appVersion: "0.0.0",
|
||||
online: true,
|
||||
}),
|
||||
metrics: async () => ({ cpuPercent: 0, memUsedPercent: 0 }),
|
||||
openExternal: unavailable("system.openExternal"),
|
||||
readClipboardText: unavailable("system.readClipboardText"),
|
||||
writeClipboardText: unavailable("system.writeClipboardText"),
|
||||
},
|
||||
settings: {
|
||||
audioAllowed: () => true,
|
||||
dynamicSpeechAllowed: () => false,
|
||||
voiceAllowed: () => true,
|
||||
listenAllowed: () => false,
|
||||
inQuietHours: () => false,
|
||||
},
|
||||
};
|
||||
}
|
||||
324
apps/desktop/src/plugin-sdk-network.ts
Normal file
324
apps/desktop/src/plugin-sdk-network.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import { lookup } from "node:dns/promises";
|
||||
import * as net from "node:net";
|
||||
|
||||
import type { PluginRuntimeLogger } from "./plugin-sdk-bridge.js";
|
||||
import { classifyPluginError, logPluginDiagnostic } from "./plugin-diagnostics.js";
|
||||
import type { FamiliarOSJavascriptPluginManifest, PluginPermission } from "./plugin-manifest.js";
|
||||
import { pluginSdkQuotas } from "./plugin-sdk-quotas.js";
|
||||
import type { PluginStateRecord } from "./plugin-state.js";
|
||||
|
||||
export type SimpleHttpResponse = {
|
||||
status: number;
|
||||
ok: boolean;
|
||||
headers: Record<string, string>;
|
||||
text: string;
|
||||
json?: unknown;
|
||||
};
|
||||
|
||||
export type ValidatedNetOptions = {
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type NetworkDiagnostics = {
|
||||
logger?: PluginRuntimeLogger;
|
||||
pluginId?: string;
|
||||
route?: string;
|
||||
};
|
||||
|
||||
const quotas = pluginSdkQuotas;
|
||||
|
||||
const forbiddenHeaderNames = new Set([
|
||||
"host",
|
||||
"cookie",
|
||||
"cookie2",
|
||||
"origin",
|
||||
"referer",
|
||||
"content-length",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"keep-alive",
|
||||
"te",
|
||||
"trailer",
|
||||
"expect",
|
||||
"via",
|
||||
]);
|
||||
|
||||
export function validateNetOptions(options: unknown, approved: ReadonlySet<PluginPermission>): ValidatedNetOptions {
|
||||
const opts = isRecord(options) ? options : {};
|
||||
const method = String(opts.method ?? "GET").toUpperCase();
|
||||
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) throw new Error("Plugin HTTP method is not allowed.");
|
||||
if (method !== "GET" && !approved.has("network:write")) throw new Error("Plugin permission is not approved: network:write");
|
||||
let body: string | undefined;
|
||||
if (opts.body !== undefined) {
|
||||
if (method === "GET") throw new Error("Plugin GET requests must not have a body.");
|
||||
body = String(opts.body);
|
||||
if (Buffer.byteLength(body) > quotas.httpRequestBodyBytes) throw new Error("Plugin HTTP request body is too large.");
|
||||
}
|
||||
return {
|
||||
method,
|
||||
headers: safeNetHeaders(opts.headers),
|
||||
body,
|
||||
timeoutMs: opts.timeoutMs === undefined ? undefined : Number(opts.timeoutMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function allowedNetworkHosts(record: PluginStateRecord, manifest: FamiliarOSJavascriptPluginManifest): Set<string> {
|
||||
const manifestHosts = new Set((manifest.network?.hosts ?? []).map((host) => host.toLowerCase()));
|
||||
const approved = record.approvedNetworkHosts?.map((host) => host.toLowerCase()) ?? [];
|
||||
return new Set(approved.filter((host) => manifestHosts.has(host)));
|
||||
}
|
||||
|
||||
export async function safeHttpFetch(urlText: string, options: ValidatedNetOptions | unknown, allowedHosts: Set<string>, diagnostics?: NetworkDiagnostics): Promise<SimpleHttpResponse> {
|
||||
const opts: ValidatedNetOptions = isValidatedNetOptions(options) ? options : { method: "GET", headers: undefined, timeoutMs: undefined };
|
||||
const started = Date.now();
|
||||
let host = "";
|
||||
try {
|
||||
host = new URL(urlText).hostname.toLowerCase();
|
||||
} catch {
|
||||
host = "invalid";
|
||||
}
|
||||
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", {
|
||||
pluginId: diagnostics?.pluginId,
|
||||
route: diagnostics?.route ?? "net.fetch",
|
||||
method: opts.method,
|
||||
host,
|
||||
phase: "begin",
|
||||
});
|
||||
|
||||
let prepared: Awaited<ReturnType<typeof prepareSafeRequest>>;
|
||||
try {
|
||||
prepared = await prepareSafeRequest(urlText, opts, allowedHosts);
|
||||
} catch (error) {
|
||||
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", {
|
||||
pluginId: diagnostics?.pluginId,
|
||||
route: diagnostics?.route ?? "net.fetch",
|
||||
method: opts.method,
|
||||
host,
|
||||
phase: "denied",
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
errorCode: classifyPluginError(error),
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { url, init, timeout } = prepared;
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
||||
throw new Error("Plugin HTTP redirects are not allowed.");
|
||||
}
|
||||
const text = await readCapped(response, quotas.httpResponseBytes);
|
||||
const headers: Record<string, string> = {};
|
||||
for (const key of ["content-type", "etag", "last-modified", "retry-after", "x-ratelimit-remaining"]) {
|
||||
const value = response.headers.get(key);
|
||||
if (value) headers[key] = value;
|
||||
}
|
||||
let json: unknown;
|
||||
if ((headers["content-type"] ?? "").includes("application/json")) {
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = undefined;
|
||||
}
|
||||
}
|
||||
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", {
|
||||
pluginId: diagnostics?.pluginId,
|
||||
route: diagnostics?.route ?? "net.fetch",
|
||||
method: opts.method,
|
||||
host: url.hostname,
|
||||
phase: "success",
|
||||
status: response.status,
|
||||
sizeBytes: Buffer.byteLength(text),
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return { status: response.status, ok: response.ok, headers, text, ...(json === undefined ? {} : { json }) };
|
||||
} catch (error) {
|
||||
const mapped = error instanceof Error && error.name === "AbortError" ? new Error("Plugin HTTP fetch timed out.") : error;
|
||||
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", {
|
||||
pluginId: diagnostics?.pluginId,
|
||||
route: diagnostics?.route ?? "net.fetch",
|
||||
method: opts.method,
|
||||
host: url.hostname,
|
||||
phase: "fail",
|
||||
reason: mapped instanceof Error ? mapped.message : String(mapped),
|
||||
errorCode: classifyPluginError(mapped),
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
if (mapped instanceof Error) throw mapped;
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function safeHttpStream(urlText: string, opts: ValidatedNetOptions, allowedHosts: Set<string>, onChunk: (chunk: string) => void, diagnostics?: NetworkDiagnostics): Promise<{ status: number; ok: boolean }> {
|
||||
const started = Date.now();
|
||||
let host = "";
|
||||
try {
|
||||
host = new URL(urlText).hostname.toLowerCase();
|
||||
} catch {
|
||||
host = "invalid";
|
||||
}
|
||||
|
||||
let prepared: Awaited<ReturnType<typeof prepareSafeRequest>>;
|
||||
try {
|
||||
prepared = await prepareSafeRequest(urlText, { ...opts, timeoutMs: opts.timeoutMs ?? 120_000 }, allowedHosts);
|
||||
} catch (error) {
|
||||
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", {
|
||||
pluginId: diagnostics?.pluginId,
|
||||
route: diagnostics?.route ?? "net.stream",
|
||||
method: opts.method,
|
||||
host,
|
||||
phase: "denied",
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
errorCode: classifyPluginError(error),
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { url, init, timeout } = prepared;
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
||||
throw new Error("Plugin HTTP redirects are not allowed.");
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > quotas.streamResponseBytes) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new Error("Plugin HTTP stream is too large.");
|
||||
}
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
if (chunk.length > 0) onChunk(chunk);
|
||||
}
|
||||
const tail = decoder.decode();
|
||||
if (tail.length > 0) onChunk(tail);
|
||||
}
|
||||
logPluginDiagnostic(diagnostics?.logger, "debug", "plugin network request", {
|
||||
pluginId: diagnostics?.pluginId,
|
||||
route: diagnostics?.route ?? "net.stream",
|
||||
method: opts.method,
|
||||
host: url.hostname,
|
||||
phase: "success",
|
||||
status: response.status,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return { status: response.status, ok: response.ok };
|
||||
} catch (error) {
|
||||
const mapped = error instanceof Error && error.name === "AbortError" ? new Error("Plugin HTTP stream timed out.") : error;
|
||||
logPluginDiagnostic(diagnostics?.logger, "warn", "plugin network request", {
|
||||
pluginId: diagnostics?.pluginId,
|
||||
route: diagnostics?.route ?? "net.stream",
|
||||
method: opts.method,
|
||||
host: url.hostname,
|
||||
phase: "fail",
|
||||
reason: mapped instanceof Error ? mapped.message : String(mapped),
|
||||
errorCode: classifyPluginError(mapped),
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
if (mapped instanceof Error) throw mapped;
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertPublicHost(host: string): Promise<void> {
|
||||
if (["localhost", "metadata.google.internal"].includes(host) || host.endsWith(".localhost")) {
|
||||
throw new Error("Plugin HTTP host is not public.");
|
||||
}
|
||||
const results = await lookup(host, { all: true, verbatim: true });
|
||||
if (results.length === 0 || results.some((result) => isPrivateIp(result.address))) {
|
||||
throw new Error("Plugin HTTP host resolves to a restricted address.");
|
||||
}
|
||||
}
|
||||
|
||||
export function isPrivateIp(address: string): boolean {
|
||||
if (net.isIPv4(address)) {
|
||||
const parts = address.split(".").map(Number);
|
||||
return parts[0] === 10
|
||||
|| parts[0] === 127
|
||||
|| parts[0] === 0
|
||||
|| (parts[0] === 169 && parts[1] === 254)
|
||||
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
||||
|| (parts[0] === 192 && parts[1] === 168)
|
||||
|| (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127);
|
||||
}
|
||||
const value = address.toLowerCase();
|
||||
return value === "::1"
|
||||
|| value === "::"
|
||||
|| value.startsWith("fc")
|
||||
|| value.startsWith("fd")
|
||||
|| value.startsWith("fe80:")
|
||||
|| value.startsWith("::ffff:127.")
|
||||
|| value.startsWith("::ffff:10.")
|
||||
|| value.startsWith("::ffff:192.168.");
|
||||
}
|
||||
|
||||
function safeNetHeaders(value: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const [name, headerValue] of Object.entries(value).slice(0, 24)) {
|
||||
const lower = name.toLowerCase();
|
||||
if (!/^[a-z0-9-]{1,64}$/.test(lower) || forbiddenHeaderNames.has(lower) || lower.startsWith("proxy-") || lower.startsWith("sec-")) continue;
|
||||
if (typeof headerValue !== "string" || headerValue.length > 4096 || /[\r\n\0]/.test(headerValue)) continue;
|
||||
out[lower] = headerValue;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function prepareSafeRequest(urlText: string, opts: ValidatedNetOptions, allowedHosts: Set<string>): Promise<{ url: URL; init: RequestInit; controller: AbortController; timeout: NodeJS.Timeout }> {
|
||||
const url = new URL(urlText);
|
||||
if (url.protocol !== "https:") throw new Error("Plugin HTTP fetch requires HTTPS.");
|
||||
if (url.username || url.password) throw new Error("Plugin HTTP fetch credentials are not allowed.");
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (!allowedHosts.has(host)) throw new Error("Plugin HTTP host is not approved.");
|
||||
await assertPublicHost(host);
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = Math.min(Math.max(Number(opts.timeoutMs ?? 10_000), 1_000), 120_000);
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const init: RequestInit = {
|
||||
method: opts.method,
|
||||
redirect: "manual",
|
||||
credentials: "omit",
|
||||
signal: controller.signal,
|
||||
headers: opts.headers ?? {},
|
||||
...(opts.body === undefined ? {} : { body: opts.body }),
|
||||
};
|
||||
return { url, init, controller, timeout };
|
||||
}
|
||||
|
||||
function isValidatedNetOptions(value: unknown): value is ValidatedNetOptions {
|
||||
return isRecord(value) && typeof value.method === "string";
|
||||
}
|
||||
|
||||
async function readCapped(response: Response, cap: number): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return "";
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > cap) throw new Error("Plugin HTTP response is too large.");
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
5
apps/desktop/src/plugin-sdk-support.ts
Normal file
5
apps/desktop/src/plugin-sdk-support.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { getActiveLocaleLang } from "./i18n/index.js";
|
||||
export { classifyPluginError } from "./plugin-diagnostics.js";
|
||||
export { resolveDeclaredAssetPath, resolveDeclaredPanelPath } from "./plugin-assets.js";
|
||||
export { makePluginT } from "./plugin-i18n.js";
|
||||
export { validateReaction, validateSayMessage, type FamiliarOSReaction } from "./local-ipc-protocol.js";
|
||||
6
apps/desktop/src/plugin-sdk-surfaces.ts
Normal file
6
apps/desktop/src/plugin-sdk-surfaces.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { createPluginAudioApi } from "./plugin-sdk-audio.js";
|
||||
export { createPluginBusApi, type PluginBusTopicEntry } from "./plugin-sdk-bus.js";
|
||||
export { createPluginConfigApi } from "./plugin-sdk-config.js";
|
||||
export { createPluginEventsApi } from "./plugin-sdk-events.js";
|
||||
export { createPluginStorageApi } from "./plugin-sdk-storage.js";
|
||||
export { createPluginUiApi } from "./plugin-sdk-ui.js";
|
||||
568
apps/desktop/src/plugin-sdk-validators.ts
Normal file
568
apps/desktop/src/plugin-sdk-validators.ts
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
import type {
|
||||
PluginAiRequest,
|
||||
PluginBubbleAction,
|
||||
PluginBubbleInput,
|
||||
PluginCommand,
|
||||
PluginCommandForm,
|
||||
PluginCommandFormField,
|
||||
PluginCommandIcon,
|
||||
PluginMenuItem,
|
||||
PluginReactOptions,
|
||||
PluginStatus,
|
||||
} from "./plugin-sdk-bridge.js";
|
||||
import { validateSayMessage } from "./local-ipc-protocol.js";
|
||||
import type { PluginAssetKind } from "./plugin-manifest.js";
|
||||
import { pluginSdkQuotas } from "./plugin-sdk-quotas.js";
|
||||
|
||||
const quotas = pluginSdkQuotas;
|
||||
|
||||
export const commandIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
|
||||
export const scheduleIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
|
||||
export const allowedAccents = new Set(["blue", "purple", "green", "amber", "red", "pink", "slate"]);
|
||||
export const namedHostIcons = new Set(["info", "check", "alert", "heart", "star", "bell", "coffee", "timer", "droplet", "sparkles", "zap", "moon", "sun", "food", "play", "pause"]);
|
||||
|
||||
const safeCssColorPattern = /^(#[0-9a-fA-F]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)|hsla?\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\))$/;
|
||||
const commandFormFieldTypes = new Set(["text", "textarea", "number", "boolean", "select", "multiSelect", "time", "date", "list"]);
|
||||
|
||||
type CronField = Set<number>;
|
||||
type ParsedCron = {
|
||||
minutes: CronField;
|
||||
hours: CronField;
|
||||
daysOfMonth: CronField;
|
||||
months: CronField;
|
||||
daysOfWeek: CronField;
|
||||
domWildcard: boolean;
|
||||
dowWildcard: boolean;
|
||||
};
|
||||
|
||||
export function check(ok: boolean, message: string): void {
|
||||
if (!ok) throw new Error(message);
|
||||
}
|
||||
|
||||
export function clampNumber(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function validateCssColor(value: unknown, message: string): string {
|
||||
const color = String(value).trim();
|
||||
check(color.length <= 48 && safeCssColorPattern.test(color), message);
|
||||
return color;
|
||||
}
|
||||
|
||||
export function validateStorageKey(key: string): string {
|
||||
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(String(key))) throw new Error("Invalid plugin storage key.");
|
||||
return String(key);
|
||||
}
|
||||
|
||||
export function validatePetHandleId(value: unknown): string {
|
||||
const id = String(value);
|
||||
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(id)) throw new Error("Invalid familiar handle id.");
|
||||
return id;
|
||||
}
|
||||
|
||||
export function validateReactOptions(value: unknown): PluginReactOptions | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value)) throw new Error("Invalid familiar reaction options.");
|
||||
const keys = Object.keys(value);
|
||||
check(keys.every((key) => key === "showMessage"), "Invalid familiar reaction option.");
|
||||
if (value.showMessage !== undefined && typeof value.showMessage !== "boolean") {
|
||||
throw new Error("Invalid familiar reaction showMessage option.");
|
||||
}
|
||||
return value.showMessage === undefined ? {} : { showMessage: value.showMessage };
|
||||
}
|
||||
|
||||
export function validatePoint(value: unknown): { x: number; y: number } {
|
||||
if (!isRecord(value)) throw new Error("Invalid point.");
|
||||
const x = Number(value.x);
|
||||
const y = Number(value.y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Invalid point.");
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
export function validateMoveToOptions(value: unknown): { durationMs?: number; easing?: string } {
|
||||
const opts = isRecord(value) ? value : {};
|
||||
const durationMs = opts.durationMs === undefined ? undefined : clampNumber(Number(opts.durationMs), 100, 10_000);
|
||||
const easing = opts.easing === undefined
|
||||
? undefined
|
||||
: (check(["linear", "ease-in", "ease-out", "ease-in-out"].includes(String(opts.easing)), "Invalid easing."), String(opts.easing));
|
||||
return { durationMs, easing };
|
||||
}
|
||||
|
||||
export function validateDynamicText(value: string): string {
|
||||
const text = value.replace(/[\0-\x08\x0B\x0C\x0E-\x1F]/g, "").trim();
|
||||
check(text.length >= 1, "Dynamic speech cannot be empty.");
|
||||
check(text.length <= quotas.dynamicTextChars, "Dynamic speech is too long.");
|
||||
return text
|
||||
.replace(/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, "[redacted]")
|
||||
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
|
||||
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[redacted]")
|
||||
.replace(/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g, "[redacted]")
|
||||
.replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]");
|
||||
}
|
||||
|
||||
export function validatePinnedBubbleText(value: string): string {
|
||||
const text = value.trim().replace(/\r\n?/g, "\n");
|
||||
check(text.length >= 1, "Pinned bubble text cannot be empty.");
|
||||
check(text.length <= 140, "Pinned bubble text is too long.");
|
||||
const lines = text.split("\n");
|
||||
check(lines.length <= 4, "Pinned bubble text has too many lines.");
|
||||
check(lines.every((line) => line.trim().length > 0), "Pinned bubble text cannot contain blank lines.");
|
||||
return lines.map((line) => validateSayMessage(line)).join("\n");
|
||||
}
|
||||
|
||||
export function screenStaticBubbleText(markdown: string): void {
|
||||
check(!/```|<script|function\s+\w+\(|\b(import|export)\s/.test(markdown), "Bubble markdown looks like code.");
|
||||
check(!/https?:\/\/|www\./.test(markdown), "Bubble markdown contains a URL.");
|
||||
check(!/(api[_-]?key|secret|password|BEGIN [A-Z ]+PRIVATE KEY)/i.test(markdown), "Bubble markdown looks secret-like.");
|
||||
}
|
||||
|
||||
export function renderLimitedMarkdown(markdown: string): string {
|
||||
const escaped = markdown
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
return escaped
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
|
||||
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
export function validateBubbleActions(value: unknown): PluginBubbleAction[] {
|
||||
check(Array.isArray(value) && value.length >= 1 && value.length <= 4, "Bubble actions must be 1-4 entries.");
|
||||
const seen = new Set<string>();
|
||||
return (value as unknown[]).map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry.id !== "string" || !commandIdPattern.test(entry.id) || seen.has(entry.id)) {
|
||||
throw new Error("Invalid bubble action id.");
|
||||
}
|
||||
seen.add(entry.id);
|
||||
if (typeof entry.label !== "string" || entry.label.trim() === "" || entry.label.length > 32) {
|
||||
throw new Error("Invalid bubble action label.");
|
||||
}
|
||||
const style = entry.style === undefined ? "default" : String(entry.style);
|
||||
check(["default", "primary", "danger"].includes(style), "Invalid bubble action style.");
|
||||
const iconName = entry.icon === undefined
|
||||
? undefined
|
||||
: (check(typeof entry.icon === "string" && namedHostIcons.has(entry.icon), "Invalid bubble action icon."), String(entry.icon));
|
||||
return {
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
style: style as PluginBubbleAction["style"],
|
||||
iconName,
|
||||
dismissesBubble: entry.dismissesBubble !== false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function validateBubbleInput(value: unknown): PluginBubbleInput {
|
||||
if (!isRecord(value) || typeof value.id !== "string" || !commandIdPattern.test(value.id)) {
|
||||
throw new Error("Invalid bubble input id.");
|
||||
}
|
||||
const type = String(value.type);
|
||||
check(["text", "number", "select"].includes(type), "Invalid bubble input type.");
|
||||
const out: PluginBubbleInput = { id: value.id, type: type as PluginBubbleInput["type"] };
|
||||
if (value.placeholder !== undefined) {
|
||||
check(typeof value.placeholder === "string" && value.placeholder.length <= 60, "Invalid bubble input placeholder.");
|
||||
out.placeholder = String(value.placeholder);
|
||||
}
|
||||
if (value.submitLabel !== undefined) {
|
||||
check(typeof value.submitLabel === "string" && value.submitLabel.length <= 24 && value.submitLabel.trim() !== "", "Invalid bubble input submitLabel.");
|
||||
out.submitLabel = String(value.submitLabel);
|
||||
}
|
||||
if (value.default !== undefined) {
|
||||
check(typeof value.default === "string" ? value.default.length <= 200 : Number.isFinite(Number(value.default)), "Invalid bubble input default.");
|
||||
out.default = typeof value.default === "string" ? value.default : Number(value.default);
|
||||
}
|
||||
if (type === "select") {
|
||||
check(Array.isArray(value.options) && value.options.length >= 1 && value.options.length <= 8, "Bubble select inputs need 1-8 options.");
|
||||
out.options = (value.options as unknown[]).map((option) => {
|
||||
if (!isRecord(option) || typeof option.value !== "string" || option.value.length > 80 || typeof option.label !== "string" || option.label.length > 60) {
|
||||
throw new Error("Invalid bubble input option.");
|
||||
}
|
||||
return { value: option.value, label: option.label };
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function validateMenuItems(value: unknown): PluginMenuItem[] {
|
||||
check(Array.isArray(value) && value.length <= quotas.menuItems, "Invalid plugin menu items.");
|
||||
const seen = new Set<string>();
|
||||
return (value as unknown[]).map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry.id !== "string" || !commandIdPattern.test(entry.id) || seen.has(entry.id)) {
|
||||
throw new Error("Invalid plugin menu item id.");
|
||||
}
|
||||
seen.add(entry.id);
|
||||
if (typeof entry.title !== "string" || entry.title.trim() === "" || entry.title.length > 80) {
|
||||
throw new Error("Invalid plugin menu item title.");
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
enabled: entry.enabled === false ? false : undefined,
|
||||
checked: entry.checked === true ? true : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function validateAiRequest(value: unknown): PluginAiRequest {
|
||||
if (!isRecord(value) || !Array.isArray(value.messages)) throw new Error("Invalid AI request.");
|
||||
check(value.messages.length >= 1 && value.messages.length <= 64, "AI request needs 1-64 messages.");
|
||||
const messages = (value.messages as unknown[]).map((entry) => {
|
||||
if (!isRecord(entry) || (entry.role !== "user" && entry.role !== "assistant") || typeof entry.content !== "string") {
|
||||
throw new Error("Invalid AI message.");
|
||||
}
|
||||
check(entry.content.length <= 32 * 1024, "AI message content is too long.");
|
||||
return { role: entry.role as "user" | "assistant", content: entry.content };
|
||||
});
|
||||
const out: PluginAiRequest = { messages };
|
||||
if (value.system !== undefined) {
|
||||
check(typeof value.system === "string" && value.system.length <= 32 * 1024, "Invalid AI system prompt.");
|
||||
out.system = String(value.system);
|
||||
}
|
||||
if (value.maxTokens !== undefined) out.maxTokens = clampNumber(Number(value.maxTokens), 1, 8192);
|
||||
if (value.temperature !== undefined) out.temperature = clampNumber(Number(value.temperature), 0, 2);
|
||||
if (value.tools !== undefined) {
|
||||
check(Array.isArray(value.tools) && value.tools.length <= 16, "AI request allows at most 16 tools.");
|
||||
out.tools = (value.tools as unknown[]).map((tool) => {
|
||||
if (!isRecord(tool) || typeof tool.name !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(tool.name) || !isRecord(tool.inputSchema)) {
|
||||
throw new Error("Invalid AI tool definition.");
|
||||
}
|
||||
const description = tool.description === undefined ? undefined : String(tool.description).slice(0, 1024);
|
||||
return {
|
||||
name: tool.name,
|
||||
description,
|
||||
inputSchema: normalizeJson(tool.inputSchema, 16 * 1024, "AI tool schema") as Record<string, unknown>,
|
||||
};
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function validateOauthConfig(value: unknown): { provider: string; authorizationUrl: string; tokenUrl: string; clientId: string; scopes: string[]; pkce: boolean; redirect: "loopback" | "appProtocol" } {
|
||||
if (!isRecord(value)) throw new Error("Invalid OAuth config.");
|
||||
const authorizationUrl = new URL(String(value.authorizationUrl));
|
||||
const tokenUrl = new URL(String(value.tokenUrl));
|
||||
check(authorizationUrl.protocol === "https:" && tokenUrl.protocol === "https:", "OAuth URLs must be HTTPS.");
|
||||
check(!authorizationUrl.username && !tokenUrl.username, "OAuth URLs must not carry credentials.");
|
||||
const clientId = String(value.clientId ?? "");
|
||||
check(clientId.length >= 1 && clientId.length <= 512 && !/[\s\0]/.test(clientId), "Invalid OAuth clientId.");
|
||||
check(Array.isArray(value.scopes) && value.scopes.length <= 32, "Invalid OAuth scopes.");
|
||||
const scopes = (value.scopes as unknown[]).map((scope) => {
|
||||
const text = String(scope);
|
||||
check(text.length >= 1 && text.length <= 256 && !/[\r\n\0]/.test(text), "Invalid OAuth scope.");
|
||||
return text;
|
||||
});
|
||||
const provider = value.provider === undefined ? "generic" : validateProviderName(value.provider);
|
||||
const redirect = value.redirect === undefined ? "loopback" : String(value.redirect);
|
||||
check(redirect === "loopback" || redirect === "appProtocol", "Invalid OAuth redirect mode.");
|
||||
return {
|
||||
provider,
|
||||
authorizationUrl: authorizationUrl.toString(),
|
||||
tokenUrl: tokenUrl.toString(),
|
||||
clientId,
|
||||
scopes,
|
||||
pkce: value.pkce !== false,
|
||||
redirect: redirect as "loopback" | "appProtocol",
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeJson(value: unknown, maxBytes: number, label: string): unknown {
|
||||
let text: string;
|
||||
try {
|
||||
text = JSON.stringify(value ?? null);
|
||||
} catch {
|
||||
throw new Error(`Plugin ${label} must be JSON-compatible.`);
|
||||
}
|
||||
if (text === undefined) throw new Error(`Plugin ${label} must be JSON-compatible.`);
|
||||
check(Buffer.byteLength(text) <= maxBytes, `Plugin ${label} is too large.`);
|
||||
return JSON.parse(text) as unknown;
|
||||
}
|
||||
|
||||
export function validateCommand(command: PluginCommand, validateIconAssetRef: (ref: unknown) => { kind: PluginAssetKind; name: string; path: string }): PluginCommand {
|
||||
if (!command || !commandIdPattern.test(command.id)) throw new Error("Invalid plugin command id.");
|
||||
if (typeof command.title !== "string" || command.title.trim() === "" || command.title.length > 80) throw new Error("Invalid plugin command title.");
|
||||
if (command.description !== undefined && (typeof command.description !== "string" || command.description.length > 240)) {
|
||||
throw new Error("Invalid plugin command description.");
|
||||
}
|
||||
const placement = command.placement === undefined
|
||||
? undefined
|
||||
: (check(command.placement === "top" || command.placement === "submenu", "Invalid plugin command placement."), command.placement);
|
||||
const priority = command.priority === undefined
|
||||
? undefined
|
||||
: (check(Number.isFinite(Number(command.priority)), "Invalid plugin command priority."), clampNumber(Number(command.priority), -1000, 1000));
|
||||
const icon = command.icon === undefined ? undefined : validateCommandIcon(command.icon, validateIconAssetRef);
|
||||
return {
|
||||
id: command.id,
|
||||
title: command.title,
|
||||
description: command.description,
|
||||
form: validateCommandForm(command.form),
|
||||
placement,
|
||||
priority,
|
||||
featured: command.featured === true || undefined,
|
||||
icon,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCommandFormValues(form: PluginCommandForm, args: unknown): Record<string, unknown> {
|
||||
const input = isRecord(args) ? args : {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of form.fields) {
|
||||
const raw = input[field.id];
|
||||
if (field.type === "number") {
|
||||
const value = Number(raw ?? field.default ?? 0);
|
||||
if (!Number.isFinite(value)) throw new Error(`${field.label} must be a number.`);
|
||||
if (field.min !== undefined && value < field.min) throw new Error(`${field.label} is too small.`);
|
||||
if (field.max !== undefined && value > field.max) throw new Error(`${field.label} is too large.`);
|
||||
out[field.id] = value;
|
||||
} else if (field.type === "boolean") {
|
||||
out[field.id] = raw === undefined ? field.default === true : raw === true || raw === "true";
|
||||
} else if (field.type === "select") {
|
||||
const value = String(raw ?? field.default ?? "").trim();
|
||||
if (field.required && !value) throw new Error(`${field.label} is required.`);
|
||||
if (value && !(field.options ?? []).some((option) => option.value === value)) throw new Error(`${field.label} has an invalid value.`);
|
||||
out[field.id] = value;
|
||||
} else if (field.type === "multiSelect") {
|
||||
const values = Array.isArray(raw) ? raw.map(String) : Array.isArray(field.default) ? field.default : [];
|
||||
const allowed = new Set((field.options ?? []).map((option) => option.value));
|
||||
if (values.some((value) => !allowed.has(value))) throw new Error(`${field.label} has an invalid value.`);
|
||||
out[field.id] = values;
|
||||
} else if (field.type === "time") {
|
||||
const value = String(raw ?? field.default ?? "").trim();
|
||||
if (field.required && !value) throw new Error(`${field.label} is required.`);
|
||||
if (value && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error(`${field.label} must be HH:mm.`);
|
||||
out[field.id] = value;
|
||||
} else if (field.type === "date") {
|
||||
const value = String(raw ?? field.default ?? "").trim();
|
||||
if (field.required && !value) throw new Error(`${field.label} is required.`);
|
||||
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error(`${field.label} must be YYYY-MM-DD.`);
|
||||
out[field.id] = value;
|
||||
} else if (field.type === "list") {
|
||||
const values = Array.isArray(raw) ? raw.map((entry) => String(entry).trim()).filter(Boolean) : Array.isArray(field.default) ? field.default : [];
|
||||
if (values.length > 32) throw new Error(`${field.label} has too many entries.`);
|
||||
const maxLength = field.maxLength;
|
||||
if (maxLength !== undefined && values.some((value) => value.length > maxLength)) throw new Error(`${field.label} entries are too long.`);
|
||||
out[field.id] = values;
|
||||
} else {
|
||||
const text = String(raw ?? field.default ?? "").trim();
|
||||
if (field.required && !text) throw new Error(`${field.label} is required.`);
|
||||
if (field.maxLength !== undefined && text.length > field.maxLength) throw new Error(`${field.label} is too long.`);
|
||||
out[field.id] = text;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function validateStatus(status: PluginStatus | string): PluginStatus {
|
||||
const value = typeof status === "string" ? { text: status } : status;
|
||||
if (!value || typeof value.text !== "string" || value.text.trim() === "" || value.text.length > 120) {
|
||||
throw new Error("Invalid plugin status text.");
|
||||
}
|
||||
if (value.tone !== undefined && !["info", "success", "warning", "error"].includes(value.tone)) {
|
||||
throw new Error("Invalid plugin status tone.");
|
||||
}
|
||||
return { text: value.text, tone: value.tone };
|
||||
}
|
||||
|
||||
export function validateMoveBy(value: unknown): { x: number; y: number; durationMs?: number } {
|
||||
if (!isRecord(value)) throw new Error("Invalid familiar movement options.");
|
||||
const x = Number(value.x);
|
||||
const y = Number(value.y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Invalid familiar movement distance.");
|
||||
return { x, y, durationMs: value.durationMs === undefined ? undefined : Number(value.durationMs) };
|
||||
}
|
||||
|
||||
export function validateWander(value: unknown): { distance?: number; durationMs?: number } {
|
||||
const options = isRecord(value) ? value : {};
|
||||
return {
|
||||
distance: options.distance === undefined ? undefined : Number(options.distance),
|
||||
durationMs: options.durationMs === undefined ? undefined : Number(options.durationMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCronExpression(expr: string): ParsedCron {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) throw new Error("Cron expressions must have 5 fields (m h dom mon dow).");
|
||||
const [minutePart, hourPart, domPart, monthPart, dowPart] = parts as [string, string, string, string, string];
|
||||
return {
|
||||
minutes: parseCronField(minutePart, 0, 59),
|
||||
hours: parseCronField(hourPart, 0, 23),
|
||||
daysOfMonth: parseCronField(domPart, 1, 31),
|
||||
months: parseCronField(monthPart, 1, 12),
|
||||
daysOfWeek: parseCronField(dowPart, 0, 7, true),
|
||||
domWildcard: domPart === "*",
|
||||
dowWildcard: dowPart === "*",
|
||||
};
|
||||
}
|
||||
|
||||
export function nextCronRunMs(expr: string, fromMs: number): number | null {
|
||||
const cron = parseCronExpression(expr);
|
||||
const candidate = new Date(fromMs);
|
||||
candidate.setSeconds(0, 0);
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
const limit = fromMs + 4 * 366 * 24 * 60 * 60 * 1000;
|
||||
while (candidate.getTime() <= limit) {
|
||||
if (!cron.months.has(candidate.getMonth() + 1)) {
|
||||
candidate.setMonth(candidate.getMonth() + 1, 1);
|
||||
candidate.setHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
const domMatch = cron.daysOfMonth.has(candidate.getDate());
|
||||
const dowMatch = cron.daysOfWeek.has(candidate.getDay());
|
||||
const dayMatch = cron.domWildcard && cron.dowWildcard ? true : cron.domWildcard ? dowMatch : cron.dowWildcard ? domMatch : domMatch || dowMatch;
|
||||
if (!dayMatch) {
|
||||
candidate.setDate(candidate.getDate() + 1);
|
||||
candidate.setHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
if (!cron.hours.has(candidate.getHours())) {
|
||||
candidate.setHours(candidate.getHours() + 1, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
if (!cron.minutes.has(candidate.getMinutes())) {
|
||||
candidate.setMinutes(candidate.getMinutes() + 1, 0, 0);
|
||||
continue;
|
||||
}
|
||||
return candidate.getTime();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function validateProviderName(value: unknown): string {
|
||||
const provider = String(value);
|
||||
check(/^[a-z0-9][a-z0-9._-]{0,63}$/.test(provider), "Invalid OAuth provider name.");
|
||||
return provider;
|
||||
}
|
||||
|
||||
function validateCommandIcon(icon: unknown, validateIconAssetRef: (ref: unknown) => { kind: PluginAssetKind; name: string; path: string }): PluginCommandIcon {
|
||||
if (typeof icon === "string") {
|
||||
check(namedHostIcons.has(icon), "Invalid plugin command icon.");
|
||||
return icon;
|
||||
}
|
||||
if (!isRecord(icon) || icon.kind !== "icon" || typeof icon.name !== "string") throw new Error("Invalid plugin command icon.");
|
||||
validateIconAssetRef(icon);
|
||||
return { kind: "icon", name: icon.name };
|
||||
}
|
||||
|
||||
function validateCommandForm(form: unknown): PluginCommandForm | undefined {
|
||||
if (form === undefined) return undefined;
|
||||
if (!isRecord(form) || !Array.isArray(form.fields) || form.fields.length < 1 || form.fields.length > 8) {
|
||||
throw new Error("Invalid plugin command form.");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const fields = form.fields.map((field) => {
|
||||
if (!isRecord(field) || typeof field.id !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(field.id) || seen.has(field.id)) {
|
||||
throw new Error("Invalid plugin command form field id.");
|
||||
}
|
||||
seen.add(field.id);
|
||||
if (!commandFormFieldTypes.has(String(field.type))) throw new Error("Invalid plugin command form field type.");
|
||||
if (typeof field.label !== "string" || field.label.trim() === "" || field.label.length > 80) {
|
||||
throw new Error("Invalid plugin command form label.");
|
||||
}
|
||||
const out: PluginCommandFormField = {
|
||||
id: field.id,
|
||||
type: field.type as PluginCommandFormField["type"],
|
||||
label: field.label,
|
||||
required: field.required === true || undefined,
|
||||
};
|
||||
if (out.type === "number") {
|
||||
if (field.default !== undefined && !Number.isFinite(Number(field.default))) throw new Error("Invalid plugin command form default.");
|
||||
if (field.min !== undefined && !Number.isFinite(Number(field.min))) throw new Error("Invalid plugin command form min.");
|
||||
if (field.max !== undefined && !Number.isFinite(Number(field.max))) throw new Error("Invalid plugin command form max.");
|
||||
if (field.min !== undefined) out.min = Number(field.min);
|
||||
if (field.max !== undefined) out.max = Number(field.max);
|
||||
if (out.min !== undefined && out.max !== undefined && out.min > out.max) throw new Error("Invalid plugin command form range.");
|
||||
if (field.default !== undefined) out.default = Number(field.default);
|
||||
} else if (out.type === "boolean") {
|
||||
if (field.default !== undefined && typeof field.default !== "boolean") throw new Error("Invalid plugin command form default.");
|
||||
if (field.default !== undefined) out.default = field.default;
|
||||
} else if (out.type === "select" || out.type === "multiSelect") {
|
||||
if (!Array.isArray(field.options) || field.options.length < 1 || field.options.length > 24) throw new Error("Invalid plugin command form options.");
|
||||
const values = new Set<string>();
|
||||
out.options = field.options.map((option) => {
|
||||
if (!isRecord(option) || typeof option.value !== "string" || option.value.length > 120 || typeof option.label !== "string" || option.label.trim() === "" || option.label.length > 80 || values.has(option.value)) {
|
||||
throw new Error("Invalid plugin command form option.");
|
||||
}
|
||||
values.add(option.value);
|
||||
return { label: option.label, value: option.value };
|
||||
});
|
||||
if (field.default !== undefined) {
|
||||
if (out.type === "multiSelect") {
|
||||
if (!Array.isArray(field.default) || field.default.some((entry) => typeof entry !== "string" || !values.has(entry))) {
|
||||
throw new Error("Invalid plugin command form default.");
|
||||
}
|
||||
out.default = field.default as string[];
|
||||
} else {
|
||||
if (typeof field.default !== "string" || !values.has(field.default)) throw new Error("Invalid plugin command form default.");
|
||||
out.default = field.default;
|
||||
}
|
||||
}
|
||||
} else if (out.type === "time") {
|
||||
if (field.default !== undefined && (typeof field.default !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(field.default))) {
|
||||
throw new Error("Invalid plugin command form default.");
|
||||
}
|
||||
if (field.default !== undefined) out.default = field.default;
|
||||
} else if (out.type === "date") {
|
||||
if (field.default !== undefined && (typeof field.default !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(field.default))) {
|
||||
throw new Error("Invalid plugin command form default.");
|
||||
}
|
||||
if (field.default !== undefined) out.default = field.default;
|
||||
} else if (out.type === "list") {
|
||||
if (field.default !== undefined && (!Array.isArray(field.default) || field.default.some((entry) => typeof entry !== "string" || entry.length > 200) || field.default.length > 32)) {
|
||||
throw new Error("Invalid plugin command form default.");
|
||||
}
|
||||
if (field.default !== undefined) out.default = field.default as string[];
|
||||
if (field.maxLength !== undefined && (!Number.isInteger(Number(field.maxLength)) || Number(field.maxLength) < 1 || Number(field.maxLength) > 1000)) {
|
||||
throw new Error("Invalid plugin command form maxLength.");
|
||||
}
|
||||
if (field.maxLength !== undefined) out.maxLength = Number(field.maxLength);
|
||||
} else {
|
||||
if (field.default !== undefined && typeof field.default !== "string") throw new Error("Invalid plugin command form default.");
|
||||
if (field.maxLength !== undefined && (!Number.isInteger(Number(field.maxLength)) || Number(field.maxLength) < 1 || Number(field.maxLength) > 1000)) {
|
||||
throw new Error("Invalid plugin command form maxLength.");
|
||||
}
|
||||
if (field.default !== undefined) out.default = field.default;
|
||||
if (field.maxLength !== undefined) out.maxLength = Number(field.maxLength);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const submitLabel = typeof form.submitLabel === "string" && form.submitLabel.trim() && form.submitLabel.length <= 40 ? form.submitLabel : undefined;
|
||||
return { fields, submitLabel };
|
||||
}
|
||||
|
||||
function parseCronField(field: string, min: number, max: number, mapSevenToZero = false): CronField {
|
||||
const values = new Set<number>();
|
||||
if (field.length === 0 || field.length > 64) throw new Error("Invalid cron field.");
|
||||
for (const part of field.split(",")) {
|
||||
const stepMatch = /^(.+)\/(\d+)$/.exec(part);
|
||||
const base = stepMatch ? stepMatch[1]! : part;
|
||||
const step = stepMatch ? Number(stepMatch[2]) : 1;
|
||||
if (!Number.isInteger(step) || step < 1 || step > max) throw new Error("Invalid cron step.");
|
||||
let start = min;
|
||||
let end = max;
|
||||
if (base !== "*") {
|
||||
const rangeMatch = /^(\d+)-(\d+)$/.exec(base);
|
||||
if (rangeMatch) {
|
||||
start = Number(rangeMatch[1]);
|
||||
end = Number(rangeMatch[2]);
|
||||
} else {
|
||||
if (!/^\d+$/.test(base)) throw new Error("Invalid cron value.");
|
||||
start = Number(base);
|
||||
end = stepMatch ? max : start;
|
||||
}
|
||||
}
|
||||
if (start < min || end > max || start > end) throw new Error("Cron value out of range.");
|
||||
for (let value = start; value <= end; value += step) {
|
||||
values.add(mapSevenToZero && value === 7 ? 0 : value);
|
||||
}
|
||||
}
|
||||
if (values.size === 0) throw new Error("Invalid cron field.");
|
||||
return values;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue