Files
saphid--frame-control/ui/apk_sources/github.py
saphidandClaude Opus 5.5 b5caee5b86 Library art: real gameplay instead of GitHub social cards; accept GIF sources
GitHub's opengraph preview is repo text, stats and an identicon; as a Steam hero
it looked broken. GitHub entries no longer default to it (the store draws its
fallback, Steam gets generated art). Source GIFs (common gameplay captures) are
accepted; the Frame's Chromium draws the first frame. Open Saber Plus uses its
gameplay GIF as banner. Verified on the Frame: hero/wide now show gameplay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:12:24 +10:00

118 lines
5.4 KiB
Python

"""Curated publisher releases; optional topic discovery is page-only."""
import fnmatch, json, os, re, urllib.parse
from . import SourceError, _web
KIND = 'github'
API = 'https://api.github.com'
TOPICS = ('oculus-quest', 'openxr', 'quest')
HOSTS = ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com')
def sources():
return [{'id': KIND, 'kind': KIND, 'name': 'GitHub releases', 'url': 'https://github.com',
'builtin': True, 'enabled': True, 'trust': 'community'}]
def _curated():
with open(os.path.join(os.path.dirname(__file__), 'github_curated.json')) as f:
return json.load(f)
def _api(path):
headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
token = os.environ.get('FRAME_GITHUB_TOKEN')
if token:
headers['Authorization'] = 'Bearer ' + token
try:
return json.loads(_web.read(API + path, ('api.github.com',), headers, name='GitHub',
hint='' if token else ' (set FRAME_GITHUB_TOKEN to raise the limit)'))
except (ValueError, TypeError) as e:
raise SourceError('Invalid GitHub response') from e
def _entry(source, c, approved=True):
artwork = c.get('images') or {}
icon = artwork.get('icon') or c.get('icon') or 'https://github.com/' + c['repo'].split('/')[0] + '.png'
images = {'icon': icon,
# No GitHub social-preview card: it's repo text and stats, not art; the UI draws a fallback.
'banner': artwork.get('banner'),
'screenshots': list(artwork.get('screenshots') or [])}
return {'source': source['id'], 'id': c['repo'], 'package': None,
'name': c['name'], 'summary': c.get('summary') or '', 'icon': icon,
'images': images,
'page': 'https://github.com/' + c['repo'], 'version': None, 'version_code': None,
'min_sdk': None, 'abis': None, 'vr': c.get('vr'), 'size': None,
'free': True if approved else None, 'license': c.get('license'), 'updated': None,
'downloadable': approved}
def search(source, query, limit=50):
limit = max(0, min(int(limit), 100))
if not limit:
return []
if query.startswith('topic:'):
topic = query[6:].strip()
if topic not in TOPICS:
raise SourceError('Choose topic:oculus-quest, topic:openxr or topic:quest')
data = _api('/search/repositories?' + urllib.parse.urlencode(
{'q': 'topic:' + topic + ' archived:false', 'sort': 'stars', 'per_page': limit}))
curated = {c['repo'].lower(): c for c in _curated()}
out = []
for repo in data.get('items', []):
c = curated.get(repo['full_name'].lower())
entry = _entry(source, c or {'repo': repo['full_name'], 'name': repo['name'],
'summary': repo.get('description'), 'vr': True,
'icon': (repo.get('owner') or {}).get('avatar_url')}, bool(c))
out.append(entry)
return out
words = query.lower().split()
return [_entry(source, c) for c in _curated()
if all(w in (c['name'] + ' ' + c['repo'] + ' ' + c['summary']).lower()
for w in words)][:limit]
def details(source, entry_id):
c = next((c for c in _curated() if c['repo'].lower() == entry_id.lower()), None)
if not c:
if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', entry_id):
raise SourceError('Invalid GitHub repository')
entry = _entry(source, {'repo': entry_id, 'name': entry_id}, False)
return {**entry, 'versions': []}
entry = _entry(source, c)
releases = _api('/repos/' + c['repo'] + '/releases?per_page=10')
versions = []
for release in releases:
if release.get('draft') or (release.get('prerelease') and not c.get('allow_prerelease')):
continue
assets = [a for a in release.get('assets', []) if
fnmatch.fnmatch(a['name'].lower(), c['asset_pattern'].lower())]
for asset in assets:
digest = asset.get('digest') or ''
versions.append({'version': release['tag_name'], 'version_code': None,
'asset_id': asset['id'], 'name': asset['name'], 'min_sdk': None,
'prerelease': bool(release.get('prerelease')),
'size': asset.get('size'), 'updated': release.get('published_at'),
'url': asset['browser_download_url'],
'sha256': digest[7:] if re.fullmatch(r'sha256:[0-9a-f]{64}', digest) else None})
entry['versions'] = versions
entry['downloadable'] = bool(versions)
if versions:
entry.update({k: versions[0][k] for k in ('version', 'size', 'updated')})
return entry
def download(source, entry_id, version_code=None):
entry = details(source, entry_id)
versions = entry['versions']
if not versions:
raise SourceError('No approved APK release; open the publisher page')
# GitHub asset IDs and tags are not Android version codes.
if version_code is not None:
raise SourceError('GitHub does not publish Android version codes; download the latest release')
v = versions[0]
expected = 'https://github.com/' + entry['id'] + '/releases/download/'
if not v['url'].startswith(expected):
raise SourceError('APK URL does not belong to the curated publisher')
return _web.apk(v['url'], HOSTS, v['sha256'], name='GitHub')