buninu-linux 0.3.1 → 0.5.1

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 (53) hide show
  1. package/LICENSES/Apache-2.0.txt +202 -0
  2. package/LICENSES/BSD-3-Clause-Skia.txt +29 -0
  3. package/LICENSES/Bitstream-Vera.txt +44 -0
  4. package/LICENSES/MIT-xterm.js.txt +21 -0
  5. package/LICENSES/OFL-1.1.txt +91 -0
  6. package/NOTICE.md +18 -1
  7. package/README.md +101 -9
  8. package/fetch-alpine.sh +6 -0
  9. package/graphics.md +218 -0
  10. package/initramfs/bin/bunterm +70 -0
  11. package/initramfs/bin/chroot +213 -0
  12. package/initramfs/bin/stripansi +27 -0
  13. package/initramfs/bin/tar +189 -14
  14. package/initramfs/buninu/CHANGELOG.md +40 -0
  15. package/initramfs/buninu/apps/jsgotty/gotty.js +4 -3
  16. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  17. package/initramfs/buninu/package.json +1 -1
  18. package/initramfs/init.js +80 -1
  19. package/initramfs/lib/bunterm/glyphs.js +446 -0
  20. package/initramfs/lib/bunterm/images.js +225 -0
  21. package/initramfs/lib/bunterm/input.js +140 -0
  22. package/initramfs/lib/bunterm/mouse.js +222 -0
  23. package/initramfs/lib/bunterm/renderer.js +469 -0
  24. package/initramfs/lib/bunterm/terminal.js +324 -0
  25. package/initramfs/lib/canvas.js +181 -0
  26. package/initramfs/lib/canvaskit/LICENSE +29 -0
  27. package/initramfs/lib/canvaskit/canvaskit.js +264 -0
  28. package/initramfs/lib/canvaskit/canvaskit.wasm +0 -0
  29. package/initramfs/lib/dlopen.js +18 -2
  30. package/initramfs/lib/document.js +5 -1
  31. package/initramfs/lib/fbdev.js +581 -0
  32. package/initramfs/lib/xterm/LICENSE +21 -0
  33. package/initramfs/lib/xterm/addon-unicode-graphemes.mjs +44 -0
  34. package/initramfs/lib/xterm/xterm-headless.mjs +46 -0
  35. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +112 -0
  36. package/initramfs/usr/share/doc/buninu-linux/canvas.md +91 -0
  37. package/initramfs/usr/share/doc/buninu-linux/chroot.md +105 -0
  38. package/initramfs/usr/share/doc/buninu-linux/fbdev.md +113 -0
  39. package/initramfs/usr/share/doc/buninu-linux/tar.md +30 -6
  40. package/initramfs/usr/share/fonts/DejaVuSansMono-Bold.ttf +0 -0
  41. package/initramfs/usr/share/fonts/DejaVuSansMono.ttf +0 -0
  42. package/initramfs/usr/share/fonts/LICENSE-Apache-2.0.txt +202 -0
  43. package/initramfs/usr/share/fonts/LICENSE-DejaVu.txt +44 -0
  44. package/initramfs/usr/share/fonts/LICENSE-OFL-1.1.txt +91 -0
  45. package/initramfs/usr/share/fonts/NotoColorEmoji.ttf +0 -0
  46. package/initramfs/usr/share/fonts/NotoColorEmojiFlags.ttf +0 -0
  47. package/initramfs/usr/share/fonts/NotoSansCJK-Regular.ttc +0 -0
  48. package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted.ttf +0 -0
  49. package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted2.ttf +0 -0
  50. package/initramfs/usr/share/fonts/Roboto-Regular.ttf +0 -0
  51. package/initramfs/usr/share/fonts/fonts.json +28 -0
  52. package/package.json +2 -2
  53. package/run-qemu.sh +1 -1
package/initramfs/bin/tar CHANGED
@@ -2,7 +2,8 @@
2
2
  // tar — a small tar/gzip/zstd CLI over Bun.Archive.
3
3
 
4
4
  import { lstat, readdir } from "node:fs/promises";
5
- import { basename, join, resolve } from "node:path";
5
+ import { linkSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
6
+ import { basename, dirname, join, resolve } from "node:path";
6
7
  import { showDocument } from "../lib/document.js";
7
8
 
8
9
  const fail = (message) => {
@@ -89,16 +90,186 @@ const archiveBytes = async () => {
89
90
  return zstd ? Bun.zstdDecompressSync(bytes) : bytes;
90
91
  };
91
92
 
93
+ // ----------------------------------------------------------- tar headers --
94
+ //
95
+ // Bun.Archive extracts files, and symbolic links only when their target is
96
+ // relative and stays inside the extraction directory, which drops every
97
+ // absolute target (`/bin/busybox` — 306 of the 335 links in an Alpine
98
+ // minirootfs), every hard link, and every non-regular entry in
99
+ // `Archive.files()`. The header scan below reads the archive itself, so
100
+ // listing is complete and the links can be restored afterwards. Formats:
101
+ // POSIX ustar, GNU long names (`L`/`K`) and pax extended headers (`x`).
102
+
103
+ const BLOCK = 512;
104
+ const decoder = new TextDecoder();
105
+
106
+ const text = (block, offset, length) => {
107
+ let end = offset;
108
+ const stop = offset + length;
109
+ while (end < stop && block[end] !== 0) end++;
110
+ return decoder.decode(block.subarray(offset, end));
111
+ };
112
+
113
+ const octal = (block, offset, length) => {
114
+ // GNU base-256 encoding for values that do not fit the octal field.
115
+ if (block[offset] & 0x80) {
116
+ let value = block[offset] & 0x7f;
117
+ for (let index = offset + 1; index < offset + length; index++) value = value * 256 + block[index];
118
+ return value;
119
+ }
120
+ const digits = text(block, offset, length).trim();
121
+ const value = Number.parseInt(digits, 8);
122
+ return Number.isFinite(value) ? value : 0;
123
+ };
124
+
125
+ // The header checksum is computed with its own field read as spaces. A
126
+ // mismatch means this is not a tar header, so scanning stops there.
127
+ const checksumMatches = (block) => {
128
+ let sum = 0;
129
+ for (let index = 0; index < BLOCK; index++) {
130
+ sum += index >= 148 && index < 156 ? 0x20 : block[index];
131
+ }
132
+ return sum === octal(block, 148, 8);
133
+ };
134
+
135
+ const paxRecords = (data) => {
136
+ const records = {};
137
+ let offset = 0;
138
+ const content = decoder.decode(data);
139
+ while (offset < content.length) {
140
+ const space = content.indexOf(" ", offset);
141
+ if (space === -1) break;
142
+ const length = Number.parseInt(content.slice(offset, space), 10);
143
+ if (!Number.isFinite(length) || length <= 0) break;
144
+ const record = content.slice(space + 1, offset + length).replace(/\n$/, "");
145
+ const equals = record.indexOf("=");
146
+ if (equals > 0) records[record.slice(0, equals)] = record.slice(equals + 1);
147
+ offset += length;
148
+ }
149
+ return records;
150
+ };
151
+
152
+ const entryKinds = {
153
+ "0": "file", "\0": "file", "1": "hardlink", "2": "symlink", "3": "character",
154
+ "4": "block", "5": "directory", "6": "fifo", "7": "file",
155
+ };
156
+
157
+ // Every member of the archive, in order, as { name, kind, target, size, mode,
158
+ // mtime }. Returns null when the bytes do not parse as tar.
159
+ const scanEntries = (bytes) => {
160
+ const entries = [];
161
+ let offset = 0;
162
+ let longName = null;
163
+ let longTarget = null;
164
+ let extended = {};
165
+ while (offset + BLOCK <= bytes.length) {
166
+ const block = bytes.subarray(offset, offset + BLOCK);
167
+ offset += BLOCK;
168
+ if (block.every((byte) => byte === 0)) break; // end-of-archive blocks
169
+ if (!checksumMatches(block)) return entries.length ? entries : null;
170
+ const flag = String.fromCharCode(block[156] || 0x30);
171
+ const size = extended.size !== undefined ? Number(extended.size) : octal(block, 124, 12);
172
+ const dataBlocks = Math.ceil(size / BLOCK) * BLOCK;
173
+ const data = bytes.subarray(offset, offset + size);
174
+ if (flag === "L" || flag === "K") {
175
+ const value = decoder.decode(data).replace(/\0+$/, "");
176
+ if (flag === "L") longName = value; else longTarget = value;
177
+ offset += dataBlocks;
178
+ continue;
179
+ }
180
+ if (flag === "x" || flag === "X") {
181
+ extended = { ...extended, ...paxRecords(data) };
182
+ offset += dataBlocks;
183
+ continue;
184
+ }
185
+ if (flag === "g") {
186
+ offset += dataBlocks;
187
+ continue;
188
+ }
189
+ const prefix = text(block, 345, 155);
190
+ const stored = text(block, 0, 100);
191
+ const name = extended.path ?? longName ?? (prefix ? `${prefix}/${stored}` : stored);
192
+ const target = extended.linkpath ?? longTarget ?? text(block, 157, 100);
193
+ entries.push({
194
+ name: name.replace(/^\.\//, ""),
195
+ kind: entryKinds[flag] ?? "file",
196
+ target,
197
+ size,
198
+ mode: octal(block, 100, 8) & 0o7777,
199
+ mtime: octal(block, 136, 12) * 1000,
200
+ });
201
+ longName = null;
202
+ longTarget = null;
203
+ extended = {};
204
+ offset += dataBlocks;
205
+ }
206
+ return entries;
207
+ };
208
+
209
+ const tarBytes = (bytes) => {
210
+ if (bytes[0] === 0x1f && bytes[1] === 0x8b) return Bun.gunzipSync(bytes);
211
+ if (bytes[0] === 0x28 && bytes[1] === 0xb5 && bytes[2] === 0x2f && bytes[3] === 0xfd) {
212
+ return Bun.zstdDecompressSync(bytes);
213
+ }
214
+ return bytes;
215
+ };
216
+
217
+ // Where a member lands, following GNU tar: a leading `/` is stripped, and a
218
+ // name that climbs out with `..` is refused. A link's target is never
219
+ // examined — an absolute symbolic link is a normal thing for a root
220
+ // filesystem to contain — and since links are made after every file, nothing
221
+ // is ever written through one.
222
+ const memberName = (name) => {
223
+ const stripped = name.replace(/^\/+/, "");
224
+ const parts = stripped.split("/");
225
+ if (parts.includes("..")) return null;
226
+ return stripped || null;
227
+ };
228
+
92
229
  const patterns = () => options.operands.flatMap((name) => {
93
230
  const clean = name.replace(/^\.\//, "").replace(/\/$/, "");
94
231
  return [clean, `${clean}/**`];
95
232
  });
96
233
 
97
- const verboseListing = (name, file) => {
98
- const modified = Number.isFinite(file.lastModified) && file.lastModified > 0
99
- ? new Date(file.lastModified).toISOString().replace("T", " ").slice(0, 16)
234
+ const kindMarks = { directory: "/", symlink: "@", fifo: "|", character: "#", block: "#" };
235
+
236
+ const verboseListing = (entry) => {
237
+ const modified = entry.mtime > 0
238
+ ? new Date(entry.mtime).toISOString().replace("T", " ").slice(0, 16)
100
239
  : "---- -- -- --:--";
101
- return `${String(file.size).padStart(12)} ${modified} ${name}`;
240
+ const suffix = entry.kind === "symlink" ? ` -> ${entry.target}`
241
+ : entry.kind === "hardlink" ? ` link to ${entry.target}`
242
+ : "";
243
+ return `${String(entry.size).padStart(12)} ${modified} ${entry.name}${kindMarks[entry.kind] ?? ""}${suffix}`;
244
+ };
245
+
246
+ // Creates the links Bun.Archive left out. Files are already in place, so a
247
+ // hard link finds its target and a symbolic link cannot be written through.
248
+ const restoreLinks = (entries) => {
249
+ for (const entry of entries) {
250
+ if (entry.kind !== "symlink" && entry.kind !== "hardlink") continue;
251
+ const name = memberName(entry.name);
252
+ if (!name || !entry.target) continue;
253
+ const path = resolve(options.directory, name);
254
+ try {
255
+ mkdirSync(dirname(path), { recursive: true });
256
+ try {
257
+ if (entry.kind === "symlink") symlinkSync(entry.target, path);
258
+ else linkSync(resolve(options.directory, memberName(entry.target) ?? entry.target), path);
259
+ } catch (error) {
260
+ if (error.code !== "EEXIST") throw error;
261
+ // GNU tar replaces what is in the way rather than following it.
262
+ rmSync(path, { force: true });
263
+ if (entry.kind === "symlink") symlinkSync(entry.target, path);
264
+ else linkSync(resolve(options.directory, memberName(entry.target) ?? entry.target), path);
265
+ }
266
+ } catch (error) {
267
+ // A hard link whose target was filtered out by a member pattern is
268
+ // expected; anything else is worth reporting without stopping.
269
+ if (entry.kind === "hardlink" && error.code === "ENOENT" && options.operands.length) continue;
270
+ console.error(`tar: ${entry.name}: ${error.message}`);
271
+ }
272
+ }
102
273
  };
103
274
 
104
275
  if (options.operation === "x" || options.operation === "t") {
@@ -109,22 +280,26 @@ if (options.operation === "x" || options.operation === "t") {
109
280
  fail(error?.message || String(error));
110
281
  }
111
282
 
283
+ // The header scan is what makes listing and links complete; if the bytes
284
+ // do not parse as tar, fall back to what Bun.Archive reports.
285
+ const scanned = scanEntries(tarBytes(await archiveBytes()));
286
+ const wanted = options.operands.length ? patterns().map((pattern) => new Bun.Glob(pattern)) : null;
287
+ const members = scanned
288
+ ? scanned.filter((entry) => !wanted || wanted.some((glob) => glob.match(entry.name)))
289
+ : [...(await archive.files(options.operands.length ? patterns() : undefined))]
290
+ .map(([name, file]) => ({ name, kind: "file", target: "", size: file.size, mode: 0o644, mtime: file.lastModified }));
291
+
112
292
  if (options.operation === "t") {
113
- try {
114
- const files = await archive.files(options.operands.length ? patterns() : undefined);
115
- for (const [name, file] of files) {
116
- console.log(options.verbose ? verboseListing(name, file) : name);
117
- }
118
- } catch (error) {
119
- fail(error?.message || String(error));
293
+ for (const entry of members) {
294
+ console.log(options.verbose ? verboseListing(entry) : entry.name);
120
295
  }
121
296
  } else {
122
297
  try {
123
298
  if (options.verbose) {
124
- const files = await archive.files(options.operands.length ? patterns() : undefined);
125
- for (const name of files.keys()) console.log(name);
299
+ for (const entry of members) console.log(entry.name);
126
300
  }
127
301
  await archive.extract(options.directory, options.operands.length ? { glob: patterns() } : undefined);
302
+ restoreLinks(members);
128
303
  } catch (error) {
129
304
  fail(error?.message || String(error));
130
305
  }
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.15 - 2026-09-22
4
+
5
+ ### Changed
6
+
7
+ - Sync jsgotty to 1.1.12
8
+ * Export `BunPtyBackend` from `gotty.js`, the PTY layer built on
9
+ `Bun.spawn({ terminal })`, so other programs can run a command on a
10
+ pseudo-terminal without their own FFI. Buninu Linux's `bunterm`, a
11
+ graphical terminal on the framebuffer, is its first user, together with
12
+ the already exported `KittyGraphicsParser`
13
+
14
+ ### Fixed
15
+
16
+ - jsgotty: a chunked kitty graphics transfer whose first chunk names an
17
+ image id (`i=`) is now continued by later chunks that carry only `m=`, as
18
+ the protocol allows. Previously those chunks were treated as new anonymous
19
+ transfers, so the image never completed. jsgotty's own `showimg` repeats
20
+ the id in every chunk and was unaffected; `kitten icat`, timg, chafa and
21
+ similar clients were
22
+
23
+ ## 0.4.13 - 2026-09-22
24
+
25
+ ### Changed
26
+
27
+ - Sync bunmsh to 0.3.9
28
+ * `Ctrl-U` removes and saves the text before the cursor; pressing it again
29
+ at the beginning of the line restores it. `Ctrl-K` independently toggles
30
+ the text after the cursor when at the end of the line
31
+ * Unquoted pathname patterns follow directory symbolic links used as
32
+ intermediate path components, as traditional shells do, so patterns such
33
+ as `/sys/class/net/*/device` pass through sysfs class links
34
+ * The fallback `ls`/`lsfancy` lists a glob's files as one listing instead
35
+ of one section per file, honors `--`, and follows a command-line symlink
36
+ to a directory
37
+ - Sync jsmdcui to 0.19.2
38
+ * Pressing `Ctrl-W` twice quickly switches to the next tab like `Alt-T`
39
+ from either an editor or a terminal pane; at the rightmost tab, when every
40
+ tab holds only terminal panes, it opens a new editor tab instead of
41
+ cycling back
42
+
3
43
  ## 0.4.12 - 2026-09-20
4
44
 
5
45
  ### Fixed
@@ -1173,9 +1173,9 @@ class KittyGraphicsParser {
1173
1173
 
1174
1174
  if (control.m === "1") {
1175
1175
  this.transfers.set(key, existing);
1176
- if (!control.i && !control.I) {
1177
- this.implicitTransferKey = key;
1178
- }
1176
+ // Later chunks of a transfer only need m=; they continue the transfer
1177
+ // in progress whether or not its first chunk named an image id.
1178
+ this.implicitTransferKey = key;
1179
1179
  return null;
1180
1180
  }
1181
1181
 
@@ -3314,6 +3314,7 @@ async function bootstrap() {
3314
3314
 
3315
3315
  module.exports.CursorStateTracker = CursorStateTracker;
3316
3316
  module.exports.KittyGraphicsParser = KittyGraphicsParser;
3317
+ module.exports.BunPtyBackend = BunPtyBackend;
3317
3318
  module.exports.webSocketOriginAllowed = webSocketOriginAllowed;
3318
3319
 
3319
3320
  if (require.main === module) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsgotty",
3
- "version": "1.1.11",
3
+ "version": "1.1.12",
4
4
  "description": "Share your terminal as a web application: A JavaScript rewrite of GoTTY",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "Buninu is a portable, self-bootstrapping Unix-like userspace built on Bun that runs across operating systems",
5
5
  "type": "module",
6
6
  "main": "./bin/init.js",
package/initramfs/init.js CHANGED
@@ -94,6 +94,79 @@ const mountFilesystems = () => {
94
94
  mountFilesystems();
95
95
  console.log("init.js: mounted /proc, /sys, /dev, /tmp, /dev/pts");
96
96
 
97
+ // Num Lock. The keypad's lock state lives per virtual console in the
98
+ // kernel's keyboard table, and the kernel starts every console with it off,
99
+ // so the keypad types arrows instead of digits. KDSKBLED carries the current
100
+ // flags in its low nibble and the ones a console is reset to in its high
101
+ // nibble; KDSETLED with a value above 7 puts the physical LED back to
102
+ // following those flags. A console allocated later (Ctrl-Alt-F3 for the
103
+ // first time) starts from the kernel default again, so numlock() is left in
104
+ // the REPL for that case.
105
+ const KDSETLED = 0x4b32;
106
+ const KDGKBLED = 0x4b64;
107
+ const KDSKBLED = 0x4b65;
108
+ const LED_NUM = 0x02;
109
+
110
+ // Nothing here is essential to booting, so a console that is not a virtual
111
+ // terminal, a missing device node or a rejected ioctl is skipped rather than
112
+ // reported: the remaining consoles are still set, and the system comes up
113
+ // either way.
114
+ const setNumLock = (enabled = true) => {
115
+ const consoles = [];
116
+ const paths = ["/dev/tty0", "/dev/console", "/dev/tty"];
117
+ for (let index = 1; index <= 12; index++) paths.push(`/dev/tty${index}`);
118
+ for (const path of paths) {
119
+ let fd = -1;
120
+ try {
121
+ fd = libc.symbols.open(cString(path), 2, 0);
122
+ if (fd < 0) continue;
123
+ const state = new Uint8Array(1);
124
+ if (libc.symbols.ioctl(fd, KDGKBLED, ptr(state)) !== 0) continue;
125
+ const current = enabled ? state[0] | LED_NUM : state[0] & ~LED_NUM;
126
+ const fallback = enabled ? (state[0] >> 4) | LED_NUM : (state[0] >> 4) & ~LED_NUM;
127
+ const flags = (current & 7) | ((fallback & 7) << 4);
128
+ if (libc.symbols.ioctl(fd, KDSKBLED, flags) !== 0) continue;
129
+ libc.symbols.ioctl(fd, KDSETLED, 0x80);
130
+ consoles.push(path);
131
+ } catch {
132
+ // This console keeps whatever state it had; the others still get set.
133
+ } finally {
134
+ if (fd >= 0) {
135
+ try { libc.symbols.close(fd); } catch {}
136
+ }
137
+ }
138
+ }
139
+ return consoles;
140
+ };
141
+
142
+ globalThis.numlock = setNumLock;
143
+ try {
144
+ const consoles = setNumLock(true);
145
+ console.log(consoles.length
146
+ ? `init.js: num lock on (${consoles.join(" ")})`
147
+ : "init.js: no virtual console accepted a num lock setting");
148
+ } catch (error) {
149
+ console.error(`init.js: num lock: ${error?.message ?? error}`);
150
+ }
151
+
152
+ // The input layer's character devices, so a pointing device has a
153
+ // /dev/input/event* node for `bunterm --mouse` to read. Loading them only
154
+ // creates those nodes: the console keyboard is unaffected, and nothing reads
155
+ // a pointer unless it is asked to. A USB mouse also needs the usbhid stack,
156
+ // which `cfg.all` or a --real boot brings in.
157
+ try {
158
+ const { tryModprobe } = await import("/lib/modprobe.js");
159
+ const loaded = ["evdev", "psmouse"].filter((module) => tryModprobe(module) !== null);
160
+ const { readdirSync } = await import("node:fs");
161
+ const devices = (() => {
162
+ try { return readdirSync("/dev/input").filter((name) => name.startsWith("event")); } catch { return []; }
163
+ })();
164
+ console.log(`init.js: input modules ${loaded.length ? loaded.join(" ") : "none"}`
165
+ + ` (${devices.length} event device${devices.length === 1 ? "" : "s"})`);
166
+ } catch (error) {
167
+ console.error(`init.js: input modules: ${error?.message ?? error}`);
168
+ }
169
+
97
170
  const { default: repl } = await import("node:repl");
98
171
  const { mkdirSync, writeFileSync } = await import("node:fs");
99
172
 
@@ -341,6 +414,12 @@ const launchBunmsh = () => {
341
414
 
342
415
  const launchBuninu = () => {
343
416
 
417
+ console.log(`
418
+ Type bunterm in bunmsh: Graphical Terminal
419
+ Shows Emojis, CJK chars
420
+ Supports Kitty Graphics Protocol
421
+ `)
422
+
344
423
  const entry = `/buninu/bin/init.js`;
345
424
  console.log(`buninu: Starting ${entry} --local`);
346
425
  const shell = Bun.spawnSync([
@@ -348,7 +427,7 @@ const launchBuninu = () => {
348
427
  ], {
349
428
  env: {
350
429
  ...process.env,
351
- PATH:"/bin:/usr/bin:/buninu/bin",
430
+ PATH:"/bin:/sbin:/usr/bin:/usr/sbin:/buninu/.bun/bin:/buninu/bin",
352
431
  HOME:"/buninu"
353
432
  },
354
433
  stdin: "inherit",