-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
executable file
·681 lines (616 loc) · 24.1 KB
/
Copy pathserver.js
File metadata and controls
executable file
·681 lines (616 loc) · 24.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
#!/usr/bin/env node
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFile, execFileSync, spawn } = require('child_process');
const WebSocket = require('ws');
const screenshot = require('screenshot-desktop');
const QRCode = require('qrcode');
let PORT = Number(process.env.PORT) || 8080;
const FPS = Number(process.env.FPS) || 15;
const SCREEN = Number(process.env.SCREEN) || 0;
// Access PIN: viewers must present it to receive the stream. The QR codes
// and printed URLs deliberately do not embed it; every viewer types it once
// in the browser. Disable with --no-pin or PIN=off, which is essential when
// the host screen is dead and the PIN can't be read.
const PIN = process.argv.includes('--no-pin') || process.env.PIN === 'off' ? null
: process.env.PIN || String(Math.floor(1000 + Math.random() * 9000));
// Quality presets, switchable live from the viewer.
// sharp was 1920/q4, but at 15fps that is 30+ Mbps of JPEG: more than most
// WiFi links sustain, so frames got skipped and motion turned jerky. 1600/q7
// halves the bitrate with little visible loss on a tablet-sized screen.
const QUALITY_PRESETS = {
sharp: { maxWidth: 1600, q: 7 },
smooth: { maxWidth: 1100, q: 12 },
};
let quality = process.env.QUALITY === 'smooth' ? 'smooth' : 'sharp';
const viewerHtml = fs.readFileSync(path.join(__dirname, 'viewer.html'));
const hostHtml = fs.readFileSync(path.join(__dirname, 'host.html'));
const logoPng = fs.readFileSync(path.join(__dirname, 'assets', 'logo.png'));
// --- HTTP ---
function isLocalRequest(req) {
const addr = req.socket.remoteAddress || '';
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(viewerHtml);
return;
}
if (req.method === 'GET' && url.pathname === '/host') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(hostHtml);
return;
}
if (req.method === 'GET' && url.pathname === '/logo.png') {
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=86400' });
res.end(logoPng);
return;
}
if (req.method === 'GET' && url.pathname === '/api/info') {
// Manual "Scan now" from the setup page: drop the cached hardware-port
// map so a phone plugged in moments ago is classified from fresh data.
// On macOS also ask the OS to re-detect network hardware — a tethered
// phone can sit on the USB bus without macOS ever creating its network
// service, and this (non-admin) command is what makes it appear.
let detect = Promise.resolve();
if (url.searchParams.get('fresh')) {
darwinPortCache = { at: 0, map: {} };
if (process.platform === 'darwin' && isLocalRequest(req)) {
detect = new Promise((resolve) => {
execFile('networksetup', ['-detectnewhardware'], { timeout: 15000 }, () => {
// Give DHCP a moment so the fresh interface answers with an IP.
setTimeout(resolve, 2000);
});
});
}
}
detect
.then(() => Promise.all(
getLocalIps().map(async ({ name, address }) => {
const viewUrl = `http://${address}:${PORT}`;
const qrSvg = await QRCode.toString(viewUrl, { type: 'svg', margin: 1 });
const { kind, label } = classifyInterface(name);
return { name, address, url: viewUrl, qrSvg, kind, label };
})
))
.then((interfaces) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
app: 'castview',
port: PORT,
interfaces,
pending: getPendingUsb(),
ffmpeg: useFfmpeg,
viewers: wss.clients.size,
// Only reveal the PIN to the machine being mirrored.
pin: isLocalRequest(req) ? PIN : undefined,
shortcutExists: isLocalRequest(req) ? fs.existsSync(shortcutPath()) : undefined,
}));
})
.catch((err) => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
return;
}
// Create a double-clickable launcher on the Desktop for easy relaunching.
if (req.method === 'POST' && url.pathname === '/api/shortcut') {
if (!isLocalRequest(req)) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'forbidden' }));
return;
}
try {
const file = createDesktopShortcut();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, path: file }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// Stop sharing from the setup page. Only the mirrored machine may call it.
if (req.method === 'POST' && url.pathname === '/api/stop') {
if (!isLocalRequest(req)) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'forbidden' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }), () => {
console.log('Stopped from setup page');
stopCapture();
process.exit(0);
});
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
});
// --- WebSocket + auth ---
const wss = new WebSocket.Server({ server });
// The low-latency path: the host machine's own /host tab captures the screen
// with getDisplayMedia and streams WebRTC to viewers. This server only relays
// the signaling messages between that tab (hostTab) and each viewer; media
// flows peer-to-peer over the LAN. Viewers without a live hostTab fall back
// to the JPEG-over-WebSocket stream below.
let hostTab = null;
let nextViewerId = 1;
function authedClients() {
return [...wss.clients].filter((c) => c.authed && !c.isHost);
}
function jpegClients() {
return authedClients().filter((c) => c.mode !== 'webrtc');
}
function broadcast(frame) {
for (const client of jpegClients()) {
// Skip clients that haven't drained the previous frame yet, so a slow
// viewer lags instead of building up a growing backlog of stale frames.
if (client.readyState === WebSocket.OPEN && client.bufferedAmount === 0) {
client.send(frame, { binary: true });
}
}
}
function sendJson(ws, obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'http://x');
// The capturing host tab: must come from this machine, no PIN needed.
if (url.searchParams.get('role') === 'host') {
if (!isLocalRequest(req)) {
ws.close(4003, 'forbidden');
return;
}
if (hostTab) hostTab.close();
hostTab = ws;
ws.isHost = true;
ws.authed = true;
console.log('Low-latency host tab connected');
for (const c of authedClients()) sendJson(c, { type: 'webrtc-available' });
ws.on('message', (data, isBinary) => {
if (isBinary) return;
try {
const msg = JSON.parse(data.toString());
// Relay offer/ice to the addressed viewer.
if (msg.to) {
const target = authedClients().find((c) => c.viewerId === msg.to);
if (target) sendJson(target, msg);
}
} catch {}
});
ws.on('close', () => {
if (hostTab !== ws) return;
hostTab = null;
console.log('Low-latency host tab disconnected');
for (const c of authedClients()) sendJson(c, { type: 'webrtc-gone' });
});
ws.on('error', () => {});
return;
}
ws.authed = !PIN || url.searchParams.get('pin') === PIN;
if (!ws.authed) {
ws.close(4001, 'invalid pin');
return;
}
ws.viewerId = nextViewerId++;
ws.mode = 'jpeg';
console.log('Viewer connected');
startCapture();
if (hostTab) sendJson(ws, { type: 'webrtc-available' });
ws.on('message', (data, isBinary) => {
if (isBinary) return;
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'quality' && QUALITY_PRESETS[msg.value] && msg.value !== quality) {
quality = msg.value;
console.log(`Quality changed to ${quality}`);
if (useFfmpeg) {
stopFfmpeg();
startFfmpeg();
}
}
// Viewer switched between WebRTC video and the JPEG fallback; only run
// the capture pipeline while someone still needs JPEG frames.
if (msg.type === 'mode' && (msg.value === 'webrtc' || msg.value === 'jpeg')) {
ws.mode = msg.value;
if (jpegClients().length === 0) stopCapture();
else startCapture();
}
// Relay signaling to the host tab, stamped with who it came from.
if (msg.type === 'webrtc-request' || msg.type === 'webrtc-answer' || msg.type === 'webrtc-ice') {
sendJson(hostTab, { ...msg, from: ws.viewerId });
}
} catch {}
});
ws.on('close', () => {
console.log('Viewer disconnected');
sendJson(hostTab, { type: 'viewer-gone', from: ws.viewerId });
if (jpegClients().length === 0) stopCapture();
});
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
});
});
// --- ffmpeg capture (preferred): continuous MJPEG stream, smooth frame rate ---
function hasFfmpeg() {
try {
execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function ffmpegInputArgs() {
if (process.platform === 'darwin') {
return ['-f', 'avfoundation', '-capture_cursor', '1', '-pixel_format', 'uyvy422', '-framerate', String(FPS), '-i', `Capture screen ${SCREEN}`];
}
if (process.platform === 'win32') {
return ['-f', 'gdigrab', '-framerate', String(FPS), '-draw_mouse', '1', '-i', 'desktop'];
}
return ['-f', 'x11grab', '-framerate', String(FPS), '-i', process.env.DISPLAY || ':0'];
}
let ffmpegProc = null;
let ffmpegRestartTimer = null;
function startFfmpeg() {
if (ffmpegProc) return;
const { maxWidth, q } = QUALITY_PRESETS[quality];
const args = [
'-loglevel', 'error',
...ffmpegInputArgs(),
'-vf', `scale='min(${maxWidth},iw)':-2`,
// avfoundation's screen device reports a bogus huge timebase; without an
// explicit output rate ffmpeg duplicates frames as fast as CPU allows.
'-r', String(FPS),
'-q:v', String(q),
'-f', 'mjpeg',
'pipe:1',
];
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
ffmpegProc = proc;
// Extract complete JPEGs from the MJPEG byte stream (SOI ffd8 .. EOI ffd9).
let buf = Buffer.alloc(0);
proc.stdout.on('data', (chunk) => {
// A replaced process (quality switch) may still flush frames while dying;
// drop them so two encoders never interleave.
if (ffmpegProc !== proc) return;
buf = Buffer.concat([buf, chunk]);
let start;
while ((start = buf.indexOf('\xff\xd8', 0, 'binary')) !== -1) {
const end = buf.indexOf('\xff\xd9', start + 2, 'binary');
if (end === -1) break;
broadcast(buf.subarray(start, end + 2));
buf = buf.subarray(end + 2);
}
// Cap the parse buffer so a marker-scan miss can't grow it unbounded.
if (buf.length > 32 * 1024 * 1024) buf = Buffer.alloc(0);
});
proc.stderr.on('data', (d) => console.error('ffmpeg:', d.toString().trim()));
proc.on('exit', (code) => {
// If we were replaced or deliberately stopped, ffmpegProc no longer
// points at us — that exit is expected, don't null the new process or
// schedule a restart. (ffmpeg traps SIGTERM and exits code 255 with no
// signal, so exit codes can't distinguish our kill from a crash.)
if (ffmpegProc !== proc) return;
ffmpegProc = null;
if (authedClients().length > 0) {
console.error(`ffmpeg exited (code ${code}), restarting in 1s`);
ffmpegRestartTimer = setTimeout(() => {
ffmpegRestartTimer = null;
if (authedClients().length > 0) startCapture();
}, 1000);
}
});
console.log('Capture: ffmpeg MJPEG stream');
}
function stopFfmpeg() {
if (ffmpegRestartTimer) {
clearTimeout(ffmpegRestartTimer);
ffmpegRestartTimer = null;
}
if (!ffmpegProc) return;
const proc = ffmpegProc;
ffmpegProc = null;
proc.kill('SIGTERM');
}
// --- screenshot fallback: used when ffmpeg is not installed ---
const darwinFramePath = path.join(os.tmpdir(), `castview-frame-${process.pid}.jpg`);
function captureFrame() {
if (process.platform === 'darwin') {
return new Promise((resolve, reject) => {
execFile('screencapture', ['-x', '-C', '-t', 'jpg', darwinFramePath], (err) => {
if (err) return reject(err);
fs.readFile(darwinFramePath, (err, buf) => (err ? reject(err) : resolve(buf)));
});
});
}
return screenshot({ format: 'jpg', screen: SCREEN || undefined });
}
let captureTimer = null;
let capturing = false;
async function captureAndBroadcast() {
if (capturing) return;
capturing = true;
try {
broadcast(await captureFrame());
} catch (err) {
console.error('Capture failed:', err.message);
if (process.platform === 'darwin') {
console.error('If this persists, grant Screen Recording permission: System Settings > Privacy & Security > Screen Recording');
}
} finally {
capturing = false;
}
}
function startScreenshotLoop() {
if (captureTimer) return;
captureTimer = setInterval(captureAndBroadcast, 1000 / FPS);
console.log('Capture: screenshot loop (install ffmpeg for smoother streaming)');
}
function stopScreenshotLoop() {
if (!captureTimer) return;
clearInterval(captureTimer);
captureTimer = null;
}
// --- capture lifecycle: run only while viewers are connected ---
const useFfmpeg = hasFfmpeg();
function startCapture() {
if (useFfmpeg) startFfmpeg();
else startScreenshotLoop();
}
function stopCapture() {
stopFfmpeg();
stopScreenshotLoop();
}
// --- network interfaces ---
// Classify each network interface as wifi / usb / ethernet so the setup page
// can tell the user which QR belongs to which connection path.
let darwinPortCache = { at: 0, map: {} };
function darwinPortMap() {
if (Date.now() - darwinPortCache.at < 10000) return darwinPortCache.map;
try {
const out = execFileSync('networksetup', ['-listallhardwareports'], { encoding: 'utf8' });
const map = {};
let port = null;
for (const line of out.split('\n')) {
const p = line.match(/^Hardware Port: (.+)$/);
if (p) { port = p[1].trim(); continue; }
const d = line.match(/^Device: (.+)$/);
if (d && port) map[d[1].trim()] = port;
}
darwinPortCache = { at: Date.now(), map };
} catch {
darwinPortCache = { at: Date.now(), map: {} };
}
return darwinPortCache.map;
}
function classifyInterface(name) {
if (process.platform === 'darwin') {
const port = darwinPortMap()[name] || name;
if (/wi-?fi|airport/i.test(port)) return { kind: 'wifi', label: 'WiFi' };
if (/ethernet|thunderbolt|bridge|lan/i.test(port)) return { kind: 'ethernet', label: port };
// Tethered phones show up under their device name, e.g. "Pixel 10 Pro"
return { kind: 'usb', label: port };
}
if (process.platform === 'win32') {
if (/wi-?fi|wireless|wlan/i.test(name)) return { kind: 'wifi', label: 'WiFi' };
if (/ndis|tether|usb/i.test(name)) return { kind: 'usb', label: name };
return { kind: 'ethernet', label: name };
}
if (/^wl/i.test(name)) return { kind: 'wifi', label: 'WiFi' };
if (/^(usb|rndis|enx)/i.test(name)) return { kind: 'usb', label: name };
return { kind: 'ethernet', label: name };
}
function getLocalIps() {
const interfaces = os.networkInterfaces();
const ips = [];
for (const [name, addrs] of Object.entries(interfaces)) {
for (const addr of addrs || []) {
if (addr.family === 'IPv4' && !addr.internal) {
ips.push({ name, address: addr.address });
}
}
}
return ips;
}
// A tethered phone that is plugged in but hasn't handed out an address yet
// shows up as an interface with only an IPv6 link-local entry. Surfacing
// those lets the setup page say "device seen, no address yet" instead of a
// blanket "waiting" — the fix (re-toggle tethering) differs from "no cable".
function getPendingUsb() {
const interfaces = os.networkInterfaces();
const pending = [];
for (const [name, addrs] of Object.entries(interfaces)) {
const hasIpv4 = (addrs || []).some((a) => a.family === 'IPv4' && !a.internal);
if (hasIpv4 || !(addrs || []).length) continue;
// On macOS only trust names networksetup knows about, otherwise virtual
// interfaces (awdl0, llw0, utun*) would all read as pending phones.
if (process.platform === 'darwin' && !darwinPortMap()[name]) continue;
const { kind, label } = classifyInterface(name);
if (kind === 'usb') pending.push({ name, label });
}
return pending;
}
// --- startup ---
// The command a desktop shortcut should run: when running from the npx cache
// (which may be pruned) fall back to npx, otherwise pin the local install.
function relaunchCommand() {
if (__dirname.includes('_npx')) return 'npx -y castview';
return `"${process.execPath}" "${path.join(__dirname, 'server.js')}"`;
}
function shortcutPath() {
const desktop = path.join(os.homedir(), 'Desktop');
if (process.platform === 'darwin') return path.join(desktop, 'Castview.app');
if (process.platform === 'win32') return path.join(desktop, 'Castview.lnk');
return path.join(desktop, 'castview.desktop');
}
// Creates a proper launcher with the Castview icon that starts the server in
// the background — no terminal window. Stopping is done from the setup page.
function createDesktopShortcut() {
const desktop = path.join(os.homedir(), 'Desktop');
if (!fs.existsSync(desktop)) throw new Error('Desktop folder not found');
const cmd = relaunchCommand();
if (process.platform === 'darwin') {
// A minimal .app bundle: gets a real icon and launches without Terminal.
const app = shortcutPath();
fs.mkdirSync(path.join(app, 'Contents', 'MacOS'), { recursive: true });
fs.mkdirSync(path.join(app, 'Contents', 'Resources'), { recursive: true });
fs.writeFileSync(path.join(app, 'Contents', 'Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>Castview</string>
<key>CFBundleIdentifier</key><string>com.castview.launcher</string>
<key>CFBundleExecutable</key><string>launcher</string>
<key>CFBundleIconFile</key><string>castview</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>LSUIElement</key><true/>
</dict>
</plist>
`);
// GUI apps get a minimal PATH; add the common node locations for npx.
fs.writeFileSync(path.join(app, 'Contents', 'MacOS', 'launcher'), `#!/bin/zsh
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
${cmd} &>/dev/null &
`, { mode: 0o755 });
fs.copyFileSync(path.join(__dirname, 'assets', 'castview.icns'),
path.join(app, 'Contents', 'Resources', 'castview.icns'));
return app;
}
if (process.platform === 'win32') {
// A .lnk with the Castview icon, launching node hidden via PowerShell.
const lnk = shortcutPath();
const ico = path.join(__dirname, 'assets', 'castview.ico');
const psTarget = cmd.replace(/'/g, "''");
const script = [
"$ws = New-Object -ComObject WScript.Shell;",
`$s = $ws.CreateShortcut('${lnk.replace(/'/g, "''")}');`,
"$s.TargetPath = 'powershell.exe';",
`$s.Arguments = '-WindowStyle Hidden -Command "${psTarget}"';`,
`$s.IconLocation = '${ico.replace(/'/g, "''")}';`,
"$s.Description = 'Share this screen to any browser';",
"$s.Save()",
].join(' ');
execFileSync('powershell.exe', ['-NoProfile', '-Command', script], { stdio: 'ignore' });
return lnk;
}
const file = shortcutPath();
fs.writeFileSync(file, [
'[Desktop Entry]',
'Type=Application',
'Name=Castview',
'Comment=Share this screen to any browser',
`Exec=${cmd}`,
`Icon=${path.join(__dirname, 'assets', 'castview.png')}`,
'Terminal=false',
'',
].join('\n'), { mode: 0o755 });
return file;
}
// On the very first run, add the desktop launcher automatically. A marker in
// ~/.castview makes this once-ever: if the user deletes the launcher we never
// recreate it behind their back (the setup page button re-adds it manually).
function autoCreateShortcut() {
if (process.env.NO_SHORTCUT) return;
const markerDir = path.join(os.homedir(), '.castview');
const marker = path.join(markerDir, 'shortcut-created');
if (fs.existsSync(marker)) return;
try {
const file = createDesktopShortcut();
console.log(`Added a launcher to your Desktop: ${path.basename(file)} (delete it anytime; NO_SHORTCUT=1 prevents this)`);
} catch {
// No Desktop folder or shortcut tooling failed; the setup page button remains.
}
try {
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(marker, new Date().toISOString());
} catch {}
}
// Open the host setup page in the default browser (best effort; NO_OPEN=1 disables).
function openHostPage(url) {
if (process.env.NO_OPEN) return;
const cmd = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'cmd'
: 'xdg-open';
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
execFile(cmd, args, () => {});
}
// Kill the ffmpeg child when the server dies, so it isn't orphaned.
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => {
stopCapture();
process.exit(0);
});
}
// Ask whoever holds the port to identify itself: a Castview instance
// answers /api/info with app: "castview" (older versions at least return
// an interfaces array there).
function probeCastview(port) {
return new Promise((resolve) => {
const req = http.get({ host: '127.0.0.1', port, path: '/api/info', timeout: 2000 }, (res) => {
let body = '';
res.on('data', (d) => { body += d; });
res.on('end', () => {
try {
const info = JSON.parse(body);
resolve(info.app === 'castview' || Array.isArray(info.interfaces));
} catch {
resolve(false);
}
});
});
req.on('timeout', () => req.destroy(new Error('timeout')));
req.on('error', () => resolve(false));
});
}
// Port already taken. If the occupant is another Castview (e.g. the desktop
// shortcut was double-clicked while a session is running), just show that
// session's setup page. Any other app: move to the next port and retry.
// The ws library re-emits http server errors on wss, so handle it there too.
const MAX_PORT_TRIES = 10;
let portTries = 0;
function onListenError(err) {
if (err.code !== 'EADDRINUSE') throw err;
probeCastview(PORT).then((isCastview) => {
if (isCastview) {
console.log(`Castview is already running on port ${PORT} — opening its setup page.`);
openHostPage(`http://localhost:${PORT}/host`);
process.exit(0);
}
portTries += 1;
if (portTries >= MAX_PORT_TRIES) {
console.error(`Ports ${PORT - portTries + 1}-${PORT} are all in use by other apps. Free one up or set PORT to something else.`);
process.exit(1);
}
console.log(`Port ${PORT} is in use by another app — trying ${PORT + 1}.`);
PORT += 1;
server.listen(PORT);
});
}
server.on('error', onListenError);
wss.on('error', () => {});
server.listen(PORT, async () => {
console.log('Castview server running');
console.log(`Port: ${PORT}`);
if (PIN) console.log(`Access PIN: ${PIN} (viewers type it in the browser; set PIN=off to disable)`);
const ips = getLocalIps();
if (ips.length === 0) {
console.log('No non-internal IPv4 interfaces found. Check your network connection.');
return;
}
console.log('Open one of these on your tablet/phone browser:');
for (const { name, address } of ips) {
console.log(` http://${address}:${PORT} (${name})`);
}
console.log(`Setup page with QR codes (open on this computer): http://localhost:${PORT}/host`);
const url = `http://${ips[0].address}:${PORT}`;
console.log(`\nScan to view (${url}):`);
console.log(await QRCode.toString(url, { type: 'terminal', small: true }));
autoCreateShortcut();
openHostPage(`http://localhost:${PORT}/host`);
});