mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-14 06:01:37 +02:00
F-Droid repositories can now be added with HTTP basic auth, either as https://user:pass@host/fdroid/repo (as F-Droid's client accepts) or with separate username/password fields (store form, server API, and the CLI's --username/--password-stdin). The login is stripped from the saved URL and kept in a new shared module, apk_sources/_credentials.py, which writes source-credentials.json in the data folder with mode 0600. A credential is only ever added per request to its exact origin (scheme, host, port): redirects elsewhere get nothing and have Authorization removed. HTTPS only. 401 (or 403 with a login) on add gives a "check the username and password" error and saves nothing; signature, pin, TOFU, hash and rollback checks are unchanged. Removing a repository deletes its login. GitHub gets a user-settable token (FRAME_GITHUB_TOKEN still wins), sent as Bearer only to api.github.com. If GitHub answers 401, the request is retried once without it and the rejection is reported in source settings. The source list shows only whether a token is set, never its value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
8.5 KiB
Python
198 lines
8.5 KiB
Python
"""Curated publisher releases; optional topic discovery is page-only."""
|
|
import fnmatch, hashlib, json, os, re, sys, threading, urllib.parse
|
|
|
|
from . import SourceAuthError, SourceError, _credentials, _web
|
|
|
|
KIND = 'github'
|
|
API = 'https://api.github.com'
|
|
ENV = 'FRAME_GITHUB_TOKEN' # wins over a token saved in source settings
|
|
CREDENTIAL = 'github' # _credentials key; only ever sent to api.github.com
|
|
_rejected = set() # SHA-256 of tokens GitHub answered 401 to (this run only)
|
|
_rejected_lock = threading.Lock()
|
|
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', 'token': token_status()}]
|
|
|
|
|
|
def _token():
|
|
"""(token, where it came from) or (None, None). The environment variable wins."""
|
|
env = os.environ.get(ENV)
|
|
if env:
|
|
return env, 'env'
|
|
saved = _credentials.get(CREDENTIAL)
|
|
if saved and saved.kind == 'bearer':
|
|
return saved, 'saved'
|
|
return None, None
|
|
|
|
|
|
def _credential(token):
|
|
if isinstance(token, _credentials.Credential):
|
|
return token
|
|
try:
|
|
return _credentials.bearer(API + '/', token)
|
|
except ValueError as e:
|
|
raise SourceError('%s is not a usable GitHub token: %s' % (ENV, e)) from None
|
|
|
|
|
|
def _digest(credential):
|
|
return hashlib.sha256(credential.header().encode()).hexdigest()
|
|
|
|
|
|
def token_status():
|
|
"""Whether a token is set, where from, and whether GitHub rejected it; never the token."""
|
|
token, where = _token()
|
|
rejected = False
|
|
if token is not None:
|
|
try:
|
|
with _rejected_lock:
|
|
rejected = _digest(_credential(token)) in _rejected
|
|
except SourceError:
|
|
rejected = True
|
|
return {'set': token is not None, 'from': where, 'rejected': rejected}
|
|
|
|
|
|
def set_token(token):
|
|
"""Save a personal access token for api.github.com (FRAME_GITHUB_TOKEN still wins)."""
|
|
if not isinstance(token, str) or not token.strip():
|
|
raise SourceError('Paste a GitHub token')
|
|
try:
|
|
credential = _credentials.bearer(API + '/', token.strip())
|
|
except ValueError as e:
|
|
raise SourceError('That is not a usable GitHub token: ' + str(e)) from None
|
|
_credentials.put(CREDENTIAL, credential)
|
|
with _rejected_lock:
|
|
_rejected.discard(_digest(credential)) # a fresh chance
|
|
return {'message': 'GitHub token saved' + (' (%s is set and is used instead)' % ENV if os.environ.get(ENV) else '')}
|
|
|
|
|
|
def clear_token():
|
|
_credentials.remove(CREDENTIAL)
|
|
return {'message': 'GitHub token removed'}
|
|
|
|
|
|
def _curated():
|
|
with open(os.path.join(os.path.dirname(__file__), 'github_curated.json')) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def _api(path):
|
|
"""GET from the GitHub API, with the user's token if there is one. If GitHub rejects
|
|
the token (401), retry once without it, like Obtainium, and remember the rejection."""
|
|
headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
|
|
token, _ = _token()
|
|
auth = _credential(token) if token is not None else None
|
|
with _rejected_lock:
|
|
if auth and _digest(auth) in _rejected:
|
|
auth = None # already refused this run: don't spend a request on it again
|
|
hint = ' (add a GitHub token in the store\'s source settings, or set %s, to raise the limit)' % ENV
|
|
if token is not None and not auth:
|
|
hint = ' (GitHub rejected your token; replace it in the store\'s source settings)'
|
|
|
|
def get(auth):
|
|
return _web.read(API + path, ('api.github.com',), headers, name='GitHub',
|
|
hint='' if auth else hint, auth=auth)
|
|
try:
|
|
try:
|
|
data = get(auth)
|
|
except SourceAuthError:
|
|
if not auth:
|
|
raise
|
|
with _rejected_lock:
|
|
_rejected.add(_digest(auth))
|
|
print('GitHub rejected the access token (HTTP 401); continuing without it', file=sys.stderr)
|
|
hint = ' (GitHub rejected your token; replace it in the store\'s source settings)'
|
|
data = get(None)
|
|
return json.loads(data)
|
|
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')
|