conduit-mobile 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/package.json +44 -0
- package/src/client/app.js +419 -0
- package/src/client/index.html +76 -0
- package/src/client/manifest.json +22 -0
- package/src/client/speech.js +39 -0
- package/src/client/style.css +320 -0
- package/src/server/auth.js +14 -0
- package/src/server/index.js +169 -0
- package/src/server/terminals.js +91 -0
- package/src/server/tunnel.js +131 -0
- package/src/server/wakelock.js +107 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { hasWebSpeech, startWebSpeech } from './speech.js';
|
|
2
|
+
|
|
3
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
4
|
+
let ws = null;
|
|
5
|
+
let activeSessionId = null;
|
|
6
|
+
|
|
7
|
+
// Map of sessionId -> { term: Terminal, fitAddon, container, tabEl }
|
|
8
|
+
const sessions = new Map();
|
|
9
|
+
|
|
10
|
+
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
|
11
|
+
const authScreen = document.getElementById('auth-screen');
|
|
12
|
+
const appScreen = document.getElementById('app-screen');
|
|
13
|
+
const tokenInput = document.getElementById('token-input');
|
|
14
|
+
const connectBtn = document.getElementById('connect-btn');
|
|
15
|
+
const authError = document.getElementById('auth-error');
|
|
16
|
+
const tabsEl = document.getElementById('tabs');
|
|
17
|
+
const newTabBtn = document.getElementById('new-tab-btn');
|
|
18
|
+
const viewport = document.getElementById('terminal-viewport');
|
|
19
|
+
const copyBtn = document.getElementById('copy-btn');
|
|
20
|
+
const msgInput = document.getElementById('msg-input');
|
|
21
|
+
const sendBtn = document.getElementById('send-btn');
|
|
22
|
+
const micBtn = document.getElementById('mic-btn');
|
|
23
|
+
|
|
24
|
+
// ── Auto-fill token from URL param ────────────────────────────────────────────
|
|
25
|
+
const urlToken = new URLSearchParams(location.search).get('token');
|
|
26
|
+
if (urlToken) tokenInput.value = urlToken;
|
|
27
|
+
|
|
28
|
+
// ── Connect ───────────────────────────────────────────────────────────────────
|
|
29
|
+
function connect(token) {
|
|
30
|
+
setAuthError('');
|
|
31
|
+
connectBtn.disabled = true;
|
|
32
|
+
connectBtn.textContent = 'Connecting…';
|
|
33
|
+
|
|
34
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
35
|
+
ws = new WebSocket(`${proto}://${location.host}`);
|
|
36
|
+
|
|
37
|
+
ws.onopen = () => {
|
|
38
|
+
ws.send(JSON.stringify({ type: 'auth', token }));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
ws.onmessage = (event) => {
|
|
42
|
+
const msg = JSON.parse(event.data);
|
|
43
|
+
handleMessage(msg);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
ws.onerror = () => {
|
|
47
|
+
setAuthError('Connection failed. Make sure the server is running.');
|
|
48
|
+
resetConnectBtn();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
ws.onclose = () => {
|
|
52
|
+
if (appScreen.classList.contains('active')) {
|
|
53
|
+
showOverlay('Disconnected. Reload to reconnect.');
|
|
54
|
+
} else {
|
|
55
|
+
setAuthError('Connection closed.');
|
|
56
|
+
resetConnectBtn();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleMessage(msg) {
|
|
62
|
+
switch (msg.type) {
|
|
63
|
+
|
|
64
|
+
case 'auth_ok':
|
|
65
|
+
showApp();
|
|
66
|
+
if (msg.sessions.length === 0) {
|
|
67
|
+
requestNewSession();
|
|
68
|
+
} else {
|
|
69
|
+
// Re-hydrate existing sessions
|
|
70
|
+
for (const s of msg.sessions) addTab(s.id, s.label, false);
|
|
71
|
+
if (msg.sessions.length > 0) switchTo(msg.sessions[0].id);
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
|
|
75
|
+
case 'auth_fail':
|
|
76
|
+
setAuthError('Invalid token. Try again.');
|
|
77
|
+
resetConnectBtn();
|
|
78
|
+
ws.close();
|
|
79
|
+
break;
|
|
80
|
+
|
|
81
|
+
case 'session_created':
|
|
82
|
+
switchTo(msg.id);
|
|
83
|
+
break;
|
|
84
|
+
|
|
85
|
+
case 'sessions_updated':
|
|
86
|
+
syncTabs(msg.sessions);
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
case 'history':
|
|
90
|
+
case 'output': {
|
|
91
|
+
const s = sessions.get(msg.sessionId);
|
|
92
|
+
if (s) {
|
|
93
|
+
s.term.write(msg.data);
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case 'exit': {
|
|
99
|
+
const s = sessions.get(msg.sessionId);
|
|
100
|
+
if (s) {
|
|
101
|
+
s.term.write(`\r\n\x1b[31m[process exited with code ${msg.exitCode}]\x1b[0m\r\n`);
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case 'error':
|
|
107
|
+
alert(`Server error: ${msg.message}`);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Session / tab management ──────────────────────────────────────────────────
|
|
113
|
+
function requestNewSession(name) {
|
|
114
|
+
ws.send(JSON.stringify({ type: 'create_session', name: name || null }));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function addTab(id, label, subscribe = true) {
|
|
118
|
+
if (sessions.has(id)) return;
|
|
119
|
+
|
|
120
|
+
// Create xterm instance
|
|
121
|
+
const term = new Terminal({
|
|
122
|
+
theme: {
|
|
123
|
+
background: '#0d0d0d',
|
|
124
|
+
foreground: '#e8e8e8',
|
|
125
|
+
cursor: '#f97316',
|
|
126
|
+
selectionBackground: '#f9731644',
|
|
127
|
+
},
|
|
128
|
+
fontSize: 13,
|
|
129
|
+
fontFamily: '"Cascadia Code", "Fira Code", monospace',
|
|
130
|
+
cursorBlink: true,
|
|
131
|
+
scrollback: 2000,
|
|
132
|
+
convertEol: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const fitAddon = new FitAddon.FitAddon();
|
|
136
|
+
term.loadAddon(fitAddon);
|
|
137
|
+
|
|
138
|
+
// Container div
|
|
139
|
+
const container = document.createElement('div');
|
|
140
|
+
container.className = 'term-container';
|
|
141
|
+
container.dataset.sessionId = id;
|
|
142
|
+
viewport.appendChild(container);
|
|
143
|
+
term.open(container);
|
|
144
|
+
// Don't fit here — container is hidden. switchTo() handles fit once visible.
|
|
145
|
+
|
|
146
|
+
// Tab element
|
|
147
|
+
const tabEl = document.createElement('div');
|
|
148
|
+
tabEl.className = 'tab';
|
|
149
|
+
tabEl.dataset.sessionId = id;
|
|
150
|
+
tabEl.innerHTML = `<span class="tab-label">${label}</span><span class="close-tab">✕</span>`;
|
|
151
|
+
tabEl.querySelector('.tab-label').addEventListener('click', () => switchTo(id));
|
|
152
|
+
tabEl.querySelector('.close-tab').addEventListener('click', (e) => {
|
|
153
|
+
e.stopPropagation();
|
|
154
|
+
killSession(id);
|
|
155
|
+
});
|
|
156
|
+
tabsEl.appendChild(tabEl);
|
|
157
|
+
|
|
158
|
+
// Show/hide the copy button whenever the selection changes
|
|
159
|
+
term.onSelectionChange(() => {
|
|
160
|
+
if (id !== activeSessionId) return;
|
|
161
|
+
const has = term.getSelection().length > 0;
|
|
162
|
+
copyBtn.classList.toggle('hidden', !has);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
sessions.set(id, { term, fitAddon, container, tabEl });
|
|
166
|
+
|
|
167
|
+
if (subscribe) {
|
|
168
|
+
ws.send(JSON.stringify({ type: 'subscribe', sessionId: id }));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function switchTo(id) {
|
|
173
|
+
if (!sessions.has(id)) return;
|
|
174
|
+
|
|
175
|
+
// Deactivate current
|
|
176
|
+
if (activeSessionId && sessions.has(activeSessionId)) {
|
|
177
|
+
const cur = sessions.get(activeSessionId);
|
|
178
|
+
cur.container.classList.remove('active');
|
|
179
|
+
cur.tabEl.classList.remove('active');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
activeSessionId = id;
|
|
183
|
+
const s = sessions.get(id);
|
|
184
|
+
s.container.classList.add('active');
|
|
185
|
+
s.tabEl.classList.add('active');
|
|
186
|
+
copyBtn.classList.add('hidden'); // hide until a selection is made in this tab
|
|
187
|
+
s.tabEl.scrollIntoView({ inline: 'nearest', behavior: 'smooth' });
|
|
188
|
+
|
|
189
|
+
// Refit after becoming visible
|
|
190
|
+
requestAnimationFrame(() => {
|
|
191
|
+
s.fitAddon.fit();
|
|
192
|
+
sendResize(id, s.term);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
msgInput.focus();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function syncTabs(serverSessions) {
|
|
199
|
+
// Remove tabs for sessions that no longer exist
|
|
200
|
+
for (const [id] of sessions) {
|
|
201
|
+
if (!serverSessions.find((s) => s.id === id)) removeTab(id);
|
|
202
|
+
}
|
|
203
|
+
// Add tabs for new sessions
|
|
204
|
+
for (const s of serverSessions) {
|
|
205
|
+
if (!sessions.has(s.id)) addTab(s.id, s.label);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function removeTab(id) {
|
|
210
|
+
const s = sessions.get(id);
|
|
211
|
+
if (!s) return;
|
|
212
|
+
s.term.dispose();
|
|
213
|
+
s.container.remove();
|
|
214
|
+
s.tabEl.remove();
|
|
215
|
+
sessions.delete(id);
|
|
216
|
+
|
|
217
|
+
if (activeSessionId === id) {
|
|
218
|
+
activeSessionId = null;
|
|
219
|
+
const remaining = [...sessions.keys()];
|
|
220
|
+
if (remaining.length > 0) switchTo(remaining[remaining.length - 1]);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function killSession(id) {
|
|
225
|
+
ws.send(JSON.stringify({ type: 'kill_session', sessionId: id }));
|
|
226
|
+
removeTab(id);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function sendResize(id, term) {
|
|
230
|
+
ws.send(JSON.stringify({ type: 'resize', sessionId: id, cols: term.cols, rows: term.rows }));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Sending input ─────────────────────────────────────────────────────────────
|
|
234
|
+
function sendInput() {
|
|
235
|
+
const text = msgInput.value.trim();
|
|
236
|
+
if (!text || !activeSessionId) return;
|
|
237
|
+
ws.send(JSON.stringify({ type: 'input', sessionId: activeSessionId, data: text }));
|
|
238
|
+
msgInput.value = '';
|
|
239
|
+
msgInput.style.height = 'auto';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
sendBtn.addEventListener('click', sendInput);
|
|
243
|
+
|
|
244
|
+
msgInput.addEventListener('keydown', (e) => {
|
|
245
|
+
// Ctrl+Enter or Shift+Enter = send; plain Enter = newline for multi-line editing
|
|
246
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
sendInput();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Auto-grow textarea
|
|
253
|
+
msgInput.addEventListener('input', () => {
|
|
254
|
+
msgInput.style.height = 'auto';
|
|
255
|
+
msgInput.style.height = Math.min(msgInput.scrollHeight, 120) + 'px';
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ── Speech to text ────────────────────────────────────────────────────────────
|
|
259
|
+
// Strategy: tap mic → focus textarea → phone keyboard opens with its mic button.
|
|
260
|
+
// The keyboard mic (Google/Apple STT) is more reliable than the Web Speech API.
|
|
261
|
+
// Web Speech API is used as a fallback on desktop or if keyboard mic isn't available.
|
|
262
|
+
|
|
263
|
+
let activeRecognition = null;
|
|
264
|
+
|
|
265
|
+
function setMsgInputValue(text) {
|
|
266
|
+
msgInput.value = text;
|
|
267
|
+
msgInput.style.height = 'auto';
|
|
268
|
+
msgInput.style.height = Math.min(msgInput.scrollHeight, 120) + 'px';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function micDone() {
|
|
272
|
+
micBtn.classList.remove('listening');
|
|
273
|
+
micBtn.textContent = '🎤';
|
|
274
|
+
activeRecognition = null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
micBtn.addEventListener('click', (e) => {
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
|
|
280
|
+
// If Web Speech API is running, stop it
|
|
281
|
+
if (activeRecognition) {
|
|
282
|
+
try { activeRecognition.stop(); } catch {}
|
|
283
|
+
micDone();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// On mobile: just focus the textarea — the keyboard opens and the user
|
|
288
|
+
// can tap the mic key on their native keyboard (most reliable approach)
|
|
289
|
+
msgInput.focus();
|
|
290
|
+
|
|
291
|
+
// Also try Web Speech API — if it works in this browser it kicks in
|
|
292
|
+
// alongside; if it fires first and gives a clean result, great.
|
|
293
|
+
// If not (e.g. permission denied / not supported) we silently fall back
|
|
294
|
+
// to the native keyboard mic.
|
|
295
|
+
if (hasWebSpeech()) {
|
|
296
|
+
micBtn.classList.add('listening');
|
|
297
|
+
micBtn.textContent = '🔴';
|
|
298
|
+
|
|
299
|
+
activeRecognition = startWebSpeech({
|
|
300
|
+
onFinal(transcript) {
|
|
301
|
+
if (transcript) setMsgInputValue((msgInput.value + ' ' + transcript).trim());
|
|
302
|
+
micDone();
|
|
303
|
+
},
|
|
304
|
+
onError(msg) {
|
|
305
|
+
// Permission denied = tell the user; anything else = silent fallback
|
|
306
|
+
if (msg.includes('denied')) alert(msg);
|
|
307
|
+
micDone();
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ── Special keys toolbar ──────────────────────────────────────────────────────
|
|
314
|
+
document.getElementById('keys-bar').addEventListener('click', (e) => {
|
|
315
|
+
const btn = e.target.closest('.key-btn');
|
|
316
|
+
if (!btn || !activeSessionId) return;
|
|
317
|
+
const raw = btn.dataset.raw;
|
|
318
|
+
if (!raw) return;
|
|
319
|
+
ws.send(JSON.stringify({ type: 'input', sessionId: activeSessionId, data: raw, raw: true }));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ── Copy button ───────────────────────────────────────────────────────────────
|
|
323
|
+
copyBtn.addEventListener('click', async () => {
|
|
324
|
+
if (!activeSessionId) return;
|
|
325
|
+
const s = sessions.get(activeSessionId);
|
|
326
|
+
if (!s) return;
|
|
327
|
+
const text = s.term.getSelection();
|
|
328
|
+
if (!text) return;
|
|
329
|
+
try {
|
|
330
|
+
await navigator.clipboard.writeText(text);
|
|
331
|
+
copyBtn.textContent = 'Copied!';
|
|
332
|
+
copyBtn.classList.add('copied');
|
|
333
|
+
setTimeout(() => {
|
|
334
|
+
copyBtn.textContent = 'Copy';
|
|
335
|
+
copyBtn.classList.remove('copied');
|
|
336
|
+
copyBtn.classList.add('hidden');
|
|
337
|
+
s.term.clearSelection();
|
|
338
|
+
}, 1500);
|
|
339
|
+
} catch {
|
|
340
|
+
// clipboard API blocked (non-https or permissions) — fall back to prompt
|
|
341
|
+
prompt('Copy this text:', text);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ── New tab button ────────────────────────────────────────────────────────────
|
|
346
|
+
newTabBtn.addEventListener('click', () => requestNewSession());
|
|
347
|
+
|
|
348
|
+
// ── Resize handling ───────────────────────────────────────────────────────────
|
|
349
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
350
|
+
if (!activeSessionId) return;
|
|
351
|
+
const s = sessions.get(activeSessionId);
|
|
352
|
+
if (s) {
|
|
353
|
+
s.fitAddon.fit();
|
|
354
|
+
sendResize(activeSessionId, s.term);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
resizeObserver.observe(viewport);
|
|
358
|
+
|
|
359
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
360
|
+
function showApp() {
|
|
361
|
+
authScreen.classList.remove('active');
|
|
362
|
+
appScreen.classList.add('active');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function setAuthError(msg) {
|
|
366
|
+
authError.textContent = msg;
|
|
367
|
+
authError.classList.toggle('hidden', !msg);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function resetConnectBtn() {
|
|
371
|
+
connectBtn.disabled = false;
|
|
372
|
+
connectBtn.textContent = 'Connect';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function showOverlay(msg) {
|
|
376
|
+
const el = document.createElement('div');
|
|
377
|
+
el.id = 'empty-state';
|
|
378
|
+
el.innerHTML = `<div class="big">⚡</div><div>${msg}</div>`;
|
|
379
|
+
viewport.appendChild(el);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── Visual Viewport (keyboard resize) ────────────────────────────────────────
|
|
383
|
+
// When the on-screen keyboard opens, the visual viewport shrinks.
|
|
384
|
+
// We pin #app-screen to the visual viewport so the terminal shrinks
|
|
385
|
+
// and the input bar stays pinned above the keyboard.
|
|
386
|
+
if (window.visualViewport) {
|
|
387
|
+
const appScreen = document.getElementById('app-screen');
|
|
388
|
+
const updateViewport = () => {
|
|
389
|
+
const vv = window.visualViewport;
|
|
390
|
+
appScreen.style.height = vv.height + 'px';
|
|
391
|
+
appScreen.style.top = vv.offsetTop + 'px';
|
|
392
|
+
// Refit the active terminal so cols/rows match the new size
|
|
393
|
+
if (activeSessionId) {
|
|
394
|
+
const s = sessions.get(activeSessionId);
|
|
395
|
+
if (s) {
|
|
396
|
+
requestAnimationFrame(() => {
|
|
397
|
+
s.fitAddon.fit();
|
|
398
|
+
sendResize(activeSessionId, s.term);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
window.visualViewport.addEventListener('resize', updateViewport);
|
|
404
|
+
window.visualViewport.addEventListener('scroll', updateViewport);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
408
|
+
connectBtn.addEventListener('click', () => {
|
|
409
|
+
const token = tokenInput.value.trim();
|
|
410
|
+
if (!token) { setAuthError('Please enter your token.'); return; }
|
|
411
|
+
connect(token);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
tokenInput.addEventListener('keydown', (e) => {
|
|
415
|
+
if (e.key === 'Enter') connectBtn.click();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Auto-connect if token in URL
|
|
419
|
+
if (urlToken) connectBtn.click();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
6
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
7
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
8
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
9
|
+
<title>Conduit</title>
|
|
10
|
+
<link rel="manifest" href="/manifest.json" />
|
|
11
|
+
<link rel="stylesheet" href="/style.css" />
|
|
12
|
+
<!-- xterm.js -->
|
|
13
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" />
|
|
14
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
|
15
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
|
|
19
|
+
<!-- ── Auth Screen ──────────────────────────────────────────────────────── -->
|
|
20
|
+
<div id="auth-screen" class="screen active">
|
|
21
|
+
<div class="auth-box">
|
|
22
|
+
<div class="logo">
|
|
23
|
+
<span class="logo-icon">⬡</span>
|
|
24
|
+
<span class="logo-text">Conduit</span>
|
|
25
|
+
</div>
|
|
26
|
+
<p class="auth-hint">Enter the token shown on your laptop</p>
|
|
27
|
+
<input id="token-input" type="text" placeholder="xxxx-xxxx-xxxx" autocomplete="off" spellcheck="false" />
|
|
28
|
+
<button id="connect-btn">Connect</button>
|
|
29
|
+
<p id="auth-error" class="error hidden"></p>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<!-- ── Main App ──────────────────────────────────────────────────────────── -->
|
|
34
|
+
<div id="app-screen" class="screen">
|
|
35
|
+
|
|
36
|
+
<!-- Tab bar -->
|
|
37
|
+
<div id="tab-bar">
|
|
38
|
+
<div id="tabs"></div>
|
|
39
|
+
<button id="new-tab-btn" title="New terminal">+</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- Terminal viewport -->
|
|
43
|
+
<div id="terminal-viewport">
|
|
44
|
+
<!-- xterm instances are injected here -->
|
|
45
|
+
<button id="copy-btn" class="hidden" aria-label="Copy selection">Copy</button>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- Special keys toolbar -->
|
|
49
|
+
<div id="keys-bar">
|
|
50
|
+
<button class="key-btn" data-raw="">Esc</button>
|
|
51
|
+
<button class="key-btn" data-raw="	">Tab</button>
|
|
52
|
+
<div class="key-sep"></div>
|
|
53
|
+
<button class="key-btn" data-raw="[A">↑</button>
|
|
54
|
+
<button class="key-btn" data-raw="[B">↓</button>
|
|
55
|
+
<button class="key-btn" data-raw="[D">←</button>
|
|
56
|
+
<button class="key-btn" data-raw="[C">→</button>
|
|
57
|
+
<div class="key-sep"></div>
|
|
58
|
+
<button class="key-btn" data-raw="
">↵</button>
|
|
59
|
+
<button class="key-btn key-danger" data-raw="">Ctrl+C</button>
|
|
60
|
+
<div class="key-sep"></div>
|
|
61
|
+
<button class="key-btn key-confirm" data-raw="y
">y</button>
|
|
62
|
+
<button class="key-btn key-danger" data-raw="n
">n</button>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<!-- Input bar -->
|
|
66
|
+
<div id="input-bar">
|
|
67
|
+
<button id="mic-btn" title="Speech to text">🎤</button>
|
|
68
|
+
<textarea id="msg-input" placeholder="Type a command..." rows="1"></textarea>
|
|
69
|
+
<button id="send-btn">Send</button>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<script type="module" src="/app.js"></script>
|
|
75
|
+
</body>
|
|
76
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Conduit",
|
|
3
|
+
"short_name": "Conduit",
|
|
4
|
+
"description": "Run Claude Code from your phone",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#0d0d0d",
|
|
8
|
+
"theme_color": "#0d0d0d",
|
|
9
|
+
"orientation": "portrait-primary",
|
|
10
|
+
"icons": [
|
|
11
|
+
{
|
|
12
|
+
"src": "/icon-192.png",
|
|
13
|
+
"sizes": "192x192",
|
|
14
|
+
"type": "image/png"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"src": "/icon-512.png",
|
|
18
|
+
"sizes": "512x512",
|
|
19
|
+
"type": "image/png"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Speech input — two strategies:
|
|
2
|
+
//
|
|
3
|
+
// 1. NATIVE (preferred): focus the textarea so the keyboard opens.
|
|
4
|
+
// The user taps the mic on their phone keyboard (Google/Apple STT).
|
|
5
|
+
// Zero bugs, best accuracy, works everywhere.
|
|
6
|
+
//
|
|
7
|
+
// 2. WEB SPEECH API (fallback): single-shot, no interim results.
|
|
8
|
+
// Avoids the duplication bug caused by continuous + interim mode.
|
|
9
|
+
|
|
10
|
+
export function hasWebSpeech() {
|
|
11
|
+
return !!(window.SpeechRecognition || window.webkitSpeechRecognition);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Single-shot Web Speech API — start, speak, auto-stops on silence, fires onFinal once.
|
|
15
|
+
export function startWebSpeech({ onFinal, onError }) {
|
|
16
|
+
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
17
|
+
if (!SR) { onError('Not supported'); return null; }
|
|
18
|
+
|
|
19
|
+
const r = new SR();
|
|
20
|
+
r.continuous = false;
|
|
21
|
+
r.interimResults = false; // final result only — eliminates duplication
|
|
22
|
+
r.lang = navigator.language || 'en-US';
|
|
23
|
+
|
|
24
|
+
r.onresult = (event) => {
|
|
25
|
+
const transcript = event.results[0]?.[0]?.transcript?.trim() ?? '';
|
|
26
|
+
if (transcript) onFinal(transcript);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
r.onerror = (e) => {
|
|
30
|
+
if (e.error === 'not-allowed') onError('Microphone permission denied.');
|
|
31
|
+
else if (e.error !== 'no-speech' && e.error !== 'aborted') onError(`Speech error: ${e.error}`);
|
|
32
|
+
else onFinal(''); // no-speech / aborted = silent, not an error
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
r.onend = () => {}; // handled via onresult / onerror
|
|
36
|
+
|
|
37
|
+
try { r.start(); } catch {}
|
|
38
|
+
return r;
|
|
39
|
+
}
|