mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add typed terminal session client
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
parent
48b711cc6a
commit
bb7bfba148
@@ -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, []);
|
||||
});
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
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<TerminalMessage, { type: "frame" }>) => void,
|
||||
onFrame: (delivery: TerminalDelivery) => void,
|
||||
): Promise<TerminalConnection> {
|
||||
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<TerminalMessage>(deliver);
|
||||
const response = await invoke<AttachResponse>("terminal_attach", {
|
||||
request,
|
||||
onFrame: channel,
|
||||
});
|
||||
connection = new TerminalConnection(response);
|
||||
for (const message of pending) deliver(message);
|
||||
return connection;
|
||||
}
|
||||
|
||||
input(data: string): Promise<void> {
|
||||
return invoke("terminal_input", { sessionId: this.sessionId, data });
|
||||
}
|
||||
|
||||
async resize(
|
||||
columns: number,
|
||||
rows: number,
|
||||
pixelWidth: number,
|
||||
pixelHeight: number,
|
||||
): Promise<TerminalViewport> {
|
||||
return invoke("terminal_resize", {
|
||||
sessionId: this.sessionId,
|
||||
columns,
|
||||
rows,
|
||||
pixelWidth,
|
||||
pixelHeight,
|
||||
});
|
||||
}
|
||||
|
||||
viewportReady(viewport: TerminalViewport): Promise<void> {
|
||||
return invoke("terminal_viewport_ready", {
|
||||
sessionId: this.sessionId,
|
||||
subscriptionId: this.subscriptionId,
|
||||
viewport,
|
||||
});
|
||||
}
|
||||
|
||||
focus(focused: boolean): Promise<void> {
|
||||
return invoke("terminal_focus", { sessionId: this.sessionId, focused });
|
||||
}
|
||||
|
||||
detach(): Promise<void> {
|
||||
return invoke("terminal_detach", {
|
||||
sessionId: this.sessionId,
|
||||
subscriptionId: this.subscriptionId,
|
||||
});
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return invoke("terminal_close", { sessionId: this.sessionId });
|
||||
}
|
||||
|
||||
private acknowledge(sequence: number): Promise<void> {
|
||||
return invoke("terminal_ack", {
|
||||
sessionId: this.sessionId,
|
||||
subscriptionId: this.subscriptionId,
|
||||
sequence,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user