Compare commits

...
Author SHA1 Message Date
saphidandClaude Opus 5.5 6746a8220d Review fixes: parser-based duplicate check, quiet panel polls, safe error state
- The duplicate-name check now compiles each page's scripts together as one
  module with node --check, where a top-level name declared twice is a
  SyntaxError. Node's parser handles nested scopes, comments and strings,
  which the old line scanner did not. Fixture tests show it catches same-line,
  cross-block and const/function duplicates and ignores nested ones.
- The live view's loadPanels no longer logs expected offline or stale poll
  failures (as refresh() does).
- After a render failure, openStoreApp skips the details request, and
  loadOfferDetails no longer writes to a missing #detailInstallNote, which
  threw an unhandled TypeError. store_detail.mjs now covers the full open flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:10:54 +11:00
saphidandClaude Opus 5.5 2861567d15 Android store: app pages open again instead of a bare line (#68)
Clicking any app in the Android tab opened an empty dialog, which shows as
a single horizontal line (its 1px border). The store's renderDetail was
replaced by the Devices page's renderDetail, declared further down the same
script: a later function declaration silently wins. The store's call then
rendered "Pick a headset." into the Devices panel and left #detailBody empty.
Every platform and both 0.4.x releases were affected.

The store's renderer is now renderAppDetail, and a render failure shows a
clear message with a Close button rather than an empty dialog. The same
clash hid the live view's loadPanels behind the Tools panel switcher's; that
one is now loadPanelSwitcher. Both names match the open store PR (#56).

Tests: a guard that no top-level name is declared twice in the page's
shared script scope, and tests/page/store_detail.mjs, which runs the real
store functions for a good listing and a broken one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:00:03 +11:00
Alex Southwell b5c8d2f19d Merge pull request #82 from saphid/fix/release-draft-id
release.yml: read the new draft's id from the REST POST, not the lagging release list
2026-10-08 20:54:18 +11:00
saphidandClaude Opus 5.5 4a489e4606 tests: skip the release draft step test on Windows
There `bash` is the WSL launcher (no distribution on GitHub's runners), so
the step couldn't run; it only ever runs on ubuntu-latest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:46:55 +11:00
saphidandClaude Opus 5.5 0304575e5b release.yml: create the draft through the REST API so its id comes straight back
v0.4.2's first run created the draft with gh release create, then looked its
id up in the release list, which didn't show the fresh draft yet; the PATCH
went to releases/ (404) and the builds were skipped. Now the draft is POSTed
and the id read from the response, after checking the tag exists on GitHub
(what --verify-tag guarded). A rerun still reuses the draft found by tag_name,
and an empty id stops the job instead of PATCHing releases/.

tests/test_release_workflow.py runs the step against tests/fakegh, which now
answers POST releases and applies --jq through jq when installed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:28:07 +11:00
6 changed files with 332 additions and 26 deletions

No files matched your search

+13 -4
View File
@@ -16,8 +16,12 @@ permissions:
jobs:
# One job makes the draft, before the builds: three matrix jobs each running
# "view || create" could race and make duplicate drafts. The draft is tied to
# the pushed tag (--verify-tag, then tag_name set explicitly), so
# scripts/publish-release.sh and `gh release upload` find it by tag.
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
# by tag. It is created with the REST API so the id comes straight back: the
# release list can lag a fresh draft (v0.4.2's first run found nothing and
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
# this step against tests/fakegh.
draft:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
@@ -28,11 +32,16 @@ jobs:
GH_REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF_NAME}"
# A rerun finds the draft an earlier attempt made.
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
if [ -z "$id" ]; then
gh release create "$tag" --draft --verify-tag --title "Frame Control ${tag#v}" --notes ""
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\") | .id" | head -n 1)
# Given a missing tag, GitHub would create it on the default branch.
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
-F draft=true -f body="" --jq .id)
fi
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
build:
+43 -11
View File
@@ -1,11 +1,17 @@
#!/usr/bin/env python3
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py. Serves
the releases in $FAKEGH_RELEASES (a JSON list, drafts included) and the tags in
$FAKEGH_TAGS (space-separated); an upload's body is written to $FAKEGH_UPLOAD.
Every call is appended to $FAKEGH_LOG as a JSON line. Anything else fails, so the
script under test can't fall back to `gh release ...` (GraphQL) unnoticed."""
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py and
tests/test_release_workflow.py. Serves the releases in $FAKEGH_RELEASES (a JSON list,
drafts included) and the tags in $FAKEGH_TAGS (space-separated); an upload's body is
written to $FAKEGH_UPLOAD. Every call is appended to $FAKEGH_LOG as a JSON line.
Anything else fails, so the script under test can't fall back to `gh release ...`
(GraphQL) unnoticed. POST .../releases (release.yml's draft step) answers as release
$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
(raw strings, compact JSON)."""
import json
import os
import shutil
import subprocess
import sys
args = sys.argv[1:]
@@ -14,7 +20,7 @@ with open(os.environ["FAKEGH_LOG"], "a") as f:
if not args or args[0] != "api":
sys.exit("fakegh: only `gh api` is supported: %r" % args)
method, endpoint, fields, inp, i = "GET", None, {}, None, 1
method, endpoint, fields, inp, jq, i = "GET", None, {}, None, None, 1
while i < len(args):
a = args[i]
if a == "-X":
@@ -23,7 +29,9 @@ while i < len(args):
k, _, v = args[i + 1].partition("="); fields[k] = v; i += 2
elif a == "--input":
inp = args[i + 1]; i += 2
elif a in ("-H", "--jq"):
elif a == "--jq":
jq = args[i + 1]; i += 2
elif a == "-H":
i += 2
elif a.startswith("-"):
i += 1
@@ -34,14 +42,34 @@ while i < len(args):
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
repo = "repos/saphid/frame-control/"
def answer(response, fallback):
"""Print response through --jq when jq is here, else the fixed fallback text."""
if jq and shutil.which("jq"):
out = subprocess.run(["jq", "-r", "-c", jq], input=json.dumps(response),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
sys.stdout.write(out.stdout)
if out.returncode:
sys.exit("fakegh: jq failed: " + out.stderr)
else:
print(fallback)
if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
tag = endpoint.rsplit("/", 1)[1]
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
sys.exit("gh: Not Found (HTTP 404)")
print(json.dumps({"ref": "refs/tags/" + tag}))
elif method == "GET" and endpoint.startswith(repo + "releases?"):
for r in releases: # what --jq '.[]' prints
print(json.dumps(r))
# The fallback is what --jq '.[]' prints.
answer(releases, "\n".join(json.dumps(r) for r in releases))
elif method == "POST" and endpoint == repo + "releases":
new = {"id": int(os.environ.get("FAKEGH_NEW_ID", "9001")), "tag_name": fields.get("tag_name"),
"name": fields.get("name"), "draft": fields.get("draft") == "true",
"body": fields.get("body"), "assets": [],
"html_url": "https://github.com/saphid/frame-control/releases/tag/untagged-be29e900f7855d794136"}
answer(new, json.dumps(new))
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
pass
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
@@ -49,7 +77,11 @@ elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + re
dst.write(src.read())
print("{}")
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
print("published %s at https://github.com/saphid/frame-control/releases/tag/%s"
% (fields.get("tag_name"), fields.get("tag_name")))
rid = endpoint[len(repo + "releases/"):]
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
sys.exit("gh: Not Found (HTTP 404)")
page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name")
answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page},
"published %s at %s" % (fields.get("tag_name"), page))
else:
sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
+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");
+106
View File
@@ -0,0 +1,106 @@
"""The "Create the draft release" step of .github/workflows/release.yml, taken out of
the workflow and run with bash (as Actions runs it) against a stand-in gh
(tests/fakegh/gh): a new draft's id comes straight from the POST even when the
release list doesn't show it yet (v0.4.2's first run), a rerun reuses the draft
found by tag_name, and a tag that isn't on GitHub stops it. Nothing here talks to
GitHub.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import shutil
import subprocess
import tempfile
import textwrap
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
FAKEGH = ROOT / "tests" / "fakegh"
STEP = "- name: Create the draft release"
def step_script():
"""The step's `run: |` block, dedented."""
lines = WORKFLOW.read_text().splitlines()
i = next(n for n, line in enumerate(lines) if line.strip() == STEP)
while lines[i].strip() != "run: |":
i += 1
indent = len(lines[i]) - len(lines[i].lstrip())
body = []
for line in lines[i + 1:]:
if line.strip() and len(line) - len(line.lstrip()) <= indent:
break
body.append(line)
return textwrap.dedent("\n".join(body)).strip() + "\n"
def release(rid, tag_name, name="Frame Control 9.8.7", draft=True):
return {"id": rid, "tag_name": tag_name, "name": name, "draft": draft, "assets": []}
# The step only runs on ubuntu-latest. On Windows `bash` is often the WSL launcher
# (with no distribution on GitHub's runners), not a bash that can run tests/fakegh.
@unittest.skipUnless(os.name != "nt" and shutil.which("bash") and shutil.which("jq"),
"needs a POSIX bash and jq (for gh --jq); the step runs on ubuntu only")
class DraftStep(unittest.TestCase):
def run_step(self, releases, tags="v9.8.7", tag="v9.8.7"):
d = Path(tempfile.mkdtemp())
(d / "step.sh").write_text(step_script())
(d / "releases.json").write_text(json.dumps(releases))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), GITHUB_REF_NAME=tag, GH_REPO="saphid/frame-control")
p = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", str(d / "step.sh")],
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
return p, log
def posts(self, log):
return [c for c in log if c[1:4] == ["-X", "POST", "repos/saphid/frame-control/releases"]]
def patches(self, log):
return [c for c in log if c[1:3] == ["-X", "PATCH"]]
def test_new_draft_uses_the_id_the_post_returns(self):
# The list doesn't show the fresh draft (it never does in the fake): v0.4.2's case.
p, log = self.run_step([])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
[post] = self.posts(log)
for f in ("tag_name=v9.8.7", "name=Frame Control 9.8.7", "draft=true"):
self.assertIn(f, post)
self.assertIn(["api", "repos/saphid/frame-control/git/ref/tags/v9.8.7"], log)
[patch] = self.patches(log)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/9001")
self.assertIn("tag_name=v9.8.7", patch)
self.assertIn("release 9001 tag_name v9.8.7", p.stdout)
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...`
def test_a_rerun_reuses_the_draft_with_the_tag(self):
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6"), release(42, "v9.8.7")])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(self.posts(log), [])
[patch] = self.patches(log)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
def test_other_releases_alone_mean_a_new_draft(self):
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6", draft=False),
release(8, "v9.8.70", "Frame Control 9.8.70")])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(len(self.posts(log)), 1)
self.assertEqual(self.patches(log)[0][3], "repos/saphid/frame-control/releases/9001")
def test_stops_when_the_tag_is_not_on_github(self):
p, log = self.run_step([], tags="")
self.assertNotEqual(p.returncode, 0)
self.assertIn("tag v9.8.7 isn't on GitHub", p.stderr)
self.assertEqual(self.posts(log), [])
self.assertEqual(self.patches(log), [])
if __name__ == "__main__":
unittest.main()
+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
+24 -11
View File
@@ -2120,7 +2120,8 @@ async function loadPanels() {
if (view === "flat" && live && now !== before) { toggleLive(false); toggleLive(true); }
ctrlShow();
} catch (e) {
log("Couldn't list the headset's panels: " + e.message, "e");
// Polled every few seconds: waiting for the Frame, or an answer about the previous headset, isn't news.
if (!e.offline && !e.stale) log("Couldn't list the headset's panels: " + e.message, "e");
}
}
function watchPanels() {
@@ -3172,7 +3173,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>`;
@@ -3192,8 +3205,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;
@@ -3201,8 +3214,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);
@@ -3232,8 +3245,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});};
@@ -3785,7 +3798,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"});
@@ -3796,7 +3809,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 => {
@@ -3894,7 +3907,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).