buninu-linux 0.14.57 → 0.15.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSES/linux-firmware/LICENCE.nvidia +131 -0
  2. package/LICENSES/linux-firmware/LICENSE.amdgpu +51 -0
  3. package/LICENSES/linux-firmware/LICENSE.amdisp +31 -0
  4. package/LICENSES/linux-firmware/LICENSE.i915 +39 -0
  5. package/LICENSES/linux-firmware/LICENSE.radeon +51 -0
  6. package/LICENSES/linux-firmware/LICENSE.xe +39 -0
  7. package/LICENSES/linux-firmware/WHENCE +10280 -0
  8. package/NOTICE.md +64 -46
  9. package/README.md +371 -87
  10. package/README.zh.md +2034 -0
  11. package/build-image.sh +10 -1
  12. package/build-uki.sh +3 -1
  13. package/fetch-alpine.sh +14 -0
  14. package/graphics.md +31 -0
  15. package/index.js +28 -11
  16. package/initramfs/bin/buninu-browser +4 -1
  17. package/initramfs/bin/buninu-land +1 -1
  18. package/initramfs/bin/bunterm +3 -3
  19. package/initramfs/bin/chroot +13 -0
  20. package/initramfs/bin/chvt +11 -0
  21. package/initramfs/bin/mount +4 -0
  22. package/initramfs/bin/umount +6 -0
  23. package/initramfs/bin/xdg-open +33 -0
  24. package/initramfs/buninu/CHANGELOG.md +19 -0
  25. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +4 -0
  26. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  27. package/initramfs/buninu/apps/bunmsh/src/main.js +1 -1
  28. package/initramfs/buninu/apps/jsgotty/README.en.md +40 -0
  29. package/initramfs/buninu/apps/jsgotty/README.md +40 -0
  30. package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
  31. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  32. package/initramfs/buninu/apps/jsgotty/static/index.html +1 -0
  33. package/initramfs/buninu/apps/jsgotty/static/js/gotty.js +82 -17
  34. package/initramfs/buninu/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
  35. package/initramfs/buninu/apps/jsgotty/tests/android-alt.test.js +115 -0
  36. package/initramfs/buninu/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
  37. package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
  38. package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
  39. package/initramfs/buninu/apps/jsgotty/tests/manual/README.md +20 -0
  40. package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
  41. package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
  42. package/initramfs/buninu/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
  43. package/initramfs/buninu/package.json +1 -1
  44. package/initramfs/init.js +19 -3
  45. package/initramfs/lib/bunterm/clipboard.js +14 -0
  46. package/initramfs/lib/bunterm/commands.js +37 -0
  47. package/initramfs/lib/bunterm/fps.js +38 -0
  48. package/initramfs/lib/bunterm/images.js +9 -5
  49. package/initramfs/lib/bunterm/input.js +58 -15
  50. package/initramfs/lib/bunterm/keyboard.js +62 -0
  51. package/initramfs/lib/bunterm/keycodes.js +104 -0
  52. package/initramfs/lib/bunterm/kitty-keyboard.cjs +195 -0
  53. package/initramfs/lib/bunterm/raw-protocol.js +33 -0
  54. package/initramfs/lib/bunterm/renderer.js +89 -20
  55. package/initramfs/lib/bunterm/selection.js +60 -0
  56. package/initramfs/lib/bunterm/terminal.js +146 -11
  57. package/initramfs/lib/bunterm/vt-menu.js +181 -0
  58. package/initramfs/lib/bunterm/vt.js +27 -1
  59. package/initramfs/lib/canvas.js +99 -0
  60. package/initramfs/lib/chvt.js +31 -0
  61. package/initramfs/lib/fbdev.js +21 -3
  62. package/initramfs/lib/gpu.js +77 -0
  63. package/initramfs/lib/raw-image.js +47 -0
  64. package/initramfs/lib/xdg-open.js +42 -0
  65. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +85 -11
  66. package/initramfs/usr/share/doc/buninu-linux/chvt.md +34 -0
  67. package/initramfs/usr/share/doc/buninu-linux/xdg-open.md +43 -0
  68. package/package.json +1 -1
  69. package/scripts/arch.js +7 -5
  70. package/scripts/arch.sh +5 -5
  71. package/scripts/build.js +16 -3
  72. package/scripts/fetch-gpu.js +105 -0
  73. package/scripts/fetch.js +11 -1
  74. package/scripts/gpu-firmware.json +22 -0
  75. package/scripts/modules.js +4 -1
  76. package/scripts/npm-packages.md +17 -17
  77. package/test/bunterm-fps.test.js +17 -0
  78. package/test/bunterm-keyboard.test.js +116 -0
  79. package/test/bunterm-next-vt.test.js +9 -0
  80. package/test/bunterm-raw-protocol.test.js +19 -0
  81. package/test/bunterm-renderer.test.js +158 -0
  82. package/test/bunterm-selection.test.js +38 -0
  83. package/test/bunterm-vt-menu.test.js +178 -0
  84. package/test/chroot-bind.test.js +40 -2
  85. package/test/chvt.test.js +46 -0
  86. package/test/fbdev-sync.test.js +33 -0
  87. package/test/gpu.test.js +155 -0
  88. package/test/image-size.test.js +17 -0
  89. package/test/manual-bunterm-image-paths.js +31 -0
  90. package/test/mount-libc-lifetime.test.js +50 -0
  91. package/test/no-gpu.test.js +43 -0
  92. package/test/readme-cli.test.js +29 -0
  93. package/test/stage-npm.test.js +11 -4
  94. package/test/xdg-open.test.js +55 -0
@@ -1,3 +1,4 @@
1
+ import {bgraParser} from './raw-protocol.js';
1
2
  // /lib/bunterm/terminal.js — a graphical terminal on the Linux framebuffer.
2
3
  //
3
4
  // Bun.spawn({ terminal }) ──── PTY output ────▶ KittyGraphicsParser
@@ -13,17 +14,22 @@
13
14
  // keyboard are Buninu's own.
14
15
 
15
16
  import { createRequire } from "node:module";
16
- import { runGraphics } from "../fbdev.js";
17
+ import { runGraphics, activeConsole } from "../fbdev.js";
17
18
  import { loadCanvasKit, openCanvas, loadFontSet } from "../canvas.js";
18
19
  import { Terminal } from "../xterm/xterm-headless.mjs";
19
20
  import { UnicodeGraphemesAddon } from "../xterm/addon-unicode-graphemes.mjs";
20
21
  import { Renderer, defaultTheme } from "./renderer.js";
21
22
  import { ImageStore } from "./images.js";
22
23
  import { openKeyboard, translateKeys } from "./input.js";
23
- import { claimVirtualConsole } from "./vt.js";
24
+ import { claimVirtualConsole, listVirtualTerminals, nextVirtualTerminal } from "./vt.js";
25
+ import { createFpsMeter } from "./fps.js";
26
+ import { createVtMenu } from "./vt-menu.js";
24
27
  import { attachGeometry } from "./geometry.js";
25
28
  import { terminalMouseEvent } from "./pointer-report.js";
26
- import { attachClipboard } from "./clipboard.js";
29
+ import { attachClipboard, readClipboard, writeClipboard, pasteText } from "./clipboard.js";
30
+ import { createSelection } from "./selection.js";
31
+ import { createCommands } from "./commands.js";
32
+ import { attachKeyboard as createKeyboard } from "./keyboard.js";
27
33
 
28
34
  const require = createRequire(import.meta.url);
29
35
 
@@ -57,6 +63,7 @@ export const createSession = ({
57
63
  mouse = true,
58
64
  clipboard = true,
59
65
  env = {},
66
+ vtMenu = null,
60
67
  onError = (error) => console.error(`bunterm: ${error?.stack ?? error}`),
61
68
  }) => {
62
69
  const { BunPtyBackend, KittyGraphicsParser } = loadJsgotty();
@@ -70,7 +77,7 @@ export const createSession = ({
70
77
  term.loadAddon(new UnicodeGraphemesAddon());
71
78
  term.unicode.activeVersion = "15-graphemes";
72
79
  const images = new ImageStore({ CanvasKit, term, renderer });
73
- const kitty = new KittyGraphicsParser({ binaryData: true });
80
+ const kitty = new (bgraParser(KittyGraphicsParser,screen.display))({ binaryData: true });
74
81
 
75
82
  const backend = new BunPtyBackend({
76
83
  command,
@@ -92,6 +99,11 @@ export const createSession = ({
92
99
  // Only set when a pointer is attached; without one the renderer is called
93
100
  // exactly as before.
94
101
  let pointer = null;
102
+ let menu = null;
103
+ const fps = createFpsMeter();
104
+ let fpsTimer = null;
105
+ let keyboardProtocol = null;
106
+ let consoleKeyboard = null;
95
107
  const paint = () => {
96
108
  if (frameTimer) clearTimeout(frameTimer);
97
109
  frameTimer = null;
@@ -100,28 +112,76 @@ export const createSession = ({
100
112
  // cursorY counts from the buffer's base, so a scrolled-back viewport
101
113
  // moves the cursor down the screen and eventually off it.
102
114
  const row = buffer.baseY + buffer.cursorY - buffer.viewportY;
103
- return renderer.render(term, {
115
+ if (menu?.visible || selection.active) renderer.invalidate();
116
+ const visibleImages = images.visible();
117
+ const rendered = renderer.render(term, {
104
118
  cursor: {
105
119
  x: buffer.cursorX,
106
120
  y: row,
107
121
  visible: blinkOn && !cursorHidden() && row >= 0 && row < renderer.rows,
108
122
  },
109
- images: images.visible(),
123
+ images: visibleImages,
110
124
  pointer,
125
+ overlay: () => { selection.draw(); fps.draw(renderer); if (menu?.visible) menu.draw(renderer); },
111
126
  });
127
+ if (rendered && fps.enabled) fps.presented(visibleImages);
128
+ return rendered;
112
129
  };
113
130
  const requestFrame = () => {
114
131
  if (!active) return;
115
132
  frameTimer ??= setTimeout(paint, 16);
116
133
  };
134
+ const changed = () => { renderer.invalidate(); requestFrame(); };
135
+ const selection = createSelection(term, renderer, changed);
136
+ const setFontSize = size => {
137
+ renderer.setFontSize(size);
138
+ selection.clear();
139
+ term.resize(renderer.columns, renderer.rows);
140
+ backend.resize(renderer.columns, renderer.rows);
141
+ changed();
142
+ };
143
+ const copy = async () => {
144
+ if (!clipboard) throw new Error("Clipboard disabled (--no-clipboard)");
145
+ if (!selection.active) throw new Error("Drag to select text first (application mouse reporting must be off)");
146
+ await writeClipboard(selection.text());
147
+ };
148
+ const paste = async () => {
149
+ if (!clipboard) throw new Error("Clipboard disabled (--no-clipboard)");
150
+ const text = await readClipboard();
151
+ if (closed) return;
152
+ pasteText(term, text, value => backend.write(Buffer.from(value, "utf8")));
153
+ menu?.close();
154
+ term.scrollToBottom();
155
+ changed();
156
+ };
157
+ const toggleFps = () => {
158
+ if (fpsTimer) clearInterval(fpsTimer);
159
+ fpsTimer = null;
160
+ if (fps.toggle(images.visible())) fpsTimer = setInterval(() => {
161
+ fps.sample();
162
+ if (active) { renderer.invalidateRows(0,1); requestFrame(); }
163
+ },1000);
164
+ renderer.invalidateRows(0,1); requestFrame();
165
+ };
166
+ const commands = createCommands({fontSize:()=>renderer.fontSize,setFontSize,copy,paste,
167
+ switchVT: number => vtMenu?.select(number)});
168
+ if (vtMenu) menu = createVtMenu({
169
+ ...vtMenu, width: screen.width, height: screen.height, rowHeight: renderer.cellHeight,
170
+ changed,
171
+ controls: {fpsEnabled:()=>fps.enabled,toggleFps,fontSize:()=>renderer.fontSize,cellHeight:()=>renderer.cellHeight,execute:line=>commands.execute(line)},
172
+ });
117
173
  const suspend = () => {
174
+ keyboardProtocol?.blur();
175
+ selection.stop();
118
176
  active = false;
177
+ menu?.close();
119
178
  if (frameTimer) clearTimeout(frameTimer);
120
179
  frameTimer = null;
121
180
  };
122
181
  const resume = () => {
123
182
  if (active) return;
124
183
  active = true;
184
+ consoleKeyboard?.clearHeld();
125
185
  renderer.invalidate();
126
186
  paint();
127
187
  };
@@ -141,6 +201,9 @@ export const createSession = ({
141
201
  const { events } = kitty.consume(chunk);
142
202
  for (const event of events) {
143
203
  if (event.kind === "plain") {
204
+ // A selection is a snapshot of the displayed cells. Drop it before
205
+ // output scrolls/replaces those cells, rather than copying stale rows.
206
+ selection.clear();
144
207
  await writeTerminal(term, event.data);
145
208
  continue;
146
209
  }
@@ -169,6 +232,8 @@ export const createSession = ({
169
232
 
170
233
  // Keyboard bytes from the console, rewritten for the program's key mode.
171
234
  const input = (bytes) => {
235
+ if (!active) return;
236
+ if (menu?.input(bytes)) return;
172
237
  const applicationCursorKeys = Boolean(term._core?.coreService?.decPrivateModes?.applicationCursorKeys);
173
238
  const text = translateKeys(bytes.toString("latin1"), { applicationCursorKeys });
174
239
  backend.write(Buffer.from(text, "latin1"));
@@ -180,6 +245,31 @@ export const createSession = ({
180
245
  resetBlink();
181
246
  requestFrame();
182
247
  };
248
+ const attachKeyboard = device => {
249
+ consoleKeyboard = device;
250
+ keyboardProtocol = createKeyboard({
251
+ term,
252
+ setEnhanced: value => device.setEnhanced(value),
253
+ write: text => {
254
+ backend.write(Buffer.from(text,"utf8"));
255
+ term.scrollToBottom(); resetBlink(); changed();
256
+ },
257
+ localInput: (event, text) => {
258
+ if (!menu?.visible) return false;
259
+ if (text) menu.input(Buffer.from(text,"utf8"));
260
+ return true;
261
+ },
262
+ switchVT: number => { Promise.resolve(vtMenu?.select(number)).catch(onError); },
263
+ switchRelative: delta => {
264
+ try {
265
+ const number = Math.max(1,Math.min(63,activeConsole({fd:device.fd})+delta));
266
+ Promise.resolve(vtMenu?.select(number)).catch(onError);
267
+ } catch (error) { onError(error); }
268
+ },
269
+ onError,
270
+ });
271
+ };
272
+ const keyEvent = event => { if (active) keyboardProtocol?.event(event); };
183
273
 
184
274
  // Turns a pointer report from mouse.js into the mouse sequences the
185
275
  // program asked for. xterm.js's CoreMouseService owns the protocol: it
@@ -221,11 +311,18 @@ export const createSession = ({
221
311
  if (!active) return;
222
312
  pointer = { x: report.x, y: report.y };
223
313
  requestFrame();
314
+ if (menu?.pointer(report)) {
315
+ selection.stop();
316
+ keyboardProtocol?.blur();
317
+ return;
318
+ }
224
319
  const service = term._core?.coreMouseService;
225
320
  if (!service?.areMouseEventsActive) {
226
321
  if (report.wheel) wheelScroll(report.wheel);
322
+ selection.pointer(report);
227
323
  return;
228
324
  }
325
+ selection.clear();
229
326
  const send = (button, action) => {
230
327
  try {
231
328
  service.triggerMouseEvent(terminalMouseEvent(report, renderer, button, action));
@@ -278,10 +375,15 @@ export const createSession = ({
278
375
  const close = () => {
279
376
  if (closed) return;
280
377
  closed = true;
378
+ active = false;
281
379
  if (blinkTimer) clearInterval(blinkTimer);
380
+ if (fpsTimer) clearInterval(fpsTimer);
381
+ fpsTimer = null;
282
382
  if (frameTimer) clearTimeout(frameTimer);
283
383
  frameTimer = null;
284
384
  backend.close();
385
+ try { keyboardProtocol?.dispose(); } catch (error) { onError(error); }
386
+ keyboardProtocol = null;
285
387
  pointerDevice?.close();
286
388
  pointerDevice = null;
287
389
  images.delete();
@@ -293,6 +395,7 @@ export const createSession = ({
293
395
  return {
294
396
  term, backend, renderer, images, kitty,
295
397
  paint, requestFrame, suspend, resume, settle, input, exited, close, attachPointer, reportMouse, wheelScroll,
398
+ attachKeyboard, keyEvent, selection, setFontSize, commands,
296
399
  };
297
400
  };
298
401
 
@@ -308,21 +411,52 @@ export const runTerminal = async ({ console: consoleDevice = null, device = "/de
308
411
 
309
412
  return runGraphics(async (display, { onRestore, onRelease, onAcquire }) => {
310
413
  const screen = openCanvas(CanvasKit, display);
311
- const session = createSession({ CanvasKit, screen, fonts, ...options });
414
+ let switching = false;
415
+ let keyboard = null;
416
+ const session = createSession({ CanvasKit, screen, fonts, ...options, vtMenu: {
417
+ list: () => {
418
+ const current = keyboard ? activeConsole(keyboard) : null;
419
+ return listVirtualTerminals().map(entry => ({...entry,current:entry.number===current}));
420
+ },
421
+ create: async fontSize => {
422
+ // Re-scan at activation, since another session may have opened while
423
+ // the menu was visible. The launcher detaches onto the new console;
424
+ // its normal VT ownership checks still apply.
425
+ const number = nextVirtualTerminal();
426
+ const child = Bun.spawn([process.execPath, `${import.meta.dir}/../../bin/bunterm`,
427
+ String(number), "-s", String(fontSize), "--fb", device],
428
+ { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
429
+ const [status, error] = await Promise.all([child.exited, new Response(child.stderr).text()]);
430
+ if (status !== 0) throw new Error(error.trim() || "無法啟動 / Failed to start bunterm");
431
+ },
432
+ select: async number => {
433
+ if (switching) return;
434
+ switching = true;
435
+ try {
436
+ // Waiting in this process would block its VT_PROCESS signal handlers.
437
+ const child = Bun.spawn([process.execPath, `${import.meta.dir}/../../bin/chvt`, String(number)],
438
+ { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
439
+ const [status, error] = await Promise.all([child.exited, new Response(child.stderr).text()]);
440
+ if (status !== 0) console.error(`bunterm: ${error.trim() || "VT switch failed"}`);
441
+ } catch (error) { console.error(`bunterm: VT switch: ${error.message}`); }
442
+ finally { switching = false; }
443
+ },
444
+ } });
312
445
  onRelease(session.suspend);
313
446
  // Coming back from another console (Ctrl-Alt-Fn): fbcon or another
314
447
  // bunterm has drawn over the framebuffer, so repaint everything.
315
448
  onAcquire(session.resume);
316
- let keyboard = null;
317
449
  try {
318
450
  keyboard = openKeyboard({
319
451
  device: consoleDevice,
320
452
  onData: session.input,
453
+ onKey: session.keyEvent,
321
454
  onError: (error) => {
322
455
  console.error(`bunterm: keyboard: ${error.message}`);
323
456
  session.backend.close();
324
457
  },
325
458
  });
459
+ session.attachKeyboard(keyboard);
326
460
  onRestore(() => keyboard.close());
327
461
  if (options.mouse !== false) {
328
462
  // A pointer that cannot be opened is reported and the session
@@ -338,9 +472,10 @@ export const runTerminal = async ({ console: consoleDevice = null, device = "/de
338
472
  }
339
473
  return await session.exited;
340
474
  } finally {
341
- keyboard?.close();
342
- session.close();
343
- screen.close();
475
+ try { session.close(); }
476
+ finally {
477
+ try { keyboard?.close(); } finally { screen.close(); }
478
+ }
344
479
  }
345
480
  }, { device, console: consolePaths, prepareConsole: claimVirtualConsole });
346
481
  };
@@ -0,0 +1,181 @@
1
+ // Local overlay; none of its input is sent to the PTY.
2
+ import { StringDecoder } from "node:string_decoder";
3
+
4
+ export function createVtMenu({ list, select, create = null, changed, width, height, rowHeight, controls = null }) {
5
+ let row = Math.max(24, Math.ceil(rowHeight + 8));
6
+ let capacity = Math.max(1, Math.floor(height / row) - 2);
7
+ const panelWidth = Math.min(width, 420);
8
+ let entries = null, selected = 0, offset = 0, swallow = false;
9
+ let command = [], caret = 0, editing = false, status = "", escape = "";
10
+ let decoder = new StringDecoder("utf8");
11
+ let creating = false;
12
+ const layout = () => {
13
+ row = Math.max(24, Math.ceil((controls?.cellHeight?.() ?? rowHeight) + 8));
14
+ capacity = Math.max(1, Math.floor(height / row) - 2);
15
+ if (selected >= offset + capacity) offset = selected - capacity + 1;
16
+ };
17
+ const close = () => { entries = null; editing = false; changed(); };
18
+ const move = delta => {
19
+ selected = Math.max(0, Math.min(entries.length - 1, selected + delta));
20
+ offset = Math.max(0, Math.min(offset, selected));
21
+ if (selected >= offset + capacity) offset = selected - capacity + 1;
22
+ changed();
23
+ };
24
+ const run = (line) => {
25
+ // Promise.resolve().then also catches synchronous callbacks.
26
+ Promise.resolve().then(() => controls.execute(line)).then(message => {
27
+ status = message || ""; changed();
28
+ }).catch(error => { status = error.message; changed(); });
29
+ };
30
+ const choose = (right = false) => {
31
+ const target = entries[selected];
32
+ if (target?.kind === "create") {
33
+ if (creating) return;
34
+ creating = true;
35
+ status = "啟動中 / Starting…"; changed();
36
+ Promise.resolve().then(() => create(controls.fontSize())).then(() => close())
37
+ .catch(error => { status = error.message; changed(); })
38
+ .finally(() => { creating = false; });
39
+ return;
40
+ }
41
+ if (target?.kind === "font") { run(`font ${right ? "+" : "-"}`); return; }
42
+ if (target?.kind === "clipboard") {
43
+ run(right ? "paste" : "copy");
44
+ return;
45
+ }
46
+ if (target?.kind === "command") { editing = true; changed(); return; }
47
+ close(); if (target) select(target.number);
48
+ };
49
+ return {
50
+ get visible() { return entries !== null; },
51
+ close,
52
+ pointer(report) {
53
+ layout();
54
+ if (swallow && !entries) {
55
+ if (!Object.values(report.buttons).some(Boolean)) swallow = false;
56
+ return true;
57
+ }
58
+ if (!entries) {
59
+ if (report.x < 0 || report.y < 0 || report.x >= 2 || report.y >= 2 || !report.pressed.includes("left")) return false;
60
+ entries = [...(controls ? [{kind:"font"}, {kind:"clipboard"}, {kind:"command"}] : []), ...list(),
61
+ ...(create && controls ? [{kind:"create",label:"+ 新增 bunterm / New bunterm"}] : [])];
62
+ selected = 0; offset = 0; swallow = true; status = ""; escape = "";
63
+ decoder = new StringDecoder("utf8"); changed(); return true;
64
+ }
65
+ swallow = Object.values(report.buttons).some(Boolean);
66
+ if (report.wheel) move(-Math.sign(report.wheel));
67
+ const index = Math.floor(report.y / row) - 1 + offset;
68
+ const inside = report.x >= 0 && report.x < panelWidth && report.y >= row && index >= offset && index < Math.min(entries.length, offset + capacity);
69
+ if (inside && report.moved && !editing) { selected = index; changed(); }
70
+ if (report.pressed.includes("left")) {
71
+ swallow = true;
72
+ if (controls?.toggleFps && report.y >= 0 && report.y < row &&
73
+ report.x >= Math.max(0,panelWidth-112) && report.x < panelWidth) {
74
+ controls.toggleFps(); changed(); return true;
75
+ }
76
+ if (inside) {
77
+ selected = index; editing = false;
78
+ if (entries[index].kind !== "font" || report.x < 64 || report.x >= panelWidth-64)
79
+ choose(report.x >= panelWidth/2);
80
+ else changed();
81
+ } else close();
82
+ } else if (report.pressed.includes("right")) { swallow = true; close(); }
83
+ return true;
84
+ },
85
+ input(bytes) {
86
+ if (!entries) return false;
87
+ layout();
88
+ const text = decoder.write(bytes);
89
+ if (text === "\x1b" && !escape || text === "\x03") { close(); return true; }
90
+ // Parse multiple keys in one console read, retaining incomplete CSI/SS3.
91
+ const input = escape + text; escape = "";
92
+ for (let i = 0; i < input.length;) {
93
+ let key = String.fromCodePoint(input.codePointAt(i));
94
+ if (key === "\x1b") {
95
+ const rest = input.slice(i);
96
+ const match = /^\x1b(?:\[[0-9;]*[A-Za-z~]|O[A-Za-z])/.exec(rest);
97
+ if (!match) { if (/^\x1b(?:\[[0-9;]*|O)?$/.test(rest)) escape = rest; break; }
98
+ key = match[0];
99
+ }
100
+ i += key.length;
101
+ if (key === "\x03") { close(); break; }
102
+ if (key === "\t" && controls) {
103
+ editing = !editing;
104
+ if (editing && controls) { selected = 2; move(0); }
105
+ } else if (editing) {
106
+ if (key === "\r" || key === "\n") { run(command.join("")); command = []; caret = 0; }
107
+ else if (key === "\x7f" || key === "\b") { if (caret) command.splice(--caret,1); }
108
+ else if (key === "\x1b[3~") command.splice(caret,1);
109
+ else if (["\x1b[D","\x1bOD"].includes(key)) caret = Math.max(0,caret-1);
110
+ else if (["\x1b[C","\x1bOC"].includes(key)) caret = Math.min(command.length,caret+1);
111
+ else if (["\x01","\x1b[H","\x1bOH"].includes(key)) caret = 0;
112
+ else if (["\x05","\x1b[F","\x1bOF"].includes(key)) caret = command.length;
113
+ else if (key === "\x15") { command = []; caret = 0; }
114
+ else if (key.codePointAt(0) >= 32 && !key.startsWith("\x1b") && command.length < 1024) command.splice(caret++,0,key);
115
+ } else if (["\x1b[A", "\x1bOA", "k"].includes(key)) move(-1);
116
+ else if (["\x1b[B", "\x1bOB", "j"].includes(key)) move(1);
117
+ else if (key === "\r" || key === "\n") { choose(); if (!entries) break; }
118
+ else if (controls && ["+","-","\x1b[C","\x1b[D"].includes(key) && entries[selected]?.kind === "font")
119
+ choose(key === "+" || key === "\x1b[C");
120
+ else if (controls && entries[selected]?.kind === "clipboard" && ["c","p","\x1b[C","\x1b[D"].includes(key))
121
+ choose(key === "p" || key === "\x1b[C");
122
+ }
123
+ changed();
124
+ return true;
125
+ },
126
+ draw(renderer) {
127
+ if (!entries) return;
128
+ layout();
129
+ const { canvas, CanvasKit } = renderer;
130
+ const visible = entries.slice(offset, offset + capacity);
131
+ const rect = (y, color) => canvas.drawRect(CanvasKit.XYWHRect(0, y, panelWidth, row), renderer.paintFor(color));
132
+ const text = (value, y, x = 8, span = panelWidth - 16, align = "left") => renderer.drawText(value, x, y + (row - renderer.cellHeight) / 2,
133
+ Math.max(1, Math.floor(span / renderer.cellWidth)), [240, 240, 240], {align});
134
+ canvas.save();
135
+ canvas.clipRect(CanvasKit.XYWHRect(0, 0, panelWidth, Math.min(height, (visible.length + 2) * row)), CanvasKit.ClipOp.Intersect, false);
136
+ rect(0, [35, 40, 48]);
137
+ const fpsWidth = controls?.toggleFps ? Math.min(panelWidth,112) : 0;
138
+ text(controls ? "bunterm" : "Switch terminal",0,8,Math.max(1,panelWidth-fpsWidth-16));
139
+ if (fpsWidth) {
140
+ canvas.drawRect(CanvasKit.XYWHRect(panelWidth-fpsWidth,2,fpsWidth-2,row-4),
141
+ renderer.paintFor(controls.fpsEnabled()?[35,100,110]:[55,65,75]));
142
+ text(controls.fpsEnabled() ? "[FPS ✓]" : "[FPS]",0,panelWidth-fpsWidth+4,fpsWidth-10,"center");
143
+ }
144
+ visible.forEach((entry, i) => {
145
+ const y = (i + 1) * row;
146
+ rect(y, i + offset === selected ? [45, 80, 125] : [25, 29, 35]);
147
+ if (entry.kind === "font") {
148
+ // Use equal, cell-aligned button widths. Centered text scales to
149
+ // fit at large font sizes instead of clipping the closing bracket.
150
+ const buttonWidth = Math.max(1,Math.floor(48/renderer.cellWidth))*renderer.cellWidth;
151
+ const labelStart = buttonWidth+16;
152
+ text("[-]",y,8,buttonWidth,"center");
153
+ text(`🔤 字體 Font ${controls.fontSize()}`,y,labelStart,Math.max(1,panelWidth-labelStart*2),"center");
154
+ text("[+]",y,Math.max(8,panelWidth-8-buttonWidth),buttonWidth,"center");
155
+ return;
156
+ }
157
+ if (entry.kind === "clipboard") {
158
+ text("📋 複製 Copy",y,8,panelWidth/2-16);
159
+ text("📥 貼上 Paste",y,panelWidth/2+8,panelWidth/2-16);
160
+ return;
161
+ }
162
+ if (entry.kind === "command") {
163
+ canvas.drawRect(CanvasKit.XYWHRect(4,y+2,panelWidth-8,row-4),renderer.paintFor(editing?[80,170,220]:[85,95,105]));
164
+ canvas.drawRect(CanvasKit.XYWHRect(5,y+3,panelWidth-10,row-6),renderer.paintFor([225,230,235]));
165
+ const placeholder = !command.length && !editing;
166
+ const label = placeholder ? "Click to type a command…"
167
+ : `${command.slice(Math.max(0,caret-24),caret).join("")}${editing?"│":""}${command.slice(caret).join("")}`;
168
+ renderer.drawText(label,10,y+(row-renderer.cellHeight)/2,
169
+ Math.max(1,Math.floor((panelWidth-20)/renderer.cellWidth)),
170
+ placeholder?[100,110,120]:[20,25,30],{align:"left"});
171
+ return;
172
+ }
173
+ const label = `${entry.label}${entry.current ? " (current)" : ""}`;
174
+ text(label, y);
175
+ });
176
+ const y = (visible.length + 1) * row;
177
+ rect(y, [35, 40, 48]); text(status || (controls ? "Tab: command · Enter · Esc" : "↑↓ / wheel · Enter · Esc"), y);
178
+ canvas.restore();
179
+ },
180
+ };
181
+ }
@@ -6,7 +6,7 @@
6
6
  // hold the console device open. Both facts live under /proc/<pid> and disappear
7
7
  // with the process; there is no persistent pid or lock file to become stale.
8
8
 
9
- import { readdirSync, readFileSync, statSync } from "node:fs";
9
+ import { readdirSync, readFileSync, readlinkSync, statSync } from "node:fs";
10
10
  import { libc, cString, check } from "../dlopen.js";
11
11
 
12
12
  const PR_SET_NAME = 15;
@@ -89,3 +89,29 @@ Switch between TTYs by Ctrl+Alt+F1-12
89
89
  try { setTaskName(previousName); } catch {}
90
90
  };
91
91
  };
92
+
93
+ // Discover live owners using the same task-name + device-fd contract as claim.
94
+ export const listVirtualTerminals = () => {
95
+ let initial = 1;
96
+ try {
97
+ initial = virtualConsoleNumber(readlinkSync("/proc/1/fd/0")) ?? 1;
98
+ } catch {}
99
+ const entries = new Map([[initial, { number: initial, label: `tty${initial} — initial console` }]]);
100
+ for (const pid of readdirSync("/proc").filter(name => /^\d+$/.test(name))) {
101
+ try {
102
+ const match = /^bunterm:tty([1-9][0-9]*)$/.exec(readFileSync(`/proc/${pid}/comm`, "utf8").trim());
103
+ if (!match) continue;
104
+ const number = Number(match[1]);
105
+ if (number > 63 || !processHoldsDevice(pid, statSync(`/dev/tty${number}`).rdev)) continue;
106
+ entries.set(number, { number, label: `tty${number} — bunterm${number === initial ? " / initial console" : ""}` });
107
+ } catch {} // processes can exit during the scan
108
+ }
109
+ return [...entries.values()].sort((a, b) => a.number - b.number);
110
+ };
111
+
112
+ // Match the menu: append after the last known session, reserving tty1.
113
+ export const nextVirtualTerminal = (entries = listVirtualTerminals()) => {
114
+ const number = Math.max(1, ...entries.map(entry => entry.number)) + 1;
115
+ if (number > 63) throw new Error("沒有可用 tty / No next tty available");
116
+ return number;
117
+ };
@@ -1,3 +1,4 @@
1
+ import {rawImageData, rawImageOpaque, skiaImage} from './raw-image.js';
1
2
  // /lib/canvas.js — CanvasKit (Skia in WebAssembly) on the Linux framebuffer.
2
3
  //
3
4
  // Skia draws into a buffer inside the wasm heap that already has the
@@ -80,6 +81,17 @@ export const openCanvas = (CanvasKit, display) => {
80
81
  memory.toTypedArray().set(previousPixels.subarray(0, length));
81
82
  display.pixels = memory.toTypedArray();
82
83
  let open = true;
84
+ let cachedImage = null, cachedOpaque = false;
85
+ let imageMemory = null, imageCapacity = 0;
86
+ const directRaw = (image, rect, source) => {
87
+ const raw = rawImageData(image);
88
+ if (!raw || display.bitsPerPixel !== 32 || raw.colorType !== colorType) return null;
89
+ const [left, top, right, bottom] = rect, [sx, sy, ex, ey] = source;
90
+ if (![...rect, ...source].every(Number.isInteger)
91
+ || right - left !== ex - sx || bottom - top !== ey - sy
92
+ || sx < 0 || sy < 0 || ex > raw.width || ey > raw.height || ex <= sx || ey <= sy) return null;
93
+ return rawImageOpaque(raw) ? raw : null;
94
+ };
83
95
  return {
84
96
  CanvasKit,
85
97
  display,
@@ -88,6 +100,90 @@ export const openCanvas = (CanvasKit, display) => {
88
100
  colorType,
89
101
  width: display.width,
90
102
  height: display.height,
103
+ // Only exact, opaque 32-bit copies bypass Skia. Keep at most one decoded
104
+ // image (32 MiB); scaled, translucent and 16-bit images use normal drawing.
105
+ canDrawRawImage(image, rect, source) { return Boolean(directRaw(image, rect, source)); },
106
+ drawOpaqueImage(image, rect, source, bands) {
107
+ if (!open) throw new Error("canvas is closed");
108
+ const [left, top, right, bottom] = rect;
109
+ const [sx, sy, ex, ey] = source;
110
+ const width = image.width(), height = image.height();
111
+ if (display.bitsPerPixel !== 32 || ![...rect, ...source].every(Number.isInteger)
112
+ || right - left !== ex - sx || bottom - top !== ey - sy
113
+ || sx < 0 || sy < 0 || ex > width || ey > height
114
+ || ex <= sx || ey <= sy || width * height * 4 > 32 * 1024 * 1024) return false;
115
+ const raw = directRaw(image, rect, source);
116
+ if (!raw) image = skiaImage(image);
117
+ if (!raw && cachedImage !== image) {
118
+ cachedImage = image;
119
+ cachedOpaque = false;
120
+ const size = width * height * 4;
121
+ if (size > imageCapacity) {
122
+ if (imageMemory) CanvasKit.Free(imageMemory);
123
+ imageMemory = CanvasKit.Malloc(Uint8Array, size);
124
+ imageCapacity = size;
125
+ }
126
+ // Supplying a destination bypasses CanvasKit's JS return-type switch,
127
+ // which otherwise rejects BGRA and copies supported formats out of WASM.
128
+ const info = {width, height, colorType,
129
+ alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB};
130
+ let pixels = image.readPixels(0, 0, info, imageMemory, width * 4);
131
+ let swap = false;
132
+ if (!pixels && colorType === CanvasKit.ColorType.BGRA_8888) {
133
+ pixels = image.readPixels(0, 0, {...info, colorType: CanvasKit.ColorType.RGBA_8888}, imageMemory, width * 4);
134
+ swap = true;
135
+ }
136
+ if (pixels) {
137
+ cachedOpaque = true;
138
+ for (let i = 0; i < size; i += 4) {
139
+ if (pixels[i + 3] !== 255) { cachedOpaque = false; break; }
140
+ if (swap) {
141
+ const red = pixels[i]; pixels[i] = pixels[i + 2]; pixels[i + 2] = red;
142
+ }
143
+ }
144
+ }
145
+ }
146
+ if (!raw && !cachedOpaque) return false;
147
+ surface.flush();
148
+ // Drawing elsewhere can grow the WASM heap between cache hits.
149
+ const sourcePixels = raw ? raw.pixels : imageMemory.toTypedArray();
150
+ const target = memory.toTypedArray();
151
+ const x0 = Math.max(0, left), x1 = Math.min(display.width, right);
152
+ if (x1 <= x0) return true;
153
+ for (const [bandTop, bandBottom] of bands) {
154
+ const y0 = Math.max(0, top, bandTop), y1 = Math.min(display.height, bottom, bandBottom);
155
+ for (let y = y0; y < y1; y++) {
156
+ const offset = ((sy + y - top) * width + sx + x0 - left) * 4;
157
+ target.set(sourcePixels.subarray(offset, offset + (x1 - x0) * 4), y * display.stride + x0 * 4);
158
+ }
159
+ }
160
+ return true;
161
+ },
162
+ // Save/restore small overlays in native pixel format, without decoding or
163
+ // channel conversion. Refresh the WASM view after every possible allocation.
164
+ saveRegion(left, top, right, bottom) {
165
+ if (!open) throw new Error("canvas is closed");
166
+ surface.flush();
167
+ const x = Math.max(0, Math.min(display.width, Math.floor(left))), y = Math.max(0, Math.min(display.height, Math.floor(top)));
168
+ const width = Math.max(0, Math.min(display.width, Math.ceil(right)) - x);
169
+ const height = Math.max(0, Math.min(display.height, Math.ceil(bottom)) - y);
170
+ const rowBytes = width * (display.bitsPerPixel / 8);
171
+ const pixels = new Uint8Array(rowBytes * height), source = memory.toTypedArray();
172
+ for (let row = 0; row < height; row++) {
173
+ const offset = (y + row) * display.stride + x * (display.bitsPerPixel / 8);
174
+ pixels.set(source.subarray(offset, offset + rowBytes), row * rowBytes);
175
+ }
176
+ return {x, y, height, rowBytes, pixels};
177
+ },
178
+ restoreRegion(region) {
179
+ if (!open) throw new Error("canvas is closed");
180
+ surface.flush();
181
+ const target = memory.toTypedArray();
182
+ for (let row = 0; row < region.height; row++) {
183
+ target.set(region.pixels.subarray(row * region.rowBytes, (row + 1) * region.rowBytes),
184
+ (region.y + row) * display.stride + region.x * (display.bitsPerPixel / 8));
185
+ }
186
+ },
91
187
  // Finishes pending Skia work and copies the frame to the framebuffer.
92
188
  // toTypedArray() is re-read because wasm heap growth detaches old views.
93
189
  flush() {
@@ -101,6 +197,9 @@ export const openCanvas = (CanvasKit, display) => {
101
197
  close() {
102
198
  if (!open) return;
103
199
  open = false;
200
+ cachedImage = null; cachedOpaque = false;
201
+ if (imageMemory) CanvasKit.Free(imageMemory);
202
+ imageMemory = null; imageCapacity = 0;
104
203
  previousPixels.set(memory.toTypedArray().subarray(0, previousPixels.length));
105
204
  display.pixels = previousPixels;
106
205
  surface.delete();
@@ -0,0 +1,31 @@
1
+ // Use the same VT_ACTIVATE/VT_WAITACTIVE path as bunterm. Never take VT ownership.
2
+ export function parseChvt(args) {
3
+ if (args.length === 1 && ["-h", "--help"].includes(args[0])) return { help: true };
4
+ if (args.length !== 1 || !/^[0-9]+$/.test(args[0]) || Number(args[0]) < 1 || Number(args[0]) > 63)
5
+ throw new Error("usage: chvt N (1–63); see chvt -h or --help");
6
+ return { number: Number(args[0]) };
7
+ }
8
+
9
+ export async function switchVt(number, backend) {
10
+ if (!Number.isInteger(number) || number < 1 || number > 63) throw new Error("VT number must be 1–63");
11
+ const { openConsole, activeConsole, activateConsole } = backend ?? await import("./fbdev.js");
12
+ const failures = [];
13
+ let consoleDevice;
14
+ // /dev/tty in bunterm is a PTY, and /dev/console may be a serial console.
15
+ // Probe VT_GETSTATE before accepting an open fd.
16
+ for (const path of ["/dev/tty0", "/dev/tty", "/dev/console"]) {
17
+ let candidate;
18
+ try {
19
+ candidate = openConsole([path]);
20
+ activeConsole(candidate);
21
+ consoleDevice = candidate;
22
+ break;
23
+ } catch (error) {
24
+ candidate?.close();
25
+ failures.push(`${path}: ${error.message}`);
26
+ }
27
+ }
28
+ if (!consoleDevice) throw new Error(`no accessible Linux VT (${failures.join("; ")})`);
29
+ try { activateConsole(consoleDevice, number); }
30
+ finally { consoleDevice.close(); }
31
+ }