← back to Dw Photo Capture
auto-save: 2026-06-25T08:32:54 (1 files) — server.js
8ca15e9f7484d42b088284a860ed5018e7ff2bd2 · 2026-06-25 08:32:56 -0700 · Steve Abrams
Files touched
Diff
commit 8ca15e9f7484d42b088284a860ed5018e7ff2bd2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jun 25 08:32:56 2026 -0700
auto-save: 2026-06-25T08:32:54 (1 files) — server.js
---
server.js | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 134 insertions(+)
diff --git a/server.js b/server.js
index 9030824..30db5dc 100644
--- a/server.js
+++ b/server.js
@@ -161,6 +161,58 @@ function checkAuth(req) {
return u === AUTH_USER && p === AUTH_PASS;
}
+// ───────────────────────── Remote-shutter pairing layer ─────────────────────────
+// Zero-new-dep: SSE (server→client push) + POST (client→server). One implicit room
+// since it's one operator — a single desktop "control" page pairs with a single
+// phone "cam" page. Either role can reconnect at will; the other side sees a live
+// status. No room codes needed (single-user). The desktop clicks SHOOT → we push a
+// `shoot` event to the phone → phone grabs a native-res frame, uploads it through the
+// EXISTING /api/photo pipeline → phone POSTs a `shot` ping → we push the result to
+// the desktop, which pulls the photo and routes it into the normal attach workflow.
+const ROOM = {
+ cam: null, // { res, lastSeen } — the phone's SSE response stream
+ desk: null, // { res, lastSeen } — the desktop's SSE response stream
+ camLive: false, // phone reports its getUserMedia stream is live
+ camInfo: '', // e.g. "1920x1080 · environment"
+ lastShot: null // { dw_sku, product_id, ok, live, live_reason, push_err, photo, ts }
+};
+function sseInit(res) {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform',
+ Connection: 'keep-alive', 'X-Accel-Buffering': 'no'
+ });
+ res.write('retry: 3000\n\n'); // tell EventSource to auto-reconnect after 3s
+}
+function sseSend(slot, event, data) {
+ const conn = ROOM[slot];
+ if (!conn || !conn.res) return false;
+ try { conn.res.write(`event: ${event}\ndata: ${JSON.stringify(data || {})}\n\n`); return true; }
+ catch (e) { return false; }
+}
+function pairStatus() {
+ const now = Date.now();
+ const fresh = c => c && c.res && (now - c.lastSeen) < 20000;
+ return {
+ cam_connected: fresh(ROOM.cam), desk_connected: fresh(ROOM.desk),
+ cam_live: !!(fresh(ROOM.cam) && ROOM.camLive), cam_info: ROOM.camInfo,
+ last_shot: ROOM.lastShot
+ };
+}
+// Push the current pairing status to BOTH ends so each page's banner stays accurate.
+function broadcastStatus() {
+ const s = pairStatus();
+ sseSend('cam', 'status', s);
+ sseSend('desk', 'status', s);
+}
+// Heartbeat: keep SSE sockets warm (proxies/iOS drop idle streams) + expire stale roles.
+setInterval(() => {
+ for (const slot of ['cam', 'desk']) {
+ const c = ROOM[slot];
+ if (c && c.res) { try { c.res.write(': ping\n\n'); } catch (e) { ROOM[slot] = null; } }
+ }
+ broadcastStatus();
+}, 8000);
+
function buildQueue() {
const q = loadJSON(QUEUE_FILE, []);
return q.map(item => {
@@ -188,6 +240,88 @@ const server = http.createServer((req, res) => {
if (u.pathname === '/healthz') return send(res, 200, { ok: true });
+ // ── Remote-shutter pairing routes ──
+ // SSE stream for the PHONE cam page. Registers the cam role; listens for `shoot`.
+ if (u.pathname === '/cam/events') {
+ sseInit(res);
+ ROOM.cam = { res, lastSeen: Date.now() };
+ sseSend('cam', 'status', pairStatus());
+ broadcastStatus();
+ req.on('close', () => { if (ROOM.cam && ROOM.cam.res === res) { ROOM.cam = null; ROOM.camLive = false; broadcastStatus(); } });
+ return;
+ }
+ // SSE stream for the DESKTOP control page. Registers the desk role; listens for `shot`/`status`.
+ if (u.pathname === '/desk/events') {
+ sseInit(res);
+ ROOM.desk = { res, lastSeen: Date.now() };
+ sseSend('desk', 'status', pairStatus());
+ broadcastStatus();
+ req.on('close', () => { if (ROOM.desk && ROOM.desk.res === res) { ROOM.desk = null; broadcastStatus(); } });
+ return;
+ }
+ // Phone reports its camera state (live/info) + keeps its lastSeen fresh.
+ if (u.pathname === '/api/pair/cam-status' && req.method === 'POST') {
+ let body = ''; req.on('data', c => body += c);
+ req.on('end', () => {
+ let p; try { p = JSON.parse(body); } catch (e) { p = {}; }
+ if (ROOM.cam) ROOM.cam.lastSeen = Date.now();
+ ROOM.camLive = !!p.live; ROOM.camInfo = String(p.info || '');
+ broadcastStatus();
+ return send(res, 200, { ok: true });
+ });
+ return;
+ }
+ // Desktop presses SHOOT → push a `shoot` event to the phone with the target SKU context.
+ // This does NOT itself write to Shopify — the phone captures + uploads via /api/photo,
+ // which keeps the exact same gated attach behavior as a manual capture today.
+ if (u.pathname === '/api/pair/shoot' && req.method === 'POST') {
+ let body = ''; req.on('data', c => body += c);
+ req.on('end', () => {
+ let p; try { p = JSON.parse(body); } catch (e) { p = {}; }
+ if (ROOM.desk) ROOM.desk.lastSeen = Date.now();
+ const st = pairStatus();
+ if (!st.cam_connected) return send(res, 409, { err: 'phone not connected' });
+ if (!st.cam_live) return send(res, 409, { err: 'phone camera not live — open /cam on the phone and allow the camera' });
+ // target = the SKU the desktop selected (or none → phone just captures into the local gallery)
+ const target = {
+ dw_sku: p.dw_sku || null, product_id: p.product_id || null,
+ keep_images: p.keep_images !== false, // default preserve existing imagery (safe)
+ meta: p.meta || null, nonce: Date.now()
+ };
+ const ok = sseSend('cam', 'shoot', target);
+ return send(res, ok ? 200 : 502, { ok, queued: ok });
+ });
+ return;
+ }
+ // Phone reports the result of a shot so the desktop can preview + see the attach outcome.
+ if (u.pathname === '/api/pair/shot' && req.method === 'POST') {
+ let body = ''; req.on('data', c => body += c);
+ req.on('end', () => {
+ let p; try { p = JSON.parse(body); } catch (e) { p = {}; }
+ if (ROOM.cam) ROOM.cam.lastSeen = Date.now();
+ ROOM.lastShot = Object.assign({ ts: new Date().toISOString() }, p);
+ sseSend('desk', 'shot', ROOM.lastShot);
+ return send(res, 200, { ok: true });
+ });
+ return;
+ }
+ // Status snapshot (polling fallback if a client's SSE is down).
+ if (u.pathname === '/api/pair/status' && req.method === 'GET') return send(res, 200, pairStatus());
+
+ // Desktop control page + phone live-camera page (both behind the existing basic-auth).
+ if (u.pathname === '/desk') {
+ const f = path.join(ROOT, 'public/desk.html');
+ if (!fs.existsSync(f)) return send(res, 404, { err: 'desk.html missing' });
+ let html = fs.readFileSync(f, 'utf8').replace(/__VER__/g, buildLabel());
+ return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html);
+ }
+ if (u.pathname === '/cam') {
+ const f = path.join(ROOT, 'public/cam.html');
+ if (!fs.existsSync(f)) return send(res, 404, { err: 'cam.html missing' });
+ let html = fs.readFileSync(f, 'utf8').replace(/__VER__/g, buildLabel());
+ return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html);
+ }
+
// /selfcheck — re-runnable health report. Verifies data integrity + AUTO-CLEANS stale
// photo refs (progress entries pointing at a deleted /photos file = the dead-link class).
if (u.pathname === '/selfcheck') {
← 8cdab9b Add /selfcheck self-check: server-side health report (catalo
·
back to Dw Photo Capture
·
Desktop-controlled remote shutter (DTD-C): /desk control pag f61b633 →