ownremote 1.0.1 → 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 +24 -3
- package/package.json +1 -1
- package/public/app.js +355 -68
- package/public/index.html +63 -7
- package/public/style.css +69 -0
- package/src/server.js +15 -0
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
|
[](https://www.npmjs.com/package/ownremote)  [](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
|
-
```
|
|
66
|
-
npm
|
|
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
|
|
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
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
|
|
12
|
-
const
|
|
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
|
|
38
|
-
padScreen.classList.
|
|
39
|
-
|
|
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
|
-
|
|
47
|
-
padScreen.classList.remove('hidden');
|
|
125
|
+
showOnly(padScreen);
|
|
48
126
|
hideOverlay();
|
|
49
|
-
$('pcName').textContent =
|
|
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(
|
|
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 || '
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
retry = 0;
|
|
118
|
-
connect();
|
|
376
|
+
openSession(s);
|
|
119
377
|
} catch (err) {
|
|
120
|
-
fail(err instanceof TypeError
|
|
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 (!
|
|
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
|
|
147
|
-
const sock = new WebSocket(`${
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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(
|
|
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(
|
|
496
|
+
function stopWith(label, message) {
|
|
199
497
|
autoReconnect = false;
|
|
200
|
-
setConn('off',
|
|
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 (!
|
|
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
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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',
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
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
|
-
<!-- ============
|
|
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="
|
|
19
|
-
<
|
|
20
|
-
|
|
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">
|
|
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();
|