tabby-rdp 0.5.1 → 0.5.3

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.
@@ -0,0 +1,186 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PackageChangeDialog = void 0;
4
+ const STYLE = `
5
+ .trd-package-dialog { width: min(460px, calc(100vw - 40px)); padding: 24px; border-radius: 8px; outline: none;
6
+ border: 1px solid rgba(128,128,128,.35); background: var(--bs-body-bg, #1b1b1b); color: var(--bs-body-color, #ddd);
7
+ box-shadow: 0 16px 60px rgba(0,0,0,.45); }
8
+ .trd-package-dialog::backdrop { background: rgba(0,0,0,.55); }
9
+ .trd-package-dialog h4 { font-size: 18px; margin: 0 0 16px; }
10
+ .trd-package-dialog p { white-space: pre-line; overflow-wrap: anywhere; }
11
+ .trd-package-dialog [data-detail] { font-size: 13px; opacity: .8; }
12
+ .trd-package-dialog p:empty { display: none; }
13
+ .trd-package-dialog progress { display: block; width: 100%; height: 8px; margin: 18px 0 8px; accent-color: #589cf0; }
14
+ .trd-package-dialog [hidden] { display: none !important; }
15
+ .trd-package-dialog [data-elapsed] { font-size: 12px; opacity: .7; }
16
+ .trd-package-dialog [data-phase=error] { color: var(--bs-danger, #f08080); }
17
+ .trd-package-dialog [data-phase=success] { color: var(--bs-success, #79c99b); }
18
+ .trd-package-dialog .trd-package-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; }
19
+ `;
20
+ /** One modal remains open from confirmation through the installer's verified result. */
21
+ class PackageChangeDialog {
22
+ constructor() {
23
+ this.dialog = null;
24
+ this.previousFocus = null;
25
+ this.started = 0;
26
+ }
27
+ update(state) {
28
+ if (state.phase === 'cancelled') {
29
+ this.close();
30
+ return;
31
+ }
32
+ this.open();
33
+ this.field('[data-title]').textContent = state.action === 'install'
34
+ ? `Install tabby-rdp ${state.version}` : 'Uninstall tabby-rdp';
35
+ const result = this.field('[data-result]');
36
+ result.dataset.phase = state.phase;
37
+ result.setAttribute('role', state.phase === 'error' ? 'alert' : 'status');
38
+ const complete = state.phase === 'success' || state.phase === 'error';
39
+ this.field('progress').hidden = complete;
40
+ this.field('[data-elapsed]').hidden = complete;
41
+ this.field('[data-cancel]').hidden = true;
42
+ this.field('[data-confirm]').hidden = true;
43
+ this.field('[data-close]').hidden = !complete;
44
+ this.field('[data-detail]').textContent = '';
45
+ let message = state.message ?? '';
46
+ if (state.phase === 'preparing') {
47
+ message = state.action === 'install' ? 'Checking compatibility…' : 'Checking installation…';
48
+ }
49
+ if (state.phase === 'installing') {
50
+ message = `Installing tabby-rdp ${state.version}…`;
51
+ }
52
+ if (state.phase === 'uninstalling') {
53
+ message = 'Uninstalling tabby-rdp…';
54
+ }
55
+ if (state.phase === 'verifying') {
56
+ message = state.action === 'install' ? 'Verifying installation…' : 'Verifying removal…';
57
+ }
58
+ if (state.phase === 'success') {
59
+ this.field('[data-title]').textContent = state.action === 'install' ? 'Installation complete' : 'Uninstall complete';
60
+ if (!state.message) {
61
+ message = state.action === 'install' ? `tabby-rdp ${state.version} is installed.` : 'tabby-rdp is uninstalled.';
62
+ this.field('[data-detail]').textContent = 'Close remote desktops, then restart Tabby.';
63
+ }
64
+ }
65
+ if (state.phase === 'error') {
66
+ this.field('[data-title]').textContent = state.action === 'install' ? 'Installation failed' : 'Uninstall failed';
67
+ }
68
+ this.field('[data-message]').textContent = message;
69
+ if (complete) {
70
+ this.stopTimer();
71
+ this.field('[data-close]').focus();
72
+ }
73
+ else {
74
+ this.dialog.focus();
75
+ }
76
+ }
77
+ confirm(message, detail, action) {
78
+ this.open();
79
+ this.stopTimer();
80
+ this.field('[data-title]').textContent = message;
81
+ this.field('[data-message]').textContent = detail;
82
+ this.field('[data-detail]').textContent = '';
83
+ this.field('progress').hidden = true;
84
+ this.field('[data-elapsed]').hidden = true;
85
+ this.field('[data-close]').hidden = true;
86
+ const cancel = this.field('[data-cancel]');
87
+ const accept = this.field('[data-confirm]');
88
+ cancel.hidden = false;
89
+ accept.hidden = false;
90
+ accept.textContent = action;
91
+ this.field('[data-result]').dataset.phase = 'confirming';
92
+ cancel.focus();
93
+ return new Promise(resolve => { this.confirmation = resolve; });
94
+ }
95
+ field(selector) { return this.dialog.querySelector(selector); }
96
+ open() {
97
+ if (this.dialog) {
98
+ if (!this.dialog.open) {
99
+ this.dialog.showModal();
100
+ }
101
+ if (!this.timer) {
102
+ this.startTimer();
103
+ }
104
+ return;
105
+ }
106
+ if (!document.getElementById('trd-package-dialog-style')) {
107
+ const style = document.createElement('style');
108
+ style.id = 'trd-package-dialog-style';
109
+ style.textContent = STYLE;
110
+ document.head.appendChild(style);
111
+ }
112
+ this.previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
113
+ const dialog = document.createElement('dialog');
114
+ this.dialog = dialog;
115
+ dialog.className = 'trd-package-dialog';
116
+ dialog.setAttribute('aria-labelledby', 'trd-package-dialog-title');
117
+ dialog.setAttribute('tabindex', '-1');
118
+ dialog.setAttribute('closedby', 'none');
119
+ dialog.innerHTML = `<h4 id="trd-package-dialog-title" data-title></h4>
120
+ <div data-result role="status" aria-live="polite"><p data-message></p><p data-detail></p></div>
121
+ <progress aria-label="Package change progress"></progress><div data-elapsed></div>
122
+ <div class="trd-package-actions"><button class="btn btn-secondary" data-cancel hidden>Cancel</button>
123
+ <button class="btn btn-primary" data-confirm hidden></button><button class="btn btn-primary" data-close hidden>Close</button></div>`;
124
+ this.field('[data-confirm]').addEventListener('click', () => this.decide(true));
125
+ this.field('[data-cancel]').addEventListener('click', () => this.decide(false));
126
+ this.field('[data-close]').addEventListener('click', () => this.close());
127
+ dialog.addEventListener('cancel', event => {
128
+ event.preventDefault();
129
+ this.dismiss();
130
+ });
131
+ // Keep package-dialog keystrokes out of Tabby's terminal and shortcut handlers.
132
+ dialog.addEventListener('keydown', event => {
133
+ event.stopPropagation();
134
+ if (event.key === 'Escape') {
135
+ event.preventDefault();
136
+ this.dismiss();
137
+ }
138
+ });
139
+ dialog.addEventListener('keyup', event => event.stopPropagation());
140
+ document.body.appendChild(dialog);
141
+ dialog.showModal();
142
+ this.startTimer();
143
+ }
144
+ decide(accepted) {
145
+ const resolve = this.confirmation;
146
+ if (!resolve) {
147
+ return;
148
+ }
149
+ this.confirmation = undefined;
150
+ this.field('[data-confirm]').hidden = true;
151
+ this.field('[data-cancel]').hidden = true;
152
+ resolve(accepted);
153
+ if (!accepted) {
154
+ this.close();
155
+ }
156
+ }
157
+ dismiss() {
158
+ if (this.confirmation) {
159
+ this.decide(false);
160
+ }
161
+ else if (!this.field('[data-close]').hidden) {
162
+ this.close();
163
+ }
164
+ }
165
+ startTimer() {
166
+ this.started = Date.now();
167
+ const tick = () => {
168
+ const seconds = Math.floor((Date.now() - this.started) / 1000);
169
+ this.field('[data-elapsed]').textContent = `Elapsed ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
170
+ };
171
+ tick();
172
+ this.timer = setInterval(tick, 1000);
173
+ }
174
+ stopTimer() { clearInterval(this.timer); this.timer = undefined; }
175
+ close() {
176
+ this.stopTimer();
177
+ this.dialog?.close();
178
+ this.dialog?.remove();
179
+ this.dialog = null;
180
+ if (this.previousFocus?.isConnected) {
181
+ this.previousFocus.focus();
182
+ }
183
+ this.previousFocus = null;
184
+ }
185
+ }
186
+ exports.PackageChangeDialog = PackageChangeDialog;
@@ -527,6 +527,8 @@ async function handshake(openUpstream, destination, pcb, x224Request, legacyTls,
527
527
  }
528
528
  const raw = await openUpstream(destination, signal);
529
529
  track(raw);
530
+ // What this handshake opened, in order, for destroyAll if it fails.
531
+ const opened = [raw];
530
532
  try {
531
533
  if (gone()) {
532
534
  throw new Error('the client left');
@@ -556,6 +558,7 @@ async function handshake(openUpstream, destination, pcb, x224Request, legacyTls,
556
558
  ...name ? { host: name, ca: (0, authorities_1.certificateAuthorities)(), ...(0, net_1.isIP)(name) ? {} : { servername: name } } : {},
557
559
  ...legacyTls ? KEY_ENCIPHERMENT_ONLY_TLS : {}, ...options.tls,
558
560
  });
561
+ opened.push(upstream);
559
562
  track(upstream);
560
563
  // An error after the one the handshake below waits for (the stream under it failing as the socket is destroyed,
561
564
  // which a TLS socket over a stream passes on as its own) would have no listener until the relay is up, and an
@@ -571,10 +574,22 @@ async function handshake(openUpstream, destination, pcb, x224Request, legacyTls,
571
574
  return { raw, upstream, x224 };
572
575
  }
573
576
  catch (e) {
574
- raw.destroy();
577
+ destroyAll(opened);
575
578
  throw e;
576
579
  }
577
580
  }
581
+ /**
582
+ * Destroys streams opened one over the other (the stream to the server, then TLS over it), the last opened first. TLS
583
+ * over a socket works inside the socket's own handle (Node's TLSWrap), and a socket destroyed under TLS still using it
584
+ * frees what TLS then reads: right after the handshake, where a certificate refused at once ends the connection, that
585
+ * crashed Tabby's window (a segmentation fault, in Node as in Electron). TLS destroyed first is done with the socket
586
+ * before the socket goes. (Over a stream of JavaScript, an SSH channel or a gateway's tunnel, the order did no harm.)
587
+ */
588
+ function destroyAll(streams) {
589
+ for (const stream of [...streams].reverse()) {
590
+ stream.destroy();
591
+ }
592
+ }
578
593
  /** Whether `given` is the secret, compared in a time that doesn't tell how much of it was right. */
579
594
  function sameSecret(given, secret) {
580
595
  const bytes = Buffer.from(given);
@@ -666,9 +681,7 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
666
681
  closed = true;
667
682
  leaving.abort();
668
683
  clearTimeout(deadline);
669
- for (const stream of streams) {
670
- stream.destroy();
671
- }
684
+ destroyAll(streams);
672
685
  // What was handed to this connection went with it: the next starts with all the room.
673
686
  if (flowClient === ws) {
674
687
  flowClient = null;
package/dist/rdpFile.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MICROPHONE_NOTE = void 0;
3
+ exports.MAX_RDP_FILE = exports.MICROPHONE_NOTE = void 0;
4
4
  exports.parseRdpFile = parseRdpFile;
5
5
  /**
6
6
  * The note for a file that asks for the microphone: not applied while the setting is off. With it on, every desktop
@@ -53,6 +53,11 @@ function decode(data) {
53
53
  }
54
54
  return b.toString('utf8');
55
55
  }
56
+ /**
57
+ * The most an .rdp file may be: one Remote Desktop Connection saves is a few KB, a signed one a few more. A larger one is
58
+ * refused before it is read: what it holds would be parsed on the thread that runs Tabby's window.
59
+ */
60
+ exports.MAX_RDP_FILE = 1024 * 1024;
56
61
  /** The connection a .rdp file describes, or null without a usable `full address`. */
57
62
  function parseRdpFile(data) {
58
63
  const settings = new Map();
@@ -60,9 +65,13 @@ function parseRdpFile(data) {
60
65
  // twice, differently, gets the narrower of the two rather than whichever line comes last.
61
66
  let clipboardOff = false;
62
67
  for (const line of decode(data).split(/\r?\n/)) {
63
- const m = /^\s*([^:]+?)\s*:\s*([sib])\s*:(.*)$/i.exec(line);
64
- if (m) {
65
- const key = m[1].toLowerCase();
68
+ // The name up to the first colon, trimmed after: a pattern with room for spaces on both sides of the name (as
69
+ // `\s*([^:]+?)\s*:` had) tries every way of splitting a line of spaces without a colon before it fails, in time
70
+ // that grows with the cube of its length, and a line of a few thousand spaces held up Tabby's window for seconds
71
+ // (security review, finding 2). This one takes a line's length, whatever it holds.
72
+ const m = /^([^:]*):\s*([sib])\s*:(.*)$/i.exec(line);
73
+ const key = m?.[1].trim().toLowerCase();
74
+ if (m && key) {
66
75
  const value = m[3].trim();
67
76
  settings.set(key, value);
68
77
  clipboardOff ||= key === 'redirectclipboard' && /^[+-]?\d+$/.test(value) && Number(value) === 0;
@@ -124,7 +124,7 @@ exports.RDPTabRecovery = RDPTabRecovery = __decorate([
124
124
  // ---- profile settings --------------------------------------------------------------------------
125
125
  /**
126
126
  * The profile's own fields in Tabby's profile editor (name, group, icon and color are Tabby's). Plain DOM like the
127
- * rest of the plugin, writing to the profile as you type.
127
+ * rest of the plugin. Address and gateway edits stay in the form until Save validates both of them.
128
128
  */
129
129
  let RDPProfileSettingsComponent = class RDPProfileSettingsComponent {
130
130
  constructor(element, injector) {
@@ -188,6 +188,7 @@ let RDPProfileSettingsComponent = class RDPProfileSettingsComponent {
188
188
  Applies on the next connection.</div>
189
189
  </div>`;
190
190
  const field = (name) => root.querySelector(`[name="${name}"]`);
191
+ this.connectionInputs = { address: field('address'), gateway: field('gateway') };
191
192
  field('address').value = o.host ? (0, desktopForm_1.formatAddress)(o.host, o.port || 3389) : '';
192
193
  field('kind').value = o.kind === 'gnome' || o.kind === 'xrdp' ? o.kind : 'windows';
193
194
  field('username').value = o.username ?? '';
@@ -196,10 +197,6 @@ let RDPProfileSettingsComponent = class RDPProfileSettingsComponent {
196
197
  const text = field('address').value;
197
198
  const address = (0, desktopForm_1.parseAddress)(text);
198
199
  root.querySelector('[data-for=address]').textContent = text.trim() && !address ? 'This doesn\'t look like an address.' : '';
199
- if (address) {
200
- o.host = address.host;
201
- o.port = address.port;
202
- }
203
200
  });
204
201
  field('kind').addEventListener('change', () => {
205
202
  const kind = field('kind').value;
@@ -227,12 +224,12 @@ let RDPProfileSettingsComponent = class RDPProfileSettingsComponent {
227
224
  ownAccount();
228
225
  field('username').addEventListener('input', () => { o.username = field('username').value.trim(); });
229
226
  field('domain').addEventListener('input', () => { o.domain = field('domain').value.trim(); });
230
- // The gateway, and its account where that isn't the desktop's (offered once there is a gateway and a saved account).
227
+ // The gateway, and its separate account or sign-in prompt.
231
228
  const gateway = field('gateway');
232
229
  const gatewayAccount = field('gatewayAccount');
233
230
  gateway.value = o.gateway ?? '';
234
- gatewayAccount.append(new Option('The same as the desktop\'s', ''), ...accounts.map(a => new Option(label(a), a.id)));
235
- if (o.gatewayAccount && !accounts.some(a => a.id === o.gatewayAccount)) {
231
+ gatewayAccount.append(new Option('The same as the desktop\'s', ''), new Option('Ask for a gateway account when connecting', accounts_1.ASK_GATEWAY_ACCOUNT), ...accounts.map(a => new Option(label(a), a.id)));
232
+ if (o.gatewayAccount && o.gatewayAccount !== accounts_1.ASK_GATEWAY_ACCOUNT && !accounts.some(a => a.id === o.gatewayAccount)) {
236
233
  gatewayAccount.append(new Option('(a saved account that no longer exists)', o.gatewayAccount));
237
234
  }
238
235
  gatewayAccount.value = o.gatewayAccount ?? '';
@@ -241,10 +238,7 @@ let RDPProfileSettingsComponent = class RDPProfileSettingsComponent {
241
238
  root.querySelector('[data-for=gateway]').textContent = text && !(0, gateway_1.parseGateway)(text) ? 'A name or address, with :port if it isn\'t 443.' : '';
242
239
  root.querySelector('[data-gateway-account]').hidden = !text || gatewayAccount.options.length < 2;
243
240
  };
244
- gateway.addEventListener('input', () => {
245
- o.gateway = gateway.value.trim();
246
- gatewayChanged();
247
- });
241
+ gateway.addEventListener('input', gatewayChanged);
248
242
  gatewayAccount.addEventListener('change', () => { o.gatewayAccount = gatewayAccount.value; });
249
243
  gatewayChanged();
250
244
  // Its own clipboard sharing, or the setting's (named, as it is now).
@@ -265,10 +259,45 @@ let RDPProfileSettingsComponent = class RDPProfileSettingsComponent {
265
259
  }
266
260
  save() {
267
261
  const o = this.profile.options;
262
+ if (this.connectionInputs) {
263
+ const { address: addressInput, gateway: gatewayInput } = this.connectionInputs;
264
+ const addressText = addressInput.value.trim();
265
+ const address = (0, desktopForm_1.parseAddress)(addressText);
266
+ const gatewayText = gatewayInput.value.trim();
267
+ // Empty addresses are useful in group/type defaults. They clear the destination instead of retaining it.
268
+ const addressError = addressText && !address ? 'This doesn\'t look like an address.' : '';
269
+ const gatewayError = gatewayText && !(0, gateway_1.parseGateway)(gatewayText) ? 'A name or address, with :port if it isn\'t 443.' : '';
270
+ const root = this.element.nativeElement;
271
+ root.querySelector('[data-for=address]').textContent = addressError;
272
+ root.querySelector('[data-for=gateway]').textContent = gatewayError;
273
+ if (addressError || gatewayError) {
274
+ ;
275
+ (addressError ? addressInput : gatewayInput).focus();
276
+ // Tabby invokes this synchronous hook before cleaning the proxy and closing the modal. Throwing
277
+ // stops that Save operation; a return value would be ignored by the host.
278
+ throw new Error(`Cannot save this RDP profile: ${addressError || gatewayError}`);
279
+ }
280
+ // Commit the two connection fields together only after both drafts pass validation.
281
+ o.host = address?.host ?? '';
282
+ // A blank destination carries no port edit: keep custom/inherited ports in group and type defaults.
283
+ if (address) {
284
+ o.port = address.port;
285
+ }
286
+ o.gateway = gatewayText;
287
+ }
268
288
  const { id, account, via, gateway } = this.before;
269
289
  if (!id) {
270
290
  return;
271
291
  }
292
+ if (!o.host) {
293
+ // A saved profile's address cleared: the desktop it led to is gone from it, as when the profile is removed
294
+ // (see RDPProfilesService.deleteProfile). Not for the type's or a group's defaults, which have no id of
295
+ // their own (an empty address is useful there): profiles that name that address keep what is saved for it.
296
+ if (!via && this.profile.id) {
297
+ this.injector.get(desktop_service_1.RemoteDesktopService).forgetDesktop(id, true, `"${this.profile.name}"`).catch(() => null);
298
+ }
299
+ return;
300
+ }
272
301
  if ((o.via ?? '') !== via) {
273
302
  // Reached another way, it is another desktop. A direct one's saved passwords (through any gateway, the ones
274
303
  // it had before included) and certificate were its own; one behind a host shares them with that host's
@@ -374,8 +403,9 @@ let RDPProfilesService = class RDPProfilesService extends tabby_core_1.QuickConn
374
403
  }
375
404
  deleteProfile(profile) {
376
405
  // A direct desktop's saved passwords (through any gateway: one it went through before too) and certificate are
377
- // its own; one behind a host shares them with that host's desktop at the same address.
378
- if (!profile.options.via) {
406
+ // its own; one behind a host shares them with that host's desktop at the same address. A profile without an
407
+ // address of its own leads to no desktop of its own: desktopIdOf would name 127.0.0.1:3389 for it.
408
+ if (!profile.options.via && (0, desktops_1.configText)(profile.options.host)) {
379
409
  this.injector.get(desktop_service_1.RemoteDesktopService).forgetDesktop((0, desktops_1.desktopIdOf)(profile.options), true, `"${profile.name}"`).catch(() => null);
380
410
  }
381
411
  }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderReleaseSettings = renderReleaseSettings;
4
+ const help_1 = require("./help");
5
+ /** Rebuilt from manager state; every package mutation stays behind the manager's confirmation and validation. */
6
+ function renderReleaseSettings(root, releases, actions) {
7
+ const oldSelection = root.dataset.releaseChannel === releases.state.channel ? root.querySelector('[data-version]')?.value : undefined;
8
+ root.dataset.releaseChannel = releases.state.channel;
9
+ const selected = releases.versions.some(p => p.version === oldSelection) ? oldSelection : releases.recommended ?? '';
10
+ const blocked = releases.busy || releases.checking || !!releases.pending || releases.removed;
11
+ const disable = (condition) => condition ? 'disabled' : '';
12
+ const reason = selected ? releases.reason(selected) : null;
13
+ const stable = releases.catalog?.stable;
14
+ const previous = releases.state.previous;
15
+ const status = releases.checking ? 'Checking…' : releases.checkedAt !== null ? `Last checked ${new Date(releases.checkedAt).toLocaleString()}.` : 'Not checked';
16
+ root.innerHTML = `
17
+ <div class="trd-release-summary">
18
+ <div><span class="trd-sub">Running version</span><h4>tabby-rdp ${(0, help_1.esc)(releases.running)}</h4></div>
19
+ <button class="btn btn-link btn-sm" data-release-notes="${(0, help_1.esc)(releases.running)}">Release notes</button>
20
+ </div>
21
+ ${releases.pending ? `<div class="trd-release-notice" role="status"><b>Restart required to use ${(0, help_1.esc)(releases.pending)}.</b> Close remote desktops and restart Tabby.</div>` : ''}
22
+ ${releases.removed ? '<div class="trd-release-notice" role="status"><b>Plugin uninstalled.</b> Close remote desktops and restart Tabby.</div>' : ''}
23
+ ${releases.notice ? `<p role="status">${(0, help_1.esc)(releases.notice)}</p>` : ''}
24
+ ${releases.storageWarning ? `<p role="alert">${(0, help_1.esc)(releases.storageWarning)}</p>` : ''}
25
+ <div class="form-line">
26
+ <div class="header"><label class="title" for="trd-release-channel">Release channel</label><div class="description">Preview includes beta releases and release candidates.</div></div>
27
+ <select id="trd-release-channel" class="form-control" data-channel ${disable(blocked)}><option value="stable" ${releases.state.channel === 'stable' ? 'selected' : ''}>Stable</option><option value="preview" ${releases.state.channel === 'preview' ? 'selected' : ''}>Preview</option></select>
28
+ </div>
29
+ <label class="trd-release-pause"><input type="checkbox" data-pause ${disable(releases.busy)} ${!releases.state.paused ? 'checked' : ''}> Check for updates automatically</label>
30
+ <div class="trd-release-actions"><button class="btn btn-secondary btn-sm" data-check ${disable(releases.checking || releases.busy)}>Check now</button><span class="trd-sub" role="status">${(0, help_1.esc)(status)}</span></div>
31
+ ${releases.error ? `<p class="trd-release-error" role="alert">${(0, help_1.esc)(releases.error)}${releases.catalog ? ' Showing the last successful check.' : ''}</p>` : ''}
32
+ ${releases.state.channel === 'preview' && releases.catalog && !releases.catalog.preview ? '<p>No preview releases available.</p>' : ''}
33
+ ${releases.recommended ? `<p>Latest: <b>${(0, help_1.esc)(releases.recommended)}</b> <button class="btn btn-link btn-sm" data-release-notes="${(0, help_1.esc)(releases.recommended)}">Release notes</button></p>` : ''}
34
+ <h5><label for="trd-release-version">Version</label></h5>
35
+ <div class="trd-release-actions">
36
+ <select id="trd-release-version" class="form-control" data-version ${disable(blocked || !releases.versions.length)}>
37
+ ${!releases.versions.length ? '<option>No versions loaded</option>' : releases.versions.map(p => {
38
+ const why = releases.reason(p.version);
39
+ return `<option value="${(0, help_1.esc)(p.version)}" ${p.version === selected ? 'selected' : ''} ${disable(!!why)}>${(0, help_1.esc)(p.version)}${why ? ` — ${(0, help_1.esc)(why)}` : ''}</option>`;
40
+ }).join('')}
41
+ </select>
42
+ <button class="btn btn-primary btn-sm" data-install ${disable(blocked || !!releases.error || !selected || !!reason || selected === releases.running)}>Install selected version…</button>
43
+ <button class="btn btn-link btn-sm" data-selected-notes ${disable(!selected)}>Release notes</button>
44
+ </div>
45
+ <p class="trd-sub" data-version-reason ${reason ? '' : 'hidden'}>${(0, help_1.esc)(reason ?? '')}</p>
46
+ <div class="trd-release-actions">
47
+ ${stable && stable !== releases.running ? `<button class="btn btn-secondary btn-sm" data-stable ${disable(blocked || !!releases.error || !!releases.reason(stable))}>Return to stable ${(0, help_1.esc)(stable)}…</button>` : ''}
48
+ <button class="btn btn-secondary btn-sm" data-restore ${disable(blocked || !!releases.error || !previous || !!releases.reason(previous) || previous === releases.running)}>Restore previous version${previous ? ` (${(0, help_1.esc)(previous)})` : ''}…</button>
49
+ </div>
50
+ ${!previous ? '<p class="trd-sub">No previous version saved.</p>' : releases.reason(previous) ? `<p class="trd-sub">Previous version unavailable: ${(0, help_1.esc)(releases.reason(previous))}</p>` : ''}
51
+ <h5>Disable or uninstall</h5>
52
+ <p class="trd-sub">Saved connections, settings, passwords and remote setup are kept.</p>
53
+ <div class="trd-release-actions"><button class="btn btn-secondary btn-sm" data-plugins>Manage in Tabby…</button><button class="btn btn-outline-danger btn-sm" data-uninstall ${disable(blocked)}>Uninstall plugin…</button><a href="#" data-recovery>Cleanup instructions</a></div>`;
54
+ root.querySelector('[data-channel]').addEventListener('change', e => {
55
+ void releases.setChannel(e.target.value);
56
+ });
57
+ root.querySelector('[data-pause]').addEventListener('change', e => { void releases.setPaused(!e.target.checked); });
58
+ root.querySelector('[data-check]').addEventListener('click', () => { void releases.refreshCatalog(true); });
59
+ root.querySelector('[data-version]').addEventListener('change', () => renderReleaseSettings(root, releases, actions));
60
+ root.querySelector('[data-install]').addEventListener('click', () => { void releases.install(selected); });
61
+ root.querySelector('[data-stable]')?.addEventListener('click', () => { if (stable) {
62
+ void releases.install(stable, true);
63
+ } });
64
+ root.querySelector('[data-restore]').addEventListener('click', () => { if (previous) {
65
+ void releases.install(previous);
66
+ } });
67
+ root.querySelector('[data-uninstall]').addEventListener('click', () => { void releases.uninstall(); });
68
+ root.querySelector('[data-plugins]').addEventListener('click', actions.plugins);
69
+ root.querySelector('[data-recovery]').addEventListener('click', e => { e.preventDefault(); actions.link(`${help_1.TROUBLESHOOTING_URL}#recovery`); });
70
+ root.querySelector('[data-selected-notes]').addEventListener('click', () => actions.notes(selected));
71
+ root.querySelectorAll('[data-release-notes]').forEach(el => el.addEventListener('click', () => actions.notes(el.dataset.releaseNotes)));
72
+ }