diff --git a/deploy/argent-lite-kiosk.desktop b/deploy/argent-lite-kiosk.desktop new file mode 100644 index 0000000..d9eb8fb --- /dev/null +++ b/deploy/argent-lite-kiosk.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Argent Lite Kiosk +Comment=Full-screen Argent Lite kiosk UI (walk-up satellite) +Exec=chromium --kiosk --noerrdialogs --disable-infobars http://127.0.0.1:7788 +Icon=utilities-terminal +Terminal=false +Categories=Utility; +StartupNotify=false diff --git a/src/cli/kiosk.ts b/src/cli/kiosk.ts new file mode 100644 index 0000000..4e37a52 --- /dev/null +++ b/src/cli/kiosk.ts @@ -0,0 +1,46 @@ +import { createKioskHandlers } from "../kiosk/handlers.js"; +import { startKioskServer } from "../kiosk/server.js"; + +export async function runKiosk( + _argv: string[] = process.argv.slice(2), +): Promise { + const handlers = createKioskHandlers({ + async startCapture() { + process.stdout.write("[kiosk] mic capture not wired yet\n"); + }, + async stopCapture() { + process.stdout.write("[kiosk] mic capture not wired yet\n"); + return undefined; + }, + async cancelSpeech() { + process.stdout.write("[kiosk] speech cancel not wired yet\n"); + }, + }); + + const server = await startKioskServer({ handlers, port: 7788 }); + process.stdout.write(`listening on http://127.0.0.1:${server.port}\n`); + + await new Promise((resolvePromise) => { + const onSig = (): void => { + process.off("SIGINT", onSig); + process.off("SIGTERM", onSig); + resolvePromise(); + }; + process.on("SIGINT", onSig); + process.on("SIGTERM", onSig); + }); + + await server.stop(); + return 0; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runKiosk().then( + (code) => process.exit(code), + (err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[argent-lite-kiosk] ${msg}\n`); + process.exit(1); + }, + ); +} diff --git a/src/kiosk/handlers.ts b/src/kiosk/handlers.ts new file mode 100644 index 0000000..8fe7f40 --- /dev/null +++ b/src/kiosk/handlers.ts @@ -0,0 +1,127 @@ +export type KioskState = "idle" | "listening" | "thinking" | "speaking"; + +export interface KioskStatus { + state: KioskState; + statusText: string; +} + +export interface KioskTalkStartResult { + ok: boolean; + error?: string; +} + +export interface KioskTalkEndResult { + ok: boolean; + transcript?: string; + error?: string; +} + +export interface KioskInterruptResult { + ok: boolean; + error?: string; +} + +export interface KioskHandlers { + status(): Promise; + talkStart(): Promise; + talkEnd(): Promise; + interrupt(): Promise; +} + +export interface KioskHandlerOptions { + startCapture?: () => Promise; + stopCapture?: () => Promise; + cancelSpeech?: () => Promise; + getState?: () => KioskState; +} + +const STATUS_TEXT: Record = { + idle: "Tap to talk", + listening: "Listening...", + thinking: "Thinking...", + speaking: "Speaking...", +}; + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export function createKioskHandlers( + opts: KioskHandlerOptions = {}, +): KioskHandlers { + let state: KioskState = "idle"; + + const startCapture = + opts.startCapture ?? + (async (): Promise => { + process.stdout.write("[kiosk] mic capture not wired yet\n"); + }); + const stopCapture = + opts.stopCapture ?? + (async (): Promise => { + process.stdout.write("[kiosk] mic capture not wired yet\n"); + return undefined; + }); + const cancelSpeech = + opts.cancelSpeech ?? + (async (): Promise => { + process.stdout.write("[kiosk] speech cancel not wired yet\n"); + }); + + function currentState(): KioskState { + return opts.getState ? opts.getState() : state; + } + + return { + async status() { + const s = currentState(); + return { state: s, statusText: STATUS_TEXT[s] }; + }, + + async talkStart() { + if (currentState() !== "idle") { + return { ok: false, error: `cannot start from state ${currentState()}` }; + } + try { + state = "listening"; + await startCapture(); + return { ok: true }; + } catch (err) { + state = "idle"; + return { ok: false, error: errorMessage(err) }; + } + }, + + async talkEnd() { + if (currentState() !== "listening") { + return { + ok: false, + error: `cannot end from state ${currentState()}`, + }; + } + state = "thinking"; + try { + const transcript = await stopCapture(); + if (transcript && transcript.length > 0) { + state = "speaking"; + return { ok: true, transcript }; + } + state = "idle"; + return { ok: true }; + } catch (err) { + state = "idle"; + return { ok: false, error: errorMessage(err) }; + } + }, + + async interrupt() { + try { + await cancelSpeech(); + state = "idle"; + return { ok: true }; + } catch (err) { + return { ok: false, error: errorMessage(err) }; + } + }, + }; +} diff --git a/src/kiosk/index.html b/src/kiosk/index.html new file mode 100644 index 0000000..52fdcd3 --- /dev/null +++ b/src/kiosk/index.html @@ -0,0 +1,197 @@ + + + + + + Argent Lite Kiosk + + + +
+ + +
Tap to talk
+ +
+ + + diff --git a/src/kiosk/index.ts b/src/kiosk/index.ts new file mode 100644 index 0000000..edcdd64 --- /dev/null +++ b/src/kiosk/index.ts @@ -0,0 +1,15 @@ +export { + createKioskHandlers, + type KioskHandlers, + type KioskHandlerOptions, + type KioskState, + type KioskStatus, + type KioskTalkStartResult, + type KioskTalkEndResult, + type KioskInterruptResult, +} from "./handlers.js"; +export { + startKioskServer, + type RunningKioskServer, + type StartKioskServerOptions, +} from "./server.js"; diff --git a/src/kiosk/server.ts b/src/kiosk/server.ts new file mode 100644 index 0000000..e45e08f --- /dev/null +++ b/src/kiosk/server.ts @@ -0,0 +1,217 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import type { KioskHandlers } from "./handlers.js"; + +export interface StartKioskServerOptions { + handlers: KioskHandlers; + port?: number; + host?: string; + indexHtmlPath?: string; + /** SSE tick interval in ms. Default 500. */ + tickIntervalMs?: number; +} + +export interface RunningKioskServer { + port: number; + stop(): Promise; +} + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 7788; +const DEFAULT_TICK_MS = 500; + +async function resolveDefaultIndexHtmlPath(): Promise { + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + resolve(here, "index.html"), + resolve(here, "..", "..", "..", "src", "kiosk", "index.html"), + ]; + for (const candidate of candidates) { + try { + await stat(candidate); + return candidate; + } catch { + /* try next */ + } + } + return candidates[0]; +} + +function sendJson( + res: ServerResponse, + status: number, + body: Record, +): void { + const payload = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload).toString(), + "cache-control": "no-store", + }); + res.end(payload); +} + +function sendText( + res: ServerResponse, + status: number, + contentType: string, + body: string | Buffer, +): void { + const buf = typeof body === "string" ? Buffer.from(body, "utf8") : body; + res.writeHead(status, { + "content-type": contentType, + "content-length": buf.length.toString(), + "cache-control": "no-store", + }); + res.end(buf); +} + +function hostHeaderAllowed( + hostHeader: string | undefined, + port: number, +): boolean { + if (!hostHeader) return false; + return ( + hostHeader === `127.0.0.1:${port}` || hostHeader === `localhost:${port}` + ); +} + +export async function startKioskServer( + opts: StartKioskServerOptions, +): Promise { + const host = opts.host ?? DEFAULT_HOST; + const requestedPort = opts.port ?? DEFAULT_PORT; + const tickMs = opts.tickIntervalMs ?? DEFAULT_TICK_MS; + const indexPath = opts.indexHtmlPath ?? (await resolveDefaultIndexHtmlPath()); + const indexHtml = await readFile(indexPath); + + let listeningPort = requestedPort; + const sseClients = new Set(); + let tickTimer: NodeJS.Timeout | null = null; + + async function writeTick(): Promise { + if (sseClients.size === 0) return; + const status = await opts.handlers.status(); + const frame = `event: status\ndata: ${JSON.stringify(status)}\n\n`; + for (const client of sseClients) { + client.write(frame); + } + } + + function ensureTicker(): void { + if (tickTimer !== null) return; + tickTimer = setInterval(() => { + void writeTick().catch(() => { + /* ignore */ + }); + }, tickMs); + tickTimer.unref(); + } + + function stopTicker(): void { + if (tickTimer !== null) { + clearInterval(tickTimer); + tickTimer = null; + } + } + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + void handle(req, res).catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + if (!res.headersSent) { + sendJson(res, 500, { ok: false, error: message }); + } else { + res.end(); + } + }); + }); + + async function handle( + req: IncomingMessage, + res: ServerResponse, + ): Promise { + if (!hostHeaderAllowed(req.headers.host, listeningPort)) { + sendJson(res, 403, { ok: false, error: "host header not allowed" }); + return; + } + + const url = req.url ?? "/"; + const method = req.method ?? "GET"; + + if (method === "GET" && (url === "/" || url === "/index.html")) { + sendText(res, 200, "text/html; charset=utf-8", indexHtml); + return; + } + + if (method === "GET" && url === "/api/status") { + sendJson(res, 200, { ...(await opts.handlers.status()) }); + return; + } + + if (method === "GET" && url === "/events") { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-store", + connection: "keep-alive", + }); + res.write(": connected\n\n"); + const initial = await opts.handlers.status(); + res.write(`event: status\ndata: ${JSON.stringify(initial)}\n\n`); + sseClients.add(res); + ensureTicker(); + req.on("close", () => { + sseClients.delete(res); + if (sseClients.size === 0) stopTicker(); + }); + return; + } + + if (method === "POST" && url === "/api/talk/start") { + sendJson(res, 200, { ...(await opts.handlers.talkStart()) }); + return; + } + + if (method === "POST" && url === "/api/talk/end") { + sendJson(res, 200, { ...(await opts.handlers.talkEnd()) }); + return; + } + + if (method === "POST" && url === "/api/interrupt") { + sendJson(res, 200, { ...(await opts.handlers.interrupt()) }); + return; + } + + sendJson(res, 404, { ok: false, error: "not found" }); + } + + await new Promise((resolvePromise, rejectPromise) => { + server.once("error", rejectPromise); + server.listen(requestedPort, host, () => { + const addr = server.address(); + if (addr && typeof addr === "object") { + listeningPort = addr.port; + } + server.off("error", rejectPromise); + resolvePromise(); + }); + }); + + return { + port: listeningPort, + stop() { + stopTicker(); + for (const client of sseClients) { + client.end(); + } + sseClients.clear(); + return new Promise((resolvePromise, rejectPromise) => { + server.close((err) => { + if (err) rejectPromise(err); + else resolvePromise(); + }); + }); + }, + }; +} diff --git a/tests/kiosk/handlers.test.ts b/tests/kiosk/handlers.test.ts new file mode 100644 index 0000000..c6fd018 --- /dev/null +++ b/tests/kiosk/handlers.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; +import { createKioskHandlers } from "../../src/kiosk/handlers.js"; + +describe("createKioskHandlers", () => { + it("starts in idle state with the idle status text", async () => { + const h = createKioskHandlers({ + startCapture: vi.fn(async () => {}), + stopCapture: vi.fn(async () => undefined), + cancelSpeech: vi.fn(async () => {}), + }); + const s = await h.status(); + expect(s).toEqual({ state: "idle", statusText: "Tap to talk" }); + }); + + it("talkStart moves idle → listening and invokes startCapture", async () => { + const startCapture = vi.fn(async () => {}); + const h = createKioskHandlers({ + startCapture, + stopCapture: vi.fn(async () => undefined), + cancelSpeech: vi.fn(async () => {}), + }); + const r = await h.talkStart(); + expect(r).toEqual({ ok: true }); + expect(startCapture).toHaveBeenCalledTimes(1); + expect((await h.status()).state).toBe("listening"); + }); + + it("talkStart rejects when not in idle", async () => { + const h = createKioskHandlers({ + startCapture: vi.fn(async () => {}), + stopCapture: vi.fn(async () => "hi"), + cancelSpeech: vi.fn(async () => {}), + }); + await h.talkStart(); + const r = await h.talkStart(); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/cannot start/); + }); + + it("talkEnd with transcript moves listening → thinking → speaking", async () => { + const stopCapture = vi.fn(async () => "hello world"); + const h = createKioskHandlers({ + startCapture: vi.fn(async () => {}), + stopCapture, + cancelSpeech: vi.fn(async () => {}), + }); + await h.talkStart(); + const r = await h.talkEnd(); + expect(r).toEqual({ ok: true, transcript: "hello world" }); + expect(stopCapture).toHaveBeenCalledTimes(1); + expect((await h.status()).state).toBe("speaking"); + }); + + it("talkEnd with no transcript returns to idle", async () => { + const h = createKioskHandlers({ + startCapture: vi.fn(async () => {}), + stopCapture: vi.fn(async () => undefined), + cancelSpeech: vi.fn(async () => {}), + }); + await h.talkStart(); + const r = await h.talkEnd(); + expect(r).toEqual({ ok: true }); + expect((await h.status()).state).toBe("idle"); + }); + + it("talkEnd rejects when not in listening", async () => { + const h = createKioskHandlers({ + startCapture: vi.fn(async () => {}), + stopCapture: vi.fn(async () => "x"), + cancelSpeech: vi.fn(async () => {}), + }); + const r = await h.talkEnd(); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/cannot end/); + }); + + it("talkStart failure resets state to idle and returns error", async () => { + const h = createKioskHandlers({ + startCapture: vi.fn(async () => { + throw new Error("no mic"); + }), + stopCapture: vi.fn(async () => undefined), + cancelSpeech: vi.fn(async () => {}), + }); + const r = await h.talkStart(); + expect(r.ok).toBe(false); + expect(r.error).toBe("no mic"); + expect((await h.status()).state).toBe("idle"); + }); + + it("talkEnd failure resets state to idle and returns error", async () => { + const h = createKioskHandlers({ + startCapture: vi.fn(async () => {}), + stopCapture: vi.fn(async () => { + throw new Error("stt exploded"); + }), + cancelSpeech: vi.fn(async () => {}), + }); + await h.talkStart(); + const r = await h.talkEnd(); + expect(r.ok).toBe(false); + expect(r.error).toBe("stt exploded"); + expect((await h.status()).state).toBe("idle"); + }); + + it("interrupt calls cancelSpeech and returns to idle", async () => { + const cancelSpeech = vi.fn(async () => {}); + const h = createKioskHandlers({ + startCapture: vi.fn(async () => {}), + stopCapture: vi.fn(async () => "hi"), + cancelSpeech, + }); + await h.talkStart(); + await h.talkEnd(); + expect((await h.status()).state).toBe("speaking"); + const r = await h.interrupt(); + expect(r).toEqual({ ok: true }); + expect(cancelSpeech).toHaveBeenCalledTimes(1); + expect((await h.status()).state).toBe("idle"); + }); + + it("interrupt propagates errors from cancelSpeech", async () => { + const h = createKioskHandlers({ + startCapture: vi.fn(async () => {}), + stopCapture: vi.fn(async () => undefined), + cancelSpeech: vi.fn(async () => { + throw new Error("tts stuck"); + }), + }); + const r = await h.interrupt(); + expect(r).toEqual({ ok: false, error: "tts stuck" }); + }); + + it("respects getState override for status reads", async () => { + const h = createKioskHandlers({ + getState: () => "thinking", + }); + const s = await h.status(); + expect(s).toEqual({ state: "thinking", statusText: "Thinking..." }); + }); +}); diff --git a/tests/kiosk/server.test.ts b/tests/kiosk/server.test.ts new file mode 100644 index 0000000..c00480d --- /dev/null +++ b/tests/kiosk/server.test.ts @@ -0,0 +1,166 @@ +import { request } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; +import { + startKioskServer, + type RunningKioskServer, +} from "../../src/kiosk/server.js"; +import type { KioskHandlers } from "../../src/kiosk/handlers.js"; + +function rawRequest( + port: number, + path: string, + headers: Record, + method = "GET", +): Promise<{ status: number; body: string }> { + return new Promise((resolvePromise, rejectPromise) => { + const req = request( + { host: "127.0.0.1", port, path, method, headers }, + (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk: string) => (body += chunk)); + res.on("end", () => + resolvePromise({ status: res.statusCode ?? 0, body }), + ); + }, + ); + req.on("error", rejectPromise); + req.end(); + }); +} + +function rawSse( + port: number, + path: string, +): Promise<{ status: number; contentType: string; firstChunk: string }> { + return new Promise((resolvePromise, rejectPromise) => { + const req = request( + { + host: "127.0.0.1", + port, + path, + method: "GET", + headers: { host: `127.0.0.1:${port}`, accept: "text/event-stream" }, + }, + (res) => { + const contentType = res.headers["content-type"] ?? ""; + res.setEncoding("utf8"); + let buf = ""; + res.on("data", (chunk: string) => { + buf += chunk; + if (buf.includes("event: status")) { + req.destroy(); + resolvePromise({ + status: res.statusCode ?? 0, + contentType: String(contentType), + firstChunk: buf, + }); + } + }); + }, + ); + req.on("error", (err) => { + if ((err as NodeJS.ErrnoException).code === "ECONNRESET") return; + rejectPromise(err); + }); + req.end(); + }); +} + +function fakeHandlers(): KioskHandlers { + return { + status: async () => ({ state: "idle", statusText: "Tap to talk" }), + talkStart: async () => ({ ok: true }), + talkEnd: async () => ({ ok: true, transcript: "hello" }), + interrupt: async () => ({ ok: true }), + }; +} + +let running: RunningKioskServer | null = null; + +afterEach(async () => { + if (running) { + await running.stop(); + running = null; + } +}); + +async function bootServer(): Promise { + running = await startKioskServer({ + handlers: fakeHandlers(), + port: 0, + tickIntervalMs: 50, + }); + return running; +} + +function baseUrl(s: RunningKioskServer): string { + return `http://127.0.0.1:${s.port}`; +} + +describe("startKioskServer", () => { + it("serves the kiosk HTML at /", async () => { + const s = await bootServer(); + const r = await fetch(`${baseUrl(s)}/`); + expect(r.status).toBe(200); + expect(r.headers.get("content-type")).toMatch(/text\/html/); + const body = await r.text(); + expect(body).toContain("Argent Lite Kiosk"); + expect(body).toContain("id=\"disc\""); + }); + + it("returns current status JSON from /api/status", async () => { + const s = await bootServer(); + const r = await fetch(`${baseUrl(s)}/api/status`); + expect(r.status).toBe(200); + const j = (await r.json()) as { state: string; statusText: string }; + expect(j).toEqual({ state: "idle", statusText: "Tap to talk" }); + }); + + it("dispatches POST /api/talk/start to the handlers", async () => { + const s = await bootServer(); + const r = await fetch(`${baseUrl(s)}/api/talk/start`, { method: "POST" }); + expect(r.status).toBe(200); + const j = (await r.json()) as { ok: boolean }; + expect(j).toEqual({ ok: true }); + }); + + it("dispatches POST /api/talk/end and returns transcript", async () => { + const s = await bootServer(); + const r = await fetch(`${baseUrl(s)}/api/talk/end`, { method: "POST" }); + expect(r.status).toBe(200); + const j = (await r.json()) as { ok: boolean; transcript?: string }; + expect(j).toEqual({ ok: true, transcript: "hello" }); + }); + + it("dispatches POST /api/interrupt to the handlers", async () => { + const s = await bootServer(); + const r = await fetch(`${baseUrl(s)}/api/interrupt`, { method: "POST" }); + expect(r.status).toBe(200); + const j = (await r.json()) as { ok: boolean }; + expect(j).toEqual({ ok: true }); + }); + + it("SSE /events streams a text/event-stream with an initial status frame", async () => { + const s = await bootServer(); + const r = await rawSse(s.port, "/events"); + expect(r.status).toBe(200); + expect(r.contentType).toMatch(/text\/event-stream/); + expect(r.firstChunk).toContain("event: status"); + expect(r.firstChunk).toContain("\"state\":\"idle\""); + }); + + it("rejects requests with a non-loopback host header (403)", async () => { + const s = await bootServer(); + const r = await rawRequest(s.port, "/api/status", { host: "evil.com" }); + expect(r.status).toBe(403); + const j = JSON.parse(r.body) as { ok: boolean }; + expect(j.ok).toBe(false); + }); + + it("404s unknown routes", async () => { + const s = await bootServer(); + const r = await fetch(`${baseUrl(s)}/api/nope`); + expect(r.status).toBe(404); + }); +});