Add interactive installer wizard and clearer download diagnostics

This commit is contained in:
toorux committed 2026-10-20 20:53:22 +08:00
1 parent cea24afbc9
commit 4b06a59995
8 files changed
+669 -49

No files matched your search

+7 -3
View File
@@ -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:
+12 -2
View File
@@ -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。
## 卸载
桌面安装器“安装与维护”提供卸载,也可直接执行:
+187 -26
View File
@@ -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[@]}"
+59
View File
@@ -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()
+4 -1
View File
@@ -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__':
+9 -1
View File
@@ -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)
+140
View File
@@ -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()
+251 -16
View File
@@ -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)