buninu-linux 0.5.1 → 0.7.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 +280 -60
- package/build-image.sh +9 -5
- package/build-uki.sh +44 -28
- package/fetch-alpine.sh +43 -35
- package/fetch-bun.sh +14 -21
- package/index.js +150 -35
- package/initramfs/bin/buninu-linux-help +12 -0
- package/initramfs/bin/bunterm +64 -5
- package/initramfs/bin/chroot +4 -1
- package/initramfs/bin/ip +3 -2
- package/initramfs/bin/ps +3 -0
- package/initramfs/bin/tar +9 -0
- package/initramfs/init.js +79 -41
- package/initramfs/lib/bunterm/images.js +17 -2
- package/initramfs/lib/bunterm/terminal.js +25 -9
- package/initramfs/lib/bunterm/vt.js +91 -0
- package/initramfs/lib/dlopen.js +4 -0
- package/initramfs/lib/fbdev.js +33 -10
- package/initramfs/usr/share/doc/buninu-linux/bunterm.md +25 -7
- package/initramfs/usr/share/doc/buninu-linux/fbdev.md +1 -1
- package/initramfs/usr/share/doc/buninu-linux/tar.md +2 -0
- package/initramfs/usr/share/licenses/README.md +4 -0
- 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 +22 -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/bin/bunterm
CHANGED
|
@@ -3,16 +3,22 @@
|
|
|
3
3
|
|
|
4
4
|
import { showDocument } from "../lib/document.js";
|
|
5
5
|
import { runTerminal } from "../lib/bunterm/terminal.js";
|
|
6
|
+
import { activeConsole, openConsole } from "../lib/fbdev.js";
|
|
7
|
+
import { virtualConsoleNumber } from "../lib/bunterm/vt.js";
|
|
8
|
+
import { closeSync, openSync, readFileSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
const CANONICAL_BUNTERM = "/bin/bunterm";
|
|
6
11
|
|
|
7
12
|
const usage = () => {
|
|
8
13
|
console.error(`Usage:
|
|
9
|
-
bunterm [/dev/ttyN] [--font-size N] [--fb /dev/fb0] [--no-mouse] [-e command [argument...]]
|
|
14
|
+
bunterm [N | /dev/ttyN] [-s N | --font-size N] [--fb /dev/fb0] [--no-mouse] [-e command [argument...]]
|
|
10
15
|
bunterm -h | --help`);
|
|
11
16
|
};
|
|
12
17
|
|
|
13
18
|
const main = async () => {
|
|
14
19
|
const options = { argv: [] };
|
|
15
20
|
const arguments_ = process.argv.slice(2);
|
|
21
|
+
let consoleArgumentIndex = -1;
|
|
16
22
|
for (let index = 0; index < arguments_.length; index++) {
|
|
17
23
|
const argument = arguments_[index];
|
|
18
24
|
if (argument === "-h" || argument === "--help") {
|
|
@@ -36,8 +42,10 @@ const main = async () => {
|
|
|
36
42
|
}
|
|
37
43
|
return value;
|
|
38
44
|
};
|
|
39
|
-
if (argument === "
|
|
40
|
-
|
|
45
|
+
if (argument === "-s" || argument.startsWith("-s=")
|
|
46
|
+
|| argument === "--font-size" || argument.startsWith("--font-size=")) {
|
|
47
|
+
const name = argument === "-s" || argument.startsWith("-s=") ? "-s" : "--font-size";
|
|
48
|
+
options.fontSize = Number(valued(name));
|
|
41
49
|
if (!Number.isFinite(options.fontSize) || options.fontSize < 6) throw new Error("--font-size must be a number of at least 6");
|
|
42
50
|
} else if (argument === "--line-height" || argument.startsWith("--line-height=")) {
|
|
43
51
|
options.lineHeight = Number(valued("--line-height"));
|
|
@@ -50,13 +58,64 @@ const main = async () => {
|
|
|
50
58
|
options.mouse = true;
|
|
51
59
|
} else if (argument === "--no-mouse") {
|
|
52
60
|
options.mouse = false;
|
|
53
|
-
} else if (argument.startsWith("/dev/")) {
|
|
54
|
-
|
|
61
|
+
} else if (/^[1-9][0-9]*$/.test(argument) || argument.startsWith("/dev/")) {
|
|
62
|
+
const number = /^[1-9][0-9]*$/.test(argument) ? Number(argument) : virtualConsoleNumber(argument);
|
|
63
|
+
if (number === null) throw new Error(`expected N or /dev/ttyN, got ${argument}`);
|
|
64
|
+
if (consoleArgumentIndex !== -1) throw new Error("only one virtual console may be specified");
|
|
65
|
+
options.console = `/dev/tty${number}`;
|
|
66
|
+
consoleArgumentIndex = index;
|
|
55
67
|
} else {
|
|
56
68
|
usage();
|
|
57
69
|
throw new Error(`unexpected argument: ${argument}`);
|
|
58
70
|
}
|
|
59
71
|
}
|
|
72
|
+
|
|
73
|
+
const probe = openConsole(["/dev/tty0"]);
|
|
74
|
+
let activeConsoleNumber;
|
|
75
|
+
try {
|
|
76
|
+
activeConsoleNumber = activeConsole(probe);
|
|
77
|
+
} finally {
|
|
78
|
+
probe.close();
|
|
79
|
+
}
|
|
80
|
+
if (!options.console) options.console = `/dev/tty${activeConsoleNumber}`;
|
|
81
|
+
|
|
82
|
+
// Every live bunterm converges on one unambiguous /proc/<pid>/cmdline:
|
|
83
|
+
// /bin/bunterm /bin/bunterm /dev/ttyN [options...]
|
|
84
|
+
// execve replaces the original process, so no forwarding parent remains.
|
|
85
|
+
const remaining = arguments_.filter((_argument, index) => index !== consoleArgumentIndex);
|
|
86
|
+
const canonicalArgv = [CANONICAL_BUNTERM, CANONICAL_BUNTERM, options.console, ...remaining];
|
|
87
|
+
const currentArgv = readFileSync("/proc/self/cmdline").toString("utf8").split("\0");
|
|
88
|
+
if (currentArgv.at(-1) === "") currentArgv.pop();
|
|
89
|
+
if (currentArgv.length !== canonicalArgv.length
|
|
90
|
+
|| currentArgv.some((argument, index) => argument !== canonicalArgv[index])) {
|
|
91
|
+
const environment = Object.fromEntries(
|
|
92
|
+
Object.entries(process.env).filter((entry) => typeof entry[1] === "string"),
|
|
93
|
+
);
|
|
94
|
+
const targetConsoleNumber = virtualConsoleNumber(options.console);
|
|
95
|
+
if (consoleArgumentIndex !== -1 && targetConsoleNumber !== activeConsoleNumber) {
|
|
96
|
+
const consoleFd = openSync(options.console, "r+");
|
|
97
|
+
try {
|
|
98
|
+
const child = Bun.spawn(
|
|
99
|
+
[process.execPath, CANONICAL_BUNTERM, options.console, ...remaining],
|
|
100
|
+
{
|
|
101
|
+
argv0: CANONICAL_BUNTERM,
|
|
102
|
+
detached: true,
|
|
103
|
+
env: environment,
|
|
104
|
+
stdin: "ignore",
|
|
105
|
+
stdout: consoleFd,
|
|
106
|
+
stderr: consoleFd,
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
child.unref();
|
|
110
|
+
} finally {
|
|
111
|
+
closeSync(consoleFd);
|
|
112
|
+
}
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
process.execve(process.execPath, canonicalArgv, environment);
|
|
116
|
+
throw new Error("process.execve returned unexpectedly");
|
|
117
|
+
}
|
|
118
|
+
|
|
60
119
|
return runTerminal(options);
|
|
61
120
|
};
|
|
62
121
|
|
package/initramfs/bin/chroot
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// /etc/resolv.conf, so `chroot /mnt/rootfs` lands in a usable system. Every
|
|
9
9
|
// mount it makes is undone when the command exits.
|
|
10
10
|
|
|
11
|
-
import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { chmodSync, existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { libc, cString, ptr, errno, strerror, Errno } from "../lib/dlopen.js";
|
|
13
13
|
import { showDocument } from "../lib/document.js";
|
|
14
14
|
|
|
@@ -125,6 +125,9 @@ if (options.mount) {
|
|
|
125
125
|
// A bound file, so name resolution inside follows the host's configuration
|
|
126
126
|
// and nothing inside the root is modified.
|
|
127
127
|
if (existsSync("/etc/resolv.conf") && existsSync(`${root}/etc`)) {
|
|
128
|
+
|
|
129
|
+
chmodSync(`/etc/resolv.conf`, 0o644);
|
|
130
|
+
|
|
128
131
|
prepare({ inside: "/etc/resolv.conf", source: "/etc/resolv.conf", type: "none", flags: MS.BIND, file: true });
|
|
129
132
|
}
|
|
130
133
|
}
|
package/initramfs/bin/ip
CHANGED
|
@@ -498,8 +498,9 @@ const commandAddress = (words, options) => {
|
|
|
498
498
|
});
|
|
499
499
|
};
|
|
500
500
|
|
|
501
|
-
// struct rtentry on x86_64: rt_dst @8,
|
|
502
|
-
// 16-byte sockaddr), rt_flags u16 @56,
|
|
501
|
+
// struct rtentry on 64-bit Linux, x86_64 and aarch64 alike: rt_dst @8,
|
|
502
|
+
// rt_gateway @24, rt_genmask @40 (each a 16-byte sockaddr), rt_flags u16 @56,
|
|
503
|
+
// rt_metric i16 @80, rt_dev char* @88.
|
|
503
504
|
const rtentry = ({ destination, prefix, gateway, device, metric }) => {
|
|
504
505
|
const route = new Uint8Array(120);
|
|
505
506
|
const routeView = view(route);
|
package/initramfs/bin/ps
CHANGED
|
@@ -16,6 +16,9 @@ const ttyName = (number) => {
|
|
|
16
16
|
const minor = (device & 0xff) | ((device >>> 12) & 0xfff00);
|
|
17
17
|
if (major >= 136 && major <= 143) return `pts/${minor}`;
|
|
18
18
|
if (major === 4 && minor < 64) return `tty${minor}`;
|
|
19
|
+
// Serial ports: the 8250 UARTs of a PC and the PL011 of QEMU's aarch64 virt.
|
|
20
|
+
if (major === 4) return `ttyS${minor - 64}`;
|
|
21
|
+
if (major === 204 && minor >= 64) return `ttyAMA${minor - 64}`;
|
|
19
22
|
if (major === 5 && minor === 0) return "tty";
|
|
20
23
|
return `${major}:${minor}`;
|
|
21
24
|
};
|
package/initramfs/bin/tar
CHANGED
|
@@ -41,6 +41,14 @@ for (let index = 0; index < argv.length; index++) {
|
|
|
41
41
|
}
|
|
42
42
|
if (argument === "--zstd") { options.compression = "zstd"; continue; }
|
|
43
43
|
if (argument === "--gzip") { options.compression = "gzip"; continue; }
|
|
44
|
+
// GNU tar compatibility used by js-udocker. Bun.Archive never restores
|
|
45
|
+
// ownership or permissions, and extraction replaces existing files, so
|
|
46
|
+
// these options already describe its normal extraction behaviour.
|
|
47
|
+
if (argument === "--no-same-owner" || argument === "--no-same-permissions" || argument === "--overwrite") continue;
|
|
48
|
+
// Bun.Archive does not extract device nodes. js-udocker separately removes
|
|
49
|
+
// whiteout files after extraction, so its excludes are safe no-ops here.
|
|
50
|
+
if (argument === "--exclude") { take(index++, argument); continue; }
|
|
51
|
+
if (argument.startsWith("--exclude=")) continue;
|
|
44
52
|
if (argument === "-C" || argument === "--directory") {
|
|
45
53
|
options.directory = take(index++, argument);
|
|
46
54
|
continue;
|
|
@@ -67,6 +75,7 @@ for (let index = 0; index < argv.length; index++) {
|
|
|
67
75
|
} else if (flag === "r") {
|
|
68
76
|
fail("-r append is not supported by Bun.Archive");
|
|
69
77
|
} else if (flag === "v") options.verbose = true;
|
|
78
|
+
else if (flag === "o") {} // GNU tar: --no-same-owner
|
|
70
79
|
else if (flag === "z") options.compression = "gzip";
|
|
71
80
|
else if (flag === "f") {
|
|
72
81
|
const attached = flags.slice(offset + 1);
|
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,
|
|
@@ -152,11 +162,13 @@ try {
|
|
|
152
162
|
// The input layer's character devices, so a pointing device has a
|
|
153
163
|
// /dev/input/event* node for `bunterm --mouse` to read. Loading them only
|
|
154
164
|
// creates those nodes: the console keyboard is unaffected, and nothing reads
|
|
155
|
-
// a pointer unless it is asked to.
|
|
156
|
-
//
|
|
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.
|
|
157
169
|
try {
|
|
158
170
|
const { tryModprobe } = await import("/lib/modprobe.js");
|
|
159
|
-
const loaded = ["evdev", "psmouse"].filter((module) => tryModprobe(module) !== null);
|
|
171
|
+
const loaded = ["evdev", "psmouse", "virtio_input"].filter((module) => tryModprobe(module) !== null);
|
|
160
172
|
const { readdirSync } = await import("node:fs");
|
|
161
173
|
const devices = (() => {
|
|
162
174
|
try { return readdirSync("/dev/input").filter((name) => name.startsWith("event")); } catch { return []; }
|
|
@@ -190,7 +202,11 @@ const loadModule = (path) => {
|
|
|
190
202
|
const parameters = cString("");
|
|
191
203
|
// -EEXIST is harmless when firmware or another module loaded it first.
|
|
192
204
|
// musl has no finit_module wrapper, so use its generic syscall(2).
|
|
193
|
-
check(
|
|
205
|
+
check(
|
|
206
|
+
`finit_module(${path})`,
|
|
207
|
+
libc.symbols.syscall(architecture.SYS_finit_module, fd, ptr(parameters), 0),
|
|
208
|
+
[-17],
|
|
209
|
+
);
|
|
194
210
|
} finally {
|
|
195
211
|
libc.symbols.close(fd);
|
|
196
212
|
}
|
|
@@ -231,21 +247,14 @@ const bringUpLoopback = () => {
|
|
|
231
247
|
}
|
|
232
248
|
};
|
|
233
249
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
`lib/modules/${release}/kernel/drivers/virtio/virtio_ring.ko`,
|
|
243
|
-
`lib/modules/${release}/kernel/drivers/virtio/virtio.ko`,
|
|
244
|
-
`lib/modules/${release}/kernel/drivers/virtio/virtio_pci_legacy_dev.ko`,
|
|
245
|
-
`lib/modules/${release}/kernel/drivers/virtio/virtio_pci_modern_dev.ko`,
|
|
246
|
-
`lib/modules/${release}/kernel/drivers/virtio/virtio_pci.ko`,
|
|
247
|
-
);
|
|
248
|
-
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");
|
|
249
258
|
|
|
250
259
|
const fd = check("socket(AF_INET, SOCK_DGRAM)", libc.symbols.socket(2, 2, 0));
|
|
251
260
|
const ioctl = (request, value, operation) =>
|
|
@@ -264,8 +273,9 @@ const configureQemuNetwork = () => {
|
|
|
264
273
|
|
|
265
274
|
bringUp(ioctl, "eth0");
|
|
266
275
|
|
|
267
|
-
// struct rtentry on Linux
|
|
268
|
-
// 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.
|
|
269
279
|
const route = new Uint8Array(120);
|
|
270
280
|
const routeView = new DataView(route.buffer);
|
|
271
281
|
writeSockaddrIpv4(route, 8, "0.0.0.0");
|
|
@@ -359,7 +369,7 @@ try {
|
|
|
359
369
|
|
|
360
370
|
|
|
361
371
|
try {
|
|
362
|
-
configureQemuNetwork();
|
|
372
|
+
await configureQemuNetwork();
|
|
363
373
|
console.log("network: eth0 10.0.2.15/24 via 10.0.2.2");
|
|
364
374
|
const response = await fetch("http://example.com");
|
|
365
375
|
console.log(`fetch example.com: HTTP ${response.status}, ${response.headers.get("content-type")}`);
|
|
@@ -412,7 +422,9 @@ const launchBunmsh = () => {
|
|
|
412
422
|
|
|
413
423
|
|
|
414
424
|
|
|
415
|
-
|
|
425
|
+
let replServer = null;
|
|
426
|
+
|
|
427
|
+
const launchBuninu = async () => {
|
|
416
428
|
|
|
417
429
|
console.log(`
|
|
418
430
|
Type bunterm in bunmsh: Graphical Terminal
|
|
@@ -420,22 +432,36 @@ Type bunterm in bunmsh: Graphical Terminal
|
|
|
420
432
|
Supports Kitty Graphics Protocol
|
|
421
433
|
`)
|
|
422
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
|
+
|
|
423
439
|
const entry = `/buninu/bin/init.js`;
|
|
424
440
|
console.log(`buninu: Starting ${entry} --local`);
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
+
}
|
|
439
465
|
|
|
440
466
|
}
|
|
441
467
|
|
|
@@ -551,12 +577,24 @@ Type start() to run buninu --local`);
|
|
|
551
577
|
showWelcome();
|
|
552
578
|
|
|
553
579
|
const startRepl = () => {
|
|
554
|
-
const server = repl.start({
|
|
580
|
+
const server = replServer = repl.start({
|
|
555
581
|
prompt: "bun-repl> ",
|
|
556
582
|
input: process.stdin,
|
|
557
583
|
output: process.stdout,
|
|
558
584
|
terminal: true,
|
|
559
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
|
+
};
|
|
560
598
|
// Run before REPL's own line listener so its next prompt is printed after
|
|
561
599
|
// the repeated welcome. Empty and whitespace-only lines both trigger it.
|
|
562
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
|
}
|
|
@@ -20,6 +20,7 @@ import { UnicodeGraphemesAddon } from "../xterm/addon-unicode-graphemes.mjs";
|
|
|
20
20
|
import { Renderer, defaultTheme } from "./renderer.js";
|
|
21
21
|
import { ImageStore } from "./images.js";
|
|
22
22
|
import { openKeyboard, translateKeys } from "./input.js";
|
|
23
|
+
import { claimVirtualConsole } from "./vt.js";
|
|
23
24
|
|
|
24
25
|
const require = createRequire(import.meta.url);
|
|
25
26
|
|
|
@@ -81,6 +82,7 @@ export const createSession = ({
|
|
|
81
82
|
});
|
|
82
83
|
|
|
83
84
|
let frameTimer = null;
|
|
85
|
+
let active = true;
|
|
84
86
|
let blinkOn = true;
|
|
85
87
|
const cursorHidden = () => Boolean(term._core?.coreService?.isCursorHidden);
|
|
86
88
|
// Only set when a pointer is attached; without one the renderer is called
|
|
@@ -89,6 +91,7 @@ export const createSession = ({
|
|
|
89
91
|
const paint = () => {
|
|
90
92
|
if (frameTimer) clearTimeout(frameTimer);
|
|
91
93
|
frameTimer = null;
|
|
94
|
+
if (!active) return false;
|
|
92
95
|
const buffer = term.buffer.active;
|
|
93
96
|
// cursorY counts from the buffer's base, so a scrolled-back viewport
|
|
94
97
|
// moves the cursor down the screen and eventually off it.
|
|
@@ -104,8 +107,20 @@ export const createSession = ({
|
|
|
104
107
|
});
|
|
105
108
|
};
|
|
106
109
|
const requestFrame = () => {
|
|
110
|
+
if (!active) return;
|
|
107
111
|
frameTimer ??= setTimeout(paint, 16);
|
|
108
112
|
};
|
|
113
|
+
const suspend = () => {
|
|
114
|
+
active = false;
|
|
115
|
+
if (frameTimer) clearTimeout(frameTimer);
|
|
116
|
+
frameTimer = null;
|
|
117
|
+
};
|
|
118
|
+
const resume = () => {
|
|
119
|
+
if (active) return;
|
|
120
|
+
active = true;
|
|
121
|
+
renderer.invalidate();
|
|
122
|
+
paint();
|
|
123
|
+
};
|
|
109
124
|
const blinkTimer = cursorBlink
|
|
110
125
|
? setInterval(() => { blinkOn = !blinkOn; requestFrame(); }, 500)
|
|
111
126
|
: null;
|
|
@@ -193,6 +208,9 @@ export const createSession = ({
|
|
|
193
208
|
};
|
|
194
209
|
|
|
195
210
|
const reportMouse = (report) => {
|
|
211
|
+
// /dev/input is global rather than routed through the active VT. Do not
|
|
212
|
+
// draw or send mouse input to a terminal whose console is off-screen.
|
|
213
|
+
if (!active) return;
|
|
196
214
|
pointer = { x: report.x, y: report.y };
|
|
197
215
|
requestFrame();
|
|
198
216
|
const service = term._core?.coreMouseService;
|
|
@@ -268,7 +286,7 @@ export const createSession = ({
|
|
|
268
286
|
paint();
|
|
269
287
|
return {
|
|
270
288
|
term, backend, renderer, images, kitty,
|
|
271
|
-
paint, requestFrame, settle, input, exited, close, attachPointer, reportMouse, wheelScroll,
|
|
289
|
+
paint, requestFrame, suspend, resume, settle, input, exited, close, attachPointer, reportMouse, wheelScroll,
|
|
272
290
|
};
|
|
273
291
|
};
|
|
274
292
|
|
|
@@ -282,15 +300,13 @@ export const runTerminal = async ({ console: consoleDevice = null, device = "/de
|
|
|
282
300
|
const fonts = loadFontSet(CanvasKit, "terminal");
|
|
283
301
|
const consolePaths = consoleDevice ? [consoleDevice] : undefined;
|
|
284
302
|
|
|
285
|
-
return runGraphics(async (display, { onRestore, onAcquire }) => {
|
|
303
|
+
return runGraphics(async (display, { onRestore, onRelease, onAcquire }) => {
|
|
286
304
|
const screen = openCanvas(CanvasKit, display);
|
|
287
305
|
const session = createSession({ CanvasKit, screen, fonts, ...options });
|
|
288
|
-
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
session.paint();
|
|
293
|
-
});
|
|
306
|
+
onRelease(session.suspend);
|
|
307
|
+
// Coming back from another console (Ctrl-Alt-Fn): fbcon or another
|
|
308
|
+
// bunterm has drawn over the framebuffer, so repaint everything.
|
|
309
|
+
onAcquire(session.resume);
|
|
294
310
|
let keyboard = null;
|
|
295
311
|
try {
|
|
296
312
|
keyboard = openKeyboard({
|
|
@@ -320,5 +336,5 @@ export const runTerminal = async ({ console: consoleDevice = null, device = "/de
|
|
|
320
336
|
session.close();
|
|
321
337
|
screen.close();
|
|
322
338
|
}
|
|
323
|
-
}, { device, console: consolePaths });
|
|
339
|
+
}, { device, console: consolePaths, prepareConsole: claimVirtualConsole });
|
|
324
340
|
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Cooperative ownership for one Linux virtual console.
|
|
2
|
+
//
|
|
3
|
+
// Linux VT_SETMODE replaces an existing VT_PROCESS owner rather than failing,
|
|
4
|
+
// and VT_GETMODE does not expose that owner's pid. bunterm therefore publishes
|
|
5
|
+
// a short per-VT task name and verifies that another such process does not also
|
|
6
|
+
// hold the console device open. Both facts live under /proc/<pid> and disappear
|
|
7
|
+
// with the process; there is no persistent pid or lock file to become stale.
|
|
8
|
+
|
|
9
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { libc, cString, check } from "../dlopen.js";
|
|
11
|
+
|
|
12
|
+
const PR_SET_NAME = 15;
|
|
13
|
+
const PR_GET_NAME = 16;
|
|
14
|
+
|
|
15
|
+
export const virtualConsoleNumber = (path) => {
|
|
16
|
+
const match = /^\/dev\/tty([1-9][0-9]*)$/.exec(path);
|
|
17
|
+
return match ? Number(match[1]) : null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const taskName = (number) => `bunterm:tty${number}`;
|
|
21
|
+
|
|
22
|
+
const getTaskName = () => {
|
|
23
|
+
const name = new Uint8Array(16);
|
|
24
|
+
check("prctl(PR_GET_NAME)", libc.symbols.prctl(PR_GET_NAME, name, 0n, 0n, 0n));
|
|
25
|
+
const zero = name.indexOf(0);
|
|
26
|
+
return new TextDecoder().decode(zero === -1 ? name : name.subarray(0, zero));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const setTaskName = (name) => {
|
|
30
|
+
const encoded = cString(name);
|
|
31
|
+
if (encoded.byteLength > 16) throw new Error(`Linux task name is too long: ${name}`);
|
|
32
|
+
check("prctl(PR_SET_NAME)", libc.symbols.prctl(PR_SET_NAME, encoded, 0n, 0n, 0n));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const processHoldsDevice = (pid, deviceId) => {
|
|
36
|
+
let descriptors;
|
|
37
|
+
try {
|
|
38
|
+
descriptors = readdirSync(`/proc/${pid}/fd`);
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
for (const descriptor of descriptors) {
|
|
43
|
+
try {
|
|
44
|
+
if (statSync(`/proc/${pid}/fd/${descriptor}`).rdev === deviceId) return true;
|
|
45
|
+
} catch {
|
|
46
|
+
// The process or descriptor disappeared between readdir and stat.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const claimVirtualConsole = (consoleDevice) => {
|
|
53
|
+
const number = virtualConsoleNumber(consoleDevice.path);
|
|
54
|
+
if (number === null) throw new Error(`bunterm needs a canonical virtual console, got ${consoleDevice.path}`);
|
|
55
|
+
const name = taskName(number);
|
|
56
|
+
const previousName = getTaskName();
|
|
57
|
+
const deviceId = statSync(consoleDevice.path).rdev;
|
|
58
|
+
|
|
59
|
+
// Publish before scanning. Two simultaneous starters can then reject each
|
|
60
|
+
// other, but they cannot both scan an unpublished peer and proceed.
|
|
61
|
+
setTaskName(name);
|
|
62
|
+
try {
|
|
63
|
+
const pids = readdirSync("/proc")
|
|
64
|
+
.filter((entry) => /^[0-9]+$/.test(entry) && Number(entry) !== process.pid);
|
|
65
|
+
for (const pid of pids) {
|
|
66
|
+
let otherName;
|
|
67
|
+
try {
|
|
68
|
+
otherName = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
|
|
69
|
+
} catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (otherName !== name || !processHoldsDevice(pid, deviceId)) continue;
|
|
73
|
+
|
|
74
|
+
throw new Error(`
|
|
75
|
+
${consoleDevice.path} is occupied
|
|
76
|
+
Controlled by bunterm pid: ${pid}
|
|
77
|
+
See --help or try another VT:
|
|
78
|
+
bunterm ${number- -1}
|
|
79
|
+
Switch between TTYs by Ctrl+Alt+F1-12
|
|
80
|
+
or Alt-Left / Alt-Right
|
|
81
|
+
`);
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
try { setTaskName(previousName); } catch {}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return () => {
|
|
89
|
+
try { setTaskName(previousName); } catch {}
|
|
90
|
+
};
|
|
91
|
+
};
|
package/initramfs/lib/dlopen.js
CHANGED
|
@@ -85,6 +85,10 @@ export const libc = dlopen(libcPath, {
|
|
|
85
85
|
args: [FFIType.i32, FFIType.ptr, FFIType.i32],
|
|
86
86
|
returns: FFIType.i32,
|
|
87
87
|
},
|
|
88
|
+
prctl: {
|
|
89
|
+
args: [FFIType.i32, FFIType.ptr, FFIType.u64, FFIType.u64, FFIType.u64],
|
|
90
|
+
returns: FFIType.i32,
|
|
91
|
+
},
|
|
88
92
|
chroot: {
|
|
89
93
|
args: [FFIType.ptr],
|
|
90
94
|
returns: FFIType.i32,
|