Compare commits

..
Author SHA1 Message Date
Alex Southwell 54c001d537 Merge pull request #134 from saphid/release/0.4.12
Bump Frame Control to 0.4.12
2026-10-24 04:29:09 +00:00
SAPHID 5ce5c023b6 Merge remote-tracking branch 'origin/main' into release/0.4.12 2026-10-24 04:20:33 +00:00
Alex Southwell 709fef0dbd Merge pull request #133 from saphid/t3/android-compat-verdict-search
Android store: show compatibility reports without blocking search
2026-10-24 04:18:57 +00:00
SAPHID 49408dc171 Bump Frame Control to 0.4.12 2026-10-24 04:12:30 +00:00
SAPHID c728e64fbd Merge remote-tracking branch 'origin/main' into coord/resolve-133
# Conflicts:
#	CHANGELOG.md
2026-10-24 04:11:32 +00:00
Alex Southwell 0626c52e12 Merge pull request #132 from saphid/t3/fix-log-scrubber-edge-cases
Fix display-name overlaps in optional log redaction
2026-10-24 04:10:58 +00:00
SAPHID b9b4a00485 Merge remote-tracking branch 'origin/main' into coord/resolve-132
# Conflicts:
#	CHANGELOG.md
2026-10-24 04:00:26 +00:00
Alex Southwell eb125b59c7 Merge pull request #131 from saphid/fix/issue-87-recovery-polish
Fix remaining host-key recovery UI clipping and punctuation
2026-10-24 03:59:21 +00:00
SAPHID 7e53fe32cb Show Android compatibility reports without blocking search 2026-10-24 03:58:37 +00:00
SAPHID ebae409632 Fix overlapping display-name redaction in optional logs 2026-10-24 03:56:45 +00:00
SAPHID 08cb267ce4 Fix remaining host-key recovery clipping and tooltip punctuation 2026-10-24 03:52:38 +00:00
Alex Southwell 0119544c3e Merge pull request #130 from saphid/release/0.4.11
Frame Control 0.4.11
2026-10-24 03:42:24 +00:00
SAPHID 2a9e7dcabf Bump Frame Control to 0.4.11 2026-10-24 03:34:25 +00:00
Alex Southwell 5a9099dddb Merge pull request #129 from saphid/t3/test-windows-vm-headset
Fix Windows PC screen capture with the bundled GStreamer libraries
2026-10-24 03:33:31 +00:00
19 changed files with 662 additions and 46 deletions

No files matched your search

+14
View File
@@ -2,6 +2,20 @@
## Unreleased
- Show saved Android compatibility reports on store search cards and app details.
Reports refresh in the background, so a stalled database or credential lookup
cannot delay search. Warnings allow installation; build compatibility blockers
still apply. Public copies use this computer's reports, not private shared ratings.
- Fix two cases where optional diagnostic logs could retain part of a headset
display name or an SSH host name, while preserving existing privacy redaction
(issue #81).
- Keep the full host-key recovery explanation readable in Devices, including
long addresses, and remove doubled punctuation from the connection tooltip
(issue #87). Refresh stays within the header and switching headsets selects
the new headset's details.
- Fix experimental PC screen capture on Windows builds whose streaming libraries
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
windows are hidden with an explanation instead of failing when shown. A Windows
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.10",
"version": "0.4.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.10",
"version": "0.4.12",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.10",
"version": "0.4.12",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+29
View File
@@ -190,6 +190,35 @@ IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on th
Frame as cards, with Play and a menu for Test, Report and Remove; the display
settings and recent reports are below it.
Store search cards and app details also show **Reported not working** or
**Reported issues** when saved compatibility reports say so. Details include
the report date, version and notes. These are advisories: Install remains
available unless the selected build has an existing compatibility blocker
(for example, no arm64-v8a build or Android API above 30). Reports never clear
those blockers. Apps reported not working are excluded from Home's featured
app and recommendation shelves; search and category results still show them.
An unrated install failure caused by the wrong APK architecture or Android API
is ignored, using the same rules as the catalogue. A person's rating takes
priority over automated results, with the latest rating winning.
Reports warm in a background worker at server start. Search and details read
only the last complete in-memory snapshot and schedule a single background
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
the report database. During initial loading they show the usual build verdict;
after a refresh, the next search or detail request sees the reports. Failed
refreshes keep the previous snapshot and retry after another 60 seconds.
**Availability:** public copies benefit from their own saved reports, including
reports for apps outside the bundled catalogue, across every source with the
same package name. This does not give public users the private shared ratings.
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
report verdicts, so it cannot supply a useful public report snapshot. No private
reports, free-text notes or credentials are exported or published. The existing
maintainer loader still uses its configured database access in the background;
the search feature adds no sharing, endpoint or network destination. Tests leave
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
server subprocesses) and use synthetic reports to exercise refresh behavior.
The store groups apps by what a headset is good for, not by F-Droid's list:
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
+6
View File
@@ -97,6 +97,12 @@ The app rereads the gateway every 5 seconds and Tailscale's state every
## The connection, stage by stage
When a connection fails, the header pill keeps its compact offline state and
retry countdown; its tooltip and connection dialog contain the full reason.
Devices shows a short reason in the headset row (with the full reason in its
tooltip) and a wrapping recovery explanation in the selected headset's details.
**Use this headset** also selects that headset's details.
The connector runs in the server (`frame_link.Link`) and moves through:
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
+5 -3
View File
@@ -203,9 +203,11 @@ and display name of the headset being tried (whole, whatever their case), and
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
in some other wording can still get through, so check the log lines in **Show
exactly what's included** before sending. A failure that repeats on every
included) becomes `<user>@<host>:`; then the scrubbing below. Whole display names
are hidden even when they contain a scrubber token such as `<host>` or overlap
ssh's wording. Those overlaps also preserve redaction of URLs, home folders,
and keys. A host name ssh mentions in some other wording can still get through,
so check the log lines in **Show exactly what's included** before sending. A failure that repeats on every
retry is written at most once every 5 minutes.
Everything is scrubbed like error details and limited to what fits in the
+15
View File
@@ -437,3 +437,18 @@ files, source preference, missing/invalid sensors and permission errors.
`tests/control_center.cjs` covers charging states and optional health fields,
alongside existing stale/offline handling. No headset is used. See
[battery monitoring](battery-health.md) for sources, units and limitations.
## Host-key recovery presentation
`python3 -m unittest discover -s tests -p test_link_ui.py -v` runs the page's
connection/banner/selection functions in Node and checks the recovery CSS guard.
`test_link.py` covers connection failures and short probe reasons with fake SSH
and local sockets. Neither suite uses a real headset.
Browser verification on 2026-10-24 used the current page's markup, styles and
recovery renderers with synthetic changed-key state and two headsets at
1280×800. It caught a later `.item .s` rule overriding the earlier wrapping
rule, which the original source-only assertion missed. Check computed wrapping,
the complete explanation, tooltip punctuation, Refresh bounds and selection
after **Use this headset** when changing these styles. This fixture does not
exercise Electron integration or a real SSH identity change.
+30 -3
View File
@@ -13,13 +13,13 @@ function grab(name) {
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;
for (const c of lines[i]) depth += c === "{" || c === "[" ? 1 : c === "}" || 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",
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeReportDetails", "storePickEligible", "STORE_CATS", "storeCat", "storeCard", "storeRow", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
const code = names.map(grab).join("");
@@ -34,7 +34,7 @@ function page(api = async () => ({}), androidApps = []) {
const document = { activeElement: null, querySelector: () => null };
const console = { error() {} };
const calls = [];
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp };")(
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp, storeCard, storeRow, storePickEligible };")(
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps,
async (title, key, work) => work(), () => {}, async (title, work) => work(),
{ open() { fail("an in-app installation opened an external page"); } }, 0);
@@ -114,4 +114,31 @@ for (const [job, expected] of [[null, 1], [{ done: false }, 0],
if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer");
if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app");
}
// Report warnings appear on real cards, shelves and details; they are advisory.
for (const [kind, tone, label] of [['no', 'warning', 'Reported not working'], ['maybe', 'issues', 'Reported issues']]) {
const p = page();
const o = {...offer, verdict: {tone, label: label+' on the Frame'},
compat_report: {verdict: kind, lines: ['Reported <unsafe> notes', '2 reports, 0 working']}};
const app = {name: o.name, offers: [o]};
p.sourceState.apps = [app];
p.sourceState.detail = {app, choice: 0};
const card = p.storeCard(app), shelf = p.storeRow('VR picks', 'Synthetic fixtures', [app]);
if (!card.includes('>'+label+'</span>') || !shelf.includes('>'+label+'</span>')) fail('warning missing on card or shelf');
if (!p.renderAppDetail()) fail('report detail did not render');
const html = p.els.detailBody.innerHTML;
if (!html.includes('Saved compatibility reports') || !html.includes('Reported &lt;unsafe&gt; notes')) fail('report evidence missing or unescaped');
if (html.includes('Reported <unsafe>')) fail('report notes injected HTML');
if (p.els.detailInstall.disabled || p.els.detailInstall.textContent !== 'Install on the Frame') fail('report disabled installation');
if (kind==='no' && p.storePickEligible(o)) fail('broken report is eligible for featured/VR picks');
// A positive report cannot remove a hard blocker. Negative evidence stays visible too.
for (const reportKind of [kind, 'works']) {
const blocked = {...o, verdict: {tone: 'blocked', label: 'No arm64 build'}, compat_report: {...o.compat_report, verdict: reportKind}};
app.offers = [blocked];
if (!p.renderAppDetail() || !p.els.detailInstall.disabled) fail('report overrode hard blocker');
if (p.storePickEligible(blocked)) fail('blocked app eligible for picks');
if (reportKind!=='works' && !p.storeCard(app).includes('>'+label+'</span>')) fail('hard blocker hid report warning');
}
}
console.log("ok");
+1
View File
@@ -22,6 +22,7 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
# A maintainer's machine holds the database key; send anything that slips through nowhere.
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
os.environ["FRAME_COMPAT_SEARCH_REPORTS"] = "0" # server subprocesses never start real report readers
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
# its lock file) and the other per-user folders some modules use. A server started
+3
View File
@@ -17,6 +17,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)
p.start()
self.addCleanup(p.stop)
with _images._lock:
_images._urls.clear()
_images._tokens.clear()
+169
View File
@@ -0,0 +1,169 @@
"""Search report snapshots: synthetic evidence only, no credentials or services."""
import sandbox # noqa: F401
import sys
import json
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_catalog
import frame_compat_db as db
from apk_sources import search
from test_apk_search import SettingsTest, fake, ENTRIES
class ReportTests(SettingsTest):
def hook(self, rows):
by = frame_catalog.reports.by_package(rows)
snapshot = {pkg: v for pkg, rs in by.items() if (v := frame_catalog.reports.verdict(rs))}
p = patch.object(search, 'compat_reports', lambda: snapshot)
p.start()
self.addCleanup(p.stop)
def test_search_and_details_share_catalogue_rules(self):
pkg = ENTRIES[0]['package']
self.hook([dict(package=pkg, date='2026-10-01', result='runs'),
dict(package=pkg, date='2026-10-02', rating='broken', notes='Crashes <here>'),
dict(package=pkg, date='2026-10-03', result='runs')])
mod = fake()
with patch.object(search, 'modules', return_value=([mod], [])):
offer = search.search(timeout=.01)['apps'][0]['offers'][0]
detail = search.details('one', ENTRIES[0]['id'])
self.assertEqual(detail['compat_report']['verdict'], 'no')
self.assertEqual(offer['compat_report'], detail['compat_report'])
self.assertEqual(detail['verdict']['tone'], 'warning')
self.assertIn('Reported not working', detail['verdict']['label'])
def test_warnings_never_change_installability_or_hard_blockers(self):
entry = dict(ENTRIES[0], min_sdk=23, abis=['arm64-v8a'])
for kind, tone in [('works', 'works'), ('issues', 'issues'), ('broken', 'warning')]:
self.hook([dict(package=entry['package'], date='2026-10-01', rating=kind)])
decorated = search.decorate(entry)
self.assertEqual(decorated['verdict']['tone'], tone)
self.assertTrue(decorated['fit']['installable'])
for blocker in [dict(min_sdk=31), dict(abis=['x86_64']),
dict(requires_meta_services=True), dict(engine='VrApi')]:
blocked = search.decorate(dict(entry, **blocker))
self.assertEqual(blocked['verdict']['tone'], 'blocked')
self.assertIsNotNone(blocked['compat_report'])
def test_wrong_file_failures_do_not_warn_but_real_failures_do(self):
entry = dict(ENTRIES[0], min_sdk=23, abis=[])
for notes in ['no arm64-v8a build', 'needs Android API 32']:
self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes=notes)])
self.assertIsNone(search.decorate(entry)['compat_report'])
self.hook([dict(package=entry['package'], date='2026-10-01', result='crashes')])
self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning')
self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes='installer crashed')])
self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning')
def test_report_warning_does_not_prevent_install(self):
self.hook([dict(package=ENTRIES[0]['package'], date='2026-10-01', rating='broken')])
mod = fake()
with patch.object(search, 'modules', return_value=([mod], [])), \
patch.object(frame_catalog.frame_android, 'install', return_value={'package': ENTRIES[0]['package']}) as install:
result = search.install('one', ENTRIES[0]['id'])
self.assertEqual(result['package'], ENTRIES[0]['package'])
install.assert_called_once()
def test_stalled_loader_does_not_block_search_or_details(self):
entered, release, finished = threading.Event(), threading.Event(), threading.Event()
main = threading.get_ident()
def loader():
self.assertNotEqual(threading.get_ident(), main)
entered.set()
try:
if not release.wait(2):
raise TimeoutError('test stalled')
return []
finally:
finished.set()
cache = search.ReportCache(loader, frame_catalog.reports.verdict)
from apk_sources import _web
with patch.object(search, 'compat_reports', cache.read), \
patch.object(search, 'modules', return_value=([fake()], [])), \
patch.object(_web, 'prune'), \
patch.object(db, 'key', side_effect=AssertionError('credentials on request')), \
patch.object(db._opener, 'open', side_effect=AssertionError('network on request')), \
patch.object(db.subprocess, 'run', side_effect=AssertionError('subprocess on request')):
try:
search.warm() # production server startup, before the first request
self.assertTrue(entered.wait(1))
start = time.monotonic()
result = search.search(timeout=.01)
detail = search.details('one', ENTRIES[0]['id'])
self.assertLess(time.monotonic() - start, .2)
self.assertTrue(result['apps'])
self.assertIsNone(detail['compat_report'])
finally:
release.set()
self.assertTrue(finished.wait(1))
class CacheTests(unittest.TestCase):
def wait_idle(self, cache):
deadline = time.monotonic() + 1
while time.monotonic() < deadline:
with cache._lock:
if not cache._running:
return
time.sleep(.001)
self.fail('report worker did not finish')
def test_public_copy_reads_its_local_mirror_and_outbox(self):
with tempfile.TemporaryDirectory() as tmp:
mirror, outbox = Path(tmp) / 'mirror.json', Path(tmp) / 'outbox.jsonl'
mirror.write_text(json.dumps({'reports': [dict(package='org.example.app', id='old',
date='2026-10-01', result='runs')]}))
outbox.write_text(json.dumps(dict(package='org.example.app', id='new',
date='2026-10-02', rating='broken')) + '\n')
with patch.object(db, 'MIRROR', str(mirror)), patch.object(db, 'OUTBOX', str(outbox)), \
patch.object(db, '_mem', dict(at=0, reports=None, source=None)), \
patch.object(db, 'shared', return_value=False), \
patch.object(db, 'fetch_all', side_effect=AssertionError('private database read')):
cache = search.ReportCache(db.load, frame_catalog.reports.verdict)
cache.read()
self.wait_idle(cache)
self.assertEqual(cache.read()['org.example.app'][0], 'no')
def test_ttl_refresh_is_single_flight_and_keeps_last_good_snapshot(self):
loader = Mock(return_value=[dict(package='org.example.app', date='2026-10-01', rating='issues')])
cache = search.ReportCache(loader, frame_catalog.reports.verdict, ttl=60)
cache.read()
self.wait_idle(cache)
first = cache.read()
self.assertEqual(first['org.example.app'][0], 'maybe')
self.assertEqual(loader.call_count, 1)
entered, release = threading.Event(), threading.Event()
def stalled():
entered.set()
release.wait(2)
raise OSError('offline')
loader.side_effect = stalled
cache._at -= 61
try:
self.assertIs(cache.read(), first)
self.assertTrue(entered.wait(1))
for _ in range(10):
self.assertIs(cache.read(), first)
self.assertEqual(loader.call_count, 2)
finally:
release.set()
self.wait_idle(cache)
self.assertIs(cache.read(), first)
self.assertEqual(loader.call_count, 2) # failure also has a retry TTL
loader.side_effect = None
loader.return_value = [dict(package='org.example.app', date='2026-10-02', rating='works')]
cache._at -= 61
cache.read()
self.wait_idle(cache)
self.assertEqual(cache.read()['org.example.app'][0], 'works')
self.assertEqual(first['org.example.app'][0], 'maybe') # published snapshots never mutated
+3
View File
@@ -27,6 +27,9 @@ def fake(source_id='one', fn=None):
class SettingsTest(unittest.TestCase):
def setUp(self):
p = patch.object(search, 'compat_reports', None)
p.start()
self.addCleanup(p.stop)
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json')
+4 -1
View File
@@ -64,6 +64,7 @@ class LinkUI(unittest.TestCase):
renderPill();
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>Offline<span class="via"> · retrying in 12 s<\/span>/);
assert.ok($('conn').title.includes(changed.error.message));
assert.ok(!$('conn').title.includes('..'), $('conn').title);
// Without a short reason: the stage that failed.
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
renderPill();
@@ -120,7 +121,9 @@ class LinkUI(unittest.TestCase):
class HeaderLayout(unittest.TestCase):
def test_the_devices_tab_explanation_wraps(self):
# It was cut to one line with no way to read the rest.
self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
# More specific than the later .item .s rule: the old equal-specificity
# rule passed this test while the browser still clipped the explanation.
self.assertIn(".item.dev-problem .s { white-space: normal; overflow-wrap: anywhere; }", PAGE)
self.assertEqual(len(re.findall(r'<div class="item dev-problem" role="alert">', PAGE)), 2)
def test_the_header_buttons_dont_give_way(self):
+151
View File
@@ -0,0 +1,151 @@
"""Issue #81: synthetic, deterministic log scrubbing; no sockets or private data."""
import sandbox # noqa: F401
import random
import re
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
import frame_link as fl
import frame_telemetry as tm
def baseline(text, hosts):
"""Frozen v0.4.11 connection pipeline, before issue #81's extra spans.
Keep these operations independent of frame_link's implementations so a change
to its ordering or matching cannot silently weaken the differential oracle.
The shared scrubber's default behavior is deliberately unchanged by this fix.
"""
keep = {'host', 'hostname', 'to', 'port', 'answer', 'the', 'a', 'an', 'this',
'it', 'its', 'be', 'connect', 'find', 'work', 'try'}
names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
parts = re.split(r'(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)', text)
for i in range(0, len(parts), 2):
for name in names:
parts[i] = re.sub(r'(?<![\w.:-])%s(?![\w-]|[:.%%]\w)' % re.escape(name),
'<host>', parts[i], flags=re.I)
plain = keep - {'host', 'hostname', 'to', 'port'}
def operand(match):
word = match.group(2).rstrip('.')
return (match.group(0) if word.lower() in plain else
f'{match.group(1)} <host>{match.group(2)[len(word):]}')
text = re.sub(r'''(?i)\b(hostname|host|to(?:\s+host)?)\s+(?!<)([^\s:,;'"()|]+)''',
operand, ''.join(parts))
return tm.scrub(text, 600)
class LogScrubbing(unittest.TestCase):
def setUp(self):
patch = mock.patch.object(tm, '_user_names', return_value={'SyntheticLogin'})
patch.start()
self.addCleanup(patch.stop)
def test_display_name_crosses_a_scrubber_token(self):
name = '<host> Jane Doe headset'
self.assertEqual(fl.scrub_failure(name + ' stopped answering', [name]), '<host> stopped answering')
self.assertEqual(fl.scrub_failure('<user>@<host>: <ip>', ['user', 'host', 'ip']),
'<user>@<host>: <ip>')
self.assertEqual(fl.scrub_failure('<host>', ['<host>']), '<host>')
def test_all_standalone_tokens_survive_and_whole_names_cross_them(self):
for token in ('host', 'user', 'email', 'ip', 'mac', 'steamid', 'hex',
'token', 'ssh-key', 'pem', 'url', 'redacted'):
token = '<' + token + '>'
name = token + ' Synthetic Fixture headset'
with self.subTest(token=token):
self.assertEqual(fl.scrub_failure(token, [token, token[1:-1]]), token)
self.assertEqual(fl.scrub_failure(name.upper() + ' failed', [name]), '<host> failed')
def test_a_name_crossing_the_output_limit_leaves_no_fragment(self):
name = '<host> ' + 'Synthetic ' * 100 + 'headset'
self.assertEqual(fl.scrub_failure(name + ' trailing detail', [name]), '<host>')
def test_display_name_cannot_swallow_ssh_operand_marker(self):
text = 'ssh: connect to host bastion-jane port 22: Connection refused'
self.assertEqual(fl.scrub_failure(text, ['to host']),
'ssh: connect <host> <host> port 22: Connection refused')
def test_reverted_fix_counterexamples(self):
cases = [
('Could not connect to hostname bastion-jane', ['connect to'], ['bastion-jane']),
('Jane Smith@<host>: Permission denied', ['Smith@<host>'], ['Jane', 'Smith']),
('frame.local Jane Doe headset stopped answering', ['frame.local Jane Doe headset'], ['Jane', 'Doe']),
('x ' * 295 + 'frame.local Jane Doe headset', ['frame.local Jane Doe headset'], ['Jane', 'Doe']),
('https://jane@example.com/private(confidential)', ['jane@example.com'], ['private', 'confidential']),
(r'C:\Users\Jane@workpc (Smith)\.ssh\config', ['Jane@workpc'], ['Jane', 'Smith']),
('-----BEGIN PRIVATE KEY-----\nSYNTHETICBODY\n-----END PRIVATE KEY-----@frame',
['KEY-----@frame'], ['SYNTHETICBODY']),
('Could not connect to hostname /home/Jane Doe/.ssh/config', ['connect to'], ['Jane', 'Doe']),
]
for text, hosts, private in cases:
with self.subTest(text=text):
out = fl.scrub_failure(text, hosts)
for value in private:
self.assertNotIn(value, out)
self.assertLessEqual(len(out), 600)
def test_overlapping_names_and_operands_and_literal_tokens(self):
cases = [
('Alpha Beta Gamma failed', ['Alpha Beta', 'Beta Gamma'], '<host> failed'),
('<host> Jane Doe headset failed', ['<host> Jane Doe headset', 'Jane Doe'], '<host> failed'),
('to hostname bastion-jane', [], 'to <host> <host>'),
('<host failed', ['<host'], '<host> <host>'),
]
for text, hosts, expected in cases:
with self.subTest(text=text):
self.assertEqual(fl.scrub_failure(text, hosts), expected)
def test_randomized_differential_against_v0411(self):
rng = random.Random(81)
templates = [
'https://{u}@example.invalid/{p}(confidential)?key={p}',
r'C:\Users\{u}@workpc ({p})\.ssh\config',
'/home/{u} {p}/.ssh/config',
'-----BEGIN PRIVATE KEY-----\n{p}\n-----END PRIVATE KEY-----@frame',
'{u}.{p}@example.invalid',
'fe80::abcd%{u}',
'{u} {p}@frame.local: Permission denied',
'ssh-ed25519 {p}',
'password={p}',
'<host> {u} {p} headset stopped answering',
'frame.local {u} {p} headset stopped answering',
]
for iteration in range(1200):
user = 'Synthetic' + ''.join(rng.choices('abcdefghijklmnopqrstuvxyz', k=14))
private = 'Fixture' + ''.join(rng.choices('abcdefghijklmnopqrstuvxyz', k=18))
field = rng.choice(templates).format(u=user, p=private)
prefix = rng.choice(['', 'Could not connect to hostname ', 'ssh: connect to host ',
'Timed out talking to ', 'debug | ', 'x ' * 295])
text = prefix + field + rng.choice(['', ' port 22: refused', '\nnext line', "'s password: "])
# Names intentionally cut through the syntax later scrubber rules need.
options = [user, user + '@workpc', private, 'connect to', 'to host',
'<host> ' + user + ' ' + private + ' headset',
'frame.local ' + user + ' ' + private + ' headset',
'KEY-----@frame', 'host', '<host>', 'SyntheticLogin']
hosts = rng.sample(options, rng.randrange(5))
before, after = baseline(text, hosts), fl.scrub_failure(text, hosts)
with self.subTest(iteration=iteration, text=text, hosts=hosts):
trace = fl._ScrubTrace(text)
traced = tm.scrub(fl.hide_operands(fl.hide_hosts(text, hosts, _trace=trace),
_sub=trace.sub), 600, _sub=trace.sub)
self.assertEqual(traced, before)
# Compare every original three-character window, not just whole
# canaries: even a partially exposed user/path/secret is a regression.
# Mask placeholders with a separator so removing one cannot join
# unrelated text into a misleadingly new three-character window.
old_visible = fl.PLACEHOLDER.sub('\0', before)
new_visible = fl.PLACEHOLDER.sub('\0', after)
for start in range(len(text) - 2):
fragment = text[start:start + 3]
if fragment not in old_visible:
self.assertNotIn(fragment, new_visible)
self.assertLessEqual(len(after), 600)
if __name__ == '__main__':
unittest.main()
+56 -5
View File
@@ -2,7 +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 produces a working-on-Frame verdict.
An explicit frame_tested=True or a saved working report produces a working verdict.
"""
import importlib
import inspect
@@ -24,6 +24,46 @@ _game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
_status = {}
TIMEOUT = 12
BROWSE_LIMIT = 300 # apps per source when browsing without a query, before grouping
compat_reports = None # optional memory-only snapshot hook; enabled at server start, not import
class ReportCache:
"""Stale-while-refresh reports. Never hold the snapshot lock while loading.
The loader may block on disk, credentials or the database. Only its daemon
worker calls it; readers keep the last complete snapshot, even after errors.
"""
def __init__(self, loader, report_verdict, ttl=60):
self.loader, self.report_verdict, self.ttl = loader, report_verdict, ttl
self._lock = threading.Lock()
self._snapshot, self._at, self._running = {}, None, False
def read(self):
with self._lock:
snapshot = self._snapshot
if not self._running and (self._at is None or time.monotonic() - self._at >= self.ttl):
self._running = True
threading.Thread(target=self._refresh, daemon=True).start()
return snapshot
def _refresh(self):
try:
by = {}
for report in self.loader():
if report.get('package'):
by.setdefault(report['package'], []).append(report)
snapshot = {pkg: v for pkg, rows in by.items() if (v := self.report_verdict(rows))}
with self._lock:
self._snapshot = snapshot
except Exception:
pass # optional evidence must never break search or discard a good snapshot
finally:
with self._lock:
self._at, self._running = time.monotonic(), False
def _reports():
return compat_reports() if compat_reports else {}
def modules():
@@ -149,7 +189,7 @@ def fit(entry):
'reasons': reasons}
def verdict(entry):
def verdict(entry, report=None):
compatibility = fit(entry)
hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower()
if entry.get('requires_meta_services') is True:
@@ -160,6 +200,12 @@ 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 report:
kind, _ = report
return {'label': {'works': 'Reported working on the Frame',
'maybe': 'Reported issues on the Frame',
'no': 'Reported not working on the Frame'}[kind],
'tone': {'works': 'works', 'maybe': 'issues', 'no': 'warning'}[kind]}
if entry.get('frame_tested') is True:
return {'label': 'Works on the Frame', 'tone': 'works'}
if compatibility['installable'] is True:
@@ -167,10 +213,13 @@ def verdict(entry):
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
def decorate(entry, shots=12):
def decorate(entry, shots=12, reports=None):
"""shots: screenshots to offer; a listing card shows three, the detail page all of them."""
from apk_sources import _images
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots),
report = (reports if reports is not None else _reports()).get(entry.get('package'))
evidence = {'verdict': report[0], 'lines': list(report[1])} if report else None
return dict(entry, fit=fit(entry), verdict=verdict(entry, report), compat_report=evidence,
artwork=_images.artwork(entry, shots),
**categories.classify(entry))
@@ -191,8 +240,9 @@ def offer_rank(entry):
def group(entries, query='', vr=None, installable=False, category=None, phone=False):
groups = {}
reports = _reports() # one complete snapshot for every offer in this response
for entry in entries:
entry = decorate(entry, shots=3)
entry = decorate(entry, shots=3, reports=reports)
if not categories.matches(entry, category, phone):
continue
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
@@ -310,6 +360,7 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l
def warm():
"""Start every enabled source's index download in the background (server start, new repo)."""
_reports() # warm independently of potentially slow source indexes
from apk_sources import _web
_web.prune()
items, _ = registry()
+143 -16
View File
@@ -25,6 +25,7 @@ when the page asks.
Python stdlib only. Runs on this computer, never on the Frame.
"""
from bisect import bisect_right
import copy
import hashlib
import ipaddress
@@ -203,16 +204,33 @@ def probes_summary(probes):
PLACEHOLDER = re.compile(r"(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)")
def hide_hosts(text, hosts, keep=()):
"""text with each of `hosts` (the headset's own addresses and names) replaced by <host>,
longest first and whatever the case, so a bare name like "steamdeck" that the scrubber
can't recognise goes too."""
keep = {k.lower() for k in keep if k} | KEYWORDS # a headset called "host" mustn't eat ssh's wording
names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
parts = PLACEHOLDER.split(text) # never inside a scrubber's own <token> already there
for i in range(0, len(parts), 2):
for h in names:
parts[i] = re.sub(r"(?<![\w.:-])%s(?![\w-]|[:.%%]\w)" % re.escape(h), "<host>", parts[i], flags=re.I)
def _host_names(hosts, keep=()):
keep = {k.lower() for k in keep if k} | KEYWORDS
return sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
def _host_pattern(name):
return r"(?<![\w.:-])%s(?![\w-]|[:.%%]\w)" % re.escape(name)
def hide_hosts(text, hosts, keep=(), *, _trace=None):
"""Hide whole known names, longest first, preserving existing scrubber tokens."""
names = _host_names(hosts, keep)
parts = PLACEHOLDER.split(text)
ranges, offset = [], 0
for i, part in enumerate(parts):
trace = _ScrubTrace(part) if _trace is not None else None
if trace is not None:
trace.ranges = _trace.ranges[offset:offset + len(part)]
offset += len(part)
if i % 2 == 0:
sub = trace.sub if trace is not None else re.sub
for name in names:
parts[i] = sub(_host_pattern(name), "<host>", parts[i], flags=re.I)
if trace is not None:
ranges.extend(trace.ranges)
if _trace is not None:
_trace.ranges = ranges
return "".join(parts)
@@ -223,19 +241,128 @@ PLAIN_WORDS = {"answer", "the", "a", "an", "this", "it", "its", "be", "connect",
KEYWORDS = {"host", "hostname", "to", "port"} | PLAIN_WORDS
def hide_operands(text):
def hide_operands(text, *, _sub=re.sub):
def one(m):
word = m.group(2).rstrip(".")
dots = m.group(2)[len(word):]
return m.group(0) if word.lower() in PLAIN_WORDS else f"{m.group(1)} <host>{dots}"
return OPERAND.sub(one, text)
return _sub(OPERAND, one, text)
class _ScrubTrace:
"""Map every output character to its source interval through sequential redactions.
Unchanged prefixes/suffixes retain their origins. The changed middle inherits
its source interval and is marked as replaced; extra spans can widen to cover
that whole interval without restoring anything the old pipeline removed.
"""
def __init__(self, text):
self.ranges = [(i, i + 1, False) for i in range(len(text))]
self.redactions = []
def sub(self, pattern, replacement, text, flags=0):
pattern = re.compile(pattern, flags)
pieces, ranges, end = [], [], 0
for match in pattern.finditer(text):
start, stop = match.span()
value = replacement(match) if callable(replacement) else match.expand(replacement)
pieces.extend((text[end:start], value))
ranges.extend(self.ranges[end:start])
if value == match.group(0):
ranges.extend(self.ranges[start:stop])
else:
original = match.group(0)
prefix = 0
while prefix < min(len(original), len(value)) and original[prefix] == value[prefix]:
prefix += 1
suffix = 0
while (suffix < min(len(original), len(value)) - prefix
and original[-suffix - 1] == value[-suffix - 1]):
suffix += 1
ranges.extend(self.ranges[start:start + prefix])
source = self.ranges[start + prefix:stop - suffix]
# A known name can itself be a prefix of "<host>". Attribute
# inserted characters to its last character in that case.
if not source:
source = self.ranges[max(start, start + prefix - 1):start + prefix]
interval = (source[0][0], source[-1][1])
self.redactions.append(interval)
ranges.extend([(*interval, True)] * (len(value) - prefix - suffix))
if suffix:
ranges.extend(self.ranges[stop - suffix:stop])
end = stop
pieces.append(text[end:])
ranges.extend(self.ranges[end:])
self.ranges = ranges
return "".join(pieces)
def _failure_spans(text, hosts):
"""Extra redactions discovered before any replacement can destroy context."""
spans = []
tokens = [m.span() for m in PLACEHOLDER.finditer(text)]
token_starts = [start for start, end in tokens]
for name in _host_names(hosts):
for match in re.finditer(_host_pattern(name), text, re.I):
# Standalone scrubber tokens survive; a whole display name may cross one.
token = bisect_right(token_starts, match.start()) - 1
if token < 0 or match.end() > tokens[token][1]:
spans.append(match.span())
start = 0
while True:
match = OPERAND.search(text, start)
if not match:
break
word = match.group(2).rstrip(".")
if word.lower() not in PLAIN_WORDS:
spans.append((match.start(2), match.start(2) + len(word)))
# An operand can itself be another marker ("to hostname bastion").
start = match.start(2)
merged = []
for start, end in sorted(spans):
if merged and start <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(end, merged[-1][1]))
else:
merged.append((start, end))
return merged
def scrub_failure(text, hosts=()):
"""Free text about a failed attempt, for the log: the attempt's own names, whole and
longest first (case-insensitively, never ssh's own words), then anything ssh names as a
host, then the shared scrubber (addresses, paths, user names)."""
return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600)
"""Keep the existing scrubbed log, then hide additional original name/ssh spans.
Source ranges carry those spans through the existing substitutions. Applying
them only to that pipeline's output cannot reveal text it previously removed.
Keep the old output boundary, then truncate again after extra redaction so
shortening a name cannot pull previously omitted text into the log.
"""
text = str(text or "")
hosts = tuple(hosts)
spans = _failure_spans(text, hosts)
# Also preserve rules whose context the old name/operand pass could destroy,
# such as a URL, home folder, or PEM footer containing a display name.
original = _ScrubTrace(text)
frame_telemetry.scrub(text, limit=None, _sub=original.sub)
privacy_spans = sorted(original.redactions)
trace = _ScrubTrace(text)
clean = frame_telemetry.scrub(
hide_operands(hide_hosts(text, hosts, _trace=trace), _sub=trace.sub),
limit=600, _sub=trace.sub)
out, hidden, index, privacy_index = [], False, 0, 0
for char, (start, end, replaced) in zip(clean, trace.ranges):
while index < len(spans) and spans[index][1] <= start:
index += 1
redact = index < len(spans) and spans[index][0] < end
while privacy_index < len(privacy_spans) and privacy_spans[privacy_index][1] <= start:
privacy_index += 1
if not replaced and privacy_index < len(privacy_spans):
redact |= privacy_spans[privacy_index][0] < end
if redact:
if not hidden:
out.append("<host>")
else:
out.append(char)
hidden = redact
return "".join(out)[:600]
def failure_detail(error):
+11 -9
View File
@@ -240,27 +240,29 @@ def _scrub_url(u):
return f'{parts.scheme}://{host}/…' if host else '<url>'
def scrub(text, limit=2000):
def scrub(text, limit=2000, *, _sub=re.sub):
"""Text with URLs, home folders, user names, addresses, hosts, ids and keys replaced."""
# The internal substitution hook lets the connection log retain source ranges through
# this exact pipeline. Ordinary callers keep the same substitutions and output limit.
if text is None:
return None
t = URL_RE.sub(lambda m: _scrub_url(m.group(0)), str(text)) # first, before anything splits a URL
t = _sub(URL_RE, lambda m: _scrub_url(m.group(0)), str(text)) # first, before anything splits a URL
home = str(Path.home())
if len(home) > 3:
t = t.replace(home, '~')
t = _sub(re.escape(home), '~', t)
# A home folder's whole name ("C:\Users\Jane Doe", "/Users/O'Brien"), up to the next separator.
t = re.sub(r'''(?i)(/Users/|/home/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\n"]+''', r'\1<user>', t)
t = _sub(r'''(?i)(/Users/|/home/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\n"]+''', r'\1<user>', t)
# ssh's "user@host: ..." and "user@host's password:", the whole field: the user even with
# spaces or a DOMAIN\ prefix, the host even a full domain name. Before the email rule, which
# would otherwise take only the last word of the user.
t = re.sub(r'''(?m)(?:^|(?<=: )|(?<=\| ))[^@\n:|"<]{1,64}@[\w.\[\]%:<>-]+?(?=:(?:\s|$)|'s\s)''',
t = _sub(r'''(?m)(?:^|(?<=: )|(?<=\| ))[^@\n:|"<]{1,64}@[\w.\[\]%:<>-]+?(?=:(?:\s|$)|'s\s)''',
'<user>@<host>', t)
for pattern, repl in SCRUBS:
t = pattern.sub(repl, t)
t = re.sub(r'(?<![\w.+\\<-])[\w.+\\-]+@(?=[A-Za-z\[<])', '<user>@', t) # any other word@host
t = IPV6_RE.sub(_ipv6, t)
t = _sub(pattern, repl, t)
t = _sub(r'(?<![\w.+\\<-])[\w.+\\-]+@(?=[A-Za-z\[<])', '<user>@', t) # any other word@host
t = _sub(IPV6_RE, _ipv6, t)
for name in _user_names():
t = re.sub(r'\b%s\b' % re.escape(name), '<user>', t)
t = _sub(r'\b%s\b' % re.escape(name), '<user>', t)
return t[:limit]
+16 -6
View File
@@ -175,7 +175,8 @@
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 8px; font: inherit; font-size: 13px; }
.addr .t { overflow-wrap: anywhere; }
.addr .s, .dev-problem .s { white-space: normal; }
.addr .s { white-space: normal; }
.item.dev-problem .s { white-space: normal; overflow-wrap: anywhere; }
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
.addr-add { display: grid; grid-template-columns: 2fr 1.4fr auto; gap: 8px; margin-top: 12px; }
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add, .adv .dev-form, .conn-add, .conn-edit { grid-template-columns: 1fr; } }
@@ -627,6 +628,8 @@
.runs { font-size:11px; font-weight:500; padding:3px 8px; border-radius:10px; white-space:nowrap; }
.runs.works { color:#c6efb0; background:#70bb5426; } .runs.ready { color:#b7d8ec; background:#6ec9ff1a; }
.runs.unknown { color:#a8b5c2; background:#ffffff0d; } .runs.blocked { color:#f1c28a; background:#d9a23a22; }
.runs.warning, .runs.issues { color:#f1c28a; background:#d9a23a22; white-space:normal; }
.store-compat-badges { display:flex; flex-wrap:wrap; gap:4px; }
.store-hero > .store-art { position:absolute; inset:0; }
.store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; }
.hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; }
@@ -773,6 +776,7 @@
.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.warning, .detail-verdict.issues { 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; }
@@ -4240,10 +4244,16 @@ async function loadSources() {
} catch (e) { $("sourceError").textContent = e.message; }
}
// Keep installation confidence explicit, including apps that have not been checked yet.
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"] };
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"], warning: ["Reported not working", "warning"], issues: ["Reported issues", "issues"] };
function storeRuns(o) {
const [text, cls] = RUNS[o.verdict?.tone] || RUNS.unknown;
return `<span class="runs ${cls}" title="${esc(o.verdict?.label || "Not yet checked on the Frame")}">${text}</span>`;
const report=o.compat_report, extra=o.verdict?.tone==='blocked' && ['no','maybe'].includes(report?.verdict);
return `<span class="store-compat-badges"><span class="runs ${cls}" title="${esc(o.verdict?.label || "Not yet checked on the Frame")}">${text}</span>${extra ? `<span class="runs warning">${report.verdict==='no' ? 'Reported not working' : 'Reported issues'}</span>` : ''}</span>`;
}
function storePickEligible(o) { return o.verdict?.tone !== 'blocked' && o.compat_report?.verdict !== 'no'; }
function storeReportDetails(o) {
const report=o.compat_report;
return report ? `<div class="detail-verdict ${report.verdict==='works' ? 'works' : 'warning'}"><strong>Saved compatibility reports</strong>${report.lines.map(line=>`<p>${esc(line)}</p>`).join('')}<p>Reports can cover other versions or runtimes. You can still try installing unless this build has a compatibility blocker.</p></div>` : '';
}
const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app";
function storeCard(app) {
@@ -4271,7 +4281,7 @@ function renderStore() {
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
if (!browsing) { $("searchGrid").innerHTML=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; return; }
// Home: one featured VR app, then a shelf per category; each app appears once, blocked ones not at all.
const ok=apps.filter(a=>a.offers[0].verdict?.tone !== 'blocked'), shown=new Set();
const ok=apps.filter(a=>storePickEligible(a.offers[0])), shown=new Set();
const day=Math.floor(Date.now()/864e5), heroes=ok.filter(a=>a.offers[0].vr && a.offers[0].artwork?.banner);
const featured=heroes[day % (heroes.length || 1)] || ok.find(a=>a.offers[0].artwork?.banner) || ok[0];
if (!featured) { $("searchGrid").innerHTML=storeEmpty(); return; } // nothing here runs on the Frame
@@ -4327,7 +4337,7 @@ function renderAppDetail() {
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">The developer's page ↗</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>`;
$("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">The developer's page ↗</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>${storeReportDetails(o)}<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>`;
updateInstall();
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
}
@@ -6618,7 +6628,7 @@ function renderPill() {
}
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(word)}` +
`${via ? `<span class="via"> · ${esc(via)}</span>` : ""}</span></span>`;
$("conn").title = `${dev}: ${detail}. Click to see or change its addresses.`;
$("conn").title = `${dev}: ${detail.replace(/[.\s]+$/, "")}. Click to see or change its addresses.`;
return true;
}
window.connPill = renderPill;
+3
View File
@@ -3593,6 +3593,9 @@ def main():
args = ap.parse_args()
httpd = LoopbackServer(("127.0.0.1", args.port), Handler)
sweep_tmp()
if os.environ.get('FRAME_COMPAT_SEARCH_REPORTS') != '0':
apk_search.compat_reports = apk_search.ReportCache(
frame_compat_db.load, frame_catalog.reports.verdict, ttl=frame_compat_db.TTL).read
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
frame_telemetry.start()
frame_contact.start()