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,665 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.RemoteDesktopService = void 0;
|
|
13
|
+
exports.installStyle = installStyle;
|
|
14
|
+
const core_1 = require("@angular/core");
|
|
15
|
+
const rxjs_1 = require("rxjs");
|
|
16
|
+
const tabby_core_1 = require("tabby-core");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const url_1 = require("url");
|
|
19
|
+
const targets_1 = require("./targets");
|
|
20
|
+
const deskScript_1 = require("./deskScript");
|
|
21
|
+
const desktopForm_1 = require("./desktopForm");
|
|
22
|
+
const desktops_1 = require("./desktops");
|
|
23
|
+
const remoteSetup_1 = require("./remoteSetup");
|
|
24
|
+
const rdcleanpath_1 = require("./rdcleanpath");
|
|
25
|
+
const signin_1 = require("./signin");
|
|
26
|
+
// tsc turns import() into require(); the vendored IronRDP bundles are ES modules.
|
|
27
|
+
const importESM = new Function('u', 'return import(u)');
|
|
28
|
+
const vendor = (f) => (0, url_1.pathToFileURL)(path.join(__dirname, '..', 'vendor', f)).href;
|
|
29
|
+
const STYLE = `
|
|
30
|
+
.trd-overlay { position: absolute; inset: 0; z-index: 30; display: flex; background: #111; }
|
|
31
|
+
.trd-host { flex: auto; min-width: 0; min-height: 0; display: flex; }
|
|
32
|
+
.trd-host iron-remote-desktop { flex: auto; }
|
|
33
|
+
.trd-status { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
|
34
|
+
padding: 2em; text-align: center; color: #bbb; font-size: 13px; white-space: pre-line; pointer-events: none; }
|
|
35
|
+
.trd-status:empty { display: none; }
|
|
36
|
+
`;
|
|
37
|
+
/**
|
|
38
|
+
* grd ignores the scale factor in RDP monitor layouts, so a Retina-sized remote monitor would render its UI at
|
|
39
|
+
* half size. This applies the scale through Mutter's DisplayConfig (temporarily, like GNOME's Display settings
|
|
40
|
+
* before "Keep changes"), once the new mode is current. Usage: python3 - <width> <height> <scale>
|
|
41
|
+
*/
|
|
42
|
+
const SCALE_SCRIPT = `
|
|
43
|
+
import sys, time
|
|
44
|
+
from gi.repository import Gio, GLib
|
|
45
|
+
W, H, SCALE = int(sys.argv[1]), int(sys.argv[2]), float(sys.argv[3])
|
|
46
|
+
bus = Gio.bus_get_sync(Gio.BusType.SESSION)
|
|
47
|
+
def call(method, args=None, reply=None):
|
|
48
|
+
return bus.call_sync('org.gnome.Mutter.DisplayConfig', '/org/gnome/Mutter/DisplayConfig',
|
|
49
|
+
'org.gnome.Mutter.DisplayConfig', method, args, reply, 0, -1, None)
|
|
50
|
+
for _ in range(50):
|
|
51
|
+
serial, monitors, logical, _ = call('GetCurrentState').unpack()
|
|
52
|
+
if len(monitors) == 1:
|
|
53
|
+
(connector, _, _, _), modes, _ = monitors[0]
|
|
54
|
+
mode = next((m for m in modes if m[6].get('is-current')), None)
|
|
55
|
+
if mode and (mode[1], mode[2]) == (W, H):
|
|
56
|
+
if logical and abs(logical[0][2] - SCALE) < 0.01:
|
|
57
|
+
print('RD_OK scale already set'); sys.exit(0)
|
|
58
|
+
if not any(abs(s - SCALE) < 0.01 for s in mode[5]):
|
|
59
|
+
print('RD_ERR scale not supported for this mode'); sys.exit(0)
|
|
60
|
+
call('ApplyMonitorsConfig', GLib.Variant('(uua(iiduba(ssa{sv}))a{sv})',
|
|
61
|
+
(serial, 1, [(0, 0, SCALE, 0, True, [(connector, mode[0], {})])], {})))
|
|
62
|
+
print('RD_OK scaled'); sys.exit(0)
|
|
63
|
+
time.sleep(0.1)
|
|
64
|
+
print('RD_ERR the new mode never became current')
|
|
65
|
+
`;
|
|
66
|
+
/** The remote display size for a pane of this size under these settings. */
|
|
67
|
+
function remoteSizeFor(rect, settings) {
|
|
68
|
+
const dpr = settings.sharpness === 'retina' ? Math.max(1, window.devicePixelRatio || 1) : 1;
|
|
69
|
+
const even = (v, min, max) => Math.max(min, Math.min(max, Math.floor(v / 2) * 2));
|
|
70
|
+
return {
|
|
71
|
+
width: rect.width ? even(rect.width * dpr, 640, 7680) : 1920,
|
|
72
|
+
height: rect.height ? even(rect.height * dpr, 480, 4320) : 1080,
|
|
73
|
+
scale: Math.round(dpr * 100),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
class DesktopSession {
|
|
77
|
+
/** `key`: see sessionKey(); one desktop per key. */
|
|
78
|
+
constructor(container, key, spec) {
|
|
79
|
+
this.key = key;
|
|
80
|
+
this.spec = spec;
|
|
81
|
+
this.state = 'connecting';
|
|
82
|
+
this.visible = false;
|
|
83
|
+
this.proxy = null;
|
|
84
|
+
this.ui = null;
|
|
85
|
+
this.log = [];
|
|
86
|
+
/** What the remote display was last set to. */
|
|
87
|
+
this.remoteSize = null;
|
|
88
|
+
this.remote = null;
|
|
89
|
+
this.reclaimFocus = (event) => {
|
|
90
|
+
if (this.visible && !this.overlay.contains(event.target)) {
|
|
91
|
+
setTimeout(() => this.visible && this.focusDesktop());
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
this.disposers = [];
|
|
95
|
+
this.disposed = new Promise(resolve => { this.markDisposed = resolve; });
|
|
96
|
+
this.overlay = document.createElement('div');
|
|
97
|
+
this.overlay.className = 'trd-overlay';
|
|
98
|
+
this.overlay.innerHTML = `
|
|
99
|
+
<div class="trd-host"></div>
|
|
100
|
+
<div class="trd-status"></div>`;
|
|
101
|
+
this.host = this.overlay.querySelector('.trd-host');
|
|
102
|
+
this.statusEl = this.overlay.querySelector('.trd-status');
|
|
103
|
+
if (getComputedStyle(container).position === 'static') {
|
|
104
|
+
container.style.position = 'relative';
|
|
105
|
+
}
|
|
106
|
+
container.appendChild(this.overlay);
|
|
107
|
+
// IronRDP only sends keys while its element has focus, but Tabby re-focuses the terminal
|
|
108
|
+
// (asynchronously) whenever the pane is focused, including on a click into the pane. See
|
|
109
|
+
// setVisible(): the terminal can't take focus while the desktop shows; this catches the rest.
|
|
110
|
+
this.container = container;
|
|
111
|
+
container.addEventListener('focusin', this.reclaimFocus, true);
|
|
112
|
+
this.overlay.addEventListener('mousedown', () => this.focusDesktop());
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Shows or hides the desktop layer. While it shows, the terminal's input textarea is disabled so
|
|
116
|
+
* nothing can focus it: keystrokes can't leak into the shell underneath, and Tabby's re-focus of
|
|
117
|
+
* the terminal becomes a no-op.
|
|
118
|
+
*/
|
|
119
|
+
setVisible(visible) {
|
|
120
|
+
this.visible = visible;
|
|
121
|
+
this.overlay.style.display = visible ? '' : 'none';
|
|
122
|
+
this.container.querySelectorAll('textarea.xterm-helper-textarea').forEach(input => {
|
|
123
|
+
input.disabled = visible;
|
|
124
|
+
});
|
|
125
|
+
if (visible) {
|
|
126
|
+
setTimeout(() => this.visible && this.focusDesktop());
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
status(msg) {
|
|
130
|
+
this.log.push(`${new Date().toISOString()} ${msg}`);
|
|
131
|
+
this.statusEl.textContent = msg;
|
|
132
|
+
}
|
|
133
|
+
onDispose(f) {
|
|
134
|
+
this.disposers.push(f);
|
|
135
|
+
}
|
|
136
|
+
/** Focuses the remote desktop. IronRDP reveals its canvas asynchronously, so retry for about half a second. */
|
|
137
|
+
focusDesktop(attempts = 30) {
|
|
138
|
+
// While the sign-in form shows, it is what takes focus back (the user name, or the password once filled).
|
|
139
|
+
const signin = this.overlay.querySelector('.trd-signin form');
|
|
140
|
+
if (signin) {
|
|
141
|
+
if (!signin.contains(document.activeElement)) {
|
|
142
|
+
const user = signin.querySelector('[name=username]');
|
|
143
|
+
(user.value ? signin.querySelector('[name=password]') : user).focus();
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const host = this.host.querySelector('iron-remote-desktop');
|
|
148
|
+
const canvas = host?.shadowRoot?.querySelector('canvas');
|
|
149
|
+
canvas?.focus();
|
|
150
|
+
if (host && document.activeElement !== host && attempts > 1 && this.visible) {
|
|
151
|
+
setTimeout(() => this.visible && this.focusDesktop(attempts - 1), 16);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
dispose() {
|
|
155
|
+
this.markDisposed();
|
|
156
|
+
this.setVisible(false);
|
|
157
|
+
this.container.removeEventListener('focusin', this.reclaimFocus, true);
|
|
158
|
+
this.disposers.forEach(f => f());
|
|
159
|
+
try {
|
|
160
|
+
this.ui?.shutdown();
|
|
161
|
+
}
|
|
162
|
+
catch { }
|
|
163
|
+
this.proxy?.close();
|
|
164
|
+
this.overlay.remove();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A remote desktop per terminal pane (an SSH tab, or a local terminal running `ssh`), shown as a layer
|
|
169
|
+
* over that pane's terminal. Toggling hides the layer and returns to the console; the RDP session keeps
|
|
170
|
+
* running until disconnected or the pane closes. One desktop per remote account.
|
|
171
|
+
*/
|
|
172
|
+
let RemoteDesktopService = class RemoteDesktopService {
|
|
173
|
+
constructor(app, targets, notifications, config, zone) {
|
|
174
|
+
this.app = app;
|
|
175
|
+
this.targets = targets;
|
|
176
|
+
this.notifications = notifications;
|
|
177
|
+
this.config = config;
|
|
178
|
+
this.zone = zone;
|
|
179
|
+
/** Emits whenever a pane's desktop is opened, shown, hidden, connected, ended or disconnected. */
|
|
180
|
+
this.changed$ = new rxjs_1.Subject();
|
|
181
|
+
this.sessions = new Map();
|
|
182
|
+
this.ironrdp = null;
|
|
183
|
+
this.lastUsed = new Map();
|
|
184
|
+
}
|
|
185
|
+
has(pane) {
|
|
186
|
+
this.prune();
|
|
187
|
+
return this.sessions.has(pane);
|
|
188
|
+
}
|
|
189
|
+
isVisible(pane) {
|
|
190
|
+
return !!this.sessions.get(pane)?.visible;
|
|
191
|
+
}
|
|
192
|
+
async toggle(pane) {
|
|
193
|
+
if (this.isVisible(pane)) {
|
|
194
|
+
this.showConsole(pane);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
await this.showDesktop(pane);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** The desktops this pane's SSH host offers: its own, then those configured behind it. */
|
|
201
|
+
desktopsOf(target) {
|
|
202
|
+
return (0, desktops_1.desktopsFor)(target, this.config.store.remoteDesktop?.desktops);
|
|
203
|
+
}
|
|
204
|
+
/** The desktop a plain toggle (hotkey, header, toolbar) opens for this target: the last one used there. */
|
|
205
|
+
defaultDesktop(target) {
|
|
206
|
+
const specs = this.desktopsOf(target);
|
|
207
|
+
return specs.find(s => s.id === this.lastUsed.get(target.key)) ?? specs[0];
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The desktops of the pane's SSH host (from the last lookup) and the one a plain toggle acts on: the one
|
|
211
|
+
* the pane has open, else the host's default.
|
|
212
|
+
*/
|
|
213
|
+
choicesOf(pane) {
|
|
214
|
+
const target = this.targets.cached(pane);
|
|
215
|
+
const specs = target ? this.desktopsOf(target) : [];
|
|
216
|
+
return { specs, current: this.desktopOf(pane) ?? (target ? this.defaultDesktop(target) : null) };
|
|
217
|
+
}
|
|
218
|
+
/** The desktop this pane shows or has open, if any. */
|
|
219
|
+
desktopOf(pane) {
|
|
220
|
+
return this.sessions.get(pane)?.spec ?? null;
|
|
221
|
+
}
|
|
222
|
+
/** `remoteDesktop.desktops`: the desktops configured behind SSH hosts. */
|
|
223
|
+
configuredDesktops() {
|
|
224
|
+
const list = this.config.store.remoteDesktop?.desktops;
|
|
225
|
+
return Array.isArray(list) ? [...list] : [];
|
|
226
|
+
}
|
|
227
|
+
/** "Add a desktop behind <host>…": asks over the pane, saves it to the config, and opens it. */
|
|
228
|
+
async addDesktop(pane) {
|
|
229
|
+
const target = await this.targets.targetOf(pane);
|
|
230
|
+
if (!target) {
|
|
231
|
+
this.notifications.error('No SSH connection found in this terminal');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (this.isVisible(pane)) {
|
|
235
|
+
this.showConsole(pane);
|
|
236
|
+
}
|
|
237
|
+
// The resolved hostname, so a profile tab and a plain `ssh` to that machine both offer it.
|
|
238
|
+
const hostname = target.key.replace(/:\d+$/, '').replace(/^[^@]*@/, '');
|
|
239
|
+
const entry = await (0, desktopForm_1.askNewDesktop)(pane.element.nativeElement, hostname, target.label);
|
|
240
|
+
if (!entry) {
|
|
241
|
+
pane.frontend?.focus();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.config.store.remoteDesktop.desktops = [...this.configuredDesktops(), entry];
|
|
245
|
+
this.config.save();
|
|
246
|
+
this.changed$.next();
|
|
247
|
+
await this.showDesktop(pane, `${entry.host}:${entry.port}`);
|
|
248
|
+
}
|
|
249
|
+
/** Removes a configured desktop and its saved accounts. Open sessions to it keep running. */
|
|
250
|
+
removeDesktop(index) {
|
|
251
|
+
const list = this.configuredDesktops();
|
|
252
|
+
const [removed] = list.splice(index, 1);
|
|
253
|
+
if (!removed) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
this.config.store.remoteDesktop.desktops = list;
|
|
257
|
+
this.config.save();
|
|
258
|
+
this.changed$.next();
|
|
259
|
+
(0, signin_1.forgetCredentialsFor)(`${removed.host ?? '127.0.0.1'}:${removed.port ?? 3389}`);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Shows a desktop over the pane: `desktopId` (see desktopsOf), or the one it has open, or the default. A pane
|
|
263
|
+
* holds one desktop at a time; choosing another one replaces it.
|
|
264
|
+
*/
|
|
265
|
+
async showDesktop(pane, desktopId) {
|
|
266
|
+
this.prune();
|
|
267
|
+
let session = this.sessions.get(pane);
|
|
268
|
+
if (session?.state === 'ended' || session && desktopId && session.spec.id !== desktopId) {
|
|
269
|
+
this.disconnect(pane);
|
|
270
|
+
session = undefined;
|
|
271
|
+
}
|
|
272
|
+
if (!session) {
|
|
273
|
+
const target = await this.targets.targetOf(pane);
|
|
274
|
+
if (!target) {
|
|
275
|
+
this.notifications.error('No SSH connection found in this terminal');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (this.sessions.has(pane)) {
|
|
279
|
+
// Opened meanwhile (e.g. a double click); just show it.
|
|
280
|
+
return this.showDesktop(pane, desktopId);
|
|
281
|
+
}
|
|
282
|
+
const spec = desktopId ? this.desktopsOf(target).find(s => s.id === desktopId) : this.defaultDesktop(target);
|
|
283
|
+
if (!spec) {
|
|
284
|
+
this.notifications.error('That desktop is no longer configured for this host');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
this.lastUsed.set(target.key, spec.id);
|
|
288
|
+
const key = (0, desktops_1.sessionKey)(target, spec);
|
|
289
|
+
// One desktop per key: another client would only get an extra, empty monitor (GNOME), or take
|
|
290
|
+
// over the session (Windows).
|
|
291
|
+
const existing = this.paneWithDesktopFor(key);
|
|
292
|
+
if (existing) {
|
|
293
|
+
this.selectPane(existing);
|
|
294
|
+
return this.showDesktop(existing);
|
|
295
|
+
}
|
|
296
|
+
const created = new DesktopSession(pane.element.nativeElement, key, spec);
|
|
297
|
+
session = created;
|
|
298
|
+
this.sessions.set(pane, created);
|
|
299
|
+
pane.destroyed$.subscribe(() => this.disconnect(pane));
|
|
300
|
+
// Tabby focuses the terminal when the pane gets focus (tab switch, split focus); take it back.
|
|
301
|
+
const focused = pane.focused$.subscribe(() => {
|
|
302
|
+
if (created.visible) {
|
|
303
|
+
setTimeout(() => created.visible && created.focusDesktop());
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
created.onDispose(() => focused.unsubscribe());
|
|
307
|
+
this.connect(pane, target, spec, created);
|
|
308
|
+
}
|
|
309
|
+
session.setVisible(true);
|
|
310
|
+
this.changed$.next();
|
|
311
|
+
const shown = session;
|
|
312
|
+
setTimeout(() => this.followPane(pane, shown));
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* `desk`: show this machine's desktop and bring the console along: a terminal on the desktop,
|
|
316
|
+
* attached to the console's tmux session (or opened in its folder when it isn't in tmux).
|
|
317
|
+
*/
|
|
318
|
+
async openConsole(pane, request) {
|
|
319
|
+
await this.showDesktop(pane, desktops_1.OWN_DESKTOP);
|
|
320
|
+
const target = await this.targets.targetOf(pane);
|
|
321
|
+
if (!target) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Wait for the desktop (possibly another pane's, for the same account) to be connected.
|
|
325
|
+
let session;
|
|
326
|
+
for (let i = 0; i < 240; i++) {
|
|
327
|
+
const owner = this.paneWithDesktopFor(target.key); // the host's own desktop
|
|
328
|
+
session = owner ? this.sessions.get(owner) : undefined;
|
|
329
|
+
if (!session || session.state !== 'connecting') {
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
await new Promise(r => setTimeout(r, 250));
|
|
333
|
+
}
|
|
334
|
+
if (session?.state !== 'connected') {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
const out = await target.exec('sh -s', (0, deskScript_1.consoleScript)(request));
|
|
339
|
+
const err = /^RD_ERR (.*)$/m.exec(out);
|
|
340
|
+
session.log.push(err ? `desk: ${err[1]}` : `desk: ${/^RD_OK (.*)$/m.exec(out)?.[1] ?? 'no result'}`);
|
|
341
|
+
if (err) {
|
|
342
|
+
this.notifications.error(`desk: ${err[1]}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
this.notifications.error(`desk: ${e?.message ?? e}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** True when this pane has no desktop but another pane has this desktop (default: the toggle's) open. */
|
|
350
|
+
isOpenElsewhere(pane, spec) {
|
|
351
|
+
const target = this.targets.cached(pane);
|
|
352
|
+
if (!target || this.has(pane)) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
return !!this.paneWithDesktopFor((0, desktops_1.sessionKey)(target, spec ?? this.defaultDesktop(target)));
|
|
356
|
+
}
|
|
357
|
+
paneWithDesktopFor(key) {
|
|
358
|
+
for (const [pane, session] of this.sessions) {
|
|
359
|
+
if (session.key === key && session.state !== 'ended') {
|
|
360
|
+
return pane;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
/** Drops sessions of panes that are gone: "close all" and closing the window skip destroyed$. */
|
|
366
|
+
prune() {
|
|
367
|
+
if (!this.sessions.size) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const alive = new Set(this.app.tabs.flatMap(t => t instanceof tabby_core_1.SplitTabComponent ? t.getAllTabs() : [t]));
|
|
371
|
+
for (const [pane, session] of [...this.sessions]) {
|
|
372
|
+
if (!alive.has(pane)) {
|
|
373
|
+
this.sessions.delete(pane);
|
|
374
|
+
session.dispose();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** Brings a pane forward: its top-level tab, and the pane itself within a split. */
|
|
379
|
+
selectPane(pane) {
|
|
380
|
+
const top = this.app.tabs.find(t => t === pane || (t instanceof tabby_core_1.SplitTabComponent && t.getAllTabs().includes(pane)));
|
|
381
|
+
if (top) {
|
|
382
|
+
this.app.selectTab(top);
|
|
383
|
+
}
|
|
384
|
+
if (pane.parent instanceof tabby_core_1.SplitTabComponent) {
|
|
385
|
+
pane.parent.focus(pane);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
showConsole(pane) {
|
|
389
|
+
const session = this.sessions.get(pane);
|
|
390
|
+
if (session) {
|
|
391
|
+
session.setVisible(false);
|
|
392
|
+
this.changed$.next();
|
|
393
|
+
}
|
|
394
|
+
pane.frontend?.focus();
|
|
395
|
+
}
|
|
396
|
+
disconnect(pane) {
|
|
397
|
+
const session = this.sessions.get(pane);
|
|
398
|
+
if (!session) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
this.sessions.delete(pane);
|
|
402
|
+
const wasVisible = session.visible;
|
|
403
|
+
session.dispose();
|
|
404
|
+
this.changed$.next();
|
|
405
|
+
if (wasVisible) {
|
|
406
|
+
pane.frontend?.focus();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
settings() {
|
|
410
|
+
const store = this.config.store.remoteDesktop ?? {};
|
|
411
|
+
return {
|
|
412
|
+
resize: ['live', 'reconnect', 'off'].includes(store.resize) ? store.resize : 'live',
|
|
413
|
+
sharpness: store.sharpness === 'retina' ? 'retina' : 'standard',
|
|
414
|
+
desk: store.desk === true,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/** Changes a setting, saves it, and applies it to open desktops. */
|
|
418
|
+
updateSettings(change) {
|
|
419
|
+
const store = this.config.store.remoteDesktop;
|
|
420
|
+
Object.assign(store, change);
|
|
421
|
+
this.config.save();
|
|
422
|
+
for (const [pane, session] of this.sessions) {
|
|
423
|
+
this.followPane(pane, session);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/** Follows the pane's size (debounced) while the desktop shows, per the resize setting. */
|
|
427
|
+
watchSize(pane, session) {
|
|
428
|
+
let timer;
|
|
429
|
+
const observer = new ResizeObserver(() => {
|
|
430
|
+
// Refit the view right away (the component only refits on window resizes by itself) ...
|
|
431
|
+
try {
|
|
432
|
+
session.ui?.setScale(1 /* ScreenScale.Fit */);
|
|
433
|
+
}
|
|
434
|
+
catch { }
|
|
435
|
+
// ... and change the remote resolution once the size settles.
|
|
436
|
+
clearTimeout(timer);
|
|
437
|
+
timer = setTimeout(() => this.followPane(pane, session), 300);
|
|
438
|
+
});
|
|
439
|
+
observer.observe(session.overlay);
|
|
440
|
+
session.onDispose(() => {
|
|
441
|
+
clearTimeout(timer);
|
|
442
|
+
observer.disconnect();
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
followPane(pane, session) {
|
|
446
|
+
const rect = session.overlay.getBoundingClientRect();
|
|
447
|
+
if (session.state !== 'connected' || !session.visible || !rect.width || !rect.height) {
|
|
448
|
+
return; // hidden panes measure 0; they catch up when shown again
|
|
449
|
+
}
|
|
450
|
+
const settings = this.settings();
|
|
451
|
+
const wanted = remoteSizeFor(rect, settings);
|
|
452
|
+
const current = session.remoteSize;
|
|
453
|
+
if (settings.resize === 'off' || current && current.width === wanted.width && current.height === wanted.height && current.scale === wanted.scale) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (settings.resize === 'reconnect') {
|
|
457
|
+
session.log.push(`resize: reconnecting at ${wanted.width}x${wanted.height}`);
|
|
458
|
+
this.zone.run(() => {
|
|
459
|
+
this.disconnect(pane);
|
|
460
|
+
this.showDesktop(pane);
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
this.applySize(session, wanted);
|
|
465
|
+
}
|
|
466
|
+
applySize(session, size) {
|
|
467
|
+
session.log.push(`resize: ${size.width}x${size.height} @${size.scale}%`);
|
|
468
|
+
session.remoteSize = size;
|
|
469
|
+
session.ui.resize(size.width, size.height, size.scale);
|
|
470
|
+
// Windows honors the scale in the monitor layout; grd needs it set through Mutter.
|
|
471
|
+
if (size.scale !== 100 && session.remote && session.spec.id === desktops_1.OWN_DESKTOP) {
|
|
472
|
+
session.remote.exec(`python3 - ${size.width} ${size.height} ${size.scale / 100}`, SCALE_SCRIPT).then(out => {
|
|
473
|
+
session.log.push(`scale: ${/^RD_(OK|ERR) (.*)$/m.exec(out)?.[2] ?? out.trim().slice(-120)}`);
|
|
474
|
+
}, e => session.log.push(`scale: ${e?.message ?? e}`));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/** Status log of the pane's desktop session (for tests and troubleshooting). */
|
|
478
|
+
logOf(pane) {
|
|
479
|
+
return this.sessions.get(pane)?.log ?? [];
|
|
480
|
+
}
|
|
481
|
+
loadIronRDP() {
|
|
482
|
+
this.ironrdp ??= (async () => {
|
|
483
|
+
await importESM(vendor('iron-remote-desktop.js'));
|
|
484
|
+
const rdp = await importESM(vendor('iron-remote-desktop-rdp.js'));
|
|
485
|
+
// Troubleshooting: localStorage.trdLogLevel = 'DEBUG' (or TRACE), then restart Tabby.
|
|
486
|
+
let level = 'INFO';
|
|
487
|
+
try {
|
|
488
|
+
level = localStorage.getItem('trdLogLevel') || level;
|
|
489
|
+
}
|
|
490
|
+
catch { }
|
|
491
|
+
await rdp.init(level);
|
|
492
|
+
return rdp;
|
|
493
|
+
})();
|
|
494
|
+
this.ironrdp.catch(() => { this.ironrdp = null; });
|
|
495
|
+
return this.ironrdp;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Where to connect and as whom. The host's own desktop: the setup script (grd, generated credentials).
|
|
499
|
+
* Others: the saved account, or the sign-in form (retry=true after a failed sign-in).
|
|
500
|
+
*/
|
|
501
|
+
async endpointFor(target, spec, session, retryError) {
|
|
502
|
+
if (spec.id === desktops_1.OWN_DESKTOP) {
|
|
503
|
+
session.status(`Preparing the remote desktop on ${target.label}…`);
|
|
504
|
+
const endpoint = await (0, remoteSetup_1.prepareRemoteDesktop)(target, this.settings().desk, this.config.store.remoteDesktop?.sessionBackend);
|
|
505
|
+
return { host: '127.0.0.1', port: endpoint.port, credentials: { username: endpoint.username, password: endpoint.password }, remember: false };
|
|
506
|
+
}
|
|
507
|
+
const saved = retryError ? null : await (0, signin_1.loadCredentials)(session.key);
|
|
508
|
+
if (saved) {
|
|
509
|
+
return { host: spec.host, port: spec.port, credentials: saved, remember: false };
|
|
510
|
+
}
|
|
511
|
+
session.status('');
|
|
512
|
+
const asked = (0, signin_1.askCredentials)(session.overlay, {
|
|
513
|
+
title: `Sign in to ${spec.name} (via ${target.label})`,
|
|
514
|
+
username: spec.username,
|
|
515
|
+
error: retryError,
|
|
516
|
+
canRemember: true,
|
|
517
|
+
}, session.disposed);
|
|
518
|
+
if (session.visible) {
|
|
519
|
+
setTimeout(() => session.visible && session.focusDesktop());
|
|
520
|
+
}
|
|
521
|
+
const entered = await asked;
|
|
522
|
+
return entered && { host: spec.host, port: spec.port, credentials: entered, remember: entered.remember };
|
|
523
|
+
}
|
|
524
|
+
async connect(pane, target, spec, session) {
|
|
525
|
+
const alive = () => this.sessions.get(pane) === session;
|
|
526
|
+
let retryError;
|
|
527
|
+
try {
|
|
528
|
+
// Signing in to a desktop behind the host can take a few tries; the host's own one can't fail that way.
|
|
529
|
+
for (let attempt = 0;; attempt++) {
|
|
530
|
+
const [endpoint, rdp] = await Promise.all([this.endpointFor(target, spec, session, retryError), this.loadIronRDP()]);
|
|
531
|
+
if (!alive()) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (!endpoint) {
|
|
535
|
+
session.state = 'ended';
|
|
536
|
+
this.changed$.next();
|
|
537
|
+
session.status('Sign-in cancelled.\nShow the desktop again to sign in.');
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
session.remote = target;
|
|
541
|
+
session.proxy ??= await (0, rdcleanpath_1.startRDCleanPathProxy)(() => target.openTcp(endpoint.host, endpoint.port), m => session.log.push(m));
|
|
542
|
+
const outcome = await this.run(pane, target, spec, session, rdp, endpoint);
|
|
543
|
+
if (outcome.signInFailed && spec.id !== desktops_1.OWN_DESKTOP && attempt < 5 && alive()) {
|
|
544
|
+
await (0, signin_1.forgetCredentials)(session.key);
|
|
545
|
+
retryError = outcome.signInFailed;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch (e) {
|
|
552
|
+
session.state = 'ended';
|
|
553
|
+
this.changed$.next();
|
|
554
|
+
session.status(`Remote desktop failed: ${e?.message ?? e}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/** One RDP connection attempt, until it ends. */
|
|
558
|
+
async run(pane, target, spec, session, rdp, endpoint) {
|
|
559
|
+
const credentials = endpoint.credentials;
|
|
560
|
+
const alive = () => this.sessions.get(pane) === session;
|
|
561
|
+
const el = document.createElement('iron-remote-desktop');
|
|
562
|
+
try {
|
|
563
|
+
el.setAttribute('scale', 'fit');
|
|
564
|
+
el.setAttribute('flexcenter', 'true');
|
|
565
|
+
el.module = rdp.Backend;
|
|
566
|
+
const ready = new Promise(resolve => el.addEventListener('ready', (e) => resolve(e.detail.irgUserInteraction), { once: true }));
|
|
567
|
+
session.host.replaceChildren(el);
|
|
568
|
+
session.ui = await ready;
|
|
569
|
+
// The component outlines itself while it has the keyboard; the desktop should look like the
|
|
570
|
+
// terminal it replaces, edge to edge. Its styles live in a shadow root, so adopt a sheet there.
|
|
571
|
+
// Not a <style> element: the component only forwards keys while its own container is the shadow
|
|
572
|
+
// root's first child, and an element added before it renders would take that place.
|
|
573
|
+
if (el.shadowRoot) {
|
|
574
|
+
const sheet = new CSSStyleSheet();
|
|
575
|
+
sheet.replaceSync('.capturing-inputs, canvas:focus, canvas:focus-visible { outline: none !important; }');
|
|
576
|
+
el.shadowRoot.adoptedStyleSheets = [...el.shadowRoot.adoptedStyleSheets, sheet];
|
|
577
|
+
}
|
|
578
|
+
// Size the remote display to the pane (see the sharpness setting).
|
|
579
|
+
const settings = this.settings();
|
|
580
|
+
const size = remoteSizeFor(session.overlay.getBoundingClientRect(), settings);
|
|
581
|
+
const { width, height } = size;
|
|
582
|
+
// DOMAIN\user or user@domain go to the server as typed; only the former needs splitting.
|
|
583
|
+
const [domain, username] = /^([^\\]+)\\(.+)$/.exec(credentials.username)?.slice(1) ?? [spec.domain ?? '', credentials.username];
|
|
584
|
+
session.status(spec.id === desktops_1.OWN_DESKTOP ? `Connecting to ${target.label}…` : `Connecting to ${spec.name} via ${target.label}…`);
|
|
585
|
+
const config = session.ui.configBuilder()
|
|
586
|
+
.withUsername(username)
|
|
587
|
+
.withPassword(credentials.password)
|
|
588
|
+
.withDestination(`${spec.id === desktops_1.OWN_DESKTOP ? target.label : endpoint.host}:${endpoint.port}`)
|
|
589
|
+
.withProxyAddress(session.proxy.url)
|
|
590
|
+
.withServerDomain(domain)
|
|
591
|
+
.withAuthToken(session.proxy.token)
|
|
592
|
+
.withDesktopSize({ width, height })
|
|
593
|
+
// Display control lets the remote monitor follow the pane (live resize, remote scaling).
|
|
594
|
+
.withExtension(rdp.displayControl(true))
|
|
595
|
+
// GNOME Remote Desktop requires the graphics pipeline; Windows does better with bitmaps
|
|
596
|
+
// (IronRDP decodes EGFX without H.264, which Windows would want).
|
|
597
|
+
.withExtension(rdp.graphicsPipeline(spec.kind === 'gnome'))
|
|
598
|
+
.build();
|
|
599
|
+
let info;
|
|
600
|
+
try {
|
|
601
|
+
info = await session.ui.connect(config);
|
|
602
|
+
}
|
|
603
|
+
catch (e) {
|
|
604
|
+
// IronErrorKind: 1 WrongPassword, 2 LogonFailure.
|
|
605
|
+
const kind = typeof e?.kind === 'function' ? e.kind() : undefined;
|
|
606
|
+
const detail = typeof e?.backtrace === 'function' ? e.backtrace() : (e?.message ?? String(e));
|
|
607
|
+
if (kind === 1 || kind === 2) {
|
|
608
|
+
session.log.push(`sign-in failed: ${detail.split('\n')[0]}`);
|
|
609
|
+
return { connected: false, signInFailed: kind === 1 ? 'Wrong user name or password.' : 'The sign-in was refused.' };
|
|
610
|
+
}
|
|
611
|
+
throw new Error(detail);
|
|
612
|
+
}
|
|
613
|
+
if (!alive()) {
|
|
614
|
+
return { connected: false };
|
|
615
|
+
}
|
|
616
|
+
session.ui.setVisibility(true);
|
|
617
|
+
session.remoteSize = { width, height, scale: 100 };
|
|
618
|
+
if (size.scale !== 100) {
|
|
619
|
+
// Retina: the connection starts unscaled; ask for the matching remote scale right away.
|
|
620
|
+
this.applySize(session, size);
|
|
621
|
+
}
|
|
622
|
+
this.watchSize(pane, session);
|
|
623
|
+
session.state = 'connected';
|
|
624
|
+
session.status('');
|
|
625
|
+
this.changed$.next();
|
|
626
|
+
if (endpoint.remember) {
|
|
627
|
+
(0, signin_1.saveCredentials)(session.key, credentials).then(() => session.log.push('sign-in: saved in the keychain'), e => session.log.push(`sign-in: keychain: ${e?.message ?? e}`));
|
|
628
|
+
}
|
|
629
|
+
if (session.visible) {
|
|
630
|
+
session.focusDesktop();
|
|
631
|
+
}
|
|
632
|
+
const end = await info.run();
|
|
633
|
+
session.state = 'ended';
|
|
634
|
+
this.changed$.next();
|
|
635
|
+
session.status(`Remote desktop session ended: ${end.reason()}\nShow the desktop again to reconnect.`);
|
|
636
|
+
return { connected: true };
|
|
637
|
+
}
|
|
638
|
+
catch (e) {
|
|
639
|
+
session.state = 'ended';
|
|
640
|
+
this.changed$.next();
|
|
641
|
+
session.status(`Remote desktop failed: ${e?.message ?? e}`);
|
|
642
|
+
return { connected: false };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
exports.RemoteDesktopService = RemoteDesktopService;
|
|
647
|
+
exports.RemoteDesktopService = RemoteDesktopService = __decorate([
|
|
648
|
+
(0, core_1.Injectable)({ providedIn: 'root' }),
|
|
649
|
+
__metadata("design:paramtypes", [tabby_core_1.AppService,
|
|
650
|
+
targets_1.RemoteTargets,
|
|
651
|
+
tabby_core_1.NotificationsService,
|
|
652
|
+
tabby_core_1.ConfigService,
|
|
653
|
+
core_1.NgZone])
|
|
654
|
+
], RemoteDesktopService);
|
|
655
|
+
let styleInstalled = false;
|
|
656
|
+
function installStyle() {
|
|
657
|
+
if (styleInstalled) {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
styleInstalled = true;
|
|
661
|
+
const style = document.createElement('style');
|
|
662
|
+
style.textContent = STYLE + signin_1.STYLE;
|
|
663
|
+
document.head.appendChild(style);
|
|
664
|
+
}
|
|
665
|
+
//# sourceMappingURL=desktop.service.js.map
|