screengraft 0.55.0 → 0.56.1

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.1",
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):
@@ -672,9 +680,10 @@ def _grain_gain(b) -> float:
672
680
  """How much of the surround's noise floor the screen carries.
673
681
 
674
682
  The page does not set this; fresh renders get grade.SCREEN_GRAIN_GAIN. It
675
- is read from the body so a sidecar replayed through the API keeps its own
676
- value -- a sidecar from before the gain existed carries none and passes
677
- 1.0 explicitly at the replay site, never here.
683
+ is read from the body so a caller that does send one (a future control, a
684
+ test) is honoured. Absent means the shipped gain, NOT 1.0: the API is for
685
+ new renders. Replaying an old sidecar is done through compose() directly,
686
+ where the parameter's own default (1.0) reproduces the old bytes.
678
687
  """
679
688
  try:
680
689
  g = float(b.get("grain_gain")) if b.get("grain_gain") is not None \
@@ -819,6 +828,44 @@ class Handler(BaseHTTPRequestHandler):
819
828
  pass
820
829
 
821
830
  # ---- helpers ----
831
+ def _authorised(self, q) -> bool:
832
+ """Two checks, both cheap, closing two different doors.
833
+
834
+ 1. `Sec-Fetch-Site`: a browser stamps every request with where it came
835
+ from. `same-origin` is our own page, `none` is the address bar or a
836
+ bookmark. Anything else -- `cross-site`, `same-site` -- is another
837
+ page on another origin driving this server, and is refused whether
838
+ or not it somehow holds the token (the URL can end up in a
839
+ screenshot). curl and urllib send no such header and pass this
840
+ check; that is what the token is for.
841
+ 2. The token, as the `X-Screengraft-Token` header (the page's fetches)
842
+ or the `t` query parameter (the page itself, <img>/<video> sources,
843
+ which cannot set headers). Constant-time compare, as a habit.
844
+ """
845
+ site = self.headers.get("Sec-Fetch-Site")
846
+ if site and site not in ("same-origin", "none"):
847
+ return False
848
+ tok = self.headers.get("X-Screengraft-Token") or (q.get("t") or [""])[0]
849
+ # compare_digest wants ASCII str on both sides; a token is url-safe
850
+ # base64, so anything else is wrong before it is compared.
851
+ if not (tok and TOKEN and tok.isascii()):
852
+ return False
853
+ return secrets.compare_digest(tok, TOKEN)
854
+
855
+ def _refuse(self, u):
856
+ if u.path == "/":
857
+ body = (b"<!doctype html><meta charset=utf-8><title>screengraft</title>"
858
+ b"<p style='font:14px system-ui;margin:2em'>This page needs the link the "
859
+ b"launcher printed &mdash; it carries a one-session token. "
860
+ b"Relaunch with <code>scripts/launch.sh</code> and open the URL it prints.")
861
+ self.send_response(403)
862
+ self.send_header("Content-Type", "text/html; charset=utf-8")
863
+ self.send_header("Content-Length", str(len(body)))
864
+ self.end_headers()
865
+ self.wfile.write(body)
866
+ return
867
+ self._json({"error": "missing or wrong session token"}, 403)
868
+
822
869
  def _json(self, obj, code=200):
823
870
  body = json.dumps(obj).encode()
824
871
  self.send_response(code)
@@ -893,6 +940,13 @@ class Handler(BaseHTTPRequestHandler):
893
940
  def do_GET(self):
894
941
  u = urllib.parse.urlparse(self.path)
895
942
  q = urllib.parse.parse_qs(u.query)
943
+ # The one open route: liveness. Says nothing but that a screengraft is
944
+ # here and which version -- launch.sh and the skill probe it before
945
+ # telling anyone the UI is ready, with no token in hand.
946
+ if u.path == "/api/ping":
947
+ return self._json({"ok": True, "version": VERSION})
948
+ if not self._authorised(q):
949
+ return self._refuse(u)
896
950
  try:
897
951
  if u.path == "/":
898
952
  return self._file(UI_HTML, "text/html; charset=utf-8")
@@ -950,6 +1004,20 @@ class Handler(BaseHTTPRequestHandler):
950
1004
  # ---- POST ----
951
1005
  def do_POST(self):
952
1006
  u = urllib.parse.urlparse(self.path)
1007
+ if not self._authorised(urllib.parse.parse_qs(u.query)):
1008
+ # Read the body off the wire first (bounded): refusing while the
1009
+ # client is still sending turns a clean 403 into a broken pipe on
1010
+ # its side, which reads as "the server died", not "you were refused".
1011
+ try:
1012
+ n = min(int(self.headers.get("Content-Length") or 0), 64 << 20)
1013
+ except ValueError:
1014
+ n = 0
1015
+ while n > 0:
1016
+ chunk = self.rfile.read(min(n, 1 << 20))
1017
+ if not chunk: # client gone; nothing left to drain
1018
+ break
1019
+ n -= len(chunk)
1020
+ return self._refuse(u)
953
1021
  try:
954
1022
  if u.path == "/api/upload":
955
1023
  # raw bytes + X-Filename + X-Role (photo|screenshot); no multipart, no cgi module.
@@ -1473,7 +1541,7 @@ def _publish_current(payload):
1473
1541
 
1474
1542
 
1475
1543
  def main():
1476
- global SESSION, OUT_DIR, VERSION, BUILD
1544
+ global SESSION, OUT_DIR, VERSION, BUILD, TOKEN
1477
1545
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
1478
1546
  ap.add_argument("--port", type=int, default=0, help="0 = pick a free port")
1479
1547
  ap.add_argument("--no-open", action="store_true", help="Don't open the browser")
@@ -1498,7 +1566,11 @@ def main():
1498
1566
  sdir = args.session or os.path.join(HOME, ".screengraft", "sessions", time.strftime("%Y%m%d-%H%M%S"))
1499
1567
  SESSION = Session(sdir)
1500
1568
  port = args.port or free_port()
1501
- url = f"http://127.0.0.1:{port}/"
1569
+ TOKEN = secrets.token_urlsafe(18)
1570
+ base = f"http://127.0.0.1:{port}/"
1571
+ # `url` is what a person opens; `base` + the token header is what a tool
1572
+ # uses. Kept apart so nobody has to strip a query string off a URL.
1573
+ url = f"{base}?t={TOKEN}"
1502
1574
  srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
1503
1575
  freed = _prune_sessions(sdir)
1504
1576
  # The live session's own copies go when this process does. Registered before
@@ -1506,11 +1578,11 @@ def main():
1506
1578
  # scripts/stop.sh sends SIGTERM for exactly this reason. A kill -9 cannot be
1507
1579
  # caught, which is what the launch-time sweep above is for.
1508
1580
  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)
1581
+ _publish_current({"session": sdir, "url": url, "base": base, "token": TOKEN,
1582
+ "pid": os.getpid(), "out_dir": OUT_DIR, "started": time.time()})
1583
+ print(json.dumps({"url": url, "base": base, "token": TOKEN, "session": sdir,
1584
+ "job": SESSION.job_path, "result": SESSION.result_path,
1585
+ "out_dir": OUT_DIR, "reclaimed_mb": round(freed / 1e6, 1)}), flush=True)
1514
1586
  if not args.no_open:
1515
1587
  opener = "open" if sys.platform == "darwin" else "xdg-open"
1516
1588
  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