diff --git a/.github/actions/setup-python-env/action.yml b/.github/actions/setup-python-env/action.yml index 750fb4d..c02a694 100644 --- a/.github/actions/setup-python-env/action.yml +++ b/.github/actions/setup-python-env/action.yml @@ -26,7 +26,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Install dependencies if: ${{ inputs.uv-sync == 'true' }} 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..dcb65c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## v1.7.2 - 2026-06-05 + +### 新增 +- **UI 視覺升級 (Fluent Design)**:導入 `PySide6-Fluent-Widgets` 框架擴充,新增 `fluent.py` 封裝 Fluent 風格元件,全面翻新主視窗與子選單(如伺服器建立、模組管理、伺服器設定)的視覺語彙與互動體驗。 + +### 調整 +- **更新與封裝機制最佳化**:全面優先改用「系統安裝程式」進行發佈,正式移除可攜式模式(Portable mode)及打包腳本 (`package-portable.ps1`) 支援,簡化後續的自動更新選擇流程。 +- **核心架構重構**:重構 `server_manager.py`、`server_instance.py` 等核心類別體系,並將耗時任務(如雜湊計算、線上資源驗證)進一步整合至共用的並行工作池(Worker Pool),降低資源佔用。 +- **設定與 I/O 強化**:調整 `settings_manager.py` 與 `subprocess_utils.py`,最佳化子處理程序生命週期管理,並套用更安全的序列化保護與重新啟動機制。 +- **政策與手冊更新**:更新官方資安通報政策與漏洞處理時程;並於《使用者手冊》與相關文檔中補充了 Quilt 與 NeoForge 的完整支援細節。 + +### 修正 +- 修正 `update_parsing.py` 在遠端資源檢查與版本驗證時的錯誤攔截邏輯,避免網路異常導致主程式崩潰。 +- 新增與修正大量自動化測試覆蓋:包含更新檢查器互動模擬 (`test_update_checker_installer_smoke.py`)、UI 元件排版測試、模組清單整合測試等。 + ## v1.7.1 - 2026-05-06 ### 新增 @@ -29,7 +44,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/assets/version_info.txt b/assets/version_info.txt index 822195b..47a9bc0 100644 --- a/assets/version_info.txt +++ b/assets/version_info.txt @@ -17,12 +17,12 @@ VSVersionInfo( [ StringStruct('CompanyName', 'Minecraft Server Manager Team'), StringStruct('FileDescription', 'Minecraft 伺服器管理器'), - StringStruct('FileVersion', '1.7.1.0'), + StringStruct('FileVersion', '1.7.2.0'), StringStruct('InternalName', 'MinecraftServerManager'), StringStruct('LegalCopyright', 'Copyright (c) 2025 Minecraft Server Manager Team'), StringStruct('OriginalFilename', 'MinecraftServerManager.exe'), StringStruct('ProductName', 'Minecraft Server Manager'), - StringStruct('ProductVersion', '1.7.1.0'), + StringStruct('ProductVersion', '1.7.2.0'), ] ) ] 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/pyproject.toml b/pyproject.toml index 21742f2..fe2598c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ license = { file = "LICENSE" } dependencies = [ "PySide6>=6.10.0", + "PySide6-Fluent-Widgets>=1.11.2", "requests>=2.33.1", "defusedxml>=0.7.1", "packaging>=26.0", 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/format_lint_check.bat b/scripts/format_lint_check.bat index 88db79b..17b9f76 100644 --- a/scripts/format_lint_check.bat +++ b/scripts/format_lint_check.bat @@ -35,7 +35,7 @@ uv run pylint --disable=all --enable=cyclic-import src if errorlevel 1 exit /b 1 echo. -choice /c YN /m "Run secret scan? (Y/N)" +choice /c YN /m "Run secret scan? (Y/N)" /t 5 /d N if errorlevel 2 ( echo Skipping secret scan. ) else ( 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/loader_manager.py b/src/core/loader_manager.py index df1d257..2c4aee6 100644 --- a/src/core/loader_manager.py +++ b/src/core/loader_manager.py @@ -114,9 +114,11 @@ def _cleanup_failed_installer_process( """在 installer 失敗或取消時清理殘留進程。""" if process is None: return + pid = int(getattr(process, "pid", 0) or 0) try: - if process.poll() is None: - SystemUtils.kill_process_tree(process.pid) + is_running = process.poll() is None + if pid and (is_running or bool(getattr(process, "cancelled", False))): + SystemUtils.kill_process_tree(pid) except Exception as e: logger.warning(f"終止安裝器進程樹失敗: {e}") try: @@ -124,7 +126,7 @@ def _cleanup_failed_installer_process( except Exception as e: logger.warning(f"清理安裝器殘留 Java 進程失敗: {e}") with suppress(Exception): - SystemUtils.unregister_managed_process(base_dir, process.pid) + SystemUtils.unregister_managed_process(base_dir, pid) with suppress(Exception): record_and_mark( RuntimeError(reason), @@ -978,44 +980,49 @@ def _download_and_run_installer( return self._fail(progress_callback, "執行安裝器失敗:無效的命令參數") process = None try: - process = SubprocessUtils.popen_checked( + output_buffer = "" + + def _on_installer_started(pid: int) -> None: + SystemUtils.register_managed_process(base_dir, pid) + + def _on_installer_output(chunk: str) -> None: + nonlocal output_buffer + output_buffer += chunk + if not progress_callback: + return + lines = output_buffer.splitlines() + if output_buffer and not output_buffer.endswith(("\n", "\r")): + output_buffer = lines.pop() if lines else output_buffer + else: + output_buffer = "" + for raw_line in lines: + line = raw_line.strip() + if not line: + continue + if "Download" in line: + progress_callback(install_start, f"安裝中: {line[:40]}...") + elif "Processor" in line: + progress_callback(install_start, f"處理中: {line[:40]}...") + + process = SubprocessUtils.run_qprocess_checked( cmd, cwd=str(base_dir), - stdin=SubprocessUtils.DEVNULL, - stdout=SubprocessUtils.PIPE, - stderr=SubprocessUtils.STDOUT, - text=True, encoding="utf-8", - errors="replace", - creationflags=SubprocessUtils.CREATE_NO_WINDOW, + on_started=_on_installer_started, + on_stdout=_on_installer_output, + cancel_check=lambda: self._is_cancel_requested(cancel_flag), ) - SystemUtils.register_managed_process(base_dir, process.pid) - if process.stdout is None: - with suppress(Exception): - SystemUtils.unregister_managed_process(base_dir, process.pid) - return False - while True: - if self._is_cancel_requested(cancel_flag): - self._cleanup_failed_installer_process( - process, - base_dir=base_dir, - installer_path=installer_path, - reason="installer_cancelled", - details={"cmd": cmd}, - ) - return self._fail(progress_callback, "已取消安裝,並已清理殘留安裝程序") - line = process.stdout.readline() - if not line and process.poll() is not None: - break - if line: - line = line.strip() - if progress_callback and line: - if "Download" in line: - progress_callback(install_start, f"安裝中: {line[:40]}...") - elif "Processor" in line: - progress_callback(install_start, f"處理中: {line[:40]}...") with suppress(Exception): SystemUtils.unregister_managed_process(base_dir, process.pid) + if process.cancelled: + self._cleanup_failed_installer_process( + process, + base_dir=base_dir, + installer_path=installer_path, + reason="installer_cancelled", + details={"cmd": cmd}, + ) + return self._fail(progress_callback, "已取消安裝,並已清理殘留安裝程序") if process.returncode != 0: logger.error(f"安裝器執行失敗 (Code {process.returncode})") return self._fail( @@ -1245,6 +1252,12 @@ def check_cancel(): if checksum is None: logger.warning(f"下載檔案未找到 SHA-256 / SHA-512 sidecar,將僅使用既有來源保護: {url}") expected_hash = checksum[1] if checksum else None + download_failure_reason = "" + + def _capture_download_failure(message: str) -> None: + nonlocal download_failure_reason + download_failure_reason = message + if HTTPUtils.download_file( url, dest_path, @@ -1252,9 +1265,10 @@ def check_cancel(): timeout=30, cancel_check=check_cancel, expected_hash=expected_hash, + failure_message_callback=_capture_download_failure, ): return True - return self._fail(progress_callback, "下載失敗:無法獲取檔案") + return self._fail(progress_callback, download_failure_reason or "下載失敗:無法獲取檔案") def _get_minecraft_server_url(self, mc_version: str) -> str | None: """根據 Minecraft 版本獲取伺服器 JAR 下載 URL。""" diff --git a/src/core/local_mod_scanner.py b/src/core/local_mod_scanner.py index 1334cc3..0ca171a 100644 --- a/src/core/local_mod_scanner.py +++ b/src/core/local_mod_scanner.py @@ -7,7 +7,6 @@ import tomllib import zipfile from collections.abc import Callable -from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any @@ -18,17 +17,21 @@ ServerDetectionVersionUtils, derive_provider_lifecycle_state, get_logger, + get_shared_manager, record_and_mark, ) from .mod_models import MODRINTH_HASH_ALGORITHM, LocalModInfo, ModPlatform, ModStatus 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, *, @@ -61,8 +64,8 @@ def scan_mods(self, create_mod_info_from_file: Callable[[Path], LocalModInfo | N if file_path.suffix == ".jar" or file_path.name.endswith(".jar.disabled") ] files_to_scan.sort(key=lambda path: path.name.lower()) - with ThreadPoolExecutor(max_workers=min(6, len(files_to_scan) or 1)) as executor: - results = executor.map(create_mod_info_from_file, files_to_scan) + futures = [get_shared_manager().run(create_mod_info_from_file, file_path) for file_path in files_to_scan] + results = [future.result() for future in futures] for mod_info in results: if mod_info: mods.append(mod_info) @@ -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_file_installer.py b/src/core/mod_file_installer.py index f42153a..71dfed0 100644 --- a/src/core/mod_file_installer.py +++ b/src/core/mod_file_installer.py @@ -257,7 +257,16 @@ def install_remote_mod_file_result( ) with tempfile.TemporaryDirectory(prefix=f"{safe_filename}.", dir=self.download_staging_root) as staging_dir: staging_path = Path(staging_dir) / safe_filename - download_kwargs: dict[str, Any] = {"progress_callback": progress_callback} + download_failure_reason = "" + + def _capture_download_failure(message: str) -> None: + nonlocal download_failure_reason + download_failure_reason = message + + download_kwargs: dict[str, Any] = { + "progress_callback": progress_callback, + "failure_message_callback": _capture_download_failure, + } if normalized_expected_hash: download_kwargs["expected_hash"] = normalized_expected_hash if cancel_check is not None: @@ -267,8 +276,9 @@ def install_remote_mod_file_result( if self.is_operation_cancelled(cancel_check): self.logger.info(f"遠端模組下載已取消: {safe_filename}", "ModFileInstaller") return ModFileOperationResult(status="cancelled", message="cancelled_during_download") - self.logger.warning(f"遠端模組下載未完成: {safe_filename}", "ModFileInstaller") - return ModFileOperationResult(status="failed", message="download_incomplete") + failure_message = download_failure_reason or "download_incomplete" + self.logger.warning(f"遠端模組下載未完成: {safe_filename} | {failure_message}", "ModFileInstaller") + return ModFileOperationResult(status="failed", message=failure_message) if self.is_operation_cancelled(cancel_check): PathUtils.delete_within(self.server_path, staging_path) self.logger.info(f"遠端模組安裝在寫入前已取消: {safe_filename}", "ModFileInstaller") 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/core/server_instance.py b/src/core/server_instance.py index de6b983..0283133 100644 --- a/src/core/server_instance.py +++ b/src/core/server_instance.py @@ -7,11 +7,14 @@ import contextlib import threading +import time from collections import deque from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any +from PySide6 import QtCore + from ..utils import SubprocessUtils, SystemUtils, get_logger if TYPE_CHECKING: @@ -39,6 +42,7 @@ class ServerInstance: process: Any | None = field(default=None, init=False, repr=False) _output_buffer: deque[str] | None = field(default=None, init=False, repr=False) _output_lock: threading.Lock | None = field(default=None, init=False, repr=False) + _output_pending: str = field(default="", init=False, repr=False) def attach_process(self, process: Any) -> Any: """綁定新的執行中的 process。 @@ -69,12 +73,14 @@ def attach_output_buffer(self, max_size: int) -> None: with self._lock: self._output_buffer = deque(maxlen=max_size) self._output_lock = threading.Lock() + self._output_pending = "" def clear_output_buffer(self) -> None: """清除伺服器輸出緩衝。""" with self._lock: self._output_buffer = None self._output_lock = None + self._output_pending = "" def append_output_line(self, line: str) -> None: """將一行伺服器輸出寫入緩衝。 @@ -90,6 +96,41 @@ def append_output_line(self, line: str) -> None: with output_lock: output_buffer.append(line.rstrip("\r\n")) + def append_output_text(self, text: str) -> None: + """將 QProcess stdout/stderr 文字片段拆成行並寫入緩衝。 + + Args: + text: 來自 QProcess signal 的輸出文字片段。 + """ + if not text: + return + with self._lock: + output_buffer = self._output_buffer + output_lock = self._output_lock + if output_buffer is None or output_lock is None: + return + with output_lock: + combined = self._output_pending + text + lines = combined.splitlines() + if combined and not combined.endswith(("\n", "\r")): + self._output_pending = lines.pop() if lines else combined + else: + self._output_pending = "" + for line in lines: + output_buffer.append(line.rstrip("\r\n")) + + def flush_output_pending(self) -> None: + """把尚未換行的輸出片段送入緩衝。""" + with self._lock: + output_buffer = self._output_buffer + output_lock = self._output_lock + if output_buffer is None or output_lock is None: + return + with output_lock: + if self._output_pending: + output_buffer.append(self._output_pending.rstrip("\r\n")) + self._output_pending = "" + def consume_output_lines(self) -> list[str]: """取出並清空目前的伺服器輸出緩衝。 @@ -115,10 +156,69 @@ def get_process(self) -> Any | None: with self._lock: return self.process + @staticmethod + def is_qprocess(process: Any) -> bool: + """判斷物件是否為 Qt 的 QProcess。 + + Args: + process: 要檢查的程序物件。 + + Returns: + 若物件為 `QProcess` 則回傳 True。 + """ + return isinstance(process, QtCore.QProcess) + + @staticmethod + def process_pid(process: Any) -> int: + """取得 QProcess 或 subprocess 類物件的 PID。 + + Args: + process: 要讀取 PID 的程序物件。 + + Returns: + 程序 PID;無法取得時回傳 0。 + """ + if isinstance(process, QtCore.QProcess): + stored_pid = process.property("_msm_pid") + if stored_pid: + return int(stored_pid) + return int(process.processId()) + return int(getattr(process, "pid", 0) or 0) + + @staticmethod + def process_is_running(process: Any) -> bool: + """判斷 QProcess 或 subprocess 類物件是否仍在執行。 + + Args: + process: 要檢查狀態的程序物件。 + + Returns: + 程序尚未結束時回傳 True。 + """ + if isinstance(process, QtCore.QProcess): + return process.state() != QtCore.QProcess.ProcessState.NotRunning + return process.poll() is None + + @staticmethod + def process_returncode(process: Any) -> int | None: + """取得 QProcess 或 subprocess 類物件的結束代碼。 + + Args: + process: 要讀取結束代碼的程序物件。 + + Returns: + 程序仍在執行時回傳 None;已結束時回傳 exit code。 + """ + if isinstance(process, QtCore.QProcess): + if ServerInstance.process_is_running(process): + return None + return int(process.exitCode()) + return process.poll() + def start(self, cmd: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None) -> Any: - """啟動伺服器,回傳 subprocess.Popen 物件。 + """啟動伺服器,回傳 QProcess 物件。 - 注意:此方法為同步呼叫(會立即 return Popen),若需非同步監控請使用 BackgroundTask。 + 注意:此方法只負責啟動與綁定;輸出處理應由呼叫端接 QProcess signal。 Args: cmd: 要執行的命令列。 @@ -126,16 +226,25 @@ def start(self, cmd: list[str], *, cwd: Path | None = None, env: dict[str, str] env: 額外的環境變數。 Returns: - 啟動後的 subprocess.Popen 物件。 + 啟動後的 QProcess 物件。 """ with self._lock: if self.process is not None: raise RuntimeError("伺服器已在執行中") cwd = cwd or self.path - proc = SubprocessUtils.popen_checked( - cmd, cwd=str(cwd), env=env, stdout=SubprocessUtils.PIPE, stderr=SubprocessUtils.PIPE - ) - SystemUtils.register_managed_process(cwd, proc.pid) + proc = SubprocessUtils.create_qprocess_checked(cmd, cwd=str(cwd)) + if env: + process_env = QtCore.QProcessEnvironment.systemEnvironment() + for key, value in env.items(): + process_env.insert(str(key), str(value)) + proc.setProcessEnvironment(process_env) + proc.start() + if not proc.waitForStarted(10000): + raise RuntimeError(proc.errorString() or "QProcess 啟動失敗") + pid = int(proc.processId()) + proc.setProperty("_msm_pid", pid) + proc.setProperty("_msm_create_time", time.time()) + SystemUtils.register_managed_process(cwd, pid) return self.attach_process(proc) def stop(self, timeout: float = 5.0) -> bool: @@ -150,14 +259,27 @@ def stop(self, timeout: float = 5.0) -> bool: with self._lock: if self.process is None: return True + process = self.process try: - self.process.terminate() - self.process.wait(timeout=timeout) + if isinstance(process, QtCore.QProcess): + if process.state() != QtCore.QProcess.ProcessState.NotRunning: + process.write(b"stop\n") + process.waitForBytesWritten(1000) + if not process.waitForFinished(max(1, int(timeout * 1000))): + process.terminate() + if process.state() != QtCore.QProcess.ProcessState.NotRunning and not process.waitForFinished( + 1000 + ): + process.kill() + process.waitForFinished(1000) + else: + process.terminate() + process.wait(timeout=timeout) except SubprocessUtils.TimeoutExpired: # 逾時,嘗試強制終止 try: - self.process.kill() - self.process.wait(timeout=1) + process.kill() + process.wait(timeout=1) except (SubprocessUtils.TimeoutExpired, OSError) as _: logger.warning( "強制終止超時伺服器進程失敗 (id=%s, name=%s).", @@ -168,8 +290,8 @@ def stop(self, timeout: float = 5.0) -> bool: except OSError: # I/O 相關錯誤(例如管線已關閉),嘗試強制終止以確保資源清理 try: - self.process.kill() - self.process.wait(timeout=1) + process.kill() + process.wait(timeout=1) except (SubprocessUtils.TimeoutExpired, OSError) as _: logger.warning( "強制終止伺服器進程失敗 (id=%s, name=%s).", @@ -179,14 +301,14 @@ def stop(self, timeout: float = 5.0) -> bool: ) finally: with contextlib.suppress(Exception): - SystemUtils.unregister_managed_process(self.path, self.process.pid) + SystemUtils.unregister_managed_process(self.path, self.process_pid(process)) self.clear_process() return True def is_running(self) -> bool: """回傳是否有正在執行的 process。""" process = self.get_process() - return process is not None and process.poll() is None + return process is not None and self.process_is_running(process) def to_dict(self) -> dict[str, Any]: """序列化不含 process 的 instance 資料,用於儲存或 UI 顯示。 diff --git a/src/core/server_manager.py b/src/core/server_manager.py index 3dc580a..3d3dc70 100644 --- a/src/core/server_manager.py +++ b/src/core/server_manager.py @@ -3,11 +3,14 @@ """ import contextlib +import os import threading import time from dataclasses import asdict, dataclass, is_dataclass from pathlib import Path -from typing import Any +from typing import Any, cast + +from PySide6 import QtCore from ..models import ServerConfig from ..utils import ( @@ -78,7 +81,7 @@ def _cleanup_running_server_state(self, server_name: str) -> None: if instance is not None: process = instance.get_process() if process is not None: - SystemUtils.unregister_managed_process(instance.path, process.pid) + SystemUtils.unregister_managed_process(instance.path, self._process_pid(process)) instance.clear_process() instance.clear_output_buffer() @@ -87,10 +90,13 @@ def _cleanup_failed_runtime_process(self, server_name: str, server_path: Path | cleaned = False if process is not None: try: - if process.poll() is None: - cleaned = bool(SystemUtils.kill_process_tree(process.pid)) or cleaned + pid = self._process_pid(process) + if self._process_is_running(process): + with contextlib.suppress(Exception): + process.kill() + cleaned = bool(SystemUtils.kill_process_tree(pid)) or cleaned if server_path is not None: - SystemUtils.unregister_managed_process(server_path, process.pid) + SystemUtils.unregister_managed_process(server_path, pid) except Exception as e: logger.warning(f"清理伺服器進程樹失敗: {server_name} | {e}") try: @@ -119,21 +125,75 @@ def _get_running_instance(self, server_name: str) -> ServerInstance | None: self._cleanup_running_server_state(server_name) return None + @staticmethod + def _process_pid(process: Any) -> int: + return ServerInstance.process_pid(process) + + @staticmethod + def _process_is_running(process: Any) -> bool: + return ServerInstance.process_is_running(process) + + @staticmethod + def _process_returncode(process: Any) -> int | None: + return ServerInstance.process_returncode(process) + + @staticmethod + def _get_process_metadata(process: Any, key: str, default: Any = None) -> Any: + if isinstance(process, QtCore.QObject): + value = process.property(key) + return default if value is None else value + return getattr(process, key.removeprefix("_msm_"), default) + + @staticmethod + def _set_process_metadata(process: Any, key: str, value: Any) -> None: + if isinstance(process, QtCore.QObject): + process.setProperty(key, value) + with contextlib.suppress(Exception): + setattr(process, key.removeprefix("_msm_"), value) + + @staticmethod + def _startup_script_command(script_path: Path) -> list[str]: + if os.name == "nt" and script_path.suffix.lower() in {".bat", ".cmd"}: + return ["cmd.exe", "/d", "/s", "/c", str(script_path)] + return [str(script_path)] + + @staticmethod + def _decode_process_output(process: QtCore.QProcess) -> str: + data = process.readAllStandardOutput() + return bytes(cast(Any, data)).decode("utf-8", errors="replace") + + @staticmethod + def _write_process_command(process: Any, command: str) -> bool: + payload = f"{command}\n" + if isinstance(process, QtCore.QProcess): + if process.state() == QtCore.QProcess.ProcessState.NotRunning: + return False + process.write(payload.encode("utf-8")) + return bool(process.waitForBytesWritten(1000)) + if process.stdin: + process.stdin.write(payload) + process.stdin.flush() + return True + return False + @staticmethod def _wait_for_process_exit(process: Any, timeout_seconds: float, interval_seconds: float | None = None) -> bool: - """在指定期限內等待 process 結束,並以 Event.wait 取代 sleep 輪詢。""" + """在指定期限內等待 process 結束。""" + if isinstance(process, QtCore.QProcess): + if timeout_seconds <= 0: + return process.state() == QtCore.QProcess.ProcessState.NotRunning + return process.waitForFinished(max(1, int(timeout_seconds * 1000))) if timeout_seconds <= 0: return process.poll() is not None wait_interval = interval_seconds if interval_seconds and interval_seconds > 0 else timeout_seconds deadline = time.monotonic() + timeout_seconds - waiter = threading.Event() while True: if process.poll() is not None: return True remaining = deadline - time.monotonic() if remaining <= 0: return process.poll() is not None - waiter.wait(min(wait_interval, remaining)) + time.sleep(min(wait_interval, remaining)) def _validate_server_runtime_path(self, config: ServerConfig) -> tuple[Path | None, ServerOperationResult | None]: """在啟動前驗證伺服器路徑是否安全且可用。""" @@ -185,11 +245,18 @@ def create_server_result( Returns: 建立流程結果,供 UI 或呼叫端決定後續呈現。 """ + server_path: Path | None = None + previous_config = self.servers.get(config.name) + added_server_entry = False + created_server_dir = False try: server_path = (self.servers_root / config.name).resolve() if not PathUtils.is_path_within(self.servers_root, server_path, strict=False): raise ValueError(f"無效的伺服器名稱 (路徑遍歷偵測): {config.name}") - server_path.mkdir(exist_ok=True) + if server_path.exists(): + raise FileExistsError(f"伺服器資料夾已存在: {server_path}") + server_path.mkdir() + created_server_dir = True config.path = str(server_path) need_detect = ( not config.loader_type @@ -222,9 +289,8 @@ def create_server_result( except Exception as e: logger.error(f"自動偵測伺服器類型失敗: {e}") raise - self.servers[config.name] = config - self.write_servers_config() - self._create_eula_file(server_path) + if not self._create_eula_file(server_path): + raise RuntimeError(f"建立 EULA 檔案失敗: {server_path}") config.eula_accepted = True self._create_server_structure(Path(config.path), config.loader_type) properties_file = server_path / "server.properties" @@ -232,15 +298,27 @@ def create_server_result( properties = self.get_default_server_properties() properties = dict(properties) properties["motd"] = f"Minecraft 伺服器 - {config.name}" - ServerPropertiesHelper.save_properties(properties_file, properties) + if not ServerPropertiesHelper.save_properties(properties_file, properties): + raise RuntimeError(f"儲存 server.properties 失敗: {properties_file}") config.properties = properties - self.create_launch_script(config) + if not self.create_launch_script(config): + raise RuntimeError(f"建立啟動腳本失敗: {server_path}") + self.servers[config.name] = config + added_server_entry = True + if not self.write_servers_config(): + raise RuntimeError(f"儲存伺服器設定失敗: {config.name}") return self._success_result(f"伺服器 {config.name} 已建立", server_name=config.name) except Exception as e: + if added_server_entry: + if previous_config is not None: + self.servers[config.name] = previous_config + else: + self.servers.pop(config.name, None) try: - server_path = (self.servers_root / config.name).resolve() + if created_server_dir and server_path and server_path.exists(): + PathUtils.delete_within(self.servers_root, server_path) except Exception: - server_path = None + logger.warning(f"建立失敗後清理伺服器資料夾失敗: {server_path}") # 嘗試終止殘留 Java 進程 try: killed = False @@ -273,10 +351,10 @@ def create_server(self, config: ServerConfig, properties: dict[str, str] | None return self.create_server_result(config, properties).success - def _create_eula_file(self, server_path: Path) -> None: - """建立並同意 EULA 檔案""" + def _create_eula_file(self, server_path: Path) -> bool: + """建立並同意 EULA 檔案。""" eula_content = "eula=true" - PathUtils.write_text_file(server_path / "eula.txt", eula_content) + return PathUtils.write_text_file(server_path / "eula.txt", eula_content) def _create_server_structure(self, path: Path, loader_type: str) -> None: """建立伺服器檔案結構""" @@ -290,12 +368,15 @@ def _create_server_structure(self, path: Path, loader_type: str) -> None: for directory in directories: (path / directory).mkdir(exist_ok=True) - def create_launch_script(self, config: ServerConfig, java_command_override: str | None = None) -> None: + def create_launch_script(self, config: ServerConfig, java_command_override: str | None = None) -> bool: """建立伺服器啟動腳本。 Args: config: 伺服器設定與啟動參數來源。 java_command_override: 匯入既有伺服器時保留的原始 Java 啟動命令。 + + Returns: + 啟動腳本寫入成功時回傳 True,失敗時回傳 False。 """ server_path = Path(config.path) if java_command_override: @@ -350,7 +431,7 @@ def create_launch_script(self, config: ServerConfig, java_command_override: str existing_content = existing_bytes.decode("utf-8-sig", errors="ignore") if existing_content == bat_content and not existing_has_bom: logger.debug("啟動腳本內容未變更,跳過寫入") - return + return True except Exception as e: with contextlib.suppress(Exception): record_and_mark( @@ -360,7 +441,7 @@ def create_launch_script(self, config: ServerConfig, java_command_override: str details={"server": getattr(config, "name", None)}, ) logger.debug(f"比較啟動腳本時發生錯誤 (將強制覆寫): {e}") - PathUtils.write_text_file(start_script_path, bat_content, encoding="utf-8", errors="replace") + return PathUtils.write_text_file(start_script_path, bat_content, encoding="utf-8", errors="replace") def update_server_properties(self, server_name: str, properties: dict[str, str]) -> bool: """更新 server.properties,只覆蓋有變動的欄位,其餘欄位保留原值。 @@ -424,7 +505,9 @@ def _resolve_startup_script_for_run(self, config: ServerConfig, server_path: Pat logger.debug(f"使用既有啟動腳本,啟動前確認 Java 路徑: {script_path}") ServerCommands.repair_startup_script_java_command(script_path, config) return script_path - self.create_launch_script(config) + if not self.create_launch_script(config): + logger.error(f"建立啟動腳本失敗: {server_path}") + return None return ServerDetectionUtils.find_startup_script(server_path) def start_server_result(self, server_name: str) -> ServerOperationResult: @@ -461,29 +544,26 @@ def start_server_result(self, server_name: str) -> ServerOperationResult: try: abs_script_path = script_path.resolve() abs_server_path = server_path.resolve() - cmd = [str(abs_script_path)] + cmd = self._startup_script_command(abs_script_path) logger.debug(f"執行命令: {cmd}") logger.debug(f"工作目錄: {abs_server_path}") - process = SubprocessUtils.popen_checked( - cmd, - cwd=str(abs_server_path), - stdin=SubprocessUtils.PIPE, - stdout=SubprocessUtils.PIPE, - stderr=SubprocessUtils.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - bufsize=0, - universal_newlines=True, - creationflags=SubprocessUtils.CREATE_NO_WINDOW, - ) - SystemUtils.register_managed_process(abs_server_path, process.pid) - process.create_time = time.time() + process = SubprocessUtils.create_qprocess_checked(cmd, cwd=str(abs_server_path)) + process.start() + if not process.waitForStarted(10000): + return self._failure_result( + "啟動失敗", + f"伺服器進程無法啟動:{process.errorString()}", + server_name=server_name, + ) + pid = int(process.processId()) + self._set_process_metadata(process, "_msm_pid", pid) + self._set_process_metadata(process, "_msm_create_time", time.time()) + SystemUtils.register_managed_process(abs_server_path, pid) if self._wait_for_process_exit(process, self.STARTUP_CHECK_DELAY): - poll_result = process.poll() + poll_result = self._process_returncode(process) logger.error(f"進程立即結束,返回碼: {poll_result}") try: - stdout, _ = process.communicate(timeout=1) + stdout = self._decode_process_output(process) if stdout: logger.error(f"程式輸出: {stdout}") except Exception as e: @@ -503,43 +583,23 @@ def start_server_result(self, server_name: str) -> ServerOperationResult: instance.attach_output_buffer(self.OUTPUT_QUEUE_MAX_SIZE) self.running_servers[server_name] = instance - def _process_waiter(proc, name): - try: - proc.wait() - logger.info(f"伺服器 {name} 已停止 (Exit code: {proc.returncode})") - except Exception as e: - logger.error(f"等待伺服器 {name}結束時發生錯誤: {e}") - - threading.Thread( - target=_process_waiter, args=(process, server_name), daemon=True, name=f"Waiter-{server_name}" - ).start() - - def _output_reader(proc, running_instance, name): + def _drain_output() -> None: try: - while True: - line = None - try: - line = proc.stdout.readline() - except UnicodeDecodeError: - try: - raw = proc.stdout.buffer.readline() - line = raw.decode("utf-8", errors="ignore") - except Exception as e2: - logger.exception(f"{name} 嚴重編碼錯誤: {e2}", "output_reader", e2) - continue - if not line: - break - running_instance.append_output_line(line) - if proc.poll() is not None: - break + instance.append_output_text(self._decode_process_output(process)) except Exception as e: - get_logger().bind(component="output_reader").exception(f"{name} 讀取錯誤: {e}") - - threading.Thread(target=_output_reader, args=(process, instance, server_name), daemon=True).start() - logger.info(f"伺服器 {server_name} 啟動成功,PID: {process.pid}") - return self._success_result( - f"伺服器 {server_name} 啟動成功,PID: {process.pid}", server_name=server_name - ) + get_logger().bind(component="server_process_output").exception(f"{server_name} 讀取錯誤: {e}") + + def _on_finished(exit_code: int, _status: QtCore.QProcess.ExitStatus) -> None: + _drain_output() + instance.flush_output_pending() + with contextlib.suppress(Exception): + SystemUtils.unregister_managed_process(abs_server_path, pid) + logger.info(f"伺服器 {server_name} 已停止 (Exit code: {exit_code})") + + process.readyReadStandardOutput.connect(_drain_output) + process.finished.connect(_on_finished) + logger.info(f"伺服器 {server_name} 啟動成功,PID: {pid}") + return self._success_result(f"伺服器 {server_name} 啟動成功,PID: {pid}", server_name=server_name) except FileNotFoundError as e: logger.exception(f"檔案路徑錯誤: {e}") return self._failure_result("啟動失敗", f"找不到啟動所需檔案: {e}", server_name=server_name) @@ -590,10 +650,18 @@ def delete_server_result(self, server_name: str) -> ServerOperationResult: f"拒絕刪除不在伺服器根目錄下的路徑: {server_path}", server_name=server_name, ) + removed_config = self.servers[server_name] + del self.servers[server_name] + if not self.write_servers_config(): + self.servers[server_name] = removed_config + return self._failure_result( + "刪除失敗", f"無法保存刪除後的伺服器配置: {server_name}", server_name=server_name + ) if not PathUtils.delete_within(self.servers_root, server_path): + self.servers[server_name] = removed_config + if not self.write_servers_config(): + logger.error(f"回滾刪除失敗時,無法恢復伺服器配置: {server_name}") return self._failure_result("刪除失敗", f"無法刪除伺服器資料夾: {server_path}", server_name=server_name) - del self.servers[server_name] - self.write_servers_config() return self._success_result(f"伺服器 {server_name} 已刪除", server_name=server_name) except Exception as e: try: @@ -664,12 +732,11 @@ def get_default_server_properties(self) -> dict[str, str]: return { "accepts-transfers": "false", "allow-flight": "false", - "allow-nether": "true", "broadcast-console-to-ops": "true", "broadcast-rcon-to-ops": "true", "bug-report-link": "", "difficulty": "easy", - "enable-command-block": "false", + "enable-code-of-conduct": "false", "enable-jmx-monitoring": "false", "enable-query": "false", "enable-rcon": "false", @@ -690,6 +757,14 @@ def get_default_server_properties(self) -> dict[str, str]: "level-seed": "", "level-type": "minecraft:normal", "log-ips": "true", + "management-server-allowed-origins": "", + "management-server-enabled": "false", + "management-server-host": "localhost", + "management-server-port": "0", + "management-server-secret": "", + "management-server-tls-enabled": "true", + "management-server-tls-keystore": "", + "management-server-tls-keystore-password": "", "max-chained-neighbor-updates": "1000000", "max-players": "20", "max-tick-time": "60000", @@ -701,7 +776,6 @@ def get_default_server_properties(self) -> dict[str, str]: "pause-when-empty-seconds": "60", "player-idle-timeout": "0", "prevent-proxy-connections": "false", - "pvp": "true", "query.port": "25565", "rate-limit": "0", "rcon.password": "", @@ -715,8 +789,8 @@ def get_default_server_properties(self) -> dict[str, str]: "server-ip": "", "server-port": "25565", "simulation-distance": "10", - "spawn-monsters": "true", "spawn-protection": "16", + "status-heartbeat-interval": "0", "sync-chunk-writes": "true", "text-filtering-config": "", "text-filtering-version": "0", @@ -815,10 +889,12 @@ def _prepare_imported_startup_scripts(self, config: ServerConfig) -> None: if removed_scripts: logger.info("已移除匯入啟動腳本: " + ", ".join(removed_scripts)) - self.create_launch_script(config, java_command_override=java_command_override) + if not self.create_launch_script(config, java_command_override=java_command_override): + raise RuntimeError(f"匯入伺服器啟動腳本建立失敗: {config.name}") logger.info(f"匯入伺服器已建立/更新標準啟動腳本 start_server.bat: {config.name}") except Exception as exc: logger.warning(f"匯入伺服器啟動腳本整理失敗,保留原始檔案: {exc}") + raise def add_server(self, config: ServerConfig) -> bool: """添加伺服器配置(用於匯入)。 @@ -829,12 +905,18 @@ def add_server(self, config: ServerConfig) -> bool: Returns: 成功寫入設定時回傳 True,失敗時回傳 False。 """ + previous_config = self.servers.get(config.name) try: self._prepare_imported_startup_scripts(config) self.servers[config.name] = config - self.write_servers_config() + if not self.write_servers_config(): + raise RuntimeError(f"保存伺服器配置失敗: {config.name}") return True except Exception as e: + if previous_config is not None: + self.servers[config.name] = previous_config + else: + self.servers.pop(config.name, None) logger.exception(f"添加伺服器失敗: {e}") return False @@ -870,7 +952,8 @@ def load_server_properties(self, server_name: str) -> dict[str, str]: existing_properties = dict(getattr(config, "properties", {}) or {}) if existing_properties != properties: config.properties = dict(properties) - self.write_servers_config() + if not self.write_servers_config(): + logger.warning(f"同步 server.properties 到 servers_config.json 失敗: server={server_name}") return properties except Exception as e: logger.exception(f"讀取 server.properties 失敗: {e}") @@ -912,25 +995,36 @@ def stop_server(self, server_name: str) -> bool: logger.info(f"伺服器 {server_name} 已停止") return True try: - is_running = process.poll() is None + is_running = self._process_is_running(process) except Exception: is_running = False if not is_running: logger.info(f"伺服器 {server_name} 已停止") return True try: - if process.stdin: - process.stdin.write("stop\n") - process.stdin.flush() - process.wait(timeout=5) + self._write_process_command(process, "stop") + if isinstance(process, QtCore.QProcess): + if not process.waitForFinished(5000): + process.terminate() + else: + process.wait(timeout=5) except (SubprocessUtils.TimeoutExpired, OSError, BrokenPipeError) as _: try: process.terminate() - process.wait(timeout=5) + if isinstance(process, QtCore.QProcess): + process.waitForFinished(5000) + else: + process.wait(timeout=5) except SubprocessUtils.TimeoutExpired: - SystemUtils.kill_process_tree(process.pid) + SystemUtils.kill_process_tree(self._process_pid(process)) with contextlib.suppress(SubprocessUtils.TimeoutExpired): - process.wait(timeout=1) + if isinstance(process, QtCore.QProcess): + process.waitForFinished(1000) + else: + process.wait(timeout=1) + if isinstance(process, QtCore.QProcess) and process.state() != QtCore.QProcess.ProcessState.NotRunning: + process.kill() + process.waitForFinished(1000) logger.info(f"伺服器 {server_name} 已停止") return True except Exception as e: @@ -992,26 +1086,27 @@ def get_server_info(self, server_name: str) -> dict | None: process = instance.get_process() if process is None: return info - info["pid"] = process.pid - if not SystemUtils.is_process_running(process.pid): + pid = self._process_pid(process) + info["pid"] = pid + if not pid or not SystemUtils.is_process_running(pid): info["is_running"] = False self._cleanup_running_server_state(server_name) return info info["is_running"] = True - java_pid = getattr(process, "java_pid", None) + java_pid = self._get_process_metadata(process, "_msm_java_pid") if not java_pid: - java_pid = SystemUtils.find_java_process(process.pid) + java_pid = SystemUtils.find_java_process(pid) if java_pid: - process.java_pid = java_pid - target_pid = java_pid if java_pid else process.pid + self._set_process_metadata(process, "_msm_java_pid", java_pid) + target_pid = java_pid if java_pid else pid if java_pid: info["pid"] = java_pid mem_bytes = SystemUtils.get_process_memory_usage(target_pid) info["memory"] = mem_bytes / (1024 * 1024) info["memory_mb"] = info["memory"] try: - if hasattr(process, "create_time"): - create_time = process.create_time + create_time = self._get_process_metadata(process, "_msm_create_time") + if create_time: uptime_seconds = int(time.time() - create_time) hours = uptime_seconds // 3600 minutes = uptime_seconds % 3600 // 60 @@ -1045,21 +1140,8 @@ def send_command(self, server_name: str, command: str) -> bool: if process is None: logger.info(f"伺服器 {server_name} 程式已結束") return False - if process.stdin: - process.stdin.write(command + "\n") - process.stdin.flush() + if self._write_process_command(process, command): logger.debug(f"已向伺服器 {server_name} 發送命令: {command}") - if command.lower() == "stop": - - def check_stop(): - if ( - self._wait_for_process_exit(process, self.STOP_TIMEOUT_SECONDS, self.STOP_CHECK_INTERVAL) - and server_name in self.running_servers - ): - self._cleanup_running_server_state(server_name) - logger.info(f"伺服器 {server_name} 已確認停止") - - threading.Thread(target=check_stop, daemon=True).start() return True logger.error(f"無法向伺服器 {server_name} 發送命令:stdin 不可用", "ServerManager") return False @@ -1080,13 +1162,21 @@ def read_server_output(self, server_name: str, _timeout: float = 0.1) -> list[st 目前緩衝中的輸出行清單。 """ try: - instance = self._get_running_instance(server_name) + instance = self.running_servers.get(server_name) if instance is None: return [] process = instance.get_process() - if process is None or process.poll() is not None: + if process is None: self._cleanup_running_server_state(server_name) return [] + if isinstance(process, QtCore.QProcess): + with contextlib.suppress(Exception): + instance.append_output_text(self._decode_process_output(process)) + if not self._process_is_running(process): + instance.flush_output_pending() + lines = instance.consume_output_lines() + self._cleanup_running_server_state(server_name) + return lines return instance.consume_output_lines() except Exception as e: with contextlib.suppress(Exception): diff --git a/src/core/version_manager.py b/src/core/version_manager.py index c29b25e..ad00eca 100644 --- a/src/core/version_manager.py +++ b/src/core/version_manager.py @@ -2,7 +2,6 @@ 負責從官方 API 取得版本資訊,提供版本查詢、下載與快取管理功能。 """ -import concurrent.futures import threading from contextlib import suppress from pathlib import Path @@ -14,6 +13,7 @@ Singleton, atomic_write_json, get_logger, + get_shared_manager, record_and_mark, ) @@ -51,10 +51,10 @@ def _save_local_cache(self, versions: list) -> None: logger.exception(f"寫入版本快取失敗: {e}") def fetch_versions(self, max_workers: int = 10) -> list: - """從官方 API 取得所有 Minecraft 版本列表並多執行緒查詢詳細資訊。 + """從官方 API 取得所有 Minecraft 版本列表並使用 Qt worker 查詢詳細資訊。 Args: - max_workers: 同時查詢版本詳細資訊的執行緒數量上限。 + max_workers: 同時查詢版本詳細資訊的工作數量上限。 Returns: 可用的 Minecraft 伺服器版本清單。 @@ -119,8 +119,13 @@ def fetch_server_url(v_obj): ) logger.debug(f"查詢版本 {v_obj['id']} 失敗: {e}") - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - list(executor.map(fetch_server_url, versions_to_process)) + batch_size = max(1, int(max_workers or 1)) + manager = get_shared_manager() + for start in range(0, len(versions_to_process), batch_size): + batch = versions_to_process[start : start + batch_size] + futures = [manager.run(fetch_server_url, version) for version in batch] + for future in futures: + future.result() else: logger.debug("版本清單比對完成: 所有版本資訊皆為最新。") self._save_local_cache(final_list) diff --git a/src/ui/create_server_frame.py b/src/ui/create_server_frame.py index d8bfbb5..72b4d53 100644 --- a/src/ui/create_server_frame.py +++ b/src/ui/create_server_frame.py @@ -7,7 +7,6 @@ import concurrent.futures import contextlib import queue -import threading import traceback from collections.abc import Callable from pathlib import Path @@ -27,7 +26,8 @@ get_shared_manager, record_and_mark, ) -from ..utils.ui_support.qt_runtime import QtCore, QtGui, QtWidgets, ValueState, is_qobject_alive +from ..utils.ui_support.fluent import FluentLineEdit, FluentPushButton +from ..utils.ui_support.qt_runtime import QtCore, QtGui, QtWidgets, ValueState, install_open_url_click, is_qobject_alive from . import CustomDropdown, FontManager, ProgressDialog, TaskUtils from .ui_config import NativeQtStyle, resolve_color @@ -127,7 +127,11 @@ def _style_control(self, widget, *, height: int = 20) -> None: widget.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) def _make_button(self, text: str, command: Callable[[], Any], *, kind: str = "secondary") -> QtWidgets.QPushButton: - button = QtWidgets.QPushButton(text) + try: + button = FluentPushButton(text, self) + except TypeError: + button = FluentPushButton(self) + button.setText(text) button.setProperty("msm_button_kind", kind) button.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) button.setFont(_qt_font(FontManager.get_font(size=FontSize.MEDIUM, weight="bold"))) @@ -156,7 +160,7 @@ def create_java_path_field(self, parent, row) -> None: """ parent.addWidget(self._make_label("Java 執行檔路徑 (可選):"), row, 0) self.java_path_var = ValueState("") - java_path_entry = QtWidgets.QLineEdit(self.form_panel) + java_path_entry = FluentLineEdit(self.form_panel) java_path_entry.setFont(FontManager.get_font(size=FontSize.MEDIUM)) self._bind_entry(java_path_entry, self.java_path_var) self._style_control(java_path_entry) @@ -278,7 +282,7 @@ def create_widgets(self) -> None: eula_link.setFont(_qt_font(FontManager.get_font(size=FontSize.MEDIUM, weight="bold", underline=True))) eula_link.setStyleSheet(NativeQtStyle.color_style(_qt_color(Colors.TEXT_WARNING))) eula_link.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) - eula_link.mousePressEvent = lambda _event: UIUtils.open_external("https://aka.ms/MinecraftEULA") # type: ignore[method-assign] + install_open_url_click(eula_link, "https://aka.ms/MinecraftEULA") eula_layout.addWidget(eula_link, 1) content_layout.addWidget(eula_frame) @@ -406,7 +410,7 @@ def create_form(self, parent) -> None: min_label.setFont(_qt_font(FontManager.get_font(size=FontSize.MEDIUM))) min_label.setStyleSheet(NativeQtStyle.color_style(_qt_color(Colors.TEXT_MUTED))) min_layout.addWidget(min_label) - self.min_memory_entry = QtWidgets.QLineEdit(min_memory_frame) + self.min_memory_entry = FluentLineEdit(min_memory_frame) self.min_memory_entry.setFont(FontManager.get_font(size=FontSize.MEDIUM)) self._bind_entry(self.min_memory_entry, self.min_memory_var) self._style_control(self.min_memory_entry) @@ -422,7 +426,7 @@ def create_form(self, parent) -> None: max_label.setFont(_qt_font(FontManager.get_font(size=FontSize.MEDIUM))) max_label.setStyleSheet(NativeQtStyle.color_style(_qt_color(Colors.TEXT_MUTED))) max_layout.addWidget(max_label) - self.max_memory_entry = QtWidgets.QLineEdit(max_memory_frame) + self.max_memory_entry = FluentLineEdit(max_memory_frame) self.max_memory_entry.setFont(FontManager.get_font(size=FontSize.MEDIUM)) self._bind_entry(self.max_memory_entry, self.max_memory_var) self._style_control(self.max_memory_entry) @@ -456,7 +460,7 @@ def _update_combo_state(self, combo, var=None, message="載入中...", state="di def _run_background_task(self, task_func: Callable, error_msg: str, error_callback: Callable | None = None) -> None: """執行背景任務並處理錯誤""" - TaskUtils.run_in_daemon_thread( + TaskUtils.run_background_task( task_func, ui_queue=getattr(self, "ui_queue", None), widget=self, @@ -565,7 +569,7 @@ def create_field(self, parent, row, label_text, default_value, var_name) -> tupl parent.addWidget(self._make_label(label_text), row, 0) var = ValueState(default_value) setattr(self, f"{var_name}_var", var) - entry = QtWidgets.QLineEdit(self.form_panel) + entry = FluentLineEdit(self.form_panel) entry.setFont(FontManager.get_font(size=FontSize.MEDIUM)) self._bind_entry(entry, var) self._style_control(entry) @@ -716,7 +720,7 @@ def update_server_config_ui(self, _event=None) -> None: if hasattr(self, "_loading_key") and self._loading_key == current_key: return self._loading_key = current_key - threading.Thread(target=self.load_loader_versions, args=(loader_type, mc_version), daemon=True).start() + TaskUtils.run_async(self.load_loader_versions, loader_type, mc_version) def load_loader_versions(self, loader_type: str, mc_version: str) -> None: """載入載入器版本,並預設選擇最新版本(使用預載入的快取資料)。 @@ -891,17 +895,12 @@ def create_server_async(self, config: ServerConfig) -> None: """ parent_window = self.window() progress_dialog = None - progress_ready = threading.Event() try: - - def create_progress(): - nonlocal progress_dialog - progress_dialog = ProgressDialog(parent_window, "正在建立伺服器") - progress_ready.set() - - self._schedule_ui_job("_create_server_progress_job", 0, create_progress) - if not progress_ready.wait(timeout=10): - raise Exception("建立進度對話框超時") + progress_dialog = TaskUtils.call_on_ui( + parent_window, + lambda: ProgressDialog(parent_window, "正在建立伺服器"), + timeout=10, + ) if progress_dialog is None: raise Exception("建立進度對話框失敗") if not progress_dialog.update_progress(5, "建立伺服器目錄結構..."): diff --git a/src/ui/dialog_utils.py b/src/ui/dialog_utils.py index 55966d9..e5b1f7e 100644 --- a/src/ui/dialog_utils.py +++ b/src/ui/dialog_utils.py @@ -2,7 +2,6 @@ from __future__ import annotations -import threading from dataclasses import dataclass from typing import Any @@ -14,6 +13,7 @@ ensure_application, invoke_later, is_qobject_alive, + run_on_ui_thread, set_modal, set_topmost, show_window, @@ -517,18 +517,7 @@ def _ask() -> bool | None: try: app = ensure_application() if QtCore.QThread.currentThread() != app.thread(): - ev = threading.Event() - result_container: dict[str, bool | None] = {"res": False if not show_cancel else None} - - def _worker(): - try: - result_container["res"] = _ask() - finally: - ev.set() - - invoke_later(0, _worker, parent=parent if isinstance(parent, QtWidgets.QWidget) else None) - ev.wait() - return result_container["res"] + return run_on_ui_thread(_ask) return _ask() except Exception: try: diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 9a0616f..3508d66 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -36,7 +36,15 @@ get_settings_manager, ) from ..utils.ui_support import qt_widgets as qt -from ..utils.ui_support.qt_runtime import QtCore, QtGui, QtWidgets, is_qobject_alive, show_window +from ..utils.ui_support.fluent import FluentPushButton +from ..utils.ui_support.qt_runtime import ( + QtCore, + QtGui, + QtWidgets, + install_open_url_click, + is_qobject_alive, + show_window, +) from ..version_info import APP_VERSION, GITHUB_OWNER, GITHUB_REPO from . import ( CreateServerFrame, @@ -55,17 +63,17 @@ def _qt_font(font: Any) -> QtGui.QFont: - """Return the native QFont stored by FontManager.""" + """回傳 FontManager 內保存的原生 QFont。""" return getattr(font, "font", font) def _qt_color(color: Any) -> str: - """Return the light-theme color from project token tuples.""" + """回傳專案色彩 token 對應的 Qt 色碼。""" return resolve_color(color) def _native_widget(widget: Any) -> QtWidgets.QWidget | None: - """Resolve adapter-backed objects to their native Qt widget.""" + """將 adapter 物件解析為原生 Qt widget。""" if widget is None: return None return getattr(widget, "_qt_widget", widget) @@ -75,6 +83,15 @@ def _set_layout_margins(layout: QtWidgets.QLayout, *margins: int) -> None: layout.setContentsMargins(*(int(v) for v in margins)) +def _make_fluent_button(text: str, parent: QtWidgets.QWidget | None = None) -> QtWidgets.QPushButton: + try: + return FluentPushButton(text, parent) + except TypeError: + button = FluentPushButton(parent) + button.setText(text) + return button + + class MinecraftServerManager: """Minecraft 伺服器管理器主視窗類別""" @@ -387,7 +404,7 @@ def create_header(self) -> None: _set_layout_margins(header_layout, 0, 0, 12, 0) header_layout.setSpacing(0) - self.sidebar_toggle_btn = QtWidgets.QPushButton("☰", header_frame) + self.sidebar_toggle_btn = _make_fluent_button("☰", header_frame) self.sidebar_toggle_btn.setObjectName("SidebarToggleButton") self.sidebar_toggle_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) self.sidebar_toggle_btn.setFixedSize(36, 33) @@ -567,7 +584,7 @@ def create_nav_button(self, parent, icon, title, description, command, key) -> Q _set_layout_margins(btn_layout, 0, 0, 0, 0) btn_layout.setSpacing(3) - btn = QtWidgets.QPushButton(f"{icon} {title}", btn_frame) + btn = _make_fluent_button(f"{icon} {title}", btn_frame) btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) btn.setFont(_qt_font(FontManager.get_font(size=FontSize.HEADING_SMALL, weight="bold"))) btn.setMinimumHeight(39) @@ -1174,10 +1191,7 @@ def add_label(text: str, *, size: int, weight: str = "normal", color: str | None github_lbl = add_label("GitHub-MinecraftServerManager", size=FontSize.MEDIUM, color=_qt_color(Colors.TEXT_LINK)) github_lbl.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) - def open_github(_event: Any) -> None: - UIUtils.open_external(github_url) - - github_lbl.mousePressEvent = open_github # type: ignore[method-assign] + install_open_url_click(github_lbl, github_url) add_label("📄 授權條款", size=FontSize.HEADING_LARGE, weight="bold") add_label( "• 本專案採用 GNU General Public License v3.0 授權條款\n" @@ -1194,7 +1208,7 @@ def open_github(_event: Any) -> None: auto_update_checkbox.setChecked(settings.is_auto_update_enabled()) content_layout.addWidget(auto_update_checkbox) - manual_check_btn = QtWidgets.QPushButton("檢查更新", scroll_content) + manual_check_btn = _make_fluent_button("檢查更新", scroll_content) manual_check_btn.setFont(_qt_font(FontManager.get_font(size=FontSize.NORMAL))) manual_check_btn.clicked.connect(lambda _checked=False: self._manual_check_updates()) manual_check_btn.setVisible(not settings.is_auto_update_enabled()) @@ -1208,16 +1222,16 @@ 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) + prefs_btn = _make_fluent_button("視窗偏好設定", scroll_content) prefs_btn.setFont(_qt_font(FontManager.get_font(size=FontSize.NORMAL))) prefs_btn.clicked.connect(lambda _checked=False: self._show_window_preferences()) content_layout.addWidget(prefs_btn, 0, QtCore.Qt.AlignmentFlag.AlignLeft) content_layout.addStretch(1) - close_btn = QtWidgets.QPushButton("關閉", about_dialog) + close_btn = _make_fluent_button("關閉", about_dialog) close_btn.setFont(_qt_font(FontManager.get_font(size=FontSize.NORMAL, weight="bold"))) close_btn.clicked.connect(lambda _checked=False: about_dialog.close()) outer_layout.addWidget(close_btn, 0, QtCore.Qt.AlignmentFlag.AlignCenter) @@ -1313,6 +1327,7 @@ def __init__(self, parent: QtWidgets.QWidget, server_config: ServerConfig, compl self.server_path = Path(server_config.path) self.completion_callback = completion_callback self.server_process: Any | None = None + self.server_process_pid: int = 0 self.done_detected = False self.init_dialog: Any | None = None self.console_text: qt.TextBox | None = None @@ -1320,6 +1335,8 @@ def __init__(self, parent: QtWidgets.QWidget, server_config: ServerConfig, compl self.close_button: qt.Button | None = None self._console_queue: queue.Queue[str] = queue.Queue() self._console_pump_job = None + self._process_output_buffer = "" + self._stop_sent = False def _enqueue_console(self, text: str) -> None: try: @@ -1478,8 +1495,8 @@ def _setup_timeout(self) -> None: self._schedule_dialog_job("_init_timeout_job", 120000, self._timeout_force_close) def _start_server_thread(self) -> None: - """在背景執行緒中啟動伺服器""" - TaskUtils.run_async(self._run_server) + """使用 QProcess 啟動伺服器。""" + self._run_server() def _close_init_server(self) -> None: """關閉初始化伺服器。""" @@ -1498,16 +1515,13 @@ def _close_init_server(self) -> None: def _terminate_server_process(self) -> None: """終止伺服器程式""" try: - if self.server_process and self.server_process.poll() is None: + if self.server_process and self.server_process.state() != QtCore.QProcess.ProcessState.NotRunning: self.server_process.terminate() - try: - self.server_process.wait(timeout=5) - except Exception as e: - logger.exception(f"等待程式終止逾時/失敗,改用 kill: {e}") + if not self.server_process.waitForFinished(5000): self.server_process.kill() if self.server_process is not None: with contextlib.suppress(Exception): - SystemUtils.unregister_managed_process(self.server_path, self.server_process.pid) + SystemUtils.unregister_managed_process(self.server_path, self.server_process_pid) except Exception as e: get_logger().bind(component="InitServerDialog").exception(f"終止伺服器程式失敗: {e}") @@ -1526,7 +1540,7 @@ def _update_console(self, text: str) -> None: logger.exception("更新控制台輸出失敗") def _run_server(self) -> None: - """在背景執行緒中啟動伺服器""" + """以 QProcess 啟動伺服器並接上 signal。""" try: if self.init_dialog: self._schedule_dialog_job( @@ -1540,29 +1554,69 @@ def _run_server(self) -> None: ) self._enqueue_console("正在啟動 Minecraft 伺服器...\n") java_cmd = self._build_java_command() - self.server_process = SubprocessUtils.popen_checked( + process = SubprocessUtils.create_qprocess_checked( java_cmd, cwd=str(self.server_path), - stdout=SubprocessUtils.PIPE, - stderr=SubprocessUtils.STDOUT, - stdin=SubprocessUtils.PIPE, - text=True, - bufsize=1, - universal_newlines=True, - creationflags=SubprocessUtils.CREATE_NO_WINDOW, ) - SystemUtils.register_managed_process(self.server_path, self.server_process.pid) - self._monitor_server_output() - self._handle_server_completion() + self.server_process = process + process.started.connect(self._on_server_process_started) + process.readyReadStandardOutput.connect(self._on_server_process_output) + process.finished.connect(self._on_server_process_finished) + process.errorOccurred.connect(self._on_server_process_error) + process.start() except Exception as e: get_logger().bind(component="ServerInitializationDialog").error( f"伺服器啟動失敗: {e}\n{traceback.format_exc()}" ) self._handle_server_error(str(e)) - finally: - if self.server_process is not None: - with contextlib.suppress(Exception): - SystemUtils.unregister_managed_process(self.server_path, self.server_process.pid) + + @QtCore.Slot() + def _on_server_process_started(self) -> None: + if self.server_process is None: + return + self.server_process_pid = int(self.server_process.processId()) + SystemUtils.register_managed_process(self.server_path, self.server_process_pid) + + @QtCore.Slot() + def _on_server_process_output(self) -> None: + if self.server_process is None: + return + try: + chunk = bytes(self.server_process.readAllStandardOutput()).decode("utf-8", errors="replace") + except Exception as exc: + logger.exception(f"讀取 QProcess 輸出失敗: {exc}") + return + if not chunk: + return + self._enqueue_console(chunk) + self._process_output_buffer += chunk + lines = self._process_output_buffer.splitlines() + if self._process_output_buffer and not self._process_output_buffer.endswith(("\n", "\r")): + self._process_output_buffer = lines.pop() if lines else self._process_output_buffer + else: + self._process_output_buffer = "" + for line in lines: + self._process_server_output(line) + if self.done_detected and not self._stop_sent: + self._handle_server_ready(line) + + @QtCore.Slot(int, QtCore.QProcess.ExitStatus) + def _on_server_process_finished(self, _exit_code: int, _status: QtCore.QProcess.ExitStatus) -> None: + if self._process_output_buffer: + line = self._process_output_buffer + self._process_output_buffer = "" + self._process_server_output(line) + if self.done_detected and not self._stop_sent: + self._handle_server_ready(line) + with contextlib.suppress(Exception): + SystemUtils.unregister_managed_process(self.server_path, self.server_process_pid) + self._handle_server_completion() + + @QtCore.Slot(QtCore.QProcess.ProcessError) + def _on_server_process_error(self, _error: QtCore.QProcess.ProcessError) -> None: + if self.server_process is None: + return + self._handle_server_error(self.server_process.errorString()) def _build_java_command(self) -> list[str]: """建立 Java 命令""" @@ -1607,23 +1661,6 @@ def _extract_java_command_from_bat(self, start_bat: Path) -> list[str] | None: logger.exception(f"提取 Java 命令失敗: {e}") return None - def _monitor_server_output(self) -> None: - """監控伺服器輸出""" - if self.server_process is None or self.server_process.stdout is None: - return - while True: - output = self.server_process.stdout.readline() - if output == "" and self.server_process.poll() is not None: - break - if output: - self._enqueue_console(output) - self._process_server_output(output) - if self.done_detected: - self._handle_server_ready(output) - break - if self.server_process is not None: - self.server_process.wait() - def _process_server_output(self, output: str) -> None: """處理伺服器輸出""" if self.init_dialog is None or not self.init_dialog.is_alive(): @@ -1678,9 +1715,9 @@ def update_closing_status(): if self.init_dialog: self._schedule_dialog_job("_init_closing_job", 0, update_closing_status) - if self.server_process and self.server_process.stdin: - self.server_process.stdin.write("stop\n") - self.server_process.stdin.flush() + if self.server_process and self.server_process.state() != QtCore.QProcess.ProcessState.NotRunning: + self._stop_sent = True + self.server_process.write(b"stop\n") def _handle_server_completion(self) -> None: """處理伺服器完成狀態""" diff --git a/src/ui/manage_server_frame.py b/src/ui/manage_server_frame.py index c17e803..6eea23f 100644 --- a/src/ui/manage_server_frame.py +++ b/src/ui/manage_server_frame.py @@ -22,7 +22,6 @@ ServerOperations, Sizes, Spacing, - SubprocessUtils, UIUtils, compute_adaptive_pool_limit, compute_exponential_moving_average, @@ -30,6 +29,7 @@ get_settings_manager, ) from ..utils.ui_support import qt_widgets as qt +from ..utils.ui_support.qt_runtime import QtCore from . import FontManager, ServerMonitorWindow, ServerPropertiesDialog, TaskUtils, TreeUtils logger = get_logger().bind(component="ManageServerFrame") @@ -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,8 +1287,13 @@ 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: + """ + 監控伺服器 + + Args: + bring_to_front: 是否將監控視窗帶到前面。 + """ if not self.selected_server: return @@ -1277,10 +1304,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) @@ -1446,17 +1470,14 @@ def backup_server(self) -> None: self.refresh_servers() return try: - startupinfo = SubprocessUtils.STARTUPINFO() - startupinfo.dwFlags |= SubprocessUtils.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = SubprocessUtils.SW_HIDE - SubprocessUtils.popen_checked( - [bat_file_path], - stdin=SubprocessUtils.DEVNULL, - stdout=SubprocessUtils.DEVNULL, - stderr=SubprocessUtils.DEVNULL, - close_fds=True, - startupinfo=startupinfo, - ) + program = bat_file_path + arguments: list[str] = [] + if Path(bat_file_path).suffix.lower() in {".bat", ".cmd"}: + program = "cmd.exe" + arguments = ["/d", "/s", "/c", bat_file_path] + success, _pid = QtCore.QProcess.startDetached(program, arguments, backup_full_path) + if not success: + raise RuntimeError("QProcess 無法啟動備份批次檔") UIUtils.show_info( "備份開始", f"備份已開始執行,請稍候...\n備份位置:{backup_full_path}", self.top_level_widget() ) diff --git a/src/ui/mod_management/frame.py b/src/ui/mod_management/frame.py index 91c239a..a15f24b 100644 --- a/src/ui/mod_management/frame.py +++ b/src/ui/mod_management/frame.py @@ -37,6 +37,10 @@ from .tree_sync import ModManagementTreeSyncMixin +class _ModManagementSignals(qt.QtCore.QObject): + progress_requested = qt.QtCore.Signal(float) + + class ModManagementFrame( ModManagementQueueMixin, ModManagementReviewMixin, ModManagementInstallExecutorMixin, ModManagementTreeSyncMixin ): @@ -97,11 +101,15 @@ def __init__( } self._last_mods_dir: str | None = None self._last_mods_dir_mtime: float | None = None + self._last_mods_dir_signature: tuple[tuple[str, int, int], ...] | None = None + self._local_mods_load_token = 0 self._status_update_job = None self._pending_status_message: str = "" self.ui_queue: queue.Queue = queue.Queue() self.create_widgets() host = self.main_frame if qt.is_alive(self.main_frame) else self.parent + self._signals = _ModManagementSignals(host if qt.is_alive(host) else None) + self._signals.progress_requested.connect(self._apply_progress_value) TaskUtils.start_ui_queue_pump(host, self.ui_queue) self.load_servers() @@ -159,24 +167,29 @@ def update_status_safe(self, message: str) -> None: self.ui_queue.put(lambda: self.update_status(message)) def update_progress_safe(self, value: float) -> None: - """將進度更新排入 UI 佇列執行。 + """將進度更新交給 Qt signal 執行。 Args: value: 介於 0 到 1 的進度值。 """ + signals = getattr(self, "_signals", None) + if signals is not None: + signals.progress_requested.emit(float(value)) + return - def _update(): - if hasattr(self, "progress_var") and self.progress_var: - try: - self.progress_var.set(value) - except (AttributeError, RuntimeError) as e: - logger.warning(f"更新進度遇到暫時性問題: {e}") - except AppException as e: - logger.info(f"更新進度被應用例外攔截: {e}") - except Exception: - logger.error("更新進度失敗: 未知錯誤\n" + traceback.format_exc()) - - self.ui_queue.put(_update) + self.ui_queue.put(lambda: self._apply_progress_value(float(value))) + + @qt.QtCore.Slot(float) + def _apply_progress_value(self, value: float) -> None: + if hasattr(self, "progress_var") and self.progress_var: + try: + self.progress_var.set(value) + except (AttributeError, RuntimeError) as e: + logger.warning(f"更新進度遇到暫時性問題: {e}") + except AppException as e: + logger.info(f"更新進度被應用例外攔截: {e}") + except Exception: + logger.error("更新進度失敗: 未知錯誤\n" + traceback.format_exc()) def _apply_local_toggle_success( self, diff --git a/src/ui/mod_management/local_mod_list_presenter.py b/src/ui/mod_management/local_mod_list_presenter.py index 768efc7..6690581 100644 --- a/src/ui/mod_management/local_mod_list_presenter.py +++ b/src/ui/mod_management/local_mod_list_presenter.py @@ -4,12 +4,11 @@ import time import traceback -from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any from ...core import ModStatus -from ...utils import Colors, FontSize, Sizes, Spacing, UIUtils +from ...utils import Colors, FontSize, Sizes, Spacing, UIUtils, get_shared_manager from ...utils.ui_support import qt_widgets as qt from ..custom_dropdown import CustomDropdown from ..font_manager import FontManager @@ -27,6 +26,40 @@ def __init__(self, frame: Any): super().__init__(frame) self.all_selected: bool = False + @staticmethod + def _get_current_server_path_key(current_server: Any | None) -> str | None: + server_path = str(getattr(current_server, "path", "") or "").strip() + if not server_path: + return None + try: + return str(Path(server_path).resolve()) + except Exception: + return server_path + + @staticmethod + def _build_mods_dir_signature(mods_dir: Path | None) -> tuple[tuple[str, int, int], ...] | None: + if mods_dir is None or not mods_dir.exists(): + return () + try: + signature: list[tuple[str, int, int]] = [] + for entry in mods_dir.iterdir(): + if not entry.is_file(): + continue + try: + stat_result = entry.stat() + except OSError: + continue + signature.append((entry.name, int(stat_result.st_mtime_ns), int(stat_result.st_size))) + return tuple(sorted(signature)) + except OSError: + return None + + def _is_local_mods_scope_current(self, request_token: int, server_path_key: str | None) -> bool: + current_token = int(getattr(self, "_local_mods_load_token", 0)) + if request_token != current_token: + return False + return self._get_current_server_path_key(getattr(self, "current_server", None)) == server_path_key + def render_local_mods(self) -> None: """重新渲染目前本地模組列表。""" self.refresh_local_list() @@ -114,17 +147,18 @@ def create_local_toolbar(self) -> None: right_frame.attach(side="right", padx=Spacing.LARGE_MINUS) search_frame = qt.Frame(right_frame, fg_color="transparent") search_frame.attach(side="left", padx=(0, Spacing.LARGE_MINUS)) - search_label = qt.Label(search_frame, text="🔍", font=FontManager.get_font(size=FontSize.HEADING_MEDIUM)) - search_label.attach(side="left") self.local_search_var = qt.TextState() - search_entry = qt.Entry( + self.local_search_filter = qt.SearchFilter() + search_entry = qt.SearchEntry( search_frame, textvariable=self.local_search_var, + filter_logic=self.local_search_filter, + placeholder_text="搜尋本地模組", font=FontManager.get_font(size=FontSize.MEDIUM), width=Sizes.DROPDOWN_COMPACT_WIDTH, height=Sizes.INPUT_HEIGHT, ) - search_entry.attach(side="left", padx=(6, 0)) + search_entry.attach(side="left") self.local_search_var.trace("w", self.filter_local_mods) self.local_filter_var = qt.TextState(value="所有") filter_combo = CustomDropdown( @@ -247,8 +281,13 @@ def load_local_mods(self) -> None: if not self.mod_manager: return manager = self.mod_manager - mods_dir = Path(self.current_server.path) / "mods" if self.current_server else None + request_token = int(getattr(self, "_local_mods_load_token", 0)) + 1 + self._local_mods_load_token = request_token + current_server = getattr(self, "current_server", None) + server_path_key = self._get_current_server_path_key(current_server) + mods_dir = Path(server_path_key) / "mods" if server_path_key else None mods_dir_key = str(mods_dir.resolve()) if mods_dir else "" + mods_dir_signature = self._build_mods_dir_signature(mods_dir) try: mods_dir_mtime = mods_dir.stat().st_mtime if mods_dir and mods_dir.exists() else None except Exception: @@ -256,8 +295,8 @@ def load_local_mods(self) -> None: if ( mods_dir_key and mods_dir_key == getattr(self, "_last_mods_dir", None) - and (mods_dir_mtime == getattr(self, "_last_mods_dir_mtime", None)) - and self.local_mods + and (mods_dir_signature is not None) + and (mods_dir_signature == getattr(self, "_last_mods_dir_signature", None)) ): self.update_status_safe(f"找到 {len(self.local_mods)} 個本地模組") self.ui_queue.put(self.refresh_local_list) @@ -275,26 +314,40 @@ def load_thread(): dedup[base_name] = mod mods = list(dedup.values()) total = len(mods) - self.local_mods = [] - self.enhanced_mods_cache = {} + new_local_mods: list[Any] = [] last_percent = -1 for idx, mod in enumerate(mods): + if not self._is_local_mods_scope_current(request_token, server_path_key): + logger.debug("略過過期的本地模組掃描結果", "ModManagementFrame") + return try: mod._cached_mtime = Path(mod.file_path).stat().st_mtime except Exception: mod._cached_mtime = None - self.local_mods.append(mod) + new_local_mods.append(mod) percent = (idx + 1) / total * 100 if total else 0 rounded_percent = int(percent) if rounded_percent != last_percent: last_percent = rounded_percent self.update_progress_safe(percent) + current_signature = self._build_mods_dir_signature(mods_dir) + if current_signature is None: + current_signature = mods_dir_signature + if not self._is_local_mods_scope_current(request_token, server_path_key): + logger.debug("略過過期的本地模組掃描結果", "ModManagementFrame") + return + if current_signature != mods_dir_signature: + logger.debug("本地模組目錄在掃描期間已變更,略過過期結果", "ModManagementFrame") + return + self.local_mods = new_local_mods + self.enhanced_mods_cache = {} self._last_mods_dir = mods_dir_key try: self._last_mods_dir_mtime = mods_dir.stat().st_mtime if mods_dir and mods_dir.exists() else None except Exception: self._last_mods_dir_mtime = mods_dir_mtime - self.enhance_local_mods() + self._last_mods_dir_signature = current_signature + self.enhance_local_mods(request_token=request_token, server_path_key=server_path_key) self.update_status_safe(f"找到 {len(mods)} 個本地模組") except Exception as e: logger.error(f"掃描失敗: {e}\n{traceback.format_exc()}") @@ -303,11 +356,25 @@ def load_thread(): TaskUtils.run_async(load_thread) - def enhance_local_mods(self) -> None: - """查詢本地模組增強資訊,查詢完成後刷新列表。""" + def enhance_local_mods(self, request_token: int | None = None, server_path_key: str | None = None) -> None: + """查詢本地模組增強資訊,查詢完成後刷新列表。 + + Args: + request_token: 用來比對目前是否仍為同一輪載入的 token。 + server_path_key: 目前伺服器路徑的正規化快照,用來避免伺服器切換後寫回舊結果。 + """ + + if request_token is None: + request_token = int(getattr(self, "_local_mods_load_token", 0)) + if server_path_key is None: + server_path_key = self._get_current_server_path_key(getattr(self, "current_server", None)) + if not self._is_local_mods_scope_current(request_token, server_path_key): + return def enhance_single(mod): try: + if not self._is_local_mods_scope_current(request_token, server_path_key): + return if mod.filename in self.enhanced_mods_cache: return enhanced = enhance_local_mod( @@ -317,6 +384,8 @@ def enhance_single(mod): local_name=getattr(mod, "name", ""), ) if enhanced: + if not self._is_local_mods_scope_current(request_token, server_path_key): + return resolved_project_id = str(getattr(enhanced, "project_id", "") or "").strip() resolved_slug = str(getattr(enhanced, "slug", "") or "").strip() if resolved_project_id: @@ -332,8 +401,16 @@ def enhance_single(mod): ) def enhance_thread(): - with ThreadPoolExecutor(max_workers=3) as executor: - executor.map(enhance_single, self.local_mods) + if not self._is_local_mods_scope_current(request_token, server_path_key): + return + futures = [get_shared_manager().run(enhance_single, mod) for mod in list(self.local_mods)] + for future in futures: + try: + future.result() + except Exception as e: + logger.debug(f"模組增強背景工作失敗: {e}", "ModManagementFrame") + if not self._is_local_mods_scope_current(request_token, server_path_key): + return self.ui_queue.put(self.refresh_local_list) TaskUtils.run_async(enhance_thread) diff --git a/src/ui/mod_management/online_browse_presenter.py b/src/ui/mod_management/online_browse_presenter.py index 5e739d0..02c9d0a 100644 --- a/src/ui/mod_management/online_browse_presenter.py +++ b/src/ui/mod_management/online_browse_presenter.py @@ -47,16 +47,18 @@ def create_browse_search(self) -> None: "最近更新": "updated", "名稱": "name", } - search_entry = qt.Entry( + self.online_search_filter = qt.SearchFilter() + search_entry = qt.SearchEntry( search_frame, textvariable=self.search_var, + search_command=self.search_online_mods, + filter_logic=self.online_search_filter, placeholder_text="請輸入關鍵字後搜尋,例如 sodium / lithium / worldedit", font=FontManager.get_font(size=FontSize.MEDIUM), width=Sizes.DIALOG_PROGRESS_WIDTH, height=Sizes.INPUT_HEIGHT, ) search_entry.attach(side="left", padx=(Spacing.MEDIUM, Spacing.SMALL_PLUS), pady=Spacing.MEDIUM) - search_entry.connect_event("return_pressed", self.search_online_mods) sort_dropdown = CustomDropdown( search_frame, variable=self.browse_sort_var, diff --git a/src/ui/mod_management/online_mod_queue.py b/src/ui/mod_management/online_mod_queue.py index 5718462..0d7686b 100644 --- a/src/ui/mod_management/online_mod_queue.py +++ b/src/ui/mod_management/online_mod_queue.py @@ -168,7 +168,11 @@ def _get_online_query_text(self) -> str: """取得目前線上模組輸入框文字。""" if not hasattr(self, "search_var"): return "" - return str(self.search_var.get() or "").strip() + query = self.search_var.get() or "" + search_filter = getattr(self, "online_search_filter", None) + if search_filter is not None: + return search_filter.normalize(query) + return str(query).strip() def _build_online_browse_request(self) -> tuple[OnlineBrowseRequest | None, str | None]: """建立目前的線上瀏覽/搜尋請求。""" diff --git a/src/ui/mod_management/review.py b/src/ui/mod_management/review.py index 2f61d52..f52daf2 100644 --- a/src/ui/mod_management/review.py +++ b/src/ui/mod_management/review.py @@ -2359,7 +2359,7 @@ def _prepare_local_update_review_entries( simulated_installed_mods = list(self._get_current_installed_mods()) review_entries: list[LocalUpdateReviewEntry] = [] for candidate in update_plan.candidates: - root_key = str(candidate.project_id or "").strip() + root_key = self._build_local_update_review_key(candidate) dependency_plan = SimpleNamespace(items=[], unresolved_required=[]) blocking_reasons = [*list(getattr(candidate, "hard_errors", []) or [])] non_blocking_warnings = self._dedupe_review_messages( diff --git a/src/ui/mod_management/tree_sync.py b/src/ui/mod_management/tree_sync.py index 5fcb237..ce7ed92 100644 --- a/src/ui/mod_management/tree_sync.py +++ b/src/ui/mod_management/tree_sync.py @@ -601,15 +601,24 @@ def refresh_local_list(self) -> None: refresh_token = self._local_refresh_token selected_mod_ids = self._capture_selected_mod_ids() self._set_local_tree_render_lock(True) - search_text = self.local_search_var.get().lower() if hasattr(self, "local_search_var") else "" + search_text = self.local_search_var.get() if hasattr(self, "local_search_var") else "" + search_filter = getattr(self, "local_search_filter", None) filter_status = self.local_filter_var.get() if hasattr(self, "local_filter_var") else "所有" version_pattern = self.VERSION_PATTERN mod_order: list[str] = [] mod_rows: dict[str, tuple[tuple[Any, ...], tuple[str, ...]]] = {} seen_mod_ids: set[str] = set() for mod in self.local_mods: - mod_name_lower = str(getattr(mod, "name", "") or "").lower() - if search_text and search_text not in mod_name_lower: + mod_name = str(getattr(mod, "name", "") or "") + search_candidate = ( + mod_name, + getattr(mod, "filename", ""), + getattr(mod, "version", ""), + getattr(mod, "author", ""), + ) + if search_text and search_filter is not None and not search_filter.matches(search_candidate, search_text): + continue + if search_text and search_filter is None and str(search_text).lower() not in mod_name.lower(): continue if filter_status != "所有" and ( (filter_status == "啟用" and mod.status != ModStatus.ENABLED) diff --git a/src/ui/mod_search_service/dependency_planner_facade.py b/src/ui/mod_search_service/dependency_planner_facade.py index 1e55e38..f64c5f7 100644 --- a/src/ui/mod_search_service/dependency_planner_facade.py +++ b/src/ui/mod_search_service/dependency_planner_facade.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from typing import Any @@ -58,12 +57,13 @@ resolve_modrinth_provider_record, select_best_mod_version, ) +from ...utils.runtime_utils.background_task import get_shared_manager from .compatibility_analyzer import ( analyze_local_mod_file_compatibility, analyze_mod_version_compatibility, resolve_dependency_reference_with_provider_context, ) -from .constants import LOCAL_HASH_MAX_WORKERS, logger +from .constants import logger from .models import ( LocalModUpdateCandidate, LocalModUpdatePlan, @@ -428,19 +428,20 @@ def _compute_local_hash(job: tuple[Any, str, str]) -> tuple[Any, str, str]: local_mod_obj, filename_key_obj, file_path_obj = job return (local_mod_obj, filename_key_obj, compute_file_hash(file_path_obj, hash_algorithm)) - max_workers = min(LOCAL_HASH_MAX_WORKERS, len(hash_compute_jobs)) - with ThreadPoolExecutor(max_workers=max_workers) as executor: - for local_mod, filename_key, local_hash in executor.map(_compute_local_hash, hash_compute_jobs): - if not local_hash: - hash_progress_done += 1 - _emit_hash_progress() - continue - local_mod.current_hash = local_hash - local_mod.hash_algorithm = hash_algorithm - if filename_key: - local_hashes_by_filename[filename_key] = local_hash + manager = get_shared_manager() + futures = [manager.run(_compute_local_hash, job) for job in hash_compute_jobs] + for future in futures: + local_mod, filename_key, local_hash = future.result() + if not local_hash: hash_progress_done += 1 _emit_hash_progress() + continue + local_mod.current_hash = local_hash + local_mod.hash_algorithm = hash_algorithm + if filename_key: + local_hashes_by_filename[filename_key] = local_hash + hash_progress_done += 1 + _emit_hash_progress() known_hashes = list(local_hashes_by_filename.values()) current_versions_by_hash = get_modrinth_current_versions_by_hashes(known_hashes, hash_algorithm) latest_versions_by_hash: dict[str, ModrinthVersionLookupResult] = {} diff --git a/src/ui/progress_dialog.py b/src/ui/progress_dialog.py index e74732f..63c23b1 100644 --- a/src/ui/progress_dialog.py +++ b/src/ui/progress_dialog.py @@ -2,17 +2,23 @@ from __future__ import annotations -import threading from typing import Any from ..utils import FontSize, Sizes, Spacing, get_logger -from ..utils.ui_support.qt_runtime import QtCore, QtWidgets, invoke_later, is_qobject_alive +from ..utils.ui_support import qt_widgets as qt +from ..utils.ui_support.fluent import FluentPushButton +from ..utils.ui_support.qt_runtime import QtCore, QtWidgets, is_qobject_alive from . import DialogUtils, FontManager from .ui_config import NativeQtStyle logger = get_logger().bind(component="ProgressDialog") +class _ProgressDialogSignals(QtCore.QObject): + progress_requested = QtCore.Signal(float, str) + close_requested = QtCore.Signal() + + class ProgressDialog: """顯示可取消的進度對話框。""" @@ -41,7 +47,7 @@ def __init__(self, parent: Any, title: str = "進度", show_cancel: bool = True) self.status_label.setWordWrap(True) layout.addWidget(self.status_label) - self.progress = QtWidgets.QProgressBar(self.dialog) + self.progress = qt.ProgressBar(self.dialog) self.progress.setRange(0, 100) self.progress.setValue(0) self.progress.setMinimumHeight(38) @@ -52,7 +58,11 @@ def __init__(self, parent: Any, title: str = "進度", show_cancel: bool = True) self.cancel_button: QtWidgets.QPushButton | None = None if show_cancel: - self.cancel_button = QtWidgets.QPushButton("取消", self.dialog) + try: + self.cancel_button = FluentPushButton("取消", self.dialog) + except TypeError: + self.cancel_button = FluentPushButton(self.dialog) + self.cancel_button.setText("取消") self.cancel_button.setFont(FontManager.get_font(size=FontSize.NORMAL)) self.cancel_button.setMinimumSize(Sizes.BUTTON_WIDTH_COMPACT, Sizes.BUTTON_HEIGHT_LARGE) self.cancel_button.setStyleSheet(NativeQtStyle.create_button(kind="secondary")) @@ -63,6 +73,9 @@ def __init__(self, parent: Any, title: str = "進度", show_cancel: bool = True) self._pending_update = False self._last_percent: float = -1.0 self._last_status = "" + self._signals = _ProgressDialogSignals(self.dialog) + self._signals.progress_requested.connect(self._apply_progress_update) + self._signals.close_requested.connect(self._close_dialog) def update_progress(self, percent: float, status_text: str) -> bool: """更新進度百分比與狀態文字。 @@ -81,22 +94,20 @@ def update_progress(self, percent: float, status_text: str) -> bool: self._last_percent = percent self._last_status = status_text - def _update() -> None: - if self.cancelled or not is_qobject_alive(self.dialog): - return - try: - clamped = max(0.0, min(100.0, float(percent))) - self.progress.setValue(round(clamped)) - self.status_label.setText(status_text) - except Exception as exc: - logger.exception(f"更新進度 UI 失敗: {exc}") - - if threading.current_thread() is threading.main_thread(): - _update() - else: - invoke_later(0, _update, parent=self.dialog) + self._signals.progress_requested.emit(float(percent), str(status_text)) return True + @QtCore.Slot(float, str) + def _apply_progress_update(self, percent: float, status_text: str) -> None: + if self.cancelled or not is_qobject_alive(self.dialog): + return + try: + clamped = max(0.0, min(100.0, float(percent))) + self.progress.setValue(round(clamped)) + self.status_label.setText(status_text) + except Exception as exc: + logger.exception(f"更新進度 UI 失敗: {exc}") + def cancel(self) -> None: """取消並關閉對話框。""" self.cancelled = True @@ -104,19 +115,16 @@ def cancel(self) -> None: def close(self) -> None: """關閉對話框。""" - - def _close() -> None: - if is_qobject_alive(self.dialog): - self.dialog.close() - self.dialog.deleteLater() - try: - if threading.current_thread() is threading.main_thread(): - _close() - else: - invoke_later(0, _close, parent=self.dialog) + self._signals.close_requested.emit() except Exception as exc: logger.exception(f"關閉進度對話框失敗: {exc}") + @QtCore.Slot() + def _close_dialog(self) -> None: + if is_qobject_alive(self.dialog): + self.dialog.close() + self.dialog.deleteLater() + __all__ = ["ProgressDialog"] diff --git a/src/ui/server_monitor_window.py b/src/ui/server_monitor_window.py index 22f51c3..61ea21a 100644 --- a/src/ui/server_monitor_window.py +++ b/src/ui/server_monitor_window.py @@ -5,11 +5,9 @@ import queue import re -import threading import time import traceback from collections.abc import Callable -from concurrent.futures import ThreadPoolExecutor from typing import Any from ..utils import Colors, FontSize, MemoryUtils, ServerOperations, Sizes, Spacing, UIUtils, WindowManager, get_logger @@ -30,7 +28,6 @@ def __init__(self, parent, server_manager, server_name: str): self.window: Any | None = None self._auto_refresh_id: str | None = None self.is_monitoring = False - self.monitor_thread = None self._last_player_count: int | None = None self._last_max_players: int | None = None self._last_player_names: tuple[str, ...] | None = None @@ -43,8 +40,11 @@ def __init__(self, parent, server_manager, server_name: str): self._refresh_log_max_lines = 2500 self._refresh_log_max_bytes = 2 * 1024 * 1024 self._command_history: list[str] = [] - self._monitor_stop_event = threading.Event() - self.executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="ServerMonitor") + self._monitor_loop_job: str | None = None + self._delayed_player_list_job: str | None = None + self._last_monitor_status_update = 0.0 + self._last_monitor_output_check = 0.0 + self._last_log_mtime = 0.0 self.ui_queue: queue.Queue[Callable[[], Any]] = queue.Queue() def start_auto_refresh(self) -> None: @@ -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") @@ -538,60 +536,66 @@ def _schedule_console_flush(self, *, force: bool = False) -> None: def start_monitoring(self) -> None: """開始監控,啟動時自動讀取現有日誌內容,避免橫幅遺漏""" if not self.is_monitoring: - self._monitor_stop_event.clear() self.is_monitoring = True + self._last_monitor_status_update = 0.0 + self._last_monitor_output_check = 0.0 + self._last_log_mtime = 0.0 self._schedule_window_job("_monitor_start_refresh_job", 0, self.refresh_status) self.start_auto_refresh() - self.monitor_future = self.executor.submit(self.monitor_loop) + self._schedule_monitor_loop_tick(0) def stop_monitoring(self) -> None: """停止監控""" self.is_monitoring = False - self._monitor_stop_event.set() self.stop_auto_refresh() if self.window: UIUtils.cancel_scheduled_job(self.window, "_console_flush_job", owner=self) + UIUtils.cancel_scheduled_job(self.window, "_monitor_loop_job", owner=self) + UIUtils.cancel_scheduled_job(self.window, "_delayed_player_list_job", owner=self) self._cancel_window_jobs() - if hasattr(self, "executor"): - self.executor.shutdown(wait=False) - if hasattr(self, "monitor_future"): - try: - self.monitor_future.result(timeout=1) - except Exception as e: - logger.exception(f"等待監控 future 結束超時/失敗(忽略): {e}", "ServerMonitorWindow", e) - if self.monitor_thread and self.monitor_thread.is_alive(): - self.monitor_thread.join(timeout=1) + + def _schedule_monitor_loop_tick(self, delay_ms: int = 100) -> None: + if not self.is_monitoring or not self.window or not self.window.is_alive(): + self._monitor_loop_job = None + return + UIUtils.schedule_debounce( + self.window, + "_monitor_loop_job", + max(1, int(delay_ms)), + self.monitor_loop, + owner=self, + ) def monitor_loop(self) -> None: - """改良的監控循環""" - last_output_check = 0.0 - last_status_update = 0.0 - last_log_mtime = 0 - while self.is_monitoring and (not self._monitor_stop_event.is_set()): - try: - current_time = time.monotonic() - if current_time - last_status_update >= 1.5: - if self.window and self.window.is_alive(): - self.ui_queue.put(self.update_status) - last_status_update = current_time - if current_time - last_output_check >= 0.1: - try: - self.read_server_output() - except Exception as e: - logger.debug(f"從輸出隊列讀取失敗(忽略): {e}", "ServerMonitorWindow") - try: - log_file = self.server_manager.get_server_log_file(self.server_name) - if log_file and log_file.exists(): - current_mtime = log_file.stat().st_mtime - if current_mtime > last_log_mtime: - last_log_mtime = current_mtime - except Exception as e: - logger.debug(f"檢查日誌檔案變更時發生例外(忽略): {e}", "ServerMonitorWindow") - last_output_check = current_time - self._monitor_stop_event.wait(0.1) - except Exception as e: - logger.error(f"監控更新錯誤: {e}\n{traceback.format_exc()}", "ServerMonitorWindow") - self._monitor_stop_event.wait(0.5) + """使用 Qt timer 驅動監控輪詢。""" + self._monitor_loop_job = None + if not self.is_monitoring: + return + try: + current_time = time.monotonic() + if current_time - self._last_monitor_status_update >= 1.5: + if self.window and self.window.is_alive(): + self.ui_queue.put(self.update_status) + self._last_monitor_status_update = current_time + if current_time - self._last_monitor_output_check >= 0.1: + try: + self.read_server_output() + except Exception as e: + logger.debug(f"從輸出隊列讀取失敗(忽略): {e}", "ServerMonitorWindow") + try: + log_file = self.server_manager.get_server_log_file(self.server_name) + if log_file and log_file.exists(): + current_mtime = log_file.stat().st_mtime + if current_mtime > self._last_log_mtime: + self._last_log_mtime = current_mtime + except Exception as e: + logger.debug(f"檢查日誌檔案變更時發生例外(忽略): {e}", "ServerMonitorWindow") + self._last_monitor_output_check = current_time + except Exception as e: + logger.error(f"監控更新錯誤: {e}\n{traceback.format_exc()}", "ServerMonitorWindow") + self._schedule_monitor_loop_tick(500) + return + self._schedule_monitor_loop_tick(100) def read_server_output(self) -> None: """讀取伺服器輸出並顯示在控制台,並即時解析玩家數量/名單與啟動完成通知""" @@ -689,15 +693,17 @@ def update_player_count(self) -> None: """更新玩家數量""" try: success = self.server_manager.send_command(self.server_name, "list") - if success: - self.executor.submit(self._delayed_read_player_list) + if success and self.window and self.window.is_alive(): + UIUtils.schedule_debounce( + self.window, + "_delayed_player_list_job", + 800, + self.read_player_list, + owner=self, + ) except Exception as e: logger.error(f"更新玩家數量錯誤: {e}\n{traceback.format_exc()}", "ServerMonitorWindow") - def _delayed_read_player_list(self): - self._monitor_stop_event.wait(0.8) - self.read_player_list() - def read_player_list(self, line=None) -> None: """讀取玩家列表。 @@ -939,9 +945,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/ui/server_properties_dialog.py b/src/ui/server_properties_dialog.py index 75cf20a..3b0f3dd 100644 --- a/src/ui/server_properties_dialog.py +++ b/src/ui/server_properties_dialog.py @@ -30,55 +30,39 @@ class ServerPropertiesDialog: 提供視覺化的 server.properties 編輯介面 """ - BOOLEAN_PROPS: tuple[str, ...] = ( - "hardcore", - "pvp", - "online-mode", - "white-list", - "generate-structures", - "spawn-monsters", - "allow-flight", - "allow-nether", - "enable-command-block", - "use-native-transport", - "enable-jmx-monitoring", - "enable-rcon", - "prevent-proxy-connections", - "hide-online-players", - "force-gamemode", - "broadcast-console-to-ops", - "broadcast-rcon-to-ops", - "enable-query", - "enable-status", - "log-ips", - "require-resource-pack", - "enable-code-of-conduct", - "accepts-transfers", - "sync-chunk-writes", - "management-server-enabled", - "management-server-tls-enabled", - ) CHOICE_PROPS: ClassVar[dict[str, tuple[str, ...]]] = { "gamemode": ("survival", "creative", "adventure", "spectator"), "difficulty": ("peaceful", "easy", "normal", "hard"), - "level-type": ("minecraft:normal", "minecraft:flat", "minecraft:large_biomes", "minecraft:amplified"), + "level-type": ( + "minecraft:normal", + "minecraft:flat", + "minecraft:large_biomes", + "minecraft:amplified", + "minecraft:single_biome_surface", + ), "region-file-compression": ("deflate", "lz4", "none"), } RANGE_PROPS: ClassVar[dict[str, tuple[int, int]]] = { "server-port": (1, 65534), - "max-players": (1, 1000), - "spawn-protection": (0, 100), + "max-players": (0, 2147483647), + "max-world-size": (1, 29999984), + "spawn-protection": (0, 2147483647), "view-distance": (3, 32), "simulation-distance": (3, 32), - "op-permission-level": (1, 4), + "op-permission-level": (0, 4), "function-permission-level": (1, 4), "rcon.port": (1, 65534), "query.port": (1, 65534), "entity-broadcast-range-percentage": (10, 1000), - "network-compression-threshold": (-1, 10000), - "max-tick-time": (-1, 600000), - "rate-limit": (0, 1000), - "player-idle-timeout": (0, 1440), + "network-compression-threshold": (-1, 2147483647), + "max-tick-time": (-1, 2147483647), + "rate-limit": (0, 2147483647), + "player-idle-timeout": (0, 2147483647), + "pause-when-empty-seconds": (-2147483648, 2147483647), + "max-chained-neighbor-updates": (-2147483648, 2147483647), + "management-server-port": (0, 65535), + "status-heartbeat-interval": (0, 2147483647), + "text-filtering-version": (0, 1), } def __init__(self, parent, server_config: ServerConfig, server_manager: ServerManager): @@ -86,6 +70,7 @@ def __init__(self, parent, server_config: ServerConfig, server_manager: ServerMa self.server_config = server_config self.server_manager = server_manager self.properties_helper = ServerPropertiesHelper() + self._default_properties: dict[str, str] = self._load_default_properties() self.result = None self.dialog = DialogUtils.create_toplevel_dialog( parent, @@ -113,6 +98,19 @@ def __init__(self, parent, server_config: ServerConfig, server_manager: ServerMa self.load_properties() self.show_dialog() + def _load_default_properties(self) -> dict[str, str]: + """載入伺服器預設設定,供欄位型別與重設流程共用。""" + if not hasattr(self.server_manager, "get_default_server_properties"): + return {} + try: + defaults = self.server_manager.get_default_server_properties() + except Exception as e: + logger.exception(f"讀取預設 server.properties 失敗: {e}") + return {} + if not isinstance(defaults, dict): + return {} + return {str(key): "" if value is None else str(value) for key, value in defaults.items()} + def setup_dialog(self) -> None: """設定對話框""" self.dialog.setWindowTitle(f"伺服器設定 - {self.server_config.name}") @@ -303,22 +301,31 @@ def _add_scrollable_tab(tab_name: str, properties: list[str] | tuple[str, ...]) categorized_keys: set[str] = set() for props in categories.values(): categorized_keys.update(props) - all_properties = dict(self.server_config.properties or {}) - if hasattr(self.server_manager, "get_default_server_properties"): - try: - defaults = self.server_manager.get_default_server_properties() - all_properties = {**defaults, **all_properties} - except Exception as e: - logger.exception(f"讀取預設 server.properties 失敗: {e}") + all_properties = dict(self._default_properties) + all_properties.update(self.server_config.properties or {}) all_keys = set(all_properties.keys()) - uncategorized_keys = sorted(all_keys - categorized_keys) for category_name, properties in categories.items(): - _add_scrollable_tab(category_name, properties) + visible_properties = [prop for prop in properties if prop in all_keys] + if visible_properties: + _add_scrollable_tab(category_name, visible_properties) + uncategorized_keys = sorted(all_keys - categorized_keys) if uncategorized_keys: _add_scrollable_tab("其他", uncategorized_keys) self.notebook.connect_event("tab_changed", self._on_tab_changed, append=True) self._on_tab_changed() + @staticmethod + def _is_boolean_string(value: Any) -> bool: + normalized = str(value).strip().lower() if value is not None else "" + return normalized in {"true", "false"} + + def _should_use_checkbox(self, prop_name: str, value: Any) -> bool: + if ServerPropertiesValidator.is_boolean_property(prop_name): + return True + if self._is_boolean_string(value): + return True + return self._is_boolean_string(self._default_properties.get(prop_name)) + def _get_or_create_property_var(self, prop_name: str) -> Any: """取得或建立屬性對應的 TextState,並同步到 cache。""" existing = self.property_vars.get(prop_name) @@ -400,7 +407,7 @@ def create_property_widget(self, parent, prop_name: str, var: Any) -> Any: 建立完成的 widget。 """ widget: Any - if prop_name in self.BOOLEAN_PROPS: + if self._should_use_checkbox(prop_name, var.get()): bool_var = self._property_bool_vars.get(prop_name) if bool_var is None: bool_var = qt.BoolState() diff --git a/src/ui/task_utils.py b/src/ui/task_utils.py index d21f578..bc76b35 100644 --- a/src/ui/task_utils.py +++ b/src/ui/task_utils.py @@ -4,7 +4,6 @@ import concurrent.futures import queue -import threading from collections.abc import Callable from typing import Any @@ -138,12 +137,14 @@ def run_async(target: Callable[..., Any], *args: Any, **kwargs: Any) -> concurre """ try: return run_in_background(target, *args, **kwargs) - except Exception: - threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True).start() - return None + except Exception as exc: + future: concurrent.futures.Future[Any] = concurrent.futures.Future() + future.set_exception(exc) + logger.exception(f"背景任務提交失敗: {exc}") + return future @staticmethod - def run_in_daemon_thread( + def run_background_task( task_func: Callable, *, ui_queue: queue.Queue | None = None, @@ -152,7 +153,7 @@ def run_in_daemon_thread( error_log_prefix: str = "", component: str = "TaskUtils", ) -> None: - """在背景 daemon thread 執行任務,失敗時可選擇回派 UI callback。 + """透過 Qt 背景工作池執行任務,失敗時可選擇回派 UI callback。 Args: task_func: 要執行的任務函式。 @@ -192,7 +193,7 @@ def _wrapper() -> None: get_logger().bind(component=component).exception(f"{prefix}{exc}") _dispatch(on_error) - threading.Thread(target=_wrapper, daemon=True).start() + TaskUtils.run_async(_wrapper) __all__ = ["TaskUtils"] diff --git a/src/ui/ui_config.py b/src/ui/ui_config.py index 3567c18..cc36f9f 100644 --- a/src/ui/ui_config.py +++ b/src/ui/ui_config.py @@ -6,6 +6,7 @@ from typing import Any, ClassVar +from ..utils.ui_support.fluent import apply_fluent_theme from ..utils.ui_support.qt_runtime import QtCore, QtGui, QtWidgets, ensure_application @@ -107,7 +108,48 @@ def _dialog_control_stylesheet( f"background: {panel_2}; }}" f"{_scope_selector('QComboBox QAbstractItemView', scope)} {{ background: {input_bg}; color: {text}; " f"selection-background-color: {selection}; selection-color: {text}; }}" - f"{_scope_selector('QCheckBox, QRadioButton', scope)} {{ color: {text}; spacing: 6px; background: transparent; }}" + + _checkbox_stylesheet( + text=text, + unchecked_fill=input_bg, + unchecked_border=input_border, + checked_fill=primary, + checked_border=primary_hover, + hover_fill=panel_2, + checked_hover_fill=primary_hover, + disabled_fill=panel_2, + disabled_border=disabled_button, + scope=scope, + ) + + f"{_scope_selector('QRadioButton', scope)} {{ color: {text}; spacing: 6px; background: transparent; }}" + ) + + +def _checkbox_stylesheet( + *, + text: str, + unchecked_fill: str, + unchecked_border: str, + checked_fill: str, + checked_border: str, + hover_fill: str, + checked_hover_fill: str, + disabled_fill: str, + disabled_border: str, + scope: str | None = None, +) -> str: + checkbox = _scope_selector("QCheckBox", scope) + indicator = _scope_selector("QCheckBox::indicator", scope) + unchecked_indicator = _scope_selector("QCheckBox::indicator:unchecked", scope) + checked_indicator = _scope_selector("QCheckBox::indicator:checked", scope) + return ( + f"{checkbox} {{ color: {text}; spacing: 8px; background: transparent; }}" + f"{indicator} {{ width: 18px; height: 18px; border-radius: 5px; border: 2px solid {unchecked_border}; " + f"background: {unchecked_fill}; }}" + f"{unchecked_indicator}:hover {{ border-color: {checked_border}; background: {hover_fill}; }}" + f"{checked_indicator} {{ border-color: {checked_border}; background: {checked_fill}; }}" + f"{checked_indicator}:hover {{ border-color: {checked_border}; background: {checked_hover_fill}; }}" + f"{unchecked_indicator}:disabled {{ border-color: {disabled_border}; background: {disabled_fill}; }}" + f"{checked_indicator}:disabled {{ border-color: {disabled_border}; background: {disabled_border}; }}" ) @@ -418,6 +460,7 @@ def initialize_ui_theme(mode: str = "light") -> None: app.styleHints().setColorScheme(_requested_scheme(mode)) dark = _is_dark_scheme(app) _refresh_native_styles(dark) + apply_fluent_theme(dark=dark, accent_color="#1d4ed8" if dark else "#2563eb") colors = { "window": "#0b0b0b" if dark else "#f3f4f6", @@ -454,7 +497,7 @@ def initialize_ui_theme(mode: str = "light") -> None: font_family = ui_font.family().replace("'", "\\'") app.setStyleSheet( f"QWidget {{ font-family: '{font_family}'; color: {colors['text']}; }}" - f"QLabel, QCheckBox, QRadioButton, QGroupBox, QTabWidget, QTreeView {{ color: {colors['text']}; }}" + f"QLabel, QRadioButton, QGroupBox, QTabWidget, QTreeView {{ color: {colors['text']}; }}" "QToolTip { background: #000000; color: #ffffff; border: 1px solid #ffffff; padding: 5px; }" "QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox, QComboBox {" f"background: {colors['base']}; color: {colors['text']}; border: 2px solid {colors['input_border']}; " @@ -466,7 +509,18 @@ def initialize_ui_theme(mode: str = "light") -> None: f"background: {colors['alternate_base']}; }}" f"QComboBox QAbstractItemView {{ background: {colors['base']}; color: {colors['text']}; " f"selection-background-color: {colors['highlight']}; selection-color: #ffffff; }}" - f"QTreeView {{ background: {colors['base']}; color: {colors['text']}; " + + _checkbox_stylesheet( + text=colors["text"], + unchecked_fill=colors["alternate_base"] if dark else colors["base"], + unchecked_border="#cbd5e1" if dark else colors["input_border"], + checked_fill=colors["button"], + checked_border="#bfdbfe" if dark else colors["button"], + hover_fill="#334155" if dark else colors["alternate_base"], + checked_hover_fill="#1e40af" if dark else "#1d4ed8", + disabled_fill=colors["alternate_base"], + disabled_border=colors["disabled_button"], + ) + + f"QTreeView {{ background: {colors['base']}; color: {colors['text']}; " f"alternate-background-color: {colors['tree_alt']}; border: 1px solid {colors['tree_border']}; " "padding: 0px; margin: 0px; }}" f"QTreeView::item {{ padding-left: 0px; margin-left: 0px; color: {colors['text']}; }}" diff --git a/src/utils/core_utils/hash_utils.py b/src/utils/core_utils/hash_utils.py index 805e08c..767a3ca 100644 --- a/src/utils/core_utils/hash_utils.py +++ b/src/utils/core_utils/hash_utils.py @@ -1,6 +1,6 @@ """檔案雜湊工具。 -提供同步與非同步的檔案雜湊計算,並使用背景 worker pool 避免阻塞主執行緒。 +提供同步與非同步的檔案雜湊計算,並使用背景工作池避免阻塞主執行緒。 """ import asyncio @@ -8,7 +8,7 @@ from functools import lru_cache from pathlib import Path -from ..runtime_utils.worker_pool import get_shared_worker_pool, submit_to_worker_pool +from ..runtime_utils.worker_pool import submit_to_worker_pool from .logger import get_logger logger = get_logger().bind(component="HashUtils") @@ -51,7 +51,7 @@ def _compute_file_hash_cached_internal( file_path: str, algorithm: str, mtime_ns: int, file_size: int, chunk_size: int ) -> str: """ - 透過快取避免重複計算,並發派任務到 ThreadPoolExecutor 防止阻塞主執行緒。 + 透過快取避免重複計算,並發派任務到 Qt 工作池防止阻塞主執行緒。 """ del mtime_ns, file_size # 用於快取鍵值 future = submit_to_worker_pool(compute_file_hash_sync, file_path, algorithm, chunk_size) @@ -105,12 +105,5 @@ async def compute_file_hash_async( Returns: 計算後的雜湊字串;失敗時回傳空字串。 """ - loop = asyncio.get_running_loop() - return await loop.run_in_executor( - get_shared_worker_pool(), - compute_file_hash, - str(file_path), - algorithm, - chunk_size, - use_cache, - ) + future = submit_to_worker_pool(compute_file_hash, str(file_path), algorithm, chunk_size, use_cache) + return await asyncio.wrap_future(future) 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/java_support/java_utils.py b/src/utils/java_support/java_utils.py index c00b112..2ac582f 100644 --- a/src/utils/java_support/java_utils.py +++ b/src/utils/java_support/java_utils.py @@ -4,7 +4,6 @@ from __future__ import annotations -import concurrent.futures import contextlib import os import re @@ -13,7 +12,7 @@ from typing import ClassVar, Protocol from ...core import MinecraftVersionManager -from .. import HTTPUtils, PathUtils, RuntimePaths, SubprocessUtils, get_logger +from .. import HTTPUtils, PathUtils, RuntimePaths, SubprocessUtils, get_logger, get_shared_manager from .java_downloader import JavaDownloader logger = get_logger().bind(component="JavaUtils") @@ -182,15 +181,14 @@ def _scan_and_cache_local_java_candidates() -> list[tuple[str, int]]: candidate_paths.add(javaw_exe) if not candidate_paths: return candidates - max_workers = min(8, max(2, os.cpu_count() or 4)) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = [ - executor.submit(JavaUtils._resolve_java_candidate, javaw_exe) for javaw_exe in sorted(candidate_paths) - ] - for future in concurrent.futures.as_completed(futures): - resolved_candidate = future.result() - if resolved_candidate: - candidates.append(resolved_candidate) + futures = [ + get_shared_manager().run(JavaUtils._resolve_java_candidate, javaw_exe) + for javaw_exe in sorted(candidate_paths) + ] + for future in futures: + resolved_candidate = future.result() + if resolved_candidate: + candidates.append(resolved_candidate) seen = set() final_results = [] for c_path, c_major in candidates: diff --git a/src/utils/mod_utils/mod_index_manager.py b/src/utils/mod_utils/mod_index_manager.py index 464d3d5..c6a6a5f 100644 --- a/src/utils/mod_utils/mod_index_manager.py +++ b/src/utils/mod_utils/mod_index_manager.py @@ -48,8 +48,8 @@ def __init__(self, server_path: str, index_dir: str | None = None): FILE_ATTRIBUTE_HIDDEN = 0x02 ctypes.windll.kernel32.SetFileAttributesW(str(self.index_dir), FILE_ATTRIBUTE_HIDDEN) except (AttributeError, OSError) as _: - # 無法設為隱藏則忽略 - logger.debug("無法設定資料夾隱藏屬性,忽略") + # 無法設為隱藏時直接略過。 + logger.debug("無法設定資料夾隱藏屬性,已略過") except OSError as e: logger.debug(f"初始化索引目錄時發生 OSError: {e}") self._index_lock = threading.RLock() diff --git a/src/utils/network_utils/http_utils.py b/src/utils/network_utils/http_utils.py index b42fe24..47e4d16 100644 --- a/src/utils/network_utils/http_utils.py +++ b/src/utils/network_utils/http_utils.py @@ -2,11 +2,11 @@ 提供標準化的 HTTP 請求功能,包含 JSON 取得、檔案下載與通用重試策略等常用操作。 """ -import asyncio -import concurrent.futures import contextlib +import errno import hashlib import os +import shutil import tempfile import threading import time @@ -17,11 +17,14 @@ import requests from requests import HTTPError, RequestException +from requests import exceptions as requests_exceptions from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from ...version_info import APP_NAME, APP_VERSION, GITHUB_OWNER, GITHUB_REPO from .. import get_logger +from ..runtime_utils.background_task import get_shared_manager +from ..runtime_utils.worker_pool import run_blocking_io logger = get_logger().bind(component="HTTPUtils") @@ -137,6 +140,52 @@ def _is_valid_url(url: str) -> bool: return False return parsed.scheme in {"http", "https"} and bool(parsed.hostname) + @staticmethod + def _format_bytes(size: int) -> str: + size = max(0, int(size)) + units = ["B", "KiB", "MiB", "GiB", "TiB"] + value = float(size) + for unit in units: + if value < 1024 or unit == units[-1]: + if unit == "B": + return f"{int(value)} {unit}" + return f"{value:.1f} {unit}" + value /= 1024 + return f"{size} B" + + @staticmethod + def _describe_request_failure(exc: Exception) -> str: + if isinstance(exc, HTTPError): + status_code = getattr(getattr(exc, "response", None), "status_code", None) + if status_code == 429: + return "HTTP 429 Too Many Requests" + if status_code is not None: + if 500 <= int(status_code) < 600: + return f"HTTP {status_code} 伺服器錯誤" + if status_code == 401: + return "HTTP 401 未授權" + if status_code == 403: + return "HTTP 403 拒絕存取" + if status_code == 404: + return "HTTP 404 找不到資源" + return f"HTTP {status_code} 回應錯誤" + return "HTTP 回應錯誤" + if isinstance(exc, requests_exceptions.Timeout): + return "請求逾時" + if isinstance(exc, requests_exceptions.ConnectionError): + return "無法建立網路連線" + if isinstance(exc, requests_exceptions.TooManyRedirects): + return "重新導向次數過多" + if isinstance(exc, requests_exceptions.SSLError): + return "SSL 驗證失敗" + if isinstance(exc, ValueError): + return "回應內容不是有效 JSON" + if isinstance(exc, OSError): + if getattr(exc, "errno", None) == errno.ENOSPC: + return "磁碟空間不足" + return f"I/O 錯誤: {exc}" + return str(exc) or exc.__class__.__name__ + @staticmethod def get_default_headers(headers: dict[str, str] | None = None) -> dict[str, str]: """獲取包含預設 User-Agent 的標頭""" @@ -180,10 +229,10 @@ def get_json( status_code = getattr(getattr(e, "response", None), "status_code", None) if status_code is not None and status_code in (suppress_status_codes or set()): return None - logger.exception(f"HTTP GET JSON 請求失敗 ({url}): {e}") + logger.exception(f"HTTP GET JSON 請求失敗 ({url}): {cls._describe_request_failure(e)}") return None except (RequestException, ValueError) as e: - logger.exception(f"HTTP GET JSON 請求失敗 ({url}): {e}") + logger.exception(f"HTTP GET JSON 請求失敗 ({url}): {cls._describe_request_failure(e)}") return None @classmethod @@ -222,10 +271,10 @@ def post_json( status_code = getattr(getattr(e, "response", None), "status_code", None) if status_code is not None and status_code in (suppress_status_codes or set()): return None - logger.exception(f"HTTP POST JSON 請求失敗 ({url}): {e}") + logger.exception(f"HTTP POST JSON 請求失敗 ({url}): {cls._describe_request_failure(e)}") return None except (RequestException, ValueError) as e: - logger.exception(f"HTTP POST JSON 請求失敗 ({url}): {e}") + logger.exception(f"HTTP POST JSON 請求失敗 ({url}): {cls._describe_request_failure(e)}") return None @classmethod @@ -260,9 +309,9 @@ def get_content( return resp.content except RequestException as e: if log_errors: - logger.exception(f"HTTP GET 請求失敗 ({url}): {e}") + logger.exception(f"HTTP GET 請求失敗 ({url}): {cls._describe_request_failure(e)}") else: - logger.debug(f"HTTP GET 請求未成功 ({url}): {e}") + logger.debug(f"HTTP GET 請求未成功 ({url}): {cls._describe_request_failure(e)}") return None @classmethod @@ -276,6 +325,7 @@ def download_file( cancel_check: Callable[[], bool] | None = None, expected_sha256: str | None = None, expected_hash: str | None = None, + failure_message_callback: Callable[[str], None] | None = None, ) -> bool: """下載檔案並儲存到本機路徑。 @@ -288,6 +338,7 @@ def download_file( cancel_check: 取消檢查回呼。 expected_sha256: 預期的 SHA-256 雜湊。 expected_hash: 預期的雜湊值,僅支援 sha256 / sha512。 + failure_message_callback: 可選的失敗原因回呼,用於回傳更具體的錯誤訊息。 Returns: 下載成功時回傳 True,失敗時回傳 False。 @@ -341,9 +392,25 @@ 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)) + if total_size > 0: + try: + free_space = shutil.disk_usage(local_path_obj.parent).free + except OSError as exc: + logger.debug(f"無法查詢目的地磁碟空間,略過預檢: {exc}") + else: + if free_space < total_size: + failure_message = ( + f"磁碟空間不足:目的地 {local_path_obj.parent} 需要至少 {cls._format_bytes(total_size)}," + f"目前剩餘 {cls._format_bytes(free_space)}。" + ) + if failure_message_callback: + failure_message_callback(failure_message) + logger.error(f"檔案下載失敗 ({url} -> {local_path}): {failure_message}") + return False downloaded = 0 hasher = hashlib.new(expected_hash_algorithm) if normalized_expected_hash else None with open(temp_path_obj, "wb") as f: @@ -364,6 +431,9 @@ def download_file( if normalized_expected_hash and hasher is not None: computed = hasher.hexdigest().lower() if computed != normalized_expected_hash: + failure_message = f"下載檔案雜湊驗證失敗:預期 {expected_hash_algorithm.upper()} 不符。" + if failure_message_callback: + failure_message_callback(failure_message) logger.error( f"下載檔案的雜湊不符: algorithm={expected_hash_algorithm} expected={normalized_expected_hash} computed={computed}" ) @@ -382,7 +452,10 @@ def download_file( logger.debug(f"目錄 fsync 失敗 (path={local_path_obj.parents[0]}): {e}") return True except (RequestException, OSError) as e: - logger.exception(f"檔案下載失敗 ({url} -> {local_path}): {e}") + failure_message = cls._describe_request_failure(e) + if failure_message_callback: + failure_message_callback(failure_message) + logger.exception(f"檔案下載失敗 ({url} -> {local_path}): {failure_message}") if temp_path_obj.exists(): with contextlib.suppress(OSError): temp_path_obj.unlink() @@ -406,9 +479,14 @@ def get_json_batch( if not urls: return [] try: - with concurrent.futures.ThreadPoolExecutor(max_workers=min(max_workers, len(urls))) as executor: - futures = [executor.submit(HTTPUtils.get_json, url, timeout, headers) for url in urls] - return [f.result() for f in futures] + batch_size = max(1, min(int(max_workers or 1), len(urls))) + manager = get_shared_manager() + results: list[dict[str, Any] | None] = [] + for start in range(0, len(urls), batch_size): + batch = urls[start : start + batch_size] + futures = [manager.run(HTTPUtils.get_json, url, timeout, headers) for url in batch] + results.extend(future.result() for future in futures) + return results except Exception as e: logger.exception(f"批次 HTTP 請求失敗: {e}") return [None] * len(urls) @@ -417,22 +495,22 @@ def get_json_batch( async def get_json_async(cls, *args, **kwargs): """使用共用 requests 實作在線程中取得 JSON 回應。""" - return await asyncio.to_thread(cls.get_json, *args, **kwargs) + return await run_blocking_io(cls.get_json, *args, **kwargs) @classmethod async def post_json_async(cls, *args, **kwargs): """使用共用 requests 實作在線程中送出 JSON POST 請求。""" - return await asyncio.to_thread(cls.post_json, *args, **kwargs) + return await run_blocking_io(cls.post_json, *args, **kwargs) @classmethod async def get_content_async(cls, *args, **kwargs): """使用共用 requests 實作在線程中取得完整回應內容。""" - return await asyncio.to_thread(cls.get_content, *args, **kwargs) + return await run_blocking_io(cls.get_content, *args, **kwargs) @classmethod async def download_file_async(cls, *args, **kwargs): """使用共用 requests 實作在線程中下載檔案。""" - return await asyncio.to_thread(cls.download_file, *args, **kwargs) + return await run_blocking_io(cls.download_file, *args, **kwargs) diff --git a/src/utils/runtime_utils/app_restart.py b/src/utils/runtime_utils/app_restart.py index ba9a891..79bba74 100644 --- a/src/utils/runtime_utils/app_restart.py +++ b/src/utils/runtime_utils/app_restart.py @@ -3,14 +3,15 @@ 提供安全的應用程式重啟功能,支援打包執行檔與 Python 腳本模式 """ +import concurrent.futures import contextlib import os import sys -import threading import time from pathlib import Path from .. import PathUtils, RuntimePaths, SubprocessUtils, get_logger, shutdown_logging +from .background_task import run_in_background logger = get_logger().bind(component="AppRestart") @@ -154,7 +155,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 +332,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) @@ -351,12 +352,10 @@ def restart_application(delay: float = 1.0) -> bool: """ try: executable_cmd, is_frozen, script_path = AppRestart._get_executable_info() - restart_success = threading.Event() - restart_error = threading.Event() script_parent = script_path.parents[0] if script_path is not None else None _ = str(script_path) if script_path is not None else None - def delayed_restart(): + def delayed_restart() -> bool: """延遲重啟函式(會在背景執行緒啟動新程式)。""" try: time.sleep(delay) @@ -426,19 +425,19 @@ def delayed_restart(): logger.debug(f"以模組方式重啟: {use_cmd}, 指令={target_cwd}") process = SubprocessUtils.popen_detached(use_cmd, cwd=target_cwd) time.sleep(0.5) - if process.poll() is None: - restart_success.set() - else: - restart_error.set() + return process.poll() is None except Exception as e: logger.exception(f"重啟失敗: {e}") - restart_error.set() + return False - threading.Thread(target=delayed_restart, daemon=True).start() + future = run_in_background(delayed_restart) max_wait_time = delay + 2.0 - if restart_success.wait(timeout=max_wait_time): + if future is None: + return True + try: + return bool(future.result(timeout=max_wait_time)) + except concurrent.futures.TimeoutError: return True - return not restart_error.is_set() except Exception as e: logger.exception(f"準備重啟時發生錯誤: {e}") return False diff --git a/src/utils/runtime_utils/background_task.py b/src/utils/runtime_utils/background_task.py index 99bdc7e..e7bc05f 100644 --- a/src/utils/runtime_utils/background_task.py +++ b/src/utils/runtime_utils/background_task.py @@ -1,6 +1,6 @@ """背景任務工具與取消標記 -提供一個簡單的背景任務執行器(基於 ThreadPoolExecutor)與協作式取消(CancellationToken), +提供一個簡單的背景任務執行器(基於 QThreadPool)與協作式取消(CancellationToken), 供 UI 與 core 層在不阻塞主執行緒下執行長時間任務。 規範:若任務支援取消,應接受名為 `cancel_token` 的參數並自行檢查其狀態。 @@ -12,10 +12,11 @@ import concurrent.futures import functools import inspect -import threading from collections.abc import Callable from typing import Any +from PySide6 import QtCore + from .. import get_logger logger = get_logger().bind(component="BackgroundTask") @@ -30,7 +31,7 @@ class CancellationToken: - """簡易的取消標記,用於協作式取消(cooperative cancellation)。""" + """簡易的取消標記,用於協作式取消。""" def __init__(self): self._cancelled = False @@ -50,7 +51,8 @@ class BackgroundTaskManager: """簡單的背景任務執行器,支援取消 token 與回呼""" def __init__(self, max_workers: int = 4): - self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + self._pool = QtCore.QThreadPool() + self._pool.setMaxThreadCount(max(1, int(max_workers))) def run( self, @@ -60,7 +62,7 @@ def run( cancel_token: CancellationToken | None = None, **kwargs, ) -> concurrent.futures.Future: - """提交背景任務,完成後若提供 callback 會在背景執行緒呼叫 callback(result)。 + """提交背景任務,完成後若提供 callback 會在背景執行緒呼叫。 Args: fn: 要執行的函式。 @@ -74,7 +76,9 @@ def run( """ if cancel_token is not None and "cancel_token" not in kwargs: kwargs["cancel_token"] = cancel_token - future = self._executor.submit(fn, *args, **kwargs) + future: concurrent.futures.Future[Any] = concurrent.futures.Future() + runnable = _QtRunnable(future, functools.partial(fn, *args, **kwargs)) + self._pool.start(runnable) if callback: def _on_done(f: concurrent.futures.Future): @@ -103,10 +107,10 @@ async def run_async( cancel_token: CancellationToken | None = None, **kwargs, ) -> asyncio.Task: - """以 coroutine 介面執行任務。 + """以協程介面執行任務。 Args: - fn: 要執行的函式或 coroutine function。 + fn: 要執行的函式或協程函式。 *args: 傳入函式的位置參數。 callback: 任務完成後的回呼。 cancel_token: 協作式取消標記。 @@ -121,12 +125,13 @@ async def run_async( if inspect.iscoroutinefunction(fn): task = loop.create_task(fn(*args, **kwargs)) else: - call = functools.partial(fn, *args, **kwargs) + future = self.run(fn, *args, callback=callback, cancel_token=cancel_token, **kwargs) - async def _run_in_executor(): - return await loop.run_in_executor(self._executor, call) + async def _await_future(): + return await asyncio.wrap_future(future) - task = loop.create_task(_run_in_executor()) + task = loop.create_task(_await_future()) + callback = None if callback: @@ -149,13 +154,36 @@ def _on_done(task_fut: asyncio.Future): return task def shutdown(self, wait: bool = True) -> None: - """關閉 executor,必要時等待既有任務完成。 + """關閉 Qt 工作池,必要時等待既有任務完成。 Args: wait: 是否等待既有任務完成。 """ - self._executor.shutdown(wait=wait) + if wait: + self._pool.waitForDone() + else: + self._pool.clear() + + +class _QtRunnable(QtCore.QRunnable): + """在 QThreadPool 中執行 Python callable,並同步完成 Future。""" + + def __init__(self, future: concurrent.futures.Future[Any], call: Callable[[], Any]) -> None: + super().__init__() + self.future = future + self.call = call + self.setAutoDelete(True) + + def run(self) -> None: + if not self.future.set_running_or_notify_cancel(): + return + try: + result = self.call() + except Exception as exc: + self.future.set_exception(exc) + return + self.future.set_result(result) _shared_manager: BackgroundTaskManager | None = None @@ -180,33 +208,25 @@ def run_in_background( """使用共享 BackgroundTaskManager 的便利函式。 若無法使用共享 manager(例:初始化失敗或其他稀有例外), - 會回退到在新的 daemon thread 中直接啟動函式以保持向後相容性。 + 會同步完成一個 Future 以保持錯誤可觀測。 """ try: return get_shared_manager().run(fn, *args, callback=callback, **kwargs) except Exception as exc: - logger.warning(f"Shared BackgroundTaskManager unavailable, falling back to daemon thread: {exc}") - - def _fallback_runner() -> None: - try: - res = fn(*args, **kwargs) - except Exception: - logger.exception("Background fallback thread raised an exception") - if callback: - try: - callback(None) - except Exception: - logger.exception("Background fallback callback raised an exception while handling failure") - return + logger.warning(f"Shared BackgroundTaskManager unavailable, running fallback Future synchronously: {exc}") + future: concurrent.futures.Future[Any] = concurrent.futures.Future() + try: + result = fn(*args, **kwargs) + except Exception as run_exc: + future.set_exception(run_exc) + logger.exception("Background fallback raised an exception") if callback: - try: - callback(res) - except Exception: - logger.exception("Background fallback callback raised an exception") - - thread = threading.Thread(target=_fallback_runner, daemon=True) - thread.start() - return None + callback(None) + else: + future.set_result(result) + if callback: + callback(result) + return future def run_async_in_background( @@ -214,7 +234,7 @@ def run_async_in_background( ) -> concurrent.futures.Future[Any] | asyncio.Task[Any]: """若在 asyncio loop 中,使用共享 manager 的 run_async;否則回傳 concurrent.futures.Future。 - 注意:呼叫者在 asyncio context 中應直接呼叫 `await get_shared_manager().run_async(...)`。 + 注意:呼叫者在 asyncio 環境中應直接呼叫 `await get_shared_manager().run_async(...)`。 此函式提供在不確定執行環境時的便利層級。 """ try: diff --git a/src/utils/runtime_utils/settings_manager.py b/src/utils/runtime_utils/settings_manager.py index b74b713..5d595a2 100644 --- a/src/utils/runtime_utils/settings_manager.py +++ b/src/utils/runtime_utils/settings_manager.py @@ -2,6 +2,7 @@ 提供統一的使用者設定管理功能,包含自動更新與視窗偏好等。 """ +import threading import time from pathlib import Path from typing import Any, TypedDict, cast @@ -86,6 +87,7 @@ class SettingsManager: """統一管理所有使用者設定的管理器類別""" def __init__(self): + self._lock = threading.RLock() self.settings_path = RuntimePaths.ensure_dir(RuntimePaths.get_user_data_dir()) / "user_settings.json" self._settings = self._load_settings() self._no_change_skip_count = 0 @@ -126,6 +128,38 @@ def build_servers_root_path(base_dir: str | Path) -> Path: return (Path(base_dir).expanduser() / "servers").resolve() + @staticmethod + def _normalize_int_value(value: Any, default: int) -> int: + try: + return int(value) + except TypeError, ValueError: + return default + + def _normalize_window_preferences(self, window_preferences: dict[str, Any]) -> WindowPreferences: + normalized_window = _copy_window_preferences() + normalized_window["remember_size_position"] = bool( + window_preferences.get("remember_size_position", normalized_window["remember_size_position"]) + ) + normalized_window["auto_center"] = bool(window_preferences.get("auto_center", normalized_window["auto_center"])) + normalized_window["adaptive_sizing"] = bool( + window_preferences.get("adaptive_sizing", normalized_window["adaptive_sizing"]) + ) + normalized_window["theme_mode"] = self._normalize_theme_mode( + window_preferences.get("theme_mode", normalized_window["theme_mode"]) + ) + main_window = window_preferences.get("main_window") + if isinstance(main_window, dict): + normalized_window["main_window"] = { + "width": self._normalize_int_value(main_window.get("width"), normalized_window["main_window"]["width"]), + "height": self._normalize_int_value( + main_window.get("height"), normalized_window["main_window"]["height"] + ), + "x": main_window.get("x"), + "y": main_window.get("y"), + "maximized": bool(main_window.get("maximized", normalized_window["main_window"]["maximized"])), + } + return normalized_window + def _normalize_settings(self, settings: dict[str, Any]) -> dict[str, Any]: normalized = dict(_get_default_settings()) normalized["servers_root"] = self.normalize_servers_base_dir( @@ -135,64 +169,45 @@ def _normalize_settings(self, settings: dict[str, Any]) -> dict[str, Any]: normalized[key] = bool(settings.get(key, default)) window_preferences = settings.get("window_preferences") if isinstance(window_preferences, dict): - normalized_window = _copy_window_preferences() - normalized_window["remember_size_position"] = bool( - window_preferences.get("remember_size_position", normalized_window["remember_size_position"]) - ) - normalized_window["auto_center"] = bool( - window_preferences.get("auto_center", normalized_window["auto_center"]) - ) - normalized_window["adaptive_sizing"] = bool( - window_preferences.get("adaptive_sizing", normalized_window["adaptive_sizing"]) - ) - normalized_window["theme_mode"] = self._normalize_theme_mode( - window_preferences.get("theme_mode", normalized_window["theme_mode"]) - ) - main_window = window_preferences.get("main_window") - if isinstance(main_window, dict): - normalized_window["main_window"] = { - "width": int(main_window.get("width", normalized_window["main_window"]["width"])), - "height": int(main_window.get("height", normalized_window["main_window"]["height"])), - "x": main_window.get("x"), - "y": main_window.get("y"), - "maximized": bool(main_window.get("maximized", normalized_window["main_window"]["maximized"])), - } - normalized["window_preferences"] = normalized_window + normalized["window_preferences"] = self._normalize_window_preferences(window_preferences) return normalized def _load_settings(self) -> dict[str, Any]: - if not self.settings_path.exists(): - default_settings = _get_default_settings() - self._save_settings(default_settings) - return default_settings - settings = PathUtils.load_json(self.settings_path) - if not settings: - return _get_default_settings() - if not isinstance(settings, dict): - return _get_default_settings() - return self._normalize_settings(settings) + with self._lock: + if not self.settings_path.exists(): + default_settings = _get_default_settings() + self._save_settings(default_settings) + return default_settings + settings = PathUtils.load_json(self.settings_path) + if not settings: + return _get_default_settings() + if not isinstance(settings, dict): + return _get_default_settings() + return self._normalize_settings(settings) def _save_settings(self, settings: dict[str, Any]) -> None: # 若設定內容未變更則略過寫入以減少不必要的 I/O - try: - if self.settings_path.exists(): - current = PathUtils.load_json(self.settings_path) - if isinstance(current, dict) and current == settings: - self._no_change_skip_count += 1 - now_monotonic = time.monotonic() - if ( - self._no_change_last_log_monotonic <= 0 - or (now_monotonic - self._no_change_last_log_monotonic) >= self._no_change_log_interval_seconds - ): - logger.debug(f"settings 未變更,跳過寫入(最近累計 {self._no_change_skip_count} 次)") - self._no_change_skip_count = 0 - self._no_change_last_log_monotonic = now_monotonic - return - except OSError as e: - logger.debug(f"比對 settings 檔案時發生 I/O 錯誤,改為直接寫入: {e}") - - if not atomic_write_json(self.settings_path, settings): - logger.error("無法寫入 user_settings.json") + with self._lock: + try: + if self.settings_path.exists(): + current = PathUtils.load_json(self.settings_path) + if isinstance(current, dict) and current == settings: + self._no_change_skip_count += 1 + now_monotonic = time.monotonic() + if ( + self._no_change_last_log_monotonic <= 0 + or (now_monotonic - self._no_change_last_log_monotonic) + >= self._no_change_log_interval_seconds + ): + logger.debug(f"settings 未變更,跳過寫入(最近累計 {self._no_change_skip_count} 次)") + self._no_change_skip_count = 0 + self._no_change_last_log_monotonic = now_monotonic + return + except OSError as e: + logger.debug(f"比對 settings 檔案時發生 I/O 錯誤,改為直接寫入: {e}") + + if not atomic_write_json(self.settings_path, settings): + logger.error("無法寫入 user_settings.json") def get(self, key: str, default: Any = None) -> Any: """取得指定鍵值的設定資料。 @@ -204,7 +219,11 @@ def get(self, key: str, default: Any = None) -> Any: Returns: 對應的設定值。 """ - return self._settings.get(key, default) + with self._lock: + value = self._settings.get(key, default) + if key == "window_preferences" and isinstance(value, dict): + return self._normalize_window_preferences(value) + return value def set(self, key: str, value: Any, immediate_save: bool = True) -> None: """設定指定鍵值的資料。 @@ -214,9 +233,10 @@ def set(self, key: str, value: Any, immediate_save: bool = True) -> None: value: 要寫入的設定值。 immediate_save: 是否立即儲存到磁碟。 """ - self._settings[key] = value - if immediate_save: - self._save_settings(self._settings) + with self._lock: + self._settings[key] = value + if immediate_save: + self._save_settings(self._settings) def update_batch(self, updates: dict) -> None: """批次更新多個設定值並一次性儲存。 @@ -224,8 +244,9 @@ def update_batch(self, updates: dict) -> None: Args: updates: 要合併寫入的設定更新項目。 """ - self._settings.update(updates) - self._save_settings(self._settings) + with self._lock: + self._settings.update(updates) + self._save_settings(self._settings) def _get_bool_setting(self, key: str) -> bool: """通用的布林設定取得方法(內部使用)""" @@ -248,8 +269,13 @@ def _normalize_theme_mode(mode: Any) -> str: return normalized if normalized in _THEME_MODES else DEFAULT_WINDOW_PREFERENCES["theme_mode"] def get_servers_root(self) -> str: - """取得使用者設定的伺服器主資料夾路徑。""" - return str(self._settings.get("servers_root", "")).strip() + """取得使用者設定的伺服器主資料夾路徑。 + + Returns: + 目前設定的伺服器主資料夾根路徑字串;若尚未設定則回傳空字串。 + """ + with self._lock: + return str(self._settings.get("servers_root", "")).strip() def set_servers_root(self, path: str | Path) -> None: normalized_path = self.normalize_servers_base_dir(path) @@ -297,10 +323,16 @@ def mark_first_run_completed(self) -> None: self._set_bool_setting("first_run_completed", True) def get_window_preferences(self) -> WindowPreferences: - value = self._settings.get("window_preferences", _copy_window_preferences()) - if isinstance(value, dict): - return cast(WindowPreferences, value) - return _copy_window_preferences() + """取得視窗偏好設定。 + + Returns: + 已正規化且可安全讀取的視窗偏好設定。 + """ + with self._lock: + value = self._settings.get("window_preferences", _copy_window_preferences()) + if isinstance(value, dict): + return self._normalize_window_preferences(value) + return _copy_window_preferences() def is_remember_size_position_enabled(self) -> bool: return bool(self.get_window_preferences().get(_WINDOW_PREF_KEYS["remember_size_position"], True)) @@ -310,11 +342,16 @@ def set_remember_size_position(self, enabled: bool) -> None: self._update_window_pref(key, enabled) def get_main_window_settings(self) -> MainWindowSettings: - """取得主視窗的大小、位置和狀態設定""" - return cast( - MainWindowSettings, - self.get_window_preferences().get("main_window", self.get_default_main_window_settings()), - ) + """取得主視窗的大小、位置和狀態設定。 + + Returns: + 主視窗尺寸、位置與最大化狀態設定。 + """ + with self._lock: + return cast( + MainWindowSettings, + dict(self.get_window_preferences().get("main_window", self.get_default_main_window_settings())), + ) @staticmethod def get_default_main_window_settings() -> MainWindowSettings: @@ -324,10 +361,19 @@ def get_default_main_window_settings() -> MainWindowSettings: def set_main_window_settings( self, width: int, height: int, x: int | None = None, y: int | None = None, maximized: bool = False ) -> None: - """設定主視窗的大小、位置和最大化狀態""" - prefs: dict[str, Any] = dict(self.get_window_preferences()) - prefs["main_window"] = {"width": width, "height": height, "x": x, "y": y, "maximized": maximized} - self.set("window_preferences", prefs) + """設定主視窗的大小、位置和最大化狀態。 + + Args: + width: 主視窗寬度。 + height: 主視窗高度。 + x: 主視窗左上角 X 座標,若不指定則保留為空。 + y: 主視窗左上角 Y 座標,若不指定則保留為空。 + maximized: 是否以最大化狀態儲存。 + """ + with self._lock: + prefs: dict[str, Any] = dict(self.get_window_preferences()) + prefs["main_window"] = {"width": width, "height": height, "x": x, "y": y, "maximized": maximized} + self.set("window_preferences", prefs) def is_auto_center_enabled(self) -> bool: """檢查是否啟用自動置中新視窗的功能""" diff --git a/src/utils/runtime_utils/singleton.py b/src/utils/runtime_utils/singleton.py index 0cf6ebb..0f53fe7 100644 --- a/src/utils/runtime_utils/singleton.py +++ b/src/utils/runtime_utils/singleton.py @@ -30,7 +30,7 @@ def guarded_init(self, *args: object, **kwargs: object) -> None: original_init(self, *args, **kwargs) self._initialized = True - cls.__init__ = guarded_init # type: ignore[method-assign] + type.__setattr__(cls, "__init__", guarded_init) def __new__(cls, *_args: object, **_kwargs: object) -> Singleton: with cls._instance_lock: diff --git a/src/utils/runtime_utils/subprocess_utils.py b/src/utils/runtime_utils/subprocess_utils.py index de0717c..12d781c 100644 --- a/src/utils/runtime_utils/subprocess_utils.py +++ b/src/utils/runtime_utils/subprocess_utils.py @@ -7,15 +7,39 @@ import os import subprocess # nosec B404 -from collections.abc import Iterable +import time +from collections.abc import Callable, Iterable +from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, cast + +from PySide6 import QtCore, QtWidgets from .. import PathUtils, get_logger logger = get_logger().bind(component="SubprocessUtils") +@dataclass(slots=True) +class QProcessResult: + """QProcess 執行結果封裝。""" + + args: list[str] + returncode: int + stdout: str = "" + pid: int = 0 + cancelled: bool = False + error_text: str = "" + + def poll(self) -> int: + """模擬 subprocess.CompletedProcess 的 poll 方法。 + + Returns: + QProcess 結束代碼。 + """ + return self.returncode + + class SubprocessUtils: """提供安全的 subprocess 包裝,強制使用 shell=False。""" @@ -73,7 +97,7 @@ def _validate_cmd(cmd: Iterable[str]) -> list[str]: def _normalize_subprocess_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: normalized_kwargs = dict(kwargs) if normalized_kwargs.get("shell", False): - logger.debug("忽略 shell=True,強制使用 shell=False for safety") + logger.debug("忽略 shell=True,基於安全考量強制使用 shell=False") normalized_kwargs["shell"] = False if (normalized_kwargs.get("text") or normalized_kwargs.get("universal_newlines")) and normalized_kwargs.get( "errors" @@ -115,6 +139,138 @@ def popen_checked(cmd: Iterable[str], **kwargs) -> subprocess.Popen: # Bandit B603: argv 已先驗證,且 wrapper 會強制 shell=False。 return subprocess.Popen(cmd_list, **kwargs) # nosec B603 + @staticmethod + def create_qprocess_checked( + cmd: Iterable[str], + *, + cwd: str | None = None, + merged_channels: bool = True, + parent: QtCore.QObject | None = None, + ) -> QtCore.QProcess: + """建立已驗證 argv 的 QProcess。 + + Args: + cmd: 命令列參數序列。 + cwd: 工作目錄;未提供時沿用目前程序工作目錄。 + merged_channels: 是否合併 stdout/stderr。 + parent: QProcess 的 Qt parent。 + + Returns: + 已設定 program、arguments 與 channel mode 的 QProcess。 + """ + + cmd_list = SubprocessUtils._validate_cmd(cmd) + process = QtCore.QProcess(parent) + process.setProgram(cmd_list[0]) + process.setArguments(cmd_list[1:]) + if cwd: + process.setWorkingDirectory(str(cwd)) + if merged_channels: + process.setProcessChannelMode(QtCore.QProcess.ProcessChannelMode.MergedChannels) + return process + + @staticmethod + def run_qprocess_checked( + cmd: Iterable[str], + *, + cwd: str | None = None, + encoding: str = "utf-8", + on_stdout: Callable[[str], Any] | None = None, + on_started: Callable[[int], Any] | None = None, + cancel_check: Callable[[], bool] | None = None, + cancel_poll_ms: int = 100, + timeout_ms: int | None = None, + ) -> QProcessResult: + """以 QProcess signal 同步執行命令並收集輸出。 + + Args: + cmd: 命令列參數序列。 + cwd: 工作目錄;未提供時沿用目前程序工作目錄。 + encoding: stdout 解碼使用的文字編碼。 + on_stdout: 每次收到 stdout 片段時呼叫的回呼。 + on_started: QProcess 啟動後以 PID 呼叫的回呼。 + cancel_check: 輪詢取消狀態的回呼。 + cancel_poll_ms: 取消與 stdout 輪詢間隔毫秒數。 + timeout_ms: 執行逾時毫秒數;未提供時不限制。 + + Returns: + QProcess 的結束代碼、輸出與取消狀態。 + """ + + app = QtWidgets.QApplication.instance() + if not isinstance(app, QtWidgets.QApplication): + app = QtWidgets.QApplication([]) + + process = SubprocessUtils.create_qprocess_checked(cmd, cwd=cwd) + stdout_chunks: list[str] = [] + state: dict[str, Any] = { + "returncode": -1, + "pid": 0, + "cancelled": False, + "error_text": "", + "finished": False, + } + + def _decode(data: QtCore.QByteArray) -> str: + return bytes(cast(Any, data)).decode(encoding, errors="replace") + + def _drain_stdout() -> None: + text = _decode(process.readAllStandardOutput()) + if not text: + return + stdout_chunks.append(text) + if on_stdout is not None: + on_stdout(text) + + def _finish(exit_code: int, _status: QtCore.QProcess.ExitStatus) -> None: + _drain_stdout() + state["returncode"] = int(exit_code) + state["finished"] = True + + def _error(_error: QtCore.QProcess.ProcessError) -> None: + state["error_text"] = process.errorString() + + process.readyReadStandardOutput.connect(_drain_stdout) + process.finished.connect(_finish) + process.errorOccurred.connect(_error) + process.start() + if not process.waitForStarted(10000): + raise OSError(process.errorString() or "QProcess 啟動失敗") + state["pid"] = int(process.processId()) + if on_started is not None: + on_started(int(state["pid"])) + + deadline = None if timeout_ms is None else time.monotonic() + max(0, int(timeout_ms)) / 1000 + poll_ms = max(25, int(cancel_poll_ms)) + while process.state() != QtCore.QProcess.ProcessState.NotRunning: + if cancel_check is not None: + try: + should_cancel = bool(cancel_check()) + except Exception: + should_cancel = False + if should_cancel: + state["cancelled"] = True + process.kill() + if deadline is not None and time.monotonic() >= deadline: + state["error_text"] = f"QProcess 執行逾時 ({timeout_ms} ms)" + process.kill() + if process.waitForReadyRead(poll_ms): + _drain_stdout() + else: + _drain_stdout() + process.waitForFinished(1000) + _drain_stdout() + if not state["finished"]: + state["returncode"] = int(process.exitCode()) + return QProcessResult( + args=SubprocessUtils._validate_cmd(cmd), + returncode=int(state["returncode"]), + stdout="".join(stdout_chunks), + pid=int(state["pid"]), + cancelled=bool(state["cancelled"]), + error_text=str(state["error_text"] or ""), + ) + @staticmethod def popen_detached(cmd: Iterable[str], cwd: str | None = None) -> subprocess.Popen: """ diff --git a/src/utils/runtime_utils/worker_pool.py b/src/utils/runtime_utils/worker_pool.py index 745cf73..8db67df 100644 --- a/src/utils/runtime_utils/worker_pool.py +++ b/src/utils/runtime_utils/worker_pool.py @@ -1,23 +1,25 @@ """受限背景工作池工具。 -集中管理檔案 I/O、壓縮與雜湊等高成本工作的共享 worker pool,避免各模組自行建立過多執行緒。 +集中管理檔案 I/O、壓縮與雜湊等高成本工作的共享 Qt 工作池,避免各模組自行建立執行緒。 """ from __future__ import annotations import asyncio import concurrent.futures -import functools import os -import threading from collections.abc import Callable from typing import Any, TypeVar +from PySide6 import QtCore + +from .background_task import BackgroundTaskManager + T = TypeVar("T") DEFAULT_WORKER_COUNT = 4 -_worker_pool_lock = threading.Lock() -_shared_worker_pool: concurrent.futures.ThreadPoolExecutor | None = None +_worker_pool_lock = QtCore.QMutex() +_shared_worker_pool: BackgroundTaskManager | None = None def resolve_worker_count(requested_workers: int | None = None) -> int: @@ -35,26 +37,23 @@ def resolve_worker_count(requested_workers: int | None = None) -> int: return max(1, min(desired, cpu_count)) -def get_shared_worker_pool() -> concurrent.futures.ThreadPoolExecutor: - """取得全專案共享的受限 worker pool。 +def get_shared_worker_pool() -> BackgroundTaskManager: + """取得全專案共享的受限工作池。 Returns: - 共用的 `ThreadPoolExecutor` 實例。 + 共用的 `BackgroundTaskManager` 實例。 """ global _shared_worker_pool if _shared_worker_pool is None: - with _worker_pool_lock: + with QtCore.QMutexLocker(_worker_pool_lock): if _shared_worker_pool is None: - _shared_worker_pool = concurrent.futures.ThreadPoolExecutor( - max_workers=resolve_worker_count(), - thread_name_prefix="msm_worker", - ) + _shared_worker_pool = BackgroundTaskManager(max_workers=resolve_worker_count()) return _shared_worker_pool def submit_to_worker_pool[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> concurrent.futures.Future[T]: - """將同步函式提交至共享 worker pool。 + """將同步函式提交至共享工作池。 Args: fn: 要執行的同步函式。 @@ -65,11 +64,11 @@ def submit_to_worker_pool[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> 已提交的 Future。 """ - return get_shared_worker_pool().submit(fn, *args, **kwargs) + return get_shared_worker_pool().run(fn, *args, **kwargs) async def run_blocking_io[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: - """在共享 worker pool 中執行阻塞 I/O 或高成本工作。 + """在共享工作池中執行阻塞 I/O 或高成本工作。 Args: fn: 要執行的同步函式。 @@ -80,20 +79,18 @@ async def run_blocking_io[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> 函式執行結果。 """ - loop = asyncio.get_running_loop() - call = functools.partial(fn, *args, **kwargs) - return await loop.run_in_executor(get_shared_worker_pool(), call) + return await asyncio.wrap_future(submit_to_worker_pool(fn, *args, **kwargs)) def shutdown_shared_worker_pool(*, wait: bool = True) -> None: - """關閉共享 worker pool,主要供測試或應用程式結束流程使用。 + """關閉共享工作池,主要供測試或應用程式結束流程使用。 Args: wait: 是否等待既有任務完成。 """ global _shared_worker_pool - with _worker_pool_lock: + with QtCore.QMutexLocker(_worker_pool_lock): pool = _shared_worker_pool _shared_worker_pool = None if pool is not None: diff --git a/src/utils/server_utils/server_properties_utils.py b/src/utils/server_utils/server_properties_utils.py index 5ceeaf8..770d31b 100644 --- a/src/utils/server_utils/server_properties_utils.py +++ b/src/utils/server_utils/server_properties_utils.py @@ -55,7 +55,7 @@ def get_property_descriptions(cls) -> types.MappingProxyType: "initial-enabled-packs": "建立世界時要啟用的數據包名稱 (逗號分隔)。", "level-name": "世界名稱及其資料夾名。 (預設: world) 也可用於讀取現有存檔。", "level-seed": "世界種子碼。留空則隨機生成。", - "level-type": "世界生成類型 ID。 (例如 minecraft:normal, minecraft:flat, minecraft:large_biomes, minecraft:amplified)", + "level-type": "世界生成類型 ID。可省略 minecraft: 前綴。 (minecraft:normal, minecraft:flat, minecraft:large_biomes, minecraft:amplified, minecraft:single_biome_surface)", "log-ips": "是否在伺服器日誌中記錄玩家 IP。 (true/false)", "max-chained-neighbor-updates": "限制連鎖方塊更新的數量。 (預設: 1000000) 負數為無限制。", "max-players": "伺服器最大玩家數量 (0-2147483647)。超過此數量新玩家無法加入 (OP除外,若設定允許)。", @@ -64,7 +64,7 @@ def get_property_descriptions(cls) -> types.MappingProxyType: "motd": "伺服器列表顯示的訊息。支援樣式代碼。", "network-compression-threshold": "網路壓縮閾值。 (預設: 256) 封包大於此位元組時進行壓縮。-1 為停用壓縮。", "online-mode": "是否啟用線上驗證 (正版驗證)。 (true/false) true - 需正版帳號登入。", - "op-permission-level": "OP 管理員的預設權限等級 (1-4)。 1:繞過重生保護 2:單人作弊指令 3:多人管理指令 4:所有指令。", + "op-permission-level": "OP 管理員的預設權限等級 (0-4)。", "pause-when-empty-seconds": "伺服器無人時自動停止計算的等待秒數。 (預設: 60) 負數為不停止。", "player-idle-timeout": "玩家閒置踢出時間 (分鐘)。 (預設: 0) 0 為不踢出。", "prevent-proxy-connections": "是否阻止代理/VPN 連接。 (false/true) 伺服器會驗證來源 IP 是否與 Mojang 驗證伺服器一致。", @@ -93,12 +93,12 @@ def get_property_descriptions(cls) -> types.MappingProxyType: "white-list": "是否啟用白名單。 (false/true) true - 只有 whitelist.json 中的玩家可加入。", "management-server-enabled": "是否啟用管理伺服器協定。", "management-server-host": "管理伺服器監聽的主機 (預設 localhost)。", - "management-server-port": "管理伺服器監聽的埠號 (預設 25585)。", - "management-server-secret": "管理伺服器使用的密鑰。", + "management-server-port": "管理伺服器監聽的埠號 (預設 0)。", + "management-server-secret": "管理伺服器使用的密鑰。留空時會由伺服器自動產生。", "management-server-tls-enabled": "是否啟用管理伺服器 TLS 加密。", "management-server-tls-keystore": "TLS 金鑰庫路徑。", "management-server-tls-keystore-password": "TLS 金鑰庫密碼。", - "management-server-allowed-origins": "管理伺服器允許的來源。", + "management-server-allowed-origins": "管理伺服器允許的來源清單。", } return types.MappingProxyType(cls._property_descriptions_cache) @@ -157,7 +157,6 @@ def get_property_categories() -> dict[str, list]: "enable-jmx-monitoring", "use-native-transport", "sync-chunk-writes", - "status-heartbeat-interval", ], "網路設定": [ "network-compression-threshold", @@ -186,6 +185,7 @@ def get_property_categories() -> dict[str, list]: "management-server-tls-keystore", "management-server-tls-keystore-password", "management-server-allowed-origins", + "status-heartbeat-interval", ], "效能設定": [ "view-distance", @@ -459,11 +459,12 @@ class ServerPropertiesValidator: "view-distance": ("int", 3, 32, None), "spawn-protection": ("int", 0, None, None), "player-idle-timeout": ("int", 0, None, None), - "pause-when-empty-seconds": ("int", -1, None, None), + "pause-when-empty-seconds": ("int", None, None, None), "rate-limit": ("int", 0, None, None), - "text-filtering-version": ("int", 0, None, None), + "text-filtering-version": ("int", 0, 1, None), "status-heartbeat-interval": ("int", 0, None, None), - "management-server-port": ("int", 0, None, None), + "management-server-port": ("int", 0, 65535, None), + "enable-code-of-conduct": ("bool", None, None, None), "accepts-transfers": ("bool", None, None, None), "allow-flight": ("bool", None, None, None), "allow-nether": ("bool", None, None, None), @@ -480,6 +481,8 @@ class ServerPropertiesValidator: "generate-structures": ("bool", None, None, None), "hardcore": ("bool", None, None, None), "hide-online-players": ("bool", None, None, None), + "management-server-enabled": ("bool", None, None, None), + "management-server-tls-enabled": ("bool", None, None, None), "log-ips": ("bool", None, None, None), "online-mode": ("bool", None, None, None), "prevent-proxy-connections": ("bool", None, None, None), @@ -489,6 +492,11 @@ class ServerPropertiesValidator: "sync-chunk-writes": ("bool", None, None, None), "use-native-transport": ("bool", None, None, None), "white-list": ("bool", None, None, None), + "management-server-host": ("str", None, None, None), + "management-server-secret": ("str", None, None, None), + "management-server-tls-keystore": ("str", None, None, None), + "management-server-tls-keystore-password": ("str", None, None, None), + "management-server-allowed-origins": ("str", None, None, None), "gamemode": ("enum", None, None, ["survival", "creative", "adventure", "spectator", "0", "1", "2", "3"]), "difficulty": ("enum", None, None, ["peaceful", "easy", "normal", "hard", "0", "1", "2", "3"]), "level-type": ( @@ -501,15 +509,14 @@ class ServerPropertiesValidator: "minecraft:large_biomes", "minecraft:amplified", "minecraft:single_biome_surface", - "default", + "normal", "flat", "large_biomes", "amplified", - "buffet", - "customized", + "single_biome_surface", ], ), - "region-file-compression": ("enum", None, None, ["deflate", "none"]), + "region-file-compression": ("enum", None, None, ["deflate", "lz4", "none"]), "bug-report-link": ("str", None, None, None), "generator-settings": ("str", None, None, None), "initial-disabled-packs": ("str", None, None, None), @@ -526,6 +533,19 @@ class ServerPropertiesValidator: "text-filtering-config": ("str", None, None, None), } + @classmethod + def is_boolean_property(cls, prop_name: str) -> bool: + """判斷指定屬性是否應以布林值處理。 + + Args: + prop_name: 屬性名稱。 + + Returns: + 若屬性是布林型設定則回傳 True。 + """ + rules = cls.VALIDATION_RULES.get(prop_name) + return bool(rules and rules[0] == "bool") + @staticmethod def validate_property(prop_name: str, value: str) -> tuple[bool, str]: """驗證單一屬性。 diff --git a/src/utils/ui_support/fluent.py b/src/utils/ui_support/fluent.py new file mode 100644 index 0000000..b8fcf0b --- /dev/null +++ b/src/utils/ui_support/fluent.py @@ -0,0 +1,156 @@ +"""PySide6 Fluent Widgets 整合工具。""" + +from __future__ import annotations + +import importlib +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from PySide6 import QtCore, QtWidgets + +from .ui_tokens import Colors + +FLUENT_AVAILABLE = False + +try: + _qfluentwidgets = importlib.import_module("qfluentwidgets") + _FluentLineEdit = _qfluentwidgets.LineEdit + _FluentProgressBar = _qfluentwidgets.ProgressBar + _FluentPushButton = _qfluentwidgets.PushButton + _FluentSearchLineEdit = _qfluentwidgets.SearchLineEdit + _Theme = _qfluentwidgets.Theme + _setTheme = _qfluentwidgets.setTheme + _setThemeColor = _qfluentwidgets.setThemeColor + + FLUENT_AVAILABLE = True +except Exception: + _FluentLineEdit = QtWidgets.QLineEdit + _FluentPushButton = QtWidgets.QPushButton + _FluentProgressBar = QtWidgets.QProgressBar + _Theme = None + _setTheme = None + _setThemeColor = None + + class _FallbackSearchLineEdit(QtWidgets.QLineEdit): + """相容 qfluentwidgets signal 的後備搜尋輸入框。""" + + searchSignal = QtCore.Signal(str) + clearSignal = QtCore.Signal() + + def __init__(self, parent: Any = None) -> None: + super().__init__(parent) + self.returnPressed.connect(self.search) + + def search(self) -> None: + self.searchSignal.emit(self.text()) + + def clear(self) -> None: + super().clear() + self.clearSignal.emit() + + def setClearButtonEnabled(self, enable: bool) -> None: + super().setClearButtonEnabled(enable) + + _FluentSearchLineEdit = _FallbackSearchLineEdit + + +FluentLineEdit = _FluentLineEdit +FluentPushButton = _FluentPushButton +FluentProgressBar = _FluentProgressBar +FluentSearchLineEdit = _FluentSearchLineEdit +Theme = _Theme +setTheme = _setTheme +setThemeColor = _setThemeColor + + +@dataclass(slots=True) +class SearchFilter: + """搜尋元件共用的文字篩選器。""" + + case_sensitive: bool = False + normalize_whitespace: bool = True + require_all_terms: bool = True + + def normalize(self, value: Any) -> str: + """正規化搜尋文字。 + + Args: + value: 要轉成搜尋字串的任意值。 + + Returns: + 正規化後的搜尋字串。 + """ + text = str(value or "").strip() + if self.normalize_whitespace: + text = re.sub(r"\s+", " ", text) + return text if self.case_sensitive else text.lower() + + def matches(self, candidate: Any, query: Any) -> bool: + """判斷候選文字是否符合查詢字串。 + + Args: + candidate: 被比對的候選值;可為字串、序列或 dict。 + query: 使用者輸入的查詢值。 + + Returns: + 候選值符合查詢時回傳 True。 + """ + normalized_query = self.normalize(query) + if not normalized_query: + return True + candidate_text = " ".join(self.normalize(value) for value in self._candidate_values(candidate)) + if not candidate_text: + return False + if not self.require_all_terms: + return normalized_query in candidate_text + return all(term in candidate_text for term in normalized_query.split()) + + def matches_any(self, candidates: Any, query: Any) -> bool: + """判斷多個候選欄位是否符合查詢。 + + Args: + candidates: 字串、序列或 dict 候選欄位。 + query: 使用者輸入的搜尋字串。 + + Returns: + 任一候選欄位符合查詢時回傳 True。 + """ + return self.matches(candidates, query) + + def _candidate_values(self, candidate: Any) -> list[Any]: + if isinstance(candidate, Mapping): + return list(candidate.values()) + if isinstance(candidate, (list, tuple, set, frozenset)): + return list(candidate) + return [candidate] + + +def apply_fluent_theme(*, dark: bool, accent_color: str | None = None) -> None: + """在 qfluentwidgets 可用時套用 Fluent 主題。 + + Args: + dark: 是否套用深色主題。 + accent_color: Fluent accent 色碼;未提供時使用專案主要按鈕色。 + """ + + if not FLUENT_AVAILABLE or Theme is None or setTheme is None: + return + try: + setTheme(Theme.DARK if dark else Theme.LIGHT) + if setThemeColor is not None: + setThemeColor(accent_color or Colors.BUTTON_PRIMARY[0]) + except Exception: + return + + +__all__ = [ + "FLUENT_AVAILABLE", + "FluentLineEdit", + "FluentProgressBar", + "FluentPushButton", + "FluentSearchLineEdit", + "SearchFilter", + "apply_fluent_theme", +] diff --git a/src/utils/ui_support/qt_runtime.py b/src/utils/ui_support/qt_runtime.py index 1cd17c2..988f1a7 100644 --- a/src/utils/ui_support/qt_runtime.py +++ b/src/utils/ui_support/qt_runtime.py @@ -3,7 +3,6 @@ from __future__ import annotations import sys -import threading from collections.abc import Callable from typing import Any, cast @@ -11,7 +10,7 @@ shiboken_is_valid: Callable[[Any], bool] | None -try: # pragma: no cover - depends on PySide6 binary package details. +try: from shiboken6 import isValid as shiboken_is_valid except ImportError: shiboken_is_valid = None @@ -111,6 +110,33 @@ def cancel_timer(timer: Any) -> None: return +class _OpenUrlClickFilter(QtCore.QObject): + """把滑鼠點擊轉成開啟外部網址的 Qt event filter。""" + + def __init__(self, url: str, parent: QtCore.QObject | None = None) -> None: + super().__init__(parent) + self._url = str(url) + + def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool: + if event.type() == QtCore.QEvent.Type.MouseButtonRelease: + QtGui.QDesktopServices.openUrl(QtCore.QUrl(self._url)) + return True + return super().eventFilter(watched, event) + + +def install_open_url_click(widget: QtWidgets.QWidget, url: str) -> None: + """讓 widget 被點擊時開啟指定外部網址。 + + Args: + widget: 要安裝點擊處理器的 Qt widget。 + url: 點擊後要開啟的外部網址。 + """ + + click_filter = _OpenUrlClickFilter(url, widget) + widget.installEventFilter(click_filter) + cast(Any, widget)._msm_open_url_click_filter = click_filter + + class _UiDispatcher(QtCore.QObject): dispatched = QtCore.Signal(object) @@ -120,13 +146,13 @@ def __init__(self) -> None: @QtCore.Slot(object) def _run(self, payload: object) -> None: - func, done, result = cast(tuple[Callable[[], Any], threading.Event, dict[str, Any]], payload) + func, done, result = cast(tuple[Callable[[], Any], QtCore.QSemaphore, dict[str, Any]], payload) try: result["value"] = func() except Exception as exc: result["exc"] = exc finally: - done.set() + done.release() def run_on_ui_thread(func: Callable[[], Any], timeout: float | None = None) -> Any: @@ -140,17 +166,19 @@ def run_on_ui_thread(func: Callable[[], Any], timeout: float | None = None) -> A callable 的回傳值。 """ app = ensure_application() - if QtCore.QThread.currentThread() is app.thread() or threading.current_thread() is threading.main_thread(): + if QtCore.QThread.currentThread() is app.thread(): return func() global _dispatcher if _dispatcher is None or not is_qobject_alive(_dispatcher): _dispatcher = _UiDispatcher() _dispatcher.moveToThread(app.thread()) - done = threading.Event() + done = QtCore.QSemaphore(0) result: dict[str, Any] = {"value": None, "exc": None} _dispatcher.dispatched.emit((func, done, result)) - if not done.wait(timeout=timeout): + if timeout is None: + done.acquire() + elif not done.tryAcquire(1, max(0, int(timeout * 1000))): raise TimeoutError(f"UI 任務等待逾時 ({timeout}秒)") if result["exc"] is not None: raise result["exc"] @@ -249,6 +277,7 @@ def set_topmost(window: QtWidgets.QWidget, enabled: bool) -> None: "ValueState", "cancel_timer", "ensure_application", + "install_open_url_click", "invoke_later", "is_qobject_alive", "run_on_ui_thread", diff --git a/src/utils/ui_support/qt_widgets.py b/src/utils/ui_support/qt_widgets.py index 9af41a0..f3bea50 100644 --- a/src/utils/ui_support/qt_widgets.py +++ b/src/utils/ui_support/qt_widgets.py @@ -12,6 +12,7 @@ from PySide6 import QtCore, QtGui, QtWidgets from .. import get_logger +from .fluent import FluentLineEdit, FluentProgressBar, FluentPushButton, FluentSearchLineEdit, SearchFilter from .qt_runtime import ValueState, is_qobject_alive logger = get_logger().bind(component="QtWidgets") @@ -30,6 +31,7 @@ X = "x" Y = "y" INVALID_MODEL_INDEX = QtCore.QModelIndex() +_QAbstractItemModel: Any = QtCore.QAbstractItemModel def ensure_app(): @@ -826,9 +828,13 @@ def __init__(self, parent: Any = None, text: str = "", **kwargs: Any) -> None: self.setAlignment(_align(kwargs.get("anchor"))) -class Button(WidgetMixin, QtWidgets.QPushButton): +class Button(WidgetMixin, FluentPushButton): def __init__(self, parent: Any = None, text: str = "", command: Callable[..., Any] | None = None, **kwargs: Any): - QtWidgets.QPushButton.__init__(self, str(text), _native_parent(parent)) + try: + FluentPushButton.__init__(self, str(text), _native_parent(parent)) + except TypeError: + FluentPushButton.__init__(self, _native_parent(parent)) + self.setText(str(text)) self._command = command if command is not None: self.clicked.connect(self._invoke_command) @@ -851,9 +857,9 @@ def configure(self, **kwargs: Any) -> None: super().configure(**kwargs) -class Entry(WidgetMixin, QtWidgets.QLineEdit): +class Entry(WidgetMixin, FluentLineEdit): def __init__(self, parent: Any = None, textvariable: Variable | None = None, **kwargs: Any) -> None: - QtWidgets.QLineEdit.__init__(self, _native_parent(parent)) + FluentLineEdit.__init__(self, _native_parent(parent)) self._variable = textvariable if textvariable is not None: self.setText(str(textvariable.get())) @@ -879,6 +885,61 @@ def select_range(self, start: int, end: Any) -> None: self.setSelection(int(start), max(0, length)) +class SearchEntry(WidgetMixin, FluentSearchLineEdit): + """SearchLineEdit wrapper with project state binding and filter logic.""" + + def __init__( + self, + parent: Any = None, + textvariable: Variable | None = None, + search_command: Callable[..., Any] | None = None, + filter_logic: SearchFilter | None = None, + **kwargs: Any, + ) -> None: + FluentSearchLineEdit.__init__(self, _native_parent(parent)) + self._variable = textvariable + self._search_command = search_command + self.filter_logic = filter_logic or SearchFilter() + if hasattr(self, "setClearButtonEnabled"): + with context_suppress(): + self.setClearButtonEnabled(True) + if textvariable is not None: + self.setText(str(textvariable.get())) + self.textChanged.connect(textvariable.set) + textvariable.trace_add("write", lambda *_: self._sync_from_variable()) + with context_suppress(): + self.searchSignal.connect(self._on_search_signal) + with context_suppress(): + self.clearSignal.connect(self._on_clear_signal) + self._init_native(parent, **kwargs) + + def _sync_from_variable(self) -> None: + if self._variable is None: + return + value = str(self._variable.get()) + if self.text() != value: + self.setText(value) + + def _on_search_signal(self, *_args: Any) -> None: + if self._search_command is not None: + self._search_command() + self._dispatch_event("search") + + def _on_clear_signal(self, *_args: Any) -> None: + if self._variable is not None: + self._variable.set("") + self._dispatch_event("clear") + + def get(self) -> str: + return self.text() + + def filter_text(self) -> str: + return self.filter_logic.normalize(self.text()) + + def matches(self, candidate: Any) -> bool: + return self.filter_logic.matches(candidate, self.text()) + + class TextBox(WidgetMixin, QtWidgets.QTextEdit): def __init__(self, parent: Any = None, **kwargs: Any) -> None: QtWidgets.QTextEdit.__init__(self, _native_parent(parent)) @@ -1002,15 +1063,61 @@ def get(self) -> float: return self.value() / self._scale -class ProgressBar(WidgetMixin, QtWidgets.QProgressBar): +class _ProgressSignalProxy(QtCore.QObject): + value_requested = QtCore.Signal(float) + + +class ProgressBar(WidgetMixin, FluentProgressBar): def __init__(self, parent: Any = None, **kwargs: Any) -> None: - QtWidgets.QProgressBar.__init__(self, _native_parent(parent)) + FluentProgressBar.__init__(self, _native_parent(parent)) self.setRange(0, 100) + self.setTextVisible(True) + self.setFormat("%p%") + self.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._progress_signal_proxy = _ProgressSignalProxy(self) + self._progress_signal_proxy.value_requested.connect(self._apply_progress_value) self._init_native(parent, **kwargs) def set(self, value: float) -> None: + self._progress_signal_proxy.value_requested.emit(float(value)) + + @QtCore.Slot(float) + def _apply_progress_value(self, value: float) -> None: self.setValue(int(float(value) * 100 if float(value) <= 1 else float(value))) + def paintEvent(self, event: Any) -> None: + super().paintEvent(event) + if not self.isTextVisible(): + return + text = self.text() + if not text: + return + + painter = QtGui.QPainter(self) + try: + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) + painter.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing, True) + + font = QtGui.QFont(self.font()) + font_size = max(8, min(12, max(8, int(self.height() * 0.58)))) + if font.pointSize() > 0: + font.setPointSize(font_size) + else: + font.setPixelSize(font_size) + painter.setFont(font) + + rect = self.rect().adjusted(4, 0, -4, 0) + alignment = QtCore.Qt.AlignmentFlag.AlignCenter + shadow = QtGui.QColor(0, 0, 0, 180) + text_color = QtGui.QColor("#f8fafc") if is_dark_color_scheme() else QtGui.QColor("#0f172a") + painter.setPen(shadow) + painter.drawText(rect.translated(1, 1), alignment, text) + painter.drawText(rect.translated(-1, 0), alignment, text) + painter.setPen(text_color) + painter.drawText(rect, alignment, text) + finally: + painter.end() + def stop(self) -> None: return None @@ -1102,7 +1209,7 @@ def foreground(self, _column: int) -> QtGui.QBrush: return QtGui.QBrush(QtGui.QColor(color)) if color else QtGui.QBrush() -class _TreeModel(QtCore.QAbstractItemModel): +class _TreeModel(_QAbstractItemModel): def __init__(self, columns: list[str], tag_styles: dict[str, dict[str, str]], parent: Any = None) -> None: super().__init__(parent) self.columns = list(columns) @@ -1147,7 +1254,10 @@ def index( except IndexError: return QtCore.QModelIndex() - def parent(self, index: QtCore.QModelIndex) -> QtCore.QModelIndex: # type: ignore[override] + def parent( + self, + index: QtCore.QModelIndex | QtCore.QPersistentModelIndex, + ) -> QtCore.QModelIndex: if not index.isValid(): return QtCore.QModelIndex() row = self._row_from_index(index) @@ -1577,7 +1687,7 @@ def set(self, item: str, column: str, value: Any = None): self._model.set_cell(str(item), idx, value) return None - def move(self, item: str, _parent: str = "", index: int = 0) -> None: # type: ignore[override] + def move(self, item: Any, _parent: Any = "", index: Any = 0) -> None: self._model.move_item(str(item), str(_parent or ""), int(index)) def index(self, item: str) -> int: @@ -1782,7 +1892,7 @@ def see(self, index: int) -> None: if item is not None: self.scrollToItem(item) - def size(self) -> int: # type: ignore[override] + def size(self) -> Any: return self.count() def get(self, index: int) -> str: diff --git a/src/utils/ui_support/ui_utils.py b/src/utils/ui_support/ui_utils.py index cd95aa6..6bb437b 100644 --- a/src/utils/ui_support/ui_utils.py +++ b/src/utils/ui_support/ui_utils.py @@ -3,7 +3,6 @@ """ import os -import threading import time import webbrowser from collections.abc import Callable @@ -18,6 +17,11 @@ logger = get_logger().bind(component="UIUtils") +def _is_ui_thread() -> bool: + app = QtWidgets.QApplication.instance() + return app is None or QtCore.QThread.currentThread() is app.thread() + + def get_button_style(button_type: str = "primary") -> dict[str, tuple[str, str]]: """取得按鈕樣式配置 @@ -392,7 +396,7 @@ def show_error(title: str = "錯誤", message: str = "發生未知錯誤", paren parent: 父視窗。 topmost: 是否置頂。 """ - if threading.current_thread() is threading.main_thread(): + if _is_ui_thread(): DialogUtils.show_error(title, message, parent, topmost) return run_on_ui_thread(lambda: DialogUtils.show_error(title, message, parent, topmost), timeout=None) @@ -407,7 +411,7 @@ def show_warning(title: str = "警告", message: str = "警告訊息", parent=No parent: 父視窗。 topmost: 是否置頂。 """ - if threading.current_thread() is threading.main_thread(): + if _is_ui_thread(): DialogUtils.show_warning(title, message, parent, topmost) return run_on_ui_thread(lambda: DialogUtils.show_warning(title, message, parent, topmost), timeout=None) @@ -422,7 +426,7 @@ def show_info(title: str = "資訊", message: str = "資訊訊息", parent=None, parent: 父視窗。 topmost: 是否置頂。 """ - if threading.current_thread() is threading.main_thread(): + if _is_ui_thread(): DialogUtils.show_info(title, message, parent, topmost) return run_on_ui_thread(lambda: DialogUtils.show_info(title, message, parent, topmost), timeout=None) @@ -520,7 +524,7 @@ def ask_yes_no_cancel( Returns: 使用者選擇結果,或在無法判斷時回傳 None。 """ - if threading.current_thread() is threading.main_thread(): + if _is_ui_thread(): return DialogUtils.ask_yes_no_cancel(title, message, parent, show_cancel, topmost) return run_on_ui_thread( lambda: DialogUtils.ask_yes_no_cancel(title, message, parent, show_cancel, topmost), timeout=None diff --git a/src/utils/update_utils/update_checker.py b/src/utils/update_utils/update_checker.py index c0ecad4..5cab4e2 100644 --- a/src/utils/update_utils/update_checker.py +++ b/src/utils/update_utils/update_checker.py @@ -7,7 +7,6 @@ import shutil import sys import tempfile -import threading import time from collections.abc import Callable from pathlib import Path @@ -16,7 +15,8 @@ from packaging.version import Version from .. import HTTPUtils, PathUtils, RuntimePaths, SubprocessUtils, UpdateParsing, get_logger -from ..ui_support.qt_runtime import is_qobject_alive +from ..runtime_utils.background_task import run_in_background +from ..ui_support.qt_runtime import invoke_later, is_qobject_alive logger = get_logger().bind(component="UpdateChecker") _UpdateResultT = TypeVar("_UpdateResultT") @@ -97,8 +97,7 @@ class _DirectUpdateCheckerInteraction: """沒有 UI adapter 時使用的安全後備互動實作。""" def run_async(self, work: Callable[[], None]) -> None: - thread = threading.Thread(target=work, daemon=True, name="UpdateChecker") - thread.start() + run_in_background(work) def call_on_ui(self, parent: Any, callback: Callable[[], _UpdateResultT]) -> _UpdateResultT: _ = parent @@ -118,11 +117,9 @@ def schedule_debounce( if alive: return widget.schedule(max(0, int(delay_ms)), callback) except Exception: - logger.debug("使用 widget.schedule 安排工作失敗,將回退到 threading.Timer", exc_info=True) - timer = threading.Timer(max(0, int(delay_ms)) / 1000, callback) - timer.daemon = True - timer.start() - return timer + logger.debug("使用 widget.schedule 安排工作失敗,將回退到 QTimer", exc_info=True) + parent = widget if is_qobject_alive(widget) else None + return invoke_later(max(0, int(delay_ms)), callback, parent=parent) def ask_yes_no_cancel(self, title: str, message: str, **kwargs: Any) -> bool | None: _ = (message, kwargs) @@ -163,117 +160,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 +193,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 +213,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 +394,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 +459,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 +503,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 +534,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, ), @@ -872,15 +554,40 @@ def _verify_file_checksum(path: Path, algorithm: str, hex_checksum: str) -> bool temp_path = tmp.name 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}") + download_failure_reason = "" + + def _capture_download_failure(message: str) -> None: + nonlocal download_failure_reason + download_failure_reason = message + + if HTTPUtils.download_file( + download_url, + str(dest), + failure_message_callback=_capture_download_failure, + ): + 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) @@ -896,15 +603,21 @@ def _verify_file_checksum(path: Path, algorithm: str, hex_checksum: str) -> bool ) time.sleep(2) logger.info("準備關閉當前程式以完成更新") - UpdateChecker._graceful_exit(parent, interaction=update_interaction) else: + failure_message = download_failure_reason or "無法下載安裝程式。" + logger.warning(f"[下載失敗] {failure_message}") TaskUtils.call_on_ui( parent, lambda: UIUtils.show_error( - "下載失敗", "無法下載安裝檔,請稍後再試。", parent=parent, topmost=True + "下載失敗", + failure_message, + parent=parent, + topmost=True, ), ) _cleanup_temp_files(temp_files_to_cleanup) + return + UpdateChecker._graceful_exit(parent, interaction=update_interaction) except Exception as e: logger.exception(f"更新檢查失敗: {e}") error_msg = str(e) 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/src/version_info/version_info.py b/src/version_info/version_info.py index be49ab0..564e627 100644 --- a/src/version_info/version_info.py +++ b/src/version_info/version_info.py @@ -4,7 +4,7 @@ Defines application version, name and related system constants """ -APP_VERSION = "1.7.1" +APP_VERSION = "1.7.2" APP_NAME = "Minecraft Server Manager" APP_DESCRIPTION = "Minecraft 伺服器管理器" GITHUB_OWNER = "Colin955023" diff --git a/tests/test_create_server_name_integration.py b/tests/test_create_server_name_integration.py index 101548d..8f84880 100644 --- a/tests/test_create_server_name_integration.py +++ b/tests/test_create_server_name_integration.py @@ -32,17 +32,6 @@ def set(self, value: str) -> None: self.selected = value -class _NoopThread: - def __init__(self, target=None, args=(), daemon=None) -> None: - self.target = target - self.args = args - self.daemon = daemon - - def start(self) -> None: - # 測試命名流程時不需要真正啟動背景載入。 - return - - def _make_frame( name: str, loader_type: str = "Vanilla", mc_version: str = "1.21.1" ) -> create_server_module.CreateServerFrame: @@ -58,7 +47,7 @@ def _make_frame( def test_server_name_keeps_manual_suffix_when_switching_loader(monkeypatch) -> None: - monkeypatch.setattr(create_server_module.threading, "Thread", _NoopThread) + monkeypatch.setattr(create_server_module.TaskUtils, "run_async", lambda *_args, **_kwargs: None) frame = _make_frame("1.21.1 我的服") frame.old_mc_version = "1.21.1" @@ -76,7 +65,7 @@ def test_server_name_keeps_manual_suffix_when_switching_loader(monkeypatch) -> N def test_server_name_keeps_manual_suffix_when_mc_version_changes(monkeypatch) -> None: - monkeypatch.setattr(create_server_module.threading, "Thread", _NoopThread) + monkeypatch.setattr(create_server_module.TaskUtils, "run_async", lambda *_args, **_kwargs: None) frame = _make_frame("Fabric 1.21.1 我的服", loader_type="Fabric", mc_version="1.20.6") frame.old_mc_version = "1.21.1" diff --git a/tests/test_http_utils_download.py b/tests/test_http_utils_download.py index 9d65699..21bacf2 100644 --- a/tests/test_http_utils_download.py +++ b/tests/test_http_utils_download.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from types import SimpleNamespace import src.utils.network_utils.http_utils as http_utils_module from src.utils import HTTPUtils @@ -33,6 +34,11 @@ def get(self, *_args, **_kwargs) -> _FakeResponse: return _FakeResponse(self.payload) +class _TimeoutSession: + def get(self, *_args, **_kwargs): + raise http_utils_module.requests.exceptions.Timeout("timed out") + + def test_download_file_without_expected_hash_skips_hashing(tmp_path, monkeypatch) -> None: target = tmp_path / "server.jar" monkeypatch.setattr(http_utils_module._rate_limiter, "wait", lambda _domain: None) @@ -47,6 +53,46 @@ def _unexpected_hash(_algorithm: str): assert target.read_bytes() == b"new-bytes" +def test_download_file_reports_insufficient_disk_space(tmp_path, monkeypatch) -> None: + target = tmp_path / "server.jar" + failure_messages: list[str] = [] + monkeypatch.setattr(http_utils_module._rate_limiter, "wait", lambda _domain: None) + monkeypatch.setattr(HTTPUtils, "_get_session", classmethod(lambda _cls: _FakeSession(b"new-bytes"))) + monkeypatch.setattr( + http_utils_module.shutil, + "disk_usage", + lambda _path: SimpleNamespace(total=10, used=9, free=1), + ) + + assert ( + HTTPUtils.download_file( + "https://example.com/server.jar", + str(target), + failure_message_callback=failure_messages.append, + ) + is False + ) + assert failure_messages and "磁碟空間不足" in failure_messages[0] + assert not target.exists() + + +def test_download_file_reports_timeout_reason(tmp_path, monkeypatch) -> None: + target = tmp_path / "server.jar" + failure_messages: list[str] = [] + monkeypatch.setattr(http_utils_module._rate_limiter, "wait", lambda _domain: None) + monkeypatch.setattr(HTTPUtils, "_get_session", classmethod(lambda _cls: _TimeoutSession())) + + assert ( + HTTPUtils.download_file( + "https://example.com/server.jar", + str(target), + failure_message_callback=failure_messages.append, + ) + is False + ) + assert failure_messages and "逾時" in failure_messages[0] + + def test_download_file_keeps_existing_target_when_replace_fails(tmp_path, monkeypatch) -> None: target = tmp_path / "server.jar" target.write_bytes(b"old-bytes") diff --git a/tests/test_loader_manager_smoke.py b/tests/test_loader_manager_smoke.py index 67321c8..43e1d24 100644 --- a/tests/test_loader_manager_smoke.py +++ b/tests/test_loader_manager_smoke.py @@ -107,8 +107,9 @@ def test_preload_forge_versions_uses_numeric_sort_for_versions(tmp_path: Path, m assert cache.get("1.21.1", [])[:3] == ["1.21.1-54.0.10", "1.21.1-54.0.9", "1.21.1-54.0.2"] -def test_get_installer_download_url_supports_known_loaders() -> None: +def test_get_installer_download_url_supports_known_loaders(monkeypatch: pytest.MonkeyPatch) -> None: manager = LoaderManager.__new__(LoaderManager) + monkeypatch.setattr(LoaderManager, "_get_latest_quilt_installer_version", staticmethod(lambda: "0.12.1")) assert manager.get_installer_download_url("fabric", "1.21.1", "0.16.0") == ( "https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.1.1/fabric-installer-1.1.1.jar" @@ -205,9 +206,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] = [] @@ -237,17 +235,13 @@ def test_download_and_run_installer_cleans_process_when_cancelled(monkeypatch: p monkeypatch.setattr(manager, "_download_file_with_progress", lambda *_args, **_kwargs: True) - class _Stdout: - def readline(self) -> str: - return "Downloading installer...\n" - class _Process: pid = 4321 - returncode = None - stdout = _Stdout() + returncode = -1 + cancelled = True def poll(self): - return None + return self.returncode cleaned: list[tuple[str, object]] = [] @@ -260,7 +254,7 @@ def _record_path_cleanup(path: object) -> bool: return True monkeypatch.setattr( - "src.core.loader_manager.SubprocessUtils.popen_checked", + "src.core.loader_manager.SubprocessUtils.run_qprocess_checked", lambda *_args, **_kwargs: _Process(), ) monkeypatch.setattr( 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_management_list_smoke.py b/tests/test_mod_management_list_smoke.py index 5bf6360..5b64471 100644 --- a/tests/test_mod_management_list_smoke.py +++ b/tests/test_mod_management_list_smoke.py @@ -2024,6 +2024,82 @@ def test_build_local_update_review_key_falls_back_to_file_path_when_project_id_m assert key == "local::C:/servers/demo/mods/unknown-mod.jar" +def test_prepare_local_update_review_entries_uses_fallback_root_key_when_project_id_missing(monkeypatch) -> None: + frame = mod_management_module.ModManagementFrame.__new__(mod_management_module.ModManagementFrame) + monkeypatch.setattr(frame, "_get_current_modrinth_context", lambda: ("1.21.1", "fabric", "")) + monkeypatch.setattr(frame, "_get_current_installed_mods", list) + monkeypatch.setattr(frame, "_dedupe_review_messages", list) + monkeypatch.setattr(frame, "_apply_review_advisory_enabled_overrides", lambda *_args, **_kwargs: None) + monkeypatch.setattr(frame, "_append_enabled_dependency_simulations", lambda *_args, **_kwargs: None) + monkeypatch.setattr(frame, "_append_simulated_installed_mod", lambda *_args, **_kwargs: None) + monkeypatch.setattr(frame, "_build_installed_mod_simulation_item", lambda *_args, **_kwargs: SimpleNamespace()) + + candidate = SimpleNamespace( + project_id="", + project_name="Unknown Mod", + filename="unknown-mod.jar", + local_mod=SimpleNamespace(file_path="C:/servers/demo/mods/unknown-mod.jar"), + actionable=True, + hard_errors=[], + current_issues=[], + dependency_issues=[], + notes=[], + update_available=False, + ) + + review_entries = frame._prepare_local_update_review_entries( + SimpleNamespace(candidates=[candidate]), + root_enabled_overrides={"local::C:/servers/demo/mods/unknown-mod.jar": False}, + ) + + assert len(review_entries) == 1 + assert review_entries[0].enabled is False + + +def test_load_local_mods_discards_stale_scan_results(monkeypatch, tmp_path: Path) -> None: + frame = mod_management_module.ModManagementFrame.__new__(mod_management_module.ModManagementFrame) + server_a = SimpleNamespace(name="server-a", path=str(tmp_path / "server-a")) + server_b = SimpleNamespace(name="server-b", path=str(tmp_path / "server-b")) + sentinel_mods = [SimpleNamespace(filename="sentinel.jar")] + enhancement_calls: list[str] = [] + queued_items: list[Any] = [] + + frame.current_server = server_a + frame.mod_manager = SimpleNamespace() + frame.local_mods = sentinel_mods + frame.enhanced_mods_cache = {"keep": object()} + frame.update_status_safe = lambda _message: None + frame.update_progress_safe = lambda _value: None + frame.refresh_local_list = lambda: queued_items.append("refresh_local_list") + frame.ui_queue = SimpleNamespace(put=lambda item: queued_items.append(item)) + frame.enhance_local_mods = lambda: enhancement_calls.append("called") + + def _scan_mods() -> list[Any]: + frame.current_server = server_b + return [ + SimpleNamespace( + filename="example.jar", + status=mod_management_module.ModStatus.ENABLED, + file_path=str(tmp_path / "server-a" / "mods" / "example.jar"), + name="Example Mod", + author="Example", + description="Example description", + version="1.0.0", + loader_type="fabric", + ) + ] + + frame.mod_manager.scan_mods = _scan_mods + monkeypatch.setattr(mod_management_module.TaskUtils, "run_async", lambda task, **_kwargs: task()) + + mod_management_module.LocalModListPresenter(frame).load_local_mods() + + assert frame.local_mods is sentinel_mods + assert frame.enhanced_mods_cache == {"keep": frame.enhanced_mods_cache["keep"]} + assert enhancement_calls == [] + assert queued_items == [] + + def test_resolve_pending_install_review_project_page_url_prefers_homepage_url() -> None: review_entry = mod_management_module.PendingInstallReviewEntry( pending=mod_management_module.PendingOnlineInstall( 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 "