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 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.
@@ -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.
@@ -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
+ }
@@ -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
- ws.send(MSG_INPUT + Buffer.from(chunk).toString("base64"));
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 Buffer.from(chunk)) {
453
- if (byte === 0x03) {
454
- exitClient(130);
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 === 0x04 || byte === 0x11) {
457
- exitClient(0);
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 = Buffer.from(chunk);
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(false);
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
- process.stdout.write(output);
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;
@@ -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.2.0",
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.2.0",
9
+ "version": "1.3.0",
10
10
  "dependencies": {
11
11
  "node-addon-api": "^7.1.1",
12
12
  "ws": "^8.18.3"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsgotty",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Share your terminal as a web application: A JavaScript rewrite of GoTTY",
5
5
  "license": "MIT",
6
6
  "bin": {