Extract local IPC request handler seam
This commit is contained in:
parent
ef4d4bcd92
commit
fa21d29fcd
6 changed files with 408 additions and 183 deletions
|
|
@ -236,6 +236,7 @@ const loggerSource = readFileSync(join(appDir, "src", "logger.ts"), "utf8");
|
|||
const mainSource = readFileSync(join(appDir, "src", "main.ts"), "utf8");
|
||||
const localIpcSourceForLogging = readFileSync(join(appDir, "src", "local-ipc.ts"), "utf8");
|
||||
const localIpcSocketSource = readFileSync(join(appDir, "src", "local-ipc-socket.ts"), "utf8");
|
||||
const localIpcRequestHandlersSource = readFileSync(join(appDir, "src", "local-ipc-request-handlers.ts"), "utf8");
|
||||
const localIpcPathsSource = readFileSync(join(appDir, "src", "local-ipc-paths.ts"), "utf8");
|
||||
const leaseManagerSource = readFileSync(join(appDir, "src", "lease-manager.ts"), "utf8");
|
||||
const defaultPetControllerSource = readFileSync(join(appDir, "src", "default-familiar-controller.ts"), "utf8");
|
||||
|
|
@ -447,11 +448,15 @@ assert.match(agentPetControllerSource, /dismissedAgentPets\.has\(petId\)/, "dism
|
|||
assert.match(agentPetControllerSource, /function clearAgentPetLeaseState/, "agent familiar lease cleanup must clear dismissal, timers, and hidden transient state.");
|
||||
assert.match(localIpcSource, /handleLastExplicitLease/, "agent familiar dismissal must clear when the explicit lease group ends.");
|
||||
assert.match(localIpcSource, /from "\.\/local-ipc-socket(?:\.js)?"/, "local-ipc must compose the extracted socket helper seam.");
|
||||
assert.match(localIpcSource, /from "\.\/local-ipc-request-handlers(?:\.js)?"/, "local-ipc must compose the extracted request-handler seam.");
|
||||
assert.match(localIpcSocketSource, /export function handleLocalIpcSocket/, "local-ipc socket seam must export the client socket handler.");
|
||||
assert.match(localIpcSocketSource, /export function getLocalIpcListeningEndpoint/, "local-ipc socket seam must export advertised endpoint resolution.");
|
||||
assert.match(localIpcSocketSource, /export function isAllowedRemoteAddress/, "local-ipc socket seam must export remote-address filtering.");
|
||||
assert.match(localIpcRequestHandlersSource, /export async function handleLocalIpcRequest/, "local-ipc request-handler seam must export the async dispatcher.");
|
||||
assert.match(localIpcRequestHandlersSource, /export function buildHelloResponse/, "local-ipc request-handler seam must export the hello response builder.");
|
||||
assert.match(localIpcRequestHandlersSource, /export function buildFamiliarsListResponse/, "local-ipc request-handler seam must export familiar list response shaping.");
|
||||
assert.match(localIpcSource, /clearAgentPetLeaseState\(petId\)/, "last explicit lease cleanup must reset dismissed agent familiar state.");
|
||||
assert.match(localIpcSource, /reason: applied\.reason/, "IPC responses must report dismissed explicit familiar events as not shown.");
|
||||
assert.match(localIpcRequestHandlersSource, /reason: applied\.reason/, "IPC responses must report dismissed explicit familiar events as not shown.");
|
||||
assert.match(updateCheckerSource, /alvinunreal\/familiaros/, "GitHub release notice must check the public FamiliarOS repository.");
|
||||
assert.match(updateCheckerSource, /api\.github\.com\/repos\/\$\{githubRepository\}\/releases\/latest/, "update checker must use GitHub latest release API.");
|
||||
assert.match(updateCheckerSource, /shell\.openExternal\(url\)/, "update action must open the GitHub release page externally.");
|
||||
|
|
|
|||
|
|
@ -27,13 +27,14 @@ main.ts
|
|||
├── plugin-service.ts (plugin state/runtime init, JS host wiring)
|
||||
├── tray.ts (tray creation)
|
||||
├── local-ipc.ts (IPC server start)
|
||||
│ ├── local-ipc-request-handlers.ts (protocol request dispatch, familiar actions, lease routes, and memory CRUD shaping)
|
||||
│ └── local-ipc-socket.ts (socket accept loop, advertised endpoint resolution, and remote address filtering)
|
||||
└── windows.ts (UI handlers)
|
||||
```
|
||||
|
||||
**IPC Request Flow**:
|
||||
```
|
||||
local-ipc.ts → parseIpcRequest() → handleRequest()
|
||||
local-ipc.ts → parseIpcRequest() → handleLocalIpcRequest()
|
||||
├── hello/status/familiars.list/familiars.install
|
||||
└── lease.acquire/heartbeat/release
|
||||
└── lease-manager.ts
|
||||
|
|
@ -280,7 +281,8 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
|
|||
- `reaction-animation-mapping.ts`: Reaction-to-animation state mapping, user-configurable overrides, sprite state definitions
|
||||
|
||||
**IPC**:
|
||||
- `local-ipc.ts`: net.Server lifecycle, request routing, discovery file management, and logging
|
||||
- `local-ipc.ts`: net.Server lifecycle, discovery file management, lease wiring, and request-entry logging
|
||||
- `local-ipc-request-handlers.ts`: Extracted local IPC protocol request dispatch, status shaping, familiar actions, and memory CRUD handlers
|
||||
- `local-ipc-socket.ts`: Extracted socket accept loop, advertised endpoint resolution, and loopback/private address filtering
|
||||
- `local-ipc-protocol.ts`: Protocol constants, request/response types, validation functions
|
||||
- `local-ipc-paths.ts`: Platform-specific socket paths and discovery file locations
|
||||
|
|
|
|||
320
apps/desktop/src/local-ipc-request-handlers.ts
Normal file
320
apps/desktop/src/local-ipc-request-handlers.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import { applyAgentPetReaction, applyAgentPetSay } from "./agent-familiar-controller.js";
|
||||
import type { FamiliarOSActivityRecord } from "./app-state.js";
|
||||
import { applyExternalPetReaction, applyExternalPetSay, getDefaultPetPaused, isDefaultPetVisible } from "./default-familiar-controller.js";
|
||||
import { forgetFamiliarOSMemory, listFamiliarOSMemories, searchFamiliarOSMemories, storeFamiliarOSMemory } from "./familiaros-memory.js";
|
||||
import { installPet } from "./familiar-installation.js";
|
||||
import { createStaleLeaseStatus, type LeaseManager, type LeaseSnapshot } from "./lease-manager.js";
|
||||
import {
|
||||
IpcProtocolError,
|
||||
isRecord,
|
||||
validateInstallPetId,
|
||||
validateMemoryId,
|
||||
validateMemoryImportance,
|
||||
validateMemoryLimit,
|
||||
validateMemoryQuery,
|
||||
validateMemoryTags,
|
||||
validateMemoryText,
|
||||
validateOptionalMemoryKind,
|
||||
validateOptionalLeaseId,
|
||||
validateReaction,
|
||||
validateRequestedPetId,
|
||||
validateSayMessage,
|
||||
type FamiliarOSIpcRequest,
|
||||
} from "./local-ipc-protocol.js";
|
||||
|
||||
interface InstalledFamiliarLike {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly builtIn: boolean;
|
||||
readonly broken?: boolean;
|
||||
}
|
||||
|
||||
interface AppStateSnapshotLike {
|
||||
readonly familiars: {
|
||||
readonly installed: readonly InstalledFamiliarLike[];
|
||||
};
|
||||
readonly preferences: {
|
||||
readonly defaultPetId: string;
|
||||
readonly openDefaultPetOnLaunch: boolean;
|
||||
readonly speechBubblesEnabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface DefaultPetLike extends InstalledFamiliarLike {}
|
||||
|
||||
export interface LocalIpcRequestDependencies {
|
||||
readonly appVersion: string;
|
||||
readonly getAppStateSnapshot: () => AppStateSnapshotLike;
|
||||
readonly getCurrentDefaultPet: () => { readonly id: string; readonly displayName: string };
|
||||
readonly getCurrentDefaultPetWithFallback: () => DefaultPetLike;
|
||||
readonly getLeaseTarget: (value: unknown) => LeaseSnapshot | null;
|
||||
readonly leaseManager: Pick<LeaseManager, "get" | "acquire" | "heartbeat" | "release">;
|
||||
readonly safeRecordFamiliarOSActivity: (activity: FamiliarOSActivityRecord) => void;
|
||||
readonly validateRequiredLeaseId: (value: unknown) => string;
|
||||
readonly onDebug: (message: string, fields: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function buildHelloResponse(appVersion: string) {
|
||||
return {
|
||||
ok: true,
|
||||
protocol: "familiaros-ipc",
|
||||
protocolVersion: 1,
|
||||
appVersion,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function buildFamiliarsListResponse(state: AppStateSnapshotLike) {
|
||||
return {
|
||||
ok: true,
|
||||
familiars: state.familiars.installed.map((familiar) => ({
|
||||
id: familiar.id,
|
||||
displayName: familiar.displayName,
|
||||
builtIn: familiar.builtIn,
|
||||
broken: familiar.broken === true,
|
||||
})),
|
||||
defaultPetId: state.preferences.defaultPetId,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function handleLocalIpcRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
dependencies: LocalIpcRequestDependencies,
|
||||
): Promise<unknown> {
|
||||
if (request.method === "hello") {
|
||||
return buildHelloResponse(dependencies.appVersion);
|
||||
}
|
||||
if (request.method === "status") {
|
||||
return handleStatusRequest(request, dependencies);
|
||||
}
|
||||
if (request.method === "familiars.list") {
|
||||
return buildFamiliarsListResponse(dependencies.getAppStateSnapshot());
|
||||
}
|
||||
if (request.method === "familiars.install") {
|
||||
return handleFamiliarInstallRequest(request);
|
||||
}
|
||||
if (request.method === "lease.acquire") {
|
||||
return handleLeaseAcquireRequest(request, dependencies);
|
||||
}
|
||||
if (request.method === "lease.heartbeat") {
|
||||
return handleLeaseHeartbeatRequest(request, dependencies);
|
||||
}
|
||||
if (request.method === "lease.release") {
|
||||
return handleLeaseReleaseRequest(request, dependencies);
|
||||
}
|
||||
if (request.method === "memory.list") {
|
||||
return handleMemoryListRequest(request, dependencies);
|
||||
}
|
||||
if (request.method === "memory.search") {
|
||||
return handleMemorySearchRequest(request, dependencies);
|
||||
}
|
||||
if (request.method === "memory.store") {
|
||||
return handleMemoryStoreRequest(request, dependencies);
|
||||
}
|
||||
if (request.method === "memory.delete") {
|
||||
return handleMemoryDeleteRequest(request, dependencies);
|
||||
}
|
||||
if (request.method === "familiar.react") {
|
||||
return handleFamiliarReactionRequest(request, dependencies);
|
||||
}
|
||||
return handleFamiliarSayRequest(request, dependencies);
|
||||
}
|
||||
|
||||
function handleStatusRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
dependencies: LocalIpcRequestDependencies,
|
||||
) {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const leaseId = validateOptionalLeaseId(params.leaseId);
|
||||
if (leaseId) {
|
||||
const lease = dependencies.leaseManager.get(leaseId);
|
||||
if (!lease) return createStaleLeaseStatus(leaseId);
|
||||
return { ok: true, appRunning: true, ...lease };
|
||||
}
|
||||
|
||||
const state = dependencies.getAppStateSnapshot();
|
||||
const defaultPet = dependencies.getCurrentDefaultPetWithFallback();
|
||||
return {
|
||||
ok: true,
|
||||
appRunning: true,
|
||||
protocolVersion: 1,
|
||||
appVersion: dependencies.appVersion,
|
||||
defaultPet: {
|
||||
id: defaultPet.id,
|
||||
displayName: defaultPet.displayName,
|
||||
builtIn: defaultPet.builtIn,
|
||||
broken: defaultPet.broken === true,
|
||||
},
|
||||
paused: getDefaultPetPaused(),
|
||||
defaultPetVisible: isDefaultPetVisible(),
|
||||
openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch,
|
||||
speechBubblesEnabled: state.preferences.speechBubblesEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleFamiliarInstallRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
): Promise<{ readonly ok: true; readonly petId: string; readonly displayName: string; readonly installed: true }> {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const petId = validateInstallPetId(params.petId);
|
||||
const state = await installPet(petId);
|
||||
const installed = state.familiars.installed.find((familiar) => familiar.id === petId);
|
||||
if (!installed) throw new IpcProtocolError("install_failed", "Familiar install did not complete.");
|
||||
return { ok: true, petId: installed.id, displayName: installed.displayName, installed: true };
|
||||
}
|
||||
|
||||
function handleLeaseAcquireRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
dependencies: LocalIpcRequestDependencies,
|
||||
) {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const requestedPetId = validateRequestedPetId(params.requestedPetId);
|
||||
dependencies.onDebug("lease acquire requested", { requestId: request.id, requestedPetId });
|
||||
return dependencies.leaseManager.acquire(requestedPetId);
|
||||
}
|
||||
|
||||
function handleLeaseHeartbeatRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
dependencies: LocalIpcRequestDependencies,
|
||||
) {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const leaseId = dependencies.validateRequiredLeaseId(params.leaseId);
|
||||
dependencies.onDebug("lease heartbeat requested", { requestId: request.id, leaseId });
|
||||
try {
|
||||
return dependencies.leaseManager.heartbeat(leaseId);
|
||||
} catch {
|
||||
throw new IpcProtocolError("unknown_lease", "Unknown or expired lease.");
|
||||
}
|
||||
}
|
||||
|
||||
function handleLeaseReleaseRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
dependencies: LocalIpcRequestDependencies,
|
||||
) {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const leaseId = dependencies.validateRequiredLeaseId(params.leaseId);
|
||||
dependencies.onDebug("lease release requested", { requestId: request.id, leaseId });
|
||||
return dependencies.leaseManager.release(leaseId);
|
||||
}
|
||||
|
||||
function handleMemoryListRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
dependencies: LocalIpcRequestDependencies,
|
||||
) {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const limit = validateMemoryLimit(params.limit, 12, 25);
|
||||
dependencies.onDebug("memory list requested", { requestId: request.id, limit });
|
||||
return {
|
||||
ok: true,
|
||||
memories: listFamiliarOSMemories(limit),
|
||||
};
|
||||
}
|
||||
|
||||
function handleMemorySearchRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
dependencies: LocalIpcRequestDependencies,
|
||||
) {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const query = validateMemoryQuery(params.query);
|
||||
const limit = validateMemoryLimit(params.limit, 8, 12);
|
||||
dependencies.onDebug("memory search requested", { requestId: request.id, limit });
|
||||
return {
|
||||
ok: true,
|
||||
query,
|
||||
memories: searchFamiliarOSMemories(query, limit),
|
||||
};
|
||||
}
|
||||
|
||||
function handleMemoryStoreRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
dependencies: LocalIpcRequestDependencies,
|
||||
) {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const text = validateMemoryText(params.text);
|
||||
const kind = validateOptionalMemoryKind(params.kind);
|
||||
const tags = validateMemoryTags(params.tags);
|
||||
const importance = validateMemoryImportance(params.importance);
|
||||
dependencies.onDebug("memory store requested", {
|
||||
requestId: request.id,
|
||||
kind,
|
||||
hasTags: Array.isArray(tags) && tags.length > 0,
|
||||
importance,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
memory: storeFamiliarOSMemory({
|
||||
text,
|
||||
kind,
|
||||
tags,
|
||||
importance,
|
||||
source: "mcp",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function handleMemoryDeleteRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
dependencies: LocalIpcRequestDependencies,
|
||||
) {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const id = validateMemoryId(params.id);
|
||||
dependencies.onDebug("memory delete requested", { requestId: request.id, id });
|
||||
return {
|
||||
ok: true,
|
||||
deleted: forgetFamiliarOSMemory(id),
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
function handleFamiliarReactionRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
dependencies: LocalIpcRequestDependencies,
|
||||
) {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const reaction = validateReaction(params.reaction);
|
||||
const lease = dependencies.getLeaseTarget(params.leaseId);
|
||||
const petId = lease?.actualTargetPetId ?? dependencies.getCurrentDefaultPet().id;
|
||||
dependencies.onDebug("familiar react requested", {
|
||||
requestId: request.id,
|
||||
reaction,
|
||||
leaseId: lease?.leaseId,
|
||||
targetKind: lease?.targetKind,
|
||||
actualPetId: lease?.actualTargetPetId,
|
||||
});
|
||||
if (lease?.targetKind === "explicit") {
|
||||
if (getDefaultPetPaused()) return { ok: true, reaction, shown: false, reason: "paused", leaseId: lease.leaseId };
|
||||
const applied = applyAgentPetReaction(lease.actualTargetPetId, reaction);
|
||||
dependencies.safeRecordFamiliarOSActivity({ kind: "react", reaction, petId });
|
||||
return { ok: true, reaction, shown: applied.shown, reason: applied.reason, leaseId: lease.leaseId };
|
||||
}
|
||||
const applied = applyExternalPetReaction(reaction);
|
||||
dependencies.safeRecordFamiliarOSActivity({ kind: "react", reaction, petId });
|
||||
return { ok: true, reaction, shown: applied.shown, reason: applied.reason };
|
||||
}
|
||||
|
||||
function handleFamiliarSayRequest(
|
||||
request: FamiliarOSIpcRequest,
|
||||
dependencies: LocalIpcRequestDependencies,
|
||||
) {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const message = validateSayMessage(params.message);
|
||||
const reaction = params.reaction === undefined ? undefined : validateReaction(params.reaction);
|
||||
const lease = dependencies.getLeaseTarget(params.leaseId);
|
||||
const petId = lease?.actualTargetPetId ?? dependencies.getCurrentDefaultPet().id;
|
||||
dependencies.onDebug("familiar say requested", {
|
||||
requestId: request.id,
|
||||
reaction,
|
||||
messageLength: message.length,
|
||||
leaseId: lease?.leaseId,
|
||||
targetKind: lease?.targetKind,
|
||||
actualPetId: lease?.actualTargetPetId,
|
||||
});
|
||||
if (lease?.targetKind === "explicit") {
|
||||
if (getDefaultPetPaused()) return { ok: true, shown: false, reason: "paused", reaction, leaseId: lease.leaseId };
|
||||
const applied = applyAgentPetSay(lease.actualTargetPetId, message, reaction);
|
||||
dependencies.safeRecordFamiliarOSActivity({ kind: "say", reaction, petId });
|
||||
return { ok: true, shown: applied.shown, reason: applied.reason, reaction, leaseId: lease.leaseId };
|
||||
}
|
||||
const applied = applyExternalPetSay(message, reaction);
|
||||
dependencies.safeRecordFamiliarOSActivity({ kind: "say", reaction, petId });
|
||||
return { ok: true, shown: applied.shown, reason: applied.reason, reaction };
|
||||
}
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
import { randomBytes } from "node:crypto";
|
||||
import net from "node:net";
|
||||
|
||||
import { applyAgentPetReaction, applyAgentPetSay, clearAgentPetLeaseState, showAgentPet } from "./agent-familiar-controller.js";
|
||||
import { clearAgentPetLeaseState, showAgentPet } from "./agent-familiar-controller.js";
|
||||
import { getAppStateSnapshot, recordFamiliarOSActivity } from "./app-state.js";
|
||||
import { builtInPet } from "./built-in-familiar.js";
|
||||
import { applyExternalPetReaction, applyExternalPetSay, getDefaultPetPaused, isDefaultPetVisible } from "./default-familiar-controller.js";
|
||||
import { createStaleLeaseStatus, LeaseManager } from "./lease-manager.js";
|
||||
import { LeaseManager } from "./lease-manager.js";
|
||||
import { debug, error as logError, info } from "./logger.js";
|
||||
import { formatIpcEndpoint, getLocalIpcListeningEndpoint, handleLocalIpcSocket, listenOnIpcEndpoint } from "./local-ipc-socket.js";
|
||||
import { cleanupUnixSocket, getDiscoveryFilePath, getIpcEndpointConfig, protectUnixSocket, removeDiscoveryFile, writeDiscoveryFile, type FamiliarOSDiscoveryFile } from "./local-ipc-paths.js";
|
||||
import { errorResponse, IpcProtocolError, isRecord, okResponse, parseIpcRequest, validateInstallPetId, validateMemoryId, validateMemoryImportance, validateMemoryLimit, validateMemoryQuery, validateMemoryTags, validateMemoryText, validateOptionalLeaseId, validateOptionalMemoryKind, validateReaction, validateRequestedPetId, validateSayMessage, type FamiliarOSIpcRequest } from "./local-ipc-protocol.js";
|
||||
import { forgetFamiliarOSMemory, listFamiliarOSMemories, searchFamiliarOSMemories, storeFamiliarOSMemory } from "./familiaros-memory.js";
|
||||
import { installPet } from "./familiar-installation.js";
|
||||
import { errorResponse, IpcProtocolError, okResponse, parseIpcRequest, validateOptionalLeaseId } from "./local-ipc-protocol.js";
|
||||
import { handleLocalIpcRequest } from "./local-ipc-request-handlers.js";
|
||||
|
||||
let ipcServer: net.Server | null = null;
|
||||
let ipcDiscovery: FamiliarOSDiscoveryFile | null = null;
|
||||
|
|
@ -98,185 +96,23 @@ async function handleRawRequest(raw: string, token: string) {
|
|||
const request = parseIpcRequest(raw, token);
|
||||
requestId = request.id;
|
||||
debug("ipc", "request received", { requestId, method: request.method });
|
||||
return okResponse(request.id, await handleRequest(request));
|
||||
return okResponse(request.id, await handleLocalIpcRequest(request, {
|
||||
appVersion: ipcDiscovery?.appVersion ?? "0.0.0",
|
||||
getAppStateSnapshot,
|
||||
getCurrentDefaultPet,
|
||||
getCurrentDefaultPetWithFallback,
|
||||
getLeaseTarget,
|
||||
leaseManager,
|
||||
safeRecordFamiliarOSActivity,
|
||||
validateRequiredLeaseId,
|
||||
onDebug: (message, fields) => debug("ipc", message, fields),
|
||||
}));
|
||||
} catch (error) {
|
||||
logError("ipc", "request failed", error instanceof Error ? error : { requestId, error });
|
||||
return errorResponse(requestId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(request: FamiliarOSIpcRequest): Promise<unknown> {
|
||||
if (request.method === "hello") {
|
||||
return {
|
||||
ok: true,
|
||||
protocol: "familiaros-ipc",
|
||||
protocolVersion: 1,
|
||||
appVersion: ipcDiscovery?.appVersion ?? "0.0.0",
|
||||
};
|
||||
}
|
||||
|
||||
if (request.method === "status") {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const leaseId = validateOptionalLeaseId(params.leaseId);
|
||||
if (leaseId) {
|
||||
const lease = leaseManager.get(leaseId);
|
||||
if (!lease) return createStaleLeaseStatus(leaseId);
|
||||
return { ok: true, appRunning: true, ...lease };
|
||||
}
|
||||
const state = getAppStateSnapshot();
|
||||
const defaultPet = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId) ?? builtInPet;
|
||||
return {
|
||||
ok: true,
|
||||
appRunning: true,
|
||||
protocolVersion: 1,
|
||||
appVersion: ipcDiscovery?.appVersion ?? "0.0.0",
|
||||
defaultPet: {
|
||||
id: defaultPet.id,
|
||||
displayName: defaultPet.displayName,
|
||||
builtIn: defaultPet.builtIn,
|
||||
broken: "broken" in defaultPet && defaultPet.broken === true,
|
||||
},
|
||||
paused: getDefaultPetPaused(),
|
||||
defaultPetVisible: isDefaultPetVisible(),
|
||||
openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch,
|
||||
speechBubblesEnabled: state.preferences.speechBubblesEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
if (request.method === "familiars.list") {
|
||||
const state = getAppStateSnapshot();
|
||||
return {
|
||||
ok: true,
|
||||
familiars: state.familiars.installed.map((familiar) => ({
|
||||
id: familiar.id,
|
||||
displayName: familiar.displayName,
|
||||
builtIn: familiar.builtIn,
|
||||
broken: familiar.broken === true,
|
||||
})),
|
||||
defaultPetId: state.preferences.defaultPetId,
|
||||
};
|
||||
}
|
||||
|
||||
if (request.method === "familiars.install") {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const petId = validateInstallPetId(params.petId);
|
||||
const state = await installPet(petId);
|
||||
const installed = state.familiars.installed.find((familiar) => familiar.id === petId);
|
||||
if (!installed) throw new IpcProtocolError("install_failed", "Familiar install did not complete.");
|
||||
return { ok: true, petId: installed.id, displayName: installed.displayName, installed: true };
|
||||
}
|
||||
|
||||
if (request.method === "lease.acquire") {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const requestedPetId = validateRequestedPetId(params.requestedPetId);
|
||||
debug("ipc", "lease acquire requested", { requestId: request.id, requestedPetId });
|
||||
return leaseManager.acquire(requestedPetId);
|
||||
}
|
||||
|
||||
if (request.method === "lease.heartbeat") {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const leaseId = validateRequiredLeaseId(params.leaseId);
|
||||
debug("ipc", "lease heartbeat requested", { requestId: request.id, leaseId });
|
||||
try {
|
||||
return leaseManager.heartbeat(leaseId);
|
||||
} catch {
|
||||
throw new IpcProtocolError("unknown_lease", "Unknown or expired lease.");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === "lease.release") {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const leaseId = validateRequiredLeaseId(params.leaseId);
|
||||
debug("ipc", "lease release requested", { requestId: request.id, leaseId });
|
||||
return leaseManager.release(leaseId);
|
||||
}
|
||||
|
||||
if (request.method === "memory.list") {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const limit = validateMemoryLimit(params.limit, 12, 25);
|
||||
debug("ipc", "memory list requested", { requestId: request.id, limit });
|
||||
return {
|
||||
ok: true,
|
||||
memories: listFamiliarOSMemories(limit),
|
||||
};
|
||||
}
|
||||
|
||||
if (request.method === "memory.search") {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const query = validateMemoryQuery(params.query);
|
||||
const limit = validateMemoryLimit(params.limit, 8, 12);
|
||||
debug("ipc", "memory search requested", { requestId: request.id, limit });
|
||||
return {
|
||||
ok: true,
|
||||
query,
|
||||
memories: searchFamiliarOSMemories(query, limit),
|
||||
};
|
||||
}
|
||||
|
||||
if (request.method === "memory.store") {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const text = validateMemoryText(params.text);
|
||||
const kind = validateOptionalMemoryKind(params.kind);
|
||||
const tags = validateMemoryTags(params.tags);
|
||||
const importance = validateMemoryImportance(params.importance);
|
||||
debug("ipc", "memory store requested", { requestId: request.id, kind, hasTags: Array.isArray(tags) && tags.length > 0, importance });
|
||||
return {
|
||||
ok: true,
|
||||
memory: storeFamiliarOSMemory({
|
||||
text,
|
||||
kind,
|
||||
tags,
|
||||
importance,
|
||||
source: "mcp",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (request.method === "memory.delete") {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const id = validateMemoryId(params.id);
|
||||
debug("ipc", "memory delete requested", { requestId: request.id, id });
|
||||
return {
|
||||
ok: true,
|
||||
deleted: forgetFamiliarOSMemory(id),
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
if (request.method === "familiar.react") {
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const reaction = validateReaction(params.reaction);
|
||||
const lease = getLeaseTarget(params.leaseId);
|
||||
const petId = lease?.actualTargetPetId ?? getCurrentDefaultPet().id;
|
||||
debug("ipc", "familiar react requested", { requestId: request.id, reaction, leaseId: lease?.leaseId, targetKind: lease?.targetKind, actualPetId: lease?.actualTargetPetId });
|
||||
if (lease?.targetKind === "explicit") {
|
||||
if (getDefaultPetPaused()) return { ok: true, reaction, shown: false, reason: "paused", leaseId: lease.leaseId };
|
||||
const applied = applyAgentPetReaction(lease.actualTargetPetId, reaction);
|
||||
safeRecordFamiliarOSActivity({ kind: "react", reaction, petId });
|
||||
return { ok: true, reaction, shown: applied.shown, reason: applied.reason, leaseId: lease.leaseId };
|
||||
}
|
||||
const applied = applyExternalPetReaction(reaction);
|
||||
safeRecordFamiliarOSActivity({ kind: "react", reaction, petId });
|
||||
return { ok: true, reaction, shown: applied.shown, reason: applied.reason };
|
||||
}
|
||||
|
||||
const params = isRecord(request.params) ? request.params : {};
|
||||
const message = validateSayMessage(params.message);
|
||||
const reaction = params.reaction === undefined ? undefined : validateReaction(params.reaction);
|
||||
const lease = getLeaseTarget(params.leaseId);
|
||||
const petId = lease?.actualTargetPetId ?? getCurrentDefaultPet().id;
|
||||
debug("ipc", "familiar say requested", { requestId: request.id, reaction, messageLength: message.length, leaseId: lease?.leaseId, targetKind: lease?.targetKind, actualPetId: lease?.actualTargetPetId });
|
||||
if (lease?.targetKind === "explicit") {
|
||||
if (getDefaultPetPaused()) return { ok: true, shown: false, reason: "paused", reaction, leaseId: lease.leaseId };
|
||||
const applied = applyAgentPetSay(lease.actualTargetPetId, message, reaction);
|
||||
safeRecordFamiliarOSActivity({ kind: "say", reaction, petId });
|
||||
return { ok: true, shown: applied.shown, reason: applied.reason, reaction, leaseId: lease.leaseId };
|
||||
}
|
||||
const applied = applyExternalPetSay(message, reaction);
|
||||
safeRecordFamiliarOSActivity({ kind: "say", reaction, petId });
|
||||
return { ok: true, shown: applied.shown, reason: applied.reason, reaction };
|
||||
}
|
||||
|
||||
function safeRecordFamiliarOSActivity(activity: Parameters<typeof recordFamiliarOSActivity>[0]): void {
|
||||
try {
|
||||
recordFamiliarOSActivity(activity);
|
||||
|
|
@ -323,7 +159,13 @@ function getCurrentDefaultPet(): { readonly id: string; readonly displayName: st
|
|||
return { id: familiar.id, displayName: familiar.displayName };
|
||||
}
|
||||
|
||||
function getCurrentDefaultPetWithFallback(): { readonly id: string; readonly displayName: string; readonly fallbackReason?: "default_broken_fallback_builtin" } {
|
||||
function getCurrentDefaultPetWithFallback(): {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly builtIn: boolean;
|
||||
readonly broken?: boolean;
|
||||
readonly fallbackReason?: "default_broken_fallback_builtin";
|
||||
} {
|
||||
const state = getAppStateSnapshot();
|
||||
const configuredDefault = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId);
|
||||
if (configuredDefault && !configuredDefault.broken) return configuredDefault;
|
||||
|
|
|
|||
53
apps/desktop/tests/local-ipc-request-handlers.test.ts
Normal file
53
apps/desktop/tests/local-ipc-request-handlers.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { buildFamiliarsListResponse, buildHelloResponse } from "../src/local-ipc-request-handlers.js";
|
||||
|
||||
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const localIpcSource = readFileSync(resolve(desktopRoot, "src/local-ipc.ts"), "utf8");
|
||||
const localIpcRequestHandlersSource = readFileSync(resolve(desktopRoot, "src/local-ipc-request-handlers.ts"), "utf8");
|
||||
|
||||
assert.match(localIpcSource, /from "\.\/local-ipc-request-handlers(?:\.js)?"/, "local-ipc must compose the extracted request-handler seam.");
|
||||
assert.match(localIpcRequestHandlersSource, /export async function handleLocalIpcRequest/, "local-ipc request-handler seam must export the async dispatcher.");
|
||||
assert.match(localIpcRequestHandlersSource, /export function buildHelloResponse/, "local-ipc request-handler seam must export the hello response builder.");
|
||||
assert.match(localIpcRequestHandlersSource, /export function buildFamiliarsListResponse/, "local-ipc request-handler seam must export familiar list response shaping.");
|
||||
|
||||
assert.deepEqual(
|
||||
buildHelloResponse("1.2.3"),
|
||||
{
|
||||
ok: true,
|
||||
protocol: "familiaros-ipc",
|
||||
protocolVersion: 1,
|
||||
appVersion: "1.2.3",
|
||||
},
|
||||
"local-ipc hello responses must preserve protocol and app version metadata.",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildFamiliarsListResponse({
|
||||
familiars: {
|
||||
installed: [
|
||||
{ id: "built-in", displayName: "Built In", builtIn: true },
|
||||
{ id: "fox", displayName: "Fox", builtIn: false, broken: true },
|
||||
],
|
||||
},
|
||||
preferences: {
|
||||
defaultPetId: "fox",
|
||||
openDefaultPetOnLaunch: true,
|
||||
speechBubblesEnabled: true,
|
||||
},
|
||||
}),
|
||||
{
|
||||
ok: true,
|
||||
familiars: [
|
||||
{ id: "built-in", displayName: "Built In", builtIn: true, broken: false },
|
||||
{ id: "fox", displayName: "Fox", builtIn: false, broken: true },
|
||||
],
|
||||
defaultPetId: "fox",
|
||||
},
|
||||
"local-ipc familiar list responses must normalize broken flags for each installed familiar.",
|
||||
);
|
||||
|
||||
console.error("Local IPC request-handler seam validation passed.");
|
||||
|
|
@ -7,12 +7,15 @@ import { getLocalIpcListeningEndpoint, isAllowedRemoteAddress } from "../src/loc
|
|||
|
||||
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const localIpcSource = readFileSync(resolve(desktopRoot, "src/local-ipc.ts"), "utf8");
|
||||
const localIpcRequestHandlersSource = readFileSync(resolve(desktopRoot, "src/local-ipc-request-handlers.ts"), "utf8");
|
||||
const localIpcSocketSource = readFileSync(resolve(desktopRoot, "src/local-ipc-socket.ts"), "utf8");
|
||||
|
||||
assert.match(localIpcSource, /from "\.\/local-ipc-socket(?:\.js)?"/, "local-ipc must compose the extracted socket helper seam.");
|
||||
assert.match(localIpcSource, /from "\.\/local-ipc-request-handlers(?:\.js)?"/, "local-ipc must compose the extracted request-handler seam.");
|
||||
assert.match(localIpcSocketSource, /export function handleLocalIpcSocket/, "local-ipc socket seam must export the client socket handler.");
|
||||
assert.match(localIpcSocketSource, /export function getLocalIpcListeningEndpoint/, "local-ipc socket seam must export advertised endpoint resolution.");
|
||||
assert.match(localIpcSocketSource, /export function isAllowedRemoteAddress/, "local-ipc socket seam must export remote-address filtering.");
|
||||
assert.match(localIpcRequestHandlersSource, /export async function handleLocalIpcRequest/, "local-ipc request-handler seam must export the async dispatcher.");
|
||||
|
||||
assert.equal(isAllowedRemoteAddress("127.0.0.1", "127.0.0.1"), true, "loopback clients must always be allowed.");
|
||||
assert.equal(isAllowedRemoteAddress("::ffff:127.0.0.1", "127.0.0.1"), true, "IPv4-mapped loopback clients must be allowed.");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue