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.
- package/Dockerfile +23 -0
- package/NOTICE.md +111 -63
- package/README.md +292 -58
- package/build-image.sh +9 -5
- package/build-uki.sh +44 -28
- package/fetch-alpine.sh +45 -31
- package/fetch-bun.sh +14 -21
- package/graphics.md +7 -2
- package/index.js +150 -35
- package/initramfs/bin/bunterm +5 -1
- package/initramfs/bin/chroot +216 -0
- package/initramfs/bin/ip +3 -2
- package/initramfs/bin/ps +3 -0
- package/initramfs/bin/stripansi +27 -0
- package/initramfs/bin/tar +198 -14
- package/initramfs/init.js +149 -38
- package/initramfs/lib/bunterm/images.js +17 -2
- package/initramfs/lib/bunterm/mouse.js +222 -0
- package/initramfs/lib/bunterm/renderer.js +48 -3
- package/initramfs/lib/bunterm/terminal.js +123 -2
- package/initramfs/lib/dlopen.js +4 -0
- package/initramfs/lib/document.js +5 -1
- package/initramfs/usr/share/doc/buninu-linux/bunterm.md +35 -2
- package/initramfs/usr/share/doc/buninu-linux/chroot.md +105 -0
- package/initramfs/usr/share/doc/buninu-linux/tar.md +32 -6
- package/native/aarch64/lib/ld-musl-aarch64.so.1 +0 -0
- package/native/aarch64/lib/libc.musl-aarch64.so.1 +0 -0
- package/native/aarch64/lib/libgcc_s.so.1 +0 -0
- package/native/aarch64/lib/libstdc++.so.6 +0 -0
- package/package.json +2 -2
- package/run-qemu.sh +61 -12
- package/scripts/arch.sh +113 -0
- package/scripts/fetch.sh +5 -3
- package/scripts/pack-initramfs.sh +18 -4
- package/test/host/Dockerfile +13 -0
- package/test/host/boot-test.exp +70 -0
- package/test/host/host-test.sh +49 -0
- package/test/host/in-container.sh +58 -0
- /package/{initramfs → native/x86_64}/lib/ld-musl-x86_64.so.1 +0 -0
- /package/{initramfs → native/x86_64}/lib/libc.musl-x86_64.so.1 +0 -0
- /package/{initramfs → native/x86_64}/lib/libgcc_s.so.1 +0 -0
- /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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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(
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
195
|
-
// the default route; rt_dst and
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
74
|
+
const replacesImage = decoded && this.imageSources.get(image.id) !== image;
|
|
75
|
+
if (!decoded || replacesImage) {
|
|
74
76
|
try {
|
|
75
|
-
|
|
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
|
|
361
|
-
// anything was
|
|
362
|
-
|
|
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
|
}
|