@dinko_abdic/claude-code-remote 0.1.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/ecosystem.config.js +17 -0
- package/package.json +22 -0
- package/src/auth.js +43 -0
- package/src/config.js +58 -0
- package/src/dashboard.js +357 -0
- package/src/index.js +445 -0
- package/src/logger.js +9 -0
- package/src/pick-folder.ps1 +82 -0
- package/src/process-scanner.js +256 -0
- package/src/protocol.js +86 -0
- package/src/sandbox.js +37 -0
- package/src/tailscale.js +90 -0
- package/src/terminal-manager.js +258 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
apps: [
|
|
3
|
+
{
|
|
4
|
+
name: 'claude-code-remote',
|
|
5
|
+
script: 'src/index.js',
|
|
6
|
+
exec_mode: 'fork', // node-pty requires main thread
|
|
7
|
+
watch: false,
|
|
8
|
+
autorestart: true,
|
|
9
|
+
max_restarts: 10,
|
|
10
|
+
restart_delay: 1000,
|
|
11
|
+
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
|
12
|
+
error_file: './logs/error.log',
|
|
13
|
+
out_file: './logs/out.log',
|
|
14
|
+
merge_logs: true,
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dinko_abdic/claude-code-remote",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "WebSocket daemon that relays terminal sessions for Claude Code Remote",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-code-remote": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node src/index.js",
|
|
11
|
+
"dev": "node --watch src/index.js",
|
|
12
|
+
"pm2:start": "pm2 start ecosystem.config.js",
|
|
13
|
+
"pm2:stop": "pm2 stop claude-code-remote",
|
|
14
|
+
"pm2:logs": "pm2 logs claude-code-remote"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"node-pty": "^1.0.0",
|
|
18
|
+
"qrcode": "^1.5.4",
|
|
19
|
+
"uuid": "^11.1.0",
|
|
20
|
+
"ws": "^8.18.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const { URL } = require('url');
|
|
3
|
+
const logger = require('./logger');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Authenticate a WebSocket upgrade request.
|
|
7
|
+
* Checks Authorization header first, then ?token= query param.
|
|
8
|
+
* Uses timing-safe comparison to prevent timing attacks.
|
|
9
|
+
*/
|
|
10
|
+
function authenticate(req, expectedToken) {
|
|
11
|
+
const tokenBuffer = Buffer.from(expectedToken, 'utf-8');
|
|
12
|
+
|
|
13
|
+
// Check Authorization: Bearer <token>
|
|
14
|
+
const authHeader = req.headers['authorization'];
|
|
15
|
+
if (authHeader) {
|
|
16
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
17
|
+
if (match) {
|
|
18
|
+
const provided = Buffer.from(match[1], 'utf-8');
|
|
19
|
+
if (provided.length === tokenBuffer.length && crypto.timingSafeEqual(provided, tokenBuffer)) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Check ?token= query param (for React Native — no custom header support)
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
28
|
+
const queryToken = url.searchParams.get('token');
|
|
29
|
+
if (queryToken) {
|
|
30
|
+
const provided = Buffer.from(queryToken, 'utf-8');
|
|
31
|
+
if (provided.length === tokenBuffer.length && crypto.timingSafeEqual(provided, tokenBuffer)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// malformed URL, ignore
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
logger.warn(`Auth failed from ${req.socket.remoteAddress}`);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { authenticate };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
const logger = require('./logger');
|
|
6
|
+
|
|
7
|
+
const CONFIG_DIR = path.join(process.env.APPDATA || path.join(require('os').homedir(), '.config'), 'claude-code-remote');
|
|
8
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
9
|
+
|
|
10
|
+
function detectShell() {
|
|
11
|
+
const candidates = ['pwsh.exe', 'powershell.exe', 'cmd.exe'];
|
|
12
|
+
for (const shell of candidates) {
|
|
13
|
+
try {
|
|
14
|
+
execSync(`where ${shell}`, { stdio: 'ignore' });
|
|
15
|
+
logger.info(`Detected shell: ${shell}`);
|
|
16
|
+
return shell;
|
|
17
|
+
} catch {
|
|
18
|
+
// not found, try next
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return 'cmd.exe'; // fallback
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loadOrCreate() {
|
|
25
|
+
// Ensure config directory exists
|
|
26
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
27
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
31
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
32
|
+
const config = JSON.parse(raw);
|
|
33
|
+
logger.info(`Config loaded from ${CONFIG_PATH}`);
|
|
34
|
+
return config;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// First run — generate config
|
|
38
|
+
const config = {
|
|
39
|
+
token: crypto.randomBytes(32).toString('hex'),
|
|
40
|
+
port: 8485,
|
|
41
|
+
sandboxRoot: null,
|
|
42
|
+
shell: detectShell(),
|
|
43
|
+
defaultCwd: null,
|
|
44
|
+
sessionKeepAliveMinutes: 30,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
|
48
|
+
logger.info(`Config created at ${CONFIG_PATH}`);
|
|
49
|
+
logger.info('Auth token generated \u2014 see config file');
|
|
50
|
+
return config;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveConfig(config) {
|
|
54
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
|
55
|
+
logger.info('Config saved');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { loadOrCreate, saveConfig, CONFIG_PATH };
|
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
const QRCode = require('qrcode');
|
|
2
|
+
|
|
3
|
+
function escapeHtml(str) {
|
|
4
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
async function generateDashboard(config, tailscaleStatus, sessionCount, connectedDevices, showQr = true) {
|
|
8
|
+
let qrHtml = '';
|
|
9
|
+
|
|
10
|
+
if (!showQr) {
|
|
11
|
+
qrHtml = `
|
|
12
|
+
<div class="qr-section">
|
|
13
|
+
<div class="no-qr">
|
|
14
|
+
<p>QR code hidden</p>
|
|
15
|
+
<p class="qr-hint">Open dashboard from your PC (localhost) to see the setup QR code</p>
|
|
16
|
+
</div>
|
|
17
|
+
</div>`;
|
|
18
|
+
} else if (tailscaleStatus.ip) {
|
|
19
|
+
const payload = JSON.stringify({
|
|
20
|
+
type: 'ccr',
|
|
21
|
+
version: 1,
|
|
22
|
+
host: tailscaleStatus.ip,
|
|
23
|
+
port: config.port,
|
|
24
|
+
token: config.token,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const dataUrl = await QRCode.toDataURL(payload, {
|
|
29
|
+
width: 280,
|
|
30
|
+
margin: 2,
|
|
31
|
+
color: { dark: '#d4d4d4', light: '#1e1e1e' },
|
|
32
|
+
errorCorrectionLevel: 'M',
|
|
33
|
+
});
|
|
34
|
+
qrHtml = `
|
|
35
|
+
<div class="qr-section">
|
|
36
|
+
<img src="${dataUrl}" alt="Setup QR Code" width="280" height="280" />
|
|
37
|
+
<p class="qr-hint">Scan with the Claude Code Remote app</p>
|
|
38
|
+
</div>`;
|
|
39
|
+
} catch {
|
|
40
|
+
qrHtml = '<p class="warning">Failed to generate QR code</p>';
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
qrHtml = `
|
|
44
|
+
<div class="qr-section">
|
|
45
|
+
<div class="no-qr">
|
|
46
|
+
<p>QR code unavailable</p>
|
|
47
|
+
<p class="qr-hint">${tailscaleStatus.error || 'Tailscale not connected'}</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tsStatusClass = tailscaleStatus.running ? 'ok' : 'warn';
|
|
53
|
+
const tsStatusText = tailscaleStatus.running
|
|
54
|
+
? `Connected — ${tailscaleStatus.ip}`
|
|
55
|
+
: tailscaleStatus.installed
|
|
56
|
+
? 'Installed but not running'
|
|
57
|
+
: 'Not installed';
|
|
58
|
+
|
|
59
|
+
return `<!DOCTYPE html>
|
|
60
|
+
<html lang="en">
|
|
61
|
+
<head>
|
|
62
|
+
<meta charset="utf-8" />
|
|
63
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
64
|
+
<title>Claude Code Remote</title>
|
|
65
|
+
<style>
|
|
66
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
67
|
+
body {
|
|
68
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
69
|
+
background: #1e1e1e;
|
|
70
|
+
color: #d4d4d4;
|
|
71
|
+
min-height: 100vh;
|
|
72
|
+
display: flex;
|
|
73
|
+
justify-content: center;
|
|
74
|
+
align-items: center;
|
|
75
|
+
}
|
|
76
|
+
.card {
|
|
77
|
+
background: #252526;
|
|
78
|
+
border: 1px solid #3c3c3c;
|
|
79
|
+
border-radius: 12px;
|
|
80
|
+
padding: 32px;
|
|
81
|
+
max-width: 400px;
|
|
82
|
+
width: 90%;
|
|
83
|
+
text-align: center;
|
|
84
|
+
}
|
|
85
|
+
h1 {
|
|
86
|
+
font-size: 22px;
|
|
87
|
+
font-weight: 700;
|
|
88
|
+
margin-bottom: 4px;
|
|
89
|
+
}
|
|
90
|
+
.subtitle {
|
|
91
|
+
color: #808080;
|
|
92
|
+
font-size: 13px;
|
|
93
|
+
margin-bottom: 24px;
|
|
94
|
+
}
|
|
95
|
+
.status-grid {
|
|
96
|
+
display: grid;
|
|
97
|
+
grid-template-columns: auto 1fr;
|
|
98
|
+
gap: 8px 12px;
|
|
99
|
+
text-align: left;
|
|
100
|
+
margin-bottom: 24px;
|
|
101
|
+
font-size: 14px;
|
|
102
|
+
}
|
|
103
|
+
.status-label { color: #808080; }
|
|
104
|
+
.status-value { font-weight: 500; }
|
|
105
|
+
.ok { color: #4caf50; }
|
|
106
|
+
.warn { color: #ff9800; }
|
|
107
|
+
.qr-section { margin: 16px 0; }
|
|
108
|
+
.qr-section img {
|
|
109
|
+
border-radius: 8px;
|
|
110
|
+
border: 1px solid #3c3c3c;
|
|
111
|
+
}
|
|
112
|
+
.qr-hint {
|
|
113
|
+
color: #808080;
|
|
114
|
+
font-size: 12px;
|
|
115
|
+
margin-top: 10px;
|
|
116
|
+
}
|
|
117
|
+
.no-qr {
|
|
118
|
+
background: #2d2d2d;
|
|
119
|
+
border: 1px dashed #3c3c3c;
|
|
120
|
+
border-radius: 8px;
|
|
121
|
+
padding: 40px 20px;
|
|
122
|
+
color: #808080;
|
|
123
|
+
}
|
|
124
|
+
.warning { color: #ff9800; }
|
|
125
|
+
.settings {
|
|
126
|
+
margin-top: 24px;
|
|
127
|
+
border-top: 1px solid #3c3c3c;
|
|
128
|
+
padding-top: 20px;
|
|
129
|
+
text-align: left;
|
|
130
|
+
}
|
|
131
|
+
.settings h2 {
|
|
132
|
+
font-size: 15px;
|
|
133
|
+
font-weight: 600;
|
|
134
|
+
margin-bottom: 12px;
|
|
135
|
+
}
|
|
136
|
+
.settings label {
|
|
137
|
+
display: block;
|
|
138
|
+
color: #808080;
|
|
139
|
+
font-size: 13px;
|
|
140
|
+
margin-bottom: 4px;
|
|
141
|
+
}
|
|
142
|
+
.settings input {
|
|
143
|
+
width: 100%;
|
|
144
|
+
background: #2d2d2d;
|
|
145
|
+
color: #d4d4d4;
|
|
146
|
+
border: 1px solid #3c3c3c;
|
|
147
|
+
border-radius: 6px;
|
|
148
|
+
padding: 8px 10px;
|
|
149
|
+
font-size: 14px;
|
|
150
|
+
font-family: monospace;
|
|
151
|
+
outline: none;
|
|
152
|
+
}
|
|
153
|
+
.devices-section {
|
|
154
|
+
margin-top: 16px;
|
|
155
|
+
margin-bottom: 24px;
|
|
156
|
+
text-align: left;
|
|
157
|
+
}
|
|
158
|
+
.devices-header {
|
|
159
|
+
display: flex;
|
|
160
|
+
justify-content: space-between;
|
|
161
|
+
align-items: center;
|
|
162
|
+
margin-bottom: 8px;
|
|
163
|
+
}
|
|
164
|
+
.devices-header h2 {
|
|
165
|
+
font-size: 14px;
|
|
166
|
+
font-weight: 600;
|
|
167
|
+
color: #808080;
|
|
168
|
+
}
|
|
169
|
+
.devices-count {
|
|
170
|
+
font-size: 12px;
|
|
171
|
+
color: #808080;
|
|
172
|
+
}
|
|
173
|
+
.device-list {
|
|
174
|
+
list-style: none;
|
|
175
|
+
}
|
|
176
|
+
.device-item {
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
gap: 8px;
|
|
180
|
+
padding: 8px 10px;
|
|
181
|
+
background: #2d2d2d;
|
|
182
|
+
border-radius: 6px;
|
|
183
|
+
margin-bottom: 4px;
|
|
184
|
+
font-size: 13px;
|
|
185
|
+
}
|
|
186
|
+
.device-dot {
|
|
187
|
+
width: 8px;
|
|
188
|
+
height: 8px;
|
|
189
|
+
border-radius: 50%;
|
|
190
|
+
background: #4caf50;
|
|
191
|
+
flex-shrink: 0;
|
|
192
|
+
}
|
|
193
|
+
.device-name {
|
|
194
|
+
font-weight: 500;
|
|
195
|
+
color: #d4d4d4;
|
|
196
|
+
}
|
|
197
|
+
.device-session {
|
|
198
|
+
color: #808080;
|
|
199
|
+
font-size: 12px;
|
|
200
|
+
margin-left: auto;
|
|
201
|
+
}
|
|
202
|
+
.no-devices {
|
|
203
|
+
color: #808080;
|
|
204
|
+
font-size: 13px;
|
|
205
|
+
padding: 12px 10px;
|
|
206
|
+
background: #2d2d2d;
|
|
207
|
+
border-radius: 6px;
|
|
208
|
+
border: 1px dashed #3c3c3c;
|
|
209
|
+
}
|
|
210
|
+
.settings input:focus { border-color: #da7756; }
|
|
211
|
+
.settings .save-btn {
|
|
212
|
+
margin-top: 10px;
|
|
213
|
+
background: #da7756;
|
|
214
|
+
color: #fff;
|
|
215
|
+
border: none;
|
|
216
|
+
border-radius: 6px;
|
|
217
|
+
padding: 8px 16px;
|
|
218
|
+
font-size: 13px;
|
|
219
|
+
font-weight: 600;
|
|
220
|
+
cursor: pointer;
|
|
221
|
+
}
|
|
222
|
+
.settings .save-btn:hover { background: #c5654a; }
|
|
223
|
+
.settings .save-msg {
|
|
224
|
+
display: inline-block;
|
|
225
|
+
margin-left: 10px;
|
|
226
|
+
font-size: 13px;
|
|
227
|
+
color: #4caf50;
|
|
228
|
+
opacity: 0;
|
|
229
|
+
transition: opacity 0.3s;
|
|
230
|
+
}
|
|
231
|
+
</style>
|
|
232
|
+
</head>
|
|
233
|
+
<body>
|
|
234
|
+
<div class="card">
|
|
235
|
+
<h1>Claude Code Remote</h1>
|
|
236
|
+
<p class="subtitle">Daemon Dashboard</p>
|
|
237
|
+
|
|
238
|
+
<div class="status-grid">
|
|
239
|
+
<span class="status-label">Tailscale</span>
|
|
240
|
+
<span class="status-value ${tsStatusClass}">${tsStatusText}</span>
|
|
241
|
+
|
|
242
|
+
<span class="status-label">Daemon Port</span>
|
|
243
|
+
<span class="status-value">${config.port}</span>
|
|
244
|
+
|
|
245
|
+
<span class="status-label">Sessions</span>
|
|
246
|
+
<span class="status-value" id="sessionCount">${sessionCount}</span>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div class="devices-section" id="devicesSection">
|
|
250
|
+
<div class="devices-header">
|
|
251
|
+
<h2>Connected Devices</h2>
|
|
252
|
+
</div>
|
|
253
|
+
${(connectedDevices && connectedDevices.length > 0)
|
|
254
|
+
? `<ul class="device-list" id="deviceList">${connectedDevices.map(d =>
|
|
255
|
+
`<li class="device-item"><span class="device-dot"></span><span class="device-name">${escapeHtml(d.deviceName || 'Unknown device')}</span><span class="device-session">${escapeHtml(d.name)}</span></li>`
|
|
256
|
+
).join('')}</ul>`
|
|
257
|
+
: '<p class="no-devices" id="deviceList">No devices connected</p>'
|
|
258
|
+
}
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
${qrHtml}
|
|
262
|
+
|
|
263
|
+
<div class="settings">
|
|
264
|
+
<h2>Settings</h2>
|
|
265
|
+
<label>Default Directory</label>
|
|
266
|
+
<div style="display: flex; gap: 8px; align-items: center; margin-top: 4px">
|
|
267
|
+
<input type="text" id="defaultCwd" value="${(config.defaultCwd || '').replace(/"/g, '"')}" placeholder="None (uses daemon working dir)" readonly style="flex:1; cursor: default; opacity: 0.8" />
|
|
268
|
+
<button class="save-btn" onclick="browseDir()" id="browseBtn" style="margin:0; white-space: nowrap">Browse...</button>
|
|
269
|
+
</div>
|
|
270
|
+
<div style="margin-top: 8px; display: flex; gap: 8px; align-items: center">
|
|
271
|
+
<button class="save-btn" onclick="saveSettings()">Save</button>
|
|
272
|
+
<button class="save-btn" onclick="clearDir()" style="background: #3c3c3c">Clear</button>
|
|
273
|
+
<span class="save-msg" id="saveMsg">Saved!</span>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
<script>
|
|
278
|
+
async function browseDir() {
|
|
279
|
+
const btn = document.getElementById('browseBtn');
|
|
280
|
+
btn.textContent = 'Opening...';
|
|
281
|
+
btn.disabled = true;
|
|
282
|
+
try {
|
|
283
|
+
const res = await fetch('/api/pick-directory', { method: 'POST' });
|
|
284
|
+
const data = await res.json();
|
|
285
|
+
if (data.path) {
|
|
286
|
+
document.getElementById('defaultCwd').value = data.path;
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
alert('Failed to open folder picker: ' + err.message);
|
|
290
|
+
}
|
|
291
|
+
btn.textContent = 'Browse...';
|
|
292
|
+
btn.disabled = false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function clearDir() {
|
|
296
|
+
document.getElementById('defaultCwd').value = '';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function saveSettings() {
|
|
300
|
+
const defaultCwd = document.getElementById('defaultCwd').value.trim();
|
|
301
|
+
try {
|
|
302
|
+
const res = await fetch('/api/settings', {
|
|
303
|
+
method: 'POST',
|
|
304
|
+
headers: { 'Content-Type': 'application/json' },
|
|
305
|
+
body: JSON.stringify({ defaultCwd }),
|
|
306
|
+
});
|
|
307
|
+
if (res.ok) {
|
|
308
|
+
const msg = document.getElementById('saveMsg');
|
|
309
|
+
msg.style.opacity = '1';
|
|
310
|
+
setTimeout(() => { msg.style.opacity = '0'; }, 2000);
|
|
311
|
+
}
|
|
312
|
+
} catch (err) {
|
|
313
|
+
alert('Failed to save: ' + err.message);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function esc(s) {
|
|
318
|
+
const d = document.createElement('div');
|
|
319
|
+
d.textContent = s;
|
|
320
|
+
return d.innerHTML;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function renderDevices(devices) {
|
|
324
|
+
const section = document.getElementById('devicesSection');
|
|
325
|
+
if (!section) return;
|
|
326
|
+
let html = '<div class="devices-header"><h2>Connected Devices</h2></div>';
|
|
327
|
+
if (devices && devices.length > 0) {
|
|
328
|
+
html += '<ul class="device-list" id="deviceList">';
|
|
329
|
+
for (const d of devices) {
|
|
330
|
+
html += '<li class="device-item"><span class="device-dot"></span>'
|
|
331
|
+
+ '<span class="device-name">' + esc(d.deviceName || 'Unknown device') + '</span>'
|
|
332
|
+
+ '<span class="device-session">' + esc(d.name) + '</span></li>';
|
|
333
|
+
}
|
|
334
|
+
html += '</ul>';
|
|
335
|
+
} else {
|
|
336
|
+
html += '<p class="no-devices" id="deviceList">No devices connected</p>';
|
|
337
|
+
}
|
|
338
|
+
section.innerHTML = html;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Poll status every 3 seconds
|
|
342
|
+
setInterval(async () => {
|
|
343
|
+
try {
|
|
344
|
+
const res = await fetch('/api/status');
|
|
345
|
+
if (res.ok) {
|
|
346
|
+
const data = await res.json();
|
|
347
|
+
document.getElementById('sessionCount').textContent = data.sessions;
|
|
348
|
+
renderDevices(data.connectedDevices);
|
|
349
|
+
}
|
|
350
|
+
} catch {}
|
|
351
|
+
}, 3000);
|
|
352
|
+
</script>
|
|
353
|
+
</body>
|
|
354
|
+
</html>`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = { generateDashboard };
|