ownremote 1.0.0 → 1.0.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 CHANGED
@@ -7,6 +7,15 @@ over your Wi-Fi or from anywhere through Cloudflare Tunnel. No app to install on
7
7
 
8
8
  [![npm](https://img.shields.io/npm/v/ownremote?color=c6f45b&labelColor=0d0f14)](https://www.npmjs.com/package/ownremote) ![platform](https://img.shields.io/badge/platform-Windows-0d0f14?labelColor=0d0f14) [![license](https://img.shields.io/badge/license-MIT-0d0f14?labelColor=0d0f14)](https://github.com/Adytm404/ownremote/blob/main/LICENSE)
9
9
 
10
+ ### 🚀 Install in one line
11
+
12
+ ```bash
13
+ npm i -g ownremote
14
+ ```
15
+
16
+ Then just type **`ownremote`** in Command Prompt, PowerShell or Windows Terminal.<br>
17
+ <sub>Requires Windows 10/11 and Node.js 18+</sub>
18
+
10
19
  <a href="https://github.com/Adytm404/ownremote/blob/main/docs/ownremote-trailer.mp4"><img src="https://raw.githubusercontent.com/Adytm404/ownremote/main/docs/trailer-preview.gif" alt="OwnRemote trailer" width="820"></a>
11
20
 
12
21
  [▶ Watch the full trailer (48 s, MP4)](https://github.com/Adytm404/ownremote/blob/main/docs/ownremote-trailer.mp4)
@@ -22,6 +31,7 @@ over your Wi-Fi or from anywhere through Cloudflare Tunnel. No app to install on
22
31
  | 🖱️ **Trackpad** | Move, tap to click, two-finger tap for right click, two-finger scroll, hold-and-drag, physical-style Left / Middle / Right buttons. |
23
32
  | ⌨️ **Live keyboard** | Everything you type appears on the PC letter by letter, including emoji, accents, autocorrect and swipe typing. Sticky Ctrl / Alt / Shift / Win, arrows, Esc, Tab, F-keys, shortcuts (Ctrl+C/V/X/Z/A/S, Alt+Tab, Win+D) and media keys. |
24
33
  | 🖥️ **Screen preview** | Off by default. Turn it on to see your PC screen with a **virtual cursor** that sits exactly where the real one is (measured error ≈ 0.05 px), zoom 1× / 2× / 3× that follows the cursor, 1–15 fps, adjustable resolution and quality, and identical frames skipped so a still screen costs ≈ 0 KB/s. |
34
+ | 🗂️ **Several PCs** | The phone keeps a dashboard of every PC you paired, shows which ones are online, and lets you switch between them with one tap. |
25
35
  | 🧑‍💻 **CLI dashboard** | Numbered menus to run in the terminal or in the background, manage paired devices, change settings and set up the tunnel. |
26
36
  | 🪟 **Windows integration** | Background mode with a tray icon, start with Windows (with an optional delay), Windows Firewall helper. |
27
37
  | 🌍 **Cloudflare Tunnel** | Quick tunnel (random `*.trycloudflare.com` URL, no account) or a named tunnel on your own domain. |
@@ -62,13 +72,13 @@ over your Wi-Fi or from anywhere through Cloudflare Tunnel. No app to install on
62
72
 
63
73
  ## Quick start
64
74
 
65
- ```bat
66
- npm install -g ownremote
75
+ ```bash
76
+ npm i -g ownremote
67
77
  ownremote
68
78
  ```
69
79
 
70
80
  That's it: `ownremote` opens the dashboard from any Command Prompt, PowerShell or Windows Terminal window.
71
- Update later with `npm install -g ownremote@latest`.
81
+ Update later with `npm i -g ownremote@latest`.
72
82
 
73
83
  <details>
74
84
  <summary>Run from source instead</summary>
@@ -92,6 +102,16 @@ If the phone cannot open the page, use **Settings → Allow port in Windows Fire
92
102
 
93
103
  ## Using it
94
104
 
105
+ ### Your PCs (dashboard)
106
+
107
+ The first time you open the page it pairs with the PC that served it. After that, the phone opens a **Your PCs** dashboard:
108
+
109
+ - Each saved PC shows **Online**, **Offline** or **Needs pairing again** (when it was removed on that PC). Tap one to control it, and use the **‹** button on the trackpad (or ⚙ → **Switch PC**) to go back.
110
+ - **Add a PC** asks for that PC's address and pairing code, both shown in its OwnRemote CLI. `192.168.1.20` becomes `http://192.168.1.20:8787`; a tunnel address such as `calm-river.trycloudflare.com` uses HTTPS.
111
+ - The **⋮** button renames a PC on this phone, pairs it again or removes it (which also unpairs the phone on that PC).
112
+
113
+ Every PC needs OwnRemote running. The list is stored in the phone's browser, so one bookmarked page can reach all your PCs. A page opened over HTTPS (tunnel) cannot reach plain `http://` Wi-Fi addresses because browsers block it; open the dashboard from a Wi-Fi address to mix both.
114
+
95
115
  ### Trackpad
96
116
 
97
117
  | Gesture | Action |
@@ -161,6 +181,7 @@ OwnRemote starts and supervises `cloudflared` itself (restarting it with back-of
161
181
  - New devices need the 6-digit pairing code. After 5 wrong codes an IP is blocked for 60 s; after 20 wrong codes from any addresses within 10 minutes, pairing is blocked for 10 minutes (a new code lifts it).
162
182
  - Each phone gets its own random token; only a SHA-256 hash is stored. Revoke devices any time from *Session management*.
163
183
  - The admin API used by the CLI only answers on `127.0.0.1`, requires a secret token, and **rejects anything that arrives through Cloudflare or another proxy**.
184
+ - The phone API (`/api/pair`, `/api/check`, `/api/unpair`) allows cross-origin requests so one dashboard can talk to several PCs. It uses no cookies, so another website gains nothing: it would still need the pairing code (with the limits above) or a device token.
164
185
  - Behind the tunnel, the real client IP comes from `CF-Connecting-IP` (trusted only for connections from localhost).
165
186
  - On your LAN the connection is plain HTTP, so use it on networks you trust, or use the tunnel (HTTPS). When the PC is reachable from the internet, enable **Auto-lock pairing**.
166
187
  - The screen preview is off until the phone turns it on, the tray shows a notification when it starts, the CLI marks the device with `◉ screen`, and you can disable the feature entirely.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ownremote",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Control your Windows PC from your phone: trackpad, live keyboard and screen preview over LAN or Cloudflare Tunnel.",
5
5
  "keywords": [
6
6
  "remote",
package/public/app.js CHANGED
@@ -7,9 +7,17 @@
7
7
  set(k, v) { try { localStorage.setItem(k, v); } catch { /* mode privat */ } },
8
8
  del(k) { try { localStorage.removeItem(k); } catch { /* mode privat */ } },
9
9
  };
10
+ // per tab: reload saat sedang mengontrol PC kembali ke PC yang sama
11
+ const tabStore = {
12
+ get(k) { try { return sessionStorage.getItem(k); } catch { return null; } },
13
+ set(k, v) { try { sessionStorage.setItem(k, v); } catch { /* mode privat */ } },
14
+ del(k) { try { sessionStorage.removeItem(k); } catch { /* mode privat */ } },
15
+ };
10
16
 
11
- const TOKEN_KEY = 'rc_token';
12
- const PC_KEY = 'rc_pc';
17
+ const SESSIONS_KEY = 'or_sessions';
18
+ const CURRENT_KEY = 'or_current';
19
+ const LEGACY_TOKEN_KEY = 'rc_token';
20
+ const LEGACY_PC_KEY = 'rc_pc';
13
21
  const SETTINGS_KEY = 'rc_settings';
14
22
  const DEFAULTS = { sens: 1.6, accel: 0.8, scrollSpeed: 4, natural: true, haptic: true, screenFps: 5, screenWidth: 960, screenQuality: 50, screenZoom: 1 };
15
23
 
@@ -22,31 +30,108 @@
22
30
  let settings = { ...DEFAULTS };
23
31
  try { Object.assign(settings, JSON.parse(store.get(SETTINGS_KEY) || '{}')); } catch { /* default */ }
24
32
 
25
- let token = store.get(TOKEN_KEY);
26
33
  let ws = null;
27
34
  let retry = 0;
28
35
  let retryTimer = 0;
29
36
  let autoReconnect = true;
30
37
  let pongTimer = 0;
31
38
 
39
+ // ================================================================ daftar PC (sesi)
40
+ // Satu HP bisa menyimpan beberapa PC. Tiap PC = { id, url (origin), token, pc (hostname), label }.
41
+ // Halaman ini boleh dibuka dari PC mana pun; API & WebSocket PC lain dipanggil lintas origin.
42
+ const HERE = location.origin;
43
+ const DEFAULT_PORT = '8787';
44
+
45
+ const newId = () => Math.random().toString(36).slice(2, 10);
46
+ const hostOf = (url) => { try { return new URL(url).host; } catch { return url; } };
47
+ const titleOf = (s) => s.label || s.pc || hostOf(s.url);
48
+ // Halaman HTTPS (tunnel) tidak boleh memanggil alamat http:// (Wi-Fi) - diblokir browser
49
+ const blockedByHttps = (url) => location.protocol === 'https:' && url.startsWith('http:');
50
+
51
+ let sessions = loadSessions();
52
+ let active = null; // PC yang sedang dikontrol
53
+ let pairTarget = null; // { url, fixed, session } saat menambah / pairing ulang PC
54
+
55
+ function loadSessions() {
56
+ let list = [];
57
+ try { list = JSON.parse(store.get(SESSIONS_KEY) || '[]'); } catch { /* rusak: mulai kosong */ }
58
+ if (!Array.isArray(list)) list = [];
59
+ list = list.filter((s) => s && typeof s.url === 'string' && typeof s.token === 'string');
60
+ // Versi lama hanya menyimpan satu PC (PC yang membuka halaman ini)
61
+ const legacy = store.get(LEGACY_TOKEN_KEY);
62
+ if (legacy) {
63
+ if (!list.some((s) => s.url === HERE)) {
64
+ list.unshift({ id: newId(), url: HERE, token: legacy, pc: store.get(LEGACY_PC_KEY) || '', label: '', used: Date.now() });
65
+ }
66
+ store.set(SESSIONS_KEY, JSON.stringify(list));
67
+ store.del(LEGACY_TOKEN_KEY);
68
+ store.del(LEGACY_PC_KEY);
69
+ }
70
+ return list;
71
+ }
72
+
73
+ const saveSessions = () => store.set(SESSIONS_KEY, JSON.stringify(sessions));
74
+ const findSession = (id) => sessions.find((s) => s.id === id) || null;
75
+
76
+ // "192.168.1.20" -> http://192.168.1.20:8787, "abc.trycloudflare.com" -> https://abc.trycloudflare.com
77
+ function normalizeUrl(raw) {
78
+ let text = String(raw || '').trim().replace(/\/+$/, '');
79
+ if (!text) throw new Error('Enter the address shown on your PC.');
80
+ const hasScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(text);
81
+ let local = false;
82
+ if (!hasScheme) {
83
+ const host = text.split(/[/:]/)[0];
84
+ local = /^(localhost|\d{1,3}(\.\d{1,3}){3})$/i.test(host) || /\.(local|lan|home)$/i.test(host) || !host.includes('.');
85
+ text = `${local ? 'http' : 'https'}://${text}`;
86
+ }
87
+ let u;
88
+ try { u = new URL(text); } catch { throw new Error("That doesn't look like an address."); }
89
+ if (!/^https?:$/.test(u.protocol) || !u.hostname) throw new Error("That doesn't look like an address.");
90
+ if (local && !u.port) u.port = DEFAULT_PORT;
91
+ return u.origin;
92
+ }
93
+
32
94
  // ================================================================ layar
95
+ const homeScreen = $('homeScreen');
33
96
  const pairScreen = $('pairScreen');
34
97
  const padScreen = $('padScreen');
35
98
  const overlay = $('overlay');
36
99
 
37
- function showPair(message = '') {
38
- padScreen.classList.add('hidden');
39
- pairScreen.classList.remove('hidden');
100
+ function showOnly(el) {
101
+ [homeScreen, pairScreen, padScreen].forEach((s) => s.classList.toggle('hidden', s !== el));
102
+ }
103
+
104
+ // Pairing PC baru (url kosong = isi alamat sendiri) atau pairing ulang PC yang sudah ada
105
+ function showPair({ url = '', session = null, message = '' } = {}) {
106
+ const target = session ? session.url : url;
107
+ pairTarget = { url: target, fixed: Boolean(target), session };
108
+ showOnly(pairScreen);
109
+ const isHere = target === HERE;
110
+ $('pairTitle').textContent = session ? `Pair ${titleOf(session)} again` : target ? 'Connect to your PC' : 'Add a PC';
111
+ $('pairSub').textContent = target && !isHere
112
+ ? `Enter the 6-digit code shown on ${hostOf(target)}.`
113
+ : target ? 'Enter the 6-digit code shown on your PC.'
114
+ : 'Enter the address and 6-digit code shown in OwnRemote on that PC.';
115
+ $('addrField').classList.toggle('hidden', Boolean(target));
116
+ $('pairBackBtn').classList.toggle('hidden', !sessions.length && isHere);
40
117
  $('pairError').textContent = message;
118
+ $('addrInput').value = '';
41
119
  codeInput.value = '';
42
120
  renderSlots();
121
+ if (!target) setTimeout(() => $('addrInput').focus(), 50);
43
122
  }
44
123
 
45
124
  function showPad() {
46
- pairScreen.classList.add('hidden');
47
- padScreen.classList.remove('hidden');
125
+ showOnly(padScreen);
48
126
  hideOverlay();
49
- $('pcName').textContent = store.get(PC_KEY) || 'PC';
127
+ $('pcName').textContent = active ? titleOf(active) : 'PC';
128
+ }
129
+
130
+ function showHome() {
131
+ showOnly(homeScreen);
132
+ closePcSheet();
133
+ renderHome();
134
+ refreshStatuses();
50
135
  }
51
136
 
52
137
  function setConn(state, text) {
@@ -62,6 +147,158 @@
62
147
 
63
148
  const hideOverlay = () => overlay.classList.add('hidden');
64
149
 
150
+ // ================================================================ dashboard
151
+ const MONITOR_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 3H3a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h7v2H8v2h8v-2h-2v-2h7a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 13H3V5h18z"/></svg>';
152
+ const STATUS_TEXT = {
153
+ checking: 'Checking…',
154
+ online: 'Online',
155
+ offline: 'Offline',
156
+ unpaired: 'Needs pairing again',
157
+ blocked: 'Open the Wi-Fi address to reach it',
158
+ };
159
+ const status = new Map(); // id -> status
160
+ const sessionList = $('sessionList');
161
+
162
+ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
163
+
164
+ function renderHome() {
165
+ const list = [...sessions].sort((a, b) => (b.used || 0) - (a.used || 0));
166
+ const rows = list.map((s) => {
167
+ const st = status.get(s.id) || 'checking';
168
+ return `<li class="pc-card">
169
+ <button class="pc-main" data-connect="${s.id}">
170
+ <span class="pc-ico">${MONITOR_SVG}</span>
171
+ <span class="pc-text">
172
+ <strong>${escapeHtml(titleOf(s))}</strong>
173
+ <small>${escapeHtml(hostOf(s.url))}${s.url === HERE ? ' · this page' : ''}</small>
174
+ </span>
175
+ <span class="pc-status ${st}"><i></i>${STATUS_TEXT[st]}</span>
176
+ </button>
177
+ <button class="pc-more" data-more="${s.id}" aria-label="Options for ${escapeHtml(titleOf(s))}">
178
+ <svg viewBox="0 0 24 24"><path d="M12 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/></svg>
179
+ </button>
180
+ </li>`;
181
+ });
182
+ // PC yang menyajikan halaman ini belum dipasangkan: tawarkan langsung
183
+ if (!sessions.some((s) => s.url === HERE)) {
184
+ rows.unshift(`<li class="pc-card new">
185
+ <button class="pc-main" data-pair-here>
186
+ <span class="pc-ico">${MONITOR_SVG}</span>
187
+ <span class="pc-text"><strong>This PC</strong><small>${escapeHtml(location.host)} · this page</small></span>
188
+ <span class="pc-status pair"><i></i>Tap to pair</span>
189
+ </button>
190
+ </li>`);
191
+ }
192
+ sessionList.innerHTML = rows.join('');
193
+ const n = sessions.length;
194
+ $('homeSub').textContent = n ? `${n} PC${n > 1 ? 's' : ''} saved on this phone. Pick one to control.` : 'No PCs yet. Pair this PC or add another one.';
195
+ }
196
+
197
+ async function checkSession(s) {
198
+ if (blockedByHttps(s.url)) return 'blocked';
199
+ const ctrl = new AbortController();
200
+ const timer = setTimeout(() => ctrl.abort(), 4000);
201
+ try {
202
+ const res = await fetch(`${s.url}/api/check`, { headers: { authorization: `Bearer ${s.token}` }, signal: ctrl.signal, cache: 'no-store' });
203
+ if (res.status === 401) return 'unpaired';
204
+ if (!res.ok) return 'offline';
205
+ const data = await res.json().catch(() => ({}));
206
+ if (data.pc && data.pc !== s.pc) { s.pc = data.pc; saveSessions(); }
207
+ return 'online';
208
+ } catch {
209
+ return 'offline';
210
+ } finally {
211
+ clearTimeout(timer);
212
+ }
213
+ }
214
+
215
+ let refreshing = 0;
216
+ function refreshStatuses() {
217
+ const run = ++refreshing;
218
+ sessions.forEach((s) => status.set(s.id, 'checking'));
219
+ renderHome();
220
+ sessions.forEach(async (s) => {
221
+ const st = await checkSession(s);
222
+ if (run !== refreshing || homeScreen.classList.contains('hidden')) return;
223
+ status.set(s.id, st);
224
+ renderHome();
225
+ });
226
+ }
227
+
228
+ sessionList.addEventListener('click', (e) => {
229
+ const btn = e.target.closest('button');
230
+ if (!btn) return;
231
+ if (btn.hasAttribute('data-pair-here')) return showPair({ url: HERE });
232
+ const s = findSession(btn.dataset.connect || btn.dataset.more);
233
+ if (!s) return;
234
+ if (btn.dataset.more) return openPcSheet(s);
235
+ if (status.get(s.id) === 'unpaired') return showPair({ session: s });
236
+ openSession(s);
237
+ });
238
+
239
+ $('addPcBtn').addEventListener('click', () => showPair());
240
+ $('refreshBtn').addEventListener('click', refreshStatuses);
241
+ $('pairBackBtn').addEventListener('click', showHome);
242
+
243
+ // ---- aksi satu PC (ganti nama, pairing ulang, hapus)
244
+ const pcSheet = $('pcSheet');
245
+ let sheetSession = null;
246
+
247
+ function openPcSheet(s) {
248
+ sheetSession = s;
249
+ $('pcSheetTitle').textContent = titleOf(s);
250
+ $('pcSheetUrl').textContent = `${s.url}${s.pc ? ` · ${s.pc}` : ''}`;
251
+ $('pcLabel').value = s.label || '';
252
+ $('pcLabel').placeholder = s.pc || 'e.g. Office laptop';
253
+ pcSheet.classList.remove('hidden');
254
+ pcSheet.setAttribute('aria-hidden', 'false');
255
+ }
256
+
257
+ function closePcSheet() {
258
+ if (sheetSession) {
259
+ const label = $('pcLabel').value.trim().slice(0, 40);
260
+ if (label !== (sheetSession.label || '')) {
261
+ sheetSession.label = label;
262
+ saveSessions();
263
+ renderHome();
264
+ }
265
+ }
266
+ sheetSession = null;
267
+ pcSheet.classList.add('hidden');
268
+ pcSheet.setAttribute('aria-hidden', 'true');
269
+ }
270
+
271
+ pcSheet.querySelector('[data-close]').addEventListener('click', closePcSheet);
272
+ $('pcLabel').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); closePcSheet(); } });
273
+ $('pcConnectBtn').addEventListener('click', () => {
274
+ const s = sheetSession;
275
+ closePcSheet();
276
+ if (s) openSession(s);
277
+ });
278
+ $('pcRepairBtn').addEventListener('click', () => {
279
+ const s = sheetSession;
280
+ closePcSheet();
281
+ if (s) showPair({ session: s });
282
+ });
283
+ $('pcRemoveBtn').addEventListener('click', () => {
284
+ const s = sheetSession;
285
+ if (!s || !confirm(`Remove ${titleOf(s)} from this phone? You will need its pairing code to connect again.`)) return;
286
+ closePcSheet();
287
+ removeSession(s);
288
+ renderHome();
289
+ });
290
+
291
+ // Hapus dari HP dan (bila terjangkau) dari daftar perangkat di PC tersebut
292
+ function removeSession(s) {
293
+ sessions = sessions.filter((x) => x.id !== s.id);
294
+ status.delete(s.id);
295
+ saveSessions();
296
+ if (!blockedByHttps(s.url)) {
297
+ fetch(`${s.url}/api/unpair`, { method: 'POST', headers: { authorization: `Bearer ${s.token}` } })
298
+ .catch(() => { /* PC tidak terjangkau, cukup hapus lokal */ });
299
+ }
300
+ }
301
+
65
302
  // ================================================================ pairing
66
303
  const codeInput = $('codeInput');
67
304
  const slots = [...document.querySelectorAll('.code-slots i')];
@@ -85,6 +322,10 @@
85
322
  });
86
323
  codeInput.addEventListener('focus', renderSlots);
87
324
  codeInput.addEventListener('blur', renderSlots);
325
+ $('addrInput').addEventListener('input', () => { $('pairError').textContent = ''; });
326
+ $('addrInput').addEventListener('keydown', (e) => {
327
+ if (e.key === 'Enter') { e.preventDefault(); codeInput.focus(); }
328
+ });
88
329
 
89
330
  $('pairForm').addEventListener('submit', (e) => {
90
331
  e.preventDefault();
@@ -92,32 +333,52 @@
92
333
  });
93
334
 
94
335
  async function pair() {
95
- if (pairing) return;
336
+ if (pairing || !pairTarget) return;
337
+ let url = pairTarget.url;
338
+ if (!pairTarget.fixed) {
339
+ try {
340
+ url = normalizeUrl($('addrInput').value);
341
+ } catch (err) {
342
+ $('pairError').textContent = err.message;
343
+ return;
344
+ }
345
+ }
96
346
  const code = codeInput.value;
97
347
  if (code.length !== 6) {
98
348
  fail('The code has 6 digits.');
99
349
  return;
100
350
  }
351
+ if (blockedByHttps(url)) {
352
+ fail(`This page is open over HTTPS, so your browser blocks the Wi-Fi address ${hostOf(url)}. Open OwnRemote from a Wi-Fi address (http://…) instead, or use that PC's tunnel address.`);
353
+ return;
354
+ }
101
355
  pairing = true;
102
356
  $('pairBtn').disabled = true;
103
357
  $('pairBtn').textContent = 'Connecting…';
104
358
  try {
105
- const res = await fetch('/api/pair', {
359
+ const res = await fetch(`${url}/api/pair`, {
106
360
  method: 'POST',
107
361
  headers: { 'content-type': 'application/json' },
108
362
  body: JSON.stringify({ code, name: $('nameInput').value.trim() }),
109
363
  });
110
364
  const data = await res.json().catch(() => ({}));
111
- if (!res.ok) throw new Error(data.error || 'Gagal terhubung.');
112
- token = data.token;
113
- store.set(TOKEN_KEY, token);
114
- store.set(PC_KEY, data.pc);
365
+ if (!res.ok) throw new Error(data.error || 'Could not connect.');
366
+ // PC yang sama (alamat sama) cukup diperbarui, bukan diduplikasi
367
+ let s = pairTarget.session || sessions.find((x) => x.url === url);
368
+ if (!s) {
369
+ s = { id: newId(), url, label: '' };
370
+ sessions.push(s);
371
+ }
372
+ Object.assign(s, { token: data.token, pc: data.pc || s.pc || '' });
373
+ saveSessions();
374
+ status.set(s.id, 'online');
115
375
  codeInput.blur();
116
- showPad();
117
- retry = 0;
118
- connect();
376
+ openSession(s);
119
377
  } catch (err) {
120
- fail(err instanceof TypeError ? "Can't reach your PC. Make sure your phone is on the same Wi-Fi." : err.message);
378
+ fail(err instanceof TypeError
379
+ ? (url === HERE ? "Can't reach your PC. Make sure your phone is on the same Wi-Fi."
380
+ : `Can't reach ${hostOf(url)}. Check the address, make sure OwnRemote is running there and your phone is on the same Wi-Fi.`)
381
+ : err.message);
121
382
  } finally {
122
383
  pairing = false;
123
384
  $('pairBtn').disabled = false;
@@ -135,16 +396,51 @@
135
396
  }
136
397
  }
137
398
 
399
+ // ================================================================ pilih / tinggalkan PC
400
+ function openSession(s) {
401
+ leaveSession({ quiet: true });
402
+ active = s;
403
+ s.used = Date.now();
404
+ saveSessions();
405
+ tabStore.set(CURRENT_KEY, s.id);
406
+ applyNetworkDefaults(s.url);
407
+ $('deviceInfo').textContent = '';
408
+ setConn('', 'Connecting…');
409
+ showPad();
410
+ retry = 0;
411
+ connect();
412
+ }
413
+
414
+ // Putus dari PC aktif. quiet = langsung pindah ke PC lain tanpa membuka dashboard.
415
+ function leaveSession({ quiet = false } = {}) {
416
+ autoReconnect = false;
417
+ clearTimeout(retryTimer);
418
+ clearTimeout(pongTimer);
419
+ if (screenOn) setScreen(false);
420
+ const sock = ws;
421
+ ws = null;
422
+ if (sock) sock.close(1000, 'Switched PC');
423
+ resetInputState();
424
+ active = null;
425
+ tabStore.del(CURRENT_KEY);
426
+ hideOverlay();
427
+ closeSheet();
428
+ if (!quiet) showHome();
429
+ }
430
+
431
+ $('homeBtn').addEventListener('click', () => leaveSession());
432
+ $('overlayHomeBtn').addEventListener('click', () => leaveSession());
433
+
138
434
  // ================================================================ koneksi
139
435
  function connect() {
140
436
  clearTimeout(retryTimer);
141
- if (!token) return showPair();
437
+ if (!active) return showHome();
142
438
  if (ws && ws.readyState <= 1) return;
143
439
  autoReconnect = true;
144
440
  setConn('', 'Connecting…');
145
441
 
146
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
147
- const sock = new WebSocket(`${proto}://${location.host}/ws?token=${encodeURIComponent(token)}`);
442
+ const target = active;
443
+ const sock = new WebSocket(`${target.url.replace(/^http/, 'ws')}/ws?token=${encodeURIComponent(target.token)}`);
148
444
  ws = sock;
149
445
  sock.binaryType = 'arraybuffer'; // frame layar dikirim sebagai JPEG biner
150
446
 
@@ -163,8 +459,8 @@
163
459
  let msg;
164
460
  try { msg = JSON.parse(e.data); } catch { return; }
165
461
  if (msg.t === 'hello') {
166
- $('pcName').textContent = msg.pc;
167
- store.set(PC_KEY, msg.pc);
462
+ if (target.pc !== msg.pc) { target.pc = msg.pc; saveSessions(); }
463
+ $('pcName').textContent = titleOf(target);
168
464
  $('deviceInfo').textContent = `This device is paired as "${msg.name}".`;
169
465
  } else if (msg.t === 'pong') {
170
466
  clearTimeout(pongTimer);
@@ -180,8 +476,10 @@
180
476
  resetInputState();
181
477
 
182
478
  if (e.code === 4001) {
183
- forgetLocal();
184
- showPair('This device was removed on the PC. Enter the code again.');
479
+ active = null;
480
+ tabStore.del(CURRENT_KEY);
481
+ status.set(target.id, 'unpaired');
482
+ showPair({ session: target, message: 'This phone was removed on that PC. Enter its code again.' });
185
483
  return;
186
484
  }
187
485
  if (e.code === 4003) return stopWith('Disconnected', 'Disconnected from the PC.');
@@ -189,15 +487,15 @@
189
487
  if (!autoReconnect) return stopWith('Disconnected', 'Connection closed.');
190
488
 
191
489
  setConn('off', 'Disconnected, retrying…');
192
- if (retry >= 3) showOverlay("Can't connect to your PC. Make sure OwnRemote is running and your phone is on the same Wi-Fi.\nRetrying…");
490
+ if (retry >= 3) showOverlay(`Can't connect to ${titleOf(target)}. Make sure OwnRemote is running and your phone is on the same Wi-Fi.\nRetrying…`);
193
491
  const delay = Math.min(10_000, 500 * 2 ** retry++);
194
492
  retryTimer = setTimeout(connect, delay);
195
493
  };
196
494
  }
197
495
 
198
- function stopWith(status, message) {
496
+ function stopWith(label, message) {
199
497
  autoReconnect = false;
200
- setConn('off', status);
498
+ setConn('off', label);
201
499
  showOverlay(message);
202
500
  }
203
501
 
@@ -205,11 +503,6 @@
205
503
  if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
206
504
  }
207
505
 
208
- function forgetLocal() {
209
- token = null;
210
- store.del(TOKEN_KEY);
211
- }
212
-
213
506
  $('reconnectBtn').addEventListener('click', () => {
214
507
  hideOverlay();
215
508
  retry = 0;
@@ -223,7 +516,7 @@
223
516
  if (screenOn) send({ t: 'screen', on: false });
224
517
  return;
225
518
  }
226
- if (!token || padScreen.classList.contains('hidden')) return;
519
+ if (!active || padScreen.classList.contains('hidden')) return;
227
520
  if (ws && ws.readyState === 1) {
228
521
  send({ t: 'ping' });
229
522
  clearTimeout(pongTimer);
@@ -480,10 +773,15 @@
480
773
  }
481
774
  delete settings.screenPreset;
482
775
  // Dibuka lewat internet (Cloudflare Tunnel) dan belum pernah diatur: mulai dari preset Hemat supaya hemat kuota
483
- const viaInternet = !/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.)/.test(location.hostname);
484
- let screenStored = false;
485
- try { screenStored = 'screenFps' in JSON.parse(store.get(SETTINGS_KEY) || '{}'); } catch { /* default */ }
486
- if (viaInternet && !screenStored) Object.assign(settings, { screenFps: 2, screenWidth: 640, screenQuality: 40 });
776
+ const isInternet = (url) => !/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.)/.test(new URL(url).hostname);
777
+ function applyNetworkDefaults(url) {
778
+ let stored = false;
779
+ try { stored = 'screenFps' in JSON.parse(store.get(SETTINGS_KEY) || '{}'); } catch { /* default */ }
780
+ if (stored) return;
781
+ const p = isInternet(url) ? SCREEN_PRESETS.saver : SCREEN_PRESETS.normal;
782
+ Object.assign(settings, { screenFps: p.fps, screenWidth: p.width, screenQuality: p.quality });
783
+ renderScreenSettings();
784
+ }
487
785
  if (!ZOOMS.includes(settings.screenZoom)) settings.screenZoom = 1;
488
786
 
489
787
  function presetName() {
@@ -916,42 +1214,31 @@
916
1214
  else document.documentElement.requestFullscreen().catch(() => {});
917
1215
  });
918
1216
 
919
- $('disconnectBtn').addEventListener('click', () => {
920
- closeSheet();
921
- autoReconnect = false;
922
- clearTimeout(retryTimer);
923
- if (ws) ws.close(1000, 'Diputus dari HP');
924
- else stopWith('Disconnected', 'Connection closed.');
925
- });
1217
+ $('disconnectBtn').addEventListener('click', () => leaveSession());
926
1218
 
927
- $('forgetBtn').addEventListener('click', async () => {
928
- if (!confirm('Forget this PC? You will need the pairing code to connect again.')) return;
929
- closeSheet();
930
- autoReconnect = false;
931
- clearTimeout(retryTimer);
932
- const old = token;
933
- forgetLocal();
934
- try {
935
- await fetch('/api/unpair', { method: 'POST', headers: { authorization: `Bearer ${old}` } });
936
- } catch { /* PC tidak terjangkau, cukup hapus lokal */ }
937
- if (ws) ws.close(1000);
938
- hideOverlay();
939
- showPair();
1219
+ $('forgetBtn').addEventListener('click', () => {
1220
+ const s = active;
1221
+ if (!s || !confirm(`Forget ${titleOf(s)}? You will need its pairing code to connect again.`)) return;
1222
+ removeSession(s); // unpair dulu selagi koneksi masih hidup
1223
+ leaveSession();
940
1224
  });
941
1225
 
942
1226
  // ================================================================ mulai
943
1227
  const hashCode = (location.hash.match(/code=(\d{6})/) || [])[1];
944
1228
  if (location.hash) history.replaceState(null, '', location.pathname);
945
1229
 
946
- if (token) {
947
- showPad();
948
- connect();
1230
+ const resume = findSession(tabStore.get(CURRENT_KEY));
1231
+ if (hashCode) {
1232
+ // QR dari CLI membawa kode pairing PC ini
1233
+ showPair({ url: HERE });
1234
+ codeInput.value = hashCode;
1235
+ renderSlots();
1236
+ pair();
1237
+ } else if (resume) {
1238
+ openSession(resume);
1239
+ } else if (!sessions.length) {
1240
+ showPair({ url: HERE });
949
1241
  } else {
950
- showPair();
951
- if (hashCode) {
952
- codeInput.value = hashCode;
953
- renderSlots();
954
- pair();
955
- }
1242
+ showHome();
956
1243
  }
957
1244
  })();
package/public/index.html CHANGED
@@ -12,17 +12,69 @@
12
12
  <link rel="stylesheet" href="style.css">
13
13
  </head>
14
14
  <body>
15
- <!-- ============ Layar pairing ============ -->
15
+ <!-- ============ Dashboard: daftar PC yang tersimpan ============ -->
16
+ <main id="homeScreen" class="screen home hidden">
17
+ <div class="home-wrap">
18
+ <header class="home-head">
19
+ <div class="brand">
20
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3l14 7-6 2-2 6z"/></svg>
21
+ <span>OwnRemote</span>
22
+ </div>
23
+ <button id="refreshBtn" class="icon-btn" aria-label="Refresh status">
24
+ <svg viewBox="0 0 24 24"><path d="M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 1 0 7.73 10h-2.08A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h7V4z"/></svg>
25
+ </button>
26
+ </header>
27
+ <h1>Your PCs</h1>
28
+ <p class="muted" id="homeSub">Pick a PC to control.</p>
29
+
30
+ <ul id="sessionList" class="session-list"></ul>
31
+
32
+ <button id="addPcBtn" class="add-pc">
33
+ <span>+</span> Add a PC
34
+ </button>
35
+ <p class="muted small home-tip">Each PC runs OwnRemote and shows its own address and pairing code in the CLI.</p>
36
+ </div>
37
+
38
+ <!-- aksi untuk satu PC -->
39
+ <div id="pcSheet" class="sheet hidden" aria-hidden="true">
40
+ <div class="sheet-backdrop" data-close></div>
41
+ <div class="sheet-panel" role="dialog" aria-labelledby="pcSheetTitle">
42
+ <div class="sheet-grip"></div>
43
+ <h2 id="pcSheetTitle">PC</h2>
44
+ <p class="muted small" id="pcSheetUrl"></p>
45
+ <label class="setting">
46
+ <span>Name on this phone</span>
47
+ <input id="pcLabel" class="text-input" maxlength="40" placeholder="e.g. Office laptop">
48
+ </label>
49
+ <div class="sheet-actions">
50
+ <button id="pcConnectBtn" class="ghost">Connect</button>
51
+ <button id="pcRepairBtn" class="ghost">Pair again</button>
52
+ <button id="pcRemoveBtn" class="danger">Remove this PC</button>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ </main>
57
+
58
+ <!-- ============ Layar pairing (tambah PC) ============ -->
16
59
  <main id="pairScreen" class="screen pair hidden">
17
60
  <div class="pair-card">
18
- <div class="brand">
19
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3l14 7-6 2-2 6z"/></svg>
20
- <span>OwnRemote</span>
61
+ <div class="pair-top">
62
+ <button id="pairBackBtn" class="back-btn hidden" type="button" aria-label="Back to your PCs">
63
+ <svg viewBox="0 0 24 24"><path d="M15.4 7.4 14 6l-6 6 6 6 1.4-1.4L10.8 12z"/></svg>
64
+ </button>
65
+ <div class="brand">
66
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3l14 7-6 2-2 6z"/></svg>
67
+ <span>OwnRemote</span>
68
+ </div>
21
69
  </div>
22
- <h1>Connect to your PC</h1>
23
- <p class="muted">Enter the 6-digit code shown on your PC.</p>
70
+ <h1 id="pairTitle">Connect to your PC</h1>
71
+ <p class="muted" id="pairSub">Enter the 6-digit code shown on your PC.</p>
24
72
 
25
73
  <form id="pairForm" autocomplete="off">
74
+ <label id="addrField" class="addr-field hidden">
75
+ <span class="field-label">PC address</span>
76
+ <input id="addrInput" class="text-input" inputmode="url" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="192.168.1.20:8787 or https://….trycloudflare.com">
77
+ </label>
26
78
  <label class="code-field" for="codeInput">
27
79
  <input id="codeInput" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" aria-label="Pairing code">
28
80
  <span class="code-slots" aria-hidden="true">
@@ -40,6 +92,9 @@
40
92
  <main id="padScreen" class="screen pad-screen hidden">
41
93
  <header class="topbar">
42
94
  <div class="conn">
95
+ <button id="homeBtn" class="back-btn" aria-label="Your PCs">
96
+ <svg viewBox="0 0 24 24"><path d="M15.4 7.4 14 6l-6 6 6 6 1.4-1.4L10.8 12z"/></svg>
97
+ </button>
43
98
  <i id="connDot" class="dot"></i>
44
99
  <div>
45
100
  <strong id="pcName">PC</strong>
@@ -146,6 +201,7 @@
146
201
  <div class="overlay-card">
147
202
  <p id="overlayText">Disconnected</p>
148
203
  <button id="reconnectBtn" class="primary">Reconnect</button>
204
+ <button id="overlayHomeBtn" class="ghost overlay-home">Your PCs</button>
149
205
  </div>
150
206
  </div>
151
207
 
@@ -198,7 +254,7 @@
198
254
 
199
255
  <div class="sheet-actions">
200
256
  <button id="fullscreenBtn" class="ghost">Full screen</button>
201
- <button id="disconnectBtn" class="ghost">Disconnect</button>
257
+ <button id="disconnectBtn" class="ghost">Switch PC</button>
202
258
  <button id="forgetBtn" class="danger">Forget this PC</button>
203
259
  </div>
204
260
  <p class="muted small" id="deviceInfo"></p>
package/public/style.css CHANGED
@@ -316,3 +316,72 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
316
316
  .kb { width: 100%; }
317
317
  .kb-open .touchpad { flex-basis: 100%; height: auto; min-height: 60px; }
318
318
  }
319
+
320
+ /* ---------------- dashboard (daftar PC) ---------------- */
321
+ .home {
322
+ overflow-y: auto;
323
+ padding: calc(16px + var(--safe-t)) 16px calc(24px + var(--safe-b));
324
+ background:
325
+ radial-gradient(600px 400px at 50% -10%, rgba(198, 244, 91, .10), transparent 70%),
326
+ var(--bg);
327
+ }
328
+ .home-wrap { width: 100%; max-width: 480px; margin: 0 auto; }
329
+ .home-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; }
330
+ .home-head .brand { margin: 0; }
331
+ .home h1 { font-size: 28px; margin: 0 0 6px; letter-spacing: -.01em; }
332
+ .home h1 + p { margin: 0 0 20px; line-height: 1.5; }
333
+
334
+ .session-list { list-style: none; margin: 0 0 14px; padding: 0; display: grid; gap: 10px; }
335
+ .pc-card {
336
+ display: flex; align-items: stretch;
337
+ background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
338
+ overflow: hidden;
339
+ }
340
+ .pc-card.new { border-style: dashed; }
341
+ .pc-main {
342
+ flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px;
343
+ padding: 14px; background: none; text-align: left;
344
+ }
345
+ .pc-main:active { background: var(--surface-2); }
346
+ .pc-ico { width: 42px; height: 42px; border-radius: 12px; background: var(--surface-2); display: grid; place-items: center; flex: none; }
347
+ .pc-ico svg { width: 22px; height: 22px; fill: var(--accent); }
348
+ .pc-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
349
+ .pc-text strong, .pc-text small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
350
+ .pc-text strong { font-size: 16px; }
351
+ .pc-text small { color: var(--muted); font-size: 12px; }
352
+ .pc-status { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); flex: none; max-width: 40%; text-align: right; }
353
+ .pc-status i { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
354
+ .pc-status.online { color: var(--ok); }
355
+ .pc-status.online i { background: var(--ok); box-shadow: 0 0 0 3px rgba(74, 222, 128, .15); }
356
+ .pc-status.offline i { background: var(--danger); }
357
+ .pc-status.unpaired, .pc-status.blocked { color: var(--warn); }
358
+ .pc-status.unpaired i, .pc-status.blocked i { background: var(--warn); }
359
+ .pc-status.pair { color: var(--accent); }
360
+ .pc-status.pair i { background: var(--accent); }
361
+ .pc-status.checking i { animation: blink 1s infinite alternate; }
362
+ @keyframes blink { to { opacity: .25; } }
363
+ .pc-more { width: 44px; flex: none; background: none; border-left: 1px solid var(--line); display: grid; place-items: center; }
364
+ .pc-more svg { width: 20px; height: 20px; fill: var(--muted); }
365
+ .pc-more:active { background: var(--surface-2); }
366
+
367
+ .add-pc {
368
+ width: 100%; height: 54px; border-radius: 14px;
369
+ background: var(--accent); color: var(--accent-ink);
370
+ font-weight: 700; font-size: 16px;
371
+ display: flex; align-items: center; justify-content: center; gap: 8px;
372
+ }
373
+ .add-pc span { font-size: 22px; line-height: 1; }
374
+ .add-pc:active { transform: scale(.98); }
375
+ .home-tip { margin: 14px 0 0; line-height: 1.5; text-align: center; }
376
+ .home .sheet { position: fixed; }
377
+ .home .sheet .text-input { margin-bottom: 0; }
378
+
379
+ /* tombol kembali (pairing & trackpad) */
380
+ .pair-top { display: flex; align-items: center; gap: 10px; margin-bottom: 36px; }
381
+ .pair-top .brand { margin: 0; }
382
+ .back-btn { width: 36px; height: 36px; border-radius: 10px; background: var(--surface); display: grid; place-items: center; flex: none; }
383
+ .back-btn svg { width: 22px; height: 22px; fill: var(--muted); }
384
+ .back-btn:active { background: var(--surface-2); }
385
+ .field-label { display: block; font-size: 13px; color: var(--muted); margin: 0 0 8px; }
386
+ .addr-field { display: block; }
387
+ .overlay-home { width: 100%; margin-top: 10px; }
package/src/server.js CHANGED
@@ -238,6 +238,21 @@ function createServer({ mode = 'foreground', log = () => {}, onShutdown = () =>
238
238
  app.use(express.json({ limit: '8kb' }));
239
239
  app.use(express.static(path.join(config.ROOT, 'public'), { index: 'index.html' }));
240
240
 
241
+ // Dashboard di HP bisa menyimpan beberapa PC: halaman dari PC A memanggil API PC B.
242
+ // Aman dibuka lintas origin karena tidak memakai cookie; semua akses butuh kode pairing atau token.
243
+ app.use('/api', (req, res, next) => {
244
+ res.set({
245
+ 'access-control-allow-origin': '*',
246
+ 'access-control-allow-methods': 'GET, POST, OPTIONS',
247
+ 'access-control-allow-headers': 'content-type, authorization',
248
+ 'access-control-max-age': '600',
249
+ });
250
+ if (req.method !== 'OPTIONS') return next();
251
+ // Chrome Private Network Access: halaman tunnel (publik) -> PC di Wi-Fi (privat)
252
+ if (req.get('access-control-request-private-network')) res.set('access-control-allow-private-network', 'true');
253
+ res.sendStatus(204);
254
+ });
255
+
241
256
  app.post('/api/pair', (req, res) => {
242
257
  const ip = clientIp(req);
243
258
  const now = Date.now();