Compare commits

...
8 Commits
17 changed files with 991 additions and 59 deletions

No files matched your search

+6 -1
View File
@@ -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
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.8.3"
version = "0.8.4"
edition = "2021"
license = "AGPL-3.0-only"
+9 -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`:
@@ -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:
+14 -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 下载。例如:
@@ -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
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,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
View File
@@ -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,
+12
View File
@@ -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 {
+18
View File
@@ -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());
}
}
+124
View File
@@ -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()
+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)
+165
View File
@@ -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
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,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)
+2 -2
View File
@@ -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}",
+2 -2
View File
@@ -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
View File
@@ -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>;
}