cli-tunnel 1.2.0-beta.9 → 1.2.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/remote-ui/app.js CHANGED
@@ -47,7 +47,10 @@
47
47
  const termContainer = $('#terminal-container');
48
48
  let currentView = 'terminal'; // 'dashboard', 'terminal', or 'grid'
49
49
  let cachedSessions = [];
50
- let gridTerminals = []; // { xterm, fitAddon, ws, session }
50
+ let gridTerminals = []; // { xterm, fitAddon, ws, session, panel }
51
+ var gridMode = 'thumbnails';
52
+ var focusedIndex = 0;
53
+ var tmuxPreset = 'equal';
51
54
 
52
55
  // ─── xterm.js Terminal ───────────────────────────────────
53
56
  let xterm = null;
@@ -117,7 +120,9 @@
117
120
 
118
121
  async function loadSessions() {
119
122
  try {
120
- const resp = await fetch('/api/sessions');
123
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
124
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
125
+ const resp = await fetch('/api/sessions', { headers });
121
126
  const data = await resp.json();
122
127
  renderDashboard(data.sessions || []);
123
128
  } catch (err) {
@@ -146,17 +151,17 @@
146
151
  '</div>';
147
152
  } else {
148
153
  html += filtered.map(s => {
149
- const sessionUrl = s.token ? s.url + '?token=' + encodeURIComponent(s.token) : '';
154
+ const hasAccess = s.hasToken;
150
155
  return `
151
- <div class="session-card" ${s.online && sessionUrl ? 'data-session-url="' + escapeHtml(sessionUrl) + '"' : ''}>
156
+ <div class="session-card" ${s.online && hasAccess ? 'data-session-port="' + s.port + '" data-session-base-url="' + escapeHtml(s.url) + '"' : ''}>
152
157
  <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
153
158
  <div class="info">
154
159
  <div class="session-name">${escapeHtml(s.name)}</div>
155
160
  <div class="repo">📦 ${escapeHtml(s.repo)}</div>
156
161
  <div class="branch">🌿 ${escapeHtml(s.branch)}</div>
157
- <div class="machine">💻 ${escapeHtml(s.machine)}${!s.token && s.online ? ' 🔒' : ''}</div>
162
+ <div class="machine">💻 ${escapeHtml(s.machine)}${!hasAccess && s.online ? ' 🔒' : ''}</div>
158
163
  </div>
159
- ${s.online && sessionUrl ? '<span class="arrow">→</span>' :
164
+ ${s.online && hasAccess ? '<span class="arrow">→</span>' :
160
165
  !s.online ? '<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'
161
166
  : '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
162
167
  </div>`;
@@ -165,8 +170,25 @@
165
170
  dashboard.innerHTML = html;
166
171
  cachedSessions = sessions;
167
172
  // Event delegation
168
- dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
169
- card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
173
+ dashboard.querySelectorAll('.session-card[data-session-port]').forEach(function(card) {
174
+ card.addEventListener('click', function() {
175
+ var port = card.dataset.sessionPort;
176
+ var baseUrl = card.dataset.sessionBaseUrl;
177
+ var tokenParam = new URLSearchParams(window.location.search).get('token');
178
+ var proxyUrl = '/api/proxy/ticket/' + port;
179
+ fetch(proxyUrl, {
180
+ method: 'POST',
181
+ headers: tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {}
182
+ }).then(function(r) { return r.json(); }).then(function(data) {
183
+ if (data.ticket) {
184
+ window.location.href = baseUrl + '?ticket=' + encodeURIComponent(data.ticket);
185
+ } else {
186
+ window.location.href = baseUrl;
187
+ }
188
+ }).catch(function() {
189
+ window.location.href = baseUrl;
190
+ });
191
+ });
170
192
  });
171
193
  dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
172
194
  btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
@@ -187,29 +209,36 @@
187
209
  };
188
210
 
189
211
  window.cleanOffline = async () => {
190
- const resp = await fetch('/api/sessions');
212
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
213
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
214
+ const resp = await fetch('/api/sessions', { headers });
191
215
  const data = await resp.json();
192
216
  const offline = (data.sessions || []).filter(s => !s.online);
193
217
  for (const s of offline) {
194
- await fetch('/api/sessions/' + s.id, { method: 'DELETE' });
218
+ await fetch('/api/sessions/' + s.id, { method: 'DELETE', headers });
195
219
  }
196
220
  loadSessions();
197
221
  };
198
222
 
199
223
  window.deleteSession = async (id) => {
200
- await fetch('/api/sessions/' + id, { method: 'DELETE' });
224
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
225
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
226
+ await fetch('/api/sessions/' + id, { method: 'DELETE', headers });
201
227
  loadSessions();
202
228
  };
203
229
 
204
- // ─── Grid View (tmux-style multi-terminal) ────────────────
230
+ // ─── Grid View (multi-terminal with layout modes) ───────────
205
231
  function showGridView(sessions) {
206
- const connectable = sessions.filter(function(s) { return s.online && s.token; });
232
+ var connectable = sessions.filter(function(s) { return s.online && s.token; });
207
233
  if (connectable.length === 0) return;
208
234
 
209
235
  // Clean up previous grid
210
236
  destroyGrid();
211
237
 
212
238
  currentView = 'grid';
239
+ gridMode = 'thumbnails';
240
+ focusedIndex = 0;
241
+ tmuxPreset = 'equal';
213
242
  dashboard.classList.add('hidden');
214
243
  terminal.classList.add('hidden');
215
244
  termContainer.classList.add('hidden');
@@ -224,30 +253,95 @@
224
253
  gridEl.classList.remove('hidden');
225
254
  gridEl.innerHTML = '';
226
255
 
227
- // Calculate grid dimensions
228
- var cols = connectable.length <= 2 ? connectable.length : connectable.length <= 4 ? 2 : 3;
229
- gridEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
256
+ // ── Toolbar ──
257
+ var toolbar = document.createElement('div');
258
+ toolbar.className = 'grid-toolbar';
259
+
260
+ var modes = [
261
+ { id: 'thumbnails', label: '\u229E Tiles' },
262
+ { id: 'tmux', label: '\u229F Tmux' },
263
+ { id: 'focus', label: '\u25C9 Focus' },
264
+ { id: 'fullscreen', label: '\u2A21 Full' }
265
+ ];
266
+ modes.forEach(function(m) {
267
+ var btn = document.createElement('button');
268
+ btn.textContent = m.label;
269
+ btn.dataset.mode = m.id;
270
+ if (m.id === gridMode) btn.classList.add('active');
271
+ btn.addEventListener('click', function() { switchGridMode(m.id); });
272
+ toolbar.appendChild(btn);
273
+ });
274
+
275
+ // Tmux preset buttons (visible only in tmux mode)
276
+ var presetGroup = document.createElement('span');
277
+ presetGroup.className = 'grid-toolbar-presets hidden';
278
+ presetGroup.id = 'tmux-presets';
279
+ var presets = [
280
+ { id: 'equal', label: '\u2550 Equal' },
281
+ { id: 'main-side', label: '\u2590 Main+Side' },
282
+ { id: 'stacked', label: '\u2261 Stacked' }
283
+ ];
284
+ presets.forEach(function(p) {
285
+ var btn = document.createElement('button');
286
+ btn.textContent = p.label;
287
+ btn.dataset.preset = p.id;
288
+ if (p.id === tmuxPreset) btn.classList.add('active');
289
+ btn.addEventListener('click', function() { switchTmuxPreset(p.id); });
290
+ presetGroup.appendChild(btn);
291
+ });
292
+ toolbar.appendChild(presetGroup);
293
+
294
+ var spacer = document.createElement('span');
295
+ spacer.className = 'spacer';
296
+ toolbar.appendChild(spacer);
297
+
298
+ var listBtn = document.createElement('button');
299
+ listBtn.textContent = '\u2190 List';
300
+ listBtn.addEventListener('click', function() {
301
+ destroyGrid();
302
+ currentView = 'dashboard';
303
+ dashboard.classList.remove('hidden');
304
+ if ($('#btn-sessions')) $('#btn-sessions').textContent = 'Terminal';
305
+ loadSessions();
306
+ });
307
+ toolbar.appendChild(listBtn);
308
+ gridEl.appendChild(toolbar);
309
+
310
+ // ── Content container ──
311
+ var contentEl = document.createElement('div');
312
+ contentEl.id = 'grid-content';
313
+ gridEl.appendChild(contentEl);
230
314
 
231
- connectable.forEach(function(s) {
315
+ // ── Create panels & connect ──
316
+ connectable.forEach(function(s, index) {
232
317
  var panel = document.createElement('div');
233
318
  panel.className = 'grid-panel';
319
+ panel.dataset.index = index;
234
320
 
235
- // Header
236
321
  var header = document.createElement('div');
237
322
  header.className = 'grid-panel-header';
238
- header.innerHTML = '<span class="grid-panel-name">' + escapeHtml(s.name) + '</span>' +
239
- '<span class="grid-panel-machine">' + escapeHtml(s.machine) + '</span>' +
240
- '<span class="grid-panel-status">●</span>';
323
+ var nameSpan = document.createElement('span');
324
+ nameSpan.className = 'grid-panel-name';
325
+ nameSpan.textContent = s.name;
326
+ var machineSpan = document.createElement('span');
327
+ machineSpan.className = 'grid-panel-machine';
328
+ machineSpan.textContent = s.machine;
329
+ var statusDot = document.createElement('span');
330
+ statusDot.className = 'grid-panel-status';
331
+ statusDot.textContent = '\u25CF';
332
+ header.appendChild(nameSpan);
333
+ header.appendChild(machineSpan);
334
+ header.appendChild(statusDot);
241
335
  panel.appendChild(header);
242
336
 
243
- // Terminal container
244
337
  var termDiv = document.createElement('div');
245
338
  termDiv.className = 'grid-panel-terminal';
246
339
  panel.appendChild(termDiv);
247
340
 
248
- gridEl.appendChild(panel);
341
+ // Append to contentEl so xterm.open has a DOM-attached container
342
+ contentEl.appendChild(panel);
249
343
 
250
- // Create xterm instance for this panel
344
+ // xterm instance
251
345
  var panelXterm = new Terminal({
252
346
  theme: {
253
347
  background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
@@ -262,60 +356,229 @@
262
356
  panelXterm.loadAddon(panelFit);
263
357
  panelXterm.open(termDiv);
264
358
 
265
- // Delay fit to ensure container has size
266
- setTimeout(function() { panelFit.fit(); }, 100);
359
+ // Store entry before async connect so index is stable
360
+ var entry = { xterm: panelXterm, fitAddon: panelFit, ws: null, session: s, panel: panel };
361
+ gridTerminals.push(entry);
267
362
 
268
363
  // Connect WebSocket to this session
269
- var proto = 'wss:';
270
- var wsUrl = s.url.replace('https://', 'wss://') + '?token=' + encodeURIComponent(s.token);
271
- var panelWs = new WebSocket(wsUrl);
272
- var statusDot = header.querySelector('.grid-panel-status');
273
-
274
- panelWs.onopen = function() {
275
- if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
276
- // Send initial size
277
- panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
278
- };
279
- panelWs.onclose = function() {
280
- if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
281
- };
282
- panelWs.onerror = function() {
283
- if (statusDot) { statusDot.style.color = 'var(--red)'; }
284
- };
285
- panelWs.onmessage = function(e) {
286
- try {
287
- var msg = JSON.parse(e.data);
288
- if (msg.type === 'pty') {
289
- panelXterm.write(msg.data);
290
- }
291
- } catch (err) {}
292
- };
364
+ (function connectPanel() {
365
+ // Use hub's proxy endpoint to get a ticket for the session
366
+ var tokenParam = new URLSearchParams(window.location.search).get('token');
367
+ var proxyUrl = '/api/proxy/ticket/' + s.port;
368
+ var wsBase = s.isLocal ? 'ws://127.0.0.1:' + s.port : s.url.replace('https://', 'wss://');
369
+
370
+ fetch(proxyUrl, {
371
+ method: 'POST',
372
+ headers: { 'Authorization': 'Bearer ' + tokenParam }
373
+ }).then(function(resp) {
374
+ if (!resp.ok) throw new Error('Auth failed');
375
+ return resp.json();
376
+ }).then(function(data) {
377
+ var panelWs = new WebSocket(wsBase + '?ticket=' + encodeURIComponent(data.ticket));
378
+ entry.ws = panelWs;
379
+
380
+ panelWs.onopen = function() {
381
+ if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
382
+ panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
383
+ };
384
+ panelWs.onclose = function() {
385
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
386
+ };
387
+ panelWs.onerror = function() {
388
+ if (statusDot) { statusDot.style.color = 'var(--red)'; }
389
+ };
390
+ panelWs.onmessage = function(e) {
391
+ try {
392
+ var msg = JSON.parse(e.data);
393
+ if (msg.type === 'pty') {
394
+ panelXterm.write(msg.data);
395
+ }
396
+ } catch (err) {}
397
+ };
398
+
399
+ panelXterm.onData(function(data) {
400
+ if (panelWs && panelWs.readyState === WebSocket.OPEN) {
401
+ panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
402
+ }
403
+ });
404
+ }).catch(function() {
405
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
406
+ });
407
+ })();
408
+ });
293
409
 
294
- // Keyboard input from this panel send to its session
295
- panelXterm.onData(function(data) {
296
- if (panelWs.readyState === WebSocket.OPEN) {
297
- panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
298
- }
410
+ // ── Event delegation for panel clicks ──
411
+ contentEl.addEventListener('click', function(e) {
412
+ var panel = e.target.closest('.grid-panel');
413
+ if (!panel) return;
414
+ var idx = parseInt(panel.dataset.index, 10);
415
+ if (isNaN(idx)) return;
416
+
417
+ if (gridMode === 'thumbnails') {
418
+ focusedIndex = idx;
419
+ switchGridMode('fullscreen');
420
+ } else if (gridMode === 'focus' && panel.classList.contains('focus-strip')) {
421
+ focusedIndex = idx;
422
+ applyGridLayout('focus');
423
+ } else if (gridMode === 'tmux') {
424
+ focusedIndex = idx;
425
+ contentEl.querySelectorAll('.grid-panel').forEach(function(p) { p.classList.remove('active'); });
426
+ panel.classList.add('active');
427
+ }
428
+ });
429
+
430
+ // Apply initial layout
431
+ applyGridLayout(gridMode);
432
+
433
+ // Handle window resize
434
+ window.removeEventListener('resize', fitGridPanels);
435
+ window.addEventListener('resize', fitGridPanels);
436
+ if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
437
+ }
438
+
439
+ function switchGridMode(mode) {
440
+ gridMode = mode;
441
+ if (mode === 'fullscreen') {
442
+ $('#input-area').classList.remove('hidden');
443
+ $('#input-form').classList.add('hidden');
444
+ } else {
445
+ $('#input-area').classList.add('hidden');
446
+ }
447
+ applyGridLayout(mode);
448
+ }
449
+
450
+ function switchTmuxPreset(preset) {
451
+ tmuxPreset = preset;
452
+ var presetGroup = document.getElementById('tmux-presets');
453
+ if (presetGroup) {
454
+ presetGroup.querySelectorAll('[data-preset]').forEach(function(btn) {
455
+ btn.classList.toggle('active', btn.dataset.preset === preset);
299
456
  });
457
+ }
458
+ if (gridMode === 'tmux') applyGridLayout('tmux');
459
+ }
460
+
461
+ function applyGridLayout(mode) {
462
+ gridMode = mode;
463
+ var contentEl = document.getElementById('grid-content');
464
+ if (!contentEl || gridTerminals.length === 0) return;
465
+
466
+ // Clamp focusedIndex
467
+ if (focusedIndex >= gridTerminals.length) focusedIndex = 0;
300
468
 
301
- // Click header to go full-screen on this session
302
- header.addEventListener('click', function() {
303
- window.location.href = s.url + '?token=' + encodeURIComponent(s.token);
469
+ // Update toolbar button states
470
+ var toolbar = contentEl.parentElement.querySelector('.grid-toolbar');
471
+ if (toolbar) {
472
+ toolbar.querySelectorAll('[data-mode]').forEach(function(btn) {
473
+ btn.classList.toggle('active', btn.dataset.mode === mode);
304
474
  });
475
+ var presetsEl = document.getElementById('tmux-presets');
476
+ if (presetsEl) presetsEl.classList.toggle('hidden', mode !== 'tmux');
477
+ }
305
478
 
306
- gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
479
+ // Detach all panels without destroying them
480
+ gridTerminals.forEach(function(gt, i) {
481
+ if (gt.panel.parentNode) gt.panel.parentNode.removeChild(gt.panel);
482
+ gt.panel.className = 'grid-panel';
483
+ gt.panel.dataset.index = i;
484
+ var termDiv = gt.panel.querySelector('.grid-panel-terminal');
485
+ if (termDiv) termDiv.style.cssText = '';
486
+ gt.panel.style.cssText = '';
307
487
  });
308
488
 
309
- // Handle window resize for grid panels
310
- window.addEventListener('resize', fitGridPanels);
489
+ // Remove leftover elements (focus-strips, back-to-grid button)
490
+ while (contentEl.firstChild) contentEl.removeChild(contentEl.firstChild);
491
+
492
+ // Reset content styles
493
+ contentEl.className = 'mode-' + mode;
494
+ contentEl.style.cssText = '';
495
+
496
+ switch (mode) {
497
+ case 'thumbnails':
498
+ gridTerminals.forEach(function(gt) {
499
+ gt.panel.classList.add('thumbnail');
500
+ var termDiv = gt.panel.querySelector('.grid-panel-terminal');
501
+ termDiv.style.width = '560px';
502
+ termDiv.style.height = '360px';
503
+ termDiv.style.transform = 'scale(0.5)';
504
+ termDiv.style.transformOrigin = 'top left';
505
+ contentEl.appendChild(gt.panel);
506
+ });
507
+ break;
508
+
509
+ case 'tmux':
510
+ gridTerminals.forEach(function(gt, i) {
511
+ if (i === focusedIndex) gt.panel.classList.add('active');
512
+ contentEl.appendChild(gt.panel);
513
+ });
514
+ if (tmuxPreset === 'equal') {
515
+ var cols = gridTerminals.length <= 2 ? gridTerminals.length : gridTerminals.length <= 4 ? 2 : 3;
516
+ contentEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
517
+ } else if (tmuxPreset === 'main-side') {
518
+ contentEl.style.gridTemplateColumns = '70% 30%';
519
+ var sideCount = Math.max(gridTerminals.length - 1, 1);
520
+ contentEl.style.gridTemplateRows = 'repeat(' + sideCount + ', 1fr)';
521
+ if (gridTerminals.length > 0) gridTerminals[0].panel.style.gridRow = '1 / -1';
522
+ } else if (tmuxPreset === 'stacked') {
523
+ contentEl.style.gridTemplateColumns = '1fr';
524
+ }
525
+ break;
526
+
527
+ case 'focus':
528
+ var mainGt = gridTerminals[focusedIndex];
529
+ mainGt.panel.classList.add('focus-main');
530
+ contentEl.appendChild(mainGt.panel);
531
+ if (gridTerminals.length > 1) {
532
+ var stripsEl = document.createElement('div');
533
+ stripsEl.className = 'focus-strips';
534
+ gridTerminals.forEach(function(gt, i) {
535
+ if (i === focusedIndex) return;
536
+ gt.panel.classList.add('focus-strip');
537
+ stripsEl.appendChild(gt.panel);
538
+ });
539
+ contentEl.appendChild(stripsEl);
540
+ }
541
+ break;
542
+
543
+ case 'fullscreen':
544
+ var fullGt = gridTerminals[focusedIndex];
545
+ fullGt.panel.classList.add('fullscreen');
546
+ contentEl.appendChild(fullGt.panel);
547
+ var backBtn = document.createElement('button');
548
+ backBtn.className = 'back-to-grid';
549
+ backBtn.textContent = '\u2190 Grid';
550
+ backBtn.addEventListener('click', function() { switchGridMode('thumbnails'); });
551
+ contentEl.appendChild(backBtn);
552
+ break;
553
+ }
311
554
 
312
- // Add back button
313
- if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
555
+ // Fit visible terminals after DOM settles
556
+ setTimeout(function() {
557
+ gridTerminals.forEach(function(gt) {
558
+ if (!document.contains(gt.panel)) return;
559
+ if (gt.fitAddon) {
560
+ try {
561
+ gt.fitAddon.fit();
562
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
563
+ gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
564
+ }
565
+ } catch(e) {}
566
+ }
567
+ });
568
+ }, 100);
314
569
  }
315
570
 
316
571
  function fitGridPanels() {
317
572
  gridTerminals.forEach(function(gt) {
318
- if (gt.fitAddon) { try { gt.fitAddon.fit(); } catch(e) {} }
573
+ if (!document.contains(gt.panel)) return;
574
+ if (gt.fitAddon) {
575
+ try {
576
+ gt.fitAddon.fit();
577
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
578
+ gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
579
+ }
580
+ } catch(e) {}
581
+ }
319
582
  });
320
583
  }
321
584
 
@@ -328,6 +591,10 @@
328
591
  window.removeEventListener('resize', fitGridPanels);
329
592
  var gridEl = document.getElementById('grid-view');
330
593
  if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
594
+ $('#input-area').classList.add('hidden');
595
+ gridMode = 'thumbnails';
596
+ focusedIndex = 0;
597
+ tmuxPreset = 'equal';
331
598
  }
332
599
 
333
600
  window.toggleView = () => {
@@ -519,7 +786,7 @@
519
786
  }
520
787
 
521
788
  // ─── Detect hub mode (no token in URL) ────────────────────
522
- const isHubMode = !new URLSearchParams(window.location.search).get('token');
789
+ const isHubMode = new URLSearchParams(window.location.search).get('hub') === '1';
523
790
 
524
791
  // ─── WebSocket ───────────────────────────────────────────
525
792
  let reconnectAttempt = 0;
@@ -544,7 +811,7 @@
544
811
 
545
812
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
546
813
 
547
- // F-02: Try ticket-based auth first
814
+ // F-02: Ticket-based auth (required)
548
815
  try {
549
816
  const resp = await fetch('/api/auth/ticket', {
550
817
  method: 'POST',
@@ -554,12 +821,12 @@
554
821
  const { ticket } = await resp.json();
555
822
  ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
556
823
  } else {
557
- // Fallback to token-in-URL (backward compat)
558
- ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
824
+ setStatus('offline', 'Auth failed');
825
+ return;
559
826
  }
560
827
  } catch {
561
- // Fallback to token-in-URL
562
- ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
828
+ setStatus('offline', 'Auth failed');
829
+ return;
563
830
  }
564
831
  setStatus('connecting', 'Connecting...');
565
832
 
@@ -714,6 +981,33 @@
714
981
  };
715
982
 
716
983
  // ─── Mobile Key Bar ───────────────────────────────────────
984
+ // F-5: Event delegation for key-bar buttons (no inline onclick)
985
+ const keyBar = document.getElementById('key-bar');
986
+ if (keyBar) {
987
+ var keyMap = {
988
+ '\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D',
989
+ '\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f',
990
+ };
991
+ keyBar.addEventListener('click', function(e) {
992
+ var btn = e.target;
993
+ if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
994
+ var key = keyMap[btn.dataset.key] || btn.dataset.key;
995
+ if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
996
+ var gt = gridTerminals[focusedIndex];
997
+ if (gt.ws && gt.ws.readyState === WebSocket.OPEN) {
998
+ gt.ws.send(JSON.stringify({ type: 'pty_input', data: key }));
999
+ }
1000
+ if (gt.xterm) gt.xterm.focus();
1001
+ } else {
1002
+ if (ws && ws.readyState === WebSocket.OPEN) {
1003
+ ws.send(JSON.stringify({ type: 'pty_input', data: key }));
1004
+ }
1005
+ if (xterm) xterm.focus();
1006
+ }
1007
+ }
1008
+ });
1009
+ }
1010
+
717
1011
  window.sendKey = (key) => {
718
1012
  if (ws && ws.readyState === WebSocket.OPEN) {
719
1013
  ws.send(JSON.stringify({ type: 'pty_input', data: key }));
@@ -758,7 +1052,7 @@
758
1052
  requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
759
1053
  }
760
1054
  function escapeHtml(s) {
761
- const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, '&#39;');
1055
+ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, '&#39;').replace(/"/g, '&quot;');
762
1056
  }
763
1057
  function formatText(text) {
764
1058
  return escapeHtml(text)
@@ -32,16 +32,16 @@
32
32
 
33
33
  <footer id="input-area">
34
34
  <div id="key-bar">
35
- <button onclick="sendKey('\x1b[A')">↑</button>
36
- <button onclick="sendKey('\x1b[B')">↓</button>
37
- <button onclick="sendKey('\x1b[C')">→</button>
38
- <button onclick="sendKey('\x1b[D')">←</button>
39
- <button onclick="sendKey('\t')">Tab</button>
40
- <button onclick="sendKey('\r')">Enter</button>
41
- <button onclick="sendKey('\x1b')">Esc</button>
42
- <button onclick="sendKey('\x03')">Ctrl+C</button>
43
- <button onclick="sendKey(' ')">Space</button>
44
- <button onclick="sendKey('\x7f')">⌫</button>
35
+ <button data-key="\x1b[A">↑</button>
36
+ <button data-key="\x1b[B">↓</button>
37
+ <button data-key="\x1b[C">→</button>
38
+ <button data-key="\x1b[D">←</button>
39
+ <button data-key="\t">Tab</button>
40
+ <button data-key="\r">Enter</button>
41
+ <button data-key="\x1b">Esc</button>
42
+ <button data-key="\x03">Ctrl+C</button>
43
+ <button data-key=" ">Space</button>
44
+ <button data-key="\x7f">⌫</button>
45
45
  </div>
46
46
  <form id="input-form">
47
47
  <span class="prompt">&gt;</span>