feat(voicewake): add gateway-owned wake words sync
This commit is contained in:
62
docs/voicewake.md
Normal file
62
docs/voicewake.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
summary: "Global voice wake words (Gateway-owned) and how they sync across nodes"
|
||||||
|
read_when:
|
||||||
|
- Changing voice wake words behavior or defaults
|
||||||
|
- Adding new node platforms that need wake word sync
|
||||||
|
---
|
||||||
|
# Voice Wake (Global Wake Words)
|
||||||
|
|
||||||
|
Clawdis treats **wake words as a single global list** owned by the **Gateway**.
|
||||||
|
|
||||||
|
- There are **no per-node custom wake words**.
|
||||||
|
- **Any node/app UI may edit** the list; changes are persisted by the Gateway and broadcast to everyone.
|
||||||
|
- Each device still keeps its own **Voice Wake enabled/disabled** toggle (local UX + permissions differ).
|
||||||
|
|
||||||
|
## Storage (Gateway host)
|
||||||
|
|
||||||
|
Wake words are stored on the gateway machine at:
|
||||||
|
|
||||||
|
- `~/.clawdis/settings/voicewake.json`
|
||||||
|
|
||||||
|
Shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "triggers": ["clawd", "claude"], "updatedAtMs": 1730000000000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
- `voicewake.get` → `{ triggers: string[] }`
|
||||||
|
- `voicewake.set` with params `{ triggers: string[] }` → `{ triggers: string[] }`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Triggers are normalized (trimmed, empties dropped). Empty lists fall back to defaults.
|
||||||
|
- Limits are enforced for safety (count/length caps).
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
- `voicewake.changed` payload `{ triggers: string[] }`
|
||||||
|
|
||||||
|
Who receives it:
|
||||||
|
- All WebSocket clients (macOS app, WebChat, etc.)
|
||||||
|
- All connected bridge nodes (iOS/Android), and also on node connect as an initial “current state” push.
|
||||||
|
|
||||||
|
## Client behavior
|
||||||
|
|
||||||
|
### macOS app
|
||||||
|
|
||||||
|
- Uses the global list to gate `VoiceWakeRuntime` triggers.
|
||||||
|
- Editing “Trigger words” in Voice Wake settings calls `voicewake.set` and then relies on the broadcast to keep other clients in sync.
|
||||||
|
|
||||||
|
### iOS node (Iris)
|
||||||
|
|
||||||
|
- Uses the global list for `VoiceWakeManager` trigger detection.
|
||||||
|
- Editing Wake Words in Settings calls `voicewake.set` (over the bridge) and also keeps local wake-word detection responsive.
|
||||||
|
|
||||||
|
### Android node
|
||||||
|
|
||||||
|
- Exposes a Wake Words editor in Settings.
|
||||||
|
- Calls `voicewake.set` over the bridge so edits sync everywhere.
|
||||||
|
|
||||||
@@ -47,6 +47,9 @@ const bridgeInvoke = vi.hoisted(() =>
|
|||||||
error: null,
|
error: null,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
const bridgeListConnected = vi.hoisted(() =>
|
||||||
|
vi.fn(() => [] as BridgeClientInfo[]),
|
||||||
|
);
|
||||||
const bridgeSendEvent = vi.hoisted(() => vi.fn());
|
const bridgeSendEvent = vi.hoisted(() => vi.fn());
|
||||||
vi.mock("../infra/bridge/server.js", () => ({
|
vi.mock("../infra/bridge/server.js", () => ({
|
||||||
startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => {
|
startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => {
|
||||||
@@ -54,7 +57,7 @@ vi.mock("../infra/bridge/server.js", () => ({
|
|||||||
return {
|
return {
|
||||||
port: 18790,
|
port: 18790,
|
||||||
close: async () => {},
|
close: async () => {},
|
||||||
listConnected: () => [],
|
listConnected: bridgeListConnected,
|
||||||
invoke: bridgeInvoke,
|
invoke: bridgeInvoke,
|
||||||
sendEvent: bridgeSendEvent,
|
sendEvent: bridgeSendEvent,
|
||||||
};
|
};
|
||||||
@@ -246,6 +249,110 @@ async function rpcReq<T = unknown>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("gateway server", () => {
|
describe("gateway server", () => {
|
||||||
|
test("voicewake.get returns defaults and voicewake.set broadcasts", async () => {
|
||||||
|
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||||
|
const prevHome = process.env.HOME;
|
||||||
|
process.env.HOME = homeDir;
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
|
||||||
|
expect(initial.ok).toBe(true);
|
||||||
|
expect(initial.payload?.triggers).toEqual(["clawd", "claude"]);
|
||||||
|
|
||||||
|
const changedP = onceMessage<{
|
||||||
|
type: "event";
|
||||||
|
event: string;
|
||||||
|
payload?: unknown;
|
||||||
|
}>(ws, (o) => o.type === "event" && o.event === "voicewake.changed");
|
||||||
|
|
||||||
|
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
|
||||||
|
triggers: [" hi ", "", "there"],
|
||||||
|
});
|
||||||
|
expect(setRes.ok).toBe(true);
|
||||||
|
expect(setRes.payload?.triggers).toEqual(["hi", "there"]);
|
||||||
|
|
||||||
|
const changed = await changedP;
|
||||||
|
expect(changed.event).toBe("voicewake.changed");
|
||||||
|
expect(
|
||||||
|
(changed.payload as { triggers?: unknown } | undefined)?.triggers,
|
||||||
|
).toEqual(["hi", "there"]);
|
||||||
|
|
||||||
|
const after = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
|
||||||
|
expect(after.ok).toBe(true);
|
||||||
|
expect(after.payload?.triggers).toEqual(["hi", "there"]);
|
||||||
|
|
||||||
|
const onDisk = JSON.parse(
|
||||||
|
await fs.readFile(
|
||||||
|
path.join(homeDir, ".clawdis", "settings", "voicewake.json"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
) as { triggers?: unknown; updatedAtMs?: unknown };
|
||||||
|
expect(onDisk.triggers).toEqual(["hi", "there"]);
|
||||||
|
expect(typeof onDisk.updatedAtMs).toBe("number");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
|
||||||
|
if (prevHome === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
} else {
|
||||||
|
process.env.HOME = prevHome;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pushes voicewake.changed to nodes on connect and on updates", async () => {
|
||||||
|
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||||
|
const prevHome = process.env.HOME;
|
||||||
|
process.env.HOME = homeDir;
|
||||||
|
|
||||||
|
bridgeSendEvent.mockClear();
|
||||||
|
bridgeListConnected.mockReturnValue([{ nodeId: "n1" }]);
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const startCall = bridgeStartCalls.at(-1);
|
||||||
|
expect(startCall).toBeTruthy();
|
||||||
|
|
||||||
|
await startCall?.onAuthenticated?.({ nodeId: "n1" });
|
||||||
|
|
||||||
|
const first = bridgeSendEvent.mock.calls.find(
|
||||||
|
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
|
||||||
|
)?.[0] as { payloadJSON?: string | null } | undefined;
|
||||||
|
expect(first?.payloadJSON).toBeTruthy();
|
||||||
|
const firstPayload = JSON.parse(String(first?.payloadJSON)) as {
|
||||||
|
triggers?: unknown;
|
||||||
|
};
|
||||||
|
expect(firstPayload.triggers).toEqual(["clawd", "claude"]);
|
||||||
|
|
||||||
|
bridgeSendEvent.mockClear();
|
||||||
|
|
||||||
|
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
|
||||||
|
triggers: ["clawd", "computer"],
|
||||||
|
});
|
||||||
|
expect(setRes.ok).toBe(true);
|
||||||
|
|
||||||
|
const broadcast = bridgeSendEvent.mock.calls.find(
|
||||||
|
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
|
||||||
|
)?.[0] as { payloadJSON?: string | null } | undefined;
|
||||||
|
expect(broadcast?.payloadJSON).toBeTruthy();
|
||||||
|
const broadcastPayload = JSON.parse(String(broadcast?.payloadJSON)) as {
|
||||||
|
triggers?: unknown;
|
||||||
|
};
|
||||||
|
expect(broadcastPayload.triggers).toEqual(["clawd", "computer"]);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
|
||||||
|
if (prevHome === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
} else {
|
||||||
|
process.env.HOME = prevHome;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("supports gateway-owned node pairing methods and events", async () => {
|
test("supports gateway-owned node pairing methods and events", async () => {
|
||||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||||
const prevHome = process.env.HOME;
|
const prevHome = process.env.HOME;
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ import {
|
|||||||
updateSystemPresence,
|
updateSystemPresence,
|
||||||
upsertPresence,
|
upsertPresence,
|
||||||
} from "../infra/system-presence.js";
|
} from "../infra/system-presence.js";
|
||||||
|
import {
|
||||||
|
defaultVoiceWakeTriggers,
|
||||||
|
loadVoiceWakeConfig,
|
||||||
|
setVoiceWakeTriggers,
|
||||||
|
} from "../infra/voicewake.js";
|
||||||
import { logError, logInfo, logWarn } from "../logger.js";
|
import { logError, logInfo, logWarn } from "../logger.js";
|
||||||
import {
|
import {
|
||||||
getChildLogger,
|
getChildLogger,
|
||||||
@@ -168,6 +173,8 @@ type SessionsPatchResult = {
|
|||||||
const METHODS = [
|
const METHODS = [
|
||||||
"health",
|
"health",
|
||||||
"status",
|
"status",
|
||||||
|
"voicewake.get",
|
||||||
|
"voicewake.set",
|
||||||
"sessions.list",
|
"sessions.list",
|
||||||
"sessions.patch",
|
"sessions.patch",
|
||||||
"last-heartbeat",
|
"last-heartbeat",
|
||||||
@@ -207,6 +214,7 @@ const EVENTS = [
|
|||||||
"cron",
|
"cron",
|
||||||
"node.pair.requested",
|
"node.pair.requested",
|
||||||
"node.pair.resolved",
|
"node.pair.resolved",
|
||||||
|
"voicewake.changed",
|
||||||
];
|
];
|
||||||
|
|
||||||
export type GatewayServer = {
|
export type GatewayServer = {
|
||||||
@@ -284,6 +292,16 @@ function formatForLog(value: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeVoiceWakeTriggers(input: unknown): string[] {
|
||||||
|
const raw = Array.isArray(input) ? input : [];
|
||||||
|
const cleaned = raw
|
||||||
|
.map((v) => (typeof v === "string" ? v.trim() : ""))
|
||||||
|
.filter((v) => v.length > 0)
|
||||||
|
.slice(0, 32)
|
||||||
|
.map((v) => v.slice(0, 64));
|
||||||
|
return cleaned.length > 0 ? cleaned : defaultVoiceWakeTriggers();
|
||||||
|
}
|
||||||
|
|
||||||
function readSessionMessages(
|
function readSessionMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
storePath: string | undefined,
|
storePath: string | undefined,
|
||||||
@@ -752,6 +770,20 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bridgeSendToAllConnected = (event: string, payload: unknown) => {
|
||||||
|
if (!bridge) return;
|
||||||
|
const payloadJSON = payload ? JSON.stringify(payload) : null;
|
||||||
|
for (const node of bridge.listConnected()) {
|
||||||
|
bridge.sendEvent({ nodeId: node.nodeId, event, payloadJSON });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const broadcastVoiceWakeChanged = (triggers: string[]) => {
|
||||||
|
const payload = { triggers };
|
||||||
|
broadcast("voicewake.changed", payload, { dropIfSlow: true });
|
||||||
|
bridgeSendToAllConnected("voicewake.changed", payload);
|
||||||
|
};
|
||||||
|
|
||||||
const handleBridgeRequest = async (
|
const handleBridgeRequest = async (
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
req: { id: string; method: string; paramsJSON?: string | null },
|
req: { id: string; method: string; paramsJSON?: string | null },
|
||||||
@@ -773,6 +805,23 @@ export async function startGatewayServer(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
|
case "voicewake.get": {
|
||||||
|
const cfg = await loadVoiceWakeConfig();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "voicewake.set": {
|
||||||
|
const params = parseParams();
|
||||||
|
const triggers = normalizeVoiceWakeTriggers(params.triggers);
|
||||||
|
const cfg = await setVoiceWakeTriggers(triggers);
|
||||||
|
broadcastVoiceWakeChanged(cfg.triggers);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
|
||||||
|
};
|
||||||
|
}
|
||||||
case "health": {
|
case "health": {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cached = healthCache;
|
const cached = healthCache;
|
||||||
@@ -1170,7 +1219,7 @@ export async function startGatewayServer(
|
|||||||
port: bridgePort,
|
port: bridgePort,
|
||||||
serverName: machineDisplayName,
|
serverName: machineDisplayName,
|
||||||
onRequest: (nodeId, req) => handleBridgeRequest(nodeId, req),
|
onRequest: (nodeId, req) => handleBridgeRequest(nodeId, req),
|
||||||
onAuthenticated: (node) => {
|
onAuthenticated: async (node) => {
|
||||||
const host = node.displayName?.trim() || node.nodeId;
|
const host = node.displayName?.trim() || node.nodeId;
|
||||||
const ip = node.remoteIp?.trim();
|
const ip = node.remoteIp?.trim();
|
||||||
const version = node.version?.trim() || "unknown";
|
const version = node.version?.trim() || "unknown";
|
||||||
@@ -1199,6 +1248,17 @@ export async function startGatewayServer(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cfg = await loadVoiceWakeConfig();
|
||||||
|
started.sendEvent({
|
||||||
|
nodeId: node.nodeId,
|
||||||
|
event: "voicewake.changed",
|
||||||
|
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDisconnected: (node) => {
|
onDisconnected: (node) => {
|
||||||
bridgeUnsubscribeAll(node.nodeId);
|
bridgeUnsubscribeAll(node.nodeId);
|
||||||
@@ -1676,6 +1736,46 @@ export async function startGatewayServer(
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "voicewake.get": {
|
||||||
|
try {
|
||||||
|
const cfg = await loadVoiceWakeConfig();
|
||||||
|
respond(true, { triggers: cfg.triggers });
|
||||||
|
} catch (err) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "voicewake.set": {
|
||||||
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
|
if (!Array.isArray(params.triggers)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
"voicewake.set requires triggers: string[]",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const triggers = normalizeVoiceWakeTriggers(params.triggers);
|
||||||
|
const cfg = await setVoiceWakeTriggers(triggers);
|
||||||
|
broadcastVoiceWakeChanged(cfg.triggers);
|
||||||
|
respond(true, { triggers: cfg.triggers });
|
||||||
|
} catch (err) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "health": {
|
case "health": {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cached = healthCache;
|
const cached = healthCache;
|
||||||
|
|||||||
46
src/infra/voicewake.test.ts
Normal file
46
src/infra/voicewake.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
defaultVoiceWakeTriggers,
|
||||||
|
loadVoiceWakeConfig,
|
||||||
|
setVoiceWakeTriggers,
|
||||||
|
} from "./voicewake.js";
|
||||||
|
|
||||||
|
describe("voicewake store", () => {
|
||||||
|
it("returns defaults when missing", async () => {
|
||||||
|
const baseDir = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "clawdis-voicewake-"),
|
||||||
|
);
|
||||||
|
const cfg = await loadVoiceWakeConfig(baseDir);
|
||||||
|
expect(cfg.triggers).toEqual(defaultVoiceWakeTriggers());
|
||||||
|
expect(cfg.updatedAtMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes and persists triggers", async () => {
|
||||||
|
const baseDir = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "clawdis-voicewake-"),
|
||||||
|
);
|
||||||
|
const saved = await setVoiceWakeTriggers(
|
||||||
|
[" hi ", "", " there "],
|
||||||
|
baseDir,
|
||||||
|
);
|
||||||
|
expect(saved.triggers).toEqual(["hi", "there"]);
|
||||||
|
expect(saved.updatedAtMs).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const loaded = await loadVoiceWakeConfig(baseDir);
|
||||||
|
expect(loaded.triggers).toEqual(["hi", "there"]);
|
||||||
|
expect(loaded.updatedAtMs).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to defaults when triggers empty", async () => {
|
||||||
|
const baseDir = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "clawdis-voicewake-"),
|
||||||
|
);
|
||||||
|
const saved = await setVoiceWakeTriggers(["", " "], baseDir);
|
||||||
|
expect(saved.triggers).toEqual(defaultVoiceWakeTriggers());
|
||||||
|
});
|
||||||
|
});
|
||||||
96
src/infra/voicewake.ts
Normal file
96
src/infra/voicewake.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export type VoiceWakeConfig = {
|
||||||
|
triggers: string[];
|
||||||
|
updatedAtMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TRIGGERS = ["clawd", "claude"];
|
||||||
|
|
||||||
|
function defaultBaseDir() {
|
||||||
|
return path.join(os.homedir(), ".clawdis");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePath(baseDir?: string) {
|
||||||
|
const root = baseDir ?? defaultBaseDir();
|
||||||
|
return path.join(root, "settings", "voicewake.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTriggers(triggers: string[] | undefined | null): string[] {
|
||||||
|
const cleaned = (triggers ?? [])
|
||||||
|
.map((w) => (typeof w === "string" ? w.trim() : ""))
|
||||||
|
.filter((w) => w.length > 0);
|
||||||
|
return cleaned.length > 0 ? cleaned : DEFAULT_TRIGGERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJSON<T>(filePath: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(filePath, "utf8");
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJSONAtomic(filePath: string, value: unknown) {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
|
||||||
|
await fs.rename(tmp, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lock: Promise<void> = Promise.resolve();
|
||||||
|
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prev = lock;
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
lock = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await prev;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultVoiceWakeTriggers() {
|
||||||
|
return [...DEFAULT_TRIGGERS];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadVoiceWakeConfig(
|
||||||
|
baseDir?: string,
|
||||||
|
): Promise<VoiceWakeConfig> {
|
||||||
|
const filePath = resolvePath(baseDir);
|
||||||
|
const existing = await readJSON<VoiceWakeConfig>(filePath);
|
||||||
|
if (!existing) {
|
||||||
|
return { triggers: defaultVoiceWakeTriggers(), updatedAtMs: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
triggers: sanitizeTriggers(existing.triggers),
|
||||||
|
updatedAtMs:
|
||||||
|
typeof existing.updatedAtMs === "number" && existing.updatedAtMs > 0
|
||||||
|
? existing.updatedAtMs
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setVoiceWakeTriggers(
|
||||||
|
triggers: string[],
|
||||||
|
baseDir?: string,
|
||||||
|
): Promise<VoiceWakeConfig> {
|
||||||
|
const sanitized = sanitizeTriggers(triggers);
|
||||||
|
const filePath = resolvePath(baseDir);
|
||||||
|
return await withLock(async () => {
|
||||||
|
const next: VoiceWakeConfig = {
|
||||||
|
triggers: sanitized,
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
await writeJSONAtomic(filePath, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user