Extract control center state validation seam
This commit is contained in:
parent
764ba3e962
commit
111d0d9fef
11 changed files with 263 additions and 200 deletions
|
|
@ -39,6 +39,7 @@ const behaviorTests = [
|
|||
".test-dist/tests/settings-view-state.test.js",
|
||||
".test-dist/tests/settings-view-action-seams.test.js",
|
||||
".test-dist/tests/settings-view-state-actions-split.test.js",
|
||||
".test-dist/tests/control-center-state-validation.test.js",
|
||||
".test-dist/tests/settings-openapi-group-provider.test.js",
|
||||
".test-dist/tests/settings-tts-group-split.test.js",
|
||||
".test-dist/tests/default-familiar-external-show.test.js",
|
||||
|
|
|
|||
|
|
@ -86,6 +86,9 @@ const controlCenterPreloadSource = readFileSync(join(appDir, "control-center-pre
|
|||
const controlCenterRendererSource = readFileSync(join(appDir, "src", "renderer", "src", "main.tsx"), "utf8");
|
||||
const controlCenterRouteLoadersSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "route-loaders.tsx"), "utf8");
|
||||
const controlCenterRouteDataLoadersSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "route-data-loaders.ts"), "utf8");
|
||||
const controlCenterCoreServicesSource = readFileSync(join(appDir, "src", "control-center-core-services.ts"), "utf8");
|
||||
const controlCenterStateSource = readFileSync(join(appDir, "src", "control-center-state.ts"), "utf8");
|
||||
const controlCenterStateValidationSource = readFileSync(join(appDir, "src", "control-center-state-validation.ts"), "utf8");
|
||||
const controlCenterSharedSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "shared.tsx"), "utf8");
|
||||
const controlCenterSharedUiSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "shared-ui.tsx"), "utf8");
|
||||
const controlCenterSharedUiIconsSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "shared-ui-icons.tsx"), "utf8");
|
||||
|
|
@ -140,6 +143,7 @@ const openApiChatSource = readFileSync(join(appDir, "src", "openapi-chat.ts"), "
|
|||
const openApiChatPromptFlowsSource = readFileSync(join(appDir, "src", "openapi-chat-prompt-flows.ts"), "utf8");
|
||||
const openApiChatPromptPlainFlowSource = readFileSync(join(appDir, "src", "openapi-chat-prompt-plain-flow.ts"), "utf8");
|
||||
const openApiChatSettingsSource = readFileSync(join(appDir, "src", "openapi-chat-settings.ts"), "utf8");
|
||||
const openApiChatModelSource = readFileSync(join(appDir, "src", "openapi-chat-model.ts"), "utf8");
|
||||
const openApiChatPresentationSource = readFileSync(join(appDir, "src", "openapi-chat-presentation.ts"), "utf8");
|
||||
const openApiChatToolLoopSource = readFileSync(join(appDir, "src", "openapi-chat-tool-loop.ts"), "utf8");
|
||||
const openApiChatConfigStoreSource = readFileSync(join(appDir, "src", "openapi-chat-config-store.ts"), "utf8");
|
||||
|
|
@ -431,6 +435,10 @@ assert.match(controlCenterInternalUiSource, /familiaros:get-reaction-animation-s
|
|||
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(controlCenterCoreServicesSource, /from "\.\/control-center-state-validation(?:\.js)?"/, "Control Center core services barrel must export the extracted state validation seam.");
|
||||
assert.match(controlCenterStateValidationSource, /export function validatePreferencePatch/, "Control Center state validation seam must export preferences validation.");
|
||||
assert.match(controlCenterStateValidationSource, /export function validateExternalUrl/, "Control Center state validation seam must export external URL validation.");
|
||||
assert.doesNotMatch(controlCenterStateSource, /export function validatePreferencePatch/, "Control Center state snapshot surface must no longer own preferences validation directly.");
|
||||
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.");
|
||||
|
|
@ -656,6 +664,9 @@ assert.match(openApiChatPromptPlainFlowSource, /from "\.\/openapi-chat-request-h
|
|||
assert.match(openApiChatSettingsSource, /from "\.\/openapi-chat-config-store(?:\.js)?"/, "OpenAPI chat settings seam must import the extracted config-store seam.");
|
||||
assert.match(openApiChatSettingsSource, /export function getOpenApiChatSettingsSnapshot/, "OpenAPI chat settings seam must export the settings snapshot helper.");
|
||||
assert.match(openApiChatSettingsSource, /export function storeOpenApiCredential/, "OpenAPI chat settings seam must export credential persistence.");
|
||||
assert.match(openApiChatSettingsSource, /from "\.\/openapi-chat-model(?:\.js)?"/, "OpenAPI chat settings seam must import the extracted model normalization helper.");
|
||||
assert.match(openApiChatSettingsSource, /export \{ normalizeChatModel \} from "\.\/openapi-chat-model(?:\.js)?"/, "OpenAPI chat settings seam must re-export model normalization.");
|
||||
assert.match(openApiChatModelSource, /export function normalizeChatModel/, "OpenAPI chat model seam must export model normalization.");
|
||||
assert.match(openApiChatPresentationSource, /export function handleOpenApiFailure/, "OpenAPI chat presentation seam must export failure presentation.");
|
||||
assert.match(openApiChatPresentationSource, /export function speakAssistantResponse/, "OpenAPI chat presentation seam must export assistant TTS presentation.");
|
||||
assert.match(openApiChatConfigStoreSource, /export function readStoredOpenApiChatConfig/, "OpenAPI chat config-store seam must export config reads.");
|
||||
|
|
|
|||
|
|
@ -198,6 +198,9 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
|
|||
- `tray.ts`: Tray icon (nativeImage), context menu builder, update status integration, route-targeted Control Center entries, logs folder
|
||||
- `windows.ts`: Control Center BrowserWindow factory, Dashboard snapshot, IPC handler registration, route targeting, reaction animation settings, plugin/integration/familiar/settings UI IPC endpoints, and scoped internal protocols
|
||||
- `control-center-ipc.ts`: Control Center IPC installer that composes extracted route registration seams for settings, knowledge, plugins, pets, and agent setup
|
||||
- `control-center-core-services.ts`: Control Center service barrel for state snapshots, validation helpers, update helpers, and locale wiring
|
||||
- `control-center-state.ts`: Control Center state snapshot and dashboard/preview loaders
|
||||
- `control-center-state-validation.ts`: Extracted Control Center preferences patch, plain-object, and external-URL validation helpers
|
||||
- `control-center-ipc-shared.ts`: Shared Control Center IPC sender validation and window-kind helpers
|
||||
- `control-center-ipc-settings.ts`: Settings/update/TTS/openapi credential/clipboard/external-link IPC routes for the Control Center
|
||||
- `control-center-ipc-knowledge.ts`: FamiliarOS memory and knowledge-store IPC routes for the Control Center
|
||||
|
|
@ -220,7 +223,8 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
|
|||
- `openapi-chat.ts`: Public OpenAPI chat facade, conversation/runtime state holder, and prompt-window entrypoint
|
||||
- `openapi-chat-prompt-flows.ts`: Public prompt-flow barrel plus MCP-tool chat orchestration over the extracted plain-flow seam
|
||||
- `openapi-chat-prompt-plain-flow.ts`: Extracted plain prompt submission flow, instruction-context assembly, and provider fallback handling
|
||||
- `openapi-chat-settings.ts`: Extracted OpenAPI chat credential persistence, settings snapshot, model/endpoint normalization, and required-credential helpers
|
||||
- `openapi-chat-settings.ts`: Extracted OpenAPI chat credential persistence, settings snapshot, endpoint normalization, and required-credential helpers
|
||||
- `openapi-chat-model.ts`: Extracted pure OpenAPI chat model-name normalization helper
|
||||
- `openapi-chat-presentation.ts`: Extracted familiar/TTS pending, success, and failure presentation helpers for prompt-window chat
|
||||
- `familiaros-memory.ts`: Public long-term memory API, CRUD orchestration, context shaping, and prompt-memory capture for chat, IPC, and knowledge-store flows
|
||||
- `familiaros-memory-store.ts`: Extracted memory cache/persistence, JSON and markdown mirror writes, normalization, retention pruning, and mutation helpers
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ export {
|
|||
getPetsStateSnapshot,
|
||||
getReactionAnimationSettingsSnapshot,
|
||||
getSettingsStateSnapshot,
|
||||
} from "./control-center-state.js";
|
||||
export {
|
||||
isPlainObject,
|
||||
validateExternalUrl,
|
||||
validatePreferencePatch,
|
||||
} from "./control-center-state.js";
|
||||
} from "./control-center-state-validation.js";
|
||||
export { getCatalogPageUiState, getCatalogSearchUiState, getCatalogUiState } from "./catalog.js";
|
||||
export { getActiveLocale, setLocaleFromPreference } from "./i18n/index.js";
|
||||
export { debug, error as logError, warn } from "./logger.js";
|
||||
|
|
|
|||
199
apps/desktop/src/control-center-state-validation.ts
Normal file
199
apps/desktop/src/control-center-state-validation.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import {
|
||||
normalizeFamiliarName,
|
||||
normalizePetScale,
|
||||
} from "./app-state-core.js";
|
||||
import {
|
||||
normalizeOpenApiChatEndpoint,
|
||||
type FamiliarOSPreferences,
|
||||
} from "./app-state-preferences.js";
|
||||
import {
|
||||
isSupportedLocale,
|
||||
} from "./i18n/index.js";
|
||||
import { normalizeChatModel } from "./openapi-chat-model.js";
|
||||
import { validateReactionAnimationOverrides } from "./reaction-animation-mapping.js";
|
||||
import { listTtsProviders } from "./tts-engine.js";
|
||||
import { type TtsProviderId } from "./tts-service.js";
|
||||
|
||||
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
||||
|
||||
export type ControlCenterPreferencesPatch = Partial<Mutable<FamiliarOSPreferences>>;
|
||||
|
||||
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 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 isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -3,16 +3,14 @@ import { join } from "node:path";
|
|||
|
||||
import { app } from "electron";
|
||||
|
||||
import { getAppStateSnapshot, normalizeFamiliarName, normalizeOpenApiChatEndpoint, normalizePetScale, petScaleOptions } from "./app-state.js";
|
||||
import { getAppStateSnapshot, 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 { getActiveLocale, getActiveMessages, 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 { getOpenApiChatSettingsSnapshot } 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 { defaultPetSprite, reactionAnimationMetadata, selectableAnimationMetadata } from "./reaction-animation-mapping.js";
|
||||
import { getUpdateStatus } from "./update-checker.js";
|
||||
|
||||
type ControlCenterSettingsPreferences = Pick<
|
||||
|
|
@ -36,10 +34,6 @@ type ControlCenterSettingsPreferences = Pick<
|
|||
| "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 };
|
||||
|
|
@ -173,191 +167,11 @@ export async function getDefaultPetPreviewSpriteInfo(): Promise<{ readonly path:
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
6
apps/desktop/src/openapi-chat-model.ts
Normal file
6
apps/desktop/src/openapi-chat-model.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function normalizeChatModel(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { app, safeStorage } from "electron";
|
|||
|
||||
import { defaultOpenApiChatEndpoint, getAppStateSnapshot, normalizeOpenApiChatEndpoint } from "./app-state.js";
|
||||
import { info } from "./logger.js";
|
||||
import { normalizeChatModel } from "./openapi-chat-model.js";
|
||||
import {
|
||||
getPreferredOpenApiChatStorageMode,
|
||||
getStoredOpenApiCredential,
|
||||
|
|
@ -82,13 +83,7 @@ export function getConfiguredOpenApiChatModel(): string {
|
|||
export function getConfiguredOpenApiChatEndpoint(): string {
|
||||
return normalizeOpenApiChatEndpoint(getAppStateSnapshot().preferences.openApiChatEndpoint) ?? defaultOpenApiChatEndpoint;
|
||||
}
|
||||
|
||||
export function normalizeChatModel(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
export { normalizeChatModel } from "./openapi-chat-model.js";
|
||||
|
||||
export function getRequiredCredential(): string {
|
||||
const credential = getStoredOpenApiCredential(readStoredChatConfig(), safeStorage);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ assert.match(toolkitInstallerSource, /from "\.\/mcp-toolkit-installer-support(?:
|
|||
assert.match(toolkitInstallerSource, /from "\.\/mcp-toolkit-installer-commands(?:\.js)?"/, "MCP toolkit installer must import the extracted command seam.");
|
||||
assert.match(coreBarrelSource, /getAppStateSnapshot/);
|
||||
assert.match(coreBarrelSource, /getSettingsStateSnapshot/);
|
||||
assert.match(coreBarrelSource, /from "\.\/control-center-state-validation\.js"/);
|
||||
assert.match(coreBarrelSource, /validatePreferencePatch/);
|
||||
assert.match(coreBarrelSource, /validateExternalUrl/);
|
||||
assert.match(coreBarrelSource, /openUpdateReleasePage/);
|
||||
assert.match(dataBarrelSource, /getPluginService/);
|
||||
assert.match(dataBarrelSource, /getOpenApiChatSettingsSnapshot/);
|
||||
|
|
|
|||
25
apps/desktop/tests/control-center-state-validation.test.ts
Normal file
25
apps/desktop/tests/control-center-state-validation.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const controlCenterStateSource = readFileSync(resolve(desktopRoot, "src/control-center-state.ts"), "utf8");
|
||||
const controlCenterStateValidationSource = readFileSync(resolve(desktopRoot, "src/control-center-state-validation.ts"), "utf8");
|
||||
const controlCenterCoreServicesSource = readFileSync(resolve(desktopRoot, "src/control-center-core-services.ts"), "utf8");
|
||||
|
||||
assert.match(controlCenterStateValidationSource, /export function validatePreferencePatch/, "Control Center state validation seam must export preferences validation.");
|
||||
assert.match(controlCenterStateValidationSource, /export function validateExternalUrl/, "Control Center state validation seam must export external URL validation.");
|
||||
assert.match(controlCenterStateValidationSource, /export function isPlainObject/, "Control Center state validation seam must export plain-object validation.");
|
||||
assert.match(controlCenterCoreServicesSource, /from "\.\/control-center-state-validation\.js"/, "Control Center core services barrel must export the extracted state validation seam.");
|
||||
assert.doesNotMatch(controlCenterStateSource, /export function validatePreferencePatch/, "Control Center state snapshot surface should no longer own preference validation directly.");
|
||||
assert.match(controlCenterStateValidationSource, /from "\.\/app-state-core(?:\.js)?"/, "Control Center state validation seam must import the extracted pure app-state core helpers.");
|
||||
assert.match(controlCenterStateValidationSource, /from "\.\/app-state-preferences(?:\.js)?"/, "Control Center state validation seam must import the extracted pure app-state preference helpers.");
|
||||
assert.match(controlCenterStateValidationSource, /from "\.\/openapi-chat-model(?:\.js)?"/, "Control Center state validation seam must import the extracted pure OpenAPI model helper.");
|
||||
assert.match(controlCenterStateValidationSource, /Math\.round\(speed \* 10\) \/ 10/, "Control Center state validation seam must round TTS speed to a single decimal place.");
|
||||
assert.match(controlCenterStateValidationSource, /Only https URLs, or localhost http URLs, are allowed\./, "Control Center state validation seam must retain the external URL allowlist.");
|
||||
assert.match(controlCenterStateValidationSource, /listTtsProviders\(\)\.map\(\(provider\) => provider\.id\)/, "Control Center state validation seam must validate TTS providers against the provider catalog.");
|
||||
assert.match(controlCenterStateValidationSource, /normalizeFamiliarName\(value\.familiarName\)/, "Control Center state validation seam must normalize familiar names through the shared helper.");
|
||||
assert.match(controlCenterStateValidationSource, /normalizeOpenApiChatEndpoint\(value\.openApiChatEndpoint\)/, "Control Center state validation seam must normalize OpenAPI endpoints through the shared helper.");
|
||||
|
||||
console.error("Control Center state validation seam passed.");
|
||||
|
|
@ -7,6 +7,7 @@ const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileU
|
|||
const openApiChatSource = readFileSync(resolve(desktopRoot, "src/openapi-chat.ts"), "utf8");
|
||||
const openApiChatPromptFlowsSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-prompt-flows.ts"), "utf8");
|
||||
const openApiChatSettingsSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-settings.ts"), "utf8");
|
||||
const openApiChatModelSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-model.ts"), "utf8");
|
||||
const openApiChatPresentationSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-presentation.ts"), "utf8");
|
||||
|
||||
assert.match(openApiChatSource, /from "\.\/openapi-chat-settings(?:\.js)?"/, "openapi-chat must import the extracted settings seam.");
|
||||
|
|
@ -16,7 +17,9 @@ assert.match(openApiChatSettingsSource, /export function getOpenApiChatSettingsS
|
|||
assert.match(openApiChatSettingsSource, /export function getOpenApiChatSystemPrompt/, "openapi-chat settings seam must export the system-prompt helper.");
|
||||
assert.match(openApiChatSettingsSource, /export function storeOpenApiCredential/, "openapi-chat settings seam must export credential persistence.");
|
||||
assert.match(openApiChatSettingsSource, /export function clearStoredOpenApiCredential/, "openapi-chat settings seam must export credential clearing.");
|
||||
assert.match(openApiChatSettingsSource, /export function normalizeChatModel/, "openapi-chat settings seam must export model normalization.");
|
||||
assert.match(openApiChatSettingsSource, /from "\.\/openapi-chat-model(?:\.js)?"/, "openapi-chat settings seam must import the extracted model normalization helper.");
|
||||
assert.match(openApiChatSettingsSource, /export \{ normalizeChatModel \} from "\.\/openapi-chat-model(?:\.js)?"/, "openapi-chat settings seam must re-export model normalization.");
|
||||
assert.match(openApiChatModelSource, /export function normalizeChatModel/, "openapi-chat model seam must export model normalization.");
|
||||
assert.match(openApiChatPresentationSource, /export function showOpenApiPendingState/, "openapi-chat presentation seam must export the pending-state helper.");
|
||||
assert.match(openApiChatPresentationSource, /export function showOpenApiSuccessState/, "openapi-chat presentation seam must export the success-state helper.");
|
||||
assert.match(openApiChatPresentationSource, /export function handleOpenApiFailure/, "openapi-chat presentation seam must export the failure presenter.");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue