Add curated GitHub APK releases and itch.io VR feed listings

Survey publisher consent and access limits; add cached sources, recorded fixtures and local APK proof.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
saphidandGPT-6 Astra committed 2026-09-28 21:03:27 +10:00
1 parent 268afccf05
commit 113216cc0e
19 files changed
+1214

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
"""Small HTTPS cache and APK downloader for public publisher sources."""
import hashlib, os, tempfile, time, urllib.error, urllib.parse, urllib.request, zipfile
import frame_host
from . import SourceError
UA = 'FrameControl/0.1'
def cache():
path = frame_host.cache_dir('apk-sources', 'publisher')
os.makedirs(path, exist_ok=True)
return str(path)
def checked_url(url, hosts):
try:
p = urllib.parse.urlsplit(url)
port = p.port
except (ValueError, TypeError) as e:
raise SourceError('Source returned an invalid URL') from e
if p.scheme != 'https' or p.username or p.password or port not in (None, 443) or p.hostname not in hosts:
raise SourceError('Source returned an unexpected download URL')
return url
class Redirect(urllib.request.HTTPRedirectHandler):
def __init__(self, hosts):
self.hosts = hosts
def redirect_request(self, req, fp, code, msg, headers, newurl):
checked_url(newurl, self.hosts)
result = super().redirect_request(req, fp, code, msg, headers, newurl)
if result:
result.remove_header('Authorization')
return result
def open_url(url, hosts, headers=None):
checked_url(url, hosts)
return urllib.request.build_opener(Redirect(hosts)).open(
urllib.request.Request(url, headers={'User-Agent': UA, **(headers or {})}), timeout=60)
def read(url, hosts, headers=None, ttl=3600):
path = os.path.join(cache(), hashlib.sha256(url.encode()).hexdigest() + '.data')
try:
if os.path.isfile(path) and time.time() - os.path.getmtime(path) < ttl:
with open(path, 'rb') as f:
return f.read()
with open_url(url, hosts, headers) as r:
data = r.read(8 * 1024 * 1024 + 1)
if len(data) > 8 * 1024 * 1024:
raise SourceError('Source index is too large')
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
try:
with os.fdopen(fd, 'wb') as f:
f.write(data)
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.remove(tmp)
return data
except urllib.error.HTTPError as e:
if e.code in (403, 429):
raise SourceError('Source refused access or reached its rate limit; try later (GitHub accepts FRAME_GITHUB_TOKEN)') from e
raise SourceError('Source HTTP error: ' + str(e.code)) from e
except (OSError, ValueError) as e:
raise SourceError('Could not read source: ' + str(e)) from e
def apk(url, hosts, digest=None):
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
h = hashlib.sha256()
with os.fdopen(fd, 'wb') as f, open_url(url, hosts) as r:
size = 0
while True:
chunk = r.read(1 << 20)
if not chunk:
break
size += len(chunk)
if size > 2 * 1024 ** 3:
raise SourceError('APK exceeds the 2 GiB download limit')
h.update(chunk)
f.write(chunk)
actual = h.hexdigest()
if digest and actual != digest:
raise SourceError('SHA-256 mismatch; download discarded')
with zipfile.ZipFile(tmp) as z:
if 'AndroidManifest.xml' not in z.namelist():
raise SourceError('Download is not an APK')
path = os.path.join(cache(), actual + '.apk')
os.replace(tmp, path)
return {'apk': path, 'obb': [], 'sha256': actual, 'verified': bool(digest)}
except (OSError, ValueError, zipfile.BadZipFile) as e:
raise SourceError('Could not download APK: ' + str(e)) from e
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
+110
View File
@@ -0,0 +1,110 @@
"""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))
except (ValueError, TypeError) as e:
raise SourceError('Invalid GitHub response') from e
def _entry(source, c, approved=True):
return {'source': source['id'], 'id': c['repo'], 'package': None,
'name': c['name'], 'summary': c.get('summary') or '', 'icon': None,
'images': {'icon': None, 'banner': None, 'screenshots': []},
'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}, bool(c))
# Repository owners' avatars are not app artwork.
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'])
+29
View File
@@ -0,0 +1,29 @@
[
{
"repo": "KhronosGroup/OpenXR-SDK-Source",
"name": "hello_xr",
"summary": "Khronos OpenXR sample (Vulkan and OpenGL ES)",
"license": "Apache-2.0",
"vr": true,
"asset_pattern": "hello_xr-Vulkan-*.apk",
"allow_prerelease": false
},
{
"repo": "icosa-foundation/open-brush",
"name": "Open Brush",
"summary": "Open source spatial painting",
"license": "Apache-2.0",
"vr": true,
"asset_pattern": "*Quest*.apk",
"allow_prerelease": true
},
{
"repo": "SgtBilko76/SuperTux-3D",
"name": "SuperTux 3D",
"summary": "OpenXR stereoscopic platform game for Quest and PICO",
"license": "GPL-3.0",
"vr": true,
"asset_pattern": "*Quest*.apk",
"allow_prerelease": true
}
]
+72
View File
@@ -0,0 +1,72 @@
"""Free Android VR RSS listings. Download pages stay in the publisher's UI."""
import html, re, urllib.parse, xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from . import SourceError, _web
KIND = 'itch'
FEEDS = ('openxr', 'oculus-quest')
def sources():
return [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
def _parse(source, data):
try:
root = ET.fromstring(data)
except ET.ParseError as e:
raise SourceError('Invalid itch.io RSS feed') from e
entries = []
for item in root.findall('./channel/item'):
page = item.findtext('link') or ''
p = urllib.parse.urlsplit(page)
if p.scheme != 'https' or not (p.hostname or '').endswith('.itch.io') or p.username or ':' in p.netloc:
continue
if item.findtext('price') != '$0.00' or item.findtext('platforms/android') != 'yes':
continue
cover = item.findtext('imageurl') or None
if cover and not cover.startswith('https://img.itch.zone/'):
cover = None
updated = None
try:
updated = parsedate_to_datetime(item.findtext('updateDate')).date().isoformat()
except (ValueError, TypeError, AttributeError):
pass
entries.append({'source': source['id'], 'id': page, 'package': None,
'name': item.findtext('plainTitle') or item.findtext('title') or page,
'summary': html.unescape(re.sub('<[^>]*>', '', item.findtext('description') or '')).strip(),
'icon': cover, 'images': {'icon': cover, 'banner': cover, 'screenshots': []},
'page': page, 'version': None, 'version_code': None, 'min_sdk': None,
'abis': None, 'vr': True, 'size': None, 'free': True, 'license': None,
'updated': updated, 'downloadable': False})
return entries
def search(source, query, limit=50):
limit = max(0, min(int(limit), 100))
if not limit:
return []
entries = {}
tag = source.get('tag', 'openxr')
if tag not in FEEDS:
raise SourceError('Unsupported itch.io feed')
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
for entry in _parse(source, _web.read(url, ('itch.io',))):
entries.setdefault(entry['id'], entry)
words = query.lower().split()
return [e for e in entries.values() if all(w in (e['name'] + ' ' + e['summary']).lower()
for w in words)][:limit]
def details(source, entry_id):
entry = next((e for e in search(source, '', 100) if e['id'] == entry_id), None)
if not entry:
raise SourceError('Game is not in the current free Android VR feed; open its publisher page')
return {**entry, 'versions': []}
def download(source, entry_id, version_code=None):
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')