buninu 0.5.0 → 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 +21 -0
- package/apps/jsgotty/LICENSES/MIT-jsmdcui.txt +22 -0
- package/apps/jsgotty/NOTICE +5 -0
- package/apps/jsgotty/README.en.md +17 -0
- package/apps/jsgotty/README.md +17 -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 +13 -0
- 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/js/gotty.js +95 -4
- package/apps/jsgotty/terminal-modes.js +332 -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/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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 - 2026-10-25
|
|
4
|
+
|
|
5
|
+
- Sync jsgotty to 1.3.0:
|
|
6
|
+
* Add OSC 52 clipboard reads and writes for browser terminals using the
|
|
7
|
+
server host's clipboard. Try xclip first on every platform, then available
|
|
8
|
+
platform tools, with an in-memory fallback when all tools fail. Only the
|
|
9
|
+
session writer performs clipboard operations; CLI clients pass OSC 52
|
|
10
|
+
through to their terminal emulator.
|
|
11
|
+
* Restore mouse reporting, mouse encoding, Kitty keyboard state and other
|
|
12
|
+
terminal modes when reconnecting to an existing session, including from
|
|
13
|
+
a fresh terminal and through nested CLI clients. Preserve keyboard mode
|
|
14
|
+
stacks without unnecessarily switching screens.
|
|
15
|
+
* Probe and restore the CLI client's original terminal modes on exit;
|
|
16
|
+
handle unsupported mode replies and independently enabled mouse modes
|
|
17
|
+
without leaving stale mouse reporting behind.
|
|
18
|
+
* Fix read-only CLI viewer exit shortcuts (Ctrl-C, Ctrl-D and Ctrl-Q) when
|
|
19
|
+
the enclosing terminal uses Kitty keyboard encoding.
|
|
20
|
+
* Fix Android WebView double-tap handling at non-default viewport zoom so
|
|
21
|
+
terminal clicks reach applications instead of triggering browser zoom,
|
|
22
|
+
while retaining two-finger pinch zoom.
|
|
23
|
+
|
|
3
24
|
## 0.5.0 - 2026-10-21
|
|
4
25
|
|
|
5
26
|
- Sync jsgotty to 1.2.0:
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016-2020: Zachary Yedidia, et al.
|
|
4
|
+
Copyright (c) 2026 Dr. John (醫者小智)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/apps/jsgotty/NOTICE
CHANGED
|
@@ -119,3 +119,8 @@ such an executable must also follow Bun's license terms, including the LGPL
|
|
|
119
119
|
relinking provisions described at
|
|
120
120
|
https://github.com/oven-sh/bun/blob/main/LICENSE.md. The executables carry
|
|
121
121
|
LICENSE, NOTICE and LICENSES/ as embedded assets.
|
|
122
|
+
|
|
123
|
+
Clipboard backend helpers reference jsmdcui (MIT).
|
|
124
|
+
clipboard-powershell.js retains its powershellClipboard function verbatim from
|
|
125
|
+
jsmdcui/src/platform/clipboard.js, Copyright (c) 2026 Dr. John (醫者小智).
|
|
126
|
+
Full source license: LICENSES/MIT-jsmdcui.txt.
|
|
@@ -332,3 +332,20 @@ bun sz.js file.txt
|
|
|
332
332
|
- Bun is the primary runtime.
|
|
333
333
|
- On Linux / macOS, PTY support will not work unless the program is launched with Bun.
|
|
334
334
|
- GoTTY config file loading is not implemented yet.
|
|
335
|
+
|
|
336
|
+
## OSC 52 clipboard
|
|
337
|
+
|
|
338
|
+
The browser frontend supports OSC 52 writes and `?` queries against the **server host's clipboard**, not the browser device's clipboard. Only the session writer can request access; viewers do not duplicate operations. Query replies are sent directly back to the originating PTY as OSC 52 sequences.
|
|
339
|
+
|
|
340
|
+
Each operation tries `xclip` first, including on Windows and Android. Missing or failing commands fall through to applicable `termux-clipboard-*`, `wl-copy`/`wl-paste`, `xsel`, macOS `pbcopy`/`pbpaste`, then Windows PowerShell. The PowerShell function is copied verbatim from jsmdcui. If all tools fail, the server uses its in-memory cache. A successful empty read remains empty, rather than returning stale cached text. There is no polling; external commands run in a worker.
|
|
341
|
+
|
|
342
|
+
Supported selections: `c` (clipboard), `p` (primary), `q` (secondary); `s` maps to clipboard, and `0`–`7` are in-memory buffers. An empty selector uses `s0`. Tools without primary/secondary support do not substitute the clipboard. Requests are limited to 1 MiB.
|
|
343
|
+
|
|
344
|
+
```sh
|
|
345
|
+
# Write to the server clipboard
|
|
346
|
+
printf '\033]52;c;%s\007' "$(printf 'Hello' | base64 | tr -d '\n')"
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Applications can send `ESC ] 52 ; c ; ? ST` to read and receive a base64-encoded OSC 52 reply on stdin.
|
|
350
|
+
|
|
351
|
+
`jsgotty --client` **does not intercept OSC 52**: it forwards reads and writes unchanged for the outer terminal emulator to handle.
|
package/apps/jsgotty/README.md
CHANGED
|
@@ -334,3 +334,20 @@ bun sz.js file.txt
|
|
|
334
334
|
- 以 Bun 為主要執行方式。
|
|
335
335
|
- Linux / macOS 如果不是用 Bun 執行,PTY 會無法使用。
|
|
336
336
|
- 目前未實作 Go 版的 config 檔載入
|
|
337
|
+
|
|
338
|
+
## OSC 52 剪貼板
|
|
339
|
+
|
|
340
|
+
瀏覽器版 jsgotty 支援 OSC 52 寫入與 `?` 查詢。操作的是 **server 所在主機**的剪貼板,不是瀏覽器裝置的剪貼板。只有 session 的 writer 可以執行;viewer 不會重複操作。查詢結果以 OSC 52 回覆直接送回原本的 PTY。
|
|
341
|
+
|
|
342
|
+
每次操作都先嘗試 `xclip`(包括 Windows/Android),不存在或執行失敗才依序嘗試適用的 `termux-clipboard-*`、`wl-copy`/`wl-paste`、`xsel`、macOS 的 `pbcopy`/`pbpaste`、Windows 的 PowerShell。PowerShell 函式原封不動取自 jsmdcui。所有工具都失敗時使用 server 記憶體快取;成功讀到空字串就是空剪貼板,不會被舊快取取代。沒有輪詢,外部工具在 worker 中執行。
|
|
343
|
+
|
|
344
|
+
支援 `c`(clipboard)、`p`(primary)、`q`(secondary);`s` 對應 clipboard,`0`~`7` 為記憶體暫存。空選擇使用 `s0`。不提供 primary/secondary 的工具不會拿 clipboard 冒充。單次 OSC 52 請求上限 1 MiB。
|
|
345
|
+
|
|
346
|
+
```sh
|
|
347
|
+
# 寫入 server 剪貼板
|
|
348
|
+
printf '\033]52;c;%s\007' "$(printf 'Hello' | base64 | tr -d '\n')"
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
程式可送出 `ESC ] 52 ; c ; ? ST` 讀取,並從 stdin 接收 base64 編碼的 OSC 52 回覆。
|
|
352
|
+
|
|
353
|
+
`jsgotty --client` **不攔截 OSC 52**,讀寫控制碼都原樣轉送,交給最外層真正的終端模擬器決定。
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Read the terminal before attaching. No reset and no guessed default modes.
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import modes from './terminal-modes.js';
|
|
4
|
+
const {TerminalModeTracker, validateSnapshot, TRACKED_DEC_FLAGS, MOUSE_PROTOCOLS, MOUSE_ENCODINGS} = modes;
|
|
5
|
+
const ESC = '\x1b';
|
|
6
|
+
export class ModeProbe {
|
|
7
|
+
constructor(nonce = randomBytes(16).toString('hex')) {
|
|
8
|
+
this.nonce = nonce;
|
|
9
|
+
this.pending = Buffer.alloc(0);
|
|
10
|
+
this.complete = false;
|
|
11
|
+
this.kittyRestore = false;
|
|
12
|
+
this.state = new TerminalModeTracker().snapshot();
|
|
13
|
+
for (const key of Object.keys(this.state.dec)) this.state.dec[key] = null;
|
|
14
|
+
for (const key of ['alternate','cursorHidden','mouse','encoding']) this.state[key] = null;
|
|
15
|
+
this.state.kitty.normal.flags = this.state.kitty.alternate.flags = null;
|
|
16
|
+
this.state.mouseModeBits = Object.fromEntries(Object.keys(MOUSE_PROTOCOLS).map(n=>[n,null]));
|
|
17
|
+
this.state.mouseEncodingBits = Object.fromEntries(Object.keys(MOUSE_ENCODINGS).map(n=>[n,null]));
|
|
18
|
+
this.replies = new Map();
|
|
19
|
+
this.outstanding = new Set();
|
|
20
|
+
this.kittyOutstanding = false;
|
|
21
|
+
this.privatePrefix = `${ESC}]777;jsgotty-modes;${nonce};`;
|
|
22
|
+
}
|
|
23
|
+
privateQuery() { return `${ESC}]777;jsgotty-modes;${this.nonce}\x07`; }
|
|
24
|
+
standardQuery() {
|
|
25
|
+
this.outstanding = new Set([...Object.keys(TRACKED_DEC_FLAGS),25,1049,...Object.keys(MOUSE_PROTOCOLS),...Object.keys(MOUSE_ENCODINGS)].map(Number));
|
|
26
|
+
this.kittyOutstanding = true;
|
|
27
|
+
this.replyDeadline = Date.now() + 1500;
|
|
28
|
+
return [...this.outstanding]
|
|
29
|
+
.map(n => `${ESC}[?${n}$p`).join('') + `${ESC}[?u`;
|
|
30
|
+
}
|
|
31
|
+
reply(mode, value) {
|
|
32
|
+
if (this.complete) return;
|
|
33
|
+
const yes = value === 1 || value === 3;
|
|
34
|
+
// An unsupported mode cannot be active, but unlike silence it is a reply.
|
|
35
|
+
// Still aggregate below when 0 is the last answer in a mouse-mode group.
|
|
36
|
+
this.replies.set(mode, yes);
|
|
37
|
+
if (MOUSE_PROTOCOLS[mode]) this.state.mouseModeBits[mode] = yes;
|
|
38
|
+
if (MOUSE_ENCODINGS[mode]) this.state.mouseEncodingBits[mode] = yes;
|
|
39
|
+
if (value !== 0) {
|
|
40
|
+
if (TRACKED_DEC_FLAGS[mode]) this.state.dec[TRACKED_DEC_FLAGS[mode]] = yes;
|
|
41
|
+
else if (mode === 25) this.state.cursorHidden = !yes;
|
|
42
|
+
else if (mode === 1049) this.state.alternate = yes;
|
|
43
|
+
}
|
|
44
|
+
for (const [name, table, fallback, priority] of [
|
|
45
|
+
['mouse',MOUSE_PROTOCOLS,'NONE',[1003,1002,1000,9]],
|
|
46
|
+
['encoding',MOUSE_ENCODINGS,'DEFAULT',[1016,1006]],
|
|
47
|
+
]) {
|
|
48
|
+
const active = priority.find(key => this.replies.get(key) === true);
|
|
49
|
+
if (active) this.state[name] = table[active];
|
|
50
|
+
else if (Object.keys(table).every(key => this.replies.has(+key))) this.state[name] = fallback;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
consume(chunk) {
|
|
54
|
+
if ((this.complete && !this.outstanding.size && !this.kittyOutstanding) ||
|
|
55
|
+
(this.replyDeadline && Date.now() > this.replyDeadline)) {
|
|
56
|
+
this.outstanding.clear(); this.kittyOutstanding = false;
|
|
57
|
+
return Buffer.concat([this.flush(), Buffer.from(chunk)]);
|
|
58
|
+
}
|
|
59
|
+
this.pending = Buffer.concat([this.pending, Buffer.from(chunk)]);
|
|
60
|
+
const out = [];
|
|
61
|
+
while (this.pending.length) {
|
|
62
|
+
const text = this.pending.toString('latin1');
|
|
63
|
+
if (text.startsWith(this.privatePrefix)) {
|
|
64
|
+
const end = text.indexOf('\x07', this.privatePrefix.length);
|
|
65
|
+
if (end < 0 && text.length < 16384) break;
|
|
66
|
+
if (end >= 0) {
|
|
67
|
+
try {
|
|
68
|
+
const reply = JSON.parse(Buffer.from(text.slice(this.privatePrefix.length,end),'base64').toString());
|
|
69
|
+
this.state = validateSnapshot(reply);
|
|
70
|
+
this.kittyRestore = reply.kittyRestore === 1;
|
|
71
|
+
this.complete = true;
|
|
72
|
+
} catch {}
|
|
73
|
+
this.pending = this.pending.subarray(end+1); continue;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const dec = /^\x1b\[\?(\d+);([0-4])\$y/.exec(text);
|
|
77
|
+
const kitty = /^\x1b\[\?(\d+)u/.exec(text);
|
|
78
|
+
if ((dec && this.outstanding.delete(+dec[1])) || (kitty && this.kittyOutstanding)) {
|
|
79
|
+
if (dec) this.reply(+dec[1],+dec[2]);
|
|
80
|
+
else {
|
|
81
|
+
this.kittyOutstanding = false;
|
|
82
|
+
if (!this.complete && this.state.alternate != null)
|
|
83
|
+
this.state.kitty[this.state.alternate?'alternate':'normal'].flags = +kitty[1] & 31;
|
|
84
|
+
}
|
|
85
|
+
this.pending = this.pending.subarray((dec||kitty)[0].length); continue;
|
|
86
|
+
}
|
|
87
|
+
// Hold only possible query replies; normal keystrokes and mouse packets pass unchanged.
|
|
88
|
+
if (this.privatePrefix.startsWith(text) || (text.length < 64 && /^\x1b\[\?[0-9;\$]*$/.test(text)) || text === ESC+'[') break;
|
|
89
|
+
// Forward ordinary input in spans; large pastes must not become quadratic.
|
|
90
|
+
const next = this.pending.indexOf(0x1b, 1);
|
|
91
|
+
const count = next < 0 ? this.pending.length : next;
|
|
92
|
+
out.push(this.pending.subarray(0,count));this.pending=this.pending.subarray(count);
|
|
93
|
+
}
|
|
94
|
+
return Buffer.concat(out);
|
|
95
|
+
}
|
|
96
|
+
flush() { const data=this.pending;this.pending=Buffer.alloc(0);return data; }
|
|
97
|
+
}
|
|
98
|
+
export async function captureTerminalModes(input, output, {timeout=1000, probe=new ModeProbe()}={}) {
|
|
99
|
+
const buffered=[];
|
|
100
|
+
if (!input.isTTY || !output.isTTY) {
|
|
101
|
+
probe.replyDeadline = 1;
|
|
102
|
+
return {state:probe.state, probe, buffered:Buffer.alloc(0)};
|
|
103
|
+
}
|
|
104
|
+
const raw=!!input.isRaw;
|
|
105
|
+
input.setRawMode(true);
|
|
106
|
+
await new Promise(resolve=>{
|
|
107
|
+
let finished=false;
|
|
108
|
+
const finish=()=>{if(finished)return;finished=true;clearTimeout(fallback);clearTimeout(end);input.off('data',data);resolve();};
|
|
109
|
+
const data=chunk=>{const rest=probe.consume(chunk);if(rest.length)buffered.push(rest);if(probe.complete)finish();};
|
|
110
|
+
const fallback=setTimeout(()=>output.write(probe.standardQuery()),Math.min(200,timeout/2));
|
|
111
|
+
const end=setTimeout(finish,timeout);
|
|
112
|
+
input.on('data',data);input.resume();output.write(probe.privateQuery());
|
|
113
|
+
});
|
|
114
|
+
input.pause();input.setRawMode(raw);
|
|
115
|
+
buffered.push(probe.flush());
|
|
116
|
+
return {state:validateSnapshot(probe.state),probe,buffered:Buffer.concat(buffered)};
|
|
117
|
+
}
|
package/apps/jsgotty/client.mjs
CHANGED
|
@@ -4,6 +4,10 @@ import process from "node:process";
|
|
|
4
4
|
import { Buffer } from "node:buffer";
|
|
5
5
|
import WebSocket from "ws";
|
|
6
6
|
import { encodeKittyGraphics, syntheticKittyCursorMotion } from "./client-kitty.mjs";
|
|
7
|
+
import terminalModes from "./terminal-modes.js";
|
|
8
|
+
|
|
9
|
+
const { TerminalModeTracker, modeTransition, validateSnapshot } = terminalModes;
|
|
10
|
+
import { captureTerminalModes } from "./client-modes.mjs";
|
|
7
11
|
|
|
8
12
|
const MSG_INPUT = "1";
|
|
9
13
|
const MSG_PING = "2";
|
|
@@ -19,6 +23,7 @@ const MSG_SET_BUFFER_SIZE = "6";
|
|
|
19
23
|
const MSG_KITTY_GRAPHICS = "7";
|
|
20
24
|
const MSG_SET_ROLE = "a";
|
|
21
25
|
const MSG_CONTROL_RESULT = "b";
|
|
26
|
+
const MSG_RESTORE_MODES = "m";
|
|
22
27
|
|
|
23
28
|
const DEFAULT_URL = "ws://127.0.0.1:8080/ws";
|
|
24
29
|
|
|
@@ -409,6 +414,40 @@ async function main() {
|
|
|
409
414
|
return;
|
|
410
415
|
}
|
|
411
416
|
const args = buildArguments(options, initArguments);
|
|
417
|
+
const originalRawMode = !!process.stdin.isRaw;
|
|
418
|
+
const localModes = new TerminalModeTracker();
|
|
419
|
+
// A closed terminal surfaces as an async EIO/EPIPE, not a throw. Nobody is
|
|
420
|
+
// left to read anything, and the PTY keeps its modes, so just exit.
|
|
421
|
+
for (const stream of [process.stdin, process.stdout, process.stderr]) {
|
|
422
|
+
stream.on("error", (error) => {
|
|
423
|
+
if (error.code !== "EIO" && error.code !== "EPIPE") {
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
process.exit(process.exitCode ?? 1);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const writeTerminal = (data) => {
|
|
430
|
+
process.stdout.write(data);
|
|
431
|
+
localModes.consume(Buffer.from(data));
|
|
432
|
+
};
|
|
433
|
+
const captured = await captureTerminalModes(process.stdin, process.stdout);
|
|
434
|
+
const originalModes = captured.state;
|
|
435
|
+
Object.assign(localModes, validateSnapshot(originalModes));
|
|
436
|
+
let bufferedInput = captured.buffered;
|
|
437
|
+
let probeFlushTimer, replayingProbe = false;
|
|
438
|
+
const terminalInput = chunk => {
|
|
439
|
+
if (replayingProbe) return Buffer.from(chunk);
|
|
440
|
+
const input = captured.probe.consume(chunk);
|
|
441
|
+
clearTimeout(probeFlushTimer);
|
|
442
|
+
// A lone Escape is user input too. Never hold it indefinitely while
|
|
443
|
+
// filtering a split or late response to our startup queries.
|
|
444
|
+
if (captured.probe.pending.length) probeFlushTimer = setTimeout(() => {
|
|
445
|
+
const pending = captured.probe.flush();
|
|
446
|
+
replayingProbe = true;
|
|
447
|
+
try { process.stdin.emit("data", pending); } finally { replayingProbe = false; }
|
|
448
|
+
}, 30);
|
|
449
|
+
return input;
|
|
450
|
+
};
|
|
412
451
|
writeStatus(`[gotty] connecting to\n ${wsUrl}`);
|
|
413
452
|
const ws = new WebSocket(wsUrl, "webtty");
|
|
414
453
|
let pingTimer = null;
|
|
@@ -432,7 +471,8 @@ async function main() {
|
|
|
432
471
|
if (!writable || ws.readyState !== WebSocket.OPEN) {
|
|
433
472
|
return;
|
|
434
473
|
}
|
|
435
|
-
|
|
474
|
+
const input = terminalInput(chunk);
|
|
475
|
+
if (input.length) ws.send(MSG_INPUT + input.toString("base64"));
|
|
436
476
|
};
|
|
437
477
|
|
|
438
478
|
const handleResize = () => {
|
|
@@ -441,21 +481,52 @@ async function main() {
|
|
|
441
481
|
}
|
|
442
482
|
};
|
|
443
483
|
|
|
484
|
+
let exiting = false;
|
|
444
485
|
const exitClient = (code = 0) => {
|
|
486
|
+
if (exiting) return;
|
|
487
|
+
exiting = true;
|
|
445
488
|
cleanup();
|
|
446
489
|
reportDisconnected();
|
|
447
490
|
ws.close();
|
|
448
|
-
process.exit(code);
|
|
491
|
+
process.stdout.write("", () => process.exit(code));
|
|
449
492
|
};
|
|
450
493
|
|
|
494
|
+
// Restoring the viewed session also restores Kitty keyboard modes. Exit
|
|
495
|
+
// keys can therefore arrive as CSI u rather than legacy control bytes.
|
|
496
|
+
// Keep CSI packets across chunks; never treat a key-release as a press.
|
|
497
|
+
let viewerSequence = "";
|
|
451
498
|
const handleViewerInput = (chunk) => {
|
|
452
|
-
for (const byte of
|
|
453
|
-
if (
|
|
454
|
-
|
|
499
|
+
for (const byte of terminalInput(chunk)) {
|
|
500
|
+
if (viewerSequence) {
|
|
501
|
+
viewerSequence += String.fromCharCode(byte);
|
|
502
|
+
if (viewerSequence === "\x1b[") continue;
|
|
503
|
+
if (viewerSequence.length === 2 && byte !== 0x5b) {
|
|
504
|
+
viewerSequence = "";
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (viewerSequence.length > 2 && byte >= 0x40 && byte <= 0x7e) {
|
|
508
|
+
const sequence = viewerSequence;
|
|
509
|
+
viewerSequence = "";
|
|
510
|
+
const kitty = /^\x1b\[(\d+)(?::[0-9:]*)?(?:;(\d+)(?::([123]))?)?(?:;[0-9:]+)?u$/.exec(sequence);
|
|
511
|
+
const modified = /^\x1b\[27;(\d+);(\d+)~$/.exec(sequence);
|
|
512
|
+
const key = kitty ? +kitty[1] : modified ? +modified[2] : 0;
|
|
513
|
+
const modifiers = (kitty ? +(kitty[2] || 1) : modified ? +modified[1] : 1) - 1;
|
|
514
|
+
// Ignore Caps/Num Lock, but require Ctrl without Alt/Shift/Super.
|
|
515
|
+
if ((modifiers & ~192) === 4 && (!kitty || kitty[3] !== "3")) {
|
|
516
|
+
if (key === 99) return exitClient(130);
|
|
517
|
+
if (key === 100 || key === 113) return exitClient(0);
|
|
518
|
+
}
|
|
519
|
+
} else if (viewerSequence.length > 128) {
|
|
520
|
+
viewerSequence = "";
|
|
521
|
+
}
|
|
522
|
+
continue;
|
|
455
523
|
}
|
|
456
|
-
if (byte ===
|
|
457
|
-
|
|
524
|
+
if (byte === 0x1b) {
|
|
525
|
+
viewerSequence = "\x1b";
|
|
526
|
+
continue;
|
|
458
527
|
}
|
|
528
|
+
if (byte === 0x03) return exitClient(130);
|
|
529
|
+
if (byte === 0x04 || byte === 0x11) return exitClient(0);
|
|
459
530
|
}
|
|
460
531
|
};
|
|
461
532
|
|
|
@@ -498,7 +569,8 @@ async function main() {
|
|
|
498
569
|
return;
|
|
499
570
|
}
|
|
500
571
|
|
|
501
|
-
const input =
|
|
572
|
+
const input = terminalInput(chunk);
|
|
573
|
+
if (!input.length) return;
|
|
502
574
|
fallbackProbe = normalizeEcho(input.toString("utf8"));
|
|
503
575
|
ws.send(MSG_INPUT + input.toString("base64"));
|
|
504
576
|
stopFallbackInput();
|
|
@@ -532,6 +604,10 @@ async function main() {
|
|
|
532
604
|
setRawMode(true);
|
|
533
605
|
rawEnabled = process.stdin.isTTY;
|
|
534
606
|
sendResize(ws, options);
|
|
607
|
+
if (bufferedInput.length) {
|
|
608
|
+
ws.send(MSG_INPUT + bufferedInput.toString("base64"));
|
|
609
|
+
bufferedInput = Buffer.alloc(0);
|
|
610
|
+
}
|
|
535
611
|
return;
|
|
536
612
|
}
|
|
537
613
|
|
|
@@ -558,6 +634,7 @@ pid:
|
|
|
558
634
|
return;
|
|
559
635
|
}
|
|
560
636
|
closed = true;
|
|
637
|
+
clearTimeout(probeFlushTimer);
|
|
561
638
|
if (pingTimer) {
|
|
562
639
|
clearInterval(pingTimer);
|
|
563
640
|
pingTimer = null;
|
|
@@ -565,8 +642,13 @@ pid:
|
|
|
565
642
|
setWritable(false);
|
|
566
643
|
stopFallbackInput();
|
|
567
644
|
stopViewerInput();
|
|
645
|
+
if (process.stdout.isTTY) {
|
|
646
|
+
try {
|
|
647
|
+
writeTerminal(modeTransition(localModes.snapshot(), originalModes, { normalizeMouse: true, kittyRestore: captured.probe.kittyRestore }));
|
|
648
|
+
} catch {}
|
|
649
|
+
}
|
|
568
650
|
if (rawEnabled) {
|
|
569
|
-
setRawMode(
|
|
651
|
+
setRawMode(originalRawMode);
|
|
570
652
|
rawEnabled = false;
|
|
571
653
|
}
|
|
572
654
|
process.stdin.pause();
|
|
@@ -611,7 +693,7 @@ pid:
|
|
|
611
693
|
break;
|
|
612
694
|
}
|
|
613
695
|
pendingKittyCursorMotion = "";
|
|
614
|
-
|
|
696
|
+
writeTerminal(output);
|
|
615
697
|
if (fallbackProbe && accessRole === "") {
|
|
616
698
|
fallbackOutputTail = normalizeEcho(
|
|
617
699
|
`${fallbackOutputTail}${output.toString("utf8")}`
|
|
@@ -641,6 +723,14 @@ pid:
|
|
|
641
723
|
if (encoded) process.stdout.write(encoded);
|
|
642
724
|
} catch {}
|
|
643
725
|
break;
|
|
726
|
+
case MSG_RESTORE_MODES:
|
|
727
|
+
// Only local terminal state; the PTY keeps its modes for the next resume.
|
|
728
|
+
if (process.stdout.isTTY) {
|
|
729
|
+
try {
|
|
730
|
+
writeTerminal(modeTransition(localModes.snapshot(), validateSnapshot(JSON.parse(payload)), { normalizeMouse: true, kittyRestore: captured.probe.kittyRestore }));
|
|
731
|
+
} catch {}
|
|
732
|
+
}
|
|
733
|
+
break;
|
|
644
734
|
case MSG_SET_RECONNECT: {
|
|
645
735
|
try {
|
|
646
736
|
const reconnect = JSON.parse(payload);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copied verbatim from jsmdcui/src/platform/clipboard.js (MIT).
|
|
3
|
+
// Keep the function below unchanged; inject its existing command helpers.
|
|
4
|
+
module.exports = function createPowerShellClipboard(shell, runSync, outputOrThrow) {
|
|
5
|
+
function powershellClipboard(shell) {
|
|
6
|
+
return {
|
|
7
|
+
name: "powershell",
|
|
8
|
+
// Get-Clipboard -Raw appends \r\n to stdout; strip exactly one trailing line ending.
|
|
9
|
+
read: () => outputOrThrow(runSync([shell, "-NoProfile", "-Command", "Get-Clipboard -Raw"], {})).replace(/\r?\n$/, ""),
|
|
10
|
+
write: (text) => {
|
|
11
|
+
text=(text+'').replaceAll("'","''") ;
|
|
12
|
+
return runSync([shell, "-NoProfile", "-Command", `Set-Clipboard '${text}'`], { stdout: "ignore" }).ok ;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return powershellClipboard(shell);
|
|
18
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const createPowerShellClipboard = require('./clipboard-powershell.js');
|
|
3
|
+
// Self-contained so the worker remains valid after bundling/minification.
|
|
4
|
+
function clipboardRuntime(createPowerShellClipboard) {
|
|
5
|
+
const {spawnSync} = require('node:child_process');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const MAX_OSC52 = 1024 * 1024;
|
|
9
|
+
function which(name) {
|
|
10
|
+
if (typeof Bun !== 'undefined') return Bun.which(name);
|
|
11
|
+
const names=process.platform==='win32' && !path.extname(name)
|
|
12
|
+
? [name,...(process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').map(ext=>name+ext)] : [name];
|
|
13
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
14
|
+
for(const candidate of names) {
|
|
15
|
+
const file=path.join(dir,candidate);
|
|
16
|
+
try {fs.accessSync(file,fs.constants.X_OK);if(fs.statSync(file).isFile())return file;} catch {}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function runSync(command, options={}) {
|
|
22
|
+
try {
|
|
23
|
+
const r=spawnSync(command[0],command.slice(1),{
|
|
24
|
+
input:options.stdin, encoding:'utf8', timeout:options.timeout ?? 2000,
|
|
25
|
+
maxBuffer:MAX_OSC52, stdio:['pipe',options.stdout ?? 'pipe','pipe'],windowsHide:true,
|
|
26
|
+
});
|
|
27
|
+
return {ok:!r.error && r.status===0,stdout:r.stdout || '',stderr:r.stderr || String(r.error || '')};
|
|
28
|
+
} catch(error) {return {ok:false,stdout:'',stderr:String(error)};}
|
|
29
|
+
}
|
|
30
|
+
function outputOrThrow(result) {
|
|
31
|
+
if (!result.ok) throw new Error(result.stderr || result.stdout || 'clipboard command failed');
|
|
32
|
+
return result.stdout;
|
|
33
|
+
}
|
|
34
|
+
function createClipboard({find=which,run=runSync,platform=process.platform}={}) {
|
|
35
|
+
const internal=new Map();
|
|
36
|
+
function candidates(register,write,text) {
|
|
37
|
+
const result=[];
|
|
38
|
+
const command=(name,args)=>{const executable=find(name);if(executable)result.push(()=>run([executable,...args],write?{stdin:text,stdout:'ignore'}:{}));};
|
|
39
|
+
// Always first, including Windows and Android. Failure does not pin a backend.
|
|
40
|
+
command('xclip',['-selection',register,...(write?[]:['-o'])]);
|
|
41
|
+
if(register==='clipboard') {
|
|
42
|
+
command(write?'termux-clipboard-set':'termux-clipboard-get',[]);
|
|
43
|
+
}
|
|
44
|
+
if(register!=='secondary')command(write?'wl-copy':'wl-paste',[...(write?[]:['--no-newline']),...(register==='primary'?['--primary']:[])]);
|
|
45
|
+
command('xsel',[`--${register}`,write?'--input':'--output']);
|
|
46
|
+
if(register==='clipboard') {
|
|
47
|
+
if(platform==='darwin')command(write?'pbcopy':'pbpaste',[]);
|
|
48
|
+
if(platform==='win32')for(const name of ['pwsh.exe','powershell.exe','pwsh','powershell']){
|
|
49
|
+
const shell=find(name);if(!shell)continue;
|
|
50
|
+
const backend=createPowerShellClipboard(shell,run,outputOrThrow);
|
|
51
|
+
result.push(()=>write?{ok:backend.write(text)}:{ok:true,stdout:backend.read()});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
read(register='clipboard') {
|
|
58
|
+
if(/^cut[0-7]$/.test(register))return internal.get(register);
|
|
59
|
+
for(const attempt of candidates(register,false))try {
|
|
60
|
+
const r=attempt();if(r.ok){internal.set(register,r.stdout);return r.stdout;}
|
|
61
|
+
}catch{}
|
|
62
|
+
return internal.get(register);
|
|
63
|
+
},
|
|
64
|
+
write(text,register='clipboard') {
|
|
65
|
+
internal.set(register,text);
|
|
66
|
+
if(/^cut[0-7]$/.test(register))return;
|
|
67
|
+
for(const attempt of candidates(register,true,text))try {if(attempt().ok)return;}catch{}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function parseOSC52(data) {
|
|
72
|
+
if(typeof data!=='string'||data.length>MAX_OSC52)return null;
|
|
73
|
+
const i=data.indexOf(';');if(i<0)return null;
|
|
74
|
+
const selection=data.slice(0,i),payload=data.slice(i+1);
|
|
75
|
+
if(!/^[cpqs0-7]*$/.test(selection)||selection.length>16)return null;
|
|
76
|
+
const registers=[...new Set([...(selection || 's0')].map(c=>({c:'clipboard',p:'primary',q:'secondary',s:'clipboard'}[c]||`cut${c}`)))];
|
|
77
|
+
if(payload==='?')return {selection,registers,read:true};
|
|
78
|
+
// OSC 52 specifies clearing the selection for non-base64 data.
|
|
79
|
+
const valid=/^[A-Za-z0-9+/]*={0,2}$/.test(payload) && payload.replace(/=+$/,'').length % 4 !== 1;
|
|
80
|
+
return {selection,registers,read:false,text:valid?Buffer.from(payload,'base64').toString('utf8'):''};
|
|
81
|
+
}
|
|
82
|
+
function executeOSC52(clipboard,request) {
|
|
83
|
+
if(request.read) {
|
|
84
|
+
let text;
|
|
85
|
+
for(const register of request.registers){text=clipboard.read(register);if(text!==undefined)break;}
|
|
86
|
+
const data=Buffer.from(text ?? '', 'utf8');
|
|
87
|
+
if(data.length>MAX_OSC52)return null;
|
|
88
|
+
return `\x1b]52;${request.selection};${data.toString('base64')}\x1b\\`;
|
|
89
|
+
}
|
|
90
|
+
for(const register of request.registers)clipboard.write(request.text,register);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return {createClipboard,parseOSC52,executeOSC52,MAX_OSC52};
|
|
94
|
+
}
|
|
95
|
+
const {createClipboard,parseOSC52,executeOSC52,MAX_OSC52}=clipboardRuntime(createPowerShellClipboard);
|
|
96
|
+
module.exports={createClipboard,parseOSC52,executeOSC52,MAX_OSC52};
|
|
97
|
+
|
|
98
|
+
// Run the synchronous jsmdcui-compatible backends off the server event loop.
|
|
99
|
+
// Inline the helpers so compiled single-file builds need no external worker file.
|
|
100
|
+
class ClipboardHost {
|
|
101
|
+
constructor(){this.pending=new Map();this.nextId=0;this.worker=null;}
|
|
102
|
+
execute(request){
|
|
103
|
+
if(this.pending.size>=32)return Promise.reject(Error('clipboard queue full'));
|
|
104
|
+
if(!this.worker){
|
|
105
|
+
const {Worker}=require('node:worker_threads');
|
|
106
|
+
const source=`const {parentPort}=require('node:worker_threads');
|
|
107
|
+
const runtime=(${clipboardRuntime.toString()})(${createPowerShellClipboard.toString()});
|
|
108
|
+
const clipboard=runtime.createClipboard();parentPort.on('message',({id,request})=>{
|
|
109
|
+
try{parentPort.postMessage({id,result:runtime.executeOSC52(clipboard,request)});}
|
|
110
|
+
catch(error){parentPort.postMessage({id,error:String(error)});}
|
|
111
|
+
});`;
|
|
112
|
+
const worker=this.worker=new Worker(source,{eval:true});
|
|
113
|
+
const failed=error=>{
|
|
114
|
+
if(this.worker!==worker)return;
|
|
115
|
+
this.worker=null;
|
|
116
|
+
for(const job of this.pending.values())job.reject(error);
|
|
117
|
+
this.pending.clear();worker.terminate();
|
|
118
|
+
};
|
|
119
|
+
worker.on('error',failed);
|
|
120
|
+
worker.on('exit',()=>failed(Error('clipboard worker exited')));
|
|
121
|
+
worker.on('message',({id,result,error})=>{
|
|
122
|
+
const job=this.pending.get(id);if(!job)return;
|
|
123
|
+
this.pending.delete(id);error?job.reject(Error(error)):job.resolve(result);
|
|
124
|
+
if(!this.pending.size)worker.unref();
|
|
125
|
+
});
|
|
126
|
+
worker.unref();
|
|
127
|
+
}
|
|
128
|
+
return new Promise((resolve,reject)=>{
|
|
129
|
+
const id=++this.nextId;this.pending.set(id,{resolve,reject});this.worker.ref();
|
|
130
|
+
this.worker.postMessage({id,request});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async close(){
|
|
134
|
+
const worker=this.worker;this.worker=null;
|
|
135
|
+
for(const job of this.pending.values())job.reject(Error('clipboard closed'));
|
|
136
|
+
this.pending.clear();if(worker)await worker.terminate();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Authorization is checked both before host access and before returning data.
|
|
140
|
+
function handleOSC52(session,ws,payload,host){
|
|
141
|
+
if(!session.isWriter(ws))return;
|
|
142
|
+
const request=parseOSC52(payload);if(!request)return;
|
|
143
|
+
host.execute(request).then(reply=>{
|
|
144
|
+
if(reply && !session.closed && session.isWriter(ws))session.backend.write(Buffer.from(reply));
|
|
145
|
+
}).catch(()=>{});
|
|
146
|
+
}
|
|
147
|
+
module.exports.ClipboardHost=ClipboardHost;
|
|
148
|
+
module.exports.handleOSC52=handleOSC52;
|
package/apps/jsgotty/gotty.js
CHANGED
|
@@ -12,7 +12,10 @@ const crypto = require("crypto");
|
|
|
12
12
|
const zlib = require("zlib");
|
|
13
13
|
const { constants: fsConstants } = require("fs");
|
|
14
14
|
const WebSocket = require("ws");
|
|
15
|
+
const { TerminalModeTracker } = require("./terminal-modes.js");
|
|
15
16
|
const { WebSocketServer } = WebSocket;
|
|
17
|
+
const { ClipboardHost, handleOSC52 } = require("./clipboard.js");
|
|
18
|
+
const clipboardHost = new ClipboardHost();
|
|
16
19
|
|
|
17
20
|
const MSG_INPUT = "1";
|
|
18
21
|
const MSG_PING = "2";
|
|
@@ -34,6 +37,7 @@ const MSG_ZMODEM_MODE = "z";
|
|
|
34
37
|
const MSG_ZMODEM_OUTPUT = "y";
|
|
35
38
|
const MSG_ZMODEM_ACK = "Y";
|
|
36
39
|
const MSG_KITTY_DEBUG = "K";
|
|
40
|
+
const MSG_RESTORE_MODES = "m";
|
|
37
41
|
const KITTY_TRACE = /^(1|true|yes|on)$/i.test(String(process.env.JSGOTTY_KITTY_DEBUG || ""));
|
|
38
42
|
const WINDOWS_BRIDGE_TRACE = process.env.WINDOWS_BRIDGE_TRACE === "1";
|
|
39
43
|
const WINDOWS_BRIDGE_PREFIX = Buffer.from("@@GOTTYCTL:", "ascii");
|
|
@@ -1680,6 +1684,7 @@ class PtySession {
|
|
|
1680
1684
|
this.supportsWindowsBridge = process.platform === "win32";
|
|
1681
1685
|
this.cursorTracker = new CursorStateTracker();
|
|
1682
1686
|
this.cursorTracker.setDimensions(this.columns || 80);
|
|
1687
|
+
this.modeTracker = new TerminalModeTracker();
|
|
1683
1688
|
this.kittyParser = new KittyGraphicsParser();
|
|
1684
1689
|
this.windowsBridgeParser = this.supportsWindowsBridge ? new WindowsBridgeParser("incoming") : null;
|
|
1685
1690
|
this.outgoingWindowsBridgeParser = this.supportsWindowsBridge ? new WindowsBridgeParser("outgoing") : null;
|
|
@@ -1759,6 +1764,7 @@ class PtySession {
|
|
|
1759
1764
|
if (!this.supportsWindowsBridge) {
|
|
1760
1765
|
const cursorBefore = this.cursorTracker.snapshot();
|
|
1761
1766
|
this.cursorTracker.consume(filteredChunk);
|
|
1767
|
+
this.modeTracker.consume(filteredChunk);
|
|
1762
1768
|
const cursorAfter = this.cursorTracker.snapshot();
|
|
1763
1769
|
const controls = cursorControls(filteredChunk);
|
|
1764
1770
|
if (controls.length > 0) {
|
|
@@ -1776,6 +1782,7 @@ class PtySession {
|
|
|
1776
1782
|
if (bridged.plain.length > 0) {
|
|
1777
1783
|
const cursorBefore = this.cursorTracker.snapshot();
|
|
1778
1784
|
this.cursorTracker.consume(bridged.plain);
|
|
1785
|
+
this.modeTracker.consume(bridged.plain);
|
|
1779
1786
|
const cursorAfter = this.cursorTracker.snapshot();
|
|
1780
1787
|
const controls = cursorControls(bridged.plain);
|
|
1781
1788
|
if (controls.length > 0) {
|
|
@@ -1855,6 +1862,9 @@ class PtySession {
|
|
|
1855
1862
|
this.writerWs = ws;
|
|
1856
1863
|
}
|
|
1857
1864
|
this.sendSessionMetadata(ws, remoteAddr);
|
|
1865
|
+
// Queued ahead of live output; the client applies only modes that differ,
|
|
1866
|
+
// which also clears modes a program turned off while the socket was down.
|
|
1867
|
+
this.send(MSG_RESTORE_MODES, JSON.stringify(this.modeTracker.snapshot()), ws);
|
|
1858
1868
|
this.startWebSocketHandlers(ws);
|
|
1859
1869
|
this.notifyRoles();
|
|
1860
1870
|
return true;
|
|
@@ -2442,6 +2452,9 @@ class PtySession {
|
|
|
2442
2452
|
}
|
|
2443
2453
|
|
|
2444
2454
|
switch (type) {
|
|
2455
|
+
case "C":
|
|
2456
|
+
handleOSC52(this, ws, payload, clipboardHost);
|
|
2457
|
+
break;
|
|
2445
2458
|
case MSG_INPUT:
|
|
2446
2459
|
if (!this.isWriter(ws) || payload.length === 0) {
|
|
2447
2460
|
return;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsgotty",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "jsgotty",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.3.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"node-addon-api": "^7.1.1",
|
|
12
12
|
"ws": "^8.18.3"
|