buninu-linux 0.4.0 → 0.6.0

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 (42) hide show
  1. package/Dockerfile +23 -0
  2. package/NOTICE.md +111 -63
  3. package/README.md +292 -58
  4. package/build-image.sh +9 -5
  5. package/build-uki.sh +44 -28
  6. package/fetch-alpine.sh +45 -31
  7. package/fetch-bun.sh +14 -21
  8. package/graphics.md +7 -2
  9. package/index.js +150 -35
  10. package/initramfs/bin/bunterm +5 -1
  11. package/initramfs/bin/chroot +216 -0
  12. package/initramfs/bin/ip +3 -2
  13. package/initramfs/bin/ps +3 -0
  14. package/initramfs/bin/stripansi +27 -0
  15. package/initramfs/bin/tar +198 -14
  16. package/initramfs/init.js +149 -38
  17. package/initramfs/lib/bunterm/images.js +17 -2
  18. package/initramfs/lib/bunterm/mouse.js +222 -0
  19. package/initramfs/lib/bunterm/renderer.js +48 -3
  20. package/initramfs/lib/bunterm/terminal.js +123 -2
  21. package/initramfs/lib/dlopen.js +4 -0
  22. package/initramfs/lib/document.js +5 -1
  23. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +35 -2
  24. package/initramfs/usr/share/doc/buninu-linux/chroot.md +105 -0
  25. package/initramfs/usr/share/doc/buninu-linux/tar.md +32 -6
  26. package/native/aarch64/lib/ld-musl-aarch64.so.1 +0 -0
  27. package/native/aarch64/lib/libc.musl-aarch64.so.1 +0 -0
  28. package/native/aarch64/lib/libgcc_s.so.1 +0 -0
  29. package/native/aarch64/lib/libstdc++.so.6 +0 -0
  30. package/package.json +2 -2
  31. package/run-qemu.sh +61 -12
  32. package/scripts/arch.sh +113 -0
  33. package/scripts/fetch.sh +5 -3
  34. package/scripts/pack-initramfs.sh +18 -4
  35. package/test/host/Dockerfile +13 -0
  36. package/test/host/boot-test.exp +70 -0
  37. package/test/host/host-test.sh +49 -0
  38. package/test/host/in-container.sh +58 -0
  39. /package/{initramfs → native/x86_64}/lib/ld-musl-x86_64.so.1 +0 -0
  40. /package/{initramfs → native/x86_64}/lib/libc.musl-x86_64.so.1 +0 -0
  41. /package/{initramfs → native/x86_64}/lib/libgcc_s.so.1 +0 -0
  42. /package/{initramfs → native/x86_64}/lib/libstdc++.so.6 +0 -0
package/initramfs/init.js CHANGED
@@ -2,9 +2,19 @@ console.log("init.js: Bun runtime entered JavaScript");
2
2
 
3
3
  const { dlopen, FFIType, ptr, read } = await import("bun:ffi");
4
4
 
5
- console.log("init.js: loading physical musl libc for FFI");
6
-
7
- const libc = dlopen("/lib/libc.musl-x86_64.so.1", {
5
+ // The musl file name and the number of finit_module, which musl has no
6
+ // wrapper for, are all that differs per CPU in this file. /lib/dlopen.js has
7
+ // the same table for the commands; it is repeated rather than imported so
8
+ // that nothing but bun:ffi loads before the mounts below.
9
+ const architecture = {
10
+ x64: { libc: "libc.musl-x86_64.so.1", SYS_finit_module: 313 },
11
+ arm64: { libc: "libc.musl-aarch64.so.1", SYS_finit_module: 273 },
12
+ }[process.arch];
13
+ if (!architecture) throw new Error(`init.js: unsupported CPU architecture ${process.arch}`);
14
+
15
+ console.log(`init.js: loading physical musl libc /lib/${architecture.libc} for FFI`);
16
+
17
+ const libc = dlopen(`/lib/${architecture.libc}`, {
8
18
  mount: {
9
19
  args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.u64, FFIType.ptr],
10
20
  returns: FFIType.i32,
@@ -94,6 +104,81 @@ const mountFilesystems = () => {
94
104
  mountFilesystems();
95
105
  console.log("init.js: mounted /proc, /sys, /dev, /tmp, /dev/pts");
96
106
 
107
+ // Num Lock. The keypad's lock state lives per virtual console in the
108
+ // kernel's keyboard table, and the kernel starts every console with it off,
109
+ // so the keypad types arrows instead of digits. KDSKBLED carries the current
110
+ // flags in its low nibble and the ones a console is reset to in its high
111
+ // nibble; KDSETLED with a value above 7 puts the physical LED back to
112
+ // following those flags. A console allocated later (Ctrl-Alt-F3 for the
113
+ // first time) starts from the kernel default again, so numlock() is left in
114
+ // the REPL for that case.
115
+ const KDSETLED = 0x4b32;
116
+ const KDGKBLED = 0x4b64;
117
+ const KDSKBLED = 0x4b65;
118
+ const LED_NUM = 0x02;
119
+
120
+ // Nothing here is essential to booting, so a console that is not a virtual
121
+ // terminal, a missing device node or a rejected ioctl is skipped rather than
122
+ // reported: the remaining consoles are still set, and the system comes up
123
+ // either way.
124
+ const setNumLock = (enabled = true) => {
125
+ const consoles = [];
126
+ const paths = ["/dev/tty0", "/dev/console", "/dev/tty"];
127
+ for (let index = 1; index <= 12; index++) paths.push(`/dev/tty${index}`);
128
+ for (const path of paths) {
129
+ let fd = -1;
130
+ try {
131
+ fd = libc.symbols.open(cString(path), 2, 0);
132
+ if (fd < 0) continue;
133
+ const state = new Uint8Array(1);
134
+ if (libc.symbols.ioctl(fd, KDGKBLED, ptr(state)) !== 0) continue;
135
+ const current = enabled ? state[0] | LED_NUM : state[0] & ~LED_NUM;
136
+ const fallback = enabled ? (state[0] >> 4) | LED_NUM : (state[0] >> 4) & ~LED_NUM;
137
+ const flags = (current & 7) | ((fallback & 7) << 4);
138
+ if (libc.symbols.ioctl(fd, KDSKBLED, flags) !== 0) continue;
139
+ libc.symbols.ioctl(fd, KDSETLED, 0x80);
140
+ consoles.push(path);
141
+ } catch {
142
+ // This console keeps whatever state it had; the others still get set.
143
+ } finally {
144
+ if (fd >= 0) {
145
+ try { libc.symbols.close(fd); } catch {}
146
+ }
147
+ }
148
+ }
149
+ return consoles;
150
+ };
151
+
152
+ globalThis.numlock = setNumLock;
153
+ try {
154
+ const consoles = setNumLock(true);
155
+ console.log(consoles.length
156
+ ? `init.js: num lock on (${consoles.join(" ")})`
157
+ : "init.js: no virtual console accepted a num lock setting");
158
+ } catch (error) {
159
+ console.error(`init.js: num lock: ${error?.message ?? error}`);
160
+ }
161
+
162
+ // The input layer's character devices, so a pointing device has a
163
+ // /dev/input/event* node for `bunterm --mouse` to read. Loading them only
164
+ // creates those nodes: the console keyboard is unaffected, and nothing reads
165
+ // a pointer unless it is asked to. psmouse is the PS/2 mouse of a PC or q35;
166
+ // QEMU's aarch64 virt machine has virtio-input instead. Each image ships only
167
+ // the one its machine has, and tryModprobe skips the other. A USB mouse also
168
+ // needs the usbhid stack, which `cfg.all` or a --real boot brings in.
169
+ try {
170
+ const { tryModprobe } = await import("/lib/modprobe.js");
171
+ const loaded = ["evdev", "psmouse", "virtio_input"].filter((module) => tryModprobe(module) !== null);
172
+ const { readdirSync } = await import("node:fs");
173
+ const devices = (() => {
174
+ try { return readdirSync("/dev/input").filter((name) => name.startsWith("event")); } catch { return []; }
175
+ })();
176
+ console.log(`init.js: input modules ${loaded.length ? loaded.join(" ") : "none"}`
177
+ + ` (${devices.length} event device${devices.length === 1 ? "" : "s"})`);
178
+ } catch (error) {
179
+ console.error(`init.js: input modules: ${error?.message ?? error}`);
180
+ }
181
+
97
182
  const { default: repl } = await import("node:repl");
98
183
  const { mkdirSync, writeFileSync } = await import("node:fs");
99
184
 
@@ -117,7 +202,11 @@ const loadModule = (path) => {
117
202
  const parameters = cString("");
118
203
  // -EEXIST is harmless when firmware or another module loaded it first.
119
204
  // musl has no finit_module wrapper, so use its generic syscall(2).
120
- check(`finit_module(${path})`, libc.symbols.syscall(313, fd, ptr(parameters), 0), [-17]);
205
+ check(
206
+ `finit_module(${path})`,
207
+ libc.symbols.syscall(architecture.SYS_finit_module, fd, ptr(parameters), 0),
208
+ [-17],
209
+ );
121
210
  } finally {
122
211
  libc.symbols.close(fd);
123
212
  }
@@ -158,21 +247,14 @@ const bringUpLoopback = () => {
158
247
  }
159
248
  };
160
249
 
161
- const configureQemuNetwork = () => {
162
- const release = process.env.KERNEL_RELEASE ?? "6.18.52-0-virt";
163
- const modules = [
164
- `lib/modules/${release}/kernel/net/core/failover.ko`,
165
- `lib/modules/${release}/kernel/drivers/net/net_failover.ko`,
166
- `lib/modules/${release}/kernel/drivers/net/virtio_net.ko`,
167
- ];
168
- if (release.endsWith("-lts")) modules.unshift(
169
- `lib/modules/${release}/kernel/drivers/virtio/virtio_ring.ko`,
170
- `lib/modules/${release}/kernel/drivers/virtio/virtio.ko`,
171
- `lib/modules/${release}/kernel/drivers/virtio/virtio_pci_legacy_dev.ko`,
172
- `lib/modules/${release}/kernel/drivers/virtio/virtio_pci_modern_dev.ko`,
173
- `lib/modules/${release}/kernel/drivers/virtio/virtio_pci.ko`,
174
- );
175
- for (const module of modules) loadModule(`/${module}`);
250
+ // Which virtio pieces are modules differs per kernel flavor and CPU (linux-lts
251
+ // has virtio_pci as a module, linux-virt builds it in), so /lib/modprobe.js
252
+ // resolves them from the shipped modules.dep and modules.builtin: the PCI
253
+ // transport first, since nothing in modules.dep depends on it, then the NIC.
254
+ const configureQemuNetwork = async () => {
255
+ const { modprobe, tryModprobe } = await import("/lib/modprobe.js");
256
+ tryModprobe("virtio_pci");
257
+ modprobe("virtio_net");
176
258
 
177
259
  const fd = check("socket(AF_INET, SOCK_DGRAM)", libc.symbols.socket(2, 2, 0));
178
260
  const ioctl = (request, value, operation) =>
@@ -191,8 +273,9 @@ const configureQemuNetwork = () => {
191
273
 
192
274
  bringUp(ioctl, "eth0");
193
275
 
194
- // struct rtentry on Linux x86_64. Only gateway and flags are needed for
195
- // the default route; rt_dst and rt_genmask remain 0.0.0.0.
276
+ // struct rtentry on 64-bit Linux, the same on x86_64 and aarch64. Only
277
+ // gateway and flags are needed for the default route; rt_dst and
278
+ // rt_genmask remain 0.0.0.0.
196
279
  const route = new Uint8Array(120);
197
280
  const routeView = new DataView(route.buffer);
198
281
  writeSockaddrIpv4(route, 8, "0.0.0.0");
@@ -286,7 +369,7 @@ try {
286
369
 
287
370
 
288
371
  try {
289
- configureQemuNetwork();
372
+ await configureQemuNetwork();
290
373
  console.log("network: eth0 10.0.2.15/24 via 10.0.2.2");
291
374
  const response = await fetch("http://example.com");
292
375
  console.log(`fetch example.com: HTTP ${response.status}, ${response.headers.get("content-type")}`);
@@ -339,7 +422,9 @@ const launchBunmsh = () => {
339
422
 
340
423
 
341
424
 
342
- const launchBuninu = () => {
425
+ let replServer = null;
426
+
427
+ const launchBuninu = async () => {
343
428
 
344
429
  console.log(`
345
430
  Type bunterm in bunmsh: Graphical Terminal
@@ -347,22 +432,36 @@ Type bunterm in bunmsh: Graphical Terminal
347
432
  Supports Kitty Graphics Protocol
348
433
  `)
349
434
 
435
+ const classic_path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
436
+
437
+ const buninu_path = "/buninu/.local/bin:/buninu/.bun/bin:/buninu/bin"
438
+
350
439
  const entry = `/buninu/bin/init.js`;
351
440
  console.log(`buninu: Starting ${entry} --local`);
352
- const shell = Bun.spawnSync([
353
- "/bin/bun", entry, "--local"
354
- ], {
355
- env: {
356
- ...process.env,
357
- PATH:"/bin:/usr/bin:/buninu/bin",
358
- HOME:"/buninu"
359
- },
360
- stdin: "inherit",
361
- stdout: "inherit",
362
- stderr: "inherit",
363
- });
364
- console.log(`buninu exited with status ${shell.exitCode}; returning to Bun REPL.`);
365
- return shell.exitCode;
441
+ replServer?.pause();
442
+ try {
443
+ const shell = Bun.spawn([
444
+ "/bin/bun", entry, "--local"
445
+ ], {
446
+ env: {
447
+ ...process.env,
448
+ PATH: classic_path + ":" +
449
+ buninu_path,
450
+ HOME:"/buninu"
451
+ },
452
+ stdin: "inherit",
453
+ stdout: "inherit",
454
+ stderr: "inherit",
455
+ });
456
+ const exitCode = await shell.exited;
457
+ console.log(`buninu exited with status ${exitCode}; returning to Bun REPL.`);
458
+ return exitCode;
459
+ } finally {
460
+ // The child inherits the console directly. Keep the REPL from consuming
461
+ // the same input while it runs, but leave PID 1's event loop alive so its
462
+ // SIGCHLD handler can reap orphaned grandchildren such as Chromium.
463
+ replServer?.resume();
464
+ }
366
465
 
367
466
  }
368
467
 
@@ -478,12 +577,24 @@ Type start() to run buninu --local`);
478
577
  showWelcome();
479
578
 
480
579
  const startRepl = () => {
481
- const server = repl.start({
580
+ const server = replServer = repl.start({
482
581
  prompt: "bun-repl> ",
483
582
  input: process.stdin,
484
583
  output: process.stdout,
485
584
  terminal: true,
486
585
  });
586
+ // `start()` returns a promise so PID 1's event loop can keep reaping
587
+ // children. Make the two convenient whole-line forms foreground commands;
588
+ // otherwise the REPL finishes evaluating the pending promise and resumes
589
+ // itself, competing with Buninu for the console input.
590
+ const defaultEval = server.eval.bind(server);
591
+ server.eval = (command, context, filename, callback) => {
592
+ const trimmed = command.trim();
593
+ if (trimmed === "start" || /^start\s*\(\s*\)\s*;?$/.test(trimmed)) {
594
+ command = "await start()\n";
595
+ }
596
+ return defaultEval(command, context, filename, callback);
597
+ };
487
598
  // Run before REPL's own line listener so its next prompt is printed after
488
599
  // the repeated welcome. Empty and whitespace-only lines both trigger it.
489
600
  server.prependListener("line", (line) => {
@@ -53,6 +53,7 @@ export class ImageStore {
53
53
  this.term = term;
54
54
  this.renderer = renderer;
55
55
  this.images = new Map(); // image id → Skia Image
56
+ this.imageSources = new Map(); // image id → parser image object
56
57
  this.placements = new Map(); // placement key → placement
57
58
  this.counter = 0;
58
59
  this.coveredKey = "";
@@ -70,14 +71,26 @@ export class ImageStore {
70
71
  if (graphic.kind !== "placement") return "";
71
72
  const { control, image } = graphic;
72
73
  let decoded = this.images.get(image.id);
73
- if (!decoded) {
74
+ const replacesImage = decoded && this.imageSources.get(image.id) !== image;
75
+ if (!decoded || replacesImage) {
74
76
  try {
75
- decoded = await decodeImage(this.CanvasKit, image);
77
+ const replacement = await decodeImage(this.CanvasKit, image);
78
+ if (replacesImage) {
79
+ // Kitty requires retransmitting a non-zero image id to delete the
80
+ // old image and all its placements. Screen-streaming clients such
81
+ // as casty deliberately reuse one id for every frame.
82
+ for (const placement of [...this.placements.values()]) {
83
+ if (placement.imageId === image.id) this.dispose(placement);
84
+ }
85
+ decoded.delete();
86
+ }
87
+ decoded = replacement;
76
88
  } catch (error) {
77
89
  graphic.responseMessage = `EBADPNG:${error.message}`;
78
90
  return "";
79
91
  }
80
92
  this.images.set(image.id, decoded);
93
+ this.imageSources.set(image.id, image);
81
94
  }
82
95
  const { cellWidth, cellHeight } = this.renderer;
83
96
  const buffer = this.term.buffer.active;
@@ -169,6 +182,7 @@ export class ImageStore {
169
182
  if (!used.has(id)) {
170
183
  image.delete();
171
184
  this.images.delete(id);
185
+ this.imageSources.delete(id);
172
186
  }
173
187
  }
174
188
  }
@@ -221,5 +235,6 @@ export class ImageStore {
221
235
  this.placements.clear();
222
236
  for (const image of this.images.values()) image.delete();
223
237
  this.images.clear();
238
+ this.imageSources.clear();
224
239
  }
225
240
  }
@@ -0,0 +1,222 @@
1
+ // /lib/bunterm/mouse.js — pointing devices through the Linux input layer.
2
+ //
3
+ // Read from /dev/input/event*, where the kernel reports one 24-byte
4
+ // input_event per axis, button or wheel notch, ending each batch with an
5
+ // EV_SYN. Relative devices (a mouse) move the pointer by their deltas;
6
+ // absolute ones (QEMU's USB tablet, a touchscreen) place it directly, which
7
+ // needs the axis range from EVIOCGABS. This module is only loaded when
8
+ // bunterm is asked for a mouse.
9
+
10
+ import { closeSync, existsSync, openSync, read, readFileSync, readdirSync } from "node:fs";
11
+ import { libc, ptr } from "../dlopen.js";
12
+
13
+ const EVENT_SIZE = 24;
14
+ const EV_SYN = 0x00;
15
+ const EV_KEY = 0x01;
16
+ const EV_REL = 0x02;
17
+ const EV_ABS = 0x03;
18
+ const REL_X = 0x00;
19
+ const REL_Y = 0x01;
20
+ const REL_WHEEL = 0x08;
21
+ const REL_HWHEEL = 0x06;
22
+ const ABS_X = 0x00;
23
+ const ABS_Y = 0x01;
24
+ const BTN_LEFT = 0x110;
25
+ const BTN_RIGHT = 0x111;
26
+ const BTN_MIDDLE = 0x112;
27
+ const BTN_TOUCH = 0x14a;
28
+
29
+ // struct input_absinfo { s32 value, minimum, maximum, fuzz, flat, resolution }
30
+ const EVIOCGABS = (axis) => 0x80184540 + axis;
31
+
32
+ const buttonNames = {
33
+ [BTN_LEFT]: "left",
34
+ [BTN_RIGHT]: "right",
35
+ [BTN_MIDDLE]: "middle",
36
+ [BTN_TOUCH]: "left",
37
+ };
38
+
39
+ const capability = (directory, name) => {
40
+ try {
41
+ // A capability file is a space-separated list of 64-bit hex words, most
42
+ // significant first, so the last word holds bits 0-63.
43
+ const words = readFileSync(`${directory}/${name}`, "utf8").trim().split(/\s+/u).reverse();
44
+ return (bit) => {
45
+ const word = words[Math.floor(bit / 64)];
46
+ return word ? (BigInt(`0x${word}`) >> BigInt(bit % 64)) & 1n ? true : false : false;
47
+ };
48
+ } catch {
49
+ return () => false;
50
+ }
51
+ };
52
+
53
+ // Every /dev/input/event* that looks like a pointer, with how it reports
54
+ // position. A device with both relative and absolute axes counts as absolute.
55
+ export const findPointers = (directory = "/dev/input") => {
56
+ const found = [];
57
+ let names = [];
58
+ try {
59
+ names = readdirSync(directory).filter((name) => /^event\d+$/u.test(name));
60
+ } catch {
61
+ return found;
62
+ }
63
+ for (const name of names.sort()) {
64
+ const sysfs = `/sys/class/input/${name}/device/capabilities`;
65
+ if (!existsSync(sysfs)) continue;
66
+ const relative = capability(sysfs, "rel");
67
+ const absolute = capability(sysfs, "abs");
68
+ const keys = capability(sysfs, "key");
69
+ const hasRelative = relative(REL_X) && relative(REL_Y);
70
+ const hasAbsolute = absolute(ABS_X) && absolute(ABS_Y);
71
+ const hasButton = keys(BTN_LEFT) || keys(BTN_TOUCH);
72
+ if (!hasButton || (!hasRelative && !hasAbsolute)) continue;
73
+ let label = name;
74
+ try {
75
+ label = readFileSync(`/sys/class/input/${name}/device/name`, "utf8").trim() || name;
76
+ } catch {}
77
+ found.push({
78
+ path: `${directory}/${name}`,
79
+ name: label,
80
+ kind: hasAbsolute ? "absolute" : "relative",
81
+ wheel: relative(REL_WHEEL),
82
+ });
83
+ }
84
+ return found;
85
+ };
86
+
87
+ const absoluteRange = (fd, axis) => {
88
+ const info = new Int32Array(6);
89
+ if (libc.symbols.ioctl(fd, EVIOCGABS(axis), ptr(info)) === -1) return null;
90
+ const [, minimum, maximum] = info;
91
+ return maximum > minimum ? { minimum, maximum } : null;
92
+ };
93
+
94
+ // Opens every pointer in `devices` and reports the pointer state after each
95
+ // EV_SYN: { x, y, buttons: { left, middle, right }, wheel, hwheel, moved,
96
+ // pressed, released }. Coordinates are pixels clamped to width x height.
97
+ // `speed` scales relative motion. close() releases the devices.
98
+ export const openPointer = ({
99
+ devices = findPointers(),
100
+ width,
101
+ height,
102
+ speed = 1,
103
+ x = Math.floor(width / 2),
104
+ y = Math.floor(height / 2),
105
+ onSync,
106
+ onError = () => {},
107
+ }) => {
108
+ const state = {
109
+ x, y,
110
+ buttons: { left: false, middle: false, right: false },
111
+ wheel: 0,
112
+ hwheel: 0,
113
+ moved: false,
114
+ pressed: [],
115
+ released: [],
116
+ };
117
+ const open = [];
118
+
119
+ for (const device of devices) {
120
+ let fd = -1;
121
+ try {
122
+ fd = openSync(device.path, "r");
123
+ const ranges = device.kind === "absolute"
124
+ ? { x: absoluteRange(fd, ABS_X), y: absoluteRange(fd, ABS_Y) }
125
+ : null;
126
+ // An absolute device without a usable range is read as relative.
127
+ const kind = ranges?.x && ranges?.y ? "absolute" : "relative";
128
+ open.push({ ...device, fd, kind, ranges, buffer: Buffer.alloc(EVENT_SIZE * 64), reading: true });
129
+ } catch (error) {
130
+ if (fd >= 0) {
131
+ try { closeSync(fd); } catch {}
132
+ }
133
+ onError(new Error(`${device.path}: ${error.message}`));
134
+ }
135
+ }
136
+
137
+ const clamp = (value, maximum) => Math.max(0, Math.min(maximum - 1, value));
138
+
139
+ const apply = (entry, type, code, value) => {
140
+ if (type === EV_REL) {
141
+ if (code === REL_X) { state.x = clamp(Math.round(state.x + value * speed), width); state.moved = true; }
142
+ else if (code === REL_Y) { state.y = clamp(Math.round(state.y + value * speed), height); state.moved = true; }
143
+ else if (code === REL_WHEEL) state.wheel += value;
144
+ else if (code === REL_HWHEEL) state.hwheel += value;
145
+ return;
146
+ }
147
+ if (type === EV_ABS && entry.kind === "absolute") {
148
+ const range = code === ABS_X ? entry.ranges.x : code === ABS_Y ? entry.ranges.y : null;
149
+ if (!range) return;
150
+ const span = range.maximum - range.minimum;
151
+ const fraction = (value - range.minimum) / span;
152
+ if (code === ABS_X) state.x = clamp(Math.round(fraction * width), width);
153
+ else state.y = clamp(Math.round(fraction * height), height);
154
+ state.moved = true;
155
+ return;
156
+ }
157
+ if (type === EV_KEY) {
158
+ const button = buttonNames[code];
159
+ if (!button) return;
160
+ const down = value !== 0;
161
+ if (state.buttons[button] === down) return; // key repeat
162
+ state.buttons[button] = down;
163
+ (down ? state.pressed : state.released).push(button);
164
+ }
165
+ };
166
+
167
+ const flush = () => {
168
+ const report = {
169
+ x: state.x,
170
+ y: state.y,
171
+ buttons: { ...state.buttons },
172
+ wheel: state.wheel,
173
+ hwheel: state.hwheel,
174
+ moved: state.moved,
175
+ pressed: state.pressed,
176
+ released: state.released,
177
+ };
178
+ state.wheel = 0;
179
+ state.hwheel = 0;
180
+ state.moved = false;
181
+ state.pressed = [];
182
+ state.released = [];
183
+ if (report.moved || report.wheel || report.hwheel || report.pressed.length || report.released.length) {
184
+ try { onSync(report); } catch (error) { onError(error); }
185
+ }
186
+ };
187
+
188
+ const pump = (entry) => {
189
+ if (!entry.reading) return;
190
+ read(entry.fd, entry.buffer, 0, entry.buffer.length, null, (error, count) => {
191
+ if (!entry.reading) return;
192
+ if (error) {
193
+ if (error.code === "EAGAIN") { setTimeout(() => pump(entry), 10); return; }
194
+ entry.reading = false;
195
+ onError(new Error(`${entry.path}: ${error.message}`));
196
+ return;
197
+ }
198
+ for (let offset = 0; offset + EVENT_SIZE <= count; offset += EVENT_SIZE) {
199
+ const type = entry.buffer.readUInt16LE(offset + 16);
200
+ const code = entry.buffer.readUInt16LE(offset + 18);
201
+ const value = entry.buffer.readInt32LE(offset + 20);
202
+ if (type === EV_SYN) flush();
203
+ else apply(entry, type, code, value);
204
+ }
205
+ pump(entry);
206
+ });
207
+ };
208
+
209
+ for (const entry of open) pump(entry);
210
+
211
+ return {
212
+ devices: open.map(({ path, name, kind }) => ({ path, name, kind })),
213
+ state,
214
+ close() {
215
+ for (const entry of open) {
216
+ entry.reading = false;
217
+ try { closeSync(entry.fd); } catch {}
218
+ }
219
+ open.length = 0;
220
+ },
221
+ };
222
+ };
@@ -105,6 +105,7 @@ export class Renderer {
105
105
  this.forcedRows = new Set();
106
106
  this.dirtyAll = true;
107
107
  this.lastCursor = { x: -1, y: -1, visible: false };
108
+ this.lastPointer = null;
108
109
  }
109
110
 
110
111
  fontFor(typeface, size, { bold = false, italic = false } = {}) {
@@ -355,11 +356,46 @@ export class Renderer {
355
356
  }
356
357
  }
357
358
 
359
+ // Draws the mouse pointer at (x, y) in pixels: an arrow outlined in the
360
+ // background colour so it stays visible over any cell.
361
+ drawPointer({ x, y }) {
362
+ const { CanvasKit, canvas } = this;
363
+ const size = Math.max(10, Math.round(this.fontSize * 0.9));
364
+ const builder = new CanvasKit.PathBuilder();
365
+ builder.moveTo(x, y);
366
+ builder.lineTo(x, y + size);
367
+ builder.lineTo(x + size * 0.28, y + size * 0.74);
368
+ builder.lineTo(x + size * 0.46, y + size * 1.08);
369
+ builder.lineTo(x + size * 0.63, y + size * 1.0);
370
+ builder.lineTo(x + size * 0.45, y + size * 0.66);
371
+ builder.lineTo(x + size * 0.72, y + size * 0.66);
372
+ builder.close();
373
+ const path = builder.detach();
374
+ builder.delete();
375
+ const outline = this.paintFor(this.background).copy();
376
+ outline.setStyle(CanvasKit.PaintStyle.Stroke);
377
+ outline.setStrokeWidth(Math.max(1, size / 8));
378
+ canvas.drawPath(path, outline);
379
+ canvas.drawPath(path, this.paintFor(this.cursorColor));
380
+ outline.delete();
381
+ path.delete();
382
+ }
383
+
384
+ // The rows an arrow at (x, y) covers, so they are repainted when it moves.
385
+ pointerRows({ x, y }) {
386
+ void x;
387
+ const size = Math.max(10, Math.round(this.fontSize * 0.9));
388
+ const first = Math.floor((y - this.offsetY) / this.cellHeight);
389
+ const last = Math.ceil((y + size * 1.1 - this.offsetY) / this.cellHeight);
390
+ return [first, last];
391
+ }
392
+
358
393
  // Repaints what changed and flushes to the framebuffer. `cursor` is
359
394
  // { x, y, visible }; `images` is a list of { image, rect } placements in
360
- // screen pixels, from images.js, drawn over the text. Returns true when
361
- // anything was painted.
362
- render(term, { cursor, images = [] } = {}) {
395
+ // screen pixels, from images.js, drawn over the text; `pointer` is the
396
+ // mouse position in pixels, drawn last. Returns true when anything was
397
+ // painted.
398
+ render(term, { cursor, images = [], pointer = null } = {}) {
363
399
  const buffer = term.buffer.active;
364
400
  const top = buffer.viewportY;
365
401
  const dirty = [];
@@ -375,6 +411,13 @@ export class Renderer {
375
411
  const cursorRows = new Set();
376
412
  if (this.lastCursor.visible) cursorRows.add(this.lastCursor.y);
377
413
  if (cursor?.visible) cursorRows.add(cursor.y);
414
+ // Wherever the pointer was and is has to be repainted: the text under it
415
+ // has not changed, so no row hash would say so.
416
+ for (const position of [this.lastPointer, pointer]) {
417
+ if (!position) continue;
418
+ const [first, last] = this.pointerRows(position);
419
+ for (let row = Math.max(0, first); row < Math.min(this.rows, last); row++) cursorRows.add(row);
420
+ }
378
421
  for (const row of cursorRows) {
379
422
  if (row >= 0 && row < this.rows && !dirty.includes(row)) dirty.push(row);
380
423
  }
@@ -390,6 +433,8 @@ export class Renderer {
390
433
  }
391
434
  this.lastCursor = { x: cursor?.x ?? -1, y: cursor?.y ?? -1, visible: Boolean(cursor?.visible) };
392
435
  if (images.length) this.drawImages(images, dirty);
436
+ if (pointer) this.drawPointer(pointer);
437
+ this.lastPointer = pointer ? { x: pointer.x, y: pointer.y } : null;
393
438
  this.screen.flush();
394
439
  return true;
395
440
  }