conduit-mobile 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +44 -44
- package/src/client/app.js +102 -5
- package/src/client/index.html +1 -0
- package/src/client/style.css +31 -2
- package/src/server/index.js +12 -0
package/package.json
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "conduit-mobile",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Run Claude Code from your phone. A secure terminal bridge with mobile web UI.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/server/index.js",
|
|
7
|
-
"bin": {
|
|
8
|
-
"conduit-mobile": "src/server/index.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"src/",
|
|
12
|
-
"README.md",
|
|
13
|
-
"LICENSE"
|
|
14
|
-
],
|
|
15
|
-
"engines": {
|
|
16
|
-
"node": ">=18.0.0"
|
|
17
|
-
},
|
|
18
|
-
"scripts": {
|
|
19
|
-
"start": "node src/server/index.js",
|
|
20
|
-
"dev": "node --watch src/server/index.js"
|
|
21
|
-
},
|
|
22
|
-
"keywords": [
|
|
23
|
-
"claude",
|
|
24
|
-
"claude-code",
|
|
25
|
-
"terminal",
|
|
26
|
-
"mobile",
|
|
27
|
-
"bridge",
|
|
28
|
-
"remote-terminal",
|
|
29
|
-
"xterm"
|
|
30
|
-
],
|
|
31
|
-
"author": "Arun <m.arunesh@gmail.com>",
|
|
32
|
-
"license": "MIT",
|
|
33
|
-
"repository": {
|
|
34
|
-
"type": "git",
|
|
35
|
-
"url": "git+https://github.com/Arun3sh/conduit.git"
|
|
36
|
-
},
|
|
37
|
-
"homepage": "https://github.com/Arun3sh/conduit#readme",
|
|
38
|
-
"dependencies": {
|
|
39
|
-
"express": "^4.19.2",
|
|
40
|
-
"node-pty": "^1.0.0",
|
|
41
|
-
"qrcode-terminal": "^0.12.0",
|
|
42
|
-
"ws": "^8.18.0"
|
|
43
|
-
}
|
|
44
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "conduit-mobile",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Run Claude Code from your phone. A secure terminal bridge with mobile web UI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/server/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"conduit-mobile": "src/server/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.0.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node src/server/index.js",
|
|
20
|
+
"dev": "node --watch src/server/index.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"claude",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"terminal",
|
|
26
|
+
"mobile",
|
|
27
|
+
"bridge",
|
|
28
|
+
"remote-terminal",
|
|
29
|
+
"xterm"
|
|
30
|
+
],
|
|
31
|
+
"author": "Arun <m.arunesh@gmail.com>",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/Arun3sh/conduit.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/Arun3sh/conduit#readme",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"express": "^4.19.2",
|
|
40
|
+
"node-pty": "^1.0.0",
|
|
41
|
+
"qrcode-terminal": "^0.12.0",
|
|
42
|
+
"ws": "^8.18.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/client/app.js
CHANGED
|
@@ -3,6 +3,9 @@ import { hasWebSpeech, startWebSpeech } from './speech.js';
|
|
|
3
3
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
4
4
|
let ws = null;
|
|
5
5
|
let activeSessionId = null;
|
|
6
|
+
let currentToken = null;
|
|
7
|
+
let reconnectTimer = null;
|
|
8
|
+
let reconnectAttempt = 0;
|
|
6
9
|
|
|
7
10
|
// Map of sessionId -> { term: Terminal, fitAddon, container, tabEl }
|
|
8
11
|
const sessions = new Map();
|
|
@@ -15,12 +18,64 @@ const connectBtn = document.getElementById('connect-btn');
|
|
|
15
18
|
const authError = document.getElementById('auth-error');
|
|
16
19
|
const tabsEl = document.getElementById('tabs');
|
|
17
20
|
const newTabBtn = document.getElementById('new-tab-btn');
|
|
21
|
+
const notifBtn = document.getElementById('notif-btn');
|
|
18
22
|
const viewport = document.getElementById('terminal-viewport');
|
|
19
23
|
const copyBtn = document.getElementById('copy-btn');
|
|
20
24
|
const msgInput = document.getElementById('msg-input');
|
|
21
25
|
const sendBtn = document.getElementById('send-btn');
|
|
22
26
|
const micBtn = document.getElementById('mic-btn');
|
|
23
27
|
|
|
28
|
+
// ── Notifications ─────────────────────────────────────────────────────────────
|
|
29
|
+
const unreadSessions = new Set();
|
|
30
|
+
let notificationsEnabled = localStorage.getItem('conduit_notifications') !== 'false';
|
|
31
|
+
|
|
32
|
+
function updateNotifBtn() {
|
|
33
|
+
notifBtn.textContent = notificationsEnabled ? '🔔' : '🔕';
|
|
34
|
+
notifBtn.title = notificationsEnabled ? 'Notifications on – click to disable' : 'Notifications off – click to enable';
|
|
35
|
+
notifBtn.classList.toggle('notif-on', notificationsEnabled);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function toggleNotifications() {
|
|
39
|
+
notificationsEnabled = !notificationsEnabled;
|
|
40
|
+
localStorage.setItem('conduit_notifications', notificationsEnabled);
|
|
41
|
+
if (notificationsEnabled && 'Notification' in window && Notification.permission === 'default') {
|
|
42
|
+
await Notification.requestPermission();
|
|
43
|
+
}
|
|
44
|
+
updateNotifBtn();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function markActivity(sessionId) {
|
|
48
|
+
if (!notificationsEnabled) return;
|
|
49
|
+
if (sessionId === activeSessionId && !document.hidden) return;
|
|
50
|
+
const s = sessions.get(sessionId);
|
|
51
|
+
if (!s || unreadSessions.has(sessionId)) return;
|
|
52
|
+
unreadSessions.add(sessionId);
|
|
53
|
+
s.tabEl.classList.add('has-activity');
|
|
54
|
+
updatePageTitle();
|
|
55
|
+
if (document.hidden && 'Notification' in window && Notification.permission === 'granted') {
|
|
56
|
+
new Notification('Conduit', {
|
|
57
|
+
body: `${s.label} has new activity`,
|
|
58
|
+
icon: '/icons/icon-192.png',
|
|
59
|
+
tag: `conduit-${sessionId}`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clearActivity(sessionId) {
|
|
65
|
+
if (!unreadSessions.has(sessionId)) return;
|
|
66
|
+
unreadSessions.delete(sessionId);
|
|
67
|
+
const s = sessions.get(sessionId);
|
|
68
|
+
if (s) s.tabEl.classList.remove('has-activity');
|
|
69
|
+
updatePageTitle();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function updatePageTitle() {
|
|
73
|
+
document.title = unreadSessions.size > 0 ? `(${unreadSessions.size}) Conduit` : 'Conduit';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
notifBtn.addEventListener('click', toggleNotifications);
|
|
77
|
+
updateNotifBtn();
|
|
78
|
+
|
|
24
79
|
// ── Auto-fill token from URL param ────────────────────────────────────────────
|
|
25
80
|
const urlToken = new URLSearchParams(location.search).get('token');
|
|
26
81
|
if (urlToken) tokenInput.value = urlToken;
|
|
@@ -44,13 +99,15 @@ function connect(token) {
|
|
|
44
99
|
};
|
|
45
100
|
|
|
46
101
|
ws.onerror = () => {
|
|
47
|
-
|
|
48
|
-
|
|
102
|
+
if (!appScreen.classList.contains('active')) {
|
|
103
|
+
setAuthError('Connection failed. Make sure the server is running.');
|
|
104
|
+
resetConnectBtn();
|
|
105
|
+
}
|
|
49
106
|
};
|
|
50
107
|
|
|
51
108
|
ws.onclose = () => {
|
|
52
109
|
if (appScreen.classList.contains('active')) {
|
|
53
|
-
|
|
110
|
+
scheduleReconnect();
|
|
54
111
|
} else {
|
|
55
112
|
setAuthError('Connection closed.');
|
|
56
113
|
resetConnectBtn();
|
|
@@ -58,10 +115,45 @@ function connect(token) {
|
|
|
58
115
|
};
|
|
59
116
|
}
|
|
60
117
|
|
|
118
|
+
function scheduleReconnect() {
|
|
119
|
+
clearTimeout(reconnectTimer);
|
|
120
|
+
const delay = Math.min(1000 * 2 ** reconnectAttempt, 30_000);
|
|
121
|
+
reconnectAttempt++;
|
|
122
|
+
showReconnectOverlay(reconnectAttempt === 1 ? 'Reconnecting…' : `Reconnecting… (attempt ${reconnectAttempt})`);
|
|
123
|
+
reconnectTimer = setTimeout(() => reconnect(), delay);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function reconnect() {
|
|
127
|
+
// Clear stale session UI without killing server-side processes
|
|
128
|
+
for (const [id, s] of sessions) {
|
|
129
|
+
s.term.dispose();
|
|
130
|
+
s.container.remove();
|
|
131
|
+
s.tabEl.remove();
|
|
132
|
+
}
|
|
133
|
+
sessions.clear();
|
|
134
|
+
activeSessionId = null;
|
|
135
|
+
removeReconnectOverlay();
|
|
136
|
+
connect(currentToken);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function showReconnectOverlay(msg) {
|
|
140
|
+
removeReconnectOverlay();
|
|
141
|
+
const el = document.createElement('div');
|
|
142
|
+
el.id = 'reconnect-overlay';
|
|
143
|
+
el.textContent = msg;
|
|
144
|
+
viewport.appendChild(el);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function removeReconnectOverlay() {
|
|
148
|
+
document.getElementById('reconnect-overlay')?.remove();
|
|
149
|
+
}
|
|
150
|
+
|
|
61
151
|
function handleMessage(msg) {
|
|
62
152
|
switch (msg.type) {
|
|
63
153
|
|
|
64
154
|
case 'auth_ok':
|
|
155
|
+
reconnectAttempt = 0;
|
|
156
|
+
removeReconnectOverlay();
|
|
65
157
|
showApp();
|
|
66
158
|
if (msg.sessions.length === 0) {
|
|
67
159
|
requestNewSession();
|
|
@@ -91,6 +183,7 @@ function handleMessage(msg) {
|
|
|
91
183
|
const s = sessions.get(msg.sessionId);
|
|
92
184
|
if (s) {
|
|
93
185
|
s.term.write(msg.data);
|
|
186
|
+
if (msg.type === 'output') markActivity(msg.sessionId);
|
|
94
187
|
}
|
|
95
188
|
break;
|
|
96
189
|
}
|
|
@@ -99,6 +192,7 @@ function handleMessage(msg) {
|
|
|
99
192
|
const s = sessions.get(msg.sessionId);
|
|
100
193
|
if (s) {
|
|
101
194
|
s.term.write(`\r\n\x1b[31m[process exited with code ${msg.exitCode}]\x1b[0m\r\n`);
|
|
195
|
+
markActivity(msg.sessionId);
|
|
102
196
|
}
|
|
103
197
|
break;
|
|
104
198
|
}
|
|
@@ -162,7 +256,7 @@ function addTab(id, label, subscribe = true) {
|
|
|
162
256
|
copyBtn.classList.toggle('hidden', !has);
|
|
163
257
|
});
|
|
164
258
|
|
|
165
|
-
sessions.set(id, { term, fitAddon, container, tabEl });
|
|
259
|
+
sessions.set(id, { term, fitAddon, container, tabEl, label });
|
|
166
260
|
|
|
167
261
|
if (subscribe) {
|
|
168
262
|
ws.send(JSON.stringify({ type: 'subscribe', sessionId: id }));
|
|
@@ -180,6 +274,7 @@ function switchTo(id) {
|
|
|
180
274
|
}
|
|
181
275
|
|
|
182
276
|
activeSessionId = id;
|
|
277
|
+
clearActivity(id);
|
|
183
278
|
const s = sessions.get(id);
|
|
184
279
|
s.container.classList.add('active');
|
|
185
280
|
s.tabEl.classList.add('active');
|
|
@@ -209,6 +304,7 @@ function syncTabs(serverSessions) {
|
|
|
209
304
|
function removeTab(id) {
|
|
210
305
|
const s = sessions.get(id);
|
|
211
306
|
if (!s) return;
|
|
307
|
+
clearActivity(id);
|
|
212
308
|
s.term.dispose();
|
|
213
309
|
s.container.remove();
|
|
214
310
|
s.tabEl.remove();
|
|
@@ -408,6 +504,7 @@ if (window.visualViewport) {
|
|
|
408
504
|
connectBtn.addEventListener('click', () => {
|
|
409
505
|
const token = tokenInput.value.trim();
|
|
410
506
|
if (!token) { setAuthError('Please enter your token.'); return; }
|
|
507
|
+
currentToken = token;
|
|
411
508
|
connect(token);
|
|
412
509
|
});
|
|
413
510
|
|
|
@@ -416,4 +513,4 @@ tokenInput.addEventListener('keydown', (e) => {
|
|
|
416
513
|
});
|
|
417
514
|
|
|
418
515
|
// Auto-connect if token in URL
|
|
419
|
-
if (urlToken) connectBtn.click();
|
|
516
|
+
if (urlToken) { currentToken = urlToken; connectBtn.click(); }
|
package/src/client/index.html
CHANGED
package/src/client/style.css
CHANGED
|
@@ -144,7 +144,7 @@ html, body {
|
|
|
144
144
|
}
|
|
145
145
|
.tab .close-tab:hover { background: #ff444444; color: #f87171; }
|
|
146
146
|
|
|
147
|
-
#new-tab-btn {
|
|
147
|
+
#new-tab-btn, #notif-btn {
|
|
148
148
|
background: none;
|
|
149
149
|
border: none;
|
|
150
150
|
color: var(--text-dim);
|
|
@@ -159,7 +159,23 @@ html, body {
|
|
|
159
159
|
justify-content: center;
|
|
160
160
|
transition: background 0.1s;
|
|
161
161
|
}
|
|
162
|
-
#new-tab-btn:hover { background: var(--border); color: var(--text); }
|
|
162
|
+
#new-tab-btn:hover, #notif-btn:hover { background: var(--border); color: var(--text); }
|
|
163
|
+
#notif-btn { font-size: 1.1rem; }
|
|
164
|
+
#notif-btn.notif-on { color: var(--accent); }
|
|
165
|
+
|
|
166
|
+
/* Activity badge on tabs */
|
|
167
|
+
.tab.has-activity { position: relative; }
|
|
168
|
+
.tab.has-activity::after {
|
|
169
|
+
content: '';
|
|
170
|
+
position: absolute;
|
|
171
|
+
top: 6px;
|
|
172
|
+
right: 6px;
|
|
173
|
+
width: 7px;
|
|
174
|
+
height: 7px;
|
|
175
|
+
border-radius: 50%;
|
|
176
|
+
background: var(--accent);
|
|
177
|
+
box-shadow: 0 0 4px var(--accent);
|
|
178
|
+
}
|
|
163
179
|
|
|
164
180
|
/* ── Terminal viewport ────────────────────────────────────────────────────── */
|
|
165
181
|
#terminal-viewport {
|
|
@@ -305,6 +321,19 @@ html, body {
|
|
|
305
321
|
#send-btn:active { opacity: 0.8; }
|
|
306
322
|
#send-btn:disabled { background: var(--accent-dim); cursor: not-allowed; }
|
|
307
323
|
|
|
324
|
+
/* ── Reconnect overlay ────────────────────────────────────────────────────── */
|
|
325
|
+
#reconnect-overlay {
|
|
326
|
+
position: absolute;
|
|
327
|
+
inset: 0;
|
|
328
|
+
display: flex;
|
|
329
|
+
align-items: center;
|
|
330
|
+
justify-content: center;
|
|
331
|
+
background: rgba(13,13,13,0.85);
|
|
332
|
+
color: var(--text-dim);
|
|
333
|
+
font-size: 0.9rem;
|
|
334
|
+
z-index: 10;
|
|
335
|
+
}
|
|
336
|
+
|
|
308
337
|
/* ── Empty state ──────────────────────────────────────────────────────────── */
|
|
309
338
|
#empty-state {
|
|
310
339
|
position: absolute;
|
package/src/server/index.js
CHANGED
|
@@ -29,8 +29,20 @@ const server = createServer(app);
|
|
|
29
29
|
// ── WebSocket ─────────────────────────────────────────────────────────────────
|
|
30
30
|
const wss = new WebSocketServer({ server });
|
|
31
31
|
|
|
32
|
+
// ── Heartbeat – keeps idle connections alive through tunnels/proxies ───────────
|
|
33
|
+
const heartbeatInterval = setInterval(() => {
|
|
34
|
+
for (const client of wss.clients) {
|
|
35
|
+
if (client.isAlive === false) { client.terminate(); continue; }
|
|
36
|
+
client.isAlive = false;
|
|
37
|
+
client.ping();
|
|
38
|
+
}
|
|
39
|
+
}, 25_000);
|
|
40
|
+
wss.on('close', () => clearInterval(heartbeatInterval));
|
|
41
|
+
|
|
32
42
|
wss.on('connection', (ws, req) => {
|
|
33
43
|
let authenticated = false;
|
|
44
|
+
ws.isAlive = true;
|
|
45
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
34
46
|
|
|
35
47
|
ws.on('message', (raw) => {
|
|
36
48
|
let msg;
|