herdr-remote-relay 0.2.5 → 0.2.7
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/package.json +2 -1
- package/src/relay-server.js +112 -208
- package/web/dist/assets/index-DTuLWvUt.css +32 -0
- package/web/dist/assets/index-x0w9a9uV.js +39 -0
- package/web/dist/assets/vendor-react-CYSfZuHu.js +17 -0
- package/web/dist/assets/vendor-xterm-Bldps5fz.js +66 -0
- package/web/dist/index.html +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "herdr-remote-relay",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Standalone relay server and web terminal for Herdr Remote",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "npm --prefix web run build",
|
|
45
|
+
"prepack": "npm run build",
|
|
45
46
|
"start": "node bin/herdr-remote-relay.js",
|
|
46
47
|
"test": "node --test tests/*.test.js"
|
|
47
48
|
},
|
package/src/relay-server.js
CHANGED
|
@@ -17,19 +17,9 @@ const VERSION = require('../package.json').version;
|
|
|
17
17
|
const { PROTOCOL_VERSION } = require('./stream-frame');
|
|
18
18
|
const MAX_DIMENSION = 500;
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* A browser that joins a session already in progress has missed everything
|
|
24
|
-
* printed before it arrived. Replaying the tail of the stream is what makes
|
|
25
|
-
* "every window shows the same thing" true on the *first* frame rather than
|
|
26
|
-
* only after the next repaint.
|
|
27
|
-
*/
|
|
28
|
-
const SESSION_REPLAY_BYTES = 512 * 1024;
|
|
29
|
-
|
|
30
|
-
/** Never shrink a shared grid below something a program can still draw in. */
|
|
31
|
-
const MIN_SHARED_COLS = 20;
|
|
32
|
-
const MIN_SHARED_ROWS = 6;
|
|
20
|
+
/** Never shrink an individual session grid below something a program can still draw in. */
|
|
21
|
+
const MIN_SESSION_COLS = 20;
|
|
22
|
+
const MIN_SESSION_ROWS = 6;
|
|
33
23
|
|
|
34
24
|
function randomId(prefix) {
|
|
35
25
|
return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
|
|
@@ -97,6 +87,7 @@ class RelayServer {
|
|
|
97
87
|
this.relayMode = config.relay?.mode === 'local' ? 'local' : 'remote';
|
|
98
88
|
this.hosts = new Map();
|
|
99
89
|
this.clients = new Map();
|
|
90
|
+
this.streams = new Map();
|
|
100
91
|
this.pairAttempts = new Map();
|
|
101
92
|
this.clientHandshakeAttempts = new Map();
|
|
102
93
|
this.hostHandshakeAttempts = new Map();
|
|
@@ -160,6 +151,7 @@ class RelayServer {
|
|
|
160
151
|
this.cleanupTimer = null;
|
|
161
152
|
for (const client of [...this.clients.values()]) this.detachClient(client, { notify: false });
|
|
162
153
|
for (const host of [...this.hosts.values()]) this.detachHost(host, { notify: false });
|
|
154
|
+
this.streams.clear();
|
|
163
155
|
this.pairAttempts.clear();
|
|
164
156
|
this.clientHandshakeAttempts.clear();
|
|
165
157
|
this.hostHandshakeAttempts.clear();
|
|
@@ -516,10 +508,6 @@ class RelayServer {
|
|
|
516
508
|
arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
|
|
517
509
|
connectedAt: new Date(pending.connectedAt).toISOString(),
|
|
518
510
|
connectedAtMs: pending.connectedAt,
|
|
519
|
-
// One shared terminal per workstation. Every browser attached to this
|
|
520
|
-
// host reads and writes the same PTY, so what one of them shows is what
|
|
521
|
-
// all of them show.
|
|
522
|
-
session: null,
|
|
523
511
|
terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
|
|
524
512
|
lastSeenAt: Date.now(),
|
|
525
513
|
clients,
|
|
@@ -555,7 +543,13 @@ class RelayServer {
|
|
|
555
543
|
host.lastSeenAt = Date.now();
|
|
556
544
|
host.load = {};
|
|
557
545
|
host.ptys = [];
|
|
558
|
-
host.
|
|
546
|
+
for (const clientId of host.clients) {
|
|
547
|
+
const client = this.clients.get(clientId);
|
|
548
|
+
if (client?.session) {
|
|
549
|
+
this.streams.delete(client.session.streamId);
|
|
550
|
+
client.session = null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
559
553
|
this.broadcastToClients(host, () => ({ type: 'host_reconnecting', code: reason }));
|
|
560
554
|
host.reconnectTimer = setTimeout(() => {
|
|
561
555
|
host.reconnectTimer = null;
|
|
@@ -598,10 +592,20 @@ class RelayServer {
|
|
|
598
592
|
if (oldHost.reconnectTimer) clearTimeout(oldHost.reconnectTimer);
|
|
599
593
|
oldHost.reconnectTimer = null;
|
|
600
594
|
if (handoff) {
|
|
601
|
-
|
|
602
|
-
|
|
595
|
+
for (const clientId of oldHost.clients) {
|
|
596
|
+
const client = this.clients.get(clientId);
|
|
597
|
+
if (client?.session) {
|
|
598
|
+
if (isOpen(oldHost.ws)) {
|
|
599
|
+
jsonSend(oldHost.ws, {
|
|
600
|
+
type: 'session_stop',
|
|
601
|
+
clientId: client.session.streamId,
|
|
602
|
+
streamId: client.session.streamId,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
this.streams.delete(client.session.streamId);
|
|
606
|
+
client.session = null;
|
|
607
|
+
}
|
|
603
608
|
}
|
|
604
|
-
oldHost.session = null;
|
|
605
609
|
oldHost.load = {};
|
|
606
610
|
oldHost.ptys = [];
|
|
607
611
|
this.broadcastToClients(oldHost, () => ({ type: 'host_reconnecting', code: 'host_replaced' }));
|
|
@@ -624,7 +628,12 @@ class RelayServer {
|
|
|
624
628
|
clientCount: host.clients.size,
|
|
625
629
|
});
|
|
626
630
|
this.notifyHostClientCount(host);
|
|
627
|
-
if (handoff)
|
|
631
|
+
if (handoff) {
|
|
632
|
+
for (const clientId of host.clients) {
|
|
633
|
+
const client = this.clients.get(clientId);
|
|
634
|
+
if (client) this.startSession(host, client, { restarted: true });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
628
637
|
return;
|
|
629
638
|
}
|
|
630
639
|
this.handleHostMessage(pending.host, raw, isBinary);
|
|
@@ -660,16 +669,13 @@ class RelayServer {
|
|
|
660
669
|
closeSocket(host.ws, 1003, error.message);
|
|
661
670
|
return;
|
|
662
671
|
}
|
|
663
|
-
// Output
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
if (
|
|
667
|
-
this.
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if (!client || !isOpen(client.ws)) continue;
|
|
671
|
-
this.sendClientBinary(host, client, frame.payload);
|
|
672
|
-
}
|
|
672
|
+
// Output is routed to the single client owning the stream rather than broadcast.
|
|
673
|
+
if (frame.type !== 'output') return;
|
|
674
|
+
const clientId = this.streams.get(frame.streamId);
|
|
675
|
+
if (!clientId) return;
|
|
676
|
+
const client = this.clients.get(clientId);
|
|
677
|
+
if (!client || !isOpen(client.ws)) return;
|
|
678
|
+
this.sendClientBinary(host, client, frame.payload);
|
|
673
679
|
return;
|
|
674
680
|
}
|
|
675
681
|
const message = parseJson(raw.toString());
|
|
@@ -684,28 +690,29 @@ class RelayServer {
|
|
|
684
690
|
if (this.hosts.get(host.id) === host) this.detachHost(host, { notify: true, reason: 'host_shutdown' });
|
|
685
691
|
return;
|
|
686
692
|
}
|
|
687
|
-
// Session
|
|
688
|
-
// shared stream, and every attached browser has to hear it.
|
|
689
|
-
const session = host.session;
|
|
693
|
+
// Session events target the single client that owns this stream.
|
|
690
694
|
const streamId = typeof message.clientId === 'string' ? message.clientId : message.streamId;
|
|
691
|
-
|
|
695
|
+
const clientId = streamId ? this.streams.get(streamId) : null;
|
|
696
|
+
const client = clientId ? this.clients.get(clientId) : null;
|
|
697
|
+
if (!client) return;
|
|
698
|
+
|
|
692
699
|
if (message.type === 'session_ready') {
|
|
693
|
-
session.ready = true;
|
|
694
|
-
|
|
700
|
+
if (client.session) client.session.ready = true;
|
|
701
|
+
jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
|
|
695
702
|
} else if (message.type === 'session_exit') {
|
|
696
703
|
const code = Number.isInteger(message.code) ? message.code : null;
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
if (client) this.detachClient(client, { notify: false });
|
|
704
|
+
jsonSend(client.ws, { type: 'exit', code });
|
|
705
|
+
if (client.session) {
|
|
706
|
+
this.streams.delete(client.session.streamId);
|
|
707
|
+
client.session = null;
|
|
702
708
|
}
|
|
709
|
+
this.detachClient(client, { notify: false });
|
|
703
710
|
} else if (message.type === 'error') {
|
|
704
|
-
|
|
711
|
+
jsonSend(client.ws, {
|
|
705
712
|
type: 'error',
|
|
706
713
|
code: message.code || 'host_error',
|
|
707
714
|
message: String(message.message || 'Host connector error'),
|
|
708
|
-
})
|
|
715
|
+
});
|
|
709
716
|
}
|
|
710
717
|
}
|
|
711
718
|
|
|
@@ -749,147 +756,35 @@ class RelayServer {
|
|
|
749
756
|
}
|
|
750
757
|
}
|
|
751
758
|
|
|
752
|
-
/**
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
/**
|
|
762
|
-
* The grid the shared PTY runs at.
|
|
763
|
-
*
|
|
764
|
-
* The smallest attached window wins, exactly as it does in tmux: a column a
|
|
765
|
-
* phone cannot show is a column the program must not paint, or every other
|
|
766
|
-
* window sees wrapped rubbish. Nothing else keeps a shared terminal legible
|
|
767
|
-
* on two different screens at once.
|
|
768
|
-
*/
|
|
769
|
-
sharedDimensions(host) {
|
|
770
|
-
let cols = MAX_DIMENSION;
|
|
771
|
-
let rows = MAX_DIMENSION;
|
|
772
|
-
let found = false;
|
|
773
|
-
for (const clientId of host.clients) {
|
|
774
|
-
const client = this.clients.get(clientId);
|
|
775
|
-
if (!client) continue;
|
|
776
|
-
found = true;
|
|
777
|
-
cols = Math.min(cols, client.cols);
|
|
778
|
-
rows = Math.min(rows, client.rows);
|
|
779
|
-
}
|
|
780
|
-
if (!found) return null;
|
|
781
|
-
return {
|
|
782
|
-
cols: Math.max(MIN_SHARED_COLS, cols),
|
|
783
|
-
rows: Math.max(MIN_SHARED_ROWS, rows),
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
/** Start the one shared PTY for a host's currently attached clients. */
|
|
788
|
-
startSession(host, { restarted = false } = {}) {
|
|
789
|
-
const firstClientId = [...host.clients][0];
|
|
790
|
-
const firstClient = firstClientId ? this.clients.get(firstClientId) : null;
|
|
791
|
-
if (!firstClient || !isOpen(host.ws)) return;
|
|
792
|
-
host.session = {
|
|
793
|
-
streamId: randomId('session'),
|
|
794
|
-
cols: firstClient.cols,
|
|
795
|
-
rows: firstClient.rows,
|
|
759
|
+
/** Start a dedicated PTY session for one attached client. */
|
|
760
|
+
startSession(host, client, { restarted = false } = {}) {
|
|
761
|
+
if (!client || !isOpen(host.ws)) return;
|
|
762
|
+
const streamId = randomId('session');
|
|
763
|
+
client.session = {
|
|
764
|
+
streamId,
|
|
765
|
+
cols: client.cols,
|
|
766
|
+
rows: client.rows,
|
|
796
767
|
ready: false,
|
|
797
|
-
replay: [],
|
|
798
|
-
replayBytes: 0,
|
|
799
768
|
};
|
|
800
|
-
|
|
801
|
-
host.session.cols = dims.cols;
|
|
802
|
-
host.session.rows = dims.rows;
|
|
769
|
+
this.streams.set(streamId, client.id);
|
|
803
770
|
jsonSend(host.ws, {
|
|
804
771
|
type: 'session_start',
|
|
805
|
-
clientId:
|
|
806
|
-
streamId
|
|
807
|
-
cols:
|
|
808
|
-
rows:
|
|
772
|
+
clientId: streamId,
|
|
773
|
+
streamId,
|
|
774
|
+
cols: Math.max(MIN_SESSION_COLS, client.cols),
|
|
775
|
+
rows: Math.max(MIN_SESSION_ROWS, client.rows),
|
|
809
776
|
role: 'controller',
|
|
810
777
|
});
|
|
811
|
-
this.broadcastToClients(host, () => ({ type: 'shared_resize', cols: dims.cols, rows: dims.rows }));
|
|
812
778
|
if (restarted) {
|
|
813
|
-
|
|
779
|
+
jsonSend(client.ws, {
|
|
814
780
|
type: 'session_restarted',
|
|
815
|
-
streamId
|
|
816
|
-
cols:
|
|
817
|
-
rows:
|
|
781
|
+
streamId,
|
|
782
|
+
cols: client.cols,
|
|
783
|
+
rows: client.rows,
|
|
818
784
|
hostname: host.hostname,
|
|
819
785
|
terminalPalette: host.terminalPalette || null,
|
|
820
|
-
}));
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
/**
|
|
825
|
-
* Attach `client` to the workstation's shared terminal, starting it if this
|
|
826
|
-
* is the first browser through the door.
|
|
827
|
-
*
|
|
828
|
-
* A later arrival does not get its own PTY: it is handed the stream already
|
|
829
|
-
* running, the output that has been printed so far, and — once the geometry
|
|
830
|
-
* has settled — a repaint, so it lands on the same screen everyone else is
|
|
831
|
-
* looking at.
|
|
832
|
-
*/
|
|
833
|
-
attachSession(host, client) {
|
|
834
|
-
if (!host.session) {
|
|
835
|
-
this.startSession(host);
|
|
836
|
-
return;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
const session = host.session;
|
|
840
|
-
if (session.ready) jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
|
|
841
|
-
for (const chunk of session.replay) {
|
|
842
|
-
if (!isOpen(client.ws)) break;
|
|
843
|
-
this.sendClientBinary(host, client, chunk);
|
|
844
|
-
}
|
|
845
|
-
// Geometry may now be smaller than it was; the resize doubles as the
|
|
846
|
-
// repaint that puts the newcomer on the same screen as everyone else.
|
|
847
|
-
this.syncDimensions(host, { force: true });
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
/**
|
|
851
|
-
* Push the shared grid to the workstation.
|
|
852
|
-
*
|
|
853
|
-
* `force` asks for a repaint even when the numbers did not move: a browser
|
|
854
|
-
* that just joined needs the program to draw itself again, and a resize is
|
|
855
|
-
* the only signal a PTY has for "paint everything".
|
|
856
|
-
*/
|
|
857
|
-
syncDimensions(host, { force = false } = {}) {
|
|
858
|
-
const session = host.session;
|
|
859
|
-
if (!session) return;
|
|
860
|
-
const dims = this.sharedDimensions(host);
|
|
861
|
-
if (!dims) return;
|
|
862
|
-
const changed = dims.cols !== session.cols || dims.rows !== session.rows;
|
|
863
|
-
session.cols = dims.cols;
|
|
864
|
-
session.rows = dims.rows;
|
|
865
|
-
if (!changed && !force) return;
|
|
866
|
-
// Every window has to be told what the shared grid became, not just the
|
|
867
|
-
// workstation. A browser that keeps rendering at its own width would wrap
|
|
868
|
-
// a stream written for a narrower terminal, which is the one thing a
|
|
869
|
-
// shared session must not do: the same bytes have to look the same in
|
|
870
|
-
// every window.
|
|
871
|
-
this.broadcastToClients(host, () => ({
|
|
872
|
-
type: 'shared_resize',
|
|
873
|
-
cols: dims.cols,
|
|
874
|
-
rows: dims.rows,
|
|
875
|
-
}));
|
|
876
|
-
if (!changed && force) {
|
|
877
|
-
// A no-op resize is ignored by the PTY, so bounce one row and come back.
|
|
878
|
-
jsonSend(host.ws, {
|
|
879
|
-
type: 'resize',
|
|
880
|
-
clientId: session.streamId,
|
|
881
|
-
streamId: session.streamId,
|
|
882
|
-
cols: dims.cols,
|
|
883
|
-
rows: Math.max(MIN_SHARED_ROWS, dims.rows - 1),
|
|
884
786
|
});
|
|
885
787
|
}
|
|
886
|
-
jsonSend(host.ws, {
|
|
887
|
-
type: 'resize',
|
|
888
|
-
clientId: session.streamId,
|
|
889
|
-
streamId: session.streamId,
|
|
890
|
-
cols: dims.cols,
|
|
891
|
-
rows: dims.rows,
|
|
892
|
-
});
|
|
893
788
|
}
|
|
894
789
|
|
|
895
790
|
handleClientConnection(ws, req) {
|
|
@@ -915,12 +810,9 @@ class RelayServer {
|
|
|
915
810
|
const host = this.hosts.get(device.hostId);
|
|
916
811
|
if (!host || host.reconnecting || !isOpen(host.ws)) return this.rejectHandshake(ws, 'paired Herdr host is offline', host?.reconnecting ? 'host_reconnecting' : 'host_offline');
|
|
917
812
|
|
|
918
|
-
// Two tabs of one browser are
|
|
919
|
-
// rivals. Nothing is retired here:
|
|
920
|
-
//
|
|
921
|
-
// evict each other in a loop that never converged — each eviction
|
|
922
|
-
// triggered the other tab's auto-reconnect, which evicted this one
|
|
923
|
-
// back, forever.
|
|
813
|
+
// Two tabs of one browser are separate windows with their own PTY sessions,
|
|
814
|
+
// not rivals. Nothing is retired here: each connection gets its own stream
|
|
815
|
+
// without evicting existing clients.
|
|
924
816
|
if (host.clients.size >= this.config.relay.maxClientsPerHost) return this.rejectHandshake(ws, 'host client limit reached', 'too_many_clients');
|
|
925
817
|
clearTimeout(deadline);
|
|
926
818
|
this.finishHandshake(ws);
|
|
@@ -934,9 +826,10 @@ class RelayServer {
|
|
|
934
826
|
// same browser rather than a genuinely separate viewer.
|
|
935
827
|
browserClientId: typeof message.clientId === 'string' ? message.clientId.slice(0, 128) : null,
|
|
936
828
|
handoffCapable: Array.isArray(message.capabilities) && message.capabilities.includes('host_handoff'),
|
|
937
|
-
|
|
938
|
-
//
|
|
939
|
-
//
|
|
829
|
+
session: null,
|
|
830
|
+
// Every paired window gets its own interactive PTY session. Pairing
|
|
831
|
+
// is the permission boundary; once a device is through it, every
|
|
832
|
+
// window can type into its own terminal.
|
|
940
833
|
role: 'controller',
|
|
941
834
|
controllerId: null,
|
|
942
835
|
connectedAt: new Date().toISOString(),
|
|
@@ -985,7 +878,7 @@ class RelayServer {
|
|
|
985
878
|
terminalPalette: host.terminalPalette || null,
|
|
986
879
|
clientCount: host.clients.size,
|
|
987
880
|
});
|
|
988
|
-
this.
|
|
881
|
+
this.startSession(host, client);
|
|
989
882
|
this.broadcastControlState(host);
|
|
990
883
|
return;
|
|
991
884
|
}
|
|
@@ -1004,11 +897,10 @@ class RelayServer {
|
|
|
1004
897
|
if (!host) return this.detachClient(client, { notify: true, reason: 'host_offline' });
|
|
1005
898
|
if (isBinary) {
|
|
1006
899
|
if (raw.length > this.config.relay.maxPayloadBytes) return;
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
//
|
|
1010
|
-
|
|
1011
|
-
const frame = packStreamFrame('input', session.streamId, raw);
|
|
900
|
+
if (!client.session) return;
|
|
901
|
+
// Each window owns its own PTY session, so input is stamped with the
|
|
902
|
+
// client's dedicated stream id.
|
|
903
|
+
const frame = packStreamFrame('input', client.session.streamId, raw);
|
|
1012
904
|
if (isOpen(host.ws)) {
|
|
1013
905
|
try {
|
|
1014
906
|
host.ws.send(frame);
|
|
@@ -1029,11 +921,23 @@ class RelayServer {
|
|
|
1029
921
|
client.lastPingAt = new Date().toISOString();
|
|
1030
922
|
jsonSend(client.ws, { type: 'pong' });
|
|
1031
923
|
} else if (message.type === 'resize') {
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
924
|
+
// Each client owns its own PTY session, so resizing directly forwards
|
|
925
|
+
// the new geometry for this client's stream to the host.
|
|
1034
926
|
client.cols = clampDimension(message.cols, client.cols);
|
|
1035
927
|
client.rows = clampDimension(message.rows, client.rows);
|
|
1036
|
-
|
|
928
|
+
if (client.session) {
|
|
929
|
+
client.session.cols = client.cols;
|
|
930
|
+
client.session.rows = client.rows;
|
|
931
|
+
if (isOpen(host.ws)) {
|
|
932
|
+
jsonSend(host.ws, {
|
|
933
|
+
type: 'resize',
|
|
934
|
+
clientId: client.session.streamId,
|
|
935
|
+
streamId: client.session.streamId,
|
|
936
|
+
cols: Math.max(MIN_SESSION_COLS, client.cols),
|
|
937
|
+
rows: Math.max(MIN_SESSION_ROWS, client.rows),
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
}
|
|
1037
941
|
} else if (message.type === 'claim_control') {
|
|
1038
942
|
// Control is no longer a lease. Answering the old request keeps clients
|
|
1039
943
|
// built against the previous protocol working.
|
|
@@ -1089,26 +993,22 @@ class RelayServer {
|
|
|
1089
993
|
if (!client || !this.clients.has(client.id)) return;
|
|
1090
994
|
this.clients.delete(client.id);
|
|
1091
995
|
const host = this.hosts.get(client.hostId);
|
|
996
|
+
// Each window has its own PTY session. Tearing down the client immediately
|
|
997
|
+
// stops its backing session on the host and cleans up its stream mapping.
|
|
998
|
+
if (client.session) {
|
|
999
|
+
const streamId = client.session.streamId;
|
|
1000
|
+
this.streams.delete(streamId);
|
|
1001
|
+
if (host && isOpen(host.ws)) {
|
|
1002
|
+
jsonSend(host.ws, { type: 'session_stop', clientId: streamId, streamId });
|
|
1003
|
+
}
|
|
1004
|
+
client.session = null;
|
|
1005
|
+
}
|
|
1092
1006
|
if (host) {
|
|
1093
1007
|
host.clients.delete(client.id);
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
// other tabs are still watching.
|
|
1008
|
+
this.notifyHostClientCount(host);
|
|
1009
|
+
this.broadcastControlState(host);
|
|
1097
1010
|
if (host.clients.size === 0) {
|
|
1098
|
-
if (host.session) {
|
|
1099
|
-
if (isOpen(host.ws)) {
|
|
1100
|
-
jsonSend(host.ws, { type: 'session_stop', clientId: host.session.streamId, streamId: host.session.streamId });
|
|
1101
|
-
}
|
|
1102
|
-
host.session = null;
|
|
1103
|
-
}
|
|
1104
1011
|
host.controllerId = null;
|
|
1105
|
-
} else {
|
|
1106
|
-
this.notifyHostClientCount(host);
|
|
1107
|
-
this.syncDimensions(host);
|
|
1108
|
-
this.broadcastControlState(host);
|
|
1109
|
-
}
|
|
1110
|
-
if (host.clients.size === 0) {
|
|
1111
|
-
this.notifyHostClientCount(host);
|
|
1112
1012
|
if (host.reconnecting) this.detachHost(host, { notify: false, reason: 'no_clients' });
|
|
1113
1013
|
}
|
|
1114
1014
|
}
|
|
@@ -1126,6 +1026,10 @@ class RelayServer {
|
|
|
1126
1026
|
const client = this.clients.get(clientId);
|
|
1127
1027
|
if (!client) continue;
|
|
1128
1028
|
this.clients.delete(client.id);
|
|
1029
|
+
if (client.session) {
|
|
1030
|
+
this.streams.delete(client.session.streamId);
|
|
1031
|
+
client.session = null;
|
|
1032
|
+
}
|
|
1129
1033
|
if (notify) jsonSend(client.ws, { type: 'error', code: reason, message: 'Herdr host disconnected' });
|
|
1130
1034
|
closeSocket(client.ws, 1012, reason);
|
|
1131
1035
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
|
3
|
+
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
|
4
|
+
* https://github.com/chjj/term.js
|
|
5
|
+
* @license MIT
|
|
6
|
+
*
|
|
7
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
* in the Software without restriction, including without limitation the rights
|
|
10
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
* furnished to do so, subject to the following conditions:
|
|
13
|
+
*
|
|
14
|
+
* The above copyright notice and this permission notice shall be included in
|
|
15
|
+
* all copies or substantial portions of the Software.
|
|
16
|
+
*
|
|
17
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
23
|
+
* THE SOFTWARE.
|
|
24
|
+
*
|
|
25
|
+
* Originally forked from (with the author's permission):
|
|
26
|
+
* Fabrice Bellard's javascript vt100 for jslinux:
|
|
27
|
+
* http://bellard.org/jslinux/
|
|
28
|
+
* Copyright (c) 2011 Fabrice Bellard
|
|
29
|
+
* The original design remains. The terminal itself
|
|
30
|
+
* has been extended to include xterm CSI codes, among
|
|
31
|
+
* other features.
|
|
32
|
+
*/.xterm{cursor:text;position:relative;-moz-user-select:none;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) *::-moz-selection{color:transparent}.xterm .xterm-accessibility-tree:not(.debug) *::selection{color:transparent}.xterm .xterm-accessibility-tree{-webkit-user-select:text;-moz-user-select:text;user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:double underline;text-decoration:double underline}.xterm-underline-3{-webkit-text-decoration:wavy underline;text-decoration:wavy underline}.xterm-underline-4{-webkit-text-decoration:dotted underline;text-decoration:dotted underline}.xterm-underline-5{-webkit-text-decoration:dashed underline;text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Sarasa Mono SC,Noto Sans Mono CJK SC,Noto Sans Mono CJK TC,Microsoft YaHei Mono,PingFang SC,monospace;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Sarasa Mono SC,Noto Sans Mono CJK SC,Noto Sans Mono CJK TC,Microsoft YaHei Mono,PingFang SC,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media(min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media(min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media(min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media(min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media(min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{left:0;right:0}.inset-y-0{top:0;bottom:0}.-top-\[10px\]{top:-10px}.bottom-0{bottom:0}.bottom-11{bottom:2.75rem}.bottom-auto{bottom:auto}.bottom-full{bottom:100%}.left-0{left:0}.left-1{left:.25rem}.right-1{right:.25rem}.right-2{right:.5rem}.top-0{top:0}.top-1{top:.25rem}.top-1\/2{top:50%}.top-12{top:3rem}.top-full{top:100%}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[100\]{z-index:100}.m-0{margin:0}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.mx-0\.5{margin-left:.125rem;margin-right:.125rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-auto{margin-top:auto;margin-bottom:auto}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-px{margin-top:1px}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1{height:.25rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-4{height:1rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[100dvh\]{height:100dvh}.h-\[3px\]{height:3px}.h-\[var\(--tui-row\)\]{height:var(--tui-row)}.h-full{height:100%}.h-px{height:1px}.max-h-48{max-height:12rem}.max-h-72{max-height:18rem}.max-h-\[85\%\]{max-height:85%}.max-h-\[min\(70vh\,24rem\)\]{max-height:min(70vh,24rem)}.min-h-0{min-height:0px}.min-h-11{min-height:2.75rem}.min-h-\[24px\]{min-height:24px}.min-h-\[var\(--tui-row\)\]{min-height:var(--tui-row)}.w-28{width:7rem}.w-3{width:.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-\[calc\(100vw-16px\)\]{width:calc(100vw - 16px)}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.w-screen{width:100vw}.min-w-0{min-width:0px}.min-w-11{min-width:2.75rem}.min-w-\[2\.25rem\]{min-width:2.25rem}.min-w-\[2ch\]{min-width:2ch}.max-w-3xl{max-width:48rem}.max-w-\[18rem\]{max-width:18rem}.max-w-\[60\%\]{max-width:60%}.max-w-\[8rem\]{max-width:8rem}.max-w-\[calc\(100vw-1rem\)\]{max-width:calc(100vw - 1rem)}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-text{-webkit-user-select:text;-moz-user-select:text;user-select:text}.resize{resize:both}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-y-0\.5{row-gap:.125rem}.gap-y-1{row-gap:.25rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-2\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.625rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.625rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-tui-border-dim>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(49 50 68 / var(--tw-divide-opacity, 1))}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:0}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.border-tui-accent{--tw-border-opacity: 1;border-color:rgb(137 180 250 / var(--tw-border-opacity, 1))}.border-tui-bad{--tw-border-opacity: 1;border-color:rgb(243 139 168 / var(--tw-border-opacity, 1))}.border-tui-border{--tw-border-opacity: 1;border-color:rgb(69 71 90 / var(--tw-border-opacity, 1))}.border-tui-border-dim{--tw-border-opacity: 1;border-color:rgb(49 50 68 / var(--tw-border-opacity, 1))}.border-tui-ok{--tw-border-opacity: 1;border-color:rgb(166 227 161 / var(--tw-border-opacity, 1))}.border-tui-warn{--tw-border-opacity: 1;border-color:rgb(249 226 175 / var(--tw-border-opacity, 1))}.border-b-tui-accent{--tw-border-opacity: 1;border-bottom-color:rgb(137 180 250 / var(--tw-border-opacity, 1))}.border-b-tui-alt{--tw-border-opacity: 1;border-bottom-color:rgb(203 166 247 / var(--tw-border-opacity, 1))}.border-b-tui-bad{--tw-border-opacity: 1;border-bottom-color:rgb(243 139 168 / var(--tw-border-opacity, 1))}.border-b-tui-border{--tw-border-opacity: 1;border-bottom-color:rgb(69 71 90 / var(--tw-border-opacity, 1))}.border-b-tui-info{--tw-border-opacity: 1;border-bottom-color:rgb(148 226 213 / var(--tw-border-opacity, 1))}.border-b-tui-ok{--tw-border-opacity: 1;border-bottom-color:rgb(166 227 161 / var(--tw-border-opacity, 1))}.border-b-tui-warn{--tw-border-opacity: 1;border-bottom-color:rgb(249 226 175 / var(--tw-border-opacity, 1))}.border-l-tui-accent{--tw-border-opacity: 1;border-left-color:rgb(137 180 250 / var(--tw-border-opacity, 1))}.border-l-tui-alt{--tw-border-opacity: 1;border-left-color:rgb(203 166 247 / var(--tw-border-opacity, 1))}.border-l-tui-bad{--tw-border-opacity: 1;border-left-color:rgb(243 139 168 / var(--tw-border-opacity, 1))}.border-l-tui-border{--tw-border-opacity: 1;border-left-color:rgb(69 71 90 / var(--tw-border-opacity, 1))}.border-l-tui-info{--tw-border-opacity: 1;border-left-color:rgb(148 226 213 / var(--tw-border-opacity, 1))}.border-l-tui-ok{--tw-border-opacity: 1;border-left-color:rgb(166 227 161 / var(--tw-border-opacity, 1))}.border-l-tui-warn{--tw-border-opacity: 1;border-left-color:rgb(249 226 175 / var(--tw-border-opacity, 1))}.bg-transparent{background-color:transparent}.bg-tui-accent{--tw-bg-opacity: 1;background-color:rgb(137 180 250 / var(--tw-bg-opacity, 1))}.bg-tui-alt{--tw-bg-opacity: 1;background-color:rgb(203 166 247 / var(--tw-bg-opacity, 1))}.bg-tui-bad{--tw-bg-opacity: 1;background-color:rgb(243 139 168 / var(--tw-bg-opacity, 1))}.bg-tui-base{--tw-bg-opacity: 1;background-color:rgb(30 30 46 / var(--tw-bg-opacity, 1))}.bg-tui-border{--tw-bg-opacity: 1;background-color:rgb(69 71 90 / var(--tw-bg-opacity, 1))}.bg-tui-border-dim{--tw-bg-opacity: 1;background-color:rgb(49 50 68 / var(--tw-bg-opacity, 1))}.bg-tui-crust{--tw-bg-opacity: 1;background-color:rgb(17 17 27 / var(--tw-bg-opacity, 1))}.bg-tui-crust\/70{background-color:#11111bb3}.bg-tui-crust\/85{background-color:#11111bd9}.bg-tui-crust\/90{background-color:#11111be6}.bg-tui-info{--tw-bg-opacity: 1;background-color:rgb(148 226 213 / var(--tw-bg-opacity, 1))}.bg-tui-mantle{--tw-bg-opacity: 1;background-color:rgb(24 24 37 / var(--tw-bg-opacity, 1))}.bg-tui-ok{--tw-bg-opacity: 1;background-color:rgb(166 227 161 / var(--tw-bg-opacity, 1))}.bg-tui-selection,.bg-tui-surface{--tw-bg-opacity: 1;background-color:rgb(49 50 68 / var(--tw-bg-opacity, 1))}.bg-tui-warn{--tw-bg-opacity: 1;background-color:rgb(249 226 175 / var(--tw-bg-opacity, 1))}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.px-0\.5{padding-left:.125rem;padding-right:.125rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-0\.5{padding-bottom:.125rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pb-\[max\(env\(safe-area-inset-bottom\,0px\)\,0\.125rem\)\]{padding-bottom:max(env(safe-area-inset-bottom,0px),.125rem)}.pb-\[max\(env\(safe-area-inset-bottom\,0px\)\,0\.25rem\)\]{padding-bottom:max(env(safe-area-inset-bottom,0px),.25rem)}.pr-7{padding-right:1.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-\[max\(env\(safe-area-inset-top\,0px\)\,0\.125rem\)\]{padding-top:max(env(safe-area-inset-top,0px),.125rem)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Sarasa Mono SC,Noto Sans Mono CJK SC,Noto Sans Mono CJK TC,Microsoft YaHei Mono,PingFang SC,monospace}.text-tui{font-size:13px;line-height:20px}.text-tui-sm{font-size:12px;line-height:18px}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.not-italic{font-style:normal}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.text-transparent{color:transparent}.text-tui-accent{--tw-text-opacity: 1;color:rgb(137 180 250 / var(--tw-text-opacity, 1))}.text-tui-alt{--tw-text-opacity: 1;color:rgb(203 166 247 / var(--tw-text-opacity, 1))}.text-tui-bad{--tw-text-opacity: 1;color:rgb(243 139 168 / var(--tw-text-opacity, 1))}.text-tui-border{--tw-text-opacity: 1;color:rgb(69 71 90 / var(--tw-text-opacity, 1))}.text-tui-crust{--tw-text-opacity: 1;color:rgb(17 17 27 / var(--tw-text-opacity, 1))}.text-tui-faint{--tw-text-opacity: 1;color:rgb(108 112 134 / var(--tw-text-opacity, 1))}.text-tui-info{--tw-text-opacity: 1;color:rgb(148 226 213 / var(--tw-text-opacity, 1))}.text-tui-muted{--tw-text-opacity: 1;color:rgb(166 173 200 / var(--tw-text-opacity, 1))}.text-tui-ok{--tw-text-opacity: 1;color:rgb(166 227 161 / var(--tw-text-opacity, 1))}.text-tui-text{--tw-text-opacity: 1;color:rgb(205 214 244 / var(--tw-text-opacity, 1))}.text-tui-warn{--tw-text-opacity: 1;color:rgb(249 226 175 / var(--tw-text-opacity, 1))}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-tui-accent{accent-color:#89b4fa}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.mix-blend-difference{mix-blend-mode:difference}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.ring{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.blur{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.invert{--tw-invert: invert(100%);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{color-scheme:dark;--safe-top: env(safe-area-inset-top, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);--safe-left: env(safe-area-inset-left, 0px);--safe-right: env(safe-area-inset-right, 0px);--tui-crust: #11111b;--tui-mantle: #181825;--tui-base: #1e1e2e;--tui-selection: #313244;--tui-border: #45475a;--tui-border-bright: #585b70;--tui-text: #cdd6f4;--tui-muted: #a6adc8;--tui-faint: #6c7086;--tui-accent: #89b4fa;--tui-ok: #a6e3a1;--tui-warn: #f9e2af;--tui-bad: #f38ba8;--tui-row: 20px;--tui-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Sarasa Mono SC", "Noto Sans Mono CJK SC", "Noto Sans Mono CJK TC", "Microsoft YaHei Mono", "PingFang SC", monospace}html.dark{color-scheme:dark}html,body{margin:0;padding:0;width:100%;height:100%;height:100dvh;height:var(--app-height, 100dvh);overflow:hidden;overscroll-behavior:none;background-color:var(--tui-crust);color:var(--tui-text);font-family:var(--tui-font);font-size:13px;line-height:20px;font-variant-ligatures:none;letter-spacing:0;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;touch-action:manipulation}#root{width:100%;height:100%;height:100dvh;height:var(--app-height, 100dvh);display:flex;flex-direction:column;overflow:hidden;background-color:var(--tui-crust)}fieldset.tui-panel{min-inline-size:0}.tui-panel>legend{padding-inline:.375rem;margin-inline-start:.25rem}.tui-corners,.tui-block{position:relative}.tui-corners:before,.tui-corners:after,.tui-block:before,.tui-block:after{position:absolute;color:var(--tui-border);font-size:inherit;line-height:1;pointer-events:none}.tui-corners:before,.tui-block:before{content:"┌";top:-1px;left:-1px}.tui-corners:after,.tui-block:after{content:"┘";bottom:-1px;right:-1px;transform:translate(.03em,.08em)}.tui-caret:after{content:"█";color:var(--tui-accent);animation:tui-caret-blink 1s step-end infinite}@keyframes tui-caret-blink{0%,49%{opacity:1}50%,to{opacity:0}}@media(prefers-reduced-motion:reduce){.tui-caret:after{animation:none}}.tui-input{-moz-appearance:none;appearance:none;-webkit-appearance:none;border-radius:0;background-color:var(--tui-mantle);color:var(--tui-text);border:1px solid var(--tui-border);font-family:inherit;font-size:inherit;line-height:inherit;letter-spacing:0;caret-color:var(--tui-accent)}@media screen and (pointer:coarse){.tui-input{font-size:16px}}.tui-input::-moz-placeholder{color:var(--tui-faint)}.tui-input::placeholder{color:var(--tui-faint)}.tui-input:focus{outline:none;border-color:var(--tui-accent);background-color:var(--tui-selection)}.tui-input:disabled{color:var(--tui-faint);cursor:not-allowed}select.tui-input{background-image:none;padding-right:1.75rem}select.tui-input option{background-color:var(--tui-base);color:var(--tui-text)}.tui-focusable:focus-visible{outline:1px solid var(--tui-accent);outline-offset:-1px}.xterm{height:100%;width:100%;padding:0}.xterm .xterm-viewport{overflow-y:scroll!important}@media(pointer:coarse){#terminal-container,#terminal-surface .xterm,#terminal-surface .xterm .xterm-viewport{touch-action:none}#terminal-surface .xterm .xterm-viewport{-webkit-overflow-scrolling:touch;overscroll-behavior-y:contain}#terminal-surface .xterm .xterm-viewport{overflow-y:auto!important;overflow-x:hidden!important;scrollbar-width:none}#terminal-surface .xterm .xterm-viewport::-webkit-scrollbar{width:0;height:0}#terminal-surface .xterm-screen{margin-inline:auto}}#terminal-surface{width:100%;height:100%}#terminal-surface,#terminal-surface .xterm,#terminal-surface .xterm-screen{min-width:1px;min-height:1px}@media(pointer:coarse){#terminal-surface .xterm .xterm-rows{-moz-user-select:none;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none}.xterm .xterm-helper-textarea{left:0;top:0;min-width:1px;min-height:1px;font-size:16px;opacity:0;pointer-events:none;caret-color:transparent}}@keyframes herdr-sheet-in{0%{transform:translateY(100%)}to{transform:translateY(0)}}.herdr-sheet{animation:herdr-sheet-in .14s steps(6,end);overscroll-behavior:contain}@media(prefers-reduced-motion:reduce){.herdr-sheet{animation:none}}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--tui-mantle)}::-webkit-scrollbar-thumb{background:var(--tui-border);border-radius:0}::-webkit-scrollbar-thumb:hover{background:var(--tui-border-bright)}*{scrollbar-width:thin;scrollbar-color:var(--tui-border) var(--tui-mantle)}.scrollbar-none::-webkit-scrollbar{display:none}.scrollbar-none{-ms-overflow-style:none;scrollbar-width:none}::-moz-selection{background:var(--tui-accent);color:var(--tui-crust)}::selection{background:var(--tui-accent);color:var(--tui-crust)}.last\:border-b-0:last-child{border-bottom-width:0px}.hover\:border-tui-accent:hover{--tw-border-opacity: 1;border-color:rgb(137 180 250 / var(--tw-border-opacity, 1))}.hover\:border-tui-border:hover{--tw-border-opacity: 1;border-color:rgb(69 71 90 / var(--tw-border-opacity, 1))}.hover\:border-tui-border-bright:hover{--tw-border-opacity: 1;border-color:rgb(88 91 112 / var(--tw-border-opacity, 1))}.hover\:bg-tui-accent:hover{--tw-bg-opacity: 1;background-color:rgb(137 180 250 / var(--tw-bg-opacity, 1))}.hover\:bg-tui-bad:hover{--tw-bg-opacity: 1;background-color:rgb(243 139 168 / var(--tw-bg-opacity, 1))}.hover\:bg-tui-ok:hover{--tw-bg-opacity: 1;background-color:rgb(166 227 161 / var(--tw-bg-opacity, 1))}.hover\:bg-tui-selection:hover{--tw-bg-opacity: 1;background-color:rgb(49 50 68 / var(--tw-bg-opacity, 1))}.hover\:bg-tui-warn:hover{--tw-bg-opacity: 1;background-color:rgb(249 226 175 / var(--tw-bg-opacity, 1))}.hover\:text-tui-accent:hover{--tw-text-opacity: 1;color:rgb(137 180 250 / var(--tw-text-opacity, 1))}.hover\:text-tui-bad:hover{--tw-text-opacity: 1;color:rgb(243 139 168 / var(--tw-text-opacity, 1))}.hover\:text-tui-crust:hover{--tw-text-opacity: 1;color:rgb(17 17 27 / var(--tw-text-opacity, 1))}.hover\:text-tui-text:hover{--tw-text-opacity: 1;color:rgb(205 214 244 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:bg-tui-accent-dim:active{--tw-bg-opacity: 1;background-color:rgb(116 199 236 / var(--tw-bg-opacity, 1))}.active\:bg-tui-bad:active{--tw-bg-opacity: 1;background-color:rgb(243 139 168 / var(--tw-bg-opacity, 1))}.active\:bg-tui-selection:active{--tw-bg-opacity: 1;background-color:rgb(49 50 68 / var(--tw-bg-opacity, 1))}.active\:bg-tui-warn:active{--tw-bg-opacity: 1;background-color:rgb(249 226 175 / var(--tw-bg-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:border-tui-border-dim:disabled{--tw-border-opacity: 1;border-color:rgb(49 50 68 / var(--tw-border-opacity, 1))}.disabled\:text-tui-faint:disabled{--tw-text-opacity: 1;color:rgb(108 112 134 / var(--tw-text-opacity, 1))}.disabled\:hover\:bg-transparent:hover:disabled{background-color:transparent}.group:hover .group-hover\:text-tui-accent{--tw-text-opacity: 1;color:rgb(137 180 250 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:p-4{padding:1rem}.sm\:p-6{padding:1.5rem}.sm\:px-3{padding-left:.75rem;padding-right:.75rem}}@media(min-width:768px){.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}
|