← Commit history

Release 0.7.7: Cache debug tab + Cloudflare no-referrer hero fix + cache self-upgrade

John Lauer ·010d0d20af ·3mo ago ·parent ee20fa5
2 files changed +193−3
dist/win/billboard.html+6−2
@@ -451,15 +451,19 @@ function mediaEl(s){   // re-downloads the missing file, so this is belt-and-suspenders: stream now, repopulate the cache later.)   const remote = s.src && /^https?:/i.test(s.src) ? s.src : "";   function fallback(el){ if (remote && el.src !== remote){ el.src = remote; return true; } return false; }+  // wiki.adom.inc/blob has Cloudflare HOTLINK PROTECTION: a cross-origin <img>/<video> request carrying a+  // Referer of this page's origin gets a 403 challenge (curl/.NET with no Referer get 200). So when we stream+  // a hero straight from the wiki (the self-heal path, or any un-cached remote media) we MUST send no Referer,+  // exactly like curl does -- otherwise the fallback silently fails and the card still goes blank.   if (s.kind === "video"){-    const v = document.createElement("video");+    const v = document.createElement("video"); v.referrerPolicy = "no-referrer";     v.src = src; v.muted = true; v.defaultMuted = true; v.volume = 0; v.loop = true;     v.autoplay = true; v.setAttribute("playsinline",""); v.preload = "auto";     v.addEventListener("canplay", ()=>{ try{ v.play(); }catch(e){} });     v.onerror = ()=>{ if (!fallback(v)) dropSlide(s.slug); };     return v;   }-  const img = document.createElement("img"); img.src = src; // <img> renders svg + raster+  const img = document.createElement("img"); img.referrerPolicy = "no-referrer"; img.src = src; // <img> renders svg + raster   img.onerror = ()=>{ if (!fallback(img)){ img.style.display="none"; dropSlide(s.slug); } };   return img; }
dist/win/config.html+187−1
@@ -139,6 +139,46 @@   .msbox .msstate { font-size:11.5px; line-height:1.5; }   .msbox .msstate.ok { color:#5dd879; } .msbox .msstate.warn { color:#f0c548; }   .msbox .msstate b { font-weight:700; }+  /* Cache / debug tab: every page the saver has fetched, the hero it stored, and the score it computes */+  .cachebar { display:flex; align-items:center; gap:14px; margin-top:14px; flex-wrap:wrap; font-size:12px; }+  .cachebar .cnt { color:var(--lo); } .cachebar .cnt b { color:var(--hi); }+  .cachebar select { padding:5px 8px; font-size:11.5px; }+  .cachebar .reload { font-family:inherit; font-size:11.5px; font-weight:600; color:var(--tealbri); background:#0f141b;+    border:1px solid var(--line); border-radius:7px; padding:6px 11px; cursor:pointer; }+  .cachebar .reload:hover { border-color:var(--teal); }+  #cacheList { margin-top:8px; }+  .ccard { display:flex; gap:13px; padding:13px 2px; border-top:1px solid var(--line); }+  .ccard:first-child { border-top:none; }+  .cthumbwrap { flex:none; width:108px; }+  .cthumb { width:108px; height:68px; border-radius:6px; border:1px solid var(--line); object-fit:cover; background:#0a0e14; display:block; }+  .cthumb.miss { display:flex; align-items:center; justify-content:center; color:var(--warn); font-size:10px; font-weight:600; text-align:center; }+  .cmeta { flex:1; min-width:0; }+  .cmeta .cslug { color:var(--tealbri); font-weight:700; font-size:13px; word-break:break-all; }+  .cmeta .ctitle { color:var(--hi); font-weight:600; font-size:12px; margin-top:2px; }+  .cmeta .cbrief { color:var(--lo); font-size:11px; line-height:1.4; margin-top:3px; }+  .cmeta .cfile { color:var(--dim); font-size:10px; margin-top:5px; word-break:break-all; line-height:1.4; }+  .cmeta .cfile .none { color:var(--warn); }+  .ctype { display:inline-block; font-size:9px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;+    padding:1px 7px; border-radius:999px; vertical-align:1.5px; margin-left:7px; }+  .ctype.t-app{background:rgba(56,184,255,.16);color:#7cc6ff;} .ctype.t-skill{background:rgba(140,107,247,.2);color:#b9a3ff;}+  .ctype.t-component{background:rgba(244,114,182,.16);color:#f7a8d8;} .ctype.t-bootstrap{background:rgba(46,160,67,.16);color:#5dd879;}+  .cscore { flex:none; width:118px; text-align:right; }+  .cscore .big { font-size:21px; font-weight:700; color:var(--tealbri); font-variant-numeric:tabular-nums; line-height:1; }+  .cscore .brk { font-size:10px; color:var(--lo); margin-top:5px; line-height:1.5; }+  .cscore .brk .f { color:#7cc6ff; font-weight:600; } .cscore .brk .n { color:#5dd879; font-weight:600; }+  .cscore .brk .j { color:var(--dim); }+  .cdlbtn { font-family:inherit; font-size:10.5px; font-weight:600; color:var(--tealbri); background:#0f141b;+    border:1px solid var(--line); border-radius:6px; padding:6px 11px; cursor:pointer; margin-top:8px; white-space:nowrap; }+  .cdlbtn:hover { border-color:var(--teal); } .cdlbtn:disabled { opacity:.45; cursor:default; }+  .ccmp { margin-top:10px; display:none; gap:13px; flex-wrap:wrap; }+  .ccmp.show { display:flex; }+  .ccmp .side { flex:1 1 0; min-width:0; }+  .ccmp .side .lbl { font-size:9.5px; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); margin-bottom:5px; }+  .ccmp .side img { width:100%; border-radius:6px; border:1px solid var(--line); background:#0a0e14; display:block; }+  .ccmp .side .dim { font-size:10px; color:var(--lo); margin-top:4px; font-variant-numeric:tabular-nums; }+  .ccmp .side .err { font-size:10px; color:var(--warn); margin-top:4px; line-height:1.4; }+  .ccmp .verdict { flex:0 0 100%; width:100%; margin-top:3px; font-size:11px; line-height:1.4; }+  .ccmp .verdict.same { color:#5dd879; } .ccmp .verdict.diff { color:var(--warn); } </style> </head> <body>@@ -151,6 +191,7 @@       <button class="tab active" data-tab="cascade">Cascade</button>       <button class="tab" data-tab="power">Power<span class="tdot" id="powerDot"></span></button>       <button class="tab" data-tab="preview">Preview</button>+      <button class="tab" data-tab="cache">Cache</button>       <button class="tab" data-tab="about">About</button>     </div>   </div>@@ -302,6 +343,23 @@     </div>   </div><!-- /tab-preview --> +  <!-- ============ TAB: CACHE (what's stored on disk + the score I compute) ============ -->+  <div class="panel" id="tab-cache">+    <div class="hw" style="font-size:11.5px;color:var(--lo);line-height:1.5;padding:13px 0 2px">+      A live look at the saver's on-disk billboard cache: every Wiki page it has fetched, the <b>hero image it+      stored</b>, the page's title &amp; description, and the <b>priority score</b> it computes to order the+      rotation (higher&nbsp;= shown sooner). Hit <b>Download current from Wiki</b> on any row to pull that page's+      hero <b>right now</b> and compare it side-by-side with what's cached, so you can spot a stale hero.+    </div>+    <div class="cachebar">+      <span class="cnt" id="cacheCnt">Reading cache…</span>+      <span style="color:var(--dim)">sort</span>+      <select id="cacheSort"><option value="score">Score</option><option value="slug">Slug</option><option value="fresh">Freshness</option><option value="type">Type</option></select>+      <button class="reload" id="cacheReload">↻ Reload from disk</button>+    </div>+    <div id="cacheList"></div>+  </div><!-- /tab-cache -->+   <!-- ============ TAB: ABOUT (why this exists) ============ -->   <div class="panel" id="tab-about">     <div class="why">@@ -620,8 +678,134 @@     send(this.checked ? "displayoff:never" : "displayoff:rec"); updateNeverWarn();   }); +  // ================= CACHE / DEBUG tab =================+  // The config window's web root IS the saver's cache dir, so manifest.json, showcounts.json and every+  // cached hero file are served right here. We read them directly and recompute the SAME priority score+  // the billboard uses (kept in sync with billboard.html slideScore), so this is a true window into the cache.+  function escA(s){ return esc(s).replace(/"/g,"&quot;").replace(/'/g,"&#39;"); }+  var cacheLoaded = false, cacheData = [], cacheCounts = {};+  function ageDaysC(u){ if(!u) return 1e9; var t=Date.parse(u); if(isNaN(t)) return 1e9; return (Date.now()-t)/86400000; }+  // freshness: <7d 100, <30d 60, <90d 35, <365d 15, older/unknown 5  (mirror of billboard freshScore)+  function freshScoreC(u){ var d=ageDaysC(u); if(d<7)return 100; if(d<30)return 60; if(d<90)return 35; if(d<365)return 15; return 5; }+  // novelty: max(0, 60 - 20*timesShown)  (never-seen +60; gone after ~3 views)+  function noveltyC(slug){ return Math.max(0, 60 - 20*(cacheCounts[slug]||0)); }+  function freshLabelC(u){ var d=ageDaysC(u);+    if(d>=1e9) return "undated"; if(d<1) return "today"; if(d<2) return "yesterday"; if(d<7) return Math.round(d)+"d ago";+    if(d<60) return Math.max(1,Math.round(d/7))+"w ago"; if(d<730) return Math.max(1,Math.round(d/30))+"mo ago"; return Math.round(d/365)+"y ago"; }++  async function loadCache(){+    var cnt = document.getElementById("cacheCnt"), list = document.getElementById("cacheList");+    if (cnt) cnt.textContent = "Reading cache…";+    try { var rc = await fetch("showcounts.json?_=" + Date.now(), {cache:"no-store"}); cacheCounts = (await rc.json()) || {}; } catch(e){ cacheCounts = {}; }+    try {+      var r = await fetch("manifest.json?_=" + Date.now(), {cache:"no-store"});+      var m = await r.json(); cacheData = (m && m.slides) || [];+    } catch(e){+      cacheData = [];+      if (cnt) cnt.innerHTML = '<span style="color:var(--warn)">No cache yet — run the screensaver once to populate it.</span>';+      if (list) list.innerHTML = "";+      return;+    }+    renderCache();+  }+  function renderCache(){+    var cnt = document.getElementById("cacheCnt"), list = document.getElementById("cacheList");+    var sortBy = (document.getElementById("cacheSort") || {}).value || "score";+    var rows = cacheData.map(function(s){ var f=freshScoreC(s.updated), n=noveltyC(s.slug); return {s:s, f:f, n:n, score:f+n}; });+    if (sortBy==="score") rows.sort(function(a,b){ return b.score-a.score; });+    else if (sortBy==="slug") rows.sort(function(a,b){ return (a.s.slug||"").localeCompare(b.s.slug||""); });+    else if (sortBy==="fresh") rows.sort(function(a,b){ return b.f-a.f; });+    else if (sortBy==="type") rows.sort(function(a,b){ return (a.s.type||"").localeCompare(b.s.type||"") || (b.score-a.score); });+    if (cnt) cnt.innerHTML = "<b>" + rows.length + "</b> page" + (rows.length===1?"":"s") + " cached" + (rows.length?" · sorted by " + sortBy:"");+    var html = "";+    for (var i=0;i<rows.length;i++){+      var R = rows[i], s = R.s, media = s.media||"", src = s.src||"", typ = (s.type||"").toLowerCase();+      var remoteOnly = /^https?:/i.test(media);   // media is a streamed URL (video / un-cached), not a local file+      var thumb = media+        ? '<img class="cthumb" referrerpolicy="no-referrer" src="' + escA(media) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'">'+            + '<div class="cthumb miss" style="display:none">no file</div>'+        : '<div class="cthumb miss">no hero</div>';+      var fileLine = media+        ? (remoteOnly ? 'streamed (not cached to disk)' : 'cached file: ' + esc(media))+        : '<span class="none">no hero file</span>';+      html += '<div class="ccard" data-slug="' + escA(s.slug) + '">'+        + '<div class="cthumbwrap">' + thumb + '</div>'+        + '<div class="cmeta">'+        +   '<div class="cslug">' + esc(s.slug) + (typ ? '<span class="ctype t-' + esc(typ) + '">' + esc(typ) + '</span>' : '') + '</div>'+        +   '<div class="ctitle">' + esc(s.title || "(no title)") + '</div>'+        +   '<div class="cbrief">' + esc(s.brief || "") + '</div>'+        +   '<div class="cfile">' + fileLine + ' · updated ' + esc(freshLabelC(s.updated)) + (s.vis && s.vis!=="public" ? ' · <span style="color:#f0c548">' + esc(s.vis) + '</span>' : '') + '</div>'+        +   '<button class="cdlbtn"' + (src ? ' data-src="' + escA(src) + '" data-media="' + escA(media) + '"' : ' disabled title="no remote URL stored for this entry"') + '>'+        +     (src ? 'Download current from Wiki ↓' : 'no remote URL') + '</button>'+        +   '<div class="ccmp"></div>'+        + '</div>'+        + '<div class="cscore"><div class="big">' + R.score + '</div>'+        +   '<div class="brk"><span class="f">fresh ' + R.f + '</span> + <span class="n">new ' + R.n + '</span><br><span class="j">+ jitter 0–12</span></div></div>'+        + '</div>';+    }+    if (list) list.innerHTML = html || '<div class="hw" style="padding:18px 0;color:var(--lo)">Cache is empty.</div>';+    var btns = list ? list.querySelectorAll(".cdlbtn") : [];+    for (var b=0;b<btns.length;b++) (function(btn){ if(!btn.disabled) btn.addEventListener("click", function(){ doCompare(btn); }); })(btns[b]);+  }+  // Fetch the page's CURRENT hero from the wiki and show it beside the cached one, so a stale cache is obvious.+  function doCompare(btn){+    var src = btn.getAttribute("data-src"), media = btn.getAttribute("data-media") || "";+    var cmp = btn.parentNode.querySelector(".ccmp"); if(!cmp) return;+    btn.disabled = true; btn.textContent = "Fetching…";+    var bust = (src.indexOf("?")>=0 ? "&" : "?") + "_=" + Date.now();+    cmp.className = "ccmp show";+    // no-referrer on BOTH: the wiki side needs it to clear Cloudflare hotlink protection (curl-like, no Referer);+    // the cached side is local so it's a harmless no-op there.+    cmp.innerHTML =+        '<div class="side"><div class="lbl">Cached (on disk)</div>'+      +   (media ? '<img referrerpolicy="no-referrer" src="' + escA(media) + '" onload="this.parentNode.querySelector(\'.dim\').textContent=this.naturalWidth+\'×\'+this.naturalHeight; cmpReport(this)" onerror="this.style.display=\'none\';this.parentNode.querySelector(\'.err\').textContent=\'cached file missing on disk\'">'+                : '<div class="err">no cached file</div>')+      +   '<div class="dim"></div><div class="err"></div></div>'+      + '<div class="side"><div class="lbl">Current on Wiki</div>'+      +   '<img referrerpolicy="no-referrer" src="' + escA(src) + bust + '" onload="this.parentNode.querySelector(\'.dim\').textContent=this.naturalWidth+\'×\'+this.naturalHeight; cmpReport(this)" onerror="this.style.display=\'none\';this.parentNode.querySelector(\'.err\').textContent=\'couldn\\\'t fetch — private page or offline\'">'+      +   '<div class="dim"></div><div class="err"></div></div>'+      + '<div class="verdict" style="display:none"></div>';+    setTimeout(function(){ btn.disabled = false; btn.textContent = "↻ Re-download"; }, 1400);+  }+  // Once both images in a compare have loaded, note whether the dimensions match (a cheap stale-hint; we+  // can't pixel-diff a cross-origin image without tainting the canvas, so dimensions + the eyeball test it is).+  window.cmpReport = function(img){+    var cmp = img.closest(".ccmp"); if(!cmp) return;+    var imgs = cmp.querySelectorAll(".side img"), loaded = [];+    for (var i=0;i<imgs.length;i++){ if(imgs[i].naturalWidth>0) loaded.push(imgs[i]); }+    if (loaded.length < 2) return;+    var a = loaded[0], b = loaded[1];+    var v = cmp.querySelector(".verdict"); if(!v) return;+    var same = (a.naturalWidth===b.naturalWidth && a.naturalHeight===b.naturalHeight);+    v.style.display = "block";+    v.className = "verdict " + (same ? "same" : "diff");+    v.innerHTML = same+      ? "✓ Same dimensions — cache looks current. (Eyeball them to be sure.)"+      : "⚠ Different dimensions (" + a.naturalWidth + "×" + a.naturalHeight + " cached vs " + b.naturalWidth + "×" + b.naturalHeight + " on Wiki) — the cached hero may be stale; the saver will self-heal it on the next fetch.";+  };+  (function(){+    var rl = document.getElementById("cacheReload"); if(rl) rl.addEventListener("click", function(){ loadCache(); });+    var so = document.getElementById("cacheSort"); if(so) so.addEventListener("change", function(){ renderCache(); });+  })();+  // Headless hook for "/cshot <out> cache <slug>": (re)load the cache, find that row, scroll it to the top and+  // fire its live wiki compare — so a background screenshot can show the real side-by-side with no mouse.+  window.adomCacheCompare = async function(slug){+    cacheLoaded = true;+    await loadCache();+    var cards = document.querySelectorAll("#cacheList .ccard");+    for (var i=0;i<cards.length;i++){+      if (cards[i].getAttribute("data-slug") === slug){+        try { cards[i].scrollIntoView({block:"start"}); } catch(e){}+        var btn = cards[i].querySelector(".cdlbtn");+        if (btn && !btn.disabled) doCompare(btn);+        return true;+      }+    }+    return false;+  };+   // Tabs: show one panel at a time, reset scroll on switch. Header + tabs + footer stay put; only the-  // middle content scrolls, so the long config is now four short, navigable sections.+  // middle content scrolls, so the long config is now five short, navigable sections.   (function(){     var tabs = document.querySelectorAll('#tabs .tab');     var content = document.querySelector('.content');@@ -630,6 +814,8 @@       var panels = document.querySelectorAll('.panel');       for (var j=0;j<panels.length;j++) panels[j].classList.toggle('active', panels[j].id==='tab-'+name);       if (content) content.scrollTop = 0;+      // Lazy-load the cache the first time the tab is opened (and refresh it on every subsequent open).+      if (name==='cache'){ if(!cacheLoaded){ cacheLoaded = true; } loadCache(); }     }     for (var k=0;k<tabs.length;k++) (function(t){ t.addEventListener('click', function(){ show(t.getAttribute('data-tab')); }); })(tabs[k]);     // deep link: "/c <tab>" opens config.html#tab=<name> so the window can start on a specific tab