From bb7bfba1487096648e9d0f2e5a6ae28e18fceddd Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sat, 1 Aug 2026 22:12:36 -0400 Subject: [PATCH] feat(desktop): add typed terminal session client Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- .../features/terminal/terminalClient.test.mjs | 114 ++++++++++++++ .../src/features/terminal/terminalClient.ts | 141 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 desktop/src/features/terminal/terminalClient.test.mjs create mode 100644 desktop/src/features/terminal/terminalClient.ts diff --git a/desktop/src/features/terminal/terminalClient.test.mjs b/desktop/src/features/terminal/terminalClient.test.mjs new file mode 100644 index 000000000..1d6298102 --- /dev/null +++ b/desktop/src/features/terminal/terminalClient.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +const callbacks = new Map(); +const calls = []; +let nextCallback = 1; +let attachDuringInvoke; + +globalThis.isTauri = true; +globalThis.window = { + __TAURI_INTERNALS__: { + invoke(command, args) { + calls.push({ command, args }); + if (command === "terminal_attach") attachDuringInvoke?.(args); + return Promise.resolve( + command === "terminal_attach" + ? { + sessionId: "session-1", + subscriptionId: "subscription-1", + viewport: { columns: 80, generation: 0, screenLines: 24 }, + } + : undefined, + ); + }, + transformCallback(callback) { + const id = nextCallback++; + callbacks.set(id, callback); + return id; + }, + unregisterCallback(id) { + callbacks.delete(id); + }, + }, + isTauri: true, +}; + +const { TerminalConnection } = await import("./terminalClient.ts"); + +function emit(channel, message, index = 0) { + const id = Number(channel.toJSON().slice("__CHANNEL__:".length)); + callbacks.get(id)({ index, message }); +} + +const request = { + channelId: "channel-1", + channelName: "general", + npub: "npub1test", + relayUrl: "wss://relay.example", + columns: 80, + rows: 24, + pixelWidth: 800, + pixelHeight: 408, +}; + +const frame = { + type: "frame", + payload: { + subscriptionId: "subscription-1", + sequence: 7, + rows: [], + cursor: { column: 0, line: 0, visible: true }, + full: true, + viewport: { columns: 80, generation: 0, screenLines: 24 }, + bracketedPaste: true, + focusReporting: true, + }, +}; + +afterEach(() => { + calls.length = 0; + attachDuringInvoke = undefined; +}); + +test("attach retains a bootstrap frame delivered before invoke resolves", async () => { + const deliveries = []; + attachDuringInvoke = ({ onFrame }) => emit(onFrame, frame); + const connection = await TerminalConnection.attach( + request, + () => {}, + (delivery) => deliveries.push(delivery), + ); + + assert.equal(connection.sessionId, "session-1"); + assert.equal(deliveries.length, 1); + assert.equal(deliveries[0].frame.sequence, 7); + await deliveries[0].acknowledge(); + assert.deepEqual(calls.at(-1), { + command: "terminal_ack", + args: { + sessionId: "session-1", + subscriptionId: "subscription-1", + sequence: 7, + }, + }); +}); + +test("stale subscription frames are ignored", async () => { + const deliveries = []; + let channel; + attachDuringInvoke = ({ onFrame }) => { + channel = onFrame; + }; + await TerminalConnection.attach( + request, + () => {}, + (value) => deliveries.push(value), + ); + emit( + channel, + { ...frame, payload: { ...frame.payload, subscriptionId: "stale" } }, + 0, + ); + assert.deepEqual(deliveries, []); +}); diff --git a/desktop/src/features/terminal/terminalClient.ts b/desktop/src/features/terminal/terminalClient.ts new file mode 100644 index 000000000..3b9dd1f70 --- /dev/null +++ b/desktop/src/features/terminal/terminalClient.ts @@ -0,0 +1,141 @@ +import { Channel, invoke, isTauri } from "@tauri-apps/api/core"; + +import type { TerminalFrame } from "./terminalRenderer"; + +export type TerminalAttachRequest = { + sessionId?: string; + channelId: string; + channelName: string; + threadId?: string; + npub: string; + relayUrl: string; + columns: number; + rows: number; + pixelWidth: number; + pixelHeight: number; +}; + +export type TerminalViewport = TerminalFrame["viewport"]; + +export type TerminalFrameMessage = TerminalFrame & { + subscriptionId: string; + sequence: number; + bracketedPaste: boolean; + focusReporting: boolean; +}; + +export type TerminalMessage = + | { type: "frame"; payload: TerminalFrameMessage } + | { type: "title"; payload: string } + | { type: "resetTitle"; payload?: never } + | { type: "bell"; payload?: never } + | { type: "exit"; payload?: never }; + +type AttachResponse = { + sessionId: string; + subscriptionId: string; + viewport: TerminalViewport; +}; + +export type TerminalDelivery = { + frame: TerminalFrameMessage; + acknowledge: () => Promise; +}; + +export class TerminalConnection { + readonly sessionId: string; + readonly subscriptionId: string; + readonly viewport: TerminalViewport; + + private constructor(response: AttachResponse) { + this.sessionId = response.sessionId; + this.subscriptionId = response.subscriptionId; + this.viewport = response.viewport; + } + + static async attach( + request: TerminalAttachRequest, + onMessage: (message: Exclude) => void, + onFrame: (delivery: TerminalDelivery) => void, + ): Promise { + if (!isTauri()) throw new Error("terminal sessions require Buzz Desktop"); + + let connection: TerminalConnection | null = null; + const pending: TerminalMessage[] = []; + const deliver = (message: TerminalMessage) => { + if (!connection) { + pending.push(message); + return; + } + if (message.type !== "frame") { + onMessage(message); + return; + } + const frame = message.payload; + if (frame.subscriptionId !== connection.subscriptionId) return; + onFrame({ + frame, + acknowledge: () => + connection?.acknowledge(frame.sequence) ?? Promise.resolve(), + }); + }; + const channel = new Channel(deliver); + const response = await invoke("terminal_attach", { + request, + onFrame: channel, + }); + connection = new TerminalConnection(response); + for (const message of pending) deliver(message); + return connection; + } + + input(data: string): Promise { + return invoke("terminal_input", { sessionId: this.sessionId, data }); + } + + async resize( + columns: number, + rows: number, + pixelWidth: number, + pixelHeight: number, + ): Promise { + return invoke("terminal_resize", { + sessionId: this.sessionId, + columns, + rows, + pixelWidth, + pixelHeight, + }); + } + + viewportReady(viewport: TerminalViewport): Promise { + return invoke("terminal_viewport_ready", { + sessionId: this.sessionId, + subscriptionId: this.subscriptionId, + viewport, + }); + } + + focus(focused: boolean): Promise { + return invoke("terminal_focus", { sessionId: this.sessionId, focused }); + } + + detach(): Promise { + return invoke("terminal_detach", { + sessionId: this.sessionId, + subscriptionId: this.subscriptionId, + }); + } + + close(): Promise { + return invoke("terminal_close", { sessionId: this.sessionId }); + } + + private acknowledge(sequence: number): Promise { + return invoke("terminal_ack", { + sessionId: this.sessionId, + subscriptionId: this.subscriptionId, + sequence, + }); + } +}