From 88a2fe67282c1ad6559acc6f531d1587fcce1e5d Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:37:08 +1100 Subject: [PATCH 1/4] Android store: say which APK to get on a wrong ABI; show compat reports in search PostHog (0.4.0): Grayjay installs failed twice with apk_wrong_abi, once with an armeabi-v7a file and once with an x86_64 file. Grayjay's own site offers one APK per ABI; the FUTO F-Droid repo's Grayjay 391 is universal and does contain arm64-v8a, and the catalogue and repo search already drop builds without arm64-v8a, so the wrong files did not come from those paths. - The wrong-ABI error now tells the user to download the arm64-v8a (or arm64, or universal) APK instead, so they don't guess again. - An install_failed report caused by a wrong-ABI (or too-new-Android) file no longer rates the whole app as broken in the catalogue. - Search results and details carry the compatibility reports the catalogue uses: "Reported not working on the Frame" (a warning, not a block) or "Works on the Frame". A report never overrides a hard blocker. - Tests: per-ABI split builds (offer and download the arm64 one), the new error text and its telemetry category, wrong-file reports, and search verdicts from reports, with the database hook off by default in tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- apk-catalog/reports.py | 14 +++++++++++ tests/test_apk_artwork.py | 3 +++ tests/test_apk_search.py | 43 ++++++++++++++++++++++++++++++++ tests/test_fdroid_sources.py | 30 ++++++++++++++++++++++ tests/test_frame_apk_versions.py | 29 +++++++++++++++++++++ ui/apk_sources/search.py | 43 ++++++++++++++++++++++++++++---- ui/frame_android.py | 6 ++++- ui/index.html | 8 +++--- ui/server.py | 4 +++ 9 files changed, 170 insertions(+), 10 deletions(-) diff --git a/apk-catalog/reports.py b/apk-catalog/reports.py index b425d04..6429910 100644 --- a/apk-catalog/reports.py +++ b/apk-catalog/reports.py @@ -6,11 +6,25 @@ A report: package, version, result (runs | crashes | install_failed | instance_failed, from an automated test), rating (works | issues | broken, from a person), notes, via (harness | probe | user), date, steamos, lepton, runtime. Newest wins, and a person's rating beats an automated result. + +An install_failed report saying the file had no arm64-v8a build (or needed a +newer Android) is about that one APK file, often the wrong per-ABI download of +an app that has an arm64 build, so it says nothing about the app and is left out. """ +import re + +FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API') + + +def about_app(r): + """False for reports about one wrong APK file rather than the app itself.""" + return not (r.get('result') == 'install_failed' and not r.get('rating') + and FILE_FAULT.search(r.get('notes') or '')) def verdict(reports): """(verdict, summary lines) for one package's reports, or None.""" + reports = [r for r in reports or () if about_app(r)] if not reports: return None rs = sorted(reports, key=lambda r: r.get('date') or '') diff --git a/tests/test_apk_artwork.py b/tests/test_apk_artwork.py index b036f90..555a465 100644 --- a/tests/test_apk_artwork.py +++ b/tests/test_apk_artwork.py @@ -16,6 +16,9 @@ PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').rea class ArtworkTests(unittest.TestCase): def setUp(self): + p = patch.object(search, 'compat_reports', None) # server.py sets the real database + p.start() + self.addCleanup(p.stop) with _images._lock: _images._urls.clear() _images._cache.clear() diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py index 20cd57e..f47656c 100644 --- a/tests/test_apk_search.py +++ b/tests/test_apk_search.py @@ -33,6 +33,10 @@ class SettingsTest(unittest.TestCase): self.addCleanup(p.stop) for state in (search._running, search._pending, search._status, search._game_data): state.clear() + # server.py points this at the real compatibility database; tests use none unless they say so. + p = patch.object(search, 'compat_reports', None) + p.start() + self.addCleanup(p.stop) from apk_sources import _web self.claims = [] for name in ('claim', 'release'): # fake downloads aren't real files @@ -75,6 +79,45 @@ class SearchTests(SettingsTest): self.assertFalse(search.fit({'min_sdk': 23, 'abis': ['x86']})['installable']) self.assertIn('Legacy VrApi', search.fit({'engine': 'VrApi'})['reasons'][0]) + def test_compat_reports_surface_in_results(self): + grayjay = dict(source='futo', id='com.futo.platformplayer', package='com.futo.platformplayer', + name='Grayjay', min_sdk=28, vr=False, free=True, downloadable=True, + abis=['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']) + reps = {'com.futo.platformplayer': [ + {'package': 'com.futo.platformplayer', 'version': '391', 'result': 'install_failed', + 'notes': 'Grayjay has no arm64-v8a build (armeabi-v7a); Lepton is 64-bit ARM only', + 'date': '2026-10-01T10:00:00'}, + {'package': 'com.futo.platformplayer', 'version': '391', 'result': 'crashes', + 'date': '2026-10-02T10:00:00', 'via': 'probe'}, + {'package': 'com.futo.platformplayer', 'version': '391', 'rating': 'broken', + 'date': '2026-10-03T10:00:00', 'via': 'user'}]} + with patch.object(search, 'compat_reports', lambda: reps): + offer = search.group([grayjay], 'grayjay')[0]['offers'][0] + self.assertEqual(offer['verdict'], {'label': 'Reported not working on the Frame', 'tone': 'warn'}) + self.assertEqual(offer['compat']['verdict'], 'no') + self.assertIn('broken', offer['compat']['lines'][0]) + self.assertEqual(offer['compat']['lines'][1], '2 reports, 0 working') # the wrong-file report is left out + self.assertTrue(offer['fit']['installable']) # a warning, not a block: the user may still try + + works = {'org.brush': [{'package': 'org.brush', 'rating': 'works', 'date': '2026-10-01'}]} + with patch.object(search, 'compat_reports', lambda: works): + brush = search.group(ENTRIES[:1])[0]['offers'][0] + self.assertEqual(brush['verdict']['tone'], 'works') + + # A report never overrides a hard blocker such as a missing arm64-v8a build. + x86 = dict(grayjay, abis=['x86_64']) + with patch.object(search, 'compat_reports', lambda: {'com.futo.platformplayer': works['org.brush']}): + self.assertEqual(search.group([x86])[0]['offers'][0]['verdict']['tone'], 'blocked') + + def test_compat_reports_failure_or_absence_changes_nothing(self): + def broken(): + raise OSError('database unavailable') + for hook in (None, broken, lambda: {}): + with patch.object(search, 'compat_reports', hook): + offer = search.group(ENTRIES[:1])[0]['offers'][0] + self.assertIsNone(offer['compat']) + self.assertEqual(offer['verdict']['label'], 'Ready to try on the Frame') + def test_timeout_and_failure_leave_other_results(self): release = threading.Event() calls = [] diff --git a/tests/test_fdroid_sources.py b/tests/test_fdroid_sources.py index 4285721..f264482 100644 --- a/tests/test_fdroid_sources.py +++ b/tests/test_fdroid_sources.py @@ -256,6 +256,36 @@ class Repositories(unittest.TestCase): self.assertEqual(result['icon'], URL + 'icons/legacy.1.png') self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png']) + def test_per_abi_builds_offer_and_download_the_arm64_one(self): + import hashlib + + def build(code, name, abis): + self.files[name] = name.encode() + return {'manifest': {'versionName': '391', 'versionCode': code, 'usesSdk': {'minSdkVersion': 28}, + 'nativecode': abis}, + 'file': {'name': '/' + name, 'sha256': hashlib.sha256(name.encode()).hexdigest(), 'size': code}, + 'added': 0} + # One APK per ABI under different codes (the x86_64 one highest, as F-Droid often does), + # plus a universal and an arm64-only build sharing a code. + builds = [build(3911, 'app-armeabi-v7a.apk', ['armeabi-v7a']), build(3914, 'app-x86_64.apk', ['x86_64']), + build(3913, 'app-x86.apk', ['x86']), + build(3912, 'app-universal.apk', ['arm64-v8a', 'armeabi-v7a', 'x86_64']), + build(3912, 'app-arm64-v8a.apk', ['arm64-v8a'])] + raw = self.root / 'splits.json' + raw.write_text(json.dumps({'packages': {'com.futo.platformplayer': { + 'metadata': {'name': {'en-US': 'Grayjay'}}, + 'versions': {str(i): b for i, b in enumerate(builds)}}}})) + source = {'id': 'test', 'url': URL} + app = fdroid._reduce(raw, source)['com.futo.platformplayer'] + self.assertEqual([v['name'] for v in app['versions']], ['/app-arm64-v8a.apk', '/app-universal.apk']) + self.assertEqual((app['version_code'], app['abis']), (3912, ['arm64-v8a'])) + with patch.object(fdroid, 'details', return_value=app): + for code in (None, 3912): + fdroid.download(source, 'com.futo.platformplayer', version_code=code) + self.assertTrue(self.fetch_mock.call_args[0][0].endswith('/app-arm64-v8a.apk')) + with self.assertRaises(SourceError): # the x86_64 build is never offered + fdroid.download(source, 'com.futo.platformplayer', version_code=3914) + def test_v2_legacy_screenshot_keys_and_limit(self): meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]}, 'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}} diff --git a/tests/test_frame_apk_versions.py b/tests/test_frame_apk_versions.py index ea70373..53da535 100644 --- a/tests/test_frame_apk_versions.py +++ b/tests/test_frame_apk_versions.py @@ -184,6 +184,35 @@ class VersionsTest(unittest.TestCase): info['abis'] = ['armeabi-v7a'] self.assertIn('no arm64-v8a build', versions.describe(info)) + def test_wrong_abi_error_says_which_file_to_get(self): + import frame_telemetry + for abis in (['armeabi-v7a'], ['x86_64']): # the two per-ABI Grayjay files users tried + info = {'label': 'Grayjay', 'min_sdk': 28, 'abis': abis} + with self.assertRaises(frame_android.FrameError) as error: + frame_android.check_installable(info) + message = str(error.exception) + self.assertIn('no arm64-v8a build (%s)' % abis[0], message) + self.assertIn('download the APK marked arm64-v8a', message) + self.assertEqual(frame_telemetry.categorize(message)[0], 'apk_wrong_abi') + frame_android.check_installable({'label': 'Universal', 'min_sdk': 28, + 'abis': ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']}) + + def test_wrong_file_reports_do_not_rate_the_app(self): + reports = frame_catalog.reports + wrong_file = {'package': 'org.example.app', 'version': '391', 'result': 'install_failed', + 'notes': 'Example has no arm64-v8a build (x86_64); Lepton is 64-bit ARM only', + 'date': '2026-10-01T10:00:00'} + self.assertIsNone(reports.verdict([wrong_file])) + app = frame_catalog.catalog_build.finalize({'pr': 'likely', 'pw': ['No known blockers']}, [wrong_file]) + self.assertEqual((app['r'], app['t']), ('likely', False)) # the prediction stands + # A real installer failure, a crash or a person's rating still counts. + installer = dict(wrong_file, notes='INSTALL_FAILED_INVALID_APK') + self.assertEqual(reports.verdict([installer])[0], 'no') + crash = dict(wrong_file, result='crashes', notes=None, date='2026-10-02') + self.assertEqual(reports.verdict([wrong_file, crash])[0], 'no') + rated = dict(wrong_file, rating='works', date='2026-10-03') + self.assertEqual(reports.verdict([wrong_file, rated])[0], 'works') + def test_install_resolves_index_hash(self): versions.alternatives('org.example.app') with patch.object(versions, 'alternatives', side_effect=AssertionError('recomputed')), \ diff --git a/ui/apk_sources/search.py b/ui/apk_sources/search.py index 883c08b..5537016 100644 --- a/ui/apk_sources/search.py +++ b/ui/apk_sources/search.py @@ -2,7 +2,13 @@ Optional store metadata: images {icon, banner, screenshots}, developer, description, popularity, open_source, requires_meta_services, frame_tested. -Only an explicit frame_tested=True produces a working-on-Frame verdict. +Only an explicit frame_tested=True, or a compatibility report, produces a +working-on-Frame verdict. + +Compatibility reports (frame_compat_db, through the compat_reports hook that +server.py sets) are matched by package name and turned into entry['compat'], +using the same rules as the catalogue (apk-catalog/reports.py). A report never +overrides a hard blocker such as a missing arm64-v8a build. """ import importlib import inspect @@ -23,6 +29,8 @@ _pending = {} # source id -> newest query waiting for the running one _game_data = {} # package -> downloaded OBB paths waiting for "Add game data" _status = {} TIMEOUT = 12 +# () -> {package: [report, ...]}; set by server.py. None (or a failure) means no reports. +compat_reports = None def modules(): @@ -140,6 +148,24 @@ def fit(entry): 'reasons': reasons} +def _reports(): + if compat_reports is None: + return {} + try: + return compat_reports() or {} + except Exception: # noqa: BLE001 - reports are a hint; search must still work + return {} + + +def compat(entry, by_package): + """{'verdict': works|maybe|no, 'lines': [...]} from reports for this package, or None.""" + found = by_package.get(entry.get('package')) if entry.get('package') else None + if found: + from frame_catalog import reports # apk-catalog/reports.py, the catalogue's rules + found = reports.verdict(found) + return {'verdict': found[0], 'lines': found[1]} if found else None + + def verdict(entry): compatibility = fit(entry) hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower() @@ -151,22 +177,28 @@ def verdict(entry): return {'label': "This version isn't made for the Frame", 'tone': 'blocked'} if 'vrapi' in hints: return {'label': "Made for older Quest headsets; won't run on the Frame", 'tone': 'blocked'} - if entry.get('frame_tested') is True: + reported = (entry.get('compat') or {}).get('verdict') + if reported == 'no': + return {'label': 'Reported not working on the Frame', 'tone': 'warn'} + if reported == 'maybe': + return {'label': 'Reported working with issues on the Frame', 'tone': 'warn'} + if entry.get('frame_tested') is True or reported == 'works': return {'label': 'Works on the Frame', 'tone': 'works'} if compatibility['installable'] is True: return {'label': 'Ready to try on the Frame', 'tone': 'ready'} return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'} -def decorate(entry): +def decorate(entry, by_package=None): from apk_sources import _images + entry = dict(entry, compat=compat(entry, by_package or {})) return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry)) def details(source_id, entry_id): module, source = resolve(source_id) return decorate(dict(module.details(source, entry_id), source=source_id, - source_name=source['name'], trust=source.get('trust'))) + source_name=source['name'], trust=source.get('trust')), _reports()) def offer_rank(entry): @@ -180,8 +212,9 @@ def offer_rank(entry): def group(entries, query='', vr=None, installable=False): groups = {} + by_package = _reports() if entries else {} for entry in entries: - entry = decorate(entry) + entry = decorate(entry, by_package) if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat continue if installable and entry['fit']['installable'] is not True: diff --git a/ui/frame_android.py b/ui/frame_android.py index eff7c19..0f0364f 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -104,7 +104,11 @@ def check_installable(info): if info['min_sdk'] and info['min_sdk'] > 30: raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)") if info['abis'] and 'arm64-v8a' not in info['abis']: - raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only") + # Sites that offer one APK per ABI (Grayjay: arm64-v8a, armeabi-v7a, x86, x86_64, + # universal) leave the choice to the user; say which file to fetch instead. + raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only. " + "This file is for other devices: download the APK marked arm64-v8a " + "(or arm64, or universal) and install that instead") _install_lock = threading.Lock() # installs are rare; one at a time avoids every race diff --git a/ui/index.html b/ui/index.html index 2d4a6c5..7a96736 100644 --- a/ui/index.html +++ b/ui/index.html @@ -572,7 +572,7 @@ .detail-buy { align-self:start; background:#0e1926; padding:18px; border:1px solid #ffffff0a; border-radius:10px; } .detail-buy .store-primary { width:100%; min-height:46px; border-radius:7px; margin:8px 0; font-size:15px; } .detail-verdict { color:#b6d6a8; font-size:12px; line-height:1.55; margin:0 0 15px; } - .detail-verdict.blocked { color:#e3b77a; } + .detail-verdict.blocked, .detail-verdict.warn { color:#e3b77a; } .detail-verdict.unknown { color:#adbac7; } .detail-price { font-size:23px; color:white; font-weight:600; } .detail-small { font-size:11px; color:var(--muted); margin:4px 0 18px; } @@ -3137,9 +3137,9 @@ function renderStore() { $("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`; if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; } if (!browsing) { $("searchGrid").innerHTML=`
${apps.map(storeCard).join("")}
`; return; } - const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && a.offers[0].verdict?.tone !== 'blocked') || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0]; + const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && !['blocked','warn'].includes(a.offers[0].verdict?.tone)) || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0]; const tested=apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works')); - const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked')); + const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && !['blocked','warn'].includes(o.verdict?.tone))); const popular=[...apps].sort((a,b)=>(b.offers[0].popularity||0)-(a.offers[0].popularity||0)); const recent=apps.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||''))); $("searchGrid").innerHTML=`` + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular apps" : "Explore apps","More ways to make your Frame yours",popular) + storeRow(tested.length ? "VR that works on the Frame" : "Step into VR",tested.length ? "Experiences checked on a Frame" : "A new perspective, a whole new playground",vr) + storeRow("Recently updated","Fresh from their creators",recent); @@ -3175,7 +3175,7 @@ function detailBadges(o) { return `${o.vr ? "VR experi function renderDetail() { 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)+`
${storeIcon(o)}

${esc(o.name)}

${esc(o.developer || o.author || 'From '+storeName(o.source_name))}

${detailBadges(o)}
${images.length ? `` : ''}

About this app

${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}
${page ? `

Meet the creators ↗

` : ''}
Technical details
Package
${esc(o.package||"Unknown")}
Version
${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})
Android API
${esc(o.min_sdk??"Unknown")}
Architecture
${esc(o.abis?.join(', ')||"Unknown / no native code")}
Updated
${esc(o.updated||"Unknown")}
Compatibility
${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}
`; + $("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`
${storeIcon(o)}

${esc(o.name)}

${esc(o.developer || o.author || 'From '+storeName(o.source_name))}

${detailBadges(o)}
${images.length ? `` : ''}

About this app

${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}
${page ? `

Meet the creators ↗

` : ''}
Technical details
Package
${esc(o.package||"Unknown")}
Version
${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})
Android API
${esc(o.min_sdk??"Unknown")}
Architecture
${esc(o.abis?.join(', ')||"Unknown / no native code")}
Updated
${esc(o.updated||"Unknown")}
Compatibility
${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}
${o.compat?.lines?.length ? `
Reports
${o.compat.lines.map(esc).join(' · ')}
` : ''}
`; updateInstall(); if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus(); } diff --git a/ui/server.py b/ui/server.py index 66108cd..c7fd5b8 100755 --- a/ui/server.py +++ b/ui/server.py @@ -2114,6 +2114,10 @@ def source_text(body, key, optional=False): return value.strip() +# Search results carry the same compatibility reports the catalogue uses (e.g. "broken"). +apk_search.compat_reports = lambda: frame_catalog.reports.by_package(frame_catalog.compat_db.load()) + + def source_search(query): args = parse_qs(query) q = args.get('q', [''])[0] From a41f635a7cd96168519e469de6f521a00b5e5810 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:41:37 +1100 Subject: [PATCH 2/4] Releases: publish drafts through REST and link update.json to the tag page publish-release.sh took update.json's "page" from the draft's url, which is releases/tag/untagged-... and dies on publishing; 0.4.0's manifest shipped that dead link. It also looked the draft up with `gh release view `, which reports "release not found" while a draft's tag_name still reads untagged-... (and uses rate-limited GraphQL). - publish-release.sh: REST only. Checks the tag exists, finds the release by tag_name or else the one untagged draft titled "Frame Control X.Y.Z", keeps the 8-installer digest check, replaces update.json via the uploads API, then PATCHes tag_name/draft/prerelease/make_latest. Adds --dry-run. - updater.js: trust only this repo's releases/tag/ pages (never untagged-...); otherwise link to releases/tag/v. - release.yml: one draft job before the matrix (no racing creates), with --verify-tag and tag_name set explicitly. - Tests: tests/test_publish_release.py with a stand-in gh (tests/fakegh/gh), and an updater test for the page. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 28 ++++++++-- app/test/updater.test.js | 14 +++++ app/updater.js | 20 ++++++-- docs/releasing.md | 15 ++++-- scripts/publish-release.sh | 88 +++++++++++++++++++++++++------ tests/fakegh/gh | 55 ++++++++++++++++++++ tests/test_publish_release.py | 97 +++++++++++++++++++++++++++++++++++ 7 files changed, 289 insertions(+), 28 deletions(-) create mode 100755 tests/fakegh/gh create mode 100644 tests/test_publish_release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04cfeaf..e3db465 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,31 @@ permissions: contents: write 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. + draft: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Create the draft release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + tag="${GITHUB_REF_NAME}" + 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) + fi + gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name' + build: + needs: draft + # Still runs for pull requests and manual runs, where the draft job is skipped. + if: ${{ !failure() && !cancelled() }} strategy: fail-fast: false matrix: @@ -46,9 +70,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - tag="${GITHUB_REF_NAME}" - gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes "" - gh release upload "$tag" ${{ matrix.files }} --clobber + gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber - uses: actions/upload-artifact@v4 with: name: frame-control-${{ matrix.os }} diff --git a/app/test/updater.test.js b/app/test/updater.test.js index 4bf9594..ffaf336 100644 --- a/app/test/updater.test.js +++ b/app/test/updater.test.js @@ -51,6 +51,20 @@ test("update.json assets always download from this repository's release", () => assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] })); }); +test("the release page is this repository's tag page, never a draft's untagged-... link", () => { + const { fromManifest, fromApi } = require("../updater"); + const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0"; + const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page; + assert.strictEqual(page(tagPage), tagPage); + // 0.4.0's real update.json: the draft's address, a 404 once published. + assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage); + assert.strictEqual(page(undefined), tagPage); + assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage); + assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage); + assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [], + html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage); +}); + test("prepare refuses a release that isn't newer (no downgrades)", async () => { const { prepare } = require("../updater"); const release = { version: "0.3.1", assets: [] }; diff --git a/app/updater.js b/app/updater.js index 85caab5..b57c12f 100644 --- a/app/updater.js +++ b/app/updater.js @@ -106,12 +106,21 @@ async function getJson(url, headers) { return JSON.parse(body); } +// The release page the banner links to. update.json for 0.4.0 carried the draft's +// address (releases/tag/untagged-...), which is dead once the release is published, +// so only this repository's tag pages are trusted; anything else becomes the tag's page. +const TAG_PAGE = new RegExp(`^https://github\\.com/${REPO}/releases/tag/(?!untagged-)[^/?#\\s]+$`); +function releasePage(page, version) { + return typeof page === "string" && TAG_PAGE.test(page) ? page : `${RELEASES}/tag/v${version}`; +} + // update.json and the API's release both become { version, notes, page, assets }. function fromManifest(m) { if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed"); - const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`; - return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000), - page: m.page || RELEASES, + const version = String(m.version).replace(/^v/i, ""); + const base = `https://github.com/${REPO}/releases/download/v${version}/`; + return { version, notes: String(m.notes || "").slice(0, 4000), + page: releasePage(m.page, version), // Assets always come from this repository's release, whatever the manifest says. assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)), size: a.size, digest: a.digest || null })) }; @@ -119,8 +128,9 @@ function fromManifest(m) { function fromApi(r) { if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release"); - return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000), - page: r.html_url || RELEASES, + const version = String(r.tag_name || "").replace(/^v/i, ""); + return { version, notes: String(r.body || "").slice(0, 4000), + page: releasePage(r.html_url, version), assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size, digest: a.digest || null })) }; } diff --git a/docs/releasing.md b/docs/releasing.md index 7db5e3a..8e10664 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -24,13 +24,20 @@ count. So a build reaches people only when you publish it, after testing it. 4. Publish: ```sh + scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing scripts/publish-release.sh v0.4.0 ``` - The script checks that all eight installers are attached, each with the - SHA-256 digest GitHub records. It attaches `update.json` (the version, the - notes and each installer's digest), then publishes the release and marks it - latest. From then on, running copies see the update. They check about 8 + The script checks that the tag is on GitHub and that all eight installers + are attached, each with the SHA-256 digest GitHub records. It attaches + `update.json` (the version, the notes, the release page and each + installer's digest), then publishes the release and marks it latest. It + uses only the REST API: `gh release view` can't find a draft whose + `tag_name` still reads `untagged-…`, and GraphQL is often rate-limited. + Such a draft is found by its title (`Frame Control 0.4.0…`) and tied to + the tag when it's published. The release page in `update.json` is always + `releases/tag/`, because a draft's own address (`releases/tag/untagged-…`) + stops working once it's published. From then on, running copies see the update. They check about 8 seconds after starting, then every 6 hours, and anyone can use **Check for Updates…** (the app menu on macOS, the Help menu elsewhere). diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index 82b5c10..66b92a1 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -3,23 +3,63 @@ # (docs/releasing.md). Checks every installer is attached with a SHA-256 # digest first, since the app's updater refuses assets without one, then # attaches update.json, the manifest the updater reads. -# Usage: scripts/publish-release.sh v0.4.0 +# Usage: scripts/publish-release.sh [--dry-run] v0.4.0 +# +# Everything goes through the REST API (gh api), not `gh release view/edit`: +# those look a draft up by its tag, and a draft can show tag_name +# "untagged-..." until it's published, so they report "release not found" +# (and GraphQL is often rate-limited). A draft's html_url is an untagged-... +# link that dies on publishing, so update.json's page is built from the tag. set -eu -tag="${1:?usage: $0 vX.Y.Z}" +dry="" +[ "${1:-}" = "--dry-run" ] && { dry=1; shift; } +tag="${1:?usage: $0 [--dry-run] vX.Y.Z}" repo=saphid/frame-control expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb" +page="https://github.com/$repo/releases/tag/$tag" -info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets) version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json") [ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +# Publishing sets tag_name; if the tag didn't exist GitHub would create it on +# the default branch, which isn't what was built and tested. +gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \ + || { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; } + +# Every release, drafts included, one JSON object per line. +gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases" +# The release whose tag_name is the tag; failing that, the one draft named +# "Frame Control X.Y.Z..." (release.yml's title) that isn't tied to a tag yet. +python3 - "$tag" "$tmp/releases" > "$tmp/release.json" <<'EOF' +import json, re, sys +tag, path = sys.argv[1], sys.argv[2] +rels = [json.loads(line) for line in open(path) if line.strip()] +hits = [r for r in rels if r.get("tag_name") == tag] +if not hits: + title = re.compile(r"Frame Control " + re.escape(tag.lstrip("v")) + r"(?![\w.])") + hits = [r for r in rels if r.get("draft") and not str(r.get("tag_name") or "").startswith("v") + and title.match(r.get("name") or "")] +if len(hits) != 1: + why = "no release" if not hits else "%d releases (ids %s)" % (len(hits), ", ".join(str(r["id"]) for r in hits)) + sys.exit("found %s for %s; expected one draft" % (why, tag)) +r = hits[0] +if not r.get("draft"): + print("note: %s is already published; refreshing update.json and marking it latest" % tag, file=sys.stderr) +json.dump(r, sys.stdout) +EOF +id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json") +echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))" + missing="" for name in $expected; do - digest=$(printf '%s' "$info" | python3 -c 'import json,sys -d=json.load(sys.stdin); n=sys.argv[1] -print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name") + digest=$(python3 -c 'import json,sys +d=json.load(open(sys.argv[1])); n=sys.argv[2] +print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$tmp/release.json" "$name") case "$digest" in sha256:*) echo "ok $name" ;; absent) echo "MISSING $name"; missing=1 ;; @@ -30,16 +70,32 @@ done # update.json: what running copies read (app/updater.js), from github.com's # latest/download link rather than the rate-limited REST API. -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT -gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys -d=json.load(sys.stdin) -names=set(sys.argv[1].split()) -print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000], +python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF' +import json, sys +d = json.load(open(sys.argv[1])) +tag, page, names = sys.argv[2], sys.argv[3], set(sys.argv[4].split()) +print(json.dumps({"version": tag.lstrip("v"), "page": page, "notes": (d.get("body") or "")[:4000], "assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]} - for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json" -gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber + for a in d["assets"] if a["name"] in names]}, indent=1)) +EOF +old=$(python3 -c 'import json,sys +d=json.load(open(sys.argv[1])) +print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))' "$tmp/release.json") + +if [ -n "$dry" ]; then + echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:" + cat "$tmp/update.json" + echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true" + exit 0 +fi + +for asset in $old; do + gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null +done +gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \ + -H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null echo "ok update.json" -gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest -echo "published $tag; running copies will offer it at their next check" +gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \ + -f make_latest=true --jq '"published " + .tag_name + " at " + .html_url' +echo "running copies will offer $tag at their next check" diff --git a/tests/fakegh/gh b/tests/fakegh/gh new file mode 100755 index 0000000..cbac489 --- /dev/null +++ b/tests/fakegh/gh @@ -0,0 +1,55 @@ +#!/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.""" +import json +import os +import sys + +args = sys.argv[1:] +with open(os.environ["FAKEGH_LOG"], "a") as f: + f.write(json.dumps(args) + "\n") +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 +while i < len(args): + a = args[i] + if a == "-X": + method = args[i + 1]; i += 2 + elif a in ("-f", "-F"): + 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"): + i += 2 + elif a.startswith("-"): + i += 1 + elif endpoint is None: + endpoint = a; i += 1 + else: + sys.exit("fakegh: unexpected argument %r" % a) + +releases = json.load(open(os.environ["FAKEGH_RELEASES"])) +repo = "repos/saphid/frame-control/" +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)) +elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"): + pass +elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"): + with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst: + 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"))) +else: + sys.exit("fakegh: unhandled %s %s" % (method, endpoint)) diff --git a/tests/test_publish_release.py b/tests/test_publish_release.py new file mode 100644 index 0000000..17d178e --- /dev/null +++ b/tests/test_publish_release.py @@ -0,0 +1,97 @@ +"""scripts/publish-release.sh against a stand-in gh (tests/fakegh/gh): finds the draft +through the REST API even when its tag_name still says untagged-..., refuses +missing installers or digests, writes update.json's page from the tag, and +publishes with one PATCH. 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 subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "scripts" / "publish-release.sh" +FAKEGH = ROOT / "tests" / "fakegh" +INSTALLERS = ["Frame-Control-mac-arm64.dmg", "Frame-Control-mac-arm64.zip", "Frame-Control-Setup-x64.exe", + "Frame-Control-win-x64.zip", "Frame-Control-linux-x86_64.AppImage", + "Frame-Control-linux-arm64.AppImage", "Frame-Control-linux-amd64.deb", + "Frame-Control-linux-arm64.deb"] + + +def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7", rid=42, assets=None): + if assets is None: + assets = [{"id": 100 + i, "name": n, "size": 1000 + i, "digest": "sha256:" + "%064x" % i} + for i, n in enumerate(INSTALLERS)] + return {"id": rid, "tag_name": tag_name, "name": name, "draft": True, "prerelease": False, + "body": "notes", "html_url": "https://github.com/saphid/frame-control/releases/tag/" + tag_name, + "assets": assets} + + +class PublishRelease(unittest.TestCase): + def run_script(self, releases, *args, tags="v9.8.7"): + d = Path(tempfile.mkdtemp()) + (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"), FAKEGH_UPLOAD=str(d / "upload.json")) + p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30) + log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else [] + upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None + return p, log, upload + + def test_publishes_an_untagged_draft_by_title_with_the_tag_page(self): + old = {"id": 7, "name": "update.json", "size": 1, "digest": None} + rel = draft(assets=draft()["assets"] + [old]) + p, log, upload = self.run_script([rel], "v9.8.7") + self.assertEqual(p.returncode, 0, p.stderr + p.stdout) + self.assertEqual(upload["page"], "https://github.com/saphid/frame-control/releases/tag/v9.8.7") + self.assertEqual(upload["version"], "9.8.7") + self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS)) + self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log) + patch = next(c for c in log if "PATCH" in c) + self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42") + for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"): + self.assertIn(f, patch) + self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL) + + def test_prefers_the_release_whose_tag_name_matches(self): + p, log, upload = self.run_script([draft(name="Frame Control 9.8.7 old", rid=1), + draft(tag_name="v9.8.7", name="Renamed", rid=2)], "v9.8.7") + self.assertEqual(p.returncode, 0, p.stderr + p.stdout) + self.assertEqual(next(c for c in log if "PATCH" in c)[3], "repos/saphid/frame-control/releases/2") + + def test_refuses_missing_or_unhashed_installers(self): + assets = draft()["assets"][1:] + assets[0] = dict(assets[0], digest=None) + p, log, upload = self.run_script([draft(assets=assets)], "v9.8.7") + self.assertNotEqual(p.returncode, 0) + self.assertIn("MISSING Frame-Control-mac-arm64.dmg", p.stdout) + self.assertIn("NO HASH Frame-Control-mac-arm64.zip", p.stdout) + self.assertIsNone(upload) + self.assertFalse(any(c[1:3] == ["-X", "PATCH"] for c in log)) + + def test_refuses_ambiguous_or_absent_drafts_and_missing_tags(self): + p, _, _ = self.run_script([draft(rid=1), draft(rid=2)], "v9.8.7") + self.assertNotEqual(p.returncode, 0) + self.assertIn("2 releases", p.stderr) + p, _, _ = self.run_script([draft(name="Frame Control 9.8.70")], "v9.8.7") + self.assertNotEqual(p.returncode, 0) + self.assertIn("no release", p.stderr) + p, log, _ = self.run_script([draft()], "v9.8.7", tags="") + self.assertNotEqual(p.returncode, 0) + self.assertIn("push it first", p.stderr) + + def test_dry_run_changes_nothing(self): + p, log, upload = self.run_script([draft()], "--dry-run", "v9.8.7") + self.assertEqual(p.returncode, 0, p.stderr + p.stdout) + self.assertIn('"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7"', p.stdout) + self.assertIsNone(upload) + self.assertTrue(all("-X" not in c for c in log)) + + +if __name__ == "__main__": + unittest.main() From a46c28d6c87db5bdc93b859363b828524520c3cf Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:46:35 +1100 Subject: [PATCH 3/4] Drop compat reports in search from this change (moved to #77) Review (T3 task pr74-review-r1, gpt-6.1-sol) found that search blocked on frame_compat_db.load() after a cache expiry (up to ~40 s with an unreachable database, or an untimed Keychain lookup without a key), and that the verdict showed only in the detail view, not on cards. Revert ui/apk_sources/search.py, ui/server.py, ui/index.html and their tests to main; keep the wrong-ABI error text, the reports.py wrong-file filter and the ABI-selection tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_apk_artwork.py | 3 --- tests/test_apk_search.py | 43 --------------------------------------- ui/apk_sources/search.py | 43 +++++---------------------------------- ui/index.html | 8 ++++---- ui/server.py | 4 ---- 5 files changed, 9 insertions(+), 92 deletions(-) diff --git a/tests/test_apk_artwork.py b/tests/test_apk_artwork.py index 555a465..b036f90 100644 --- a/tests/test_apk_artwork.py +++ b/tests/test_apk_artwork.py @@ -16,9 +16,6 @@ PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').rea class ArtworkTests(unittest.TestCase): def setUp(self): - p = patch.object(search, 'compat_reports', None) # server.py sets the real database - p.start() - self.addCleanup(p.stop) with _images._lock: _images._urls.clear() _images._cache.clear() diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py index f47656c..20cd57e 100644 --- a/tests/test_apk_search.py +++ b/tests/test_apk_search.py @@ -33,10 +33,6 @@ class SettingsTest(unittest.TestCase): self.addCleanup(p.stop) for state in (search._running, search._pending, search._status, search._game_data): state.clear() - # server.py points this at the real compatibility database; tests use none unless they say so. - p = patch.object(search, 'compat_reports', None) - p.start() - self.addCleanup(p.stop) from apk_sources import _web self.claims = [] for name in ('claim', 'release'): # fake downloads aren't real files @@ -79,45 +75,6 @@ class SearchTests(SettingsTest): self.assertFalse(search.fit({'min_sdk': 23, 'abis': ['x86']})['installable']) self.assertIn('Legacy VrApi', search.fit({'engine': 'VrApi'})['reasons'][0]) - def test_compat_reports_surface_in_results(self): - grayjay = dict(source='futo', id='com.futo.platformplayer', package='com.futo.platformplayer', - name='Grayjay', min_sdk=28, vr=False, free=True, downloadable=True, - abis=['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']) - reps = {'com.futo.platformplayer': [ - {'package': 'com.futo.platformplayer', 'version': '391', 'result': 'install_failed', - 'notes': 'Grayjay has no arm64-v8a build (armeabi-v7a); Lepton is 64-bit ARM only', - 'date': '2026-10-01T10:00:00'}, - {'package': 'com.futo.platformplayer', 'version': '391', 'result': 'crashes', - 'date': '2026-10-02T10:00:00', 'via': 'probe'}, - {'package': 'com.futo.platformplayer', 'version': '391', 'rating': 'broken', - 'date': '2026-10-03T10:00:00', 'via': 'user'}]} - with patch.object(search, 'compat_reports', lambda: reps): - offer = search.group([grayjay], 'grayjay')[0]['offers'][0] - self.assertEqual(offer['verdict'], {'label': 'Reported not working on the Frame', 'tone': 'warn'}) - self.assertEqual(offer['compat']['verdict'], 'no') - self.assertIn('broken', offer['compat']['lines'][0]) - self.assertEqual(offer['compat']['lines'][1], '2 reports, 0 working') # the wrong-file report is left out - self.assertTrue(offer['fit']['installable']) # a warning, not a block: the user may still try - - works = {'org.brush': [{'package': 'org.brush', 'rating': 'works', 'date': '2026-10-01'}]} - with patch.object(search, 'compat_reports', lambda: works): - brush = search.group(ENTRIES[:1])[0]['offers'][0] - self.assertEqual(brush['verdict']['tone'], 'works') - - # A report never overrides a hard blocker such as a missing arm64-v8a build. - x86 = dict(grayjay, abis=['x86_64']) - with patch.object(search, 'compat_reports', lambda: {'com.futo.platformplayer': works['org.brush']}): - self.assertEqual(search.group([x86])[0]['offers'][0]['verdict']['tone'], 'blocked') - - def test_compat_reports_failure_or_absence_changes_nothing(self): - def broken(): - raise OSError('database unavailable') - for hook in (None, broken, lambda: {}): - with patch.object(search, 'compat_reports', hook): - offer = search.group(ENTRIES[:1])[0]['offers'][0] - self.assertIsNone(offer['compat']) - self.assertEqual(offer['verdict']['label'], 'Ready to try on the Frame') - def test_timeout_and_failure_leave_other_results(self): release = threading.Event() calls = [] diff --git a/ui/apk_sources/search.py b/ui/apk_sources/search.py index 5537016..883c08b 100644 --- a/ui/apk_sources/search.py +++ b/ui/apk_sources/search.py @@ -2,13 +2,7 @@ Optional store metadata: images {icon, banner, screenshots}, developer, description, popularity, open_source, requires_meta_services, frame_tested. -Only an explicit frame_tested=True, or a compatibility report, produces a -working-on-Frame verdict. - -Compatibility reports (frame_compat_db, through the compat_reports hook that -server.py sets) are matched by package name and turned into entry['compat'], -using the same rules as the catalogue (apk-catalog/reports.py). A report never -overrides a hard blocker such as a missing arm64-v8a build. +Only an explicit frame_tested=True produces a working-on-Frame verdict. """ import importlib import inspect @@ -29,8 +23,6 @@ _pending = {} # source id -> newest query waiting for the running one _game_data = {} # package -> downloaded OBB paths waiting for "Add game data" _status = {} TIMEOUT = 12 -# () -> {package: [report, ...]}; set by server.py. None (or a failure) means no reports. -compat_reports = None def modules(): @@ -148,24 +140,6 @@ def fit(entry): 'reasons': reasons} -def _reports(): - if compat_reports is None: - return {} - try: - return compat_reports() or {} - except Exception: # noqa: BLE001 - reports are a hint; search must still work - return {} - - -def compat(entry, by_package): - """{'verdict': works|maybe|no, 'lines': [...]} from reports for this package, or None.""" - found = by_package.get(entry.get('package')) if entry.get('package') else None - if found: - from frame_catalog import reports # apk-catalog/reports.py, the catalogue's rules - found = reports.verdict(found) - return {'verdict': found[0], 'lines': found[1]} if found else None - - def verdict(entry): compatibility = fit(entry) hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower() @@ -177,28 +151,22 @@ def verdict(entry): return {'label': "This version isn't made for the Frame", 'tone': 'blocked'} if 'vrapi' in hints: return {'label': "Made for older Quest headsets; won't run on the Frame", 'tone': 'blocked'} - reported = (entry.get('compat') or {}).get('verdict') - if reported == 'no': - return {'label': 'Reported not working on the Frame', 'tone': 'warn'} - if reported == 'maybe': - return {'label': 'Reported working with issues on the Frame', 'tone': 'warn'} - if entry.get('frame_tested') is True or reported == 'works': + if entry.get('frame_tested') is True: return {'label': 'Works on the Frame', 'tone': 'works'} if compatibility['installable'] is True: return {'label': 'Ready to try on the Frame', 'tone': 'ready'} return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'} -def decorate(entry, by_package=None): +def decorate(entry): from apk_sources import _images - entry = dict(entry, compat=compat(entry, by_package or {})) return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry)) def details(source_id, entry_id): module, source = resolve(source_id) return decorate(dict(module.details(source, entry_id), source=source_id, - source_name=source['name'], trust=source.get('trust')), _reports()) + source_name=source['name'], trust=source.get('trust'))) def offer_rank(entry): @@ -212,9 +180,8 @@ def offer_rank(entry): def group(entries, query='', vr=None, installable=False): groups = {} - by_package = _reports() if entries else {} for entry in entries: - entry = decorate(entry, by_package) + entry = decorate(entry) if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat continue if installable and entry['fit']['installable'] is not True: diff --git a/ui/index.html b/ui/index.html index 7a96736..2d4a6c5 100644 --- a/ui/index.html +++ b/ui/index.html @@ -572,7 +572,7 @@ .detail-buy { align-self:start; background:#0e1926; padding:18px; border:1px solid #ffffff0a; border-radius:10px; } .detail-buy .store-primary { width:100%; min-height:46px; border-radius:7px; margin:8px 0; font-size:15px; } .detail-verdict { color:#b6d6a8; font-size:12px; line-height:1.55; margin:0 0 15px; } - .detail-verdict.blocked, .detail-verdict.warn { color:#e3b77a; } + .detail-verdict.blocked { color:#e3b77a; } .detail-verdict.unknown { color:#adbac7; } .detail-price { font-size:23px; color:white; font-weight:600; } .detail-small { font-size:11px; color:var(--muted); margin:4px 0 18px; } @@ -3137,9 +3137,9 @@ function renderStore() { $("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`; if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; } if (!browsing) { $("searchGrid").innerHTML=`
${apps.map(storeCard).join("")}
`; return; } - const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && !['blocked','warn'].includes(a.offers[0].verdict?.tone)) || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0]; + const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && a.offers[0].verdict?.tone !== 'blocked') || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0]; const tested=apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works')); - const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && !['blocked','warn'].includes(o.verdict?.tone))); + const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked')); const popular=[...apps].sort((a,b)=>(b.offers[0].popularity||0)-(a.offers[0].popularity||0)); const recent=apps.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||''))); $("searchGrid").innerHTML=`` + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular apps" : "Explore apps","More ways to make your Frame yours",popular) + storeRow(tested.length ? "VR that works on the Frame" : "Step into VR",tested.length ? "Experiences checked on a Frame" : "A new perspective, a whole new playground",vr) + storeRow("Recently updated","Fresh from their creators",recent); @@ -3175,7 +3175,7 @@ function detailBadges(o) { return `${o.vr ? "VR experi function renderDetail() { 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)+`
${storeIcon(o)}

${esc(o.name)}

${esc(o.developer || o.author || 'From '+storeName(o.source_name))}

${detailBadges(o)}
${images.length ? `` : ''}

About this app

${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}
${page ? `

Meet the creators ↗

` : ''}
Technical details
Package
${esc(o.package||"Unknown")}
Version
${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})
Android API
${esc(o.min_sdk??"Unknown")}
Architecture
${esc(o.abis?.join(', ')||"Unknown / no native code")}
Updated
${esc(o.updated||"Unknown")}
Compatibility
${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}
${o.compat?.lines?.length ? `
Reports
${o.compat.lines.map(esc).join(' · ')}
` : ''}
`; + $("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`
${storeIcon(o)}

${esc(o.name)}

${esc(o.developer || o.author || 'From '+storeName(o.source_name))}

${detailBadges(o)}
${images.length ? `` : ''}

About this app

${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}
${page ? `

Meet the creators ↗

` : ''}
Technical details
Package
${esc(o.package||"Unknown")}
Version
${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})
Android API
${esc(o.min_sdk??"Unknown")}
Architecture
${esc(o.abis?.join(', ')||"Unknown / no native code")}
Updated
${esc(o.updated||"Unknown")}
Compatibility
${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}
`; updateInstall(); if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus(); } diff --git a/ui/server.py b/ui/server.py index c7fd5b8..66108cd 100755 --- a/ui/server.py +++ b/ui/server.py @@ -2114,10 +2114,6 @@ def source_text(body, key, optional=False): return value.strip() -# Search results carry the same compatibility reports the catalogue uses (e.g. "broken"). -apk_search.compat_reports = lambda: frame_catalog.reports.by_package(frame_catalog.compat_db.load()) - - def source_search(query): args = parse_qs(query) q = args.get('q', [''])[0] From f5f3a1f9cafd663f52f55684f3e0d0e7844d661f Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:50:00 +1100 Subject: [PATCH 4/4] Releases: exact draft match and a fixed release page (review fixes) - publish-release.sh: the fallback takes only an untagged-... draft titled exactly "Frame Control X.Y.Z" (optionally ": subtitle"); a pre-release's draft ("9.8.7-rc.1") or one bound to another tag is refused. - updater.js: ignore the page in update.json/the API entirely and always link to releases/tag/v built from the validated version, so a path like tag/..\..\other/repo can't reach shell.openExternal. - Tests for both, including backslash and %2e%2e traversal. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/test/updater.test.js | 7 +++++++ app/updater.js | 15 +++++++++------ docs/releasing.md | 4 ++-- scripts/publish-release.sh | 12 +++++++----- tests/test_publish_release.py | 13 +++++++++++++ 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/app/test/updater.test.js b/app/test/updater.test.js index ffaf336..2ae00af 100644 --- a/app/test/updater.test.js +++ b/app/test/updater.test.js @@ -61,6 +61,13 @@ test("the release page is this repository's tag page, never a draft's untagged-. assert.strictEqual(page(undefined), tagPage); assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage); assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage); + // Paths that normalize to another repository. + assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"), + tagPage); + assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"), + tagPage); + assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag + assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases"); assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [], html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage); }); diff --git a/app/updater.js b/app/updater.js index b57c12f..e6ccbc9 100644 --- a/app/updater.js +++ b/app/updater.js @@ -108,10 +108,13 @@ async function getJson(url, headers) { // The release page the banner links to. update.json for 0.4.0 carried the draft's // address (releases/tag/untagged-...), which is dead once the release is published, -// so only this repository's tag pages are trusted; anything else becomes the tag's page. -const TAG_PAGE = new RegExp(`^https://github\\.com/${REPO}/releases/tag/(?!untagged-)[^/?#\\s]+$`); -function releasePage(page, version) { - return typeof page === "string" && TAG_PAGE.test(page) ? page : `${RELEASES}/tag/v${version}`; +// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo +// normalizes to another repository) before shell.openExternal. So the given page is +// ignored: the link is always this repository's tag page, built from a valid version. +function releasePage(version) { + const v = parseVersion(version); + if (!v) return RELEASES; + return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`; } // update.json and the API's release both become { version, notes, page, assets }. @@ -120,7 +123,7 @@ function fromManifest(m) { const version = String(m.version).replace(/^v/i, ""); const base = `https://github.com/${REPO}/releases/download/v${version}/`; return { version, notes: String(m.notes || "").slice(0, 4000), - page: releasePage(m.page, version), + page: releasePage(version), // Assets always come from this repository's release, whatever the manifest says. assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)), size: a.size, digest: a.digest || null })) }; @@ -130,7 +133,7 @@ function fromApi(r) { if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release"); const version = String(r.tag_name || "").replace(/^v/i, ""); return { version, notes: String(r.body || "").slice(0, 4000), - page: releasePage(r.html_url, version), + page: releasePage(version), assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size, digest: a.digest || null })) }; } diff --git a/docs/releasing.md b/docs/releasing.md index 8e10664..c4ce8e1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -34,8 +34,8 @@ count. So a build reaches people only when you publish it, after testing it. installer's digest), then publishes the release and marks it latest. It uses only the REST API: `gh release view` can't find a draft whose `tag_name` still reads `untagged-…`, and GraphQL is often rate-limited. - Such a draft is found by its title (`Frame Control 0.4.0…`) and tied to - the tag when it's published. The release page in `update.json` is always + Such a draft is found by its exact title (`Frame Control 0.4.0`, or that + followed by `: subtitle`) and tied to the tag when it's published. The release page in `update.json` is always `releases/tag/`, because a draft's own address (`releases/tag/untagged-…`) stops working once it's published. From then on, running copies see the update. They check about 8 seconds after starting, then every 6 hours, and anyone can use **Check for diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index 66b92a1..9101c45 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -33,17 +33,19 @@ gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \ # Every release, drafts included, one JSON object per line. gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases" -# The release whose tag_name is the tag; failing that, the one draft named -# "Frame Control X.Y.Z..." (release.yml's title) that isn't tied to a tag yet. +# The release whose tag_name is the tag; failing that, the one untagged-... draft +# titled "Frame Control X.Y.Z" (release.yml's title), optionally ": subtitle". python3 - "$tag" "$tmp/releases" > "$tmp/release.json" <<'EOF' import json, re, sys tag, path = sys.argv[1], sys.argv[2] rels = [json.loads(line) for line in open(path) if line.strip()] hits = [r for r in rels if r.get("tag_name") == tag] if not hits: - title = re.compile(r"Frame Control " + re.escape(tag.lstrip("v")) + r"(?![\w.])") - hits = [r for r in rels if r.get("draft") and not str(r.get("tag_name") or "").startswith("v") - and title.match(r.get("name") or "")] + # Exactly this version: "Frame Control 0.4.0", or that followed by ": ". + # Never "Frame Control 0.4.0-rc.1" or "0.4.00", and only drafts with no real tag. + title = re.compile(r"Frame Control " + re.escape(tag.lstrip("v")) + r"(: .*)?", re.S) + hits = [r for r in rels if r.get("draft") and str(r.get("tag_name") or "").startswith("untagged-") + and title.fullmatch(r.get("name") or "")] if len(hits) != 1: why = "no release" if not hits else "%d releases (ids %s)" % (len(hits), ", ".join(str(r["id"]) for r in hits)) sys.exit("found %s for %s; expected one draft" % (why, tag)) diff --git a/tests/test_publish_release.py b/tests/test_publish_release.py index 17d178e..07baece 100644 --- a/tests/test_publish_release.py +++ b/tests/test_publish_release.py @@ -58,6 +58,11 @@ class PublishRelease(unittest.TestCase): self.assertIn(f, patch) self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL) + def test_an_untagged_draft_may_carry_a_subtitle(self): + p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7") + self.assertEqual(p.returncode, 0, p.stderr + p.stdout) + self.assertEqual(upload["version"], "9.8.7") + def test_prefers_the_release_whose_tag_name_matches(self): p, log, upload = self.run_script([draft(name="Frame Control 9.8.7 old", rid=1), draft(tag_name="v9.8.7", name="Renamed", rid=2)], "v9.8.7") @@ -81,6 +86,14 @@ class PublishRelease(unittest.TestCase): p, _, _ = self.run_script([draft(name="Frame Control 9.8.70")], "v9.8.7") self.assertNotEqual(p.returncode, 0) self.assertIn("no release", p.stderr) + # A pre-release's draft, or one bound to an unrelated tag, is never taken for v9.8.7. + for rel in (draft(name="Frame Control 9.8.7-rc.1"), draft(name="Frame Control 9.8.7.1"), + draft(tag_name="kdeconnect-frame-1"), draft(tag_name="")): + p, log, upload = self.run_script([rel], "v9.8.7") + self.assertNotEqual(p.returncode, 0, rel) + self.assertIn("no release", p.stderr) + self.assertIsNone(upload) + self.assertFalse(any("PATCH" in c for c in log)) p, log, _ = self.run_script([draft()], "v9.8.7", tags="") self.assertNotEqual(p.returncode, 0) self.assertIn("push it first", p.stderr)