@hmduc16031996/claude-mb-bridge 2.3.6 → 2.4.1
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/dist/index.js +2 -2
- package/dist/server.d.ts +1 -1
- package/dist/server.js +272 -52
- package/package.json +1 -1
- package/public/app.js +2160 -109
- package/public/index.html +249 -1
- package/public/styles.css +5 -5
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ const program = new Command();
|
|
|
6
6
|
program
|
|
7
7
|
.name('claude-mobile-bridge')
|
|
8
8
|
.description('Bridge Claude Code CLI to mobile via WebView')
|
|
9
|
-
.version('2.
|
|
9
|
+
.version('2.4.1')
|
|
10
10
|
.option('--token <token>', 'Pairing token from mobile app')
|
|
11
11
|
.option('--server <url>', 'Backend server URL', 'http://127.0.0.1:3110')
|
|
12
12
|
.option('--path <path>', 'Working directory', process.cwd())
|
|
@@ -33,7 +33,7 @@ program
|
|
|
33
33
|
// 1. Start local terminal server
|
|
34
34
|
console.log('📦 Starting terminal server...');
|
|
35
35
|
const localPort = parseInt(port, 10);
|
|
36
|
-
const { server: terminalServer, actualPort } = await startTerminalServer(localPort, path, cleanup);
|
|
36
|
+
const { server: terminalServer, actualPort } = await startTerminalServer(localPort, path, token, cleanup);
|
|
37
37
|
console.log(`✅ Terminal server started on port ${actualPort}`);
|
|
38
38
|
// 2. Start Cloudflare Tunnel
|
|
39
39
|
console.log('🌐 Establishing secure tunnel...');
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare function startTerminalServer(port: number, workingDir: string, onDisconnect?: () => void): Promise<{
|
|
1
|
+
export declare function startTerminalServer(port: number, workingDir: string, terminalToken: string, onDisconnect?: () => void): Promise<{
|
|
2
2
|
server: any;
|
|
3
3
|
actualPort: number;
|
|
4
4
|
}>;
|
package/dist/server.js
CHANGED
|
@@ -1,81 +1,301 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { createServer } from 'http';
|
|
3
|
-
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { createRequire } from 'module';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
import { randomUUID } from 'crypto';
|
|
7
10
|
const require = createRequire(import.meta.url);
|
|
8
11
|
const pty = require('node-pty');
|
|
9
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
-
|
|
13
|
+
class Session {
|
|
14
|
+
term;
|
|
15
|
+
id;
|
|
16
|
+
cwd;
|
|
17
|
+
createdAt;
|
|
18
|
+
constructor(id, cwd, shell) {
|
|
19
|
+
this.id = id;
|
|
20
|
+
this.cwd = cwd;
|
|
21
|
+
this.createdAt = new Date().toISOString();
|
|
22
|
+
this.term = pty.spawn(shell, [], {
|
|
23
|
+
name: 'xterm-256color',
|
|
24
|
+
cols: 120,
|
|
25
|
+
rows: 40,
|
|
26
|
+
cwd: cwd,
|
|
27
|
+
env: {
|
|
28
|
+
...process.env,
|
|
29
|
+
TERM: 'xterm-256color',
|
|
30
|
+
FORCE_COLOR: '1',
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
this.term.write('claude\r');
|
|
34
|
+
}
|
|
35
|
+
getInfo() {
|
|
36
|
+
return {
|
|
37
|
+
id: this.id,
|
|
38
|
+
cwd: this.cwd,
|
|
39
|
+
createdAt: this.createdAt,
|
|
40
|
+
status: 'running'
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
kill() {
|
|
44
|
+
try {
|
|
45
|
+
this.term.kill();
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
console.warn(`Failed to kill pty ${this.id}:`, e);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
class SessionManager {
|
|
53
|
+
sessions = new Map();
|
|
54
|
+
createSession(cwd) {
|
|
55
|
+
const id = randomUUID().slice(0, 8);
|
|
56
|
+
let shell = '/bin/bash';
|
|
57
|
+
if (process.platform === 'win32') {
|
|
58
|
+
shell = 'powershell.exe';
|
|
59
|
+
}
|
|
60
|
+
else if (process.platform === 'darwin') {
|
|
61
|
+
shell = fs.existsSync('/bin/zsh') ? '/bin/zsh' : '/bin/bash';
|
|
62
|
+
}
|
|
63
|
+
const session = new Session(id, cwd, shell);
|
|
64
|
+
this.sessions.set(id, session);
|
|
65
|
+
return session;
|
|
66
|
+
}
|
|
67
|
+
getSession(id) {
|
|
68
|
+
return this.sessions.get(id);
|
|
69
|
+
}
|
|
70
|
+
removeSession(id) {
|
|
71
|
+
const session = this.sessions.get(id);
|
|
72
|
+
if (session) {
|
|
73
|
+
session.kill();
|
|
74
|
+
this.sessions.delete(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
listSessions() {
|
|
78
|
+
return Array.from(this.sessions.values()).map(s => s.getInfo());
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function startTerminalServer(port, workingDir, terminalToken, onDisconnect) {
|
|
11
82
|
const app = express();
|
|
12
83
|
const server = createServer(app);
|
|
13
84
|
const wss = new WebSocketServer({ server });
|
|
85
|
+
const sessionManager = new SessionManager();
|
|
14
86
|
const publicPath = path.join(__dirname, '../public');
|
|
15
87
|
app.use(express.static(publicPath));
|
|
16
88
|
app.get('/health', (req, res) => {
|
|
17
89
|
res.json({ status: 'ok' });
|
|
18
90
|
});
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
shell = 'powershell.exe';
|
|
26
|
-
}
|
|
27
|
-
else if (process.platform === 'darwin') {
|
|
28
|
-
// Prefer zsh on macOS as it's the modern default, fallback to bash
|
|
29
|
-
shell = require('fs').existsSync('/bin/zsh') ? '/bin/zsh' : '/bin/bash';
|
|
30
|
-
}
|
|
31
|
-
console.log(`🚀 Spawning shell: ${shell} in ${workingDir}`);
|
|
32
|
-
let term;
|
|
91
|
+
// Ports endpoint - returns empty list (no port detection in bridge mode)
|
|
92
|
+
app.get('/api/ports', (req, res) => {
|
|
93
|
+
res.json([]);
|
|
94
|
+
});
|
|
95
|
+
// Directory listing for autocomplete
|
|
96
|
+
app.get('/api/dirs', (req, res) => {
|
|
33
97
|
try {
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
ws.send('\r\n\x1b[31mError: Working directory does not exist\x1b[0m\r\n');
|
|
38
|
-
ws.close();
|
|
39
|
-
return;
|
|
98
|
+
let inputPath = req.query.path || '';
|
|
99
|
+
if (inputPath.startsWith('~/')) {
|
|
100
|
+
inputPath = inputPath.replace('~/', `${os.homedir()}/`);
|
|
40
101
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
102
|
+
else if (inputPath === '~') {
|
|
103
|
+
inputPath = os.homedir();
|
|
104
|
+
}
|
|
105
|
+
let dirToRead;
|
|
106
|
+
let prefix;
|
|
107
|
+
if (inputPath.endsWith('/')) {
|
|
108
|
+
dirToRead = inputPath;
|
|
109
|
+
prefix = '';
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
dirToRead = path.dirname(inputPath) || '/';
|
|
113
|
+
prefix = path.basename(inputPath).toLowerCase();
|
|
114
|
+
}
|
|
115
|
+
if (!path.isAbsolute(dirToRead)) {
|
|
116
|
+
dirToRead = path.resolve(process.cwd(), dirToRead);
|
|
117
|
+
}
|
|
118
|
+
const entries = fs.readdirSync(dirToRead, { withFileTypes: true });
|
|
119
|
+
const dirs = entries
|
|
120
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
121
|
+
.filter(e => !prefix || e.name.toLowerCase().startsWith(prefix))
|
|
122
|
+
.slice(0, 20)
|
|
123
|
+
.map(e => {
|
|
124
|
+
const fullPath = path.join(dirToRead, e.name);
|
|
125
|
+
const displayPath = fullPath.startsWith(os.homedir())
|
|
126
|
+
? fullPath.replace(os.homedir(), '~')
|
|
127
|
+
: fullPath;
|
|
128
|
+
return { name: e.name, path: displayPath + '/' };
|
|
47
129
|
});
|
|
130
|
+
res.json(dirs);
|
|
48
131
|
}
|
|
49
|
-
catch
|
|
50
|
-
|
|
51
|
-
ws.send(`\r\n\x1b[31mError: Failed to spawn terminal (${err.message})\x1b[0m\r\n`);
|
|
52
|
-
ws.close();
|
|
53
|
-
return;
|
|
132
|
+
catch {
|
|
133
|
+
res.json([]);
|
|
54
134
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
135
|
+
});
|
|
136
|
+
wss.on('connection', (ws, req) => {
|
|
137
|
+
console.log(`📡 New connection from ${req.socket.remoteAddress}`);
|
|
138
|
+
let authenticated = false;
|
|
139
|
+
let activeSessionId = null;
|
|
140
|
+
let outputHandler = null;
|
|
141
|
+
const sendControl = (message) => {
|
|
142
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
143
|
+
ws.send(Buffer.from(JSON.stringify(message)));
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const attachToSession = (sessionId) => {
|
|
147
|
+
const session = sessionManager.getSession(sessionId);
|
|
148
|
+
if (!session) {
|
|
149
|
+
sendControl({ type: 'error', error: 'Session not found' });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Cleanup old handler
|
|
153
|
+
if (activeSessionId && outputHandler) {
|
|
154
|
+
const oldSession = sessionManager.getSession(activeSessionId);
|
|
155
|
+
if (oldSession) {
|
|
156
|
+
oldSession.term.removeListener('data', outputHandler);
|
|
65
157
|
}
|
|
66
|
-
|
|
67
|
-
|
|
158
|
+
}
|
|
159
|
+
activeSessionId = sessionId;
|
|
160
|
+
outputHandler = (data) => {
|
|
161
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
162
|
+
ws.send(data); // Send as text
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
session.term.on('data', outputHandler);
|
|
166
|
+
sendControl({ type: 'session:attached', session: session.getInfo() });
|
|
167
|
+
};
|
|
168
|
+
ws.on('message', (msg, isBinary) => {
|
|
169
|
+
if (isBinary) {
|
|
170
|
+
let message;
|
|
171
|
+
try {
|
|
172
|
+
message = JSON.parse(msg.toString());
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
sendControl({ type: 'error', error: 'Invalid control message' });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Auth must happen first
|
|
179
|
+
if (message.type === 'auth') {
|
|
180
|
+
if (message.token === terminalToken) {
|
|
181
|
+
authenticated = true;
|
|
182
|
+
sendControl({ type: 'auth:success' });
|
|
183
|
+
// Auto-create initial session and attach
|
|
184
|
+
const initialSession = sessionManager.createSession(workingDir);
|
|
185
|
+
attachToSession(initialSession.id);
|
|
186
|
+
console.log('✅ Client authenticated');
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
sendControl({ type: 'auth:failed', error: 'Invalid token' });
|
|
190
|
+
ws.close();
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!authenticated) {
|
|
195
|
+
sendControl({ type: 'auth:failed', error: 'Not authenticated' });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
switch (message.type) {
|
|
199
|
+
case 'session:create': {
|
|
200
|
+
let cwd = message.cwd || workingDir;
|
|
201
|
+
if (cwd.startsWith('~/')) {
|
|
202
|
+
cwd = cwd.replace('~/', `${os.homedir()}/`);
|
|
203
|
+
}
|
|
204
|
+
if (!fs.existsSync(cwd)) {
|
|
205
|
+
sendControl({ type: 'error', error: 'Directory not found' });
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
const session = sessionManager.createSession(cwd);
|
|
209
|
+
sendControl({ type: 'session:created', session: session.getInfo() });
|
|
210
|
+
attachToSession(session.id);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case 'session:attach': {
|
|
214
|
+
if (message.sessionId) {
|
|
215
|
+
attachToSession(message.sessionId);
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case 'session:list': {
|
|
220
|
+
sendControl({ type: 'session:list', sessions: sessionManager.listSessions() });
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case 'session:discover': {
|
|
224
|
+
// No external session discovery in bridge mode
|
|
225
|
+
sendControl({ type: 'session:discovered', sessions: [] });
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case 'resize': {
|
|
229
|
+
const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null;
|
|
230
|
+
if (session && message.cols && message.rows) {
|
|
231
|
+
session.term.resize(message.cols, message.rows);
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case 'image:upload': {
|
|
236
|
+
const { data, mimeType, filename } = message;
|
|
237
|
+
if (!data)
|
|
238
|
+
break;
|
|
239
|
+
const ext = mimeType?.split('/')[1] || 'png';
|
|
240
|
+
const tempPath = path.join(os.tmpdir(), `claude-bridge-${Date.now()}-${filename || 'image'}.${ext}`);
|
|
241
|
+
try {
|
|
242
|
+
const buffer = Buffer.from(data, 'base64');
|
|
243
|
+
fs.writeFileSync(tempPath, buffer);
|
|
244
|
+
sendControl({ type: 'image:uploaded', path: tempPath });
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
sendControl({ type: 'error', error: 'Failed to save image' });
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
case 'session:destroy': {
|
|
252
|
+
if (message.sessionId) {
|
|
253
|
+
sessionManager.removeSession(message.sessionId);
|
|
254
|
+
sendControl({ type: 'session:destroyed', sessionId: message.sessionId });
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
// Schedule stubs - not supported in bridge mode
|
|
259
|
+
case 'schedule:list': {
|
|
260
|
+
sendControl({ type: 'schedule:list', schedules: [] });
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case 'schedule:create': {
|
|
264
|
+
sendControl({ type: 'schedule:create_error', error: 'Schedules are not supported in bridge mode' });
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case 'schedule:update':
|
|
268
|
+
case 'schedule:delete':
|
|
269
|
+
case 'schedule:trigger':
|
|
270
|
+
case 'schedule:runs':
|
|
271
|
+
case 'schedule:log': {
|
|
272
|
+
// No-op stubs
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
// Preferences - store in memory only (no persistence needed)
|
|
276
|
+
case 'preferences:set': {
|
|
277
|
+
// No-op: preferences are managed client-side via localStorage
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
68
280
|
}
|
|
69
281
|
}
|
|
70
|
-
|
|
71
|
-
|
|
282
|
+
else {
|
|
283
|
+
if (!authenticated)
|
|
284
|
+
return;
|
|
285
|
+
// Raw text -> forward to active pty
|
|
286
|
+
const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null;
|
|
287
|
+
if (session) {
|
|
288
|
+
session.term.write(msg.toString());
|
|
289
|
+
}
|
|
72
290
|
}
|
|
73
291
|
});
|
|
74
|
-
term.onExit(() => {
|
|
75
|
-
ws.close();
|
|
76
|
-
});
|
|
77
292
|
ws.on('close', () => {
|
|
78
|
-
|
|
293
|
+
if (activeSessionId && outputHandler) {
|
|
294
|
+
const session = sessionManager.getSession(activeSessionId);
|
|
295
|
+
if (session) {
|
|
296
|
+
session.term.removeListener('data', outputHandler);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
79
299
|
if (onDisconnect)
|
|
80
300
|
onDisconnect();
|
|
81
301
|
});
|
|
@@ -84,7 +304,7 @@ export function startTerminalServer(port, workingDir, onDisconnect) {
|
|
|
84
304
|
server.on('error', (err) => {
|
|
85
305
|
if (err.code === 'EADDRINUSE' && port !== 0) {
|
|
86
306
|
console.warn(`⚠️ Port ${port} is busy, trying a random port...`);
|
|
87
|
-
resolve(startTerminalServer(0, workingDir, onDisconnect));
|
|
307
|
+
resolve(startTerminalServer(0, workingDir, terminalToken, onDisconnect));
|
|
88
308
|
}
|
|
89
309
|
else {
|
|
90
310
|
reject(err);
|