aigo 1.0.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/AGENTS.md +1 -0
- package/README.md +453 -0
- package/bin/vigo.js +282 -0
- package/lib/cli.js +81 -0
- package/lib/config.js +80 -0
- package/lib/cursor.js +74 -0
- package/lib/port.js +39 -0
- package/lib/server.js +284 -0
- package/lib/tmux.js +86 -0
- package/lib/tunnel.js +150 -0
- package/package.json +27 -0
- package/public/index.html +545 -0
- package/public/terminal.js +759 -0
- package/specs/claude-code-web-terminal.md +258 -0
- package/specs/cursor-cli-support.md +139 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vigo terminal client
|
|
3
|
+
* Connects to the WebSocket server and renders the terminal
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(function() {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
// Wait for DOM to be ready
|
|
10
|
+
if (document.readyState === 'loading') {
|
|
11
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
12
|
+
} else {
|
|
13
|
+
init();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function init() {
|
|
17
|
+
// Extract token from URL path: /t/:token
|
|
18
|
+
const pathParts = window.location.pathname.split('/');
|
|
19
|
+
const tokenIndex = pathParts.indexOf('t') + 1;
|
|
20
|
+
const token = pathParts[tokenIndex];
|
|
21
|
+
|
|
22
|
+
if (!token) {
|
|
23
|
+
document.body.innerHTML = '<div style="color: #ff4444; padding: 20px;">Error: Invalid URL. Token not found.</div>';
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check if xterm.js is loaded
|
|
28
|
+
if (typeof Terminal === 'undefined') {
|
|
29
|
+
document.body.innerHTML = '<div style="color: #ff4444; padding: 20px;">Error: Terminal library failed to load. Please refresh.</div>';
|
|
30
|
+
console.error('Terminal (xterm.js) is not defined. Check CDN script loading.');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// DOM elements
|
|
35
|
+
const terminalEl = document.getElementById('terminal');
|
|
36
|
+
const statusEl = document.getElementById('status');
|
|
37
|
+
const statusText = statusEl.querySelector('.text');
|
|
38
|
+
const overlay = document.getElementById('reconnect-overlay');
|
|
39
|
+
const overlayMessage = overlay.querySelector('.message');
|
|
40
|
+
const reconnectBtn = document.getElementById('reconnect-btn');
|
|
41
|
+
const authOverlay = document.getElementById('auth-overlay');
|
|
42
|
+
const passwordInput = document.getElementById('password-input');
|
|
43
|
+
const authBtn = document.getElementById('auth-btn');
|
|
44
|
+
const authError = document.getElementById('auth-error');
|
|
45
|
+
const authTitle = authOverlay.querySelector('.auth-title');
|
|
46
|
+
const exitBtn = document.getElementById('exit-btn');
|
|
47
|
+
const exitOverlay = document.getElementById('exit-overlay');
|
|
48
|
+
const exitCancel = document.getElementById('exit-cancel');
|
|
49
|
+
const exitConfirm = document.getElementById('exit-confirm');
|
|
50
|
+
const mobileControls = document.getElementById('mobile-controls');
|
|
51
|
+
const mobileToggleBtn = document.getElementById('mobile-mode-toggle');
|
|
52
|
+
const mobileEnterBtn = document.getElementById('mobile-enter-btn');
|
|
53
|
+
const mobileStopBtn = document.getElementById('mobile-stop-btn');
|
|
54
|
+
const mobileExitBtn = document.getElementById('mobile-exit-btn');
|
|
55
|
+
|
|
56
|
+
// Terminal setup
|
|
57
|
+
const terminal = new Terminal({
|
|
58
|
+
theme: {
|
|
59
|
+
background: '#0a0a0f',
|
|
60
|
+
foreground: '#e0e0e0',
|
|
61
|
+
cursor: '#00ff88',
|
|
62
|
+
cursorAccent: '#0a0a0f',
|
|
63
|
+
selectionBackground: 'rgba(0, 255, 136, 0.3)',
|
|
64
|
+
black: '#1a1a25',
|
|
65
|
+
red: '#ff5555',
|
|
66
|
+
green: '#00ff88',
|
|
67
|
+
yellow: '#ffaa00',
|
|
68
|
+
blue: '#66aaff',
|
|
69
|
+
magenta: '#ff79c6',
|
|
70
|
+
cyan: '#00ffff',
|
|
71
|
+
white: '#e0e0e0',
|
|
72
|
+
brightBlack: '#555555',
|
|
73
|
+
brightRed: '#ff6e6e',
|
|
74
|
+
brightGreen: '#69ff94',
|
|
75
|
+
brightYellow: '#ffffa5',
|
|
76
|
+
brightBlue: '#d6acff',
|
|
77
|
+
brightMagenta: '#ff92df',
|
|
78
|
+
brightCyan: '#a4ffff',
|
|
79
|
+
brightWhite: '#ffffff'
|
|
80
|
+
},
|
|
81
|
+
fontFamily: '"SF Mono", "Fira Code", "JetBrains Mono", "Cascadia Code", Menlo, Monaco, monospace',
|
|
82
|
+
fontSize: 14,
|
|
83
|
+
lineHeight: 1.2,
|
|
84
|
+
cursorBlink: true,
|
|
85
|
+
cursorStyle: 'block',
|
|
86
|
+
scrollback: 10000,
|
|
87
|
+
allowProposedApi: true
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Load addons (check for different possible global names)
|
|
91
|
+
const FitAddonClass = window.FitAddon?.FitAddon || window.FitAddon;
|
|
92
|
+
const WebLinksAddonClass = window.WebLinksAddon?.WebLinksAddon || window.WebLinksAddon;
|
|
93
|
+
|
|
94
|
+
let fitAddon = null;
|
|
95
|
+
if (FitAddonClass) {
|
|
96
|
+
fitAddon = new FitAddonClass();
|
|
97
|
+
terminal.loadAddon(fitAddon);
|
|
98
|
+
} else {
|
|
99
|
+
console.warn('FitAddon not available');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (WebLinksAddonClass) {
|
|
103
|
+
const webLinksAddon = new WebLinksAddonClass();
|
|
104
|
+
terminal.loadAddon(webLinksAddon);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Open terminal in DOM
|
|
108
|
+
terminal.open(terminalEl);
|
|
109
|
+
if (fitAddon) {
|
|
110
|
+
fitAddon.fit();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// WebSocket connection
|
|
114
|
+
let ws = null;
|
|
115
|
+
let reconnectAttempts = 0;
|
|
116
|
+
let reconnectTimer = null;
|
|
117
|
+
let authenticated = false;
|
|
118
|
+
let savedPassword = null;
|
|
119
|
+
let isLocked = false;
|
|
120
|
+
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
121
|
+
const RECONNECT_DELAY = 2000;
|
|
122
|
+
|
|
123
|
+
// Timeout config (received from server)
|
|
124
|
+
let lockTimeout = 15; // minutes, 0 = disabled
|
|
125
|
+
let exitTimeout = 30; // minutes, 0 = disabled
|
|
126
|
+
|
|
127
|
+
// Activity tracking
|
|
128
|
+
let lastActivityTime = Date.now();
|
|
129
|
+
let lockCheckTimer = null;
|
|
130
|
+
let activityPingTimer = null;
|
|
131
|
+
|
|
132
|
+
// Browser notifications
|
|
133
|
+
let notificationsEnabled = false;
|
|
134
|
+
let lastNotificationTime = 0;
|
|
135
|
+
const NOTIFICATION_COOLDOWN = 30000; // 30 seconds between notifications
|
|
136
|
+
|
|
137
|
+
// Mobile device detection
|
|
138
|
+
function isMobileDevice() {
|
|
139
|
+
// Check for touch capability
|
|
140
|
+
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
141
|
+
// Check for mobile user agent
|
|
142
|
+
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
143
|
+
// Check screen width
|
|
144
|
+
const smallScreen = window.innerWidth <= 768;
|
|
145
|
+
// Consider coarse pointer (touch screens)
|
|
146
|
+
const coarsePointer = window.matchMedia('(pointer: coarse)').matches;
|
|
147
|
+
|
|
148
|
+
return (hasTouch && (mobileUA || smallScreen)) || coarsePointer;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Show/hide mobile control buttons
|
|
152
|
+
function updateMobileControlsVisibility() {
|
|
153
|
+
if (isMobileDevice() && authenticated && !isLocked) {
|
|
154
|
+
mobileControls.classList.add('visible');
|
|
155
|
+
} else {
|
|
156
|
+
mobileControls.classList.remove('visible');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Alias for backward compatibility
|
|
161
|
+
const updateMobileToggleVisibility = updateMobileControlsVisibility;
|
|
162
|
+
|
|
163
|
+
// Patterns that indicate Claude is waiting for input or finished
|
|
164
|
+
const idlePatterns = [
|
|
165
|
+
/>\s*$/, // Shell prompt
|
|
166
|
+
/\$\s*$/, // Bash prompt
|
|
167
|
+
/claude>\s*$/i, // Claude prompt
|
|
168
|
+
/waiting for input/i,
|
|
169
|
+
/press enter to continue/i,
|
|
170
|
+
/\[Y\/n\]/i, // Confirmation prompt
|
|
171
|
+
/completed successfully/i,
|
|
172
|
+
/finished/i,
|
|
173
|
+
/done\./i
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
// Update activity timestamp
|
|
177
|
+
function updateActivity() {
|
|
178
|
+
lastActivityTime = Date.now();
|
|
179
|
+
|
|
180
|
+
// If locked, don't auto-unlock (need password)
|
|
181
|
+
// But do send activity to server
|
|
182
|
+
if (ws && ws.readyState === WebSocket.OPEN && authenticated) {
|
|
183
|
+
ws.send(JSON.stringify({ type: 'activity' }));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check for lock timeout
|
|
188
|
+
function startLockChecker() {
|
|
189
|
+
if (lockCheckTimer) clearInterval(lockCheckTimer);
|
|
190
|
+
if (lockTimeout <= 0) return;
|
|
191
|
+
|
|
192
|
+
lockCheckTimer = setInterval(() => {
|
|
193
|
+
if (!authenticated || isLocked) return;
|
|
194
|
+
|
|
195
|
+
const inactiveMinutes = (Date.now() - lastActivityTime) / 1000 / 60;
|
|
196
|
+
if (inactiveMinutes >= lockTimeout) {
|
|
197
|
+
lockSession();
|
|
198
|
+
}
|
|
199
|
+
}, 10000); // Check every 10 seconds
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Lock the session (show password prompt)
|
|
203
|
+
function lockSession() {
|
|
204
|
+
if (isLocked) return;
|
|
205
|
+
isLocked = true;
|
|
206
|
+
authTitle.textContent = 'Session Locked';
|
|
207
|
+
showAuth();
|
|
208
|
+
setStatus('disconnected', 'Locked');
|
|
209
|
+
updateMobileToggleVisibility();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Unlock the session
|
|
213
|
+
function unlockSession() {
|
|
214
|
+
isLocked = false;
|
|
215
|
+
authTitle.textContent = 'Enter Password';
|
|
216
|
+
hideAuth();
|
|
217
|
+
setStatus('connected', 'Connected');
|
|
218
|
+
terminal.focus();
|
|
219
|
+
updateActivity();
|
|
220
|
+
updateMobileToggleVisibility();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Track user interactions
|
|
224
|
+
function setupActivityTracking() {
|
|
225
|
+
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];
|
|
226
|
+
events.forEach(event => {
|
|
227
|
+
document.addEventListener(event, () => {
|
|
228
|
+
if (!isLocked && authenticated) {
|
|
229
|
+
updateActivity();
|
|
230
|
+
}
|
|
231
|
+
// Request notification permission on first interaction
|
|
232
|
+
requestNotificationPermission();
|
|
233
|
+
}, { passive: true, once: event === 'keydown' || event === 'mousedown' });
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Request notification permission
|
|
238
|
+
function requestNotificationPermission() {
|
|
239
|
+
if (!('Notification' in window)) return;
|
|
240
|
+
if (Notification.permission === 'granted') {
|
|
241
|
+
notificationsEnabled = true;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (Notification.permission !== 'denied') {
|
|
245
|
+
Notification.requestPermission().then(permission => {
|
|
246
|
+
notificationsEnabled = permission === 'granted';
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Show browser notification
|
|
252
|
+
function showNotification(title, body) {
|
|
253
|
+
if (!notificationsEnabled) return;
|
|
254
|
+
if (document.visibilityState === 'visible') return; // Don't notify if tab is active
|
|
255
|
+
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
if (now - lastNotificationTime < NOTIFICATION_COOLDOWN) return;
|
|
258
|
+
lastNotificationTime = now;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const notification = new Notification(title, {
|
|
262
|
+
body: body,
|
|
263
|
+
icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">▸</text></svg>',
|
|
264
|
+
tag: 'vigo-notification',
|
|
265
|
+
requireInteraction: false
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
notification.onclick = () => {
|
|
269
|
+
window.focus();
|
|
270
|
+
notification.close();
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Auto-close after 5 seconds
|
|
274
|
+
setTimeout(() => notification.close(), 5000);
|
|
275
|
+
} catch (e) {
|
|
276
|
+
console.warn('Failed to show notification:', e);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Check terminal output for idle patterns
|
|
281
|
+
let outputBuffer = '';
|
|
282
|
+
function checkForIdlePatterns(data) {
|
|
283
|
+
// Add to buffer (keep last 500 chars)
|
|
284
|
+
outputBuffer += data;
|
|
285
|
+
if (outputBuffer.length > 500) {
|
|
286
|
+
outputBuffer = outputBuffer.slice(-500);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Check patterns
|
|
290
|
+
for (const pattern of idlePatterns) {
|
|
291
|
+
if (pattern.test(outputBuffer)) {
|
|
292
|
+
showNotification('vigo', 'Claude Code is waiting for input');
|
|
293
|
+
outputBuffer = ''; // Reset to avoid repeated notifications
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function showAuth() {
|
|
300
|
+
authOverlay.classList.add('visible');
|
|
301
|
+
authError.textContent = '';
|
|
302
|
+
passwordInput.value = '';
|
|
303
|
+
passwordInput.focus();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function hideAuth() {
|
|
307
|
+
authOverlay.classList.remove('visible');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function submitPassword() {
|
|
311
|
+
const pwd = passwordInput.value.trim();
|
|
312
|
+
if (pwd.length === 0) {
|
|
313
|
+
authError.textContent = 'Password required';
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
authError.textContent = '';
|
|
317
|
+
|
|
318
|
+
if (isLocked) {
|
|
319
|
+
// Re-auth for unlock
|
|
320
|
+
ws.send(JSON.stringify({
|
|
321
|
+
type: 're-auth',
|
|
322
|
+
password: pwd
|
|
323
|
+
}));
|
|
324
|
+
} else {
|
|
325
|
+
// Initial auth
|
|
326
|
+
savedPassword = pwd;
|
|
327
|
+
const dimensions = fitAddon ? fitAddon.proposeDimensions() : { cols: 80, rows: 24 };
|
|
328
|
+
ws.send(JSON.stringify({
|
|
329
|
+
type: 'auth',
|
|
330
|
+
password: pwd,
|
|
331
|
+
cols: dimensions?.cols || 80,
|
|
332
|
+
rows: dimensions?.rows || 24
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Auth button and enter key
|
|
338
|
+
authBtn.addEventListener('click', submitPassword);
|
|
339
|
+
passwordInput.addEventListener('keypress', (e) => {
|
|
340
|
+
if (e.key === 'Enter') submitPassword();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Exit/cleanup functionality
|
|
344
|
+
function showExitConfirm() {
|
|
345
|
+
exitOverlay.classList.add('visible');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function hideExitConfirm() {
|
|
349
|
+
exitOverlay.classList.remove('visible');
|
|
350
|
+
terminal.focus();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function requestCleanup() {
|
|
354
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
355
|
+
exitOverlay.classList.remove('visible');
|
|
356
|
+
showOverlay(true, 'Cleaning up session...');
|
|
357
|
+
ws.send(JSON.stringify({ type: 'cleanup' }));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function showCleanupComplete() {
|
|
362
|
+
hideAuth();
|
|
363
|
+
showOverlay(false);
|
|
364
|
+
|
|
365
|
+
const completeDiv = document.createElement('div');
|
|
366
|
+
completeDiv.id = 'cleanup-overlay';
|
|
367
|
+
completeDiv.innerHTML = `
|
|
368
|
+
<div class="cleanup-box">
|
|
369
|
+
<div class="cleanup-icon">✓</div>
|
|
370
|
+
<div class="cleanup-title">Session Ended</div>
|
|
371
|
+
<div class="cleanup-message">The session has been cleaned up.<br>tmux session killed, server stopped.</div>
|
|
372
|
+
<button onclick="window.close()">Close Window</button>
|
|
373
|
+
</div>
|
|
374
|
+
`;
|
|
375
|
+
completeDiv.style.cssText = `
|
|
376
|
+
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
377
|
+
background: #0a0a0f; display: flex; justify-content: center;
|
|
378
|
+
align-items: center; z-index: 300;
|
|
379
|
+
`;
|
|
380
|
+
const box = completeDiv.querySelector('.cleanup-box');
|
|
381
|
+
box.style.cssText = `
|
|
382
|
+
background: #12121a; border: 1px solid #1a1a25; border-radius: 8px;
|
|
383
|
+
padding: 40px; text-align: center;
|
|
384
|
+
`;
|
|
385
|
+
const icon = completeDiv.querySelector('.cleanup-icon');
|
|
386
|
+
icon.style.cssText = 'font-size: 48px; margin-bottom: 16px; color: #00ff88;';
|
|
387
|
+
const title = completeDiv.querySelector('.cleanup-title');
|
|
388
|
+
title.style.cssText = 'color: #00ff88; font-size: 20px; font-weight: 600; margin-bottom: 12px;';
|
|
389
|
+
const msg = completeDiv.querySelector('.cleanup-message');
|
|
390
|
+
msg.style.cssText = 'color: #888; margin-bottom: 24px; line-height: 1.5;';
|
|
391
|
+
const btn = completeDiv.querySelector('button');
|
|
392
|
+
btn.style.cssText = `
|
|
393
|
+
padding: 12px 32px; background: #1a1a25; color: #e0e0e0;
|
|
394
|
+
border: 1px solid #2a2a35; border-radius: 4px; cursor: pointer;
|
|
395
|
+
font-family: inherit; font-size: 14px;
|
|
396
|
+
`;
|
|
397
|
+
|
|
398
|
+
document.body.appendChild(completeDiv);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Exit button handlers
|
|
402
|
+
exitBtn.addEventListener('click', showExitConfirm);
|
|
403
|
+
exitCancel.addEventListener('click', hideExitConfirm);
|
|
404
|
+
exitConfirm.addEventListener('click', requestCleanup);
|
|
405
|
+
|
|
406
|
+
// Helper for mobile button visual feedback
|
|
407
|
+
function mobileButtonFeedback(btn) {
|
|
408
|
+
btn.style.transform = 'scale(0.95)';
|
|
409
|
+
setTimeout(() => {
|
|
410
|
+
btn.style.transform = '';
|
|
411
|
+
}, 100);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Mobile mode toggle button handler (sends Shift+Tab)
|
|
415
|
+
mobileToggleBtn.addEventListener('click', () => {
|
|
416
|
+
if (ws && ws.readyState === WebSocket.OPEN && authenticated && !isLocked) {
|
|
417
|
+
// Send Shift+Tab escape sequence: ESC [ Z (reverse tab / backtab)
|
|
418
|
+
// This is the standard escape sequence for Shift+Tab
|
|
419
|
+
ws.send('\x1b[Z');
|
|
420
|
+
updateActivity();
|
|
421
|
+
mobileButtonFeedback(mobileToggleBtn);
|
|
422
|
+
terminal.focus();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Mobile enter button handler (sends Enter key)
|
|
427
|
+
mobileEnterBtn.addEventListener('click', () => {
|
|
428
|
+
if (ws && ws.readyState === WebSocket.OPEN && authenticated && !isLocked) {
|
|
429
|
+
// Send Enter key (carriage return)
|
|
430
|
+
ws.send('\r');
|
|
431
|
+
updateActivity();
|
|
432
|
+
mobileButtonFeedback(mobileEnterBtn);
|
|
433
|
+
terminal.focus();
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// Mobile stop button handler (sends Ctrl+C)
|
|
438
|
+
mobileStopBtn.addEventListener('click', () => {
|
|
439
|
+
if (ws && ws.readyState === WebSocket.OPEN && authenticated && !isLocked) {
|
|
440
|
+
// Send Ctrl+C (ASCII code 3, ETX - End of Text)
|
|
441
|
+
ws.send('\x03');
|
|
442
|
+
updateActivity();
|
|
443
|
+
mobileButtonFeedback(mobileStopBtn);
|
|
444
|
+
terminal.focus();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Mobile exit button handler (shows confirmation dialog)
|
|
449
|
+
mobileExitBtn.addEventListener('click', () => {
|
|
450
|
+
if (authenticated && !isLocked) {
|
|
451
|
+
mobileButtonFeedback(mobileExitBtn);
|
|
452
|
+
showExitConfirm();
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ESC to close exit confirm
|
|
457
|
+
document.addEventListener('keydown', (e) => {
|
|
458
|
+
if (e.key === 'Escape' && exitOverlay.classList.contains('visible')) {
|
|
459
|
+
hideExitConfirm();
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
function setStatus(status, text) {
|
|
464
|
+
statusEl.className = status;
|
|
465
|
+
statusText.textContent = text;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function showOverlay(show, message = '', showButton = false) {
|
|
469
|
+
if (show) {
|
|
470
|
+
overlay.classList.add('visible');
|
|
471
|
+
overlayMessage.textContent = message;
|
|
472
|
+
reconnectBtn.style.display = showButton ? 'block' : 'none';
|
|
473
|
+
} else {
|
|
474
|
+
overlay.classList.remove('visible');
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function showSessionExpired() {
|
|
479
|
+
hideAuth();
|
|
480
|
+
showOverlay(false);
|
|
481
|
+
|
|
482
|
+
// Create expired overlay
|
|
483
|
+
const expiredDiv = document.createElement('div');
|
|
484
|
+
expiredDiv.id = 'expired-overlay';
|
|
485
|
+
expiredDiv.innerHTML = `
|
|
486
|
+
<div class="expired-box">
|
|
487
|
+
<div class="expired-icon">⏱</div>
|
|
488
|
+
<div class="expired-title">Session Expired</div>
|
|
489
|
+
<div class="expired-message">The session was closed due to inactivity.</div>
|
|
490
|
+
<button onclick="window.close()">Close Window</button>
|
|
491
|
+
</div>
|
|
492
|
+
`;
|
|
493
|
+
expiredDiv.style.cssText = `
|
|
494
|
+
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
495
|
+
background: #0a0a0f; display: flex; justify-content: center;
|
|
496
|
+
align-items: center; z-index: 300;
|
|
497
|
+
`;
|
|
498
|
+
const box = expiredDiv.querySelector('.expired-box');
|
|
499
|
+
box.style.cssText = `
|
|
500
|
+
background: #12121a; border: 1px solid #1a1a25; border-radius: 8px;
|
|
501
|
+
padding: 40px; text-align: center;
|
|
502
|
+
`;
|
|
503
|
+
const icon = expiredDiv.querySelector('.expired-icon');
|
|
504
|
+
icon.style.cssText = 'font-size: 48px; margin-bottom: 16px;';
|
|
505
|
+
const title = expiredDiv.querySelector('.expired-title');
|
|
506
|
+
title.style.cssText = 'color: #ff5555; font-size: 20px; font-weight: 600; margin-bottom: 12px;';
|
|
507
|
+
const msg = expiredDiv.querySelector('.expired-message');
|
|
508
|
+
msg.style.cssText = 'color: #888; margin-bottom: 24px;';
|
|
509
|
+
const btn = expiredDiv.querySelector('button');
|
|
510
|
+
btn.style.cssText = `
|
|
511
|
+
padding: 12px 32px; background: #1a1a25; color: #e0e0e0;
|
|
512
|
+
border: 1px solid #2a2a35; border-radius: 4px; cursor: pointer;
|
|
513
|
+
font-family: inherit; font-size: 14px;
|
|
514
|
+
`;
|
|
515
|
+
|
|
516
|
+
document.body.appendChild(expiredDiv);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function connect() {
|
|
520
|
+
// Build WebSocket URL
|
|
521
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
522
|
+
const wsUrl = `${protocol}//${window.location.host}/ws/${token}`;
|
|
523
|
+
|
|
524
|
+
setStatus('connecting', 'Connecting...');
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
ws = new WebSocket(wsUrl);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
console.error('Failed to create WebSocket:', err);
|
|
530
|
+
setStatus('disconnected', 'Connection failed');
|
|
531
|
+
scheduleReconnect();
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
ws.onopen = () => {
|
|
536
|
+
console.log('WebSocket connected');
|
|
537
|
+
setStatus('connecting', 'Authenticating...');
|
|
538
|
+
showOverlay(false);
|
|
539
|
+
reconnectAttempts = 0;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
ws.onmessage = (event) => {
|
|
543
|
+
const data = event.data;
|
|
544
|
+
|
|
545
|
+
// Check for JSON control messages
|
|
546
|
+
if (data.startsWith('{')) {
|
|
547
|
+
try {
|
|
548
|
+
const msg = JSON.parse(data);
|
|
549
|
+
|
|
550
|
+
if (msg.type === 'auth_required') {
|
|
551
|
+
if (savedPassword && !isLocked) {
|
|
552
|
+
const dimensions = fitAddon ? fitAddon.proposeDimensions() : { cols: 80, rows: 24 };
|
|
553
|
+
ws.send(JSON.stringify({
|
|
554
|
+
type: 'auth',
|
|
555
|
+
password: savedPassword,
|
|
556
|
+
cols: dimensions?.cols || 80,
|
|
557
|
+
rows: dimensions?.rows || 24
|
|
558
|
+
}));
|
|
559
|
+
} else {
|
|
560
|
+
authTitle.textContent = 'Enter Password';
|
|
561
|
+
showAuth();
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (msg.type === 'auth_success') {
|
|
567
|
+
// Get config if provided
|
|
568
|
+
if (msg.config) {
|
|
569
|
+
lockTimeout = msg.config.lockTimeout || 15;
|
|
570
|
+
exitTimeout = msg.config.exitTimeout || 30;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (isLocked) {
|
|
574
|
+
// Unlock successful
|
|
575
|
+
unlockSession();
|
|
576
|
+
} else {
|
|
577
|
+
// Initial auth successful
|
|
578
|
+
authenticated = true;
|
|
579
|
+
hideAuth();
|
|
580
|
+
setStatus('connected', 'Connected');
|
|
581
|
+
terminal.focus();
|
|
582
|
+
updateActivity();
|
|
583
|
+
startLockChecker();
|
|
584
|
+
setupActivityTracking();
|
|
585
|
+
updateMobileToggleVisibility();
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (msg.type === 'auth_failed') {
|
|
591
|
+
authError.textContent = 'Invalid password';
|
|
592
|
+
if (!isLocked) {
|
|
593
|
+
savedPassword = null;
|
|
594
|
+
}
|
|
595
|
+
passwordInput.value = '';
|
|
596
|
+
passwordInput.focus();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (msg.type === 'session_expired') {
|
|
601
|
+
showSessionExpired();
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (msg.type === 'cleanup_complete') {
|
|
606
|
+
showCleanupComplete();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
} catch {
|
|
610
|
+
// Not JSON, treat as terminal data
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Write terminal data
|
|
615
|
+
terminal.write(data);
|
|
616
|
+
|
|
617
|
+
// Check for idle patterns (for notifications)
|
|
618
|
+
checkForIdlePatterns(data);
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
ws.onclose = (event) => {
|
|
622
|
+
console.log('WebSocket closed:', event.code, event.reason);
|
|
623
|
+
setStatus('disconnected', 'Disconnected');
|
|
624
|
+
authenticated = false;
|
|
625
|
+
isLocked = false;
|
|
626
|
+
hideAuth();
|
|
627
|
+
updateMobileToggleVisibility();
|
|
628
|
+
|
|
629
|
+
if (lockCheckTimer) clearInterval(lockCheckTimer);
|
|
630
|
+
|
|
631
|
+
if (event.code !== 1000) {
|
|
632
|
+
// Notify user of unexpected disconnect
|
|
633
|
+
showNotification('vigo', 'Connection lost. Attempting to reconnect...');
|
|
634
|
+
scheduleReconnect();
|
|
635
|
+
} else {
|
|
636
|
+
showOverlay(true, 'Session ended.', true);
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
ws.onerror = (error) => {
|
|
641
|
+
console.error('WebSocket error:', error);
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function scheduleReconnect() {
|
|
646
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
647
|
+
showOverlay(true, 'Connection failed. Please check your network.', true);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
reconnectAttempts++;
|
|
652
|
+
const delay = RECONNECT_DELAY * Math.min(reconnectAttempts, 5);
|
|
653
|
+
|
|
654
|
+
showOverlay(true, `Reconnecting in ${delay / 1000}s... (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
655
|
+
|
|
656
|
+
clearTimeout(reconnectTimer);
|
|
657
|
+
reconnectTimer = setTimeout(() => {
|
|
658
|
+
showOverlay(true, 'Reconnecting...');
|
|
659
|
+
connect();
|
|
660
|
+
}, delay);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function sendResize() {
|
|
664
|
+
if (ws && ws.readyState === WebSocket.OPEN && fitAddon && authenticated && !isLocked) {
|
|
665
|
+
const dimensions = fitAddon.proposeDimensions();
|
|
666
|
+
if (dimensions) {
|
|
667
|
+
ws.send(JSON.stringify({
|
|
668
|
+
type: 'resize',
|
|
669
|
+
cols: dimensions.cols,
|
|
670
|
+
rows: dimensions.rows
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Handle terminal input
|
|
677
|
+
terminal.onData((data) => {
|
|
678
|
+
if (ws && ws.readyState === WebSocket.OPEN && authenticated && !isLocked) {
|
|
679
|
+
updateActivity();
|
|
680
|
+
ws.send(data);
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// Handle window resize
|
|
685
|
+
let resizeTimer = null;
|
|
686
|
+
window.addEventListener('resize', () => {
|
|
687
|
+
clearTimeout(resizeTimer);
|
|
688
|
+
resizeTimer = setTimeout(() => {
|
|
689
|
+
if (fitAddon) {
|
|
690
|
+
fitAddon.fit();
|
|
691
|
+
}
|
|
692
|
+
sendResize();
|
|
693
|
+
updateMobileToggleVisibility();
|
|
694
|
+
}, 100);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// Handle terminal resize
|
|
698
|
+
terminal.onResize(({ cols, rows }) => {
|
|
699
|
+
if (ws && ws.readyState === WebSocket.OPEN && authenticated && !isLocked) {
|
|
700
|
+
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// Reconnect button handler
|
|
705
|
+
reconnectBtn.addEventListener('click', () => {
|
|
706
|
+
reconnectAttempts = 0;
|
|
707
|
+
showOverlay(true, 'Connecting...');
|
|
708
|
+
connect();
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// Handle visibility change - reconnect when tab becomes visible
|
|
712
|
+
document.addEventListener('visibilitychange', () => {
|
|
713
|
+
if (document.visibilityState === 'visible') {
|
|
714
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
715
|
+
reconnectAttempts = 0;
|
|
716
|
+
connect();
|
|
717
|
+
} else if (authenticated && !isLocked) {
|
|
718
|
+
// Check if we should be locked
|
|
719
|
+
const inactiveMinutes = (Date.now() - lastActivityTime) / 1000 / 60;
|
|
720
|
+
if (lockTimeout > 0 && inactiveMinutes >= lockTimeout) {
|
|
721
|
+
lockSession();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// Mobile keyboard handling
|
|
728
|
+
if (terminal.textarea) {
|
|
729
|
+
terminal.textarea.addEventListener('focus', () => {
|
|
730
|
+
setTimeout(() => {
|
|
731
|
+
terminalEl.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
732
|
+
}, 300);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Initial connection
|
|
737
|
+
connect();
|
|
738
|
+
|
|
739
|
+
// Expose for debugging
|
|
740
|
+
window.vigoTerminal = {
|
|
741
|
+
terminal,
|
|
742
|
+
fitAddon,
|
|
743
|
+
reconnect: () => {
|
|
744
|
+
reconnectAttempts = 0;
|
|
745
|
+
if (ws) ws.close();
|
|
746
|
+
connect();
|
|
747
|
+
},
|
|
748
|
+
lock: lockSession,
|
|
749
|
+
getActivity: () => ({
|
|
750
|
+
lastActivityTime,
|
|
751
|
+
inactiveMinutes: (Date.now() - lastActivityTime) / 1000 / 60,
|
|
752
|
+
lockTimeout,
|
|
753
|
+
exitTimeout,
|
|
754
|
+
isLocked,
|
|
755
|
+
authenticated
|
|
756
|
+
})
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
})();
|