master
John Lauer Publish 0.5.55 0c09208
#!/usr/bin/env python3
"""Shared shape for the sample Hydrogen widgets: a tiny HTTP server that serves one page for
the title-bar slot and a JSON feed the page polls. A widget binary only needs: serve on
$HYDROGEN_WIDGET_PORT, answer GET / with HTML sized for a ~30 px strip, and keep the JSON fresh.
Hydrogen hands the slot over as soon as GET / answers (or the binary can POST
$ADOM_HD_CONTROL_URL/widgets/navigate {id, url} itself)."""
import json, os, sys, time, threading, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PAGE = """<!doctype html><meta charset="utf-8"><title>%(title)s</title>
<style>
html,body{margin:0;height:100%%;background:transparent;color:#e6ebef;font-family:"Familjen Grotesk","Satoshi",system-ui,sans-serif;overflow:hidden}
/* Two layouts, chosen by the slot's height (the Title Bar, full or compact), like the meter:
   tall = rows stacked to use the height; short = one horizontal row. */
.w{display:flex;align-items:center;height:100vh;padding:0 5px;gap:5px;white-space:nowrap}
.mark{display:inline-flex;flex:none;width:15px;height:15px}.mark svg{width:100%%;height:100%%}
.rows{display:flex;align-items:center;gap:8px;min-width:0;flex:1 1 auto}
.row{display:flex;align-items:center;gap:4px}
.lbl{opacity:.75;letter-spacing:.04em;text-transform:uppercase}
.bar{position:relative;border-radius:3px;background:rgba(255,255,255,.14);overflow:hidden}
.bar i{position:absolute;left:0;top:0;bottom:0;background:#19b3a6;border-radius:3px}
.bar.warn i{background:#f0a060}.bar.hot i{background:#e5484d}
.v{font-variant-numeric:tabular-nums;text-align:right}
.big{font-weight:600;font-variant-numeric:tabular-nums}
.err{color:#f0a060}
#w.stale{opacity:.45}
@media (max-height:33px){
  .w{font-size:clamp(7px,37vh,12px);line-height:1.2}
  .lbl{font-size:clamp(6px,30vh,9px)}.bar{width:clamp(24px,130vh,40px);height:clamp(4px,20vh,6px)}.v{min-width:2.4em}
  .big{font-size:clamp(9px,47vh,14px)}.err{font-size:clamp(7px,33vh,10px)}
  .mark{width:clamp(10px,55vh,17px);height:clamp(10px,55vh,17px)}
}
@media (min-height:34px){
  /* Tall: rows stacked. Columns take their natural width (label, value) and the bar takes the
     rest, so nothing is ever clipped whatever the slot width; a long label is trimmed with an
     ellipsis instead of pushing the value out of view. */
  /* Narrow on purpose (John 2026-09-14: "make this 1/3rd the width"): the bar is a short gauge,
     not a ruler; the label and value carry the number. About 90 px for three rows with a mark. */
  .w{font-size:8px;line-height:1;padding:1px 4px;gap:4px}
  .mark{width:13px;height:13px}
  .rows{flex-direction:column;align-items:stretch;justify-content:center;gap:2px;min-width:0}
  .row{display:grid;grid-template-columns:auto minmax(18px,1fr) auto;align-items:center;gap:3px;min-width:0}
  .lbl{font-size:6.5px}.row .lbl{max-width:4.2em;overflow:hidden;text-overflow:ellipsis}.bar{width:auto;height:4px}.v{font-size:8px;min-width:0}
  .big{font-size:14px}.err{font-size:8px;white-space:normal;line-height:1.1;max-width:96px}
}
@media (max-height:33px){ .err{overflow:hidden;text-overflow:ellipsis;max-width:100%%} }
</style><body><div class="w">%(mark_html)s<div class="rows" id="w">%(title)s…</div></div>
<script>
const R=%(refresh_ms)d;
function bar(l,p,sev){const c=(sev==='critical'||p>=90)?'hot':(sev==='warning'||p>=70)?'warn':'';return `<span class="row"><span class="lbl">${l}</span><span class="bar ${c}"><i style="width:${Math.max(0,Math.min(100,p))}%%"></i></span><span class="v">${Math.round(p)}%%</span></span>`;}
/* d.stale = the last good data while the provider is unreachable or the token is between refreshes:
   drawn dimmed, the reason in the tooltip, never an error where numbers were. */
function render(d0){const d=d0&&d0.stale?d0.stale:d0;const el=document.getElementById('w');el.classList.toggle('stale',!!(d0&&d0.stale));el.title=(d0&&d0.stale)?String(d0.error||''):'';el.innerHTML=%(render_js)s;postWidth();}
/* THE WIDGET DECIDES ITS WIDTH: ask the host for the intrinsic width of what is shown in the
   current layout (tall rows are narrow, the compact row is wide). Measured on a hidden clone laid
   out at max-content, so the slot's current width never feeds back into the answer (rows stretch to
   the slot, so measuring the live boxes would only ever grow). Works for any markup, not just rows. */
function postWidth(){try{const w=document.querySelector('.w');if(!w)return;
  const c=w.cloneNode(true);c.querySelectorAll('[id]').forEach(e=>e.removeAttribute('id'));
  c.style.cssText='position:absolute;left:-10000px;top:0;width:max-content;height:100vh;visibility:hidden;pointer-events:none';
  document.body.appendChild(c);const total=Math.ceil(c.getBoundingClientRect().width)+2;c.remove();
  if(total>0&&window.parent!==window)window.parent.postMessage({hdWidget:{width:total}},'*');}catch(e){}}
/* A failed poll (the widget process restarting, the proxy between the page and it) shows the last
   data dimmed and tries again in 5 s, then 15, then 45, then the normal interval: never "no data"
   for three minutes over a blip (AdomLapper 2026-09-14). */
let fails=0;async function tick(){try{const r=await fetch('/data.json',{cache:'no-store'});if(!r.ok)throw new Error('http '+r.status);const d=await r.json();fails=0;window.__last=d;render(d);}catch(e){fails++;if(window.__last){render({error:'no answer from the widget',stale:window.__last});}else{document.getElementById('w').innerHTML='<span class="err">no data</span>';postWidth();}const back=[5000,15000,45000][fails-1];if(back)setTimeout(tick,back);}}
/* align_ms: fire on the boundary (a clock ticks once per minute, on the minute) instead of every R ms. */
const A=%(align_ms)d;let tm=null;function arm(){if(tm)return;if(A){const ms=A-(Date.now()%%A)+50;tm=setTimeout(()=>{tm=null;tick();arm();},ms);}else tm=setInterval(tick,R);}function disarm(){if(tm){if(A)clearTimeout(tm);else clearInterval(tm);tm=null;}}
document.addEventListener('visibilitychange',()=>{if(document.hidden)disarm();else{tick();arm();}});
tick();if(!document.hidden)arm();
/* The slot resizes when the Title Bar goes compact or tall; the iframe's own window gets the resize event and the
   height media queries above re-evaluate. Re-render too, for widgets whose render depends on the size. */
let rz=null;window.addEventListener('resize',()=>{clearTimeout(rz);rz=setTimeout(()=>{if(window.__last)render(window.__last);},80);});
</script></body>"""

def serve(title, data_fn, render_js, refresh_ms=60000, mark=None, align_ms=0):
    mark = mark or {'svg': '', 'color': 'currentColor'}
    port = int(os.environ.get('HYDROGEN_WIDGET_PORT') or (sys.argv[sys.argv.index('--port')+1] if '--port' in sys.argv else 8930))
    # One upstream call per interval at most, however often the page asks; on an upstream
    # error the interval doubles up to 15 minutes and returns to normal on the next success.
    cache = {'t': 0, 'v': {'error': 'starting'}, 'wait': refresh_ms/1000, 'good': None}
    lock = threading.Lock()
    def _creds_mtime():
        try: return max(os.stat(os.path.expanduser(f)).st_mtime for f in ('~/.claude/.credentials.json', '~/.codex/auth.json') if os.path.exists(os.path.expanduser(f)))
        except Exception: return 0
    def data():
        with lock:
            due = time.time() - cache['t'] >= cache['wait'] - 1
            if cache.get('expired_mtime') is not None and not due and _creds_mtime() != cache['expired_mtime']:
                due = True  # the sign-in file changed: try now
            if due:
                cache['expired_mtime'] = None
                try:
                    cache['v'] = data_fn(); cache['wait'] = refresh_ms/1000
                    if not cache['v'].get('error'): cache['good'] = cache['v']
                except Exception as e:
                    # A 401/403 is almost always an EXPIRED access token, not a missing sign-in:
                    # Claude Code and Codex refresh their own tokens the next time they run, and
                    # the widget re-reads the file. Never tell a signed-in user to sign in again
                    # (AdomLapper 2026-09-14: "sign in to Claude again" while John was chatting).
                    # Say what is true, keep the last good numbers on screen (dimmed), and retry
                    # every minute instead of backing off; other errors still back off.
                    code = getattr(e, 'code', None)
                    if code in (401, 403):
                        msg = '%s token expired, waiting for %s to refresh it' % (title.replace(' usage', ''), 'Claude Code' if 'Claude' in title else 'Codex')
                        # No polling while expired (John 2026-09-14: "stop that loop when it is not
                        # needed"): the next call happens when the sign-in file changes, or after
                        # fifteen minutes as a safety net. A stat per page poll is the whole cost.
                        cache['wait'] = 900; cache['expired_mtime'] = _creds_mtime()
                    else:
                        msg = str(e)[:120]; cache['wait'] = min(900, max(refresh_ms/1000, cache['wait']) * 2)
                    cache['v'] = {'error': msg, 'stale': cache['good']} if cache['good'] else {'error': msg}
                cache['t'] = time.time()
            return cache['v']
    class H(BaseHTTPRequestHandler):
        def log_message(self, *a): pass
        def _send(self, code, ctype, body):
            self.send_response(code); self.send_header('content-type', ctype); self.send_header('cache-control', 'no-store')
            self.send_header('access-control-allow-origin', '*'); self.end_headers(); self.wfile.write(body)
        def do_GET(self):
            if self.path.startswith('/data.json'): self._send(200, 'application/json', json.dumps(data()).encode())
            elif self.path.startswith('/preview'):
                # /preview?h=38 (or ?w=175&h=38): this widget's own page in a frame of that height, offset
                # from the corner, so a browser screenshot of the frame (at any device scale) is a
                # clean, crisp picture for docs. Without w the frame follows the width the widget asks
                # for, exactly as the Title Bar slot does; with w it is pinned. The slot never uses this.
                from urllib.parse import urlparse, parse_qs
                q = parse_qs(urlparse(self.path).query)
                fixed = 'w' in q
                try: w = int(q.get('w', ['175'])[0]); h = int(q.get('h', ['38'])[0])
                except ValueError: w, h = 175, 38
                w = max(48, min(640, w)); h = max(16, min(120, h))
                page = ('<!doctype html><meta charset="utf-8"><title>%s preview</title>'
                        '<body style="margin:0;background:#0f1419"><div style="padding:150px 0 40px 40px">'
                        '<iframe id="w" src="/" data-w="0" style="width:%dpx;height:%dpx;border:1px solid #2c323a;border-radius:6px;background:#1c2128;display:block"></iframe>'
                        '</div><script>const fixed=%s;window.addEventListener("message",e=>{const m=e.data&&e.data.hdWidget;if(!m||!m.width)return;'
                        'const f=document.getElementById("w");f.dataset.w=m.width;if(!fixed)f.style.width=Math.max(48,Math.min(640,m.width))+"px";});</script></body>') % (title, w, h, 'true' if fixed else 'false')
                self._send(200, 'text/html; charset=utf-8', page.encode())
            elif self.path.startswith('/health'): self._send(200, 'application/json', b'{"ok":true}')
            else: self._send(200, 'text/html; charset=utf-8', (PAGE % {'title': title, 'refresh_ms': refresh_ms, 'align_ms': int(align_ms), 'render_js': render_js, 'mark_html': ('<span class="mark" style="color:%s"><svg viewBox="0 0 16 16">%s</svg></span>' % (mark['color'], mark['svg'])) if (mark and mark.get('svg')) else ''}).encode())
    # Bind with retries: a previous instance Hydrogen just stopped may still be releasing the
    # port for a moment. Ten tries over five seconds, then give up loudly (the log says why).
    ThreadingHTTPServer.allow_reuse_address = True
    srv = None
    for attempt in range(10):
        try:
            srv = ThreadingHTTPServer(('127.0.0.1', port), H); break
        except OSError as e:
            if attempt == 9: raise
            time.sleep(0.5)
    # Tell Hydrogen we are up (the health poll would find us anyway; this is the explicit handshake).
    ctl = os.environ.get('ADOM_HD_CONTROL_URL'); wid = os.environ.get('HYDROGEN_WIDGET_ID')
    if ctl and wid:
        def announce():
            try:
                proxy = os.environ.get('HYDROGEN_PROXY_PORT', '')
                url = f"http://{port}.localhost:{proxy}/" if proxy else f"http://127.0.0.1:{port}/"
                req = urllib.request.Request(f"{ctl}/widgets/navigate", data=json.dumps({'id': wid, 'url': url}).encode(), headers={'content-type': 'application/json'})
                urllib.request.urlopen(req, timeout=5)
            except Exception: pass
        threading.Timer(0.5, announce).start()
    srv.serve_forever()

_creds = {}
def read_json_if_changed(path):
    """Parse a JSON file only when its mtime moved; the parsed value is cached otherwise."""
    path = os.path.expanduser(path)
    st = os.stat(path)
    ent = _creds.get(path)
    if ent and ent[0] == st.st_mtime_ns: return ent[1]
    v = json.load(open(path)); _creds[path] = (st.st_mtime_ns, v); return v

def http_json(url, headers):
    req = urllib.request.Request(url, headers=headers)
    with urllib.request.urlopen(req, timeout=15) as r: return json.loads(r.read().decode())