|
| 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