From 508813ff213905d9aa456777ed9c6a640d63a515 Mon Sep 17 00:00:00 2001 From: Colin955023 <123829404+Colin955023@users.noreply.github.com> Date: Wed, 13 May 2026 12:29:02 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E8=B3=87?= =?UTF-8?q?=E6=BA=90=E9=81=B8=E6=93=87=E9=82=8F=E8=BC=AF=EF=BC=8C=E5=84=AA?= =?UTF-8?q?=E5=85=88=E9=81=B8=E6=93=87=E5=AE=89=E8=A3=9D=E7=A8=8B=E5=BC=8F?= =?UTF-8?q?=E6=AA=94=E6=A1=88=E4=B8=A6=E7=A7=BB=E9=99=A4=E5=8F=AF=E6=94=9C?= =?UTF-8?q?=E5=BC=8F=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8F=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 `UpdateParsing` 類別,調整選擇安裝程式的邏輯,僅返回符合條件的安裝程式資源。 - 移除可攜式資源的選擇邏輯,簡化更新資源的選擇流程。 - 新增測試以驗證更新資源選擇的正確性。 fix: 修正檢查資源時的錯誤處理 - 在 `update_parsing.py` 中,改善資源檢查的錯誤處理,確保在發生異常時不會中斷流程。 test: 增加針對更新檢查器的單元測試 - 新增測試檔案 `test_update_checker_installer_smoke.py`,涵蓋安裝程式啟動參數的生成及用戶互動的模擬。 - 移除不再需要的測試檔案 `test_update_checker_path_script.py`。 test: 增加對資源解析的測試 - 新增測試檔案 `test_update_parsing_smoke.py`,驗證最新版本的獲取及資源選擇的正確性。 --- .github/workflows/build-release.yml | 12 +- CHANGELOG.md | 2 +- README.md | 21 +- docs/PORTABLE_INSTALLER_MATRIX.md | 44 +- docs/TECHNICAL_OVERVIEW.md | 50 +- docs/USER_GUIDE.md | 26 +- scripts/build_installer_nuitka.py | 45 +- scripts/installer.iss | 117 ++++- scripts/package-portable.ps1 | 119 ----- src/core/local_mod_scanner.py | 52 ++- src/core/mod_manager.py | 13 +- src/ui/main_window.py | 2 +- src/ui/manage_server_frame.py | 31 +- src/ui/server_monitor_window.py | 7 +- src/utils/core_utils/path_utils.py | 75 ++- src/utils/network_utils/http_utils.py | 1 + src/utils/runtime_utils/app_restart.py | 4 +- src/utils/update_utils/update_checker.py | 426 +++--------------- src/utils/update_utils/update_parsing.py | 58 +-- tests/test_loader_manager_smoke.py | 3 - tests/test_local_mod_metadata_utils.py | 11 + tests/test_manage_server_frame_smoke.py | 52 +++ tests/test_mod_manager_local_file_ops.py | 26 ++ tests/test_path_utils_json_smoke.py | 14 +- tests/test_update_checker_installer_smoke.py | 150 ++++++ tests/test_update_checker_path_script.py | 87 ---- ..._smoke.py => test_update_parsing_smoke.py} | 44 +- 27 files changed, 726 insertions(+), 766 deletions(-) delete mode 100644 scripts/package-portable.ps1 create mode 100644 tests/test_update_checker_installer_smoke.py delete mode 100644 tests/test_update_checker_path_script.py rename tests/{test_update_parsing_fallback_smoke.py => test_update_parsing_smoke.py} (63%) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 6fb84ce..64b3ad4 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -21,6 +21,7 @@ jobs: permissions: contents: write discussions: write + id-token: write steps: - name: Checkout repository @@ -67,6 +68,12 @@ jobs: exit $LASTEXITCODE } + - name: Sign release artifacts + uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0 + with: + inputs: | + dist/*.exe + - name: Create and push release tag shell: pwsh run: | @@ -117,7 +124,10 @@ jobs: $tag = $env:RELEASE_TAG $prereleaseArgs = @() if ($tag -match '-pre') { $prereleaseArgs += '--prerelease' } - $assets = Get-ChildItem -Path 'dist' -File | Where-Object { $_.Name -match '^(MinecraftServerManager|.*-Setup-).*\.(exe|zip)$' } | ForEach-Object { $_.FullName } + $assets = Get-ChildItem -Path 'dist' -File | Where-Object { + $_.Name -match '^(MinecraftServerManager|.*-Setup-).*\.exe$' -or + $_.Name -match '^(MinecraftServerManager|.*-Setup-).*\.exe\.sigstore\.json$' + } | ForEach-Object { $_.FullName } if (-not $assets -or $assets.Count -eq 0) { Write-Host 'No release assets found in dist; aborting release creation' exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2292231..25eb11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ - **模組索引管線**:新增 `mod_index_manager`、`mod_provider_metadata` 與 `mod_semantics`,支援增量掃描與 provider 資料整併功能。 - **伺服器實例工具組**:新增 `server_instance` 及拆分後的 `server_detection_utils`、`server_detection_version_utils`、`server_properties_utils` 與 `server_runtime_utils` 以落實職責分離。 - **通用基礎設施**:新增 `background_task`(背景任務)、`atomic_writer`(原子寫入)、`exception_utils`(例外處理)、`update_parsing` 與 `virtual_list`(虛擬列表)等工具模組。 -- **自動化測試測試**:新增並擴充 Smoke 與整合測試,涵蓋模組管理、版本解析、設定檔 I/O 及 UI DPI 適應行為。 +- **自動化測試**:新增並擴充 Smoke 與整合測試,涵蓋模組管理、版本解析、設定檔 I/O 及 UI DPI 適應行為。 ### 調整 - **架構重構**:重構 `mod_management`、`manage_server_frame` 與 `main_window` UI 架構,降低模組間耦合度並改善操作流暢度。 diff --git a/README.md b/README.md index 1179d34..0749491 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![CI](https://github.com/Colin955023/MinecraftServerManager/actions/workflows/ci-test.yml/badge.svg)](https://github.com/Colin955023/MinecraftServerManager/actions/workflows/ci-test.yml) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Colin955023/MinecraftServerManager/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Colin955023/MinecraftServerManager) -Windows 上的 Minecraft 伺服器 GUI 管理工具。從建立伺服器、啟動監控到模組安裝更新,全程在圖形介面內完成,模組操作均附帶可審查的 Review 步驟。 +Windows 上的 Minecraft 伺服器 GUI 管理工具。從建立伺服器、啟動監控到模組安裝更新,主要流程都可在圖形介面內完成;線上模組安裝與本地更新提供可審查的 Review 步驟。 > **僅支援 Windows 10 / 11(64-bit)** > 介面使用 PySide6 / Qt Widgets,顯示縮放跟隨 Windows 與 Qt 高 DPI 行為。 @@ -18,25 +18,22 @@ Windows 上的 Minecraft 伺服器 GUI 管理工具。從建立伺服器、啟 - **建立伺服器** — Vanilla/Fabric/Forge/Quilt/NeoForge 精靈式設定流程 - **Java 管理** — 自動偵測已安裝 Java,缺少時可引導 winget 或手動安裝 - **即時監控** — 控制台輸出、記憶體、運行狀態與玩家資訊集中顯示 -- **模組管理** — 本地掃描 + Modrinth 線上搜尋,安裝前 Review 確認 +- **模組管理** — 本地掃描 + Modrinth 線上搜尋,線上安裝前 Review 確認 - **模組更新** — Hash-first 批次比對,相依套件自動規劃 - **匯入伺服器** — 掃描既有資料夾或壓縮檔快速匯入 -- **兩種發佈格式** — 可攜版(免安裝)與安裝版 +- **兩種安裝模式** — 同一個 installer 支援一般安裝與可攜式安裝 --- ## 安裝 -**可攜版(推薦初次使用)** +1. 前往 [Releases](https://github.com/Colin955023/MinecraftServerManager/releases) 下載最新的 `*-Setup-*.exe` +2. 執行安裝程式 +3. 選擇一般安裝,或選擇可攜式安裝並指定目標資料夾 -1. 前往 [Releases](https://github.com/Colin955023/MinecraftServerManager/releases) 下載最新的 `*-portable.zip` -2. 解壓縮至任意資料夾 -3. 執行 `MinecraftServerManager.exe` +一般安裝會使用 `%LOCALAPPDATA%\Programs\MinecraftServerManager`。可攜式安裝會在指定資料夾內建立 `.portable` 標記,並把資料寫入該資料夾下的 `.config` 與 `.log`。 -**安裝版** - -1. 下載最新的 `*-installer.exe` -2. 執行安裝程式,完成後由開始功能表啟動 +可攜式安裝不會建立 Windows 解除安裝項目;如要移除,請關閉程式後直接刪除整個指定資料夾。 --- @@ -100,7 +97,7 @@ report/ 產生綜合報告的腳本與輸出 - [使用者手冊](docs/USER_GUIDE.md) - [技術手冊](docs/TECHNICAL_OVERVIEW.md) -- [Portable / Installer 差異矩陣](docs/PORTABLE_INSTALLER_MATRIX.md) +- [可攜式 / 一般安裝差異矩陣](docs/PORTABLE_INSTALLER_MATRIX.md) --- diff --git a/docs/PORTABLE_INSTALLER_MATRIX.md b/docs/PORTABLE_INSTALLER_MATRIX.md index 2a95fc7..e30178a 100644 --- a/docs/PORTABLE_INSTALLER_MATRIX.md +++ b/docs/PORTABLE_INSTALLER_MATRIX.md @@ -1,22 +1,38 @@ -# Portable / Installer 差異矩陣 +# 可攜式 / 一般安裝差異矩陣 -本表格描述兩種發佈模式在路徑、更新、權限與回滾行為上的差異。 -實際行為以 `src/utils/runtime_paths.py`、`src/utils/update_checker.py` 為準。 +本文件描述執行模式、資料路徑與自動更新流程的差異。實際行為以以下程式碼為準: -| 項目 | Portable | Installer | +- `src/utils/runtime_utils/runtime_paths.py`:模式判定與資料路徑 +- `src/utils/update_utils/update_parsing.py`:GitHub Release asset 選擇與 digest 解析 +- `src/utils/update_utils/update_checker.py`:下載、驗證、套用更新與關閉流程 + +## 執行模式與資料路徑 + +| 項目 | 可攜式安裝 / Portable | 一般安裝 / Installer | |---|---|---| -| 程式主目錄 | `exe` 同層目錄(可搬移) | `%LOCALAPPDATA%\Programs\MinecraftServerManager` | +| 程式主目錄 | 使用者在 installer 指定的資料夾;搬移後可執行 | `%LOCALAPPDATA%\Programs\MinecraftServerManager` | +| 模式判定 | `/.portable` 或 `/.config` 存在時視為 portable | 不符合 portable 條件時採 installer 路徑 | | 設定檔路徑 | `/.config/user_settings.json` | `%LOCALAPPDATA%\Programs\MinecraftServerManager\user_settings.json` | | 日誌路徑 | `/.log/` | `%LOCALAPPDATA%\Programs\MinecraftServerManager\log\` | | 快取路徑 | `/.config/Cache/` | `%LOCALAPPDATA%\Programs\MinecraftServerManager\Cache\` | -| 模式判定 | `/.portable` 或 `/.config` 存在即視為 portable | 不符合 portable 條件時採 installer 模式 | -| 更新資產優先順序 | 1. `*portable*.zip` 2. 回退 `*.exe` | 1. `*.exe` | -| 更新流程 | 下載 zip -> 讀取 GitHub asset digest 驗證 checksum -> 解壓到暫存 -> 備份原目錄與 `.config/.log` -> 關閉程式後由批次檔套用 | 下載 exe -> 讀取 GitHub asset digest 驗證 checksum -> 啟動 installer -> 關閉主程式 | -| 權限需求 | 須對程式目錄有讀寫刪除權限(覆寫與備份) | 須可在安裝目錄寫入,且可啟動 installer | -| 回滾/回退行為 | 更新前建立完整備份,套用流程保留 `.config/.log`,若前置驗證失敗則不下載或不套用 | 若找不到 digest 或驗證失敗,更新直接取消;不覆寫既有程式 | -| 失敗時安全策略 | digest / checksum 或路徑安全檢查任一失敗即中止,清理暫存 | digest / checksum 或下載失敗即中止,清理暫存 | +| 目錄權限需求 | 程式目錄需允許建立、覆寫、刪除與備份檔案 | 應用程式需可寫入使用者資料、日誌與快取目錄;安裝程式本身的權限需求由 installer 決定 | +| 移除方式 | 不建立 Windows 解除安裝項目;關閉程式後直接刪除整個指定資料夾 | 透過 Windows 已安裝應用程式或 Inno uninstaller 解除安裝 | + +## 更新資產與流程 + +| 項目 | Portable 流程 | Installer 流程 | +|---|---|---| +| 資產選擇 | 選擇 `.exe` asset;名稱含 `setup` 或 `installer` 時優先 | 同 portable | +| digest 規格 | 讀取 GitHub Release asset 的 `digest` 欄位,接受 GitHub 產出的 `sha256:` | 同 portable | +| 下載前安全檢查 | 缺少可解析 digest 時直接取消,且不下載安裝檔 | 同 portable | +| 下載後驗證 | 以 digest 指定的演算法驗證 exe 檔雜湊 | 同 portable | +| 套用方式 | 啟動驗證後的 installer,傳入 `/MSMPortable=1` 與 `/DIR=` | 啟動驗證後的 installer,傳入 `/MSMPortable=0` | +| 使用者資料保留 | installer 不打包 `.portable`、`.config`、`.log` 或 `user_settings.json`;portable 資料留在 `` 下,移除時由使用者刪除整個資料夾 | installer 不打包 `user_settings.json`、`log` 或 `Cache`;資料留在 `%LOCALAPPDATA%\Programs\MinecraftServerManager` | +| 自動回滾能力 | 更新器不覆寫既有程式;installer 的復原能力不在本文件範圍內 | 同 portable | +| 暫存清理 | 下載失敗或驗證失敗會清理暫存;installer 啟動後保留 exe 交由安裝流程使用 | 同 portable | -## 備註 +## 術語 -- 「回退判定」指 portable 模式下找不到 portable zip 時,回退使用 installer asset。 -- 兩種模式都優先採用 GitHub Release asset digest;若舊版 release 沒有 digest,才回退到 body/獨立 checksum 檔。 +- `Portable` 與 `Installer` 是執行模式與資料路徑策略;release 仍只提供同一個 installer exe。 +- `digest` 指 GitHub Release asset metadata;`checksum` 或「雜湊」指本機下載後重新計算出的檔案雜湊。 +- 本文件描述應用程式更新器行為,不描述 Inno Setup installer 內部的安裝、權限提升或回滾能力。 diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 09786cb..cbfb1ce 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -10,7 +10,7 @@ | 網路 | requests + urllib3 Retry(集中 timeout / retry policy) | | 版本解析 | packaging | | XML 解析 | defusedxml(防止 XXE 攻擊) | -| Release Notes 解析 | markdown | +| Release Notes 清理 | 內建正規表示式與 HTML entity 解碼,轉為純文字後顯示 | | 測試 | pytest(smoke、integration) | | 靜態檢查 | ruff、mypy、bandit | @@ -30,7 +30,7 @@ src/main.py ├── core/loader_manager.py Fabric/Forge/Quilt/NeoForge 版本查詢與快取 ├── ui/mod_management/* 本地模組列表、Review、安裝清單與同步顯示 ├── ui/mod_search_service/* Modrinth 搜尋、相容性分析、依賴規劃 - └── utils/update_checker.py 更新檢查、下載與套用流程 + └── utils/update_utils/* 更新檢查、資產選擇、下載與套用流程 ``` --- @@ -64,26 +64,26 @@ src/main.py | 檔案 | 職責 | |------|------| -| `settings_manager.py` | 設定讀寫與共享設定管理器存取 | -| `http_utils.py` | requests session,集中 timeout/retry | -| `window_manager.py` | Qt 視窗定位與狀態持久化 | -| `logger.py` | 集中日誌初始化 | -| `java_utils.py` / `java_downloader.py` | Java 自動偵測;必要時在背景透過 winget 安裝官方 JDK / JRE 並自動同意授權 | -| `path_utils.py` / `runtime_paths.py` | 路徑解析(安裝版 vs. 可攜版) | -| `update_checker.py` / `update_parsing.py` | GitHub Releases 更新檢查、資產選擇與驗證 | +| `runtime_utils/settings_manager.py` | 設定讀寫與共享設定管理器存取 | +| `network_utils/http_utils.py` | requests session,集中 timeout/retry | +| `ui_support/window_manager.py` | Qt 視窗定位與狀態持久化 | +| `core_utils/logger.py` | 集中日誌初始化 | +| `java_support/java_utils.py` / `java_support/java_downloader.py` | Java 自動偵測;必要時透過 winget 安裝 Oracle JRE 8 或 Microsoft OpenJDK 並帶入授權接受參數 | +| `core_utils/path_utils.py` / `runtime_utils/runtime_paths.py` | 安全路徑操作與執行模式資料路徑解析 | +| `update_utils/update_checker.py` / `update_utils/update_parsing.py` | GitHub Releases 更新檢查、資產選擇與 digest 驗證 | --- ## 4. 視窗生命週期 -主視窗與大多數對話框採固定的顯示順序,避免初始化時出現閃爍: +主視窗與大多數對話框採 Qt 視窗生命週期,避免在元件尚未完成佈局時顯示: -1. `withdraw()` — 先隱藏 -2. 建立並佈置元件 -3. `geometry()` / `minsize()` 設定尺寸 -4. `deiconify()` — 完成後再顯示 +1. 建立 Qt widget 與 layout。 +2. 透過 `WindowManager` 計算螢幕、尺寸與置中位置。 +3. 呼叫 `resize()`、`move()`、`setMinimumSize()` 套用視窗幾何。 +4. 元件完成後再呼叫 `show()`;需要最大化時延後呼叫 `showMaximized()`。 -視窗偏好(位置、大小)由 `window_manager` 持久化至設定檔。可調整視窗不強制設定 `maxsize`;主視窗狀態僅在可見時追蹤。模組相關 Treeview 支援雙擊欄位標題自動調整欄寬。 +視窗偏好(位置、大小與最大化狀態)由 `ui_support/window_manager.py` 持久化至設定檔。可調整視窗不強制設定最大尺寸;主視窗狀態僅在視窗有效且非最小化時追蹤。模組相關 `qt.Treeview` 支援雙擊欄位標題自動調整欄寬。 高解析度顯示縮放交由 Qt 6 與 Windows 原生設定處理。Qt Widgets 使用 device-independent pixels,Qt 6 在 Windows 會自動套用使用者的顯示比例,因此專案內不再保存或套用額外的 UI 縮放倍率。 @@ -97,31 +97,31 @@ src/main.py - **列表差異更新**:Treeview 只更新變動列,不整批重繪。 - **Lazy re-export**:`__init__.py` 採延遲匯出,降低啟動 import 成本。 -## 6. 支援的模組載入器 +## 6. 支援的伺服器類型與載入器 -本專案支援以下四種模組載入器: +本專案支援原版伺服器與四種模組載入器: | 載入器 | 支援版本 | 說明 | |---|---|---| | Vanilla(原版) | 所有版本 | 官方 Minecraft 伺服器,無模組載入器 | | Fabric | 1.14+ | 輕量級模組載入器,廣泛支援 1.16+ 版本 | -| Quilt | 1.14+ | 基於 Fabric 的改進版本,提供更好的相容性 | -| Forge | 1.5+ | 功能豐富的老牌模組載入器,支援 1.5 到最新版本 | -| NeoForge | 1.20.1+ | Forge 的現代化分支,在 1.20.1+ 上支援 | +| Quilt | 1.14+ | 與 Fabric 生態相近的模組載入器,使用 Quilt Meta API 查詢版本 | +| Forge | 1.5+ | 老牌模組載入器;可用版本以 Maven metadata 可解析結果為準 | +| NeoForge | 1.20.1+ | Forge 生態的現代分支;可用版本以 NeoForge Maven metadata 為準 | ### 版本管理 - **Fabric / Quilt**:從官方 Fabric / Quilt Meta API 取得穩定版本清單,支援依 Minecraft 版本過濾 -- **Forge / NeoForge**:從 Maven metadata 解析穩定版本,每個 Minecraft 版本保留最新 10 個版本 +- **Forge / NeoForge**:從 Maven metadata 解析版本,每個 Minecraft 版本保留最新 10 個版本 ## 7. 資料與設定路徑 | 模式 | 設定 | 日誌 | 快取 | |------|------|------|------| -| 安裝版 | `%LOCALAPPDATA%\Programs\MinecraftServerManager\user_settings.json` | `%LOCALAPPDATA%\Programs\MinecraftServerManager\log\` | `%LOCALAPPDATA%\Programs\MinecraftServerManager\Cache\` | -| 可攜版 | `\.config\user_settings.json` | `\.log\` | `\.config\Cache\` | +| 一般安裝 | `%LOCALAPPDATA%\Programs\MinecraftServerManager\user_settings.json` | `%LOCALAPPDATA%\Programs\MinecraftServerManager\log\` | `%LOCALAPPDATA%\Programs\MinecraftServerManager\Cache\` | +| 可攜式安裝 | `\.config\user_settings.json` | `\.log\` | `\.config\Cache\` | -設定由 `settings_manager` 模組統一讀寫並持久化,對外主要透過 `get_settings_manager()` 提供共享實例。 +設定由 `runtime_utils/settings_manager.py` 統一讀寫並持久化,對外主要透過 `get_settings_manager()` 提供共享實例。 ## 8. 開發指令 @@ -151,4 +151,4 @@ uv run report\comprehensive_report.py 3. `src/core/server_manager.py` — 伺服器核心邏輯 4. `src/core/mod_manager.py` — 模組服務 5. `src/ui/mod_search_service/` — Modrinth 整合(最複雜的模組) -6. `src/utils/window_manager.py` — 視窗管理慣例 +6. `src/utils/ui_support/window_manager.py` — 視窗管理慣例 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 776cdc0..ce8eaac 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -4,11 +4,13 @@ | 類型 | 適合對象 | 取得方式 | |------|----------|----------| -| **可攜版**(推薦) | 初次使用、不想安裝 | 下載 `*-portable.zip`,解壓後直接執行 | -| **安裝版** | 日常長期使用 | 下載 `*-installer.exe`,安裝後由開始功能表啟動 | +| **可攜式安裝** | 初次使用或需要自訂安裝位置 | 下載 `*-Setup-*.exe`,選擇可攜式安裝並指定資料夾 | +| **一般安裝** | 日常長期使用 | 下載 `*-Setup-*.exe`,選擇一般安裝 | 從 [GitHub Releases](https://github.com/Colin955023/MinecraftServerManager/releases) 下載最新版本。 +可攜式安裝不會建立 Windows 解除安裝項目。若要移除可攜式安裝,請先關閉程式,再直接刪除整個指定資料夾。 + --- ### Java 與 winget 安裝說明 @@ -60,9 +62,9 @@ 1. 前往「**模組管理**」,確認目前選中的伺服器 2. 選擇任一方式: - - **本地匯入**:直接選擇 `.jar` 檔案 - - **線上搜尋**:搜尋 Modrinth 後加入安裝清單,再至 Review 視窗確認 -3. 在 Review 視窗確認後執行安裝 + - **本地匯入**:直接選擇 `.jar` 檔案 + - **線上搜尋**:搜尋 Modrinth 後加入安裝清單,再至 Review 視窗確認 +3. 線上安裝清單會在 Review 視窗確認後執行安裝 ### 更新已安裝的模組 @@ -80,7 +82,7 @@ **建立伺服器載入器**:Vanilla、Fabric、Forge、Quilt、NeoForge -本專案已新增對 Quilt 與 NeoForge 的完整支援,允許直接建立和管理搭載這些載入器的伺服器。每個載入器都擁有獨立的版本管理和下載流程。 +程式目前支援直接建立和管理 Vanilla 伺服器,以及 Fabric、Forge、Quilt、NeoForge 載入器伺服器。各載入器使用各自的版本查詢與下載流程。 --- @@ -94,7 +96,7 @@ - 確認防毒軟體未封鎖 `MinecraftServerManager.exe` - 嘗試以系統管理員身分執行 -- 若使用可攜版且遇到啟動或 Java 偵測問題,可改用簡短英文路徑重新測試 +- 若使用可攜式安裝且遇到啟動或 Java 偵測問題,可改用簡短英文路徑重新測試 ### 伺服器無法啟動 @@ -108,14 +110,20 @@ - 檔案副檔名需為 `.jar` 或 `.jar.disabled` - 按「**重新整理**」手動刷新清單 +### 如何移除可攜式安裝 + +可攜式安裝的程式、設定、日誌與快取都在安裝時指定的資料夾內。關閉程式後直接刪除整個資料夾即可。 + --- ## 資料位置 | 模式 | 設定檔 | 日誌 | 快取 | |------|--------|------|------| -| 安裝版 | `%LOCALAPPDATA%\Programs\MinecraftServerManager\user_settings.json` | 同目錄 `log\` | 同目錄 `Cache\` | -| 可攜版 | 程式目錄 `.config\user_settings.json` | 程式目錄 `.log\` | 程式目錄 `.config\Cache\` | +| 一般安裝 | `%LOCALAPPDATA%\Programs\MinecraftServerManager\user_settings.json` | 同目錄 `log\` | 同目錄 `Cache\` | +| 可攜式安裝 | 程式目錄 `.config\user_settings.json` | 程式目錄 `.log\` | 程式目錄 `.config\Cache\` | + +可攜式 / 一般安裝路徑與更新流程差異請見 [可攜式 / 一般安裝差異矩陣](PORTABLE_INSTALLER_MATRIX.md)。 --- diff --git a/scripts/build_installer_nuitka.py b/scripts/build_installer_nuitka.py index d341299..2449182 100644 --- a/scripts/build_installer_nuitka.py +++ b/scripts/build_installer_nuitka.py @@ -30,6 +30,7 @@ "PySide6/qt-plugins/platforms/qoffscreen.dll", ) QT_PLUGIN_INCLUDE_FAMILIES = "platforms,imageformats" +EXECUTABLE_NAME = "MinecraftServerManager.exe" def print_error_and_exit(msg: str, exit_code: int = 1): @@ -42,8 +43,6 @@ def main(): project_root = script_dir.parents[0] os.chdir(project_root) - is_ci = os.environ.get("GITHUB_ACTIONS", "").lower() == "true" - logging.info("Step 0: 讀取版本資訊...") try: sys.path.insert(0, str(project_root)) @@ -55,7 +54,7 @@ def main(): logging.info("Step 1: 清理舊產物與鎖定進程...") subprocess.run( - ["taskkill", "/F", "/T", "/IM", f"{APP_NAME}.exe"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ["taskkill", "/F", "/T", "/IM", EXECUTABLE_NAME], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) clean_dirs = ["build", "dist", "main.dist", "main.build"] @@ -73,10 +72,14 @@ def main(): subprocess.run([sys.executable, "-m", "pip", "install", "uv"], check=True) venv_path = project_root / ".venv" - if not is_ci and venv_path.exists(): + recreate_venv = os.environ.get("MSM_RECREATE_VENV", "").strip().lower() in {"1", "true", "yes", "y"} + if recreate_venv and venv_path.exists(): shutil.rmtree(venv_path, ignore_errors=True) - subprocess.run(["uv", "venv", ".venv", "--clear"], check=True) + if recreate_venv or not (venv_path / "pyvenv.cfg").exists(): + subprocess.run(["uv", "venv", ".venv", "--clear"], check=True) + else: + logging.info("重用既有 .venv;若需完全重建,請設定 MSM_RECREATE_VENV=1") subprocess.run(["uv", "sync", "--group", "build", "--frozen"], check=True) logging.info("Step 3: Nuitka 高效編譯...") @@ -93,7 +96,7 @@ def main(): "--assume-yes-for-downloads", "--remove-output", "--output-dir=dist", - "--output-filename=MinecraftServerManager.exe", + f"--output-filename={EXECUTABLE_NAME}", "--include-package=src", "--include-data-dir=assets=assets", "--include-data-file=README.md=README.md", @@ -143,16 +146,14 @@ def main(): if not dist_target.exists(): print_error_and_exit(f"找不到 Nuitka 輸出目錄,預期 {dist_main_dist} 或 {dist_msm_dist}") - if not (dist_target / "MinecraftServerManager.exe").exists(): + if not (dist_target / EXECUTABLE_NAME).exists(): print_error_and_exit("找不到已編譯的執行檔") required_qt_plugins = [ dist_target / "PySide6" / "qt-plugins" / "platforms" / "qwindows.dll", dist_target / "PySide6" / "qt-plugins" / "imageformats" / "qico.dll", ] - missing_qt_plugins = [ - str(plugin.relative_to(dist_target)) for plugin in required_qt_plugins if not plugin.exists() - ] + missing_qt_plugins = [str(plugin.relative_to(dist_target)) for plugin in required_qt_plugins if not plugin.exists()] if missing_qt_plugins: error_msg = "Qt runtime 外掛遺失,打包後可能無法啟動:\n" + "\n".join( [f" - {plugin}" for plugin in missing_qt_plugins] @@ -175,32 +176,13 @@ def main(): check=True, ) - logging.info("Step 5: 封裝免安裝版...") - pwsh = shutil.which("pwsh") or "powershell" - subprocess.run( - [ - pwsh, - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/package-portable.ps1", - "-Version", - APP_VERSION, - ], - check=True, - ) - - logging.info("Step 6: 驗證建置產物...") + logging.info("Step 5: 驗證建置產物...") dist_dir = project_root / "dist" setup_file = dist_dir / f"{APP_NAME}-Setup-{APP_VERSION}.exe" - portable_file = dist_dir / f"MinecraftServerManager-v{APP_VERSION}-portable.zip" missing_artifacts = [] if not setup_file.exists(): missing_artifacts.append(f"安裝程式 ({setup_file.name})") - if not portable_file.exists(): - missing_artifacts.append(f"免安裝版 ({portable_file.name})") if missing_artifacts: error_msg = "遺失建置產物:\n" + "\n".join([f" - {a}" for a in missing_artifacts]) @@ -211,8 +193,7 @@ def main(): logging.info("========================================================") logging.info("") logging.info(f"安裝程式:{setup_file.relative_to(project_root)}") - logging.info(f"免安裝版:{portable_file.relative_to(project_root)}") - logging.info("SHA256 將由 GitHub Release asset 的 digest 提供") + logging.info("SHA-256 將由 GitHub Release asset 的 digest 提供") logging.info("========================================================") logging.info("") diff --git a/scripts/installer.iss b/scripts/installer.iss index 4ecfca7..80c5156 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -5,8 +5,10 @@ AppVersion={#AppVersion} VersionInfoVersion={#AppVersion} VersionInfoProductVersion={#AppVersion} AppPublisher=Colin955023 -AppPublisherURL=[https://github.com/Colin955023/MinecraftServerManager](https://github.com/Colin955023/MinecraftServerManager) +AppPublisherURL=https://github.com/Colin955023/MinecraftServerManager DefaultDirName={localappdata}\Programs\MinecraftServerManager +DisableDirPage=no +UsePreviousAppDir=no DefaultGroupName=Minecraft 伺服器管理器 DisableProgramGroupPage=yes OutputDir=..\dist @@ -16,6 +18,8 @@ SolidCompression=yes WizardStyle=modern SetupIconFile=..\assets\icon.ico UninstallDisplayIcon={app}\assets\icon.ico +Uninstallable=not IsPortableInstall +CreateUninstallRegKey=not IsPortableInstall ArchitecturesInstallIn64BitMode=x64compatible PrivilegesRequired=lowest AppMutex=MinecraftServerManagerMutex @@ -29,41 +33,132 @@ Name: "chinesetraditional"; MessagesFile: "compiler:Default.isl,inno\\ChineseTra [Files] Source: "..\dist\MinecraftServerManager\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion; \ -Excludes: "user_settings.json;__pycache__\*;*.pyc;*.pyo;*.pdb;*.log;.DS_Store;Thumbs.db;*.tmp;*.temp" +Excludes: ".portable,.config,.config\*,.log,.log\*,log,log\*,Cache,Cache\*,user_settings.json,__pycache__,__pycache__\*,*.pyc,*.pyo,*.pdb,*.log,.DS_Store,Thumbs.db,*.tmp,*.temp" + +[InstallDelete] +Type: files; Name: "{app}\unins*.dat"; Check: IsPortableInstall +Type: files; Name: "{app}\unins*.exe"; Check: IsPortableInstall +Type: files; Name: "{app}\unins*.msg"; Check: IsPortableInstall [Icons] -Name: "{group}\Minecraft 伺服器管理器"; Filename: "{app}\MinecraftServerManager.exe"; IconFilename: "{app}\assets\icon.ico" -Name: "{commondesktop}\Minecraft 伺服器管理器"; Filename: "{app}\MinecraftServerManager.exe"; Tasks: desktopicon +Name: "{group}\Minecraft 伺服器管理器"; Filename: "{app}\MinecraftServerManager.exe"; IconFilename: "{app}\assets\icon.ico"; Check: not IsPortableInstall +Name: "{autodesktop}\Minecraft 伺服器管理器"; Filename: "{app}\MinecraftServerManager.exe"; Tasks: desktopicon; Check: not IsPortableInstall [Tasks] -Name: "desktopicon"; Description: "在桌面建立捷徑"; GroupDescription: "其他選項:" +Name: "desktopicon"; Description: "在桌面建立捷徑"; GroupDescription: "其他選項:"; Check: not IsPortableInstall [Run] Filename: "{app}\MinecraftServerManager.exe"; Description: "安裝後立即執行"; Flags: nowait postinstall skipifsilent runasoriginaluser +[UninstallDelete] +Type: files; Name: "{app}\.portable" + [Code] -function GetDataRoot(): string; +var + InstallModePage: TInputOptionWizardPage; + PortableInstallMode: Boolean; + +function GetNormalInstallDir(): string; begin - { 使用 ExpandConstant 確保路徑動態獲取,比硬編碼更穩定 } Result := ExpandConstant('{localappdata}\Programs\MinecraftServerManager'); end; +function GetPortableInstallDir(): string; +begin + Result := ExpandConstant('{localappdata}\Programs\MinecraftServerManager-Portable'); +end; + +function IsPortableInstall(): Boolean; +begin + Result := PortableInstallMode; +end; + +procedure ApplyInstallModeDir(); +begin + if IsPortableInstall() then + begin + if (WizardForm.DirEdit.Text = '') or (WizardForm.DirEdit.Text = GetNormalInstallDir()) then + WizardForm.DirEdit.Text := GetPortableInstallDir(); + end + else + WizardForm.DirEdit.Text := GetNormalInstallDir(); +end; + +function InitializeSetup(): Boolean; +begin + PortableInstallMode := CompareText(ExpandConstant('{param:MSMPortable|0}'), '1') = 0; + Result := True; +end; + +procedure InitializeWizard(); +begin + InstallModePage := CreateInputOptionPage( + wpWelcome, + '選擇安裝模式', + '請選擇 Minecraft 伺服器管理器的安裝方式。', + '一般安裝會使用固定的本機使用者資料夾;可攜式安裝會讓您指定資料夾,並把設定與日誌保存在程式目錄下。', + True, + False + ); + InstallModePage.Add('正常安裝'); + InstallModePage.Add('可攜式'); + if PortableInstallMode then + InstallModePage.SelectedValueIndex := 1 + else + InstallModePage.SelectedValueIndex := 0; + ApplyInstallModeDir(); +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + if (InstallModePage <> nil) and (CurPageID = InstallModePage.ID) then + begin + PortableInstallMode := InstallModePage.SelectedValueIndex = 1; + ApplyInstallModeDir(); + end; +end; + +function ShouldSkipPage(PageID: Integer): Boolean; +begin + Result := (PageID = wpSelectDir) and (not IsPortableInstall()); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + PortableMarker: string; +begin + if CurStep = ssPostInstall then + begin + PortableMarker := ExpandConstant('{app}\.portable'); + if IsPortableInstall() then + begin + if not SaveStringToFile(PortableMarker, 'portable', False) then + MsgBox('無法建立可攜式安裝標記:' + PortableMarker, mbError, MB_OK); + end + else if FileExists(PortableMarker) then + DeleteFile(PortableMarker); + end; +end; + procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); var - DataRoot, CacheDir, LogDir, SettingsPath: string; + DataRoot, CacheDir, LogDir, SettingsPath, PortableMarker: string; begin if CurUninstallStep = usUninstall then begin - DataRoot := GetDataRoot(); + PortableMarker := ExpandConstant('{app}\.portable'); + DataRoot := GetNormalInstallDir(); CacheDir := DataRoot + '\Cache'; LogDir := DataRoot + '\log'; SettingsPath := DataRoot + '\user_settings.json'; - { 安全性優化:確認目錄存在才執行刪除,並加入錯誤處理防止解除安裝中斷 } + { 一般安裝解除安裝只清理固定的使用者本機資料;可攜式安裝不建立解除安裝程序。 } try if DirExists(CacheDir) then DelTree(CacheDir, True, True, True); if DirExists(LogDir) then DelTree(LogDir, True, True, True); if FileExists(SettingsPath) then DeleteFile(SettingsPath); + if FileExists(PortableMarker) then DeleteFile(PortableMarker); except { 即使刪除資料失敗,也要讓解除安裝繼續進行 } end; @@ -75,4 +170,4 @@ begin if DirExists(ExpandConstant('{app}')) then RemoveDir(ExpandConstant('{app}')); end; -end; \ No newline at end of file +end; diff --git a/scripts/package-portable.ps1 b/scripts/package-portable.ps1 deleted file mode 100644 index 9b12d81..0000000 --- a/scripts/package-portable.ps1 +++ /dev/null @@ -1,119 +0,0 @@ -param( - [string]$Version = "" -) - -[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() -$OutputEncoding = [Console]::OutputEncoding -[Console]::InputEncoding = [System.Text.UTF8Encoding]::new() - -try { $Host.UI.RawUI.WindowTitle = "Minecraft 伺服器管理器 - 可攜式版本打包工具" } catch { } -$ErrorActionPreference = "Stop" - -Set-Location $PSScriptRoot\.. - -# 取得版本號:從 src/version_info.py 的 APP_VERSION 取得 -[string]$version = "$Version".Trim() - -if (-not $version) { - try { - $pythonOut = & py -c "from src.version_info import APP_VERSION; print(APP_VERSION)" 2>$null - if ($LASTEXITCODE -eq 0 -and $pythonOut) { - $version = "$pythonOut".Trim() - } - } catch { - Write-Host "[警告] 無法從 Python 讀取版本" -ForegroundColor Yellow - } -} - -# 驗證版本格式,確保不是意外的物件 -if (-not $version -or -not ($version -match '^\d+\.\d+\.\d+')) { - Write-Host "[錯誤] 版本號無效,請確認 src.version_info.APP_VERSION 或傳入 -Version 參數" -ForegroundColor Red - exit 1 -} - -Write-Host "[資訊] 版本號: $version" -ForegroundColor Cyan - -if (-not (Test-Path "dist\MinecraftServerManager")) { - Write-Host "錯誤: 找不到 dist\MinecraftServerManager 資料夾。" -ForegroundColor Red - Write-Host "請先執行 build_installer_nuitka.py 來生成可攜式版本。" -ForegroundColor Yellow - exit 1 -} - -Write-Host "========================================================" -ForegroundColor Cyan -Write-Host " Minecraft 伺服器管理器 - 可攜式版本打包" -ForegroundColor Cyan -Write-Host "========================================================" -ForegroundColor Cyan -Write-Host "" - -Write-Host "[1/3] 準備說明與授權檔案(複製 LICENSE/README.md)..." -ForegroundColor Yellow - -# 補充步驟:複製 LICENSE 或 README.md(若存在於專案根目錄)進入可攜版資料夾 -if (Test-Path "LICENSE") { - Copy-Item -Path "LICENSE" -Destination "dist\MinecraftServerManager\LICENSE" -Force -} -if (Test-Path "README.md") { - Copy-Item -Path "README.md" -Destination "dist\MinecraftServerManager\README.md" -Force -} - -# 檢查必要執行檔是否存在,避免建立空或不完整的壓縮檔 -if (-not (Test-Path "dist\MinecraftServerManager\MinecraftServerManager.exe")) { - Write-Host "[錯誤] 找不到 dist\\MinecraftServerManager\\MinecraftServerManager.exe,請先完成 Nuitka 打包。" -ForegroundColor Red - exit 1 -} - -$zipFile = "MinecraftServerManager-v$version-portable.zip" -$zipPath = "dist\$zipFile" - -if (Test-Path $zipPath) { - Remove-Item $zipPath -Force -} -Write-Host "[2/3] 建立 .portable 標記檔(供程式偵測為便攜模式)..." -ForegroundColor Yellow - -# 建立 .portable 標記檔於可攜版資料夾(若已存在則覆寫) -$portablePath = "dist\MinecraftServerManager\.portable" -if (Test-Path $portablePath) { - Remove-Item $portablePath -Force -} -# 使用無 BOM 的 ASCII 編碼建立空標記檔,減少 3 bytes 的體積 -[IO.File]::WriteAllBytes((Join-Path $PWD $portablePath), [byte[]]@()) - -# 確保移除 unins000.exe 與 unins000.dat (若存在),可攜式版本不需要 -if (Test-Path "dist\MinecraftServerManager\unins000.exe") { - Remove-Item "dist\MinecraftServerManager\unins000.exe" -Force -} -if (Test-Path "dist\MinecraftServerManager\unins000.dat") { - Remove-Item "dist\MinecraftServerManager\unins000.dat" -Force -} - -Write-Host "[3/3] 建立可攜式版本壓縮檔 (使用高效能 ZipFile)..." -ForegroundColor Yellow - -# 使用 .NET 原生 ZipFile 以提升壓縮效能與避免 Compress-Archive 的路徑長度限制 -Add-Type -AssemblyName System.IO.Compression.FileSystem - -$sourceDir = "dist\MinecraftServerManager" -$compressionLevel = [System.IO.Compression.CompressionLevel]::Optimal - -# .NET 6+ 支援 SmallestSize (更慢但更小),CI 環境建議使用 -try { - $compressionLevel = [System.IO.Compression.CompressionLevel]::SmallestSize -} catch { - # .NET Framework 或舊版 .NET 不支援 SmallestSize,回退到 Optimal - $compressionLevel = [System.IO.Compression.CompressionLevel]::Optimal -} - -$zipPathTemp = "$zipPath.tmp" -if (Test-Path $zipPathTemp) { Remove-Item $zipPathTemp -Force } -[System.IO.Compression.ZipFile]::CreateFromDirectory($sourceDir, $zipPathTemp, $compressionLevel, $false) - -if (Test-Path $zipPath) { Remove-Item $zipPath -Force } -Rename-Item -Path $zipPathTemp -NewName $zipFile - -if (-not (Test-Path $zipPath)) { - Write-Host "[錯誤] 壓縮檔建立失敗" -ForegroundColor Red - exit 1 -} - -Write-Host "[成功] 已建立 $zipFile" -ForegroundColor Green -Write-Host "" -Write-Host "========================================================" -ForegroundColor Cyan -Write-Host " 打包完成!" -ForegroundColor Green -Write-Host "========================================================" -ForegroundColor Cyan diff --git a/src/core/local_mod_scanner.py b/src/core/local_mod_scanner.py index 1334cc3..8b13469 100644 --- a/src/core/local_mod_scanner.py +++ b/src/core/local_mod_scanner.py @@ -24,11 +24,14 @@ TomlDecodeError = tomllib.TOMLDecodeError logger = get_logger().bind(component="LocalModScanner") +MAX_JAR_METADATA_BYTES = 2 * 1024 * 1024 class LocalModScanner: """掃描 mods 目錄並建立 `LocalModInfo`。""" + MAX_METADATA_BYTES = MAX_JAR_METADATA_BYTES + def __init__( self, *, @@ -379,7 +382,37 @@ def extract_legacy_forge_metadata(self, jar: Any, mod_data: dict[str, str]) -> N logger.exception(f"解析 legacy Forge mcmod.info 時發生未預期錯誤: {exc}") @staticmethod - def read_json_from_jar(jar: Any, file_path: str) -> dict | list | None: + def read_zip_member_bytes(jar: Any, file_path: str, *, max_bytes: int = MAX_JAR_METADATA_BYTES) -> bytes | None: + """以大小上限讀取 JAR 內部檔案,避免惡意 metadata 造成記憶體暴增。 + + Args: + jar: 已開啟的 JAR/ZIP 物件。 + file_path: JAR 內部檔案路徑。 + max_bytes: 允許讀取的最大位元組數。 + + Returns: + 讀取到的 bytes;找不到檔案、讀取失敗或超過上限時回傳 None。 + """ + + try: + info = jar.getinfo(file_path) + if int(info.file_size) > max_bytes: + logger.warning(f"略過過大的 JAR metadata: {file_path} ({info.file_size} bytes)") + return None + with jar.open(info) as file_obj: + payload = file_obj.read(max_bytes + 1) + if len(payload) > max_bytes: + logger.warning(f"略過超過讀取上限的 JAR metadata: {file_path}") + return None + return payload + except KeyError: + return None + except (OSError, ValueError) as exc: + logger.debug(f"讀取 JAR 內部檔案失敗 {file_path}: {exc}") + return None + + @staticmethod + def read_json_from_jar(jar: Any, file_path: str, *, max_bytes: int = MAX_JAR_METADATA_BYTES) -> dict | list | None: """讀取 JAR 內的 JSON 檔案並解析。 Args: @@ -391,14 +424,18 @@ def read_json_from_jar(jar: Any, file_path: str) -> dict | list | None: """ try: - with jar.open(file_path) as file_obj: - return PathUtils.from_json_str(file_obj.read().decode("utf-8")) + payload = LocalModScanner.read_zip_member_bytes(jar, file_path, max_bytes=max_bytes) + if payload is None: + return None + return PathUtils.from_json_str(payload.decode("utf-8")) except (KeyError, OSError, ValueError) as exc: logger.debug(f"讀取 JAR 中的 JSON 失敗 {file_path}: {exc}") return None @staticmethod - def read_toml_from_jar(jar: Any, file_path: str) -> dict[str, Any] | None: + def read_toml_from_jar( + jar: Any, file_path: str, *, max_bytes: int = MAX_JAR_METADATA_BYTES + ) -> dict[str, Any] | None: """讀取 JAR 內的 TOML 檔案並解析。 Args: @@ -410,9 +447,10 @@ def read_toml_from_jar(jar: Any, file_path: str) -> dict[str, Any] | None: """ try: - with jar.open(file_path) as file_obj: - toml_txt = file_obj.read().decode(errors="ignore") - return tomllib.loads(toml_txt) + payload = LocalModScanner.read_zip_member_bytes(jar, file_path, max_bytes=max_bytes) + if payload is None: + return None + return tomllib.loads(payload.decode(errors="ignore")) except (KeyError, TomlDecodeError) as exc: logger.debug(f"讀取 JAR 中的 TOML 失敗 {file_path}: {exc}") return None diff --git a/src/core/mod_manager.py b/src/core/mod_manager.py index 7bda98d..f4cf1bc 100644 --- a/src/core/mod_manager.py +++ b/src/core/mod_manager.py @@ -3,6 +3,7 @@ """ from collections.abc import Callable +from html import escape from pathlib import Path from ..utils import ( @@ -410,6 +411,10 @@ def export_mod_list(self, format_type: str = "text") -> str: ) return PathUtils.to_json_str(export_data, indent=2) if format_type == "html": + + def _html(value: object) -> str: + return escape(str(value or ""), quote=True) + html = [ "", '', @@ -422,7 +427,13 @@ def export_mod_list(self, format_type: str = "text") -> str: ] for mod in mods: html.append( - f"{('✅' if mod.status == ModStatus.ENABLED else '❌')}{mod.name}{mod.version}{mod.author}{mod.description}" + "" + f"{'✅' if mod.status == ModStatus.ENABLED else '❌'}" + f"{_html(mod.name)}" + f"{_html(mod.version)}" + f"{_html(mod.author)}" + f"{_html(mod.description)}" + "" ) html.append("") return "\n".join(html) diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 9a0616f..75f318c 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -1208,7 +1208,7 @@ def on_auto_update_toggled(enabled: bool) -> None: if RuntimePaths.is_portable_mode(): add_label("📦 便攜模式", size=FontSize.HEADING_LARGE, weight="bold") add_label( - "您正在使用便攜版本。\n如需更新,請從 Releases 下載新版 portable ZIP,或使用內建的檢查更新功能。", + "您正在使用便攜模式。\n如需更新,請使用內建的檢查更新功能,或從 Releases 下載安裝程式 exe 後選擇可攜式安裝。", size=FontSize.NORMAL_PLUS, ) prefs_btn = QtWidgets.QPushButton("視窗偏好設定", scroll_content) diff --git a/src/ui/manage_server_frame.py b/src/ui/manage_server_frame.py index c17e803..e4e7be0 100644 --- a/src/ui/manage_server_frame.py +++ b/src/ui/manage_server_frame.py @@ -1231,6 +1231,28 @@ def update_selection(self) -> None: else: self.info_label.configure(text="✨ 選擇一個伺服器以查看詳細資訊") + @staticmethod + def _show_existing_monitor_window(window: Any, *, bring_to_front: bool) -> None: + """顯示已存在的監控視窗,並選擇性地帶出到前面。""" + if bring_to_front: + show_normal = getattr(window, "showNormal", None) + if callable(show_normal): + with contextlib.suppress(Exception): + show_normal() + else: + with contextlib.suppress(Exception): + window.show() + for method_name in ("raise_", "activateWindow", "setFocus"): + method = getattr(window, method_name, None) + if callable(method): + try: + method() + except Exception as e: + logger.debug(f"帶出監控視窗失敗 method={method_name}: {e}", "ManageServerFrame") + return + + window.show() + def start_server(self) -> None: """啟動/停止伺服器""" if not self.selected_server: @@ -1246,7 +1268,7 @@ def start_server(self) -> None: else: start_result = self.server_manager.start_server_result(self.selected_server) if start_result.success: - self.monitor_server() + self.monitor_server(bring_to_front=False) else: UIUtils.show_error( start_result.title or "錯誤", @@ -1265,7 +1287,7 @@ def _delayed_update(self) -> None: self.update_selection() self.refresh_servers() - def monitor_server(self) -> None: + def monitor_server(self, *, bring_to_front: bool = True) -> None: """監控伺服器""" if not self.selected_server: return @@ -1277,10 +1299,7 @@ def monitor_server(self) -> None: if self.selected_server in self._monitor_windows: old_win = self._monitor_windows[self.selected_server] if old_win and hasattr(old_win, "window") and old_win.window and old_win.window.is_alive(): - old_win.window.show() - old_win.window.raise_() - old_win.window.activateWindow() - old_win.window.setFocus() + self._show_existing_monitor_window(old_win.window, bring_to_front=bring_to_front) return monitor_window = ServerMonitorWindow(self.top_level_widget(), self.server_manager, self.selected_server) diff --git a/src/ui/server_monitor_window.py b/src/ui/server_monitor_window.py index 22f51c3..d72a27e 100644 --- a/src/ui/server_monitor_window.py +++ b/src/ui/server_monitor_window.py @@ -132,6 +132,7 @@ def create_window(self) -> None: height=Sizes.DIALOG_LARGE_HEIGHT, center_on_parent=False, make_modal=False, + topmost=False, delay_ms=250, ) self.window.setObjectName("ServerMonitorWindow") @@ -185,9 +186,6 @@ def create_window(self) -> None: finally: try: self.window.show() - self.window.raise_() - self.window.activateWindow() - self.window.setFocus() except Exception as e: logger.debug(f"顯示監控視窗失敗: {e}", "ServerMonitorWindow") @@ -939,9 +937,6 @@ def show(self) -> None: self.start_console_flusher() if self.window: self.window.show() - self.window.raise_() - self.window.activateWindow() - self.window.setFocus() def handle_server_ready(self): """伺服器啟動完成後的 UI 處理""" diff --git a/src/utils/core_utils/path_utils.py b/src/utils/core_utils/path_utils.py index 259c807..6fedb03 100644 --- a/src/utils/core_utils/path_utils.py +++ b/src/utils/core_utils/path_utils.py @@ -27,6 +27,9 @@ class PathUtils: """路徑處理工具類別,提供專案路徑管理和安全路徑操作""" + SAFE_ZIP_MAX_MEMBER_BYTES: ClassVar[int] = 512 * 1024 * 1024 + SAFE_ZIP_MAX_TOTAL_BYTES: ClassVar[int] = 2 * 1024 * 1024 * 1024 + SAFE_ZIP_MAX_COMPRESSION_RATIO: ClassVar[int] = 200 _json_lock_registry_lock = threading.Lock() _json_path_locks: ClassVar[dict[str, threading.RLock]] = {} _json_write_retry_count = 3 @@ -144,6 +147,34 @@ def _sanitize_archive_member_name(member_name: str) -> Path | None: except ValueError: return None + @staticmethod + def _is_zip_symlink(member: zipfile.ZipInfo) -> bool: + """檢查 zip entry 是否宣告為 Unix symlink。""" + + return ((member.external_attr >> 16) & 0o170000) == 0o120000 + + @staticmethod + def _validate_zip_member_size( + member: zipfile.ZipInfo, + *, + max_member_uncompressed_bytes: int | None, + max_compression_ratio: int | None, + ) -> None: + """檢查單一 zip member 的大小與壓縮比例。""" + + if member.is_dir(): + return + file_size = max(0, int(member.file_size)) + compressed_size = max(0, int(member.compress_size)) + if max_member_uncompressed_bytes is not None and file_size > max_member_uncompressed_bytes: + raise ValueError(f"壓縮檔成員過大: {member.filename}") + if max_compression_ratio is None or file_size == 0: + return + if compressed_size == 0: + raise ValueError(f"壓縮檔成員壓縮比例異常: {member.filename}") + if file_size / compressed_size > max_compression_ratio: + raise ValueError(f"壓縮檔成員壓縮比例過高: {member.filename}") + @staticmethod def sanitize_filename(filename: str) -> str: """ @@ -161,7 +192,13 @@ def sanitize_filename(filename: str) -> str: @staticmethod def safe_extract_zip( - zip_path: Path, dest_dir: Path, progress_callback: Callable[[int, int], None] | None = None + zip_path: Path, + dest_dir: Path, + progress_callback: Callable[[int, int], None] | None = None, + *, + max_total_uncompressed_bytes: int | None = SAFE_ZIP_MAX_TOTAL_BYTES, + max_member_uncompressed_bytes: int | None = SAFE_ZIP_MAX_MEMBER_BYTES, + max_compression_ratio: int | None = SAFE_ZIP_MAX_COMPRESSION_RATIO, ) -> None: """安全地解壓縮 Zip 檔案,防止 Zip Slip 漏洞。 @@ -172,32 +209,56 @@ def safe_extract_zip( progress_callback 會收到 (已解壓位元組數, 總位元組數)。 """ - dest_dir = dest_dir.resolve() + dest_dir = dest_dir.resolve(strict=False) + dest_dir.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(zip_path, "r") as zf: members = zf.infolist() total_bytes = sum(max(0, int(member.file_size)) for member in members if not member.is_dir()) - extracted_bytes = 0 - if progress_callback is not None: - progress_callback(0, total_bytes) + if max_total_uncompressed_bytes is not None and total_bytes > max_total_uncompressed_bytes: + raise ValueError("壓縮檔解壓後大小超過安全上限") + sanitized_members: list[tuple[zipfile.ZipInfo, Path]] = [] for member in members: - # 先將 zip 內部名稱清理成安全的相對路徑,避免 Zip Slip + if PathUtils._is_zip_symlink(member): + raise ValueError(f"壓縮檔包含不支援的符號連結: {member.filename}") + PathUtils._validate_zip_member_size( + member, + max_member_uncompressed_bytes=max_member_uncompressed_bytes, + max_compression_ratio=max_compression_ratio, + ) sanitized = PathUtils._sanitize_archive_member_name(member.filename) if sanitized is None: raise ValueError(f"壓縮檔包含不安全的成員名稱: {member.filename}") member_path = dest_dir / sanitized if not PathUtils.is_path_within(dest_dir, member_path, strict=False): raise ValueError(f"壓縮檔嘗試路徑遍歷: {member.filename}") + sanitized_members.append((member, sanitized)) + extracted_bytes = 0 + if progress_callback is not None: + progress_callback(0, total_bytes) + for member, sanitized in sanitized_members: + member_path = dest_dir / sanitized if member.is_dir() or str(member.filename).endswith("/"): member_path.mkdir(parents=True, exist_ok=True) continue member_path.parents[0].mkdir(parents=True, exist_ok=True) + member_extracted_bytes = 0 with zf.open(member, "r") as source, open(member_path, "wb") as target: while True: chunk = source.read(1024 * 1024) if not chunk: break + next_member_bytes = member_extracted_bytes + len(chunk) + next_total_bytes = extracted_bytes + len(chunk) + if ( + max_member_uncompressed_bytes is not None + and next_member_bytes > max_member_uncompressed_bytes + ): + raise ValueError(f"壓縮檔成員實際解壓大小超過安全上限: {member.filename}") + if max_total_uncompressed_bytes is not None and next_total_bytes > max_total_uncompressed_bytes: + raise ValueError("壓縮檔實際解壓大小超過安全上限") target.write(chunk) - extracted_bytes += len(chunk) + member_extracted_bytes = next_member_bytes + extracted_bytes = next_total_bytes if progress_callback is not None and total_bytes > 0: progress_callback(extracted_bytes, total_bytes) if progress_callback is not None: diff --git a/src/utils/network_utils/http_utils.py b/src/utils/network_utils/http_utils.py index b42fe24..241b23a 100644 --- a/src/utils/network_utils/http_utils.py +++ b/src/utils/network_utils/http_utils.py @@ -341,6 +341,7 @@ def download_file( temp_path_obj = local_path_obj.with_name(local_path_obj.name + ".part") try: final_headers = cls.get_default_headers() + _rate_limiter.wait(urlparse(url).netloc) with cls._get_session().get(url, headers=final_headers, timeout=timeout, stream=True) as resp: resp.raise_for_status() total_size = int(resp.headers.get("Content-Length", 0)) diff --git a/src/utils/runtime_utils/app_restart.py b/src/utils/runtime_utils/app_restart.py index ba9a891..8638bd4 100644 --- a/src/utils/runtime_utils/app_restart.py +++ b/src/utils/runtime_utils/app_restart.py @@ -154,7 +154,7 @@ def _find_main_in_parents(start_dir: Path | str, max_levels: int = 5) -> Path | @staticmethod def _find_exe_fallback() -> Path | None: - """尋找可能的可執行檔(可攜版 exe)作為重啟備援,回傳第一個存在的 Path 或 None。""" + """尋找可能的可執行檔(可攜式 exe)作為重啟備援,回傳第一個存在的 Path 或 None。""" try: candidates = [ Path.cwd() / "MinecraftServerManager.exe", @@ -331,7 +331,7 @@ def get_restart_diagnostics() -> tuple[bool, str]: if not supported: exe_fallback = AppRestart._find_exe_fallback() if exe_fallback: - details = f"模式=打包(備援); 執行檔={str(exe_fallback)!r}; 是否存在=True; 是否是檔案=True; 備註='找到可攜版 exe 備援'" + details = f"模式=打包(備援); 執行檔={str(exe_fallback)!r}; 是否存在=True; 是否是檔案=True; 備註='找到可攜式 exe 備援'" return (True, details) details = f"模式=腳本; 腳本路徑={script_path!r}; 解析後腳本={str(script_resolved)!r}; 是否存在={exists}; 是否是檔案={is_file}" return (supported, details) diff --git a/src/utils/update_utils/update_checker.py b/src/utils/update_utils/update_checker.py index c0ecad4..31ec539 100644 --- a/src/utils/update_utils/update_checker.py +++ b/src/utils/update_utils/update_checker.py @@ -163,117 +163,31 @@ def _choose_installer_asset(release: dict) -> dict: return UpdateParsing.choose_installer_asset(release) @staticmethod - def _select_update_asset(release: dict, portable_mode: bool) -> tuple[dict, str]: - return UpdateParsing.select_update_asset(release, portable_mode) + def _select_update_asset(release: dict) -> tuple[dict, str]: + return UpdateParsing.select_update_asset(release) @staticmethod - def _escape_powershell_single_quoted_literal(value: str) -> str: - """ - 回傳可安全嵌入 PowerShell 單引號字串的文字。 - - Args: - value: 任意字串。 - - Returns: - 已轉義的字串,適合放在 PowerShell 單引號字串中。 - """ - return "'" + value.replace("'", "''") + "'" - - @staticmethod - def _build_portable_update_script( - source_dir: Path, - destination_dir: Path, - backup_dir: Path, - cleanup_dir: Path, - executable_name: str = "MinecraftServerManager.exe", - ) -> str: - """產生 portable 更新流程使用的 PowerShell 腳本。 - - Args: - source_dir: 更新檔解壓來源目錄。 - destination_dir: 最終安裝目錄。 - backup_dir: 原始安裝備份目錄。 - cleanup_dir: 解壓暫存目錄。 - executable_name: 啟動用可執行檔名稱。 - - Returns: - 可直接寫入 `.ps1` 檔案的腳本文字。 - """ - source_literal = UpdateChecker._escape_powershell_single_quoted_literal(str(source_dir)) - destination_literal = UpdateChecker._escape_powershell_single_quoted_literal(str(destination_dir)) - backup_literal = UpdateChecker._escape_powershell_single_quoted_literal(str(backup_dir)) - cleanup_literal = UpdateChecker._escape_powershell_single_quoted_literal(str(cleanup_dir)) - exe_literal = UpdateChecker._escape_powershell_single_quoted_literal(executable_name) - lines = [ - "$ErrorActionPreference = 'Stop'", - f"$sourceDir = {source_literal}", - f"$destinationDir = {destination_literal}", - f"$backupDir = {backup_literal}", - f"$cleanupDir = {cleanup_literal}", - f"$executableName = {exe_literal}", - "for ($count = 0; $count -lt 20; $count++) {", - " $process = Get-Process -Name 'MinecraftServerManager' -ErrorAction SilentlyContinue", - " if (-not $process) {", - " break", - " }", - " Start-Sleep -Seconds 1", - "}", - "Start-Sleep -Seconds 3", - "if (Test-Path -LiteralPath $destinationDir) {", - " Get-ChildItem -LiteralPath $destinationDir -Force | ForEach-Object {", - " Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue", - " }", - "}", - "if (Test-Path -LiteralPath $sourceDir) {", - " Get-ChildItem -LiteralPath $sourceDir -Force | ForEach-Object {", - " Copy-Item -LiteralPath $_.FullName -Destination $destinationDir -Recurse -Force", - " }", - "}", - "$configSource = Join-Path $backupDir '.config'", - "if (Test-Path -LiteralPath $configSource) {", - " $configDestination = Join-Path $destinationDir '.config'", - " New-Item -ItemType Directory -Force -Path $configDestination | Out-Null", - " Get-ChildItem -LiteralPath $configSource -Force | ForEach-Object {", - " Copy-Item -LiteralPath $_.FullName -Destination $configDestination -Recurse -Force", - " }", - "}", - "$logSource = Join-Path $backupDir '.log'", - "if (Test-Path -LiteralPath $logSource) {", - " $logDestination = Join-Path $destinationDir '.log'", - " New-Item -ItemType Directory -Force -Path $logDestination | Out-Null", - " Get-ChildItem -LiteralPath $logSource -Force | ForEach-Object {", - " Copy-Item -LiteralPath $_.FullName -Destination $logDestination -Recurse -Force", - " }", - "}", - "$portableMarker = Join-Path $destinationDir '.portable'", - "if (-not (Test-Path -LiteralPath $portableMarker)) {", - " New-Item -ItemType File -Force -Path $portableMarker | Out-Null", - "}", - "try {", - " $portableFile = Get-Item -LiteralPath $portableMarker -ErrorAction Stop", - " $portableFile.Attributes = $portableFile.Attributes -bor [System.IO.FileAttributes]::Hidden", - "} catch {", - ' Write-Verbose "無法隱藏 .portable 標記:$($_.Exception.Message)"', - "}", - "Start-Sleep -Seconds 2", - "Start-Process -FilePath (Join-Path $destinationDir $executableName) -WorkingDirectory $destinationDir", - "Remove-Item -LiteralPath $cleanupDir -Recurse -Force -ErrorAction SilentlyContinue", - "Start-Sleep -Seconds 5", - "Remove-Item -LiteralPath $backupDir -Recurse -Force -ErrorAction SilentlyContinue", - "Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue", - ] - return "\n".join(lines) + "\n" + def _build_installer_launch_args(installer_path: Path) -> list[str]: + args = [str(installer_path)] + if RuntimePaths.is_portable_mode(): + args.extend(["/MSMPortable=1", f"/DIR={RuntimePaths.get_portable_base_dir()}"]) + else: + args.append("/MSMPortable=0") + return args @staticmethod def _launch_installer( installer_path: Path, parent=None, interaction: UpdateCheckerInteraction | None = None - ) -> None: + ) -> bool: """啟動安裝程式 Args: installer_path: 安裝程式檔案路徑 parent: 父視窗物件,用於在主執行緒顯示 UI 對話框 interaction: 更新流程互動介面。 + + Returns: + 成功啟動安裝程式時回傳 True,取消或啟動失敗時回傳 False。 """ installer_interaction: UpdateCheckerInteraction = interaction or _DirectUpdateCheckerInteraction() try: @@ -282,13 +196,13 @@ def _launch_installer( resolved_path = installer_path.resolve(strict=True) except FileNotFoundError as e: logger.error(f"安裝程式路徑解析失敗:{installer_path},錯誤:{e}") - return + return False except Exception as e: logger.error(f"解析安裝程式路徑時發生未預期錯誤:{installer_path},錯誤:{e}") - return + return False if not PathUtils.is_path_within(temp_dir, resolved_path, strict=True): logger.error(f"安裝程式路徑不在允許的暫存目錄中:{resolved_path}") - return + return False if resolved_path.is_file(): confirm = installer_interaction.call_on_ui( parent, @@ -302,20 +216,21 @@ def _launch_installer( ) if not confirm: logger.info(f"使用者取消執行安裝程式:{resolved_path}") - return - process = SubprocessUtils.popen_detached([str(resolved_path)]) + return False + process = SubprocessUtils.popen_detached(UpdateChecker._build_installer_launch_args(resolved_path)) time.sleep(0.5) returncode = process.poll() + if returncode is not None and returncode != 0: + logger.error(f"安裝程式啟動失敗,退出碼:{returncode}") + return False if returncode is not None: - if returncode != 0: - logger.error(f"安裝程式啟動失敗,退出碼:{returncode}") - return logger.debug(f"安裝程式進程已退出(可能啟動了子進程),退出碼:{returncode}") logger.info(f"已啟動安裝程式(PID: {process.pid}): {resolved_path}") - else: - logger.error(f"安裝程式不存在或不是檔案:{resolved_path}") + return True + logger.error(f"安裝程式不存在或不是檔案:{resolved_path}") except Exception as e: logger.exception(f"安裝程式啟動失敗: {e}") + return False @staticmethod def _graceful_exit(parent, delay_ms: int = 100, interaction: UpdateCheckerInteraction | None = None) -> None: @@ -482,14 +397,15 @@ def _cleanup_temp_files(temp_files: list[Path]) -> None: except Exception as e: logger.debug(f"清理暫存檔案時發生錯誤 {temp_path}: {e}") - def _handle_checksum_mismatch(asset_name: str) -> None: - """統一處理下載檔案 SHA256 驗證失敗。""" - logger.error(f"[驗證失敗] SHA256 不符合!檔案: {asset_name}") + def _handle_checksum_mismatch(asset_name: str, algorithm: str) -> None: + """統一處理下載檔案雜湊驗證失敗。""" + algorithm_label = algorithm.upper() + logger.error(f"[驗證失敗] {algorithm_label} 不符合!檔案: {asset_name}") TaskUtils.call_on_ui( parent, lambda: UIUtils.show_error( - "SHA256 驗證失敗", - "下載的檔案 SHA256 驗證失敗!\n\n可能原因:\n• 下載過程中檔案損壞\n• 檔案被惡意篡改\n• 網路傳輸錯誤\n\n為了您的安全:\n- 已立即刪除下載的檔案\n- 更新已取消\n\n請稍後重試,或手動從 GitHub 下載。", + "檔案雜湊驗證失敗", + f"下載的檔案 {algorithm_label} 驗證失敗!\n\n可能原因:\n• 下載過程中檔案損壞\n• 檔案被惡意篡改\n• 網路傳輸錯誤\n\n為了您的安全:\n- 已立即刪除下載的檔案\n- 更新已取消\n\n請稍後重試,或手動從 GitHub 下載。", parent=parent, topmost=True, ), @@ -546,16 +462,13 @@ def _handle_checksum_mismatch(asset_name: str) -> None: if not result: return logger.info("使用者確認更新,準備下載...") - portable_mode = RuntimePaths.is_portable_mode() - asset, asset_mode = UpdateChecker._select_update_asset(latest, portable_mode) - if asset_mode == "installer_fallback": - logger.info("可攜式更新資源不存在,回退使用 installer 資源") + asset, _ = UpdateChecker._select_update_asset(latest) if not asset: TaskUtils.call_on_ui( parent, lambda: UIUtils.show_info( "無安裝檔", - "找不到可用的安裝檔(.exe 或 portable.zip)。將開啟發行頁面,請手動下載。", + "找不到可用的安裝檔(.exe)。將開啟發行頁面,請手動下載。", parent=parent, topmost=True, ), @@ -593,258 +506,30 @@ def _fetch_checksum_for_asset(release: dict) -> tuple[str, str] | None: digest = _parse_asset_digest(asset_obj) if digest: logger.info( - f"[SHA256 查詢成功] 已從 GitHub asset digest 取得 checksum({digest[0]}),無需額外下載" + f"[digest 查詢成功] 已從 GitHub asset digest 取得 checksum({digest[0]}),無需額外下載" ) return digest - logger.warning("[SHA256 查詢失敗] GitHub asset digest 不存在或無法解析,已拒絕使用未驗證檔案") + logger.warning("[digest 查詢失敗] GitHub asset digest 不存在或無法解析,已拒絕使用未驗證檔案") return None except Exception as e: - logger.exception(f"[SHA256 查詢錯誤] 在查詢過程中發生未預期的錯誤: {e}") + logger.exception(f"[digest 查詢錯誤] 在查詢過程中發生未預期的錯誤: {e}") return None def _verify_file_checksum(path: Path, algorithm: str, hex_checksum: str) -> bool: checksum = PathUtils.calculate_checksum(path, algorithm) return checksum == hex_checksum.lower() if checksum else False - if asset_mode == "portable" and download_url and str(download_url).lower().endswith(".zip"): - if not TaskUtils.call_on_ui( - parent, - lambda: UIUtils.ask_yes_no_cancel( - "可攜式更新可用", - f"發現可攜式更新:{name}\n是否下載並套用?\n(會備份整個應用程式與 .config/.log,確保可恢復)", - parent=parent, - show_cancel=False, - topmost=True, - ), - ): - return - logger.info("使用者確認更新,開始更新流程") - close_delay_seconds = 3 - logger.info("[安全檢查] 正在線上查詢更新檔的 SHA256 驗證資訊...") - try: - latest["_selected_asset"] = asset - chk = _fetch_checksum_for_asset(latest) - if not chk: - logger.error("[安全檢查失敗] 未找到 SHA256,拒絕下載未經驗證的檔案") - TaskUtils.call_on_ui( - parent, - lambda: UIUtils.show_error( - "缺少 SHA256 驗證資訊", - "無法從 GitHub Release 中取得此更新檔的 SHA256 驗證資訊。\n\n為了您的系統安全:\n- 將不會下載任何檔案\n- 更新已取消\n\n建議聯絡開發者確認 Release 是否包含 SHA256 資訊。", - parent=parent, - topmost=True, - ), - ) - _cleanup_temp_files(temp_files_to_cleanup) - return - alg, expected_checksum = chk - logger.info(f"[安全檢查通過] 已取得 SHA256 驗證資訊 ({alg}: {expected_checksum[:16]}...)") - logger.info("[開始下載] 確認有 SHA256 可驗證,現在開始安全下載主檔案") - except Exception: - logger.exception("[安全檢查錯誤] 在查詢 SHA256 時發生錯誤,為避免風險將中止更新") - TaskUtils.call_on_ui( - parent, - lambda: UIUtils.show_error( - "安全驗證錯誤", - "在線上查詢 SHA256 驗證資訊時發生錯誤。\n\n為了您的系統安全:\n- 將不會下載任何檔案\n- 更新已取消", - parent=parent, - topmost=True, - ), - ) - _cleanup_temp_files(temp_files_to_cleanup) - return - logger.info("[下載階段] 開始下載可攜式更新檔...") - with tempfile.NamedTemporaryFile(delete=False, prefix="msm_portable_", suffix=".zip") as tmpf: - tmp_zip_path = tmpf.name - temp_files_to_cleanup.append(Path(tmp_zip_path)) - if not HTTPUtils.download_file(download_url, tmp_zip_path): - TaskUtils.call_on_ui( - parent, - lambda: UIUtils.show_error("下載失敗", "無法下載可攜式更新。", parent=parent, topmost=True), - ) - _cleanup_temp_files(temp_files_to_cleanup) - return - logger.info("[驗證階段] 正在計算並驗證下載檔案的 SHA256...") - logger.info(f"[驗證階段] 預期 SHA256: {expected_checksum}") - ok = _verify_file_checksum(Path(tmp_zip_path), alg, expected_checksum) - if not ok: - _handle_checksum_mismatch(asset.get("name") or "unknown") - return - logger.info(f"[驗證通過] SHA256 驗證成功:{asset.get('name')}") - extracted_dir = Path(tempfile.mkdtemp(prefix="msm_portable_extracted_")) - temp_files_to_cleanup.append(extracted_dir) - try: - PathUtils.safe_extract_zip(Path(tmp_zip_path), extracted_dir) - except Exception as e: - logger.exception(f"解壓更新檔失敗: {e}") - TaskUtils.call_on_ui( - parent, - lambda: UIUtils.show_error( - "解壓失敗", "無法解壓下載的更新檔。", parent=parent, topmost=True - ), - ) - _cleanup_temp_files(temp_files_to_cleanup) - return - base = RuntimePaths.get_portable_base_dir() - cfg = base / ".config" - lg = base / ".log" - TaskUtils.call_on_ui( - parent, - lambda: UIUtils.show_info( - "更新中", "正在應用更新,程式將在 3 秒後關閉...", parent=parent, topmost=True - ), - ) - logger.info("通知使用者程式將關閉進行更新") - backup_root = Path(tempfile.mkdtemp(prefix="msm_portable_backup_")) - logger.info(f"開始備份原始目錄與配置: {backup_root}") - try: - backup_dir = backup_root / "original" - PathUtils.copy_dir(base, backup_dir, ignore_patterns=[".config", ".log", ".portable"]) - if cfg.exists(): - PathUtils.copy_dir(cfg, backup_root / ".config") - logger.info("已備份 .config") - if lg.exists(): - PathUtils.copy_dir(lg, backup_root / ".log") - logger.info("已備份 .log") - except Exception as e: - error_msg = str(e) - logger.exception(f"備份失敗: {error_msg}") - TaskUtils.call_on_ui( - parent, - lambda: UIUtils.show_error( - "備份失敗", - f"無法備份現有配置,停止更新以確保安全。\n{error_msg}", - parent=parent, - topmost=True, - ), - ) - return - try: - temp_root = Path(tempfile.gettempdir()).resolve(strict=True) - extracted_dir_resolved = extracted_dir.resolve(strict=True) - backup_root_resolved = backup_root.resolve(strict=True) - base.resolve(strict=True) - if not PathUtils.is_path_within(temp_root, extracted_dir_resolved, strict=True): - logger.error(f"解壓目錄不在暫存目錄中,已取消更新:{extracted_dir_resolved}") - TaskUtils.call_on_ui( - parent, - lambda: UIUtils.show_error( - "安全錯誤", - "偵測到異常的解壓路徑,已取消更新以確保安全。", - parent=parent, - topmost=True, - ), - ) - _cleanup_temp_files(temp_files_to_cleanup) - return - if not PathUtils.is_path_within(temp_root, backup_root_resolved, strict=True): - logger.error(f"備份目錄不在暫存目錄中,已取消更新:{backup_root_resolved}") - TaskUtils.call_on_ui( - parent, - lambda: UIUtils.show_error( - "安全錯誤", - "偵測到異常的備份路徑,已取消更新以確保安全。", - parent=parent, - topmost=True, - ), - ) - _cleanup_temp_files(temp_files_to_cleanup) - return - except Exception as e: - logger.exception(f"驗證路徑時發生錯誤: {e}") - TaskUtils.call_on_ui( - parent, - lambda: UIUtils.show_error( - "錯誤", "路徑驗證失敗,已取消更新。", parent=parent, topmost=True - ), - ) - _cleanup_temp_files(temp_files_to_cleanup) - return - source_dir = Path(extracted_dir).expanduser() - cleanup_dir = Path(extracted_dir).expanduser() - destination_dir = Path(base).expanduser() - backup_dir = Path(backup_root).expanduser() - extracted_items = list(extracted_dir.iterdir()) - if ( - len(extracted_items) == 1 - and extracted_items[0].is_dir() - and (extracted_items[0].name == "MinecraftServerManager") - ): - source_dir = Path(extracted_items[0]).expanduser() - logger.info(f"檢測到嵌套資料夾,調整源路徑為: {source_dir}") - script = UpdateChecker._build_portable_update_script( - source_dir=source_dir, - destination_dir=destination_dir, - backup_dir=backup_dir, - cleanup_dir=cleanup_dir, - ) - apply_script = temp_root / "apply_update.ps1" - try: - if not PathUtils.write_text_file(apply_script, script, encoding="utf-8"): - raise OSError(f"無法寫入更新腳本: {apply_script}") - except Exception: - logger.exception("寫入 PowerShell 更新腳本失敗") - TaskUtils.call_on_ui( - parent, - lambda: UIUtils.show_error("錯誤", "無法建立套用更新的腳本。", parent=parent, topmost=True), - ) - return - try: - apply_script_resolved = apply_script.resolve(strict=True) - if not PathUtils.is_path_within(temp_root, apply_script_resolved, strict=True): - logger.error( - "套用更新腳本的路徑不在暫存目錄中,已拒絕執行。", - apply_script=str(apply_script_resolved), - temp_root=str(temp_root), - ) - TaskUtils.call_on_ui( - parent, - lambda: UIUtils.show_error( - "錯誤", - "偵測到異常的更新腳本路徑,已取消自動更新以確保安全。", - parent=parent, - topmost=True, - ), - ) - return - powershell_executable = ( - PathUtils.find_executable("pwsh") or PathUtils.find_executable("powershell") or "powershell" - ) - SubprocessUtils.popen_detached( - [ - str(powershell_executable), - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - str(apply_script_resolved), - ], - cwd=str(apply_script_resolved.parents[0]), - ) - except Exception: - logger.exception("啟動套用更新腳本失敗") - _cleanup_temp_files(temp_files_to_cleanup) - return - logger.info("更新腳本已啟動,準備關閉程式以進行更新") - if extracted_dir in temp_files_to_cleanup: - temp_files_to_cleanup.remove(extracted_dir) - _cleanup_temp_files(temp_files_to_cleanup) - time.sleep(close_delay_seconds) - UpdateChecker._graceful_exit(parent, interaction=update_interaction) - return - logger.info("[安全檢查] 正在線上查詢安裝程式的 SHA256 驗證資訊...") + logger.info("[安全檢查] 正在線上查詢安裝程式的 digest 驗證資訊...") try: latest["_selected_asset"] = asset chk = _fetch_checksum_for_asset(latest) if not chk: - logger.error("[安全檢查失敗] 未找到 SHA256,拒絕下載未經驗證的檔案") + logger.error("[安全檢查失敗] 未找到可用 digest,拒絕下載未經驗證的檔案") TaskUtils.call_on_ui( parent, lambda: UIUtils.show_error( - "缺少 SHA256 驗證資訊", - "無法從 GitHub Release 中取得此安裝程式的 SHA256 驗證資訊。\n\n為了您的系統安全:\n- 將不會下載任何檔案\n- 更新已取消\n\n建議聯絡開發者確認 Release 是否包含 SHA256 資訊。", + "缺少 digest 驗證資訊", + "無法從 GitHub Release 中取得此安裝程式的 SHA-256 digest 驗證資訊。\n\n為了您的系統安全:\n- 將不會下載任何檔案\n- 更新已取消\n\n建議聯絡開發者確認 Release 是否包含 digest 資訊。", parent=parent, topmost=True, ), @@ -852,15 +537,15 @@ def _verify_file_checksum(path: Path, algorithm: str, hex_checksum: str) -> bool _cleanup_temp_files(temp_files_to_cleanup) return alg, expected_checksum = chk - logger.info(f"[安全檢查通過] 已取得 SHA256 驗證資訊 ({alg}: {expected_checksum[:16]}...)") - logger.info("[開始下載] 確認有 SHA256 可驗證,現在開始安全下載安裝程式") + logger.info(f"[安全檢查通過] 已取得 digest 驗證資訊 ({alg}: {expected_checksum[:16]}...)") + logger.info("[開始下載] 確認有 digest 可驗證,現在開始安全下載安裝程式") except Exception: - logger.exception("[安全檢查錯誤] 在查詢 SHA256 時發生錯誤,為避免風險將中止更新") + logger.exception("[安全檢查錯誤] 在查詢 digest 時發生錯誤,為避免風險將中止更新") TaskUtils.call_on_ui( parent, lambda: UIUtils.show_error( "安全驗證錯誤", - "在線上查詢 SHA256 驗證資訊時發生錯誤。\n\n為了您的系統安全:\n- 將不會下載任何檔案\n- 更新已取消", + "在線上查詢 digest 驗證資訊時發生錯誤。\n\n為了您的系統安全:\n- 將不會下載任何檔案\n- 更新已取消", parent=parent, topmost=True, ), @@ -873,14 +558,29 @@ def _verify_file_checksum(path: Path, algorithm: str, hex_checksum: str) -> bool dest = Path(temp_path) temp_files_to_cleanup.append(dest) if HTTPUtils.download_file(download_url, str(dest)): - logger.info("[驗證階段] 正在計算並驗證下載檔案的 SHA256...") - logger.info(f"[驗證階段] 預期 SHA256: {expected_checksum}") + logger.info(f"[驗證階段] 正在計算並驗證下載檔案的 {alg.upper()}...") + logger.info(f"[驗證階段] 預期 {alg.upper()}: {expected_checksum}") ok = _verify_file_checksum(dest, alg, expected_checksum) if not ok: - _handle_checksum_mismatch(asset.get("name") or "unknown") + _handle_checksum_mismatch(asset.get("name") or "unknown", alg) + return + logger.info(f"[驗證通過] {alg.upper()} 驗證成功:{asset.get('name')}") + installer_started = UpdateChecker._launch_installer( + dest, parent=parent, interaction=update_interaction + ) + if not installer_started: + logger.info("安裝程式未啟動,更新流程已取消") + _cleanup_temp_files(temp_files_to_cleanup) + TaskUtils.call_on_ui( + parent, + lambda: UIUtils.show_info( + "更新已取消", + "安裝程式未啟動,程式將繼續執行。請稍後重試或手動從 GitHub Releases 下載。", + parent=parent, + topmost=True, + ), + ) return - logger.info(f"[驗證通過] SHA256 驗證成功:{asset.get('name')}") - UpdateChecker._launch_installer(dest, parent=parent, interaction=update_interaction) logger.info("安裝程式已啟動(獨立進程)") if dest in temp_files_to_cleanup: temp_files_to_cleanup.remove(dest) diff --git a/src/utils/update_utils/update_parsing.py b/src/utils/update_utils/update_parsing.py index 11040e6..b98a57c 100644 --- a/src/utils/update_utils/update_parsing.py +++ b/src/utils/update_utils/update_parsing.py @@ -75,68 +75,36 @@ def choose_installer_asset(release: dict[str, Any]) -> dict[str, Any]: 選中的 installer 資源,找不到時回傳空字典。 """ assets = release.get("assets") or [] - exe_assets = [] + installer_assets = [] for asset in assets: try: name = (asset.get("name") or "").lower() - if name.endswith(".exe") and asset.get("browser_download_url"): - exe_assets.append(asset) + if ( + name.endswith(".exe") + and ("setup" in name or "installer" in name) + and asset.get("browser_download_url") + ): + installer_assets.append(asset) except Exception as e: logger.debug(f"檢查 asset 資料時發生錯誤: {e}") continue - if not exe_assets: + if not installer_assets: return {} - for asset in exe_assets: - name = (asset.get("name") or "").lower() - if "setup" in name or "installer" in name: - return asset - return exe_assets[0] + return installer_assets[0] @staticmethod - def choose_portable_asset(release: dict[str, Any]) -> dict[str, Any]: - """挑選 portable.zip 更新檔。 + def select_update_asset(release: dict[str, Any]) -> tuple[dict[str, Any], str]: + """挑選更新資產,並回傳選擇策略。 Args: release: GitHub release 資料。 - Returns: - 選中的 portable 資源,找不到時回傳空字典。 - """ - assets = release.get("assets") or [] - for asset in assets: - try: - name_l = (asset.get("name") or "").lower() - if name_l.endswith(".zip") and "portable" in name_l and asset.get("browser_download_url"): - return asset - except Exception as e: - logger.debug(f"檢查可攜式資源時發生錯誤,跳過此資源: {e}") - continue - return {} - - @staticmethod - def select_update_asset(release: dict[str, Any], portable_mode: bool) -> tuple[dict[str, Any], str]: - """根據執行模式挑選更新資產,並回傳選擇策略。 - - Args: - release: GitHub release 資料。 - portable_mode: 是否為可攜式模式。 - Returns: (asset, mode) mode: - - portable: portable 模式且找到 portable zip - - installer: installer 模式,使用 installer exe - - installer_fallback: portable 模式找不到 zip,回退 installer exe + - installer: 使用 installer exe - none: 找不到可用更新資源 """ - if portable_mode: - portable_asset = UpdateParsing.choose_portable_asset(release) - if portable_asset: - return (portable_asset, "portable") - installer_asset = UpdateParsing.choose_installer_asset(release) - if installer_asset: - return (installer_asset, "installer_fallback") - return ({}, "none") installer_asset = UpdateParsing.choose_installer_asset(release) if installer_asset: return (installer_asset, "installer") @@ -169,6 +137,4 @@ def parse_asset_digest(asset: dict[str, Any]) -> tuple[str, str] | None: checksum = checksum.strip().lower() if algorithm == "sha256" and UpdateParsing._is_hex_hash(checksum, 64): return (algorithm, checksum) - if algorithm == "sha512" and UpdateParsing._is_hex_hash(checksum, 128): - return (algorithm, checksum) return None diff --git a/tests/test_loader_manager_smoke.py b/tests/test_loader_manager_smoke.py index 67321c8..051426e 100644 --- a/tests/test_loader_manager_smoke.py +++ b/tests/test_loader_manager_smoke.py @@ -205,9 +205,6 @@ def test_parse_remote_checksum_payload_accepts_sha256() -> None: assert LoaderManager._parse_remote_checksum_payload(payload, "sha256") == checksum -# SHA-1 is intentionally unsupported; only SHA-256 / SHA-512 are accepted. - - def test_download_file_with_progress_requires_secure_hash(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: manager = LoaderManager.__new__(LoaderManager) errors: list[str] = [] diff --git a/tests/test_local_mod_metadata_utils.py b/tests/test_local_mod_metadata_utils.py index 048a4b3..30d23b7 100644 --- a/tests/test_local_mod_metadata_utils.py +++ b/tests/test_local_mod_metadata_utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +import zipfile from types import SimpleNamespace from src.core.local_mod_scanner import LocalModScanner @@ -65,3 +66,13 @@ def test_collect_installed_mod_versions_groups_by_project_id() -> None: def test_local_mod_scanner_extract_version_uses_clean_version() -> None: assert LocalModScanner.extract_version_from_filename("Connector-1.0.0-beta.46+1.20.1") == "1.0.0" + + +def test_local_mod_scanner_rejects_oversized_json_metadata(tmp_path) -> None: + jar_path = tmp_path / "oversized.jar" + with zipfile.ZipFile(jar_path, "w") as jar: + jar.writestr("fabric.mod.json", '{"name":"Example"}') + + with zipfile.ZipFile(jar_path, "r") as jar: + assert LocalModScanner.read_json_from_jar(jar, "fabric.mod.json", max_bytes=8) is None + assert LocalModScanner.read_json_from_jar(jar, "fabric.mod.json", max_bytes=128) == {"name": "Example"} diff --git a/tests/test_manage_server_frame_smoke.py b/tests/test_manage_server_frame_smoke.py index c0b2084..ae47224 100644 --- a/tests/test_manage_server_frame_smoke.py +++ b/tests/test_manage_server_frame_smoke.py @@ -1,5 +1,6 @@ from __future__ import annotations +from types import SimpleNamespace from typing import Any, cast import pytest @@ -26,6 +27,29 @@ def item(self, item: str | int, option: str | None = None, **kw: Any) -> Any: return None +class FakeMonitorWindow: + def __init__(self) -> None: + self.show_calls = 0 + self.raise_calls = 0 + self.activate_calls = 0 + self.focus_calls = 0 + + def is_alive(self) -> bool: + return True + + def show(self) -> None: + self.show_calls += 1 + + def raise_(self) -> None: + self.raise_calls += 1 + + def activateWindow(self) -> None: + self.activate_calls += 1 + + def setFocus(self) -> None: + self.focus_calls += 1 + + def test_build_server_tree_payload_skips_empty_rows_and_preserves_order() -> None: server_data = [ ["Alpha", "1.21", "Fabric", "運行中", "已備份", "servers\\Alpha"], @@ -105,6 +129,34 @@ def test_begin_server_refresh_cycle_cancels_old_job_and_increments_token(monkeyp assert frame._server_refresh_token == 4 +def test_monitor_server_reuses_existing_window_for_user_click_and_brings_to_front() -> None: + frame = object.__new__(manage_server_frame_module.ManageServerFrame) + frame.selected_server = "Alpha" + fake_window = FakeMonitorWindow() + frame._monitor_windows = {"Alpha": SimpleNamespace(window=fake_window)} + + frame.monitor_server() + + assert fake_window.show_calls == 1 + assert fake_window.raise_calls == 1 + assert fake_window.activate_calls == 1 + assert fake_window.focus_calls == 1 + + +def test_monitor_server_auto_reuses_existing_window_without_forcing_focus() -> None: + frame = object.__new__(manage_server_frame_module.ManageServerFrame) + frame.selected_server = "Alpha" + fake_window = FakeMonitorWindow() + frame._monitor_windows = {"Alpha": SimpleNamespace(window=fake_window)} + + frame.monitor_server(bring_to_front=False) + + assert fake_window.show_calls == 1 + assert fake_window.raise_calls == 0 + assert fake_window.activate_calls == 0 + assert fake_window.focus_calls == 0 + + def test_remove_stale_server_items_recycles_and_prunes_names(monkeypatch: pytest.MonkeyPatch) -> None: frame = object.__new__(manage_server_frame_module.ManageServerFrame) frame._server_item_by_name = {"Alpha": "item-a", "Beta": "item-b", "Gamma": "item-c"} diff --git a/tests/test_mod_manager_local_file_ops.py b/tests/test_mod_manager_local_file_ops.py index 29de774..c6c5d91 100644 --- a/tests/test_mod_manager_local_file_ops.py +++ b/tests/test_mod_manager_local_file_ops.py @@ -3,6 +3,7 @@ from pathlib import Path from src.core import ModManager +from src.core.mod_models import LocalModInfo, ModStatus from src.utils import ( build_non_official_source_warning, build_non_official_source_warning_message, @@ -88,3 +89,28 @@ def test_download_source_policy_flags_non_official_hosts_only() -> None: "modrinth", provider_label="Modrinth", ) == ("非官方下載來源:Edge Mod 將從 edge.example.net 下載,非 Modrinth 官方網域,請再次確認來源可信度。") + + +def test_export_mod_list_html_escapes_mod_metadata() -> None: + manager = ModManager.__new__(ModManager) + manager.get_mod_list = lambda: [ + LocalModInfo( + id="evil", + name='', + filename="evil.jar", + version="1.0", + minecraft_version="1.21", + loader_type="Fabric", + author="Alice & Bob", + description='">', + status=ModStatus.ENABLED, + ) + ] + + html = manager.export_mod_list("html") + + assert "