Merge remote-tracking branch 'origin/main' into fix/no-app-panel-73

# Conflicts:
#	ui/index.html
This commit is contained in:
saphid committed 2026-10-12 14:20:08 +11:00
commit 4e374de040
4 files changed
+191 -11

No files matched your search

+20
View File
@@ -8,6 +8,12 @@ const grab = name => {
if (!m) throw new Error("not found: " + name);
return m[0];
};
// The page's scripts share one global scope, so a second loadPanels would silently replace
// the Desktop view's (what broke #73 until #84): the one grabbed here must be the only one.
for (const name of ["loadPanels", "noPanelWhy", "deskPanel", "ctrlTarget"]) {
const count = (src.match(new RegExp(`\\n(?:async )?function ${name}\\(`, "g")) || []).length;
if (count !== 1) { console.log(`FAIL ${name} is defined ${count} times`); process.exit(1); }
}
const code = ["desk", "panelKey", "noPanelWhy", "deskPanel", "loadPanels", "ctrlTarget"].map(grab).join("");
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
@@ -59,4 +65,18 @@ function page(answer) {
if (!t.why || t.why.includes("Couldn't list")) fail("after recovery: " + JSON.stringify(t));
if (p.desk.panels.length !== 1) fail("panels after recovery");
}
// Waiting for the Frame: shown in Control, not logged every poll. An answer about the
// previous headset (stale) is ignored.
{
let err;
const p = page(() => { throw err; });
err = Object.assign(new Error("Can't reach the Frame"), { offline: true });
await p.loadPanels(); await p.loadPanels();
if (p.logged.length) fail("offline logged: " + p.logged);
if (p.ctrlTarget().why !== "Couldn't list the headset's panels: Can't reach the Frame") fail("offline: " + p.ctrlTarget().why);
const q = page(() => { throw Object.assign(new Error("old headset"), { offline: true, stale: true }); });
await q.loadPanels();
if (q.logged.length || q.desk.error) fail("stale answer used: " + q.desk.error);
if (q.ctrlTarget().why !== "Finding the headset's panels…") fail("stale: " + q.ctrlTarget().why);
}
console.log("ok");
+93
View File
@@ -0,0 +1,93 @@
// The Android store's app page, run in node against the real functions from ui/index.html.
// Issue #68: clicking an app opened an empty dialog (a bare horizontal line), because the
// Devices page's renderDetail replaced the store's. The store's is renderAppDetail now.
// (tests/test_server.py PageScripts checks no top-level name is declared twice.)
import { readFileSync } from "fs";
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
const lines = src.split("\n");
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
// A top-level declaration, from its first line to where its braces balance.
function grab(name) {
const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l));
if (start < 0) fail(`no top-level ${name} in index.html`);
let depth = 0;
for (let i = start; i < lines.length; i++) {
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
}
fail(`unbalanced ${name}`);
}
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
"storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails"];
const code = names.map(grab).join("");
// A fake page: elements by id. The install panel's elements exist only while
// #detailBody holds the real app page, as in the browser.
function page(api = async () => ({})) {
const els = { appDetail: { open: false, scrollTop: 0, showModal() { this.open = true; } } };
const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id);
const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null
: (els[id] ||= { innerHTML: "", textContent: "", hidden: false, value: 0, removeAttribute() {}, querySelectorAll: () => [] });
const sourceState = { apps: [], detail: null, detailRequest: 0, jobs: new Map() };
const document = { activeElement: null, querySelector: () => null };
const console = { error() {} };
const calls = [];
const fns = new Function("$", "sourceState", "document", "console", "api", code + "; return { renderAppDetail, openStoreApp };")(
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); });
return { els, sourceState, calls, ...fns };
}
const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid", name: "Open Brush", summary: "Paint in VR",
description: "Paint in 3D.", version: "2.1", version_code: 21, package: "org.example.brush",
artwork: { icon: "/source-image/abc", screenshots: [] }, downloadable: true, verdict: { tone: "works", label: "Works" } };
// An app from the catalogue: its page renders, with its name, install and sources.
{
const p = page();
p.sourceState.detail = { app: { name: "Open Brush", offers: [{ ...offer }] }, choice: 0 };
if (p.renderAppDetail() !== true) fail("a good listing reported a render failure");
const html = p.els.detailBody.innerHTML;
if (!html.includes('id="detailTitle">Open Brush<')) fail("app page has no title: " + html.slice(0, 200));
if (!html.includes('id="detailInstall"') || !html.includes("Get it from")) fail("app page has no install panel");
if (html.includes('role="alert"')) fail("a good listing showed the error state");
}
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
{
const p = page();
p.sourceState.detail = { app: { name: "Broken <App>", offers: [] }, choice: 0 };
if (p.renderAppDetail() !== false) fail("a broken listing reported success");
const html = p.els.detailBody.innerHTML;
if (!html.includes('role="alert"') || !html.includes("We couldn't show Broken &lt;App&gt;")) fail("no error state: " + html);
if (!html.includes('data-close="appDetail"')) fail("error state has no way to close");
}
// Opening a listing that can't render: the error stays up, no details request, nothing thrown.
{
const p = page(async () => { throw new Error("offline"); });
p.sourceState.apps = [{ name: "Broken", offers: [] }];
await p.openStoreApp(0);
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('role="alert"')) fail("broken listing didn't open with the error");
if (p.calls.length) fail("asked for details of a listing that couldn't render");
}
// Opening a good listing: the dialog opens with its page, and fresher details fill it in.
{
const p = page(async () => ({ description: "Fresh description." }));
p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
await p.openStoreApp(0);
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes("Fresh description.")) fail("details didn't fill in the page");
if (!String(p.calls[0]?.[0]).startsWith("/api/sources/details?")) fail("no details request: " + JSON.stringify(p.calls));
}
// A good listing whose details request fails keeps its page, with a note.
{
const p = page(async () => { throw new Error("offline"); });
p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
await p.openStoreApp(0);
if (!p.els.detailBody.innerHTML.includes('id="detailTitle">Open Brush<')) fail("page lost after a failed details request");
if (!p.els.detailInstallNote.textContent.includes("couldn't be refreshed")) fail("no note after a failed details request");
}
console.log("ok");
+53
View File
@@ -411,6 +411,59 @@ class ArtworkSettings(unittest.TestCase):
self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">'))
@unittest.skipUnless(shutil.which("node"), "needs node")
class PageScripts(unittest.TestCase):
"""A page's scripts share one global scope, where a later function declaration
silently replaces an earlier one of the same name. Issue #68: the Devices page's
renderDetail replaced the Android store's, so every app opened as an empty
dialog (a bare horizontal line). Compiled together as one module, any top-level
name declared twice is a SyntaxError; node's parser handles nested scopes,
comments and strings."""
PAGES = ("index.html", "assistant.html", "mac-view.html")
@staticmethod
def page_scripts(html):
"""A page's inline scripts, then its /*.js scripts, in one source."""
import re
parts = re.findall(r"<script>([\s\S]*?)</script>", html)
parts += [(ROOT / "ui" / src).read_text(encoding="utf-8") for src in re.findall(r'<script src="/([\w.-]+\.js)">', html)]
return "\n;\n".join(parts)
@staticmethod
def module_check(source):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "page.mjs"
path.write_text(source, encoding="utf-8")
r = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True)
return r.returncode, r.stderr
def test_the_check_catches_top_level_duplicates_only(self):
dup = "<script>function a() {}\nfunction a() {}</script>"
across = "<script>function a() {}</script><p>x</p><script> async function a() {}</script>"
same_line = "<script>function a() {} function a() {}</script>"
const_and_function = "<script>const a = 1, b = 2;\nfunction b() {}</script>"
for html in (dup, across, same_line, const_and_function):
rc, err = self.module_check(self.page_scripts(html))
self.assertNotEqual(rc, 0, html)
self.assertIn("has already been declared", err, html)
nested = ("<script>function a() {}\nfunction f() { function a() {} if (1) { const a = 2; } }\n"
"// function a() {}\nconst s = `function a() {}`;</script>")
self.assertEqual(self.module_check(self.page_scripts(nested)), (0, ""))
def test_no_top_level_name_is_declared_twice(self):
for name in self.PAGES:
source = self.page_scripts((ROOT / "ui" / name).read_text(encoding="utf-8"))
self.assertIn("function", source, name)
rc, err = self.module_check(source)
self.assertEqual(rc, 0, f"{name}: {err}")
def test_store_app_page_renders(self):
"""tests/page/store_detail.mjs runs the real store functions from index.html."""
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "store_detail.mjs")], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
class LocalMode(unittest.TestCase):
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
+25 -11
View File
@@ -2127,7 +2127,9 @@ async function loadPanels() {
if (view === "flat" && live && now !== before) { toggleLive(false); toggleLive(true); }
ctrlShow();
} catch (e) {
if (e.message !== desk.error) log("Couldn't list the headset's panels: " + e.message, "e");
if (e.stale) return; // an answer about the previous headset says nothing about this one
// Polled every few seconds: waiting for the Frame, or the same failure again, isn't news.
if (!e.offline && e.message !== desk.error) log("Couldn't list the headset's panels: " + e.message, "e");
desk.error = e.message;
ctrlShow();
}
@@ -3180,7 +3182,19 @@ async function searchSources(quiet) {
}
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
function detailBadges(o) { return `<span class="store-badge">${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}</span>${o.verdict?.tone==='works' ? '<span class="store-badge works">✓ Works on Frame</span>' : ''}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
function renderDetail() {
// The store's app page. (Not renderDetail: the Devices page declares that further down,
// and a later function declaration silently replaces an earlier one; issue #68.)
// True when the page rendered; false when it shows the error instead.
function renderAppDetail() {
try { renderAppDetailBody(); return true; }
catch(e) {
console.error("App details failed to render", e);
const name=sourceState.detail?.app?.name;
$("detailBody").innerHTML=`<div class="store-empty detail-error" role="alert"><h2 id="detailTitle">${name ? `We couldn't show ${esc(name)}` : "We couldn't show this app"}</h2><p>Something went wrong opening its page. Close this and try again, or pick another app.</p><button class="action" data-close="appDetail">Close</button></div>`;
return false;
}
}
function renderAppDetailBody() {
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Meet the creators ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
@@ -3200,8 +3214,8 @@ function updateInstall() {
async function openStoreApp(index) {
const app=sourceState.apps[index];if(!app)return;
sourceState.detail={app:{...app,offers:app.offers.map(o=>({...o}))},choice:0};
renderDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
await loadOfferDetails();
const shown=renderAppDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
if(shown)await loadOfferDetails();
}
async function loadOfferDetails() {
const d=sourceState.detail,o=currentOffer(), request=++sourceState.detailRequest;
@@ -3209,8 +3223,8 @@ async function loadOfferDetails() {
const detail=await api('/api/sources/details?'+new URLSearchParams({source:o.source,id:o.id}));
if(request!==sourceState.detailRequest || d!==sourceState.detail)return;
const artwork={icon:detail.artwork?.icon||o.artwork?.icon,banner:detail.artwork?.banner||o.artwork?.banner,screenshots:detail.artwork?.screenshots?.length?detail.artwork.screenshots:o.artwork?.screenshots||[]};
d.app.offers[d.choice]={...o,...detail,artwork};renderDetail();
} catch(e) { if(d===sourceState.detail)$("detailInstallNote").textContent="Some app details couldn't be refreshed. You can still view this listing."; }
d.app.offers[d.choice]={...o,...detail,artwork};renderAppDetail();
} catch(e) { const note=$("detailInstallNote");if(d===sourceState.detail && note)note.textContent="Some app details couldn't be refreshed. You can still view this listing."; }
}
async function installStoreApp() {
const o=currentOffer(), key=`${o.source}:${o.id}`, existing=sourceState.jobs.get(key);
@@ -3240,8 +3254,8 @@ $("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer)
$("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();};
$("searchFilters").onclick=e=>{const b=e.target.closest('[data-filter]');if(!b)return;const f=b.dataset.filter;if(f==='all'){sourceState.vr=null;sourceState.installable=false;}else if(f==='installable')sourceState.installable=!sourceState.installable;else sourceState.vr=sourceState.vr===(f==='vr')?null:f==='vr';storeChips();searchSources();};
$("searchGrid").onclick=e=>{const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}};
$("detailBody").onclick=e=>{if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});};
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderDetail();loadOfferDetails();}};
$("detailBody").onclick=e=>{if(e.target.closest('[data-close]'))return $("appDetail").close();if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});};
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderAppDetail();loadOfferDetails();}};
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
@@ -3793,7 +3807,7 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden && on
// ---- Panel switcher: our UI over SteamVR's panel API ----
let panelSeq = 0;
async function loadPanels() {
async function loadPanelSwitcher() {
const seq = ++panelSeq;
try {
const s = await api("/api/panels", {action: "list"});
@@ -3804,7 +3818,7 @@ async function loadPanels() {
: '<div class="sub">No open panels.</div>';
} catch (e) { if (seq === panelSeq) failed($("panelList"), e); }
}
$("panelsRefresh").onclick = loadPanels;
$("panelsRefresh").onclick = loadPanelSwitcher;
$("panelsOpen").onclick = e => act("Open panel switcher in headset",
() => api("/api/panels", {action: "open"}), e.currentTarget);
$("panelList").onclick = e => {
@@ -3902,7 +3916,7 @@ function showPage() {
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
const section = !PAGES.includes(id) && id && $(id);
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
if (page === "tools") { loadMacView(); loadPanels(); }
if (page === "tools") { loadMacView(); loadPanelSwitcher(); }
}
window.addEventListener("hashchange", showPage);
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).