mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): harden terminal mounted boundaries
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
parent
56721b9698
commit
ef0decaef5
@@ -91,9 +91,11 @@
|
||||
"@tanstack/router-plugin": "^1.167.12",
|
||||
"@tanstack/virtual-file-routes": "^1.161.7",
|
||||
"@tauri-apps/cli": "~2.11",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"nostr-tools": "^2.23.3",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.3.0",
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, beforeEach, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
let act;
|
||||
let cleanup;
|
||||
let createElement;
|
||||
let fireEvent;
|
||||
let render;
|
||||
let waitFor;
|
||||
let ThemeProvider;
|
||||
let TerminalSubstrate;
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
before(async () => {
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
HTMLCanvasElement: dom.window.HTMLCanvasElement,
|
||||
KeyboardEvent: dom.window.KeyboardEvent,
|
||||
window: dom.window,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
});
|
||||
dom.window.matchMedia = () => ({
|
||||
matches: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
});
|
||||
dom.window.HTMLElement.prototype.animate = () => ({
|
||||
cancel() {},
|
||||
currentTime: 0,
|
||||
finished: new Promise(() => {}),
|
||||
play() {},
|
||||
playbackRate: 1,
|
||||
reverse() {},
|
||||
});
|
||||
({ act, cleanup, fireEvent, render, waitFor } = await import(
|
||||
"@testing-library/react"
|
||||
));
|
||||
({ createElement } = await import("react"));
|
||||
({ ThemeProvider } = await import("@/shared/theme/ThemeProvider"));
|
||||
({ TerminalSubstrate } = await import("./TerminalSubstrate.tsx"));
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
beforeEach(() => {
|
||||
cleanup?.();
|
||||
dom.window.localStorage.clear();
|
||||
dom.window.HTMLCanvasElement.prototype.getContext = () => ({
|
||||
fillRect() {},
|
||||
fillStyle: "",
|
||||
fillText() {},
|
||||
font: "",
|
||||
restore() {},
|
||||
save() {},
|
||||
setTransform() {},
|
||||
textBaseline: "",
|
||||
});
|
||||
});
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
const calls = { input: [], scroll: [] };
|
||||
const props = {
|
||||
bracketedPaste: false,
|
||||
channelName: "terminal-test",
|
||||
focusReportingEnabled: false,
|
||||
onCloseSession() {},
|
||||
onInput(value) {
|
||||
calls.input.push(value);
|
||||
},
|
||||
onNewSession() {},
|
||||
onScroll(lines) {
|
||||
calls.scroll.push(lines);
|
||||
},
|
||||
onSelectSession() {},
|
||||
onTerminalFocusChange() {},
|
||||
sessions: [{ active: true, closing: false, id: "one", title: "SHELL" }],
|
||||
...overrides,
|
||||
};
|
||||
const view = render(
|
||||
createElement(
|
||||
ThemeProvider,
|
||||
null,
|
||||
createElement("div", {
|
||||
className: "buzz-huddle-app-surface",
|
||||
tabIndex: -1,
|
||||
}),
|
||||
createElement(TerminalSubstrate, props),
|
||||
),
|
||||
);
|
||||
return { calls, props, view };
|
||||
}
|
||||
|
||||
async function ready(view) {
|
||||
await waitFor(() =>
|
||||
assert.ok(view.container.querySelector(".buzz-terminal-substrate")),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleChord(composing = false) {
|
||||
const init = {
|
||||
bubbles: true,
|
||||
code: "KeyJ",
|
||||
isComposing: composing,
|
||||
metaKey: true,
|
||||
};
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", init));
|
||||
window.dispatchEvent(new KeyboardEvent("keyup", init));
|
||||
});
|
||||
}
|
||||
|
||||
test("mounted IME paths neither toggle nor emit preedit text", async () => {
|
||||
const { calls, view } = fixture();
|
||||
await ready(view);
|
||||
const substrate = view.container.querySelector(".buzz-terminal-substrate");
|
||||
toggleChord(true);
|
||||
assert.equal(substrate.dataset.terminalOwner, "buzz");
|
||||
|
||||
toggleChord();
|
||||
await waitFor(() =>
|
||||
assert.equal(substrate.dataset.terminalOwner, "terminal"),
|
||||
);
|
||||
const input = view.getByLabelText("Terminal input");
|
||||
fireEvent.compositionStart(input);
|
||||
fireEvent.input(input, { target: { value: "k" } });
|
||||
fireEvent.input(input, { target: { value: "か" } });
|
||||
assert.deepEqual(calls.input, []);
|
||||
fireEvent.compositionEnd(input, { data: "か" });
|
||||
assert.deepEqual(calls.input, ["か"]);
|
||||
});
|
||||
|
||||
test("welcome survives non-intersecting frame and dismisses on input or intersecting damage", async () => {
|
||||
const baseFrame = {
|
||||
cursor: { column: 0, line: 0, visible: false },
|
||||
full: false,
|
||||
rows: [
|
||||
{
|
||||
line: 0,
|
||||
spans: [
|
||||
{
|
||||
style: { fg: 0, bg: 0, flags: 0 },
|
||||
clusters: [{ column: 0, text: "$", width: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
viewport: { columns: 80, generation: 1, screenLines: 24 },
|
||||
};
|
||||
const { view } = fixture({ frame: baseFrame });
|
||||
await ready(view);
|
||||
assert.ok(view.container.querySelector(".buzz-terminal-welcome"));
|
||||
toggleChord();
|
||||
const input = view.getByLabelText("Terminal input");
|
||||
fireEvent.input(input, { target: { value: "x" } });
|
||||
await waitFor(() =>
|
||||
assert.equal(view.container.querySelector(".buzz-terminal-welcome"), null),
|
||||
);
|
||||
|
||||
cleanup();
|
||||
const intersecting = fixture({
|
||||
frame: {
|
||||
...baseFrame,
|
||||
rows: [
|
||||
{
|
||||
line: 8,
|
||||
spans: [
|
||||
{
|
||||
style: { fg: 0, bg: 0, flags: 0 },
|
||||
clusters: [{ column: 0, text: "x", width: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await ready(intersecting.view);
|
||||
await waitFor(() =>
|
||||
assert.equal(
|
||||
intersecting.view.container.querySelector(".buzz-terminal-welcome"),
|
||||
null,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("mounted wheel path accumulates fractional lines per active session", async () => {
|
||||
const { calls, view } = fixture();
|
||||
await ready(view);
|
||||
const substrate = view.container.querySelector(".buzz-terminal-substrate");
|
||||
fireEvent.wheel(substrate, { deltaMode: 0, deltaY: 8 });
|
||||
assert.deepEqual(calls.scroll, []);
|
||||
fireEvent.wheel(substrate, { deltaMode: 0, deltaY: 10 });
|
||||
assert.deepEqual(calls.scroll, [1]);
|
||||
});
|
||||
|
||||
test("canvas failure atomically restores Buzz ownership", async () => {
|
||||
const { props, view } = fixture();
|
||||
await ready(view);
|
||||
const substrate = view.container.querySelector(".buzz-terminal-substrate");
|
||||
toggleChord();
|
||||
await waitFor(() =>
|
||||
assert.equal(substrate.dataset.terminalOwner, "terminal"),
|
||||
);
|
||||
dom.window.HTMLCanvasElement.prototype.getContext = () => null;
|
||||
view.rerender(
|
||||
createElement(
|
||||
ThemeProvider,
|
||||
null,
|
||||
createElement("div", {
|
||||
className: "buzz-huddle-app-surface",
|
||||
tabIndex: -1,
|
||||
}),
|
||||
createElement(TerminalSubstrate, {
|
||||
...props,
|
||||
frame: {
|
||||
cursor: { column: 0, line: 0, visible: false },
|
||||
full: false,
|
||||
rows: [],
|
||||
viewport: { columns: 80, generation: 1, screenLines: 24 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz"));
|
||||
toggleChord();
|
||||
await waitFor(() =>
|
||||
assert.equal(substrate.dataset.terminalOwner, "terminal"),
|
||||
);
|
||||
});
|
||||
@@ -5,9 +5,12 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { FadeController } from "./fadeController";
|
||||
import {
|
||||
INITIAL_HANDOFF_STATE,
|
||||
accumulateScrollLines,
|
||||
encodePaste,
|
||||
encodeTerminalKey,
|
||||
reduceHandoff,
|
||||
updateWelcomeForOutput,
|
||||
type WelcomeState,
|
||||
} from "./terminalState";
|
||||
import { type TerminalFrame, TerminalGrid } from "./terminalRenderer";
|
||||
|
||||
@@ -42,6 +45,42 @@ function isToggleChord(event: KeyboardEvent): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const CELL_HEIGHT = 17;
|
||||
const WELCOME_COLUMNS = 25;
|
||||
const WELCOME_LINES = 4;
|
||||
|
||||
function welcomeRect(frame: TerminalFrame) {
|
||||
const top = Math.max(
|
||||
0,
|
||||
Math.floor(frame.viewport.screenLines * 0.42 - WELCOME_LINES / 2),
|
||||
);
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.floor((frame.viewport.columns - WELCOME_COLUMNS) / 2),
|
||||
);
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
bottom: top + WELCOME_LINES,
|
||||
right: left + WELCOME_COLUMNS,
|
||||
};
|
||||
}
|
||||
|
||||
function frameDamageRects(frame: TerminalFrame) {
|
||||
return frame.rows
|
||||
.filter((row) =>
|
||||
row.spans.some((span) =>
|
||||
span.clusters.some((cluster) => cluster.text.trim().length > 0),
|
||||
),
|
||||
)
|
||||
.map((row) => ({
|
||||
top: row.line,
|
||||
left: 0,
|
||||
bottom: row.line + 1,
|
||||
right: frame.viewport.columns,
|
||||
}));
|
||||
}
|
||||
|
||||
export function TerminalSubstrate({
|
||||
appSurfaceRef,
|
||||
channelName,
|
||||
@@ -65,7 +104,13 @@ export function TerminalSubstrate({
|
||||
const paintedPaletteRef = React.useRef(terminalPalette);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
const reportedFocusRef = React.useRef<boolean | null>(null);
|
||||
const scrollBySessionRef = React.useRef(new Map<string, number>());
|
||||
const activeSession = sessions.find((session) => session.active);
|
||||
const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz");
|
||||
const [welcome, setWelcome] = React.useState<WelcomeState>({
|
||||
visible: true,
|
||||
reserved: null,
|
||||
});
|
||||
const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
? "⌘J"
|
||||
: "CTRL+J";
|
||||
@@ -103,6 +148,18 @@ export function TerminalSubstrate({
|
||||
setOwner(next);
|
||||
});
|
||||
|
||||
const forceBuzzFallback = React.useEffectEvent(() => {
|
||||
handoffRef.current = { ...INITIAL_HANDOFF_STATE };
|
||||
commitOwner("buzz");
|
||||
fadeRef.current?.settle("conceal");
|
||||
});
|
||||
|
||||
const sendInput = React.useEffectEvent((text: string) => {
|
||||
if (!text) return;
|
||||
setWelcome((current) => ({ ...current, visible: false }));
|
||||
onInput(text);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focusReportingEnabled) {
|
||||
reportedFocusRef.current = null;
|
||||
@@ -129,6 +186,12 @@ export function TerminalSubstrate({
|
||||
fadeRef.current = new FadeController(appSurface);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isToggleChord(event)) return;
|
||||
if (event.isComposing) {
|
||||
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||||
type: "focus-lost",
|
||||
}).state;
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const result = reduceHandoff(handoffRef.current, {
|
||||
@@ -173,6 +236,12 @@ export function TerminalSubstrate({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (frame) {
|
||||
setWelcome((current) =>
|
||||
updateWelcomeForOutput(
|
||||
{ ...current, reserved: welcomeRect(frame) },
|
||||
frameDamageRects(frame),
|
||||
),
|
||||
);
|
||||
const current = gridRef.current;
|
||||
if (!current) gridRef.current = new TerminalGrid(frame.viewport);
|
||||
else if (
|
||||
@@ -186,11 +255,14 @@ export function TerminalSubstrate({
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !terminalPalette) return;
|
||||
if (!canvas) return;
|
||||
if (!terminalPalette) {
|
||||
forceBuzzFallback();
|
||||
return;
|
||||
}
|
||||
const context = canvas.getContext("2d", { alpha: false });
|
||||
if (!context) {
|
||||
commitOwner("buzz");
|
||||
fadeRef.current?.settle("conceal");
|
||||
forceBuzzFallback();
|
||||
return;
|
||||
}
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
@@ -234,7 +306,21 @@ export function TerminalSubstrate({
|
||||
style={terminalStyle}
|
||||
onWheel={(event) => {
|
||||
event.preventDefault();
|
||||
onScroll(event.deltaY);
|
||||
const sessionId = activeSession?.id;
|
||||
if (!sessionId) return;
|
||||
const deltaPx =
|
||||
event.deltaMode === 1
|
||||
? event.deltaY * CELL_HEIGHT
|
||||
: event.deltaMode === 2
|
||||
? event.deltaY * event.currentTarget.clientHeight
|
||||
: event.deltaY;
|
||||
const result = accumulateScrollLines(
|
||||
{ remainderPx: scrollBySessionRef.current.get(sessionId) ?? 0 },
|
||||
deltaPx,
|
||||
CELL_HEIGHT,
|
||||
);
|
||||
scrollBySessionRef.current.set(sessionId, result.state.remainderPx);
|
||||
if (result.lines !== 0) onScroll(result.lines);
|
||||
}}
|
||||
>
|
||||
<div className="buzz-terminal-contract-bar">
|
||||
@@ -289,7 +375,7 @@ export function TerminalSubstrate({
|
||||
</div>
|
||||
<div className="buzz-terminal-viewport">
|
||||
<canvas ref={canvasRef} />
|
||||
{!frame ? (
|
||||
{welcome.visible ? (
|
||||
<div className="buzz-terminal-welcome" aria-hidden="true">
|
||||
<pre>{`┌─╲ BUZZ SUBSTRATE ╱─┐
|
||||
│ B U Z Z /\\_/\\ │
|
||||
@@ -302,10 +388,13 @@ export function TerminalSubstrate({
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
className="buzz-terminal-input"
|
||||
onCompositionEnd={() => {
|
||||
onCompositionEnd={(event) => {
|
||||
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||||
type: "composition-end",
|
||||
}).state;
|
||||
const committed = event.currentTarget.value;
|
||||
event.currentTarget.value = "";
|
||||
sendInput(committed);
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||||
@@ -313,10 +402,10 @@ export function TerminalSubstrate({
|
||||
}).state;
|
||||
}}
|
||||
onInput={(event) => {
|
||||
if (event.currentTarget.value) {
|
||||
onInput(event.currentTarget.value);
|
||||
event.currentTarget.value = "";
|
||||
}
|
||||
if (handoffRef.current.composing) return;
|
||||
const committed = event.currentTarget.value;
|
||||
event.currentTarget.value = "";
|
||||
sendInput(committed);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
@@ -327,12 +416,12 @@ export function TerminalSubstrate({
|
||||
const encoded = encodeTerminalKey(event);
|
||||
if (encoded) {
|
||||
event.preventDefault();
|
||||
onInput(encoded);
|
||||
sendInput(encoded);
|
||||
}
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
event.preventDefault();
|
||||
onInput(
|
||||
sendInput(
|
||||
encodePaste(
|
||||
event.clipboardData.getData("text/plain"),
|
||||
bracketedPaste,
|
||||
|
||||
Generated
+6
@@ -262,6 +262,9 @@ importers:
|
||||
'@tauri-apps/cli':
|
||||
specifier: ~2.11
|
||||
version: 2.11.2
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.2
|
||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@types/react':
|
||||
specifier: ^19.1.8
|
||||
version: 19.2.17
|
||||
@@ -271,6 +274,9 @@ importers:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.3(vite@8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))
|
||||
jsdom:
|
||||
specifier: ^27.4.0
|
||||
version: 27.4.0(@noble/hashes@2.2.0)
|
||||
nostr-tools:
|
||||
specifier: ^2.23.3
|
||||
version: 2.23.12(typescript@6.0.3)
|
||||
|
||||
Reference in New Issue
Block a user