ownremote 0.0.0-stage → 1.0.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/src/server.js ADDED
@@ -0,0 +1,567 @@
1
+ const express = require('express');
2
+ const http = require('http');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { WebSocketServer } = require('ws');
7
+ const config = require('./config');
8
+ const { InputController, BUTTONS } = require('./input');
9
+ const { TrayIcon, openCliWindow } = require('./tray');
10
+ const { ScreenStreamer } = require('./screen');
11
+ const { TunnelManager } = require('./tunnel');
12
+
13
+ // Kode close WebSocket yang dipahami halaman HP (public/app.js)
14
+ const CLOSE_REVOKED = 4001;
15
+ const CLOSE_REPLACED = 4002;
16
+ const CLOSE_KICKED = 4003;
17
+
18
+ const MAX_FAILED_ATTEMPTS = 5; // per IP
19
+ const LOCKOUT_MS = 60_000;
20
+ // Batas global (semua IP digabung): menahan brute force dari banyak IP saat server terbuka lewat tunnel
21
+ const GLOBAL_FAIL_LIMIT = 20;
22
+ const GLOBAL_FAIL_WINDOW_MS = 10 * 60_000;
23
+ const GLOBAL_LOCKOUT_MS = 10 * 60_000;
24
+
25
+ // Header yang ditambahkan Cloudflare / reverse proxy. Request yang membawanya datang dari internet,
26
+ // walaupun koneksi TCP-nya dari 127.0.0.1 (cloudflared berjalan di PC ini).
27
+ const PROXY_HEADERS = ['cf-connecting-ip', 'cf-ray', 'cdn-loop', 'x-forwarded-for', 'forwarded'];
28
+ const viaProxy = (req) => PROXY_HEADERS.some((h) => req.headers[h] !== undefined);
29
+
30
+ function getLanAddresses() {
31
+ const virtual = /vethernet|virtualbox|vmware|hyper-v|wsl|docker|loopback|tailscale|zerotier|vpn/i;
32
+ const list = [];
33
+ for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
34
+ for (const a of addrs || []) {
35
+ const v4 = a.family === 'IPv4' || a.family === 4;
36
+ if (!v4 || a.internal || a.address.startsWith('169.254.')) continue;
37
+ list.push({ name, address: a.address, virtual: virtual.test(name) });
38
+ }
39
+ }
40
+ // Adapter fisik (Wi-Fi/Ethernet) diutamakan
41
+ return list.sort((a, b) => a.virtual - b.virtual);
42
+ }
43
+
44
+ function hashToken(token) {
45
+ return crypto.createHash('sha256').update(String(token)).digest('hex');
46
+ }
47
+
48
+ function safeEqual(a, b) {
49
+ const ba = Buffer.from(String(a));
50
+ const bb = Buffer.from(String(b));
51
+ return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
52
+ }
53
+
54
+ function normalizeIp(ip = '') {
55
+ return ip.replace(/^::ffff:/, '');
56
+ }
57
+
58
+ function isLoopback(ip) {
59
+ const n = normalizeIp(ip);
60
+ return n === '127.0.0.1' || n === '::1';
61
+ }
62
+
63
+ // IP asli klien. Header proxy hanya dipercaya bila koneksi datang dari localhost (yaitu dari cloudflared).
64
+ function clientIp(req) {
65
+ const socketIp = normalizeIp(req.socket.remoteAddress);
66
+ if (isLoopback(socketIp)) {
67
+ const cf = req.headers['cf-connecting-ip'];
68
+ if (cf) return String(cf).trim().slice(0, 64);
69
+ const xff = req.headers['x-forwarded-for'];
70
+ if (xff) return String(xff).split(',')[0].trim().slice(0, 64);
71
+ }
72
+ return socketIp;
73
+ }
74
+
75
+ function guessDeviceName(ua = '') {
76
+ let osName = 'Device';
77
+ if (/iPhone/i.test(ua)) osName = 'iPhone';
78
+ else if (/iPad/i.test(ua)) osName = 'iPad';
79
+ else if (/Android/i.test(ua)) osName = 'Android';
80
+ else if (/Windows/i.test(ua)) osName = 'Windows';
81
+ else if (/Mac OS/i.test(ua)) osName = 'Mac';
82
+ let browser = '';
83
+ if (/SamsungBrowser/i.test(ua)) browser = 'Samsung Internet';
84
+ else if (/EdgA?|Edg\//i.test(ua)) browser = 'Edge';
85
+ else if (/Firefox|FxiOS/i.test(ua)) browser = 'Firefox';
86
+ else if (/Chrome|CriOS/i.test(ua)) browser = 'Chrome';
87
+ else if (/Safari/i.test(ua)) browser = 'Safari';
88
+ return browser ? `${osName} · ${browser}` : osName;
89
+ }
90
+
91
+ // Pengaturan untuk ditampilkan di CLI. Token tunnel tidak pernah dikirim balik, hanya penandanya.
92
+ function settingsOf(cfg) {
93
+ const out = Object.fromEntries(config.RUNTIME_SETTINGS.filter((k) => k !== 'tunnelToken').map((k) => [k, cfg[k]]));
94
+ out.tunnelTokenSet = Boolean(cfg.tunnelToken);
95
+ return out;
96
+ }
97
+
98
+ // Validasi tiap pengaturan yang dikirim CLI
99
+ function sanitizeSetting(key, value) {
100
+ switch (key) {
101
+ case 'startupDelay': return Math.max(0, Math.min(600, Math.round(Number(value) || 0)));
102
+ case 'tunnelMode': return ['off', 'quick', 'token'].includes(value) ? value : 'off';
103
+ case 'tunnelToken': return String(value || '').trim().slice(0, 4096);
104
+ case 'cloudflaredPath': return String(value || '').trim().slice(0, 500);
105
+ case 'publicUrl': {
106
+ const v = String(value || '').trim().replace(/\/+$/, '');
107
+ return /^https?:\/\/[^\s/]+$/i.test(v) ? v : '';
108
+ }
109
+ default: return Boolean(value);
110
+ }
111
+ }
112
+
113
+ function cleanName(name) {
114
+ return String(name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 40);
115
+ }
116
+
117
+ /**
118
+ * @param {object} opts
119
+ * @param {'foreground'|'background'} opts.mode
120
+ * @param {(msg: string) => void} opts.log
121
+ * @param {() => void} [opts.onShutdown] dipanggil setelah server berhenti karena perintah /admin/shutdown
122
+ */
123
+ function createServer({ mode = 'foreground', log = () => {}, onShutdown = () => {} } = {}) {
124
+ const cfg = config.loadConfig();
125
+ let devices = config.loadDevices();
126
+ const sockets = new Map(); // deviceId -> ws
127
+ const failed = new Map(); // ip -> { count, until }
128
+ let globalFails = []; // waktu kode salah dari semua IP
129
+ let globalLockUntil = 0;
130
+ const input = new InputController(log);
131
+ const screen = new ScreenStreamer({ log });
132
+ const startedAt = new Date().toISOString();
133
+ let devicesDirty = false;
134
+ let stopping = null;
135
+
136
+ const persistDevices = () => {
137
+ config.saveDevices(devices);
138
+ devicesDirty = false;
139
+ };
140
+
141
+ const findDevice = (token) => {
142
+ if (!token) return null;
143
+ const h = hashToken(token);
144
+ return devices.find((d) => safeEqual(d.tokenHash, h)) || null;
145
+ };
146
+
147
+ const publicDevice = (d) => ({
148
+ id: d.id,
149
+ name: d.name,
150
+ ip: d.ip,
151
+ userAgent: d.userAgent,
152
+ pairedAt: d.pairedAt,
153
+ lastSeen: d.lastSeen,
154
+ online: sockets.has(d.id),
155
+ connectedAt: sockets.get(d.id)?.connectedAt || null,
156
+ viewingScreen: sockets.has(d.id) && screen.isViewing(sockets.get(d.id)),
157
+ });
158
+
159
+ const disconnect = (id, code, reason) => {
160
+ const ws = sockets.get(id);
161
+ if (!ws) return false;
162
+ ws.close(code, reason);
163
+ return true;
164
+ };
165
+
166
+ function regenerateCode() {
167
+ cfg.pairCode = config.newPairCode();
168
+ globalFails = []; // kode baru: percobaan brute force sebelumnya tidak berguna lagi
169
+ globalLockUntil = 0;
170
+ config.saveConfig(cfg);
171
+ failed.clear();
172
+ log('New pairing code generated.');
173
+ pushTray();
174
+ }
175
+
176
+ function setPairing(allow) {
177
+ cfg.allowPairing = allow;
178
+ config.saveConfig(cfg);
179
+ log(allow ? 'Pairing for new devices UNLOCKED.' : 'Pairing for new devices LOCKED.');
180
+ pushTray();
181
+ }
182
+
183
+ // ---------- ikon system tray (hanya mode background) ----------
184
+ const tray = new TrayIcon({
185
+ log,
186
+ onCommand: (cmd) => {
187
+ if (cmd === 'open') openCliWindow();
188
+ else if (cmd === 'newcode') regenerateCode();
189
+ else if (cmd === 'togglepair') setPairing(!cfg.allowPairing);
190
+ else if (cmd === 'stop') {
191
+ log('Stop command received from the tray icon.');
192
+ stop().then(onShutdown);
193
+ }
194
+ },
195
+ });
196
+
197
+ const trayWanted = () => mode === 'background' && cfg.trayIcon && !stopping;
198
+
199
+ // ---------- Cloudflare Tunnel (opsional) ----------
200
+ const tunnel = new TunnelManager({ log, onChange: () => pushTray() });
201
+
202
+ function syncTunnel() {
203
+ if (stopping) return;
204
+ tunnel.apply({
205
+ mode: cfg.tunnelMode,
206
+ port: cfg.port,
207
+ token: cfg.tunnelToken,
208
+ publicUrl: cfg.publicUrl,
209
+ cloudflaredPath: cfg.cloudflaredPath,
210
+ });
211
+ }
212
+
213
+ function pushTray() {
214
+ if (!tray.proc) return;
215
+ const addr = getLanAddresses()[0];
216
+ tray.update({
217
+ publicUrl: tunnel.status().url,
218
+ url: addr ? `http://${addr.address}:${cfg.port}` : '',
219
+ pairCode: cfg.pairCode,
220
+ online: sockets.size,
221
+ paired: devices.length,
222
+ allowPairing: cfg.allowPairing,
223
+ });
224
+ }
225
+
226
+ function syncTray() {
227
+ if (trayWanted()) {
228
+ tray.start();
229
+ pushTray();
230
+ } else {
231
+ tray.stop();
232
+ }
233
+ }
234
+
235
+ // ---------- HTTP ----------
236
+ const app = express();
237
+ app.disable('x-powered-by');
238
+ app.use(express.json({ limit: '8kb' }));
239
+ app.use(express.static(path.join(config.ROOT, 'public'), { index: 'index.html' }));
240
+
241
+ app.post('/api/pair', (req, res) => {
242
+ const ip = clientIp(req);
243
+ const now = Date.now();
244
+ if (globalLockUntil > now) {
245
+ const wait = Math.ceil((globalLockUntil - now) / 60_000);
246
+ return res.status(429).json({ error: `Pairing is paused after too many wrong codes. Try again in ${wait} min.` });
247
+ }
248
+ const f = failed.get(ip);
249
+ if (f && f.until > now) {
250
+ const wait = Math.ceil((f.until - now) / 1000);
251
+ return res.status(429).json({ error: `Too many attempts. Try again in ${wait} s.` });
252
+ }
253
+ if (!cfg.allowPairing) {
254
+ return res.status(403).json({ error: 'Pairing new devices is locked on the PC.' });
255
+ }
256
+ const code = String(req.body?.code ?? '').replace(/\D/g, '');
257
+ if (!safeEqual(code, cfg.pairCode)) {
258
+ const count = (f?.count || 0) + 1;
259
+ failed.set(ip, count >= MAX_FAILED_ATTEMPTS ? { count: 0, until: now + LOCKOUT_MS } : { count, until: 0 });
260
+ log(`Wrong code from ${ip}${viaProxy(req) ? ' (via tunnel)' : ''} (${count}/${MAX_FAILED_ATTEMPTS})`);
261
+ globalFails = globalFails.filter((t) => now - t < GLOBAL_FAIL_WINDOW_MS);
262
+ globalFails.push(now);
263
+ if (globalFails.length >= GLOBAL_FAIL_LIMIT) {
264
+ globalFails = [];
265
+ globalLockUntil = now + GLOBAL_LOCKOUT_MS;
266
+ log(`Too many wrong codes from several addresses: pairing blocked for ${GLOBAL_LOCKOUT_MS / 60_000} minutes.`);
267
+ tray.notify('Pairing blocked', 'Too many wrong pairing codes. Consider generating a new code.');
268
+ }
269
+ return res.status(401).json({ error: 'Wrong code.' });
270
+ }
271
+ failed.delete(ip);
272
+
273
+ const ua = String(req.get('user-agent') || '').slice(0, 300);
274
+ const token = crypto.randomBytes(32).toString('hex');
275
+ const device = {
276
+ id: crypto.randomBytes(4).toString('hex'),
277
+ name: cleanName(req.body?.name) || guessDeviceName(ua),
278
+ tokenHash: hashToken(token),
279
+ ip,
280
+ userAgent: ua,
281
+ pairedAt: new Date().toISOString(),
282
+ lastSeen: new Date().toISOString(),
283
+ };
284
+ devices.push(device);
285
+ persistDevices();
286
+ log(`New device paired: ${device.name} (${ip})`);
287
+ if (cfg.autoLockPairing && cfg.allowPairing) setPairing(false);
288
+ pushTray();
289
+ res.json({ token, id: device.id, name: device.name, pc: os.hostname() });
290
+ });
291
+
292
+ const bearer = (req) => String(req.get('authorization') || '').replace(/^Bearer\s+/i, '');
293
+
294
+ app.get('/api/check', (req, res) => {
295
+ const device = findDevice(bearer(req));
296
+ if (!device) return res.status(401).json({ error: 'Invalid token.' });
297
+ res.json({ id: device.id, name: device.name, pc: os.hostname() });
298
+ });
299
+
300
+ // HP memilih "Lupakan PC ini": hapus perangkat dari daftar
301
+ app.post('/api/unpair', (req, res) => {
302
+ const device = findDevice(bearer(req));
303
+ if (!device) return res.status(401).json({ error: 'Invalid token.' });
304
+ devices = devices.filter((d) => d.id !== device.id);
305
+ persistDevices();
306
+ disconnect(device.id, CLOSE_REVOKED, 'Device forgotten');
307
+ log(`${device.name} unpaired itself from the phone.`);
308
+ pushTray();
309
+ res.json({ ok: true });
310
+ });
311
+
312
+ // ---------- Admin API (hanya dari localhost + admin token, dipakai oleh CLI) ----------
313
+ const admin = express.Router();
314
+ admin.use((req, res, next) => {
315
+ // Hanya CLI di PC ini: koneksi localhost, TIDAK lewat tunnel/proxy, dan membawa admin token
316
+ if (!isLoopback(req.socket.remoteAddress) || viaProxy(req) || !safeEqual(req.get('x-admin-token') || '', cfg.adminToken)) {
317
+ return res.status(403).json({ error: 'forbidden' });
318
+ }
319
+ next();
320
+ });
321
+
322
+ admin.get('/status', (req, res) => {
323
+ res.json({
324
+ pid: process.pid,
325
+ mode,
326
+ port: cfg.port,
327
+ startedAt,
328
+ pairCode: cfg.pairCode,
329
+ allowPairing: cfg.allowPairing,
330
+ mouseReady: input.ready,
331
+ addresses: getLanAddresses(),
332
+ hostname: os.hostname(),
333
+ devices: devices.length,
334
+ online: sockets.size,
335
+ trayActive: Boolean(tray.proc),
336
+ tunnel: tunnel.status(),
337
+ settings: settingsOf(cfg),
338
+ });
339
+ });
340
+
341
+ admin.get('/sessions', (req, res) => {
342
+ res.json(devices.map(publicDevice));
343
+ });
344
+
345
+ admin.post('/sessions/:id/disconnect', (req, res) => {
346
+ const d = devices.find((x) => x.id === req.params.id);
347
+ if (!d) return res.status(404).json({ error: 'Device not found.' });
348
+ const ok = disconnect(d.id, CLOSE_KICKED, 'Disconnected from the PC');
349
+ if (ok) log(`${d.name} disconnected from the CLI.`);
350
+ res.json({ ok });
351
+ });
352
+
353
+ admin.post('/sessions/:id/revoke', (req, res) => {
354
+ const d = devices.find((x) => x.id === req.params.id);
355
+ if (!d) return res.status(404).json({ error: 'Device not found.' });
356
+ devices = devices.filter((x) => x.id !== d.id);
357
+ persistDevices();
358
+ disconnect(d.id, CLOSE_REVOKED, 'Access revoked');
359
+ log(`Access revoked for ${d.name}.`);
360
+ pushTray();
361
+ res.json({ ok: true });
362
+ });
363
+
364
+ admin.post('/sessions/revoke-all', (req, res) => {
365
+ const count = devices.length;
366
+ for (const d of devices) disconnect(d.id, CLOSE_REVOKED, 'Access revoked');
367
+ devices = [];
368
+ persistDevices();
369
+ log(`Access revoked for all devices (${count}).`);
370
+ pushTray();
371
+ res.json({ ok: true, count });
372
+ });
373
+
374
+ admin.post('/pair-code/regenerate', (req, res) => {
375
+ regenerateCode();
376
+ res.json({ pairCode: cfg.pairCode });
377
+ });
378
+
379
+ admin.post('/pairing', (req, res) => {
380
+ setPairing(Boolean(req.body?.allow));
381
+ res.json({ allowPairing: cfg.allowPairing });
382
+ });
383
+
384
+ admin.post('/settings', (req, res) => {
385
+ const patch = req.body || {};
386
+ for (const key of config.RUNTIME_SETTINGS) {
387
+ if (!(key in patch)) continue;
388
+ cfg[key] = sanitizeSetting(key, patch[key]);
389
+ }
390
+ config.saveConfig(cfg);
391
+ syncTray();
392
+ syncTunnel();
393
+ if (!cfg.allowScreen) screen.unsubscribeAll('disabled');
394
+ log('Settings updated.');
395
+ res.json(settingsOf(cfg));
396
+ });
397
+
398
+ admin.post('/shutdown', (req, res) => {
399
+ res.json({ ok: true });
400
+ log('Stop command received from the CLI.');
401
+ setImmediate(() => stop().then(onShutdown));
402
+ });
403
+
404
+ app.use('/admin', admin);
405
+
406
+ // ---------- WebSocket (trackpad) ----------
407
+ const server = http.createServer(app);
408
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 4096 });
409
+
410
+ server.on('upgrade', (req, socket, head) => {
411
+ let url;
412
+ try {
413
+ url = new URL(req.url, 'http://localhost');
414
+ } catch {
415
+ return socket.destroy();
416
+ }
417
+ if (url.pathname !== '/ws') return socket.destroy();
418
+ wss.handleUpgrade(req, socket, head, (ws) => {
419
+ const device = findDevice(url.searchParams.get('token'));
420
+ if (!device) return ws.close(CLOSE_REVOKED, 'Invalid token');
421
+ onConnection(ws, device, req);
422
+ });
423
+ });
424
+
425
+ function onConnection(ws, device, req) {
426
+ const old = sockets.get(device.id);
427
+ if (old) old.close(CLOSE_REPLACED, 'Connected from another tab or device');
428
+
429
+ const ip = clientIp(req);
430
+ ws.connectedAt = new Date().toISOString();
431
+ ws.isAlive = true;
432
+ const pressed = new Set();
433
+ sockets.set(device.id, ws);
434
+ device.ip = ip;
435
+ device.lastSeen = ws.connectedAt;
436
+ persistDevices();
437
+ log(`Connected: ${device.name} (${ip})`);
438
+ pushTray();
439
+ if (cfg.notifyOnConnect) tray.notify('Device connected', `${device.name} (${ip})`);
440
+
441
+ ws.send(JSON.stringify({ t: 'hello', name: device.name, pc: os.hostname() }));
442
+
443
+ ws.on('pong', () => { ws.isAlive = true; });
444
+ ws.on('message', (raw) => {
445
+ let msg;
446
+ try {
447
+ msg = JSON.parse(raw);
448
+ } catch {
449
+ return;
450
+ }
451
+ device.lastSeen = new Date().toISOString();
452
+ devicesDirty = true;
453
+ switch (msg.t) {
454
+ case 'm': input.move(Number(msg.x), Number(msg.y)); break;
455
+ case 's': input.scroll(Number(msg.y) || 0, Number(msg.x) || 0); break;
456
+ case 'c': input.click(msg.b); break;
457
+ case 'd':
458
+ if (BUTTONS.has(msg.b) && !pressed.has(msg.b)) { pressed.add(msg.b); input.down(msg.b); }
459
+ break;
460
+ case 'u':
461
+ if (pressed.delete(msg.b)) input.up(msg.b);
462
+ break;
463
+ // keyboard
464
+ case 'type': if (typeof msg.s === 'string') input.typeText(msg.s); break;
465
+ case 'key': input.key(String(msg.k), Number(msg.m) || 0); break;
466
+ case 'kc': if (typeof msg.c === 'string') input.keyChar(msg.c, Number(msg.m) || 0); break;
467
+ case 'bs': {
468
+ // hapus n karakter (backspace beruntun dari diff teks di HP)
469
+ const n = Math.min(500, Math.max(0, Math.floor(Number(msg.n) || 0)));
470
+ for (let i = 0; i < n; i++) input.key('backspace');
471
+ break;
472
+ }
473
+ // preview layar (opt-in dari HP)
474
+ case 'screen':
475
+ if (!msg.on) screen.unsubscribe(ws);
476
+ else if (!cfg.allowScreen) ws.send(JSON.stringify({ t: 'screen-off', reason: 'disabled' }));
477
+ else {
478
+ const first = !screen.isViewing(ws);
479
+ screen.subscribe(ws, { preset: msg.preset, fps: msg.fps, width: msg.width, quality: msg.quality }, device.name);
480
+ if (first && cfg.notifyOnConnect) tray.notify('Screen preview started', `${device.name} is viewing your screen`);
481
+ }
482
+ break;
483
+ case 'fack': screen.ack(ws); break;
484
+ case 'ping': ws.send(JSON.stringify({ t: 'pong' })); break;
485
+ default: break;
486
+ }
487
+ });
488
+
489
+ ws.on('close', () => {
490
+ // lepas tombol yang masih ditekan supaya tidak "nyangkut" drag
491
+ for (const b of pressed) input.up(b);
492
+ screen.unsubscribe(ws);
493
+ pressed.clear();
494
+ if (sockets.get(device.id) === ws) sockets.delete(device.id);
495
+ pushTray();
496
+ if (devices.includes(device)) {
497
+ device.lastSeen = new Date().toISOString();
498
+ persistDevices();
499
+ }
500
+ log(`Disconnected: ${device.name}`);
501
+ });
502
+ }
503
+
504
+ const heartbeat = setInterval(() => {
505
+ for (const ws of sockets.values()) {
506
+ if (!ws.isAlive) {
507
+ ws.terminate();
508
+ continue;
509
+ }
510
+ ws.isAlive = false;
511
+ ws.ping();
512
+ }
513
+ if (devicesDirty) persistDevices();
514
+ pushTray(); // IP bisa berubah (ganti Wi-Fi)
515
+ }, 15_000);
516
+ heartbeat.unref();
517
+
518
+ // ---------- lifecycle ----------
519
+ function start() {
520
+ return new Promise((resolve, reject) => {
521
+ const onError = (err) => {
522
+ if (err.code === 'EADDRINUSE') {
523
+ reject(new Error(`Port ${cfg.port} is already in use by another app (or this server is already running).`));
524
+ } else {
525
+ reject(err);
526
+ }
527
+ };
528
+ server.once('error', onError);
529
+ server.listen(cfg.port, '0.0.0.0', () => {
530
+ server.off('error', onError);
531
+ input.start();
532
+ syncTray();
533
+ syncTunnel();
534
+ config.writeRuntime({ pid: process.pid, port: cfg.port, mode, startedAt });
535
+ log(`Server listening on port ${cfg.port} (${mode} mode).`);
536
+ resolve({ port: cfg.port, pairCode: cfg.pairCode, addresses: getLanAddresses() });
537
+ });
538
+ });
539
+ }
540
+
541
+ function stop() {
542
+ if (stopping) return stopping;
543
+ stopping = new Promise((resolve) => {
544
+ clearInterval(heartbeat);
545
+ for (const ws of sockets.values()) ws.close(1001, 'Server stopped');
546
+ if (devicesDirty) persistDevices();
547
+ input.stop();
548
+ screen.stop();
549
+ tunnel.stop();
550
+ tray.stop();
551
+ config.clearRuntime();
552
+ wss.close();
553
+ server.close(() => resolve());
554
+ // koneksi keep-alive bisa menahan close; paksa setelah 1.5 detik
555
+ setTimeout(() => {
556
+ server.closeAllConnections?.();
557
+ resolve();
558
+ }, 1500).unref();
559
+ log('Server stopped.');
560
+ });
561
+ return stopping;
562
+ }
563
+
564
+ return { start, stop, getLanAddresses };
565
+ }
566
+
567
+ module.exports = { createServer, getLanAddresses };
package/src/startup.js ADDED
@@ -0,0 +1,74 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawn, execFileSync } = require('child_process');
4
+
5
+ // Start with Windows lewat entri registry standar milik user (tanpa hak admin):
6
+ // HKCU\Software\Microsoft\Windows\CurrentVersion\Run -> OwnRemote
7
+ // Nilainya menjalankan launcher statis src/launch-hidden.vbs (ikut di paket) supaya tidak muncul jendela console.
8
+ const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
9
+ const VALUE_NAME = 'OwnRemote';
10
+ const STARTUP_LOCATION = `${RUN_KEY}\\${VALUE_NAME}`;
11
+ const LAUNCHER = path.join(__dirname, 'launch-hidden.vbs');
12
+ const DAEMON = path.join(__dirname, 'daemon.js');
13
+ const WSCRIPT = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'wscript.exe');
14
+
15
+ // Versi lama menaruh launcher .vbs di folder Startup; dibersihkan & dipindahkan ke registry.
16
+ const STARTUP_DIR = path.join(process.env.APPDATA || '', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
17
+ const LEGACY_FILES = ['OwnRemote.vbs', 'RemoteCursor.vbs'].map((f) => path.join(STARTUP_DIR, f));
18
+
19
+ const quote = (s) => `"${s}"`;
20
+
21
+ function runCommand() {
22
+ return [WSCRIPT, LAUNCHER, process.execPath, DAEMON].map(quote).join(' ') + ' --startup';
23
+ }
24
+
25
+ function readRunValue() {
26
+ try {
27
+ const out = execFileSync('reg', ['query', RUN_KEY, '/v', VALUE_NAME], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
28
+ const m = out.match(new RegExp(`${VALUE_NAME}\\s+REG_\\w+\\s+(.*)`));
29
+ return m ? m[1].trim() : null;
30
+ } catch {
31
+ return null; // nilai belum ada
32
+ }
33
+ }
34
+
35
+ function isStartupEnabled() {
36
+ return readRunValue() !== null;
37
+ }
38
+
39
+ function enableStartup() {
40
+ execFileSync('reg', ['add', RUN_KEY, '/v', VALUE_NAME, '/t', 'REG_SZ', '/d', runCommand(), '/f'], { windowsHide: true, stdio: 'ignore' });
41
+ }
42
+
43
+ function disableStartup() {
44
+ try {
45
+ execFileSync('reg', ['delete', RUN_KEY, '/v', VALUE_NAME, '/f'], { windowsHide: true, stdio: 'ignore' });
46
+ } catch { /* sudah tidak ada */ }
47
+ }
48
+
49
+ // Dipanggil setiap CLI dibuka: migrasi dari versi lama, dan perbarui path bila node/aplikasi pindah lokasi.
50
+ function refreshStartup() {
51
+ let hadLegacy = false;
52
+ for (const f of LEGACY_FILES) {
53
+ if (fs.existsSync(f)) {
54
+ try { fs.unlinkSync(f); hadLegacy = true; } catch { /* abaikan */ }
55
+ }
56
+ }
57
+ const current = readRunValue();
58
+ if (hadLegacy || (current !== null && current !== runCommand())) enableStartup();
59
+ }
60
+
61
+ // Jalankan server di background: proses node terpisah tanpa console (DETACHED_PROCESS).
62
+ function launchBackground() {
63
+ const child = spawn(process.execPath, [DAEMON], { detached: true, stdio: 'ignore', windowsHide: true });
64
+ child.unref();
65
+ }
66
+
67
+ module.exports = {
68
+ STARTUP_LOCATION,
69
+ isStartupEnabled,
70
+ enableStartup,
71
+ disableStartup,
72
+ refreshStartup,
73
+ launchBackground,
74
+ };