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
@@ -12543,6 +12543,12 @@
12543
12543
  toTerminal: (e) => this.term.write(e),
12544
12544
  toServer: (e) => this.sendInput(e),
12545
12545
  })),
12546
+ (this.kittyKeyboard = new globalThis.GoTTYKittyKeyboard.KittyKeyboard(this.term, {
12547
+ android: /Android/i.test(navigator.userAgent),
12548
+ })),
12549
+ this.term.attachCustomKeyEventHandler((event) =>
12550
+ this.kittyKeyboard.handle(event) && this.handleAndroidAltKey(event),
12551
+ ),
12546
12552
  this.setPreferences(t),
12547
12553
  (() => {
12548
12554
  try {
@@ -12594,12 +12600,73 @@
12594
12600
  ));
12595
12601
  }),
12596
12602
  this.term.open(e),
12603
+ this.kittyKeyboard.attach(this.term.textarea),
12604
+ this.term.textarea?.addEventListener("blur", () => {
12605
+ this.androidAltDownKeys?.clear();
12606
+ }),
12597
12607
  this.term.focus(),
12598
12608
  this.resizeListener(),
12599
12609
  window.addEventListener("resize", () => {
12600
12610
  this.resizeListener();
12601
12611
  }));
12602
12612
  }
12613
+ // Android can translate physical Alt shortcuts into accent dead keys.
12614
+ // Resolve printable physical keys before xterm's composition handling.
12615
+ handleAndroidAltKey(event) {
12616
+ if (!/Android/i.test(navigator.userAgent)) return true;
12617
+ let code = event.code;
12618
+ // Some Android IMEs omit code and swallow the dead-key keydown,
12619
+ // but still deliver keyup with a usable legacy keyCode.
12620
+ if (!code) {
12621
+ if (event.keyCode >= 65 && event.keyCode <= 90)
12622
+ code = "Key" + String.fromCharCode(event.keyCode);
12623
+ else if (event.keyCode >= 48 && event.keyCode <= 57)
12624
+ code = "Digit" + String.fromCharCode(event.keyCode);
12625
+ else code = {
12626
+ 32: "Space", 186: "Semicolon", 187: "Equal", 188: "Comma",
12627
+ 189: "Minus", 190: "Period", 191: "Slash", 192: "Backquote",
12628
+ 219: "BracketLeft", 220: "Backslash", 221: "BracketRight", 222: "Quote",
12629
+ }[event.keyCode];
12630
+ }
12631
+ this.androidAltDownKeys ??= new Set();
12632
+ const alreadySent = event.type === "keyup" && this.androidAltDownKeys.delete(code);
12633
+ if (
12634
+ !["keydown", "keypress", "keyup"].includes(event.type) ||
12635
+ !event.altKey || event.ctrlKey || event.metaKey ||
12636
+ event.isComposing || event.getModifierState?.("AltGraph") ||
12637
+ (event.type === "keyup" && event.key !== "Dead")
12638
+ ) return true;
12639
+
12640
+ let character;
12641
+ if (/^Key[A-Z]$/.test(code)) {
12642
+ const letter = code.slice(3);
12643
+ character = event.shiftKey ? letter : letter.toLowerCase();
12644
+ } else if (/^Digit[0-9]$/.test(code)) {
12645
+ const digit = code.slice(5);
12646
+ character = event.shiftKey ? ")!@#$%^&*("[Number(digit)] : digit;
12647
+ } else {
12648
+ const punctuation = {
12649
+ Backquote: ["`", "~"], Minus: ["-", "_"], Equal: ["=", "+"],
12650
+ BracketLeft: ["[", "{"], BracketRight: ["]", "}"],
12651
+ Backslash: ["\\", "|"], Semicolon: [";", ":"], Quote: ["'", '"'],
12652
+ Comma: [",", "<"], Period: [".", ">"], Slash: ["/", "?"],
12653
+ Space: [" ", " "],
12654
+ };
12655
+ character = Object.hasOwn(punctuation, code)
12656
+ ? punctuation[code][event.shiftKey ? 1 : 0]
12657
+ : undefined;
12658
+ }
12659
+ if (character === undefined) return true;
12660
+ event.preventDefault();
12661
+ event.stopPropagation();
12662
+ if (event.type === "keydown") {
12663
+ this.androidAltDownKeys.add(code);
12664
+ this.term.input("\x1b" + character, true);
12665
+ } else if (event.type === "keyup" && !alreadySent) {
12666
+ this.term.input("\x1b" + character, true);
12667
+ }
12668
+ return false;
12669
+ }
12603
12670
  info() {
12604
12671
  return { columns: this.term.cols, rows: this.term.rows };
12605
12672
  }
@@ -12610,6 +12677,7 @@
12610
12677
  return this.message;
12611
12678
  }
12612
12679
  setDisconnected(e) {
12680
+ if (e) this.kittyKeyboard.reset();
12613
12681
  this.disconnected = !!e;
12614
12682
  }
12615
12683
  setReconnectAction(e) {
@@ -12721,9 +12789,11 @@
12721
12789
  this.term.blur();
12722
12790
  }
12723
12791
  reset() {
12792
+ this.kittyKeyboard.reset();
12724
12793
  (this.removeMessage(), this.term.clear());
12725
12794
  }
12726
12795
  close() {
12796
+ this.kittyKeyboard.dispose();
12727
12797
  (window.removeEventListener("resize", this.resizeListener),
12728
12798
  this.term.dispose());
12729
12799
  }
@@ -26746,39 +26816,32 @@
26746
26816
  case 37:
26747
26817
  if (e.metaKey) break;
26748
26818
  a
26749
- ? ((o.key = r.C0.ESC + "[1;" + (a + 1) + "D"),
26750
- o.key === r.C0.ESC + "[1;3D" &&
26751
- (o.key = r.C0.ESC + (i ? "b" : "[1;5D")))
26819
+ ? (o.key = r.C0.ESC + "[1;" + (a + 1) + "D")
26752
26820
  : (o.key = t ? r.C0.ESC + "OD" : r.C0.ESC + "[D");
26753
26821
  break;
26754
26822
  case 39:
26755
26823
  if (e.metaKey) break;
26756
26824
  a
26757
- ? ((o.key = r.C0.ESC + "[1;" + (a + 1) + "C"),
26758
- o.key === r.C0.ESC + "[1;3C" &&
26759
- (o.key = r.C0.ESC + (i ? "f" : "[1;5C")))
26825
+ ? (o.key = r.C0.ESC + "[1;" + (a + 1) + "C")
26760
26826
  : (o.key = t ? r.C0.ESC + "OC" : r.C0.ESC + "[C");
26761
26827
  break;
26762
26828
  case 38:
26763
26829
  if (e.metaKey) break;
26764
26830
  a
26765
- ? ((o.key = r.C0.ESC + "[1;" + (a + 1) + "A"),
26766
- i ||
26767
- o.key !== r.C0.ESC + "[1;3A" ||
26768
- (o.key = r.C0.ESC + "[1;5A"))
26831
+ ? (o.key = r.C0.ESC + "[1;" + (a + 1) + "A")
26769
26832
  : (o.key = t ? r.C0.ESC + "OA" : r.C0.ESC + "[A");
26770
26833
  break;
26771
26834
  case 40:
26772
26835
  if (e.metaKey) break;
26773
26836
  a
26774
- ? ((o.key = r.C0.ESC + "[1;" + (a + 1) + "B"),
26775
- i ||
26776
- o.key !== r.C0.ESC + "[1;3B" ||
26777
- (o.key = r.C0.ESC + "[1;5B"))
26837
+ ? (o.key = r.C0.ESC + "[1;" + (a + 1) + "B")
26778
26838
  : (o.key = t ? r.C0.ESC + "OB" : r.C0.ESC + "[B");
26779
26839
  break;
26780
26840
  case 45:
26781
- e.shiftKey || e.ctrlKey || (o.key = r.C0.ESC + "[2~");
26841
+ e.shiftKey || e.ctrlKey ||
26842
+ (o.key = e.altKey
26843
+ ? r.C0.ESC + "[2;" + (a + 1) + "~"
26844
+ : r.C0.ESC + "[2~");
26782
26845
  break;
26783
26846
  case 46:
26784
26847
  o.key = a
@@ -26802,14 +26865,14 @@
26802
26865
  case 33:
26803
26866
  e.shiftKey
26804
26867
  ? (o.type = 2)
26805
- : e.ctrlKey
26868
+ : e.ctrlKey || e.altKey
26806
26869
  ? (o.key = r.C0.ESC + "[5;" + (a + 1) + "~")
26807
26870
  : (o.key = r.C0.ESC + "[5~");
26808
26871
  break;
26809
26872
  case 34:
26810
26873
  e.shiftKey
26811
26874
  ? (o.type = 3)
26812
- : e.ctrlKey
26875
+ : e.ctrlKey || e.altKey
26813
26876
  ? (o.key = r.C0.ESC + "[6;" + (a + 1) + "~")
26814
26877
  : (o.key = r.C0.ESC + "[6~");
26815
26878
  break;
@@ -26927,6 +26990,8 @@
26927
26990
  ))
26928
26991
  : 56 === e.keyCode
26929
26992
  ? (o.key = r.C0.DEL)
26993
+ : "/" === e.key
26994
+ ? (o.key = r.C0.US)
26930
26995
  : 219 === e.keyCode
26931
26996
  ? (o.key = r.C0.ESC)
26932
26997
  : 220 === e.keyCode
@@ -0,0 +1,193 @@
1
+ // Kitty keyboard transport for the shipped xterm wrapper (no build step).
2
+ // Spec: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
3
+ (function (root) {
4
+ 'use strict';
5
+ // Supported progressive enhancements: disambiguation, event types, all keys.
6
+ // Do not advertise alternate-layout/text reporting until those are implemented.
7
+ const SUPPORTED = 1 | 2 | 8;
8
+ const functional = {
9
+ Escape:[27,'u'], Enter:[13,'u'], Tab:[9,'u'], Backspace:[127,'u'],
10
+ Insert:[2,'~'], Delete:[3,'~'], ArrowLeft:[1,'D'], ArrowRight:[1,'C'],
11
+ ArrowUp:[1,'A'], ArrowDown:[1,'B'], PageUp:[5,'~'], PageDown:[6,'~'],
12
+ Home:[1,'H'], End:[1,'F'], CapsLock:[57358,'u'], ScrollLock:[57359,'u'],
13
+ NumLock:[57360,'u'], PrintScreen:[57361,'u'], Pause:[57362,'u'], ContextMenu:[57363,'u'],
14
+ MediaPlay:[57428,'u'], MediaPause:[57429,'u'], MediaPlayPause:[57430,'u'],
15
+ MediaStop:[57432,'u'], MediaFastForward:[57433,'u'], MediaRewind:[57434,'u'],
16
+ MediaTrackNext:[57435,'u'], MediaTrackPrevious:[57436,'u'], MediaRecord:[57437,'u'],
17
+ AudioVolumeDown:[57438,'u'], AudioVolumeUp:[57439,'u'], AudioVolumeMute:[57440,'u'],
18
+ };
19
+ [11,12,13,14,15,17,18,19,20,21,23,24].forEach((n,i) => functional['F'+(i+1)] = [n,'~']);
20
+ for (let i=13;i<=35;i++) functional['F'+i] = [57376+i-13,'u'];
21
+ const punctuation = {
22
+ Backquote:['`','~'], Minus:['-','_'], Equal:['=','+'], BracketLeft:['[','{'],
23
+ BracketRight:[']','}'], Backslash:['\\','|'], Semicolon:[';',':'], Quote:["'",'"'],
24
+ Comma:[',','<'], Period:['.','>'], Slash:['/','?'], Space:[' ',' '],
25
+ };
26
+ const modifiers = {Shift:[1,57441], Control:[4,57442], Alt:[2,57443], Meta:[8,57444],
27
+ OS:[8,57444], Super:[8,57444], Hyper:[16,57445], AltGraph:[0,57453]};
28
+ const has = (object,key) => Object.prototype.hasOwnProperty.call(object,key);
29
+ const lock = (e,name) => Boolean(e.getModifierState?.(name));
30
+ function physicalCode(e) {
31
+ if (e.code && e.code !== 'Unidentified') return e.code;
32
+ if (e.keyCode>=65 && e.keyCode<=90) return 'Key'+String.fromCharCode(e.keyCode);
33
+ if (e.keyCode>=48 && e.keyCode<=57) return 'Digit'+String.fromCharCode(e.keyCode);
34
+ return ({32:'Space',186:'Semicolon',187:'Equal',188:'Comma',189:'Minus',190:'Period',
35
+ 191:'Slash',192:'Backquote',219:'BracketLeft',220:'Backslash',221:'BracketRight',222:'Quote'})[e.keyCode] || '';
36
+ }
37
+ function baseCharacter(e, android) {
38
+ const code=physicalCode(e);
39
+ // Android's Alt accent conversion must not turn an E/I/N/U shortcut into a dead key.
40
+ const recover=android && e.altKey && !lock(e,'AltGraph');
41
+ if (recover || ([...e.key || ''].length===1 && e.key.codePointAt(0)<32)) {
42
+ if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase();
43
+ if (/^Digit[0-9]$/.test(code)) return code.slice(5);
44
+ if (has(punctuation,code)) return punctuation[code][0];
45
+ }
46
+ if ([...e.key || ''].length!==1) return null;
47
+ if (e.shiftKey) {
48
+ if (has(punctuation,code) && punctuation[code][1]===e.key) return punctuation[code][0];
49
+ if (/^Digit[0-9]$/.test(code) && ')!@#$%^&*('[Number(code.slice(5))]===e.key) return code.slice(5);
50
+ }
51
+ const lower=e.key.toLowerCase();
52
+ return [...lower].length===1 ? lower : e.key;
53
+ }
54
+ function keyDefinition(e, flags, android) {
55
+ if (has(modifiers,e.key)) {
56
+ const [bit,left]=modifiers[e.key];
57
+ const right=e.location===2 || /Right$/.test(e.code || '');
58
+ return {number:left+(right && e.key!=='AltGraph'?6:0),suffix:'u',modifier:bit};
59
+ }
60
+ if ((flags & 9) && (e.location===3 || /^Numpad/.test(e.code || ''))) {
61
+ const numpad={NumpadDecimal:57409,NumpadDivide:57410,NumpadMultiply:57411,
62
+ NumpadSubtract:57412,NumpadAdd:57413,NumpadEnter:57414,NumpadEqual:57415,
63
+ NumpadComma:57416};
64
+ const nav={ArrowLeft:57417,ArrowRight:57418,ArrowUp:57419,ArrowDown:57420,
65
+ PageUp:57421,PageDown:57422,Home:57423,End:57424,Insert:57425,Delete:57426,Clear:57427};
66
+ const number=has(nav,e.key)?nav[e.key]:/^Numpad[0-9]$/.test(e.code || '')
67
+ ?57399+Number(e.code.slice(6)):numpad[e.code];
68
+ if (number) return {number,suffix:'u'};
69
+ }
70
+ if (has(functional,e.key)) {
71
+ const [number,suffix]=functional[e.key];
72
+ return {number,suffix,recovery:['Enter','Tab','Backspace'].includes(e.key)};
73
+ }
74
+ const ch=baseCharacter(e,android);
75
+ return ch===null?null:{number:ch.codePointAt(0),suffix:'u',text:true};
76
+ }
77
+ class KittyKeyboard {
78
+ constructor(term, {android=false}={}) {
79
+ this.term=term; this.android=android; this.disposables=[];
80
+ this.metaKeys=new Set(); this.down=new Map(); this.suppressed=new Set(); this.reset();
81
+ const register=(prefix,fn) => this.disposables.push(term.parser.registerCsiHandler({prefix,final:'u'},p=>{fn(p);return true;}));
82
+ const value=(p,i,fallback=0) => Number.isInteger(p[i])?p[i]:fallback;
83
+ register('?',()=>term.input(`\x1b[?${this.flags}u`,false));
84
+ register('>',p=>{const s=this.state;s.stack.push(s.flags);if(s.stack.length>32)s.stack.shift();s.flags=value(p,0)&SUPPORTED;});
85
+ register('<',p=>{const s=this.state;let count=Math.min(value(p,0)||1,33);while(count--)s.flags=s.stack.pop()??0;});
86
+ register('=',p=>{const s=this.state,f=value(p,0)&SUPPORTED,m=value(p,1)||1;if(m===1)s.flags=f;else if(m===2)s.flags|=f;else if(m===3)s.flags&=~f;});
87
+ this.disposables.push(term.parser.registerEscHandler({final:'c'},()=>{this.reset();return false;}));
88
+ this.disposables.push(term.parser.registerCsiHandler({intermediates:'!',final:'p'},()=>{this.reset();return false;}));
89
+ }
90
+ get state() { return this.states[this.term.buffer.active.type==='alternate'?'alternate':'normal']; }
91
+ get flags() { return this.state.flags; }
92
+ reset() {
93
+ this.states={normal:{flags:0,stack:[]},alternate:{flags:0,stack:[]}};
94
+ this.clearHeld(false);
95
+ }
96
+ clearHeld(report=true) {
97
+ // Release keys reported to the client before losing focus, avoiding stuck remote keys.
98
+ if (report && (this.flags & 2)) for (const key of this.down.values()) {
99
+ if(key.reported) this.term.input(this.sequence(key.def,0,3),true);
100
+ }
101
+ this.metaKeys.clear();this.down.clear();this.suppressed.clear();this.suppressKeypress=false;
102
+ }
103
+ attach(element) {
104
+ const doc=element.ownerDocument, win=doc.defaultView;
105
+ const blur=()=>this.clearHeld();
106
+ const visibility=()=>{if(doc.hidden)this.clearHeld();};
107
+ element.addEventListener('blur',blur);win.addEventListener('blur',blur);
108
+ doc.addEventListener('visibilitychange',visibility);
109
+ this.disposables.push({dispose:()=>{element.removeEventListener('blur',blur);win.removeEventListener('blur',blur);doc.removeEventListener('visibilitychange',visibility);}});
110
+ }
111
+ dispose() {this.clearHeld(false);for(const d of this.disposables)d.dispose();}
112
+ sequence(def,bits,type) {
113
+ const event=(this.flags&2) && type!==1?':'+type:'';
114
+ const mods=bits+1;
115
+ if(def.suffix!=='u' && mods===1 && !event)
116
+ return '\x1b['+(def.number===1?'':def.number)+def.suffix;
117
+ return `\x1b[${def.number}${mods!==1||event?';'+mods+event:''}${def.suffix}`;
118
+ }
119
+ handle(event) {
120
+ if (!['keydown','keyup','keypress'].includes(event.type)) return true;
121
+ const identity=physicalCode(event)||event.key;
122
+ const metaKey=['Meta','OS','Super'].includes(event.key);
123
+ if(this.android && metaKey) {
124
+ if(event.type==='keydown')this.metaKeys.add(event.code||String(event.location||0));
125
+ if(event.type==='keyup')this.metaKeys.delete(event.code||String(event.location||0));
126
+ }
127
+ // Patch only this terminal's Android event, preserving the native DOM event.
128
+ if(this.android && this.metaKeys.size && !event.metaKey) {
129
+ const original=event.getModifierState?.bind(event);
130
+ Object.defineProperties(event,{
131
+ metaKey:{configurable:true,value:true},
132
+ getModifierState:{configurable:true,value:name=>name==='Meta'||Boolean(original?.(name))},
133
+ });
134
+ }
135
+ if(!this.flags || this.term.options.disableStdin) return true;
136
+ const consume=()=>{event.preventDefault();event.stopPropagation();return false;};
137
+ if(event.type==='keypress')return this.suppressKeypress?consume():true;
138
+ let previous=this.down.get(identity);
139
+ if(event.type==='keyup') {
140
+ // Some Android IMEs emit only the release of an Alt dead key. Recover
141
+ // that chord here too, never falling back to legacy ESC text in Kitty mode.
142
+ if(!previous && this.android && event.altKey && event.key==='Dead' &&
143
+ !event.isComposing && !lock(event,'AltGraph') && (this.flags&9)) {
144
+ const def=keyDefinition(event,this.flags,this.android);
145
+ if(def) {
146
+ this.term.input(this.sequence(def,this.bits(event),1),true);
147
+ previous={def,reported:true};this.suppressed.add(identity);
148
+ }
149
+ }
150
+ this.down.delete(identity);
151
+ const suppressed=this.suppressed.delete(identity);
152
+ this.suppressKeypress=false;
153
+ if(previous?.reported && (this.flags&2)) {
154
+ let bits=this.bits(event);
155
+ if(previous.def.modifier && ![...this.down.values()].some(k=>k.def.modifier===previous.def.modifier)) bits &= ~previous.def.modifier;
156
+ if(metaKey && this.metaKeys.size) bits|=8;
157
+ this.term.input(this.sequence(previous.def,bits,3),true);
158
+ }
159
+ return suppressed?consume():true;
160
+ }
161
+ this.suppressKeypress=false;
162
+ if(event.isComposing || event.keyCode===229 && event.key==='Unidentified')return true;
163
+ if(event.key==='Dead' && !(this.android && event.altKey && !lock(event,'AltGraph')))return true;
164
+ const def=keyDefinition(event,this.flags,this.android);
165
+ if(!def)return true;
166
+ let bits=this.bits(event);
167
+ if(def.modifier!==undefined) {
168
+ if(!(this.flags&8))return true;
169
+ bits|=def.modifier;
170
+ }
171
+ const all=Boolean(this.flags&8);
172
+ // Enter/Tab/Backspace remain recovery keys unless modified or report-all is requested.
173
+ if(!all && def.recovery && !(bits&~192))return true;
174
+ const shortcut=Boolean(bits&62);
175
+ if(def.text && !all && !(this.flags&1 && shortcut))return true;
176
+ // With only event reporting requested, leave legacy printable shortcuts untouched.
177
+ if(def.text && !all && !(this.flags&1))return true;
178
+ const type=event.repeat?2:1;
179
+ this.down.set(identity,{def,reported:!(def.recovery && !all)});this.suppressed.add(identity);this.suppressKeypress=true;
180
+ this.term.input(this.sequence(def,bits,type),true);
181
+ return consume();
182
+ }
183
+ bits(e) {
184
+ const altgr=lock(e,'AltGraph');
185
+ return (e.shiftKey?1:0)|(!altgr&&e.altKey?2:0)|(!altgr&&e.ctrlKey?4:0)|
186
+ (e.metaKey || this.android&&this.metaKeys.size?8:0)|
187
+ (lock(e,'Hyper')?16:0)|(lock(e,'CapsLock')?64:0)|(lock(e,'NumLock')?128:0);
188
+ }
189
+ }
190
+ const api={KittyKeyboard,SUPPORTED};
191
+ if(typeof module==='object' && module.exports)module.exports=api;
192
+ else root.GoTTYKittyKeyboard=api;
193
+ })(globalThis);
@@ -0,0 +1,115 @@
1
+ const assert = require('node:assert/strict');
2
+ const test = require('node:test');
3
+ const fs = require('node:fs');
4
+ const vm = require('node:vm');
5
+ const path = require('node:path');
6
+
7
+ // Exercise the shipped bundle's wrapper method, without a DOM or new dependencies.
8
+ const bundle = fs.readFileSync(path.join(__dirname, '../static/js/gotty.js'), 'utf8');
9
+ const start = bundle.indexOf(' handleAndroidAltKey(event) {');
10
+ assert.ok(start >= 0);
11
+ const end = bundle.indexOf(' info() {', start);
12
+ assert.ok(end > start);
13
+ const method = bundle.slice(start, end);
14
+ function setup(userAgent = 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 wv') {
15
+ const handler = vm.runInNewContext(`({${method}}).handleAndroidAltKey`, {
16
+ navigator: { userAgent },
17
+ });
18
+ const sent = [];
19
+ const target = { term: { input: (text, userInput) => sent.push([text, userInput]) } };
20
+ function dispatch(overrides = {}) {
21
+ const event = {
22
+ type: 'keydown', code: 'KeyE', key: 'Dead', keyCode: 69,
23
+ altKey: true, ctrlKey: false, metaKey: false, shiftKey: false,
24
+ isComposing: false, getModifierState: () => false,
25
+ prevented: false, stopped: false,
26
+ preventDefault() { this.prevented = true; },
27
+ stopPropagation() { this.stopped = true; },
28
+ ...overrides,
29
+ };
30
+ return { result: handler.call(target, event), event };
31
+ }
32
+ return { sent, dispatch };
33
+ }
34
+
35
+ test('Android accent shortcuts emit one ESC sequence; keypress/keyup do not duplicate it', () => {
36
+ for (const letter of ['E', 'I', 'N', 'U']) {
37
+ for (const keyCode of [letter.charCodeAt(0), 229]) {
38
+ const { sent, dispatch } = setup();
39
+ const { event, result } = dispatch({ code: `Key${letter}`, keyCode });
40
+ assert.equal(result, false);
41
+ assert.equal(event.prevented, true);
42
+ assert.equal(event.stopped, true);
43
+ dispatch({ type: 'keypress', code: `Key${letter}`, keyCode });
44
+ dispatch({ type: 'keyup', code: `Key${letter}`, keyCode });
45
+ assert.deepEqual(sent, [[`\x1b${letter.toLowerCase()}`, true]]);
46
+ // No dead-key state is retained in this handler for the next ordinary key.
47
+ assert.equal(dispatch({ altKey: false, key: 'e' }).result, true);
48
+ assert.equal(sent.length, 1);
49
+ }
50
+ }
51
+ });
52
+
53
+ test('all printable physical Alt keys bypass dead-key handling, including Shift and repeat', () => {
54
+ const { sent, dispatch } = setup();
55
+ for (const [code, shiftKey, expected] of [
56
+ ['KeyA', false, 'a'], ['KeyE', true, 'E'], ['Digit0', false, '0'],
57
+ ['Digit1', true, '!'], ['Digit0', true, ')'], ['Quote', false, "'"],
58
+ ['Backquote', true, '~'], ['Backslash', false, '\\'], ['Space', false, ' '],
59
+ ]) {
60
+ dispatch({ code, shiftKey, key: 'Dead' });
61
+ assert.deepEqual(sent.at(-1), [`\x1b${expected}`, true]);
62
+ }
63
+ dispatch({ code: 'KeyE', key: 'é', repeat: true });
64
+ assert.deepEqual(sent.at(-1), ['\x1be', true]);
65
+ });
66
+
67
+ test('composition, AltGr, other modifiers, and unmapped keys retain existing handling', () => {
68
+ for (const overrides of [
69
+ { isComposing: true, keyCode: 229 }, { getModifierState: key => key === 'AltGraph' },
70
+ { altKey: false }, { ctrlKey: true }, { metaKey: true },
71
+ { code: '', keyCode: 229 }, { code: 'ArrowLeft' }, { code: 'Enter' },
72
+ { code: 'Unidentified', keyCode: 229 }, { code: 'toString' },
73
+ { type: 'keyup', key: 'e' },
74
+ ]) {
75
+ const { sent, dispatch } = setup();
76
+ const { result, event } = dispatch(overrides);
77
+ assert.equal(result, true);
78
+ assert.equal(event.prevented, false);
79
+ assert.deepEqual(sent, []);
80
+ }
81
+ });
82
+
83
+ test('non-Android browsers retain existing Alt/dead-key handling', () => {
84
+ for (const ua of ['Mozilla/5.0 (X11; Linux x86_64)', 'Mozilla/5.0 (Macintosh)', 'Windows']) {
85
+ const { sent, dispatch } = setup(ua);
86
+ assert.equal(dispatch().result, true);
87
+ assert.deepEqual(sent, []);
88
+ }
89
+ });
90
+
91
+ // Replay the actual Android 16 WebView trace: no letter keydown and empty code.
92
+ test('Android keyup-only dead keys use legacy keyCode, and ordinary IME input stays untouched', () => {
93
+ const { sent, dispatch } = setup();
94
+ for (const keyCode of [69, 78, 85, 73]) {
95
+ dispatch({ type: 'keydown', code: '', key: 'Alt', keyCode: 18 });
96
+ assert.equal(dispatch({ type: 'keyup', code: '', key: 'Dead', keyCode }).result, false);
97
+ dispatch({ type: 'keyup', code: '', key: 'Alt', keyCode: 18 });
98
+ }
99
+ assert.deepEqual(sent, ['e', 'n', 'u', 'i'].map(c => ['\x1b' + c, true]));
100
+ assert.equal(dispatch({ code: '', key: 'Unidentified', keyCode: 229, altKey: false }).result, true);
101
+ assert.equal(sent.length, 4);
102
+ });
103
+
104
+ test('legacy keydown plus dead keyup emits once, even with different code availability', () => {
105
+ for (const [downCode, upCode] of [['', ''], ['KeyE', ''], ['', 'KeyE']]) {
106
+ const { sent, dispatch } = setup();
107
+ dispatch({ code: downCode, keyCode: 69 });
108
+ dispatch({ type: 'keypress', code: downCode, keyCode: 69 });
109
+ dispatch({ type: 'keyup', code: upCode, keyCode: 69 });
110
+ assert.deepEqual(sent, [['\x1be', true]]);
111
+ // The next keyup-only shortcut must not be suppressed by the preceding press.
112
+ dispatch({ type: 'keyup', code: '', keyCode: 69 });
113
+ assert.equal(sent.length, 2);
114
+ }
115
+ });
@@ -0,0 +1,80 @@
1
+ const assert = require('node:assert/strict');
2
+ const test = require('node:test');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const vm = require('node:vm');
6
+
7
+ // Exercise the keyboard encoder shipped in the frontend bundle.
8
+ const bundle = fs.readFileSync(path.join(__dirname, '../static/js/gotty.js'), 'utf8');
9
+ const marker = 't.evaluateKeyboardEvent = function (e, t, i, n) {';
10
+ const start = bundle.indexOf(marker);
11
+ assert.ok(start >= 0);
12
+ const end = bundle.indexOf('\n },\n 482:', start);
13
+ assert.ok(end > start);
14
+ const source = bundle.slice(start + 't.evaluateKeyboardEvent = '.length, end).trim().replace(/;$/, '');
15
+ const evaluate = vm.runInNewContext(`(${source})`, { r: { C0: { ESC: '\x1b', US: '\x1f' } }, s: {} });
16
+ const arrows = [[37, 'D'], [38, 'A'], [39, 'C'], [40, 'B']];
17
+
18
+ for (const isMac of [false, true]) {
19
+ test(`arrow modifiers stay distinct (isMac=${isMac})`, () => {
20
+ for (const [keyCode, suffix] of arrows) {
21
+ for (const applicationCursorMode of [false, true]) {
22
+ const sequences = new Set();
23
+ for (let mask = 0; mask < 8; mask++) {
24
+ const result = evaluate({
25
+ keyCode, metaKey: false,
26
+ shiftKey: Boolean(mask & 1), altKey: Boolean(mask & 2), ctrlKey: Boolean(mask & 4),
27
+ }, applicationCursorMode, isMac, false);
28
+ const expected = mask
29
+ ? `\x1b[1;${mask + 1}${suffix}`
30
+ : `\x1b${applicationCursorMode ? 'O' : '['}${suffix}`;
31
+ assert.equal(result.key, expected);
32
+ sequences.add(result.key);
33
+ }
34
+ assert.equal(sequences.size, 8);
35
+ }
36
+ }
37
+ });
38
+ }
39
+
40
+ test('Alt Insert/PageUp/PageDown keep Alt while preserving existing shortcuts', () => {
41
+ for (const isMac of [false, true]) {
42
+ const encode = (keyCode, modifiers = {}) => evaluate({
43
+ keyCode, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false,
44
+ ...modifiers,
45
+ }, false, isMac, false);
46
+ for (const [keyCode, parameter] of [[45, 2], [33, 5], [34, 6]]) {
47
+ assert.equal(encode(keyCode).key, `\x1b[${parameter}~`);
48
+ assert.equal(encode(keyCode, { altKey: true }).key, `\x1b[${parameter};3~`);
49
+ }
50
+ for (const [keyCode, parameter, scrollType] of [[33, 5, 2], [34, 6, 3]]) {
51
+ assert.equal(encode(keyCode, { ctrlKey: true }).key, `\x1b[${parameter};5~`);
52
+ assert.equal(encode(keyCode, { ctrlKey: true, altKey: true }).key, `\x1b[${parameter};7~`);
53
+ for (const ctrlKey of [false, true]) {
54
+ for (const altKey of [false, true]) {
55
+ const result = encode(keyCode, { shiftKey: true, ctrlKey, altKey });
56
+ assert.equal(result.type, scrollType);
57
+ assert.equal(result.key, undefined);
58
+ }
59
+ }
60
+ }
61
+ for (const modifiers of [
62
+ { ctrlKey: true }, { shiftKey: true }, { ctrlKey: true, shiftKey: true },
63
+ { ctrlKey: true, altKey: true }, { shiftKey: true, altKey: true },
64
+ ]) assert.equal(encode(45, modifiers).key, undefined);
65
+ }
66
+ });
67
+
68
+
69
+ test('Ctrl slash sends US like Ctrl underscore, including alternate layout keyCodes', () => {
70
+ for (const isMac of [false, true]) {
71
+ for (const keyCode of [191, 226]) {
72
+ const result = evaluate({ key: '/', keyCode, ctrlKey: true,
73
+ shiftKey: false, altKey: false, metaKey: false }, false, isMac, false);
74
+ assert.equal(result.key, '\x1f');
75
+ }
76
+ const plain = evaluate({ key: '/', keyCode: 191, ctrlKey: false,
77
+ shiftKey: false, altKey: false, metaKey: false }, false, isMac, false);
78
+ assert.equal(plain.key, '/');
79
+ }
80
+ });
@@ -0,0 +1,53 @@
1
+ // Opt-in real bundled-xterm integration; no browser/dependency downloads.
2
+ // CHROMIUM_HEADLESS=/path/to/chromium-headless-shell node --test tests/kitty-keyboard-browser.test.js
3
+ const test=require('node:test');
4
+ const assert=require('node:assert/strict');
5
+ const fs=require('node:fs');
6
+ const path=require('node:path');
7
+ const http=require('node:http');
8
+ const {spawn}=require('node:child_process');
9
+ const {once}=require('node:events');
10
+ const WebSocket=require('ws');
11
+ const root=path.join(__dirname,'../static');
12
+ test('shipped xterm parses Kitty modes and delivers one encoded DOM event per key', {skip:!process.env.CHROMIUM_HEADLESS,timeout:60000},async()=>{
13
+ const bundle=fs.readFileSync(path.join(root,'js/gotty.js'),'utf8').replace('(i.nc = void 0));','(i.nc = void 0)); window.TestGoTTYXterm = i(634).GoTTYXterm;');
14
+ const server=http.createServer((req,res)=>{
15
+ if(req.url==='/bundle.js'){res.setHeader('Content-Type','text/javascript');res.end(bundle);}
16
+ else if(req.url==='/keyboard.js'){res.setHeader('Content-Type','text/javascript');res.end(fs.readFileSync(path.join(root,'js/kitty-keyboard.js')));}
17
+ else res.end('<!doctype html><div id="fixture" style="width:800px;height:600px"></div><script src="/keyboard.js"></script><script src="/bundle.js"></script><script>window.wrapper=new TestGoTTYXterm(document.getElementById("fixture"));window.sent=[];wrapper.onInput(b=>sent.push(new TextDecoder().decode(b)));wrapper.kittyKeyboard.android=true;</script>');
18
+ });
19
+ server.listen(0,'127.0.0.1');await once(server,'listening');
20
+ const child=spawn(process.env.CHROMIUM_HEADLESS,['--no-sandbox','--disable-gpu','--remote-debugging-port=0','http://127.0.0.1:'+server.address().port],{detached:true,stdio:['ignore','ignore','pipe']});
21
+ let ws;
22
+ try{
23
+ const endpoint=await new Promise((resolve,reject)=>{let log='';const timer=setTimeout(()=>reject(Error('Browser startup timeout: '+log)),15000);child.on('error',reject);child.on('exit',code=>{clearTimeout(timer);reject(Error('Browser exited '+code+': '+log));});child.stderr.on('data',b=>{log+=b;const m=log.match(/DevTools listening on (ws:\/\/[^\s]+)/);if(m){clearTimeout(timer);resolve(m[1]);}});});
24
+ const u=new URL(endpoint);let tab;
25
+ for(let i=0;i<50;i++){tab=(await (await fetch('http://'+u.host+'/json/list')).json()).find(x=>x.type==='page');if(tab)break;await new Promise(r=>setTimeout(r,100));}
26
+ ws=new WebSocket(tab.webSocketDebuggerUrl);await once(ws,'open');let id=0;const pending=new Map();ws.on('message',data=>{const m=JSON.parse(data);if(pending.has(m.id)){const {resolve,reject}=pending.get(m.id);pending.delete(m.id);m.error?reject(m.error):resolve(m.result);}});
27
+ const call=(method,params)=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
28
+ for(let i=0;i<50;i++){const r=await call('Runtime.evaluate',{expression:'Boolean(window.wrapper)',returnByValue:true});if(r.result.value)break;await new Promise(r=>setTimeout(r,100));}
29
+ const result=await call('Runtime.evaluate',{awaitPromise:true,returnByValue:true,expression:`(async()=>{
30
+ const term=wrapper.term, kb=wrapper.kittyKeyboard;
31
+ const write=s=>new Promise(r=>term.write(s,r));
32
+ const equal=(a,b)=>{if(JSON.stringify(a)!==JSON.stringify(b))throw Error(JSON.stringify({actual:a,expected:b}));};
33
+ const key=(type,key,code,mods={})=>term.textarea.dispatchEvent(new KeyboardEvent(type,{key,code,bubbles:true,cancelable:true,...mods}));
34
+ await write('\\x1b[?u');equal(sent.splice(0),['\\x1b[?0u']);
35
+ // Split escape sequence across writes: only the parser may enable the mode.
36
+ await write('\\x1b[>');await write('11u');equal(kb.flags,11);
37
+ key('keydown','Meta','MetaLeft');key('keydown','x','KeyX',{ctrlKey:true,shiftKey:true});
38
+ key('keypress','x','KeyX',{ctrlKey:true,shiftKey:true});key('keyup','x','KeyX',{ctrlKey:true,shiftKey:true});key('keyup','Meta','MetaLeft');
39
+ equal(sent.splice(0),['\\x1b[57444;9u','\\x1b[120;14u','\\x1b[120;14:3u','\\x1b[57444;1:3u']);
40
+ await write('\\x1b[?1049h');equal(kb.flags,0);await write('\\x1b[>1u');equal(kb.flags,1);
41
+ await write('\\x1b[?1049l');equal(kb.flags,11);
42
+ await write('\\x1b[<u');equal(kb.flags,0);
43
+ key('keydown','c','KeyC',{ctrlKey:true,keyCode:67});key('keyup','c','KeyC',{ctrlKey:true,keyCode:67});equal(sent.splice(0),['\\x03']);
44
+ await write('\\x1b[>11u');await write('\\x1bc');equal(kb.flags,0);
45
+ await write('\\x1b[?u');equal(sent.splice(0),['\\x1b[?0u']);
46
+ wrapper.close();return 'ok';
47
+ })()`});
48
+ assert.equal(result.exceptionDetails,undefined,JSON.stringify(result.exceptionDetails));assert.equal(result.result.value,'ok');
49
+ }finally{
50
+ ws?.close();if(child.pid){try{process.kill(-child.pid,'SIGTERM');}catch{}}
51
+ server.closeAllConnections();await new Promise(r=>server.close(r));
52
+ }
53
+ });