buninu 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/apps/jsgotty/LICENSES/MIT-jsmdcui.txt +22 -0
  3. package/apps/jsgotty/NOTICE +5 -0
  4. package/apps/jsgotty/README.en.md +21 -0
  5. package/apps/jsgotty/README.md +21 -0
  6. package/apps/jsgotty/client-modes.mjs +117 -0
  7. package/apps/jsgotty/client.mjs +100 -10
  8. package/apps/jsgotty/clipboard-powershell.js +18 -0
  9. package/apps/jsgotty/clipboard.js +148 -0
  10. package/apps/jsgotty/gotty.js +13 -0
  11. package/apps/jsgotty/package-lock.json +2 -2
  12. package/apps/jsgotty/package.json +1 -1
  13. package/apps/jsgotty/static/css/index.css +14 -1
  14. package/apps/jsgotty/static/js/gotty.js +107 -8
  15. package/apps/jsgotty/static/js/kitty.js +115 -7
  16. package/apps/jsgotty/terminal-modes.js +332 -0
  17. package/apps/jsgotty/tests/client-exit.test.mjs +45 -0
  18. package/apps/jsgotty/tests/client-modes.test.mjs +105 -0
  19. package/apps/jsgotty/tests/clipboard-browser.test.js +81 -0
  20. package/apps/jsgotty/tests/clipboard.test.js +75 -0
  21. package/apps/jsgotty/tests/manual/zoom-collector.cjs +55 -0
  22. package/apps/jsgotty/tests/manual/zoom-debug.js +39 -0
  23. package/apps/jsgotty/tests/manual-touch-diagnostics.js +33 -0
  24. package/apps/jsgotty/tests/manual-touch-diagnostics.mjs +22 -0
  25. package/apps/jsgotty/tests/nested-cli-pty.test.mjs +116 -0
  26. package/apps/jsgotty/tests/reconnect-modes-browser.test.js +144 -0
  27. package/apps/jsgotty/tests/reconnect-modes.md +44 -0
  28. package/apps/jsgotty/tests/reconnect-modes.test.js +166 -0
  29. package/apps/jsgotty/tests/touch-drag-browser.test.js +106 -0
  30. package/apps/jsgotty/tests/wheel-browser.test.js +60 -0
  31. package/apps/jsmdcui/CHANGELOG.md +44 -0
  32. package/apps/jsmdcui/package.json +1 -1
  33. package/apps/jsmdcui/src/highlight/go-regexp.js +83 -0
  34. package/apps/jsmdcui/src/highlight/highlighter.js +49 -62
  35. package/apps/jsmdcui/src/highlight/parser.js +8 -5
  36. package/apps/jsmdcui/tests/go-regexp.test.js +98 -0
  37. package/apps/jsmdcui/tests/heading-ids.test.js +21 -16
  38. package/apps/jsmdcui/tests/highlight-regions.test.js +73 -0
  39. package/apps/jsmdcui/tests/id-collision.test.js +32 -9
  40. package/apps/jsmdcui/tests/markdown-behavior.mjs +9 -0
  41. package/apps/jsmdcui/tests/syntax-parity/NORMAL-HTML.md +46 -0
  42. package/apps/jsmdcui/tests/syntax-parity/README.md +85 -0
  43. package/apps/jsmdcui/tests/syntax-parity/REPORT.md +328 -0
  44. package/apps/jsmdcui/tests/syntax-parity/compare.mjs +75 -0
  45. package/apps/jsmdcui/tests/syntax-parity/go.mod +5 -0
  46. package/apps/jsmdcui/tests/syntax-parity/normal-html-report.json +619 -0
  47. package/apps/jsmdcui/tests/syntax-parity/normal-html.html +9 -0
  48. package/apps/jsmdcui/tests/syntax-parity/oracle.go +49 -0
  49. package/apps/jsmdcui/tests/syntax-parity/report.json +2174 -0
  50. package/apps/jsmdcui/tests/syntax-parity/samples/PowerShell.txt +39 -0
  51. package/apps/jsmdcui/tests/syntax-parity/samples/ada.txt +25 -0
  52. package/apps/jsmdcui/tests/syntax-parity/samples/apacheconf.txt +49 -0
  53. package/apps/jsmdcui/tests/syntax-parity/samples/arduino.txt +38 -0
  54. package/apps/jsmdcui/tests/syntax-parity/samples/asciidoc.txt +33 -0
  55. package/apps/jsmdcui/tests/syntax-parity/samples/asm.txt +77 -0
  56. package/apps/jsmdcui/tests/syntax-parity/samples/ats.txt +48 -0
  57. package/apps/jsmdcui/tests/syntax-parity/samples/awk.txt +28 -0
  58. package/apps/jsmdcui/tests/syntax-parity/samples/b.txt +27 -0
  59. package/apps/jsmdcui/tests/syntax-parity/samples/bat.txt +39 -0
  60. package/apps/jsmdcui/tests/syntax-parity/samples/c.txt +34 -0
  61. package/apps/jsmdcui/tests/syntax-parity/samples/caddyfile.txt +14 -0
  62. package/apps/jsmdcui/tests/syntax-parity/samples/cake.txt +7 -0
  63. package/apps/jsmdcui/tests/syntax-parity/samples/clojure.txt +18 -0
  64. package/apps/jsmdcui/tests/syntax-parity/samples/cmake.txt +20 -0
  65. package/apps/jsmdcui/tests/syntax-parity/samples/coffeescript.txt +32 -0
  66. package/apps/jsmdcui/tests/syntax-parity/samples/colortest.txt +19 -0
  67. package/apps/jsmdcui/tests/syntax-parity/samples/conky.txt +12 -0
  68. package/apps/jsmdcui/tests/syntax-parity/samples/cpp.txt +46 -0
  69. package/apps/jsmdcui/tests/syntax-parity/samples/crontab.txt +16 -0
  70. package/apps/jsmdcui/tests/syntax-parity/samples/crystal.txt +28 -0
  71. package/apps/jsmdcui/tests/syntax-parity/samples/csharp.txt +28 -0
  72. package/apps/jsmdcui/tests/syntax-parity/samples/css.txt +19 -0
  73. package/apps/jsmdcui/tests/syntax-parity/samples/csx.txt +7 -0
  74. package/apps/jsmdcui/tests/syntax-parity/samples/cuda.txt +37 -0
  75. package/apps/jsmdcui/tests/syntax-parity/samples/cython.txt +22 -0
  76. package/apps/jsmdcui/tests/syntax-parity/samples/d.txt +52 -0
  77. package/apps/jsmdcui/tests/syntax-parity/samples/dart.txt +27 -0
  78. package/apps/jsmdcui/tests/syntax-parity/samples/default.txt +8 -0
  79. package/apps/jsmdcui/tests/syntax-parity/samples/dockerfile.txt +10 -0
  80. package/apps/jsmdcui/tests/syntax-parity/samples/dot.txt +15 -0
  81. package/apps/jsmdcui/tests/syntax-parity/samples/elixir.txt +23 -0
  82. package/apps/jsmdcui/tests/syntax-parity/samples/elm.txt +20 -0
  83. package/apps/jsmdcui/tests/syntax-parity/samples/erb.txt +32 -0
  84. package/apps/jsmdcui/tests/syntax-parity/samples/erlang.txt +23 -0
  85. package/apps/jsmdcui/tests/syntax-parity/samples/fish.txt +20 -0
  86. package/apps/jsmdcui/tests/syntax-parity/samples/forth.txt +17 -0
  87. package/apps/jsmdcui/tests/syntax-parity/samples/fortran.txt +40 -0
  88. package/apps/jsmdcui/tests/syntax-parity/samples/freebsd-kernel.txt +10 -0
  89. package/apps/jsmdcui/tests/syntax-parity/samples/fsharp.txt +27 -0
  90. package/apps/jsmdcui/tests/syntax-parity/samples/gdscript.txt +26 -0
  91. package/apps/jsmdcui/tests/syntax-parity/samples/gemini.txt +11 -0
  92. package/apps/jsmdcui/tests/syntax-parity/samples/gentoo-ebuild.txt +28 -0
  93. package/apps/jsmdcui/tests/syntax-parity/samples/gentoo-etc-portage.txt +14 -0
  94. package/apps/jsmdcui/tests/syntax-parity/samples/git-commit.txt +19 -0
  95. package/apps/jsmdcui/tests/syntax-parity/samples/git-config.txt +11 -0
  96. package/apps/jsmdcui/tests/syntax-parity/samples/git-rebase-todo.txt +10 -0
  97. package/apps/jsmdcui/tests/syntax-parity/samples/gleam.txt +44 -0
  98. package/apps/jsmdcui/tests/syntax-parity/samples/glsl.txt +18 -0
  99. package/apps/jsmdcui/tests/syntax-parity/samples/gnuplot.txt +11 -0
  100. package/apps/jsmdcui/tests/syntax-parity/samples/go.txt +33 -0
  101. package/apps/jsmdcui/tests/syntax-parity/samples/godoc.txt +9 -0
  102. package/apps/jsmdcui/tests/syntax-parity/samples/golo.txt +50 -0
  103. package/apps/jsmdcui/tests/syntax-parity/samples/gomod.txt +16 -0
  104. package/apps/jsmdcui/tests/syntax-parity/samples/graphql.txt +20 -0
  105. package/apps/jsmdcui/tests/syntax-parity/samples/groff.txt +18 -0
  106. package/apps/jsmdcui/tests/syntax-parity/samples/groovy.txt +42 -0
  107. package/apps/jsmdcui/tests/syntax-parity/samples/haml.txt +16 -0
  108. package/apps/jsmdcui/tests/syntax-parity/samples/hare.txt +31 -0
  109. package/apps/jsmdcui/tests/syntax-parity/samples/haskell.txt +26 -0
  110. package/apps/jsmdcui/tests/syntax-parity/samples/hc.txt +28 -0
  111. package/apps/jsmdcui/tests/syntax-parity/samples/html.txt +26 -0
  112. package/apps/jsmdcui/tests/syntax-parity/samples/html4.txt +20 -0
  113. package/apps/jsmdcui/tests/syntax-parity/samples/html5.txt +20 -0
  114. package/apps/jsmdcui/tests/syntax-parity/samples/ini.txt +16 -0
  115. package/apps/jsmdcui/tests/syntax-parity/samples/inputrc.txt +14 -0
  116. package/apps/jsmdcui/tests/syntax-parity/samples/java.txt +18 -0
  117. package/apps/jsmdcui/tests/syntax-parity/samples/javascript.txt +41 -0
  118. package/apps/jsmdcui/tests/syntax-parity/samples/jinja2.txt +13 -0
  119. package/apps/jsmdcui/tests/syntax-parity/samples/json.txt +21 -0
  120. package/apps/jsmdcui/tests/syntax-parity/samples/jsonnet.txt +28 -0
  121. package/apps/jsmdcui/tests/syntax-parity/samples/julia.txt +24 -0
  122. package/apps/jsmdcui/tests/syntax-parity/samples/justfile.txt +26 -0
  123. package/apps/jsmdcui/tests/syntax-parity/samples/keymap.txt +15 -0
  124. package/apps/jsmdcui/tests/syntax-parity/samples/kickstart.txt +16 -0
  125. package/apps/jsmdcui/tests/syntax-parity/samples/kotlin.txt +30 -0
  126. package/apps/jsmdcui/tests/syntax-parity/samples/kvlang.txt +37 -0
  127. package/apps/jsmdcui/tests/syntax-parity/samples/ledger.txt +13 -0
  128. package/apps/jsmdcui/tests/syntax-parity/samples/lfe.txt +17 -0
  129. package/apps/jsmdcui/tests/syntax-parity/samples/lilypond.txt +15 -0
  130. package/apps/jsmdcui/tests/syntax-parity/samples/lisp.txt +17 -0
  131. package/apps/jsmdcui/tests/syntax-parity/samples/log.txt +45 -0
  132. package/apps/jsmdcui/tests/syntax-parity/samples/lua.txt +48 -0
  133. package/apps/jsmdcui/tests/syntax-parity/samples/mail.txt +17 -0
  134. package/apps/jsmdcui/tests/syntax-parity/samples/makefile.txt +25 -0
  135. package/apps/jsmdcui/tests/syntax-parity/samples/man.txt +12 -0
  136. package/apps/jsmdcui/tests/syntax-parity/samples/markdown.txt +21 -0
  137. package/apps/jsmdcui/tests/syntax-parity/samples/mc.txt +11 -0
  138. package/apps/jsmdcui/tests/syntax-parity/samples/meson.txt +29 -0
  139. package/apps/jsmdcui/tests/syntax-parity/samples/micro.txt +25 -0
  140. package/apps/jsmdcui/tests/syntax-parity/samples/mpdconf.txt +13 -0
  141. package/apps/jsmdcui/tests/syntax-parity/samples/msbuild.txt +7 -0
  142. package/apps/jsmdcui/tests/syntax-parity/samples/nanorc.txt +16 -0
  143. package/apps/jsmdcui/tests/syntax-parity/samples/nftables.txt +22 -0
  144. package/apps/jsmdcui/tests/syntax-parity/samples/nginx.txt +17 -0
  145. package/apps/jsmdcui/tests/syntax-parity/samples/nim.txt +23 -0
  146. package/apps/jsmdcui/tests/syntax-parity/samples/nix.txt +15 -0
  147. package/apps/jsmdcui/tests/syntax-parity/samples/nu.txt +92 -0
  148. package/apps/jsmdcui/tests/syntax-parity/samples/objc.txt +36 -0
  149. package/apps/jsmdcui/tests/syntax-parity/samples/ocaml.txt +25 -0
  150. package/apps/jsmdcui/tests/syntax-parity/samples/octave.txt +35 -0
  151. package/apps/jsmdcui/tests/syntax-parity/samples/odin.txt +35 -0
  152. package/apps/jsmdcui/tests/syntax-parity/samples/pascal.txt +22 -0
  153. package/apps/jsmdcui/tests/syntax-parity/samples/patch.txt +13 -0
  154. package/apps/jsmdcui/tests/syntax-parity/samples/peg.txt +16 -0
  155. package/apps/jsmdcui/tests/syntax-parity/samples/perl.txt +27 -0
  156. package/apps/jsmdcui/tests/syntax-parity/samples/php.txt +44 -0
  157. package/apps/jsmdcui/tests/syntax-parity/samples/pkg-config.txt +12 -0
  158. package/apps/jsmdcui/tests/syntax-parity/samples/po.txt +12 -0
  159. package/apps/jsmdcui/tests/syntax-parity/samples/pony.txt +29 -0
  160. package/apps/jsmdcui/tests/syntax-parity/samples/pov.txt +17 -0
  161. package/apps/jsmdcui/tests/syntax-parity/samples/privoxy-action.txt +14 -0
  162. package/apps/jsmdcui/tests/syntax-parity/samples/privoxy-config.txt +10 -0
  163. package/apps/jsmdcui/tests/syntax-parity/samples/privoxy-filter.txt +12 -0
  164. package/apps/jsmdcui/tests/syntax-parity/samples/proto.txt +22 -0
  165. package/apps/jsmdcui/tests/syntax-parity/samples/prql.txt +42 -0
  166. package/apps/jsmdcui/tests/syntax-parity/samples/puppet.txt +22 -0
  167. package/apps/jsmdcui/tests/syntax-parity/samples/python2.txt +24 -0
  168. package/apps/jsmdcui/tests/syntax-parity/samples/python3.txt +28 -0
  169. package/apps/jsmdcui/tests/syntax-parity/samples/r.txt +16 -0
  170. package/apps/jsmdcui/tests/syntax-parity/samples/raku.txt +19 -0
  171. package/apps/jsmdcui/tests/syntax-parity/samples/reST.txt +18 -0
  172. package/apps/jsmdcui/tests/syntax-parity/samples/renpy.txt +11 -0
  173. package/apps/jsmdcui/tests/syntax-parity/samples/rpmspec.txt +31 -0
  174. package/apps/jsmdcui/tests/syntax-parity/samples/ruby.txt +34 -0
  175. package/apps/jsmdcui/tests/syntax-parity/samples/rust.txt +27 -0
  176. package/apps/jsmdcui/tests/syntax-parity/samples/sage.txt +24 -0
  177. package/apps/jsmdcui/tests/syntax-parity/samples/scad.txt +23 -0
  178. package/apps/jsmdcui/tests/syntax-parity/samples/scala.txt +16 -0
  179. package/apps/jsmdcui/tests/syntax-parity/samples/sed.txt +12 -0
  180. package/apps/jsmdcui/tests/syntax-parity/samples/sh.txt +20 -0
  181. package/apps/jsmdcui/tests/syntax-parity/samples/sls.txt +15 -0
  182. package/apps/jsmdcui/tests/syntax-parity/samples/smalltalk.txt +28 -0
  183. package/apps/jsmdcui/tests/syntax-parity/samples/solidity.txt +26 -0
  184. package/apps/jsmdcui/tests/syntax-parity/samples/sql.txt +35 -0
  185. package/apps/jsmdcui/tests/syntax-parity/samples/stata.txt +32 -0
  186. package/apps/jsmdcui/tests/syntax-parity/samples/svelte.txt +10 -0
  187. package/apps/jsmdcui/tests/syntax-parity/samples/swift.txt +53 -0
  188. package/apps/jsmdcui/tests/syntax-parity/samples/systemd.txt +15 -0
  189. package/apps/jsmdcui/tests/syntax-parity/samples/tcl.txt +17 -0
  190. package/apps/jsmdcui/tests/syntax-parity/samples/terraform.txt +28 -0
  191. package/apps/jsmdcui/tests/syntax-parity/samples/tex.txt +15 -0
  192. package/apps/jsmdcui/tests/syntax-parity/samples/toml.txt +31 -0
  193. package/apps/jsmdcui/tests/syntax-parity/samples/twig.txt +29 -0
  194. package/apps/jsmdcui/tests/syntax-parity/samples/typescript.txt +32 -0
  195. package/apps/jsmdcui/tests/syntax-parity/samples/v.txt +47 -0
  196. package/apps/jsmdcui/tests/syntax-parity/samples/vala.txt +22 -0
  197. package/apps/jsmdcui/tests/syntax-parity/samples/verilog.txt +37 -0
  198. package/apps/jsmdcui/tests/syntax-parity/samples/vhdl.txt +37 -0
  199. package/apps/jsmdcui/tests/syntax-parity/samples/vi.txt +17 -0
  200. package/apps/jsmdcui/tests/syntax-parity/samples/vue.txt +29 -0
  201. package/apps/jsmdcui/tests/syntax-parity/samples/xml.txt +14 -0
  202. package/apps/jsmdcui/tests/syntax-parity/samples/xresources.txt +14 -0
  203. package/apps/jsmdcui/tests/syntax-parity/samples/yaml.txt +19 -0
  204. package/apps/jsmdcui/tests/syntax-parity/samples/yum.txt +12 -0
  205. package/apps/jsmdcui/tests/syntax-parity/samples/zig.txt +24 -0
  206. package/apps/jsmdcui/tests/syntax-parity/samples/zscript.txt +31 -0
  207. package/apps/jsmdcui/tests/syntax-parity/samples/zsh.txt +21 -0
  208. package/package.json +1 -1
@@ -0,0 +1,332 @@
1
+ "use strict";
2
+
3
+ // All flags defined by the kitty keyboard protocol. Kept unmasked so a CLI
4
+ // client on a real kitty terminal can restore them; the browser masks its own.
5
+ const KITTY_KEYBOARD_FLAGS = 0x1f;
6
+ const TRACKED_DEC_FLAGS = {
7
+ 1: "applicationCursorKeys",
8
+ 6: "origin",
9
+ 7: "wraparound",
10
+ 45: "reverseWraparound",
11
+ 66: "applicationKeypad",
12
+ 1004: "sendFocus",
13
+ 2004: "bracketedPasteMode",
14
+ };
15
+ const MOUSE_PROTOCOLS = { 9: "X10", 1000: "VT200", 1002: "DRAG", 1003: "ANY" };
16
+ const MOUSE_ENCODINGS = { 1006: "SGR", 1016: "SGR_PIXELS" };
17
+
18
+ // Mirrors the PTY's input/protocol modes so a resumed client can be brought
19
+ // back in sync. Mode semantics follow xterm.js, which renders the output.
20
+ class TerminalModeTracker {
21
+ constructor() {
22
+ this.reset();
23
+ this._state = "text";
24
+ this._seq = "";
25
+ }
26
+
27
+ reset() {
28
+ this.dec = {
29
+ applicationCursorKeys: false,
30
+ applicationKeypad: false,
31
+ bracketedPasteMode: false,
32
+ origin: false,
33
+ reverseWraparound: false,
34
+ sendFocus: false,
35
+ wraparound: true,
36
+ };
37
+ this.cursorHidden = false;
38
+ this.mouse = "NONE";
39
+ this.encoding = "DEFAULT";
40
+ this.alternate = false;
41
+ this.kitty = { normal: { flags: 0, stack: [] }, alternate: { flags: 0, stack: [] } };
42
+ }
43
+
44
+ snapshot() {
45
+ return {
46
+ dec: { ...this.dec },
47
+ cursorHidden: this.cursorHidden,
48
+ mouse: this.mouse,
49
+ encoding: this.encoding,
50
+ alternate: this.alternate,
51
+ kitty: {
52
+ normal: { flags: this.kitty.normal.flags, stack: [...this.kitty.normal.stack] },
53
+ alternate: { flags: this.kitty.alternate.flags, stack: [...this.kitty.alternate.stack] },
54
+ },
55
+ };
56
+ }
57
+
58
+ consume(buffer) {
59
+ // Control sequences are ASCII, so bytes can be scanned without UTF-8 decoding.
60
+ for (let i = 0; i < buffer.length; i += 1) {
61
+ const byte = buffer[i];
62
+ switch (this._state) {
63
+ case "text":
64
+ if (byte === 0x1b) {
65
+ this._state = "esc";
66
+ }
67
+ break;
68
+ case "esc":
69
+ if (byte === 0x5b) {
70
+ this._state = "csi";
71
+ this._seq = "";
72
+ } else if (byte === 0x5d || byte === 0x50 || byte === 0x5f || byte === 0x5e || byte === 0x58) {
73
+ this._state = "string";
74
+ this._osc = byte === 0x5d ? "" : null;
75
+ } else if (byte === 0x1b) {
76
+ this._state = "esc";
77
+ } else if (byte >= 0x20 && byte <= 0x2f) {
78
+ this._state = "esc-intermediate";
79
+ } else {
80
+ this._state = "text";
81
+ this.handleEsc(byte);
82
+ }
83
+ break;
84
+ case "esc-intermediate":
85
+ if (byte < 0x20 || byte > 0x2f) {
86
+ this._state = byte === 0x1b ? "esc" : "text";
87
+ }
88
+ break;
89
+ case "csi":
90
+ if (byte >= 0x40 && byte <= 0x7e) {
91
+ this._state = "text";
92
+ this.handleCsi(this._seq, String.fromCharCode(byte));
93
+ } else if (byte === 0x1b) {
94
+ this._state = "esc";
95
+ } else if (byte === 0x18 || byte === 0x1a || this._seq.length >= 64) {
96
+ this._state = "text";
97
+ } else if (byte >= 0x20) {
98
+ this._seq += String.fromCharCode(byte);
99
+ }
100
+ break;
101
+ case "string":
102
+ if (byte === 0x07 || byte === 0x18 || byte === 0x1a) {
103
+ if (byte === 0x07) this.handleOsc();
104
+ this._state = "text";
105
+ } else if (byte === 0x1b) {
106
+ this._state = "string-esc";
107
+ } else if (this._osc != null) {
108
+ this._osc = this._osc.length < 256 ? this._osc + String.fromCharCode(byte) : null;
109
+ }
110
+ break;
111
+ case "string-esc":
112
+ if (byte === 0x5c) this.handleOsc();
113
+ else this._osc = null;
114
+ this._state = byte === 0x1b ? "string-esc" : byte === 0x5c ? "text" : "string";
115
+ break;
116
+ }
117
+ }
118
+ }
119
+
120
+ handleOsc() {
121
+ const match = /^777;jsgotty-kitty-v1;(normal|alternate);(\d{1,2});(\d{1,2}(?:,\d{1,2})*)?$/.exec(this._osc || '');
122
+ if (!match) return;
123
+ const flags = +match[2], stack = match[3] ? match[3].split(',').map(Number) : [];
124
+ if (flags > 31 || stack.length > 32 || stack.some(n=>n > 31)) return;
125
+ this.kitty[match[1]] = {flags, stack};
126
+ }
127
+
128
+ handleEsc(byte) {
129
+ if (byte === 0x3d) {
130
+ this.dec.applicationKeypad = true;
131
+ } else if (byte === 0x3e) {
132
+ this.dec.applicationKeypad = false;
133
+ } else if (byte === 0x63) {
134
+ this.reset();
135
+ }
136
+ }
137
+
138
+ handleCsi(seq, final) {
139
+ const prefix = /^[<=>?]/.test(seq) ? seq[0] : "";
140
+ const body = prefix ? seq.slice(1) : seq;
141
+ if (!/^[0-9;:]*$/.test(body)) {
142
+ if (body === "!" && final === "p") {
143
+ this.softReset();
144
+ }
145
+ return;
146
+ }
147
+ const params = body === "" ? [] : body.split(";").map((part) => {
148
+ const value = Number.parseInt(part, 10);
149
+ return Number.isFinite(value) ? value : 0;
150
+ });
151
+ if (prefix === "?" && (final === "h" || final === "l")) {
152
+ for (const mode of params) {
153
+ this.setPrivateMode(mode, final === "h");
154
+ }
155
+ } else if (final === "u" && prefix !== "?") {
156
+ this.handleKittyKeyboard(prefix, params);
157
+ }
158
+ }
159
+
160
+ softReset() {
161
+ // DECSTR keeps the active screen and mouse reporting, like xterm.js.
162
+ const { alternate, mouse, encoding } = this;
163
+ this.reset();
164
+ Object.assign(this, { alternate, mouse, encoding });
165
+ }
166
+
167
+ setPrivateMode(mode, enabled) {
168
+ if (TRACKED_DEC_FLAGS[mode]) {
169
+ this.dec[TRACKED_DEC_FLAGS[mode]] = enabled;
170
+ } else if (MOUSE_PROTOCOLS[mode]) {
171
+ this.mouse = enabled ? MOUSE_PROTOCOLS[mode] : "NONE";
172
+ } else if (MOUSE_ENCODINGS[mode]) {
173
+ this.encoding = enabled ? MOUSE_ENCODINGS[mode] : "DEFAULT";
174
+ } else if (mode === 25) {
175
+ this.cursorHidden = !enabled;
176
+ } else if (mode === 47 || mode === 1047 || mode === 1049) {
177
+ this.alternate = enabled;
178
+ }
179
+ }
180
+
181
+ handleKittyKeyboard(prefix, params) {
182
+ const state = this.kitty[this.alternate ? "alternate" : "normal"];
183
+ const value = (index, fallback = 0) => (Number.isInteger(params[index]) ? params[index] : fallback);
184
+ if (prefix === ">") {
185
+ state.stack.push(state.flags);
186
+ if (state.stack.length > 32) {
187
+ state.stack.shift();
188
+ }
189
+ state.flags = value(0) & KITTY_KEYBOARD_FLAGS;
190
+ } else if (prefix === "<") {
191
+ let count = Math.min(value(0) || 1, 33);
192
+ while (count--) {
193
+ state.flags = state.stack.pop() ?? 0;
194
+ }
195
+ } else if (prefix === "=") {
196
+ const flags = value(0) & KITTY_KEYBOARD_FLAGS;
197
+ const mode = value(1) || 1;
198
+ if (mode === 1) {
199
+ state.flags = flags;
200
+ } else if (mode === 2) {
201
+ state.flags |= flags;
202
+ } else if (mode === 3) {
203
+ state.flags &= ~flags;
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ const DEFAULT_MODES = new TerminalModeTracker().snapshot();
210
+ const MOUSE_PROTOCOL_MODES = { X10: 9, VT200: 1000, DRAG: 1002, ANY: 1003 };
211
+ const MOUSE_ENCODING_MODES = { SGR: 1006, SGR_PIXELS: 1016 };
212
+
213
+ function sameKitty(a, b) {
214
+ return a.flags === b.flags && a.stack.length === b.stack.length &&
215
+ a.stack.every((flags, index) => flags === b.stack[index]);
216
+ }
217
+
218
+ // Work only on the observed stack; never pop an arbitrary number of entries.
219
+ // With a terminal that cannot report its stack, [] denotes the unseen baseline.
220
+ function kittyTransition(from, to) {
221
+ if (to.flags == null) return "";
222
+ let common = 0;
223
+ while (common < from.stack.length && common < to.stack.length &&
224
+ from.stack[common] === to.stack[common]) common++;
225
+ let sequence = "";
226
+ let flags = from.flags;
227
+ const pops = from.stack.length - common;
228
+ if (pops) {
229
+ sequence += `\x1b[<${pops}u`;
230
+ flags = from.stack[common];
231
+ }
232
+ const first = to.stack[common] ?? to.flags;
233
+ if (flags !== first) sequence += `\x1b[=${first};1u`;
234
+ for (let i = common; i < to.stack.length; i++)
235
+ sequence += `\x1b[>${to.stack[i + 1] ?? to.flags}u`;
236
+ return sequence;
237
+ }
238
+
239
+ // Escape sequence that moves a terminal known to be in `from` to `to`, emitting
240
+ // only differences: DECOM homes the cursor and 1049 overwrites the saved cursor.
241
+ function modeTransition(from, to = DEFAULT_MODES, { normalizeMouse = false, kittyRestore = false } = {}) {
242
+ let sequence = "";
243
+ const fromScreen = from.alternate ? "alternate" : "normal";
244
+ const toScreen = to.alternate ? "alternate" : "normal";
245
+ // Kitty keyboard stacks are per screen, so each is fixed while it is active.
246
+ if (from.alternate != null && !sameKitty(from.kitty[fromScreen], to.kitty[fromScreen])) {
247
+ sequence += kittyTransition(from.kitty[fromScreen], to.kitty[fromScreen]);
248
+ }
249
+ if (to.alternate != null && from.alternate !== to.alternate) {
250
+ sequence += `\x1b[?1049${to.alternate ? "h" : "l"}`;
251
+ if (!sameKitty(from.kitty[toScreen], to.kitty[toScreen])) {
252
+ sequence += kittyTransition(from.kitty[toScreen], to.kitty[toScreen]);
253
+ }
254
+ }
255
+ const inactive = toScreen === "normal" ? "alternate" : "normal";
256
+ // Switching screens just to edit a hidden stack destroys alternate contents.
257
+ // Only emit this extension after the actual terminal advertised support.
258
+ const hidden = to.kitty[inactive];
259
+ if (kittyRestore && from.alternate != null && to.alternate != null && fromScreen === toScreen &&
260
+ !sameKitty(from.kitty[inactive], hidden) && hidden.flags != null && hidden.stack.every(Number.isInteger)) {
261
+ sequence += `\x1b]777;jsgotty-kitty-v1;${inactive};${hidden.flags};${hidden.stack.join(',')}\x07`;
262
+ }
263
+ for (const [mode, name] of Object.entries(TRACKED_DEC_FLAGS)) {
264
+ if (to.dec[name] == null || from.dec[name] === to.dec[name]) {
265
+ continue;
266
+ }
267
+ if (name === "applicationKeypad") {
268
+ sequence += to.dec[name] ? "\x1b=" : "\x1b>"; // more widely supported than ?66
269
+ } else {
270
+ sequence += `\x1b[?${mode}${to.dec[name] ? "h" : "l"}`;
271
+ }
272
+ }
273
+ if (to.cursorHidden != null && from.cursorHidden !== to.cursorHidden) {
274
+ sequence += `\x1b[?25${to.cursorHidden ? "l" : "h"}`;
275
+ }
276
+ // Raw query results preserve independent switches on native terminals. Reset
277
+ // first, then enable in priority order so exclusive terminals also end in the
278
+ // intended effective mode. Unanswered switches are never guessed.
279
+ for (const [name, bitsName, table] of [
280
+ ['mouse', 'mouseModeBits', MOUSE_PROTOCOL_MODES],
281
+ ['encoding', 'mouseEncodingBits', MOUSE_ENCODING_MODES],
282
+ ]) {
283
+ const bits = to[bitsName];
284
+ if (bits) {
285
+ for (const mode of Object.values(table))
286
+ if (bits[mode] === false) sequence += `\x1b[?${mode}l`;
287
+ for (const mode of Object.values(table))
288
+ if (bits[mode] === true) sequence += `\x1b[?${mode}h`;
289
+ } else if (to[name] != null && (normalizeMouse || from[name] !== to[name])) {
290
+ for (const mode of Object.values(table)) sequence += `\x1b[?${mode}l`;
291
+ if (table[to[name]]) sequence += `\x1b[?${table[to[name]]}h`;
292
+ }
293
+ }
294
+ return sequence;
295
+ }
296
+
297
+ function validateSnapshot(value) {
298
+ if (!value || !value.dec || !value.kitty) throw Error("Invalid terminal modes");
299
+ const result = new TerminalModeTracker().snapshot();
300
+ for (const key of Object.values(TRACKED_DEC_FLAGS)) {
301
+ if (typeof value.dec[key] !== 'boolean' && value.dec[key] !== null) throw Error("Invalid DEC mode");
302
+ result.dec[key] = value.dec[key];
303
+ }
304
+ for (const key of ['alternate', 'cursorHidden']) {
305
+ if (typeof value[key] !== 'boolean' && value[key] !== null) throw Error("Invalid screen mode");
306
+ result[key] = value[key];
307
+ }
308
+ for (const [key, choices] of [['mouse', ['NONE', ...Object.values(MOUSE_PROTOCOLS)]], ['encoding', ['DEFAULT', ...Object.values(MOUSE_ENCODINGS)]]]) {
309
+ if (value[key] !== null && !choices.includes(value[key])) throw Error("Invalid mouse mode");
310
+ result[key] = value[key];
311
+ }
312
+ for (const [key, table] of [['mouseModeBits', MOUSE_PROTOCOLS], ['mouseEncodingBits', MOUSE_ENCODINGS]]) {
313
+ if (value[key] === undefined) continue;
314
+ if (!value[key] || typeof value[key] !== 'object') throw Error('Invalid mouse bits');
315
+ result[key] = {};
316
+ for (const mode of Object.keys(table)) {
317
+ const bit = value[key][mode];
318
+ if (bit !== null && typeof bit !== 'boolean') throw Error('Invalid mouse bit');
319
+ result[key][mode] = bit;
320
+ }
321
+ }
322
+ for (const key of ['normal', 'alternate']) {
323
+ const state = value.kitty[key];
324
+ const flag = n => Number.isInteger(n) && n >= 0 && n <= KITTY_KEYBOARD_FLAGS;
325
+ if (!state || (state.flags !== null && !flag(state.flags)) || !Array.isArray(state.stack) ||
326
+ state.stack.length > 32 || !state.stack.every(n => n === null || flag(n))) throw Error("Invalid Kitty state");
327
+ result.kitty[key] = {flags: state.flags, stack: [...state.stack]};
328
+ }
329
+ return result;
330
+ }
331
+
332
+ module.exports = { TerminalModeTracker, modeTransition, validateSnapshot, TRACKED_DEC_FLAGS, MOUSE_PROTOCOLS, MOUSE_ENCODINGS };
@@ -0,0 +1,45 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import vm from 'node:vm';
5
+ const source=fs.readFileSync(new URL('../client.mjs',import.meta.url),'utf8');
6
+ const methods=source.slice(source.indexOf(' let exiting = false;'),source.indexOf(' const stopViewerInput ='));
7
+ function fixture(){
8
+ const calls=[],callbacks=[];
9
+ const handlers=vm.runInNewContext(`(()=>{${methods};return {exitClient,handleViewerInput};})()`,{
10
+ cleanup:()=>calls.push('cleanup'),reportDisconnected:()=>calls.push('report'),
11
+ ws:{close:()=>calls.push('close')},terminalInput:b=>b,
12
+ process:{stdout:{write:(s,done)=>callbacks.push(done)},exit:code=>calls.push(code)},
13
+ });
14
+ return {calls,callbacks,...handlers};
15
+ }
16
+ for(const [input,code] of [[[3,4],130],[[4,3],0],[[17,3],0]]){
17
+ test(`viewer exit handles first key only: ${input}`,()=>{
18
+ const f=fixture();f.handleViewerInput(Buffer.from(input));
19
+ assert.deepEqual(f.calls,['cleanup','report','close']);assert.equal(f.callbacks.length,1);
20
+ assert.ok(!f.calls.includes(code),'does not exit before output flush');
21
+ f.callbacks[0]();assert.equal(f.calls.at(-1),code);
22
+ });
23
+ }
24
+ test('concurrent exit requests queue only one flush and retain first exit code',()=>{
25
+ const f=fixture();f.exitClient(130);f.exitClient(143);f.exitClient(0);
26
+ assert.equal(f.callbacks.length,1);assert.deepEqual(f.calls,['cleanup','report','close']);
27
+ f.callbacks[0]();assert.equal(f.calls.at(-1),130);
28
+ });
29
+ for (const [key, code] of [[99,130],[100,0],[113,0]]) {
30
+ for (const sequence of ['\x1b['+key+';5u', '\x1b['+key+';5:1u', '\x1b['+key+';197u', '\x1b[27;5;'+key+'~']) {
31
+ test('viewer encoded Ctrl key '+JSON.stringify(sequence)+' split',()=>{
32
+ const f=fixture();
33
+ for(const byte of Buffer.from(sequence)) f.handleViewerInput(Buffer.from([byte]));
34
+ assert.equal(f.callbacks.length,1);f.callbacks[0]();assert.equal(f.calls.at(-1),code);
35
+ });
36
+ }
37
+ }
38
+ test('viewer ignores release and other modifiers before exit press',()=>{
39
+ const f=fixture();
40
+ for(const sequence of ['\x1b[99;5:3u','\x1b[100;1u','\x1b[113;7u','\x1b[<0;99;100M'])
41
+ f.handleViewerInput(Buffer.from(sequence));
42
+ assert.equal(f.callbacks.length,0);
43
+ f.handleViewerInput(Buffer.from('\x1b[113;5:2u'));
44
+ assert.equal(f.callbacks.length,1);
45
+ });
@@ -0,0 +1,105 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {ModeProbe,captureTerminalModes} from '../client-modes.mjs';
4
+ import modes from '../terminal-modes.js';
5
+ import {PassThrough} from 'node:stream';
6
+ const {TerminalModeTracker}=modes;
7
+ test('private reply split at every byte preserves typing and both Kitty stacks',()=>{
8
+ const p=new ModeProbe('a'.repeat(32));const t=new TerminalModeTracker();
9
+ t.consume(Buffer.from('\x1b[>1u\x1b[?1049h\x1b[>11u'));
10
+ const reply=p.privatePrefix+Buffer.from(JSON.stringify(t.snapshot())).toString('base64')+'\x07';
11
+ const out=[];for(const c of Buffer.from('before'+reply+'after'))out.push(p.consume(Buffer.from([c])));
12
+ assert.equal(Buffer.concat(out).toString(),'beforeafter');assert.equal(p.complete,true);assert.deepEqual(p.state,t.snapshot());
13
+ assert.equal(p.consume('\x1b').toString(),'\x1b','Escape is immediate after successful negotiation');
14
+ });
15
+ test('standard replies are consumed once; later application queries pass unchanged',()=>{
16
+ const p=new ModeProbe();p.standardQuery();
17
+ const replies='\x1b[?1049;2$y\x1b[?1002;1$y\x1b[?7;0$y\x1b[?11u';
18
+ assert.equal(p.consume(replies).length,0);assert.equal(p.state.mouse,'DRAG');assert.equal(p.state.alternate,false);
19
+ assert.equal(p.state.kitty.normal.flags,11);assert.equal(p.state.dec.wraparound,null);
20
+ assert.equal(p.consume(replies).toString(),replies);
21
+ });
22
+ test('no reply does not mean defaults, nor intercept application replies forever',()=>{
23
+ const p=new ModeProbe();p.standardQuery();p.replyDeadline=1;
24
+ const reply='\x1b[?1049;1$y\x1b[?11u';assert.equal(p.consume(reply).toString(),reply);
25
+ assert.equal(p.state.alternate,null);assert.equal(p.state.kitty.normal.flags,null);
26
+ });
27
+ test('foreign nonce, keys, mouse and a large paste are transparent',()=>{
28
+ const p=new ModeProbe();const data=Buffer.from('\x1b]777;jsgotty-modes;other;ignored\x07\x1b[<64;3;4M'+ '漢字'.repeat(200000));
29
+ assert.deepEqual(p.consume(data),data);
30
+ });
31
+ test('capture falls back after silence and restores stdin raw state',async()=>{
32
+ const input=new PassThrough();input.isTTY=true;input.isRaw=false;input.setRawMode=v=>{input.isRaw=v;};
33
+ const output=new PassThrough();output.isTTY=true;const sent=[];output.on('data',b=>sent.push(b.toString()));
34
+ const result=await captureTerminalModes(input,output,{timeout:30});
35
+ assert.equal(input.isRaw,false);assert.equal(result.state.mouse,null);assert.equal(sent.length,2);
36
+ assert.match(sent[0],/jsgotty-modes/);assert.match(sent[1],/\?1049\$p/);
37
+ });
38
+
39
+ for (const unsupportedFirst of [false,true]) {
40
+ test(`unsupported mouse modes count as inactive replies (${unsupportedFirst?'first':'last'})`,()=>{
41
+ const p=new ModeProbe();p.standardQuery();
42
+ const known='\x1b[?1000;2$y\x1b[?1002;2$y\x1b[?1003;2$y\x1b[?1006;2$y';
43
+ const unsupported='\x1b[?9;0$y\x1b[?1016;0$y';
44
+ p.consume(unsupportedFirst?unsupported+known:known+unsupported);
45
+ assert.equal(p.state.mouse,'NONE');assert.equal(p.state.encoding,'DEFAULT');
46
+ p.consume('\x1b[?7;0$y\x1b[?25;0$y\x1b[?1049;0$y');
47
+ assert.equal(p.state.dec.wraparound,null);assert.equal(p.state.cursorHidden,null);assert.equal(p.state.alternate,null);
48
+ const local=new TerminalModeTracker();Object.assign(local,structuredClone(p.state));
49
+ local.consume(Buffer.from('\x1b[?1003h\x1b[?1006h'));
50
+ const cleanup=modes.modeTransition(local.snapshot(),p.state);
51
+ assert.ok(cleanup.includes('\x1b[?1003l'));assert.ok(cleanup.includes('\x1b[?1006l'));
52
+ local.consume(Buffer.from(cleanup));assert.equal(local.mouse,'NONE');assert.equal(local.encoding,'DEFAULT');
53
+ });
54
+ }
55
+ test('unsupported replies do not override active modes or fill unanswered fields',()=>{
56
+ const p=new ModeProbe();p.standardQuery();
57
+ p.consume('\x1b[?9;0$y\x1b[?1016;0$y');
58
+ assert.equal(p.state.mouse,null);assert.equal(p.state.encoding,null);
59
+ p.consume('\x1b[?1002;1$y\x1b[?1006;1$y');
60
+ assert.equal(p.state.mouse,'DRAG');assert.equal(p.state.encoding,'SGR');
61
+ p.consume('\x1b[?1000;0$y\x1b[?1003;0$y');
62
+ assert.equal(p.state.mouse,'DRAG');
63
+ });
64
+
65
+ test('multiple active replies preserve individual switches and select explicit priorities',()=>{
66
+ const p=new ModeProbe();p.standardQuery();
67
+ for(const n of [1003,1002,1000,1016,1006])p.consume(`\x1b[?${n};1$y`);
68
+ p.consume('\x1b[?9;2$y');
69
+ assert.equal(p.state.mouse,'ANY');assert.equal(p.state.encoding,'SGR_PIXELS');
70
+ assert.deepEqual(p.state.mouseModeBits,{9:false,1000:true,1002:true,1003:true});
71
+ assert.deepEqual(p.state.mouseEncodingBits,{1006:true,1016:true});
72
+ assert.deepEqual(modes.validateSnapshot(p.state),p.state);
73
+ const bits={9:true,1000:false,1002:false,1003:false,1006:false,1016:false};
74
+ const cleanup=modes.modeTransition(new TerminalModeTracker().snapshot(),p.state);
75
+ for(const [,n,on] of cleanup.matchAll(/\x1b\[\?(\d+)([hl])/g))bits[n]=on==='h';
76
+ assert.deepEqual(bits,{...p.state.mouseModeBits,...p.state.mouseEncodingBits});
77
+ // The same sequence on an exclusive xterm-style terminal ends at the
78
+ // effective mode, without trying to change its parser semantics.
79
+ const exclusive=new TerminalModeTracker();exclusive.consume(Buffer.from(cleanup));
80
+ assert.equal(exclusive.mouse,'ANY');assert.equal(exclusive.encoding,'SGR_PIXELS');
81
+ });
82
+
83
+ test('partial query restoration leaves unreported switches untouched',()=>{
84
+ const p=new ModeProbe();p.standardQuery();p.consume('\x1b[?1000;2$y\x1b[?1006;1$y');
85
+ const cleanup=modes.modeTransition(new TerminalModeTracker().snapshot(),p.state,{normalizeMouse:true});
86
+ assert.equal(cleanup,'\x1b[?1000l\x1b[?1006h');
87
+ });
88
+
89
+ test('normalization clears every conflicting switch, even if aggregate appears unchanged',()=>{
90
+ for(const target of [{mouse:'NONE',encoding:'DEFAULT'},{mouse:'VT200',encoding:'SGR'}]){
91
+ const state={...new TerminalModeTracker().snapshot(),...target};
92
+ const bits={9:true,1000:true,1002:true,1003:true,1006:true,1016:true};
93
+ const seq=modes.modeTransition(state,state,{normalizeMouse:true});
94
+ for(const [,n,on] of seq.matchAll(/\x1b\[\?(\d+)([hl])/g))bits[n]=on==='h';
95
+ assert.deepEqual(bits,{9:false,1000:target.mouse==='VT200',1002:false,1003:false,1006:target.encoding==='SGR',1016:false});
96
+ }
97
+ });
98
+
99
+ test('hidden Kitty restore requires an explicit capability in a valid private reply',()=>{
100
+ for(const advertised of [undefined,0,true,1]){
101
+ const p=new ModeProbe(),reply={...new TerminalModeTracker().snapshot(),kittyRestore:advertised};
102
+ p.consume(p.privatePrefix+Buffer.from(JSON.stringify(reply)).toString('base64')+'\x07');
103
+ assert.equal(p.complete,true);assert.equal(p.kittyRestore,advertised===1);
104
+ }
105
+ });
@@ -0,0 +1,81 @@
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 {ClipboardHost,handleOSC52}=require('../clipboard.js');
12
+ const root=path.join(__dirname,'../static');
13
+ test('browser OSC52 write/read reaches server clipboard and replies to PTY', {skip:!process.env.CHROMIUM_HEADLESS,timeout:60000},async()=>{
14
+ 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; window.TestWebTTY = i(609).WebTTY;');
15
+ const server=http.createServer((req,res)=>{
16
+ if(req.url==='/bundle.js'){res.setHeader('Content-Type','text/javascript');res.end(bundle);}
17
+ else if(req.url==='/keyboard.js'){res.setHeader('Content-Type','text/javascript');res.end(fs.readFileSync(path.join(root,'js/kitty-keyboard.js')));}
18
+ 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>');
19
+ });
20
+ const dir=fs.mkdtempSync(require('node:os').tmpdir()+'/jsgotty-clipboard-');
21
+ const oldPath=process.env.PATH,oldFixture=process.env.JSGOTTY_CLIPBOARD_FIXTURE;
22
+ const fake=`#!${process.execPath}
23
+ const fs=require('node:fs'),file=process.env.JSGOTTY_CLIPBOARD_FIXTURE;
24
+ const read=process.argv.includes('-o');fs.appendFileSync(file+'.log',read?'read\\n':'write\\n');
25
+ if(read)process.stdout.write(fs.readFileSync(file));else fs.writeFileSync(file,fs.readFileSync(0));
26
+ `;
27
+ fs.writeFileSync(path.join(dir,'xclip'),fake,{mode:0o700});
28
+ process.env.PATH=dir+path.delimiter+oldPath;process.env.JSGOTTY_CLIPBOARD_FIXTURE=path.join(dir,'data');
29
+ const host=new ClipboardHost();
30
+ const sockets=new WebSocket.Server({server});
31
+ sockets.on('connection',socket=>{
32
+ const session={closed:false,isWriter:ws=>ws===socket && socket.readyState===WebSocket.OPEN,
33
+ backend:{write:data=>socket.send(data.toString())}};
34
+ socket.on('message',data=>{const text=data.toString();if(text[0]==='C')handleOSC52(session,socket,text.slice(1),host);});
35
+ socket.on('close',()=>{session.closed=true;});
36
+ });
37
+ server.listen(0,'127.0.0.1');await once(server,'listening');
38
+ 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']});
39
+ let ws;
40
+ try{
41
+ 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]);}});});
42
+ const u=new URL(endpoint);let tab;
43
+ 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));}
44
+ 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);}});
45
+ const call=(method,params)=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
46
+ 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));}
47
+ const result=await call('Runtime.evaluate',{awaitPromise:true,returnByValue:true,expression:`(async()=>{
48
+
49
+ const term=wrapper.term;
50
+ const socket=new WebSocket('ws://'+location.host);
51
+ await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;});
52
+ const replies=[];socket.onmessage=e=>replies.push(e.data);
53
+ const webtty=new TestWebTTY(wrapper,null,'','');
54
+ webtty.connection={send:data=>socket.send(data)};
55
+ const write=s=>new Promise(r=>term.write(s,r));
56
+ const equal=(a,b)=>{if(a!==b)throw Error(JSON.stringify({a,b}));};
57
+ const encoded=btoa(unescape(encodeURIComponent('你好 OSC52\\n')));
58
+ // The worker invokes an isolated fake xclip, never the system clipboard.
59
+ await write('\\x1b]52;c;'+encoded.slice(0,2));
60
+ await write(encoded.slice(2)+'\\x07');
61
+ await write('\\x1b]52;c;?\\x1b\\\\');
62
+ for(let i=0;i<200&&!replies.length;i++)await new Promise(r=>setTimeout(r,10));
63
+ equal(replies.shift(),'\\x1b]52;c;'+encoded+'\\x1b\\\\');
64
+ await write('\\x1b]52;c;\\x07\\x1b]52;c;?\\x07');
65
+ for(let i=0;i<200&&!replies.length;i++)await new Promise(r=>setTimeout(r,10));
66
+ equal(replies.shift(),'\\x1b]52;c;\\x1b\\\\');
67
+ equal(term.buffer.active.getLine(0).translateToString(true),'');
68
+ socket.close();
69
+ wrapper.close();return 'ok';
70
+ })()`});
71
+ assert.equal(result.exceptionDetails,undefined,JSON.stringify(result.exceptionDetails));assert.equal(result.result.value,'ok');
72
+ assert.equal(fs.readFileSync(path.join(dir,'data.log'),'utf8'),'write\nread\nwrite\nread\n');
73
+ }finally{
74
+ ws?.close();if(child.pid){try{process.kill(-child.pid,'SIGTERM');}catch{}}
75
+ for(const socket of sockets.clients)socket.terminate();
76
+ await host.close();sockets.close();
77
+ process.env.PATH=oldPath;if(oldFixture===undefined)delete process.env.JSGOTTY_CLIPBOARD_FIXTURE;else process.env.JSGOTTY_CLIPBOARD_FIXTURE=oldFixture;
78
+ fs.rmSync(dir,{recursive:true,force:true});
79
+ server.closeAllConnections();await new Promise(r=>server.close(r));
80
+ }
81
+ });
@@ -0,0 +1,75 @@
1
+ const test=require('node:test');
2
+ const assert=require('node:assert/strict');
3
+ const fs=require('node:fs');
4
+ const crypto=require('node:crypto');
5
+ const {createClipboard,parseOSC52,executeOSC52,handleOSC52,ClipboardHost}=require('../clipboard.js');
6
+ const powershell=require('../clipboard-powershell.js');
7
+ function fixture(platform,available,results={}){
8
+ const calls=[];const clipboard=createClipboard({platform,find:name=>available.includes(name)?name:null,run:(args,opts)=>{
9
+ calls.push({args,opts});const result=results[args[0]];return typeof result==='function'?result(args,opts):(result||{ok:true,stdout:'value'});
10
+ }});return {clipboard,calls};
11
+ }
12
+ test('xclip is first on Linux, Android, macOS and Windows',()=>{
13
+ for(const platform of ['linux','android','darwin','win32']){
14
+ const f=fixture(platform,['xclip','wl-copy','wl-paste','termux-clipboard-set','termux-clipboard-get','pbcopy','pbpaste','powershell.exe']);
15
+ f.clipboard.write('你好');assert.equal(f.clipboard.read(),'value');
16
+ assert.deepEqual(f.calls.map(c=>c.args[0]),['xclip','xclip']);
17
+ assert.equal(f.calls[0].opts.stdin,'你好');
18
+ }
19
+ });
20
+ test('missing or failing backends fall through per operation; empty read is success',()=>{
21
+ const f=fixture('android',['xclip','termux-clipboard-get','termux-clipboard-set','wl-paste'],{
22
+ xclip:{ok:false},'termux-clipboard-get':{ok:true,stdout:''},'termux-clipboard-set':{ok:true},
23
+ });
24
+ f.clipboard.write('cached');assert.equal(f.clipboard.read(),'');
25
+ assert.deepEqual(f.calls.map(c=>c.args[0]),['xclip','termux-clipboard-set','xclip','termux-clipboard-get']);
26
+ const g=fixture('linux',['wl-paste','xsel'],{'wl-paste':{ok:false}});
27
+ assert.equal(g.clipboard.read(),'value');assert.deepEqual(g.calls.map(c=>c.args[0]),['wl-paste','xsel']);
28
+ });
29
+ test('failed host access uses cache; every read retries host before cache',()=>{
30
+ let fail=false;const f=fixture('linux',['xclip'],{xclip:()=>fail?{ok:false}:{ok:true,stdout:'fresh'}});
31
+ f.clipboard.write('cached');fail=true;assert.equal(f.clipboard.read(),'cached');
32
+ fail=false;assert.equal(f.clipboard.read(),'fresh');assert.equal(f.calls.length,3);
33
+ });
34
+ test('selections, base64 Unicode, clearing and queries use OSC52 responses',()=>{
35
+ const c=createClipboard({find:()=>null});const encoded=Buffer.from('你好\ntext').toString('base64');
36
+ assert.equal(executeOSC52(c,parseOSC52('cp;'+encoded)),null);
37
+ assert.equal(executeOSC52(c,parseOSC52('p;?')),`\x1b]52;p;${encoded}\x1b\\`);
38
+ executeOSC52(c,parseOSC52('c;!'));assert.equal(executeOSC52(c,parseOSC52('c;?')),'\x1b]52;c;\x1b\\');
39
+ executeOSC52(c,parseOSC52(';'+encoded));assert.equal(c.read('cut0'),'你好\ntext');
40
+ assert.equal(parseOSC52('invalid;?'),null);assert.equal(parseOSC52('c;'+ 'A'.repeat(1048576)),null);
41
+ });
42
+ test('PowerShell uses original quote escaping and strips exactly one line ending',()=>{
43
+ const calls=[];const p=powershell('powershell.exe',(args,options)=>{calls.push({args,options});return {ok:true,stdout:'text\r\n\r\n'};},r=>r.stdout);
44
+ assert.equal(p.read(),'text\r\n');assert.equal(p.write("a'b\n$literal"),true);
45
+ assert.equal(calls[1].args[3],"Set-Clipboard 'a''b\n$literal'");
46
+ });
47
+ test('read-only connections cannot access host clipboard or inject replies',async()=>{
48
+ let writer=false,resolve;const writes=[],requests=[];
49
+ const session={closed:false,isWriter:()=>writer,backend:{write:b=>writes.push(b.toString())}};
50
+ const host={execute:r=>{requests.push(r);return new Promise(r=>resolve=r);}};
51
+ handleOSC52(session,{},'c;?',host);assert.equal(requests.length,0);
52
+ writer=true;handleOSC52(session,{},'c;?',host);assert.equal(requests.length,1);
53
+ writer=false;resolve('secret');await Promise.resolve();assert.equal(writes.length,0);
54
+ writer=true;handleOSC52(session,{},'c;?',host);resolve('reply');await Promise.resolve();assert.deepEqual(writes,['reply']);
55
+ });
56
+ test('worker preserves ordered writes/reads without touching system clipboard',async()=>{
57
+ const host=new ClipboardHost();
58
+ try{
59
+ const encoded=Buffer.from('isolated cut buffer').toString('base64');
60
+ const write=host.execute(parseOSC52('7;'+encoded));const read=host.execute(parseOSC52('7;?'));
61
+ assert.equal(await write,null);assert.equal(await read,`\x1b]52;7;${encoded}\x1b\\`);
62
+ }finally{await host.close();}
63
+ });
64
+
65
+ test('vendored PowerShell function remains byte-for-byte identical to jsmdcui',()=>{
66
+ const source=fs.readFileSync(require.resolve('../clipboard-powershell.js'),'utf8');
67
+ const body=source.slice(source.indexOf('function powershellClipboard(shell) {'),source.indexOf('\nreturn powershellClipboard(shell);'));
68
+ assert.equal(crypto.createHash('sha256').update(body).digest('hex'),'38246f0cea399f9c6a56ac8716e11b8d3fba89233b439f31011cc75a47a7d241');
69
+ });
70
+
71
+ test('large and unpadded base64 payloads parse without recursive regexp overflow',()=>{
72
+ const text='x'.repeat(500000);assert.equal(parseOSC52('c;'+Buffer.from(text).toString('base64')).text,text);
73
+ assert.equal(parseOSC52('c;aGk').text,'hi');
74
+ assert.deepEqual(parseOSC52('cs;?').registers,['clipboard']);
75
+ });