-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
509 lines (443 loc) · 17 KB
/
Copy pathserver.js
File metadata and controls
509 lines (443 loc) · 17 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
#!/usr/bin/env node
/*
* camtint — local web widget for UVC webcam colour controls.
*
* Serves a small UI on 127.0.0.1 that drives ./bin/uvcctl, which talks UVC
* over USB. No dependencies, no network access, nothing leaves the machine.
*/
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const { execFile, execFileSync } = require('child_process');
const ROOT = __dirname;
const UVCCTL = path.join(ROOT, 'bin', 'uvcctl');
const PRESETS = path.join(ROOT, 'presets.json');
const LAST = path.join(ROOT, 'last.json');
const AUTO = path.join(ROOT, 'autoapply.json');
const BLACKOUT = path.join(ROOT, 'blackout.json');
const PATCH = path.join(ROOT, 'patch.json');
const PORT = Number(process.env.CAMTINT_PORT || 7654);
const DEVICE = process.env.CAMTINT_DEVICE || '';
/* ---- uvcctl bridge ----------------------------------------------------- */
function devArgs() {
return DEVICE ? ['-d', DEVICE] : [];
}
function uvc(args) {
return new Promise((resolve, reject) => {
execFile(UVCCTL, args, { timeout: 8000 }, (err, stdout, stderr) => {
if (err) return reject(new Error((stderr || err.message).trim()));
resolve(stdout.trim());
});
});
}
const caps = () => uvc(['caps', ...devArgs()]).then(JSON.parse);
const cameras = () => uvc(['list']).then(JSON.parse);
const setCtrl = (name, value) =>
uvc(['set', String(name), String(Math.round(Number(value))), ...devArgs()]).then(Number);
/* ---- presets + last-known state ---------------------------------------- */
function readJsonFile(file, fallback) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return fallback;
}
}
const loadPresets = () => readJsonFile(PRESETS, {});
const savePresets = (p) => fs.writeFileSync(PRESETS, JSON.stringify(p, null, 2) + '\n');
/* What to do when the camera reappears: last used values, a named preset, or nothing. */
const loadAuto = () => readJsonFile(AUTO, { mode: 'last' });
const saveAuto = (a) => fs.writeFileSync(AUTO, JSON.stringify(a, null, 2) + '\n');
const loadLast = () => readJsonFile(LAST, null);
/* Where the sample square sits, as fractions of the frame. Kept server-side
rather than in localStorage so it survives a new browser, a cleared profile,
or reaching the widget by a different hostname. */
const loadPatch = () => readJsonFile(PATCH, { cx: 0.5, cy: 0.5, size: 0.14 });
/*
* Snapshot every writable value so a replug can be undone silently. Debounced:
* dragging a slider fires many writes and each snapshot costs a full re-probe.
*/
let snapTimer = null;
function scheduleSnapshot() {
/* never let a blacked-out camera become the state auto-restore brings back */
if (fs.existsSync(BLACKOUT)) return;
if (snapTimer) clearTimeout(snapTimer);
snapTimer = setTimeout(async () => {
snapTimer = null;
try {
const spec = await caps();
const values = {};
for (const c of spec.controls) if (c.writable) values[c.name] = c.value;
fs.writeFileSync(LAST, JSON.stringify({ saved: new Date().toISOString(), values }, null, 2) + '\n');
} catch {
/* camera unplugged mid-snapshot — keep the previous one, it is the good one */
}
}, 1500);
}
/*
* Auto-mode controls gate the manual ones underneath them: while auto-WB or
* auto-exposure is engaged the camera discards writes to the values it owns.
* So apply in three passes — drop the gates to manual, write the manual
* values, then restore the gates — or a preset silently half-applies.
*/
const GATE_MANUAL = {
white_balance_temperature_auto: 0,
hue_auto: 0,
contrast_auto: 0,
focus_auto: 0,
exposure_auto: 1, // 1 = manual mode in the UVC auto-exposure bitmap
};
async function applyValues(values) {
const spec = await caps();
const known = new Set(spec.controls.map((c) => c.name));
const names = Object.keys(values).filter((n) => known.has(n));
const gates = names.filter((n) => n in GATE_MANUAL);
const manual = names.filter((n) => !(n in GATE_MANUAL));
const applied = {};
const attempt = async (n, v) => {
try {
applied[n] = await setCtrl(n, v);
} catch (e) {
applied[n] = { error: e.message };
}
};
for (const n of gates) {
try { await setCtrl(n, GATE_MANUAL[n]); } catch { /* not all cameras allow it */ }
}
for (const n of manual) await attempt(n, values[n]);
for (const n of gates) await attempt(n, values[n]);
return applied;
}
/* ---- blackout ----------------------------------------------------------- */
/*
* The camera has no hardware off switch unless it implements the UVC privacy
* control (most cheap webcams do not). Blackout is the next best thing: drive
* exposure, gain and brightness to their minimums so every app sees black.
*
* It is NOT a privacy guarantee — the sensor is still capturing and streaming.
* Unplugging is the only real off.
*
* The pre-blackout look is stashed so it can be put back exactly, and the
* rolling snapshot is suppressed while active so auto-restore never brings the
* camera back dark after a replug.
*/
const loadBlackout = () => readJsonFile(BLACKOUT, null);
async function setBlackout(on) {
const spec = await caps();
const by = new Map(spec.controls.map((c) => [c.name, c]));
const stash = loadBlackout();
if (on) {
if (stash) return { active: true }; // already dark, keep the original stash
const saved = {};
for (const c of spec.controls) if (c.writable) saved[c.name] = c.value;
fs.writeFileSync(BLACKOUT, JSON.stringify({ saved }, null, 2) + '\n');
// exposure is gated behind manual mode, so switch that first
if (by.has('exposure_auto')) {
try { await setCtrl('exposure_auto', 1); } catch { /* fixed-mode camera */ }
}
for (const name of ['exposure_time_absolute', 'gain', 'brightness']) {
const c = by.get(name);
if (c) {
try { await setCtrl(name, c.min); } catch { /* not writable */ }
}
}
return { active: true };
}
if (!stash) return { active: false };
fs.unlinkSync(BLACKOUT);
await applyValues(stash.saved);
scheduleSnapshot();
return { active: false };
}
/* ---- http -------------------------------------------------------------- */
function send(res, code, body, type = 'application/json') {
const buf = Buffer.isBuffer(body)
? body
: Buffer.from(typeof body === 'string' ? body : JSON.stringify(body));
res.writeHead(code, {
'Content-Type': type,
'Content-Length': buf.length,
'Cache-Control': 'no-store',
});
res.end(buf);
}
function readJson(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (c) => {
data += c;
if (data.length > 1e6) reject(new Error('body too large'));
});
req.on('end', () => {
try {
resolve(data ? JSON.parse(data) : {});
} catch (e) {
reject(e);
}
});
});
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
const p = url.pathname;
try {
if (p === '/' || p === '/index.html') {
return send(res, 200, fs.readFileSync(path.join(ROOT, 'public', 'index.html')), 'text/html; charset=utf-8');
}
if (p === '/favicon.ico') {
res.writeHead(204);
return res.end();
}
if (p === '/api/state' && req.method === 'GET') {
const [spec, cams] = await Promise.all([caps(), cameras()]);
return send(res, 200, {
...spec,
cameras: cams,
presets: Object.keys(loadPresets()).sort(),
autoapply: loadAuto(),
blackout: !!loadBlackout(),
patch: loadPatch(),
});
}
if (p === '/api/set' && req.method === 'POST') {
const { name, value } = await readJson(req);
const applied = await setCtrl(name, value);
scheduleSnapshot();
return send(res, 200, { name, value: applied });
}
if (p === '/api/reset' && req.method === 'POST') {
await uvc(['reset', ...devArgs()]);
scheduleSnapshot();
return send(res, 200, await caps());
}
if (p === '/api/patch' && req.method === 'POST') {
const { cx, cy, size } = await readJson(req);
const clamp01 = (v, d) => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : d);
const saved = {
cx: clamp01(cx, 0.5),
cy: clamp01(cy, 0.5),
size: Math.min(0.6, Math.max(0.04, Number(size) || 0.14)),
};
fs.writeFileSync(PATCH, JSON.stringify(saved, null, 2) + '\n');
return send(res, 200, saved);
}
if (p === '/api/blackout' && req.method === 'POST') {
const { on } = await readJson(req);
const r = await setBlackout(!!on);
return send(res, 200, { ...r, controls: (await caps()).controls });
}
if (p === '/api/autoapply' && req.method === 'POST') {
const { mode, name } = await readJson(req);
saveAuto({ mode, name: name || null });
return send(res, 200, { ok: true, autoapply: loadAuto() });
}
if (p === '/api/preset' && req.method === 'POST') {
const { action, name, values } = await readJson(req);
const presets = loadPresets();
if (action === 'save') {
const spec = await caps();
const snap = {};
for (const c of spec.controls) if (c.writable) snap[c.name] = c.value;
presets[name] = { saved: new Date().toISOString(), values: values || snap };
savePresets(presets);
return send(res, 200, { ok: true, presets: Object.keys(presets).sort() });
}
if (action === 'apply') {
if (!presets[name]) return send(res, 404, { error: 'no such preset' });
await applyValues(presets[name].values);
scheduleSnapshot();
return send(res, 200, await caps());
}
if (action === 'delete') {
delete presets[name];
savePresets(presets);
return send(res, 200, { ok: true, presets: Object.keys(presets).sort() });
}
return send(res, 400, { error: 'unknown action' });
}
send(res, 404, { error: 'not found' });
} catch (e) {
send(res, 500, { error: String(e.message || e) });
}
});
/* ---- cli --------------------------------------------------------------- */
const cmd = process.argv[2] || 'serve';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/* ---- launchd agent ----------------------------------------------------- */
const AGENT_LABEL = 'camtint';
const UI_LABEL = 'camtint.ui';
const agentPlistPath = (label) =>
path.join(process.env.HOME, 'Library', 'LaunchAgents', `${label}.plist`);
const AGENT_PLIST = agentPlistPath(AGENT_LABEL);
const xml = (s) => String(s).replace(/[<>&'"]/g, (c) =>
({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]));
/*
* The agent is generated rather than shipped: it has to carry this checkout's
* absolute path and this machine's camera id, neither of which is portable.
*/
function plistFor(label, args, comment, keepAlive) {
return `<?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>Label</key>
<string>${label}</string>
<!-- Generated by "camtint install-agent" — re-run it if you move this folder.
${comment} -->
<key>ProgramArguments</key>
<array>
${args.map((a) => ` <string>${xml(a)}</string>`).join('\n')}
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<${keepAlive ? 'true' : 'false'}/>
<key>StandardOutPath</key>
<string>${xml(path.join(ROOT, 'restore.log'))}</string>
<key>StandardErrorPath</key>
<string>${xml(path.join(ROOT, 'restore.log'))}</string>
</dict>
</plist>
`;
}
const launchctl = (args) =>
new Promise((resolve) => execFile('launchctl', args, () => resolve()));
async function installAgent(opts = {}) {
let device = DEVICE;
if (!device) {
const cams = await cameras();
if (!cams.length) throw new Error('no UVC camera found — plug it in first');
device = cams[0].id;
if (cams.length > 1) {
console.log(`camtint: ${cams.length} cameras found, watching ${cams[0].name} (${device})`);
console.log(' set CAMTINT_DEVICE=vid:pid and re-run to pick a different one');
}
}
const uid = process.getuid();
const camtint = path.join(ROOT, 'camtint');
fs.mkdirSync(path.dirname(AGENT_PLIST), { recursive: true });
const load = async (label, args, comment, keepAlive) => {
const file = agentPlistPath(label);
fs.writeFileSync(file, plistFor(label, [camtint, ...args], comment, keepAlive));
await launchctl(['bootout', `gui/${uid}/${label}`]); // ignore "not loaded"
await launchctl(['bootstrap', `gui/${uid}`, file]);
return file;
};
await load(AGENT_LABEL, ['watch', '-d', device],
`Watches for the camera on USB and re-applies settings. Deliberately not a
launchd LaunchEvents/com.apple.iokit.matching rule: that requires the job to
consume an XPC event stream, and a job that cannot gets relaunched every 10
seconds forever. This registers its own IOKit notification and blocks at 0% CPU.`, true);
console.log(`camtint: watching ${device}; settings restore automatically on replug`);
if (opts.ui) {
await load(UI_LABEL, ['serve', ...(opts.open ? ['--open'] : [])],
'Starts the widget server at login. KeepAlive is off so "camtint stop" works.', false);
console.log(` UI server starts at login on http://127.0.0.1:${PORT}` +
(opts.open ? ' and opens the window' : ' (open it whenever you like)'));
} else {
await launchctl(['bootout', `gui/${uid}/${UI_LABEL}`]);
try { fs.unlinkSync(agentPlistPath(UI_LABEL)); } catch { /* was not installed */ }
}
console.log(` log: ${path.join(ROOT, 'restore.log')}`);
}
async function uninstallAgent() {
for (const label of [AGENT_LABEL, UI_LABEL]) {
await launchctl(['bootout', `gui/${process.getuid()}/${label}`]);
try {
fs.unlinkSync(agentPlistPath(label));
} catch { /* already gone */ }
}
console.log('camtint: login agents removed');
}
/*
* Called by launchd when the camera is plugged back in. The USB device shows up
* a beat before its VideoControl interface will answer, so poll for it instead
* of firing once and failing.
*/
async function restore() {
const cfg = loadAuto();
if (cfg.mode === 'off') return console.log('camtint: auto-restore disabled');
let values = null;
let label = '';
if (cfg.mode === 'preset' && cfg.name) {
const p = loadPresets()[cfg.name];
if (!p) throw new Error(`preset '${cfg.name}' no longer exists`);
values = p.values;
label = `preset '${cfg.name}'`;
} else {
const last = loadLast();
if (!last) throw new Error('no saved settings yet — open the widget and adjust something first');
values = last.values;
label = 'last used settings';
}
let ready = false;
for (let i = 0; i < 40 && !ready; i++) {
try {
await caps();
ready = true;
} catch {
await sleep(500);
}
}
if (!ready) throw new Error('camera did not come up within 20s');
/* A replug clears blackout in the hardware, so clear our flag too — otherwise
the switch would read "off" while the camera is plainly live. Fail visible. */
try {
fs.unlinkSync(BLACKOUT);
console.log('camtint: blackout cleared by the reconnect');
} catch { /* was not blacked out */ }
await applyValues(values);
console.log(`camtint: restored ${label} at ${new Date().toISOString()}`);
}
if (cmd === 'install-agent' || cmd === 'uninstall-agent') {
const flags = process.argv.slice(3);
const opts = { ui: flags.includes('--ui'), open: flags.includes('--open') };
if (opts.open) opts.ui = true;
(cmd === 'install-agent' ? installAgent(opts) : uninstallAgent()).catch((e) => {
console.error('camtint:', e.message);
process.exit(1);
});
} else if (cmd === 'restore') {
restore().catch((e) => {
console.error('camtint:', e.message);
process.exit(1);
});
} else if (cmd === 'apply') {
/* camtint apply <preset> — used to restore settings after a replug. */
const name = process.argv[3];
const presets = loadPresets();
if (!presets[name]) {
console.error(`camtint: no preset named '${name}'. Have: ${Object.keys(presets).join(', ') || '(none)'}`);
process.exit(1);
}
applyValues(presets[name].values)
.then((a) => {
console.log(`applied '${name}':`, a);
})
.catch((e) => {
console.error('camtint:', e.message);
process.exit(1);
});
} else if (cmd === 'presets') {
console.log(Object.keys(loadPresets()).sort().join('\n') || '(none)');
} else {
try {
execFileSync(UVCCTL, ['list'], { stdio: 'ignore' });
} catch {
console.error('camtint: no UVC camera detected — is your webcam plugged in?');
}
const pidfile = path.join(ROOT, 'camtint.pid');
server.listen(PORT, '127.0.0.1', () => {
fs.writeFileSync(pidfile, String(process.pid));
console.log(`camtint → http://127.0.0.1:${PORT}`);
});
const bye = () => {
try { fs.unlinkSync(pidfile); } catch { /* already gone */ }
process.exit(0);
};
process.on('SIGINT', bye);
process.on('SIGTERM', bye);
process.on('exit', () => {
try { fs.unlinkSync(pidfile); } catch { /* already gone */ }
});
}