@vaishnavkm/flutterbridge 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.
Files changed (3) hide show
  1. package/README.md +99 -0
  2. package/index.js +475 -0
  3. package/package.json +45 -0
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # FlutterBridge CLI
2
+
3
+ > Wireless Flutter development with QR code pairing
4
+
5
+ Bridge your Flutter code to your phone instantly. No USB cables. No complex ADB setup.
6
+
7
+ ## Installation
8
+
9
+ ### Global Installation
10
+
11
+ ```bash
12
+ # Using npm
13
+ npm install -g flutterbridge
14
+
15
+ # Using pnpm
16
+ pnpm add -g flutterbridge
17
+
18
+ # Using bun
19
+ bun add -g flutterbridge
20
+ ```
21
+
22
+ ### One-time Use (No Installation)
23
+
24
+ ```bash
25
+ # Using npm
26
+ npx flutterbridge
27
+
28
+ # Using pnpm
29
+ pnpm dlx flutterbridge
30
+
31
+ # Using bun
32
+ bunx flutterbridge
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ Navigate to your Flutter project directory and run:
38
+
39
+ ```bash
40
+ flutterbridge
41
+ ```
42
+
43
+ A QR code will appear in your terminal. Scan it with the FlutterBridge companion app to connect.
44
+
45
+ ## CLI Options
46
+
47
+ ```bash
48
+ # Choose a specific device
49
+ flutterbridge --device <device-id>
50
+ flutterbridge -d <device-id>
51
+
52
+ # Print only the QR code (no extra logs)
53
+ flutterbridge --qr-only
54
+
55
+ # Print machine-readable JSON output
56
+ flutterbridge --json
57
+
58
+ # Pass additional Flutter flags
59
+ flutterbridge -- --release
60
+ flutterbridge -- --flavor production
61
+ ```
62
+
63
+ ## Requirements
64
+
65
+ - Node.js >= 18
66
+ - Flutter >= 3.0
67
+ - Package manager: npm, pnpm, or bun
68
+ - Both PC and phone on the same WiFi network
69
+
70
+ ## Troubleshooting
71
+
72
+ ### "Flutter was not found on your PATH"
73
+ Install Flutter: https://flutter.dev/docs/get-started/install
74
+
75
+ ### "No pubspec.yaml file found"
76
+ Run the command from the root of your Flutter project.
77
+
78
+ ### "No devices found"
79
+ - Connect a physical device via USB
80
+ - Start an Android/iOS emulator
81
+ - Run `flutter devices` to verify
82
+
83
+ ### "Found offline/unauthorized devices"
84
+ - Enable USB debugging on your device
85
+ - Run `adb devices` and authorize the device
86
+ - Reconnect your device
87
+
88
+ ### Connection fails
89
+ - Ensure both devices are on the same WiFi network
90
+ - Check firewall settings
91
+ - Try disabling VPN
92
+
93
+ ## Documentation
94
+
95
+ Full documentation: https://github.com/vaishnavkm/flutterbridge
96
+
97
+ ## License
98
+
99
+ MIT © Vaishnav K M
package/index.js ADDED
@@ -0,0 +1,475 @@
1
+ #!/usr/bin/env node
2
+
3
+ const qrcode = require('qrcode-terminal');
4
+ const chalk = require('chalk');
5
+ const { spawn } = require('child_process');
6
+ const readline = require('readline');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ const VM_URL_TIMEOUT_MS = 60000;
12
+ const SERVICE_URI_KEYS = [
13
+ 'vmServiceUri',
14
+ 'observatoryUri',
15
+ 'debugServiceUri',
16
+ 'debuggerUri',
17
+ 'debugWsUri',
18
+ 'wsUri',
19
+ ];
20
+
21
+ function parseArgs(argv) {
22
+ let deviceId = null;
23
+ let qrOnly = false;
24
+ let jsonOutput = false;
25
+ const passthrough = [];
26
+
27
+ for (let i = 0; i < argv.length; i += 1) {
28
+ const arg = argv[i];
29
+
30
+ if (arg === '--device' || arg === '-d') {
31
+ const next = argv[i + 1];
32
+ if (!next || next.startsWith('-')) {
33
+ throw new Error('Missing value for --device.');
34
+ }
35
+ deviceId = next;
36
+ i += 1;
37
+ continue;
38
+ }
39
+
40
+ if (arg.startsWith('--device=')) {
41
+ const value = arg.split('=').slice(1).join('=');
42
+ if (!value) {
43
+ throw new Error('Missing value for --device.');
44
+ }
45
+ deviceId = value;
46
+ continue;
47
+ }
48
+
49
+ if (arg === '--qr-only') {
50
+ qrOnly = true;
51
+ continue;
52
+ }
53
+
54
+ if (arg === '--json') {
55
+ jsonOutput = true;
56
+ continue;
57
+ }
58
+
59
+ passthrough.push(arg);
60
+ }
61
+
62
+ return { deviceId, qrOnly, jsonOutput, passthrough };
63
+ }
64
+
65
+ function parseMachineLine(line) {
66
+ const trimmed = line.trim();
67
+ if (!trimmed) {
68
+ return null;
69
+ }
70
+
71
+ if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) {
72
+ return null;
73
+ }
74
+
75
+ try {
76
+ return JSON.parse(trimmed);
77
+ } catch (_) {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ function isValidUri(value) {
83
+ return typeof value === 'string' && /^(ws|http)s?:\/\//.test(value);
84
+ }
85
+
86
+ function findUriByKeys(obj, keys) {
87
+ if (!obj || typeof obj !== 'object') {
88
+ return null;
89
+ }
90
+
91
+ for (const key of keys) {
92
+ if (isValidUri(obj[key])) {
93
+ return obj[key];
94
+ }
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ function findAnyUri(obj) {
101
+ if (!obj || typeof obj !== 'object') {
102
+ return null;
103
+ }
104
+
105
+ for (const [key, value] of Object.entries(obj)) {
106
+ if (/uri/i.test(key) && isValidUri(value)) {
107
+ return value;
108
+ }
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ function extractVmServiceUri(event) {
115
+ if (!event || typeof event !== 'object') {
116
+ return null;
117
+ }
118
+
119
+ const params = event.params || event;
120
+ let uri = findUriByKeys(params, SERVICE_URI_KEYS);
121
+ if (uri) {
122
+ return uri;
123
+ }
124
+
125
+ const debuggingOptions = params.debuggingOptions || params.debugOptions;
126
+ uri = findUriByKeys(debuggingOptions, SERVICE_URI_KEYS);
127
+ if (uri) {
128
+ return uri;
129
+ }
130
+
131
+ return findAnyUri(params);
132
+ }
133
+
134
+ function isLoopbackHost(hostname) {
135
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0';
136
+ }
137
+
138
+ function scoreLanIp(ip) {
139
+ if (ip.startsWith('192.168.')) {
140
+ return 3;
141
+ }
142
+ if (ip.startsWith('10.')) {
143
+ return 2;
144
+ }
145
+ if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(ip)) {
146
+ return 1;
147
+ }
148
+ return 0;
149
+ }
150
+
151
+ function getLanIp() {
152
+ const interfaces = os.networkInterfaces();
153
+ const candidates = [];
154
+
155
+ for (const entries of Object.values(interfaces)) {
156
+ for (const net of entries || []) {
157
+ if (net.family !== 'IPv4' || net.internal) {
158
+ continue;
159
+ }
160
+ candidates.push(net.address);
161
+ }
162
+ }
163
+
164
+ if (candidates.length === 0) {
165
+ return null;
166
+ }
167
+
168
+ candidates.sort((a, b) => scoreLanIp(b) - scoreLanIp(a));
169
+ return candidates[0];
170
+ }
171
+
172
+ function rewriteVmServiceUrl(originalUrl) {
173
+ try {
174
+ const parsed = new URL(originalUrl);
175
+ if (!isLoopbackHost(parsed.hostname)) {
176
+ return { url: originalUrl, replaced: false };
177
+ }
178
+
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 };
185
+ }
186
+
187
+ parsed.hostname = lanIp;
188
+ return { url: parsed.toString(), replaced: true };
189
+ } catch (_) {
190
+ return { url: originalUrl, replaced: false };
191
+ }
192
+ }
193
+
194
+ function assertFlutterProject(cwd) {
195
+ const pubspecPath = path.join(cwd, 'pubspec.yaml');
196
+ if (!fs.existsSync(pubspecPath)) {
197
+ throw new Error('No pubspec.yaml file found. Run this command from the root of your Flutter project.');
198
+ }
199
+ }
200
+
201
+ function runFlutterDevices() {
202
+ return new Promise((resolve, reject) => {
203
+ const child = spawn('flutter', ['devices', '--machine'], { stdio: ['ignore', 'pipe', 'pipe'] });
204
+ let stdout = '';
205
+ let stderr = '';
206
+
207
+ child.stdout.on('data', (data) => {
208
+ stdout += data.toString();
209
+ });
210
+
211
+ child.stderr.on('data', (data) => {
212
+ stderr += data.toString();
213
+ });
214
+
215
+ child.on('error', (err) => {
216
+ reject(err);
217
+ });
218
+
219
+ child.on('close', (code) => {
220
+ if (code !== 0) {
221
+ reject(new Error(stderr || `flutter devices exited with code ${code}`));
222
+ return;
223
+ }
224
+
225
+ try {
226
+ const devices = JSON.parse(stdout.trim());
227
+ if (!Array.isArray(devices)) {
228
+ reject(new Error('Unexpected output from flutter devices.'));
229
+ return;
230
+ }
231
+ resolve(devices);
232
+ } catch (err) {
233
+ reject(err);
234
+ }
235
+ });
236
+ });
237
+ }
238
+
239
+ function formatDevice(device) {
240
+ const platform = device.platform || device.platformType || 'unknown';
241
+ return `${device.name} (${platform}) - id: ${device.id}`;
242
+ }
243
+
244
+ async function promptForDevice(devices) {
245
+ if (!process.stdin.isTTY) {
246
+ throw new Error('Multiple devices detected. Use --device <id> to select one.');
247
+ }
248
+
249
+ console.log(chalk.yellow('\nAvailable devices:'));
250
+ devices.forEach((device, index) => {
251
+ console.log(` ${index + 1}) ${formatDevice(device)}`);
252
+ });
253
+
254
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
255
+
256
+ const answer = await new Promise((resolve) => {
257
+ rl.question(chalk.cyan('\nSelect a device by number or id: '), resolve);
258
+ });
259
+
260
+ rl.close();
261
+
262
+ const trimmed = answer.trim();
263
+ if (!trimmed) {
264
+ throw new Error('No device selected.');
265
+ }
266
+
267
+ const asIndex = Number(trimmed);
268
+ if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= devices.length) {
269
+ return devices[asIndex - 1].id;
270
+ }
271
+
272
+ const match = devices.find((device) => device.id === trimmed);
273
+ if (match) {
274
+ return match.id;
275
+ }
276
+
277
+ throw new Error(`Unknown device selection: ${trimmed}`);
278
+ }
279
+
280
+ async function resolveDeviceId(deviceIdArg, options = {}) {
281
+ if (deviceIdArg) {
282
+ return deviceIdArg;
283
+ }
284
+
285
+ let devices = [];
286
+ try {
287
+ devices = await runFlutterDevices();
288
+ } catch (err) {
289
+ if (err.code === 'ENOENT') {
290
+ throw new Error('Flutter was not found on your PATH. Install Flutter and try again.');
291
+ }
292
+ throw err;
293
+ }
294
+ const supported = devices.filter((device) => device.isSupported !== false);
295
+
296
+ if (supported.length === 0) {
297
+ const offline = devices.filter((d) => !d.isSupported && (d.emulator === false || d.emulator === undefined));
298
+ if (offline.length > 0) {
299
+ const hints = offline.map((d) => ` - ${d.name} (${d.id})`).join('\n');
300
+ throw new Error(`No available devices. Found offline/unauthorized devices:\n${hints}\n\nTry:\n - Enable USB debugging on your device\n - Run 'adb devices' and authorize the device\n - Reconnect your device`);
301
+ }
302
+ throw new Error('No devices found. Connect a device or start an emulator.');
303
+ }
304
+
305
+ if (supported.length === 1) {
306
+ if (!options.quiet) {
307
+ console.log(chalk.gray(`Using device: ${formatDevice(supported[0])}`));
308
+ }
309
+ return supported[0].id;
310
+ }
311
+
312
+ if (options.jsonOutput) {
313
+ throw new Error('Multiple devices detected. Use --device <id> with --json.');
314
+ }
315
+
316
+ return promptForDevice(supported);
317
+ }
318
+
319
+ function handleFlutterError(err) {
320
+ if (err.code === 'ENOENT') {
321
+ console.error(chalk.red('Flutter was not found on your PATH. Install Flutter and try again.'));
322
+ } else {
323
+ console.error(chalk.red(`Failed to start Flutter: ${err.message}`));
324
+ }
325
+
326
+ process.exitCode = 1;
327
+ }
328
+
329
+ function hasWebHostnameFlag(args) {
330
+ return args.some((arg) => arg === '--web-hostname' || arg.startsWith('--web-hostname='));
331
+ }
332
+
333
+ async function main() {
334
+ let options = null;
335
+ try {
336
+ options = parseArgs(process.argv.slice(2));
337
+ } catch (err) {
338
+ console.error(chalk.red(err.message));
339
+ process.exitCode = 1;
340
+ return;
341
+ }
342
+
343
+ const {
344
+ deviceId: deviceIdArg,
345
+ passthrough,
346
+ qrOnly,
347
+ jsonOutput,
348
+ } = options;
349
+
350
+ if (qrOnly && jsonOutput) {
351
+ console.error(chalk.red('Use either --qr-only or --json, not both.'));
352
+ process.exitCode = 1;
353
+ return;
354
+ }
355
+
356
+ const quiet = qrOnly || jsonOutput;
357
+
358
+ if (!quiet) {
359
+ console.log(chalk.cyan.bold('\n🚀 FlutterBridge CLI'));
360
+ }
361
+
362
+ try {
363
+ assertFlutterProject(process.cwd());
364
+ } catch (err) {
365
+ console.error(chalk.red(err.message));
366
+ process.exitCode = 1;
367
+ return;
368
+ }
369
+
370
+ let deviceId = null;
371
+ try {
372
+ deviceId = await resolveDeviceId(deviceIdArg, { quiet, jsonOutput });
373
+ } catch (err) {
374
+ console.error(chalk.red(`Device selection failed: ${err.message}`));
375
+ process.exitCode = 1;
376
+ return;
377
+ }
378
+
379
+ if (!quiet) {
380
+ console.log(chalk.gray('Starting Flutter...\n'));
381
+ }
382
+
383
+ const flutterArgs = ['run', '--machine'];
384
+ if (deviceId) {
385
+ flutterArgs.push('-d', deviceId);
386
+ }
387
+ if (deviceId === 'chrome' && !hasWebHostnameFlag(passthrough)) {
388
+ flutterArgs.push('--web-hostname', '0.0.0.0');
389
+ }
390
+ if (passthrough.length > 0) {
391
+ flutterArgs.push(...passthrough);
392
+ }
393
+
394
+ const flutter = spawn('flutter', flutterArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
395
+ let stdoutBuffer = '';
396
+ let stderrBuffer = '';
397
+ let vmServiceUrl = null;
398
+
399
+ const vmTimeout = setTimeout(() => {
400
+ if (!vmServiceUrl) {
401
+ console.error(chalk.red(`Timed out after ${VM_URL_TIMEOUT_MS / 1000}s waiting for VM service URL.`));
402
+ flutter.kill('SIGINT');
403
+ process.exitCode = 1;
404
+ }
405
+ }, VM_URL_TIMEOUT_MS);
406
+
407
+ flutter.on('error', handleFlutterError);
408
+
409
+ const handleMachineChunk = (buffer, data) => {
410
+ let nextBuffer = buffer + data.toString();
411
+ const lines = nextBuffer.split(/\r?\n/);
412
+ nextBuffer = lines.pop();
413
+
414
+ for (const line of lines) {
415
+ const json = parseMachineLine(line);
416
+ if (!json) {
417
+ continue;
418
+ }
419
+
420
+ const events = Array.isArray(json) ? json : [json];
421
+ for (const event of events) {
422
+ 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;
431
+ }
432
+ console.log(JSON.stringify(payload));
433
+ } else {
434
+ if (!qrOnly) {
435
+ console.log(chalk.yellow('\nScan this QR with FlutterBridge app:\n'));
436
+ }
437
+ qrcode.generate(vmServiceUrl, { small: true });
438
+ if (!qrOnly) {
439
+ if (rewritten.replaced) {
440
+ console.log(chalk.gray(`Rewrote VM URL for LAN access: ${vmServiceUrl}`));
441
+ console.log(chalk.gray(`Original VM URL: ${url}`));
442
+ }
443
+ console.log(chalk.green(`\nVM URL: ${vmServiceUrl}`));
444
+ }
445
+ }
446
+ }
447
+ }
448
+ }
449
+
450
+ return nextBuffer;
451
+ };
452
+
453
+ flutter.stdout.on('data', (data) => {
454
+ stdoutBuffer = handleMachineChunk(stdoutBuffer, data);
455
+ });
456
+
457
+ flutter.stderr.on('data', (data) => {
458
+ stderrBuffer = handleMachineChunk(stderrBuffer, data);
459
+ if (!quiet) {
460
+ process.stdout.write(chalk.gray(data.toString()));
461
+ }
462
+ });
463
+
464
+ flutter.on('close', (code) => {
465
+ clearTimeout(vmTimeout);
466
+ if (code && code !== 0) {
467
+ process.exitCode = code;
468
+ }
469
+ });
470
+ }
471
+
472
+ main().catch((err) => {
473
+ console.error(chalk.red(`Unexpected error: ${err.message}`));
474
+ process.exitCode = 1;
475
+ });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@vaishnavkm/flutterbridge",
3
+ "version": "0.1.0",
4
+ "description": "Bridge your Flutter code to your phone instantly. Wireless development with QR code pairing.",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "flutterbridge": "./index.js"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "scripts": {
13
+ "test": "echo \"Error: no test specified\" && exit 1"
14
+ },
15
+ "keywords": [
16
+ "flutter",
17
+ "mobile",
18
+ "development",
19
+ "wireless",
20
+ "hot-reload",
21
+ "qr-code",
22
+ "cli",
23
+ "developer-tools",
24
+ "expo",
25
+ "flutter-tools"
26
+ ],
27
+ "author": "Vaishnav K M",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/vaishnavkm/flutterbridge.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/vaishnavkm/flutterbridge/issues"
35
+ },
36
+ "homepage": "https://github.com/vaishnavkm/flutterbridge#readme",
37
+ "engines": {
38
+ "node": ">=18.0.0"
39
+ },
40
+ "packageManager": "pnpm@10.33.0",
41
+ "dependencies": {
42
+ "chalk": "^4.1.2",
43
+ "qrcode-terminal": "^0.12.0"
44
+ }
45
+ }