screengraft 0.55.0 → 0.56.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
5
5
  "keywords": [
6
6
  "mockup",
package/scripts/launch.sh CHANGED
@@ -73,13 +73,15 @@ if [ ! -s "$LOG" ]; then
73
73
  exit 1
74
74
  fi
75
75
  URL=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['url'])" "$LOG" 2>/dev/null || true)
76
- if [ -z "$URL" ]; then
76
+ BASE=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['base'])" "$LOG" 2>/dev/null || true)
77
+ if [ -z "$URL" ] || [ -z "$BASE" ]; then
77
78
  echo "error: couldn't parse url from $LOG:" >&2
78
79
  cat "$LOG" >&2
79
80
  exit 1
80
81
  fi
81
82
  sleep 1
82
- if ! curl -sf -o /dev/null "${URL}api/state"; then
83
+ # /api/ping is the one route that needs no token: it answers liveness only.
84
+ if ! curl -sf -o /dev/null "${BASE}api/ping"; then
83
85
  echo "error: server not responding at $URL after launch. Log: $LOG" >&2
84
86
  cat "$LOG" >&2
85
87
  exit 1
package/scripts/ui.py CHANGED
@@ -31,6 +31,7 @@ import atexit
31
31
  import json
32
32
  import mimetypes
33
33
  import os
34
+ import secrets
34
35
  import signal
35
36
  import socket
36
37
  import subprocess
@@ -65,6 +66,13 @@ UI_HTML = os.path.join(ROOT, "ui", "index.html")
65
66
  # to find the session, and checks the pid so a pointer left by a crashed UI is
66
67
  # treated as no UI at all rather than one that never answers.
67
68
  CURRENT = os.path.join(HOME, ".screengraft", "current.json")
69
+ # The session token. Minted once per launch in main(); the page gets it in its
70
+ # URL (`/?t=<token>`) and echoes it on every request. Without it the server
71
+ # binds 127.0.0.1 but authenticates nothing: a page the person happens to have
72
+ # open in the same browser can sweep localhost ports and fire blind POSTs --
73
+ # not readable back (same-origin), but delivered, and one of them enqueues a
74
+ # job that an agent then acts on. See _authorised() for the two checks.
75
+ TOKEN = None
68
76
 
69
77
 
70
78
  def _write_json_atomic(path, obj):
@@ -819,6 +827,40 @@ class Handler(BaseHTTPRequestHandler):
819
827
  pass
820
828
 
821
829
  # ---- helpers ----
830
+ def _authorised(self, q) -> bool:
831
+ """Two checks, both cheap, closing two different doors.
832
+
833
+ 1. `Sec-Fetch-Site`: a browser stamps every request with where it came
834
+ from. `same-origin` is our own page, `none` is the address bar or a
835
+ bookmark. Anything else -- `cross-site`, `same-site` -- is another
836
+ page on another origin driving this server, and is refused whether
837
+ or not it somehow holds the token (the URL can end up in a
838
+ screenshot). curl and urllib send no such header and pass this
839
+ check; that is what the token is for.
840
+ 2. The token, as the `X-Screengraft-Token` header (the page's fetches)
841
+ or the `t` query parameter (the page itself, <img>/<video> sources,
842
+ which cannot set headers). Constant-time compare, as a habit.
843
+ """
844
+ site = self.headers.get("Sec-Fetch-Site")
845
+ if site and site not in ("same-origin", "none"):
846
+ return False
847
+ tok = self.headers.get("X-Screengraft-Token") or (q.get("t") or [""])[0]
848
+ return bool(tok) and bool(TOKEN) and secrets.compare_digest(tok, TOKEN)
849
+
850
+ def _refuse(self, u):
851
+ if u.path == "/":
852
+ body = (b"<!doctype html><meta charset=utf-8><title>screengraft</title>"
853
+ b"<p style='font:14px system-ui;margin:2em'>This page needs the link the "
854
+ b"launcher printed &mdash; it carries a one-session token. "
855
+ b"Relaunch with <code>scripts/launch.sh</code> and open the URL it prints.")
856
+ self.send_response(403)
857
+ self.send_header("Content-Type", "text/html; charset=utf-8")
858
+ self.send_header("Content-Length", str(len(body)))
859
+ self.end_headers()
860
+ self.wfile.write(body)
861
+ return
862
+ self._json({"error": "missing or wrong session token"}, 403)
863
+
822
864
  def _json(self, obj, code=200):
823
865
  body = json.dumps(obj).encode()
824
866
  self.send_response(code)
@@ -893,6 +935,13 @@ class Handler(BaseHTTPRequestHandler):
893
935
  def do_GET(self):
894
936
  u = urllib.parse.urlparse(self.path)
895
937
  q = urllib.parse.parse_qs(u.query)
938
+ # The one open route: liveness. Says nothing but that a screengraft is
939
+ # here and which version -- launch.sh and the skill probe it before
940
+ # telling anyone the UI is ready, with no token in hand.
941
+ if u.path == "/api/ping":
942
+ return self._json({"ok": True, "version": VERSION})
943
+ if not self._authorised(q):
944
+ return self._refuse(u)
896
945
  try:
897
946
  if u.path == "/":
898
947
  return self._file(UI_HTML, "text/html; charset=utf-8")
@@ -950,6 +999,14 @@ class Handler(BaseHTTPRequestHandler):
950
999
  # ---- POST ----
951
1000
  def do_POST(self):
952
1001
  u = urllib.parse.urlparse(self.path)
1002
+ if not self._authorised(urllib.parse.parse_qs(u.query)):
1003
+ # Read the body off the wire first (bounded): refusing while the
1004
+ # client is still sending turns a clean 403 into a broken pipe on
1005
+ # its side, which reads as "the server died", not "you were refused".
1006
+ n = min(int(self.headers.get("Content-Length") or 0), 64 << 20)
1007
+ while n > 0:
1008
+ n -= len(self.rfile.read(min(n, 1 << 20)) or b"\0")
1009
+ return self._refuse(u)
953
1010
  try:
954
1011
  if u.path == "/api/upload":
955
1012
  # raw bytes + X-Filename + X-Role (photo|screenshot); no multipart, no cgi module.
@@ -1473,7 +1530,7 @@ def _publish_current(payload):
1473
1530
 
1474
1531
 
1475
1532
  def main():
1476
- global SESSION, OUT_DIR, VERSION, BUILD
1533
+ global SESSION, OUT_DIR, VERSION, BUILD, TOKEN
1477
1534
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
1478
1535
  ap.add_argument("--port", type=int, default=0, help="0 = pick a free port")
1479
1536
  ap.add_argument("--no-open", action="store_true", help="Don't open the browser")
@@ -1498,7 +1555,11 @@ def main():
1498
1555
  sdir = args.session or os.path.join(HOME, ".screengraft", "sessions", time.strftime("%Y%m%d-%H%M%S"))
1499
1556
  SESSION = Session(sdir)
1500
1557
  port = args.port or free_port()
1501
- url = f"http://127.0.0.1:{port}/"
1558
+ TOKEN = secrets.token_urlsafe(18)
1559
+ base = f"http://127.0.0.1:{port}/"
1560
+ # `url` is what a person opens; `base` + the token header is what a tool
1561
+ # uses. Kept apart so nobody has to strip a query string off a URL.
1562
+ url = f"{base}?t={TOKEN}"
1502
1563
  srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
1503
1564
  freed = _prune_sessions(sdir)
1504
1565
  # The live session's own copies go when this process does. Registered before
@@ -1506,11 +1567,11 @@ def main():
1506
1567
  # scripts/stop.sh sends SIGTERM for exactly this reason. A kill -9 cannot be
1507
1568
  # caught, which is what the launch-time sweep above is for.
1508
1569
  atexit.register(lambda: _sweep_session(sdir))
1509
- _publish_current({"session": sdir, "url": url, "pid": os.getpid(),
1510
- "out_dir": OUT_DIR, "started": time.time()})
1511
- print(json.dumps({"url": url, "session": sdir, "job": SESSION.job_path,
1512
- "result": SESSION.result_path, "out_dir": OUT_DIR,
1513
- "reclaimed_mb": round(freed / 1e6, 1)}), flush=True)
1570
+ _publish_current({"session": sdir, "url": url, "base": base, "token": TOKEN,
1571
+ "pid": os.getpid(), "out_dir": OUT_DIR, "started": time.time()})
1572
+ print(json.dumps({"url": url, "base": base, "token": TOKEN, "session": sdir,
1573
+ "job": SESSION.job_path, "result": SESSION.result_path,
1574
+ "out_dir": OUT_DIR, "reclaimed_mb": round(freed / 1e6, 1)}), flush=True)
1514
1575
  if not args.no_open:
1515
1576
  opener = "open" if sys.platform == "darwin" else "xdg-open"
1516
1577
  threading.Timer(0.3, lambda: subprocess.Popen([opener, url])).start()
@@ -5,7 +5,7 @@ description: Injects a UI screenshot OR a screen recording onto a photographed d
5
5
 
6
6
  # Inject a screenshot onto a photographed device
7
7
 
8
- **What ships (v0.55):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
8
+ **What ships (v0.56):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
9
9
 
10
10
  The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it. **When a detection is wrong and you want to know why**, ask for the candidate list: `POST /api/detect {"trace": true}` writes `<session>/candidates.json`, or run `python3 scripts/detect.py --photo P --out-corners /tmp/c.json --trace /tmp/t.json` (add `--click X,Y`). Every candidate quad is in there with its score and whether it was accepted, rejected, never reached, or filtered out by the click — which is what separates "the screen was never proposed" from "it was proposed and something else won".
11
11
 
@@ -60,9 +60,9 @@ scripts/launch.sh --out-dir "<the user's current project folder>/mockups"
60
60
 
61
61
  A Terminal.app window was the previous fix for the same problem. It worked but left a dead "[Process completed]" window behind after every session, and closing those from AppleScript proved unreliable — Terminal reports stale ttys for dead windows and leaves zero-tab husks that `close` reports success on. The daemon removes the window entirely, so there is nothing to clean up.
62
62
 
63
- It prints one JSON line — `url`, `session`, `job`, `result`, `out_dir` — and opens the browser tab itself. If it exits non-zero, read the error and the log path it names before telling the user anything is ready.
63
+ It prints one JSON line — `url`, `base`, `token`, `session`, `job`, `result`, `out_dir` — and opens the browser tab itself. `url` carries a one-session token (`/?t=…`); the server refuses any request without it, so always hand the user `url`, never `base`. If it exits non-zero, read the error and the log path it names before telling the user anything is ready.
64
64
 
65
- **Verify liveness again right before telling the user to interact with it** — `curl -sf <url>api/state` — especially if any time has passed since launch. If that fails, the server died; say so, relaunch, and have the user redo their last action rather than assuming it's still there.
65
+ **Verify liveness again right before telling the user to interact with it** — `curl -sf <base>api/ping` (the one route that needs no token) — especially if any time has passed since launch. If that fails, the server died; say so, relaunch, and have the user redo their last action rather than assuming it's still there.
66
66
 
67
67
  **Then explain the job in chat.** The page deliberately carries no onboarding — the explanation belongs here, where the user already is. Say this, in your own words but keeping all of it:
68
68
 
package/ui/index.html CHANGED
@@ -1444,11 +1444,18 @@ function toast(kind, html, opts = {}) {
1444
1444
  return item;
1445
1445
  }
1446
1446
 
1447
+ // The session token rides in the page's own URL (`/?t=...`, minted per launch)
1448
+ // and goes back on every request: as a header from fetch, as a query
1449
+ // parameter on <img>/<video> sources, which cannot set headers. The server
1450
+ // refuses anything without it, and anything cross-site with it.
1451
+ const TOKEN = new URLSearchParams(location.search).get('t') || '';
1452
+ const authFetch = (p, init = {}) =>
1453
+ fetch(p, {...init, headers: {...(init.headers || {}), 'X-Screengraft-Token': TOKEN}});
1447
1454
  const api = async (p, body, raw) => {
1448
- const r = await fetch(p, body===undefined ? {} : {method:'POST', headers: raw ? raw.headers : {'Content-Type':'application/json'}, body: raw ? raw.body : JSON.stringify(body)});
1455
+ const r = await authFetch(p, body===undefined ? {} : {method:'POST', headers: raw ? raw.headers : {'Content-Type':'application/json'}, body: raw ? raw.body : JSON.stringify(body)});
1449
1456
  const j = await r.json(); if (!r.ok) throw new Error(j.error || r.statusText); return j;
1450
1457
  };
1451
- const fileURL = p => '/file?path=' + encodeURIComponent(p);
1458
+ const fileURL = p => '/file?t=' + encodeURIComponent(TOKEN) + '&path=' + encodeURIComponent(p);
1452
1459
  // A source's URL carries the file's mtime, so new bytes at an old path are
1453
1460
  // fetched rather than served from the browser's cache (see _adopt in ui.py).
1454
1461
  const srcURL = (p, v) => fileURL(p) + (v ? '&v=' + v : '');
@@ -3291,7 +3298,7 @@ async function playClip(){
3291
3298
  // that FINISHED left the button on "Rendering…" for ever, with no toast and
3292
3299
  // Send to Claude never enabled. It looks exactly like a hung encode, which
3293
3300
  // is why it was reported as "no progress" rather than as a broken poll.
3294
- let d; try { d = await (await fetch('/api/render_status')).json(); }
3301
+ let d; try { d = await (await authFetch('/api/render_status')).json(); }
3295
3302
  catch(e){ return; }
3296
3303
  if (d.state === 'running'){
3297
3304
  // The wait is real — tens of seconds on a long clip — so it counts frames
@@ -3481,7 +3488,7 @@ async function renderVideo(){
3481
3488
  // that FINISHED left the button on "Rendering…" for ever, with no toast and
3482
3489
  // Send to Claude never enabled. It looks exactly like a hung encode, which
3483
3490
  // is why it was reported as "no progress" rather than as a broken poll.
3484
- let d; try { d = await (await fetch('/api/render_status')).json(); }
3491
+ let d; try { d = await (await authFetch('/api/render_status')).json(); }
3485
3492
  catch(e){ return; }
3486
3493
  if (d.state === 'running'){
3487
3494
  // The button IS the progress bar now. A spinner cannot be told apart