buninu-linux 0.13.56 → 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 (109) 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 +410 -96
  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 +15 -1
  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/dhclient +8 -0
  22. package/initramfs/bin/mount +4 -0
  23. package/initramfs/bin/ping +8 -0
  24. package/initramfs/bin/umount +6 -0
  25. package/initramfs/bin/xdg-open +33 -0
  26. package/initramfs/buninu/CHANGELOG.md +19 -0
  27. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +4 -0
  28. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  29. package/initramfs/buninu/apps/bunmsh/src/main.js +1 -1
  30. package/initramfs/buninu/apps/jsgotty/README.en.md +40 -0
  31. package/initramfs/buninu/apps/jsgotty/README.md +40 -0
  32. package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
  33. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  34. package/initramfs/buninu/apps/jsgotty/static/index.html +1 -0
  35. package/initramfs/buninu/apps/jsgotty/static/js/gotty.js +82 -17
  36. package/initramfs/buninu/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
  37. package/initramfs/buninu/apps/jsgotty/tests/android-alt.test.js +115 -0
  38. package/initramfs/buninu/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
  39. package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
  40. package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
  41. package/initramfs/buninu/apps/jsgotty/tests/manual/README.md +20 -0
  42. package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
  43. package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
  44. package/initramfs/buninu/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
  45. package/initramfs/buninu/package.json +1 -1
  46. package/initramfs/init.js +27 -10
  47. package/initramfs/lib/bunterm/clipboard.js +14 -0
  48. package/initramfs/lib/bunterm/commands.js +37 -0
  49. package/initramfs/lib/bunterm/fps.js +38 -0
  50. package/initramfs/lib/bunterm/images.js +9 -5
  51. package/initramfs/lib/bunterm/input.js +58 -15
  52. package/initramfs/lib/bunterm/keyboard.js +62 -0
  53. package/initramfs/lib/bunterm/keycodes.js +104 -0
  54. package/initramfs/lib/bunterm/kitty-keyboard.cjs +195 -0
  55. package/initramfs/lib/bunterm/raw-protocol.js +33 -0
  56. package/initramfs/lib/bunterm/renderer.js +90 -21
  57. package/initramfs/lib/bunterm/selection.js +60 -0
  58. package/initramfs/lib/bunterm/terminal.js +147 -12
  59. package/initramfs/lib/bunterm/vt-menu.js +181 -0
  60. package/initramfs/lib/bunterm/vt.js +27 -1
  61. package/initramfs/lib/canvas.js +99 -0
  62. package/initramfs/lib/chvt.js +31 -0
  63. package/initramfs/lib/dhclient.js +135 -0
  64. package/initramfs/lib/dhcp-start.js +85 -0
  65. package/initramfs/lib/dhcp-transport.js +44 -0
  66. package/initramfs/lib/dhcp.js +124 -0
  67. package/initramfs/lib/dlopen.js +58 -11
  68. package/initramfs/lib/fbdev.js +21 -3
  69. package/initramfs/lib/gpu.js +77 -0
  70. package/initramfs/lib/ping.js +110 -0
  71. package/initramfs/lib/raw-image.js +47 -0
  72. package/initramfs/lib/xdg-open.js +42 -0
  73. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +86 -12
  74. package/initramfs/usr/share/doc/buninu-linux/chvt.md +34 -0
  75. package/initramfs/usr/share/doc/buninu-linux/dhclient.md +82 -0
  76. package/initramfs/usr/share/doc/buninu-linux/ping.md +91 -0
  77. package/initramfs/usr/share/doc/buninu-linux/xdg-open.md +43 -0
  78. package/package.json +2 -2
  79. package/scripts/arch.js +7 -5
  80. package/scripts/arch.sh +5 -5
  81. package/scripts/build.js +16 -3
  82. package/scripts/fetch-gpu.js +105 -0
  83. package/scripts/fetch.js +11 -1
  84. package/scripts/gpu-firmware.json +22 -0
  85. package/scripts/modules.js +5 -1
  86. package/scripts/npm-packages.md +17 -17
  87. package/test/bunterm-fps.test.js +17 -0
  88. package/test/bunterm-keyboard.test.js +116 -0
  89. package/test/bunterm-next-vt.test.js +9 -0
  90. package/test/bunterm-raw-protocol.test.js +19 -0
  91. package/test/bunterm-renderer.test.js +158 -0
  92. package/test/bunterm-selection.test.js +38 -0
  93. package/test/bunterm-vt-menu.test.js +178 -0
  94. package/test/chroot-bind.test.js +40 -2
  95. package/test/chvt.test.js +46 -0
  96. package/test/dhcp-start.test.js +67 -0
  97. package/test/dhcp.test.js +99 -0
  98. package/test/fbdev-sync.test.js +33 -0
  99. package/test/fetch-npm.test.js +1 -1
  100. package/test/gpu.test.js +155 -0
  101. package/test/image-size.test.js +17 -0
  102. package/test/libc.test.js +41 -0
  103. package/test/manual-bunterm-image-paths.js +31 -0
  104. package/test/mount-libc-lifetime.test.js +50 -0
  105. package/test/no-gpu.test.js +43 -0
  106. package/test/ping.test.js +80 -0
  107. package/test/readme-cli.test.js +29 -0
  108. package/test/stage-npm.test.js +11 -4
  109. package/test/xdg-open.test.js +55 -0
@@ -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
+ }
@@ -0,0 +1,135 @@
1
+ import {readFileSync,writeFileSync,mkdirSync,renameSync,unlinkSync,openSync,closeSync} from 'node:fs';
2
+ import {dirname,resolve} from 'node:path';
3
+ import {DhcpClient} from './dhcp.js';
4
+ import {interfaceInfo,openTransport} from './dhcp-transport.js';
5
+ export function parseDhclient(args) {
6
+ const o={timeout:60,dns:true,resolv:'/etc/resolv.conf',once:true,release:false,verbose:false};
7
+ for(let i=0;i<args.length;i++){
8
+ const a=args[i];
9
+ if(['--help','-h','-help'].includes(a))return {...o,help:true};
10
+ if(a==='-4'||a==='-d')continue;
11
+ if(a==='-1')o.once=true;
12
+ else if(a==='-r')o.release=true;
13
+ else if(a==='-v')o.verbose=true;
14
+ else if(a==='-q')o.quiet=true;
15
+ else if(a==='--no-dns')o.dns=false;
16
+ else if(['-timeout','-lf','-H','--resolv-conf','--metric'].includes(a)){
17
+ const value=args[++i];if(!value||value.startsWith('-'))throw new Error(`${a} requires a value`);
18
+ if(a==='-timeout'){o.timeout=Number(value);if(!Number.isFinite(o.timeout)||o.timeout<=0)throw new Error('timeout must be positive');}
19
+ if(a==='--metric'){o.metric=Number(value);if(!Number.isInteger(o.metric)||o.metric<0||o.metric>32766)throw new Error('metric must be between 0 and 32766');}
20
+ if(a==='-lf')o.leaseFile=resolve(value);
21
+ if(a==='-H'){if(!/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(value))throw new Error('invalid hostname');o.hostname=value;}
22
+ if(a==='--resolv-conf')o.resolv=resolve(value);
23
+ }else if(a.startsWith('-'))throw new Error(`unsupported option: ${a} (DHCPv4 only; see --help)`);
24
+ else if(o.interface)throw new Error('specify one interface per process');
25
+ else o.interface=a;
26
+ }
27
+ if(!o.interface||!/^[a-zA-Z0-9_.:-]{1,15}$/.test(o.interface))throw new Error('specify an Ethernet interface, e.g. dhclient usb0. Use ip link to list network interfaces; use dhclient -h or dhclient --help for help.');
28
+ o.leaseFile??=`/var/lib/dhclient/${o.interface}.json`;
29
+ if(args.includes('-1')&&o.release)throw new Error('-1 and -r cannot be combined');
30
+ return o;
31
+ }
32
+ export async function runIp(args) {
33
+ const p=Bun.spawn([process.execPath,`${import.meta.dir}/../bin/ip`,...args],{stdout:'pipe',stderr:'pipe'});
34
+ const [status,out,err]=await Promise.all([p.exited,new Response(p.stdout).text(),new Response(p.stderr).text()]);
35
+ if(status)throw new Error(`ip ${args.join(' ')}: ${err.trim()||out.trim()}`);
36
+ return out;
37
+ }
38
+ const readOptional=path=>{try{return readFileSync(path,'utf8');}catch(e){if(e.code==='ENOENT')return null;throw e;}};
39
+ export function saveLease(path,lease) {
40
+ mkdirSync(dirname(path),{recursive:true});const tmp=`${path}.${process.pid}.tmp`;
41
+ writeFileSync(tmp,JSON.stringify(lease,null,2)+'\n',{mode:0o600});renameSync(tmp,path);
42
+ }
43
+ export class Configuration {
44
+ constructor(options,ip=runIp){this.options=options;this.ip=ip;}
45
+ metric(lease){return lease.metric===undefined?[]:['metric',String(lease.metric)];}
46
+ async apply(lease,previous) {
47
+ const o=this.options;
48
+ lease.metric=o.metric;
49
+ if(previous&&previous.routers[0]!==lease.routers[0])await this.removeRoute(previous);
50
+ await this.ip(['addr','replace',`${lease.address}/${lease.prefix}`,'brd','+','dev',o.interface]);
51
+ if(lease.routers[0])await this.ip(['route','replace','default','via',lease.routers[0],'dev',o.interface,...this.metric(lease)]);
52
+ if(previous?.resolver)lease.resolver=previous.resolver;
53
+ if(o.dns&&lease.dns.length){
54
+ const content=`# dhclient ${o.interface}\n`+lease.dns.map(ip=>`nameserver ${ip}\n`).join('');
55
+ const old=lease.resolver;
56
+ // Keep the original resolver across repeated invocations, unless another writer took over.
57
+ const current=readOptional(o.resolv);
58
+ lease.resolver={path:o.resolv,before:old?.path===o.resolv&&current===old.written?old.before:current,written:content};
59
+ writeFileSync(o.resolv,content);
60
+ }
61
+ }
62
+ async removeRoute(lease) {
63
+ if(!lease.routers[0])return;
64
+ try{await this.ip(['route','del','default','via',lease.routers[0],'dev',this.options.interface,...this.metric(lease)]);}
65
+ catch(e){if(!/No such process|No such device|Cannot find device/.test(e.message))throw e;}
66
+ }
67
+ async clear(lease) {
68
+ const errors=[];
69
+ try{await this.removeRoute(lease);}catch(e){errors.push(e);}
70
+ // ip addr del checks the current address before deleting it.
71
+ try{await this.ip(['addr','del',`${lease.address}/${lease.prefix}`,'dev',this.options.interface]);}
72
+ catch(e){if(!/Cannot assign requested address|Cannot find device/.test(e.message))errors.push(e);}
73
+ try{
74
+ const r=lease.resolver;
75
+ if(r&&readOptional(r.path)===r.written){if(r.before===null)unlinkSync(r.path);else writeFileSync(r.path,r.before);}
76
+ }catch(e){errors.push(e);}
77
+ if(errors.length)throw new AggregateError(errors,errors.map(e=>e.message).join('; '));
78
+ }
79
+ }
80
+ // A command invocation acquires once and exits. There is no renewal daemon.
81
+ export async function manageLease(client,config,{previous,signal,log=console.log,save=()=>{},forget=()=>{}}={}) {
82
+ let lease,kept=false;
83
+ try {
84
+ lease=await client.acquire();
85
+ if(!lease){if(signal?.aborted)return 0;throw new Error('no DHCP lease received before timeout');}
86
+ if(signal?.aborted)return 0;
87
+ await config.apply(lease,previous);await save(lease);
88
+ log(`bound to ${lease.address}/${lease.prefix}, lease ${lease.seconds}s; configured and exiting (no automatic renewal)`);
89
+ kept=true;return 0;
90
+ }finally{
91
+ if(lease&&!kept){
92
+ try{client.release(lease);}catch(e){log(`release: ${e.message}`);}
93
+ await config.clear(lease);await forget();
94
+ }
95
+ }
96
+ }
97
+ export async function runDhclient(o) {
98
+ if(!['linux','android'].includes(process.platform))throw new Error('dhclient requires Linux/Android networking');
99
+ // Serialize this interface even when callers choose different lease files.
100
+ const lock=`/run/dhclient-${o.interface}.lock`;mkdirSync(dirname(lock),{recursive:true});
101
+ let handle;
102
+ try{handle=openSync(lock,'wx',0o600);}catch(e){if(e.code==='EEXIST')throw new Error(`${lock} exists; stop the other client, or remove this lock if its process died`);throw e;}
103
+ writeFileSync(handle,`${process.pid}\n`);
104
+ let transport;
105
+ const controller=new AbortController(),stop=()=>controller.abort();
106
+ process.on('SIGINT',stop);process.on('SIGTERM',stop);
107
+ const log=message=>{if(!o.quiet)console.log(`dhclient: ${message}`);};
108
+ const forget=()=>{try{unlinkSync(o.leaseFile);}catch(e){if(e.code!=='ENOENT')throw e;}};
109
+ try{
110
+ const info=interfaceInfo(o.interface);
111
+ await runIp(['link','set','dev',o.interface,'up']);
112
+ try { transport=openTransport(o.interface,info); }
113
+ catch(error) {
114
+ if(error.errno!==97)throw error; // EAFNOSUPPORT: Alpine ships AF_PACKET as a module.
115
+ const {modprobe}=await import('./modprobe.js');
116
+ modprobe('af_packet');transport=openTransport(o.interface,info);
117
+ }
118
+ const client=new DhcpClient({transport,...info,...o,signal:controller.signal,log:o.verbose?log:()=>{}});
119
+ const config=new Configuration(o);
120
+ if(o.release){
121
+ const text=readOptional(o.leaseFile);if(!text)throw new Error(`no saved lease: ${o.leaseFile}`);
122
+ const lease=JSON.parse(text);if(lease.interface!==o.interface||lease.mac!==info.mac.toString('hex'))throw new Error('saved lease belongs to another interface');
123
+ try{client.release(lease);}finally{await config.clear(lease);forget();}log(`released ${lease.address}`);return 0;
124
+ }
125
+ let previous;
126
+ const saved=readOptional(o.leaseFile);
127
+ if(saved){
128
+ const candidate=JSON.parse(saved);
129
+ if(candidate.interface===o.interface&&candidate.mac===info.mac.toString('hex'))previous=candidate;
130
+ }
131
+ return await manageLease(client,config,{...o,previous,signal:controller.signal,log,save:lease=>saveLease(o.leaseFile,{...lease,interface:o.interface,mac:info.mac.toString('hex')}),forget});
132
+ }finally{
133
+ transport?.close();process.off('SIGINT',stop);process.off('SIGTERM',stop);closeSync(handle);unlinkSync(lock);
134
+ }
135
+ }
@@ -0,0 +1,85 @@
1
+ // Best-effort one-shot DHCP for cfg.net. Each interface owns its result and
2
+ // bounded output buffer; a failed link never changes another link's status.
3
+ export function createDhcpLauncher({spawn=(argv,options)=>Bun.spawn(argv,options),log=console.log,
4
+ acquisitionSeconds=20,watchdogMs=25000,killGraceMs=2000,outputGraceMs=250}={}) {
5
+ const active=new Map();
6
+ const messageOf=error=>String(error?.message??error);
7
+ const clean=text=>String(text).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g,'').replace(/[\x00-\x1f\x7f]/g,' ').trim();
8
+ const report=(name,message)=>{try{log(`[dhcp ${clean(name)}] ${clean(message)}`);}catch{}};
9
+ return function startDhcp(interfaces) {
10
+ const started=[];
11
+ try {
12
+ for(const [index,name] of interfaces.entries()) {
13
+ if(name==='lo'||active.has(name))continue;
14
+ let child,state;
15
+ try {
16
+ if(!/^[a-zA-Z0-9_.:-]{1,15}$/.test(name))throw new Error('invalid interface name');
17
+ const args=[process.execPath,`${import.meta.dir}/../bin/dhclient`,'-1','-timeout',String(acquisitionSeconds),'--metric',String(100+index)];
18
+ // Keep a single resolver owner; init already supplies fallback DNS.
19
+ if(index>0)args.push('--no-dns');
20
+ args.push(name);
21
+ child=spawn(args,{stdin:'ignore',stdout:'pipe',stderr:'pipe'});
22
+ state={child,done:false,timer:null,killTimer:null,timedOut:false,address:null,errors:[],notes:[],readers:[]};active.set(name,state);
23
+ const remember=(list,text)=>{list.push(clean(text).slice(0,512));if(list.length>4)list.shift();};
24
+ const kill=signal=>{try{child.kill(signal);}catch(e){remember(state.errors,`${signal}: ${messageOf(e)}`);}};
25
+ state.timer=setTimeout(()=>{
26
+ if(state.done)return;
27
+ state.timedOut=true;kill('SIGTERM');
28
+ state.killTimer=setTimeout(()=>{if(!state.done)kill('SIGKILL');},killGraceMs);
29
+ state.killTimer.unref?.();
30
+ },watchdogMs);state.timer.unref?.();
31
+ const line=(text,isError)=>{
32
+ text=clean(text);if(!text)return;
33
+ const bound=!isError&&/^dhclient: bound to ([^,\s]+)/.exec(text);
34
+ if(bound)state.address=bound[1];
35
+ else remember(isError?state.errors:state.notes,text.replace(/^dhclient:\s*/,''));
36
+ };
37
+ const drain=async(stream,isError)=>{
38
+ if(!stream)return;
39
+ const reader=stream.getReader();state.readers.push(reader);
40
+ const decoder=new TextDecoder();let pending='';
41
+ try {
42
+ for(;;){
43
+ const {done,value}=await reader.read();if(done)break;
44
+ pending+=decoder.decode(value,{stream:true});
45
+ let at;while((at=pending.indexOf('\n'))>=0){line(pending.slice(0,at),isError);pending=pending.slice(at+1);}
46
+ if(pending.length>4096){line(pending.slice(0,4096),isError);pending='';}
47
+ }
48
+ if(pending)line(pending,isError);
49
+ }finally{reader.releaseLock();}
50
+ };
51
+ const drains=[drain(child.stdout,false),drain(child.stderr,true)].map(p=>p.catch(e=>remember(state.errors,`output: ${messageOf(e)}`)));
52
+ const finish=async(code,error)=>{
53
+ state.done=true;clearTimeout(state.timer);clearTimeout(state.killTimer);
54
+ // exited can resolve before the last stderr/stdout chunk arrives.
55
+ // Bound the wait even if an inherited pipe never closes.
56
+ let outputTimer;
57
+ try {
58
+ await Promise.race([Promise.all(drains),new Promise(resolve=>{outputTimer=setTimeout(resolve,outputGraceMs);})]);
59
+ }finally{
60
+ clearTimeout(outputTimer);
61
+ for(const reader of state.readers){try{void reader.cancel().catch(()=>{});}catch{}}
62
+ if(active.get(name)===state)active.delete(name);
63
+ }
64
+ if(state.timedOut)report(name,`failed: timed out; other interfaces are unaffected`);
65
+ else if(error!==undefined)report(name,`failed: ${messageOf(error)}`);
66
+ else if(code===0&&state.address)report(name,`configured ${state.address}; command finished`);
67
+ else if(code===0)report(name,'finished without configuring an address');
68
+ else {
69
+ const detail=state.errors.at(-1)??state.notes.at(-1)??'no error details';
70
+ report(name,`failed: ${detail} (exit ${code}); other interfaces are unaffected`);
71
+ }
72
+ };
73
+ void Promise.resolve(child.exited).then(code=>finish(code),error=>finish(null,error??'unknown exit error')).catch(e=>report(name,`result unavailable: ${messageOf(e)}`));
74
+ started.push(name);report(name,'requesting configuration');
75
+ }catch(error){
76
+ if(state){clearTimeout(state.timer);clearTimeout(state.killTimer);active.delete(name);}
77
+ if(child){try{child.kill('SIGKILL');}catch{}}
78
+ report(name,`failed to start: ${messageOf(error)}; other interfaces are unaffected`);
79
+ }
80
+ }
81
+ }catch(error){report('setup',`failed: ${messageOf(error)}; init continues`);}
82
+ return started;
83
+ };
84
+ }
85
+ export const startDhcp=createDhcpLauncher();
@@ -0,0 +1,44 @@
1
+ import {libc,ptr,check,errno,SysError,cString,withInetSocket} from './dlopen.js';
2
+ import {ipv4,wrapIPv4,unwrapIPv4} from './dhcp.js';
3
+ export function interfaceInfo(name) {
4
+ if(!/^[a-zA-Z0-9_.:-]{1,15}$/.test(name))throw new Error('invalid interface name');
5
+ return withInetSocket(ioctl=>{
6
+ const req=Buffer.alloc(40);req.write(name);ioctl(0x8933,req,'SIOCGIFINDEX');const index=req.readInt32LE(16);
7
+ ioctl(0x8927,req,'SIOCGIFHWADDR');if(req.readUInt16LE(16)!==1)throw new Error('DHCP requires an Ethernet-compatible interface');
8
+ return {index,mac:Buffer.from(req.subarray(18,24))};
9
+ });
10
+ }
11
+ const sockaddr=(ip,port)=>{const b=Buffer.alloc(16);b.writeUInt16LE(2);b.writeUInt16BE(port,2);ipv4(ip).copy(b,4);return b;};
12
+ export function openTransport(name,{index}) {
13
+ const f=libc.symbols, descriptors=[];
14
+ try {
15
+ // Packet sockets work before the interface has any IPv4 address or route.
16
+ const raw=check('socket(AF_PACKET): requires CAP_NET_RAW',f.socket(17,2|0x800|0x80000,0x0008));descriptors.push(raw);
17
+ const link=Buffer.alloc(20);link.writeUInt16LE(17);link.writeUInt16BE(0x0800,2);link.writeInt32LE(index,4);link[11]=6;link.fill(255,12,18);
18
+ check('bind(AF_PACKET)',f.bind(raw,ptr(link),link.length));
19
+ // Reserve the client UDP port and use kernel routing/ARP for unicast renew
20
+ // and release. The packet socket receives both broadcast and unicast replies.
21
+ const udp=check('socket(UDP)',f.socket(2,2|0x800|0x80000,17));descriptors.push(udp);
22
+ const device=cString(name);check('SO_BINDTODEVICE',f.setsockopt(udp,1,25,ptr(device),device.length));
23
+ const local=sockaddr('0.0.0.0',68);check('bind UDP port 68 (another DHCP client may be running)',f.bind(udp,ptr(local),16));
24
+ const buffer=Buffer.alloc(65535);
25
+ return {
26
+ send(payload,server){
27
+ const destination=server?sockaddr(server,67):link;
28
+ const bytes=server?payload:wrapIPv4(payload,payload.subarray(12,16).join('.'));
29
+ check('sendto(DHCP)',Number(f.sendto(server?udp:raw,ptr(bytes),bytes.length,0,ptr(destination),destination.length)));
30
+ },
31
+ receive(){
32
+ // Drain the UDP copy so its receive buffer does not remain full.
33
+ for(let i=0;i<64;i++)if(f.recv(udp,ptr(buffer),buffer.length,0)<0)break;
34
+ for(let i=0;i<64;i++){
35
+ const n=f.recv(raw,ptr(buffer),buffer.length,0);
36
+ if(n<0){const code=errno();if(code===11||code===4)return null;throw new SysError('recv(DHCP)',code);}
37
+ const payload=unwrapIPv4(buffer.subarray(0,Number(n)));if(payload)return Buffer.from(payload);
38
+ }
39
+ return null;
40
+ },
41
+ close(){for(const fd of descriptors.splice(0))f.close(fd);},
42
+ };
43
+ }catch(error){for(const fd of descriptors)f.close(fd);throw error;}
44
+ }