#!/usr/bin/env python3 """Shared device-side installation engine, also embedded in the desktop installer.""" import argparse import errno import hashlib import json import os import pathlib import re import shutil import subprocess import sys import tarfile import tempfile import time import urllib.parse import urllib.request import urllib.error import socket import ssl import http.client DEFAULT_REPO = 'SteamFramelyHomebrew/framely' MAX_ARCHIVE = 2 * 1024**3 def emit_progress(step, completed=None, total=None): payload = {'step': step} if total is not None and total > 0: payload.update(completed=completed, total=total) print('FRAMELY_PROGRESS ' + json.dumps(payload), flush=True) class HTTPSRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): if urllib.parse.urlparse(newurl).scheme != 'https': raise ValueError('HTTPS redirect downgrade rejected') return super().redirect_request(req, fp, code, msg, headers, newurl) GITHUB_HOSTS = {'github.com', 'api.github.com', 'raw.githubusercontent.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com'} GITHUB_FALLBACK = 'https://gh-proxy.com/' def device_proxy_settings(): try: with open('/var/lib/framely/state.json', encoding='utf-8') as state: return json.load(state).get('proxy', {}) except (OSError, ValueError): return {} def fallback_enabled(): if 'FRAMELY_GITHUB_AUTO_FALLBACK' in os.environ: return os.environ['FRAMELY_GITHUB_AUTO_FALLBACK'] != '0' return device_proxy_settings().get('githubAutoFallback', True) def public_github(url): parsed = urllib.parse.urlsplit(url) return (parsed.scheme == 'https' and parsed.hostname in GITHUB_HOSTS and parsed.port in (None, 443) and not parsed.username and not parsed.password and not parsed.fragment and all(key in ('page', 'per_page') and value.isascii() and value.isdigit() for key, value in urllib.parse.parse_qsl(parsed.query, keep_blank_values=True))) def retryable(error): if isinstance(error, (ssl.SSLCertVerificationError, ssl.CertificateError)): return False if isinstance(error, urllib.error.HTTPError): return (error.code in (408, 429) or 500 <= error.code <= 599 or error.code == 403 and (error.headers.get('X-RateLimit-Remaining') == '0' or error.headers.get('Retry-After') is not None)) if isinstance(error, urllib.error.URLError): return retryable(error.reason) # These routing failures are plain OSError, not ConnectionError. Keep an # explicit network-only allowlist so disk/permission failures never retry. if isinstance(error, OSError) and error.errno in (errno.ENETDOWN, errno.ENETUNREACH, errno.EHOSTUNREACH): return True return isinstance(error, (socket.gaierror, TimeoutError, ConnectionError, http.client.IncompleteRead, http.client.RemoteDisconnected)) def network_diagnostic(error): # HTTPError and URLError strings may contain signed redirect URLs. if isinstance(error, urllib.error.HTTPError): return 'HTTP ' + str(error.code) if isinstance(error, urllib.error.URLError): return network_diagnostic(error.reason) return type(error).__name__ def github_attempt(url, operation, enabled=None): try: return operation(False) except Exception as primary: if not (fallback_enabled() if enabled is None else enabled) or not public_github(url) or not retryable(primary): raise print('GitHub connection failed; trying gh-proxy.com once', file=sys.stderr, flush=True) try: return operation(True) except Exception as secondary: raise RuntimeError('GitHub primary route failed: ' + network_diagnostic(primary) + '; gh-proxy.com fallback failed: ' + network_diagnostic(secondary)) from None def fetch_once(url, fallback=False): if urllib.parse.urlparse(url).scheme != 'https': raise ValueError('HTTPS is required') settings = device_proxy_settings() manual = os.environ.get('FRAMELY_GITHUB_PROXY', settings.get('github', '') if settings.get('githubEnabled') else '').strip().rstrip('/') if fallback: url = GITHUB_FALLBACK + url elif manual and public_github(url): if urllib.parse.urlsplit(manual).scheme != 'https': raise ValueError('GitHub proxy must use HTTPS') url = manual + '/' + url handlers = [HTTPSRedirect()] if 'FRAMELY_HTTP_PROXY' in os.environ or settings.get('httpEnabled'): proxy = os.environ.get('FRAMELY_HTTP_PROXY', settings.get('http', '')) handlers.append(urllib.request.ProxyHandler({'http': proxy, 'https': proxy} if proxy else {})) return urllib.request.build_opener(*handlers).open( urllib.request.Request(url, headers={'User-Agent': 'Framely-Installer', 'Accept': 'application/vnd.github+json'}), timeout=60) def fetch(url): return github_attempt(url, lambda fallback: fetch_once(url, fallback)) def read_metadata(url, limit): def attempt(fallback): with fetch_once(url, fallback) as response: data = response.read(limit + 1) if len(data) > limit: raise ValueError('Release metadata too large') length = response.headers.get('Content-Length') if length is not None and len(data) != int(length): raise http.client.IncompleteRead(b'', max(0, int(length) - len(data))) return data return github_attempt(url, attempt) def download(url, destination, limit, progress=None, expected_size=None): identity = None def attempt(fallback): nonlocal identity with fetch_once(url, fallback) as response, open(destination, 'r+b' if identity else 'xb') as output: metadata = os.fstat(output.fileno()) current = (metadata.st_dev, metadata.st_ino) if identity is not None and identity != current: raise ValueError('Download temporary file identity changed') identity = current if fallback: output.truncate(0) size = 0 reported_at = time.monotonic() if progress: progress(0) while block := response.read(256 * 1024): size += len(block) if size > limit: raise ValueError('Download exceeds size limit') output.write(block) if progress and time.monotonic() - reported_at >= 0.2 and (expected_size is None or size < expected_size): progress(size) reported_at = time.monotonic() length = response.headers.get('Content-Length') expected = expected_size if expected_size is not None else int(length) if length is not None else None if expected is not None and size != expected: raise http.client.IncompleteRead(b'', max(0, expected - size)) if progress: progress(size) return github_attempt(url, attempt) def verify_archive(archive, checksums): name = pathlib.Path(archive).name matches = [] for line in pathlib.Path(checksums).read_text().splitlines(): if not line.strip(): continue match = re.fullmatch(r'([a-fA-F0-9]{64}) [ *](.+)', line) if not match: raise ValueError('Invalid SHA256SUMS entry') if match[2] == name: matches.append(match[1].lower()) if len(matches) != 1: raise ValueError('SHA256SUMS must contain exactly one entry for ' + name) digest = hashlib.sha256() size = 0 with open(archive, 'rb') as source: while block := source.read(256 * 1024): size += len(block) if size > MAX_ARCHIVE: raise ValueError('Archive exceeds size limit') digest.update(block) if digest.hexdigest() != matches[0]: raise ValueError('Archive SHA256 mismatch; installation stopped') def extract_archive(archive, destination, progress=None, runtime=False): # Check all headers before writing, and never use tar's link extraction. with tarfile.open(archive, 'r:gz') as source: members = source.getmembers() if len(members) > 20000 or sum(m.size for m in members) > MAX_ARCHIVE: raise ValueError('Expanded release exceeds limits') seen, prefixes = set(), set() for member in members: path = pathlib.PurePosixPath(member.name) if (not path.parts or path.is_absolute() or '..' in path.parts or '\\' in member.name or member.name in seen or not (member.isdir() or member.isfile())): raise ValueError('Unsafe archive member: ' + member.name) if not re.fullmatch(r'framely-[a-zA-Z0-9.+-]+', path.parts[0]): raise ValueError('Invalid release directory') prefixes.add(path.parts[0]) seen.add(member.name) if len(prefixes) != 1: raise ValueError('Expected one release directory') destination = pathlib.Path(destination) destination.mkdir(mode=0o700) total, completed, reported_at = sum(m.size for m in members), 0, time.monotonic() if progress: progress('extract', completed, total) for member in members: path = destination.joinpath(*pathlib.PurePosixPath(member.name).parts) if member.isdir(): path.mkdir(parents=True, exist_ok=True) else: path.parent.mkdir(parents=True, exist_ok=True) with source.extractfile(member) as data, path.open('xb') as output: while block := data.read(256 * 1024): output.write(block) completed += len(block) if progress and time.monotonic() - reported_at >= 0.2: progress('extract', completed, total) reported_at = time.monotonic() path.chmod(0o755 if member.mode & 0o111 else 0o644) base = destination / prefixes.pop() version = (base / 'VERSION').read_text().strip() if base.name != ('framely-cef-' if runtime else 'framely-') + version: raise ValueError('Release VERSION does not match directory') required_files = ['SHA256SUMS', 'lib/cef/libcef.so'] if runtime else ['SHA256SUMS', 'install.sh', 'uninstall.sh', 'bin/framely'] for required in required_files: if not (base / required).is_file(): raise ValueError('Missing release file: ' + required) if progress: progress('extract', completed, total) return base def select_package(assets, action): packages = [name for name in assets if re.fullmatch(r'framely-[0-9][a-zA-Z0-9.+-]*-linux-arm64\.tar\.gz', name)] offline = [name for name in packages if name.endswith('-offline-linux-arm64.tar.gz')] core = [name for name in packages if name not in offline] selected = offline if action == 'install' and offline else core if action == 'install' and not offline and 'framely-cef.json' in assets: raise ValueError('First installation requires the complete offline package') if len(selected) != 1 or 'SHA256SUMS' not in assets: raise ValueError('Release must contain one matching ARM64 package and SHA256SUMS') return selected[0] def current_release(): state = pathlib.Path('/home/.framely/state') if not state.is_dir(): state = pathlib.Path('/var/lib/framely') current = state / 'current' if not current.is_symlink(): raise ValueError('Framely is not installed, or its installation needs repair') link = os.readlink(current) if not re.fullmatch(r'releases/[a-zA-Z0-9.+-]+', link) or '..' in link: raise ValueError('Invalid current release link') return state / link def confirm(message): try: with open('/dev/tty', 'r+') as terminal: terminal.write(message + ' [y/N]: ') terminal.flush() return terminal.readline().strip().lower() == 'y' except OSError: return False def root_operation(args): report = emit_progress if getattr(args, 'progress_json', False) else lambda *args: None import fcntl with open('/run/framely-installer.lock', 'a') as lock: try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: raise ValueError('Another Framely maintenance operation is running') # Validate the package before installing it. base, work = None, None try: if args.action in ('install', 'update'): if args.action == 'update': current_release() report('verify') verify_archive(args.archive, args.checksums) report('stage') work = tempfile.mkdtemp(prefix='.framely-install-', dir='/home') staged_archive = pathlib.Path(work) / args.archive.name staged_checksums = pathlib.Path(work) / 'SHA256SUMS' shutil.copyfile(args.archive, staged_archive) shutil.copyfile(args.checksums, staged_checksums) verify_archive(staged_archive, staged_checksums) base = extract_archive(staged_archive, pathlib.Path(work) / 'unpacked', report) if args.action == 'install' and (base / 'CEF_RUNTIME.json').exists() and not (base / 'lib/cef/libcef.so').is_file(): raise ValueError('First installation requires the complete offline package with CEF') report('extract') subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=base, check=True) else: base = current_release() report('configure') if args.action in ('install', 'update'): command = ['bash', str(base / 'install.sh')] try: same = (base / 'VERSION').read_bytes() == (current_release() / 'VERSION').read_bytes() except ValueError: same = False if same: command.append('--repair') subprocess.run(command + [args.user], check=True, env=dict(os.environ, FRAMELY_PROGRESS_JSON='1' if getattr(args, 'progress_json', False) else '0')) report('activate') # Preconfigure the official update source without requiring agreement acceptance. state = pathlib.Path('/home/.framely/state') config = state / 'update-source.json' if not config.exists(): config.write_text(json.dumps({'url': f'https://github.com/{args.repo}/releases/latest/download/framely-release.json'})) config.chmod(0o600) subprocess.run(['systemctl', 'restart', 'framely.service'], check=True) subprocess.run(['systemctl', 'start', '--no-block', 'framely-session.service'], check=True) elif args.action == 'uninstall': if not pathlib.Path('/var/lib/framely/current').exists(): subprocess.run(['bash', '/home/.framely/repair.sh', args.user], check=True) # Copy out of the release which uninstall deletes. work = tempfile.mkdtemp(prefix='.framely-uninstall-', dir='/home') script = pathlib.Path(work) / 'uninstall.sh' shutil.copyfile(base / 'uninstall.sh', script) subprocess.run(['bash', str(script)], check=True) elif args.action == 'repair': subprocess.run(['bash', '/home/.framely/repair.sh', args.user], check=True) elif args.action == 'rollback': subprocess.run(['bash', str(base / 'rollback.sh')], check=True) report('activate') print('Framely operation completed.', flush=True) finally: if work: shutil.rmtree(work) def main(argv=None): parser = argparse.ArgumentParser(description='Install, update, repair, rollback or uninstall Framely') parser.add_argument('action', nargs='?', choices=['install', 'update', 'repair', 'rollback', 'uninstall']) parser.add_argument('--repo', default=DEFAULT_REPO) parser.add_argument('--version', help='GitHub release tag; default: latest stable') parser.add_argument('--archive', type=pathlib.Path) parser.add_argument('--checksums', type=pathlib.Path, help='External SHA256SUMS for the archive') parser.add_argument('--user', default=os.environ.get('SUDO_USER', 'steamos')) parser.add_argument('--yes', action='store_true', help='Confirm the selected operation') parser.add_argument('--progress-json', action='store_true', help=argparse.SUPPRESS) args = parser.parse_args(argv) if not re.fullmatch(r'[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+', args.repo): parser.error('Invalid repository') if not re.fullmatch(r'[a-z_][a-zA-Z0-9_-]*', args.user) or args.user == 'root': parser.error('Invalid Steam user') if sys.platform != 'linux' or os.uname().machine != 'aarch64': parser.error('This installation engine requires Linux ARM64 Steam Frame') if args.action is None: with open('/dev/tty', 'r+') as terminal: terminal.write('1 Install 2 Update 3 Repair 4 Rollback 5 Uninstall\nChoose [1-5]: ') terminal.flush() choices = {'1':'install', '2':'update', '3':'repair', '4':'rollback', '5':'uninstall'} args.action = choices.get(terminal.readline().strip()) if args.action is None: parser.error('Invalid action') if not args.yes and not confirm(f'{args.action}: ' + ('disable and uninstall ALL plugins, then remove Framely (saved data retained)' if args.action == 'uninstall' else 'continue with Framely maintenance')): raise ValueError('Cancelled') cache = pathlib.Path.home() / '.cache' / 'framely-installer' if args.action in ('install', 'update') and not args.archive else None if cache is not None: cache.mkdir(parents=True, mode=0o700, exist_ok=True) with tempfile.TemporaryDirectory(prefix='framely-download-', dir=cache) as folder: if args.action in ('install', 'update'): if bool(args.archive) != bool(args.checksums): parser.error('--archive and --checksums must be provided together') if not args.archive: endpoint = 'latest' if not args.version else 'tags/' + urllib.parse.quote(args.version, safe='') metadata = read_metadata(f'https://api.github.com/repos/{args.repo}/releases/{endpoint}', 4 * 1024**2) release = json.loads(metadata) assets = {asset['name']: asset for asset in release['assets']} package = select_package(assets, args.action) args.archive = pathlib.Path(folder) / package args.checksums = pathlib.Path(folder) / 'SHA256SUMS' print('Downloading ' + package, flush=True) download(assets[package]['browser_download_url'], args.archive, MAX_ARCHIVE) download(assets['SHA256SUMS']['browser_download_url'], args.checksums, 1024**2) args.archive, args.checksums = args.archive.resolve(), args.checksums.resolve() if args.progress_json: emit_progress('verify') verify_archive(args.archive, args.checksums) print('Archive SHA256 verified.', flush=True) if os.geteuid() == 0: root_operation(args) else: command = ['sudo', '--', sys.executable, str(pathlib.Path(__file__).resolve()), args.action, '--repo', args.repo, '--user', args.user, '--yes'] if args.archive: command += ['--archive', str(args.archive), '--checksums', str(args.checksums)] if args.progress_json: command += ['--progress-json'] # stdin belongs to curl | bash; sudo reads its password from the terminal. subprocess.run(command, check=True) if __name__ == '__main__': try: main() except (ValueError, OSError, subprocess.CalledProcessError) as error: print('Framely: ' + str(error), file=sys.stderr) sys.exit(1)