@vaishnavkm/flutterbridge 0.1.0 → 0.1.2
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/README.md +9 -6
- package/index.js +211 -28
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -10,26 +10,26 @@ Bridge your Flutter code to your phone instantly. No USB cables. No complex ADB
|
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
# Using npm
|
|
13
|
-
npm install -g flutterbridge
|
|
13
|
+
npm install -g @vaishnavkm/flutterbridge
|
|
14
14
|
|
|
15
15
|
# Using pnpm
|
|
16
|
-
pnpm add -g flutterbridge
|
|
16
|
+
pnpm add -g @vaishnavkm/flutterbridge
|
|
17
17
|
|
|
18
18
|
# Using bun
|
|
19
|
-
bun add -g flutterbridge
|
|
19
|
+
bun add -g @vaishnavkm/flutterbridge
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
### One-time Use (No Installation)
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
# Using npm
|
|
26
|
-
npx flutterbridge
|
|
26
|
+
npx @vaishnavkm/flutterbridge
|
|
27
27
|
|
|
28
28
|
# Using pnpm
|
|
29
|
-
pnpm dlx flutterbridge
|
|
29
|
+
pnpm dlx @vaishnavkm/flutterbridge
|
|
30
30
|
|
|
31
31
|
# Using bun
|
|
32
|
-
bunx flutterbridge
|
|
32
|
+
bunx @vaishnavkm/flutterbridge
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
## Usage
|
|
@@ -42,6 +42,9 @@ flutterbridge
|
|
|
42
42
|
|
|
43
43
|
A QR code will appear in your terminal. Scan it with the FlutterBridge companion app to connect.
|
|
44
44
|
|
|
45
|
+
If the VM service is bound to localhost, FlutterBridge will start a LAN proxy
|
|
46
|
+
and encode that address in the QR so phones can connect over WiFi.
|
|
47
|
+
|
|
45
48
|
## CLI Options
|
|
46
49
|
|
|
47
50
|
```bash
|
package/index.js
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
const qrcode = require('qrcode-terminal');
|
|
4
4
|
const chalk = require('chalk');
|
|
5
|
+
const WebSocket = require('ws');
|
|
5
6
|
const { spawn } = require('child_process');
|
|
6
7
|
const readline = require('readline');
|
|
7
8
|
const fs = require('fs');
|
|
8
9
|
const path = require('path');
|
|
9
10
|
const os = require('os');
|
|
11
|
+
const net = require('net');
|
|
10
12
|
|
|
11
|
-
const VM_URL_TIMEOUT_MS =
|
|
13
|
+
const VM_URL_TIMEOUT_MS = 300000; // 5 minutes (initial gradle builds take time)
|
|
12
14
|
const SERVICE_URI_KEYS = [
|
|
13
15
|
'vmServiceUri',
|
|
14
16
|
'observatoryUri',
|
|
@@ -18,6 +20,8 @@ const SERVICE_URI_KEYS = [
|
|
|
18
20
|
'wsUri',
|
|
19
21
|
];
|
|
20
22
|
|
|
23
|
+
const activeProxies = new Set();
|
|
24
|
+
|
|
21
25
|
function parseArgs(argv) {
|
|
22
26
|
let deviceId = null;
|
|
23
27
|
let qrOnly = false;
|
|
@@ -169,25 +173,158 @@ function getLanIp() {
|
|
|
169
173
|
return candidates[0];
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
function
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
176
|
+
function getDefaultPort(protocol) {
|
|
177
|
+
if (protocol === 'wss:' || protocol === 'https:') {
|
|
178
|
+
return 443;
|
|
179
|
+
}
|
|
180
|
+
if (protocol === 'ws:' || protocol === 'http:') {
|
|
181
|
+
return 80;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function registerProxy(server) {
|
|
187
|
+
activeProxies.add(server);
|
|
188
|
+
server.on('close', () => activeProxies.delete(server));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function closeAllProxies() {
|
|
192
|
+
for (const server of activeProxies) {
|
|
193
|
+
try {
|
|
194
|
+
server.close();
|
|
195
|
+
} catch (_) {
|
|
196
|
+
// Ignore errors while shutting down.
|
|
177
197
|
}
|
|
198
|
+
}
|
|
199
|
+
activeProxies.clear();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
process.on('exit', closeAllProxies);
|
|
203
|
+
|
|
204
|
+
function startScreenshotServer(deviceId) {
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
try {
|
|
207
|
+
const wss = new WebSocket.Server({ port: 0, host: '0.0.0.0' });
|
|
208
|
+
let intervalId = null;
|
|
209
|
+
let connections = 0;
|
|
210
|
+
|
|
211
|
+
wss.on('listening', () => {
|
|
212
|
+
const port = wss.address().port;
|
|
213
|
+
resolve(port);
|
|
214
|
+
});
|
|
178
215
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
216
|
+
wss.on('error', (err) => {
|
|
217
|
+
reject(err);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
wss.on('connection', (ws) => {
|
|
221
|
+
connections++;
|
|
222
|
+
|
|
223
|
+
if (connections === 1) {
|
|
224
|
+
intervalId = setInterval(() => {
|
|
225
|
+
const adbArgs = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
|
|
226
|
+
const child = spawn('adb', adbArgs);
|
|
227
|
+
const chunks = [];
|
|
228
|
+
|
|
229
|
+
child.stdout.on('data', chunk => chunks.push(chunk));
|
|
230
|
+
child.on('close', code => {
|
|
231
|
+
if (code === 0 && wss.clients.size > 0) {
|
|
232
|
+
const buffer = Buffer.concat(chunks);
|
|
233
|
+
wss.clients.forEach(client => {
|
|
234
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
235
|
+
client.send(buffer);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}, 500); // 2 fps polling
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
ws.on('close', () => {
|
|
244
|
+
connections--;
|
|
245
|
+
if (connections === 0 && intervalId) {
|
|
246
|
+
clearInterval(intervalId);
|
|
247
|
+
intervalId = null;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
registerProxy(wss);
|
|
253
|
+
} catch(err) {
|
|
254
|
+
reject(err);
|
|
185
255
|
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
186
258
|
|
|
187
|
-
|
|
188
|
-
|
|
259
|
+
function startTcpProxy(targetHost, targetPort) {
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
const server = net.createServer((client) => {
|
|
262
|
+
const upstream = net.connect({ host: targetHost, port: targetPort });
|
|
263
|
+
|
|
264
|
+
const closeBoth = () => {
|
|
265
|
+
client.destroy();
|
|
266
|
+
upstream.destroy();
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
client.on('error', closeBoth);
|
|
270
|
+
upstream.on('error', closeBoth);
|
|
271
|
+
|
|
272
|
+
client.pipe(upstream);
|
|
273
|
+
upstream.pipe(client);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
server.on('error', reject);
|
|
277
|
+
|
|
278
|
+
server.listen(0, '0.0.0.0', () => {
|
|
279
|
+
registerProxy(server);
|
|
280
|
+
const address = server.address();
|
|
281
|
+
resolve({ server, port: address.port });
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function prepareVmServiceUrl(originalUrl) {
|
|
287
|
+
let parsed;
|
|
288
|
+
try {
|
|
289
|
+
parsed = new URL(originalUrl);
|
|
189
290
|
} catch (_) {
|
|
190
|
-
return { url: originalUrl, replaced: false };
|
|
291
|
+
return { url: originalUrl, originalUrl, replaced: false, proxied: false };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!isLoopbackHost(parsed.hostname)) {
|
|
295
|
+
return { url: originalUrl, originalUrl, replaced: false, proxied: false };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const lanIp = getLanIp();
|
|
299
|
+
if (!lanIp) {
|
|
300
|
+
console.warn(chalk.yellow('\n⚠️ Warning: Could not detect LAN IP address.'));
|
|
301
|
+
console.warn(chalk.yellow('Make sure your PC and phone are on the same WiFi network.'));
|
|
302
|
+
console.warn(chalk.yellow('Connection may fail with localhost URL.\n'));
|
|
303
|
+
return { url: originalUrl, originalUrl, replaced: false, proxied: false };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const port = parsed.port ? Number(parsed.port) : getDefaultPort(parsed.protocol);
|
|
307
|
+
if (!port) {
|
|
308
|
+
return { url: originalUrl, originalUrl, replaced: false, proxied: false };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const targetHost = parsed.hostname === '0.0.0.0' ? '127.0.0.1' : parsed.hostname;
|
|
312
|
+
try {
|
|
313
|
+
const proxy = await startTcpProxy(targetHost, port);
|
|
314
|
+
parsed.hostname = lanIp;
|
|
315
|
+
parsed.port = String(proxy.port);
|
|
316
|
+
return {
|
|
317
|
+
url: parsed.toString(),
|
|
318
|
+
originalUrl,
|
|
319
|
+
replaced: true,
|
|
320
|
+
proxied: true,
|
|
321
|
+
proxyHost: lanIp,
|
|
322
|
+
proxyPort: proxy.port,
|
|
323
|
+
};
|
|
324
|
+
} catch (err) {
|
|
325
|
+
console.warn(chalk.yellow(`\n⚠️ Warning: Failed to start LAN proxy (${err.message}).`));
|
|
326
|
+
parsed.hostname = lanIp;
|
|
327
|
+
return { url: parsed.toString(), originalUrl, replaced: true, proxied: false };
|
|
191
328
|
}
|
|
192
329
|
}
|
|
193
330
|
|
|
@@ -376,6 +513,14 @@ async function main() {
|
|
|
376
513
|
return;
|
|
377
514
|
}
|
|
378
515
|
|
|
516
|
+
let previewPort = null;
|
|
517
|
+
try {
|
|
518
|
+
previewPort = await startScreenshotServer(deviceId);
|
|
519
|
+
if (!quiet) console.log(chalk.gray(`Started Live Preview server on port ${previewPort}`));
|
|
520
|
+
} catch (e) {
|
|
521
|
+
if (!quiet) console.warn(chalk.yellow(`Warning: Failed to start Live Preview server: ${e.message}`));
|
|
522
|
+
}
|
|
523
|
+
|
|
379
524
|
if (!quiet) {
|
|
380
525
|
console.log(chalk.gray('Starting Flutter...\n'));
|
|
381
526
|
}
|
|
@@ -395,6 +540,7 @@ async function main() {
|
|
|
395
540
|
let stdoutBuffer = '';
|
|
396
541
|
let stderrBuffer = '';
|
|
397
542
|
let vmServiceUrl = null;
|
|
543
|
+
let publishPending = false;
|
|
398
544
|
|
|
399
545
|
const vmTimeout = setTimeout(() => {
|
|
400
546
|
if (!vmServiceUrl) {
|
|
@@ -420,29 +566,66 @@ async function main() {
|
|
|
420
566
|
const events = Array.isArray(json) ? json : [json];
|
|
421
567
|
for (const event of events) {
|
|
422
568
|
const url = extractVmServiceUri(event);
|
|
423
|
-
if (url && !vmServiceUrl) {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (rewritten.replaced) {
|
|
430
|
-
payload.originalVmServiceUri = url;
|
|
569
|
+
if (url && !vmServiceUrl && !publishPending) {
|
|
570
|
+
publishPending = true;
|
|
571
|
+
|
|
572
|
+
const publishUrl = (result) => {
|
|
573
|
+
if (vmServiceUrl) {
|
|
574
|
+
return;
|
|
431
575
|
}
|
|
432
|
-
|
|
433
|
-
|
|
576
|
+
|
|
577
|
+
let finalUrl = result.url;
|
|
578
|
+
if (previewPort) {
|
|
579
|
+
try {
|
|
580
|
+
const u = new URL(finalUrl);
|
|
581
|
+
u.searchParams.set('previewPort', previewPort);
|
|
582
|
+
finalUrl = u.toString();
|
|
583
|
+
} catch(e) {}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
vmServiceUrl = finalUrl;
|
|
587
|
+
clearTimeout(vmTimeout);
|
|
588
|
+
|
|
589
|
+
if (jsonOutput) {
|
|
590
|
+
const payload = { vmServiceUri: vmServiceUrl, deviceId };
|
|
591
|
+
if (result.replaced) {
|
|
592
|
+
payload.originalVmServiceUri = result.originalUrl;
|
|
593
|
+
}
|
|
594
|
+
if (result.proxied) {
|
|
595
|
+
payload.proxy = { host: result.proxyHost, port: result.proxyPort };
|
|
596
|
+
}
|
|
597
|
+
console.log(JSON.stringify(payload));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
434
601
|
if (!qrOnly) {
|
|
435
602
|
console.log(chalk.yellow('\nScan this QR with FlutterBridge app:\n'));
|
|
436
603
|
}
|
|
437
604
|
qrcode.generate(vmServiceUrl, { small: true });
|
|
438
605
|
if (!qrOnly) {
|
|
439
|
-
if (
|
|
606
|
+
if (result.proxied) {
|
|
607
|
+
console.log(chalk.gray(`LAN proxy running at ws://${result.proxyHost}:${result.proxyPort}`));
|
|
608
|
+
console.log(chalk.gray(`Proxy target: ${result.originalUrl}`));
|
|
609
|
+
} else if (result.replaced) {
|
|
440
610
|
console.log(chalk.gray(`Rewrote VM URL for LAN access: ${vmServiceUrl}`));
|
|
441
|
-
console.log(chalk.gray(`Original VM URL: ${
|
|
611
|
+
console.log(chalk.gray(`Original VM URL: ${result.originalUrl}`));
|
|
442
612
|
}
|
|
443
613
|
console.log(chalk.green(`\nVM URL: ${vmServiceUrl}`));
|
|
444
614
|
}
|
|
445
|
-
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
prepareVmServiceUrl(url)
|
|
618
|
+
.then((result) => {
|
|
619
|
+
publishPending = false;
|
|
620
|
+
publishUrl(result);
|
|
621
|
+
})
|
|
622
|
+
.catch((err) => {
|
|
623
|
+
publishPending = false;
|
|
624
|
+
if (!quiet) {
|
|
625
|
+
console.error(chalk.red(`Failed to prepare VM service URL: ${err.message}`));
|
|
626
|
+
}
|
|
627
|
+
publishUrl({ url, originalUrl: url, replaced: false, proxied: false });
|
|
628
|
+
});
|
|
446
629
|
}
|
|
447
630
|
}
|
|
448
631
|
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vaishnavkm/flutterbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Bridge your Flutter code to your phone instantly. Wireless development with QR code pairing.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"
|
|
7
|
+
"bridge": "index.js"
|
|
8
8
|
},
|
|
9
9
|
"publishConfig": {
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
|
-
"scripts": {
|
|
13
|
-
"test": "echo \"Error: no test specified\" && exit 1"
|
|
14
|
-
},
|
|
15
12
|
"keywords": [
|
|
16
13
|
"flutter",
|
|
17
14
|
"mobile",
|
|
@@ -28,7 +25,7 @@
|
|
|
28
25
|
"license": "MIT",
|
|
29
26
|
"repository": {
|
|
30
27
|
"type": "git",
|
|
31
|
-
"url": "https://github.com/vaishnavkm/flutterbridge.git"
|
|
28
|
+
"url": "git+https://github.com/vaishnavkm/flutterbridge.git"
|
|
32
29
|
},
|
|
33
30
|
"bugs": {
|
|
34
31
|
"url": "https://github.com/vaishnavkm/flutterbridge/issues"
|
|
@@ -37,9 +34,12 @@
|
|
|
37
34
|
"engines": {
|
|
38
35
|
"node": ">=18.0.0"
|
|
39
36
|
},
|
|
40
|
-
"packageManager": "pnpm@10.33.0",
|
|
41
37
|
"dependencies": {
|
|
42
38
|
"chalk": "^4.1.2",
|
|
43
|
-
"qrcode-terminal": "^0.12.0"
|
|
39
|
+
"qrcode-terminal": "^0.12.0",
|
|
40
|
+
"ws": "^8.21.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
44
44
|
}
|
|
45
|
-
}
|
|
45
|
+
}
|