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.
@@ -0,0 +1,320 @@
1
+ /* ── Reset & base ─────────────────────────────────────────────────────────── */
2
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
3
+
4
+ :root {
5
+ --bg: #0d0d0d;
6
+ --surface: #161616;
7
+ --border: #2a2a2a;
8
+ --accent: #f97316; /* orange — Claude-ish */
9
+ --accent-dim:#7c3910;
10
+ --text: #e8e8e8;
11
+ --text-dim: #666;
12
+ --tab-h: 48px;
13
+ --input-h: 64px;
14
+ --safe-b: env(safe-area-inset-bottom, 0px);
15
+ }
16
+
17
+ html, body {
18
+ height: 100%;
19
+ width: 100%;
20
+ overflow: hidden;
21
+ background: var(--bg);
22
+ color: var(--text);
23
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
24
+ -webkit-tap-highlight-color: transparent;
25
+ }
26
+
27
+ /* ── Screens ──────────────────────────────────────────────────────────────── */
28
+ .screen { display: none; width: 100%; position: fixed; top: 0; left: 0; right: 0; bottom: 0; }
29
+ .screen.active { display: flex; }
30
+
31
+ /* ── Auth ─────────────────────────────────────────────────────────────────── */
32
+ #auth-screen {
33
+ align-items: center;
34
+ justify-content: center;
35
+ background: var(--bg);
36
+ }
37
+
38
+ .auth-box {
39
+ display: flex;
40
+ flex-direction: column;
41
+ align-items: center;
42
+ gap: 16px;
43
+ width: 90%;
44
+ max-width: 360px;
45
+ }
46
+
47
+ .logo {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 10px;
51
+ margin-bottom: 8px;
52
+ }
53
+ .logo-icon { font-size: 2rem; }
54
+ .logo-text { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.5px; }
55
+
56
+ .auth-hint { color: var(--text-dim); font-size: 0.875rem; text-align: center; }
57
+
58
+ #token-input {
59
+ width: 100%;
60
+ background: var(--surface);
61
+ border: 1px solid var(--border);
62
+ border-radius: 10px;
63
+ color: var(--text);
64
+ font-size: 1.1rem;
65
+ letter-spacing: 2px;
66
+ padding: 14px 16px;
67
+ text-align: center;
68
+ outline: none;
69
+ }
70
+ #token-input:focus { border-color: var(--accent); }
71
+
72
+ #connect-btn {
73
+ width: 100%;
74
+ background: var(--accent);
75
+ border: none;
76
+ border-radius: 10px;
77
+ color: #fff;
78
+ font-size: 1rem;
79
+ font-weight: 600;
80
+ padding: 14px;
81
+ cursor: pointer;
82
+ transition: opacity 0.15s;
83
+ }
84
+ #connect-btn:active { opacity: 0.8; }
85
+
86
+ .error { color: #f87171; font-size: 0.85rem; }
87
+ .hidden { display: none !important; }
88
+
89
+ /* ── App layout ───────────────────────────────────────────────────────────── */
90
+ #app-screen {
91
+ flex-direction: column;
92
+ background: var(--bg);
93
+ overflow: hidden;
94
+ }
95
+
96
+ /* ── Tab bar ──────────────────────────────────────────────────────────────── */
97
+ #tab-bar {
98
+ display: flex;
99
+ align-items: center;
100
+ height: var(--tab-h);
101
+ background: var(--surface);
102
+ border-bottom: 1px solid var(--border);
103
+ flex-shrink: 0;
104
+ padding-left: 4px;
105
+ overflow: hidden;
106
+ }
107
+
108
+ #tabs {
109
+ display: flex;
110
+ gap: 2px;
111
+ overflow-x: auto;
112
+ flex: 1;
113
+ scrollbar-width: none;
114
+ }
115
+ #tabs::-webkit-scrollbar { display: none; }
116
+
117
+ .tab {
118
+ display: flex;
119
+ align-items: center;
120
+ gap: 6px;
121
+ padding: 0 12px;
122
+ height: 36px;
123
+ border-radius: 8px;
124
+ font-size: 0.8rem;
125
+ white-space: nowrap;
126
+ cursor: pointer;
127
+ color: var(--text-dim);
128
+ border: 1px solid transparent;
129
+ flex-shrink: 0;
130
+ transition: background 0.1s;
131
+ }
132
+ .tab:hover { background: var(--border); color: var(--text); }
133
+ .tab.active {
134
+ background: var(--bg);
135
+ border-color: var(--border);
136
+ color: var(--text);
137
+ }
138
+ .tab .close-tab {
139
+ font-size: 0.7rem;
140
+ color: var(--text-dim);
141
+ padding: 2px 4px;
142
+ border-radius: 4px;
143
+ line-height: 1;
144
+ }
145
+ .tab .close-tab:hover { background: #ff444444; color: #f87171; }
146
+
147
+ #new-tab-btn {
148
+ background: none;
149
+ border: none;
150
+ color: var(--text-dim);
151
+ font-size: 1.4rem;
152
+ width: 40px;
153
+ height: 40px;
154
+ cursor: pointer;
155
+ border-radius: 8px;
156
+ flex-shrink: 0;
157
+ display: flex;
158
+ align-items: center;
159
+ justify-content: center;
160
+ transition: background 0.1s;
161
+ }
162
+ #new-tab-btn:hover { background: var(--border); color: var(--text); }
163
+
164
+ /* ── Terminal viewport ────────────────────────────────────────────────────── */
165
+ #terminal-viewport {
166
+ flex: 1;
167
+ overflow: hidden;
168
+ position: relative;
169
+ min-height: 0; /* required: lets flex child shrink below content size */
170
+ }
171
+
172
+ .term-container {
173
+ position: absolute;
174
+ inset: 0;
175
+ padding: 4px;
176
+ display: none;
177
+ }
178
+ .term-container.active { display: block; }
179
+
180
+ /* ── Floating copy button ─────────────────────────────────────────────────── */
181
+ #copy-btn {
182
+ position: absolute;
183
+ top: 10px;
184
+ right: 10px;
185
+ z-index: 10;
186
+ background: var(--accent);
187
+ border: none;
188
+ border-radius: 8px;
189
+ color: #fff;
190
+ font-size: 0.85rem;
191
+ font-weight: 600;
192
+ padding: 7px 14px;
193
+ cursor: pointer;
194
+ box-shadow: 0 2px 10px #0008;
195
+ transition: opacity 0.15s, transform 0.1s;
196
+ }
197
+ #copy-btn:active { transform: scale(0.94); }
198
+ #copy-btn.hidden { display: none; }
199
+ #copy-btn.copied { background: #16a34a; }
200
+
201
+ /* xterm overrides */
202
+ .xterm { height: 100%; }
203
+ .xterm-viewport { border-radius: 4px; }
204
+
205
+ /* ── Special keys toolbar ────────────────────────────────────────────────── */
206
+ #keys-bar {
207
+ display: flex;
208
+ align-items: center;
209
+ gap: 4px;
210
+ padding: 5px 8px;
211
+ background: var(--surface);
212
+ border-top: 1px solid var(--border);
213
+ overflow-x: auto;
214
+ flex-shrink: 0;
215
+ scrollbar-width: none;
216
+ }
217
+ #keys-bar::-webkit-scrollbar { display: none; }
218
+
219
+ .key-btn {
220
+ background: var(--bg);
221
+ border: 1px solid var(--border);
222
+ border-radius: 6px;
223
+ color: var(--text);
224
+ font-size: 0.78rem;
225
+ font-weight: 500;
226
+ padding: 5px 10px;
227
+ cursor: pointer;
228
+ white-space: nowrap;
229
+ flex-shrink: 0;
230
+ height: 30px;
231
+ transition: background 0.1s, transform 0.08s;
232
+ font-family: inherit;
233
+ }
234
+ .key-btn:active { background: var(--border); transform: scale(0.93); }
235
+ .key-confirm { border-color: #16a34a; color: #4ade80; }
236
+ .key-danger { border-color: #7f1d1d; color: #f87171; }
237
+ .key-sep { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; margin: 0 2px; }
238
+
239
+ /* ── Input bar ────────────────────────────────────────────────────────────── */
240
+ #input-bar {
241
+ display: flex;
242
+ align-items: flex-end;
243
+ gap: 8px;
244
+ padding: 8px 10px calc(8px + var(--safe-b));
245
+ background: var(--surface);
246
+ border-top: 1px solid var(--border);
247
+ flex-shrink: 0;
248
+ }
249
+
250
+ #mic-btn {
251
+ background: var(--border);
252
+ border: none;
253
+ border-radius: 50%;
254
+ width: 40px;
255
+ height: 40px;
256
+ font-size: 1.1rem;
257
+ cursor: pointer;
258
+ flex-shrink: 0;
259
+ display: flex;
260
+ align-items: center;
261
+ justify-content: center;
262
+ transition: background 0.15s;
263
+ }
264
+ #mic-btn.listening {
265
+ background: #7f1d1d;
266
+ border: 2px solid #f87171;
267
+ animation: mic-pulse 0.8s ease-in-out infinite;
268
+ }
269
+
270
+ @keyframes mic-pulse {
271
+ 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 #f8717144; }
272
+ 50% { transform: scale(1.12); box-shadow: 0 0 0 6px #f8717100; }
273
+ }
274
+
275
+ #msg-input {
276
+ flex: 1;
277
+ background: var(--bg);
278
+ border: 1px solid var(--border);
279
+ border-radius: 10px;
280
+ color: var(--text);
281
+ font-size: 0.95rem;
282
+ line-height: 1.4;
283
+ padding: 10px 12px;
284
+ resize: none;
285
+ outline: none;
286
+ max-height: 120px;
287
+ overflow-y: auto;
288
+ font-family: inherit;
289
+ }
290
+ #msg-input:focus { border-color: var(--accent); }
291
+
292
+ #send-btn {
293
+ background: var(--accent);
294
+ border: none;
295
+ border-radius: 10px;
296
+ color: #fff;
297
+ font-size: 0.9rem;
298
+ font-weight: 600;
299
+ padding: 10px 16px;
300
+ cursor: pointer;
301
+ flex-shrink: 0;
302
+ height: 40px;
303
+ transition: opacity 0.15s;
304
+ }
305
+ #send-btn:active { opacity: 0.8; }
306
+ #send-btn:disabled { background: var(--accent-dim); cursor: not-allowed; }
307
+
308
+ /* ── Empty state ──────────────────────────────────────────────────────────── */
309
+ #empty-state {
310
+ position: absolute;
311
+ inset: 0;
312
+ display: flex;
313
+ flex-direction: column;
314
+ align-items: center;
315
+ justify-content: center;
316
+ gap: 12px;
317
+ color: var(--text-dim);
318
+ font-size: 0.9rem;
319
+ }
320
+ #empty-state .big { font-size: 2.5rem; }
@@ -0,0 +1,14 @@
1
+ import { randomBytes } from 'crypto';
2
+
3
+ let sessionToken = null;
4
+
5
+ export function generateToken() {
6
+ const raw = randomBytes(12).toString('hex');
7
+ // Format as XXX-XXX-XXX for readability
8
+ sessionToken = `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`;
9
+ return sessionToken;
10
+ }
11
+
12
+ export function validateToken(token) {
13
+ return token === sessionToken;
14
+ }
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ import express from 'express';
3
+ import { createServer } from 'http';
4
+ import { WebSocketServer } from 'ws';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, join } from 'path';
7
+ import { networkInterfaces } from 'os';
8
+ import qrcode from 'qrcode-terminal';
9
+
10
+ import { generateToken, validateToken } from './auth.js';
11
+ import * as terminals from './terminals.js';
12
+ import { startTunnel } from './tunnel.js';
13
+ import { acquireWakeLock } from './wakelock.js';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const PORT = process.env.PORT || 3131;
17
+
18
+ // ── Express ──────────────────────────────────────────────────────────────────
19
+ const app = express();
20
+ app.use(express.json());
21
+ app.use(express.static(join(__dirname, '../../src/client')));
22
+
23
+ // Health check
24
+ app.get('/health', (_req, res) => res.json({ ok: true }));
25
+
26
+ // ── HTTP server ───────────────────────────────────────────────────────────────
27
+ const server = createServer(app);
28
+
29
+ // ── WebSocket ─────────────────────────────────────────────────────────────────
30
+ const wss = new WebSocketServer({ server });
31
+
32
+ wss.on('connection', (ws, req) => {
33
+ let authenticated = false;
34
+
35
+ ws.on('message', (raw) => {
36
+ let msg;
37
+ try { msg = JSON.parse(raw); } catch { return; }
38
+
39
+ // ── Auth handshake ──────────────────────────────────────────────────────
40
+ if (!authenticated) {
41
+ if (msg.type === 'auth' && validateToken(msg.token)) {
42
+ authenticated = true;
43
+ ws.send(JSON.stringify({ type: 'auth_ok', sessions: terminals.listSessions() }));
44
+ } else {
45
+ ws.send(JSON.stringify({ type: 'auth_fail' }));
46
+ ws.close();
47
+ }
48
+ return;
49
+ }
50
+
51
+ // ── Authenticated messages ──────────────────────────────────────────────
52
+ switch (msg.type) {
53
+
54
+ case 'create_session': {
55
+ try {
56
+ const { id, label } = terminals.createSession(msg.name);
57
+ const sessions = terminals.listSessions();
58
+ broadcast({ type: 'sessions_updated', sessions });
59
+ terminals.subscribe(id, ws);
60
+ ws.send(JSON.stringify({ type: 'session_created', id, label }));
61
+ } catch (err) {
62
+ console.error('Failed to create session:', err.message);
63
+ ws.send(JSON.stringify({ type: 'error', message: `Could not spawn terminal: ${err.message}` }));
64
+ }
65
+ break;
66
+ }
67
+
68
+ case 'subscribe': {
69
+ const ok = terminals.subscribe(msg.sessionId, ws);
70
+ if (!ok) ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
71
+ break;
72
+ }
73
+
74
+ case 'unsubscribe': {
75
+ // handled on close; explicit unsubscribe from a single session not needed for MVP
76
+ break;
77
+ }
78
+
79
+ case 'input': {
80
+ // raw=true means the data is an escape sequence — send as-is
81
+ // otherwise it's a typed command — ensure it ends with \r to execute
82
+ let toSend = msg.data;
83
+ if (!msg.raw) {
84
+ toSend = toSend.replace(/\n/g, '\r');
85
+ if (!toSend.endsWith('\r')) toSend += '\r';
86
+ }
87
+ terminals.sendInput(msg.sessionId, toSend);
88
+ break;
89
+ }
90
+
91
+ case 'resize': {
92
+ terminals.resizeSession(msg.sessionId, msg.cols, msg.rows);
93
+ break;
94
+ }
95
+
96
+ case 'kill_session': {
97
+ const killed = terminals.killSession(msg.sessionId);
98
+ if (killed) broadcast({ type: 'sessions_updated', sessions: terminals.listSessions() });
99
+ break;
100
+ }
101
+
102
+ case 'list_sessions': {
103
+ ws.send(JSON.stringify({ type: 'sessions_updated', sessions: terminals.listSessions() }));
104
+ break;
105
+ }
106
+ }
107
+ });
108
+
109
+ ws.on('close', () => {
110
+ terminals.unsubscribe(ws);
111
+ });
112
+ });
113
+
114
+ function broadcast(msg) {
115
+ const data = JSON.stringify(msg);
116
+ for (const client of wss.clients) {
117
+ if (client.readyState === 1) client.send(data);
118
+ }
119
+ }
120
+
121
+ // ── Boot ──────────────────────────────────────────────────────────────────────
122
+ server.listen(PORT, async () => {
123
+ await acquireWakeLock();
124
+ const token = generateToken();
125
+
126
+ console.clear();
127
+ console.log('\n ╔══════════════════════════════════════════╗');
128
+ console.log(' ║ Conduit v0.1.0 ║');
129
+ console.log(' ╚══════════════════════════════════════════╝\n');
130
+ console.log(` Local: http://localhost:${PORT}`);
131
+ console.log(` Token: ${token}\n`);
132
+
133
+ // Try to start Cloudflare tunnel
134
+ console.log(' Starting Cloudflare tunnel...');
135
+ const tunnelUrl = await startTunnel(PORT);
136
+
137
+ if (tunnelUrl) {
138
+ const connectUrl = `${tunnelUrl}?token=${token}`;
139
+ console.log(`\n Remote: ${tunnelUrl}`);
140
+ console.log('\n ┌─ Scan with phone ──────────────────────────┐\n');
141
+ qrcode.generate(connectUrl, { small: true }, (qr) => {
142
+ qr.split('\n').forEach((line) => console.log(' ' + line));
143
+ });
144
+ console.log('\n └────────────────────────────────────────────┘\n');
145
+ console.log(` Or open: ${connectUrl}\n`);
146
+ } else {
147
+ console.log('\n ⚠ Remote access unavailable (cloudflared could not be downloaded).\n');
148
+ console.log(' ┌─ Local network QR ─────────────────────────┐\n');
149
+ const localIp = getLocalIp();
150
+ const localUrl = `http://${localIp}:${PORT}?token=${token}`;
151
+ qrcode.generate(localUrl, { small: true }, (qr) => {
152
+ qr.split('\n').forEach((line) => console.log(' ' + line));
153
+ });
154
+ console.log('\n └────────────────────────────────────────────┘\n');
155
+ console.log(` Or open: ${localUrl}\n`);
156
+ }
157
+
158
+ console.log(' Press Ctrl+C to stop.\n');
159
+ });
160
+
161
+ function getLocalIp() {
162
+ const nets = networkInterfaces();
163
+ for (const iface of Object.values(nets)) {
164
+ for (const net of iface) {
165
+ if (net.family === 'IPv4' && !net.internal) return net.address;
166
+ }
167
+ }
168
+ return 'localhost';
169
+ }
@@ -0,0 +1,91 @@
1
+ import * as pty from 'node-pty';
2
+ import { randomBytes } from 'crypto';
3
+
4
+ // Map of sessionId -> { pty, name, subscribers: Set<ws> }
5
+ const sessions = new Map();
6
+
7
+ function defaultShell() {
8
+ return process.platform === 'win32' ? 'powershell.exe' : (process.env.SHELL || 'bash');
9
+ }
10
+
11
+ export function createSession(name) {
12
+ const id = randomBytes(4).toString('hex');
13
+ const label = name || `terminal-${sessions.size + 1}`;
14
+
15
+ const term = pty.spawn(defaultShell(), [], {
16
+ name: 'xterm-color',
17
+ cols: 220,
18
+ rows: 50,
19
+ cwd: process.env.HOME || process.cwd(),
20
+ env: process.env,
21
+ });
22
+
23
+ const session = { id, label, pty: term, subscribers: new Set(), history: [] };
24
+ sessions.set(id, session);
25
+
26
+ term.onData((data) => {
27
+ // Keep a scrollback buffer (last 2000 lines worth)
28
+ session.history.push(data);
29
+ if (session.history.length > 500) session.history.shift();
30
+
31
+ // Broadcast to all subscribers of this session
32
+ for (const ws of session.subscribers) {
33
+ if (ws.readyState === 1 /* OPEN */) {
34
+ ws.send(JSON.stringify({ type: 'output', sessionId: id, data }));
35
+ }
36
+ }
37
+ });
38
+
39
+ term.onExit(({ exitCode }) => {
40
+ for (const ws of session.subscribers) {
41
+ if (ws.readyState === 1) {
42
+ ws.send(JSON.stringify({ type: 'exit', sessionId: id, exitCode }));
43
+ }
44
+ }
45
+ sessions.delete(id);
46
+ });
47
+
48
+ return { id, label };
49
+ }
50
+
51
+ export function listSessions() {
52
+ return [...sessions.values()].map(({ id, label }) => ({ id, label }));
53
+ }
54
+
55
+ export function sendInput(sessionId, data) {
56
+ const session = sessions.get(sessionId);
57
+ if (!session) return false;
58
+ session.pty.write(data);
59
+ return true;
60
+ }
61
+
62
+ export function resizeSession(sessionId, cols, rows) {
63
+ const session = sessions.get(sessionId);
64
+ if (!session) return;
65
+ session.pty.resize(cols, rows);
66
+ }
67
+
68
+ export function subscribe(sessionId, ws) {
69
+ const session = sessions.get(sessionId);
70
+ if (!session) return false;
71
+ session.subscribers.add(ws);
72
+ // Send scrollback history so the client sees existing output on connect
73
+ if (session.history.length > 0) {
74
+ ws.send(JSON.stringify({ type: 'history', sessionId, data: session.history.join('') }));
75
+ }
76
+ return true;
77
+ }
78
+
79
+ export function unsubscribe(ws) {
80
+ for (const session of sessions.values()) {
81
+ session.subscribers.delete(ws);
82
+ }
83
+ }
84
+
85
+ export function killSession(sessionId) {
86
+ const session = sessions.get(sessionId);
87
+ if (!session) return false;
88
+ session.pty.kill();
89
+ sessions.delete(sessionId);
90
+ return true;
91
+ }