mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-24 01:01:45 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2de8dc6d | ||
|
|
5886198f4b | ||
|
|
f6e7cc4674 | ||
|
|
58fcbb851e | ||
|
|
7f12cdb7d7 | ||
|
|
da0bb44a98 | ||
|
|
4e6e538b23 | ||
|
|
4b06a59995 |
No files matched your search
@@ -135,7 +135,12 @@ jobs:
|
||||
run: |
|
||||
bash tools/package-release.sh target/cef
|
||||
mkdir -p release-assets
|
||||
archive=$(find release -maxdepth 1 -name 'framely-*-linux-arm64.tar.gz' ! -name '*-offline-linux-arm64.tar.gz' ! -name 'framely-cef-*' -print -quit)
|
||||
mapfile -t core_archives < <(find release -maxdepth 1 -type f -name 'framely-[0-9]*-linux-arm64.tar.gz' ! -name '*-offline-linux-arm64.tar.gz' -print)
|
||||
if [[ ${#core_archives[@]} != 1 ]]; then
|
||||
printf 'Expected exactly one Framely core archive, found %s\n' "${#core_archives[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
archive=${core_archives[0]}
|
||||
cp release/*.tar.gz release/framely-cef.json release/framely-q2f.json release-assets/
|
||||
cp tools/bootstrap.py release-assets/bootstrap.py
|
||||
cp install.sh release-assets/install.sh
|
||||
|
||||
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -58,6 +60,8 @@ python3 bootstrap.py install --archive "./framely-<version-and-build>-offline-li
|
||||
|
||||
Angle brackets are placeholders; substitute the actual filename. The engine verifies, stages and extracts under `/home`, then checks internal hashes, avoiding the device's limited `/tmp`.
|
||||
|
||||
To walk through the interactive installer with a staged archive, place `install.sh`, `bootstrap.py`, the archive, and its `SHA256SUMS` in one directory, then run `bash install.sh --local-bundle /absolute/path/to/directory`. The wizard still checks the selected channel's latest release tag; the local archive must belong to that tag. After confirmation, the normal engine verifies and installs the complete archive.
|
||||
|
||||
## Release packages and CEF
|
||||
|
||||
Each device release provides three archives:
|
||||
@@ -109,6 +113,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:
|
||||
|
||||
@@ -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 下载。例如:
|
||||
|
||||
@@ -58,6 +66,8 @@ python3 bootstrap.py install --archive "./framely-<版本及构建号>-offline-l
|
||||
|
||||
尖括号是占位符,执行前替换为真实文件名。引擎校验后在 `/home` 暂存、解压并检查包内哈希,避免耗尽设备的 `/tmp`。
|
||||
|
||||
若要用已暂存的完整安装包测试交互向导,将 `install.sh`、`bootstrap.py`、安装包和对应的 `SHA256SUMS` 放在同一目录,运行 `bash install.sh --local-bundle /目录的绝对路径`。向导仍会查询所选渠道的最新标签,本地包必须属于该标签;最终确认后由正常安装引擎校验并安装整包。
|
||||
|
||||
## 发行包与 CEF
|
||||
|
||||
每个本体 Release 提供三种压缩包:
|
||||
@@ -109,6 +119,8 @@ sudo journalctl -u framely -u framely-session --no-pager -n 100
|
||||
|
||||
修复会使用已保留的发行恢复账号及服务;UID 冲突时停止。它不能找回已删除的 `/home` 数据,SteamOS 更新后的恢复仍需实机验证。
|
||||
|
||||
命令行安装日志使用同一个追踪编号记录开始时间、版本、发行包、下载线路、阶段耗时与安全错误码。下载时会持续显示已下载量;失败时会给出原因和下一步。反馈安装问题时,请提供追踪编号、出错阶段和相关输出;发送前仍应检查并移除终端中其他程序可能打印的密码、Token 或个人信息。安装器会隐藏自定义代理地址及带参数的下载 URL。
|
||||
|
||||
## 卸载
|
||||
|
||||
桌面安装器“安装与维护”提供卸载,也可直接执行:
|
||||
|
||||
+231
-26
@@ -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,234 @@ trap 'rm -rf -- "$work"' EXIT
|
||||
release_path=latest/download
|
||||
args=("$@")
|
||||
github_proxy=${FRAMELY_GITHUB_PROXY:-}
|
||||
local_bundle=''
|
||||
if (( $# == 2 )) && [[ $1 == --local-bundle ]]; then
|
||||
local_bundle=$2
|
||||
[[ $local_bundle == /* && -f $local_bundle/bootstrap.py && -f $local_bundle/SHA256SUMS ]] || {
|
||||
echo 'Framely: --local-bundle needs an absolute directory containing bootstrap.py and SHA256SUMS.' >&2
|
||||
exit 1
|
||||
}
|
||||
args=()
|
||||
fi
|
||||
fetch_engine() {
|
||||
if [[ -n $local_bundle ]]; then
|
||||
cp -- "$local_bundle/bootstrap.py" "$work/bootstrap.py"
|
||||
return
|
||||
fi
|
||||
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 (( ${#args[@]} == 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
|
||||
[[ -z $local_bundle ]] || echo ' Package source: local bundle' >&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")
|
||||
if [[ -n $local_bundle ]]; then
|
||||
local_archives=()
|
||||
for candidate in "$local_bundle"/framely-*-linux-arm64.tar.gz; do
|
||||
[[ -f $candidate ]] || continue
|
||||
if [[ $wizard_action == install && $candidate == *-offline-linux-arm64.tar.gz ]] ||
|
||||
[[ $wizard_action == update && $candidate != *-offline-linux-arm64.tar.gz ]]; then
|
||||
local_archives+=("$candidate")
|
||||
fi
|
||||
done
|
||||
if (( ${#local_archives[@]} != 1 )); then
|
||||
echo ' The local bundle must contain exactly one archive for this operation.' >&3
|
||||
step=lookup_error
|
||||
continue
|
||||
fi
|
||||
local_archive=${local_archives[0]}
|
||||
local_name=${local_archive##*/}
|
||||
local_suffix='-linux-arm64.tar.gz'
|
||||
[[ $wizard_action != install ]] || local_suffix='-offline-linux-arm64.tar.gz'
|
||||
if [[ ! $local_name =~ ^framely-${selected_tag#v}-[a-f0-9]{12}${local_suffix//./\.}$ ]]; then
|
||||
echo ' The local archive does not match the selected release tag.' >&3
|
||||
step=lookup_error
|
||||
continue
|
||||
fi
|
||||
selected_size=$(stat -c %s -- "$local_archive")
|
||||
fi
|
||||
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
|
||||
if [[ -n $local_bundle ]]; then
|
||||
echo ' Package source: Local staged archive (SHA256 checked before installation)' >&3
|
||||
echo " Release lookup: $([[ -n $github_proxy ]] && echo 'GitHub mirror (address hidden)' || echo 'Direct from GitHub')" >&3
|
||||
else
|
||||
echo " Download route: $([[ -n $github_proxy ]] && echo 'GitHub mirror (address hidden)' || echo 'Direct from GitHub')" >&3
|
||||
fi
|
||||
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"); [[ -z $local_bundle ]] || args+=(--archive "$local_archive" --checksums "$local_bundle/SHA256SUMS"); 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 +268,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[@]}"
|
||||
+92
@@ -3,6 +3,29 @@ use std::time::Duration;
|
||||
pub(crate) mod github_fallback;
|
||||
|
||||
pub(crate) fn with_github_fallback<T>(initial: &str, headers: &[(&str, &str)], proxy: &crate::model::ProxySettings, mut attempt: impl FnMut(&crate::model::ProxySettings) -> Result<T>) -> Result<T> {
|
||||
if proxy.github_enabled {
|
||||
let mut selected = proxy.clone();
|
||||
selected.github_auto_fallback = false;
|
||||
return match attempt(&selected) {
|
||||
Err(first)
|
||||
if proxy.github_auto_fallback
|
||||
&& github_fallback::eligible(initial, headers)
|
||||
&& github_fallback::mirror_retryable(&first) =>
|
||||
{
|
||||
let mut original = selected;
|
||||
original.github_enabled = false;
|
||||
eprintln!("Configured GitHub mirror failed; trying the original URL once");
|
||||
attempt(&original).map_err(|second| {
|
||||
anyhow::anyhow!(
|
||||
"Configured GitHub mirror failed: {}; original GitHub route failed: {}",
|
||||
github_fallback::diagnostic(&first),
|
||||
github_fallback::diagnostic(&second)
|
||||
)
|
||||
})
|
||||
}
|
||||
result => result,
|
||||
};
|
||||
}
|
||||
github_fallback::run(proxy.github_auto_fallback, initial, headers, |fallback| {
|
||||
let mut route=proxy.clone();route.github_auto_fallback=false;
|
||||
if fallback {route.github_enabled=true;route.github=github_fallback::PREFIX.into();route.github_fallback_attempt=true;}
|
||||
@@ -468,6 +491,75 @@ mod proxy_tests {
|
||||
use super::*;
|
||||
use crate::model::ProxySettings;
|
||||
#[test]
|
||||
fn configured_github_mirror_retries_rate_limit_at_original_url_once() {
|
||||
let source = "https://api.github.com/repos/SteamFramelyHomebrew/framely/releases?per_page=100&page=1";
|
||||
let proxy = ProxySettings {
|
||||
github_enabled: true,
|
||||
github: "https://gh-proxy.com".into(),
|
||||
github_auto_fallback: true,
|
||||
..Default::default()
|
||||
};
|
||||
for (code, headers, retry) in [
|
||||
(403, "X-RateLimit-Remaining: 0\r\n", true),
|
||||
(429, "", true),
|
||||
(403, "", false),
|
||||
(503, "", false),
|
||||
] {
|
||||
let mut routes = Vec::new();
|
||||
let result = with_github_fallback(source, &[], &proxy, |route| {
|
||||
routes.push(routed_url(&url::Url::parse(source).unwrap(), route));
|
||||
if !route.github_enabled {
|
||||
return Ok(7);
|
||||
}
|
||||
let response: ureq::Response =
|
||||
format!("HTTP/1.1 {code} Test\r\n{headers}\r\n").parse().unwrap();
|
||||
Err(ureq::Error::Status(code, response).into())
|
||||
});
|
||||
assert_eq!(result.is_ok(), retry, "HTTP {code} with {headers:?}");
|
||||
assert_eq!(routes[0], format!("https://gh-proxy.com/{source}"));
|
||||
assert_eq!(routes.len(), if retry { 2 } else { 1 });
|
||||
if retry {
|
||||
assert_eq!(routes[1], source);
|
||||
}
|
||||
}
|
||||
let mut calls = 0;
|
||||
let mut disabled = proxy.clone();
|
||||
disabled.github_auto_fallback = false;
|
||||
assert!(with_github_fallback::<()>(source, &[], &disabled, |_| {
|
||||
calls += 1;
|
||||
let response: ureq::Response =
|
||||
"HTTP/1.1 403 Test\r\nX-RateLimit-Remaining: 0\r\n\r\n".parse().unwrap();
|
||||
Err(ureq::Error::Status(403, response).into())
|
||||
}).is_err());
|
||||
assert_eq!(calls, 1);
|
||||
}
|
||||
#[test]
|
||||
fn configured_github_mirror_retries_network_error_only_for_public_requests() {
|
||||
let proxy = ProxySettings {
|
||||
github_enabled: true,
|
||||
github: "https://gh-proxy.com".into(),
|
||||
github_auto_fallback: true,
|
||||
..Default::default()
|
||||
};
|
||||
for (source, headers, retry) in [
|
||||
("https://api.github.com/repos/a/b/releases?page=1", vec![], true),
|
||||
("https://api.github.com/repos/a/b/releases?token=secret", vec![], false),
|
||||
("https://api.github.com/repos/a/b/releases?page=1", vec![("Authorization", "secret")], false),
|
||||
] {
|
||||
let mut calls = 0;
|
||||
let result = with_github_fallback(source, &headers, &proxy, |route| {
|
||||
calls += 1;
|
||||
if route.github_enabled {
|
||||
Err(std::io::Error::from(std::io::ErrorKind::ConnectionReset).into())
|
||||
} else {
|
||||
Ok(7)
|
||||
}
|
||||
});
|
||||
assert_eq!(result.is_ok(), retry);
|
||||
assert_eq!(calls, if retry { 2 } else { 1 });
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn proxy_routing_and_validation() {
|
||||
let proxy = ProxySettings {
|
||||
github_auto_fallback: true,
|
||||
|
||||
@@ -154,6 +154,18 @@ pub fn retryable(error: &anyhow::Error) -> bool {
|
||||
})
|
||||
})
|
||||
}
|
||||
// A configured GitHub mirror can exhaust a shared API quota. In that case,
|
||||
// retry the original public URL once, but do not bypass a deliberate HTTP
|
||||
// error such as 404 or 500 from the selected mirror.
|
||||
pub fn mirror_retryable(error: &anyhow::Error) -> bool {
|
||||
retryable(error)
|
||||
&& !error.chain().any(|cause| {
|
||||
matches!(
|
||||
cause.downcast_ref::<ureq::Error>(),
|
||||
Some(ureq::Error::Status(code, _)) if !matches!(code, 403 | 429)
|
||||
)
|
||||
})
|
||||
}
|
||||
// Error display from HTTP clients can contain signed redirect URLs. Do not
|
||||
// surface those URLs when reporting both routes' errors.
|
||||
pub fn diagnostic(error: &anyhow::Error) -> String {
|
||||
|
||||
@@ -49,6 +49,8 @@ pub fn verify_descriptor(bytes: &[u8]) -> Result<Release> {
|
||||
.all(|c| c.is_ascii_alphanumeric() || b".+-".contains(&c)),
|
||||
"无效版本号"
|
||||
);
|
||||
descriptor_version(&release.version)
|
||||
.with_context(|| format!("更新清单版本号无效:{}", release.version))?;
|
||||
crate::model::validate_url(&release.url, false)?;
|
||||
ensure!(
|
||||
hex::decode(&release.sha256)?.len() == 32
|
||||
@@ -886,4 +888,20 @@ mod tests {
|
||||
value["sha256"] = json!("invalid");
|
||||
assert!(verify_descriptor(&serde_json::to_vec(&value).unwrap()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn q2f_archive_cannot_be_used_as_core_update_manifest() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let archive = dir.path().join("framely-q2f-overport-1.2.3-linux-arm64.tar.gz");
|
||||
fs::write(&archive, b"q2f payload").unwrap();
|
||||
let output = dir.path().join("framely-release.json");
|
||||
let error = create_descriptor(
|
||||
&archive,
|
||||
"https://example.org/framely-q2f-overport-1.2.3-linux-arm64.tar.gz",
|
||||
&output,
|
||||
"Notes",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(format!("{error:#}").contains("更新清单版本号无效:q2f-overport-1.2.3"));
|
||||
assert!(!output.exists());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import pathlib
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
import errno
|
||||
import http.client
|
||||
import urllib.error
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
@@ -16,6 +19,92 @@ spec.loader.exec_module(bootstrap)
|
||||
|
||||
|
||||
class BootstrapTests(unittest.TestCase):
|
||||
def test_incomplete_metadata_has_actionable_error(self):
|
||||
with self.assertRaises(bootstrap.InstallationFailure) as failure:
|
||||
with bootstrap.installation_step('Read release metadata', 'Retry or choose another mirror.'):
|
||||
raise http.client.IncompleteRead(b'partial', 10)
|
||||
self.assertIn('Retry or choose another mirror.', str(failure.exception))
|
||||
|
||||
def test_interactive_release_lookup_retries_partial_transfer(self):
|
||||
releases = [{'tag_name': 'v0.8.3', 'draft': False, 'prerelease': False,
|
||||
'assets': [{'name': name, 'size': 1024} for name in (
|
||||
'bootstrap.py', 'SHA256SUMS',
|
||||
'framely-0.8.3-012345abcdef-linux-arm64.tar.gz')]}]
|
||||
with patch.object(bootstrap, 'read_metadata', side_effect=[
|
||||
http.client.IncompleteRead(b'partial', 10), json.dumps(releases).encode()]) as read, \
|
||||
patch.object(bootstrap.time, 'sleep'):
|
||||
self.assertEqual(bootstrap.resolve_release('owner/repo', 'stable', 'update')['tag'], 'v0.8.3')
|
||||
self.assertEqual(read.call_count, 2)
|
||||
|
||||
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_explicit_preview_uses_release_assets_without_api(self):
|
||||
tag = 'v0.8.4-preview.1'
|
||||
package = 'framely-0.8.4-preview.1-012345abcdef-linux-arm64.tar.gz'
|
||||
checksums = hashlib.sha256(b'archive').hexdigest() + ' ' + package + '\n'
|
||||
urls = []
|
||||
|
||||
def download(url, destination, _limit, _progress=None, expected_size=None):
|
||||
urls.append(url)
|
||||
pathlib.Path(destination).write_bytes(checksums.encode() if url.endswith('SHA256SUMS') else b'archive')
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp, \
|
||||
patch.dict(bootstrap.os.environ, {}, clear=True), \
|
||||
patch.object(bootstrap.pathlib.Path, 'home', return_value=pathlib.Path(tmp)), \
|
||||
patch.object(bootstrap.os, 'uname', return_value=SimpleNamespace(machine='aarch64')), \
|
||||
patch.object(bootstrap.os, 'geteuid', return_value=0), \
|
||||
patch.object(bootstrap, 'read_metadata', side_effect=AssertionError('API must not be used')), \
|
||||
patch.object(bootstrap, 'download', side_effect=download), \
|
||||
patch.object(bootstrap, 'root_operation') as operation:
|
||||
bootstrap.main(['update', '--version', tag, '--github-proxy', 'https://gh-proxy.com', '--yes'])
|
||||
operation.assert_called_once()
|
||||
self.assertEqual(urls, [
|
||||
f'https://github.com/{bootstrap.DEFAULT_REPO}/releases/download/{tag}/SHA256SUMS',
|
||||
f'https://github.com/{bootstrap.DEFAULT_REPO}/releases/download/{tag}/{package}',
|
||||
])
|
||||
|
||||
def test_explicit_tag_requires_one_matching_package(self):
|
||||
package = 'framely-0.8.4-preview.1-012345abcdef-linux-arm64.tar.gz'
|
||||
offline = 'framely-0.8.4-preview.1-012345abcdef-offline-linux-arm64.tar.gz'
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
sums = pathlib.Path(tmp) / 'SHA256SUMS'
|
||||
sums.write_text('0' * 64 + ' framely-0.8.3-012345abcdef-linux-arm64.tar.gz\n')
|
||||
with self.assertRaisesRegex(ValueError, 'one matching ARM64 package'):
|
||||
bootstrap.package_from_checksums(sums, 'v0.8.4-preview.1', 'update')
|
||||
sums.write_text('0' * 64 + ' ' + package + '\n' + '1' * 64 + ' ' + package + '\n')
|
||||
with self.assertRaisesRegex(ValueError, 'duplicate SHA256SUMS'):
|
||||
bootstrap.package_from_checksums(sums, 'v0.8.4-preview.1', 'update')
|
||||
sums.write_text('0' * 64 + ' ' + package + '\n' + '1' * 64 + ' ' + offline + '\n')
|
||||
self.assertEqual(bootstrap.package_from_checksums(sums, 'v0.8.4-preview.1', 'update'), package)
|
||||
self.assertEqual(bootstrap.package_from_checksums(sums, 'v0.8.4-preview.1', 'install'), offline)
|
||||
|
||||
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 +225,41 @@ 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())
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
progress = bootstrap.terminal_download_progress(None)
|
||||
progress(0)
|
||||
progress(2 * 1024**2)
|
||||
self.assertIn('总大小待确认', output.getvalue())
|
||||
self.assertNotIn('100.0%', output.getvalue())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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__':
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""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, local_bundle=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)))
|
||||
engine_source = '''
|
||||
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 = 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(engine_source) + ''')
|
||||
''')
|
||||
curl.chmod(0o755)
|
||||
bundle = root / 'bundle'
|
||||
if local_bundle:
|
||||
bundle.mkdir()
|
||||
(bundle / 'bootstrap.py').write_text(engine_source)
|
||||
(bundle / 'SHA256SUMS').write_text('test\n')
|
||||
suffix = '-linux-arm64.tar.gz' if installed else '-offline-linux-arm64.tar.gz'
|
||||
(bundle / ('framely-0.9.0-preview.10-012345abcdef' + suffix)).write_bytes(b'archive')
|
||||
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:
|
||||
command = ['bash', str(script)]
|
||||
if local_bundle:
|
||||
command += ['--local-bundle', str(bundle)]
|
||||
process = subprocess.Popen(command, 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))
|
||||
|
||||
def test_local_bundle_runs_full_interactive_flow_without_fetching_engine(self):
|
||||
code, output, calls = self.run_wizard('1\n2\n1\n1\n', installed=True, local_bundle=True)
|
||||
self.assertEqual(code, 0, output)
|
||||
self.assertIn('Package source: Local staged archive', output)
|
||||
self.assertFalse(any('url' in call for call in calls))
|
||||
self.assertIn('--archive', calls[-1]['engine_args'])
|
||||
self.assertIn('--checksums', calls[-1]['engine_args'])
|
||||
self.assertIn('update', calls[-1]['engine_args'])
|
||||
|
||||
def test_local_bundle_first_install_selects_offline_archive(self):
|
||||
code, output, calls = self.run_wizard('1\n1\n1\n1\n', local_bundle=True)
|
||||
self.assertEqual(code, 0, output)
|
||||
self.assertIn('-offline-linux-arm64.tar.gz', calls[-1]['engine_args'][calls[-1]['engine_args'].index('--archive') + 1])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+300
-18
@@ -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,107 @@ 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 != 0 and completed != total and now - last_reported < 5:
|
||||
return
|
||||
last_reported = now
|
||||
if total is None:
|
||||
log_event(f'下载进度 {completed / 1024**2:.1f} MiB(总大小待确认)')
|
||||
else:
|
||||
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, http.client.IncompleteRead) 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 +193,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 +241,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 +404,102 @@ def select_package(assets, action):
|
||||
return selected[0]
|
||||
|
||||
|
||||
def package_from_checksums(checksums, tag, action):
|
||||
"""Find a tagged release package without relying on the GitHub Releases API."""
|
||||
plain_tag = tag[1:] if tag.startswith('v') else tag
|
||||
package_name = re.compile(r'framely-' + re.escape(plain_tag)
|
||||
+ r'-[a-f0-9]{12}(?:-offline)?-linux-arm64\.tar\.gz')
|
||||
names = set()
|
||||
for line in pathlib.Path(checksums).read_text(encoding='utf-8').splitlines():
|
||||
match = re.fullmatch(r'([a-fA-F0-9]{64}) [ *]([a-zA-Z0-9._+-]+)', line)
|
||||
if not match or match[2] in names:
|
||||
raise ValueError('Invalid or duplicate SHA256SUMS entry')
|
||||
names.add(match[2])
|
||||
assets = {name: {} for name in names
|
||||
if package_name.fullmatch(name) or name == 'framely-cef.json'}
|
||||
assets['SHA256SUMS'] = {}
|
||||
return select_package(assets, action)
|
||||
|
||||
|
||||
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}'
|
||||
for attempt in range(3):
|
||||
try:
|
||||
metadata = read_metadata(url, 4 * 1024**2)
|
||||
break
|
||||
except Exception as error:
|
||||
if attempt == 2 or not retryable(error):
|
||||
raise
|
||||
log_failure('Release metadata transfer was interrupted; retrying the same route.')
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
releases = json.loads(metadata)
|
||||
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 +553,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 +566,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 +591,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 +608,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 +642,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)
|
||||
@@ -415,20 +652,48 @@ def main(argv=None):
|
||||
if bool(args.archive) != bool(args.checksums):
|
||||
parser.error('--archive and --checksums must be provided together')
|
||||
if not args.archive:
|
||||
endpoint = 'latest' if not args.version else 'tags/' + urllib.parse.quote(args.version, safe='')
|
||||
metadata = read_metadata(f'https://api.github.com/repos/{args.repo}/releases/{endpoint}', 4 * 1024**2)
|
||||
release = json.loads(metadata)
|
||||
assets = {asset['name']: asset for asset in release['assets']}
|
||||
package = select_package(assets, args.action)
|
||||
args.archive = pathlib.Path(folder) / package
|
||||
args.checksums = pathlib.Path(folder) / 'SHA256SUMS'
|
||||
print('Downloading ' + package, flush=True)
|
||||
download(assets[package]['browser_download_url'], args.archive, MAX_ARCHIVE)
|
||||
download(assets['SHA256SUMS']['browser_download_url'], args.checksums, 1024**2)
|
||||
if args.version:
|
||||
# A known tag has stable asset URLs. The proxy may serve release
|
||||
# downloads while rejecting GitHub's API with HTTP 403.
|
||||
base = (f'https://github.com/{args.repo}/releases/download/'
|
||||
+ urllib.parse.quote(args.version, safe=''))
|
||||
print('正在下载校验文件…', flush=True)
|
||||
with installation_step('下载校验文件', '确认版本已发布,检查网络或更换 GitHub 代理后重试。'):
|
||||
download(base + '/SHA256SUMS', args.checksums, 1024**2)
|
||||
with installation_step('读取发行版信息', '确认此版本包含适用的 ARM64 安装包。'):
|
||||
package = package_from_checksums(args.checksums, args.version, args.action)
|
||||
args.archive = pathlib.Path(folder) / package
|
||||
log_event(f'发行版:{args.version},文件={package},下载线路={download_route()}')
|
||||
with installation_step('下载安装包', '检查网络或更换 GitHub 代理后重新运行安装命令。'):
|
||||
download(base + '/' + package, args.archive, MAX_ARCHIVE,
|
||||
terminal_download_progress(None))
|
||||
log_event(f'安装包大小:{args.archive.stat().st_size / 1024**2:.1f} MiB')
|
||||
else:
|
||||
with installation_step('读取发行版信息', '确认版本已发布,检查 GitHub 或代理连接后重试。'):
|
||||
metadata = read_metadata(f'https://api.github.com/repos/{args.repo}/releases/latest', 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
|
||||
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 +703,30 @@ 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,
|
||||
http.client.IncompleteRead) 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)
|
||||
@@ -54,8 +54,8 @@
|
||||
"自动换行": "Word wrap",
|
||||
"正在加载代码编辑器…": "Loading code editor…",
|
||||
"代码编辑器加载失败,可以使用普通文本编辑。": "The code editor could not load. You can use the plain text editor.",
|
||||
"GitHub 连接失败时自动使用 gh-proxy.com": "Automatically try gh-proxy.com when GitHub cannot be reached",
|
||||
"公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。": "After a network failure, public GitHub requests go through a third-party proxy once. Download verification remains enabled.",
|
||||
"GitHub 连接失败时自动尝试备用线路": "Automatically try another GitHub route when the selected route fails",
|
||||
"直连失败时尝试 gh-proxy.com;已启用 GitHub 代理时,代理遇到限额或网络错误则尝试直连。只重试一次,下载仍会校验。": "If direct GitHub access fails, try gh-proxy.com. If a configured GitHub proxy hits a rate limit or network error, try direct access. Retry once; downloads remain verified.",
|
||||
"显示录制指示器": "Show recording indicator",
|
||||
"关闭后仅隐藏右上角录制提示,不会停止录制。": "Hides only the recording indicator in the upper-right corner; recording continues.",
|
||||
"安装失败,本次新建的容器已清理:{0}": "Installation failed; the newly created container was cleaned up: {0}",
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
"自动换行": "自动换行",
|
||||
"正在加载代码编辑器…": "正在加载代码编辑器…",
|
||||
"代码编辑器加载失败,可以使用普通文本编辑。": "代码编辑器加载失败,可以使用普通文本编辑。",
|
||||
"GitHub 连接失败时自动使用 gh-proxy.com": "GitHub 连接失败时自动使用 gh-proxy.com",
|
||||
"公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。": "公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。",
|
||||
"GitHub 连接失败时自动尝试备用线路": "GitHub 连接失败时自动尝试备用线路",
|
||||
"直连失败时尝试 gh-proxy.com;已启用 GitHub 代理时,代理遇到限额或网络错误则尝试直连。只重试一次,下载仍会校验。": "直连失败时尝试 gh-proxy.com;已启用 GitHub 代理时,代理遇到限额或网络错误则尝试直连。只重试一次,下载仍会校验。",
|
||||
"显示录制指示器": "显示录制指示器",
|
||||
"关闭后仅隐藏右上角录制提示,不会停止录制。": "关闭后仅隐藏右上角录制提示,不会停止录制。",
|
||||
"安装失败,本次新建的容器已清理:{0}": "安装失败,本次新建的容器已清理:{0}",
|
||||
|
||||
+1
-1
@@ -8,5 +8,5 @@ export function ProxySettings({config,refresh}:{config:ProxyConfig;refresh:()=>P
|
||||
useEffect(()=>{setHttp(config.http);setGithub(config.github);setHttpEnabled(config.httpEnabled??false);setGithubEnabled(config.githubEnabled??false);setGithubAutoFallback(config.githubAutoFallback??true);},[config.http,config.github,config.httpEnabled,config.githubEnabled,config.githubAutoFallback]);
|
||||
async function save(){setBusy(true);setError('');try{await api('proxy.save',{http:http.trim(),github:github.trim(),httpEnabled,githubEnabled,githubAutoFallback});await refresh();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
const unchanged=http===config.http&&github===config.github&&httpEnabled===(config.httpEnabled??false)&&githubEnabled===(config.githubEnabled??false)&&githubAutoFallback===(config.githubAutoFallback??true);
|
||||
return <section className="settings-card"><h2>{t("代理设置")}</h2><div className="proxy-fields"><div><SwitchRow checked={httpEnabled} label={t("启用 HTTP 代理")} disabled={busy} onChange={setHttpEnabled}/><label><input aria-label={t("HTTP 代理地址")} disabled={!httpEnabled} maxLength={2048} type="url" value={http} placeholder="http://192.168.1.2:7890" onChange={e=>setHttp(e.target.value)}/></label></div><div><SwitchRow checked={githubEnabled} label={t("启用 GitHub 代理")} disabled={busy} onChange={setGithubEnabled}/><label><input aria-label={t("GitHub 代理地址")} disabled={!githubEnabled} maxLength={2048} type="url" value={github} placeholder="https://gh-proxy.com" onChange={e=>setGithub(e.target.value)}/></label></div></div><SwitchRow checked={githubAutoFallback} label={t("GitHub 连接失败时自动使用 gh-proxy.com")} description={t("公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。")} disabled={busy} onChange={setGithubAutoFallback}/><div className="row"><button disabled={busy||unchanged||(httpEnabled&&!http.trim())||(githubEnabled&&!github.trim())} onClick={()=>void save()}>{busy?t("保存中…"):t("保存")}</button></div>{error&&<p className="error" role="alert">{error}</p>}</section>;
|
||||
return <section className="settings-card"><h2>{t("代理设置")}</h2><div className="proxy-fields"><div><SwitchRow checked={httpEnabled} label={t("启用 HTTP 代理")} disabled={busy} onChange={setHttpEnabled}/><label><input aria-label={t("HTTP 代理地址")} disabled={!httpEnabled} maxLength={2048} type="url" value={http} placeholder="http://192.168.1.2:7890" onChange={e=>setHttp(e.target.value)}/></label></div><div><SwitchRow checked={githubEnabled} label={t("启用 GitHub 代理")} disabled={busy} onChange={setGithubEnabled}/><label><input aria-label={t("GitHub 代理地址")} disabled={!githubEnabled} maxLength={2048} type="url" value={github} placeholder="https://gh-proxy.com" onChange={e=>setGithub(e.target.value)}/></label></div></div><SwitchRow checked={githubAutoFallback} label={t("GitHub 连接失败时自动尝试备用线路")} description={t("直连失败时尝试 gh-proxy.com;已启用 GitHub 代理时,代理遇到限额或网络错误则尝试直连。只重试一次,下载仍会校验。")} disabled={busy} onChange={setGithubAutoFallback}/><div className="row"><button disabled={busy||unchanged||(httpEnabled&&!http.trim())||(githubEnabled&&!github.trim())} onClick={()=>void save()}>{busy?t("保存中…"):t("保存")}</button></div>{error&&<p className="error" role="alert">{error}</p>}</section>;
|
||||
}
|
||||
Reference in new issue
Block a user