From 4b06a59995e9e314b23b8827ee6bfaaa06cd46d0 Mon Sep 17 00:00:00 2001 From: toorux Date: Tue, 20 Oct 2026 20:53:22 +0800 Subject: [PATCH] Add interactive installer wizard and clearer download diagnostics --- docs/user-guide/installation.md | 10 +- docs/zh-CN/user-guide/installation.md | 14 +- install.sh | 213 +++++++++++++++++--- tests/test_bootstrap.py | 59 ++++++ tests/test_github_fallback.py | 5 +- tests/test_install_entry.py | 10 +- tests/test_install_wizard.py | 140 ++++++++++++++ tools/bootstrap.py | 267 ++++++++++++++++++++++++-- 8 files changed, 669 insertions(+), 49 deletions(-) create mode 100644 tests/test_install_wizard.py diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index fa27e67..6c0250e 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -24,12 +24,14 @@ Downloads are checked locally and again on the device. Passwords are not saved i ## Option 2: install from Frame's SSH terminal -Run these commands **on Frame**. To install the latest stable release: +Run this command **on Frame** to open the installer: ```bash -curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes +curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash ``` +With no arguments, this opens a wizard that detects whether to install or update, offers stable and testing releases, and lets you choose direct GitHub access or a mirror. You can go back or quit before final confirmation. For an explicit command without the wizard, use `bash -s -- install --yes` after the pipe. + To select a Preview or another tag: ```bash @@ -38,7 +40,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i Replace the example with an existing device release tag. An explicit tag selects the engine and package from that Release and works without a stable release. Without a tag, the latest stable release is selected. Enter the sudo password when prompted. -Explicit `install` and `update` commands need no second confirmation. The interactive action menu and uninstall still ask for `y` and Enter. If sudo needs a password in a single remote `ssh` command, allocate a terminal with `ssh -t`; `--yes` skips only Framely's confirmation, not sudo authentication. +Explicit `install` and `update` commands retain their behavior. In the wizard, direct GitHub access disables automatic mirror fallback for that run; leaving the mirror address blank selects `https://gh-proxy.com`. If sudo needs a password in a single remote `ssh` command, allocate a terminal with `ssh -t`; `--yes` skips only Framely's confirmation, not sudo authentication. To route the script download and all later GitHub requests through a chosen HTTPS URL prefix, use `--github-proxy`: @@ -109,6 +111,8 @@ sudo journalctl -u framely -u framely-session --no-pager -n 100 Repair restores accounts and services from the retained release and stops on UID conflicts. It cannot restore deleted `/home` data. Recovery after SteamOS updates still requires device validation. +Command-line installation logs use one trace ID across download and device installation. They show the release package, download route, stage duration, safe error code, and next action. When reporting a failure, include the trace ID, failing stage, and relevant output. Review the output for passwords, tokens, or personal information printed by other programs before sharing it. Framely hides custom proxy addresses and parameterized download URLs in its own logs. + ## Uninstall Use the desktop maintenance page or run: diff --git a/docs/zh-CN/user-guide/installation.md b/docs/zh-CN/user-guide/installation.md index 97520dd..fc006c3 100644 --- a/docs/zh-CN/user-guide/installation.md +++ b/docs/zh-CN/user-guide/installation.md @@ -24,7 +24,15 @@ Linux 用户可在安装器解压目录执行 `bash install-desktop-entry.sh`, ## 方式二:在 Frame 的 SSH 终端安装 -以下命令在 **Frame 上** 执行。默认安装最新正式版: +以下命令在 **Frame 上** 执行。 + +不带参数时会打开交互向导,自动识别安装或更新,选择正式版/测试版及下载线路;在最终确认前可返回或退出: + +```bash +curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash +``` + +需要跳过向导时,继续使用明确的命令: ```bash curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes @@ -38,7 +46,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i 将示例标签换成 Releases 中实际存在的本体标签。指定标签时从该 Release 下载引擎和包,不要求已有正式版;不指定标签时使用最新正式版。终端会按需请求 sudo 密码。 -明确指定 `install` 或 `update` 时无需再次确认。使用交互菜单或卸载时,脚本会询问是否继续,需要输入 `y` 并回车。如果通过一条 `ssh` 远程命令执行且 sudo 需要密码,请用 `ssh -t` 分配交互终端;`--yes` 仅跳过 Framely 的确认,不会跳过 sudo 密码。 +明确指定 `install` 或 `update` 时仍按原方式执行。交互向导从终端读取选项,选择直连时本次运行不会自动切换镜像;选择镜像并将地址留空时使用 `https://gh-proxy.com`。如果通过一条 `ssh` 远程命令执行且 sudo 需要密码,请用 `ssh -t` 分配交互终端;`--yes` 仅跳过 Framely 的确认,不会跳过 sudo 密码。 使用 `--github-proxy` 可通过指定的 HTTPS 地址代理入口脚本及后续所有 GitHub 下载。例如: @@ -109,6 +117,8 @@ sudo journalctl -u framely -u framely-session --no-pager -n 100 修复会使用已保留的发行恢复账号及服务;UID 冲突时停止。它不能找回已删除的 `/home` 数据,SteamOS 更新后的恢复仍需实机验证。 +命令行安装日志使用同一个追踪编号记录开始时间、版本、发行包、下载线路、阶段耗时与安全错误码。下载时会持续显示已下载量;失败时会给出原因和下一步。反馈安装问题时,请提供追踪编号、出错阶段和相关输出;发送前仍应检查并移除终端中其他程序可能打印的密码、Token 或个人信息。安装器会隐藏自定义代理地址及带参数的下载 URL。 + ## 卸载 桌面安装器“安装与维护”提供卸载,也可直接执行: diff --git a/install.sh b/install.sh index 79a76b5..5f0cdc3 100644 --- a/install.sh +++ b/install.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Public bootstrap: curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash set -euo pipefail +export FRAMELY_INSTALL_TRACE_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" repo=${FRAMELY_REPOSITORY:-SteamFramelyHomebrew/framely} [[ $repo =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]] || { echo 'Invalid repository.' >&2; exit 1; } command -v python3 >/dev/null || { echo 'Python 3 is required.' >&2; exit 1; } @@ -9,6 +10,190 @@ trap 'rm -rf -- "$work"' EXIT release_path=latest/download args=("$@") github_proxy=${FRAMELY_GITHUB_PROXY:-} +fetch_engine() { + local bootstrap_url="https://github.com/$repo/releases/$release_path/bootstrap.py" +if [[ -n $github_proxy ]]; then + echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: Fetching installer engine through configured GitHub proxy (address hidden)." + if status=$(curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --write-out '%{http_code}' --output "$work/bootstrap.py" "$github_proxy/$bootstrap_url"); then + : + else + code=$? + echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: Failed to download the installer engine through the configured GitHub proxy (curl $code, HTTP ${status:-000}). Check the proxy connection or use another trusted proxy, then retry." >&2 + return 1 + fi + return 0 +fi +echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: Fetching installer engine from GitHub." +if status=$(curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --dump-header "$work/headers" --write-out '%{http_code}' --output "$work/bootstrap.py" "$bootstrap_url"); then + : +else + code=$? + retry=false + case "$code" in 5|6|7|18|28|35|52|55|56) retry=true;; esac + if [[ $code == 22 ]]; then + case "$status" in 408|429|5??) retry=true;; 403) + if grep -Eiq '^(x-ratelimit-remaining:[[:space:]]*0[[:space:]]*$|retry-after:)' "$work/headers"; then retry=true; fi;; + esac + fi + if [[ ${FRAMELY_GITHUB_AUTO_FALLBACK:-1} == 0 || $retry != true ]]; then + echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: Failed to download the installer engine from GitHub (curl $code, HTTP $status). Check the network, release version and proxy settings, then retry." >&2; return 1 + fi + echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: GitHub request failed (curl $code, HTTP $status); trying gh-proxy.com once." >&2 + if ! curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --output "$work/bootstrap.py" "https://gh-proxy.com/$bootstrap_url"; then + echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: Failed to download the installer engine from GitHub (curl $code, HTTP $status) and gh-proxy.com. Check the network or specify another trusted --github-proxy, then retry." >&2; return 1 + fi +fi +} +if (( $# == 0 )); then + if ! exec 3<>/dev/tty; then + echo 'Framely: The interactive installer needs a terminal. Run it in a Frame terminal, use an explicit command such as install --yes, or add -t to SSH.' >&2 + exit 1 + fi + state=/home/.framely/state + [[ -L $state/current || ! -L /var/lib/framely/current ]] || state=/var/lib/framely + if [[ -L $state/current ]]; then + link=$(readlink "$state/current") + if [[ ! $link =~ ^releases/[a-zA-Z0-9.+-]+$ || ! -f $state/current/VERSION || ! -f $state/current/uninstall.sh ]]; then + echo 'Framely: Installation records are incomplete. Run /home/.framely/repair.sh, then try again.' >&3 + exit 1 + fi + installed_version=$(<"$state/current/VERSION") + if [[ ! $installed_version =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*$ ]]; then + echo 'Framely: The installed version record is invalid. Repair the installation, then try again.' >&3 + exit 1 + fi + installed=true + elif [[ -e $state/current || -d /home/.framely/releases && -n $(ls -A /home/.framely/releases 2>/dev/null) ]]; then + echo 'Framely: Installation records are incomplete. Run /home/.framely/repair.sh, then try again.' >&3 + exit 1 + else + installed=false + installed_version='Not installed' + fi + echo >&3 + echo ' FRAMELY INSTALL & MAINTAIN' >&3 + echo ' ──────────────────────────────' >&3 + echo " Current version: $installed_version" >&3 + echo >&3 + step=menu + while :; do + case $step in + menu) + echo " 1) $([[ $installed == true ]] && echo 'Update Framely' || echo 'Install Framely')" >&3 + [[ $installed == true ]] && echo ' 2) Uninstall Framely' >&3 + echo ' q) Quit' >&3 + printf ' Select: ' >&3 + IFS= read -r choice <&3 || exit 1 + case $choice in + 1) [[ $installed == true ]] && wizard_action=update || wizard_action=install; step=channel ;; + 2) [[ $installed == true ]] && step=uninstall_confirm ;; + q|Q) echo 'Framely: Exited.' >&3; exit 0 ;; + *) echo ' Enter one of the listed options.' >&3 ;; + esac ;; + uninstall_confirm) + echo ' Uninstall disables and removes all plugins, then removes Framely. Saved data is kept.' >&3 + printf ' 1) Start b) Back q) Quit: ' >&3 + IFS= read -r choice <&3 || exit 1 + case $choice in + 1) + cp -- "$state/current/uninstall.sh" "$work/uninstall.sh" + if [[ ! -e /var/lib/framely/current && -f /home/.framely/repair.sh ]]; then + if (( EUID == 0 )); then bash /home/.framely/repair.sh steamos; else sudo bash /home/.framely/repair.sh steamos; fi + fi + if (( EUID == 0 )); then bash "$work/uninstall.sh"; else sudo bash "$work/uninstall.sh"; fi + exit ;; + b|B) step=menu ;; + q|Q) exit 0 ;; + esac ;; + channel) + echo >&3 + echo ' Release channel' >&3 + echo ' 1) Stable 2) Testing (includes stable releases)' >&3 + printf ' Select (b: back, q: quit): ' >&3 + IFS= read -r choice <&3 || exit 1 + case $choice in + 1) channel=stable; step=mirror ;; + 2) channel=testing; step=mirror ;; + b|B) step=menu ;; + q|Q) exit 0 ;; + *) echo ' Enter one of the listed options.' >&3 ;; + esac ;; + mirror) + echo >&3 + echo ' Download route' >&3 + echo ' 1) Direct from GitHub 2) Use a mirror' >&3 + printf ' Select (b: back, q: quit): ' >&3 + IFS= read -r choice <&3 || exit 1 + case $choice in + 1) github_proxy=''; export FRAMELY_GITHUB_PROXY=''; export FRAMELY_GITHUB_AUTO_FALLBACK=0; step=lookup ;; + 2) step=mirror_address ;; + b|B) step=channel ;; + q|Q) exit 0 ;; + *) echo ' Enter one of the listed options.' >&3 ;; + esac ;; + mirror_address) + echo >&3 + printf ' Mirror HTTPS base URL (blank: https://gh-proxy.com; b: back): ' >&3 + IFS= read -r choice <&3 || exit 1 + case $choice in + b|B) step=mirror ;; + q|Q) exit 0 ;; + *) github_proxy=${choice:-https://gh-proxy.com}; step=lookup ;; + esac ;; + lookup) + if [[ -n $github_proxy ]]; then + if [[ ! $github_proxy =~ ^https://[a-zA-Z0-9.-]+(:[0-9]{1,5})?(/[a-zA-Z0-9._~/-]*)?$ || /$github_proxy/ == *'/../'* || /$github_proxy/ == *'/./'* ]]; then + echo ' Invalid address. Enter an HTTPS mirror base URL without query parameters.' >&3 + step=mirror_address + continue + fi + github_proxy=${github_proxy%/} + export FRAMELY_GITHUB_PROXY=$github_proxy + unset FRAMELY_GITHUB_AUTO_FALLBACK + fi + echo ' Checking available releases...' >&3 + # The current stable release supplies the resolver; the chosen tag + # supplies the actual engine used after final confirmation. + release_path=latest/download + if ! fetch_engine; then + echo ' Could not download the release checker. Check your connection or mirror, then retry.' >&3 + step=lookup_error + continue + fi + if ! selection=$(python3 "$work/bootstrap.py" --repo "$repo" --resolve-release "$channel" --selection-action "$wizard_action"); then + echo ' Could not find an installable release. Check the connection, mirror, and release assets, then retry.' >&3 + step=lookup_error + continue + fi + selected_tag=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["tag"])' "$selection") + selected_size=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["size"])' "$selection") + step=confirm ;; + lookup_error) + printf ' r) Retry b) Back to download route q) Quit: ' >&3 + IFS= read -r choice <&3 || exit 1 + case $choice in + r|R) step=lookup ;; + b|B) step=mirror ;; + q|Q) exit 0 ;; + esac ;; + confirm) + echo >&3 + echo ' Ready to proceed' >&3 + echo " Action: $([[ $wizard_action == update ]] && echo Update || echo Install)" >&3 + echo " Current version: $installed_version" >&3 + echo " Target release: $selected_tag" >&3 + echo " Package size: about $(( (selected_size + 1048575) / 1048576 )) MiB" >&3 + echo " Download route: $([[ -n $github_proxy ]] && echo 'GitHub mirror (address hidden)' || echo 'Direct from GitHub')" >&3 + printf ' 1) Start b) Back q) Quit: ' >&3 + IFS= read -r choice <&3 || exit 1 + case $choice in + 1) args=("$wizard_action" --version "$selected_tag" --yes); [[ -n $github_proxy ]] && args+=(--github-proxy "$github_proxy"); release_path="download/$selected_tag"; break ;; + b|B) [[ -n $github_proxy ]] && step=mirror_address || step=mirror ;; + q|Q) exit 0 ;; + esac ;; + esac + done +fi for ((i=0; i<${#args[@]}; i++)); do case ${args[i]} in --version) @@ -39,29 +224,5 @@ if [[ -n $github_proxy ]]; then github_proxy=${github_proxy%/} export FRAMELY_GITHUB_PROXY=$github_proxy fi -bootstrap_url="https://github.com/$repo/releases/$release_path/bootstrap.py" -if [[ -n $github_proxy ]]; then - curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --output "$work/bootstrap.py" "$github_proxy/$bootstrap_url" - python3 "$work/bootstrap.py" --repo "$repo" "$@" - exit -fi -if status=$(curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --dump-header "$work/headers" --write-out '%{http_code}' --output "$work/bootstrap.py" "$bootstrap_url"); then - : -else - code=$? - retry=false - case "$code" in 5|6|7|18|28|35|52|55|56) retry=true;; esac - if [[ $code == 22 ]]; then - case "$status" in 408|429|5??) retry=true;; 403) - if grep -Eiq '^(x-ratelimit-remaining:[[:space:]]*0[[:space:]]*$|retry-after:)' "$work/headers"; then retry=true; fi;; - esac - fi - if [[ ${FRAMELY_GITHUB_AUTO_FALLBACK:-1} == 0 || $retry != true ]]; then - echo "Bootstrap primary route failed (curl $code, HTTP $status); not eligible for automatic proxy." >&2; exit 1 - fi - echo 'GitHub connection failed; trying gh-proxy.com once' >&2 - if ! curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --output "$work/bootstrap.py" "https://gh-proxy.com/$bootstrap_url"; then - echo "Bootstrap primary route failed (curl $code, HTTP $status); gh-proxy.com fallback also failed." >&2; exit 1 - fi -fi -python3 "$work/bootstrap.py" --repo "$repo" "$@" +fetch_engine +python3 "$work/bootstrap.py" --repo "$repo" "${args[@]}" diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index e2b1163..84e4e59 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -7,6 +7,8 @@ import pathlib import tarfile import tempfile import unittest +import errno +import urllib.error from types import SimpleNamespace from unittest.mock import mock_open, patch @@ -16,6 +18,35 @@ spec.loader.exec_module(bootstrap) class BootstrapTests(unittest.TestCase): + def test_explicit_direct_route_overrides_saved_proxy(self): + with patch.dict(bootstrap.os.environ, {'FRAMELY_GITHUB_PROXY': '', + 'FRAMELY_GITHUB_AUTO_FALLBACK': '0'}), \ + patch.object(bootstrap, 'device_proxy_settings', return_value={ + 'githubEnabled': True, 'github': 'https://proxy.example'}): + self.assertEqual(bootstrap.configured_github_proxy(), '') + self.assertFalse(bootstrap.fallback_enabled()) + + def test_interactive_release_selection_uses_semver_and_installable_assets(self): + def release(tag, prerelease=False, missing_offline=False): + version = tag.removeprefix('v') + files = ['bootstrap.py', 'SHA256SUMS', + f'framely-{version}-012345abcdef-linux-arm64.tar.gz'] + if not missing_offline: + files.append(f'framely-{version}-012345abcdef-offline-linux-arm64.tar.gz') + else: + files.append('framely-cef.json') + return {'tag_name': tag, 'draft': False, 'prerelease': prerelease, + 'assets': [{'name': name, 'size': 1024} for name in files]} + releases = [release('v0.8.9'), release('v0.9.0-preview.2', True), + release('v0.9.0-preview.10', True), + release('v1.0.0', missing_offline=True), release('v2.0.0', True)] + with patch.object(bootstrap, 'read_metadata', return_value=json.dumps(releases).encode()): + self.assertEqual(bootstrap.resolve_release('owner/repo', 'stable', 'install')['tag'], 'v0.8.9') + self.assertEqual(bootstrap.resolve_release('owner/repo', 'testing', 'install')['tag'], 'v0.9.0-preview.10') + self.assertEqual(bootstrap.resolve_release('owner/repo', 'stable', 'update')['tag'], 'v1.0.0') + self.assertGreater(bootstrap.release_version('v0.9.0-preview.10'), + bootstrap.release_version('v0.9.0-preview.2')) + def test_explicit_install_and_update_skip_confirmation(self): with tempfile.TemporaryDirectory() as tmp: archive = pathlib.Path(tmp) / 'package.tar.gz' @@ -136,6 +167,34 @@ class BootstrapTests(unittest.TestCase): payload = json.loads(output.getvalue().removeprefix('FRAMELY_PROGRESS ')) self.assertEqual(payload, {'step': 'configure'}) + def test_installation_failure_explains_step_cause_and_next_action_without_url(self): + with self.assertRaises(bootstrap.InstallationFailure) as failure: + with bootstrap.installation_step('下载安装包', '更换代理后重试。'): + raise urllib.error.HTTPError('https://example.invalid/?token=secret', 502, '', {}, None) + message = str(failure.exception) + self.assertIn('下载安装包失败', message) + self.assertIn('HTTP 502', message) + self.assertIn('更换代理后重试', message) + self.assertNotIn('secret', message) + with self.assertRaises(bootstrap.InstallationFailure) as failure: + with bootstrap.installation_step('下载安装包', '释放空间后重试。'): + raise OSError(errno.ENOSPC, 'disk full') + self.assertIn('设备存储空间不足', str(failure.exception)) + self.assertIn('errno=28', str(failure.exception)) + with self.assertRaises(bootstrap.InstallationFailure) as failure: + with bootstrap.installation_step('读取发行版信息', '检查连接后重试。'): + raise ValueError('bad URL https://example.invalid/?token=secret') + self.assertNotIn('secret', str(failure.exception)) + + def test_terminal_download_progress_reports_bytes_and_completion(self): + output = io.StringIO() + with contextlib.redirect_stdout(output): + progress = bootstrap.terminal_download_progress(2 * 1024**2) + progress(0) + progress(2 * 1024**2) + self.assertIn('0.0 / 2.0 MiB', output.getvalue()) + self.assertIn('100.0%', output.getvalue()) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_github_fallback.py b/tests/test_github_fallback.py index 67e00e3..ff21175 100644 --- a/tests/test_github_fallback.py +++ b/tests/test_github_fallback.py @@ -142,8 +142,11 @@ exit {code} python = root / 'python3'; python.write_text('#!/bin/sh\nexit 0\n') curl.chmod(0o755); python.chmod(0o755) env = dict(os.environ, PATH=temp + os.pathsep + os.environ['PATH'], FRAMELY_GITHUB_AUTO_FALLBACK=enabled) - subprocess.run(['bash', str(ROOT / 'install.sh')], env=env, capture_output=True) + result = subprocess.run(['bash', str(ROOT / 'install.sh'), 'install'], env=env, capture_output=True, text=True) self.assertEqual(len(log.read_text().splitlines()), attempts) + if attempts == 1: + self.assertIn('Failed to download the installer engine', result.stderr) + self.assertIn('then retry', result.stderr) if __name__ == '__main__': diff --git a/tests/test_install_entry.py b/tests/test_install_entry.py index 2e207f1..ce87304 100644 --- a/tests/test_install_entry.py +++ b/tests/test_install_entry.py @@ -34,12 +34,20 @@ class InstallEntryTests(unittest.TestCase): self.assertEqual(calls['url'], 'https://gh-proxy.com/https://github.com/TestOwner/framely/releases/latest/download/bootstrap.py') self.assertEqual(calls['proxy'], 'https://gh-proxy.com') self.assertEqual(calls['args'], ['--repo', 'TestOwner/framely', *args]) + self.assertRegex(calls['trace'], r'^[0-9]{8}T[0-9]{6}Z-[0-9]+$') + self.assertIn(calls['trace'], result.stdout) for invalid in ['http://gh-proxy.com', 'https://gh-proxy.com/../bad', 'https://gh-proxy.com?key=1', '']: with self.subTest(invalid=invalid): result, calls = self.run_entry(['install', '--github-proxy', invalid]) self.assertNotEqual(result.returncode, 0) self.assertEqual(calls, {}) + def test_proxy_path_is_not_written_to_installer_log(self): + result, calls = self.run_entry(['install', '--github-proxy', 'https://proxy.example/private-route']) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn('private-route', result.stdout + result.stderr) + self.assertEqual(calls['proxy'], 'https://proxy.example/private-route') + def run_entry(self, args): with tempfile.TemporaryDirectory() as temp: root = pathlib.Path(temp) @@ -48,7 +56,7 @@ class InstallEntryTests(unittest.TestCase): curl.write_text('''#!/usr/bin/env python3 import json, os, pathlib, sys args = sys.argv[1:] -pathlib.Path(os.environ['FRAMELY_ENTRY_LOG']).write_text(json.dumps({'url': args[-1], 'proxy': os.environ.get('FRAMELY_GITHUB_PROXY', '')})) +pathlib.Path(os.environ['FRAMELY_ENTRY_LOG']).write_text(json.dumps({'url': args[-1], 'proxy': os.environ.get('FRAMELY_GITHUB_PROXY', ''), 'trace': os.environ.get('FRAMELY_INSTALL_TRACE_ID', '')})) pathlib.Path(args[args.index('--output') + 1]).write_text("import json,os,pathlib,sys; p=pathlib.Path(os.environ['FRAMELY_ENTRY_LOG']); d=json.loads(p.read_text()); d['args']=sys.argv[1:]; p.write_text(json.dumps(d))") ''') curl.chmod(0o755) diff --git a/tests/test_install_wizard.py b/tests/test_install_wizard.py new file mode 100644 index 0000000..8cb0d92 --- /dev/null +++ b/tests/test_install_wizard.py @@ -0,0 +1,140 @@ +"""Exercise the public installer wizard through a controlling pseudo-terminal.""" +import errno +import fcntl +import json +import os +import pathlib +import pty +import subprocess +import tempfile +import termios +import unittest + + +ROOT = pathlib.Path(__file__).parents[1] + + +class InstallWizardTests(unittest.TestCase): + def test_no_terminal_explains_how_to_run(self): + result = subprocess.run(['bash', str(ROOT / 'install.sh')], capture_output=True, + text=True, preexec_fn=os.setsid) + self.assertNotEqual(result.returncode, 0) + self.assertIn('interactive installer needs a terminal', result.stderr) + + def run_wizard(self, answers, installed=False, damaged=False): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + state = root / 'state' + releases = root / 'releases' + legacy = root / 'legacy-current' + state.mkdir() + releases.mkdir() + if installed or damaged: + current = releases / 'v0.8.3-test' + current.mkdir() + if installed: + (current / 'VERSION').write_text('0.8.3-test') + (current / 'uninstall.sh').write_text('#!/bin/sh\nexit 0\n') + (state / 'current').symlink_to('releases/v0.8.3-test') + # The real state layout keeps releases under the state root. + (state / 'releases').symlink_to(releases) + script = root / 'install.sh' + script.write_text((ROOT / 'install.sh').read_text() + .replace('/home/.framely/state', str(state)) + .replace('/home/.framely/releases', str(releases)) + .replace('/home/.framely/repair.sh', str(root / 'repair.sh')) + .replace('/var/lib/framely/current', str(legacy))) + curl = root / 'curl' + curl.write_text('''#!/usr/bin/env python3 +import json, os, pathlib, sys +args=sys.argv[1:] +with open(os.environ['WIZARD_LOG'], 'a') as out: + out.write(json.dumps({'url':args[-1], 'proxy':os.environ.get('FRAMELY_GITHUB_PROXY',''), + 'fallback':os.environ.get('FRAMELY_GITHUB_AUTO_FALLBACK','')})+'\\n') +pathlib.Path(args[args.index('--output')+1]).write_text(''' + repr(''' +import json, os, pathlib, sys +args=sys.argv[1:] +with open(os.environ['WIZARD_LOG'], 'a') as out: + out.write(json.dumps({'engine_args':args, 'proxy':os.environ.get('FRAMELY_GITHUB_PROXY',''), + 'fallback':os.environ.get('FRAMELY_GITHUB_AUTO_FALLBACK','')})+'\\n') +if '--resolve-release' in args: + print(json.dumps({'tag':'v0.9.0-preview.10','size':1048576})) +''') + ''') +''') + curl.chmod(0o755) + sudo = root / 'sudo' + sudo.write_text('#!/bin/sh\nexec "$@"\n') + sudo.chmod(0o755) + log = root / 'calls' + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], + WIZARD_LOG=str(log), FRAMELY_REPOSITORY='TestOwner/framely') + env.pop('FRAMELY_GITHUB_PROXY', None) + master, slave = pty.openpty() + + def controlling_terminal(): + os.setsid() + fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + + try: + process = subprocess.Popen(['bash', str(script)], stdin=slave, stdout=slave, + stderr=slave, env=env, preexec_fn=controlling_terminal) + os.close(slave) + os.write(master, answers.encode()) + code = process.wait(timeout=10) + output = bytearray() + while True: + try: + block = os.read(master, 65536) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not block: + break + output.extend(block) + finally: + os.close(master) + calls = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + return code, output.decode(errors='replace'), calls + + def test_fresh_install_with_mirror_and_confirmation(self): + code, output, calls = self.run_wizard('1\n2\n2\n\n1\n') + self.assertEqual(code, 0, output) + self.assertIn('Target release: v0.9.0-preview.10', output) + self.assertEqual(len([call for call in calls if 'url' in call]), 2) + self.assertTrue(all(call['url'].startswith('https://gh-proxy.com/') for call in calls if 'url' in call)) + self.assertIn('install', calls[-1]['engine_args']) + self.assertIn('--yes', calls[-1]['engine_args']) + + def test_existing_install_direct_and_back_navigation(self): + code, output, calls = self.run_wizard('1\nb\n2\nb\n1\n1\nb\nb\nb\nq\n', installed=True) + self.assertEqual(code, 0, output) + self.assertEqual(calls, []) + self.assertIn('Update Framely', output) + self.assertIn('Uninstall Framely', output) + code, output, calls = self.run_wizard('1\n1\n1\n1\n', installed=True) + self.assertEqual(code, 0, output) + self.assertIn('update', calls[-1]['engine_args']) + self.assertTrue(all(call['fallback'] == '0' for call in calls)) + self.assertTrue(all(call['proxy'] == '' for call in calls)) + + def test_damaged_state_stops_before_network(self): + code, output, calls = self.run_wizard('', damaged=True) + self.assertNotEqual(code, 0) + self.assertIn('Installation records are incomplete', output) + self.assertEqual(calls, []) + + def test_uninstall_uses_installed_script_without_network(self): + code, output, calls = self.run_wizard('2\n1\n', installed=True) + self.assertEqual(code, 0, output) + self.assertEqual(calls, []) + + def test_bad_mirror_address_can_be_corrected(self): + code, output, calls = self.run_wizard('1\n1\n2\nhttp://invalid\nb\n1\n1\n') + self.assertEqual(code, 0, output) + self.assertIn('Invalid address', output) + self.assertTrue(all(call['fallback'] == '0' for call in calls)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/bootstrap.py b/tools/bootstrap.py index 61574c3..9cb5a5f 100644 --- a/tools/bootstrap.py +++ b/tools/bootstrap.py @@ -1,6 +1,8 @@ #!/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 @@ -16,12 +18,24 @@ 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): @@ -31,6 +45,103 @@ def emit_progress(step, completed=None, total=None): 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': @@ -78,6 +189,14 @@ def configured_github_proxy(): 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 @@ -118,7 +237,7 @@ def github_attempt(url, operation, enabled=None): 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 - print('GitHub connection failed; trying gh-proxy.com once', file=sys.stderr, flush=True) + log_failure(f'GitHub 请求失败({network_diagnostic(primary)}),正在尝试 gh-proxy.com') try: return operation(True) except Exception as secondary: @@ -281,6 +400,76 @@ def select_package(assets, action): 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(): @@ -334,7 +523,8 @@ def root_operation(args): 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) + with installation_step('校验解压文件', '重新运行安装命令;若仍失败,请保存上方校验输出供开发者排查。'): + subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=base, check=True) else: base = current_release() report('configure') @@ -346,8 +536,9 @@ def root_operation(args): 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')) + 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') @@ -370,7 +561,7 @@ def root_operation(args): elif args.action == 'rollback': subprocess.run(['bash', str(base / 'rollback.sh')], check=True) report('activate') - print('Framely operation completed.', flush=True) + log_event('设备端操作完成') finally: if work: shutil.rmtree(work) @@ -387,13 +578,27 @@ def main(argv=None): 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 @@ -407,6 +612,8 @@ def main(argv=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) @@ -416,19 +623,31 @@ def main(argv=None): 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) + 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' - 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) + 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') - verify_archive(args.archive, args.checksums) + 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) @@ -438,13 +657,29 @@ def main(argv=None): 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. - subprocess.run(command, check=True) + 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, subprocess.CalledProcessError) as error: - print('Framely: ' + str(error), file=sys.stderr) + 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)