@bakapiano/ccsm 0.17.11 → 0.18.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/lib/devices.js +215 -0
- package/lib/tunnel.js +253 -0
- package/package.json +1 -1
- package/public/css/layout.css +7 -0
- package/public/css/responsive.css +123 -3
- package/public/css/terminals.css +15 -1
- package/public/css/wco.css +14 -13
- package/public/css/widgets.css +276 -2
- package/public/js/api.js +43 -2
- package/public/js/backend.js +66 -10
- package/public/js/components/App.js +38 -2
- package/public/js/components/HealthOverlay.js +12 -0
- package/public/js/components/MobileNavFab.js +29 -0
- package/public/js/components/PendingApprovalOverlay.js +86 -0
- package/public/js/components/Sidebar.js +13 -4
- package/public/js/components/TerminalView.js +19 -3
- package/public/js/icons.js +24 -0
- package/public/js/main.js +94 -11
- package/public/js/pages/RemotePage.js +446 -0
- package/public/js/state.js +10 -0
- package/scripts/dev.js +11 -0
- package/server.js +214 -8
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Full-screen blocker shown on the remote browser while waiting for
|
|
2
|
+
// the host to approve this device. Triggered by api.js setting the
|
|
3
|
+
// pendingDevice signal in response to a 403 {pending:true}.
|
|
4
|
+
//
|
|
5
|
+
// While visible, polls /api/devices/me every 3s. When the server
|
|
6
|
+
// flips status to 'approved', the next polled api() call clears
|
|
7
|
+
// pendingDevice (api.js does that on any 2xx response), the overlay
|
|
8
|
+
// unmounts, and the rest of the app keeps loading.
|
|
9
|
+
//
|
|
10
|
+
// Reuses .offline-overlay / .offline-card classes so styling matches
|
|
11
|
+
// the existing HealthOverlay's blocking modal aesthetic.
|
|
12
|
+
|
|
13
|
+
import { html } from '../html.js';
|
|
14
|
+
import { useEffect } from 'preact/hooks';
|
|
15
|
+
import { api, pendingDevice, loadConfig, refreshAll } from '../api.js';
|
|
16
|
+
import { BrandMark } from '../icons.js';
|
|
17
|
+
|
|
18
|
+
const POLL_MS = 3000;
|
|
19
|
+
|
|
20
|
+
export function PendingApprovalOverlay() {
|
|
21
|
+
const p = pendingDevice.value;
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!p) return;
|
|
25
|
+
let stopped = false;
|
|
26
|
+
const tick = async () => {
|
|
27
|
+
if (stopped) return;
|
|
28
|
+
try {
|
|
29
|
+
// /api/devices/me is gate-exempt: returns 200 with the current
|
|
30
|
+
// device record regardless of approval state. We inspect the
|
|
31
|
+
// body ourselves to decide whether to dismiss the overlay.
|
|
32
|
+
const d = await api('GET', '/api/devices/me');
|
|
33
|
+
if (d && d.status === 'approved') {
|
|
34
|
+
pendingDevice.value = null;
|
|
35
|
+
// First load failed because we weren't approved yet — main.js'
|
|
36
|
+
// boot tried /api/config and got 401, no auto-retry. Now that
|
|
37
|
+
// we're through the gate, kick a one-shot load so config +
|
|
38
|
+
// sessions/folders/workspaces hydrate without waiting for the
|
|
39
|
+
// 5s tick (and config without that ever happening, since the
|
|
40
|
+
// periodic loop doesn't include loadConfig).
|
|
41
|
+
loadConfig().catch(() => {});
|
|
42
|
+
refreshAll().catch(() => {});
|
|
43
|
+
} else if (d) {
|
|
44
|
+
pendingDevice.value = {
|
|
45
|
+
pending: d.status === 'pending',
|
|
46
|
+
rejected: d.status === 'rejected',
|
|
47
|
+
deviceId: d.id,
|
|
48
|
+
firstSeen: d.firstSeen,
|
|
49
|
+
at: Date.now(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
} catch { /* network blip — try again next tick */ }
|
|
53
|
+
};
|
|
54
|
+
const id = setInterval(tick, POLL_MS);
|
|
55
|
+
tick();
|
|
56
|
+
return () => { stopped = true; clearInterval(id); };
|
|
57
|
+
}, [!!p]);
|
|
58
|
+
|
|
59
|
+
if (!p) return null;
|
|
60
|
+
|
|
61
|
+
const rejected = !!p.rejected;
|
|
62
|
+
const firstSeen = p.firstSeen ? new Date(p.firstSeen).toLocaleTimeString() : null;
|
|
63
|
+
|
|
64
|
+
return html`
|
|
65
|
+
<div class="offline-overlay" role="dialog" aria-modal="true" aria-live="polite">
|
|
66
|
+
<div class="offline-card">
|
|
67
|
+
<div class="offline-brand"><${BrandMark} /></div>
|
|
68
|
+
${rejected ? html`
|
|
69
|
+
<h1 class="offline-title">Access declined</h1>
|
|
70
|
+
<p class="offline-copy">
|
|
71
|
+
The host machine rejected this device. If you think this was a
|
|
72
|
+
mistake, ask the operator to re-approve from the Remote page.
|
|
73
|
+
</p>
|
|
74
|
+
` : html`
|
|
75
|
+
<h1 class="offline-title">Waiting for host approval</h1>
|
|
76
|
+
<p class="offline-copy">
|
|
77
|
+
The host machine got your request${firstSeen ? ` at ${firstSeen}` : ''}.
|
|
78
|
+
Approve this device from the Remote page over there to continue.
|
|
79
|
+
</p>
|
|
80
|
+
<p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
|
|
81
|
+
We'll auto-unlock the moment the host clicks Approve.
|
|
82
|
+
</p>
|
|
83
|
+
`}
|
|
84
|
+
</div>
|
|
85
|
+
</div>`;
|
|
86
|
+
}
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { html } from '../html.js';
|
|
2
2
|
import { signal } from '@preact/signals';
|
|
3
3
|
import {
|
|
4
|
-
activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities,
|
|
4
|
+
activeTab, sidebarCollapsed, sidebarForcedCollapsed, isMobile, configDirty, capabilities,
|
|
5
5
|
sessions, folders, sessionsByFolder, foldersCollapsed, activeSessionId,
|
|
6
6
|
selectTab, selectSession, toggleSidebar, toggleFolder, setSidebarWidth,
|
|
7
7
|
} from '../state.js';
|
|
8
8
|
import { createFolder, renameFolder, deleteFolder, reorderFolders, setSessionFolder, reorderSessions, deleteSession, resumeSession, setSessionTitle } from '../api.js';
|
|
9
|
+
import { isRemoteAccess } from '../backend.js';
|
|
9
10
|
import { ccsmPrompt, ccsmConfirm } from '../dialog.js';
|
|
10
11
|
import { setToast } from '../toast.js';
|
|
11
12
|
import { fmtAgo } from '../util.js';
|
|
12
13
|
import { clockTick } from '../state.js';
|
|
13
14
|
import { useDragSort } from './useDragSort.js';
|
|
14
15
|
import {
|
|
15
|
-
IconLaunch, IconConfigure,
|
|
16
|
+
IconLaunch, IconConfigure, IconRemote,
|
|
16
17
|
IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, IconPlus, BrandMark,
|
|
17
18
|
} from '../icons.js';
|
|
18
19
|
|
|
@@ -309,8 +310,13 @@ function SessionTree() {
|
|
|
309
310
|
}
|
|
310
311
|
|
|
311
312
|
export function Sidebar() {
|
|
312
|
-
|
|
313
|
-
|
|
313
|
+
// On phones the sidebar is rendered inside a full-screen drawer
|
|
314
|
+
// (App applies .is-mobile + .drawer-open classes). It should always
|
|
315
|
+
// appear in EXPANDED form there — full labels + sessions tree.
|
|
316
|
+
// Desktop/tablet keeps the original collapse behaviour.
|
|
317
|
+
const mobile = isMobile.value;
|
|
318
|
+
const collapsed = !mobile && (sidebarCollapsed.value || sidebarForcedCollapsed.value);
|
|
319
|
+
const forced = !mobile && sidebarForcedCollapsed.value;
|
|
314
320
|
|
|
315
321
|
const onResizeStart = (ev) => {
|
|
316
322
|
if (collapsed) return;
|
|
@@ -346,6 +352,9 @@ export function Sidebar() {
|
|
|
346
352
|
<nav class="sidebar-nav compact" role="tablist" aria-label="Sections">
|
|
347
353
|
<${NavItem} tab="launch" icon=${html`<${IconLaunch} />`} label="New Session" />
|
|
348
354
|
<${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Settings" dirty=${configDirty.value} />
|
|
355
|
+
${!isRemoteAccess() ? html`
|
|
356
|
+
<${NavItem} tab="remote" icon=${html`<${IconRemote} />`} label="Remote" />
|
|
357
|
+
` : null}
|
|
349
358
|
</nav>
|
|
350
359
|
|
|
351
360
|
${!collapsed ? html`<${SessionTree} />` : null}
|
|
@@ -9,7 +9,7 @@ import { FitAddon } from '@xterm/addon-fit';
|
|
|
9
9
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
10
10
|
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
|
11
11
|
import { WebglAddon } from '@xterm/addon-webgl';
|
|
12
|
-
import { wsBase } from '../backend.js';
|
|
12
|
+
import { wsBase, getToken, getDeviceId } from '../backend.js';
|
|
13
13
|
|
|
14
14
|
// Dark xterm theme. We give the terminal a near-black ink background to
|
|
15
15
|
// match what claude code's TUI assumes (it paints its own input box +
|
|
@@ -40,9 +40,15 @@ export function TerminalView({ terminalId }) {
|
|
|
40
40
|
useEffect(() => {
|
|
41
41
|
if (!terminalId || !hostRef.current) return;
|
|
42
42
|
|
|
43
|
+
// Mobile viewports (≤ 640px) get a smaller default font so claude's
|
|
44
|
+
// UI fits ~50 cols instead of the ~26 that 13px would buy at 390px.
|
|
45
|
+
// Desktop stays at 13. We re-evaluate on every mount, so a viewport
|
|
46
|
+
// rotation that crosses the breakpoint picks up the new size on
|
|
47
|
+
// next mount (rare; users typically don't rotate mid-session).
|
|
48
|
+
const baseFontSize = window.matchMedia('(max-width: 640px)').matches ? 11 : 13;
|
|
43
49
|
const term = new Terminal({
|
|
44
50
|
fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
|
|
45
|
-
fontSize:
|
|
51
|
+
fontSize: baseFontSize,
|
|
46
52
|
lineHeight: 1.2,
|
|
47
53
|
cursorBlink: true,
|
|
48
54
|
cursorStyle: 'bar',
|
|
@@ -108,7 +114,17 @@ export function TerminalView({ terminalId }) {
|
|
|
108
114
|
requestAnimationFrame(() => { try { fit.fit(); } catch {} });
|
|
109
115
|
termRef.current = term;
|
|
110
116
|
|
|
111
|
-
|
|
117
|
+
// Browser WS API can't set Authorization headers — token + device
|
|
118
|
+
// ride as query string when we have them (Remote-mode access).
|
|
119
|
+
// Server's upgrade handler reads both when Host is non-loopback.
|
|
120
|
+
const tok = getToken();
|
|
121
|
+
const dev = getDeviceId();
|
|
122
|
+
const params = new URLSearchParams();
|
|
123
|
+
if (tok) params.set('token', tok);
|
|
124
|
+
if (dev) params.set('device', dev);
|
|
125
|
+
const qs = params.toString();
|
|
126
|
+
const wsUrl = `${wsBase()}/ws/terminal/${encodeURIComponent(terminalId)}${qs ? `?${qs}` : ''}`;
|
|
127
|
+
const ws = new WebSocket(wsUrl);
|
|
112
128
|
ws.binaryType = 'arraybuffer';
|
|
113
129
|
wsRef.current = ws;
|
|
114
130
|
|
package/public/js/icons.js
CHANGED
|
@@ -131,6 +131,30 @@ export const IconMoreVert = ic('0 0 24 24', html`
|
|
|
131
131
|
<circle cx="12" cy="19" r="1.6" fill="currentColor" stroke="none"/>
|
|
132
132
|
`, 16);
|
|
133
133
|
|
|
134
|
+
// Broadcast / remote — radiating arcs over a centre dot. Used on the
|
|
135
|
+
// Remote nav tab; reads as "this machine is broadcasting" / "remote
|
|
136
|
+
// access available".
|
|
137
|
+
export const IconRemote = ic('0 0 24 24', html`
|
|
138
|
+
<circle cx="12" cy="12" r="2"/>
|
|
139
|
+
<path d="M8.5 8.5a5 5 0 0 0 0 7"/>
|
|
140
|
+
<path d="M15.5 8.5a5 5 0 0 1 0 7"/>
|
|
141
|
+
<path d="M5.5 5.5a9 9 0 0 0 0 13"/>
|
|
142
|
+
<path d="M18.5 5.5a9 9 0 0 1 0 13"/>
|
|
143
|
+
`, 18);
|
|
144
|
+
|
|
145
|
+
// Copy — two stacked rectangles. For "copy to clipboard" affordance
|
|
146
|
+
// next to URLs / tokens in the Remote page.
|
|
147
|
+
export const IconCopy = ic('0 0 24 24', html`
|
|
148
|
+
<rect x="9" y="9" width="11" height="11" rx="2"/>
|
|
149
|
+
<path d="M5 15V6a2 2 0 0 1 2-2h9"/>
|
|
150
|
+
`, 13);
|
|
151
|
+
|
|
152
|
+
// Recycle / regenerate — for "regenerate token" button.
|
|
153
|
+
export const IconRecycle = ic('0 0 24 24', html`
|
|
154
|
+
<polyline points="23 4 23 10 17 10"/>
|
|
155
|
+
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
|
156
|
+
`, 14);
|
|
157
|
+
|
|
134
158
|
// Brand-colored CLI marks. These use external SVG assets (full color),
|
|
135
159
|
// rendered as <img> so the gradients / fills in the file are preserved.
|
|
136
160
|
export const IconClaudeColor = () => html`
|
package/public/js/main.js
CHANGED
|
@@ -5,13 +5,34 @@
|
|
|
5
5
|
import { render } from 'preact';
|
|
6
6
|
import { effect } from '@preact/signals';
|
|
7
7
|
import { html } from './html.js';
|
|
8
|
-
import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed, activeTab, activeSessionId, sessions, TAB_HEADINGS } from './state.js';
|
|
9
|
-
import { httpBase } from './backend.js';
|
|
10
|
-
import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
|
|
8
|
+
import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed, isMobile, mobileDrawerOpen, activeTab, activeSessionId, sessions, TAB_HEADINGS } from './state.js';
|
|
9
|
+
import { httpBase, setToken, getDeviceId, isRemoteAccess } from './backend.js';
|
|
10
|
+
import { api, loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth, pendingDevice } from './api.js';
|
|
11
11
|
import { setToast } from './toast.js';
|
|
12
12
|
import { App } from './components/App.js';
|
|
13
13
|
import { installGlobalKeybindings } from './keybindings.js';
|
|
14
14
|
|
|
15
|
+
// First thing we do on boot: if the URL carries `?token=…` it's a fresh
|
|
16
|
+
// share link from the Remote page on the host machine. Stash it in
|
|
17
|
+
// localStorage so api.js / TerminalView pick it up, then strip the query
|
|
18
|
+
// string from the URL via history.replaceState — keeps the secret out
|
|
19
|
+
// of the address bar / browser history / clipboard sharing later.
|
|
20
|
+
// Also ensure a device id exists in localStorage right away — getDeviceId
|
|
21
|
+
// is a side-effecting getter (creates + persists on first call). Calling
|
|
22
|
+
// it here means api.js sees a stable id from the very first fetch.
|
|
23
|
+
(() => {
|
|
24
|
+
try {
|
|
25
|
+
const u = new URL(location.href);
|
|
26
|
+
const t = u.searchParams.get('token');
|
|
27
|
+
if (t) {
|
|
28
|
+
setToken(t);
|
|
29
|
+
u.searchParams.delete('token');
|
|
30
|
+
history.replaceState(null, '', u.pathname + (u.search ? `?${u.searchParams.toString()}` : '') + u.hash);
|
|
31
|
+
}
|
|
32
|
+
getDeviceId();
|
|
33
|
+
} catch {}
|
|
34
|
+
})();
|
|
35
|
+
|
|
15
36
|
loadPersisted();
|
|
16
37
|
installGlobalKeybindings();
|
|
17
38
|
// Window/tab title — reactive. In standalone PWA mode we hide our own
|
|
@@ -84,13 +105,24 @@ matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass
|
|
|
84
105
|
matchMedia('(display-mode: window-controls-overlay)').addEventListener('change', applyIsAppClass);
|
|
85
106
|
matchMedia('(display-mode: standalone)').addEventListener('change', applyIsAppClass);
|
|
86
107
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
108
|
+
// The old 640–900px "force-collapse" mode is gone — narrow desktops
|
|
109
|
+
// keep the full sidebar, phone viewports get the FAB drawer below.
|
|
110
|
+
// `sidebarForcedCollapsed` is left at its default `false` so any
|
|
111
|
+
// remaining readers (Sidebar resize handle gate, etc.) behave like
|
|
112
|
+
// desktop. Removing the signal entirely would mean touching every
|
|
113
|
+
// consumer; leaving it inert is a smaller blast radius.
|
|
114
|
+
|
|
115
|
+
// Phone-sized viewports get a different nav model: sidebar hidden,
|
|
116
|
+
// floating bottom-left button opens a full-screen drawer.
|
|
117
|
+
const mobileMq = matchMedia('(max-width: 640px)');
|
|
118
|
+
function applyMobile() {
|
|
119
|
+
isMobile.value = mobileMq.matches;
|
|
120
|
+
// Always close the drawer on a breakpoint flip so the user doesn't
|
|
121
|
+
// resize from desktop into mobile with a phantom open drawer.
|
|
122
|
+
if (mobileDrawerOpen.value) mobileDrawerOpen.value = false;
|
|
123
|
+
}
|
|
124
|
+
applyMobile();
|
|
125
|
+
mobileMq.addEventListener('change', applyMobile);
|
|
94
126
|
|
|
95
127
|
// Counter-zoom for the page-title-bar. Browser page zoom (Ctrl+wheel) scales every CSS px including our header heights;
|
|
96
128
|
// without this, the header gets visually taller at 150%+ which the user
|
|
@@ -105,6 +137,25 @@ function syncAntiZoom() {
|
|
|
105
137
|
syncAntiZoom();
|
|
106
138
|
window.addEventListener('resize', syncAntiZoom);
|
|
107
139
|
|
|
140
|
+
// WCO title-bar height — read the actual OS strip height via
|
|
141
|
+
// navigator.windowControlsOverlay.getTitlebarAreaRect() and publish it
|
|
142
|
+
// as --titlebar-h. CSS env(titlebar-area-height) is the analogous value
|
|
143
|
+
// but Chromium occasionally lies (under-reports by a couple px on Edge),
|
|
144
|
+
// and we don't get a JS handle to drive other measurements from. The
|
|
145
|
+
// JS API is the source of truth here; the rect's height is exactly the
|
|
146
|
+
// strip the OS leaves us. Fires on geometrychange so window-move-across-
|
|
147
|
+
// monitors / DPI-flip / restore-from-maximize re-sync.
|
|
148
|
+
function syncTitlebarHeight() {
|
|
149
|
+
try {
|
|
150
|
+
const r = navigator.windowControlsOverlay?.getTitlebarAreaRect?.();
|
|
151
|
+
if (r && r.height > 0) {
|
|
152
|
+
document.documentElement.style.setProperty('--titlebar-h', `${r.height}px`);
|
|
153
|
+
}
|
|
154
|
+
} catch { /* unsupported · CSS falls back to env() then 32px */ }
|
|
155
|
+
}
|
|
156
|
+
syncTitlebarHeight();
|
|
157
|
+
navigator.windowControlsOverlay?.addEventListener?.('geometrychange', syncTitlebarHeight);
|
|
158
|
+
|
|
108
159
|
(async () => {
|
|
109
160
|
// Version-mismatch guard runs FIRST. If the user's backend has been
|
|
110
161
|
// upgraded since this per-version frontend was loaded, bounce back to
|
|
@@ -113,6 +164,28 @@ window.addEventListener('resize', syncAntiZoom);
|
|
|
113
164
|
// the build-time <meta>).
|
|
114
165
|
await bootVersionGuard();
|
|
115
166
|
|
|
167
|
+
// On a remote browser we MUST register at /api/devices/me before any
|
|
168
|
+
// other /api/* call — the device gate 401s with "unknown device"
|
|
169
|
+
// otherwise. The /me handler accepts the token from the share URL,
|
|
170
|
+
// creates a pending record, and (post-approval) keeps returning the
|
|
171
|
+
// existing record without a token. Setting pendingDevice from the
|
|
172
|
+
// response wakes PendingApprovalOverlay; on approval the signal
|
|
173
|
+
// clears in there.
|
|
174
|
+
if (isRemoteAccess()) {
|
|
175
|
+
try {
|
|
176
|
+
const me = await api('GET', '/api/devices/me');
|
|
177
|
+
if (me && me.status !== 'approved') {
|
|
178
|
+
pendingDevice.value = {
|
|
179
|
+
pending: me.status === 'pending',
|
|
180
|
+
rejected: me.status === 'rejected',
|
|
181
|
+
deviceId: me.id,
|
|
182
|
+
firstSeen: me.firstSeen,
|
|
183
|
+
at: Date.now(),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
} catch (e) { /* token bad / network blip — surfaces via other calls */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
116
189
|
try {
|
|
117
190
|
await loadConfig();
|
|
118
191
|
await refreshAll();
|
|
@@ -143,7 +216,17 @@ window.addEventListener('resize', syncAntiZoom);
|
|
|
143
216
|
// 10s cadence is short enough that any tab open for one full cycle gets
|
|
144
217
|
// caught by the post-close decision in server.js; long enough not to be
|
|
145
218
|
// chatty.
|
|
146
|
-
const ping = () =>
|
|
219
|
+
const ping = () => {
|
|
220
|
+
const headers = {};
|
|
221
|
+
// Heartbeat doesn't go through api.js' wrapper but still needs the
|
|
222
|
+
// bearer token + device id when called via tunnel (the middleware
|
|
223
|
+
// blocks it otherwise and the server thinks the session went idle).
|
|
224
|
+
const t = (typeof localStorage !== 'undefined') ? localStorage.getItem('ccsm.token') : null;
|
|
225
|
+
if (t) headers['Authorization'] = `Bearer ${t}`;
|
|
226
|
+
const d = getDeviceId();
|
|
227
|
+
if (d) headers['X-Device-Id'] = d;
|
|
228
|
+
return fetch(httpBase() + '/api/heartbeat', { method: 'POST', headers, keepalive: true }).catch(() => {});
|
|
229
|
+
};
|
|
147
230
|
ping();
|
|
148
231
|
setInterval(ping, 10_000);
|
|
149
232
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
|