buninu 0.4.21 → 0.6.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.
- package/CHANGELOG.md +40 -0
- package/apps/bunmsh/CHANGELOG.md +4 -0
- package/apps/bunmsh/package.json +1 -1
- package/apps/bunmsh/src/shell.js +1 -1
- package/apps/jsgotty/LICENSES/MIT-jsmdcui.txt +22 -0
- package/apps/jsgotty/NOTICE +5 -0
- package/apps/jsgotty/README.en.md +57 -0
- package/apps/jsgotty/README.md +57 -0
- package/apps/jsgotty/client-modes.mjs +117 -0
- package/apps/jsgotty/client.mjs +100 -10
- package/apps/jsgotty/clipboard-powershell.js +18 -0
- package/apps/jsgotty/clipboard.js +148 -0
- package/apps/jsgotty/gotty.js +17 -2
- package/apps/jsgotty/package-lock.json +2 -2
- package/apps/jsgotty/package.json +1 -1
- package/apps/jsgotty/static/css/index.css +7 -1
- package/apps/jsgotty/static/index.html +1 -0
- package/apps/jsgotty/static/js/gotty.js +175 -19
- package/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
- package/apps/jsgotty/terminal-modes.js +332 -0
- package/apps/jsgotty/tests/android-alt.test.js +115 -0
- package/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
- package/apps/jsgotty/tests/client-exit.test.mjs +45 -0
- package/apps/jsgotty/tests/client-modes.test.mjs +105 -0
- package/apps/jsgotty/tests/clipboard-browser.test.js +81 -0
- package/apps/jsgotty/tests/clipboard.test.js +75 -0
- package/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
- package/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
- package/apps/jsgotty/tests/manual/README.md +20 -0
- package/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
- package/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
- package/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
- package/apps/jsgotty/tests/manual-touch-diagnostics.js +33 -0
- package/apps/jsgotty/tests/manual-touch-diagnostics.mjs +22 -0
- package/apps/jsgotty/tests/nested-cli-pty.test.mjs +116 -0
- package/apps/jsgotty/tests/reconnect-modes-browser.test.js +144 -0
- package/apps/jsgotty/tests/reconnect-modes.md +44 -0
- package/apps/jsgotty/tests/reconnect-modes.test.js +166 -0
- package/package.json +1 -1
|
@@ -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,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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|