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.
- package/LICENSES/linux-firmware/LICENCE.nvidia +131 -0
- package/LICENSES/linux-firmware/LICENSE.amdgpu +51 -0
- package/LICENSES/linux-firmware/LICENSE.amdisp +31 -0
- package/LICENSES/linux-firmware/LICENSE.i915 +39 -0
- package/LICENSES/linux-firmware/LICENSE.radeon +51 -0
- package/LICENSES/linux-firmware/LICENSE.xe +39 -0
- package/LICENSES/linux-firmware/WHENCE +10280 -0
- package/NOTICE.md +64 -46
- package/README.md +371 -87
- package/README.zh.md +2034 -0
- package/build-image.sh +10 -1
- package/build-uki.sh +3 -1
- package/fetch-alpine.sh +14 -0
- package/graphics.md +31 -0
- package/index.js +28 -11
- package/initramfs/bin/buninu-browser +4 -1
- package/initramfs/bin/buninu-land +1 -1
- package/initramfs/bin/bunterm +3 -3
- package/initramfs/bin/chroot +13 -0
- package/initramfs/bin/chvt +11 -0
- package/initramfs/bin/mount +4 -0
- package/initramfs/bin/umount +6 -0
- package/initramfs/bin/xdg-open +33 -0
- package/initramfs/buninu/CHANGELOG.md +19 -0
- package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +4 -0
- package/initramfs/buninu/apps/bunmsh/package.json +1 -1
- package/initramfs/buninu/apps/bunmsh/src/main.js +1 -1
- package/initramfs/buninu/apps/jsgotty/README.en.md +40 -0
- package/initramfs/buninu/apps/jsgotty/README.md +40 -0
- package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
- package/initramfs/buninu/apps/jsgotty/package.json +1 -1
- package/initramfs/buninu/apps/jsgotty/static/index.html +1 -0
- package/initramfs/buninu/apps/jsgotty/static/js/gotty.js +82 -17
- package/initramfs/buninu/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
- package/initramfs/buninu/apps/jsgotty/tests/android-alt.test.js +115 -0
- package/initramfs/buninu/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
- package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
- package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/README.md +20 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
- package/initramfs/buninu/package.json +1 -1
- package/initramfs/init.js +19 -3
- package/initramfs/lib/bunterm/clipboard.js +14 -0
- package/initramfs/lib/bunterm/commands.js +37 -0
- package/initramfs/lib/bunterm/fps.js +38 -0
- package/initramfs/lib/bunterm/images.js +9 -5
- package/initramfs/lib/bunterm/input.js +58 -15
- package/initramfs/lib/bunterm/keyboard.js +62 -0
- package/initramfs/lib/bunterm/keycodes.js +104 -0
- package/initramfs/lib/bunterm/kitty-keyboard.cjs +195 -0
- package/initramfs/lib/bunterm/raw-protocol.js +33 -0
- package/initramfs/lib/bunterm/renderer.js +89 -20
- package/initramfs/lib/bunterm/selection.js +60 -0
- package/initramfs/lib/bunterm/terminal.js +146 -11
- package/initramfs/lib/bunterm/vt-menu.js +181 -0
- package/initramfs/lib/bunterm/vt.js +27 -1
- package/initramfs/lib/canvas.js +99 -0
- package/initramfs/lib/chvt.js +31 -0
- package/initramfs/lib/fbdev.js +21 -3
- package/initramfs/lib/gpu.js +77 -0
- package/initramfs/lib/raw-image.js +47 -0
- package/initramfs/lib/xdg-open.js +42 -0
- package/initramfs/usr/share/doc/buninu-linux/bunterm.md +85 -11
- package/initramfs/usr/share/doc/buninu-linux/chvt.md +34 -0
- package/initramfs/usr/share/doc/buninu-linux/xdg-open.md +43 -0
- package/package.json +1 -1
- package/scripts/arch.js +7 -5
- package/scripts/arch.sh +5 -5
- package/scripts/build.js +16 -3
- package/scripts/fetch-gpu.js +105 -0
- package/scripts/fetch.js +11 -1
- package/scripts/gpu-firmware.json +22 -0
- package/scripts/modules.js +4 -1
- package/scripts/npm-packages.md +17 -17
- package/test/bunterm-fps.test.js +17 -0
- package/test/bunterm-keyboard.test.js +116 -0
- package/test/bunterm-next-vt.test.js +9 -0
- package/test/bunterm-raw-protocol.test.js +19 -0
- package/test/bunterm-renderer.test.js +158 -0
- package/test/bunterm-selection.test.js +38 -0
- package/test/bunterm-vt-menu.test.js +178 -0
- package/test/chroot-bind.test.js +40 -2
- package/test/chvt.test.js +46 -0
- package/test/fbdev-sync.test.js +33 -0
- package/test/gpu.test.js +155 -0
- package/test/image-size.test.js +17 -0
- package/test/manual-bunterm-image-paths.js +31 -0
- package/test/mount-libc-lifetime.test.js +50 -0
- package/test/no-gpu.test.js +43 -0
- package/test/readme-cli.test.js +29 -0
- package/test/stage-npm.test.js +11 -4
- 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
|
+
});
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
180
|
+
closeSync(fd);
|
|
138
181
|
},
|
|
139
182
|
};
|
|
140
183
|
};
|