main
Kyle Bergstedt Publish 1.1.112 8d0a92c 6d ago
#!/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).

Views (John 2026-09-15, "the font size is too small"): a widget that returns `items`
(label, pct, severity) gets a set of layouts for free and the user clicks the slot to cycle
them: every meter as rows (the original), one item alone as a big number, one item as a ring
gauge, any pair side by side, all items as numbers, all items as rings. Each view asks the
Title Bar for exactly the width it needs, so a single number takes a third of the space the
rows did and its digits fill the slot's height. The chosen view is remembered on disk.

Robustness (John 2026-09-15, "HTTP Error 404" after a reboot): the last good numbers are
saved to disk and shown dimmed from the first second after a restart; a provider error is said
in plain words ("no answer from OpenAI (HTTP 404), retrying") and retried at 5, 15, 45 s
before backing off; the page polls again at the server's own retry time, never three minutes
later."""
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;cursor:pointer;user-select:none}
.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}
/* An error is a button: a click checks again now (John 2026-09-17: "make this clickable so i can
   manually refresh it after i login", and "i can't read this text its so small"). */
#wrap.act{cursor:pointer}#wrap.act:hover .err{text-decoration:underline;text-underline-offset:2px}
.checking{opacity:.7}
/* Stale (last numbers while the provider is between answers): a light fade only, so the colours
   read the same as a fresh neighbour (John 2026-09-15: "the claude one has washed out colors"). */
#w.stale{opacity:.78}
/* Views: a big number per item (cell), pairs with a hairline between (sep), ring gauges. */
.cell{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:1px;min-width:0}
.pct{font-weight:700;font-variant-numeric:tabular-nums;line-height:1;color:#19b3a6;letter-spacing:-.01em}
.pct.warn{color:#f0a060}.pct.hot{color:#e5484d}
.sep{flex:none;width:1px;align-self:stretch;background:rgba(255,255,255,.18);margin:0 2px}
.rw{display:flex;align-items:center;gap:4px}
.ring{position:relative;flex:none}.ring svg{width:100%%;height:100%%;transform:rotate(-90deg);display:block}
.ring .in{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-weight:700;font-variant-numeric:tabular-nums;line-height:1}
@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(9px,52vh,12px)}
  .mark{width:clamp(10px,55vh,17px);height:clamp(10px,55vh,17px)}
  .cell{flex-direction:row;align-items:baseline;gap:3px}
  .pct{font-size:clamp(9px,66vh,14px)}.cell .lbl{font-size:clamp(6px,32vh,8px)}
  .ring{width:clamp(10px,80vh,17px);height:clamp(10px,80vh,17px)}.ring .in{display:none}
}
@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}
  .rows.hz{flex-direction:row;align-items:center;gap:6px}
  .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:11.5px;white-space:normal;line-height:1.15;max-width:150px}
  /* Big numbers fill the 36 px the slot gives: 22 px digits over a 7 px label. */
  .cell .lbl{font-size:7px}.pct{font-size:22px}
  /* One percentage size in every view (John 2026-09-15: Claude's all-numbers view sat at 11.6 px
     beside Codex's single number at 13.2 px, and the two widgets are always side by side). */
  .nums .cell{gap:2px}
  .ring{width:30px;height:30px}.ring .in{font-size:9.5px}
  .rw .cell .lbl{font-size:7px}
}
@media (max-height:33px){ .err{overflow:hidden;text-overflow:ellipsis;max-width:100%%} }
</style><body><div class="w" id="wrap">%(mark_html)s<div class="rows" id="w">%(title)s…</div></div>
<script>
const R=%(refresh_ms)d;
function sev(p,s){return (s==='critical'||p>=90)?'hot':(s==='warning'||p>=70)?'warn':'';}
function bar(l,p,s){const c=sev(p,s);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>`;}
function cell(it){return `<span class="cell"><span class="lbl">${it.label}</span><span class="pct ${sev(it.pct,it.severity)}">${Math.round(it.pct)}%%</span></span>`;}
function ring(it,withLabel){const p=Math.max(0,Math.min(100,it.pct));const r=15.5,c=2*Math.PI*r;const col={hot:'#e5484d',warn:'#f0a060'}[sev(it.pct,it.severity)]||'#19b3a6';
  const g=`<span class="ring"><svg viewBox="0 0 36 36"><circle cx="18" cy="18" r="${r}" fill="none" stroke="rgba(255,255,255,.14)" stroke-width="4.5"/><circle cx="18" cy="18" r="${r}" fill="none" stroke="${col}" stroke-width="4.5" stroke-dasharray="${(p/100*c).toFixed(1)} ${c.toFixed(1)}" stroke-linecap="round"/></svg><span class="in">${Math.round(p)}</span></span>`;
  const compact=window.innerHeight<=33;
  const lab=compact?`<span class="cell"><span class="lbl">${it.label}</span><span class="pct ${sev(it.pct,it.severity)}">${Math.round(p)}%%</span></span>`:`<span class="lbl">${it.label}</span>`;
  return `<span class="rw">${g}${withLabel?lab:''}</span>`;}
/* The views a set of items offers, in click order. */
function viewsFor(items){const n=items.length;const v=[{id:'rows',name:'all meters'}];
  items.forEach((it,i)=>v.push({id:'big:'+i,name:it.label+' only'}));
  items.forEach((it,i)=>v.push({id:'ring:'+i,name:it.label+' as a ring'}));
  for(let i=0;i<n;i++)for(let j=i+1;j<n;j++)v.push({id:'duo:'+i+','+j,name:items[i].label+' + '+items[j].label});
  if(n>1){v.push({id:'nums',name:'all as numbers'});v.push({id:'rings',name:'all as rings'});}
  return v;}
/* A widget whose default is 'nums' (all as numbers) and that has a single limit gets that limit as
   one big number, the same look: 'nums' needs two items, and falling to the first view (rows) showed
   Codex's lone week as a thin meter (John 2026-10-01). */
const DEF_VIEW='%(default_view)s';
let curView=(function(){try{return localStorage.getItem('hw.view')||DEF_VIEW;}catch(e){return DEF_VIEW;}})();
function renderItems(items,id){const [kind,arg]=String(id).split(':');const at=(k)=>items[Math.max(0,Math.min(items.length-1,+k||0))];
  switch(kind){
    case 'big':return cell(at(arg));
    case 'ring':return ring(at(arg),true);
    case 'duo':{const [a,b]=String(arg||'0,1').split(',');return cell(at(a))+'<span class="sep"></span>'+cell(at(b));}
    case 'nums':return '<span class="nums rw">'+items.map(cell).join('<span class="sep"></span>')+'</span>';
    case 'rings':return '<span class="nums rw">'+items.map(it=>ring(it,true)).join('')+'</span>';
    default:return items.map(b=>bar(b.label,b.pct,b.severity)).join('');}}
/* 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');const wrap=document.getElementById('wrap');
  el.classList.toggle('stale',!!(d0&&d0.stale));let tip=(d0&&d0.stale)?String(d0.error||''):'';
  if(d0&&d0.view&&d0.view!==curView){curView=d0.view;try{localStorage.setItem('hw.view',curView);}catch(e){}}
  if(d&&Array.isArray(d.items)&&d.items.length){const vs=viewsFor(d.items);let k=vs.findIndex(v=>v.id===curView);if(k<0){k=vs.findIndex(v=>v.id===DEF_VIEW);if(k<0&&DEF_VIEW==='nums')k=vs.findIndex(v=>v.id==='big:0');if(k<0)k=0;curView=vs[k].id;}
    el.classList.toggle('hz',curView!=='rows');el.innerHTML=renderItems(d.items,curView);
    tip=(tip?tip+'. ':'')+'%(title)s: '+vs[k].name+'. Click for the next view ('+(k+1)+' of '+vs.length+').';}
  else{el.classList.remove('hz');el.innerHTML=%(render_js)s;tip=(d0&&d0.error?String(d0.error)+'. ':'')+'Click to check again now.';}
  wrap.classList.toggle('act',!!(d0&&d0.error));wrap.title=tip;postWidth();}
/* The click's own re-render must carry the NEW view: window.__last is the last poll's copy, whose
   view field still names the old one, and render() adopts the server's view over the local one.
   Without this line a click changed the server and nothing on screen until the next poll, three
   minutes later (Ray, adom/hydrogen#81, 2026-09-15). */
function setView(id){curView=id;try{localStorage.setItem('hw.view',id);}catch(e){}
  if(window.__last)window.__last.view=id;
  fetch('/view',{method:'POST',body:id}).then(r=>{if(!r.ok)throw new Error('http '+r.status);}).catch(()=>{setTimeout(()=>fetch('/view',{method:'POST',body:id}).catch(()=>{}),3000);});
  if(window.__last)render(window.__last);}
/* CHECK NOW: POST /refresh makes the server ask the provider (or re-read the sign-in file) right away
   instead of at its next scheduled check, and answers with the fresh data. One call per click. */
let refreshing=false;
async function refreshNow(showChecking){if(refreshing)return;refreshing=true;
  if(showChecking){const el=document.getElementById('w');el.classList.remove('hz');el.innerHTML='<span class="err checking">checking…</span>';postWidth();}
  try{const r=await fetch('/refresh',{method:'POST',cache:'no-store'});if(!r.ok)throw new Error('http '+r.status);const d=await r.json();window.__last=d;render(d);}
  catch(e){tick();}finally{refreshing=false;}}
document.getElementById('wrap').addEventListener('click',()=>{const d0=window.__last;const d=d0&&d0.stale?d0.stale:d0;
  if(d0&&d0.error){if(!d||!Array.isArray(d.items)||!d.items.length){refreshNow(true);return;}refreshNow(false);}
  if(!d||!Array.isArray(d.items)||!d.items.length)return;
  const vs=viewsFor(d.items);const k=vs.findIndex(v=>v.id===curView);setView(vs[(k+1)%%vs.length].id);});
/* 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). When the server itself is waiting on the
   provider it says when it will try again (retry_in) and the page asks again right after. */
let fails=0,qt=null;function quick(ms){clearTimeout(qt);qt=setTimeout(tick,ms);}
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);
  if(d.error&&typeof d.retry_in==='number'&&d.retry_in*1000<R)quick(Math.max(2000,d.retry_in*1000+500));}
  catch(e){fails++;if(window.__last){render({error:'no answer from the widget, retrying',stale:window.__last.stale||window.__last});}else{render({error:'no answer from the widget, retrying'});}
    quick([5000,15000,45000][Math.min(fails-1,2)]);}}
/* 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 _state_path(title):
    d = os.path.expanduser('~/.adom/widgets'); os.makedirs(d, exist_ok=True)
    return os.path.join(d, ''.join(ch if ch.isalnum() else '-' for ch in title.lower()) + '.state.json')

def _load_state(title):
    try: return json.load(open(_state_path(title)))
    except Exception: return {}

def _save_state(title, st):
    try:
        p = _state_path(title); tmp = p + '.tmp'
        json.dump(st, open(tmp, 'w')); os.replace(tmp, p)
    except Exception: pass

def serve(title, data_fn, render_js, refresh_ms=60000, mark=None, align_ms=0, default_view='rows'):
    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))
    provider = 'Anthropic' if 'Claude' in title else ('OpenAI' if 'Codex' in title else 'the provider')
    # One upstream call per interval at most, however often the page asks. On an upstream error
    # the next tries come at 5, 15, 45, 90 s, then minutes, capped at 15; back to normal on the
    # next success. The last good numbers live on disk so a restart shows them at once, dimmed.
    st = _load_state(title)
    # The view a fresh install opens on: the widget's choice (Claude and Codex: every limit as a
    # big number in a row, John 2026-10-01; Codex was "the week only" from 2026-09-19), until the
    # user clicks to another one, which is remembered on disk and wins from then on.
    cache = {'t': 0, 'v': {'error': 'starting'}, 'wait': refresh_ms/1000, 'good': None, 'fails': 0, 'view': str(st.get('view') or default_view)}
    if isinstance(st.get('good'), dict) and st['good'].get('items') is not None:
        cache['good'] = st['good']
        when = time.strftime('%H:%M', time.localtime(st.get('at') or 0)) if st.get('at') else 'earlier'
        cache['v'] = {'error': 'waiting for %s, last numbers from %s' % (provider, when), 'stale': cache['good']}
    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']; cache['fails'] = 0
                        _save_state(title, {'good': cache['good'], 'at': time.time(), 'view': cache['view']})
                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:
                        # Plain words, never a raw exception (AdomLapper 2026-09-15 after a reboot:
                        # "HTTP Error 404: Not Found" sat in the Title Bar for fifteen minutes while
                        # the endpoint answered 200 to a manual call a minute later).
                        reason = ('HTTP %s' % code) if code else ('network' if 'URLError' in type(e).__name__ or 'timed out' in str(e) else type(e).__name__)
                        msg = 'no answer from %s (%s), retrying' % (provider, reason)
                        cache['fails'] += 1
                        cache['wait'] = [5, 15, 45, 90, 180, 300, 600, 900][min(cache['fails'] - 1, 7)]
                    cache['v'] = {'error': msg, 'stale': cache['good']} if cache['good'] else {'error': msg}
                cache['t'] = time.time()
            out = dict(cache['v']); out['view'] = cache['view']
            if out.get('error'): out['retry_in'] = max(0, int(cache['wait'] - (time.time() - cache['t'])))
            return out
    def refresh_now():
        # A click on the widget: make the next data() call due now, whatever the schedule says.
        with lock:
            cache['t'] = 0; cache['expired_mtime'] = None
        return data()
    def set_view(v):
        with lock:
            cache['view'] = v
            s = _load_state(title); s['view'] = v; _save_state(title, s)
    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_POST(self):
            if self.path.startswith('/view'):
                n = int(self.headers.get('content-length') or 0)
                v = self.rfile.read(n).decode(errors='replace').strip()[:40] if n else ''
                if v: set_view(v)
                self._send(200, 'application/json', json.dumps({'ok': True, 'view': cache['view']}).encode())
            elif self.path.startswith('/refresh'):
                self._send(200, 'application/json', json.dumps(refresh_now()).encode())
            else: self._send(404, 'text/plain', b'not found')
        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, 'default_view': default_view, '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.get('color', 'currentColor'), mark.get('svg', ''))) if 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())