From 4e6e538b232d83ef2c2520c4351a5cf52d96b9e2 Mon Sep 17 00:00:00 2001 From: toorux Date: Tue, 20 Oct 2026 21:30:17 +0800 Subject: [PATCH] Support interactive installation from a staged release bundle --- docs/user-guide/installation.md | 2 ++ docs/zh-CN/user-guide/installation.md | 2 ++ install.sh | 50 +++++++++++++++++++++++++-- tests/test_install_wizard.py | 47 +++++++++++++++++++------ 4 files changed, 87 insertions(+), 14 deletions(-) diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index 6c0250e..34fe4de 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -60,6 +60,8 @@ python3 bootstrap.py install --archive "./framely--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: diff --git a/docs/zh-CN/user-guide/installation.md b/docs/zh-CN/user-guide/installation.md index fc006c3..2e5d2f8 100644 --- a/docs/zh-CN/user-guide/installation.md +++ b/docs/zh-CN/user-guide/installation.md @@ -66,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 提供三种压缩包: diff --git a/install.sh b/install.sh index 5f0cdc3..92bc1ed 100644 --- a/install.sh +++ b/install.sh @@ -10,7 +10,20 @@ 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)." @@ -44,7 +57,7 @@ else fi fi } -if (( $# == 0 )); then +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 @@ -74,6 +87,7 @@ if (( $# == 0 )); then 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 @@ -167,6 +181,31 @@ if (( $# == 0 )); then 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 @@ -183,11 +222,16 @@ if (( $# == 0 )); then echo " Current version: $installed_version" >&3 echo " Target release: $selected_tag" >&3 echo " Package size: about $(( (selected_size + 1048575) / 1048576 )) MiB" >&3 - echo " Download route: $([[ -n $github_proxy ]] && echo 'GitHub mirror (address hidden)' || echo 'Direct from GitHub')" >&3 + 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"); release_path="download/$selected_tag"; break ;; + 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 ;; diff --git a/tests/test_install_wizard.py b/tests/test_install_wizard.py index 8cb0d92..52f364e 100644 --- a/tests/test_install_wizard.py +++ b/tests/test_install_wizard.py @@ -21,7 +21,7 @@ class InstallWizardTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertIn('interactive installer needs a terminal', result.stderr) - def run_wizard(self, answers, installed=False, damaged=False): + def run_wizard(self, answers, installed=False, damaged=False, local_bundle=False): with tempfile.TemporaryDirectory() as temp: root = pathlib.Path(temp) state = root / 'state' @@ -44,14 +44,7 @@ class InstallWizardTests(unittest.TestCase): .replace('/home/.framely/releases', str(releases)) .replace('/home/.framely/repair.sh', str(root / 'repair.sh')) .replace('/var/lib/framely/current', str(legacy))) - curl = root / 'curl' - curl.write_text('''#!/usr/bin/env python3 -import json, os, pathlib, sys -args=sys.argv[1:] -with open(os.environ['WIZARD_LOG'], 'a') as out: - out.write(json.dumps({'url':args[-1], 'proxy':os.environ.get('FRAMELY_GITHUB_PROXY',''), - 'fallback':os.environ.get('FRAMELY_GITHUB_AUTO_FALLBACK','')})+'\\n') -pathlib.Path(args[args.index('--output')+1]).write_text(''' + repr(''' + engine_source = ''' import json, os, pathlib, sys args=sys.argv[1:] with open(os.environ['WIZARD_LOG'], 'a') as out: @@ -59,9 +52,24 @@ with open(os.environ['WIZARD_LOG'], 'a') as out: '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) @@ -76,7 +84,10 @@ if '--resolve-release' in args: fcntl.ioctl(slave, termios.TIOCSCTTY, 0) try: - process = subprocess.Popen(['bash', str(script)], stdin=slave, stdout=slave, + 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()) @@ -135,6 +146,20 @@ if '--resolve-release' in args: 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()