Compare commits

...
20 Commits
Author SHA1 Message Date
Qiufeng 65b5d95937 fix: omit empty release note sections
TallyNote release / linux-x64 (push) Failing after 8m51s
2026-09-04 12:42:45 +08:00
Qiufeng 89a8edad88 fix: stabilize release test workers
TallyNote release / linux-x64 (push) Successful in 8m25s
2026-09-04 12:15:29 +08:00
Qiufeng 283c1d77b4 fix: generate detailed markdown release notes
TallyNote release / linux-x64 (push) Failing after 8m37s
2026-09-04 10:43:45 +08:00
Qiufeng f060f917a0 release: 1.1.26
TallyNote release / linux-x64 (push) Successful in 6m41s
2026-09-04 01:13:42 +08:00
Qiufeng 704740182a fix: hide stale update failures on status load 2026-09-04 01:08:46 +08:00
Qiufeng a61860fcb3 refactor: move update pipeline into dialog 2026-09-04 01:02:12 +08:00
Qiufeng 340d9b5245 feat: add lightweight application updates
TallyNote release / linux-x64 (push) Failing after 8m43s
2026-09-03 23:32:16 +08:00
Qiufeng 5d02fa5769 fix: simplify update metrics
TallyNote release / linux-x64 (push) Successful in 7m2s
2026-09-03 21:58:37 +08:00
Qiufeng 526b2df8ea feat: refine update center and release notes
TallyNote release / linux-x64 (push) Successful in 6m55s
2026-09-03 21:31:39 +08:00
Qiufeng 4f9629b089 fix: allow reverse proxy login
TallyNote release / linux-x64 (push) Successful in 6m56s
2026-09-03 15:27:10 +08:00
Qiufeng 36c2ed1361 fix: show completed download progress
TallyNote release / linux-x64 (push) Successful in 7m0s
2026-09-03 15:02:44 +08:00
Qiufeng 755b82d2e5 chore: align package version with v1.1.21
TallyNote release / linux-x64 (push) Successful in 7m27s
2026-09-03 14:55:38 +08:00
Qiufeng 0bdc812935 fix: support proxied origins and update progress
TallyNote release / linux-x64 (push) Failing after 11s
2026-09-03 14:54:30 +08:00
Qiufeng 2de08f1358 chore: bump release to 1.1.20
TallyNote release / linux-x64 (push) Successful in 6m45s
2026-09-03 13:42:57 +08:00
Qiufeng 91621df6c4 fix: recover stuck update queue
TallyNote release / linux-x64 (push) Successful in 6m34s
2026-09-03 12:39:44 +08:00
Qiufeng 0690fe298c fix: initialize optional origin port
TallyNote release / linux-x64 (push) Successful in 6m49s
2026-09-03 08:22:46 +08:00
Qiufeng 6a0d9e34dd fix: make admin wrapper fixture portable in CI
TallyNote release / linux-x64 (push) Failing after 3m6s
2026-09-03 08:10:52 +08:00
Qiufeng 77598ecc81 fix: make installer gate portable in root CI
TallyNote release / linux-x64 (push) Failing after 3m5s
2026-09-03 08:02:48 +08:00
Qiufeng 69a4b482ec fix: prevent stuck background updates
TallyNote release / linux-x64 (push) Failing after 2m51s
2026-09-03 07:51:35 +08:00
Qiufeng ee89e04aae fix: recover stuck background updates
TallyNote release / linux-x64 (push) Successful in 6m3s
2026-09-03 06:49:48 +08:00
29 changed files with 2859 additions and 167 deletions
+9 -1
View File
@@ -17,6 +17,10 @@ jobs:
steps:
- name: Checkout tag
uses: actions/checkout@v4
with:
# Release notes are derived from the previous version tag. A shallow
# checkout would leave only the synthetic release commit available.
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@@ -29,7 +33,11 @@ jobs:
test "$(node -p 'require("./package.json").version')" = "${GITHUB_REF_NAME#v}"
pnpm install --frozen-lockfile
pnpm check
pnpm test
# better-sqlite3 is a native addon; a single Vitest worker avoids a
# Node cleanup race observed on the hosted runner while preserving
# the complete test suite.
pnpm test -- --maxWorkers=1 --minWorkers=1
pnpm test:installer
- name: Build Linux release
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
- name: Create and publish Gitea Release
+1 -1
View File
@@ -140,7 +140,7 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra
卸载器会逐项输出停止、禁用和删除进度;每次 systemd/dbus 调用默认最多等待 30 秒,避免终端无限无响应。可通过 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整超时时间。
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。
公网反代推荐使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。反代只需把域名转发到 TallyNote 端口并保留 `Host`、`X-Forwarded-Proto`;应用不会因为代理缺少或改写浏览器 `Origin` 而拦截登录。已认证写请求仍使用会话 Cookie 与 CSRF 令牌保护。
### 构建发布包
+4 -2
View File
@@ -12,6 +12,8 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`
2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。
3. 如果提供 Ed25519 私钥则生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和可选签名。
发布脚本会根据当前 tag 与上一个版本 tag 之间的真实 Git 提交自动生成 Release 正文,按“新增功能、问题修复、优化与重构、文档与测试”分类,并以 Markdown 写入 Gitea。Gitea 页面会渲染这些标题和列表;更新中心读取同一份正文后再进行安全的 Markdown 子集渲染,不会显示 Markdown 源代码。旧版本曾使用单行占位正文 `TallyNote <版本>`,新版本发布时不会再使用该占位内容。
在仓库的 Actions secrets 配置:
- `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。
@@ -28,7 +30,7 @@ GITEA_TOKEN=... \
./scripts/publish-gitea-release.sh v1.1.2 ./release
```
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。构建脚本会同时生成完整安装包和轻量更新包:`tallynote-1.1.2-linux-x64-glibc.tar.gz` 用于首次安装,`tallynote-1.1.2-linux-x64-glibc.update-<锁文件 SHA256>.tar.gz` 仅用于复用现有运行时的后台更新。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
## curl 安装
@@ -104,7 +106,7 @@ sudo /usr/local/sbin/tallynote-uninstall
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;当前安装如果存在匹配的锁文件指纹,更新器会自动选择轻量 `update-<锁文件 SHA256>` 资产,仅下载 `dist`、迁移和版本元数据,并复用当前版本的 Node 与生产依赖;如果运行时指纹不匹配或轻量包不可用,则自动选择完整安装包。root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚:
+6 -3
View File
@@ -1012,7 +1012,10 @@ validate_listen_port() {
}
validate_public_origin() {
local value=$1 authority host path_part origin_port suffix
# Keep the optional origin port defined under `set -u`. Origins without an
# explicit port (for example https://example.test) are valid and should
# proceed to the default-port handling below.
local value=$1 authority host path_part origin_port='' suffix
case "$value" in
http://*|https://*) ;;
*) die '公开访问地址必须是 http:// 或 https:// 地址' ;;
@@ -1074,7 +1077,7 @@ validate_existing_env() {
mode_bits=$(stat_mode_bits "$file")
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
local key key_count
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOWED_ORIGINS TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do
key_count=$(env_key_count "$file" "$key")
[[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key"
done
@@ -1395,7 +1398,7 @@ main() {
unit_tmp=$(mktemp -d)
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.service" > "$unit_tmp/tallynote.service"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/var/lib/tallynote-backups#$(dirname -- "$DATA_DIR")/tallynote-backups#g" "$release_dir/systemd/tallynote-update.service" > "$unit_tmp/tallynote-update.service"
sed "s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path"
sed "s#/opt/tallynote#$PREFIX#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/bin/tallynote-admin-init" > "$unit_tmp/tallynote-admin-init"
install -o root -g root -m 644 "$unit_tmp/tallynote.service" /etc/systemd/system/tallynote.service
install -o root -g root -m 644 "$unit_tmp/tallynote-update.service" /etc/systemd/system/tallynote-update.service
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE update_jobs ADD COLUMN downloaded_bytes INTEGER;
ALTER TABLE update_jobs ADD COLUMN download_started_at INTEGER;
ALTER TABLE update_jobs ADD COLUMN download_speed_bps INTEGER;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.1.13",
"version": "1.1.29",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
+25 -1
View File
@@ -27,7 +27,11 @@ pnpm build
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd" "$stage/runtime/bin"
cp -a dist/. "$stage/dist/"
# Copy only the production build outputs. In particular, do not carry a
# stale dist/web-next directory from a previous local preview build.
cp -a dist/server "$stage/dist/"
cp -a dist/shared "$stage/dist/"
cp -a dist/web "$stage/dist/"
cp -a migrations/. "$stage/migrations/"
cp package.json pnpm-lock.yaml "$stage/"
cp -a bin/. "$stage/bin/"
@@ -46,6 +50,26 @@ find "$stage" -type l -delete
mkdir -p "$OUT_DIR"
archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz"
tar -C "$stage" -czf "$archive" --owner=0 --group=0 --numeric-owner .
# The application-only asset is used by the online updater. It deliberately
# excludes the stable runtime (Node and production dependencies), systemd
# helpers and installer files; the updater overlays it on the currently
# installed, already-validated runtime before atomically switching releases.
app_stage=$(mktemp -d)
trap 'rm -rf "$stage" "$app_stage"' EXIT
mkdir -p "$app_stage/dist" "$app_stage/migrations" "$app_stage/bin" "$app_stage/scripts" "$app_stage/systemd"
cp -a "$stage/dist/server" "$app_stage/dist/"
cp -a "$stage/dist/shared" "$app_stage/dist/"
cp -a "$stage/dist/web" "$app_stage/dist/"
cp -a "$stage/migrations/." "$app_stage/migrations/"
cp -a "$stage/bin/." "$app_stage/bin/"
cp -a "$stage/scripts/." "$app_stage/scripts/"
cp -a "$stage/systemd/." "$app_stage/systemd/"
cp "$stage/package.json" "$app_stage/package.json"
cp "$stage/uninstall.sh" "$app_stage/uninstall.sh"
runtime_hash=$(sha256sum pnpm-lock.yaml | awk '{print $1}')
app_archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.update-${runtime_hash}.tar.gz"
tar -C "$app_stage" -czf "$app_archive" --owner=0 --group=0 --numeric-owner .
# Keep the sidecar useful when a caller builds more than one architecture into
# the same directory. The publishing script recomputes this list immediately
# before signing, so stale or hand-edited entries can never reach a Release.
+104 -3
View File
@@ -24,6 +24,7 @@ DRY_RUN=0
AUTH_CONFIG=''
SUMS_TMP=''
SIG_TMP=''
RELEASE_NOTES_TMP=''
SIGNATURE_GENERATED=0
usage() {
@@ -43,6 +44,81 @@ EOF
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
log() { printf 'release publisher: %s\n' "$*"; }
generate_release_notes() {
local current=${TAG#v} previous='' subject kind line count=0
local -a commits
commits=()
# A workflow checks out the tag with history. Prefer an explicitly supplied
# notes file for mirrors, then derive notes from the immutable tag range.
if [[ -n "${TALLYNOTE_RELEASE_NOTES_FILE:-}" && -f "$TALLYNOTE_RELEASE_NOTES_FILE" ]]; then
# Read at most the API's bounded notes size without a pipe that can turn a
# deliberately truncated input into a SIGPIPE failure under pipefail.
LC_ALL=C awk 'BEGIN { remaining = 65536 } { if (remaining <= 0) exit; line=$0; gsub(/[[:cntrl:]]/, "", line); bytes=length(line)+1; if (bytes > remaining) { print substr(line, 1, remaining); exit } print line; remaining-=bytes }' "$TALLYNOTE_RELEASE_NOTES_FILE"
return
fi
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
while IFS= read -r line; do
[[ -n "$line" ]] || continue
[[ "$line" == "v${current}" ]] && continue
previous="$line"
break
done < <(git tag --sort=-version:refname --list 'v*')
if [[ -n "$previous" && "$previous" != "v${current}" ]]; then
while IFS= read -r line; do
[[ -n "$line" ]] && commits+=("$line")
done < <(git log --format='%s' "${previous}..${TAG}")
else
while IFS= read -r line; do
[[ -n "$line" ]] && commits+=("$line")
done < <(git log -n 30 --format='%s' "$TAG")
fi
fi
printf '# TallyNote %s\n\n' "$current"
if [[ -n "$previous" ]]; then
printf '> 从 `%s` 到 `%s` 的变更\n\n' "$previous" "v${current}"
else
printf '> 本版本变更\n\n'
fi
local -a features fixes improvements docs other
features=(); fixes=(); improvements=(); docs=(); other=()
for subject in "${commits[@]-}"; do
# Do not expose merge noise or the synthetic release commit in user notes.
[[ "$subject" != Merge\ * && "$subject" != release:* ]] || continue
kind=${subject%%:*}
if [[ "$subject" == *:* ]]; then subject=${subject#*: }; fi
subject=${subject# }
[[ -n "$subject" ]] || continue
case "$kind" in
feat|feature) features+=("$subject") ;;
fix|bugfix) fixes+=("$subject") ;;
refactor|perf|style|improvement) improvements+=("$subject") ;;
docs|doc|test|tests) docs+=("$subject") ;;
*) other+=("$subject") ;;
esac
done
print_group() {
local title=$1; shift
local item
(($# > 0)) || return 0
printf '## %s\n\n' "$title"
for item in "$@"; do printf -- '- %s\n' "$item"; done
printf '\n'
}
((${#features[@]})) && print_group '新增功能' "${features[@]}"
((${#fixes[@]})) && print_group '问题修复' "${fixes[@]}"
((${#improvements[@]})) && print_group '优化与重构' "${improvements[@]}"
((${#docs[@]})) && print_group '文档与测试' "${docs[@]}"
((${#other[@]})) && print_group '其他变更' "${other[@]}"
if (( ${#features[@]} + ${#fixes[@]} + ${#improvements[@]} + ${#docs[@]} + ${#other[@]} == 0 )); then
printf '本版本包含内部维护更新。\n'
fi
}
validate_semver() {
local value=$1 prerelease part
[[ "$value" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || return 1
@@ -128,7 +204,8 @@ fi
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
assets=()
full_assets=()
update_assets=()
for file in "$ASSET_DIR"/*.tar.gz; do
[[ -f "$file" && ! -L "$file" ]] || continue
name=$(basename -- "$file")
@@ -136,9 +213,16 @@ for file in "$ASSET_DIR"/*.tar.gz; do
asset_version=${name#tallynote-}
asset_version=${asset_version%%-linux-*}
[[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name"
assets+=("$file")
if [[ "$name" =~ \.update-[a-f0-9]{64}\.tar\.gz$ ]]; then
update_assets+=("$file")
else
full_assets+=("$file")
fi
done
assets=("${full_assets[@]}")
if ((${#update_assets[@]})); then assets+=("${update_assets[@]}"); fi
(( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found'
(( ${#full_assets[@]} > 0 )) || die 'no full release asset found'
SUMS_FILE="$ASSET_DIR/SHA256SUMS"
SIG_FILE="$ASSET_DIR/SHA256SUMS.sig"
@@ -161,6 +245,7 @@ cleanup() {
if [[ -n "$AUTH_CONFIG" ]]; then rm -f -- "$AUTH_CONFIG"; fi
if [[ -n "$SUMS_TMP" ]]; then rm -f -- "$SUMS_TMP"; fi
if [[ -n "$SIG_TMP" ]]; then rm -f -- "$SIG_TMP"; fi
if [[ -n "$RELEASE_NOTES_TMP" ]]; then rm -f -- "$RELEASE_NOTES_TMP"; fi
}
trap cleanup EXIT
if [[ -n "$SIGNING_KEY_FILE" ]]; then
@@ -196,6 +281,13 @@ command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
write_auth_config
unset TOKEN
# Keep the release body deterministic and human-readable. Gitea renders this
# Markdown in the Release page; the update API later exposes the same body as
# text for the safe client-side Markdown renderer.
RELEASE_NOTES_TMP=$(mktemp)
generate_release_notes > "$RELEASE_NOTES_TMP"
release_notes=$(<"$RELEASE_NOTES_TMP")
api_curl() {
"$CURL_BIN" --proto '=https' --tlsv1.2 --fail --silent --show-error --connect-timeout 15 --max-time 120 \
--config "$AUTH_CONFIG" "$@"
@@ -213,8 +305,17 @@ release_json=$(mktemp)
status=$(api_curl_status --max-time 30 -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/tags/$TAG") || die '无法读取 Gitea Release'
if [[ "$status" == 200 ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
existing_body=$(jq -r '.body // ""' "$release_json")
# Older releases used a one-line placeholder. Upgrade that placeholder when
# a tag is republished, while leaving deliberately authored release notes
# untouched.
if [[ "$existing_body" == "TallyNote $TAG" || -z "$existing_body" ]]; then
patch_body=$(jq -cn --arg body "$release_notes" '{body:$body}')
patch_status=$(api_curl_status -X PATCH -H 'Content-Type: application/json' -d "$patch_body" -o /dev/null -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/$release_id") || die '无法更新 Gitea Release 日志'
[[ "$patch_status" == 2* ]] || die "无法更新 Gitea Release 日志(HTTP $patch_status)"
fi
elif [[ "$status" == 404 ]]; then
body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "TallyNote $TAG" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')
body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "$release_notes" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')
create_status=$(api_curl_status -H 'Content-Type: application/json' -d "$body" -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases") || die '无法创建 Gitea Release'
if [[ "$create_status" == 2* ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
+174 -30
View File
@@ -32,16 +32,120 @@ if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
[[ "$request_operation" == download || "$request_operation" == apply ]] || request_operation='apply'
fi
# Capture the request id before any privileged preflight can fail. The
# request file is an application-owned one-shot marker; removing it on an
# early runner failure lets the server-side lease reaper release the DB row.
job_id=''
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
job_id=$(sed -n 's/.*"jobId"[[:space:]]*:[[:space:]]*"\([0-9a-f-]*\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
fi
STATE_CREATED=0
heartbeat_pid=''
heartbeat_owner=$$
write_recovery_state() {
local phase=$1 temporary
temporary="$PREFIX/.update-state-$$-${RANDOM}.tmp"
[[ ! -e "$temporary" && ! -L "$temporary" ]] || return 1
printf 'job_id=%s\nold_target=%s\nphase=%s\n' "$job_id" "$old_target" "$phase" > "$temporary"
chmod 600 "$temporary"
mv -Tf -- "$temporary" "$STATE_FILE"
STATE_CREATED=1
}
clear_recovery_state() {
[[ ! -L "$STATE_FILE" ]] || return 1
rm -f -- "$STATE_FILE"
STATE_CREATED=0
}
stop_heartbeat() {
if [[ -n "$heartbeat_pid" ]]; then
kill "$heartbeat_pid" 2>/dev/null || true
wait "$heartbeat_pid" 2>/dev/null || true
heartbeat_pid=''
fi
}
heartbeat() {
# Keep the lease fresh during long downloads/backups, but stop on a hard
# runner kill so an orphaned child cannot keep the recovery marker alive.
while kill -0 "$heartbeat_owner" 2>/dev/null; do
sleep 10 || exit 0
[[ -f "$STATE_FILE" && ! -L "$STATE_FILE" ]] || exit 0
touch "$STATE_FILE" 2>/dev/null || exit 0
done
}
start_heartbeat() {
stop_heartbeat
heartbeat &
heartbeat_pid=$!
}
# This trap covers failures before the normal apply cleanup trap is installed,
# including a missing runtime, an invalid current link, and a failed service
# stop. It deliberately does not remove a pre-existing recovery marker.
preflight_cleanup() {
local result=$?
stop_heartbeat
if (( result != 0 )); then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
if (( STATE_CREATED == 1 )); then clear_recovery_state || true; fi
fi
return "$result"
}
trap preflight_cleanup EXIT
# Downloading is intentionally handled while the main service remains up.
# The CLI persists the validated payload under the root-owned workspace and
# leaves the job staged for a later apply request.
if [[ "$request_operation" == download ]]; then
# A previous download runner may have been interrupted after creating its
# marker. Clear only that download marker and retry the idempotent request.
if [[ -f "$STATE_FILE" && ! -L "$STATE_FILE" ]] && grep -q '^phase=download$' "$STATE_FILE"; then
clear_recovery_state || die '无法清理上一次下载状态'
fi
write_recovery_state download || die '无法写入更新恢复状态'
cleanup_download() {
local result=$?
stop_heartbeat
if (( result != 0 )); then
# The CLI normally records failed itself. If it died before opening the
# database, the expired marker/request will be reconciled by the app.
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
fi
clear_recovery_state || true
return "$result"
}
trap cleanup_download EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
start_heartbeat
node_bin="$CURRENT_LINK/runtime/bin/node"
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
[[ -n "$node_bin" ]] || die 'node runtime not found'
cli="$CURRENT_LINK/dist/server/cli/update.js"
[[ -f "$cli" ]] || die 'update CLI not found in current release'
"$node_bin" "$cli" --request-file "$REQUEST_FILE" || exit $?
set +e
"$node_bin" "$cli" --request-file "$REQUEST_FILE"
download_result=$?
set -e
if (( download_result != 0 )); then
# The CLI normally records failed itself. Retry the explicit finalization
# for failures that happen before its catch handler can persist the row,
# then remove the one-shot request so a failed download cannot keep the
# path unit in a permanently triggered state.
download_job_id=$(sed -n 's/.*"jobId"[[:space:]]*:[[:space:]]*"\([0-9a-f-]*\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
if [[ "$download_job_id" =~ ^[0-9a-f-]{36}$ ]]; then
for _ in 1 2 3; do
if "$node_bin" "$cli" --finalize-job "$download_job_id" --finalize-status failed --message '更新下载失败' >/dev/null 2>&1; then break; fi
sleep 1
done
fi
rm -f -- "$REQUEST_FILE"
exit "$download_result"
fi
rm -f -- "$REQUEST_FILE"
exit 0
fi
@@ -51,34 +155,21 @@ if systemctl is-active --quiet "$SERVICE_NAME"; then was_active=1; fi
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
restore_initial_service() {
local result=$?
stop_heartbeat
if (( result != 0 )); then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
if (( STATE_CREATED == 1 )); then clear_recovery_state || true; fi
fi
if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi
return "$result"
}
trap restore_initial_service EXIT
systemctl stop "$SERVICE_NAME"
job_id=''
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
job_id=$(sed -n 's/.*"jobId"[[:space:]]*:[[:space:]]*"\([0-9a-f-]*\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
fi
old_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$old_node" ]] || old_node=$(command -v node || true)
switched=0
handled=0
write_update_state() {
local phase=$1 temporary
temporary="$PREFIX/.update-state-$$-${RANDOM}.tmp"
[[ ! -e "$temporary" && ! -L "$temporary" ]] || return 1
printf 'job_id=%s\nold_target=%s\nphase=%s\n' "$job_id" "$old_target" "$phase" > "$temporary"
chmod 600 "$temporary"
mv -Tf -- "$temporary" "$STATE_FILE"
}
clear_update_state() {
[[ ! -L "$STATE_FILE" ]] || return 1
rm -f -- "$STATE_FILE"
}
write_update_state() { write_recovery_state "$1"; }
clear_update_state() { clear_recovery_state; }
finalize_state_job() {
local node=$1 status=$2 state_job=$3
@@ -99,6 +190,15 @@ recover_stale_state() {
[[ "$state_job" =~ ^[0-9a-f-]{36}$ ]] || die 'update state job id is invalid'
[[ "$state_old" == "$PREFIX/releases/"* && -d "$state_old" && ! -L "$state_old" ]] || die 'update state target is invalid'
current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)
if [[ "$state_phase" == download && "$current_target" == "$state_old" ]]; then
# Downloading never changes the active release. If the runner was killed
# after the CLI staged its payload but before it removed the recovery
# marker, keep the request available for an idempotent retry. Treating
# every stale download marker as a failed apply would discard a usable
# staged payload and leave the browser showing a misleading failure.
clear_update_state || true
return 0
fi
if [[ "$state_phase" == finalizing && "$current_target" != "$state_old" ]]; then
recovery_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
@@ -112,6 +212,27 @@ recover_stale_state() {
done
return 1
fi
if [[ "$current_target" == "$state_old" ]]; then
# The process may have restored the old release before it was killed. In
# that case the old link is already safe to serve, but the database row
# can still be `applying`; finish it as failed before clearing recovery
# markers so the UI does not poll forever.
recovery_node="$CURRENT_LINK/runtime/bin/node"
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
if finalize_state_job "$recovery_node" failed "$state_job"; then
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
clear_update_state || true
return 11
fi
# A crash before the CLI created its job row is safe to retry. Preserve
# the request while dropping only the stale state marker.
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
clear_update_state || true
return 0
fi
clear_update_state || true
return 0
fi
if [[ "$current_target" != "$state_old" ]]; then
rollback_link="$PREFIX/.current-recovery-$$-${RANDOM}.tmp"
[[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1
@@ -156,10 +277,26 @@ fi
[[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]] || exit 0
# Only create the marker for this invocation after any marker from a previous
# interrupted run has been reconciled. Otherwise the freshly-created `running`
# marker is indistinguishable from stale recovery state and the runner can
# finalize its own queued job as failed before the update CLI starts.
if [[ ! -e "$STATE_FILE" ]]; then
write_recovery_state running || die '无法写入更新恢复状态'
fi
start_heartbeat
if ! systemctl stop "$SERVICE_NAME"; then
die '无法停止 TallyNote 服务'
fi
rollback_current() {
local current_target rollback_link
current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)
[[ "$current_target" == "$old_target" ]] && return 0
if [[ "$current_target" == "$old_target" ]]; then
# An earlier failure branch may already have restored the link. Keep the
# marker truthful so the EXIT trap can still finalize the job.
return 0
fi
rollback_link="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
[[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1
ln -s -- "$old_target" "$rollback_link" || return 1
@@ -167,13 +304,20 @@ rollback_current() {
rm -f -- "$rollback_link" 2>/dev/null || true
return 1
fi
switched=0
}
finalize_failed_job() {
[[ "$job_id" =~ ^[0-9a-f-]{36}$ ]] || return 0
[[ -n "$old_node" && -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 0
"$old_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status failed --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1
[[ -n "$old_node" && -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 1
# Give SQLite a moment to release a transient lock before declaring the
# recovery itself failed.
for _ in 1 2 3; do
if "$old_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status failed --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1; then
return 0
fi
sleep 1
done
return 1
}
finalize_completed_job() {
@@ -185,9 +329,13 @@ finalize_completed_job() {
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
cleanup_after_update() {
local result=$? rollback_ok=1
stop_heartbeat
if (( result != 0 && handled == 0 )); then
if ! rollback_current; then rollback_ok=0; fi
if (( rollback_ok == 1 && switched == 0 )); then
# Once the old release is active again, always try to close the job. The
# previous marker could remain set when an earlier branch had already
# rolled back before entering this EXIT trap, leaving `applying` forever.
if (( rollback_ok == 1 )); then
if finalize_failed_job; then
rm -f -- "$REQUEST_FILE"
clear_update_state || true
@@ -203,7 +351,6 @@ cleanup_after_update() {
}
trap cleanup_after_update EXIT
write_update_state running || exit 1
node_bin="$CURRENT_LINK/runtime/bin/node"
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
[[ -n "$node_bin" ]] || die 'node runtime not found'
@@ -218,9 +365,6 @@ if (( update_result != 0 )); then
exit "$update_result"
fi
if [[ "$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)" != "$old_target" ]]; then
switched=1
fi
write_update_state health-check || exit 1
systemctl start "$SERVICE_NAME"
+95 -1
View File
@@ -4,6 +4,13 @@ root=$(cd "$(dirname "$0")/.." && pwd)
bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh"
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote.service"
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote-update.service"
grep -Eq '^PathExists=/opt/tallynote/\.update-state$' "$root/systemd/tallynote-update.path"
grep -Eq '^PathChanged=/opt/tallynote/\.update-state$' "$root/systemd/tallynote-update.path"
grep -Eq '^PathChanged=/opt/tallynote$' "$root/systemd/tallynote-update.path"
if grep -Eq '^ConditionPathExists=' "$root/systemd/tallynote-update.service"; then
echo 'update service must not require only the request file' >&2
exit 1
fi
output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases)
grep -q 'dry-run' <<<"$output"
grep -q '\[阶段\] 检查运行环境' <<<"$output"
@@ -81,6 +88,12 @@ bash -c '
chmod 700 "$mode_dir"
[[ "$(stat_mode_bits "$mode_dir")" == 448 ]]
mkdir -p "$owner_parent"
# CI runs this shell suite as root. Make the parent genuinely non-root in
# that environment so the assertion exercises the ownership guard instead
# of accidentally passing because root-owned parents are allowed.
if [[ "${EUID:-$(id -u)}" == 0 ]]; then
chown 65534:65534 "$owner_parent"
fi
if (assert_path_chain "$owner_parent/child") >/dev/null 2>&1; then
echo "expected non-root path parent to fail" >&2
exit 1
@@ -153,6 +166,69 @@ bash -c '
wait_for_service_health 0.0.0.0 3011
' _ "$installer_lib"
# Exercise the privileged runner's normal apply hand-off with portable command
# shims. In particular, the freshly-created running marker must not be treated
# as stale state before the update CLI gets a chance to process the request.
runner_root="$tmp/runner"
runner_prefix="$runner_root/prefix"
runner_data="$runner_root/data"
runner_tools="$runner_root/tools"
mkdir -p "$runner_prefix/releases/1.0.0/runtime/bin" "$runner_prefix/releases/1.0.0/dist/server/cli" "$runner_data" "$runner_tools"
ln -s "$runner_prefix/releases/1.0.0" "$runner_prefix/current"
printf '%s\n' '{"jobId":"00000000-0000-4000-8000-000000000001","operation":"apply"}' > "$runner_data/update-request.json"
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "$*" >> "$TALLYNOTE_NODE_TRACE"' 'exit 0' > "$runner_prefix/releases/1.0.0/runtime/bin/node"
printf '%s\n' cli > "$runner_prefix/releases/1.0.0/dist/server/cli/update.js"
printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$runner_tools/systemctl"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$runner_tools/readlink"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-Tf" ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi' > "$runner_tools/mv"
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$runner_tools/curl"
chmod 755 "$runner_prefix/releases/1.0.0/runtime/bin/node" "$runner_tools/systemctl" "$runner_tools/readlink" "$runner_tools/mv" "$runner_tools/curl"
runner_script="$runner_root/runner.sh"
runner_path="$runner_tools:/usr/sbin:/usr/bin:/sbin:/bin"
sed "s#PATH=/usr/sbin:/usr/bin:/sbin:/bin#PATH=$runner_path#" "$root/scripts/tallynote-update-runner.sh" > "$runner_script"
chmod 755 "$runner_script"
runner_prefix_physical=$(cd "$runner_prefix" && pwd -P)
runner_data_physical=$(cd "$runner_data" && pwd -P)
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$runner_prefix_physical" TALLYNOTE_DATA_DIR="$runner_data_physical" TALLYNOTE_NODE_TRACE="$runner_root/node.log" bash "$runner_script"
grep -q -- '--request-file' "$runner_root/node.log"
grep -q -- '--finalize-job' "$runner_root/node.log"
[[ ! -e "$runner_data/update-request.json" ]]
[[ ! -e "$runner_prefix/.update-state" ]]
# A stale download marker must be recoverable without finalizing the staged
# download as a failed apply. The next runner invocation should retry the
# request and let the CLI preserve/refresh its staged workspace.
download_runner_root="$tmp/download-runner"
download_runner_prefix="$download_runner_root/prefix"
download_runner_data="$download_runner_root/data"
download_runner_tools="$download_runner_root/tools"
mkdir -p "$download_runner_prefix/releases/1.0.0/runtime/bin" "$download_runner_prefix/releases/1.0.0/dist/server/cli" "$download_runner_data" "$download_runner_tools"
ln -s "$download_runner_prefix/releases/1.0.0" "$download_runner_prefix/current"
# The request has already been consumed; only the stale download marker is
# left, which is the narrow recovery window covered by this fixture.
download_runner_prefix_physical=$(cd "$download_runner_prefix" && pwd -P)
download_runner_data_physical=$(cd "$download_runner_data" && pwd -P)
printf '%s\n' 'job_id=00000000-0000-4000-8000-000000000002' "old_target=$download_runner_prefix_physical/releases/1.0.0" 'phase=download' > "$download_runner_prefix/.update-state"
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" "$*" >> "$TALLYNOTE_DOWNLOAD_NODE_TRACE"' 'exit 0' > "$download_runner_prefix/releases/1.0.0/runtime/bin/node"
printf '%s\n' cli > "$download_runner_prefix/releases/1.0.0/dist/server/cli/update.js"
printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$download_runner_tools/systemctl"
printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$download_runner_tools/readlink"
cat >"$download_runner_tools/stat" <<'EOF'
#!/usr/bin/env bash
case "$*" in
*"-c %u"*|*"-f %u"*) printf '0\n' ;;
*"-c %a"*|*"-f %Lp"*) printf '600\n' ;;
*) /usr/bin/stat "$@" ;;
esac
EOF
chmod 755 "$download_runner_prefix/releases/1.0.0/runtime/bin/node" "$download_runner_tools/systemctl" "$download_runner_tools/readlink" "$download_runner_tools/stat"
download_runner_script="$download_runner_root/runner.sh"
sed "s#PATH=/usr/sbin:/usr/bin:/sbin:/bin#PATH=$download_runner_tools:/usr/sbin:/usr/bin:/sbin:/bin#" "$root/scripts/tallynote-update-runner.sh" > "$download_runner_script"
chmod 755 "$download_runner_script"
env EUID=0 TALLYNOTE_INSTALL_PREFIX="$download_runner_prefix_physical" TALLYNOTE_DATA_DIR="$download_runner_data_physical" TALLYNOTE_DOWNLOAD_NODE_TRACE="$download_runner_root/node.log" bash "$download_runner_script"
[[ ! -e "$download_runner_root/node.log" ]]
[[ ! -e "$download_runner_prefix/.update-state" ]]
# A RETURN trap installed by install_release must be cleared while its local
# temporary variables still exist; otherwise set -u fails at the end of main.
release_fixture="$tmp/release-fixture"
@@ -192,6 +268,17 @@ bash -c '
set_env_key test value
' _ "$installer_lib" "$release_archive" "$tmp/install-release"
# The installed path unit must watch both the data-directory request and the
# release-prefix recovery marker after custom paths are substituted.
rendered_path="$tmp/rendered-update.path"
sed "s#/opt/tallynote#$tmp/custom-prefix#g; s#/var/lib/tallynote#$tmp/custom-data#g" \
"$root/systemd/tallynote-update.path" > "$rendered_path"
grep -Fxq "PathExists=$tmp/custom-data/update-request.json" "$rendered_path"
grep -Fxq "PathChanged=$tmp/custom-data/update-request.json" "$rendered_path"
grep -Fxq "PathExists=$tmp/custom-prefix/.update-state" "$rendered_path"
grep -Fxq "PathChanged=$tmp/custom-prefix/.update-state" "$rendered_path"
grep -Fxq "PathChanged=$tmp/custom-prefix" "$rendered_path"
# The production admin wrapper must load a release-relative runtime, change to
# the release root, and forward CLI arguments without requiring pnpm.
wrapper_prefix="$tmp/wrapper-prefix"
@@ -200,7 +287,11 @@ ln -s "$wrapper_prefix/releases/1.0.0" "$wrapper_prefix/current"
printf '%s\n' '#!/usr/bin/env bash' 'pwd -P > "$TALLYNOTE_WRAPPER_LOG"' 'printf "%s\n" "$@" >> "$TALLYNOTE_WRAPPER_LOG"' > "$wrapper_prefix/releases/1.0.0/runtime/bin/node"
chmod 755 "$wrapper_prefix/releases/1.0.0/runtime/bin/node"
printf '%s\n' cli > "$wrapper_prefix/releases/1.0.0/dist/server/cli/admin-init.js"
TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_WRAPPER_LOG="$tmp/wrapper.log" \
# This fixture verifies release-relative execution and argument forwarding.
# Force the wrapper's non-root branch so the root CI runner does not need a
# real `tallynote` service account or a privileged runuser hand-off; that
# privilege boundary is validated by the production checks themselves.
env EUID=1000 TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_WRAPPER_LOG="$tmp/wrapper.log" \
bash "$root/bin/tallynote-admin-init" --generate
wrapper_expected_root=$(cd "$wrapper_prefix/releases/1.0.0" && pwd -P)
grep -Fxq "$wrapper_expected_root" "$tmp/wrapper.log"
@@ -367,6 +458,9 @@ bash -c '
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_public_origin "http://[2001:db8::10]:3000"
# A standard HTTPS origin may omit its default port; this must remain valid
# under the installer strict unset-variable mode.
validate_public_origin "https://example.test"
' _ "$installer_lib"
printf '%s\n' \
'TALLYNOTE_HOST=0.0.0.0' \
+29 -17
View File
@@ -59,6 +59,7 @@ import {
checkForUpdate,
publicCheckFromCache,
publicUpdateJob,
reconcileOrphanedUpdateJobs,
readCachedRelease,
writeUpdateRequest,
type UpdateRequest,
@@ -118,7 +119,7 @@ function enforceUpdateCooldown(
adminId: string,
operation: "check" | "download" | "apply",
reply: FastifyReply,
): void {
): number {
const state = updateRateState(database, adminId);
const now = Date.now();
const previous = operation === "check" ? state.checkedAt : operation === "download" ? state.downloadedAt : state.appliedAt;
@@ -133,6 +134,7 @@ function enforceUpdateCooldown(
if (operation === "check") state.checkedAt = now;
else if (operation === "download") state.downloadedAt = now;
else state.appliedAt = now;
return now;
}
function adminSelect(alias = ""): string {
@@ -646,19 +648,6 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
return payload;
});
app.addHook("onRequest", async (request) => {
if (!unsafeMethods.has(request.method) || !request.url.startsWith("/api/")) return;
const origin = request.headers.origin;
const allowed = new Set([config.publicOrigin]);
if (!config.isProduction) {
allowed.add("http://127.0.0.1:5173");
allowed.add("http://localhost:5173");
}
if (typeof origin !== "string" || !allowed.has(origin)) {
throw new AppError(403, "ORIGIN_FORBIDDEN", "请求来源不受信任");
}
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) return reply.code(error.statusCode).send(errorPayload(request, error));
if (error instanceof ZodError) {
@@ -939,14 +928,24 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
// Release metadata and task state should never be stored by an upstream
// proxy or a shared browser cache.
reply.header("Cache-Control", "no-store");
reconcileOrphanedUpdateJobs(database.sqlite, config);
const cached = publicCheckFromCache(database.sqlite, config);
// Status is a live control surface, not an update history endpoint.
// Terminal failures/cancellations from a previous attempt must not be
// replayed as if the operator had just started an update. They remain in
// the database/audit log, while this endpoint exposes only an actionable
// task (or the latest successful completion for confirmation).
const row = database.sqlite.prepare(`
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
download_speed_bps AS downloadSpeedBps,
requested_at AS applyQueuedAt
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
FROM update_jobs
WHERE admin_id=? AND status IN (${[...ACTIVE_UPDATE_STATUSES, "completed"].map(() => "?").join(",")})
ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id, ...ACTIVE_UPDATE_STATUSES, "completed") as Record<string, unknown> | undefined;
return {
...cached,
strategy: config.updateStrategy,
@@ -955,11 +954,15 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
});
app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
const rateState = updateRateState(database.sqlite, request.auth!.admin.id);
const previousCheckedAt = rateState.checkedAt;
let reservedCheckedAt: number | null = null;
try {
reconcileOrphanedUpdateJobs(database.sqlite, config);
// Disabled/dev installs do not contact a release endpoint, so repeated
// checks are local status reads and should remain immediately usable.
if (config.updateStrategy !== "disabled") {
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply);
reservedCheckedAt = enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply);
}
const result = await checkForUpdate(database.sqlite, config);
writeAudit(database.sqlite, {
@@ -978,6 +981,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
reply.header("Cache-Control", "no-store");
return { ...result, strategy: config.updateStrategy };
} catch (error) {
// A failed upstream request is not a successful check. Release the
// reservation only when this request still owns it, so a concurrent
// successful check cannot have its cooldown overwritten.
if (reservedCheckedAt !== null && rateState.checkedAt === reservedCheckedAt) rateState.checkedAt = previousCheckedAt;
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
@@ -995,6 +1002,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
let applyAuditRecorded = false;
let applyAuditTarget: string | undefined;
try {
reconcileOrphanedUpdateJobs(database.sqlite, config);
if (config.updateStrategy !== "systemd") {
throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
}
@@ -1145,6 +1153,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
app.post("/api/update/download", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
const input = updateDownloadSchema.parse(request.body);
reconcileOrphanedUpdateJobs(database.sqlite, config);
if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
if (active) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
@@ -1175,10 +1184,13 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
app.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
reconcileOrphanedUpdateJobs(database.sqlite, config);
const row = database.sqlite.prepare(`
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
download_speed_bps AS downloadSpeedBps,
requested_at AS applyQueuedAt
FROM update_jobs WHERE id=? AND admin_id=?
`).get(id, request.auth!.admin.id) as Record<string, unknown> | undefined;
+73 -27
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type Database from "better-sqlite3";
@@ -10,6 +10,7 @@ import { writeAudit } from "../audit.js";
import {
atomicSwitchDirectory,
atomicSwitchRelease,
applicationUpdateRuntimeHash,
compareSemver,
createSafeArchive,
detectPlatform,
@@ -19,6 +20,7 @@ import {
isNewerVersion,
normalizeReleasePermissions,
parseSemver,
runtimeHashFromLockfile,
selectReleaseAsset,
sanitizeAssetName,
validateHttpsUrl,
@@ -26,7 +28,7 @@ import {
type ReleaseMetadata,
type UrlPolicy,
} from "../update.js";
import { attachSidecarHash } from "../update-service.js";
import { ACTIVE_UPDATE_STATUSES, attachSidecarHash } from "../update-service.js";
import type { UpdateJobStatus } from "../../shared/contracts.js";
const updateRequestFileSchema = z.object({
@@ -153,7 +155,7 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
operation, status, version, platform, release_url, asset_name, asset_url,
expected_sha256, actual_sha256, download_path, backup_path, size_bytes, error_message,
created_at, updated_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
admin_id=COALESCE(excluded.admin_id, update_jobs.admin_id),
session_hash=COALESCE(excluded.session_hash, update_jobs.session_hash),
@@ -212,9 +214,16 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
if (options.metadataUrl) {
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
const release = await fetchReleaseMetadata(metadataUrl, options);
let runtimeHash: string | undefined;
try {
runtimeHash = runtimeHashFromLockfile(await readFile(path.join(options.currentDir, "pnpm-lock.yaml")));
} catch {
// Fall back to the full archive when the current installation predates
// runtime fingerprints or is missing deployment provenance.
}
let asset = options.assetUrl && !options.requireSignature
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
: selectReleaseAsset(release, platform);
: selectReleaseAsset(release, platform, runtimeHash);
if (!asset) throw new Error("没有匹配当前平台的更新文件");
const integrity = await attachSidecarHash(release, asset, {
allowedHosts: options.allowedHosts ?? [],
@@ -291,12 +300,40 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
const archivePath = path.join(workspace, resolved.asset.name.endsWith(".gz") || resolved.asset.name.endsWith(".zip") ? resolved.asset.name : `${resolved.asset.name}.tar.gz`);
try {
updateJob(options.sqlite, jobId, { operation, status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, options);
const progressStartedAt = Date.now();
let lastProgressWrite = 0;
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, {
...options,
onProgress: (downloadedBytes, totalBytes) => {
const now = Date.now();
if (!options.sqlite || now - lastProgressWrite < 250) return;
lastProgressWrite = now;
const elapsed = Math.max(1, now - progressStartedAt);
const speedBps = Math.round(downloadedBytes * 1000 / elapsed);
options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=COALESCE(?, size_bytes), download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloadedBytes, totalBytes, progressStartedAt, speedBps, now, jobId);
},
});
if (options.sqlite) {
const finishedAt = Date.now();
const elapsed = Math.max(1, finishedAt - progressStartedAt);
options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=?, download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloaded.size, downloaded.size, progressStartedAt, Math.round(downloaded.size * 1000 / elapsed), finishedAt, jobId);
}
if (expectedSha256 && downloaded.sha256 !== expectedSha256) throw new Error("更新文件 SHA-256 校验失败");
updateJob(options.sqlite, jobId, { operation, status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
const stagedDir = path.join(workspace, "payload");
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
if (applicationUpdateRuntimeHash(resolved.asset.name)) {
const currentRelease = await realpath(options.currentDir).catch(() => { throw new Error("当前安装目录无效"); });
const currentInfo = await lstat(currentRelease).catch(() => null);
if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效");
for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) {
const source = path.join(currentRelease, entry);
const sourceInfo = await lstat(source).catch(() => null);
if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新");
await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false });
}
}
await normalizeReleasePermissions(stagedDir);
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
@@ -355,7 +392,9 @@ export function finalizeUpdateJob(
FROM update_jobs WHERE id=?
`).get(jobId) as { id: string; status: UpdateJobStatus; version: string; platform: string; adminId: string | null; requestId: string | null; sessionHash: string | null } | undefined;
if (!row) throw new Error("更新任务不存在");
if (row.status !== "applying" && row.status !== "completed" && row.status !== "failed") throw new Error("更新任务状态不允许完成");
const canComplete = row.status === "applying" || row.status === "completed";
const canFail = ACTIVE_UPDATE_STATUSES.includes(row.status) || row.status === "completed" || row.status === "failed";
if (status === "completed" ? !canComplete : !canFail) throw new Error("更新任务状态不允许完成");
const now = Date.now();
const safeFailureMessage = status === "failed" ? "新版本健康检查失败,已恢复上一版本" : null;
sqlite.transaction(() => {
@@ -477,27 +516,34 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
const database = openDatabase(config);
try {
if (request?.operation === "apply") {
const staged = database.sqlite.prepare("SELECT download_path AS downloadPath, version FROM update_jobs WHERE id=? AND status='staged' AND operation='apply'").get(request.jobId) as { downloadPath: string | null; version: string } | undefined;
if (!staged?.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效");
const root = path.resolve(config.updateWorkspaceDir);
const candidate = await validateStagedWorkspacePath(staged.downloadPath, root);
await applyStagedUpdate({
sqlite: database.sqlite,
jobId: request.jobId,
version: request.version,
stagedPath: candidate,
currentDir,
currentLink: request.currentLink,
releasesDir: request.releasesDir,
workspaceRoot: root,
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
dataBackupSource: config.dataDir,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
});
console.log(`更新已切换:${request.version}`);
return;
const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string } | undefined;
if (staged?.status === "staged" && staged.operation === "apply") {
if (!staged.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效");
const root = path.resolve(config.updateWorkspaceDir);
const candidate = await validateStagedWorkspacePath(staged.downloadPath, root);
await applyStagedUpdate({
sqlite: database.sqlite,
jobId: request.jobId,
version: request.version,
stagedPath: candidate,
currentDir,
currentLink: request.currentLink,
releasesDir: request.releasesDir,
workspaceRoot: root,
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
dataBackupSource: config.dataDir,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
});
console.log(`更新已切换:${request.version}`);
return;
}
if (staged && !(staged.status === "queued" && staged.operation === "apply")) throw new Error("更新任务状态无效");
// A direct one-click request starts in queued/apply. Older clients do
// not have a separate download step, so fall through to runUpdate,
// which downloads, verifies, backs up, and switches the release in one
// transaction. A staged request still takes the branch above.
}
const result = await runUpdate({
...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}),
+3
View File
@@ -148,6 +148,9 @@ export const updateJobs = sqliteTable("update_jobs", {
downloadPath: text("download_path"),
backupPath: text("backup_path"),
sizeBytes: integer("size_bytes"),
downloadedBytes: integer("downloaded_bytes"),
downloadStartedAt: integer("download_started_at"),
downloadSpeedBps: integer("download_speed_bps"),
errorMessage: text("error_message"),
createdAt: integer("created_at").notNull(),
requestedAt: integer("requested_at"),
+3
View File
@@ -3,6 +3,7 @@ import { loadConfig, prepareDataDirectories, acquireInstanceLock } from "./confi
import { openDatabase } from "./db/index.js";
import { buildApp } from "./app.js";
import { cleanupOrphanedExports, expireExports, resumeExports } from "./exporter.js";
import { reconcileOrphanedUpdateJobs } from "./update-service.js";
const config = loadConfig();
prepareDataDirectories(config);
@@ -18,6 +19,7 @@ async function start() {
await expireExports(database.sqlite, config);
await cleanupOrphanedExports(database.sqlite, config);
await resumeExports(database.sqlite, config);
reconcileOrphanedUpdateJobs(database.sqlite, config);
const app = await buildApp(database, config);
const janitor = setInterval(() => {
void cleanupStaging(config);
@@ -27,6 +29,7 @@ async function start() {
void processFileDeletions(database.sqlite, config);
void expireExports(database.sqlite, config);
void cleanupOrphanedExports(database.sqlite, config);
reconcileOrphanedUpdateJobs(database.sqlite, config);
}, 60_000);
const shutdown = async () => {
clearInterval(janitor);
+174 -1
View File
@@ -1,7 +1,9 @@
import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import type Database from "better-sqlite3";
import { writeAudit } from "./audit.js";
import { AppError } from "./errors.js";
import type { AppConfig } from "./config.js";
import {
@@ -11,6 +13,7 @@ import {
fetchReleaseText,
isNewerVersion,
parseSemver,
runtimeHashFromLockfile,
sanitizeAssetName,
selectReleaseAsset,
validateHttpsUrl,
@@ -30,6 +33,12 @@ export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [
"applying",
];
// A queued job normally starts within seconds and an applying job completes
// after the service health check. The runner refreshes its recovery marker as
// a lease while doing long downloads/backups; only an expired lease permits
// the server to reclaim an active row.
export const ORPHANED_UPDATE_TIMEOUT_MS = 5 * 60 * 1000;
export type CachedRelease = {
checkedAt: number;
metadataUrl: string;
@@ -196,7 +205,14 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
} catch {
throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试");
}
let asset = selectReleaseAsset(metadata, platform);
let runtimeHash: string | undefined;
try {
runtimeHash = runtimeHashFromLockfile(readFileSync(path.join(config.projectRoot, "pnpm-lock.yaml")));
} catch {
// Legacy or source installations may not contain the lockfile. They stay
// on the full release asset instead of risking an incompatible runtime.
}
let asset = selectReleaseAsset(metadata, platform, runtimeHash);
let signatureVerified = false;
if (asset) {
const integrity = await attachSidecarHash(metadata, asset, {
@@ -343,6 +359,9 @@ export function publicUpdateJob(row: Record<string, unknown> | undefined): Recor
platform: row.platform,
assetName: row.assetName ?? null,
sizeBytes: row.sizeBytes ?? null,
downloadedBytes: row.downloadedBytes ?? null,
downloadStartedAt: row.downloadStartedAt ?? null,
downloadSpeedBps: row.downloadSpeedBps ?? null,
// Do not expose filesystem paths, command output, or upstream response
// text through the authenticated status endpoint. Detailed diagnostics
// remain in the server journal for operators.
@@ -355,3 +374,157 @@ export function publicUpdateJob(row: Record<string, unknown> | undefined): Recor
...(row.status === "applying" ? { restartWindowSeconds: 30 } : {}),
};
}
function markerMtime(filePath: string): number | null {
try {
const info = lstatSync(filePath);
return info.isFile() ? info.mtimeMs : null;
} catch {
return null;
}
}
function removeExpiredRequest(filePath: string, now: number): void {
try {
const info = lstatSync(filePath);
if (!info.isFile() && !info.isSymbolicLink()) return;
if (now - info.mtimeMs < ORPHANED_UPDATE_TIMEOUT_MS) return;
unlinkSync(filePath);
} catch {
// The root runner may own the marker during a recovery race. The DB
// transition below is still enough to release the browser queue.
}
}
function requestJobId(filePath: string): string | null {
try {
const info = lstatSync(filePath);
if (!info.isFile() || info.isSymbolicLink()) return null;
const value = JSON.parse(readFileSync(filePath, "utf8")) as { jobId?: unknown };
return typeof value.jobId === "string" && /^[0-9a-f-]{36}$/.test(value.jobId) ? value.jobId : null;
} catch {
return null;
}
}
function currentReleaseVersion(config: AppConfig): string | null {
try {
const target = realpathSync(config.currentLink);
const releases = realpathSync(config.releasesDir);
if (!target.startsWith(`${releases}${path.sep}`)) return null;
return path.basename(target);
} catch {
return null;
}
}
/**
* Release an update row left behind after its privileged runner lease expired.
* This is deliberately conservative: staged downloads remain available for an
* explicit apply, and a fresh request/state marker means the runner still owns
* recovery.
*/
export function reconcileOrphanedUpdateJobs(database: Database.Database, config: AppConfig, now = Date.now()): number {
const placeholders = ACTIVE_UPDATE_STATUSES.map(() => "?").join(",");
const rows = database.prepare(`
SELECT id, status, operation, version, admin_id AS adminId, request_id AS requestId,
updated_at AS updatedAt
FROM update_jobs
WHERE status IN (${placeholders})
ORDER BY updated_at ASC
`).all(...ACTIVE_UPDATE_STATUSES) as Array<{ id: string; status: UpdateJobStatus; operation: "download" | "apply"; version: string; adminId: string | null; requestId: string | null; updatedAt: number | null }>;
if (rows.length === 0) return 0;
const statePath = path.join(config.installPrefix, ".update-state");
const requestMtime = markerMtime(config.updateRequestPath);
const stateMtime = markerMtime(statePath);
const requestPresent = requestMtime !== null;
const statePresent = stateMtime !== null;
const requestFresh = requestPresent && now - (requestMtime ?? 0) < ORPHANED_UPDATE_TIMEOUT_MS;
const stateFresh = statePresent && now - (stateMtime ?? 0) < ORPHANED_UPDATE_TIMEOUT_MS;
// A staged download is normally kept for an explicit apply. The one
// exception is the hand-off window where the API has already changed the
// operation to `apply` but crashed before writing the request file. That
// row is still safe to retry and must not block the queue forever.
const releaseVersion = currentReleaseVersion(config);
let reconciled = 0;
const reconciledIds = new Set<string>();
for (const row of rows) {
if (typeof row.updatedAt !== "number" || now - row.updatedAt < ORPHANED_UPDATE_TIMEOUT_MS) continue;
// The runner refreshes the state marker while a download is in flight.
// A stale request/state marker therefore no longer protects an orphaned
// row forever, while a fresh marker remains owned by the runner.
if (row.status === "staged") {
if (row.operation !== "apply" || requestFresh || stateFresh) continue;
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
SET operation='download', error_message=NULL, updated_at=?
WHERE id=? AND status='staged' AND operation='apply' AND updated_at=?
`).run(now, row.id, row.updatedAt);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: "update.reconciled",
targetType: "update",
targetId: row.id,
outcome: "success",
before: { status: row.status, operation: row.operation, version: row.version },
after: { status: "staged", operation: "download", version: row.version, reason: "apply_request_missing" },
});
return true;
})();
if (changed) {
reconciled += 1;
reconciledIds.add(row.id);
}
continue;
}
if (requestFresh || stateFresh) continue;
const status: "completed" | "failed" = row.status === "applying" && releaseVersion === row.version ? "completed" : "failed";
const errorMessage = status === "failed" ? "更新任务超时,已释放更新队列" : null;
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
SET status=?, error_message=?, completed_at=?, updated_at=?
WHERE id=? AND status=? AND updated_at=?
`).run(status, errorMessage, now, now, row.id, row.status, row.updatedAt);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: "update.reconciled",
targetType: "update",
targetId: row.id,
outcome: status === "completed" ? "success" : "failure",
before: { status: row.status, version: row.version },
after: { status, version: row.version, reason: "orphaned_timeout" },
});
return true;
})();
if (changed) {
reconciled += 1;
reconciledIds.add(row.id);
}
}
// Prevent a stale request from being replayed after its DB row has been
// marked failed. The path is fixed by the server configuration and the
// operation is safe even when a root runner is racing with this call.
// A download runner refreshes the state marker while it is still using the
// request. Keep the request until that lease also expires; otherwise a
// long download can lose its job id and fail to finalize its row.
const queuedRequestId = requestPresent ? requestJobId(config.updateRequestPath) : null;
const queuedRequest = queuedRequestId ? rows.find((row) => row.id === queuedRequestId) : undefined;
const requestStillNeeded = Boolean(
queuedRequest
&& ACTIVE_UPDATE_STATUSES.includes(queuedRequest.status)
&& !reconciledIds.has(queuedRequest.id)
&& !(queuedRequest.status === "staged" && queuedRequest.operation === "download"),
);
if (!stateFresh
&& (!requestPresent || (requestMtime !== null && now - requestMtime >= ORPHANED_UPDATE_TIMEOUT_MS))
&& !requestStillNeeded) {
removeExpiredRequest(config.updateRequestPath, now);
}
return reconciled;
}
+23 -3
View File
@@ -43,6 +43,16 @@ export type ReleaseMetadata = {
assets: ReleaseAsset[];
};
const APPLICATION_UPDATE_ASSET = /\.update-([a-f0-9]{64})\.tar\.gz$/i;
export function applicationUpdateRuntimeHash(assetName: string): string | undefined {
return APPLICATION_UPDATE_ASSET.exec(assetName)?.[1]?.toLowerCase();
}
export function runtimeHashFromLockfile(lockfile: string | Buffer): string {
return createHash("sha256").update(lockfile).digest("hex");
}
export type UrlPolicy = {
/** Host names or HTTPS URLs which are allowed for requests. */
allowedHosts?: readonly string[] | undefined;
@@ -379,7 +389,7 @@ export async function fetchReleaseBytes(
}
}
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform()): ReleaseAsset | undefined {
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform(), runtimeHash?: string): ReleaseAsset | undefined {
const platformCandidates = release.assets.filter((asset) => {
const name = asset.name.toLowerCase();
return platform.aliases.filter((alias) => alias.toLowerCase().includes(platform.arch.toLowerCase())).some((alias) => name.includes(alias.toLowerCase()));
@@ -398,7 +408,15 @@ export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPl
const target = platform.target.toLowerCase();
return Number(b.name.toLowerCase().includes(target)) - Number(a.name.toLowerCase().includes(target));
});
return candidates[0];
const normalizedRuntimeHash = runtimeHash?.trim().toLowerCase();
if (normalizedRuntimeHash && /^[a-f0-9]{64}$/.test(normalizedRuntimeHash)) {
const applicationUpdate = candidates.find((asset) => applicationUpdateRuntimeHash(asset.name) === normalizedRuntimeHash);
if (applicationUpdate) return applicationUpdate;
}
// Older clients choose the first matching asset. Releases therefore keep
// the traditional full archive first, while current clients explicitly
// opt into a compatible application-only asset.
return candidates.find((asset) => !applicationUpdateRuntimeHash(asset.name));
}
export function sanitizeAssetName(value: string): string {
@@ -423,7 +441,7 @@ export async function verifySha256(filePath: string, expected: string): Promise<
export async function downloadReleaseAsset(
url: string | URL,
destination: string,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined; onProgress?: ((downloadedBytes: number, totalBytes: number | null) => void) | undefined } = {},
): Promise<{ size: number; sha256: string }> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(url, options);
@@ -446,6 +464,7 @@ export async function downloadReleaseAsset(
}
if (response.status < 200 || response.status >= 300 || !response.body) throw new Error("更新文件下载失败");
const declared = Number(response.headers.get("content-length") ?? 0);
const totalBytes = Number.isSafeInteger(declared) && declared > 0 ? declared : null;
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
if (declared > maxBytes) throw new Error("更新文件超过大小限制");
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
@@ -454,6 +473,7 @@ export async function downloadReleaseAsset(
const hash = createHash("sha256");
const meter = new Transform({ transform(chunk: Buffer, _encoding, callback) {
size += chunk.length;
options.onProgress?.(size, totalBytes);
if (size > maxBytes) return callback(new Error("更新文件超过大小限制"));
hash.update(chunk);
callback(null, chunk);
+9
View File
@@ -4,6 +4,15 @@ Description=Watch for TallyNote release update requests
[Path]
PathExists=/var/lib/tallynote/update-request.json
PathChanged=/var/lib/tallynote/update-request.json
# The recovery marker lives beside the release link. Watching it as well
# allows systemd to resume reconciliation when the runner is interrupted
# after consuming the request but before clearing its state file.
PathExists=/opt/tallynote/.update-state
PathChanged=/opt/tallynote/.update-state
# Keep a directory-level fallback because some systemd/inotify versions skip
# dotfiles when watching an individual path. State writes are atomic renames,
# so the containing directory changes even when the marker itself is hidden.
PathChanged=/opt/tallynote
Unit=tallynote-update.service
[Install]
+4 -1
View File
@@ -2,7 +2,6 @@
Description=TallyNote privileged release updater
After=network-online.target
Wants=network-online.target
ConditionPathExists=/var/lib/tallynote/update-request.json
[Service]
Type=oneshot
@@ -12,6 +11,10 @@ WorkingDirectory=/opt/tallynote/current
EnvironmentFile=-/etc/tallynote/tallynote.env
ExecStart=/usr/local/libexec/tallynote-update-runner
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
# Downloads, archive validation and data backups can exceed systemd's 90s
# default start timeout on a slower server. Keep one update job alive long
# enough to finish or reach its own health-check/recovery path.
TimeoutStartSec=30min
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
+2
View File
@@ -14,6 +14,8 @@ Environment=PATH=/opt/tallynote/current/runtime/bin:/usr/sbin:/usr/bin:/sbin:/bi
ExecStart=/opt/tallynote/current/bin/tallynote
Restart=on-failure
RestartSec=5s
# Do not let a wedged Node process hold an update stop forever.
TimeoutStopSec=30s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
+3 -3
View File
@@ -129,10 +129,10 @@ describe("TallyNote API", () => {
}
});
it("拒绝没有 Origin 的写请求", async () => {
it("反向代理缺少 Origin 时仍允许登录请求进入认证流程", async () => {
const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "x" } });
expect(response.statusCode).toBe(403);
expect(response.json().error.code).toBe("ORIGIN_FORBIDDEN");
expect(response.statusCode).toBe(401);
expect(response.json().error.code).toBe("INVALID_CREDENTIALS");
});
it("将非法 JSON、伪造请求 ID 处理为结构化 400", async () => {
+2 -1
View File
@@ -42,9 +42,10 @@ describe("数据库迁移", () => {
{ name: "0002_update_jobs.sql" },
{ name: "0003_update_job_ownership.sql" },
{ name: "0004_update_download_apply.sql" },
{ name: "0005_update_progress.sql" },
]);
const updateColumns = migrated.sqlite.prepare("PRAGMA table_info(update_jobs)").all() as Array<{ name: string }>;
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at", "operation"]));
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at", "operation", "downloaded_bytes", "download_started_at", "download_speed_bps"]));
expect(migrated.sqlite.prepare("SELECT note, invoice_missing_reason AS reason FROM expenses WHERE id='00000000-0000-4000-8000-000000000099'").get()).toEqual({ note: "旧账目", reason: null });
migrated.sqlite.close();
migrated = openDatabase(config);
+1
View File
@@ -48,6 +48,7 @@ describe("部署安全配置", () => {
expect(loadConfig().trustProxy).toBe(1);
});
it("systemd 更新必须绑定主机白名单,签名校验默认关闭", () => {
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
+30
View File
@@ -76,6 +76,12 @@ describe("更新 API", () => {
expect(tooSoon.statusCode).toBe(429);
expect(tooSoon.headers["retry-after"]).toBeDefined();
// Cooldown is scoped to the authenticated administrator, not the whole
// database or release endpoint.
const otherSession = await login("update-admin-other");
const otherChecked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: otherSession.cookies, "x-csrf-token": otherSession.csrf }, payload: {} });
expect(otherChecked.statusCode).toBe(200);
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } });
expect(applied.statusCode).toBe(202);
const jobId = applied.json().job.id as string;
@@ -131,6 +137,24 @@ describe("更新 API", () => {
expect(disabledConfig.updateStrategy).toBe("disabled");
});
it("首次进入状态页不会展示历史失败任务,也不会阻断新的检查", async () => {
const session = await login("update-history");
const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-history") as { id: string };
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, admin_id, operation, status, version, platform, asset_url, error_message, created_at, updated_at)
VALUES (?, ?, 'download', 'failed', '1.1.0', ?, 'https://updates.example/old.tar.gz', 'old failure', ?, ?)
`).run(randomUUID(), admin.id, detectPlatform().target, now - 60_000, now - 60_000);
const initial = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(initial.statusCode).toBe(200);
expect(initial.json().job).toBeNull();
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
expect(checked.json().latest).toMatchObject({ version: "1.2.0", isNewer: true });
});
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
const owner = await login("update-owner");
const other = await login("update-other");
@@ -157,6 +181,12 @@ describe("更新 API", () => {
globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch;
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } });
expect(response.statusCode).toBe(502);
// A failed upstream check must not reserve the per-admin cooldown; an
// operator can retry immediately after fixing the release endpoint.
const check = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(check.statusCode).toBe(502);
const retry = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(retry.statusCode).toBe(502);
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
expect(audit?.outcome).toBe("failure");
});
+224 -5
View File
@@ -1,11 +1,12 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir } from "node:fs/promises";
import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir, utimes } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto";
import {
atomicSwitchRelease,
applicationUpdateRuntimeHash,
createSafeArchive,
detectPlatform,
downloadReleaseAsset,
@@ -16,15 +17,16 @@ import {
normalizeReleasePermissions,
sanitizeAssetName,
selectReleaseAsset,
runtimeHashFromLockfile,
validateHttpsUrl,
} from "../server/update.js";
import { runUpdate } from "../server/cli/update.js";
import { finalizeUpdateJob, runUpdate } from "../server/cli/update.js";
import { validateUpdateRequest } from "../server/cli/update.js";
import { checkForUpdate, verifyReleaseSignature } from "../server/update-service.js";
import { checkForUpdate, ORPHANED_UPDATE_TIMEOUT_MS, reconcileOrphanedUpdateJobs, verifyReleaseSignature } from "../server/update-service.js";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"];
const envKeys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_INSTALL_PREFIX", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY"];
const originalFetch = globalThis.fetch;
afterEach(() => {
@@ -50,6 +52,17 @@ describe("更新安全工具", () => {
expect(() => sanitizeAssetName("../release.tar.gz")).toThrow();
});
it("优先选择运行时匹配的轻量更新包,并对旧客户端保留完整包回退", () => {
const runtimeHash = runtimeHashFromLockfile("lockfile-v1\n");
const full = { name: "tallynote-1.2.0-linux-x64-glibc.tar.gz", url: "https://updates.example/full" };
const app = { name: `tallynote-1.2.0-linux-x64-glibc.update-${runtimeHash}.tar.gz`, url: "https://updates.example/app" };
const release = { version: "1.2.0", assets: [full, app] };
expect(applicationUpdateRuntimeHash(app.name)).toBe(runtimeHash);
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"), runtimeHash)).toEqual(app);
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))).toEqual(full);
expect(applicationUpdateRuntimeHash(full.name)).toBeUndefined();
});
it("验证 SHA256SUMS 的 Ed25519 detached signature", () => {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const payload = "a".repeat(64) + " tallynote.tar.gz\n";
@@ -207,6 +220,212 @@ describe("更新安全工具", () => {
}
});
it("更新器支持旧客户端创建的 queued/apply 直接更新请求", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-direct-"));
const previousFetch = globalThis.fetch;
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
await mkdir(config.releasesDir, { recursive: true, mode: 0o755 });
const oldRelease = path.join(config.releasesDir, config.appVersion);
await mkdir(path.join(oldRelease, "dist"), { recursive: true, mode: 0o755 });
await writeFile(path.join(oldRelease, "dist", "marker"), "old");
await symlink(oldRelease, config.currentLink);
const source = path.join(root, "source");
await mkdir(path.join(source, "dist"), { recursive: true, mode: 0o755 });
await writeFile(path.join(source, "dist", "marker"), "new");
const archive = path.join(root, "release.tar.gz");
await createSafeArchive(source, archive);
const bytes = await readFile(archive);
const digest = createHash("sha256").update(bytes).digest("hex");
const jobId = randomUUID();
database = openDatabase(config);
const now = Date.now();
const assetName = `tallynote-1.2.0-${detectPlatform().target}.tar.gz`;
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url,
expected_sha256, created_at, updated_at, requested_at)
VALUES (?, 'apply', 'queued', '1.2.0', ?, ?, ?, ?, ?, ?)
`).run(jobId, detectPlatform().target, "https://updates.example/" + assetName, digest, now, now, now);
globalThis.fetch = (async (input: string | URL) => {
const url = input.toString();
if (url.endsWith("/latest")) return new Response(JSON.stringify({ tag_name: "v1.2.0", assets: [{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }] }));
if (url.endsWith("SHA256SUMS")) return new Response(`${digest} ${assetName}\n`);
return new Response(bytes, { headers: { "content-length": String(bytes.length) } });
}) as typeof fetch;
await runUpdate({
metadataUrl: config.updateMetadataUrl,
version: "1.2.0",
currentVersion: config.appVersion,
currentDir: config.currentLink,
stagingDir: path.join(root, "staging"),
currentLink: config.currentLink,
releasesDir: config.releasesDir,
allowedHosts: config.updateAllowedHosts,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
deferCompletion: true,
operation: "apply",
jobId,
sqlite: database.sqlite,
fetchImpl: globalThis.fetch,
});
expect(await readFile(path.join(config.currentLink, "dist", "marker"), "utf8")).toBe("new");
const row = database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(jobId);
expect(row).toEqual({ operation: "apply", status: "applying" });
finalizeUpdateJob(database.sqlite, jobId, "failed");
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" });
} finally {
globalThis.fetch = previousFetch;
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("在请求和恢复标记丢失后收敛孤儿任务,但保留 staged 下载", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-reconcile-"));
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
await mkdir(path.join(config.releasesDir, config.appVersion, "dist"), { recursive: true });
await symlink(path.join(config.releasesDir, config.appVersion), config.currentLink);
database = openDatabase(config);
const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1;
const insert = database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const queuedId = randomUUID();
const applyingId = randomUUID();
const stagedId = randomUUID();
const stagedApplyId = randomUUID();
insert.run(queuedId, "apply", "queued", "1.2.0", "linux-x64", "https://updates.example/queued.tar.gz", staleAt, staleAt);
insert.run(applyingId, "apply", "applying", config.appVersion, "linux-x64", "https://updates.example/applying.tar.gz", staleAt, staleAt);
insert.run(stagedId, "download", "staged", "1.2.0", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt);
insert.run(stagedApplyId, "apply", "staged", "1.2.0", "linux-x64", "https://updates.example/staged-apply.tar.gz", staleAt, staleAt);
const now = Date.now();
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(3);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(queuedId)).toEqual({ status: "failed" });
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(applyingId)).toEqual({ status: "completed" });
expect(database.sqlite.prepare("SELECT status, operation FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ status: "staged", operation: "download" });
expect(database.sqlite.prepare("SELECT status, operation FROM update_jobs WHERE id=?").get(stagedApplyId)).toEqual({ status: "staged", operation: "download" });
} finally {
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("下载心跳有效时不回收任务或删除仍在使用的请求文件", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-heartbeat-"));
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
await mkdir(path.join(config.releasesDir, config.appVersion, "dist"), { recursive: true });
await symlink(path.join(config.releasesDir, config.appVersion), config.currentLink);
database = openDatabase(config);
const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1;
const jobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'download', 'downloading', '1.2.0', 'linux-x64', ?, ?, ?)
`).run(jobId, "https://updates.example/download.tar.gz", staleAt, staleAt);
await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "download" }));
const statePath = path.join(config.installPrefix, ".update-state");
await writeFile(statePath, `job_id=${jobId}\nold_target=${path.join(config.releasesDir, config.appVersion)}\nphase=download\n`);
const now = Date.now();
await utimes(config.updateRequestPath, new Date(staleAt), new Date(staleAt));
await utimes(statePath, new Date(now), new Date(now));
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(0);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "downloading" });
expect(await stat(config.updateRequestPath)).toBeTruthy();
const expiredNow = now + ORPHANED_UPDATE_TIMEOUT_MS + 1;
await utimes(statePath, new Date(staleAt), new Date(staleAt));
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, expiredNow)).toBe(1);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" });
await expect(stat(config.updateRequestPath)).rejects.toThrow();
} finally {
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("队列任务有新请求标记时可被重新检查,标记过期后才回收", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-queued-marker-"));
let database: ReturnType<typeof openDatabase> | undefined;
try {
const dataDir = path.join(root, "data");
const installPrefix = path.join(root, "install");
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix;
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest";
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false";
const config = loadConfig();
prepareDataDirectories(config);
database = openDatabase(config);
const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1;
const jobId = randomUUID();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at)
VALUES (?, 'apply', 'queued', '1.2.0', 'linux-x64', ?, ?, ?)
`).run(jobId, "https://updates.example/queued.tar.gz", staleAt, staleAt);
await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "apply" }));
const now = Date.now();
await utimes(config.updateRequestPath, new Date(now), new Date(now));
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(0);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "queued" });
expect(await stat(config.updateRequestPath)).toBeTruthy();
const expiredNow = now + ORPHANED_UPDATE_TIMEOUT_MS + 1;
expect(reconcileOrphanedUpdateJobs(database.sqlite, config, expiredNow)).toBe(1);
expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" });
await expect(stat(config.updateRequestPath)).rejects.toThrow();
} finally {
if (database) database.sqlite.close();
await rm(root, { recursive: true, force: true });
}
});
it("流式解包在展开大小上限前拒绝高压缩比归档,并修正发布树权限", async () => {
const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-stream-"));
try {
@@ -66,7 +66,7 @@ export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onC
</Drawer>
<Dialog visible={action === "status"} header={detail.status === "reimbursed" ? "改回未报销?" : "标记为已报销?"} confirmBtn={{ content: "确认变更", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "这笔账目会重新出现在未报销列表。" : "确认这笔账目已完成报销,并从未报销列表移出?"}</Dialog>
<Dialog visible={action === "trash"} header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>账目会从普通列表和导出结果中隐藏,附件会保留,可在回收站恢复。</Dialog>
<Dialog visible={Boolean(removeTarget)} header="删除附件?" confirmBtn={{ content: "删除附件", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <><p>{removeTarget.kind === "payment_proof" ? "账目至少需要保留一张付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "这是最后一张发票。删除后必须填写无发票原因。" : "将删除这张发票。"}</p>{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() && <Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(removeError)} aria-describedby={removeError ? "remove-attachment-error" : undefined} value={removeReason} onChange={value => { setRemoveReason(value); setRemoveError(""); }} placeholder="例如:商家无法开具发票" maxlength={500} />}{removeError && <div id="remove-attachment-error" className="expense-error" role="alert"><AlertCircle size={16} />{removeError}</div>}</>}</Dialog>
<Dialog visible={Boolean(removeTarget)} header="删除附件?" confirmBtn={{ content: "删除附件", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <><p>{removeTarget.kind === "payment_proof" ? "账目至少需要保留一张付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "这是最后一张发票。删除后必须填写无发票原因。" : "将删除这张发票。"}</p>{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() && <Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(removeError)} aria-describedby={removeError ? "remove-attachment-error" : undefined} value={removeReason} onChange={value => { setRemoveReason(value); setRemoveError(""); }} placeholder="例如:商家无法开具发票" maxlength={500} />}{removeError && <div id="remove-attachment-error" className="expense-error" role="alert">{removeError}</div>}</>}</Dialog>
<Dialog visible={Boolean(preview)} header={preview?.originalName} onClose={() => setPreview(null)} cancelBtn="关闭" footer={null}>{preview && <div className="tn-preview">{preview.mimeType.startsWith("image/") ? <img src={`/api/attachments/${preview.id}/content`} alt={preview.originalName} /> : <iframe src={`/api/attachments/${preview.id}/content`} title={preview.originalName} />}</div>}</Dialog>
</>;
}
File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
export type MockScenario =
| "latest" // 已是最新
| "available" // 发现新版本(待下载)
| "downloading_30" // 下载中 30%
| "downloading_85" // 下载中 85% + 高速
| "staged" // 下载完成已校验,待立即更新
| "backing_up" // 正在备份数据
| "applying" // 正在原子切换并重启中(倒计时)
| "completed" // 更新完成
| "failed_verify" // 完整性校验失败
| "disabled"; // 手动模式未配置源
export interface MockUpdateState {
info: any;
title: string;
description: string;
}
export const MOCK_SCENARIOS: Record<MockScenario, MockUpdateState> = {
latest: {
title: "版本健康(已是最新)",
description: "展示当前运行版本已是最新,各项指标正常,无待处理任务",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 600_000,
latest: {
version: "1.1.22",
tagName: "v1.1.22",
releaseName: "v1.1.22 稳定版",
publishedAt: new Date(Date.now() - 3600_000 * 24).toISOString(),
compatible: true,
integrityReady: true,
signatureReady: true,
isNewer: false,
assetName: "tallynote-1.1.22-linux-x64-glibc.tar.gz",
assetSize: 120540160,
notes: "### TallyNote 1.1.22\n\n- 优化反向代理下登录兼容性\n- 增强安全审计与防重放机制\n- 前端组件性能深度优化",
},
job: null,
},
},
available: {
title: "发现新版本(待下载)",
description: "检查到官方发布了更高版本,显示更新日志与文件校验信息,可点击下载",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 60_000,
latest: {
version: "1.1.23",
tagName: "v1.1.23",
releaseName: "v1.1.23 重大更新",
publishedAt: new Date(Date.now() - 1800_000).toISOString(),
compatible: true,
integrityReady: true,
signatureReady: true,
isNewer: true,
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
assetSize: 121000000,
notes: "### TallyNote 1.1.23\n\n- 【新功能】系统更新中心全面重构,支持动态速率流光进度条与平滑重启倒计时\n- 【交互】优化抽屉展开动效与手机端自适应导航\n- 【安全】发布包支持双重 Ed25519 签名与 SHA-256 清单交叉校验",
},
job: null,
},
},
downloading_30: {
title: "下载更新中(进度 38%)",
description: "展示真实下载速率、已下载字节数与动态流光进度条",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: {
version: "1.1.23",
tagName: "v1.1.23",
compatible: true,
integrityReady: true,
signatureReady: true,
isNewer: true,
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
assetSize: 121000000,
},
job: {
id: "mock-job-001",
operation: "download",
status: "downloading",
version: "1.1.23",
platform: "x64/glibc",
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
sizeBytes: 121000000,
downloadedBytes: 46200000,
downloadStartedAt: Date.now() - 10000,
downloadSpeedBps: 8800000, // 8.4 MB/s
createdAt: Date.now() - 10000,
updatedAt: Date.now(),
},
},
},
downloading_85: {
title: "下载冲刺中(进度 88%)",
description: "高速冲刺状态,即将触发 SHA-256 校验",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: {
version: "1.1.23",
tagName: "v1.1.23",
compatible: true,
integrityReady: true,
signatureReady: true,
isNewer: true,
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
assetSize: 121000000,
},
job: {
id: "mock-job-002",
operation: "download",
status: "downloading",
version: "1.1.23",
platform: "x64/glibc",
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
sizeBytes: 121000000,
downloadedBytes: 106480000,
downloadStartedAt: Date.now() - 15000,
downloadSpeedBps: 12500000, // 11.9 MB/s
createdAt: Date.now() - 15000,
updatedAt: Date.now(),
},
},
},
staged: {
title: "下载完成(待立即应用)",
description: "更新包与签名均已校验就绪,随时可以安全点击【立即更新】",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: {
version: "1.1.23",
tagName: "v1.1.23",
compatible: true,
integrityReady: true,
signatureReady: true,
isNewer: true,
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
assetSize: 121000000,
notes: "### TallyNote 1.1.23\n\n- 更新包已完整解压检验通过,具备升级条件。",
},
job: {
id: "mock-job-003",
operation: "download",
status: "staged",
version: "1.1.23",
platform: "x64/glibc",
assetName: "tallynote-1.1.23-linux-x64-glibc.tar.gz",
sizeBytes: 121000000,
downloadedBytes: 121000000,
createdAt: Date.now() - 60000,
updatedAt: Date.now() - 5000,
},
},
},
backing_up: {
title: "数据备份中(更新保护)",
description: "正在为 /var/lib/tallynote 生成自动还原快照",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: { version: "1.1.23", tagName: "v1.1.23", compatible: true, integrityReady: true, signatureReady: true, isNewer: true },
job: {
id: "mock-job-004",
operation: "apply",
status: "backing_up",
version: "1.1.23",
platform: "x64/glibc",
createdAt: Date.now() - 20000,
updatedAt: Date.now() - 2000,
},
},
},
applying: {
title: "服务平滑重启中(倒计时中)",
description: "已原子切换版本,systemd 正在热重启,前端实时探活",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: { version: "1.1.23", tagName: "v1.1.23", compatible: true, integrityReady: true, signatureReady: true, isNewer: true },
job: {
id: "mock-job-005",
operation: "apply",
status: "applying",
version: "1.1.23",
platform: "x64/glibc",
applyQueuedAt: Date.now() - 12000,
restartWindowSeconds: 30,
restartDeadline: Date.now() + 18000,
createdAt: Date.now() - 25000,
updatedAt: Date.now() - 2000,
},
},
},
completed: {
title: "更新成功完成",
description: "新版本健康检查通过,已平滑无感升级至最新",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.23",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 30_000,
latest: { version: "1.1.23", tagName: "v1.1.23", compatible: true, integrityReady: true, signatureReady: true, isNewer: false },
job: {
id: "mock-job-006",
operation: "apply",
status: "completed",
version: "1.1.23",
platform: "x64/glibc",
completedAt: Date.now() - 10000,
createdAt: Date.now() - 45000,
updatedAt: Date.now() - 10000,
},
},
},
failed_verify: {
title: "更新失败状态(安全拦截)",
description: "模拟签名不匹配或发布包篡改时的安全拦截展示与错误提示",
info: {
configured: true,
strategy: "systemd",
currentVersion: "1.1.22",
platform: { target: "x64/glibc", os: "linux", arch: "x64" },
checkedAt: Date.now() - 120_000,
latest: {
version: "1.1.23",
tagName: "v1.1.23",
compatible: true,
integrityReady: false,
signatureReady: false,
isNewer: true,
},
job: {
id: "mock-job-007",
operation: "download",
status: "failed",
version: "1.1.23",
platform: "x64/glibc",
errorMessage: "发布包 SHA-256 校验与清单不一致,系统已自动阻断并保护原有数据。",
createdAt: Date.now() - 30000,
updatedAt: Date.now() - 5000,
},
},
},
disabled: {
title: "手动源码模式",
description: "未接入 systemd 时的只读说明与命令引导展示",
info: {
configured: false,
strategy: "disabled",
currentVersion: "1.1.22",
platform: { target: "macOS/darwin", os: "darwin", arch: "arm64" },
checkedAt: Date.now() - 3600_000,
latest: null,
job: null,
},
},
};
+570
View File
@@ -410,6 +410,34 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
@keyframes tn-app-enter { from { opacity: 0; } to { opacity: 1; } }
@keyframes tn-auth-page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.t-dialog { border: 1px solid var(--tn-border); border-radius: 4px; box-shadow: 0 18px 42px rgba(8, 47, 118, .18); }
/* TDesign Dialog global no-icon & pure baseline typography normalization */
.t-dialog__header .t-icon:not(.t-icon-close),
.t-dialog__body .t-icon,
.t-dialog .t-icon.t-is-info,
.t-dialog .t-icon.t-is-success,
.t-dialog .t-icon.t-is-warning,
.t-dialog .t-icon.t-is-error {
display: none !important;
}
.t-dialog__header {
font-size: 16px;
font-weight: 600;
color: var(--tn-navy-900);
line-height: 1.4;
margin-bottom: 8px;
gap: 0 !important;
}
.t-dialog__header-content {
display: block !important;
width: 100%;
}
.t-dialog__body {
font-size: 13.5px;
line-height: 1.65;
color: var(--tn-text);
padding: 8px 0 20px 0;
}
.t-dialog__mask { background: var(--td-mask-active) !important; }
.t-dialog__footer .t-button { min-width: 76px; }
@media (max-width: 900px) {
@@ -537,3 +565,545 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
}
/* ==========================================================================
TallyNote Update Center (Redesigned UI & Interactive Styles)
========================================================================== */
/* Mock Console Controller */
.tn-mock-console {
margin-bottom: 16px;
padding: 14px 18px;
background: #f8fbff;
border: 1px dashed var(--td-brand-color-3);
border-radius: 4px;
}
.tn-mock-console-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.tn-mock-console-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--tn-navy-900);
}
.tn-mock-console-tip {
font-size: 12px;
color: var(--tn-text-secondary);
}
.tn-mock-scenario-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tn-scenario-chip {
padding: 5px 11px;
font-size: 12px;
border: 1px solid var(--tn-border);
border-radius: 3px;
background: #ffffff;
color: var(--tn-text-secondary);
cursor: pointer;
transition: all 0.16s ease;
}
.tn-scenario-chip:hover {
border-color: var(--td-brand-color-4);
color: var(--td-brand-color);
background: var(--td-brand-color-1);
}
.tn-scenario-chip.active {
background: var(--td-brand-color);
border-color: var(--td-brand-color);
color: #ffffff;
font-weight: 600;
box-shadow: 0 2px 6px rgba(23, 92, 211, 0.2);
}
/* Update Page Actions */
.tn-update-page-actions {
display: flex;
align-items: center;
gap: 10px;
}
/* 4 Metrics Grid */
.tn-update-metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.tn-metric-card .t-card__body {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px 18px;
min-height: 104px;
}
.tn-metric-content {
width: 100%;
}
.tn-metric-label {
display: block;
font-size: 12px;
color: var(--tn-text-secondary);
margin-bottom: 4px;
}
.tn-metric-value {
font-size: 20px;
font-weight: 700;
color: var(--tn-navy-900);
font-family: "Plus Jakarta Sans Variable", sans-serif;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.tn-metric-foot {
margin-top: 6px;
font-size: 12px;
color: var(--tn-text-muted);
}
/* Stepper Surface */
.tn-stepper-surface {
margin-bottom: 16px;
padding: 20px;
}
.tn-stepper-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.tn-section-heading {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: var(--tn-text);
}
.tn-section-subheading {
font-size: 12px;
color: var(--tn-text-secondary);
}
.tn-stepper-wrap {
padding: 10px 0 20px;
}
.tn-stepper-wrap .t-steps {
max-width: 860px;
margin: 0 auto;
}
.tn-stepper-wrap {
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--td-brand-color-3) transparent;
}
.tn-stepper-wrap .t-steps {
min-width: 680px;
}
/* Job Runtime Panel (Active download & progress) */
.tn-job-runtime-panel {
margin-top: 14px;
padding: 16px;
background: #f8fbff;
border: 1px solid var(--td-brand-color-2);
border-radius: 4px;
}
.tn-job-runtime-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.tn-job-status-badge {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--tn-navy-900);
min-width: 0;
overflow-wrap: anywhere;
}
.tn-pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--td-brand-color);
box-shadow: 0 0 0 0 rgba(23, 92, 211, 0.7);
animation: tn-pulse 1.8s infinite;
}
@keyframes tn-pulse {
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(23, 92, 211, 0.7); }
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(23, 92, 211, 0); }
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(23, 92, 211, 0); }
}
.tn-job-subtext {
color: var(--tn-text-secondary);
font-size: 12px;
font-family: "Plus Jakarta Sans Variable", sans-serif;
font-variant-numeric: tabular-nums;
}
.tn-speed-indicator {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.tn-speed-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 3px;
background: #ffffff;
border: 1px solid var(--td-brand-color-3);
color: var(--td-brand-color);
font-size: 12px;
font-weight: 600;
font-family: "Plus Jakarta Sans Variable", sans-serif;
font-variant-numeric: tabular-nums;
}
.tn-eta-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
background: #f2f5f9;
color: var(--tn-text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
/* Progress Bar with modern stream light effect */
.tn-progress-stream {
position: relative;
height: 8px;
background: #e1e9f4;
border-radius: 999px;
overflow: hidden;
margin-bottom: 12px;
}
.tn-progress-stream-bar {
height: 100%;
background: linear-gradient(90deg, #175cd3 0%, #3d7be5 100%);
border-radius: inherit;
transition: width 0.35s ease;
position: relative;
}
.tn-progress-stream-bar::after {
content: "";
position: absolute;
top: 0; left: 0; bottom: 0; right: 0;
background-image: linear-gradient(
-45deg,
rgba(255, 255, 255, 0.25) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.25) 50%,
rgba(255, 255, 255, 0.25) 75%,
transparent 75%,
transparent
);
background-size: 30px 30px;
animation: tn-stream-flow 1.5s linear infinite;
}
@keyframes tn-stream-flow {
0% { background-position: 0 0; }
100% { background-position: 30px 30px; }
}
.tn-job-runtime-desc {
font-size: 12px;
color: var(--tn-text-secondary);
line-height: 1.6;
}
/* Restarting State Banner */
.tn-restarting-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #fff8e6;
border: 1px solid #ffd666;
border-radius: 3px;
color: #8c5b00;
}
.tn-restarting-spinner {
color: #faad14;
}
/* Release Surface */
.tn-release-surface {
margin-bottom: 16px;
padding: 20px;
}
.tn-release-notes-surface {
margin-bottom: 16px;
padding: 20px;
}
.tn-release-notes-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.tn-release-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.tn-release-tag-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.tn-release-title {
margin: 0 0 6px;
font-size: 22px;
font-weight: 700;
color: var(--tn-navy-900);
}
.tn-release-time {
font-size: 12px;
color: var(--tn-text-secondary);
}
.tn-release-notes-box {
margin: 0;
padding: 12px 16px;
background: #f8fafc;
border: 1px solid var(--tn-border);
border-left: 3px solid var(--td-brand-color);
border-radius: 3px;
}
.tn-release-notes-heading {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--tn-navy-900);
margin-bottom: 6px;
}
.tn-release-notes-content {
font-size: 13px;
color: var(--tn-text-secondary);
line-height: 1.6;
max-height: 140px;
overflow-y: auto;
}
.tn-markdown-notes {
color: inherit;
line-height: 1.7;
overflow-wrap: anywhere;
}
.tn-markdown-notes p,
.tn-markdown-notes h3,
.tn-markdown-notes h4,
.tn-markdown-notes h5 {
margin: 0 0 8px;
}
.tn-markdown-notes p:last-child,
.tn-markdown-notes ul:last-child,
.tn-markdown-notes pre:last-child,
.tn-markdown-notes h3:last-child,
.tn-markdown-notes h4:last-child,
.tn-markdown-notes h5:last-child {
margin-bottom: 0;
}
.tn-markdown-notes h3,
.tn-markdown-notes h4,
.tn-markdown-notes h5 {
color: var(--tn-navy-900);
font-weight: 700;
}
.tn-markdown-notes h3 { font-size: 15px; }
.tn-markdown-notes h4 { font-size: 14px; }
.tn-markdown-notes h5 { font-size: 13px; }
.tn-markdown-notes ul {
margin: 0 0 8px;
padding-left: 20px;
}
.tn-markdown-notes li { margin: 3px 0; }
.tn-markdown-notes strong { color: var(--tn-navy-900); font-weight: 700; }
.tn-markdown-notes code {
padding: 1px 4px;
border: 1px solid var(--tn-border-subtle);
border-radius: 3px;
background: #f2f5f9;
color: var(--tn-navy-900);
font-family: "Plus Jakarta Sans Variable", ui-monospace, monospace;
font-size: .92em;
}
.tn-markdown-notes pre {
margin: 0 0 8px;
padding: 10px 12px;
overflow-x: auto;
border: 1px solid var(--tn-border-subtle);
border-radius: 3px;
background: #f8fafc;
white-space: pre-wrap;
}
.tn-markdown-notes pre code { padding: 0; border: 0; background: transparent; }
.tn-markdown-notes a { color: var(--td-brand-color); text-decoration: underline; text-underline-offset: 2px; }
.tn-markdown-notes-compact { font-size: 13px; }
/* Facts Grid */
.tn-facts-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin: 18px 0;
padding: 14px 0;
border-top: 1px solid var(--tn-border-subtle);
border-bottom: 1px solid var(--tn-border-subtle);
}
.tn-fact-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.tn-fact-label {
font-size: 12px;
color: var(--tn-text-secondary);
}
.tn-fact-value {
font-size: 14px;
color: var(--tn-text);
font-weight: 600;
overflow-wrap: anywhere;
}
.tn-fact-hint {
font-size: 11px;
color: var(--tn-text-muted);
}
.tn-release-card-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
/* Dialog content styles */
.tn-confirm-dialog-content {
font-size: 13px;
line-height: 1.6;
color: var(--tn-text);
}
.tn-update-safety-tips {
margin-top: 10px;
padding: 10px 14px;
background: #f6f8fb;
border-radius: 3px;
border: 1px solid var(--tn-border-subtle);
font-size: 12px;
color: var(--tn-text-secondary);
display: flex;
flex-direction: column;
gap: 4px;
}
.tn-full-notes-modal {
margin: 0;
padding: 12px;
background: #f8fafc;
border-radius: 3px;
font-size: 13px;
line-height: 1.6;
color: var(--tn-text);
max-height: 50vh;
overflow-y: auto;
}
.tn-empty-surface {
padding: 36px 20px;
text-align: center;
}
.tn-empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--tn-text-secondary);
font-size: 13px;
}
.tn-inline-warning {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 3px;
color: #d48806;
font-size: 13px;
}
/* Responsive adjustments */
@media (max-width: 992px) {
.tn-update-metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tn-facts-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.tn-update-metrics-grid {
grid-template-columns: 1fr;
}
.tn-mock-console-header {
flex-direction: column;
align-items: flex-start;
}
.tn-release-header {
flex-direction: column;
}
.tn-release-notes-header {
align-items: flex-start;
flex-direction: column;
}
.tn-release-notes-header .t-button {
width: 100%;
}
.tn-release-card-actions {
flex-direction: column;
width: 100%;
}
.tn-release-card-actions .t-button {
width: 100%;
}
.tn-stepper-wrap {
margin-inline: -4px;
padding-inline: 4px;
}
.tn-stepper-wrap .t-steps {
min-width: 620px;
}
.tn-job-runtime-header {
align-items: stretch;
}
.tn-job-status-badge,
.tn-speed-indicator {
width: 100%;
}
.tn-speed-indicator {
justify-content: flex-start;
}
}
+1 -1
View File
@@ -771,7 +771,7 @@ function Update({ notify }: { notify: (message: string, kind?: Notice["kind"]) =
const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && (!job || job.status === "failed" || job.version !== latest.version));
return <div className="page update-page">
<div className="page-head"><div><div className="eyebrow">系统</div><h1>系统更新</h1></div><div className="head-actions"><Button onClick={() => void load()} disabled={loading || checking}><RotateCcw size={15} />刷新</Button><Button kind="primary" onClick={() => void check()} disabled={loading || checking || hasActiveJob}><RefreshCw size={15} className={checking ? "spin" : undefined} />检查更新</Button></div></div>
<div className="page-head"><div><div className="eyebrow">系统</div><h1>系统更新</h1></div><div className="head-actions"><Button onClick={() => void load()} disabled={loading || checking}><RotateCcw size={15} />刷新</Button><Button kind="primary" onClick={() => void check()} disabled={loading || checking}><RefreshCw size={15} className={checking ? "spin" : undefined} />检查更新</Button></div></div>
{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={() => void load()}>重试</button></div>}
{loading ? <div className="update-loading"><Loader2 className="spin" size={22} />正在读取版本信息</div> : info && <>
<div className="update-overview">