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 CHANGED
@@ -1,44 +1,44 @@
1
- {
2
- "name": "conduit-mobile",
3
- "version": "0.1.2",
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
- setAuthError('Connection failed. Make sure the server is running.');
48
- resetConnectBtn();
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
- showOverlay('Disconnected. Reload to reconnect.');
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(); }
@@ -36,6 +36,7 @@
36
36
  <!-- Tab bar -->
37
37
  <div id="tab-bar">
38
38
  <div id="tabs"></div>
39
+ <button id="notif-btn" title="Notifications off">🔕</button>
39
40
  <button id="new-tab-btn" title="New terminal">+</button>
40
41
  </div>
41
42
 
@@ -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;
@@ -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;