Files
SteamFramelyHomebrew--framely/tools/bootstrap.py
T

686 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Shared device-side installation engine, also embedded in the desktop installer."""
import argparse
import contextlib
import datetime
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 uuid
import socket
import ssl
import http.client
DEFAULT_REPO = 'SteamFramelyHomebrew/framely'
MAX_ARCHIVE = 2 * 1024**3
TRACE_ID = uuid.uuid4().hex[:12]
def log_event(message):
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
print(f'[{timestamp}] Framely[{TRACE_ID}] {message}', flush=True)
def log_failure(message):
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
print(f'[{timestamp}] Framely[{TRACE_ID}] {message}', file=sys.stderr, flush=True)
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)
def terminal_download_progress(total):
last_reported = 0.0
def report(completed):
nonlocal last_reported
now = time.monotonic()
if completed not in (0, total) and now - last_reported < 5:
return
last_reported = now
percent = completed * 100 / total
log_event(f'下载进度 {percent:5.1f}% {completed / 1024**2:.1f} / {total / 1024**2:.1f} MiB')
return report
class InstallationFailure(RuntimeError):
pass
def failure_reason(error):
if isinstance(error, urllib.error.HTTPError):
if error.code == 404:
return 'HTTP 404:请求的版本或发布文件不存在。'
if error.code == 403:
return 'HTTP 403:下载服务拒绝访问,可能触发了访问限制。'
if error.code == 429:
return 'HTTP 429:下载服务正在限流。'
return f'HTTP {error.code}:下载服务返回错误。'
if isinstance(error, urllib.error.URLError):
return failure_reason(error.reason)
if isinstance(error, (ssl.SSLCertVerificationError, ssl.CertificateError)):
return 'TLS 证书验证失败;请检查设备时间、证书和代理。'
if isinstance(error, socket.gaierror):
return '域名解析失败;请检查网络和代理地址。'
if isinstance(error, (TimeoutError, ConnectionError, http.client.IncompleteRead,
http.client.RemoteDisconnected)):
return '连接超时、中断或文件传输不完整。'
if isinstance(error, OSError):
if error.errno == errno.ENOSPC:
return '设备存储空间不足。'
if error.errno in (errno.EACCES, errno.EPERM):
return '没有写入安装文件的权限。'
if error.errno == errno.ENOENT:
return '安装暂存文件或目录不存在。'
if error.errno == errno.EIO:
return '存储设备读写失败。'
if isinstance(error, ValueError) and 'SHA256 mismatch' in str(error):
return '文件校验不通过;下载内容可能损坏或被代理修改。'
if isinstance(error, (KeyError, TypeError)):
return '发行版信息缺少必要字段或格式无效。'
if isinstance(error, subprocess.CalledProcessError):
return '安装子程序执行失败;详细错误见上方输出。'
# The dual-route failure already contains only sanitized error types.
if isinstance(error, RuntimeError) and 'GitHub primary route failed:' in str(error):
return str(error)
if isinstance(error, ValueError):
if str(error) == 'Cancelled':
return '操作已由用户取消。'
if str(error).startswith(('No interactive terminal', 'No confirmation input')):
return '缺少交互终端或确认输入。'
if str(error) == 'Download exceeds size limit':
return '下载文件超过允许大小。'
if str(error) == 'Download temporary file identity changed':
return '下载暂存文件在传输期间被替换。'
return '配置或下载数据格式无效。'
return f'{type(error).__name__}'
def failure_diagnostic(error):
if isinstance(error, subprocess.CalledProcessError):
return f'{type(error).__name__} exit={error.returncode}'
if isinstance(error, urllib.error.URLError):
return failure_diagnostic(error.reason) if not isinstance(error, urllib.error.HTTPError) else f'HTTP {error.code}'
if isinstance(error, OSError) and error.errno is not None:
return f'{type(error).__name__} errno={error.errno}'
if isinstance(error, RuntimeError) and 'GitHub primary route failed:' in str(error):
return str(error)
return type(error).__name__
@contextlib.contextmanager
def installation_step(stage, next_action):
started = time.monotonic()
log_event(f'{stage}:开始')
try:
yield
except (OSError, ValueError, RuntimeError, KeyError, TypeError, subprocess.CalledProcessError) as error:
reason = failure_reason(error)
diagnostic = failure_diagnostic(error)
raise InstallationFailure(
f'{stage}失败(耗时 {time.monotonic() - started:.1f} 秒)。\n原因:{reason}'
f'\n诊断:{diagnostic}\n下一步:{next_action}'
) from None
else:
log_event(f'{stage}:完成,耗时 {time.monotonic() - started:.1f} 秒')
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 github_proxy_url(value):
value = value.strip().rstrip('/')
parsed = urllib.parse.urlsplit(value)
if (not value or any(ord(char) <= 32 for char in value)
or parsed.scheme != 'https' or not parsed.hostname
or parsed.username or parsed.password or parsed.query or parsed.fragment
or '..' in parsed.path.split('/')):
raise ValueError('GitHub proxy must be an HTTPS base URL')
try:
parsed.port
except ValueError as error:
raise ValueError('Invalid GitHub proxy port') from error
return value
def configured_github_proxy():
settings = device_proxy_settings()
value = os.environ.get('FRAMELY_GITHUB_PROXY', settings.get('github', '') if settings.get('githubEnabled') else '')
return github_proxy_url(value) if value.strip() else ''
def download_route():
proxy = configured_github_proxy()
if proxy:
host = urllib.parse.urlsplit(proxy).hostname
return 'gh-proxy.com' if host == 'gh-proxy.com' else '自定义 GitHub 代理(地址已隐藏)'
return 'GitHub 直连(自动代理回退已开启)' if fallback_enabled() else 'GitHub 直连'
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 configured_github_proxy() or not (fallback_enabled() if enabled is None else enabled) or not public_github(url) or not retryable(primary):
raise
log_failure(f'GitHub 请求失败({network_diagnostic(primary)}),正在尝试 gh-proxy.com')
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 = configured_github_proxy()
if fallback:
url = GITHUB_FALLBACK + url
elif manual and public_github(url):
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 release_version(tag):
"""Return a SemVer precedence key, excluding build metadata."""
match = re.fullmatch(
r'v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?', tag)
if not match:
return None
identifiers = []
for part in (match[4] or '').split('.') if match[4] else []:
if part.isdigit():
if len(part) > 1 and part.startswith('0'):
return None
identifiers.append((0, int(part)))
else:
identifiers.append((1, part))
# A final release has higher precedence than its prereleases.
return (int(match[1]), int(match[2]), int(match[3]),
1 if not identifiers else 0, tuple(identifiers))
def resolve_release(repo, channel, action):
"""Choose an installable GitHub release for the interactive entry point."""
candidates = []
for page in range(1, 11):
url = f'https://api.github.com/repos/{repo}/releases?per_page=100&page={page}'
releases = json.loads(read_metadata(url, 4 * 1024**2))
if not isinstance(releases, list):
raise ValueError('Invalid GitHub release list')
for release in releases:
if not isinstance(release, dict) or release.get('draft'):
continue
tag = release.get('tag_name')
version = release_version(tag) if isinstance(tag, str) else None
if version is None or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.+-]*', tag):
continue
prerelease = version[3] == 0
if release.get('prerelease') != prerelease or channel == 'stable' and prerelease:
continue
if not isinstance(release.get('assets'), list):
continue
assets = {asset['name']: asset for asset in release['assets']
if isinstance(asset, dict) and isinstance(asset.get('name'), str)}
if 'bootstrap.py' not in assets:
continue
try:
package = select_package(assets, action)
except ValueError:
continue
# A release must contain a package for its own tag.
name = package[len('framely-'):-len('-linux-arm64.tar.gz')]
if name.endswith('-offline'):
name = name[:-len('-offline')]
plain_tag = tag[1:] if tag.startswith('v') else tag
if not re.fullmatch(re.escape(plain_tag) + r'-[a-f0-9]{12}', name):
continue
size = assets[package].get('size')
if not isinstance(size, int) or not 0 < size <= MAX_ARCHIVE:
continue
candidates.append((version, tag, size))
if len(releases) < 100:
break
else:
raise ValueError('Release list exceeded ten pages')
if not candidates:
raise ValueError('No installable release found for selected channel')
_, tag, size = max(candidates, key=lambda item: item[0])
return {'tag': tag, 'size': size}
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()
answer = terminal.readline()
except OSError as error:
raise ValueError('No interactive terminal for confirmation; run in a terminal or pass --yes') from error
if not answer:
raise ValueError('No confirmation input; run in a terminal or pass --yes')
return answer.strip().lower() == 'y'
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')
with installation_step('校验解压文件', '重新运行安装命令;若仍失败,请保存上方校验输出供开发者排查。'):
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')
with installation_step('执行安装脚本', '查看上方脚本输出,修复所提示的问题后重新运行安装命令。'):
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')
log_event('设备端操作完成')
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('--github-proxy', help='HTTPS URL prefix for GitHub downloads, such as https://gh-proxy.com')
parser.add_argument('--progress-json', action='store_true', help=argparse.SUPPRESS)
parser.add_argument('--trace-id', help=argparse.SUPPRESS)
parser.add_argument('--resolve-release', choices=['stable', 'testing'], help=argparse.SUPPRESS)
parser.add_argument('--selection-action', choices=['install', 'update'], help=argparse.SUPPRESS)
args = parser.parse_args(argv)
global TRACE_ID
candidate = args.trace_id or os.environ.get('FRAMELY_INSTALL_TRACE_ID', '')
if re.fullmatch(r'[0-9]{8}T[0-9]{6}Z-[0-9]{1,10}|[0-9a-f]{12}', candidate):
TRACE_ID = candidate
if args.github_proxy:
os.environ['FRAMELY_GITHUB_PROXY'] = github_proxy_url(args.github_proxy)
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 args.version and not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.+-]*', args.version):
parser.error('Invalid release version')
if args.resolve_release:
if not args.selection_action:
parser.error('--selection-action is required with --resolve-release')
print(json.dumps(resolve_release(args.repo, args.resolve_release, args.selection_action)))
return
if sys.platform != 'linux' or os.uname().machine != 'aarch64':
parser.error('This installation engine requires Linux ARM64 Steam Frame')
explicit_action = args.action is not None
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 (explicit_action and args.action in ('install', 'update')) 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')
log_event(f'开始:操作={args.action},版本={args.version or "latest stable"},来源={"official" if args.repo == DEFAULT_REPO else "custom"},'
f'架构={os.uname().machine},Python={sys.version_info.major}.{sys.version_info.minor},下载线路={download_route()}')
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='')
with installation_step('读取发行版信息', '确认版本已发布,检查 GitHub 或代理连接后重试。'):
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)
size = assets[package]['size']
if not isinstance(size, int) or size <= 0 or size > MAX_ARCHIVE:
raise ValueError('发布包大小无效')
tag = release.get('tag_name', '')
log_event(f'发行版:{tag if isinstance(tag, str) and re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9.+-]*", tag) else "unknown"},文件={package}')
args.archive = pathlib.Path(folder) / package
args.checksums = pathlib.Path(folder) / 'SHA256SUMS'
log_event(f'安装包大小:{size / 1024**2:.1f} MiB,下载线路:{download_route()}')
with installation_step('下载安装包', '检查网络或更换 GitHub 代理后重新运行安装命令。'):
download(assets[package]['browser_download_url'], args.archive, MAX_ARCHIVE,
terminal_download_progress(size), expected_size=size)
print('正在下载校验文件…', flush=True)
with installation_step('下载校验文件', '检查网络或更换 GitHub 代理后重新运行安装命令。'):
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')
print('正在校验安装包…', flush=True)
with installation_step('校验安装包', '重新运行安装命令;若仍失败,换用可信的下载线路并向开发者提供诊断信息。'):
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']
if args.github_proxy:
command += ['--github-proxy', args.github_proxy]
command += ['--trace-id', TRACE_ID]
# stdin belongs to curl | bash; sudo reads its password from the terminal.
try:
subprocess.run(command, check=True)
except subprocess.CalledProcessError as error:
raise InstallationFailure('设备端安装失败。\n原因:sudo 安装步骤退出,详细错误见上方输出。'
'\n下一步:按上方提示修复后,重新运行安装命令。') from error
log_event(f'操作完成:{args.action}')
if __name__ == '__main__':
try:
main()
except (ValueError, OSError, RuntimeError, subprocess.CalledProcessError) as error:
if '--resolve-release' in sys.argv:
log_failure('Release lookup failed. Diagnostic: '
+ failure_diagnostic(error)
+ '. Check the network, mirror, and release assets, then retry.')
elif isinstance(error, InstallationFailure):
log_failure(str(error))
else:
log_failure(f'安装操作失败。\n原因:{failure_reason(error)}\n诊断:{failure_diagnostic(error)}'
'\n下一步:检查上方日志和运行环境,修复问题后重试;若仍失败,请提供追踪编号及完整安装输出。')
sys.exit(1)