claude-remote 0.5.2 → 0.6.1

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,619 @@
1
+ // ============================================================
2
+ // Hub — server list management & connect screen
3
+ // ============================================================
4
+ import {
5
+ STORAGE_KEY, LAST_KEY, SERVERS_MAX,
6
+ HUB_PROBE_INTERVAL_MS, HUB_PROBE_TIMEOUT_MS, HUB_PROBE_FAST_RETRY_MS,
7
+ HUB_PROBE_FAILS_TO_OFFLINE,
8
+ } from './constants.js';
9
+ import { $, esc, timeAgo, parseServerAddress, generateServerId } from './utils.js';
10
+ import {
11
+ S, serverAddr, serverWsUrl, serverCacheAddr, serverToken,
12
+ setServerAddr, setServerWsUrl, setServerCacheAddr, setServerToken,
13
+ } from './state.js';
14
+ import { showToast } from './toast.js';
15
+ import { showConfirm } from './confirm.js';
16
+ import { isAuthReadyMessage, connect, clearForegroundProbe } from './websocket.js';
17
+ import { clearConversationUi, updateHeaderInfo, flushSessionCacheSave } from './renderer.js';
18
+
19
+ let hubStatus = new Map();
20
+ let hubProbeTimer = null;
21
+ let hubEditingServerId = null;
22
+ let hubRetryTimers = new Map();
23
+ let hubConnectingServerId = null;
24
+
25
+ function normalizeServerEntry(raw) {
26
+ const base = (raw && typeof raw === 'object') ? raw : { addr: raw };
27
+ const parsed = parseServerAddress(String(base.addr || ''));
28
+ return {
29
+ id: typeof base.id === 'string' && base.id ? base.id : generateServerId(),
30
+ addr: parsed.ok ? parsed.displayAddr : String(base.addr || '').trim(),
31
+ wsUrl: parsed.ok ? parsed.wsUrl : String(base.wsUrl || ''),
32
+ cacheAddr: parsed.ok ? parsed.cacheAddr : String(base.cacheAddr || ''),
33
+ alias: typeof base.alias === 'string' ? base.alias.trim() : '',
34
+ token: typeof base.token === 'string' ? base.token : '',
35
+ addedAt: Number.isFinite(base.addedAt) ? base.addedAt : Date.now(),
36
+ lastConnectedAt: Number.isFinite(base.lastConnectedAt) ? base.lastConnectedAt : 0,
37
+ };
38
+ }
39
+
40
+ function getServerDedupKey(server) {
41
+ if (server.wsUrl) return `ws:${server.wsUrl}`;
42
+ const parsed = parseServerAddress(server.addr || '');
43
+ if (parsed.ok) return `ws:${parsed.wsUrl}`;
44
+ return `addr:${String(server.addr || '').trim().toLowerCase()}`;
45
+ }
46
+
47
+ function getServerDisplayName(server) {
48
+ return (server.alias || server.addr || '').trim();
49
+ }
50
+
51
+ function normalizeServerList(list) {
52
+ if (!Array.isArray(list)) return [];
53
+ const seen = new Set();
54
+ const normalized = [];
55
+ list.forEach(item => {
56
+ const entry = normalizeServerEntry(item);
57
+ const key = getServerDedupKey(entry);
58
+ if (seen.has(key)) return;
59
+ seen.add(key);
60
+ normalized.push(entry);
61
+ });
62
+ return normalized.slice(0, SERVERS_MAX);
63
+ }
64
+
65
+ export function getSavedServers() {
66
+ try {
67
+ const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
68
+ return normalizeServerList(raw);
69
+ } catch { return []; }
70
+ }
71
+
72
+ function saveServerList(list) {
73
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(normalizeServerList(list)));
74
+ }
75
+
76
+ function findDuplicateServer(list, wsUrl, excludeId = null) {
77
+ return list.find(server => server.id !== excludeId && getServerDedupKey(server) === `ws:${wsUrl}`);
78
+ }
79
+
80
+ function addServer(addr, alias, token) {
81
+ const parsed = parseServerAddress(addr);
82
+ if (!parsed.ok) return { ok: false, error: parsed.error };
83
+ let list = getSavedServers();
84
+ if (findDuplicateServer(list, parsed.wsUrl)) {
85
+ return { ok: false, error: 'Server already exists' };
86
+ }
87
+ const entry = {
88
+ id: generateServerId(),
89
+ addr: parsed.displayAddr,
90
+ wsUrl: parsed.wsUrl,
91
+ cacheAddr: parsed.cacheAddr,
92
+ alias: (alias || '').trim(),
93
+ token: (token || '').trim(),
94
+ addedAt: Date.now(),
95
+ lastConnectedAt: 0,
96
+ };
97
+ list.unshift(entry);
98
+ saveServerList(list);
99
+ return { ok: true, entry };
100
+ }
101
+
102
+ export function saveServer(addr) {
103
+ let list = getSavedServers();
104
+ const idx = list.findIndex(s => s.addr === addr || s.wsUrl === serverWsUrl);
105
+ if (idx >= 0) {
106
+ list[idx].lastConnectedAt = Date.now();
107
+ }
108
+ saveServerList(list);
109
+ localStorage.setItem(LAST_KEY, addr);
110
+ }
111
+
112
+ function removeServer(id) {
113
+ let list = getSavedServers();
114
+ const idx = list.findIndex(s => s.id === id);
115
+ if (idx < 0) return null;
116
+ const removed = list[idx];
117
+ list.splice(idx, 1);
118
+ saveServerList(list);
119
+ const last = localStorage.getItem(LAST_KEY) || '';
120
+ if (removed && (last === removed.addr || last === removed.wsUrl || last === removed.cacheAddr)) {
121
+ localStorage.removeItem(LAST_KEY);
122
+ }
123
+ return removed;
124
+ }
125
+
126
+ function migrateServerList() {
127
+ const rawText = localStorage.getItem(STORAGE_KEY);
128
+ if (!rawText) return;
129
+ try {
130
+ const raw = JSON.parse(rawText);
131
+ const normalized = normalizeServerList(raw);
132
+ if (JSON.stringify(raw) !== JSON.stringify(normalized)) saveServerList(normalized);
133
+ } catch {
134
+ saveServerList([]);
135
+ }
136
+ }
137
+
138
+ // ---- Hub probe ----
139
+ function createHubProbeInfo() {
140
+ return {
141
+ status: 'probing',
142
+ latencyMs: null,
143
+ lastProbeAt: 0,
144
+ lastSuccessAt: 0,
145
+ consecutiveFailures: 0,
146
+ probeToken: 0,
147
+ };
148
+ }
149
+
150
+ function getHubProbeInfo(serverId) {
151
+ if (!hubStatus.has(serverId)) hubStatus.set(serverId, createHubProbeInfo());
152
+ return hubStatus.get(serverId);
153
+ }
154
+
155
+ function resetHubProbeInfo(serverId) {
156
+ const previous = getHubProbeInfo(serverId);
157
+ const next = createHubProbeInfo();
158
+ next.probeToken = previous.probeToken;
159
+ hubStatus.set(serverId, next);
160
+ return next;
161
+ }
162
+
163
+ function syncHubProbeState(servers) {
164
+ const ids = new Set(servers.map(server => server.id));
165
+ Array.from(hubStatus.keys()).forEach(id => {
166
+ if (ids.has(id)) return;
167
+ hubStatus.delete(id);
168
+ if (hubRetryTimers.has(id)) {
169
+ clearTimeout(hubRetryTimers.get(id));
170
+ hubRetryTimers.delete(id);
171
+ }
172
+ });
173
+ }
174
+
175
+ function hubPingTone(latencyMs) {
176
+ if (!Number.isFinite(latencyMs)) return 'offline';
177
+ if (latencyMs <= 120) return 'excellent';
178
+ if (latencyMs <= 300) return 'good';
179
+ if (latencyMs <= 800) return 'warn';
180
+ return 'bad';
181
+ }
182
+
183
+ function renderHubPing(info) {
184
+ const classes = ['hub-card-ping'];
185
+ let label = '--';
186
+
187
+ if (info.status === 'probing') {
188
+ classes.push('probing');
189
+ label = '...';
190
+ } else if (info.status === 'offline') {
191
+ classes.push('offline');
192
+ } else if (Number.isFinite(info.latencyMs)) {
193
+ classes.push(hubPingTone(info.latencyMs));
194
+ label = `${Math.round(info.latencyMs)}ms`;
195
+ if (info.status === 'unstable') {
196
+ classes.push('stale');
197
+ label = `~${label}`;
198
+ }
199
+ }
200
+
201
+ return `<span class="${classes.join(' ')}">${label}</span>`;
202
+ }
203
+
204
+ export function renderHubCards() {
205
+ const servers = getSavedServers();
206
+ syncHubProbeState(servers);
207
+ const empty = $('hub-empty');
208
+ const secOnline = $('hub-section-online');
209
+ const secOffline = $('hub-section-offline');
210
+ const listOnline = $('hub-list-online');
211
+ const listOffline = $('hub-list-offline');
212
+
213
+ if (servers.length === 0) {
214
+ empty.style.display = '';
215
+ secOnline.style.display = 'none';
216
+ secOffline.style.display = 'none';
217
+ return;
218
+ }
219
+ empty.style.display = 'none';
220
+
221
+ const onlineCards = [];
222
+ const offlineCards = [];
223
+
224
+ servers.forEach(s => {
225
+ const probe = getHubProbeInfo(s.id);
226
+ const status = probe.status || 'probing';
227
+ const displayName = getServerDisplayName(s);
228
+ const showAddr = s.alias ? s.addr : '';
229
+ const isConnecting = hubConnectingServerId === s.id;
230
+ const card = `<div class="hub-card" data-server-id="${esc(s.id)}">
231
+ <div class="hub-card-status ${status}"></div>
232
+ <div class="hub-card-info">
233
+ <div class="hub-card-name">${esc(displayName)}</div>
234
+ ${showAddr ? `<div class="hub-card-addr">${esc(showAddr)}</div>` : ''}
235
+ </div>
236
+ <div class="hub-card-side">
237
+ ${isConnecting ? '<span class="hub-card-ping probing">...</span>' : renderHubPing(probe)}
238
+ <span class="hub-card-time">${esc(timeAgo(s.lastConnectedAt))}</span>
239
+ </div>
240
+ <button class="hub-card-edit" data-edit-id="${esc(s.id)}" title="Edit">
241
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
242
+ </button>
243
+ </div>`;
244
+
245
+ if (status === 'online' || status === 'unstable') onlineCards.push(card);
246
+ else offlineCards.push(card);
247
+ });
248
+
249
+ secOnline.style.display = onlineCards.length ? '' : 'none';
250
+ secOffline.style.display = offlineCards.length ? '' : 'none';
251
+ listOnline.innerHTML = onlineCards.join('');
252
+ listOffline.innerHTML = offlineCards.join('');
253
+
254
+ document.querySelectorAll('.hub-card').forEach(card => {
255
+ card.addEventListener('click', (e) => {
256
+ if (e.target.closest('.hub-card-edit')) {
257
+ e.stopPropagation();
258
+ openEditServerDialog(e.target.closest('.hub-card-edit').dataset.editId);
259
+ return;
260
+ }
261
+ connectToServer(card.dataset.serverId);
262
+ });
263
+ });
264
+ }
265
+
266
+ export function showHubConnectOverlay(server) {
267
+ hubConnectingServerId = server && server.id ? server.id : null;
268
+ const sub = $('hub-connect-sub');
269
+ sub.textContent = server ? `Connecting to ${getServerDisplayName(server)}` : 'Preparing server session';
270
+ $('hub-connect-overlay').classList.add('visible');
271
+ }
272
+
273
+ export function hideHubConnectOverlay() {
274
+ hubConnectingServerId = null;
275
+ $('hub-connect-overlay').classList.remove('visible');
276
+ }
277
+
278
+ function clearHubRetry(serverId) {
279
+ if (!hubRetryTimers.has(serverId)) return;
280
+ clearTimeout(hubRetryTimers.get(serverId));
281
+ hubRetryTimers.delete(serverId);
282
+ }
283
+
284
+ function scheduleHubRetry(serverId) {
285
+ if (hubRetryTimers.has(serverId)) return;
286
+ const timer = setTimeout(() => {
287
+ hubRetryTimers.delete(serverId);
288
+ if ($('connect-screen').classList.contains('hidden')) return;
289
+ const server = getSavedServers().find(item => item.id === serverId);
290
+ if (!server) return;
291
+ runHubProbes([server]);
292
+ }, HUB_PROBE_FAST_RETRY_MS);
293
+ hubRetryTimers.set(serverId, timer);
294
+ }
295
+
296
+ function probeServer(server) {
297
+ return new Promise(resolve => {
298
+ if (!server.wsUrl) { resolve({ ok: false, latencyMs: null }); return; }
299
+ let done = false;
300
+ const startedAt = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
301
+ const finish = (ok) => {
302
+ if (done) return;
303
+ done = true;
304
+ const endedAt = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
305
+ resolve({
306
+ ok,
307
+ latencyMs: ok ? Math.max(1, Math.round(endedAt - startedAt)) : null,
308
+ });
309
+ };
310
+ try {
311
+ const ws = new WebSocket(server.wsUrl);
312
+ const timer = setTimeout(() => { try { ws.close(); } catch {} finish(false); }, HUB_PROBE_TIMEOUT_MS);
313
+ ws.onopen = () => {
314
+ try {
315
+ ws.send(JSON.stringify({
316
+ type: 'hello',
317
+ clientInstanceId: `hub_probe_${Date.now().toString(36)}`,
318
+ token: server.token || '',
319
+ page: '/hub-probe',
320
+ userAgent: navigator.userAgent || '',
321
+ }));
322
+ } catch {
323
+ clearTimeout(timer);
324
+ finish(false);
325
+ }
326
+ };
327
+ ws.onmessage = (event) => {
328
+ let msg;
329
+ try { msg = JSON.parse(event.data); } catch { return; }
330
+ if (!isAuthReadyMessage(msg)) return;
331
+ clearTimeout(timer);
332
+ try { ws.close(); } catch {}
333
+ finish(true);
334
+ };
335
+ ws.onerror = () => { clearTimeout(timer); finish(false); };
336
+ ws.onclose = () => { clearTimeout(timer); if (!done) finish(false); };
337
+ } catch { finish(false); }
338
+ });
339
+ }
340
+
341
+ function applyHubProbeResult(server, result) {
342
+ const info = getHubProbeInfo(server.id);
343
+ info.lastProbeAt = Date.now();
344
+
345
+ if (result.ok) {
346
+ clearHubRetry(server.id);
347
+ info.status = 'online';
348
+ info.latencyMs = result.latencyMs;
349
+ info.lastSuccessAt = info.lastProbeAt;
350
+ info.consecutiveFailures = 0;
351
+ return;
352
+ }
353
+
354
+ info.consecutiveFailures += 1;
355
+ if (info.lastSuccessAt && info.consecutiveFailures < HUB_PROBE_FAILS_TO_OFFLINE) {
356
+ info.status = 'unstable';
357
+ scheduleHubRetry(server.id);
358
+ return;
359
+ }
360
+
361
+ clearHubRetry(server.id);
362
+ info.status = 'offline';
363
+ info.latencyMs = null;
364
+ }
365
+
366
+ async function runHubProbes(servers, { markUnknown = false } = {}) {
367
+ if (!servers.length) return;
368
+ const results = await Promise.all(servers.map(server => {
369
+ const info = getHubProbeInfo(server.id);
370
+ const token = ++info.probeToken;
371
+ if (markUnknown && !info.lastProbeAt) info.status = 'probing';
372
+ return probeServer(server).then(result => ({ server, result, token }));
373
+ }));
374
+ results.forEach(({ server, result, token }) => {
375
+ const current = hubStatus.get(server.id);
376
+ if (!current || current.probeToken !== token) return;
377
+ applyHubProbeResult(server, result);
378
+ });
379
+ renderHubCards();
380
+ }
381
+
382
+ async function probeAllServers() {
383
+ const servers = getSavedServers();
384
+ syncHubProbeState(servers);
385
+ if (servers.length === 0) {
386
+ renderHubCards();
387
+ return;
388
+ }
389
+ renderHubCards();
390
+ await runHubProbes(servers, { markUnknown: true });
391
+ }
392
+
393
+ export function startHubProbes() {
394
+ stopHubProbes();
395
+ probeAllServers();
396
+ hubProbeTimer = setInterval(() => probeAllServers(), HUB_PROBE_INTERVAL_MS);
397
+ }
398
+
399
+ export function stopHubProbes() {
400
+ if (hubProbeTimer) { clearInterval(hubProbeTimer); hubProbeTimer = null; }
401
+ Array.from(hubRetryTimers.values()).forEach(timer => clearTimeout(timer));
402
+ hubRetryTimers.clear();
403
+ }
404
+
405
+ export function connectToServer(serverId) {
406
+ if (hubConnectingServerId) return;
407
+ const servers = getSavedServers();
408
+ const s = servers.find(x => x.id === serverId);
409
+ if (!s) { showToast('Server not found'); return; }
410
+ if (!s.wsUrl) {
411
+ const parsed = parseServerAddress(s.addr);
412
+ if (!parsed.ok) { showToast('Invalid server address'); return; }
413
+ s.wsUrl = parsed.wsUrl;
414
+ s.cacheAddr = parsed.cacheAddr;
415
+ saveServerList(servers);
416
+ }
417
+ setServerAddr(s.addr);
418
+ setServerWsUrl(s.wsUrl);
419
+ setServerCacheAddr(s.cacheAddr);
420
+ setServerToken(s.token || '');
421
+ showHubConnectOverlay(s);
422
+ renderHubCards();
423
+ connect();
424
+ }
425
+
426
+ // ---- Hub add/edit dialog ----
427
+ function openAddServerDialog() {
428
+ hubEditingServerId = null;
429
+ $('hub-dialog-title').textContent = 'Add Server';
430
+ $('hub-dialog-addr').value = '';
431
+ $('hub-dialog-alias').value = '';
432
+ $('hub-dialog-token').value = '';
433
+ $('hub-dialog-token').type = 'password';
434
+ $('hub-dialog-error').textContent = '';
435
+ $('hub-dialog-delete').style.display = 'none';
436
+ $('hub-add-overlay').classList.add('visible');
437
+ $('hub-dialog-addr').focus();
438
+ }
439
+
440
+ export function openEditServerDialog(id) {
441
+ const servers = getSavedServers();
442
+ const s = servers.find(x => x.id === id);
443
+ if (!s) return;
444
+ hubEditingServerId = id;
445
+ $('hub-dialog-title').textContent = 'Edit Server';
446
+ $('hub-dialog-addr').value = s.addr;
447
+ $('hub-dialog-alias').value = s.alias || '';
448
+ $('hub-dialog-token').value = s.token || '';
449
+ $('hub-dialog-token').type = 'password';
450
+ $('hub-dialog-error').textContent = '';
451
+ $('hub-dialog-delete').style.display = '';
452
+ $('hub-add-overlay').classList.add('visible');
453
+ $('hub-dialog-addr').focus();
454
+ }
455
+
456
+ function closeServerDialog() {
457
+ $('hub-add-overlay').classList.remove('visible');
458
+ hubEditingServerId = null;
459
+ }
460
+
461
+ function saveServerDialog() {
462
+ const addr = $('hub-dialog-addr').value.trim();
463
+ const alias = $('hub-dialog-alias').value.trim();
464
+ const token = $('hub-dialog-token').value.trim();
465
+ if (!addr) { $('hub-dialog-error').textContent = 'Please enter a server address'; return; }
466
+ const parsed = parseServerAddress(addr);
467
+ if (!parsed.ok) { $('hub-dialog-error').textContent = parsed.error; return; }
468
+
469
+ if (hubEditingServerId) {
470
+ let list = getSavedServers();
471
+ const s = list.find(x => x.id === hubEditingServerId);
472
+ if (s) {
473
+ const duplicate = findDuplicateServer(list, parsed.wsUrl, hubEditingServerId);
474
+ if (duplicate) {
475
+ $('hub-dialog-error').textContent = 'Server already exists';
476
+ return;
477
+ }
478
+ s.addr = parsed.displayAddr;
479
+ s.wsUrl = parsed.wsUrl;
480
+ s.cacheAddr = parsed.cacheAddr;
481
+ s.alias = alias;
482
+ s.token = token;
483
+ saveServerList(list);
484
+ resetHubProbeInfo(s.id);
485
+ clearHubRetry(s.id);
486
+ runHubProbes([s], { markUnknown: true });
487
+ }
488
+ } else {
489
+ const result = addServer(addr, alias, token);
490
+ if (!result.ok) { $('hub-dialog-error').textContent = result.error || 'Invalid address'; return; }
491
+ hubStatus.set(result.entry.id, createHubProbeInfo());
492
+ runHubProbes([result.entry], { markUnknown: true });
493
+ }
494
+ closeServerDialog();
495
+ renderHubCards();
496
+ }
497
+
498
+ async function deleteServerFromDialog() {
499
+ if (!hubEditingServerId) return;
500
+ const id = hubEditingServerId;
501
+ const servers = getSavedServers();
502
+ const target = servers.find(x => x.id === id) || null;
503
+ const deletingCurrent = !!target && (
504
+ (!!serverWsUrl && target.wsUrl === serverWsUrl) ||
505
+ (!!serverAddr && target.addr === serverAddr) ||
506
+ (!!serverCacheAddr && target.cacheAddr === serverCacheAddr)
507
+ );
508
+ const deletingConnecting = hubConnectingServerId === id;
509
+ closeServerDialog();
510
+ const ok = await showConfirm('Delete this server?');
511
+ if (!ok) { renderHubCards(); return; }
512
+ const removed = removeServer(id);
513
+ hubStatus.delete(id);
514
+ clearHubRetry(id);
515
+ if (deletingConnecting) {
516
+ hubConnectingServerId = null;
517
+ hideHubConnectOverlay();
518
+ }
519
+ if (deletingCurrent || deletingConnecting || (removed && removed.wsUrl === serverWsUrl)) {
520
+ if (S.reconnectTimer) { clearTimeout(S.reconnectTimer); S.reconnectTimer = null; }
521
+ const hadWs = !!S.ws;
522
+ S.intentionalDisconnect = hadWs;
523
+ S.skipNextCloseHandling = hadWs;
524
+ if (S.ws) {
525
+ try { S.ws.close(); } catch {}
526
+ } else {
527
+ S.intentionalDisconnect = false;
528
+ S.skipNextCloseHandling = false;
529
+ }
530
+ setServerAddr('');
531
+ setServerWsUrl('');
532
+ setServerCacheAddr('');
533
+ setServerToken('');
534
+ localStorage.removeItem(LAST_KEY);
535
+ resetAppState();
536
+ showConnectScreen();
537
+ }
538
+ renderHubCards();
539
+ }
540
+
541
+ export function resetAppState() {
542
+ clearForegroundProbe('reset_app_state');
543
+ S.ws = null;
544
+ S.authenticated = false;
545
+ S.sessionId = '';
546
+ S.resumeRequestedFor = '';
547
+ S.lastMessageAt = 0;
548
+ S.sessionSyncToken = 0;
549
+ S.turnStateVersion = 0;
550
+ S.pendingTurnState = null;
551
+ S.pendingPlanContent = '';
552
+ S.cwd = '';
553
+ S.model = '';
554
+ S.pendingPerms = [];
555
+ S.replaying = true;
556
+ S.intentionalDisconnect = false;
557
+ clearConversationUi();
558
+ $('input').value = '';
559
+ updateHeaderInfo();
560
+ $('perm-overlay').classList.remove('visible');
561
+ }
562
+
563
+ export function showConnectScreen() {
564
+ hideHubConnectOverlay();
565
+ $('connect-screen').classList.remove('hidden');
566
+ $('app').classList.add('hidden');
567
+ hubStatus.clear();
568
+ renderHubCards();
569
+ startHubProbes();
570
+ }
571
+
572
+ export function showApp() {
573
+ hideHubConnectOverlay();
574
+ stopHubProbes();
575
+ $('connect-screen').classList.add('hidden');
576
+ $('app').classList.remove('hidden');
577
+ saveServer(serverAddr);
578
+ }
579
+
580
+ export function initHub() {
581
+ migrateServerList();
582
+ renderHubCards();
583
+ startHubProbes();
584
+
585
+ $('hub-add-btn').addEventListener('click', openAddServerDialog);
586
+ $('hub-dialog-cancel').addEventListener('click', closeServerDialog);
587
+ $('hub-dialog-save').addEventListener('click', saveServerDialog);
588
+ $('hub-dialog-delete').addEventListener('click', deleteServerFromDialog);
589
+ $('hub-add-overlay').addEventListener('click', (e) => {
590
+ if (e.target === $('hub-add-overlay')) closeServerDialog();
591
+ });
592
+ $('hub-dialog-addr').addEventListener('keydown', e => {
593
+ if (e.key === 'Enter') saveServerDialog();
594
+ });
595
+ $('hub-dialog-token-toggle').addEventListener('click', () => {
596
+ const inp = $('hub-dialog-token');
597
+ inp.type = inp.type === 'password' ? 'text' : 'password';
598
+ });
599
+
600
+ // Back button
601
+ $('btn-back').addEventListener('click', () => {
602
+ (async () => {
603
+ const hadWs = !!S.ws;
604
+ S.intentionalDisconnect = hadWs;
605
+ S.skipNextCloseHandling = hadWs;
606
+ try {
607
+ await flushSessionCacheSave();
608
+ } catch {}
609
+ if (S.ws) S.ws.close();
610
+ else {
611
+ S.intentionalDisconnect = false;
612
+ S.skipNextCloseHandling = false;
613
+ }
614
+ if (S.reconnectTimer) { clearTimeout(S.reconnectTimer); S.reconnectTimer = null; }
615
+ resetAppState();
616
+ showConnectScreen();
617
+ })();
618
+ });
619
+ }