@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.
Files changed (3) hide show
  1. package/README.md +9 -6
  2. package/index.js +211 -28
  3. 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 = 60000;
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 rewriteVmServiceUrl(originalUrl) {
173
- try {
174
- const parsed = new URL(originalUrl);
175
- if (!isLoopbackHost(parsed.hostname)) {
176
- return { url: originalUrl, replaced: false };
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
- const lanIp = getLanIp();
180
- if (!lanIp) {
181
- console.warn(chalk.yellow('\n⚠️ Warning: Could not detect LAN IP address.'));
182
- console.warn(chalk.yellow('Make sure your PC and phone are on the same WiFi network.'));
183
- console.warn(chalk.yellow('Connection may fail with localhost URL.\n'));
184
- return { url: originalUrl, replaced: false };
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
- parsed.hostname = lanIp;
188
- return { url: parsed.toString(), replaced: true };
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
- const rewritten = rewriteVmServiceUrl(url);
425
- vmServiceUrl = rewritten.url;
426
- clearTimeout(vmTimeout);
427
- if (jsonOutput) {
428
- const payload = { vmServiceUri: vmServiceUrl, deviceId };
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
- console.log(JSON.stringify(payload));
433
- } else {
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 (rewritten.replaced) {
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: ${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.0",
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
- "flutterbridge": "./index.js"
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
+ }