buninu-linux 0.14.57 → 0.15.58

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 (94) hide show
  1. package/LICENSES/linux-firmware/LICENCE.nvidia +131 -0
  2. package/LICENSES/linux-firmware/LICENSE.amdgpu +51 -0
  3. package/LICENSES/linux-firmware/LICENSE.amdisp +31 -0
  4. package/LICENSES/linux-firmware/LICENSE.i915 +39 -0
  5. package/LICENSES/linux-firmware/LICENSE.radeon +51 -0
  6. package/LICENSES/linux-firmware/LICENSE.xe +39 -0
  7. package/LICENSES/linux-firmware/WHENCE +10280 -0
  8. package/NOTICE.md +64 -46
  9. package/README.md +371 -87
  10. package/README.zh.md +2034 -0
  11. package/build-image.sh +10 -1
  12. package/build-uki.sh +3 -1
  13. package/fetch-alpine.sh +14 -0
  14. package/graphics.md +31 -0
  15. package/index.js +28 -11
  16. package/initramfs/bin/buninu-browser +4 -1
  17. package/initramfs/bin/buninu-land +1 -1
  18. package/initramfs/bin/bunterm +3 -3
  19. package/initramfs/bin/chroot +13 -0
  20. package/initramfs/bin/chvt +11 -0
  21. package/initramfs/bin/mount +4 -0
  22. package/initramfs/bin/umount +6 -0
  23. package/initramfs/bin/xdg-open +33 -0
  24. package/initramfs/buninu/CHANGELOG.md +19 -0
  25. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +4 -0
  26. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  27. package/initramfs/buninu/apps/bunmsh/src/main.js +1 -1
  28. package/initramfs/buninu/apps/jsgotty/README.en.md +40 -0
  29. package/initramfs/buninu/apps/jsgotty/README.md +40 -0
  30. package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
  31. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  32. package/initramfs/buninu/apps/jsgotty/static/index.html +1 -0
  33. package/initramfs/buninu/apps/jsgotty/static/js/gotty.js +82 -17
  34. package/initramfs/buninu/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
  35. package/initramfs/buninu/apps/jsgotty/tests/android-alt.test.js +115 -0
  36. package/initramfs/buninu/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
  37. package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
  38. package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
  39. package/initramfs/buninu/apps/jsgotty/tests/manual/README.md +20 -0
  40. package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
  41. package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
  42. package/initramfs/buninu/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
  43. package/initramfs/buninu/package.json +1 -1
  44. package/initramfs/init.js +19 -3
  45. package/initramfs/lib/bunterm/clipboard.js +14 -0
  46. package/initramfs/lib/bunterm/commands.js +37 -0
  47. package/initramfs/lib/bunterm/fps.js +38 -0
  48. package/initramfs/lib/bunterm/images.js +9 -5
  49. package/initramfs/lib/bunterm/input.js +58 -15
  50. package/initramfs/lib/bunterm/keyboard.js +62 -0
  51. package/initramfs/lib/bunterm/keycodes.js +104 -0
  52. package/initramfs/lib/bunterm/kitty-keyboard.cjs +195 -0
  53. package/initramfs/lib/bunterm/raw-protocol.js +33 -0
  54. package/initramfs/lib/bunterm/renderer.js +89 -20
  55. package/initramfs/lib/bunterm/selection.js +60 -0
  56. package/initramfs/lib/bunterm/terminal.js +146 -11
  57. package/initramfs/lib/bunterm/vt-menu.js +181 -0
  58. package/initramfs/lib/bunterm/vt.js +27 -1
  59. package/initramfs/lib/canvas.js +99 -0
  60. package/initramfs/lib/chvt.js +31 -0
  61. package/initramfs/lib/fbdev.js +21 -3
  62. package/initramfs/lib/gpu.js +77 -0
  63. package/initramfs/lib/raw-image.js +47 -0
  64. package/initramfs/lib/xdg-open.js +42 -0
  65. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +85 -11
  66. package/initramfs/usr/share/doc/buninu-linux/chvt.md +34 -0
  67. package/initramfs/usr/share/doc/buninu-linux/xdg-open.md +43 -0
  68. package/package.json +1 -1
  69. package/scripts/arch.js +7 -5
  70. package/scripts/arch.sh +5 -5
  71. package/scripts/build.js +16 -3
  72. package/scripts/fetch-gpu.js +105 -0
  73. package/scripts/fetch.js +11 -1
  74. package/scripts/gpu-firmware.json +22 -0
  75. package/scripts/modules.js +4 -1
  76. package/scripts/npm-packages.md +17 -17
  77. package/test/bunterm-fps.test.js +17 -0
  78. package/test/bunterm-keyboard.test.js +116 -0
  79. package/test/bunterm-next-vt.test.js +9 -0
  80. package/test/bunterm-raw-protocol.test.js +19 -0
  81. package/test/bunterm-renderer.test.js +158 -0
  82. package/test/bunterm-selection.test.js +38 -0
  83. package/test/bunterm-vt-menu.test.js +178 -0
  84. package/test/chroot-bind.test.js +40 -2
  85. package/test/chvt.test.js +46 -0
  86. package/test/fbdev-sync.test.js +33 -0
  87. package/test/gpu.test.js +155 -0
  88. package/test/image-size.test.js +17 -0
  89. package/test/manual-bunterm-image-paths.js +31 -0
  90. package/test/mount-libc-lifetime.test.js +50 -0
  91. package/test/no-gpu.test.js +43 -0
  92. package/test/readme-cli.test.js +29 -0
  93. package/test/stage-npm.test.js +11 -4
  94. package/test/xdg-open.test.js +55 -0
@@ -0,0 +1,102 @@
1
+ const test=require('node:test');
2
+ const assert=require('node:assert/strict');
3
+ const {KittyKeyboard,SUPPORTED}=require('../static/js/kitty-keyboard.js');
4
+ function setup(android=true) {
5
+ const handlers=new Map(),sent=[];
6
+ const term={parser:{registerCsiHandler:(id,fn)=>{handlers.set((id.prefix||id.intermediates||'')+id.final,fn);return {dispose(){}};},registerEscHandler:(id,fn)=>{handlers.set('ESC'+id.final,fn);return {dispose(){}};}},buffer:{active:{type:'normal'}},options:{},input:(s,user)=>sent.push([s,user])};
7
+ const keyboard=new KittyKeyboard(term,{android});
8
+ const mode=(prefix,params=[])=>handlers.get(prefix+'u')(params);
9
+ const event=(options={})=>({type:'keydown',key:'a',code:'KeyA',keyCode:65,location:0,repeat:false,ctrlKey:false,altKey:false,shiftKey:false,metaKey:false,getModifierState:()=>false,preventDefault(){this.prevented=true;},stopPropagation(){},...options});
10
+ const send=options=>{const e=event(options);return {e,result:keyboard.handle(e)};};
11
+ return {keyboard,term,sent,handlers,mode,send,event};
12
+ }
13
+ test('negotiation, bit operations, bounded stacks, separate screens and reset',()=>{
14
+ const t=setup();t.mode('?');assert.deepEqual(t.sent.pop(),['\x1b[?0u',false]);
15
+ t.mode('>',[11]);t.mode('>',[1]);t.mode('<');assert.equal(t.keyboard.flags,11);
16
+ t.mode('=',[2,3]);assert.equal(t.keyboard.flags,9);t.mode('=',[2,2]);assert.equal(t.keyboard.flags,11);
17
+ t.term.buffer.active.type='alternate';assert.equal(t.keyboard.flags,0);t.mode('>',[3]);
18
+ t.term.buffer.active.type='normal';assert.equal(t.keyboard.flags,11);
19
+ for(let i=0;i<100;i++)t.mode('>',[1]);assert.equal(t.keyboard.state.stack.length,32);
20
+ t.mode('<',[99999]);assert.equal(t.keyboard.flags,0);
21
+ t.mode('=',[31]);assert.equal(t.keyboard.flags,SUPPORTED);t.mode('=',[0]);assert.equal(t.keyboard.flags,0);
22
+ t.handlers.get('ESCc')();t.term.buffer.active.type='alternate';assert.equal(t.keyboard.flags,0);
23
+ });
24
+ test('all sixteen Shift/Alt/Ctrl/Super combinations stay distinct for all letters and navigation',()=>{
25
+ for(const key of [...'abcdefghijklmnopqrstuvwxyz','ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Tab','Enter','Backspace','Insert','PageUp','PageDown','F1','F12','PrintScreen']) {
26
+ const seen=new Set();
27
+ for(let mask=0;mask<16;mask++) {
28
+ const t=setup();t.mode('>',[11]);t.send({key,code:key.length===1?'Key'+key.toUpperCase():key,shiftKey:!!(mask&1),altKey:!!(mask&2),ctrlKey:!!(mask&4),metaKey:!!(mask&8)});
29
+ assert.equal(t.sent.length,1,`${key} ${mask}`);seen.add(t.sent[0][0]);
30
+ }
31
+ assert.equal(seen.size,16,key);
32
+ }
33
+ });
34
+ test('Android Meta pair repairs missing metaKey, including keyup and combinations',()=>{
35
+ const t=setup();t.mode('>',[11]);
36
+ t.send({key:'Meta',code:'',keyCode:91});
37
+ assert.deepEqual(t.sent.pop(),['\x1b[57444;9u',true]);
38
+ const {e}=t.send({key:'x',code:'',keyCode:88,ctrlKey:true,shiftKey:true});
39
+ assert.equal(e.metaKey,true);assert.equal(e.getModifierState('Meta'),true);
40
+ assert.deepEqual(t.sent.pop(),['\x1b[120;14u',true]);
41
+ t.send({type:'keyup',key:'x',code:'',keyCode:88,ctrlKey:true,shiftKey:true});assert.equal(t.sent.pop()[0],'\x1b[120;14:3u');
42
+ t.send({type:'keyup',key:'Meta',code:'',keyCode:91});assert.equal(t.sent.pop()[0],'\x1b[57444;1:3u');
43
+ t.send({key:'x',code:'KeyX'});assert.equal(t.sent.pop()[0],'\x1b[120u');
44
+ });
45
+ test('both Meta keys, repeats and focus cleanup do not leave modifiers held',()=>{
46
+ const t=setup();t.mode('>',[11]);
47
+ t.send({key:'Meta',code:'MetaLeft',location:1});t.send({key:'Meta',code:'MetaRight',location:2});
48
+ t.send({type:'keyup',key:'Meta',code:'MetaLeft',location:1});assert.equal(t.sent.at(-1)[0],'\x1b[57444;9:3u');
49
+ t.send({repeat:true});assert.equal(t.sent.at(-1)[0],'\x1b[97;9:2u');
50
+ t.keyboard.clearHeld();assert.equal(t.keyboard.metaKeys.size,0);
51
+ t.send();assert.equal(t.sent.at(-1)[0],'\x1b[97u');
52
+ });
53
+ test('legacy remains unchanged; disabling mode restores text and ordinary controls',()=>{
54
+ const t=setup();assert.equal(t.send({ctrlKey:true}).result,true);assert.equal(t.sent.length,0);
55
+ t.mode('>',[1]);assert.equal(t.send().result,true);
56
+ for(const key of ['Tab','Enter','Backspace'])assert.equal(t.send({key,code:key}).result,true);
57
+ t.send({ctrlKey:true,key:'i',code:'KeyI'});assert.equal(t.sent.pop()[0],'\x1b[105;5u');
58
+ t.send({ctrlKey:true,shiftKey:true,key:'I',code:'KeyI'});assert.equal(t.sent.pop()[0],'\x1b[105;6u');
59
+ t.mode('<');assert.equal(t.send({ctrlKey:true}).result,true);
60
+ });
61
+ test('printable keys in disambiguation mode do not generate release events',()=>{
62
+ const t=setup();t.mode('>',[3]);t.send();t.send({type:'keyup'});assert.equal(t.sent.length,0);
63
+ t.send({ctrlKey:true});t.send({type:'keyup',ctrlKey:true});assert.deepEqual(t.sent.map(s=>s[0]),['\x1b[97;5u','\x1b[97;5:3u']);
64
+ });
65
+ test('dead Alt keys recover before encoding; regular composition is untouched',()=>{
66
+ const t=setup();t.mode('>',[11]);
67
+ for(const k of 'ENUI'){t.send({key:'Dead',code:'Key'+k,altKey:true});assert.equal(t.sent.pop()[0],`\x1b[${k.toLowerCase().charCodeAt(0)};3u`);}
68
+ assert.equal(t.send({isComposing:true,key:'Process'}).result,true);
69
+ assert.equal(t.send({key:'Dead',altKey:false}).result,true);
70
+ });
71
+ test('AltGr is not encoded as Ctrl+Alt; non-Latin layout key values are preserved',()=>{
72
+ const t=setup();t.mode('>',[11]);
73
+ t.send({key:'€',code:'KeyE',ctrlKey:true,altKey:true,getModifierState:k=>k==='AltGraph'});assert.equal(t.sent.pop()[0],'\x1b[8364u');
74
+ t.send({key:'ф',code:'KeyA',ctrlKey:true});assert.equal(t.sent.pop()[0],'\x1b[1092;5u');
75
+ });
76
+ test('keypad, media and lock modifiers have dedicated encodings',()=>{
77
+ const t=setup();t.mode('>',[11]);
78
+ t.send({key:'Enter',code:'NumpadEnter',location:3});assert.equal(t.sent.pop()[0],'\x1b[57414u');
79
+ t.send({key:'AudioVolumeUp',code:'AudioVolumeUp'});assert.equal(t.sent.pop()[0],'\x1b[57439u');
80
+ t.send({getModifierState:k=>k==='CapsLock'});assert.equal(t.sent.pop()[0],'\x1b[97;65u');
81
+ });
82
+ test('normal non-Android Meta and input disabled are not rewritten',()=>{
83
+ const t=setup(false);t.mode('>',[11]);t.send({key:'Meta',code:'MetaLeft'});const {e}=t.send();assert.equal(e.metaKey,false);
84
+ t.term.options.disableStdin=true;const count=t.sent.length;assert.equal(t.send({metaKey:true}).result,true);assert.equal(t.sent.length,count);
85
+ });
86
+ test('keypress cannot duplicate encoded keydown, but later ordinary text is not swallowed',()=>{
87
+ const t=setup();t.mode('>',[1]);t.send({ctrlKey:true});assert.equal(t.send({type:'keypress',ctrlKey:true}).result,false);
88
+ t.send({key:'b',code:'KeyB'});assert.equal(t.send({type:'keypress',key:'b',code:'KeyB'}).result,true);
89
+ });
90
+ test('Android release-only accent shortcuts still use Kitty encoding',()=>{
91
+ const t=setup();t.mode('>',[11]);t.send({type:'keyup',key:'Dead',code:'',keyCode:69,altKey:true});
92
+ assert.deepEqual(t.sent.map(x=>x[0]),['\x1b[101;3u','\x1b[101;3:3u']);
93
+ });
94
+ test('Omarchy workspace and monitor chords retain all modifiers',()=>{
95
+ for(const key of [...'1234567890','ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Tab','Home','`']) {
96
+ const t=setup();t.mode('>',[11]);
97
+ t.send({key,code:key.length===1?'':key,metaKey:true,shiftKey:true,altKey:true});
98
+ assert.match(t.sent.at(-1)[0],/;12[~uABCDH]$/);
99
+ t.send({key,code:key.length===1?'':key,metaKey:true,shiftKey:true,ctrlKey:true});
100
+ assert.match(t.sent.at(-1)[0],/;14[~uABCDH]$/);
101
+ }
102
+ });
@@ -0,0 +1,20 @@
1
+ # 鍵盤診斷(手動)
2
+
3
+ 此工具不由正式前端載入,也不需要修改 `static/js/gotty.js` 或安裝依賴。
4
+
5
+ 1. 在與 Android WebView 共用本機網路的環境啟動收集器:
6
+
7
+ ```sh
8
+ node tests/manual/keyboard-collector.cjs
9
+ # 可指定 port 與輸出檔;預設 18766、系統暫存目錄的 jsgotty-keyboard-debug.jsonl
10
+ ```
11
+
12
+ 2. 連上 jsgotty 後,透過該頁面的 DevTools Console 或 CDP `Runtime.evaluate` 執行收集器印出的 JavaScript。每次啟動會產生新 token。若要從另一台裝置連線,需要另行調整收集器位址與監聽設定。
13
+ 3. 按下要測試的組合鍵,讀取收集器顯示的 JSONL 路徑。每頁最多 2000 筆、10 分鐘;重跑載入指令即可開始新一輪。檔案採追加寫入,以 `session` 分組並依 `sequence` 排序,避免 HTTP 到達順序不同造成誤判。
14
+ 4. 停止頁面診斷:`window.stopGottyKeyboardDebug()`;或重新載入頁面。收集器按 Ctrl-C 停止。
15
+
16
+ 記錄內容包含原始 keydown/keypress/keyup、beforeinput/input、composition 事件、既有按鍵處理器回傳值、事件處理完成後的取消狀態,以及 xterm `onData` 的 UTF-8 位元組(前 64 bytes)。onData 是前端產生的終端資料,並非伺服器 PTY 接收確認。診斷期間只輸入測試內容,避免留下密碼或其他敏感輸入。
17
+
18
+ 紀錄也保留於 `window.__gottyKeyboardRecords`;傳輸失敗原因在 `window.__gottyKeyboardDeliveryError`。若無需 HTTP 收集器,可直接在 Console/CDP 執行 `keyboard-debug.js` 的內容,再呼叫 `window.startGottyKeyboardDebug()`。
19
+
20
+ 工具透過 `element.__gottyXterm` 尋找終端,暫時包裝 xterm 的 `_core._customKeyEventHandler`,停止時還原。因此升級 xterm 後需要再確認這個內部欄位;正式產品不依賴此診斷工具。舊的 `#keyboard-debug` 網址片段不再啟用任何診斷。
@@ -0,0 +1,55 @@
1
+ // Usage: node tests/manual/keyboard-collector.cjs [port] [output.jsonl]
2
+ const http = require('node:http');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const { randomBytes } = require('node:crypto');
7
+ const port = Number(process.argv[2] || 18766);
8
+ const file = path.resolve(process.argv[3] || path.join(os.tmpdir(), 'jsgotty-keyboard-debug.jsonl'));
9
+ const token = randomBytes(16).toString('hex');
10
+ const endpoint = `http://127.0.0.1:${port}/${token}`;
11
+ const client = fs.readFileSync(path.join(__dirname, 'keyboard-debug.js'), 'utf8') +
12
+ `\nwindow.startGottyKeyboardDebug({endpoint:${JSON.stringify(endpoint)}});\n`;
13
+ let count = 0;
14
+ http.createServer((req, res) => {
15
+ res.setHeader('Access-Control-Allow-Origin', '*');
16
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
17
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
18
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
19
+ res.setHeader('Cache-Control', 'no-store');
20
+ if (req.url !== '/' + token && req.url !== '/' + token + '/client.js') {
21
+ res.writeHead(404); return res.end();
22
+ }
23
+ if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
24
+ if (req.method === 'GET' && req.url.endsWith('/client.js')) {
25
+ res.setHeader('Content-Type', 'application/javascript');
26
+ return res.end(client);
27
+ }
28
+ if (req.method !== 'POST' || req.url !== '/' + token) {
29
+ res.writeHead(405); return res.end();
30
+ }
31
+ let body = '';
32
+ req.on('data', chunk => {
33
+ body += chunk;
34
+ if (body.length > 16384) req.destroy();
35
+ });
36
+ req.on('end', () => {
37
+ if (count >= 20000) { res.writeHead(429); return res.end(); }
38
+ let event;
39
+ try { event = JSON.parse(body); }
40
+ catch { res.writeHead(400); return res.end(); }
41
+ try {
42
+ fs.appendFileSync(file, JSON.stringify({receivedAt: new Date().toISOString(), event}) + '\n', {mode: 0o600});
43
+ count++;
44
+ res.writeHead(204); res.end();
45
+ } catch (error) {
46
+ console.error(error.message);
47
+ res.writeHead(500); res.end();
48
+ }
49
+ });
50
+ }).listen(port, '127.0.0.1', () => {
51
+ console.log('Log (append): ' + file);
52
+ console.log('Run in the connected jsgotty page via DevTools or CDP:');
53
+ console.log(`fetch(${JSON.stringify(endpoint + '/client.js')}).then(r => r.text()).then(source => (0, eval)(source))`);
54
+ console.log('Stop browser capture: window.stopGottyKeyboardDebug()');
55
+ });
@@ -0,0 +1,74 @@
1
+ // Manually loaded diagnostic helper. Never loaded by the production frontend.
2
+ window.startGottyKeyboardDebug = ({ endpoint, term } = {}) => {
3
+ window.stopGottyKeyboardDebug?.();
4
+ term ??= Array.from(document.querySelectorAll("*")).find(el => el.__gottyXterm)?.__gottyXterm;
5
+ if (!term) throw new Error("No jsgotty xterm found; connect the terminal first.");
6
+ const listeners = [];
7
+ let stopped = false;
8
+ const records = [];
9
+ const started = Date.now();
10
+ const session = Math.random().toString(36).slice(2);
11
+ window.__gottyKeyboardRecords = records;
12
+ window.__gottyKeyboardDebug = (stage, details = {}) => {
13
+ if (stopped || records.length >= 2000 || Date.now() - started > 600000) return;
14
+ const record = { session, sequence: records.length, time: new Date().toISOString(), stage, ...details };
15
+ records.push(record);
16
+ console.log("[gotty-keyboard]", record);
17
+ if (endpoint) fetch(endpoint, {
18
+ method: "POST", headers: { "Content-Type": "text/plain" },
19
+ body: JSON.stringify(record),
20
+ }).catch(error => { window.__gottyKeyboardDeliveryError = String(error); });
21
+ };
22
+ window.__gottyKeyboardDebug("ready", {
23
+ userAgent: navigator.userAgent, platform: navigator.platform,
24
+ userAgentData: navigator.userAgentData?.platform,
25
+ });
26
+ for (const type of ["keydown", "keypress", "keyup", "beforeinput", "input",
27
+ "compositionstart", "compositionupdate", "compositionend"]) {
28
+ const listener = event => {
29
+ const details = {
30
+ type, key: event.key, code: event.code, keyCode: event.keyCode,
31
+ charCode: event.charCode, which: event.which, altKey: event.altKey,
32
+ ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey,
33
+ altGraph: event.getModifierState?.("AltGraph"),
34
+ isComposing: event.isComposing, repeat: event.repeat,
35
+ cancelable: event.cancelable, defaultPrevented: event.defaultPrevented,
36
+ inputType: event.inputType, data: event.data?.slice(0, 64),
37
+ target: event.target?.tagName, trusted: event.isTrusted,
38
+ };
39
+ window.__gottyKeyboardDebug("capture", details);
40
+ setTimeout(() => window.__gottyKeyboardDebug("after-dispatch", {
41
+ ...details, defaultPrevented: event.defaultPrevented,
42
+ }));
43
+ };
44
+ window.addEventListener(type, listener, true);
45
+ listeners.push([type, listener]);
46
+ }
47
+ // Preserve the existing Android handler, including its return value.
48
+ const previousHandler = term._core?._customKeyEventHandler;
49
+ if (!term._core) throw new Error("Unsupported xterm internals");
50
+ const handler = event => {
51
+ const result = previousHandler ? previousHandler.call(term._core, event) : true;
52
+ window.__gottyKeyboardDebug("alt-handler", {
53
+ type: event.type, key: event.key, code: event.code,
54
+ result, defaultPrevented: event.defaultPrevented,
55
+ });
56
+ return result;
57
+ };
58
+ term.attachCustomKeyEventHandler(handler);
59
+ const subscription = term.onData(text => {
60
+ const bytes = new TextEncoder().encode(text);
61
+ window.__gottyKeyboardDebug("terminal-data", {
62
+ length: bytes.length, bytes: Array.from(bytes.slice(0, 64)),
63
+ });
64
+ });
65
+ window.stopGottyKeyboardDebug = () => {
66
+ stopped = true;
67
+ for (const [type, listener] of listeners) window.removeEventListener(type, listener, true);
68
+ subscription.dispose();
69
+ if (term._core._customKeyEventHandler === handler)
70
+ term.attachCustomKeyEventHandler(previousHandler);
71
+ };
72
+ return { records, stop: window.stopGottyKeyboardDebug };
73
+ };
74
+
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // Run inside jsgotty. Exits after 60 seconds or unmodified Escape/q/Ctrl-C.
3
+ // Uses only built-ins and restores the prior Kitty mode and raw mode on exit.
4
+ const input=process.stdin, output=process.stdout;
5
+ const cyan = '\x1b[96m', reset = '\x1b[0m';
6
+ const keyNames = {
7
+ 9: 'tab', 13: 'enter', 27: 'esc', 32: 'space', 127: 'backspace',
8
+ 57358: 'caps-lock', 57359: 'scroll-lock', 57360: 'num-lock',
9
+ 57361: 'print-screen', 57362: 'pause', 57363: 'menu',
10
+ 57409: 'kp-decimal', 57410: 'kp-divide', 57411: 'kp-multiply',
11
+ 57412: 'kp-subtract', 57413: 'kp-add', 57414: 'kp-enter',
12
+ 57415: 'kp-equal', 57416: 'kp-separator',
13
+ 57417: 'kp-left', 57418: 'kp-right', 57419: 'kp-up', 57420: 'kp-down',
14
+ 57421: 'kp-page-up', 57422: 'kp-page-down', 57423: 'kp-home',
15
+ 57424: 'kp-end', 57425: 'kp-insert', 57426: 'kp-delete', 57427: 'kp-begin',
16
+ 57428: 'media-play', 57429: 'media-pause', 57430: 'media-play-pause',
17
+ 57431: 'media-reverse', 57432: 'media-stop', 57433: 'media-fast-forward',
18
+ 57434: 'media-rewind', 57435: 'media-next', 57436: 'media-previous',
19
+ 57437: 'media-record', 57438: 'volume-down', 57439: 'volume-up',
20
+ 57440: 'volume-mute', 57453: 'altgr', 57454: 'iso-level5-shift',
21
+ };
22
+ for (let n=13; n<=35; n++) keyNames[57376+n-13] = 'f'+n;
23
+ for (let n=0; n<=9; n++) keyNames[57399+n] = 'kp-'+n;
24
+ const modifierKeys = ['shift', 'ctrl', 'alt', 'super', 'hyper', 'meta'];
25
+ const modifierBits = [1, 4, 2, 8, 16, 32];
26
+ function readableChord(key, suffix, bits) {
27
+ const number = Number(key.split(':')[0] || 1);
28
+ let name;
29
+ if (suffix === 'u') {
30
+ if (number >= 57441 && number <= 57452) {
31
+ const index = (number-57441)%6;
32
+ name = modifierKeys[index] + (number >= 57447 ? '-right' : '-left');
33
+ bits &= ~modifierBits[index]; // "super-left", not "super-super-left".
34
+ } else {
35
+ name = keyNames[number];
36
+ if (!name) name = number >= 33 && number <= 0x10ffff &&
37
+ !(number >= 0x7f && number <= 0x9f) &&
38
+ !(number >= 0xd800 && number <= 0xf8ff)
39
+ ? String.fromCodePoint(number) : 'key-'+number;
40
+ }
41
+ } else if (suffix === '~') {
42
+ name = {2:'insert', 3:'delete', 5:'page-up', 6:'page-down', 7:'home', 8:'end',
43
+ 11:'f1', 12:'f2', 13:'f3', 14:'f4', 15:'f5', 17:'f6',
44
+ 18:'f7', 19:'f8', 20:'f9', 21:'f10', 23:'f11', 24:'f12'}[number];
45
+ } else {
46
+ name = {A:'up', B:'down', C:'right', D:'left', E:'begin',
47
+ F:'end', H:'home', P:'f1', Q:'f2', S:'f4'}[suffix];
48
+ }
49
+ const modifiers = [[4,'ctrl'], [2,'alt'], [1,'shift'], [8,'super'],
50
+ [16,'hyper'], [32,'meta'], [64,'caps-lock'], [128,'num-lock']]
51
+ .filter(([bit]) => bits & bit).map(([,label]) => label);
52
+ return [...modifiers, name || `key-${number}${suffix}`].join('-');
53
+ }
54
+ if(!input.isTTY || !output.isTTY) {console.error('Run this inside an interactive jsgotty terminal.');process.exit(1);}
55
+ const wasRaw=Boolean(input.isRaw);let closed=false,buffer='';
56
+ const finish=()=>{if(closed)return;closed=true;clearTimeout(timer);output.write('\x1b[<u\r\nKeyboard mode restored.\r\n');input.setRawMode(wasRaw);input.pause();};
57
+ const timer=setTimeout(finish,60000);
58
+ process.on('SIGINT',finish);process.on('SIGTERM',finish);process.on('exit',finish);
59
+ input.setRawMode(true);input.setEncoding('utf8');input.resume();
60
+ output.write('Kitty keyboard test: try SUP, CTRL, ALT, SHFT + letters/arrows.\r\nEscape, q or Ctrl-C exits; automatic exit in 60 seconds.\r\n\x1b[>11u\x1b[?u');
61
+ input.on('data',data=>{
62
+ if(closed)return;
63
+ buffer+=data;
64
+ while(buffer) {
65
+ const match=/^\x1b\[([?]?)([0-9:;]*)([u~A-Z])/.exec(buffer);
66
+ if(!match) {
67
+ if(buffer==='\x1b' || /^\x1b\[[?0-9:;]*$/.test(buffer))break;
68
+ const ch=[...buffer][0];buffer=buffer.slice(ch.length);output.write('legacy '+JSON.stringify(ch)+'\r\n');
69
+ if(ch==='q'||ch==='\x03') {finish();return;}continue;
70
+ }
71
+ buffer=buffer.slice(match[0].length);
72
+ if(match[1]==='?'){output.write('negotiated flags='+match[2]+' (expected 11)\r\n');continue;}
73
+ const [key,mod='1']=match[2].split(';');const [encoded,type='1']=mod.split(':');const bits=Number(encoded)-1;
74
+ const names=[[1,'Shift'],[2,'Alt'],[4,'Ctrl'],[8,'Super'],[16,'Hyper'],[32,'Meta'],[64,'CapsLock'],[128,'NumLock']].filter(([bit])=>bits&bit).map(([,name])=>name);
75
+ const chord = readableChord(key, match[3], bits);
76
+ output.write(`${cyan}${chord}${reset} ${{1:'press',2:'repeat',3:'release'}[type]||type} raw=${JSON.stringify(match[0])} key=${key}${match[3]} modifiers=${names.join('+')||'none'}\r\n`);
77
+ if(match[3]==='u' && type==='1' && ((!(bits&63)&&['27','113'].includes(key)) || ((bits&63)===4 && key==='99'))) {finish();return;}
78
+ }
79
+ if(buffer.length>4096)buffer='';
80
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu",
3
- "version": "0.4.21",
3
+ "version": "0.5.0",
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
@@ -462,7 +462,10 @@ Type bunterm in bunmsh: Graphical Terminal
462
462
  ...process.env,
463
463
  PATH: classic_path + ":" +
464
464
  buninu_path,
465
- HOME:"/buninu"
465
+ HOME:"/buninu",
466
+ PULSE_SERVER:"127.0.0.1",
467
+ XDG_RUNTIME_DIR:"/tmp",
468
+ WAYLAND_DISPLAY:"wayland-buninu"
466
469
  },
467
470
  stdin: "inherit",
468
471
  stdout: "inherit",
@@ -540,11 +543,12 @@ const configureEthernet = () => {
540
543
  };
541
544
 
542
545
  // Explicit full coldplug for this deliberately tiny system: load every .ko
543
- // represented in modules.dep, recursively including each module's declared
546
+ // represented in modules.dep except GPU modules (cfg.gpu only), including declared
544
547
  // dependencies. Failures are reported individually without stopping the rest.
545
548
  const configureModules = () => {
546
549
  const { modprobeAll } = require("/lib/modprobe.js");
547
- const report = modprobeAll();
550
+ const { isGpuPath } = require("/lib/gpu.js");
551
+ const report = modprobeAll({ accept: (module, path) => !isGpuPath(module, path) });
548
552
  console.log(`modules: ${report.inserted.length} newly loaded, ${report.errors.length} failed`);
549
553
  for (const { module, error } of report.errors) console.log(`modules: ${module}: ${error}`);
550
554
  return report;
@@ -579,12 +583,24 @@ const configureSound = () => {
579
583
  return devices;
580
584
  };
581
585
 
586
+ const configureGraphics = () => {
587
+ try {
588
+ const { configureGpu } = require("/lib/gpu.js");
589
+ const { autoload, tryModprobe } = require("/lib/modprobe.js");
590
+ return configureGpu({ autoload, tryModprobe });
591
+ } catch (error) {
592
+ console.log(`[gpu] failed: ${error?.message ?? error}; init continues`);
593
+ return { errors: [String(error?.message ?? error)] };
594
+ }
595
+ };
596
+
582
597
  globalThis.cfg = Object.freeze(Object.defineProperties({}, {
583
598
  all: { enumerable: true, get: configureModules },
584
599
  disk: { enumerable: true, get: configureDisks },
585
600
  net: { enumerable: true, get: configureEthernet },
586
601
  power: { enumerable: true, get: configureBattery },
587
602
  sound: { enumerable: true, get: configureSound },
603
+ gpu: { enumerable: true, get: configureGraphics },
588
604
  }));
589
605
 
590
606
  // Compatibility aliases for images and notes that used the original API.
@@ -24,6 +24,20 @@ const runXclip = async (args, input) => {
24
24
  return output;
25
25
  };
26
26
 
27
+ export const readClipboard = async () => Buffer.from(await runXclip(["-o"])).toString("utf8");
28
+ export const writeClipboard = (text) => runXclip([], Buffer.from(text, "utf8"));
29
+
30
+ // Match xterm's paste behavior: normalize newlines and prevent embedded closing
31
+ // delimiters from escaping bracketed paste. Legacy programs have not negotiated
32
+ // DECSET 2004 and must receive plain text.
33
+ export function pasteText(term, text, write) {
34
+ text = text.replace(/\r\n|\n/g, "\r");
35
+ if (term._core?.coreService?.decPrivateModes?.bracketedPasteMode) {
36
+ text = `\x1b[200~${text.replace(/\x1b/g, "")}\x1b[201~`;
37
+ }
38
+ write(text);
39
+ }
40
+
27
41
  // Registers the OSC 52 handler on an xterm.js terminal. `reply` sends bytes
28
42
  // back to the program, as the terminal's other replies do. A read the xclip
29
43
  // call cannot answer is still answered, with empty contents, so the program
@@ -0,0 +1,37 @@
1
+ // Local bunterm commands. Deliberately separate from the child shell.
2
+ export function createCommands({fontSize, setFontSize, copy, paste, switchVT}) {
3
+ const commands = new Map([
4
+ ["help", () => "font SIZE / + / - | copy | paste | vt NUMBER | help"],
5
+ ["font", args => {
6
+ if (args.length !== 1) throw new Error("Usage: font SIZE / + / -");
7
+ const size = args[0] === "+" ? fontSize()+1 : args[0] === "-" ? fontSize()-1 : Number(args[0]);
8
+ setFontSize(size);
9
+ return `Font: ${fontSize()}`;
10
+ }],
11
+ ["copy", async args => {
12
+ if (args.length) throw new Error("Usage: copy");
13
+ await copy();
14
+ return "Copied";
15
+ }],
16
+ ["paste", async args => {
17
+ if (args.length) throw new Error("Usage: paste");
18
+ await paste();
19
+ return "Pasted";
20
+ }],
21
+ ["vt", async args => {
22
+ if (args.length !== 1 || !/^[1-9]\d*$/.test(args[0]) || Number(args[0]) > 63) throw new Error("Usage: vt 1..63");
23
+ await switchVT(Number(args[0]));
24
+ return "";
25
+ }],
26
+ ]);
27
+ return {
28
+ commands,
29
+ async execute(line) {
30
+ const [name, ...args] = line.trim().split(/\s+/);
31
+ if (!name) return "";
32
+ const command = commands.get(name);
33
+ if (!command) throw new Error(`Unknown command: ${name}; try help`);
34
+ return await command(args);
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,38 @@
1
+ // Count newly presented Kitty images, not pointer, menu or counter repaints.
2
+ export function createFpsMeter(now = () => performance.now()) {
3
+ let enabled = false, seen = new WeakSet(), frames = 0, since = 0, value = 0;
4
+ return {
5
+ get enabled() { return enabled; },
6
+ get value() { return value; },
7
+ toggle(images = []) {
8
+ enabled = !enabled;
9
+ seen = new WeakSet(images.map(({image}) => image)); frames = 0; value = 0; since = now();
10
+ return enabled;
11
+ },
12
+ presented(images) {
13
+ if (!enabled) return;
14
+ let fresh = false;
15
+ for (const {image} of images) {
16
+ if (!seen.has(image)) { seen.add(image); fresh = true; }
17
+ }
18
+ if (fresh) frames++;
19
+ },
20
+ sample() {
21
+ const time = now(), elapsed = time - since;
22
+ value = enabled && elapsed > 0 ? frames * 1000 / elapsed : 0;
23
+ frames = 0; since = time;
24
+ return value;
25
+ },
26
+ draw(renderer) {
27
+ if (!enabled) return;
28
+ const label = `FPS ${value.toFixed(1)}`;
29
+ const columns = Math.min(renderer.columns, label.length + 2);
30
+ const width = columns * renderer.cellWidth;
31
+ const x = renderer.offsetX + renderer.columns * renderer.cellWidth - width;
32
+ const y = renderer.offsetY;
33
+ renderer.canvas.drawRect(renderer.CanvasKit.XYWHRect(x,y,width,renderer.cellHeight),
34
+ renderer.paintFor([20,25,30]));
35
+ renderer.drawText(label,x,y,columns,[65,220,230],{align:"center"});
36
+ },
37
+ };
38
+ }
@@ -1,3 +1,4 @@
1
+ import {createRawImage} from '../raw-image.js';
1
2
  // /lib/bunterm/images.js — kitty graphics protocol placements on the cell
2
3
  // grid.
3
4
  //
@@ -15,12 +16,13 @@ const number = (value, fallback = 0) => {
15
16
  // Decodes a transferred image into a Skia Image. Format 100 is PNG (or any
16
17
  // encoded format Skia knows); 24 and 32 are raw RGB / RGBA. Bun.Image, when
17
18
  // present, transcodes encodings Skia cannot read on its own.
18
- export const decodeImage = async (CanvasKit, image) => {
19
+ export const decodeImage = async (CanvasKit, image, {deferRaw = false} = {}) => {
19
20
  // The local parser supplies bytes; retain compatibility with base64 callers.
20
21
  const bytes = typeof image.data === "string" ? Buffer.from(image.data, "base64") : image.data;
21
- if (image.format === 24 || image.format === 32) {
22
+ if (image.format === 24 || image.format === 32 || image.format === 0x42475241) {
22
23
  const { width, height } = image;
23
- if (!width || !height) throw new Error("raw image without dimensions");
24
+ if (!Number.isInteger(width)||!Number.isInteger(height)||width<1||height<1||width*height>16777216
25
+ ||bytes.byteLength!==width*height*(image.format===24?3:4)) throw new Error("invalid raw image dimensions or byte count");
24
26
  let pixels = bytes;
25
27
  if (image.format === 24) {
26
28
  pixels = Buffer.alloc(width * height * 4, 255);
@@ -30,9 +32,11 @@ export const decodeImage = async (CanvasKit, image) => {
30
32
  pixels[o + 2] = bytes[i + 2];
31
33
  }
32
34
  }
35
+ const colorType = image.format === 0x42475241 ? CanvasKit.ColorType.BGRA_8888 : CanvasKit.ColorType.RGBA_8888;
36
+ if (deferRaw) return createRawImage(CanvasKit, pixels, width, height, colorType);
33
37
  const decoded = CanvasKit.MakeImage({
34
38
  width, height,
35
- colorType: CanvasKit.ColorType.RGBA_8888,
39
+ colorType: image.format === 0x42475241 ? CanvasKit.ColorType.BGRA_8888 : CanvasKit.ColorType.RGBA_8888,
36
40
  alphaType: CanvasKit.AlphaType.Unpremul,
37
41
  colorSpace: CanvasKit.ColorSpace.SRGB,
38
42
  }, pixels, width * 4);
@@ -75,7 +79,7 @@ export class ImageStore {
75
79
  const replacesImage = decoded && this.imageSources.get(image.id) !== image;
76
80
  if (!decoded || replacesImage) {
77
81
  try {
78
- const replacement = await decodeImage(this.CanvasKit, image);
82
+ const replacement = await decodeImage(this.CanvasKit, image, {deferRaw: true});
79
83
  if (replacesImage) {
80
84
  // Kitty requires retransmitting a non-zero image id to delete the
81
85
  // old image and all its placements. Screen-streaming clients such
@@ -6,8 +6,9 @@
6
6
  // "linux" console sequences for a few keys are rewritten into the xterm
7
7
  // forms that programs seeing TERM=xterm-256color expect.
8
8
 
9
- import { closeSync, openSync, read } from "node:fs";
9
+ import { closeSync, openSync, readSync, constants } from "node:fs";
10
10
  import { libc, ptr, errno, strerror } from "../dlopen.js";
11
+ import { ConsoleKeys, MediumRawDecoder } from "./keycodes.js";
11
12
 
12
13
  const TCGETS = 0x5401;
13
14
  const TCSETS = 0x5402;
@@ -108,33 +109,75 @@ export const translateKeys = (text, { applicationCursorKeys = false } = {}) => {
108
109
  // Opens the keyboard: `device` names a console such as /dev/tty1; without
109
110
  // it the process's own stdin is used (it must be a terminal). `onData`
110
111
  // receives Buffers. close() restores the previous terminal settings.
111
- export const openKeyboard = ({ device = null, onData, onError = () => {} }) => {
112
- const fd = device ? openSync(device, "r+") : 0;
113
- const saved = getTermios(fd);
114
- setTermios(fd, rawTermios(saved));
112
+ export const openKeyboard = ({ device = null, onData, onKey = () => {}, onError = () => {} }) => {
113
+ // Own a nonblocking file description: no outstanding async read can deliver
114
+ // bytes from the previous keyboard mode after a protocol transition.
115
+ const fd = openSync(device || "/proc/self/fd/0", constants.O_RDWR | constants.O_NONBLOCK);
116
+ let saved;
117
+ try {
118
+ saved = getTermios(fd);
119
+ setTermios(fd, rawTermios(saved));
120
+ } catch (error) {
121
+ closeSync(fd);
122
+ throw error;
123
+ }
124
+ const originalMode = new Int32Array(1);
125
+ const canReadMode = libc.symbols.ioctl(fd, 0x4b44, ptr(originalMode)) === 0;
126
+ let enhanced = false, decoder, keys;
127
+ const setEnhanced = (enabled) => {
128
+ if (enabled === enhanced) return;
129
+ if (!canReadMode) throw new Error("Kitty keyboard needs a Linux virtual console (KDGKBMODE)");
130
+ const maps = [];
131
+ if (enabled) {
132
+ // Unicode keysyms can only be queried while the console is still in
133
+ // K_UNICODE, before switching to K_MEDIUMRAW.
134
+ for (let table = 0; table < 4; table++) {
135
+ maps[table] = [];
136
+ for (let number = 0; number < 256; number++) {
137
+ const entry = new Uint8Array([table,number,0,0]);
138
+ maps[table][number] = libc.symbols.ioctl(fd,0x4b46,ptr(entry)) === 0
139
+ ? new DataView(entry.buffer).getUint16(2,true) : null;
140
+ }
141
+ }
142
+ }
143
+ // Discard only console typeahead at the mode boundary, never PTY input.
144
+ if (libc.symbols.ioctl(fd, 0x540b, 0) === -1) fail("TCFLSH");
145
+ if (libc.symbols.ioctl(fd, 0x4b45, enabled ? 2 : originalMode[0]) === -1) fail("KDSKBMODE");
146
+ enhanced = enabled;
147
+ decoder = new MediumRawDecoder();
148
+ const leds = new Uint8Array(1);
149
+ libc.symbols.ioctl(fd, 0x4b64, ptr(leds));
150
+ keys = new ConsoleKeys({ leds: leds[0], lookup: (table, number) => maps[table]?.[number] });
151
+ };
115
152
  let open = true;
153
+ let timer;
116
154
  const chunk = Buffer.alloc(4096);
117
155
  const pump = () => {
118
156
  if (!open) return;
119
- read(fd, chunk, 0, chunk.length, null, (error, count) => {
120
- if (!open) return;
121
- if (error) {
122
- if (error.code === "EAGAIN") { setTimeout(pump, 10); return; }
123
- onError(error);
124
- return;
157
+ try {
158
+ for (let batch = 0; batch < 16; batch++) {
159
+ const count = readSync(fd, chunk, 0, chunk.length, null);
160
+ if (!count) break;
161
+ if (enhanced) decoder.feed(chunk.subarray(0, count), (code, down) => onKey(keys.event(code, down)));
162
+ else onData(Buffer.from(chunk.subarray(0, count)));
125
163
  }
126
- if (count > 0) onData(Buffer.from(chunk.subarray(0, count)));
127
- pump();
128
- });
164
+ } catch (error) {
165
+ if (error.code !== "EAGAIN" && error.code !== "EWOULDBLOCK") { onError(error); return; }
166
+ }
167
+ timer = setTimeout(pump, 4);
129
168
  };
130
169
  pump();
131
170
  return {
132
171
  fd,
172
+ setEnhanced,
173
+ clearHeld() { keys?.held.clear(); },
133
174
  close() {
134
175
  if (!open) return;
135
176
  open = false;
177
+ clearTimeout(timer);
178
+ try { setEnhanced(false); } catch (error) { onError(error); }
136
179
  try { setTermios(fd, saved); } catch {}
137
- if (device) closeSync(fd);
180
+ closeSync(fd);
138
181
  },
139
182
  };
140
183
  };