Skip to content

Commit 60b1a17

Browse files
feat(update): one-click in-app auto-update for the frozen build
The settings page could only link to the release zip for manual extract-and- overwrite. Add a real self-update: the backend downloads the OS-matched release, hands off to a detached OS-native helper that waits for this process to exit, swaps the app files in place (leaving oasis.db/movies/ untouched), and relaunches the backend; the UI polls /api/health and reconnects. - backend/updater.py: download + generate/spawn sh/PowerShell helper, then exit - POST /api/update/apply endpoint - settings page "立即更新" button with download → restart → re-check flow, keeping a manual-download fallback for source checkouts / failures - README: document both update paths Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5ecbf33 commit 60b1a17

5 files changed

Lines changed: 394 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ chmod +x oasis-portal.sh
8686
8787
---
8888
89+
## 🔄 軟體更新 (Updating)
90+
91+
Oasis 有兩種更新方式,依你的安裝來源自動採用:
92+
93+
- **原始碼版(`git clone` + 啟動腳本)**:`oasis-portal.sh` / `oasis-portal.ps1` 每次啟動時會自動 `git pull --ff-only` 拉取最新程式碼,無需手動操作(離線時會略過並沿用本機版本)。
94+
- **打包版(GitHub Releases 的 zip)**:到 **設定頁 → 關於與更新** 按「檢查更新」;若有新版本,按 **「立即更新」** 即可一鍵完成:
95+
1. 後端下載對應你作業系統的最新 Release
96+
2. 一支獨立的 helper 程序在後端關閉後就地抽換程式檔案
97+
3. 後端自行重新啟動,網頁前端會自動重新連線
98+
99+
你的資料庫(`oasis.db`)與影片(`movies/`)不在更新包內,會**完整保留**。更新期間請避免有下載工作正在進行,以免檔案被占用。若一鍵更新失敗,仍可透過同區塊的「或手動下載」連結取得 zip,解壓縮覆蓋原資料夾即可。
100+
101+
> 版本號來自 CI 建置時寫入的 `VERSION` 檔(發行的 git tag);原始碼直接執行時會顯示為 `dev`,並被視為「永遠不落後」,因此不會被提示更新。
102+
103+
---
104+
89105
## 🧩 站台 Adapter 設定 (Site Adapters)
90106
91107
本工具是一個**通用的網頁讀取/下載引擎,本身不內建任何特定網站的定義**。要解析或下載某個網站,需由使用者自行提供一份該網站的「adapter」設定檔:

‎backend/api.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,20 @@ def update_check():
163163
return version.check_for_update()
164164

165165

166+
@app.post('/api/update/apply')
167+
def update_apply():
168+
"""Download the latest release and swap it in, then relaunch the backend.
169+
170+
Sync `def` so FastAPI runs the (blocking) download in a threadpool. On
171+
success the backend exits ~1.5s after responding while a detached helper
172+
performs the file swap and relaunch, so the frontend should poll
173+
/api/health and reconnect. Only meaningful for the frozen build; a source
174+
checkout gets an error telling it to use git.
175+
"""
176+
import updater
177+
return updater.apply_update()
178+
179+
166180
@app.get('/api/supported-sites')
167181
def supported_sites():
168182
"""List the sites the URL analyser currently supports (for the UI).

‎backend/updater.py‎

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""
4+
Apply an in-app update for the frozen (PyInstaller onedir) build.
5+
6+
The settings page can already *check* GitHub Releases (see version.py). This
7+
module performs the actual swap: download the new build's zip, then hand off to
8+
a small OS-native helper script that waits for this backend to exit, replaces
9+
the app files in place, and relaunches the new backend.
10+
11+
Why a helper script and not plain Python:
12+
13+
* A running process cannot overwrite its own executable / loaded libraries
14+
(hard error on Windows, and `_internal/` holds the frozen Python itself), so
15+
the swap must happen *after* this process exits.
16+
* The helper is OS-native (sh / PowerShell) so it can keep running once this
17+
process is gone, and so extraction uses `ditto`/`unzip`/`Expand-Archive`,
18+
which preserve the macOS bundle's symlinks and exec bits that Python's
19+
zipfile would flatten.
20+
21+
User data (oasis.db, movies/) lives next to the executable but is NOT part of
22+
the release zip, so the entry-by-entry swap leaves it untouched.
23+
24+
Only meaningful for a frozen build; a source checkout updates via `git pull`
25+
(the portal launcher already does this) and returns an error here.
26+
"""
27+
28+
import os
29+
import subprocess
30+
import sys
31+
import threading
32+
import urllib.request
33+
34+
import version
35+
36+
# Folder (next to the executable) that holds the downloaded zip, the generated
37+
# helper script, and its log. Kept out of the way with a leading dot.
38+
_WORK_DIRNAME = ".oasis-update"
39+
40+
# Grace period after responding before this process exits, so the HTTP response
41+
# reaches the browser first. The helper is already spawned and blocks on our PID.
42+
_SHUTDOWN_DELAY_S = 1.5
43+
44+
45+
def _base_dir() -> str:
46+
"""Install folder holding the executable (and the user's data)."""
47+
return os.path.dirname(sys.executable)
48+
49+
50+
def _download(url: str, dest: str) -> None:
51+
"""Stream the release asset to `dest`. Raises on any network/HTTP error."""
52+
request = urllib.request.Request(url, headers={"User-Agent": "oasis-updater"})
53+
with urllib.request.urlopen(request, timeout=30) as response, open(dest, "wb") as out:
54+
while True:
55+
chunk = response.read(1 << 16)
56+
if not chunk:
57+
break
58+
out.write(chunk)
59+
if os.path.getsize(dest) == 0:
60+
raise OSError("下載的更新檔為空")
61+
62+
63+
def _write_helper(base: str, work: str, zip_path: str, exe: str) -> str:
64+
"""Write the OS-native updater script and return its path."""
65+
if sys.platform == "win32":
66+
return _write_helper_windows(base, work, zip_path, exe)
67+
return _write_helper_posix(base, work, zip_path, exe)
68+
69+
70+
def _write_helper_posix(base: str, work: str, zip_path: str, exe: str) -> str:
71+
script = f"""#!/bin/sh
72+
# Auto-generated by oasis updater.py — safe to delete.
73+
BASE='{base}'
74+
WORK='{work}'
75+
ZIP='{zip_path}'
76+
OLDPID={os.getpid()}
77+
EXENAME='{os.path.basename(exe)}'
78+
STAGING="$WORK/staging"
79+
LOG="$WORK/update.log"
80+
exec >>"$LOG" 2>&1
81+
echo "=== oasis update $(date) ==="
82+
83+
# 1. Wait for the old backend to exit so its files unlock (cap ~120s).
84+
i=0
85+
while kill -0 "$OLDPID" 2>/dev/null; do
86+
sleep 0.5; i=$((i+1)); [ "$i" -ge 240 ] && break
87+
done
88+
sleep 1
89+
90+
# 2. Extract the fresh build (native tools preserve symlinks + exec bits).
91+
rm -rf "$STAGING"; mkdir -p "$STAGING"
92+
if command -v ditto >/dev/null 2>&1; then
93+
ditto -x -k "$ZIP" "$STAGING"
94+
else
95+
unzip -q -o "$ZIP" -d "$STAGING"
96+
fi
97+
98+
# 3. Locate the app root (some zips wrap everything in a single folder).
99+
ROOT="$STAGING"
100+
if [ ! -e "$ROOT/$EXENAME" ]; then
101+
inner=$(find "$STAGING" -maxdepth 2 -name "$EXENAME" -print 2>/dev/null | head -n 1)
102+
[ -n "$inner" ] && ROOT=$(dirname "$inner")
103+
fi
104+
105+
if [ ! -e "$ROOT/$EXENAME" ]; then
106+
echo "ERROR: new build is missing $EXENAME — aborting swap, relaunching old build"
107+
else
108+
# 4. Swap each top-level entry into the install dir. oasis.db / movies/ aren't
109+
# in the zip, so they're left in place.
110+
for src in "$ROOT"/* "$ROOT"/.[!.]*; do
111+
[ -e "$src" ] || continue
112+
name=$(basename "$src")
113+
rm -rf "$BASE/$name"
114+
mv "$src" "$BASE/$name"
115+
done
116+
echo "swap complete"
117+
fi
118+
119+
# 5. Clear the download quarantine + ensure the exec bit, then relaunch.
120+
xattr -dr com.apple.quarantine "$BASE" 2>/dev/null || true
121+
chmod +x "$BASE/$EXENAME" 2>/dev/null || true
122+
cd "$BASE"
123+
"$BASE/$EXENAME" >"$WORK/backend.log" 2>&1 &
124+
125+
# 6. Cleanup.
126+
rm -rf "$STAGING" "$ZIP"
127+
echo "=== done ==="
128+
"""
129+
path = os.path.join(work, "apply-update.sh")
130+
with open(path, "w", encoding="utf-8") as f:
131+
f.write(script)
132+
os.chmod(path, 0o755)
133+
return path
134+
135+
136+
def _write_helper_windows(base: str, work: str, zip_path: str, exe: str) -> str:
137+
exe_name = os.path.basename(exe)
138+
script = f"""# Auto-generated by oasis updater.py — safe to delete.
139+
$Base = '{base}'
140+
$Work = '{work}'
141+
$Zip = '{zip_path}'
142+
$OldPid = {os.getpid()}
143+
$ExeName = '{exe_name}'
144+
$Staging = Join-Path $Work 'staging'
145+
$Log = Join-Path $Work 'update.log'
146+
function Log($m) {{ Add-Content -LiteralPath $Log -Value ("{{0}} {{1}}" -f (Get-Date -Format o), $m) }}
147+
Log '=== oasis update ==='
148+
149+
# 1. Wait for the old backend to exit so its files unlock (cap ~120s).
150+
$deadline = (Get-Date).AddSeconds(120)
151+
while ((Get-Process -Id $OldPid -ErrorAction SilentlyContinue) -and ((Get-Date) -lt $deadline)) {{
152+
Start-Sleep -Milliseconds 500
153+
}}
154+
Start-Sleep -Seconds 1
155+
156+
try {{
157+
# 2. Extract the fresh build.
158+
if (Test-Path -LiteralPath $Staging) {{ Remove-Item -LiteralPath $Staging -Recurse -Force }}
159+
New-Item -ItemType Directory -Force -Path $Staging | Out-Null
160+
Expand-Archive -LiteralPath $Zip -DestinationPath $Staging -Force
161+
162+
# 3. Locate the app root (some zips wrap everything in a single folder).
163+
$Root = $Staging
164+
if (-not (Test-Path -LiteralPath (Join-Path $Root $ExeName))) {{
165+
$found = Get-ChildItem -LiteralPath $Staging -Recurse -Filter $ExeName -ErrorAction SilentlyContinue | Select-Object -First 1
166+
if ($found) {{ $Root = $found.DirectoryName }}
167+
}}
168+
169+
if (-not (Test-Path -LiteralPath (Join-Path $Root $ExeName))) {{
170+
Log "ERROR: new build is missing $ExeName - aborting swap"
171+
}} else {{
172+
# 4. Swap each top-level entry into the install dir. oasis.db / movies\\
173+
# aren't in the zip, so they're left in place.
174+
Get-ChildItem -LiteralPath $Root -Force | ForEach-Object {{
175+
$dst = Join-Path $Base $_.Name
176+
if (Test-Path -LiteralPath $dst) {{ Remove-Item -LiteralPath $dst -Recurse -Force }}
177+
Move-Item -LiteralPath $_.FullName -Destination $dst -Force
178+
}}
179+
Log 'swap complete'
180+
}}
181+
}} catch {{
182+
Log ("EXCEPTION: " + $_.Exception.Message)
183+
}}
184+
185+
# 5. Relaunch (new build if swapped, otherwise the old one is still in place).
186+
Start-Process -FilePath (Join-Path $Base $ExeName) -WorkingDirectory $Base
187+
188+
# 6. Cleanup.
189+
if (Test-Path -LiteralPath $Staging) {{ Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue }}
190+
Remove-Item -LiteralPath $Zip -Force -ErrorAction SilentlyContinue
191+
Log '=== done ==='
192+
"""
193+
path = os.path.join(work, "apply-update.ps1")
194+
with open(path, "w", encoding="utf-8") as f:
195+
f.write(script)
196+
return path
197+
198+
199+
def _spawn_detached(script: str) -> None:
200+
"""Launch the helper so it outlives this process."""
201+
if sys.platform == "win32":
202+
DETACHED_PROCESS = 0x00000008
203+
CREATE_NEW_PROCESS_GROUP = 0x00000200
204+
subprocess.Popen(
205+
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script],
206+
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
207+
close_fds=True,
208+
)
209+
else:
210+
subprocess.Popen(
211+
["/bin/sh", script],
212+
start_new_session=True,
213+
stdin=subprocess.DEVNULL,
214+
stdout=subprocess.DEVNULL,
215+
stderr=subprocess.DEVNULL,
216+
close_fds=True,
217+
)
218+
219+
220+
def apply_update(timeout: float = 6.0) -> dict:
221+
"""Download the latest release and hand off to the helper that swaps it in.
222+
223+
On success the response is `{"status": "updating", "latest": <tag>}` and this
224+
process exits shortly after, so the frontend should poll /api/health and
225+
reconnect once the relaunched backend answers. Failures return
226+
`{"status": "error", "error": <msg>}` and leave the running backend intact.
227+
"""
228+
if not getattr(sys, "frozen", False):
229+
return {
230+
"status": "error",
231+
"error": "自動更新僅適用於打包版。原始碼版本請用 git pull(啟動腳本會自動更新)。",
232+
}
233+
234+
info = version.check_for_update(timeout)
235+
if info.get("error"):
236+
return {"status": "error", "error": info["error"]}
237+
if not info.get("update_available"):
238+
return {"status": "error", "error": "已是最新版本,沒有可用的更新。"}
239+
240+
download_url = info.get("download_url")
241+
if not download_url:
242+
return {"status": "error", "error": "找不到適用於此系統的更新下載檔。"}
243+
244+
base = _base_dir()
245+
work = os.path.join(base, _WORK_DIRNAME)
246+
zip_path = os.path.join(work, "download.zip")
247+
try:
248+
os.makedirs(work, exist_ok=True)
249+
_download(download_url, zip_path)
250+
script = _write_helper(base, work, zip_path, sys.executable)
251+
_spawn_detached(script)
252+
except Exception as exc: # download failed, disk full, spawn blocked, ...
253+
return {"status": "error", "error": f"更新失敗:{exc}"}
254+
255+
# Give the response time to flush before we exit and unlock our files; the
256+
# helper is already waiting on this PID and will swap + relaunch.
257+
threading.Timer(_SHUTDOWN_DELAY_S, lambda: os._exit(0)).start()
258+
return {"status": "updating", "latest": info.get("latest")}

0 commit comments

Comments
 (0)