[object Object]

← 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 →