tabby-rdp 0.1.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/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/desk.js +86 -0
- package/dist/desk.js.map +1 -0
- package/dist/deskScript.js +44 -0
- package/dist/deskScript.js.map +1 -0
- package/dist/desktop.service.js +665 -0
- package/dist/desktop.service.js.map +1 -0
- package/dist/desktopForm.js +96 -0
- package/dist/desktopForm.js.map +1 -0
- package/dist/desktops.js +45 -0
- package/dist/desktops.js.map +1 -0
- package/dist/header.js +164 -0
- package/dist/header.js.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/rdcleanpath.js +255 -0
- package/dist/rdcleanpath.js.map +1 -0
- package/dist/remoteSetup.js +188 -0
- package/dist/remoteSetup.js.map +1 -0
- package/dist/signin.js +114 -0
- package/dist/signin.js.map +1 -0
- package/dist/ssh.js +54 -0
- package/dist/ssh.js.map +1 -0
- package/dist/sshChannelStream.js +34 -0
- package/dist/sshChannelStream.js.map +1 -0
- package/dist/targets.js +247 -0
- package/dist/targets.js.map +1 -0
- package/dist/ui.js +212 -0
- package/dist/ui.js.map +1 -0
- package/package.json +41 -0
- package/remote/trd-pty.py +561 -0
- package/vendor/IRONRDP-LICENSE +27 -0
- package/vendor/iron-remote-desktop-rdp.js +2878 -0
- package/vendor/iron-remote-desktop.js +4136 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""trd-pty: a shell session that outlives its terminals and can be shown in several at once.
|
|
3
|
+
|
|
4
|
+
A session is one small process that owns a PTY running the user's login shell and listens on a Unix
|
|
5
|
+
socket in a private per-user directory (0700; peers must have the same uid). Terminals attach with
|
|
6
|
+
`trd-pty attach`:
|
|
7
|
+
|
|
8
|
+
- output from the shell goes, byte for byte, to every attached terminal;
|
|
9
|
+
- input from any attached terminal goes to the shell;
|
|
10
|
+
- the PTY size follows the *controlling* terminal: the one that attached, typed or was focused last,
|
|
11
|
+
so terminals of different sizes never fight over it;
|
|
12
|
+
- a terminal that attaches late first gets a bounded replay of recent output, then a forced redraw
|
|
13
|
+
(full-screen programs repaint on the size change).
|
|
14
|
+
|
|
15
|
+
Lifetime matches a plain SSH login: the shell gets SIGHUP when its last terminal detaches (set
|
|
16
|
+
TRD_PTY_LINGER=<seconds> to keep it around for a reattach), and the session ends when the shell exits.
|
|
17
|
+
|
|
18
|
+
trd-pty new start a session running the login shell and attach this terminal
|
|
19
|
+
(exit 0: the session ended; 3: no session could be started)
|
|
20
|
+
trd-pty attach ID attach this terminal to session ID
|
|
21
|
+
trd-pty ls list sessions (id, attached terminals, shell pid, cwd)
|
|
22
|
+
trd-pty clients ID print how many terminals are attached to session ID
|
|
23
|
+
"""
|
|
24
|
+
import errno
|
|
25
|
+
import fcntl
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import pty
|
|
29
|
+
import secrets
|
|
30
|
+
import selectors
|
|
31
|
+
import signal
|
|
32
|
+
import socket
|
|
33
|
+
import struct
|
|
34
|
+
import sys
|
|
35
|
+
import termios
|
|
36
|
+
import time
|
|
37
|
+
import tty
|
|
38
|
+
|
|
39
|
+
REPLAY_BYTES = 256 * 1024 # recent output replayed to a terminal that attaches late
|
|
40
|
+
MAX_FRAME = 1024 * 1024 # larger frames are treated as a broken client
|
|
41
|
+
MAX_BACKLOG = 8 * 1024 * 1024 # a terminal this far behind is dropped rather than stalling the others
|
|
42
|
+
QUIET_AFTER_REPLAY = 0.3 # seconds: drop input right after a replay (the terminal's automatic
|
|
43
|
+
# answers to replayed queries, e.g. cursor position reports)
|
|
44
|
+
HEADER = struct.Struct('!cI')
|
|
45
|
+
SIZE = struct.Struct('!HH')
|
|
46
|
+
|
|
47
|
+
# client -> session: h hello(rows, cols, claim) | i input | r resize(rows, cols) | c claim control | q query
|
|
48
|
+
# session -> client: o output | x exit(status) | q query answer (json)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def runtime_dir():
|
|
52
|
+
"""Private per-user directory for session sockets; refuses anything another user could touch."""
|
|
53
|
+
uid = os.getuid()
|
|
54
|
+
base = os.environ.get('XDG_RUNTIME_DIR')
|
|
55
|
+
if not base or not os.path.isdir(base) or os.stat(base).st_uid != uid:
|
|
56
|
+
base = '/tmp'
|
|
57
|
+
path = os.path.join(base, 'trd-pty' if base != '/tmp' else f'trd-pty-{uid}')
|
|
58
|
+
try:
|
|
59
|
+
os.mkdir(path, 0o700)
|
|
60
|
+
except FileExistsError:
|
|
61
|
+
pass
|
|
62
|
+
st = os.lstat(path)
|
|
63
|
+
if not os.path.isdir(path) or os.path.islink(path) or st.st_uid != uid or st.st_mode & 0o077:
|
|
64
|
+
sys.exit(f'trd-pty: unsafe session directory {path}')
|
|
65
|
+
return path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def socket_path(session_id):
|
|
69
|
+
if not session_id or not all(c in '0123456789abcdef' for c in session_id):
|
|
70
|
+
sys.exit(f'trd-pty: bad session id {session_id!r}')
|
|
71
|
+
return os.path.join(runtime_dir(), session_id + '.sock')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def frame(kind, payload=b''):
|
|
75
|
+
return HEADER.pack(kind, len(payload)) + payload
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Frames:
|
|
79
|
+
"""Splits a byte stream into (kind, payload) frames."""
|
|
80
|
+
|
|
81
|
+
def __init__(self):
|
|
82
|
+
self.buf = bytearray()
|
|
83
|
+
|
|
84
|
+
def feed(self, data):
|
|
85
|
+
self.buf += data
|
|
86
|
+
frames = []
|
|
87
|
+
while len(self.buf) >= HEADER.size:
|
|
88
|
+
kind, length = HEADER.unpack_from(self.buf)
|
|
89
|
+
if length > MAX_FRAME:
|
|
90
|
+
raise ValueError('frame too large')
|
|
91
|
+
if len(self.buf) < HEADER.size + length:
|
|
92
|
+
break
|
|
93
|
+
frames.append((kind, bytes(self.buf[HEADER.size:HEADER.size + length])))
|
|
94
|
+
del self.buf[:HEADER.size + length]
|
|
95
|
+
return frames
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def term_size(fd):
|
|
99
|
+
try:
|
|
100
|
+
rows, cols, _, _ = struct.unpack('HHHH', fcntl.ioctl(fd, termios.TIOCGWINSZ, b'\0' * 8))
|
|
101
|
+
return rows or 24, cols or 80
|
|
102
|
+
except OSError:
|
|
103
|
+
return 24, 80
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---- session owner -------------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
class Client:
|
|
109
|
+
def __init__(self, sock):
|
|
110
|
+
self.sock = sock
|
|
111
|
+
self.frames = Frames()
|
|
112
|
+
self.out = bytearray()
|
|
113
|
+
self.hello = False
|
|
114
|
+
self.size = (24, 80)
|
|
115
|
+
self.quiet_until = 0.0
|
|
116
|
+
self.active = time.monotonic()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Session:
|
|
120
|
+
def __init__(self, session_id, path, rows, cols, cwd, env):
|
|
121
|
+
self.id = session_id
|
|
122
|
+
self.path = path
|
|
123
|
+
self.sel = selectors.DefaultSelector()
|
|
124
|
+
self.clients = {}
|
|
125
|
+
self.controller = None
|
|
126
|
+
self.replay = bytearray()
|
|
127
|
+
self.to_shell = bytearray()
|
|
128
|
+
self.size = None
|
|
129
|
+
self.linger = float(os.environ.get('TRD_PTY_LINGER', '0') or 0)
|
|
130
|
+
self.empty_since = None
|
|
131
|
+
|
|
132
|
+
shell = env.get('SHELL') or '/bin/sh'
|
|
133
|
+
pid, fd = pty.fork()
|
|
134
|
+
if pid == 0: # the shell
|
|
135
|
+
try:
|
|
136
|
+
os.chdir(cwd)
|
|
137
|
+
except OSError:
|
|
138
|
+
pass
|
|
139
|
+
env = dict(env, TRD_SESSION=session_id, TRD_SOCKET=path)
|
|
140
|
+
os.execve(shell, ['-' + os.path.basename(shell)], env) # login shell, like a fresh SSH login
|
|
141
|
+
self.pid, self.master = pid, fd
|
|
142
|
+
os.set_blocking(fd, False)
|
|
143
|
+
self.apply_size(rows, cols)
|
|
144
|
+
|
|
145
|
+
self.listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
146
|
+
old_umask = os.umask(0o177)
|
|
147
|
+
try:
|
|
148
|
+
self.listener.bind(path)
|
|
149
|
+
finally:
|
|
150
|
+
os.umask(old_umask)
|
|
151
|
+
self.listener.listen(8)
|
|
152
|
+
self.listener.setblocking(False)
|
|
153
|
+
self.sel.register(self.listener, selectors.EVENT_READ, 'accept')
|
|
154
|
+
self.sel.register(self.master, selectors.EVENT_READ, 'pty')
|
|
155
|
+
|
|
156
|
+
# -- pty
|
|
157
|
+
|
|
158
|
+
def apply_size(self, rows, cols, force=False):
|
|
159
|
+
if (rows, cols) == self.size and not force:
|
|
160
|
+
return
|
|
161
|
+
if force and (rows, cols) == self.size and rows > 1:
|
|
162
|
+
# Same size: nudge it so full-screen programs still get SIGWINCH and repaint.
|
|
163
|
+
fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack('HHHH', rows - 1, cols, 0, 0))
|
|
164
|
+
fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
|
|
165
|
+
self.size = (rows, cols)
|
|
166
|
+
|
|
167
|
+
def take_control(self, client, force=False):
|
|
168
|
+
self.controller = client
|
|
169
|
+
self.apply_size(*client.size, force=force)
|
|
170
|
+
|
|
171
|
+
def from_shell(self):
|
|
172
|
+
try:
|
|
173
|
+
data = os.read(self.master, 65536)
|
|
174
|
+
except OSError as e:
|
|
175
|
+
if e.errno in (errno.EAGAIN, errno.EINTR):
|
|
176
|
+
return True
|
|
177
|
+
data = b'' # EIO: the shell side is gone
|
|
178
|
+
if not data:
|
|
179
|
+
return False
|
|
180
|
+
self.replay += data
|
|
181
|
+
if len(self.replay) > REPLAY_BYTES:
|
|
182
|
+
del self.replay[:len(self.replay) - REPLAY_BYTES]
|
|
183
|
+
# Start the replay at a line boundary, not inside an escape sequence or character.
|
|
184
|
+
nl = self.replay.find(b'\n')
|
|
185
|
+
if 0 <= nl < 4096:
|
|
186
|
+
del self.replay[:nl + 1]
|
|
187
|
+
out = frame(b'o', data)
|
|
188
|
+
for client in list(self.clients.values()):
|
|
189
|
+
if client.hello:
|
|
190
|
+
self.send(client, out)
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
def write_shell(self, data):
|
|
194
|
+
self.to_shell += data
|
|
195
|
+
self.flush_shell()
|
|
196
|
+
|
|
197
|
+
def flush_shell(self):
|
|
198
|
+
try:
|
|
199
|
+
while self.to_shell:
|
|
200
|
+
n = os.write(self.master, self.to_shell)
|
|
201
|
+
del self.to_shell[:n]
|
|
202
|
+
except OSError as e:
|
|
203
|
+
if e.errno not in (errno.EAGAIN, errno.EINTR):
|
|
204
|
+
self.to_shell.clear()
|
|
205
|
+
events = selectors.EVENT_READ | (selectors.EVENT_WRITE if self.to_shell else 0)
|
|
206
|
+
self.sel.modify(self.master, events, 'pty')
|
|
207
|
+
|
|
208
|
+
# -- clients
|
|
209
|
+
|
|
210
|
+
def accept(self):
|
|
211
|
+
try:
|
|
212
|
+
sock, _ = self.listener.accept()
|
|
213
|
+
except OSError:
|
|
214
|
+
return
|
|
215
|
+
if hasattr(socket, 'SO_PEERCRED'):
|
|
216
|
+
_, uid, _ = struct.unpack('3i', sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
|
217
|
+
if uid != os.getuid():
|
|
218
|
+
sock.close()
|
|
219
|
+
return
|
|
220
|
+
sock.setblocking(False)
|
|
221
|
+
client = Client(sock)
|
|
222
|
+
self.clients[sock] = client
|
|
223
|
+
self.sel.register(sock, selectors.EVENT_READ, client)
|
|
224
|
+
|
|
225
|
+
def send(self, client, data):
|
|
226
|
+
client.out += data
|
|
227
|
+
if len(client.out) > MAX_BACKLOG:
|
|
228
|
+
self.drop(client)
|
|
229
|
+
return
|
|
230
|
+
self.flush(client)
|
|
231
|
+
|
|
232
|
+
def flush(self, client):
|
|
233
|
+
try:
|
|
234
|
+
while client.out:
|
|
235
|
+
n = client.sock.send(client.out)
|
|
236
|
+
del client.out[:n]
|
|
237
|
+
except BlockingIOError:
|
|
238
|
+
pass
|
|
239
|
+
except OSError:
|
|
240
|
+
self.drop(client)
|
|
241
|
+
return
|
|
242
|
+
if client.sock in self.clients:
|
|
243
|
+
events = selectors.EVENT_READ | (selectors.EVENT_WRITE if client.out else 0)
|
|
244
|
+
self.sel.modify(client.sock, events, client)
|
|
245
|
+
|
|
246
|
+
def drop(self, client):
|
|
247
|
+
if self.clients.pop(client.sock, None) is None:
|
|
248
|
+
return
|
|
249
|
+
try:
|
|
250
|
+
self.sel.unregister(client.sock)
|
|
251
|
+
except (KeyError, ValueError):
|
|
252
|
+
pass
|
|
253
|
+
client.sock.close()
|
|
254
|
+
if self.controller is client:
|
|
255
|
+
self.controller = None
|
|
256
|
+
rest = [c for c in self.clients.values() if c.hello]
|
|
257
|
+
if rest:
|
|
258
|
+
self.take_control(max(rest, key=lambda c: c.active))
|
|
259
|
+
if not any(c.hello for c in self.clients.values()):
|
|
260
|
+
self.empty_since = time.monotonic()
|
|
261
|
+
|
|
262
|
+
def from_client(self, client):
|
|
263
|
+
try:
|
|
264
|
+
data = client.sock.recv(65536)
|
|
265
|
+
except BlockingIOError:
|
|
266
|
+
return
|
|
267
|
+
except OSError:
|
|
268
|
+
data = b''
|
|
269
|
+
if not data:
|
|
270
|
+
self.drop(client)
|
|
271
|
+
return
|
|
272
|
+
try:
|
|
273
|
+
frames = client.frames.feed(data)
|
|
274
|
+
except ValueError:
|
|
275
|
+
self.drop(client)
|
|
276
|
+
return
|
|
277
|
+
for kind, payload in frames:
|
|
278
|
+
try:
|
|
279
|
+
self.handle(client, kind, payload)
|
|
280
|
+
except (struct.error, ValueError):
|
|
281
|
+
self.drop(client) # malformed: this client goes, the session stays
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
def handle(self, client, kind, payload):
|
|
285
|
+
now = time.monotonic()
|
|
286
|
+
if kind == b'q':
|
|
287
|
+
info = {'id': self.id, 'pid': self.pid, 'clients': sum(c.hello for c in self.clients.values())}
|
|
288
|
+
try:
|
|
289
|
+
info['cwd'] = os.readlink(f'/proc/{self.pid}/cwd')
|
|
290
|
+
except OSError:
|
|
291
|
+
pass
|
|
292
|
+
self.send(client, frame(b'q', json.dumps(info).encode()))
|
|
293
|
+
return
|
|
294
|
+
if kind == b'h' and not client.hello:
|
|
295
|
+
rows, cols, claim = struct.unpack('!HHB', payload)
|
|
296
|
+
client.hello = True
|
|
297
|
+
client.size = (max(rows, 1), max(cols, 1))
|
|
298
|
+
client.active = now
|
|
299
|
+
self.empty_since = None
|
|
300
|
+
if self.replay:
|
|
301
|
+
self.send(client, frame(b'o', bytes(self.replay)))
|
|
302
|
+
client.quiet_until = now + QUIET_AFTER_REPLAY
|
|
303
|
+
if claim or self.controller is None:
|
|
304
|
+
self.take_control(client, force=True)
|
|
305
|
+
return
|
|
306
|
+
if not client.hello:
|
|
307
|
+
raise ValueError('frame before hello')
|
|
308
|
+
if kind == b'i':
|
|
309
|
+
if now < client.quiet_until:
|
|
310
|
+
return
|
|
311
|
+
client.active = now
|
|
312
|
+
if self.controller is not client:
|
|
313
|
+
self.take_control(client)
|
|
314
|
+
self.write_shell(payload)
|
|
315
|
+
elif kind == b'r':
|
|
316
|
+
rows, cols = SIZE.unpack(payload)
|
|
317
|
+
client.size = (max(rows, 1), max(cols, 1))
|
|
318
|
+
if self.controller is client:
|
|
319
|
+
self.apply_size(*client.size)
|
|
320
|
+
elif kind == b'c':
|
|
321
|
+
client.active = now
|
|
322
|
+
if self.controller is not client:
|
|
323
|
+
self.take_control(client)
|
|
324
|
+
else:
|
|
325
|
+
raise ValueError('unknown frame')
|
|
326
|
+
|
|
327
|
+
# -- main loop
|
|
328
|
+
|
|
329
|
+
def run(self):
|
|
330
|
+
status = 0
|
|
331
|
+
try:
|
|
332
|
+
while True:
|
|
333
|
+
timeout = None
|
|
334
|
+
if self.empty_since is not None:
|
|
335
|
+
left = self.linger - (time.monotonic() - self.empty_since)
|
|
336
|
+
if left <= 0:
|
|
337
|
+
os.killpg(os.getpgid(self.pid), signal.SIGHUP) # like a dropped SSH connection
|
|
338
|
+
self.empty_since = None
|
|
339
|
+
else:
|
|
340
|
+
timeout = left
|
|
341
|
+
for key, events in self.sel.select(timeout):
|
|
342
|
+
if key.data == 'accept':
|
|
343
|
+
self.accept()
|
|
344
|
+
elif key.data == 'pty':
|
|
345
|
+
if events & selectors.EVENT_WRITE:
|
|
346
|
+
self.flush_shell()
|
|
347
|
+
if events & selectors.EVENT_READ and not self.from_shell():
|
|
348
|
+
raise StopIteration
|
|
349
|
+
else:
|
|
350
|
+
client = key.data
|
|
351
|
+
if events & selectors.EVENT_WRITE:
|
|
352
|
+
self.flush(client)
|
|
353
|
+
if events & selectors.EVENT_READ and client.sock in self.clients:
|
|
354
|
+
self.from_client(client)
|
|
355
|
+
except StopIteration:
|
|
356
|
+
pass
|
|
357
|
+
finally:
|
|
358
|
+
try:
|
|
359
|
+
_, st = os.waitpid(self.pid, 0)
|
|
360
|
+
status = os.waitstatus_to_exitcode(st)
|
|
361
|
+
except ChildProcessError:
|
|
362
|
+
pass
|
|
363
|
+
try:
|
|
364
|
+
os.unlink(self.path)
|
|
365
|
+
except OSError:
|
|
366
|
+
pass
|
|
367
|
+
bye = frame(b'x', struct.pack('!i', status))
|
|
368
|
+
for client in list(self.clients.values()):
|
|
369
|
+
client.sock.setblocking(True)
|
|
370
|
+
try:
|
|
371
|
+
client.sock.sendall(client.out + bye)
|
|
372
|
+
except OSError:
|
|
373
|
+
pass
|
|
374
|
+
client.sock.close()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def start_session(rows, cols):
|
|
378
|
+
"""Forks a detached session owner; returns its id once its socket accepts connections."""
|
|
379
|
+
session_id = secrets.token_hex(4)
|
|
380
|
+
path = socket_path(session_id)
|
|
381
|
+
cwd, env = os.getcwd(), dict(os.environ)
|
|
382
|
+
ready_r, ready_w = os.pipe()
|
|
383
|
+
if os.fork() == 0:
|
|
384
|
+
os.close(ready_r)
|
|
385
|
+
os.setsid() # not part of the SSH login: survives it like tmux does
|
|
386
|
+
if os.fork() != 0:
|
|
387
|
+
os._exit(0)
|
|
388
|
+
null = os.open(os.devnull, os.O_RDWR)
|
|
389
|
+
for fd in (0, 1, 2):
|
|
390
|
+
os.dup2(null, fd)
|
|
391
|
+
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
|
392
|
+
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
|
393
|
+
try:
|
|
394
|
+
session = Session(session_id, path, rows, cols, cwd, env)
|
|
395
|
+
except Exception:
|
|
396
|
+
os._exit(1)
|
|
397
|
+
os.write(ready_w, b'1')
|
|
398
|
+
os.close(ready_w)
|
|
399
|
+
session.run()
|
|
400
|
+
os._exit(0)
|
|
401
|
+
os.close(ready_w)
|
|
402
|
+
ok = os.read(ready_r, 1) == b'1'
|
|
403
|
+
os.close(ready_r)
|
|
404
|
+
os.wait() # the intermediate child
|
|
405
|
+
if not ok:
|
|
406
|
+
sys.stderr.write('trd-pty: could not start a session\n')
|
|
407
|
+
sys.exit(3) # login.sh falls back to tmux / a plain shell on this
|
|
408
|
+
return session_id
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---- terminal side -------------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
FOCUS_ON, FOCUS_OFF = b'\x1b[?1004h', b'\x1b[?1004l'
|
|
414
|
+
FOCUS_IN, FOCUS_OUT = b'\x1b[I', b'\x1b[O'
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def attach(session_id, claim=True):
|
|
418
|
+
"""Relays this terminal to a session until the session ends (0) or the connection breaks (1)."""
|
|
419
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
420
|
+
try:
|
|
421
|
+
sock.connect(socket_path(session_id))
|
|
422
|
+
except OSError as e:
|
|
423
|
+
sys.exit(f'trd-pty: no session {session_id} ({e.strerror})')
|
|
424
|
+
fd_in, fd_out = sys.stdin.fileno(), sys.stdout.fileno()
|
|
425
|
+
rows, cols = term_size(fd_in)
|
|
426
|
+
sock.sendall(frame(b'h', struct.pack('!HHB', rows, cols, 1 if claim else 0)))
|
|
427
|
+
|
|
428
|
+
winch_r, winch_w = os.pipe()
|
|
429
|
+
os.set_blocking(winch_w, False)
|
|
430
|
+
signal.set_wakeup_fd(winch_w)
|
|
431
|
+
signal.signal(signal.SIGWINCH, lambda *_: None)
|
|
432
|
+
|
|
433
|
+
saved = termios.tcgetattr(fd_in)
|
|
434
|
+
tty.setraw(fd_in)
|
|
435
|
+
# Ask this terminal for focus reports: focusing it makes it the controlling terminal. They are
|
|
436
|
+
# only forwarded to the program in the session when that program asked for them itself.
|
|
437
|
+
os.write(fd_out, FOCUS_ON)
|
|
438
|
+
app_wants_focus = False
|
|
439
|
+
frames = Frames()
|
|
440
|
+
status = 1
|
|
441
|
+
try:
|
|
442
|
+
while True:
|
|
443
|
+
ready = select_read([fd_in, sock.fileno(), winch_r])
|
|
444
|
+
if winch_r in ready:
|
|
445
|
+
os.read(winch_r, 64)
|
|
446
|
+
sock.sendall(frame(b'r', SIZE.pack(*term_size(fd_in))))
|
|
447
|
+
if fd_in in ready:
|
|
448
|
+
data = os.read(fd_in, 65536)
|
|
449
|
+
if not data:
|
|
450
|
+
break
|
|
451
|
+
if FOCUS_IN in data:
|
|
452
|
+
sock.sendall(frame(b'c'))
|
|
453
|
+
if not app_wants_focus:
|
|
454
|
+
data = data.replace(FOCUS_IN, b'').replace(FOCUS_OUT, b'')
|
|
455
|
+
if data:
|
|
456
|
+
sock.sendall(frame(b'i', data))
|
|
457
|
+
if sock.fileno() in ready:
|
|
458
|
+
data = sock.recv(65536)
|
|
459
|
+
if not data:
|
|
460
|
+
break
|
|
461
|
+
for kind, payload in frames.feed(data):
|
|
462
|
+
if kind == b'o':
|
|
463
|
+
on, off = payload.rfind(FOCUS_ON), payload.rfind(FOCUS_OFF)
|
|
464
|
+
if on != off:
|
|
465
|
+
app_wants_focus = on > off
|
|
466
|
+
write_all(fd_out, payload)
|
|
467
|
+
elif kind == b'x':
|
|
468
|
+
status = 0
|
|
469
|
+
return status
|
|
470
|
+
finally:
|
|
471
|
+
if not app_wants_focus:
|
|
472
|
+
os.write(fd_out, FOCUS_OFF)
|
|
473
|
+
termios.tcsetattr(fd_in, termios.TCSAFLUSH, saved)
|
|
474
|
+
sock.close()
|
|
475
|
+
return status
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def select_read(fds):
|
|
479
|
+
sel = selectors.DefaultSelector()
|
|
480
|
+
for fd in fds:
|
|
481
|
+
sel.register(fd, selectors.EVENT_READ)
|
|
482
|
+
try:
|
|
483
|
+
return {key.fd for key, _ in sel.select()}
|
|
484
|
+
finally:
|
|
485
|
+
sel.close()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def write_all(fd, data):
|
|
489
|
+
view = memoryview(data)
|
|
490
|
+
while view:
|
|
491
|
+
try:
|
|
492
|
+
n = os.write(fd, view)
|
|
493
|
+
except BlockingIOError:
|
|
494
|
+
select_write(fd)
|
|
495
|
+
continue
|
|
496
|
+
view = view[n:]
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def select_write(fd):
|
|
500
|
+
sel = selectors.DefaultSelector()
|
|
501
|
+
sel.register(fd, selectors.EVENT_WRITE)
|
|
502
|
+
sel.select()
|
|
503
|
+
sel.close()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def query(path):
|
|
507
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
508
|
+
sock.settimeout(2)
|
|
509
|
+
sock.connect(path)
|
|
510
|
+
sock.sendall(frame(b'q'))
|
|
511
|
+
frames = Frames()
|
|
512
|
+
while True:
|
|
513
|
+
data = sock.recv(65536)
|
|
514
|
+
if not data:
|
|
515
|
+
raise OSError('closed')
|
|
516
|
+
for kind, payload in frames.feed(data):
|
|
517
|
+
if kind == b'q':
|
|
518
|
+
sock.close()
|
|
519
|
+
return json.loads(payload)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def list_sessions():
|
|
523
|
+
base = runtime_dir()
|
|
524
|
+
sessions = []
|
|
525
|
+
for name in sorted(os.listdir(base)):
|
|
526
|
+
if not name.endswith('.sock'):
|
|
527
|
+
continue
|
|
528
|
+
path = os.path.join(base, name)
|
|
529
|
+
try:
|
|
530
|
+
sessions.append(query(path))
|
|
531
|
+
except ConnectionRefusedError:
|
|
532
|
+
os.unlink(path) # its owner is gone (killed, or before a reboot's tmpfs wipe)
|
|
533
|
+
except OSError:
|
|
534
|
+
pass
|
|
535
|
+
return sessions
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def main(argv):
|
|
539
|
+
cmd = argv[1] if len(argv) > 1 else ''
|
|
540
|
+
if cmd == 'new':
|
|
541
|
+
if not os.isatty(0):
|
|
542
|
+
sys.exit('trd-pty: new needs a terminal')
|
|
543
|
+
return attach(start_session(*term_size(0)))
|
|
544
|
+
if cmd == 'attach' and len(argv) == 3:
|
|
545
|
+
return attach(argv[2])
|
|
546
|
+
if cmd == 'ls':
|
|
547
|
+
for s in list_sessions():
|
|
548
|
+
print(s['id'], s['clients'], s['pid'], s.get('cwd', ''))
|
|
549
|
+
return 0
|
|
550
|
+
if cmd == 'clients' and len(argv) == 3:
|
|
551
|
+
try:
|
|
552
|
+
print(query(socket_path(argv[2]))['clients'])
|
|
553
|
+
except OSError:
|
|
554
|
+
print(0)
|
|
555
|
+
return 0
|
|
556
|
+
sys.stderr.write(__doc__.split('\n\n', 1)[1])
|
|
557
|
+
return 2
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
if __name__ == '__main__':
|
|
561
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
vendor/iron-remote-desktop*.js are built from IronRDP (https://github.com/Devolutions/IronRDP) at
|
|
2
|
+
commit 9b151c4c2e47c6014e1e8e55909d4180aa8bdb99 plus ironrdp/gnome-egfx.patch. IronRDP is dual-licensed MIT or Apache-2.0;
|
|
3
|
+
its MIT license follows.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any
|
|
6
|
+
person obtaining a copy of this software and associated
|
|
7
|
+
documentation files (the "Software"), to deal in the
|
|
8
|
+
Software without restriction, including without
|
|
9
|
+
limitation the rights to use, copy, modify, merge,
|
|
10
|
+
publish, distribute, sublicense, and/or sell copies of
|
|
11
|
+
the Software, and to permit persons to whom the Software
|
|
12
|
+
is furnished to do so, subject to the following
|
|
13
|
+
conditions:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice
|
|
16
|
+
shall be included in all copies or substantial portions
|
|
17
|
+
of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
|
20
|
+
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
|
21
|
+
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
22
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
|
23
|
+
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
24
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
25
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
|
26
|
+
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
27
|
+
DEALINGS IN THE SOFTWARE.
|