ownremote 0.0.0-stage → 1.0.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.
package/cli.js ADDED
@@ -0,0 +1,843 @@
1
+ #!/usr/bin/env node
2
+ // OwnRemote - CLI utama (perintah `ownremote` setelah `npm install -g ownremote`).
3
+ // ownremote menu interaktif
4
+ // ownremote run jalankan server di terminal ini
5
+ // ownremote start jalankan server di background
6
+ // ownremote stop hentikan server
7
+ // ownremote status tampilkan status
8
+ const readline = require('readline');
9
+ const fs = require('fs');
10
+ const { execFile, spawn } = require('child_process');
11
+ const qrcode = require('qrcode-terminal');
12
+ const config = require('./src/config');
13
+ const api = require('./src/admin-client');
14
+ const startup = require('./src/startup');
15
+ const { createServer } = require('./src/server');
16
+ const logo = require('./src/logo');
17
+ const { findCloudflared } = require('./src/tunnel');
18
+
19
+ // ---------------------------------------------------------------- tampilan
20
+ const C = {
21
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', underline: '\x1b[4m',
22
+ red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m',
23
+ magenta: '\x1b[35m', cyan: '\x1b[36m', gray: '\x1b[90m',
24
+ };
25
+ const paint = (style, s) => `${style.split(' ').map((k) => C[k]).join('')}${s}${C.reset}`;
26
+ const visibleLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
27
+ const pad = (s, n) => s + ' '.repeat(Math.max(0, n - visibleLen(s)));
28
+ const time = () => new Date().toLocaleTimeString('en-GB', { hour12: false });
29
+
30
+ // Menu utama memakai logo besar; sub-menu memakai header 1 baris supaya daftar & QR tetap muat di layar.
31
+ function header(subtitle) {
32
+ console.clear();
33
+ console.log();
34
+ if (subtitle) {
35
+ console.log(logo.smallLogo(subtitle));
36
+ console.log(paint('gray', ` ${'-=#'.repeat(20)}`));
37
+ } else {
38
+ console.log(logo.bigLogo());
39
+ }
40
+ console.log();
41
+ }
42
+
43
+ function relTime(iso) {
44
+ if (!iso) return '-';
45
+ const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
46
+ if (s < 60) return 'just now';
47
+ if (s < 3600) return `${Math.floor(s / 60)} min ago`;
48
+ if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
49
+ return `${Math.floor(s / 86400)} d ago`;
50
+ }
51
+
52
+ const fmtCode = (code) => `${code.slice(0, 3)} ${code.slice(3)}`;
53
+ const urlsOf = (st) => st.addresses.map((a) => `http://${a.address}:${st.port}`);
54
+
55
+ function printStatus(st) {
56
+ const row = (label, value) => console.log(` ${paint('gray', pad(label, 15))}${value}`);
57
+ if (st) {
58
+ const mode = st.mode === 'background' ? 'background' : 'another terminal';
59
+ row('Server', `${paint('green bold', '● RUNNING')} ${paint('gray', `(${mode}, PID ${st.pid})`)}`);
60
+ printConnectInfo(st, row);
61
+ row('Devices', `${paint('bold', st.online)} online / ${st.devices} paired`);
62
+ if (st.mode === 'background') {
63
+ row('Tray icon', st.trayActive ? paint('green', 'shown (bottom-right of the taskbar)') : paint('gray', 'off'));
64
+ }
65
+ } else {
66
+ row('Server', paint('red bold', '○ NOT RUNNING'));
67
+ }
68
+ row('Startup', startup.isStartupEnabled() ? paint('green', 'ENABLED (runs automatically at Windows sign-in)') : paint('gray', 'disabled'));
69
+ console.log();
70
+ }
71
+
72
+ function printConnectInfo(st, row = (l, v) => console.log(` ${paint('gray', pad(l, 15))}${v}`)) {
73
+ const urls = urlsOf(st);
74
+ if (urls.length === 0) {
75
+ row('Address', paint('red', 'no LAN/Wi-Fi network detected'));
76
+ } else {
77
+ urls.forEach((u, i) => row(i === 0 ? 'Open on phone' : '', paint(i === 0 ? 'bold underline cyan' : 'cyan', u) + (st.addresses[i].virtual ? paint('gray', ` (${st.addresses[i].name})`) : '')));
78
+ }
79
+ const t = st.tunnel;
80
+ if (t && t.mode !== 'off') {
81
+ if (t.state === 'connected' && t.url) row('Internet', `${paint('bold underline magenta', t.url)} ${paint('gray', '(Cloudflare Tunnel)')}`);
82
+ else if (t.state === 'error') row('Internet', paint('red', t.error));
83
+ else row('Internet', paint('yellow', 'Cloudflare Tunnel connecting...'));
84
+ }
85
+ const pairing = st.allowPairing ? paint('green', 'pairing open') : paint('red', 'pairing LOCKED');
86
+ row('Code', `${paint('bold yellow', fmtCode(st.pairCode))} ${paint('gray', '·')} ${pairing}`);
87
+ }
88
+
89
+ const tunnelUrl = (st) => (st?.tunnel?.state === 'connected' && st.tunnel.url) || '';
90
+
91
+ function printQr(st, url = urlsOf(st)[0]) {
92
+ if (!url) return;
93
+ qrcode.generate(`${url}/#code=${st.pairCode}`, { small: true }, (qr) => {
94
+ console.log(qr.split('\n').map((l) => ` ${l}`).join('\n'));
95
+ });
96
+ console.log(paint('gray', ` Scan with your phone camera (the code is filled in automatically): ${url}`));
97
+ console.log();
98
+ }
99
+
100
+ function menu(items) {
101
+ for (const [key, label] of items) console.log(` ${paint('cyan bold', `[${key}]`)} ${label}`);
102
+ console.log();
103
+ }
104
+
105
+ const ok = (msg) => console.log(` ${paint('green', '✔')} ${msg}`);
106
+ const fail = (msg) => console.log(` ${paint('red', '✖')} ${msg}`);
107
+ const info = (msg) => console.log(` ${paint('cyan', '›')} ${msg}`);
108
+
109
+ // ---------------------------------------------------------------- input
110
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
111
+ let pendingAsk = null;
112
+ let foregroundServer = null;
113
+ let inputClosed = false;
114
+ const queuedLines = []; // baris yang masuk sebelum ada pertanyaan (mis. paste beberapa baris)
115
+ const STOPPED = Symbol('stopped');
116
+
117
+ rl.on('line', (line) => {
118
+ if (!pendingAsk) {
119
+ queuedLines.push(line.trim());
120
+ return;
121
+ }
122
+ const resolve = pendingAsk;
123
+ pendingAsk = null;
124
+ resolve(line.trim());
125
+ });
126
+ rl.on('SIGINT', () => exitApp());
127
+
128
+ function ask(question) {
129
+ return new Promise((resolve) => {
130
+ if (queuedLines.length) {
131
+ const line = queuedLines.shift();
132
+ process.stdout.write(`${question}${line}\n`);
133
+ resolve(line);
134
+ } else if (inputClosed) {
135
+ exitApp();
136
+ } else {
137
+ pendingAsk = resolve;
138
+ rl.setPrompt(question);
139
+ rl.prompt();
140
+ }
141
+ });
142
+ }
143
+
144
+ function cancelAsk(value) {
145
+ if (!pendingAsk) return;
146
+ const resolve = pendingAsk;
147
+ pendingAsk = null;
148
+ process.stdout.write('\n');
149
+ resolve(value);
150
+ }
151
+
152
+ const choose = (label = 'Choose') => ask(` ${paint('bold', label)} ${paint('cyan', '›')} `);
153
+ const pause = () => ask(paint('gray', '\n Press Enter to go back...'));
154
+
155
+ async function confirm(question) {
156
+ const ans = await ask(` ${question} ${paint('gray', '(y/n)')} `);
157
+ return /^y(a|es)?$/i.test(String(ans));
158
+ }
159
+
160
+ // Tulis log di atas prompt tanpa merusak input yang sedang diketik.
161
+ function logAbove(text) {
162
+ readline.clearLine(process.stdout, 0);
163
+ readline.cursorTo(process.stdout, 0);
164
+ process.stdout.write(`${text}\n`);
165
+ if (pendingAsk) rl.prompt(true);
166
+ }
167
+
168
+ let exiting = false;
169
+ async function exitApp(code = 0) {
170
+ if (exiting) return;
171
+ exiting = true;
172
+ if (foregroundServer) {
173
+ console.log(paint('gray', '\n Stopping server...'));
174
+ await foregroundServer.stop();
175
+ }
176
+ process.exit(code);
177
+ }
178
+
179
+ async function spinner(text, promise) {
180
+ if (!process.stdout.isTTY) return promise;
181
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
182
+ let i = 0;
183
+ const t = setInterval(() => process.stdout.write(`\r ${paint('cyan', frames[i++ % frames.length])} ${text}`), 80);
184
+ try {
185
+ return await promise;
186
+ } finally {
187
+ clearInterval(t);
188
+ readline.clearLine(process.stdout, 0);
189
+ readline.cursorTo(process.stdout, 0);
190
+ }
191
+ }
192
+
193
+ // ---------------------------------------------------------------- aksi server
194
+ async function startBackground() {
195
+ const st = await api.getStatus();
196
+ if (st) {
197
+ info(`Server is already running (${st.mode}, PID ${st.pid}).`);
198
+ return st;
199
+ }
200
+ startup.launchBackground();
201
+ const started = await spinner('Starting server in the background...', api.waitUntilUp(12_000));
202
+ if (!started) {
203
+ fail('Server failed to start. Latest log:');
204
+ printLogTail(10);
205
+ return null;
206
+ }
207
+ ok(`Server is running in the background ${paint('gray', `(PID ${started.pid})`)}. You can close this terminal.`);
208
+ console.log();
209
+ printConnectInfo(started);
210
+ console.log();
211
+ return started;
212
+ }
213
+
214
+ async function stopServer() {
215
+ const st = await api.getStatus();
216
+ if (!st) {
217
+ info('Server is not running.');
218
+ return;
219
+ }
220
+ try {
221
+ await api.shutdown();
222
+ } catch { /* fallback di bawah */ }
223
+ let down = await spinner('Stopping server...', api.waitUntilDown(5000));
224
+ if (!down) {
225
+ try { process.kill(st.pid); } catch { /* sudah mati */ }
226
+ down = await api.waitUntilDown(2000);
227
+ }
228
+ if (down) ok('Server stopped.');
229
+ else fail(`Could not stop the server. Try ending node process PID ${st.pid} in Task Manager.`);
230
+ }
231
+
232
+ async function runForeground() {
233
+ const st = await api.getStatus();
234
+ if (st) {
235
+ fail(`Server is already running (${st.mode}, PID ${st.pid}). Stop it first with menu [4].`);
236
+ await pause();
237
+ return;
238
+ }
239
+
240
+ const recent = [];
241
+ let stoppedExternally = false;
242
+ const server = createServer({
243
+ mode: 'foreground',
244
+ log: (msg) => {
245
+ const line = `${paint('gray', time())} ${msg}`;
246
+ recent.push(line);
247
+ if (recent.length > 8) recent.shift();
248
+ logAbove(` ${line}`);
249
+ },
250
+ onShutdown: () => {
251
+ stoppedExternally = true;
252
+ foregroundServer = null;
253
+ cancelAsk(STOPPED);
254
+ },
255
+ });
256
+
257
+ try {
258
+ await server.start();
259
+ } catch (err) {
260
+ fail(err.message);
261
+ await server.stop();
262
+ await pause();
263
+ return;
264
+ }
265
+ foregroundServer = server;
266
+ await sessionMenu({ foreground: true, recent });
267
+
268
+ if (stoppedExternally) {
269
+ header();
270
+ info('Server was stopped from another terminal.');
271
+ await pause();
272
+ } else {
273
+ await server.stop();
274
+ foregroundServer = null;
275
+ }
276
+ }
277
+
278
+ // ---------------------------------------------------------------- manajemen session
279
+ function printSessions(list) {
280
+ console.log(paint('bold', ' Paired devices'));
281
+ if (list.length === 0) {
282
+ console.log(paint('gray', ' None yet. Open the address above on your phone and enter the code.'));
283
+ console.log();
284
+ return;
285
+ }
286
+ const cols = [4, 24, 17, 11, 16];
287
+ const head = ['No', 'Name', 'IP', 'Status', 'Last active'];
288
+ console.log(paint('gray', ` ${head.map((h, i) => pad(h, cols[i])).join('')}`));
289
+ console.log(paint('gray', ` ${'─'.repeat(cols.reduce((a, b) => a + b, 0))}`));
290
+ list.forEach((d, i) => {
291
+ let status = d.online ? paint('green', '● online') : paint('gray', '○ offline');
292
+ if (d.viewingScreen) status = paint('cyan bold', '◉ screen');
293
+ const last = d.online ? `since ${relTime(d.connectedAt)}` : relTime(d.lastSeen);
294
+ const name = d.name.length > 22 ? `${d.name.slice(0, 21)}…` : d.name;
295
+ console.log(` ${pad(String(i + 1), cols[0])}${pad(name, cols[1])}${pad(d.ip || '-', cols[2])}${pad(status, cols[3])}${paint('gray', last)}`);
296
+ });
297
+ console.log();
298
+ }
299
+
300
+ async function pickDevice(list, verb) {
301
+ if (list.length === 0) {
302
+ info('No paired devices yet.');
303
+ await pause();
304
+ return null;
305
+ }
306
+ const ans = await ask(` Device number to ${verb} ${paint('gray', '(empty = cancel)')} › `);
307
+ if (ans === STOPPED || ans === '') return null;
308
+ const d = list[Number(ans) - 1];
309
+ if (!d) {
310
+ fail('Invalid number.');
311
+ await pause();
312
+ return null;
313
+ }
314
+ return d;
315
+ }
316
+
317
+ async function sessionMenu({ foreground = false, recent = [] } = {}) {
318
+ let showQr = false;
319
+ for (;;) {
320
+ const st = await api.getStatus();
321
+ if (!st) {
322
+ if (!foreground) {
323
+ header('SESSION MANAGEMENT');
324
+ fail('Server is not running. Start it first with menu [1] or [2].');
325
+ await pause();
326
+ }
327
+ return;
328
+ }
329
+ let list = [];
330
+ try { list = await api.sessions(); } catch { /* tampilkan kosong */ }
331
+
332
+ header(foreground ? 'SERVER RUNNING IN THIS TERMINAL' : 'SESSION MANAGEMENT');
333
+ printConnectInfo(st);
334
+ console.log();
335
+ if (showQr === 'internet') printQr(st, tunnelUrl(st));
336
+ else if (showQr || list.length === 0) printQr(st);
337
+ showQr = false;
338
+ printSessions(list);
339
+
340
+ if (foreground && recent.length) {
341
+ console.log(paint('bold', ' Recent activity'));
342
+ recent.forEach((l) => console.log(` ${l}`));
343
+ console.log();
344
+ }
345
+
346
+ menu([
347
+ ['1', 'Refresh'],
348
+ ['2', 'Disconnect a device'],
349
+ ['3', 'Revoke device access (must pair again)'],
350
+ ['4', 'Revoke access for ALL devices'],
351
+ ['5', 'Generate new pairing code'],
352
+ ['6', st.allowPairing ? 'Lock pairing (reject new devices)' : 'Unlock pairing for new devices'],
353
+ ['7', 'Show QR code (Wi-Fi / LAN)'],
354
+ ...(tunnelUrl(st) ? [['8', 'Show QR code (internet, Cloudflare Tunnel)']] : []),
355
+ ['0', foreground ? paint('red', 'Stop server & return to main menu') : 'Back'],
356
+ ]);
357
+
358
+ const ch = await choose();
359
+ if (ch === STOPPED) return;
360
+ try {
361
+ switch (ch) {
362
+ case '1': case '': break;
363
+ case '2': {
364
+ const d = await pickDevice(list, 'disconnect');
365
+ if (d) {
366
+ const r = await api.disconnect(d.id);
367
+ if (!r.ok) { info(`${d.name} is not connected.`); await pause(); }
368
+ }
369
+ break;
370
+ }
371
+ case '3': {
372
+ const d = await pickDevice(list, 'revoke');
373
+ if (d && await confirm(`Revoke access for ${paint('bold', d.name)}?`)) await api.revoke(d.id);
374
+ break;
375
+ }
376
+ case '4':
377
+ if (list.length && await confirm(paint('red', `Revoke access for ${list.length} device(s)?`))) await api.revokeAll();
378
+ break;
379
+ case '5':
380
+ await api.regenerateCode();
381
+ break;
382
+ case '6':
383
+ await api.setPairing(!st.allowPairing);
384
+ break;
385
+ case '7':
386
+ showQr = true;
387
+ break;
388
+ case '8':
389
+ if (tunnelUrl(st)) showQr = 'internet';
390
+ break;
391
+ case '0':
392
+ return;
393
+ default:
394
+ break;
395
+ }
396
+ } catch (err) {
397
+ fail(err.message);
398
+ await pause();
399
+ }
400
+ }
401
+ }
402
+
403
+ // ---------------------------------------------------------------- pengaturan
404
+ function printLogTail(lines = 30) {
405
+ let text = '';
406
+ try { text = fs.readFileSync(config.LOG_FILE, 'utf8'); } catch { /* belum ada */ }
407
+ const tail = text.trimEnd().split(/\r?\n/).filter(Boolean).slice(-lines);
408
+ if (tail.length === 0) info('Log is empty.');
409
+ tail.forEach((l) => console.log(paint('gray', ` ${l}`)));
410
+ }
411
+
412
+ function addFirewallRule(port) {
413
+ const name = `OwnRemote-${port}`;
414
+ const args = `/c netsh advfirewall firewall delete rule name=${name} >nul & netsh advfirewall firewall add rule name=${name} dir=in action=allow protocol=TCP localport=${port}`;
415
+ const ps = `$p = Start-Process cmd -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList '${args}'; exit $p.ExitCode`;
416
+ return new Promise((resolve) => {
417
+ execFile('powershell.exe', ['-NoProfile', '-Command', ps], { windowsHide: true }, (err) => resolve(!err));
418
+ });
419
+ }
420
+
421
+ async function settingsMenu() {
422
+ for (;;) {
423
+ const cfg = config.loadConfig();
424
+ const st = await api.getStatus();
425
+ header('SETTINGS');
426
+ menu([
427
+ ['1', `Change port ${paint('gray', `(current: ${cfg.port})`)}`],
428
+ ['2', 'Generate new pairing code'],
429
+ ['3', `Allow port ${cfg.port} in Windows Firewall ${paint('gray', '(requires admin)')}`],
430
+ ['4', 'View background server log'],
431
+ ['5', 'Open data folder'],
432
+ ['6', `${paint('bold', 'Advanced settings')} ${paint('gray', '(startup, tray icon, notifications, ...)')}`],
433
+ ['7', `${paint('bold', 'Cloudflare Tunnel')} ${paint('gray', '(use OwnRemote outside your Wi-Fi)')}`],
434
+ ['0', 'Back'],
435
+ ]);
436
+ const ch = await choose();
437
+ switch (ch) {
438
+ case '6':
439
+ await advancedMenu();
440
+ break;
441
+ case '7':
442
+ await tunnelMenu();
443
+ break;
444
+ case '1': {
445
+ if (st) {
446
+ fail('Stop the server before changing the port.');
447
+ await pause();
448
+ break;
449
+ }
450
+ const ans = await ask(` New port ${paint('gray', '(1024-65535, empty = cancel)')} › `);
451
+ if (!ans) break;
452
+ const port = Number(ans);
453
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
454
+ fail('Invalid port.');
455
+ await pause();
456
+ break;
457
+ }
458
+ config.saveConfig({ ...cfg, port });
459
+ ok(`Port changed to ${port}.`);
460
+ await pause();
461
+ break;
462
+ }
463
+ case '2': {
464
+ const code = st ? (await api.regenerateCode()).pairCode : config.newPairCode();
465
+ if (!st) config.saveConfig({ ...cfg, pairCode: code });
466
+ ok(`New pairing code: ${paint('bold yellow', fmtCode(code))}`);
467
+ await pause();
468
+ break;
469
+ }
470
+ case '3': {
471
+ info('Approve the UAC prompt that appears...');
472
+ const added = await addFirewallRule(cfg.port);
473
+ if (added) ok(`Firewall rule "OwnRemote-${cfg.port}" added.`);
474
+ else fail('Failed or cancelled. Run the terminal as Administrator and try again.');
475
+ await pause();
476
+ break;
477
+ }
478
+ case '4':
479
+ header('BACKGROUND SERVER LOG');
480
+ printLogTail(30);
481
+ await pause();
482
+ break;
483
+ case '5':
484
+ config.ensureDataDir();
485
+ spawn('explorer.exe', [config.DATA_DIR], { detached: true, stdio: 'ignore' }).unref();
486
+ break;
487
+ case '0':
488
+ return;
489
+ default:
490
+ break;
491
+ }
492
+ }
493
+ }
494
+
495
+ // ---------------------------------------------------------------- advanced settings
496
+ const onOff = (v) => (v ? paint('green bold', 'ON') : paint('gray', 'OFF'));
497
+
498
+ function settingLine(key, label, value, hint = '') {
499
+ const dots = paint('gray', ` ${'.'.repeat(Math.max(2, 40 - label.length))} `);
500
+ console.log(` ${paint('cyan bold', `[${key}]`)} ${label}${dots}${pad(value, 6)}${hint ? paint('gray', ` ${hint}`) : ''}`);
501
+ }
502
+
503
+ // Saat server berjalan, pengaturan dikirim lewat Admin API supaya langsung berlaku (dan tidak tertimpa server).
504
+ async function applySettings(st, patch) {
505
+ if (st) return api.updateSettings(patch);
506
+ config.saveConfig({ ...config.loadConfig(), ...patch });
507
+ return null;
508
+ }
509
+
510
+ async function advancedMenu() {
511
+ for (;;) {
512
+ const st = await api.getStatus();
513
+ const s = { ...config.loadConfig(), ...(st?.settings || {}) }; // server versi lama: lengkapi dari config
514
+ const autostart = startup.isStartupEnabled();
515
+ const trayHint = st?.mode === 'background' ? (st.trayActive ? 'showing now' : 'hidden now') : 'background mode only';
516
+
517
+ header('ADVANCED SETTINGS');
518
+ console.log(paint('bold', ' Startup'));
519
+ settingLine('1', 'Start with Windows', onOff(autostart), 'runs in the background at sign-in');
520
+ settingLine('2', 'Startup delay', `${s.startupDelay}s`, 'wait after sign-in (e.g. for Wi-Fi)');
521
+ console.log();
522
+ console.log(paint('bold', ' System tray'));
523
+ settingLine('3', 'Show tray icon', onOff(s.trayIcon), trayHint);
524
+ settingLine('4', 'Notify when a device connects', onOff(s.notifyOnConnect), 'tray notification');
525
+ console.log();
526
+ console.log(paint('bold', ' Security & privacy'));
527
+ settingLine('5', 'Auto-lock pairing after a new device', onOff(s.autoLockPairing), 'rejects further devices');
528
+ settingLine('6', 'Allow screen preview on phone', onOff(s.allowScreen), 'phone must still turn it on');
529
+ settingLine('7', 'Reset all devices & pairing code', '', 'every phone must pair again');
530
+ console.log();
531
+ menu([['0', 'Back']]);
532
+
533
+ const ch = await choose();
534
+ try {
535
+ switch (ch) {
536
+ case '1':
537
+ if (autostart) {
538
+ startup.disableStartup();
539
+ ok('Start with Windows disabled.');
540
+ } else {
541
+ startup.enableStartup();
542
+ ok('The server will start in the background at every Windows sign-in.');
543
+ console.log(paint('gray', ` ${startup.STARTUP_LOCATION}`));
544
+ if (!st && await confirm('Start the server in the background now?')) await startBackground();
545
+ }
546
+ await pause();
547
+ break;
548
+ case '2': {
549
+ const ans = await ask(` Startup delay in seconds ${paint('gray', '(0-600, empty = cancel)')} › `);
550
+ if (!ans) break;
551
+ const sec = Number(ans);
552
+ if (!Number.isInteger(sec) || sec < 0 || sec > 600) {
553
+ fail('Enter a whole number between 0 and 600.');
554
+ await pause();
555
+ break;
556
+ }
557
+ await applySettings(st, { startupDelay: sec });
558
+ break;
559
+ }
560
+ case '3':
561
+ await applySettings(st, { trayIcon: !s.trayIcon });
562
+ if (st?.mode === 'background') await new Promise((r) => setTimeout(r, 500)); // beri waktu ikon muncul/hilang
563
+ break;
564
+ case '4':
565
+ await applySettings(st, { notifyOnConnect: !s.notifyOnConnect });
566
+ break;
567
+ case '5':
568
+ await applySettings(st, { autoLockPairing: !s.autoLockPairing });
569
+ break;
570
+ case '6':
571
+ await applySettings(st, { allowScreen: !s.allowScreen });
572
+ break;
573
+ case '7':
574
+ if (!await confirm(paint('red', 'Revoke ALL devices and generate a new pairing code?'))) break;
575
+ if (st) {
576
+ await api.revokeAll();
577
+ await api.regenerateCode();
578
+ await api.setPairing(true);
579
+ } else {
580
+ config.saveDevices([]);
581
+ config.saveConfig({ ...config.loadConfig(), pairCode: config.newPairCode(), allowPairing: true });
582
+ }
583
+ ok('All devices removed. New pairing code created.');
584
+ await pause();
585
+ break;
586
+ case '0':
587
+ return;
588
+ default:
589
+ break;
590
+ }
591
+ } catch (err) {
592
+ fail(err.message);
593
+ await pause();
594
+ }
595
+ }
596
+ }
597
+
598
+ // ---------------------------------------------------------------- Cloudflare Tunnel
599
+ const TUNNEL_LABELS = { off: 'Off', quick: 'Quick tunnel (*.trycloudflare.com)', token: 'Named tunnel (your domain)' };
600
+
601
+ async function enableTunnel(st, patch) {
602
+ const s = { ...config.loadConfig(), ...(st?.settings || {}) }; // server versi lama: lengkapi dari config
603
+ console.log();
604
+ console.log(paint('yellow', ' ! Anyone on the internet who has this URL can open the OwnRemote page.'));
605
+ console.log(paint('yellow', ' They still need the 6-digit pairing code, but locking pairing after your phone is paired is safer.'));
606
+ if (!s.autoLockPairing && await confirm('Enable auto-lock pairing after a new device pairs? (recommended)')) {
607
+ patch.autoLockPairing = true;
608
+ }
609
+ await applySettings(st, patch);
610
+ if (!st) {
611
+ ok('Saved. The tunnel starts together with the server.');
612
+ await pause();
613
+ return;
614
+ }
615
+ const up = await spinner('Connecting to Cloudflare...', (async () => {
616
+ for (let i = 0; i < 60; i++) {
617
+ const now = await api.getStatus();
618
+ const t = now?.tunnel;
619
+ if (t && (t.state === 'connected' || t.state === 'error')) return now;
620
+ await new Promise((r) => setTimeout(r, 500));
621
+ }
622
+ return api.getStatus();
623
+ })());
624
+ const t = up?.tunnel;
625
+ if (t?.state === 'connected') {
626
+ ok(`Tunnel connected${t.url ? `: ${paint('bold magenta', t.url)}` : ''}`);
627
+ if (patch.tunnelMode === 'quick') info('A brand-new quick tunnel URL can take up to a minute before it opens on your phone.');
628
+ if (t.url) printQr(up, t.url);
629
+ } else {
630
+ fail(t?.error || 'Tunnel is still connecting. Check the status again in a moment.');
631
+ }
632
+ await pause();
633
+ }
634
+
635
+ async function tunnelMenu() {
636
+ for (;;) {
637
+ const st = await api.getStatus();
638
+ const s = { ...config.loadConfig(), ...(st?.settings || {}) }; // server versi lama: lengkapi dari config
639
+ const tokenSet = st ? s.tunnelTokenSet : Boolean(s.tunnelToken);
640
+ const exe = findCloudflared(s.cloudflaredPath);
641
+ const t = st?.tunnel;
642
+
643
+ header('CLOUDFLARE TUNNEL');
644
+ console.log(paint('gray', ' Reach your PC from anywhere (mobile data, other Wi-Fi) through a secure HTTPS tunnel.'));
645
+ console.log();
646
+ const row = (l, v) => console.log(` ${paint('gray', pad(l, 15))}${v}`);
647
+ row('cloudflared', exe ? paint('green', exe) : paint('red', 'not found -> winget install --id Cloudflare.cloudflared'));
648
+ row('Mode', s.tunnelMode === 'off' ? paint('gray', 'off') : paint('bold', TUNNEL_LABELS[s.tunnelMode]));
649
+ if (s.tunnelMode !== 'off') {
650
+ if (!st) row('Status', paint('gray', 'starts when the server starts'));
651
+ else if (!t) row('Status', paint('yellow', 'restart the server to start the tunnel (it is running an older version)'));
652
+ else if (t?.state === 'connected') row('Status', `${paint('green bold', '● connected')} ${t.url ? paint('bold underline magenta', t.url) : ''}`);
653
+ else if (t?.state === 'error') row('Status', paint('red', t.error));
654
+ else row('Status', paint('yellow', 'connecting...'));
655
+ }
656
+ row('Tunnel token', tokenSet ? paint('green', 'set') : paint('gray', 'not set (only needed for named tunnels)'));
657
+ row('Public URL', s.publicUrl ? paint('cyan', s.publicUrl) : paint('gray', 'not set (only needed for named tunnels)'));
658
+ console.log();
659
+
660
+ menu([
661
+ ['1', 'Turn tunnel off'],
662
+ ['2', `Quick tunnel ${paint('gray', '- random https://*.trycloudflare.com URL, no account needed')}`],
663
+ ['3', `Named tunnel ${paint('gray', '- your own domain, needs a Cloudflare tunnel token')}`],
664
+ ['4', 'Set tunnel token'],
665
+ ['5', 'Set public URL of the named tunnel'],
666
+ ...(tunnelUrl(st) ? [['6', 'Show QR code for the internet URL']] : []),
667
+ ['0', 'Back'],
668
+ ]);
669
+
670
+ const ch = await choose();
671
+ try {
672
+ switch (ch) {
673
+ case '1':
674
+ await applySettings(st, { tunnelMode: 'off' });
675
+ break;
676
+ case '2':
677
+ if (!exe) { fail('Install cloudflared first: winget install --id Cloudflare.cloudflared'); await pause(); break; }
678
+ await enableTunnel(st, { tunnelMode: 'quick' });
679
+ break;
680
+ case '3':
681
+ if (!exe) { fail('Install cloudflared first: winget install --id Cloudflare.cloudflared'); await pause(); break; }
682
+ if (!tokenSet || !s.publicUrl) {
683
+ console.log();
684
+ info('Setup in the Cloudflare dashboard (Zero Trust > Networks > Tunnels > Create a tunnel):');
685
+ console.log(paint('gray', ` 1. Choose "Cloudflared", copy the token from the install command.`));
686
+ console.log(paint('gray', ` 2. Add a public hostname (e.g. remote.yourdomain.com) -> service HTTP, URL localhost:${config.loadConfig().port}`));
687
+ console.log(paint('gray', ' 3. Paste the token (menu 4) and the public URL (menu 5) here, then pick menu 3 again.'));
688
+ await pause();
689
+ break;
690
+ }
691
+ await enableTunnel(st, { tunnelMode: 'token' });
692
+ break;
693
+ case '4': {
694
+ console.log(paint('gray', ' Paste the token or the whole "cloudflared service install <token>" command.'));
695
+ const ans = await ask(` Tunnel token ${paint('gray', '(empty = cancel, "-" = remove)')} › `);
696
+ if (!ans) break;
697
+ const token = ans === '-' ? '' : ans.trim().split(/\s+/).pop();
698
+ await applySettings(st, { tunnelToken: token });
699
+ ok(token ? 'Tunnel token saved.' : 'Tunnel token removed.');
700
+ await pause();
701
+ break;
702
+ }
703
+ case '5': {
704
+ const ans = await ask(` Public URL, e.g. https://remote.yourdomain.com ${paint('gray', '(empty = cancel, "-" = remove)')} › `);
705
+ if (!ans) break;
706
+ const url = ans === '-' ? '' : ans.trim().replace(/\/+$/, '');
707
+ if (url && !/^https?:\/\/[^\s/]+$/i.test(url)) { fail('Enter a URL like https://remote.yourdomain.com'); await pause(); break; }
708
+ await applySettings(st, { publicUrl: url });
709
+ break;
710
+ }
711
+ case '6':
712
+ if (tunnelUrl(st)) {
713
+ header('CLOUDFLARE TUNNEL');
714
+ printQr(st, tunnelUrl(st));
715
+ await pause();
716
+ }
717
+ break;
718
+ case '0':
719
+ return;
720
+ default:
721
+ break;
722
+ }
723
+ } catch (err) {
724
+ fail(err.message);
725
+ await pause();
726
+ }
727
+ }
728
+ }
729
+
730
+ // ---------------------------------------------------------------- menu utama
731
+ async function mainMenu() {
732
+ for (;;) {
733
+ const st = await api.getStatus();
734
+ header();
735
+ printStatus(st);
736
+ menu([
737
+ ['1', `Run in this terminal${st ? paint('gray', ' (server already running)') : ''}`],
738
+ ['2', `Run in the background${st ? paint('gray', ' (server already running)') : ''}`],
739
+ ['3', 'Session / device management'],
740
+ ['4', 'Stop server'],
741
+ ['5', `Settings ${paint('gray', '(port, firewall, log, advanced)')}`],
742
+ ['0', 'Exit'],
743
+ ]);
744
+
745
+ const ch = await choose();
746
+ switch (ch) {
747
+ case '1':
748
+ await runForeground();
749
+ break;
750
+ case '2':
751
+ await startBackground();
752
+ await pause();
753
+ break;
754
+ case '3':
755
+ await sessionMenu();
756
+ break;
757
+ case '4':
758
+ await stopServer();
759
+ await pause();
760
+ break;
761
+ case '5':
762
+ await settingsMenu();
763
+ break;
764
+ case '0': {
765
+ const running = await api.getStatus();
766
+ if (running?.mode === 'background') console.log(paint('gray', '\n The server keeps running in the background. Bye!\n'));
767
+ else if (running) console.log(paint('gray', '\n The server keeps running in the other terminal. Bye!\n'));
768
+ return exitApp();
769
+ }
770
+ default:
771
+ break;
772
+ }
773
+ }
774
+ }
775
+
776
+ // ---------------------------------------------------------------- entry
777
+ const USAGE = `
778
+ ${paint('bold cyan', 'ownremote')} ${paint('gray', `v${require('./package.json').version}`)} control your Windows PC from your phone
779
+
780
+ ${paint('bold', 'Usage')}
781
+ ownremote interactive menu
782
+ ownremote run run the server in this terminal
783
+ ownremote start run the server in the background
784
+ ownremote stop stop the server
785
+ ownremote status show status, address and pairing code
786
+ ownremote --version print the version
787
+ ownremote --help show this help
788
+
789
+ Data folder: ${config.DATA_DIR}
790
+ `;
791
+
792
+ async function main() {
793
+ const cmd = (process.argv[2] || '').toLowerCase();
794
+ // perintah info: tanpa efek samping (tidak membuat folder data)
795
+ if (['-v', '--version', 'version'].includes(cmd)) {
796
+ console.log(require('./package.json').version);
797
+ return exitApp();
798
+ }
799
+ if (['-h', '--help', 'help'].includes(cmd)) {
800
+ console.log(USAGE);
801
+ return exitApp();
802
+ }
803
+
804
+ config.loadConfig();
805
+ startup.refreshStartup();
806
+
807
+ // Ctrl+D / stdin ditutup hanya mengakhiri program di mode interaktif
808
+ if (cmd === '' || cmd === 'run') {
809
+ rl.on('close', () => {
810
+ inputClosed = true;
811
+ if (pendingAsk) exitApp();
812
+ });
813
+ }
814
+ switch (cmd) {
815
+ case '':
816
+ return mainMenu();
817
+ case 'run':
818
+ await runForeground();
819
+ return exitApp();
820
+ case 'start': {
821
+ const st = await startBackground();
822
+ return exitApp(st ? 0 : 1);
823
+ }
824
+ case 'stop':
825
+ await stopServer();
826
+ return exitApp();
827
+ case 'status': {
828
+ const st = await api.getStatus();
829
+ console.log();
830
+ printStatus(st);
831
+ return exitApp();
832
+ }
833
+ default:
834
+ console.log(` Unknown command: ${cmd}`);
835
+ console.log(USAGE);
836
+ return exitApp(1);
837
+ }
838
+ }
839
+
840
+ main().catch((err) => {
841
+ console.error(err);
842
+ exitApp(1);
843
+ });