buninu-linux 0.3.1 → 0.4.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 (45) 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 +48 -1
  8. package/graphics.md +213 -0
  9. package/initramfs/bin/bunterm +66 -0
  10. package/initramfs/buninu/CHANGELOG.md +40 -0
  11. package/initramfs/buninu/apps/jsgotty/gotty.js +4 -3
  12. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  13. package/initramfs/buninu/package.json +1 -1
  14. package/initramfs/init.js +6 -0
  15. package/initramfs/lib/bunterm/glyphs.js +446 -0
  16. package/initramfs/lib/bunterm/images.js +225 -0
  17. package/initramfs/lib/bunterm/input.js +140 -0
  18. package/initramfs/lib/bunterm/renderer.js +424 -0
  19. package/initramfs/lib/bunterm/terminal.js +203 -0
  20. package/initramfs/lib/canvas.js +181 -0
  21. package/initramfs/lib/canvaskit/LICENSE +29 -0
  22. package/initramfs/lib/canvaskit/canvaskit.js +264 -0
  23. package/initramfs/lib/canvaskit/canvaskit.wasm +0 -0
  24. package/initramfs/lib/dlopen.js +14 -2
  25. package/initramfs/lib/fbdev.js +581 -0
  26. package/initramfs/lib/xterm/LICENSE +21 -0
  27. package/initramfs/lib/xterm/addon-unicode-graphemes.mjs +44 -0
  28. package/initramfs/lib/xterm/xterm-headless.mjs +46 -0
  29. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +83 -0
  30. package/initramfs/usr/share/doc/buninu-linux/canvas.md +91 -0
  31. package/initramfs/usr/share/doc/buninu-linux/fbdev.md +113 -0
  32. package/initramfs/usr/share/fonts/DejaVuSansMono-Bold.ttf +0 -0
  33. package/initramfs/usr/share/fonts/DejaVuSansMono.ttf +0 -0
  34. package/initramfs/usr/share/fonts/LICENSE-Apache-2.0.txt +202 -0
  35. package/initramfs/usr/share/fonts/LICENSE-DejaVu.txt +44 -0
  36. package/initramfs/usr/share/fonts/LICENSE-OFL-1.1.txt +91 -0
  37. package/initramfs/usr/share/fonts/NotoColorEmoji.ttf +0 -0
  38. package/initramfs/usr/share/fonts/NotoColorEmojiFlags.ttf +0 -0
  39. package/initramfs/usr/share/fonts/NotoSansCJK-Regular.ttc +0 -0
  40. package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted.ttf +0 -0
  41. package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted2.ttf +0 -0
  42. package/initramfs/usr/share/fonts/Roboto-Regular.ttf +0 -0
  43. package/initramfs/usr/share/fonts/fonts.json +28 -0
  44. package/package.json +2 -2
  45. package/run-qemu.sh +1 -1
@@ -0,0 +1,203 @@
1
+ // /lib/bunterm/terminal.js — a graphical terminal on the Linux framebuffer.
2
+ //
3
+ // Bun.spawn({ terminal }) ──── PTY output ────▶ KittyGraphicsParser
4
+ // ▲ │ plain │ APC
5
+ // │ keyboard / replies ▼ ▼
6
+ // jsgotty BunPtyBackend @xterm/headless Terminal ImageStore
7
+ // │ buffer │ placements
8
+ // ▼ ▼
9
+ // Renderer (CanvasKit) ──▶ /dev/fb0
10
+ //
11
+ // The PTY, the terminal emulation and the kitty packet parser are reused
12
+ // from jsgotty and xterm.js; only the cell renderer, the images and the
13
+ // keyboard are Buninu's own.
14
+
15
+ import { createRequire } from "node:module";
16
+ import { runGraphics } from "../fbdev.js";
17
+ import { loadCanvasKit, openCanvas, loadFontSet } from "../canvas.js";
18
+ import { Terminal } from "../xterm/xterm-headless.mjs";
19
+ import { UnicodeGraphemesAddon } from "../xterm/addon-unicode-graphemes.mjs";
20
+ import { Renderer, defaultTheme } from "./renderer.js";
21
+ import { ImageStore } from "./images.js";
22
+ import { openKeyboard, translateKeys } from "./input.js";
23
+
24
+ const require = createRequire(import.meta.url);
25
+
26
+ // jsgotty is CommonJS and guards its server start on require.main, so this
27
+ // only loads the classes.
28
+ export const loadJsgotty = () => {
29
+ const gotty = require("../../buninu/apps/jsgotty/gotty.js");
30
+ if (!gotty.BunPtyBackend) {
31
+ throw new Error("this jsgotty does not export BunPtyBackend; bunterm needs jsgotty ≥ 1.1.12");
32
+ }
33
+ return gotty;
34
+ };
35
+
36
+ const writeTerminal = (term, data) => new Promise((resolve) => term.write(data, resolve));
37
+
38
+ // One terminal session: a program on a PTY, the emulator fed from it, and a
39
+ // renderer painting onto `screen` (an open canvas from canvas.js). Frames
40
+ // are coalesced, so whatever arrives between two paints is drawn together,
41
+ // at most ~60 times a second. `exited` resolves with the program's status.
42
+ export const createSession = ({
43
+ CanvasKit,
44
+ screen,
45
+ fonts,
46
+ command = "/bin/sh",
47
+ argv = [],
48
+ fontSize = 16,
49
+ lineHeight = 1,
50
+ theme = defaultTheme,
51
+ scrollback = 1000,
52
+ cursorBlink = true,
53
+ env = {},
54
+ onError = (error) => console.error(`bunterm: ${error?.stack ?? error}`),
55
+ }) => {
56
+ const { BunPtyBackend, KittyGraphicsParser } = loadJsgotty();
57
+ const renderer = new Renderer({ CanvasKit, screen, fonts, fontSize, lineHeight, theme });
58
+ const term = new Terminal({
59
+ cols: renderer.columns,
60
+ rows: renderer.rows,
61
+ allowProposedApi: true,
62
+ scrollback,
63
+ });
64
+ term.loadAddon(new UnicodeGraphemesAddon());
65
+ term.unicode.activeVersion = "15-graphemes";
66
+ const images = new ImageStore({ CanvasKit, term, renderer });
67
+ const kitty = new KittyGraphicsParser();
68
+
69
+ const backend = new BunPtyBackend({
70
+ command,
71
+ argv,
72
+ width: renderer.columns,
73
+ height: renderer.rows,
74
+ closeSignal: "SIGHUP",
75
+ closeTimeout: -1,
76
+ // JSMDCUI_KITTY_MODE=extended: jsmdcui sends Markdown images as kitty
77
+ // placements with the original JPEG/WebP/GIF bytes (jsgotty's U= MIME
78
+ // extension); Skia decodes those directly, so nothing is transcoded.
79
+ headerEnv: { COLORTERM: "truecolor", TERM_PROGRAM: "bunterm", JSMDCUI_KITTY_MODE: "extended", ...env },
80
+ });
81
+
82
+ let frameTimer = null;
83
+ let blinkOn = true;
84
+ const cursorHidden = () => Boolean(term._core?.coreService?.isCursorHidden);
85
+ const paint = () => {
86
+ if (frameTimer) clearTimeout(frameTimer);
87
+ frameTimer = null;
88
+ const buffer = term.buffer.active;
89
+ return renderer.render(term, {
90
+ cursor: { x: buffer.cursorX, y: buffer.cursorY, visible: blinkOn && !cursorHidden() },
91
+ images: images.visible(),
92
+ });
93
+ };
94
+ const requestFrame = () => {
95
+ frameTimer ??= setTimeout(paint, 16);
96
+ };
97
+ const blinkTimer = cursorBlink
98
+ ? setInterval(() => { blinkOn = !blinkOn; requestFrame(); }, 500)
99
+ : null;
100
+ const resetBlink = () => {
101
+ blinkOn = true;
102
+ if (blinkTimer) blinkTimer.refresh();
103
+ };
104
+
105
+ // Output is processed in order: plain text is parsed before the kitty
106
+ // packet that follows it, so placements see the right cursor position.
107
+ let queue = Promise.resolve();
108
+ const pump = (chunk) => {
109
+ queue = queue.then(async () => {
110
+ const { events } = kitty.consume(chunk);
111
+ for (const event of events) {
112
+ if (event.kind === "plain") {
113
+ await writeTerminal(term, event.data);
114
+ continue;
115
+ }
116
+ const buffer = term.buffer.active;
117
+ const graphic = kitty.parsePacket(event.packet, { row: buffer.cursorY + 1, col: buffer.cursorX + 1 });
118
+ if (!graphic) continue;
119
+ const motion = await images.handle(graphic);
120
+ if (motion) await writeTerminal(term, motion);
121
+ const ack = kitty.acknowledge(graphic);
122
+ if (ack) backend.write(Buffer.from(ack, "utf8"));
123
+ }
124
+ requestFrame();
125
+ }).catch(onError);
126
+ };
127
+
128
+ let closed = false;
129
+ const exited = new Promise((resolve) => {
130
+ backend.onExit(() => { queue.then(() => resolve(backend.proc?.exitCode ?? 0)); });
131
+ });
132
+ backend.onData(pump);
133
+ term.onData((data) => backend.write(Buffer.from(data, "utf8")));
134
+
135
+ // Keyboard bytes from the console, rewritten for the program's key mode.
136
+ const input = (bytes) => {
137
+ const applicationCursorKeys = Boolean(term._core?.coreService?.decPrivateModes?.applicationCursorKeys);
138
+ const text = translateKeys(bytes.toString("latin1"), { applicationCursorKeys });
139
+ backend.write(Buffer.from(text, "latin1"));
140
+ resetBlink();
141
+ requestFrame();
142
+ };
143
+
144
+ // Waits until every byte received so far has been parsed, then paints.
145
+ const settle = async () => {
146
+ await queue;
147
+ return paint();
148
+ };
149
+
150
+ const close = () => {
151
+ if (closed) return;
152
+ closed = true;
153
+ if (blinkTimer) clearInterval(blinkTimer);
154
+ if (frameTimer) clearTimeout(frameTimer);
155
+ frameTimer = null;
156
+ backend.close();
157
+ images.delete();
158
+ renderer.delete();
159
+ term.dispose();
160
+ };
161
+
162
+ paint();
163
+ return { term, backend, renderer, images, kitty, paint, requestFrame, settle, input, exited, close };
164
+ };
165
+
166
+ // Runs a session on the framebuffer with the console in graphics mode and
167
+ // the keyboard in raw mode, until the program exits. `console` names the
168
+ // virtual console to use (for example /dev/tty1); by default the process's
169
+ // own console and stdin are used.
170
+ export const runTerminal = async ({ console: consoleDevice = null, device = "/dev/fb0", ...options } = {}) => {
171
+ loadJsgotty();
172
+ const CanvasKit = await loadCanvasKit();
173
+ const fonts = loadFontSet(CanvasKit, "terminal");
174
+ const consolePaths = consoleDevice ? [consoleDevice] : undefined;
175
+
176
+ return runGraphics(async (display, { onRestore, onAcquire }) => {
177
+ const screen = openCanvas(CanvasKit, display);
178
+ const session = createSession({ CanvasKit, screen, fonts, ...options });
179
+ // Coming back from another console (Ctrl-Alt-Fn): fbcon has drawn over
180
+ // the framebuffer, so repaint everything.
181
+ onAcquire(() => {
182
+ session.renderer.invalidate();
183
+ session.paint();
184
+ });
185
+ let keyboard = null;
186
+ try {
187
+ keyboard = openKeyboard({
188
+ device: consoleDevice,
189
+ onData: session.input,
190
+ onError: (error) => {
191
+ console.error(`bunterm: keyboard: ${error.message}`);
192
+ session.backend.close();
193
+ },
194
+ });
195
+ onRestore(() => keyboard.close());
196
+ return await session.exited;
197
+ } finally {
198
+ keyboard?.close();
199
+ session.close();
200
+ screen.close();
201
+ }
202
+ }, { device, console: consolePaths });
203
+ };
@@ -0,0 +1,181 @@
1
+ // /lib/canvas.js — CanvasKit (Skia in WebAssembly) on the Linux framebuffer.
2
+ //
3
+ // Skia draws into a buffer inside the wasm heap that already has the
4
+ // framebuffer's own pixel layout, so a frame reaches /dev/fb0 with a single
5
+ // copy: exactly what /lib/fbdev.js does with its JavaScript back buffer. The
6
+ // Framebuffer's `pixels` view is pointed at that heap buffer while a canvas is
7
+ // open, so the fbdev.js drawing calls and flush() keep working on the same
8
+ // frame and Skia and JavaScript drawing can be mixed freely.
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import CanvasKitInit from "./canvaskit/canvaskit.js";
12
+ import { flush as flushFramebuffer } from "./fbdev.js";
13
+
14
+ export const canvasKitDirectory = `${import.meta.dir}/canvaskit`;
15
+ export const fontDirectory = `${import.meta.dir}/../usr/share/fonts`;
16
+
17
+ let canvasKitPromise = null;
18
+
19
+ // One CanvasKit instance per process; the wasm heap starts at 128 MiB.
20
+ export const loadCanvasKit = () => {
21
+ canvasKitPromise ??= CanvasKitInit({
22
+ wasmBinary: readFileSync(`${canvasKitDirectory}/canvaskit.wasm`),
23
+ });
24
+ return canvasKitPromise;
25
+ };
26
+
27
+ const fieldIs = (field, offset, length) =>
28
+ field.offset === offset && field.length === length;
29
+
30
+ // Maps the kernel's bitfield description to a Skia ColorType with the same
31
+ // byte order in memory. The fields are little-endian bit offsets, so 32-bit
32
+ // red@16 green@8 blue@0 is B,G,R,X in memory: Skia's BGRA_8888. Only formats
33
+ // Skia can rasterize into directly are accepted; others must go through
34
+ // blitImageData() in fbdev.js.
35
+ export const pickColorType = (CanvasKit, display) => {
36
+ const { red, green, blue } = display.fields;
37
+ if (display.bitsPerPixel === 32) {
38
+ if (fieldIs(red, 16, 8) && fieldIs(green, 8, 8) && fieldIs(blue, 0, 8)) {
39
+ return CanvasKit.ColorType.BGRA_8888;
40
+ }
41
+ if (fieldIs(red, 0, 8) && fieldIs(green, 8, 8) && fieldIs(blue, 16, 8)) {
42
+ return CanvasKit.ColorType.RGBA_8888;
43
+ }
44
+ }
45
+ if (display.bitsPerPixel === 16
46
+ && fieldIs(red, 11, 5) && fieldIs(green, 5, 6) && fieldIs(blue, 0, 5)) {
47
+ return CanvasKit.ColorType.RGB_565;
48
+ }
49
+ return null;
50
+ };
51
+
52
+ const describeFields = ({ red, green, blue }) =>
53
+ `red@${red.offset}/${red.length} green@${green.offset}/${green.length} blue@${blue.offset}/${blue.length}`;
54
+
55
+ // Binds a Skia raster surface to an open Framebuffer. `display` only needs
56
+ // the fields fbdev.js's Framebuffer exposes (width, height, stride,
57
+ // bitsPerPixel, fields, pixels), so a plain object backed by memory works for
58
+ // tests and off-screen rendering.
59
+ export const openCanvas = (CanvasKit, display) => {
60
+ const colorType = pickColorType(CanvasKit, display);
61
+ if (colorType === null) {
62
+ throw new Error(`no Skia color type for a ${display.bitsPerPixel} bpp framebuffer `
63
+ + `(${describeFields(display.fields)}); use blitImageData() instead`);
64
+ }
65
+ const length = display.stride * display.height;
66
+ const memory = CanvasKit.Malloc(Uint8Array, length);
67
+ const surface = CanvasKit.MakeRasterDirectSurface({
68
+ width: display.width,
69
+ height: display.height,
70
+ colorType,
71
+ alphaType: CanvasKit.AlphaType.Opaque,
72
+ colorSpace: CanvasKit.ColorSpace.SRGB,
73
+ }, memory, display.stride);
74
+ if (!surface) {
75
+ CanvasKit.Free(memory);
76
+ throw new Error("MakeRasterDirectSurface failed");
77
+ }
78
+ const previousPixels = display.pixels;
79
+ // Start from whatever the JavaScript back buffer held (usually zeros).
80
+ memory.toTypedArray().set(previousPixels.subarray(0, length));
81
+ display.pixels = memory.toTypedArray();
82
+ let open = true;
83
+ return {
84
+ CanvasKit,
85
+ display,
86
+ surface,
87
+ canvas: surface.getCanvas(),
88
+ colorType,
89
+ width: display.width,
90
+ height: display.height,
91
+ // Finishes pending Skia work and copies the frame to the framebuffer.
92
+ // toTypedArray() is re-read because wasm heap growth detaches old views.
93
+ flush() {
94
+ if (!open) throw new Error("canvas is closed");
95
+ surface.flush();
96
+ display.pixels = memory.toTypedArray();
97
+ flushFramebuffer(display);
98
+ },
99
+ // Releases the Skia surface and heap buffer; the Framebuffer stays open
100
+ // and gets its own back buffer back, holding the last frame.
101
+ close() {
102
+ if (!open) return;
103
+ open = false;
104
+ previousPixels.set(memory.toTypedArray().subarray(0, previousPixels.length));
105
+ display.pixels = previousPixels;
106
+ surface.delete();
107
+ CanvasKit.Free(memory);
108
+ },
109
+ };
110
+ };
111
+
112
+ // Font catalogue: /usr/share/fonts/fonts.json names every bundled file and
113
+ // the family sets built from them. Files load lazily, since the CJK
114
+ // collection alone is 32 MiB and is copied into the wasm heap.
115
+ let catalogue = null;
116
+
117
+ export const fontCatalogue = () => {
118
+ catalogue ??= JSON.parse(readFileSync(`${fontDirectory}/fonts.json`, "utf8"));
119
+ return catalogue;
120
+ };
121
+
122
+ export const fontPath = (name) => {
123
+ const entry = fontCatalogue().fonts[name];
124
+ if (!entry) throw new Error(`unknown font ${JSON.stringify(name)}`);
125
+ return `${fontDirectory}/${entry.file}`;
126
+ };
127
+
128
+ const fontData = new Map();
129
+
130
+ export const loadFontData = (name) => {
131
+ let data = fontData.get(name);
132
+ if (!data) {
133
+ const bytes = readFileSync(fontPath(name));
134
+ data = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
135
+ fontData.set(name, data);
136
+ }
137
+ return data;
138
+ };
139
+
140
+ // A named font set from fonts.json as CanvasKit objects:
141
+ // typefaces the "regular", "bold" and each fallback font as a Typeface
142
+ // (a collection such as Noto Sans CJK yields its preferred face)
143
+ // fontMgr every file of the set registered by family name, for the
144
+ // Paragraph API; a collection registers all of its families
145
+ // families family names for ParagraphStyle.textStyle.fontFamilies, in
146
+ // lookup order
147
+ export const loadFontSet = (CanvasKit, setName = "terminal") => {
148
+ const { sets, fonts } = fontCatalogue();
149
+ const set = sets[setName];
150
+ if (!set) throw new Error(`unknown font set ${JSON.stringify(setName)}`);
151
+ const names = [set.regular, set.bold, ...(set.fallback ?? [])].filter(Boolean);
152
+ const buffers = names.map(loadFontData);
153
+ const fontMgr = CanvasKit.FontMgr.FromData(...buffers);
154
+ if (!fontMgr) throw new Error("FontMgr.FromData failed");
155
+ const typefaces = {};
156
+ names.forEach((name, index) => {
157
+ // A collection's "preferred" family picks the face (Noto Sans CJK TC for
158
+ // Taiwanese glyph forms); a single-face file loads directly.
159
+ const preferred = fonts[name].preferred;
160
+ const typeface = preferred
161
+ ? fontMgr.matchFamilyStyle(preferred, {})
162
+ : CanvasKit.Typeface.MakeFreeTypeFaceFromData(buffers[index]);
163
+ if (!typeface) throw new Error(`cannot load font ${name} (${fonts[name].file})`);
164
+ typefaces[name] = typeface;
165
+ });
166
+ const families = names.flatMap((name) => fonts[name].families ?? [name]);
167
+ return {
168
+ set,
169
+ names,
170
+ typefaces,
171
+ regular: typefaces[set.regular],
172
+ bold: set.bold ? typefaces[set.bold] : null,
173
+ fallback: (set.fallback ?? []).map((name) => typefaces[name]),
174
+ fontMgr,
175
+ families,
176
+ delete() {
177
+ for (const typeface of Object.values(typefaces)) typeface.delete();
178
+ fontMgr.delete();
179
+ },
180
+ };
181
+ };
@@ -0,0 +1,29 @@
1
+ // Copyright (c) 2011 Google Inc. All rights reserved.
2
+ //
3
+ // Redistribution and use in source and binary forms, with or without
4
+ // modification, are permitted provided that the following conditions are
5
+ // met:
6
+ //
7
+ // * Redistributions of source code must retain the above copyright
8
+ // notice, this list of conditions and the following disclaimer.
9
+ // * Redistributions in binary form must reproduce the above
10
+ // copyright notice, this list of conditions and the following disclaimer
11
+ // in the documentation and/or other materials provided with the
12
+ // distribution.
13
+ // * Neither the name of Google Inc. nor the names of its
14
+ // contributors may be used to endorse or promote products derived from
15
+ // this software without specific prior written permission.
16
+ //
17
+ // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18
+ // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19
+ // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20
+ // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21
+ // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22
+ // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23
+ // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24
+ // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25
+ // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
+ // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
+
29
+ --------------------------------------------------------------------------------