From 8430cac7d0f32273cb950139f3dda2c340023084 Mon Sep 17 00:00:00 2001 From: Colin955023 <123829404+Colin955023@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:22:42 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=E6=94=B6=E6=96=82=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E5=B7=A5=E5=85=B7=E5=85=A5=E5=8F=A3=E3=80=81=E8=A7=A3?= =?UTF-8?q?=E8=80=A6=E5=AE=B9=E9=87=8F=E8=88=87=E5=AE=8C=E6=95=B4=E6=80=A7?= =?UTF-8?q?=E6=9C=8D=E5=8B=99=E4=B8=A6=E6=9C=80=E4=BD=B3=E5=8C=96=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工具入口收斂:檔案/磁碟操作統一至 SafeFileSystem、行程監控改經 IServerRuntime.GetWorkingSet64()、UI 互動統一至 IUiService。 - 架構解耦:抽取 IServerDirectorySizeService 與 IServerIntegrityService,移除孤兒型別。 - UI 與效能最佳化:伺服器資訊區改為橫向平均分攤、補齊 DataGrid 行虛擬化回收與字串陣列快取。 - 編碼與品質保證:修復文字編碼異常,全數通過 482 項測試且 Release 建置 0 警告 0 錯誤。 --- .gitignore | 2 - CHANGELOG.md | 26 +- README.md | 4 +- docs/TECHNICAL_OVERVIEW.md | 14 +- docs/USER_GUIDE.md | 8 +- src/MinecraftServerManager.App/App.xaml.cs | 54 +- .../AppCompositionRoot.cs | 14 +- .../MainWindow.xaml.cs | 14 +- .../Services/IUiService.cs | 4 +- .../Services/NoOpUiService.cs | 2 +- .../Services/WpfUiService.cs | 26 +- .../ViewModels/AboutPreferencesViewModel.cs | 16 +- .../ViewModels/CreateServerViewModel.cs | 5 +- .../ViewModels/MainViewModel.cs | 12 +- .../ViewModels/ManageJavaViewModel.cs | 3 +- .../ViewModels/ManageServerViewModel.cs | 533 +++++++++--------- .../ViewModels/ModsViewModel.cs | 14 +- .../ViewModels/PlayerManagementViewModel.cs | 12 +- .../ViewModels/RestoreBackupViewModel.cs | 2 +- .../ViewModels/ServerMemoryViewModel.cs | 4 - .../ViewModels/ServerMonitorViewModel.cs | 50 +- .../ViewModels/ServerPropertiesViewModel.cs | 25 - .../Views/AboutPreferencesView.xaml | 16 +- .../Views/CreateServerView.xaml | 22 +- .../Views/ImportServerDialog.xaml | 2 +- .../Views/ManageJavaDialog.xaml | 2 + .../Views/ManageServerView.xaml | 98 +++- .../Views/ModInstallQueueDialog.xaml | 4 +- .../Views/ModUpdatesDialog.xaml | 14 +- .../Views/ModUpdatesDialog.xaml.cs | 9 +- .../Views/ModVersionPickerDialog.xaml | 4 +- .../Views/ModsView.xaml | 6 +- .../Views/NotificationDialog.xaml.cs | 39 +- .../Views/OperationProgressDialog.xaml.cs | 22 +- .../Views/PlayerManagementDialog.xaml | 12 +- .../Views/RestoreBackupDialog.xaml | 4 +- .../Views/ServerInitializationDialog.xaml | 2 +- .../Views/ServerInitializationDialog.xaml.cs | 60 +- .../Views/ServerMemoryDialog.xaml | 4 +- .../Views/ServerMonitorWindow.xaml | 8 +- .../Views/ServerMonitorWindow.xaml.cs | 14 +- .../Views/ToastWindow.xaml.cs | 26 +- .../Ports/IAppUpdateService.cs | 4 +- .../Ports/IHttpPort.cs | 2 +- .../Ports/IMinecraftJavaRequirementService.cs | 4 +- .../Ports/IServerDirectorySizeService.cs | 28 + .../Ports/IServerIntegrityService.cs | 19 + .../Ports/IServerManager.cs | 2 +- .../Ports/IServerRuntime.cs | 5 + .../Ports/ProcessPort.cs | 8 + .../Servers/ServerPropertiesValidator.cs | 10 +- .../Utilities/MinecraftVersionSemantics.cs | 2 +- .../Mods/FlexVersion.cs | 6 +- .../Mods/LocalModUpdateCandidate.cs | 23 - .../Servers/ProgressEvent.cs | 13 - .../FileSystem/SafeFileSystem.cs | 339 ++++++++++- .../FileSystem/SafeZipArchive.cs | 107 +++- .../FileSystem/ServerTombstoneManager.cs | 16 +- .../Http/HttpDownloadClient.cs | 426 ++++++-------- .../Java/JavaRuntimeDetector.cs | 35 +- .../Java/MinecraftJavaRequirementService.cs | 66 +-- .../Loaders/LoaderCatalogService.cs | 38 +- .../Loaders/LoaderInstallerService.cs | 34 +- .../Logging/RotatingFileLoggerProvider.cs | 17 +- .../Mods/LocalModScanner.cs | 16 +- .../Mods/ModFileInstaller.cs | 32 +- .../Mods/ModIndexPersistence.cs | 18 +- .../Mods/ModListExporter.cs | 15 +- .../Mods/ModrinthClient.cs | 10 +- .../Process/ProcessRunner.cs | 83 ++- .../Serialization/AppJsonSerializerContext.cs | 2 +- .../Servers/SafeScriptValidator.cs | 8 +- .../Servers/ServerBackupService.cs | 191 +++---- .../Servers/ServerDirectorySizeService.cs | 136 +++++ .../Servers/ServerInspector.cs | 136 ++--- .../Servers/ServerIntegrityService.cs | 132 +++++ .../Servers/ServerLaunchPlanner.cs | 2 +- .../Servers/ServerManager.cs | 117 ++-- .../Servers/ServerPlayerConfigStore.cs | 30 +- .../ServerPropertiesMigrationService.cs | 4 +- .../Servers/ServerPropertiesStore.cs | 10 +- .../Servers/ServerRuntime.cs | 1 + .../Servers/ServerStartupTargetResolver.cs | 12 +- .../Services/AppUpdateService.cs | 189 ++++--- .../Services/UpdateCheckerService.cs | 10 +- .../Settings/SettingsManager.cs | 32 +- .../Utilities/HashCalculator.cs | 18 +- .../Utilities/JsonCodec.cs | 63 ++- .../ServerWorkflowIntegrationTests.cs | 12 +- .../DomainModelTests.cs | 19 - .../JavaWingetInstallerTests.cs | 1 + .../LoaderInstallerServiceTests.cs | 1 + .../ModManagerTests.cs | 4 +- .../Mods/FlexVersionTests.cs | 2 +- .../ServerBackupServiceTests.cs | 62 +- .../ServerPlayerConfigStoreTests.cs | 16 +- .../ServerRuntimeTests.cs | 2 + .../ServerDirectorySizeServiceTests.cs | 94 +++ .../Servers/ServerIntegrityServiceTests.cs | 110 ++++ .../UI/ManageServerViewModelTests.cs | 32 ++ .../UI/PlayerManagementViewModelTests.cs | 16 +- .../UI/ServerMonitorViewModelTests.cs | 1 + 102 files changed, 2498 insertions(+), 1557 deletions(-) create mode 100644 src/MinecraftServerManager.Core/Ports/IServerDirectorySizeService.cs create mode 100644 src/MinecraftServerManager.Core/Ports/IServerIntegrityService.cs delete mode 100644 src/MinecraftServerManager.Domain/Mods/LocalModUpdateCandidate.cs delete mode 100644 src/MinecraftServerManager.Domain/Servers/ProgressEvent.cs create mode 100644 src/MinecraftServerManager.Infrastructure/Servers/ServerDirectorySizeService.cs create mode 100644 src/MinecraftServerManager.Infrastructure/Servers/ServerIntegrityService.cs create mode 100644 tests/MinecraftServerManager.UnitTests/Servers/ServerDirectorySizeServiceTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/Servers/ServerIntegrityServiceTests.cs diff --git a/.gitignore b/.gitignore index a04a1d0..9eae1f2 100644 --- a/.gitignore +++ b/.gitignore @@ -46,9 +46,7 @@ TestResults/ *.sln.docstates .artifacts/ publish/ -coverage/ output/ -.codebase-memory/ *_wpftmp.csproj *_wpftmp.* diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a7061..e9657a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,20 @@ ### 新增功能 - **伺服器名單與權限管理**: - 管理伺服器頁面新增「名單管理」按鈕,集中視覺化維護管理員名冊(`ops.json`)、白名單(`whitelist.json`)、玩家封鎖名冊(`banned-players.json`)與 IP 封鎖名冊(`banned-ips.json`)。 - - 支援管理員 1~4 級 OP 權限層級調整與「略過人數限制」旗標設定。 + - 支援管理員 1~4 級 OP 權限層級調整與「略過人數限制」旗標設定。 - 支援讀取離線玩家快取索引(`usercache.json`),在離線或無外網環境下自動關聯玩家名稱與真實 UUID。 - - 名單核取方塊支援單擊即時切換狀態,並啟用清單虛擬化提升大量名單時的捲動流暢度。 + - 名單核取方塊支援按一下即時切換狀態,並啟用清單虛擬化提升大量名單時的捲動流暢度。 - **全客製化 WPF 確認與警示對話方塊**: - 淘汰舊式 Windows 原生 `MessageBox`,全系統改採符合深淺色主題之 WPF 自訂對話方塊(`CustomConfirmDialog` 與 `NotificationDialog`)。 - 對話視窗彈出時同步播放 Windows 系統提示音效(詢問、警告、錯誤、資訊)。 - **右下角非阻塞式快顯通知(Toast Notifications)**: - - 導入輕量級快顯浮動提示視窗(`ToastWindow`),支援淡入滑出動畫、滑鼠懸停暫停倒數與成功/資訊/警告/錯誤等狀態標示。 + - 引入輕量級快顯浮動提示視窗(`ToastWindow`),支援淡入滑出動畫、滑鼠懸停暫停倒數與成功/資訊/警告/錯誤等狀態標示。 - 伺服器啟動成功、設定儲存完成與一般通知全面改採 Toast,操作過程流暢不中斷。 - **未儲存變更防誤關閉防護**: - 伺服器屬性(`server.properties`)與記憶體設定視窗新增修改偵測;有尚未儲存的變更時,關閉或取消視窗會主動提示確認是否捨棄變更,防範誤操作遺失設定。 ### 介面與功能調整 -- **伺服器清單路徑顯示優化**: +- **伺服器清單路徑顯示最佳化**: - 管理伺服器頁面之「路徑」欄位改以精簡相對路徑顯示,滑鼠懸停顯示完整路徑,使介面更加整潔。 - **Java 偵測去重**: - 自動排除 Oracle Shim 跳板轉發器與 Junction 目錄,消除 Java 8 在環境偵測清單中重複顯示的問題。 @@ -30,16 +30,16 @@ - 修復執行伺服器備份壓縮與還原解壓作業時,因同步 I/O 導致主視窗與進度視窗卡死未回應的問題;相關檔案走訪與 ZIP 讀寫全面轉入背景執行緒集區執行,維持介面順暢更新。 - **作業進度視窗動態流光光效與平滑過渡**: - 全新設計作業進度條視覺效果,引入 Shimmer 動態流光循環光效;無論背景處於下載、解壓或長耗時安裝程式執行中,進度條始終持續流動運作,徹底解決畫面凍結的視覺體驗問題。 - - 進度百分比更新時導入平滑過渡動畫,消除數值跳動的生硬突變感。 + - 進度百分比更新時引入平滑過渡動畫,消除數值跳動的生硬突變感。 - Java Installer 伺服器安裝程序擴充動態關鍵字進度估算,維持動畫流暢推進且完全不影響實際執行效能。 - **NeoForge 與 Forge 伺服器缺失重要檔案判定修正**: - 修復當伺服器根目錄檔案被清空(僅剩餘資料夾)時,清單仍誤判為正常而未顯示「缺少重要檔案」的問題;補充對根目錄啟動腳本與主程式庫的完整性驗證。 - **伺服器備份關聯比對去重修復**: - 修正管理伺服器頁面與備份歷史清單因檔名模糊比對導致 NeoForge 與 Forge 備份互相混淆的問題,改採一致之精確伺服器名稱前綴匹配,杜絕誤計備份。 -- **初次啟動伺服器初始化強制中斷通知優化**: +- **初次啟動伺服器初始化強制中斷通知最佳化**: - 初次建立伺服器於啟動初始化視窗若遭強制中斷關閉,即時跳出單一警告對話方塊,明確告知檔案已保留並導引至管理頁面,避免重複跳出多個通知視窗。 - **伺服器建立表單並行鎖定**: - - 建立伺服器執行期間自動停用「建立伺服器」與「重設表單」按鈕,防範重複點擊與競態衝突。 + - 建立伺服器執行期間自動停用「建立伺服器」與「重設表單」按鈕,防範重複點選與競態衝突。 - **伺服器 ZIP 匯入進度與動畫修復**: - 修復匯入 ZIP 壓縮檔時主視窗介面凍結未回應的問題,解壓縮改由背景非同步執行,進度條與百分比皆能順暢更新。 - **初次啟動目錄引導持久化修正**: @@ -62,7 +62,7 @@ - **效能與啟動速度提升**:改以 .NET 原生技術重寫,大幅降低記憶體佔用並提供免安裝單一執行檔。 - **清單自然數字排序**:伺服器與模組清單改依自然數字順序排列(如 1, 2, 10,不再是字典序 1, 10, 2)。 - **行程管理強化**:關閉伺服器時優先正常存檔,逾時強制關閉整個行程樹,防止背景殘留 Java 行程。 -- **操作負擔精簡**:一般操作回饋改為 3 秒自動關閉,減少手動點擊「確定」的次數。 +- **操作負擔精簡**:一般操作回饋改為 3 秒自動關閉,減少手動點選「確定」的次數。 ## v1.8.0 - 2026-09-14 @@ -110,10 +110,10 @@ ## v1.7.2 - 2026-06-05 ### 新增 -- **UI 視覺升級 (Fluent Design)**:導入 `PySide6-Fluent-Widgets` 框架擴充,新增 `fluent.py` 封裝 Fluent 風格元件,全面翻新主視窗與子選單(如伺服器建立、模組管理、伺服器設定)的視覺語彙與互動體驗。 +- **UI 視覺升級 (Fluent Design)**:引入 `PySide6-Fluent-Widgets` 框架擴充,新增 `fluent.py` 封裝 Fluent 風格元件,全面翻新主視窗與子選單(如伺服器建立、模組管理、伺服器設定)的視覺語彙與互動體驗。 ### 調整 -- **更新與封裝機制最佳化**:全面優先改用「系統安裝程式」進行釋出,正式移除可攜式模式(Portable mode)及打包腳本 (`package-portable.ps1`) 支援,簡化後續的自動更新選擇流程。 +- **更新與封裝機制最佳化**:全面優先改用「系統安裝程式」釋出,正式移除可攜式模式(Portable mode)及打包腳本 (`package-portable.ps1`) 支援,簡化後續的自動更新選擇流程。 - **核心架構重構**:重構 `server_manager.py`、`server_instance.py` 等核心類別體系,並將耗時任務(如雜湊計算、線上資源驗證)進一步整合至共用的並行工作池(Worker Pool),降低資源佔用。 - **設定與 I/O 強化**:調整 `settings_manager.py` 與 `subprocess_utils.py`,最佳化子行程生命週期管理,並套用更安全的序列化保護與重新啟動機制。 - **政策與手冊更新**:更新官方資安通報政策與漏洞處理時程;並於《使用者手冊》與相關文件中補充了 Quilt 與 NeoForge 的完整支援細節。 @@ -130,7 +130,7 @@ - **擴充自動化測試**:增加針對 HTTP 下載、Qt UI 版面配置 (`test_qt_widget_layout_smoke.py`)、主監控畫面 (`test_server_monitor_window_players.py`) 等層級的最新測試模組。 ### 調整 -- **UI 框架遷移**:全面將所有視窗結構與管理工具 (`main_window`, `manage_server_frame`, `mod_management` 等) 移植至 Qt 環境,並刪除與舊版 CustomTkinter 繫結的遺留檔案(例如 `virtual_list.py`)。 +- **UI 框架遷移**:全面將所有視窗結構與管理工具(`main_window`、`manage_server_frame`、`mod_management` 等)移植至 Qt 環境,並刪除與舊版 CustomTkinter 繫結的遺留檔案(例如 `virtual_list.py`)。 - **網路核心最佳化**:移除了 `async_http_utils.py`,統一下載行為至 `http_utils.py` 並支援 40 字元的 SHA-1 雜湊校驗(適用於 Maven 來源下載)。 - **I/O 與工具層簡化**:移除過多的工具拆分模組(如 `checksum_utils.py`、`json_io.py` 及 `safe_path_ops.py`),將邏輯直接整併進路徑檢查與儲存機制內。 @@ -155,12 +155,12 @@ ### 調整 - **架構重構**:重構 `mod_management`、`manage_server_frame` 與 `main_window` UI 架構,降低模組間耦合度並改善操作流暢度。 -- **效能最佳化**:最佳化 Java 與 I/O 熱路徑,導入快取機制與受限工作池(Task Pooling)並行處理,大幅降低重複掃描與雜湊運算成本。 +- **效能最佳化**:最佳化 Java 與 I/O 熱路徑,引入快取機制與受限工作池(Task Pooling)並行處理,大幅降低重複掃描與雜湊運算成本。 - **建置流程最佳化**:調整 Nuitka 建置快取策略,修正環境變數處理邏輯,並更新可攜版封裝腳本。 - **CI/CD 與安全加固**:升級 GitHub Actions 工作流,整合 Bandit/detect-secrets 安全掃描與品質檢查機制。 ### 修正 -- 修正 Windows API 呼叫的相容性問題(導入保護性呼叫機制)。 +- 修正 Windows API 呼叫的相容性問題(引入保護性呼叫機制)。 - 修正多處 I/O 下載與解析流程中的銜接問題,提升系統啟動與模組操作的穩定性。 - 修正工作流程腳本中重複的設定值,降低 CI 自動化建置失敗的機率。 diff --git a/README.md b/README.md index 8eafd06..2a7ed8c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Minecraft 伺服器管理器 +# Minecraft 伺服器管理器 [![Platform](https://img.shields.io/badge/Windows-10%2F11-0078D4?logo=windows&logoColor=white)](https://www.microsoft.com/windows) [![.NET](https://img.shields.io/badge/.NET-10-512BD4?logo=dotnet&logoColor=white)](https://dotnet.microsoft.com/) @@ -15,7 +15,7 @@ - **核心與載入器**:支援 Paper、Vanilla、Fabric、Quilt、Forge、NeoForge 等官方核心之快速下載與建立 - **Java 管理與配對**:依照 Minecraft 官方版本需求自動配對,支援 Windows winget 自動安裝 Java(Java 8 到 21+)與自訂路徑,具備跳板轉發器去重機制 - **即時監控視窗**:獨立主控台、開機就緒 Windows 桌面通知、方向鍵瀏覽歷史指令、線上玩家清單、指令傳送與安全關閉流程 -- **玩家名冊與權限管理**:視覺化管理營運管理員名單(OP 1~4 級權限與略過人數限制)、白名單及封鎖名冊(玩家與 IP),支援離線玩家快取 +- **玩家名冊與權限管理**:視覺化管理營運管理員名單(OP 1~4 級權限與略過人數限制)、白名單及封鎖名冊(玩家與 IP),支援離線玩家快取 - **安全備份與還原**:還原時自動保留現場最新記錄檔與當機報告,具備失敗自動回復機制,自動保留最新 10 份備份 - **模組管理**:支援 Windows 自然排序、模組啟用與停用、自動下載相依模組、Modrinth 線上搜尋與安裝、官方下載來源驗證,以及匯出試算表(XLSX)、HTML、JSON 與純文字 - **伺服器設定驗證**:編輯 `server.properties` 時自動檢查連接埠(1~65534)與 IP 等數值正確性,避免設定錯誤 diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 4453025..5d8867a 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -1,4 +1,4 @@ -# Minecraft 伺服器管理器技術手冊 +# Minecraft 伺服器管理器技術手冊 本手冊說明 Minecraft 伺服器管理器程式所採用的技術堆疊、分層架構、核心演算法與安全機制。 @@ -85,7 +85,7 @@ Domain (不相依其他任何專案,純粹領域邏輯) - **平行化本地掃描與防抖動機制**:透過 `Parallel.ForEachAsync` 平行讀取伺服器 `mods` 目錄,快速擷取模組中繼資料並計算雜湊;模組重新整理內建 300ms 智慧防抖(Debounce),防範介面頻繁觸發時的重度磁碟 I/O 開銷 - **原生 FlexVersion 彈性語意版本比較引擎**: - - 規格參照開放原始碼專案 Prism Launcher 之版本解析與偏序比較原則,由本專案採用 C# / .NET 10 原生機制(`ReadOnlySpan`、自訂 `Section` 值型別)進行乾淨室獨立實作(Clean-room Implementation),支援多區段語意化版本、前導零正規化、忽略 `+` 建置中繼資料與預發布版降級比較,確保版本新舊判定的數學自反性、反對稱性與傳遞性。 + - 規格參照開放原始碼專案 Prism Launcher 之版本解析與偏序比較原則,由本專案採用 C# / .NET 10 原生機制(`ReadOnlySpan`、自訂 `Section` 值型別)落實乾淨室獨立實作(Clean-room Implementation),支援多區段語意化版本、前導零正規化、忽略 `+` 建置中繼資料與預發布版降級比較,確保版本新舊判定的數學自反性、反對稱性與傳遞性。 - 模組更新檢查演算法利用 FlexVersion 全序關係由相容候選集合中判定最大版本(最高相容版本),確保版本決策之單調性與確定性。 - **模組智慧啟閉**:透過標準化副檔名切換(`.jar` 與 `.jar.disabled`)實現無損啟用與停用,並自動處理檔名衝突備份 - **邊下載邊串流增量雜湊校驗(Single-Pass Streaming Hash Verification)**:下載 Modrinth 線上模組與更新資產時,於 HTTP 回應串流寫入本機磁碟的同時透過增量雜湊轉換器同步計算 SHA-512/SHA-256/SHA-1,寫入完成立即驗證,徹底消除下載完成後二次重讀全檔磁碟 I/O 的效能開銷;雜湊不符即主動清理暫存檔。 @@ -95,18 +95,18 @@ Domain (不相依其他任何專案,純粹領域邏輯) - **前台焦點維持(Focus Preservation)**:子對話方塊(如 `ServerInitializationDialog`)關閉或檢視頁面跳轉時,主動透過擁有者視窗啟動機制(`Owner?.Activate()`)維持 WPF 主視窗之前台狀態,防止 Windows 桌面視窗管理員(DWM)因前台暫態失去焦點而將焦點轉移至其他第三方應用程式。 - **淘汰原生 MessageBox 與全 WPF 客製化視窗**:系統淘汰舊式原生 `MessageBox`,全面改由 WPF 原生自訂對話方塊(`CustomConfirmDialog` 與 `NotificationDialog`)負責確認與警示,100% 契合深淺色動態主題。 -- **視窗渲染就緒音效回饋(AudioFeedback)**:將 Windows 系統音效呼叫移至對話視窗的 `Loaded` 事件中觸發,並透過安全標準 Windows 音效機制精準發聲(Warning / Error / Info / Question),防範未渲染視窗在建構時期被 Windows 靜音的問題。 +- **視窗算繪就緒音效回饋(AudioFeedback)**:將 Windows 系統音效呼叫移至對話視窗的 `Loaded` 事件中觸發,並透過安全標準 Windows 音效機制精準發聲(Warning / Error / Info / Question),防範未算繪視窗在建構時期被 Windows 靜音的問題。 ### 6. 名單管理系統架構(Player Management System) -- **核心名冊整合(ServerPlayerConfigStore)**:封裝伺服器四大名單持久化檔案:管理員名單(`ops.json`,含 1~4 級權限與略過人數上限標記)、白名單(`whitelist.json`)、玩家封鎖名冊(`banned-players.json`)與 IP 封鎖名冊(`banned-ips.json`)。 +- **核心名冊整合(ServerPlayerConfigStore)**:封裝伺服器四大名單持久化檔案:管理員名單(`ops.json`,含 1~4 級權限與略過人數上限標記)、白名單(`whitelist.json`)、玩家封鎖名冊(`banned-players.json`)與 IP 封鎖名冊(`banned-ips.json`)。 - **離線玩家索引快取(usercache.json)**:主動讀取伺服器內部之 `usercache.json`,在離線或無公網環境下自動解析關聯玩家名稱與 UUID,提供名稱快速自動補完。 -- **原子不可分割寫入保證**:名單儲存時一律透過 `AtomicFileWriter` 進行原子置換,防止名冊並行編輯損毀。 -- **高效單擊 UI 互動與虛擬化回收**:表格採用 `DataGridTemplateColumn` 內嵌 ``,實現單擊立即切換核取狀態;長列表與表格全面開啟 `EnableRowVirtualization="True"` 與 `VirtualizingPanel.VirtualizationMode="Recycling"`,確保記憶體低駐留與捲動高流暢度。 +- **原子不可分割寫入保證**:名單儲存時一律透過 `AtomicFileWriter` 進行不可分割置換,防止名冊並行編輯損毀。 +- **高效單次點選 UI 互動與虛擬化回收**:表格採用 `DataGridTemplateColumn` 內嵌 ``,實現按一下立即切換核取狀態;長列表與表格全面開啟 `EnableRowVirtualization="True"` 與 `VirtualizingPanel.VirtualizationMode="Recycling"`,確保記憶體低駐留與捲動高流暢度。 ### 7. Java 探測去重與安全邊界防護 -- **Oracle Shim 跳板轉發器與 Junction 排除**:在環境變數 `PATH` 與候選目錄掃描中,精準過濾包含 `\Oracle\Java\javapath`、`\Oracle\Java\java8path` 與 `\Common Files\Oracle\Java\` 之目錄,並檢查執行檔 PE 元資料之 `shimconsole.exe` / `shim.exe`,徹底杜絕 Java 8 重複項。 +- **Oracle Shim 跳板轉發器與 Junction 排除**:在環境變數 `PATH` 與候選目錄掃描中,精準過濾包含 `\Oracle\Java\javapath`、`\Oracle\Java\java8path` 與 `\Common Files\Oracle\Java\` 之目錄,並檢查執行檔 PE 中繼資料之 `shimconsole.exe` / `shim.exe`,徹底杜絕 Java 8 重複項。 - **原生符號連結解析(ResolveLinkTarget)**:利用 .NET 原生 `FileSystemInfo.ResolveLinkTarget(returnFinalTarget: true)` 精準解析實體 JRE 目錄,確保候選路徑之唯一性與真實性。 ### 8. 雙版本原生單一檔案發布 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a8ada6d..226b18c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -52,15 +52,15 @@ - **啟動伺服器**:點選卡片上的「啟動」按鈕,系統會先自動預檢伺服器關鍵啟動檔案(如核心 JAR 或啟動腳本),確認齊全後於獨立監控視窗中啟動。 - **開啟資料夾**:點選「開啟資料夾」,直接在檔案總管中開啟該伺服器的根目錄。 - **名單管理**:點選「名單管理」開啟伺服器玩家名單與權限設定對話方塊,集中控管四大關鍵設定: - - **管理員名冊 (`ops.json`)**:設定玩家 OP 權限層級(Level 1~4)與是否略過人數限制;核取方塊支援單擊即時切換。 + - **管理員名冊 (`ops.json`)**:設定玩家 OP 權限層級(Level 1~4)與是否略過人數限制;核取方塊支援按一下即時切換。 - **白名單 (`whitelist.json`)**:維護准入名冊,搭配屬性中的白名單功能限制進場。 - **玩家封鎖名冊 (`banned-players.json`)** 與 **IP 封鎖名冊 (`banned-ips.json`)**:檢視與維護禁止進入名單。 - 支援離線快取索引(`usercache.json`),自動關聯玩家 UUID 與名稱。 - **記憶體配置**:點選「記憶體設定」,快速調整該伺服器的最大與最小 JVM 記憶體分配量。 - **相對路徑顯示**:伺服器清單之「路徑」欄位採用相對於存放目錄之精簡相對路徑顯示,滑鼠懸停即可顯示完整絕對路徑。 -- **屬性設定**:點選「屬性設定」開啟 `server.properties` 編輯視窗,修改遊戲難度、PVP、最大玩家數等參數;系統具備屬性數值合法性驗證(如連接埠 1~65534、IP 位址格式與列舉值驗證)及版本樂觀鎖保護,防止並行儲存衝突與無效設定。 +- **屬性設定**:點選「屬性設定」開啟 `server.properties` 編輯視窗,修改遊戲難度、PVP、最大玩家數等參數;系統具備屬性數值合法性驗證(如連接埠 1~65534、IP 位址格式與列舉值驗證)及版本樂觀鎖保護,防止並行儲存衝突與無效設定。 - **立即備份**:點選「備份」,系統將自動封裝為 ZIP 備份檔,並自動保留最新 10 份備份。 -- **還原備份**:點選「還原」,選擇歷史備份點進行交易式安全還原。 +- **還原備份**:點選「還原」,選擇歷史備份點執行交易式安全還原。 - **刪除伺服器**:點選「刪除」並確認後安全移除伺服器目錄;若該伺服器存在受管理的歷史備份,系統將主動詢問是否一併刪除備份檔案,避免佔用磁碟空間。 ### 3. 獨立主控台即時監控 @@ -105,7 +105,7 @@ 點選導覽列底部的「關於與設定」: -- **版本與開發資訊**:檢視目前應用程式版本號碼、技術堆疊與開源授權條款(GPLv3),並可快速點擊前往 GitHub 專案頁面。 +- **版本與開發資訊**:檢視目前應用程式版本號碼、技術堆疊與開源授權條款(GPLv3),並可快速點選前往 GitHub 專案頁面。 - **軟體更新檢查**: - 勾選「自動檢查更新」可在啟動時自動比對遠端版本。 - 點選「檢查更新」立即連線至 GitHub Releases 檢查有無新版本,下載時強制透過官方提供的 SHA-256 digest 雜湊驗證,確保檔案完整與安全。 diff --git a/src/MinecraftServerManager.App/App.xaml.cs b/src/MinecraftServerManager.App/App.xaml.cs index 0bcfdc3..f90b5da 100644 --- a/src/MinecraftServerManager.App/App.xaml.cs +++ b/src/MinecraftServerManager.App/App.xaml.cs @@ -2,7 +2,6 @@ using System.Windows.Threading; using Microsoft.Win32; using MinecraftServerManager.Core; -using MinecraftServerManager.App.Views; using MinecraftServerManager.Infrastructure.Logging; using MinecraftServerManager.Infrastructure.Settings; @@ -13,6 +12,11 @@ public partial class App : Application private static readonly ComponentLogger Logger = AppLogging.CreateComponentLogger("App"); private static Mutex? _singleInstanceMutex; + private static int s_unhandledExceptionCount; + private static long s_lastUnhandledExceptionTicks; + private const int MaxConsecutiveExceptions = 3; + private static readonly TimeSpan ExceptionThrottleWindow = TimeSpan.FromSeconds(5); + protected override void OnStartup(StartupEventArgs e) { #if SELF_CONTAINED @@ -121,18 +125,18 @@ private static string EnsureServersRootConfigured(SettingsManager settings) catch (Exception ex) { Logger.Error(ex, "寫入伺服器目錄設定失敗: {Message}", ex.Message); - NotificationDialog.Show(null, "設定錯誤", $"無法寫入設定或建立資料夾:{ex.Message}", NotificationLevel.Error); + new Services.WpfUiService().ShowError($"無法寫入設定或建立資料夾:{ex.Message}", "設定錯誤"); } } else { - var res = MessageBox.Show( - "未選擇資料夾,是否要結束程式?", - "結束程式", - MessageBoxButton.YesNo, - MessageBoxImage.Question); + bool exitApp = Views.CustomConfirmDialog.Show( + owner: null, + message: "未選擇資料夾,是否要結束程式?", + title: "結束程式", + level: Services.UiMessageLevel.Question); - if (res == MessageBoxResult.Yes) + if (exitApp) { return string.Empty; } @@ -171,8 +175,23 @@ private static void App_DispatcherUnhandledException(object sender, DispatcherUn { Logger.Error(e.Exception, "Dispatcher 攔截到未處理例外: {Message}", e.Exception.Message); + string logsDir = Infrastructure.Utilities.RuntimePaths.GetLogDir(); + bool shouldTerminate = RecordAndCheckThrottling(); + + if (shouldTerminate) + { + MessageBox.Show( + $"應用程式發生頻繁且嚴重的未處理例外:\n\n{e.Exception.Message}\n\n為避免資料損毀,應用程式即將結束。\n詳細堆疊與錯誤資訊已儲存於記錄檔目錄:\n{logsDir}", + "Minecraft Server Manager 致命錯誤", + MessageBoxButton.OK, + MessageBoxImage.Error); + + Environment.Exit(1); + return; + } + MessageBox.Show( - $"應用程式發生未預期的例外:\n\n{e.Exception.Message}\n\n堆疊追蹤:\n{e.Exception.StackTrace}", + $"應用程式發生未預期的例外:\n\n{e.Exception.Message}\n\n詳細堆疊與錯誤資訊已記錄於記錄檔目錄:\n{logsDir}", "Minecraft Server Manager 錯誤", MessageBoxButton.OK, MessageBoxImage.Error); @@ -186,11 +205,26 @@ private static void CurrentDomain_UnhandledException(object sender, UnhandledExc { Logger.Error(ex, "AppDomain 攔截到致命未處理例外: {Message}", ex.Message); + string logsDir = Infrastructure.Utilities.RuntimePaths.GetLogDir(); MessageBox.Show( - $"應用程式發生致命錯誤:\n\n{ex.Message}\n\n堆疊追蹤:\n{ex.StackTrace}", + $"應用程式發生致命錯誤:\n\n{ex.Message}\n\n詳細堆疊與錯誤資訊已記錄於記錄檔目錄:\n{logsDir}", "Minecraft Server Manager 致命錯誤", MessageBoxButton.OK, MessageBoxImage.Error); } } + + private static bool RecordAndCheckThrottling() + { + long nowTicks = DateTime.UtcNow.Ticks; + long lastTicks = Interlocked.Exchange(ref s_lastUnhandledExceptionTicks, nowTicks); + if (nowTicks - lastTicks <= ExceptionThrottleWindow.Ticks) + { + int count = Interlocked.Increment(ref s_unhandledExceptionCount); + return count >= MaxConsecutiveExceptions; + } + + Interlocked.Exchange(ref s_unhandledExceptionCount, 1); + return false; + } } diff --git a/src/MinecraftServerManager.App/AppCompositionRoot.cs b/src/MinecraftServerManager.App/AppCompositionRoot.cs index 919f4c1..0ccb16a 100644 --- a/src/MinecraftServerManager.App/AppCompositionRoot.cs +++ b/src/MinecraftServerManager.App/AppCompositionRoot.cs @@ -29,8 +29,10 @@ public sealed class AppCompositionRoot : IAsyncDisposable private readonly JavaRuntimeDetector _javaDetector; private readonly LoaderCatalogService _loaderCatalog; private readonly MainViewModel _mainViewModel; + private readonly Services.WpfUiService _uiService; public MainViewModel MainViewModel => _mainViewModel; + public Services.IUiService UiService => _uiService; public SettingsManager Settings => _settingsManager; public bool IsServerRunning => _sharedServerRuntime.IsRunning; @@ -62,6 +64,8 @@ public AppCompositionRoot(string serversRoot, SettingsManager? settingsManager = javaDetector: _javaDetector, javaRequirementService: javaRequirements); var backupService = new ServerBackupService(serversRoot); + var directorySizeService = new ServerDirectorySizeService(); + var integrityService = new ServerIntegrityService(); var modScanner = new LocalModScanner(); var modInstaller = new ModFileInstaller(httpPort); @@ -70,9 +74,9 @@ public AppCompositionRoot(string serversRoot, SettingsManager? settingsManager = var dependencyPlanner = new ModDependencyPlanner(); var modListExporter = new ModListExporter(); var updateCheckerService = new UpdateCheckerService(httpPort); - var appUpdateService = new AppUpdateService(httpPort); + var appUpdateService = new AppUpdateService(httpPort, processRunner: _sharedProcessRunner); - var uiService = new Services.WpfUiService(); + _uiService = new Services.WpfUiService(); _mainViewModel = new MainViewModel( _settingsManager, @@ -87,10 +91,12 @@ public AppCompositionRoot(string serversRoot, SettingsManager? settingsManager = _sharedProcessRunner, javaRequirements, javaInstaller, - uiService: uiService, + uiService: _uiService, appUpdateService: appUpdateService, dependencyPlanner: dependencyPlanner, - modListExporter: modListExporter); + modListExporter: modListExporter, + directorySizeService: directorySizeService, + integrityService: integrityService); } /// diff --git a/src/MinecraftServerManager.App/MainWindow.xaml.cs b/src/MinecraftServerManager.App/MainWindow.xaml.cs index f127143..525a15d 100644 --- a/src/MinecraftServerManager.App/MainWindow.xaml.cs +++ b/src/MinecraftServerManager.App/MainWindow.xaml.cs @@ -88,7 +88,17 @@ private void OnResetWindowSizeRequested() Top = (SystemParameters.PrimaryScreenHeight - Height) / 2; } - private void ViewModel_NotificationRequested(string title, string message, NotificationLevel level) => NotificationDialog.Show(this, title, message, level); + private void ViewModel_NotificationRequested(string title, string message, NotificationLevel level) + { + if (level == NotificationLevel.Error) + { + _compositionRoot?.UiService.ShowError(message, title); + } + else + { + _compositionRoot?.UiService.ShowWarning(message, title); + } + } private void MainWindow_Loaded(object sender, RoutedEventArgs e) { @@ -188,7 +198,7 @@ private async Task ShutdownAndCloseAsync() finally { _shutdownCompleted = true; - await Dispatcher.InvokeAsync(Close); + _compositionRoot?.UiService.RunOnUi(Close); } } diff --git a/src/MinecraftServerManager.App/Services/IUiService.cs b/src/MinecraftServerManager.App/Services/IUiService.cs index f592f48..49cbd5b 100644 --- a/src/MinecraftServerManager.App/Services/IUiService.cs +++ b/src/MinecraftServerManager.App/Services/IUiService.cs @@ -55,7 +55,7 @@ public interface IMonitorWindow /// /// 所有使用者介面互動(訊息框、檔案選取、對話方塊、進度與視窗)的抽象。 -/// ViewModel 僅依賴此介面,具體 WPF 實作位於 。 +/// ViewModel 僅相依此介面,具體 WPF 實作位於 。 /// public interface IUiService { @@ -85,7 +85,7 @@ public interface IUiService /// /// 以非阻塞方式於 UI 執行緒執行動作;非 UI 環境時不執行 /// - public void PostToUi(Action action); + public void PostToUi(Action action, bool afterLayout = false); /// /// 已在 UI 執行緒則直接執行,否則派送至 UI 執行緒;非 UI 環境時直接執行 diff --git a/src/MinecraftServerManager.App/Services/NoOpUiService.cs b/src/MinecraftServerManager.App/Services/NoOpUiService.cs index 35ce9ad..8fff19c 100644 --- a/src/MinecraftServerManager.App/Services/NoOpUiService.cs +++ b/src/MinecraftServerManager.App/Services/NoOpUiService.cs @@ -28,7 +28,7 @@ public void ActivateMainWindow() { } public void ShutdownApplication(int exitCode = 0) { } - public void PostToUi(Action action) { } + public void PostToUi(Action action, bool afterLayout = false) { } public void RunOnUi(Action action) => action(); diff --git a/src/MinecraftServerManager.App/Services/WpfUiService.cs b/src/MinecraftServerManager.App/Services/WpfUiService.cs index 528debe..30e54e8 100644 --- a/src/MinecraftServerManager.App/Services/WpfUiService.cs +++ b/src/MinecraftServerManager.App/Services/WpfUiService.cs @@ -25,18 +25,19 @@ public bool Confirm(string message, string title, UiMessageLevel level = UiMessa return CustomConfirmDialog.Show(owner, message, title, level); } - public void ShowToast(string message, string? title = null, ToastType type = ToastType.Info, int durationSeconds = 3) => ToastWindow.Instance.ShowToast(message, title, type, durationSeconds); + public void ShowToast(string message, string? title = null, ToastType type = ToastType.Info, int durationSeconds = 3) => + RunOnUi(() => ToastWindow.Instance.ShowToast(message, title, type, durationSeconds, this)); public void ShowWarning(string message, string title) { var owner = GetDefaultOwner(); - NotificationDialog.Show(owner, title, message, NotificationLevel.Warning); + NotificationDialog.Show(this, owner, title, message, NotificationLevel.Warning); } public void ShowError(string message, string title) { var owner = GetDefaultOwner(); - NotificationDialog.Show(owner, title, message, NotificationLevel.Error); + NotificationDialog.Show(this, owner, title, message, NotificationLevel.Error); } private static Window? GetDefaultOwner() @@ -114,7 +115,8 @@ public void ActivateMainWindow() => return dialog.ShowDialog() == true ? dialog.FolderName : null; } - public void PostToUi(Action action) => Application.Current?.Dispatcher.InvokeAsync(action); + public void PostToUi(Action action, bool afterLayout = false) => + Application.Current?.Dispatcher.InvokeAsync(action, afterLayout ? DispatcherPriority.Loaded : DispatcherPriority.Normal); public void RunOnUi(Action action) { @@ -151,7 +153,7 @@ public void RunOnUi(Action action) public IReadOnlyList? ShowModUpdates(IEnumerable items) { - var dialog = Attach(new ModUpdatesDialog(items)); + var dialog = Attach(new ModUpdatesDialog(items, this)); return dialog.ShowDialog() == true ? dialog.GetSelectedItems() : null; } @@ -166,7 +168,7 @@ public void ShowExportModList(string serverName, IReadOnlyList public bool ShowServerInitialization(ServerConfig config, string javaPath, IServerRuntime runtime) { - using var dialog = new ServerInitializationDialog(runtime, config, javaPath); + using var dialog = new ServerInitializationDialog(this, runtime, config, javaPath); PrepareDialog(dialog, Application.Current?.MainWindow); dialog.ShowDialog(); return dialog.IsSuccess; @@ -174,7 +176,7 @@ public bool ShowServerInitialization(ServerConfig config, string javaPath, IServ public IMonitorWindow OpenServerMonitor(ServerMonitorViewModel viewModel) { - var window = new ServerMonitorWindow(viewModel); + var window = new ServerMonitorWindow(viewModel, this); PrepareDialog(window, owner: null); var handle = new MonitorWindowHandle(window); window.Show(); @@ -183,13 +185,13 @@ public IMonitorWindow OpenServerMonitor(ServerMonitorViewModel viewModel) public IProgressSession ShowServerBackupProgress(string serverName, string actionTitle = "備份") { - var dialog = Attach(new OperationProgressDialog($"{actionTitle}伺服器中", $"正在{actionTitle}伺服器「{serverName}」", actionTitle == "還原" ? "正在準備還原環境與解壓縮備份..." : "正在收集伺服器檔案...")); + var dialog = Attach(new OperationProgressDialog(this, $"{actionTitle}伺服器中", $"正在{actionTitle}伺服器「{serverName}」", actionTitle == "還原" ? "正在準備還原環境與解壓縮備份..." : "正在收集伺服器檔案...")); dialog.Show(); var progress = new Progress(report => { int percent = Math.Clamp(report.PercentCompleted, 0, 100); string msg = !string.IsNullOrWhiteSpace(report.CurrentFile) - ? $"正在壓縮: {report.CurrentFile}" + ? $"正在壓縮:{report.CurrentFile}" : (percent >= 100 ? $"{actionTitle}壓縮完成!" : string.Empty); dialog.UpdateProgress(percent, msg); }); @@ -198,7 +200,7 @@ public IProgressSession ShowServerBackupProgress(str public IProgressSession ShowServerImportProgress(string serverName) { - var dialog = Attach(new OperationProgressDialog("匯入伺服器中", $"正在匯入伺服器「{serverName}」", "正在驗證伺服器目錄...")); + var dialog = Attach(new OperationProgressDialog(this, "匯入伺服器中", $"正在匯入伺服器「{serverName}」", "正在驗證伺服器目錄...")); dialog.Show(); var progress = new Progress(report => { @@ -210,7 +212,7 @@ public IProgressSession ShowServerImportProgress(str public IProgressSession ShowServerCreationProgress(string serverName) { - var dialog = Attach(new OperationProgressDialog("建立伺服器中", $"正在建立伺服器「{serverName}」", "正在驗證伺服器名稱與目錄...")); + var dialog = Attach(new OperationProgressDialog(this, "建立伺服器中", $"正在建立伺服器「{serverName}」", "正在驗證伺服器名稱與目錄...")); dialog.Show(); var progress = new Progress(report => { @@ -222,7 +224,7 @@ public IProgressSession ShowServerCreationProgress public IOperationProgress ShowModOperationProgress(string title, string initialMessage) { - var dialog = Attach(new OperationProgressDialog(title, title, initialMessage)); + var dialog = Attach(new OperationProgressDialog(this, title, title, initialMessage)); dialog.Show(); return new ModOperationProgressHandle(dialog); } diff --git a/src/MinecraftServerManager.App/ViewModels/AboutPreferencesViewModel.cs b/src/MinecraftServerManager.App/ViewModels/AboutPreferencesViewModel.cs index 44dc82a..a2f533f 100644 --- a/src/MinecraftServerManager.App/ViewModels/AboutPreferencesViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/AboutPreferencesViewModel.cs @@ -48,10 +48,10 @@ public sealed partial class AboutPreferencesViewModel : PageViewModel public bool IsCustomScaleVisible => SelectedUiScaleIndex == UiScaleOptions.Count - 1; [ObservableProperty] - public partial string ScreenResolutionInfo { get; set; } = "目前螢幕解析度: 1920 × 1080"; + public partial string ScreenResolutionInfo { get; set; } = "目前螢幕解析度:1920 × 1080"; [ObservableProperty] - public partial string CurrentWindowSizeInfo { get; set; } = "目前主視窗大小: 1350 × 820"; + public partial string CurrentWindowSizeInfo { get; set; } = "目前主視窗大小:1350 × 820"; public AboutPreferencesViewModel( SettingsManager? settingsManager = null, @@ -129,6 +129,7 @@ public double ResolveUiScale() return 1.0; } + [RelayCommand] partial void OnIsAutoUpdateEnabledChanged(bool value) => _settingsManager?.SetAutoUpdateEnabled(value); partial void OnSelectedThemeModeIndexChanged(int value) @@ -330,13 +331,18 @@ private void ResetAllSettings() SelectedUiScaleIndex = 1; CustomUiScaleText = "100"; + _settingsManager?.SetAutoUpdateEnabled(true); + _settingsManager?.SetThemeMode("system"); + _settingsManager?.SetUiScale(1.0); + _onThemeModeChanged?.Invoke("system"); + _onUiScaleChanged?.Invoke(1.0); _onResetWindowSizeRequested?.Invoke(); - } + } public void UpdateDisplayInfo(int screenWidth, int screenHeight, int windowWidth, int windowHeight) { - ScreenResolutionInfo = $"目前螢幕解析度: {screenWidth} × {screenHeight}"; - CurrentWindowSizeInfo = $"目前主視窗大小: {windowWidth} × {windowHeight}"; + ScreenResolutionInfo = $"目前螢幕解析度:{screenWidth} × {screenHeight}"; + CurrentWindowSizeInfo = $"目前主視窗大小:{windowWidth} × {windowHeight}"; } } diff --git a/src/MinecraftServerManager.App/ViewModels/CreateServerViewModel.cs b/src/MinecraftServerManager.App/ViewModels/CreateServerViewModel.cs index 4cf8fb6..0959ad6 100644 --- a/src/MinecraftServerManager.App/ViewModels/CreateServerViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/CreateServerViewModel.cs @@ -17,6 +17,7 @@ public sealed partial class CreateServerViewModel : PageViewModel private readonly IUiService _ui; private static readonly ComponentLogger Logger = AppLogging.CreateComponentLogger("CreateServer"); + private static readonly string[] NoAvailableVersions = ["無可用版本"]; private readonly IJavaRuntimeDetector? _javaDetector; private readonly IMinecraftJavaRequirementService? _javaRequirementService; private readonly IJavaInstaller? _javaInstaller; @@ -368,7 +369,7 @@ private async Task RefreshLoaderVersionsAsync() } else { - LoaderVersions.ReplaceAll(["無可用版本"]); + LoaderVersions.ReplaceAll(NoAvailableVersions); SelectedLoaderVersion = "無可用版本"; IsLoaderVersionEnabled = false; } @@ -710,7 +711,7 @@ public async Task CreateServer() { Logger.Warning("建立伺服器已終止:本機未安裝符合 Minecraft {Version} 所需之 Java {Major}", SelectedMinecraftVersion, targetMajor.Value); _ui.ShowError( - $"建立伺服器已終止!\n\nMinecraft {SelectedMinecraftVersion} 強制需要 Java {targetMajor.Value},但系統偵測不到相容的 Java 執行檔。\n請點選「管理 Java」安裝或設定 Java {targetMajor.Value} 後再進行建立。", + $"建立伺服器已終止!\n\nMinecraft {SelectedMinecraftVersion} 強制需要 Java {targetMajor.Value},但系統偵測不到相容的 Java 執行檔。\n請點選「管理 Java」安裝或設定 Java {targetMajor.Value} 後再建立伺服器。", "Java 版本不相符"); return; } diff --git a/src/MinecraftServerManager.App/ViewModels/MainViewModel.cs b/src/MinecraftServerManager.App/ViewModels/MainViewModel.cs index d1e547e..6f54767 100644 --- a/src/MinecraftServerManager.App/ViewModels/MainViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/MainViewModel.cs @@ -1,11 +1,11 @@ using MinecraftServerManager.App.Services; -using System.IO; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Win32; using MinecraftServerManager.App.Views; using MinecraftServerManager.Core.Ports; using MinecraftServerManager.Infrastructure.Logging; +using MinecraftServerManager.Infrastructure.FileSystem; using MinecraftServerManager.Infrastructure.Settings; namespace MinecraftServerManager.App.ViewModels; @@ -71,7 +71,9 @@ public MainViewModel( IUiService? uiService = null, IAppUpdateService? appUpdateService = null, IModDependencyPlanner? dependencyPlanner = null, - IModListExporter? modListExporter = null) + IModListExporter? modListExporter = null, + IServerDirectorySizeService? directorySizeService = null, + IServerIntegrityService? integrityService = null) { _ui = uiService ?? NoOpUiService.Instance; _settingsManager = settingsManager ?? new SettingsManager(); @@ -114,7 +116,9 @@ public MainViewModel( launcher, javaDetector, javaRequirementService, - uiService: _ui); + uiService: _ui, + directorySizeService: directorySizeService, + integrityService: integrityService); _pageFactories["mods"] = () => new ModsViewModel( serverManager, modManager, @@ -293,7 +297,7 @@ public Task OpenServersFolder() return Task.CompletedTask; } - Directory.CreateDirectory(path); + SafeFileSystem.CreateDirectory(path); if (_launcher is not null && !_launcher.OpenFolder(path, isTrusted: true)) { diff --git a/src/MinecraftServerManager.App/ViewModels/ManageJavaViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ManageJavaViewModel.cs index 3dbb46a..922ebb0 100644 --- a/src/MinecraftServerManager.App/ViewModels/ManageJavaViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ManageJavaViewModel.cs @@ -6,6 +6,7 @@ using MinecraftServerManager.Core.Ports; using MinecraftServerManager.Domain.Servers; using MinecraftServerManager.Infrastructure.Logging; +using MinecraftServerManager.Infrastructure.FileSystem; namespace MinecraftServerManager.App.ViewModels; @@ -174,7 +175,7 @@ public async Task InstallWithWingetAsync() var existing = await Task.Run(() => javaSnapshot.FirstOrDefault(j => j.MajorVersion == targetMajor && !string.IsNullOrWhiteSpace(j.ExecutablePath) && - File.Exists(j.ExecutablePath))).ConfigureAwait(true); + SafeFileSystem.FileExists(j.ExecutablePath))).ConfigureAwait(true); if (existing is not null) { diff --git a/src/MinecraftServerManager.App/ViewModels/ManageServerViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ManageServerViewModel.cs index b8c7d13..72914b0 100644 --- a/src/MinecraftServerManager.App/ViewModels/ManageServerViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ManageServerViewModel.cs @@ -1,14 +1,15 @@ -using MinecraftServerManager.Infrastructure.FileSystem; -using MinecraftServerManager.App.Services; using System.Collections.ObjectModel; +using System.Globalization; using System.IO; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using MinecraftServerManager.App.Services; using MinecraftServerManager.App.Views; using MinecraftServerManager.Core.Ports; using MinecraftServerManager.Core.Servers; using MinecraftServerManager.Domain.Servers; using MinecraftServerManager.Domain.ValueObjects; +using MinecraftServerManager.Infrastructure.FileSystem; using MinecraftServerManager.Infrastructure.Logging; using MinecraftServerManager.Infrastructure.Servers; using MinecraftServerManager.Infrastructure.Settings; @@ -16,36 +17,45 @@ namespace MinecraftServerManager.App.ViewModels; /// -/// 伺服器列表項目模型。 +/// 伺服器列表項目展示模型 /// public sealed partial class ServerRowItem : ObservableObject { - public string Name { get; init; } = string.Empty; + [ObservableProperty] + public partial string Name { get; set; } = string.Empty; - public string Version { get; init; } = string.Empty; + [ObservableProperty] + public partial string Version { get; set; } = string.Empty; - public string Loader { get; init; } = string.Empty; + [ObservableProperty] + public partial string Loader { get; set; } = string.Empty; + + [ObservableProperty] + public partial string Status { get; set; } = string.Empty; - public string Path { get; init; } = string.Empty; - public string RelativePath { get; init; } = string.Empty; + [ObservableProperty] + public partial string Size { get; set; } = "-"; - public int MemoryMaxMb { get; init; } = 2048; + [ObservableProperty] + public partial string BackupStatus { get; set; } = string.Empty; - public int? MemoryMinMb { get; init; } + [ObservableProperty] + public partial string Path { get; set; } = string.Empty; - public string BackupPath { get; set; } = string.Empty; + [ObservableProperty] + public partial string RelativePath { get; set; } = string.Empty; [ObservableProperty] - public partial string Status { get; set; } = "已停止"; + public partial int MemoryMaxMb { get; set; } = 2048; [ObservableProperty] - public partial bool HasMissingFiles { get; set; } + public partial int? MemoryMinMb { get; set; } [ObservableProperty] - public partial string Size { get; set; } = "-"; + public partial string? BackupPath { get; set; } [ObservableProperty] - public partial string BackupStatus { get; set; } = "無備份"; + public partial bool HasMissingFiles { get; set; } [ObservableProperty] public partial string LastModified { get; set; } = "-"; @@ -71,10 +81,13 @@ public sealed partial class ManageServerViewModel : PageViewModel, IDisposable private readonly IJavaRuntimeDetector? _javaDetector; private readonly IMinecraftJavaRequirementService? _javaRequirementService; private readonly IExternalLauncher? _launcher; + private readonly IServerDirectorySizeService _directorySizeService; + private readonly IServerIntegrityService _integrityService; private readonly Action? _navigateCallback; private readonly Action? _notificationSink; private IMonitorWindow? _activeMonitorWindow; private ServerMonitorViewModel? _activeMonitorViewModel; + private CancellationTokenSource? _sizeCalculationCts; [ObservableProperty] public partial string DetectPath { get; set; } = string.Empty; @@ -85,6 +98,12 @@ public sealed partial class ManageServerViewModel : PageViewModel, IDisposable [ObservableProperty] public partial string SelectedServerInfo { get; set; } = "選擇一個伺服器以檢視詳細資訊"; + [ObservableProperty] + public partial string SelectedServerMemory { get; set; } = "-"; + + [ObservableProperty] + public partial string SelectedServerBackupPath { get; set; } = "-"; + [ObservableProperty] public partial bool IsBusy { get; set; } @@ -129,8 +148,10 @@ public ManageServerViewModel( IJavaRuntimeDetector? javaDetector = null, IMinecraftJavaRequirementService? javaRequirementService = null, IUiService? uiService = null, - IServerPlayerConfigStore? playerConfigStore = null) - : base("manage", "管理現有伺服器", "檢視已安裝伺服器清單,進行啟動、停止、設定與備份維護") + IServerPlayerConfigStore? playerConfigStore = null, + IServerDirectorySizeService? directorySizeService = null, + IServerIntegrityService? integrityService = null) + : base("manage", "管理現有伺服器", "檢視已安裝伺服器清單,執行啟動、停止、設定與備份維護") { _ui = uiService ?? NoOpUiService.Instance; _settingsManager = settingsManager; @@ -141,6 +162,8 @@ public ManageServerViewModel( _launchPlanner = launchPlanner ?? new ServerLaunchPlanner(); _propertiesStore = propertiesStore; _playerConfigStore = playerConfigStore ?? new ServerPlayerConfigStore(); + _directorySizeService = directorySizeService ?? new ServerDirectorySizeService(); + _integrityService = integrityService ?? new ServerIntegrityService(); _navigateCallback = navigateCallback; _notificationSink = notificationSink; _launcher = launcher; @@ -167,6 +190,10 @@ public void Dispose() _serverRuntime.ServerReady -= RequestStatusRefresh; _serverRuntime.ServerExited -= OnServerExitedHandler; } + + _sizeCalculationCts?.Cancel(); + _sizeCalculationCts?.Dispose(); + _sizeCalculationCts = null; } public ObservableCollection Servers { get; } @@ -188,24 +215,45 @@ private string ResolveServersDirectory() } } + private static string FormatMemoryDisplay(int? minMb, int maxMb) + { + return minMb is { } min && min > 0 + ? $"{min} MB ~ {maxMb} MB" + : $"{maxMb} MB"; + } + partial void OnSelectedServerChanged(ServerRowItem? value) { HasSelectedServer = value is not null; if (value is not null) { - string minText = value.MemoryMinMb.HasValue ? $"{value.MemoryMinMb.Value} MB" : "未設定"; - SelectedServerInfo = $"已選取伺服器:{value.Name} ({value.Version} / {value.Loader}) — 狀態:{value.Status} | 記憶體:最小 {minText} / 最大 {value.MemoryMaxMb} MB"; + string backupInfo = !string.IsNullOrWhiteSpace(value.BackupPath) + ? value.BackupPath + : "未設定外部備份資料夾"; + + string memoryDisplay = FormatMemoryDisplay(value.MemoryMinMb, value.MemoryMaxMb); + SelectedServerMemory = memoryDisplay; + SelectedServerBackupPath = backupInfo; + + SelectedServerInfo = $"伺服器名稱:{value.Name}\n" + + $"Minecraft 版本:{value.Version}\n" + + $"載入器類型:{value.Loader}\n" + + $"目前狀態:{value.Status}\n" + + $"目錄容量:{value.Size}\n" + + $"備份狀況:{value.BackupStatus}\n" + + $"外部備份路徑:{backupInfo}\n" + + $"記憶體配置:{memoryDisplay}\n" + + $"實體路徑:{value.Path}"; } else { + SelectedServerMemory = "-"; + SelectedServerBackupPath = "-"; SelectedServerInfo = "選擇一個伺服器以檢視詳細資訊"; } OnPropertyChanged(nameof(CanStartOrMonitorServer)); OnPropertyChanged(nameof(CanManagePlayers)); - StartServerCommand.NotifyCanExecuteChanged(); - MonitorServerCommand.NotifyCanExecuteChanged(); - ManagePlayersCommand.NotifyCanExecuteChanged(); } [RelayCommand] @@ -213,7 +261,7 @@ public async Task DetectServers() { if (_serverManager is null) { - await RefreshServers(); + _notificationSink?.Invoke("伺服器管理員尚未就緒", true); return; } @@ -257,26 +305,61 @@ public async Task RefreshServers() bool runtimeRunning = _serverRuntime?.IsRunning == true; string? activeName = _serverRuntime?.ActiveServerName; - // 目錄容量、備份掃描與檔案檢查屬於磁碟 I/O,於背景執行緒完成,UI 執行緒僅負責更新集合 IReadOnlyList snapshots = await Task.Run(() => serverEntries.Select(s => BuildServerSnapshot(s, runtimeRunning, activeName)).ToList()) .ConfigureAwait(true); - Servers.Clear(); - foreach (ServerRowSnapshot snapshot in snapshots) + // 差分增量對齊(Reconciliation):保留既有物件與選取狀態,杜絕捲動跳轉 + var existingByName = Servers.ToDictionary(static s => s.Name, StringComparer.OrdinalIgnoreCase); + var newNames = new HashSet(snapshots.Count, StringComparer.OrdinalIgnoreCase); + + foreach (var snapshot in snapshots) + { + string name = snapshot.Config.Name.Value; + newNames.Add(name); + + if (existingByName.TryGetValue(name, out var existingRow)) + { + existingRow.Version = snapshot.Config.MinecraftVersion.Value; + existingRow.Loader = snapshot.Config.LoaderType.ToString(); + existingRow.Status = snapshot.Status; + existingRow.BackupStatus = snapshot.BackupStatus; + existingRow.LastModified = snapshot.LastModified; + existingRow.Path = snapshot.Config.Path; + existingRow.RelativePath = !string.IsNullOrWhiteSpace(DetectPath) && !string.IsNullOrWhiteSpace(snapshot.Config.Path) + ? Path.GetRelativePath(DetectPath, snapshot.Config.Path) + : snapshot.Config.Path; + existingRow.MemoryMaxMb = snapshot.Config.MemoryMaxMb; + existingRow.MemoryMinMb = snapshot.Config.MemoryMinMb; + existingRow.BackupPath = snapshot.Config.BackupPath; + existingRow.HasMissingFiles = snapshot.HasMissingFiles; + } + else + { + Servers.Add(ToRowItem(snapshot, DetectPath)); + } + } + + for (int i = Servers.Count - 1; i >= 0; i--) { - Servers.Add(ToRowItem(snapshot, DetectPath)); + if (!newNames.Contains(Servers[i].Name)) + { + Servers.RemoveAt(i); + } } if (!string.IsNullOrEmpty(prevSelectedName)) { SelectedServer = Servers.FirstOrDefault(x => string.Equals(x.Name, prevSelectedName, StringComparison.OrdinalIgnoreCase)) ?? Servers.FirstOrDefault(); } - else + else if (SelectedServer is null || !newNames.Contains(SelectedServer.Name)) { SelectedServer = Servers.FirstOrDefault(); } + // 啟動背景容量非同步回填 + TriggerBackgroundSizeBackfill(); + Logger.Information("成功重新整理伺服器清單,共 {Count} 個伺服器,路徑: {Path}", Servers.Count, DetectPath); return; } @@ -295,6 +378,53 @@ public async Task RefreshServers() } } + private void TriggerBackgroundSizeBackfill() + { + _sizeCalculationCts?.Cancel(); + _sizeCalculationCts?.Dispose(); + var cts = new CancellationTokenSource(); + _sizeCalculationCts = cts; + var token = cts.Token; + + var targets = Servers.Select(s => (Row: s, s.Path)).ToList(); + + _ = Task.Run(async () => + { + using var semaphore = new SemaphoreSlim(2, 2); + var tasks = targets.Select(async target => + { + await semaphore.WaitAsync(token).ConfigureAwait(false); + try + { + token.ThrowIfCancellationRequested(); + long bytes = await _directorySizeService.CalculateServerSizeAsync(target.Path, token).ConfigureAwait(false); + string formatted = Core.Utilities.UnitFormatter.FormatBytes(bytes); + + _ui.PostToUi(() => + { + if (!token.IsCancellationRequested) + { + target.Row.Size = formatted; + } + }); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Logger.Warning("非同步計算伺服器容量失敗 ({Path}): {Message}", target.Path, ex.Message); + } + finally + { + semaphore.Release(); + } + }); + + await Task.WhenAll(tasks).ConfigureAwait(false); + }, token); + } + private sealed record ServerRowSnapshot( ServerConfig Config, string Size, @@ -305,13 +435,13 @@ private sealed record ServerRowSnapshot( private sealed record ServerStatusProbe(string Status, bool HasMissingFiles, string? LastModified); - private static ServerRowSnapshot BuildServerSnapshot(ServerConfig s, bool runtimeRunning, string? activeName) + private ServerRowSnapshot BuildServerSnapshot(ServerConfig s, bool runtimeRunning, string? activeName) { bool isThisServerRunning = runtimeRunning && string.Equals(activeName, s.Name.Value, StringComparison.OrdinalIgnoreCase); - ServerStatusProbe probe = ProbeServerStatus(s.Path, s.LoaderType.ToString(), isThisServerRunning); + ServerStatusProbe probe = ProbeServerStatus(s.Path, s.LoaderType, isThisServerRunning); return new ServerRowSnapshot( Config: s, - Size: FormatDirectorySize(s.Path), + Size: "-", BackupStatus: BuildBackupStatus(s), Status: probe.Status, LastModified: probe.LastModified ?? "-", @@ -343,65 +473,70 @@ private static ServerRowItem ToRowItem(ServerRowSnapshot snapshot, string? baseD }; } - private static ServerStatusProbe ProbeServerStatus(string path, string loader, bool isRunning) + private ServerStatusProbe ProbeServerStatus(string path, LoaderKind loader, bool isRunning) { if (isRunning) { return new ServerStatusProbe("執行中", false, GetLastModifiedText(path)); } - var (hasMissing, missingFiles) = CheckImportantFiles(path, loader); - string status = hasMissing ? $"缺少重要檔案:{string.Join("、", missingFiles)}" : "已停止"; - return new ServerStatusProbe(status, hasMissing, GetLastModifiedText(path)); + var result = _integrityService.CheckImportantFiles(path, loader); + string status = result.HasMissing ? $"缺少重要檔案:{string.Join("、", result.MissingFiles)}" : "已停止"; + return new ServerStatusProbe(status, result.HasMissing, GetLastModifiedText(path)); } private static string BuildBackupStatus(ServerConfig s) { + if (string.IsNullOrWhiteSpace(s.BackupPath)) + { + return "未設定外部備份"; + } + + if (!SafeFileSystem.DirectoryExists(s.BackupPath)) + { + return "備份資料夾不存在"; + } + try { - string backupDir = !string.IsNullOrWhiteSpace(s.BackupPath) && Directory.Exists(s.BackupPath) - ? s.BackupPath - : Path.Combine(s.Path, "backups"); + string serverName = s.Name.Value; + string prefix = $"backup-{serverName}-"; + string altPrefix = $"{serverName}-"; + string exact1 = $"backup-{serverName}.zip"; + string exact2 = $"{serverName}.zip"; - if (Directory.Exists(backupDir)) - { - string serverName = s.Name.Value; - string prefix = $"backup-{serverName}-"; - string altPrefix = $"{serverName}-"; - string exact1 = $"backup-{serverName}.zip"; - string exact2 = $"{serverName}.zip"; - - var archives = Directory.EnumerateFiles(backupDir, "*.zip") - .Where(f => - { - string fn = Path.GetFileName(f); - return fn.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || - fn.StartsWith(altPrefix, StringComparison.OrdinalIgnoreCase) || - string.Equals(fn, exact1, StringComparison.OrdinalIgnoreCase) || - string.Equals(fn, exact2, StringComparison.OrdinalIgnoreCase); - }) - .ToList(); - - if (archives.Count > 0) + var archives = SafeFileSystem.EnumerateFiles(s.BackupPath, "*.zip") + .Where(f => { - DateTime latest = archives.Max(f => new FileInfo(f).LastWriteTime); - return $"{archives.Count} 個備份({FormatRelativeTime(latest)})"; - } + string fn = Path.GetFileName(f); + return fn.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || + fn.StartsWith(altPrefix, StringComparison.OrdinalIgnoreCase) || + string.Equals(fn, exact1, StringComparison.OrdinalIgnoreCase) || + string.Equals(fn, exact2, StringComparison.OrdinalIgnoreCase); + }) + .ToList(); + + if (archives.Count > 0) + { + DateTime latest = archives.Max(SafeFileSystem.GetFileLastWriteTime); + return $"{archives.Count} 個備份({FormatRelativeTime(latest)})"; } + + return "尚無備份"; } - catch + catch (Exception ex) { + Logger.Warning("讀取備份清單失敗 ({Path}): {Message}", s.BackupPath, ex.Message); + return "讀取備份失敗"; } - - return "無備份"; } private static string? GetLastModifiedText(string path) { try { - return Directory.Exists(path) - ? Directory.GetLastWriteTime(path).ToString("yyyy/MM/dd HH:mm", System.Globalization.CultureInfo.InvariantCulture) + return SafeFileSystem.DirectoryExists(path) + ? SafeFileSystem.GetDirectoryLastWriteTime(path).ToString("yyyy/MM/dd HH:mm", CultureInfo.InvariantCulture) : null; } catch @@ -422,10 +557,14 @@ public async Task UpdateServerStatusInPlaceAsync() var targets = Servers.Select(s => (Row: s, s.Path, s.Loader, s.Name)).ToList(); var probes = await Task.Run(() => targets - .Select(t => ProbeServerStatus( - t.Path, - t.Loader, - runtimeRunning && string.Equals(activeName, t.Name, StringComparison.OrdinalIgnoreCase))) + .Select(t => + { + var lk = Enum.TryParse(t.Loader, ignoreCase: true, out var parsedLk) ? parsedLk : LoaderKind.Unknown; + return ProbeServerStatus( + t.Path, + lk, + runtimeRunning && string.Equals(activeName, t.Name, StringComparison.OrdinalIgnoreCase)); + }) .ToList()).ConfigureAwait(true); for (int i = 0; i < targets.Count; i++) @@ -533,13 +672,11 @@ public async Task StartServerByNameAsync(string serverName) var plan = _launchPlanner!.CreatePlan(server, inspection, javaPath); - // 啟動前即開啟或帶至前景監控視窗,讓使用者能即時看到啟動記錄 MonitorServerByName(server.Name.Value, server.Path, server.MinecraftVersion.Value); await _serverRuntime.StartAsync(plan).ConfigureAwait(true); _ = UpdateServerStatusInPlaceAsync(); - // 啟動後偵測是否於 3 秒內異常結束 string? earlyExit = await DetectEarlyExitAsync().ConfigureAwait(true); if (earlyExit is not null) { @@ -641,7 +778,7 @@ public void ConfigureServer() if (store is not null) { var vm = new ServerPropertiesViewModel(store, SelectedServer.Name, - uiService: _ui); + uiService: _ui); if (_ui.ShowServerProperties(vm) == true) { _notificationSink?.Invoke($"伺服器「{SelectedServer.Name}」屬性設定已更新", false); @@ -684,7 +821,7 @@ public Task OpenServerFolder() } string serverFolder = SelectedServer.Path; - if (!Directory.Exists(serverFolder)) + if (!SafeFileSystem.DirectoryExists(serverFolder)) { _notificationSink?.Invoke($"資料夾不存在:{SelectedServer.Path}", true); return Task.CompletedTask; @@ -707,42 +844,42 @@ public async Task BackupServer() } string serverName = SelectedServer.Name; - string destinationPath; - if (!string.IsNullOrWhiteSpace(SelectedServer.BackupPath)) + if (string.IsNullOrWhiteSpace(SelectedServer.BackupPath)) { - try - { - string backupRoot = SelectedServer.BackupPath; - Directory.CreateDirectory(backupRoot); - } - catch (Exception ex) + _ui.ShowToast("伺服器尚未設定外部備份資料夾,請先設定儲存位置", "請設定備份路徑", ToastType.Info); + await EditBackupPath(); + if (string.IsNullOrWhiteSpace(SelectedServer.BackupPath)) { - _notificationSink?.Invoke($"建立備份目錄失敗:{ex.Message}", true); return; } - - destinationPath = Path.Combine(SelectedServer.BackupPath, $"backup-{serverName}-{DateTime.Now:yyyyMMdd-HHmmss}.zip"); } - else + + string destinationDir = SelectedServer.BackupPath; + string? boundaryViolation = ValidateBackupDestination(destinationDir); + if (boundaryViolation is not null) { - string? savePath = _ui.PickSaveFile($"請選擇伺服器「{serverName}」的備份儲存檔案路徑", $"backup-{serverName}-{DateTime.Now:yyyyMMdd-HHmmss}.zip", "ZIP 壓縮檔 (*.zip)|*.zip"); - if (savePath is null) + _ui.ShowToast(boundaryViolation, "備份路徑無效,請重新設定", ToastType.Warning); + await EditBackupPath(); + if (string.IsNullOrWhiteSpace(SelectedServer.BackupPath) || ValidateBackupDestination(SelectedServer.BackupPath) is not null) { return; } - - destinationPath = savePath; + destinationDir = SelectedServer.BackupPath; } - string? boundaryViolation = ValidateBackupDestination(destinationPath); - if (boundaryViolation is not null) + try + { + SafeFileSystem.CreateDirectory(destinationDir); + } + catch (Exception ex) { - Logger.Warning("備份路徑越界遭阻擋: {Path}", destinationPath); - _notificationSink?.Invoke(boundaryViolation, true); + _notificationSink?.Invoke($"建立備份目錄失敗:{ex.Message}", true); return; } + string destinationPath = Path.Combine(destinationDir, $"backup-{serverName}-{DateTime.Now:yyyyMMdd-HHmmss}.zip"); + if (_backupService is not null) { var progressDialog = _ui.ShowServerBackupProgress(serverName); @@ -754,24 +891,6 @@ public async Task BackupServer() destinationPath: destinationPath, progress: progressDialog.Progress).ConfigureAwait(true); - var config = _serverManager is null ? null : await _serverManager.GetServerAsync(serverName); - if (config is not null && _serverManager is not null) - { - string backupDir = Path.GetDirectoryName(backup.FullPath) ?? string.Empty; - var updated = new ServerConfig( - config.Name, - config.MinecraftVersion, - config.LoaderType, - config.LoaderVersion, - config.MemoryMaxMb, - config.MemoryMinMb, - config.Path, - config.JvmArgs, - backupDir); - - await _serverManager.UpdateServerAsync(updated); - } - progressDialog.Complete(); Logger.Information("伺服器「{Name}」備份建立成功: {File}", serverName, backup.FullPath); _notificationSink?.Invoke($"已成功建立伺服器「{serverName}」備份:{backup.FileName}", false); @@ -798,10 +917,10 @@ public async Task BackupServer() } /// - /// 備份目的地邊界檢查:禁止落在 servers 根目錄或任一伺服器子資料夾內。 + /// 備份目的地邊界檢查:禁止位於伺服器存放目錄 (servers/) 或其子目錄內。 /// /// 合法時回傳 null,違規時回傳阻擋訊息。 - private string? ValidateBackupDestination(string destinationFile) + private string? ValidateBackupDestination(string destinationPath) { string serversRoot = ResolveServersDirectory(); if (string.IsNullOrWhiteSpace(serversRoot)) @@ -811,25 +930,23 @@ public async Task BackupServer() try { - string targetDir = Path.GetFullPath(Path.GetDirectoryName(destinationFile) ?? destinationFile); - string rootDir = Path.GetFullPath(serversRoot); + string targetDir = SafeFileSystem.FileExists(destinationPath) || destinationPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) + ? (Path.GetDirectoryName(destinationPath) ?? destinationPath) + : destinationPath; - if (string.Equals(targetDir.TrimEnd(Path.DirectorySeparatorChar), rootDir.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) - { - return "備份檔案不可存放於 servers 根目錄,請另選其他位置"; - } + string normalizedTarget = Path.GetFullPath(targetDir); + string normalizedServersRoot = Path.GetFullPath(serversRoot); - string rootPrefix = rootDir.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; - if (targetDir.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) + if (SafeFileSystem.IsPathWithin(normalizedServersRoot, normalizedTarget, strict: false)) { - return "備份檔案不可存放於任何伺服器資料夾內,請另選 servers 目錄以外的位置"; + return "備份路徑不得位於伺服器存放目錄 (servers/) 或其子目錄內,請重新選擇其他外部資料夾。"; } return null; } - catch + catch (Exception ex) { - return null; + return $"路徑解析失敗:{ex.Message}"; } } @@ -851,7 +968,7 @@ public async Task RestoreBackup() SelectedServer.Name, SelectedServer.BackupPath, isServerRunningCheck: () => isRunning, - uiService: _ui); + uiService: _ui); if (_ui.ShowRestoreBackup(vm) == true) { string serverName = SelectedServer.Name; @@ -873,12 +990,12 @@ public async Task DeleteServer() } string name = SelectedServer.Name; - string backupPath = SelectedServer.BackupPath; + string? backupPath = SelectedServer.BackupPath; var currentServer = SelectedServer; int backupCount = 0; IReadOnlyList? backupsToDelete = null; - if (_backupService is not null) + if (_backupService is not null && !string.IsNullOrWhiteSpace(backupPath)) { try { @@ -901,7 +1018,7 @@ public async Task DeleteServer() { try { - if (decision == ServerDeleteDecision.DeleteServerAndBackups && _backupService is not null && backupsToDelete is { Count: > 0 }) + if (decision == ServerDeleteDecision.DeleteServerAndBackups && _backupService is not null && backupsToDelete is { Count: > 0 } && !string.IsNullOrWhiteSpace(backupPath)) { try { @@ -1033,7 +1150,7 @@ public async Task OpenBackupFolder() } string? backupFolder = SelectedServer.BackupPath; - if (string.IsNullOrWhiteSpace(backupFolder) || !Directory.Exists(backupFolder)) + if (string.IsNullOrWhiteSpace(backupFolder) || !SafeFileSystem.DirectoryExists(backupFolder)) { if (_ui.Confirm( $"伺服器「{SelectedServer.Name}」尚未設定自訂備份路徑。\n\n是否要立即設定外部備份資料夾?", @@ -1044,9 +1161,9 @@ public async Task OpenBackupFolder() return; } - if (_launcher is not null && !_launcher.OpenFolder(SelectedServer.BackupPath, isTrusted: true)) + if (_launcher is not null && !_launcher.OpenFolder(backupFolder, isTrusted: true)) { - _notificationSink?.Invoke($"無法開啟備份資料夾:{SelectedServer.BackupPath}", true); + _notificationSink?.Invoke($"無法開啟備份資料夾:{backupFolder}", true); } } @@ -1064,15 +1181,20 @@ public async Task EditBackupPath() return; } - string? folder = _ui.PickFolder($"選擇伺服器「{SelectedServer.Name}」的外部備份資料夾", !string.IsNullOrWhiteSpace(config.BackupPath) && Directory.Exists(config.BackupPath) ? config.BackupPath : null); - if (!string.IsNullOrWhiteSpace(folder)) + while (true) { + string? folder = _ui.PickFolder($"選擇伺服器「{SelectedServer.Name}」的外部備份資料夾", !string.IsNullOrWhiteSpace(config.BackupPath) && SafeFileSystem.DirectoryExists(config.BackupPath) ? config.BackupPath : null); + if (string.IsNullOrWhiteSpace(folder)) + { + return; + } + string selected = folder.Trim(); string? invalidReason = ValidateBackupDestination(selected); if (invalidReason is not null) { - _ui.ShowToast(invalidReason, "備份路徑無效", ToastType.Warning); - return; + _ui.ShowToast(invalidReason, "備份路徑無效,請重新選擇", ToastType.Warning); + continue; } var updated = new ServerConfig( @@ -1096,121 +1218,7 @@ public async Task EditBackupPath() { _notificationSink?.Invoke($"更新備份資料夾路徑失敗:{res.Message}", true); } - } - } - - /// - /// 檢查伺服器是否缺失關鍵啟動檔案,並回傳缺失檔案清單。 - /// - private static (bool HasMissing, IReadOnlyList MissingFiles) CheckImportantFiles(string serverPath, string? loader = null) - { - if (string.IsNullOrWhiteSpace(serverPath) || !Directory.Exists(serverPath)) - { - return (true, ["伺服器目錄不存在"]); - } - - try - { - var lk = Enum.TryParse(loader, ignoreCase: true, out var kind) ? kind : LoaderKind.Unknown; - - switch (lk) - { - case LoaderKind.Paper: - { - bool hasPaper = File.Exists(Path.Combine(serverPath, "paper.jar")) || - File.Exists(Path.Combine(serverPath, "server.jar")) || - Directory.EnumerateFiles(serverPath, "paper*.jar", SearchOption.TopDirectoryOnly).Any(); - return hasPaper ? (false, []) : (true, ["paper.jar"]); - } - - case LoaderKind.Fabric: - { - var missing = new List(2); - if (!File.Exists(Path.Combine(serverPath, "fabric-server-launch.jar"))) - { - missing.Add("fabric-server-launch.jar"); - } - if (!File.Exists(Path.Combine(serverPath, "server.jar"))) - { - missing.Add("server.jar"); - } - return missing.Count == 0 ? (false, []) : (true, missing); - } - - case LoaderKind.Quilt: - { - bool hasQuiltLaunch = File.Exists(Path.Combine(serverPath, "quilt-server-launch.jar")) || - File.Exists(Path.Combine(serverPath, "server", "quilt-server-launch.jar")); - bool hasVanillaServer = File.Exists(Path.Combine(serverPath, "server.jar")); - - if (hasQuiltLaunch || hasVanillaServer) - { - return (false, []); - } - - return (true, ["quilt-server-launch.jar"]); - } - - case LoaderKind.NeoForge: - { - bool hasRunScript = File.Exists(Path.Combine(serverPath, "start_server.bat")) || - File.Exists(Path.Combine(serverPath, "run.bat")) || - File.Exists(Path.Combine(serverPath, "run.sh")); - string neoLib = Path.Combine(serverPath, "libraries", "net", "neoforged", "neoforge"); - bool hasWinArgs = Directory.Exists(neoLib) && Directory.EnumerateFiles(neoLib, "*args.txt", SearchOption.AllDirectories).Any(); - - var missing = new List(2); - if (!hasRunScript) - { - missing.Add("start_server.bat / run.bat"); - } - if (!hasWinArgs) - { - missing.Add("win_args.txt"); - } - - return missing.Count == 0 ? (false, []) : (true, missing); - } - - case LoaderKind.Forge: - { - bool hasLegacyForge = Directory.EnumerateFiles(serverPath, "forge*.jar", SearchOption.TopDirectoryOnly).Any(); - if (hasLegacyForge) - { - return (false, []); - } - - bool hasRunScript = File.Exists(Path.Combine(serverPath, "start_server.bat")) || - File.Exists(Path.Combine(serverPath, "run.bat")) || - File.Exists(Path.Combine(serverPath, "run.sh")); - string forgeLib = Path.Combine(serverPath, "libraries", "net", "minecraftforge", "forge"); - bool hasForgeArgs = Directory.Exists(forgeLib) && Directory.EnumerateFiles(forgeLib, "*args.txt", SearchOption.AllDirectories).Any(); - - var missing = new List(2); - if (!hasRunScript) - { - missing.Add("start_server.bat / run.bat"); - } - if (!hasForgeArgs) - { - missing.Add("win_args.txt"); - } - - return missing.Count == 0 ? (false, []) : (true, missing); - } - - default: - { - bool hasAnyServer = File.Exists(Path.Combine(serverPath, "server.jar")) || - File.Exists(Path.Combine(serverPath, "run.bat")) || - Directory.EnumerateFiles(serverPath, "*.jar", SearchOption.TopDirectoryOnly).Any(); - return hasAnyServer ? (false, []) : (true, ["server.jar"]); - } - } - } - catch - { - return (true, ["未預期的檔案系統錯誤"]); + break; } } @@ -1227,7 +1235,7 @@ private static (bool HasMissing, IReadOnlyList MissingFiles) CheckImport try { string fullPath = Path.GetFullPath(Path.Combine(serverPath, target)); - return File.Exists(fullPath) ? null : target; + return SafeFileSystem.FileExists(fullPath) ? null : target; } catch { @@ -1243,7 +1251,6 @@ private static (bool HasMissing, IReadOnlyList MissingFiles) CheckImport { if (_javaDetector is null || _javaRequirementService is null) { - // 未注入偵測器或需求服務時維持既有行為,交由 ServerLaunchPlanner 處理 return (null, null); } @@ -1325,24 +1332,4 @@ private static string FormatRelativeTime(DateTime timestamp) : $"{(int)span.TotalDays} 天前" }; } - - private static string FormatDirectorySize(string directoryPath) - { - if (string.IsNullOrWhiteSpace(directoryPath) || !Directory.Exists(directoryPath)) - { - return "-"; - } - - try - { - var entries = SafeFileSystem.WalkBoundedTree(directoryPath, rejectReparse: false); - long bytes = entries.Where(static e => !e.IsDirectory).Sum(static e => e.Length); - return Core.Utilities.UnitFormatter.FormatBytes(bytes); - } - catch (Exception ex) - { - Logger.Warning("計算目錄容量失敗 ({Path}): {Message}", directoryPath, ex.Message); - return "-"; - } - } } diff --git a/src/MinecraftServerManager.App/ViewModels/ModsViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ModsViewModel.cs index cc9016f..88e3b49 100644 --- a/src/MinecraftServerManager.App/ViewModels/ModsViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ModsViewModel.cs @@ -751,7 +751,7 @@ public async Task CheckModUpdatesAsync() } } - // 階段 2:平行查詢相容版本並進行相容性與語意版本比對(MaxDegreeOfParallelism = 4) + // 階段 2:平行查詢相容版本並執行相容性與語意版本比對(MaxDegreeOfParallelism = 4) var candidates = new ConcurrentBag(); int completedCount = 0; int totalCount = targets.Count; @@ -799,7 +799,7 @@ ver.PrimaryFile is null || continue; } - // 標準相容性評估:進行相容性與相依性評估,排除硬性不相容與衝突版本 + // 標準相容性評估:執行相容性與相依性評估,排除硬性不相容與衝突版本 var report = _dependencyPlanner.EvaluateCompatibility(ver, mcVer, loader, installedMods); if (!report.HasHardErrors && report.IncompatibleInstalled.Count == 0) { @@ -993,7 +993,7 @@ public async Task OpenModsFolderAsync() } string modsDir = Path.Combine(server.Path, "mods"); - Directory.CreateDirectory(modsDir); + SafeFileSystem.CreateDirectory(modsDir); Logger.Information("開啟模組資料夾: {Path}", modsDir); if (_launcher is not null && !_launcher.OpenFolder(modsDir, isTrusted: true)) @@ -1046,18 +1046,18 @@ public async Task ShowInFolderAsync(LocalModRowItem? mod) private static string ResolveModFilePath(string serverPath, string fileName) { string filePath = Path.Combine(serverPath, "mods", fileName); - if (File.Exists(filePath)) + if (SafeFileSystem.FileExists(filePath)) { return filePath; } string disabledPath = filePath + ".disabled"; - return File.Exists(disabledPath) ? disabledPath : Path.Combine(serverPath, "mods"); + return SafeFileSystem.FileExists(disabledPath) ? disabledPath : Path.Combine(serverPath, "mods"); } private static void DeleteModFileIfExists(string serverPath, string path) { - if (File.Exists(path)) + if (SafeFileSystem.FileExists(path)) { SafeFileSystem.DeleteWithin(serverPath, path); } @@ -1437,7 +1437,7 @@ public async Task PickOnlineModVersionAsync(OnlineModRowItem? mod) } /// - /// 進行相依性評估後統一批次下載安裝。 + /// 完成相依性評估後統一批次下載安裝。 /// [RelayCommand] public async Task InstallQueuedModsAsync(CancellationToken cancellationToken = default) diff --git a/src/MinecraftServerManager.App/ViewModels/PlayerManagementViewModel.cs b/src/MinecraftServerManager.App/ViewModels/PlayerManagementViewModel.cs index 59a1000..923038d 100644 --- a/src/MinecraftServerManager.App/ViewModels/PlayerManagementViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/PlayerManagementViewModel.cs @@ -23,6 +23,8 @@ public sealed partial class PlayerManagementViewModel : ObservableObject [ObservableProperty] public partial string PlayerSearchText { get; set; } = string.Empty; + partial void OnPlayerSearchTextChanged(string value) => ApplyUserCacheFilter(); + [ObservableProperty] public partial ServerUserCacheEntry? SelectedUserCacheEntry { get; set; } @@ -167,8 +169,6 @@ public async Task InitializeAsync() } } - partial void OnPlayerSearchTextChanged(string value) => ApplyUserCacheFilter(); - private void ApplyUserCacheFilter() { string q = PlayerSearchText.Trim(); @@ -506,6 +506,10 @@ public sealed partial class OperatorRowItem : ObservableObject [ObservableProperty] public partial bool BypassesPlayerLimit { get; set; } + partial void OnIsOpChanged(bool value) => _onChanged?.Invoke(); + partial void OnLevelChanged(int value) => _onChanged?.Invoke(); + partial void OnBypassesPlayerLimitChanged(bool value) => _onChanged?.Invoke(); + public static IReadOnlyList AvailableLevels { get; } = [1, 2, 3, 4]; public OperatorRowItem(string name, string uuid, bool isOp, int level, bool bypassesPlayerLimit, Action? onChanged = null) @@ -517,8 +521,4 @@ public OperatorRowItem(string name, string uuid, bool isOp, int level, bool bypa BypassesPlayerLimit = bypassesPlayerLimit; _onChanged = onChanged; } - - partial void OnIsOpChanged(bool value) => _onChanged?.Invoke(); - partial void OnLevelChanged(int value) => _onChanged?.Invoke(); - partial void OnBypassesPlayerLimitChanged(bool value) => _onChanged?.Invoke(); } diff --git a/src/MinecraftServerManager.App/ViewModels/RestoreBackupViewModel.cs b/src/MinecraftServerManager.App/ViewModels/RestoreBackupViewModel.cs index 7a419f2..edd2b6e 100644 --- a/src/MinecraftServerManager.App/ViewModels/RestoreBackupViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/RestoreBackupViewModel.cs @@ -105,7 +105,7 @@ public async Task RestoreAsync() if (_isServerRunningCheck?.Invoke() == true) { StatusMessage = "伺服器正在執行中,無法還原備份!請先停止伺服器。"; - _ui.ShowToast("伺服器正在執行中,請先停止伺服器後再進行備份還原。", "無法還原", ToastType.Warning); + _ui.ShowToast("伺服器正在執行中,請先停止伺服器後再還原備份。", "無法還原", ToastType.Warning); return; } diff --git a/src/MinecraftServerManager.App/ViewModels/ServerMemoryViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ServerMemoryViewModel.cs index 26da6e1..309783c 100644 --- a/src/MinecraftServerManager.App/ViewModels/ServerMemoryViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ServerMemoryViewModel.cs @@ -37,10 +37,6 @@ public sealed partial class ServerMemoryViewModel( public event Action? RequestClose; - partial void OnMaxMemoryMbChanged(string value) => Validate(out _, out _); - - partial void OnMinMemoryMbChanged(string value) => Validate(out _, out _); - private bool Validate(out int max, out int? min) { max = 0; diff --git a/src/MinecraftServerManager.App/ViewModels/ServerMonitorViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ServerMonitorViewModel.cs index e551ab7..4397175 100644 --- a/src/MinecraftServerManager.App/ViewModels/ServerMonitorViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ServerMonitorViewModel.cs @@ -1,6 +1,6 @@ using MinecraftServerManager.App.Services; -using System.Buffers; using System.Diagnostics; +using System.Buffers; using System.IO; using System.Text; using System.Text.RegularExpressions; @@ -11,6 +11,7 @@ using MinecraftServerManager.Core.Mods; using MinecraftServerManager.Core.Ports; using MinecraftServerManager.Infrastructure.Logging; +using MinecraftServerManager.Infrastructure.FileSystem; namespace MinecraftServerManager.App.ViewModels; @@ -37,8 +38,6 @@ public sealed partial class ServerMonitorViewModel : ObservableObject, IDisposab }); private readonly string _serverPath; private readonly string _serverName; - private Process? _monitoredProcess; - private int _monitoredPid; private long? _startTimestamp; private bool _serverReadyNotified; private int _logSessionId; @@ -72,11 +71,6 @@ public sealed partial class ServerMonitorViewModel : ObservableObject, IDisposab [ObservableProperty] public partial bool IsServerRunning { get; set; } - partial void OnIsServerRunningChanged(bool value) - { - OnPropertyChanged(nameof(CanStartServer)); - } - public bool CanStartServer => !IsServerRunning; [ObservableProperty] @@ -197,7 +191,7 @@ private async Task LoadLatestLogHistoryAsync() private static List? ReadLatestLogTail(string serverPath) { string logFile = Path.Combine(serverPath, "logs", "latest.log"); - if (!File.Exists(logFile)) + if (!SafeFileSystem.FileExists(logFile)) { return null; } @@ -207,7 +201,7 @@ private async Task LoadLatestLogHistoryAsync() const int maxBytesToRead = 2 * 1024 * 1024; const int maxLines = 2500; - using var fs = new FileStream(logFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var fs = SafeFileSystem.OpenRead(logFile, FileShare.ReadWrite); long length = fs.Length; if (length == 0) { @@ -286,32 +280,11 @@ private void OnStatsTimerTick() var elapsed = Stopwatch.GetElapsedTime(_startTimestamp.Value); UptimeText = $"{elapsed:hh\\:mm\\:ss}"; - if (_serverRuntime.Pid > 0) - { - int pid = _serverRuntime.Pid; - try - { - if (_monitoredPid != pid || _monitoredProcess is null) - { - DisposeMonitoredProcess(); - _monitoredProcess = Process.GetProcessById(pid); - _monitoredPid = pid; - } - - _monitoredProcess.Refresh(); - long memMb = _monitoredProcess.WorkingSet64 / (1024 * 1024); - MemoryUsageText = $"{memMb} MB"; - } - catch - { - DisposeMonitoredProcess(); - MemoryUsageText = "-"; - } - } + long? workingSet = _serverRuntime.GetWorkingSet64(); + MemoryUsageText = workingSet.HasValue ? $"{workingSet.Value / (1024 * 1024)} MB" : "-"; } else { - DisposeMonitoredProcess(); MemoryUsageText = "-"; ServerStatusText = "已停止"; } @@ -613,11 +586,11 @@ private static (string ServerIp, string ServerPort) ReadServerAddress(string? se if (!string.IsNullOrWhiteSpace(serverPath)) { string propsPath = Path.Combine(serverPath, "server.properties"); - if (File.Exists(propsPath)) + if (SafeFileSystem.FileExists(propsPath)) { try { - using var stream = new FileStream(propsPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var stream = SafeFileSystem.OpenRead(propsPath, FileShare.ReadWrite | FileShare.Delete); using var reader = new StreamReader(stream, Encoding.UTF8); string? line; while ((line = reader.ReadLine()) is not null) @@ -703,12 +676,6 @@ private void TrimDisplayedLogs() } } - private void DisposeMonitoredProcess() - { - _monitoredProcess?.Dispose(); - _monitoredProcess = null; - _monitoredPid = 0; - } public void Dispose() { @@ -718,6 +685,5 @@ public void Dispose() _serverRuntime.ServerStarted -= OnServerStarted; _serverRuntime.ServerExited -= OnServerExited; _serverRuntime.ServerReady -= OnServerReady; - DisposeMonitoredProcess(); } } diff --git a/src/MinecraftServerManager.App/ViewModels/ServerPropertiesViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ServerPropertiesViewModel.cs index 2f91bba..e4b40c0 100644 --- a/src/MinecraftServerManager.App/ViewModels/ServerPropertiesViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ServerPropertiesViewModel.cs @@ -83,27 +83,6 @@ public bool Validate() return isValid; } - partial void OnBoolValueChanged(bool value) - { - if (IsBoolean) - { - Value = value.ToString().ToLowerInvariant(); - } - } - - partial void OnValueChanged(string value) - { - if (IsBoolean && bool.TryParse(value, out bool b)) - { - if (BoolValue != b) - { - BoolValue = b; - } - } - - Validate(); - } - [RelayCommand] public void ResetToDefault() { @@ -175,10 +154,6 @@ public ServerPropertiesViewModel(IServerPropertiesStore propertiesStore, string _ = LoadPropertiesAsync(); } - partial void OnSearchTextChanged(string value) => ApplyFilter(); - - partial void OnSelectedCategoryChanged(string value) => ApplyFilter(); - public async Task LoadPropertiesAsync() { try diff --git a/src/MinecraftServerManager.App/Views/AboutPreferencesView.xaml b/src/MinecraftServerManager.App/Views/AboutPreferencesView.xaml index 54ec305..f6d1fc5 100644 --- a/src/MinecraftServerManager.App/Views/AboutPreferencesView.xaml +++ b/src/MinecraftServerManager.App/Views/AboutPreferencesView.xaml @@ -45,11 +45,11 @@ Padding="12,10" Margin="0,0,0,14"> - - - - - + + + + + @@ -142,7 +142,7 @@ - + - + - diff --git a/src/MinecraftServerManager.App/Views/CreateServerView.xaml b/src/MinecraftServerManager.App/Views/CreateServerView.xaml index f5e7a42..6be1d68 100644 --- a/src/MinecraftServerManager.App/Views/CreateServerView.xaml +++ b/src/MinecraftServerManager.App/Views/CreateServerView.xaml @@ -54,7 +54,7 @@ - + @@ -82,13 +82,13 @@ - + - + @@ -99,14 +99,14 @@ Margin="0,6,0,6" /> - + - + - + - + @@ -144,11 +144,11 @@ - + - + @@ -156,7 +156,7 @@ - @@ -180,7 +180,7 @@ - + /// 檢查更新結果資訊 - /// 下載與驗證進度回報 (百分比 0~100, 狀態訊息) + /// 下載與驗證進度回報(百分比 0~100、狀態訊息) /// 取消權杖 /// 安全下載並校驗通過的更新檔路徑 public Task DownloadAndVerifyUpdateAsync( @@ -18,7 +18,7 @@ public Task DownloadAndVerifyUpdateAsync( CancellationToken cancellationToken = default); /// - /// 套用更新並重新啟動主程式(安全置換執行檔並喚醒新版進程)。 + /// 套用更新並重新啟動主程式(安全置換執行檔並喚醒新版行程)。 /// /// 已驗證的更新檔案路徑 public void ApplyUpdateAndRestart(string verifiedInstallerPath); diff --git a/src/MinecraftServerManager.Core/Ports/IHttpPort.cs b/src/MinecraftServerManager.Core/Ports/IHttpPort.cs index 8def9ba..8737f6d 100644 --- a/src/MinecraftServerManager.Core/Ports/IHttpPort.cs +++ b/src/MinecraftServerManager.Core/Ports/IHttpPort.cs @@ -47,7 +47,7 @@ public Task> GetJsonAsync( CancellationToken cancellationToken = default); /// - /// 安全取得遠端 JSON 文件並以串流解析(調用端負責處置 JsonDocument) + /// 安全取得遠端 JSON 文件並以串流解析(呼叫端負責處置 JsonDocument) /// public Task> GetJsonDocumentAsync( Uri uri, diff --git a/src/MinecraftServerManager.Core/Ports/IMinecraftJavaRequirementService.cs b/src/MinecraftServerManager.Core/Ports/IMinecraftJavaRequirementService.cs index ed46633..cca93e1 100644 --- a/src/MinecraftServerManager.Core/Ports/IMinecraftJavaRequirementService.cs +++ b/src/MinecraftServerManager.Core/Ports/IMinecraftJavaRequirementService.cs @@ -1,9 +1,9 @@ namespace MinecraftServerManager.Core.Ports; /// -/// Minecraft 版本的 Java 主要版本 (majorVersion) 需求服務。 +/// Minecraft 版本的 Java 主要版本(majorVersion)需求服務。 /// 核心依據 Mojang 官方 version package 中的 javaVersion.majorVersion 動態取得並快取, -/// 嚴禁以 Minecraft 版本號進行任何硬編碼推算。 +/// 嚴禁以 Minecraft 版本號做任何硬編碼推算。 /// public interface IMinecraftJavaRequirementService { diff --git a/src/MinecraftServerManager.Core/Ports/IServerDirectorySizeService.cs b/src/MinecraftServerManager.Core/Ports/IServerDirectorySizeService.cs new file mode 100644 index 0000000..409229a --- /dev/null +++ b/src/MinecraftServerManager.Core/Ports/IServerDirectorySizeService.cs @@ -0,0 +1,28 @@ +namespace MinecraftServerManager.Core.Ports; + +/// +/// 伺服器目錄大小計算服務合約。 +/// 統一負責伺服器本體容量計算與快取管理(嚴格排除備份目錄)。 +/// +public interface IServerDirectorySizeService +{ + /// + /// 計算伺服器本體目錄大小(以位元組為單位),排除備份等外部檔案 + /// + public Task CalculateServerSizeAsync(string serverPath, CancellationToken cancellationToken = default); + + /// + /// 格式化位元組為人類易讀格式(如 1.25 GB) + /// + public string FormatServerSize(long bytes); + + /// + /// 使特定伺服器之容量快取失效(例如備份完成、伺服器停止或手動重新整理時呼叫) + /// + public void InvalidateCache(string serverPath); + + /// + /// 清除所有快取 + /// + public void ClearAllCache(); +} diff --git a/src/MinecraftServerManager.Core/Ports/IServerIntegrityService.cs b/src/MinecraftServerManager.Core/Ports/IServerIntegrityService.cs new file mode 100644 index 0000000..5562881 --- /dev/null +++ b/src/MinecraftServerManager.Core/Ports/IServerIntegrityService.cs @@ -0,0 +1,19 @@ +using MinecraftServerManager.Domain.Servers; + +namespace MinecraftServerManager.Core.Ports; + +/// +/// 伺服器完整性檢驗結果 +/// +public sealed record ServerIntegrityResult(bool HasMissing, IReadOnlyList MissingFiles); + +/// +/// 伺服器關鍵檔案與結構完整性檢驗服務合約 +/// +public interface IServerIntegrityService +{ + /// + /// 檢查伺服器核心關鍵啟動檔案是否存在 + /// + public ServerIntegrityResult CheckImportantFiles(string serverPath, LoaderKind loader); +} diff --git a/src/MinecraftServerManager.Core/Ports/IServerManager.cs b/src/MinecraftServerManager.Core/Ports/IServerManager.cs index b1e52e1..2c6c903 100644 --- a/src/MinecraftServerManager.Core/Ports/IServerManager.cs +++ b/src/MinecraftServerManager.Core/Ports/IServerManager.cs @@ -46,7 +46,7 @@ public Task ImportServerAsync( CancellationToken cancellationToken = default); /// - /// 掃描 servers 根目錄之一級子目錄,將符合伺服器結構但尚未註冊者自動加入註冊表 + /// 掃描 servers 根目錄之一級子目錄,將符合伺服器結構但尚未註冊者自動註冊加入伺服器清單 /// public Task ScanAndRegisterAsync(CancellationToken cancellationToken = default); } diff --git a/src/MinecraftServerManager.Core/Ports/IServerRuntime.cs b/src/MinecraftServerManager.Core/Ports/IServerRuntime.cs index 6086486..ffa8f37 100644 --- a/src/MinecraftServerManager.Core/Ports/IServerRuntime.cs +++ b/src/MinecraftServerManager.Core/Ports/IServerRuntime.cs @@ -13,6 +13,11 @@ public interface IServerRuntime : IAsyncDisposable public int? ExitCode { get; } public string? ActiveServerName { get; } + /// + /// 取得伺服器行程目前之工作集(實體記憶體用量,位元組),若未執行或查詢失敗則傳回 null。 + /// + public long? GetWorkingSet64(); + public event Action? OutputLineReceived; public event Action? ServerStarted; public event Action? ServerExited; diff --git a/src/MinecraftServerManager.Core/Ports/ProcessPort.cs b/src/MinecraftServerManager.Core/Ports/ProcessPort.cs index 33f3820..d5bf216 100644 --- a/src/MinecraftServerManager.Core/Ports/ProcessPort.cs +++ b/src/MinecraftServerManager.Core/Ports/ProcessPort.cs @@ -33,11 +33,19 @@ public interface IManagedProcess : IAsyncDisposable public Task WaitForExitAsync(CancellationToken cancellationToken = default); public Task StopAsync(TimeSpan gracefulTimeout, CancellationToken cancellationToken = default); + + /// + /// 取得目前行程之工作集(實體記憶體用量,位元組),若行程已結束或查詢失敗則傳回 null。 + /// + public long? GetWorkingSet64(); } public interface IProcessRunner { public Task StartAsync(ProcessStartSpec specification, CancellationToken cancellationToken = default); + + public void StartDetached(ProcessStartSpec specification) => + throw new NotSupportedException("此行程執行器不支援啟動脫離管理的行程"); } public interface IExternalLauncher diff --git a/src/MinecraftServerManager.Core/Servers/ServerPropertiesValidator.cs b/src/MinecraftServerManager.Core/Servers/ServerPropertiesValidator.cs index 20a7569..f87de0a 100644 --- a/src/MinecraftServerManager.Core/Servers/ServerPropertiesValidator.cs +++ b/src/MinecraftServerManager.Core/Servers/ServerPropertiesValidator.cs @@ -52,7 +52,7 @@ public static (bool IsValid, string? Error) ValidateProperty(string key, string return (false, "請填入有效的主機名稱或 IP 位址"); } - // 3. 連接埠範圍驗證 (1 ~ 65534) + // 3. 連接埠範圍驗證(1~65534) if (string.Equals(key, "server-port", StringComparison.OrdinalIgnoreCase) || string.Equals(key, "query.port", StringComparison.OrdinalIgnoreCase) || string.Equals(key, "rcon.port", StringComparison.OrdinalIgnoreCase)) @@ -65,7 +65,7 @@ public static (bool IsValid, string? Error) ValidateProperty(string key, string return (true, null); } - // 4. 管理端協定連接埠 (0 ~ 65535) + // 4. 管理端協定連接埠(0~65535) if (string.Equals(key, "management-server-port", StringComparison.OrdinalIgnoreCase)) { if (!int.TryParse(val, out int port) || port < 0 || port > 65535) @@ -87,7 +87,7 @@ public static (bool IsValid, string? Error) ValidateProperty(string key, string return (true, null); } - // 6. 視野與模擬距離驗證 (2 ~ 32) + // 6. 視野與模擬距離驗證(2~32) if (string.Equals(key, "view-distance", StringComparison.OrdinalIgnoreCase) || string.Equals(key, "simulation-distance", StringComparison.OrdinalIgnoreCase)) { @@ -99,7 +99,7 @@ public static (bool IsValid, string? Error) ValidateProperty(string key, string return (true, null); } - // 7. 實體廣播範圍百分比 (10 ~ 1000) + // 7. 實體廣播範圍百分比(10~1000) if (string.Equals(key, "entity-broadcast-range-percentage", StringComparison.OrdinalIgnoreCase)) { if (!int.TryParse(val, out int pct) || pct < 10 || pct > 1000) @@ -132,7 +132,7 @@ public static (bool IsValid, string? Error) ValidateProperty(string key, string return (true, null); } - // 10. 權限等級驗證 (0 ~ 4) + // 10. 權限等級驗證(0~4) if (string.Equals(key, "function-permission-level", StringComparison.OrdinalIgnoreCase) || string.Equals(key, "op-permission-level", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/MinecraftServerManager.Core/Utilities/MinecraftVersionSemantics.cs b/src/MinecraftServerManager.Core/Utilities/MinecraftVersionSemantics.cs index 49fcd30..a9f1f3c 100644 --- a/src/MinecraftServerManager.Core/Utilities/MinecraftVersionSemantics.cs +++ b/src/MinecraftServerManager.Core/Utilities/MinecraftVersionSemantics.cs @@ -66,7 +66,7 @@ public static bool IsFabricCompatible(string minecraftVersion) return false; } - // 明確為舊版 1.0 ~ 1.13.x 則不支援 + // 明確為舊版 1.0~1.13.x 則不支援 if (VersionValue.TryParse(minecraftVersion, out var parsed)) { if (parsed.Major == 1 && parsed.Minor < 14) diff --git a/src/MinecraftServerManager.Domain/Mods/FlexVersion.cs b/src/MinecraftServerManager.Domain/Mods/FlexVersion.cs index e0ef3ad..3ccbe18 100644 --- a/src/MinecraftServerManager.Domain/Mods/FlexVersion.cs +++ b/src/MinecraftServerManager.Domain/Mods/FlexVersion.cs @@ -2,7 +2,7 @@ namespace MinecraftServerManager.Domain.Mods; /// /// 彈性語意版本比較引擎(FlexVersion)。 -/// 規格參照 Prism Launcher 之版本解析標準,以原生 C# / .NET 10 進行乾淨室獨立實作(Clean-room implementation)。 +/// 規格參照 Prism Launcher 之版本解析標準,以原生 C# / .NET 10 落實乾淨室獨立實作(Clean-room implementation)。 /// 能精準解析 Minecraft 自由格式之模組版本字串,支援多區段比對、前導零正規化、忽略 '+' 建置中繼資料與預發布版降級,提供與人類直覺完全相符之版本排序與比較。 /// public sealed class FlexVersion : IComparable, IEquatable, IComparable @@ -106,7 +106,7 @@ public static int Compare(string? a, string? b) } /// - /// 正規化 Minecraft 模組常見的版本字串前綴與元資料。 + /// 正規化 Minecraft 模組常見的版本字串前綴與中繼資料。 /// 例如:v1.2.3 -> 1.2.3, mc1.21.1-0.15.2-fabric -> 0.15.2-fabric /// public static string NormalizeModVersionString(string? raw) @@ -161,7 +161,7 @@ private static List
ParseSections(string raw) char c = span[i]; if (c == '+') { - // 依照 FlexVer 規範:忽略 '+' 之後的建置元資料 (Build Metadata) + // 依照 FlexVer 規範:忽略 '+' 之後的建置中繼資料 (Build Metadata) break; } diff --git a/src/MinecraftServerManager.Domain/Mods/LocalModUpdateCandidate.cs b/src/MinecraftServerManager.Domain/Mods/LocalModUpdateCandidate.cs deleted file mode 100644 index 711054c..0000000 --- a/src/MinecraftServerManager.Domain/Mods/LocalModUpdateCandidate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace MinecraftServerManager.Domain.Mods; - -/// -/// 本地模組的更新候選版本與相容性檢查結果 -/// -public sealed record LocalModUpdateCandidate -{ - public LocalModInfo LocalMod { get; } - public OnlineModVersion? UpdateVersion { get; } - public OnlineModCompatibilityReport? CompatibilityReport { get; } - public bool HasUpdate => UpdateVersion != null; - - public LocalModUpdateCandidate( - LocalModInfo localMod, - OnlineModVersion? updateVersion = null, - OnlineModCompatibilityReport? compatibilityReport = null) - { - ArgumentNullException.ThrowIfNull(localMod); - LocalMod = localMod; - UpdateVersion = updateVersion; - CompatibilityReport = compatibilityReport; - } -} diff --git a/src/MinecraftServerManager.Domain/Servers/ProgressEvent.cs b/src/MinecraftServerManager.Domain/Servers/ProgressEvent.cs deleted file mode 100644 index 3a111bc..0000000 --- a/src/MinecraftServerManager.Domain/Servers/ProgressEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace MinecraftServerManager.Domain.Servers; - -public sealed record ProgressEvent( - string Phase, - string Message, - int CompletedUnits = 0, - int? TotalUnits = null, - double? OverallPercent = null) -{ - public double? PhasePercent => TotalUnits is > 0 - ? Math.Clamp((double)CompletedUnits / TotalUnits.Value * 100, 0, 100) - : null; -} diff --git a/src/MinecraftServerManager.Infrastructure/FileSystem/SafeFileSystem.cs b/src/MinecraftServerManager.Infrastructure/FileSystem/SafeFileSystem.cs index 15097f8..4974319 100644 --- a/src/MinecraftServerManager.Infrastructure/FileSystem/SafeFileSystem.cs +++ b/src/MinecraftServerManager.Infrastructure/FileSystem/SafeFileSystem.cs @@ -1,6 +1,23 @@ +using System.Text; +using MinecraftServerManager.Infrastructure.Utilities; + namespace MinecraftServerManager.Infrastructure.FileSystem; -public sealed record FileSystemEntry(string FullPath, bool IsDirectory, long Length); +public sealed record FileSystemEntry( + string FullPath, + bool IsDirectory, + long Length, + DateTime LastWriteTimeUtc = default, + DateTime CreationTimeUtc = default); + +public sealed record FileSystemMetadata( + string FullPath, + string Name, + long Length, + DateTime LastWriteTime, + DateTime LastWriteTimeUtc, + DateTime CreationTimeUtc, + FileAttributes Attributes); public static class SafeFileSystem { @@ -38,6 +55,324 @@ public static string ResolveStablePath(string path, bool createParent = false) return fullPath; } + /// + /// 驗證一般檔案路徑後檢查檔案是否存在。 + /// + public static bool FileExists(string path) + { + try + { + return File.Exists(ResolveStablePath(path)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) + { + return false; + } + } + + /// + /// 驗證目錄路徑後檢查目錄是否存在。 + /// + public static bool DirectoryExists(string path) + { + try + { + return Directory.Exists(ResolveStablePath(path)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) + { + return false; + } + } + + /// + /// 建立經過路徑穩定性檢查的目錄。 + /// + public static string CreateDirectory(string path) => ResolveStableDirectory(path, create: true); + + /// + /// 以受限走訪列舉目錄中的檔案。 + /// + public static IReadOnlyList EnumerateFiles( + string directoryPath, + string searchPattern, + SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + ArgumentException.ThrowIfNullOrWhiteSpace(searchPattern); + IReadOnlyList entries = searchOption switch + { + SearchOption.TopDirectoryOnly => ListBoundedDirectory(directoryPath), + SearchOption.AllDirectories => WalkBoundedTree(directoryPath), + _ => throw new ArgumentOutOfRangeException(nameof(searchOption)), + }; + + return entries + .Where(entry => !entry.IsDirectory && + System.IO.Enumeration.FileSystemName.MatchesSimpleExpression( + searchPattern, + Path.GetFileName(entry.FullPath), + ignoreCase: true)) + .Select(static entry => entry.FullPath) + .ToArray(); + } + + /// + /// 取得經過路徑穩定性檢查之目錄的最後修改時間。 + /// + public static DateTime GetDirectoryLastWriteTime(string path) => + Directory.GetLastWriteTime(ResolveStableDirectory(path)); + + /// + /// 取得指定路徑所在磁碟之可用剩餘空間位元組數;若無法取得或路徑無效則傳回 null。 + /// + public static long? GetAvailableFreeSpace(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + string? root = Path.GetPathRoot(Path.GetFullPath(path)); + if (string.IsNullOrWhiteSpace(root)) + { + return null; + } + + var drive = new DriveInfo(root); + if (drive.IsReady) + { + return drive.AvailableFreeSpace; + } + } + catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException) + { + } + + return null; + } + + public static long GetFileLength(string path) => new FileInfo(ResolveStablePath(path)).Length; + + public static DateTime GetFileLastWriteTime(string path) => File.GetLastWriteTime(ResolveStablePath(path)); + + public static DateTime GetFileLastWriteTimeUtc(string path) => File.GetLastWriteTimeUtc(ResolveStablePath(path)); + + public static FileSystemMetadata GetFileMetadata(string path) + { + string stablePath = ResolveStablePath(path); + var info = new FileInfo(stablePath); + info.Refresh(); + if (!info.Exists) + { + throw new FileNotFoundException("找不到檔案", stablePath); + } + + return new FileSystemMetadata( + info.FullName, + info.Name, + info.Length, + info.LastWriteTime, + info.LastWriteTimeUtc, + info.CreationTimeUtc, + info.Attributes); + } + + public static string? ResolveFileLinkTarget(string path) + { + string stablePath = GetFullPath(path); + var info = new FileInfo(stablePath); + return info.ResolveLinkTarget(returnFinalTarget: true)?.FullName; + } + + public static void SetDirectoryHidden(string path, bool hidden) + { + string stablePath = ResolveStableDirectory(path); + FileAttributes attributes = File.GetAttributes(stablePath); + FileAttributes updated = hidden ? attributes | FileAttributes.Hidden : attributes & ~FileAttributes.Hidden; + if (updated != attributes) + { + File.SetAttributes(stablePath, updated); + } + } + + public static IReadOnlyList EnumerateDirectories( + string directoryPath, + string searchPattern = "*", + SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + IReadOnlyList entries = searchOption switch + { + SearchOption.TopDirectoryOnly => ListBoundedDirectory(directoryPath), + SearchOption.AllDirectories => WalkBoundedTree(directoryPath), + _ => throw new ArgumentOutOfRangeException(nameof(searchOption)), + }; + + return entries + .Where(entry => entry.IsDirectory && + System.IO.Enumeration.FileSystemName.MatchesSimpleExpression( + searchPattern, + Path.GetFileName(entry.FullPath), + ignoreCase: true)) + .Select(static entry => entry.FullPath) + .ToArray(); + } + + public static IReadOnlyList EnumerateFileSystemEntries(string directoryPath) => + ListBoundedDirectory(directoryPath, rejectReparse: false).Select(static entry => entry.FullPath).ToArray(); + + public static FileStream OpenRead( + string filePath, + FileShare share = FileShare.Read, + FileOptions options = FileOptions.SequentialScan) + { + string stablePath = ResolveStablePath(filePath); + if (!File.Exists(stablePath)) + { + throw new FileNotFoundException("找不到檔案", stablePath); + } + + return new FileStream(stablePath, FileMode.Open, FileAccess.Read, share, 4096, options); + } + + public static string ReadAllText(string filePath, Encoding? encoding = null, long maxBytes = 16L * 1024 * 1024) + { + byte[] bytes = ReadRegularFile(filePath, maxBytes) + ?? throw new FileNotFoundException("找不到或無法安全讀取檔案", filePath); + using var reader = new StreamReader(new MemoryStream(bytes, writable: false), encoding ?? Encoding.UTF8); + return reader.ReadToEnd(); + } + + public static string[] ReadAllLines(string filePath, Encoding? encoding = null, long maxBytes = 16L * 1024 * 1024) + { + if (GetFileLength(filePath) > maxBytes) + { + throw new IOException($"檔案大小超過安全上限:{filePath}"); + } + + using var reader = new StreamReader(OpenRead(filePath), encoding ?? Encoding.UTF8); + var lines = new List(); + while (reader.ReadLine() is { } line) + { + lines.Add(line); + } + + return [.. lines]; + } + + public static Task ReadAllTextAsync( + string filePath, + Encoding? encoding = null, + long maxBytes = 16L * 1024 * 1024, + CancellationToken cancellationToken = default) => + Task.Run(() => ReadAllText(filePath, encoding, maxBytes), cancellationToken); + + public static Task WriteAllBytesAsync( + string filePath, + ReadOnlyMemory content, + CancellationToken cancellationToken = default) => + Task.Run(() => WriteAllBytes(filePath, content.Span), cancellationToken); + + public static Task WriteAllTextAsync( + string filePath, + string content, + Encoding? encoding = null, + CancellationToken cancellationToken = default) => + Task.Run(() => WriteAllText(filePath, content, encoding), cancellationToken); + + public static void WriteAllBytes(string filePath, ReadOnlySpan content) + { + if (!AtomicFileWriter.WriteBytes(filePath, content)) + { + throw new IOException($"無法安全寫入檔案:{filePath}"); + } + } + + public static void WriteAllText(string filePath, string content, Encoding? encoding = null) + { + ArgumentNullException.ThrowIfNull(content); + Encoding textEncoding = encoding ?? new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + byte[] preamble = textEncoding.GetPreamble(); + byte[] body = textEncoding.GetBytes(content); + byte[] payload = new byte[preamble.Length + body.Length]; + preamble.CopyTo(payload, 0); + body.CopyTo(payload, preamble.Length); + WriteAllBytes(filePath, payload); + } + + public static void DeleteFile(string filePath) + { + string stablePath = ResolveStablePath(filePath); + if (File.Exists(stablePath)) + { + ClearReadOnlyAttribute(stablePath); + File.Delete(stablePath); + } + } + + public static void DeleteDirectory(string directoryPath, bool recursive = false) + { + string stablePath = ResolveStableDirectory(directoryPath); + if (string.Equals(stablePath, Path.GetPathRoot(stablePath), StringComparison.OrdinalIgnoreCase)) + { + throw new SafeFileSystemException("不得刪除磁碟根目錄"); + } + + if (recursive) + { + RemoveReadOnlyAttributesRecursive(stablePath); + } + else + { + ClearReadOnlyAttribute(stablePath); + } + + Directory.Delete(stablePath, recursive); + } + + public static void MovePath(string sourcePath, string destinationPath, bool overwrite = false) + { + string source = ResolveStablePath(sourcePath); + bool isDirectory = Directory.Exists(source); + string destination = ResolveStablePath(destinationPath, createParent: true); + + if (IsReparsePoint(source) || IsReparsePoint(destination)) + { + throw new SafeFileSystemException("移動來源與目的地不得為 reparse point"); + } + + if (isDirectory) + { + if (overwrite && Directory.Exists(destination)) + { + DeleteDirectory(destination, recursive: true); + } + + Directory.Move(source, destination); + return; + } + + File.Move(source, destination, overwrite); + } + + public static void CopyFile(string sourcePath, string destinationPath, bool overwrite = false) + { + string source = ResolveStablePath(sourcePath); + if (!File.Exists(source) || IsReparsePoint(source)) + { + throw new FileNotFoundException("找不到一般檔案來源", source); + } + + string destination = ResolveStablePath(destinationPath, createParent: true); + if (IsReparsePoint(destination)) + { + throw new SafeFileSystemException("複製目的地不得為 reparse point"); + } + + File.Copy(source, destination, overwrite); + } + public static bool IsReparsePoint(string path) { try @@ -183,7 +518,7 @@ private static void AddEntry( } totalBytes += length; - entries.Add(new FileSystemEntry(info.FullName, isDirectory, length)); + entries.Add(new FileSystemEntry(info.FullName, isDirectory, length, info.LastWriteTimeUtc, info.CreationTimeUtc)); } public static bool DeleteWithin(string baseDirectory, string targetPath) diff --git a/src/MinecraftServerManager.Infrastructure/FileSystem/SafeZipArchive.cs b/src/MinecraftServerManager.Infrastructure/FileSystem/SafeZipArchive.cs index ca56c80..3c0530f 100644 --- a/src/MinecraftServerManager.Infrastructure/FileSystem/SafeZipArchive.cs +++ b/src/MinecraftServerManager.Infrastructure/FileSystem/SafeZipArchive.cs @@ -41,13 +41,12 @@ public static ZipArchive OpenRead( ArgumentOutOfRangeException.ThrowIfNegative(maxArchiveBytes); string path = SafeFileSystem.ResolveStablePath(archivePath); - var fileInfo = new FileInfo(path); - if (!fileInfo.Exists) + if (!SafeFileSystem.FileExists(path)) { throw new FileNotFoundException("找不到 ZIP 檔案", path); } - if (fileInfo.Length > maxArchiveBytes) + if (SafeFileSystem.GetFileLength(path) > maxArchiveBytes) { throw new SafeArchiveException("壓縮檔大小超過安全上限"); } @@ -55,18 +54,80 @@ public static ZipArchive OpenRead( try { var archive = ZipFile.OpenRead(path); - if (archive.Entries.Count > maxMembers) + try + { + ValidateArchive(archive, maxMembers, DefaultMaxTotalBytes); + return archive; + } + catch { archive.Dispose(); - throw new SafeArchiveException("壓縮檔成員數量超過安全上限"); + throw; } + } + catch (InvalidDataException exception) + { + throw new SafeArchiveException("ZIP 檔案格式無效", exception); + } + } - return archive; + public static ZipArchive OpenRead( + Stream stream, + bool leaveOpen = false, + int maxMembers = DefaultMaxMembers, + long maxArchiveBytes = DefaultMaxArchiveBytes) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentOutOfRangeException.ThrowIfNegative(maxMembers); + ArgumentOutOfRangeException.ThrowIfNegative(maxArchiveBytes); + if (!stream.CanRead || !stream.CanSeek || stream.Length > maxArchiveBytes) + { + throw new SafeArchiveException("壓縮檔串流無法讀取或超過安全大小上限"); + } + + ZipArchive archive; + try + { + archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen); } catch (InvalidDataException exception) { throw new SafeArchiveException("ZIP 檔案格式無效", exception); } + + try + { + ValidateArchive(archive, maxMembers, DefaultMaxTotalBytes); + return archive; + } + catch + { + archive.Dispose(); + throw; + } + } + + private static void ValidateArchive(ZipArchive archive, int maxMembers, long maxTotalBytes) + { + if (archive.Entries.Count > maxMembers) + { + throw new SafeArchiveException("壓縮檔成員數量超過安全上限"); + } + + long totalBytes = 0; + var paths = new Dictionary(StringComparer.OrdinalIgnoreCase); + var parentPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (ZipArchiveEntry entry in archive.Entries) + { + ValidateEntry(entry, DefaultMaxMemberBytes, DefaultMaxCompressionRatio); + string relativePath = SanitizeMemberName(entry.FullName); + ValidatePathCollision(entry, relativePath, paths, parentPaths); + totalBytes = checked(totalBytes + entry.Length); + if (totalBytes > maxTotalBytes) + { + throw new SafeArchiveException("壓縮檔解壓縮後大小超過安全上限"); + } + } } public static void Extract( @@ -186,17 +247,17 @@ private static void EnsureSufficientDiskSpace(string destination, long requiredB return; } - var drive = new DriveInfo(root); - if (!drive.IsReady) + long? availableFreeSpace = SafeFileSystem.GetAvailableFreeSpace(destination); + if (!availableFreeSpace.HasValue) { return; } long headroom = Math.Min(512L * 1024 * 1024, requiredBytes / 20); - if (drive.AvailableFreeSpace < requiredBytes + headroom) + if (availableFreeSpace.Value < requiredBytes + headroom) { throw new SafeArchiveException( - $"目標磁碟 {root} 剩餘空間不足:需要約 {UnitFormatter.FormatBytes(requiredBytes)},實際可用 {UnitFormatter.FormatBytes(drive.AvailableFreeSpace)}"); + $"目標磁碟 {root} 剩餘空間不足:需要約 {UnitFormatter.FormatBytes(requiredBytes)},實際可用 {UnitFormatter.FormatBytes(availableFreeSpace.Value)}"); } } catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException) @@ -397,23 +458,23 @@ public long WriteFile(string memberName, string sourcePath) { ObjectDisposedException.ThrowIf(_disposed, this); string source = SafeFileSystem.ResolveStablePath(sourcePath); - var info = new FileInfo(source); - if (!info.Exists) + if (!SafeFileSystem.FileExists(source)) { throw new FileNotFoundException("找不到要壓縮的檔案", source); } - string normalized = PrepareMember(memberName, info.Length); + long fileLength = SafeFileSystem.GetFileLength(source); + string normalized = PrepareMember(memberName, fileLength); var entry = _archive.CreateEntry(normalized, CompressionLevel.Optimal); - using var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.SequentialScan); + using var input = SafeFileSystem.OpenRead(source, FileShare.Read); using var output = entry.Open(); byte[] buffer = ArrayPool.Shared.Rent(81920); long copied = 0L; try { - while (copied < info.Length) + while (copied < fileLength) { - long remaining = info.Length - copied; + long remaining = fileLength - copied; int requested = (int)Math.Min(buffer.Length, remaining + 1); int read = input.Read(buffer, 0, requested); if (read == 0) @@ -422,7 +483,7 @@ public long WriteFile(string memberName, string sourcePath) } copied += read; - if (copied > info.Length) + if (copied > fileLength) { throw new SafeArchiveException($"壓縮來源檔案在讀取期間成長: {source}"); } @@ -430,7 +491,7 @@ public long WriteFile(string memberName, string sourcePath) output.Write(buffer, 0, read); } - if (copied != info.Length) + if (copied != fileLength) { throw new SafeArchiveException($"壓縮來源檔案在讀取期間縮短: {source}"); } @@ -513,15 +574,9 @@ private void TryDeleteTemporaryFile() { try { - if (File.Exists(_temporaryPath)) - { - File.Delete(_temporaryPath); - } - } - catch (IOException) - { + SafeFileSystem.DeleteFile(_temporaryPath); } - catch (UnauthorizedAccessException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } } diff --git a/src/MinecraftServerManager.Infrastructure/FileSystem/ServerTombstoneManager.cs b/src/MinecraftServerManager.Infrastructure/FileSystem/ServerTombstoneManager.cs index 145d5cb..3aa9ba7 100644 --- a/src/MinecraftServerManager.Infrastructure/FileSystem/ServerTombstoneManager.cs +++ b/src/MinecraftServerManager.Infrastructure/FileSystem/ServerTombstoneManager.cs @@ -31,7 +31,7 @@ public static (string TombstonePath, string JournalPath) PrepareDeleteTombstone( string root = SafeFileSystem.ResolveStableDirectory(serversRoot); string original = Path.GetFullPath(originalPath); - if (!Directory.Exists(original)) + if (!SafeFileSystem.DirectoryExists(original)) { throw new DirectoryNotFoundException($"找不到伺服器目錄: {original}"); } @@ -67,7 +67,7 @@ public static void RecoverDeleteTombstones( Action? onRecovered = null, Action? onCleaned = null) { - if (!Directory.Exists(serversRoot)) + if (!SafeFileSystem.DirectoryExists(serversRoot)) { return; } @@ -92,11 +92,11 @@ public static void RecoverDeleteTombstones( string journalPath = Path.Combine(root, $"{dirName}.json"); DeleteTombstoneRecord? record = null; - if (File.Exists(markerPath)) + if (SafeFileSystem.FileExists(markerPath)) { try { - record = JsonCodec.Deserialize(File.ReadAllText(markerPath)); + record = JsonCodec.Deserialize(SafeFileSystem.ReadAllText(markerPath)); } catch { @@ -104,11 +104,11 @@ record = null; } } - if (record == null && File.Exists(journalPath)) + if (record == null && SafeFileSystem.FileExists(journalPath)) { try { - record = JsonCodec.Deserialize(File.ReadAllText(journalPath)); + record = JsonCodec.Deserialize(SafeFileSystem.ReadAllText(journalPath)); } catch { @@ -132,9 +132,9 @@ record = null; continue; } - // 伺服器仍在登記中,代表在提交前異常終止,進行 fail-safe 還原 + // 伺服器仍在登記中,代表在提交前異常終止,執行 fail-safe 還原 string targetOriginal = Path.GetFullPath(registeredPath); - if (Directory.Exists(targetOriginal)) + if (SafeFileSystem.DirectoryExists(targetOriginal)) { // 原目錄已存在,保留暫存目錄以避免覆蓋 continue; diff --git a/src/MinecraftServerManager.Infrastructure/Http/HttpDownloadClient.cs b/src/MinecraftServerManager.Infrastructure/Http/HttpDownloadClient.cs index ec99e42..04f9de0 100644 --- a/src/MinecraftServerManager.Infrastructure/Http/HttpDownloadClient.cs +++ b/src/MinecraftServerManager.Infrastructure/Http/HttpDownloadClient.cs @@ -30,6 +30,17 @@ public sealed record HttpDownloadProgress(long BytesDownloaded, long TotalBytes) public sealed record HttpDownloadResult(bool Success, string? Error = null, long BytesDownloaded = 0); +public sealed record HttpRequestRetryOptions +{ + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30); + + public int MaxAttempts { get; init; } = 3; + + public TimeSpan RetryDelay { get; init; } = TimeSpan.FromMilliseconds(100); + + public int MaxRedirects { get; init; } = 10; +} + public sealed class HttpDownloadClient : IHttpPort { public const long MaxJsonBytes = 16L * 1024 * 1024; @@ -50,15 +61,9 @@ public sealed class HttpDownloadClient : IHttpPort private static readonly FrozenSet RetryableStatuses = RetryableStatusesSource.ToFrozenSet(); - private static readonly JsonSerializerOptions DefaultJsonCodecOptions = new() - { - PropertyNameCaseInsensitive = true, - TypeInfoResolver = Serialization.AppJsonSerializerContext.Default, - }; - private readonly HttpClient _client; private readonly Func>? _resolver; - private readonly JsonSerializerOptions _jsonCodecOptions; + private readonly JsonSerializerOptions? _jsonCodecOptions; public HttpDownloadClient( HttpClient client, @@ -68,7 +73,7 @@ public HttpDownloadClient( ArgumentNullException.ThrowIfNull(client); _client = client; _resolver = resolver; - _jsonCodecOptions = jsonSerializerOptions ?? DefaultJsonCodecOptions; + _jsonCodecOptions = jsonSerializerOptions; } /// @@ -99,11 +104,14 @@ async Task IHttpPort.DownloadAsync( } /// - /// IHttpPort 介面實作:安全取得 JSON 資料並反序列化 + /// 執行具備重試、重定向與逾時控制的 HTTP 請求 /// - public async Task> GetJsonAsync( + private async Task ExecuteRequestWithRetryAsync( Uri uri, - CancellationToken cancellationToken = default) + HttpRequestRetryOptions options, + Func> responseHandler, + Func failureFactory, + CancellationToken cancellationToken) { try { @@ -111,303 +119,225 @@ public async Task> GetJsonAsync( } catch (HttpSecurityException) { - return new HttpJsonResponse(null, default, "invalid_request"); + return failureFactory("invalid_request", null); } - const int maxAttempts = DefaultMaxAttempts; - var timeout = TimeSpan.FromSeconds(10); - var retryDelay = TimeSpan.FromMilliseconds(100); - - for (int attempt = 1; attempt <= maxAttempts; attempt++) + for (int attempt = 1; attempt <= options.MaxAttempts; attempt++) { using var attemptCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - attemptCancellation.CancelAfter(timeout); + attemptCancellation.CancelAfter(options.Timeout); try { - var response = await SendWithRedirectsAsync(uri, DefaultMaxRedirects, attemptCancellation.Token).ConfigureAwait(false); + var response = await SendWithRedirectsAsync(uri, options.MaxRedirects, attemptCancellation.Token).ConfigureAwait(false); using (response) { - if (RetryableStatuses.Contains(response.StatusCode) && attempt < maxAttempts) + if (RetryableStatuses.Contains(response.StatusCode) && attempt < options.MaxAttempts) { - await DelayBeforeRetryAsync(response, retryDelay, attempt, cancellationToken).ConfigureAwait(false); + await DelayBeforeRetryAsync(response, options.RetryDelay, attempt, cancellationToken).ConfigureAwait(false); continue; } - int statusCode = (int)response.StatusCode; - if (response.StatusCode == HttpStatusCode.NotFound) - { - return new HttpJsonResponse(statusCode, default, "not_found"); - } - - if (response.StatusCode == HttpStatusCode.TooManyRequests) - { - return new HttpJsonResponse(statusCode, default, "rate_limited"); - } - - if ((int)response.StatusCode >= 500) - { - return new HttpJsonResponse(statusCode, default, "transient"); - } - - if (!response.IsSuccessStatusCode) - { - return new HttpJsonResponse(statusCode, default, "invalid_request"); - } - - long contentLength = response.Content.Headers.ContentLength ?? 0; - if (contentLength > MaxJsonBytes) - { - return new HttpJsonResponse(statusCode, default, "invalid_response"); - } - - using var bodyStream = await ReadLimitedMemoryStreamAsync(response, MaxJsonBytes, attemptCancellation.Token).ConfigureAwait(false); - try - { - ReadOnlySpan utf8Span = bodyStream.TryGetBuffer(out ArraySegment segment) - ? segment - : bodyStream.ToArray(); - - var payload = JsonSerializer.Deserialize(utf8Span, _jsonCodecOptions); - if (payload is null) - { - return new HttpJsonResponse(statusCode, default, "invalid_response"); - } - - return new HttpJsonResponse(statusCode, payload, string.Empty); - } - catch (JsonException) + var (handled, result) = await responseHandler(response, attemptCancellation.Token).ConfigureAwait(false); + if (handled) { - return new HttpJsonResponse(statusCode, default, "invalid_response"); + return result; } } } catch (HttpSecurityException) { - return new HttpJsonResponse(null, default, "invalid_request"); + return failureFactory("invalid_request", null); } - catch (HttpRequestException) when (attempt < maxAttempts) + catch (HttpRequestException) when (attempt < options.MaxAttempts) { - await Task.Delay(ScaleDelay(retryDelay, attempt), cancellationToken).ConfigureAwait(false); + await Task.Delay(ScaleDelay(options.RetryDelay, attempt), cancellationToken).ConfigureAwait(false); } catch (HttpRequestException) { - return new HttpJsonResponse(null, default, "transient"); + return failureFactory("transient", null); } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && attempt < maxAttempts) + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && attempt < options.MaxAttempts) { - await Task.Delay(ScaleDelay(retryDelay, attempt), cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return new HttpJsonResponse(null, default, "timeout"); + await Task.Delay(ScaleDelay(options.RetryDelay, attempt), cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { - return new HttpJsonResponse(null, default, "timeout"); + return failureFactory("timeout", null); } catch (IOException) { - return new HttpJsonResponse(null, default, "transient"); + return failureFactory("transient", null); } } - return new HttpJsonResponse(null, default, "transient"); + return failureFactory("transient", null); } /// - /// IHttpPort 介面實作:安全取得 JSON 文件並以串流解析(調用端負責處置 JsonDocument) + /// 執行通用 JSON 請求解析 /// - public async Task> GetJsonDocumentAsync( + private Task> ExecuteJsonRequestAsync( Uri uri, - CancellationToken cancellationToken = default) + HttpRequestRetryOptions options, + Func> parser, + CancellationToken cancellationToken) { - try - { - HttpUrlPolicy.ValidateStatic(uri); - } - catch (HttpSecurityException) - { - return new HttpJsonResponse(null, default, "invalid_request"); - } - - const int maxAttempts = DefaultMaxAttempts; - var timeout = TimeSpan.FromSeconds(30); - var retryDelay = TimeSpan.FromMilliseconds(100); - - for (int attempt = 1; attempt <= maxAttempts; attempt++) - { - using var attemptCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - attemptCancellation.CancelAfter(timeout); - - try + return ExecuteRequestWithRetryAsync( + uri, + options, + async (response, token) => { - var response = await SendWithRedirectsAsync(uri, DefaultMaxRedirects, attemptCancellation.Token).ConfigureAwait(false); - using (response) + int statusCode = (int)response.StatusCode; + if (response.StatusCode == HttpStatusCode.NotFound) { - if (RetryableStatuses.Contains(response.StatusCode) && attempt < maxAttempts) - { - await DelayBeforeRetryAsync(response, retryDelay, attempt, cancellationToken).ConfigureAwait(false); - continue; - } + return (true, new HttpJsonResponse(statusCode, default, "not_found")); + } - int statusCode = (int)response.StatusCode; - if (response.StatusCode == HttpStatusCode.NotFound) - { - return new HttpJsonResponse(statusCode, default, "not_found"); - } + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + return (true, new HttpJsonResponse(statusCode, default, "rate_limited")); + } - if (response.StatusCode == HttpStatusCode.TooManyRequests) - { - return new HttpJsonResponse(statusCode, default, "rate_limited"); - } + if ((int)response.StatusCode >= 500) + { + return (true, new HttpJsonResponse(statusCode, default, "transient")); + } - if ((int)response.StatusCode >= 500) - { - return new HttpJsonResponse(statusCode, default, "transient"); - } + if (!response.IsSuccessStatusCode) + { + return (true, new HttpJsonResponse(statusCode, default, "invalid_request")); + } - if (!response.IsSuccessStatusCode) - { - return new HttpJsonResponse(statusCode, default, "invalid_request"); - } + long contentLength = response.Content.Headers.ContentLength ?? 0; + if (contentLength > MaxJsonBytes) + { + return (true, new HttpJsonResponse(statusCode, default, "invalid_response")); + } - long contentLength = response.Content.Headers.ContentLength ?? 0; - if (contentLength > MaxJsonBytes) + using var bodyStream = await ReadLimitedMemoryStreamAsync(response, MaxJsonBytes, token).ConfigureAwait(false); + try + { + var (success, value) = await parser(bodyStream, token).ConfigureAwait(false); + if (!success || value is null) { - return new HttpJsonResponse(statusCode, default, "invalid_response"); + return (true, new HttpJsonResponse(statusCode, default, "invalid_response")); } - using var bodyStream = await ReadLimitedMemoryStreamAsync(response, MaxJsonBytes, attemptCancellation.Token).ConfigureAwait(false); - try - { - bodyStream.Position = 0; - var doc = await JsonDocument.ParseAsync(bodyStream, cancellationToken: attemptCancellation.Token).ConfigureAwait(false); - return new HttpJsonResponse(statusCode, doc, string.Empty); - } - catch (JsonException) - { - return new HttpJsonResponse(statusCode, default, "invalid_response"); - } + return (true, new HttpJsonResponse(statusCode, value, string.Empty)); } - } - catch (HttpSecurityException) - { - return new HttpJsonResponse(null, default, "invalid_request"); - } - catch (HttpRequestException) when (attempt < maxAttempts) - { - await Task.Delay(ScaleDelay(retryDelay, attempt), cancellationToken).ConfigureAwait(false); - } - catch (HttpRequestException) - { - return new HttpJsonResponse(null, default, "transient"); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && attempt < maxAttempts) - { - await Task.Delay(ScaleDelay(retryDelay, attempt), cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return new HttpJsonResponse(null, default, "timeout"); - } - catch (OperationCanceledException) - { - return new HttpJsonResponse(null, default, "timeout"); - } - catch (IOException) - { - return new HttpJsonResponse(null, default, "transient"); - } - } - - return new HttpJsonResponse(null, default, "transient"); + catch (JsonException) + { + return (true, new HttpJsonResponse(statusCode, default, "invalid_response")); + } + }, + failureFactory: (error, status) => new HttpJsonResponse(status, default, error), + cancellationToken); } /// - /// IHttpPort 介面實作:安全取得文字內容 + /// IHttpPort 介面實作:安全取得 JSON 資料並反序列化 + /// + public Task> GetJsonAsync( + Uri uri, + CancellationToken cancellationToken = default) => + GetJsonAsync(uri, options: null, cancellationToken); + + /// + /// 安全取得 JSON 資料並反序列化(支援自訂重試選項) /// - public async Task GetTextAsync( + public Task> GetJsonAsync( Uri uri, + HttpRequestRetryOptions? options, CancellationToken cancellationToken = default) { - try - { - HttpUrlPolicy.ValidateStatic(uri); - } - catch (HttpSecurityException) - { - return null; - } + var retryOptions = options ?? new HttpRequestRetryOptions { Timeout = TimeSpan.FromSeconds(10) }; + return ExecuteJsonRequestAsync( + uri, + retryOptions, + (stream, _) => + { + ReadOnlySpan utf8Span = stream.TryGetBuffer(out ArraySegment segment) + ? segment + : stream.ToArray(); + var payload = JsonCodec.Deserialize(utf8Span, _jsonCodecOptions); + return ValueTask.FromResult((payload is not null, payload)); + }, + cancellationToken); + } - const int maxAttempts = DefaultMaxAttempts; - var timeout = TimeSpan.FromSeconds(30); - var retryDelay = TimeSpan.FromMilliseconds(100); + /// + /// IHttpPort 介面實作:安全取得 JSON 文件並以串流解析(呼叫端負責處置 JsonDocument) + /// + public Task> GetJsonDocumentAsync( + Uri uri, + CancellationToken cancellationToken = default) => + GetJsonDocumentAsync(uri, options: null, cancellationToken); - for (int attempt = 1; attempt <= maxAttempts; attempt++) - { - using var attemptCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - attemptCancellation.CancelAfter(timeout); + /// + /// 安全取得 JSON 文件並以串流解析(支援自訂重試選項) + /// + public Task> GetJsonDocumentAsync( + Uri uri, + HttpRequestRetryOptions? options, + CancellationToken cancellationToken = default) + { + var retryOptions = options ?? new HttpRequestRetryOptions { Timeout = TimeSpan.FromSeconds(30) }; + return ExecuteJsonRequestAsync( + uri, + retryOptions, + async (stream, token) => + { + stream.Position = 0; + var doc = await JsonCodec.ParseDocumentAsync(stream, cancellationToken: token).ConfigureAwait(false); + return (doc is not null, doc); + }, + cancellationToken); + } - try + /// + /// IHttpPort 介面實作:安全取得文字內容 + /// + public Task GetTextAsync( + Uri uri, + CancellationToken cancellationToken = default) => + GetTextAsync(uri, options: null, cancellationToken); + + /// + /// 安全取得文字內容(支援自訂重試選項) + /// + public Task GetTextAsync( + Uri uri, + HttpRequestRetryOptions? options, + CancellationToken cancellationToken = default) + { + var retryOptions = options ?? new HttpRequestRetryOptions { Timeout = TimeSpan.FromSeconds(30) }; + return ExecuteRequestWithRetryAsync( + uri, + retryOptions, + async (response, token) => { - var response = await SendWithRedirectsAsync(uri, DefaultMaxRedirects, attemptCancellation.Token).ConfigureAwait(false); - using (response) + if (!response.IsSuccessStatusCode) { - if (RetryableStatuses.Contains(response.StatusCode) && attempt < maxAttempts) - { - await DelayBeforeRetryAsync(response, retryDelay, attempt, cancellationToken).ConfigureAwait(false); - continue; - } - - if (!response.IsSuccessStatusCode) - { - return null; - } - - long contentLength = response.Content.Headers.ContentLength ?? 0; - if (contentLength > MaxTextBytes) - { - return null; - } - - using var bodyStream = await ReadLimitedMemoryStreamAsync(response, MaxTextBytes, attemptCancellation.Token).ConfigureAwait(false); - ReadOnlySpan utf8Span = bodyStream.TryGetBuffer(out ArraySegment segment) - ? segment - : bodyStream.ToArray(); + return (true, null); + } - return Encoding.UTF8.GetString(utf8Span); + long contentLength = response.Content.Headers.ContentLength ?? 0; + if (contentLength > MaxTextBytes) + { + return (true, null); } - } - catch (HttpSecurityException) - { - return null; - } - catch (HttpRequestException) when (attempt < maxAttempts) - { - await Task.Delay(ScaleDelay(retryDelay, attempt), cancellationToken).ConfigureAwait(false); - } - catch (HttpRequestException) - { - return null; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && attempt < maxAttempts) - { - await Task.Delay(ScaleDelay(retryDelay, attempt), cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - return null; - } - catch (IOException) - { - return null; - } - } - return null; - } + using var bodyStream = await ReadLimitedMemoryStreamAsync(response, MaxTextBytes, token).ConfigureAwait(false); + ReadOnlySpan utf8Span = bodyStream.TryGetBuffer(out ArraySegment segment) + ? segment + : bodyStream.ToArray(); + return (true, Encoding.UTF8.GetString(utf8Span)); + }, + failureFactory: (_, _) => null, + cancellationToken); + } /// /// IHttpPort 介面實作:安全傳送 POST JSON 請求並取得文字回應 /// @@ -480,19 +410,19 @@ public async Task DownloadAsync( HttpUrlPolicy.ValidateStatic(uri); string target = SafeFileSystem.ResolveStablePath(targetPath, createParent: true); string? normalizedHash = NormalizeExpectedHash(expectedHash, expectedHashAlgorithm); - if (normalizedHash is not null && File.Exists(target)) + if (normalizedHash is not null && SafeFileSystem.FileExists(target)) { string existing = HashCalculator.Shared.ComputeFileHash(target, expectedHashAlgorithm, maxBytes: options.MaxBytes); if (string.Equals(existing, normalizedHash, StringComparison.OrdinalIgnoreCase)) { - return new HttpDownloadResult(true, BytesDownloaded: new FileInfo(target).Length); + return new HttpDownloadResult(true, BytesDownloaded: SafeFileSystem.GetFileLength(target)); } } string temporary = SafeFileSystem.ResolveStablePath( target + ".part", createParent: true); - if (!options.EnableRangeResumption && File.Exists(temporary)) + if (!options.EnableRangeResumption && SafeFileSystem.FileExists(temporary)) { TryDeleteTemporaryFile(temporary); } @@ -549,11 +479,11 @@ public async Task DownloadAsync( attemptCancellation.CancelAfter(options.Timeout); long existingBytes = 0; - if (options.EnableRangeResumption && File.Exists(temporary)) + if (options.EnableRangeResumption && SafeFileSystem.FileExists(temporary)) { try { - existingBytes = new FileInfo(temporary).Length; + existingBytes = SafeFileSystem.GetFileLength(temporary); if (existingBytes >= options.MaxBytes) { TryDeleteTemporaryFile(temporary); @@ -643,7 +573,7 @@ public async Task DownloadAsync( return (false, new HttpDownloadResult(false, "無法原子提交下載檔案"), responseETag ?? knownETag); } - return (false, new HttpDownloadResult(true, BytesDownloaded: new FileInfo(target).Length), responseETag ?? knownETag); + return (false, new HttpDownloadResult(true, BytesDownloaded: SafeFileSystem.GetFileLength(target)), responseETag ?? knownETag); } } catch (HttpSecurityException exception) @@ -942,9 +872,9 @@ private static void TryDeleteTemporaryFile(string path) try { - if (File.Exists(path)) + if (SafeFileSystem.FileExists(path)) { - File.Delete(path); + SafeFileSystem.DeleteFile(path); } } catch (IOException) diff --git a/src/MinecraftServerManager.Infrastructure/Java/JavaRuntimeDetector.cs b/src/MinecraftServerManager.Infrastructure/Java/JavaRuntimeDetector.cs index 8875b55..8d90cfc 100644 --- a/src/MinecraftServerManager.Infrastructure/Java/JavaRuntimeDetector.cs +++ b/src/MinecraftServerManager.Infrastructure/Java/JavaRuntimeDetector.cs @@ -136,7 +136,7 @@ await Parallel.ForEachAsync(candidates, parallelOptions, async (candidate, ct) = if (!string.IsNullOrEmpty(directory)) { string consoleExe = Path.Combine(directory, "java.exe"); - if (File.Exists(consoleExe)) + if (SafeFileSystem.FileExists(consoleExe)) { probeExecutable = consoleExe; } @@ -147,7 +147,7 @@ await Parallel.ForEachAsync(candidates, parallelOptions, async (candidate, ct) = if (!string.IsNullOrEmpty(directory)) { string wExe = Path.Combine(directory, "javaw.exe"); - if (File.Exists(wExe)) + if (SafeFileSystem.FileExists(wExe)) { javawPath = wExe; } @@ -247,7 +247,7 @@ private static HashSet EnumerateCandidates() if (!string.IsNullOrWhiteSpace(userProfile)) { string jdksDir = Path.Combine(userProfile, ".jdks"); - if (Directory.Exists(jdksDir)) + if (SafeFileSystem.DirectoryExists(jdksDir)) { try { @@ -288,7 +288,7 @@ private static void AddCandidate(HashSet candidates, string path) } string resolved = ResolveActualExecutablePath(path); - if (File.Exists(resolved) && !IsIgnoredJavaPath(resolved) && !IsReparseOrJunctionPath(resolved) && !IsShimLauncher(resolved)) + if (SafeFileSystem.FileExists(resolved) && !IsIgnoredJavaPath(resolved) && !IsReparseOrJunctionPath(resolved) && !IsShimLauncher(resolved)) { candidates.Add(resolved); } @@ -338,7 +338,7 @@ private static bool IsShimLauncher(string path) { try { - if (!File.Exists(path)) + if (!SafeFileSystem.FileExists(path)) { return false; } @@ -363,16 +363,15 @@ private static string ResolveActualExecutablePath(string path) try { string full = Path.GetFullPath(path); - if (!File.Exists(full)) + if (!SafeFileSystem.FileExists(full)) { return full; } - var fileInfo = new FileInfo(full); - var target = fileInfo.ResolveLinkTarget(returnFinalTarget: true); - if (target is not null && File.Exists(target.FullName)) + string? targetPath = SafeFileSystem.ResolveFileLinkTarget(full); + if (targetPath is not null && SafeFileSystem.FileExists(targetPath)) { - return target.FullName; + return targetPath; } return full; @@ -385,7 +384,7 @@ private static string ResolveActualExecutablePath(string path) private static void AddRoot(HashSet roots, string? path) { - if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) + if (!string.IsNullOrWhiteSpace(path) && SafeFileSystem.DirectoryExists(path)) { roots.Add(path); } @@ -396,12 +395,12 @@ private static void AddRoot(HashSet roots, string? path) try { string cachePath = Path.Combine(RuntimePaths.GetVersionCacheDir(), CacheFileName); - if (!File.Exists(cachePath)) + if (!SafeFileSystem.FileExists(cachePath)) { return null; } - string content = await File.ReadAllTextAsync(cachePath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); + string content = await SafeFileSystem.ReadAllTextAsync(cachePath, Encoding.UTF8, cancellationToken: cancellationToken).ConfigureAwait(false); var cacheData = JsonCodec.Deserialize(content); if (cacheData?.Candidates is null or { Count: 0 }) { @@ -416,14 +415,14 @@ private static void AddRoot(HashSet roots, string? path) if (!string.IsNullOrWhiteSpace(item.Path) && Path.IsPathRooted(item.Path) && !string.Equals(Path.GetFileName(item.Path), item.Path, StringComparison.OrdinalIgnoreCase) && - File.Exists(item.Path) && + SafeFileSystem.FileExists(item.Path) && !IsIgnoredJavaPath(item.Path) && !IsReparseOrJunctionPath(item.Path) && !IsShimLauncher(item.Path)) { try { - var fileInfo = new FileInfo(item.Path); + FileSystemMetadata fileInfo = SafeFileSystem.GetFileMetadata(item.Path); if (item.LastWriteTimeUtcTicks > 0 && fileInfo.LastWriteTimeUtc.Ticks == item.LastWriteTimeUtcTicks && fileInfo.Length == item.FileSizeBytes && @@ -500,14 +499,14 @@ private static void SaveToCache(IReadOnlyList runtimes) try { string cacheDir = RuntimePaths.GetVersionCacheDir(); - Directory.CreateDirectory(cacheDir); + SafeFileSystem.CreateDirectory(cacheDir); string cachePath = Path.Combine(cacheDir, CacheFileName); var entries = runtimes .Where(static r => !string.IsNullOrWhiteSpace(r.ExecutablePath) && Path.IsPathRooted(r.ExecutablePath) && !string.Equals(Path.GetFileName(r.ExecutablePath), r.ExecutablePath, StringComparison.OrdinalIgnoreCase) && - File.Exists(r.ExecutablePath)) + SafeFileSystem.FileExists(r.ExecutablePath)) .GroupBy(static r => r.ExecutablePath, StringComparer.OrdinalIgnoreCase) .Select(static g => g.First()) .OrderBy(static r => r.MajorVersion) @@ -518,7 +517,7 @@ private static void SaveToCache(IReadOnlyList runtimes) long fileSize = 0; try { - var fi = new FileInfo(r.ExecutablePath); + FileSystemMetadata fi = SafeFileSystem.GetFileMetadata(r.ExecutablePath); lastWriteTicks = fi.LastWriteTimeUtc.Ticks; fileSize = fi.Length; } diff --git a/src/MinecraftServerManager.Infrastructure/Java/MinecraftJavaRequirementService.cs b/src/MinecraftServerManager.Infrastructure/Java/MinecraftJavaRequirementService.cs index 6e39bda..9fe285a 100644 --- a/src/MinecraftServerManager.Infrastructure/Java/MinecraftJavaRequirementService.cs +++ b/src/MinecraftServerManager.Infrastructure/Java/MinecraftJavaRequirementService.cs @@ -13,7 +13,7 @@ namespace MinecraftServerManager.Infrastructure.Java; /// /// Minecraft 版本 Java major 需求服務實作。 /// 完全依據 Mojang 官方 version package JSON 的 javaVersion.majorVersion 動態取得並快取, -/// 嚴格禁止依據版本號進行硬編碼推算。 +/// 嚴格禁止依據版本號做硬編碼推算。 /// public sealed class MinecraftJavaRequirementService : IMinecraftJavaRequirementService { @@ -184,12 +184,12 @@ private async Task> GetMinecraftVersionsWithServerAsy // 優先從本地 mc_versions_cache.json 讀取 string versionsCachePath = Path.Combine(_cacheDirectory, VersionsCacheFileName); - if (File.Exists(versionsCachePath)) + if (SafeFileSystem.FileExists(versionsCachePath)) { try { - using var stream = new FileStream(versionsCachePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); - using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + using var stream = SafeFileSystem.OpenRead(versionsCachePath, FileShare.ReadWrite | FileShare.Delete, FileOptions.Asynchronous | FileOptions.SequentialScan); + using var doc = await JsonCodec.ParseDocumentAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); if (doc.RootElement.ValueKind == JsonValueKind.Array) { @@ -356,12 +356,12 @@ private void EnsureCacheLoaded() } string legacyCachePath = Path.Combine(_cacheDirectory, RequirementsCacheFileName); - if (File.Exists(legacyCachePath)) + if (SafeFileSystem.FileExists(legacyCachePath)) { try { - string json = File.ReadAllText(legacyCachePath, Encoding.UTF8); - using var doc = JsonDocument.Parse(json); + string json = SafeFileSystem.ReadAllText(legacyCachePath, Encoding.UTF8); + using var doc = JsonCodec.ParseDocument(json); if (doc.RootElement.ValueKind == JsonValueKind.Object) { foreach (var prop in doc.RootElement.EnumerateObject()) @@ -374,7 +374,7 @@ private void EnsureCacheLoaded() } // 舊檔案已完成記憶體載入遷移,安全清理刪除 - File.Delete(legacyCachePath); + SafeFileSystem.DeleteFile(legacyCachePath); } catch (Exception ex) { @@ -383,12 +383,12 @@ private void EnsureCacheLoaded() } string paperCachePath = Path.Combine(_cacheDirectory, "paper_mc_versions_cache.json"); - if (File.Exists(paperCachePath)) + if (SafeFileSystem.FileExists(paperCachePath)) { try { - string pJson = File.ReadAllText(paperCachePath, Encoding.UTF8); - using var pDoc = JsonDocument.Parse(pJson); + string pJson = SafeFileSystem.ReadAllText(paperCachePath, Encoding.UTF8); + using var pDoc = JsonCodec.ParseDocument(pJson); if (pDoc.RootElement.ValueKind == JsonValueKind.Array) { foreach (var elem in pDoc.RootElement.EnumerateArray()) @@ -411,12 +411,12 @@ private void EnsureCacheLoaded() } string mcCachePath = Path.Combine(_cacheDirectory, VersionsCacheFileName); - if (File.Exists(mcCachePath)) + if (SafeFileSystem.FileExists(mcCachePath)) { try { - string mJson = File.ReadAllText(mcCachePath, Encoding.UTF8); - using var mDoc = JsonDocument.Parse(mJson); + string mJson = SafeFileSystem.ReadAllText(mcCachePath, Encoding.UTF8); + using var mDoc = JsonCodec.ParseDocument(mJson); if (mDoc.RootElement.ValueKind == JsonValueKind.Array) { foreach (var elem in mDoc.RootElement.EnumerateArray()) @@ -460,14 +460,14 @@ private void PersistCache() { // 刪除已廢棄之獨立快取檔案 string legacyCachePath = Path.Combine(_cacheDirectory, RequirementsCacheFileName); - if (File.Exists(legacyCachePath)) + if (SafeFileSystem.FileExists(legacyCachePath)) { - try { File.Delete(legacyCachePath); } catch { } + try { SafeFileSystem.DeleteFile(legacyCachePath); } catch { } } // 1. 同步更新官方 MC 快取 mc_versions_cache.json string mcCachePath = Path.Combine(_cacheDirectory, VersionsCacheFileName); - var mcVersions = File.Exists(mcCachePath) ? LoadCachedVersions(mcCachePath) : []; + var mcVersions = SafeFileSystem.FileExists(mcCachePath) ? LoadCachedVersions(mcCachePath) : []; bool mcChanged = false; var updatedMc = new List(mcVersions.Count + _memoryCache.Count); var seenMc = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -502,7 +502,7 @@ private void PersistCache() // 2. 同步更新 Paper 快取 paper_mc_versions_cache.json string paperCachePath = Path.Combine(_cacheDirectory, "paper_mc_versions_cache.json"); - if (File.Exists(paperCachePath)) + if (SafeFileSystem.FileExists(paperCachePath)) { var paperVersions = LoadCachedVersions(paperCachePath); bool paperChanged = false; @@ -561,18 +561,18 @@ private void PersistCache() try { string versionsCachePath = Path.Combine(_cacheDirectory, VersionsCacheFileName); - if (!File.Exists(versionsCachePath)) + if (!SafeFileSystem.FileExists(versionsCachePath)) { return null; } - string? json = ReadAllTextShared(versionsCachePath); + string json = SafeFileSystem.ReadAllText(versionsCachePath); if (json is null) { return null; } - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); if (doc.RootElement.ValueKind == JsonValueKind.Array) { @@ -674,7 +674,7 @@ private void PersistCache() return null; } - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); return ParseJavaMajorFromVersionJson(doc.RootElement); } @@ -682,11 +682,11 @@ private static List LoadCachedVersions(string cachePath) { try { - if (!File.Exists(cachePath)) + if (!SafeFileSystem.FileExists(cachePath)) { return []; } - string json = File.ReadAllText(cachePath, Encoding.UTF8); + string json = SafeFileSystem.ReadAllText(cachePath, Encoding.UTF8); return JsonCodec.Deserialize>(json) ?? []; } catch @@ -734,22 +734,4 @@ private static void SaveCachedVersions(string cachePath, IReadOnlyList> GetMinecraftVersionsAsync( catch { // 遠端失敗時嘗試過期快取 - if (File.Exists(cacheFile)) + if (SafeFileSystem.FileExists(cacheFile)) { var stale = LoadCachedVersions(cacheFile); var validStale = stale.Where(v => v.Stable && HasOfficialServerJar(v.Version)).ToList(); @@ -202,9 +202,9 @@ public async Task> GetPaperMinecraftVersionsAsync( } if (baseVersions.Count > 0) { - // 載入既有快取以進行差異比對(已具備 JavaMajor 與有效 Build 的版本無須重複呼叫 API) + // 載入既有快取以比對差異(已具備 JavaMajor 與有效 Build 的版本無須重複呼叫 API) var existingMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (File.Exists(cacheFile)) + if (SafeFileSystem.FileExists(cacheFile)) { foreach (var ev in LoadCachedVersions(cacheFile)) { @@ -257,7 +257,7 @@ public async Task> GetPaperMinecraftVersionsAsync( } catch { - if (File.Exists(cacheFile)) + if (SafeFileSystem.FileExists(cacheFile)) { var stale = LoadCachedVersions(cacheFile); var validStale = stale.Where(v => !string.IsNullOrWhiteSpace(v.Build) && v.JavaMajor is > 0).ToList(); @@ -282,7 +282,7 @@ public async Task> GetPaperMinecraftVersionsAsync( return null; } - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); if (doc.RootElement.TryGetProperty("version", out var vProp) && vProp.TryGetProperty("java", out var jProp) && jProp.TryGetProperty("version", out var jvProp) && @@ -371,7 +371,7 @@ public async Task> GetLoaderVersionsAsync( } catch { - if (File.Exists(cacheFile)) + if (SafeFileSystem.FileExists(cacheFile)) { var stale = LoadCachedVersions(cacheFile); var filtered = FilterLoaderByMcVersion(stale, loader, minecraftVersion); @@ -417,7 +417,7 @@ private async Task> FetchFabricVersionsAsync(Cancel return []; } - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); var list = new List(); foreach (var element in doc.RootElement.EnumerateArray()) { @@ -446,7 +446,7 @@ private async Task> FetchQuiltVersionsAsync(Cancell return []; } - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); var list = new List(); foreach (var element in doc.RootElement.EnumerateArray()) { @@ -533,7 +533,7 @@ public async Task> ReloadAndMergeMinecraftVersionsA { cancellationToken.ThrowIfCancellationRequested(); string cacheFile = Path.Combine(_cacheDir, "mc_versions_cache.json"); - var existing = File.Exists(cacheFile) ? LoadCachedVersions(cacheFile) : []; + var existing = SafeFileSystem.FileExists(cacheFile) ? LoadCachedVersions(cacheFile) : []; try { @@ -776,7 +776,7 @@ private static bool HasOfficialServerJar(string versionId) internal static List ParseMinecraftManifest(string json) { - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); return ParseMinecraftManifest(doc); } @@ -822,12 +822,12 @@ private static IReadOnlyList FilterMinecraftVersions( private bool IsCacheFresh(string cachePath) { - if (!File.Exists(cachePath)) + if (!SafeFileSystem.FileExists(cachePath)) { return false; } - var writeTime = File.GetLastWriteTimeUtc(cachePath); + var writeTime = SafeFileSystem.GetFileLastWriteTimeUtc(cachePath); return DateTime.UtcNow - writeTime < _cacheTtl; } @@ -835,7 +835,7 @@ private static List LoadCachedVersions(string cachePath) { try { - string json = File.ReadAllText(cachePath); + string json = SafeFileSystem.ReadAllText(cachePath); return JsonCodec.Deserialize>(json) ?? []; } catch @@ -858,7 +858,7 @@ private static void SaveCachedVersions(string cachePath, IReadOnlyList ParsePaperMinecraftVersions(string json) { - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); return ParsePaperMinecraftVersions(doc); } @@ -981,7 +981,7 @@ private void RemovePaperVersionIfNoStableBuild(string minecraftVersion) try { string cacheFile = Path.Combine(_cacheDir, "paper_mc_versions_cache.json"); - var versions = _memoryPaperMinecraftVersions ?? (File.Exists(cacheFile) ? LoadCachedVersions(cacheFile) : null); + var versions = _memoryPaperMinecraftVersions ?? (SafeFileSystem.FileExists(cacheFile) ? LoadCachedVersions(cacheFile) : null); if (versions is null || versions.Count == 0) { return; @@ -1006,7 +1006,7 @@ private void UpdatePaperVersionLatestBuild(string minecraftVersion, string build try { string cacheFile = Path.Combine(_cacheDir, "paper_mc_versions_cache.json"); - var versions = _memoryPaperMinecraftVersions ?? (File.Exists(cacheFile) ? LoadCachedVersions(cacheFile) : null); + var versions = _memoryPaperMinecraftVersions ?? (SafeFileSystem.FileExists(cacheFile) ? LoadCachedVersions(cacheFile) : null); if (versions is null || versions.Count == 0) { return; diff --git a/src/MinecraftServerManager.Infrastructure/Loaders/LoaderInstallerService.cs b/src/MinecraftServerManager.Infrastructure/Loaders/LoaderInstallerService.cs index a457462..618d8b5 100644 --- a/src/MinecraftServerManager.Infrastructure/Loaders/LoaderInstallerService.cs +++ b/src/MinecraftServerManager.Infrastructure/Loaders/LoaderInstallerService.cs @@ -50,7 +50,7 @@ void SafeReport(string phase, string message, int pct) { SafeReport("vanilla_prepare", "正在準備原版伺服器檔案...", 5); string serverJarPath = Path.Combine(stableServerDir, "server.jar"); - if (!File.Exists(serverJarPath)) + if (!SafeFileSystem.FileExists(serverJarPath)) { var vanillaProgress = progress is null ? null : new Progress(vp => { @@ -151,7 +151,7 @@ private async Task InstallPaperAsync( private static (string? DownloadUrl, string? FileName, string? Sha256) ExtractPaperDownloadInfo(string json) { - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); if (!doc.RootElement.TryGetProperty("downloads", out var downloadsProp) || downloadsProp.ValueKind != JsonValueKind.Object) { return (null, null, null); @@ -193,12 +193,12 @@ private async Task EnsureVanillaServerJarAsync( string? versionMetaUrl = null; string versionsCachePath = Path.Combine(RuntimePaths.GetVersionCacheDir(), "mc_versions_cache.json"); - if (File.Exists(versionsCachePath)) + if (SafeFileSystem.FileExists(versionsCachePath)) { try { - using var stream = new FileStream(versionsCachePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); - using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + using var stream = SafeFileSystem.OpenRead(versionsCachePath, FileShare.ReadWrite | FileShare.Delete, FileOptions.Asynchronous | FileOptions.SequentialScan); + using var doc = await JsonCodec.ParseDocumentAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); if (doc.RootElement.ValueKind == JsonValueKind.Array) { foreach (var elem in doc.RootElement.EnumerateArray()) @@ -303,7 +303,7 @@ private async Task DownloadInstallerJarAsync( string fileName = Path.GetFileName(uri.AbsolutePath); string destination = Path.Combine(_cacheDir, fileName); - if (!File.Exists(destination)) + if (!SafeFileSystem.FileExists(destination)) { string? expectedHash = null; string expectedAlgorithm = "sha256"; @@ -432,11 +432,11 @@ private static string ResolveLaunchTarget(string serverDirectory, LoaderKind loa { // 優先從 Forge / NeoForge 的 run.bat 提取 @...win_args.txt string runBat = Path.Combine(serverDirectory, "run.bat"); - if (File.Exists(runBat)) + if (SafeFileSystem.FileExists(runBat)) { try { - string runBatContent = File.ReadAllText(runBat); + string runBatContent = SafeFileSystem.ReadAllText(runBat); var match = WinArgsRegex().Match(runBatContent); if (match.Success) { @@ -451,12 +451,12 @@ private static string ResolveLaunchTarget(string serverDirectory, LoaderKind loa // 檢查 Forge / NeoForge libraries 中的 win_args.txt string forgeLib = Path.Combine(serverDirectory, "libraries", "net", "minecraftforge", "forge"); - if (Directory.Exists(forgeLib)) + if (SafeFileSystem.DirectoryExists(forgeLib)) { - foreach (string sub in Directory.GetDirectories(forgeLib)) + foreach (string sub in SafeFileSystem.EnumerateDirectories(forgeLib)) { string winArgs = Path.Combine(sub, "win_args.txt"); - if (File.Exists(winArgs)) + if (SafeFileSystem.FileExists(winArgs)) { return "@" + Path.GetRelativePath(serverDirectory, winArgs).Replace('\\', '/'); } @@ -464,12 +464,12 @@ private static string ResolveLaunchTarget(string serverDirectory, LoaderKind loa } string neoLib = Path.Combine(serverDirectory, "libraries", "net", "neoforged", "neoforge"); - if (Directory.Exists(neoLib)) + if (SafeFileSystem.DirectoryExists(neoLib)) { - foreach (string sub in Directory.GetDirectories(neoLib)) + foreach (string sub in SafeFileSystem.EnumerateDirectories(neoLib)) { string winArgs = Path.Combine(sub, "win_args.txt"); - if (File.Exists(winArgs)) + if (SafeFileSystem.FileExists(winArgs)) { return "@" + Path.GetRelativePath(serverDirectory, winArgs).Replace('\\', '/'); } @@ -496,7 +496,7 @@ private static string ResolveLaunchTarget(string serverDirectory, LoaderKind loa return preferred; } - if (File.Exists(runBat)) + if (SafeFileSystem.FileExists(runBat)) { return "run.bat"; } @@ -509,7 +509,7 @@ private static string ResolveLaunchTarget(string serverDirectory, LoaderKind loa internal static string? ExtractVersionMetaUrl(string manifestJson, string targetVersion) { - using var doc = JsonDocument.Parse(manifestJson); + using var doc = JsonCodec.ParseDocument(manifestJson); return ExtractVersionMetaUrl(doc, targetVersion); } @@ -531,7 +531,7 @@ private static string ResolveLaunchTarget(string serverDirectory, LoaderKind loa internal static (string? Url, string? Sha1) ExtractServerDownloadInfo(string metaJson) { - using var doc = JsonDocument.Parse(metaJson); + using var doc = JsonCodec.ParseDocument(metaJson); return ExtractServerDownloadInfo(doc); } diff --git a/src/MinecraftServerManager.Infrastructure/Logging/RotatingFileLoggerProvider.cs b/src/MinecraftServerManager.Infrastructure/Logging/RotatingFileLoggerProvider.cs index dc012cd..367eae2 100644 --- a/src/MinecraftServerManager.Infrastructure/Logging/RotatingFileLoggerProvider.cs +++ b/src/MinecraftServerManager.Infrastructure/Logging/RotatingFileLoggerProvider.cs @@ -147,7 +147,7 @@ private void InitializeWriter() { Console.Error.WriteLine($"[RotatingFileLoggerProvider] 無法於預設目錄建立記錄檔 ({ex.Message}),嘗試切換至暫存目錄備援"); string fallbackDir = Path.Combine(Path.GetTempPath(), "MinecraftServerManager", "Logs"); - Directory.CreateDirectory(fallbackDir); + SafeFileSystem.CreateDirectory(fallbackDir); logDir = fallbackDir; } @@ -180,7 +180,7 @@ private void OpenWriter() { AutoFlush = true, }; - _currentFileBytes = new FileInfo(_currentFilePath).Length; + _currentFileBytes = SafeFileSystem.GetFileLength(_currentFilePath); } private void EnsureWriter() @@ -208,14 +208,14 @@ private void DoRollover() { string source = $"{_currentFilePath}.{i}"; string target = $"{_currentFilePath}.{i + 1}"; - if (File.Exists(source)) + if (SafeFileSystem.FileExists(source)) { SafeFileSystem.MoveWithin(logDir, source, target); } } string rolloverTarget = $"{_currentFilePath}.1"; - if (File.Exists(_currentFilePath)) + if (SafeFileSystem.FileExists(_currentFilePath)) { SafeFileSystem.MoveWithin(logDir, _currentFilePath, rolloverTarget); } @@ -233,7 +233,7 @@ private void DoRollover() public static void PruneLogs(string logDir, int keep) { - if (!Directory.Exists(logDir)) + if (!SafeFileSystem.DirectoryExists(logDir)) { return; } @@ -241,16 +241,15 @@ public static void PruneLogs(string logDir, int keep) try { var files = SafeFileSystem.ListBoundedDirectory(logDir) - .Where(static entry => !entry.IsDirectory && File.Exists(entry.FullPath) && + .Where(static entry => !entry.IsDirectory && (entry.FullPath.EndsWith(".log", StringComparison.OrdinalIgnoreCase) || entry.FullPath.Contains(".log.", StringComparison.OrdinalIgnoreCase))) - .Select(static entry => new FileInfo(entry.FullPath)) - .OrderBy(static f => f.LastWriteTimeUtc) + .OrderBy(static entry => entry.LastWriteTimeUtc) .ToList(); int removeCount = Math.Max(0, files.Count - Math.Max(0, keep)); for (int i = 0; i < removeCount; i++) { - SafeFileSystem.DeleteWithin(logDir, files[i].FullName); + SafeFileSystem.DeleteWithin(logDir, files[i].FullPath); } } catch diff --git a/src/MinecraftServerManager.Infrastructure/Mods/LocalModScanner.cs b/src/MinecraftServerManager.Infrastructure/Mods/LocalModScanner.cs index ee3c5c9..263d8a8 100644 --- a/src/MinecraftServerManager.Infrastructure/Mods/LocalModScanner.cs +++ b/src/MinecraftServerManager.Infrastructure/Mods/LocalModScanner.cs @@ -70,12 +70,12 @@ await Parallel.ForEachAsync(entries, parallelOptions, async (entry, ct) => { cancellationToken.ThrowIfCancellationRequested(); - if (!File.Exists(filePath)) + if (!SafeFileSystem.FileExists(filePath)) { return Task.FromResult(null); } - var fileInfo = new FileInfo(filePath); + FileSystemMetadata fileInfo = SafeFileSystem.GetFileMetadata(filePath); string fileName = fileInfo.Name; bool isEnabled = !fileName.EndsWith(".jar.disabled", StringComparison.OrdinalIgnoreCase); string baseStem = ModHelpers.ModFilenameStem(fileName); @@ -229,13 +229,13 @@ private static LocalModMetadataBuilder ExtractMetadataFromJar(string filePath, s try { - using var archive = ZipFile.OpenRead(filePath); + using var archive = SafeZipArchive.OpenRead(filePath); // 1. fabric.mod.json / quilt.mod.json var fabricEntry = archive.GetEntry("fabric.mod.json") ?? archive.GetEntry("quilt.mod.json"); if (fabricEntry is not null && ReadEntryTextBounded(fabricEntry) is { } fabricJson) { - using var doc = JsonDocument.Parse(fabricJson); + using var doc = JsonCodec.ParseDocument(fabricJson); ParseFabricJson(doc.RootElement, meta, archive); return meta; } @@ -252,7 +252,7 @@ private static LocalModMetadataBuilder ExtractMetadataFromJar(string filePath, s var legacyEntry = archive.GetEntry("mcmod.info"); if (legacyEntry is not null && ReadEntryTextBounded(legacyEntry) is { } legacyJson) { - using var doc = JsonDocument.Parse(legacyJson); + using var doc = JsonCodec.ParseDocument(legacyJson); ParseLegacyMcModInfo(doc.RootElement, meta); return meta; } @@ -334,7 +334,7 @@ private static bool TryExtractFromManifestOrJarJar(ZipArchive archive, LocalModM e.FullName.EndsWith(".jar", StringComparison.OrdinalIgnoreCase) && e.Length <= 10 * 1024 * 1024); - if (innerJarEntry is not null) + if (innerJarEntry is { Length: <= 10 * 1024 * 1024 }) { try { @@ -343,7 +343,7 @@ private static bool TryExtractFromManifestOrJarJar(ZipArchive archive, LocalModM using var ms = new MemoryStream(initialCapacity); innerStream.CopyTo(ms); ms.Position = 0; - using var innerZip = new ZipArchive(ms, ZipArchiveMode.Read); + using var innerZip = SafeZipArchive.OpenRead(ms, leaveOpen: true, maxArchiveBytes: 10 * 1024 * 1024); var innerToml = innerZip.GetEntry("META-INF/mods.toml") ?? innerZip.GetEntry("META-INF/neoforge.mods.toml"); if (innerToml is not null && innerToml.Length <= MaxMetadataEntryBytes) @@ -363,7 +363,7 @@ private static bool TryExtractFromManifestOrJarJar(ZipArchive archive, LocalModM if (innerFabric is not null && innerFabric.Length <= MaxMetadataEntryBytes) { using var s = innerFabric.Open(); - using var doc = JsonDocument.Parse(s); + using var doc = JsonCodec.ParseDocument(s); ParseFabricJson(doc.RootElement, meta, innerZip); if ((meta.Version == "未知" || string.IsNullOrWhiteSpace(meta.Version)) && !string.IsNullOrWhiteSpace(manifestVersion)) { diff --git a/src/MinecraftServerManager.Infrastructure/Mods/ModFileInstaller.cs b/src/MinecraftServerManager.Infrastructure/Mods/ModFileInstaller.cs index a624af8..40cb276 100644 --- a/src/MinecraftServerManager.Infrastructure/Mods/ModFileInstaller.cs +++ b/src/MinecraftServerManager.Infrastructure/Mods/ModFileInstaller.cs @@ -28,21 +28,21 @@ public Task SetModStateAsync( string dstFile = enable ? enabledFile : disabledFile; string action = enable ? "啟用" : "停用"; - if (File.Exists(dstFile) && !File.Exists(srcFile)) + if (SafeFileSystem.FileExists(dstFile) && !SafeFileSystem.FileExists(srcFile)) { return Task.FromResult(LocalModMutationResult.Success($"模組已處於{action}狀態", dstFile)); } - if (File.Exists(dstFile) && File.Exists(srcFile)) + if (SafeFileSystem.FileExists(dstFile) && SafeFileSystem.FileExists(srcFile)) { - if (new FileInfo(srcFile).Length == new FileInfo(dstFile).Length && HasSameContent(srcFile, dstFile)) + if (SafeFileSystem.GetFileLength(srcFile) == SafeFileSystem.GetFileLength(dstFile) && HasSameContent(srcFile, dstFile)) { SafeFileSystem.DeleteWithin(stableModsDir, srcFile); return Task.FromResult(LocalModMutationResult.Success($"模組已處於{action}狀態並已清理重複檔案", dstFile)); } string bakFile = Path.Combine(stableModsDir, $"{cleanModId}.conflict.bak"); - if (File.Exists(bakFile)) + if (SafeFileSystem.FileExists(bakFile)) { bakFile = Path.Combine(stableModsDir, $"{cleanModId}.conflict.{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.bak"); } @@ -50,7 +50,7 @@ public Task SetModStateAsync( return Task.FromResult(LocalModMutationResult.Success($"偵測到衝突檔案,已改名保留備份:{Path.GetFileName(bakFile)}", bakFile)); } - if (File.Exists(srcFile)) + if (SafeFileSystem.FileExists(srcFile)) { SafeFileSystem.MoveWithin(stableModsDir, srcFile, dstFile); return Task.FromResult(LocalModMutationResult.Success($"已成功{action}模組:{Path.GetFileName(dstFile)}", dstFile)); @@ -83,12 +83,12 @@ public Task DeleteModsAsync( string cleanModId = Path.GetFileNameWithoutExtension(enabledPath); bool deleted = false; - if (File.Exists(enabledPath)) + if (SafeFileSystem.FileExists(enabledPath)) { SafeFileSystem.DeleteWithin(stableModsDir, enabledPath); deleted = true; } - else if (File.Exists(disabledPath)) + else if (SafeFileSystem.FileExists(disabledPath)) { SafeFileSystem.DeleteWithin(stableModsDir, disabledPath); deleted = true; @@ -126,7 +126,7 @@ public Task ImportLocalModAsync( ArgumentException.ThrowIfNullOrWhiteSpace(sourceFilePath); cancellationToken.ThrowIfCancellationRequested(); - if (!File.Exists(sourceFilePath)) + if (!SafeFileSystem.FileExists(sourceFilePath)) { return Task.FromResult(LocalModMutationResult.Failure("匯入失敗", $"來源檔案不存在:{sourceFilePath}")); } @@ -144,7 +144,7 @@ public Task ImportLocalModAsync( return Task.FromResult(LocalModMutationResult.Failure("匯入失敗", "目標路徑超出模組資料夾邊界")); } - File.Copy(sourceFilePath, targetPath, overwrite: true); + SafeFileSystem.CopyFile(sourceFilePath, targetPath, overwrite: true); return Task.FromResult(LocalModMutationResult.Success($"模組「{fileName}」已成功匯入", targetPath)); } @@ -203,7 +203,7 @@ public async Task InstallRemoteModAsync( } // 備份舊檔案(若存在) - if (File.Exists(finalTargetPath)) + if (SafeFileSystem.FileExists(finalTargetPath)) { backupFilePath = Path.Combine(stagingDir, $"{safeFileName}.bak"); if (!SafeFileSystem.MoveWithin(stableModsDir, finalTargetPath, backupFilePath)) @@ -222,7 +222,7 @@ public async Task InstallRemoteModAsync( } // 清理備份 - if (backupFilePath is not null && File.Exists(backupFilePath)) + if (backupFilePath is not null && SafeFileSystem.FileExists(backupFilePath)) { SafeFileSystem.DeleteWithin(stagingDir, backupFilePath); } @@ -254,7 +254,7 @@ public static void CleanupStagingDirectory(string modsDirectory) { string stableModsDir = SafeFileSystem.ResolveStableDirectory(modsDirectory); string stagingDir = Path.Combine(stableModsDir, ".download_staging"); - if (Directory.Exists(stagingDir)) + if (SafeFileSystem.DirectoryExists(stagingDir)) { SafeFileSystem.DeleteWithin(stableModsDir, stagingDir); } @@ -268,7 +268,7 @@ private static void TryCleanupStagingDir(string stableModsDir, string stagingDir { try { - if (Directory.Exists(stagingDir)) + if (SafeFileSystem.DirectoryExists(stagingDir)) { var entries = SafeFileSystem.ListBoundedDirectory(stagingDir, maxEntries: 10, rejectReparse: false); if (entries.Count == 0) @@ -286,11 +286,11 @@ private static void Rollback(string stableModsDir, string tempPath, string targe { try { - if (File.Exists(tempPath)) + if (SafeFileSystem.FileExists(tempPath)) { SafeFileSystem.DeleteWithin(stableModsDir, tempPath); } - if (backupPath is not null && File.Exists(backupPath)) + if (backupPath is not null && SafeFileSystem.FileExists(backupPath)) { SafeFileSystem.MoveWithin(stableModsDir, backupPath, targetPath); } @@ -321,7 +321,7 @@ private static (string EnabledPath, string DisabledPath) ResolveModFilePaths(str string enabledPath = Path.Combine(stableModsDir, $"{cleanModId}.jar"); string disabledPath = Path.Combine(stableModsDir, $"{cleanModId}.jar.disabled"); - if (File.Exists(enabledPath) || File.Exists(disabledPath)) + if (SafeFileSystem.FileExists(enabledPath) || SafeFileSystem.FileExists(disabledPath)) { return (enabledPath, disabledPath); } diff --git a/src/MinecraftServerManager.Infrastructure/Mods/ModIndexPersistence.cs b/src/MinecraftServerManager.Infrastructure/Mods/ModIndexPersistence.cs index a700ef8..ab0cfbf 100644 --- a/src/MinecraftServerManager.Infrastructure/Mods/ModIndexPersistence.cs +++ b/src/MinecraftServerManager.Infrastructure/Mods/ModIndexPersistence.cs @@ -1,7 +1,5 @@ -using System.Text.Json; using MinecraftServerManager.Core.Ports; using MinecraftServerManager.Infrastructure.FileSystem; -using MinecraftServerManager.Infrastructure.Serialization; using MinecraftServerManager.Infrastructure.Utilities; namespace MinecraftServerManager.Infrastructure.Mods; @@ -25,11 +23,7 @@ public ModIndexPersistence(string serverDirectory) try { - var dirInfo = new DirectoryInfo(cacheDir); - if ((dirInfo.Attributes & FileAttributes.Hidden) == 0) - { - dirInfo.Attributes |= FileAttributes.Hidden; - } + SafeFileSystem.SetDirectoryHidden(cacheDir, hidden: true); } catch { @@ -167,7 +161,11 @@ public void Flush() try { - byte[] utf8Bytes = JsonSerializer.SerializeToUtf8Bytes(_index, AppJsonSerializerContext.Default.DictionaryStringModCacheEntry); + byte[]? utf8Bytes = JsonCodec.SerializeToUtf8Bytes(_index, indented: true); + if (utf8Bytes is null) + { + return; + } AtomicFileWriter.WriteBytes(_indexFilePath, utf8Bytes); _dirty = false; } @@ -181,7 +179,7 @@ private void LoadIndex() { lock (_lock) { - if (!File.Exists(_indexFilePath)) + if (!SafeFileSystem.FileExists(_indexFilePath)) { _index = new Dictionary(StringComparer.OrdinalIgnoreCase); return; @@ -191,7 +189,7 @@ private void LoadIndex() { byte[]? bytes = SafeFileSystem.ReadRegularFile(_indexFilePath, JsonCodec.DefaultMaxBytes); var loaded = bytes is not null - ? JsonSerializer.Deserialize(bytes, AppJsonSerializerContext.Default.DictionaryStringModCacheEntry) + ? JsonCodec.Deserialize>(bytes) : null; _index = loaded is not null ? new Dictionary(loaded, StringComparer.OrdinalIgnoreCase) diff --git a/src/MinecraftServerManager.Infrastructure/Mods/ModListExporter.cs b/src/MinecraftServerManager.Infrastructure/Mods/ModListExporter.cs index 2681ac9..d5f8053 100644 --- a/src/MinecraftServerManager.Infrastructure/Mods/ModListExporter.cs +++ b/src/MinecraftServerManager.Infrastructure/Mods/ModListExporter.cs @@ -1,13 +1,12 @@ using System.Globalization; using System.Net; using System.Text; -using System.Text.Encodings.Web; -using System.Text.Json; using System.Text.Json.Serialization; using MinecraftServerManager.Core.Mods; using MinecraftServerManager.Core.Ports; using MinecraftServerManager.Domain.Mods; using MinecraftServerManager.Infrastructure.Utilities; +using MinecraftServerManager.Infrastructure.FileSystem; namespace MinecraftServerManager.Infrastructure.Mods; @@ -18,12 +17,6 @@ public sealed class ModListExporter : IModListExporter { private const string EmptyPlaceholder = "-"; private const string SheetName = "模組列表"; - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - TypeInfoResolver = Serialization.AppJsonSerializerContext.Default, - }; private static readonly string[] XlsxHeaders = ["啟用狀態", "模組名稱", "版本", "Minecraft版本", "載入器", "作者", "檔案名稱", "模組ID", "檔案大小", "修改時間", "描述"]; @@ -41,7 +34,7 @@ public async Task ExportAsync( if (format == ModListExportFormat.Xlsx) { byte[] workbook = await Task.Run(() => BuildXlsx(rows), cancellationToken).ConfigureAwait(false); - await File.WriteAllBytesAsync(filePath, workbook, cancellationToken).ConfigureAwait(false); + await SafeFileSystem.WriteAllBytesAsync(filePath, workbook, cancellationToken).ConfigureAwait(false); return; } @@ -52,7 +45,7 @@ public async Task ExportAsync( _ => BuildText(rows), }, cancellationToken).ConfigureAwait(false); - await File.WriteAllTextAsync(filePath, content, Encoding.UTF8, cancellationToken).ConfigureAwait(false); + await SafeFileSystem.WriteAllTextAsync(filePath, content, Encoding.UTF8, cancellationToken).ConfigureAwait(false); } private static string Clean(string value) => @@ -74,7 +67,7 @@ private static string BuildJson(IReadOnlyList rows) ModifiedTime: Clean(m.ModifiedTime), Description: ModHelpers.StripColorAndFormattingCodes(m.Description)))]; - return JsonSerializer.Serialize(items, JsonOptions); + return JsonCodec.SerializeRelaxed(items); } private static string BuildHtml(IReadOnlyList rows) diff --git a/src/MinecraftServerManager.Infrastructure/Mods/ModrinthClient.cs b/src/MinecraftServerManager.Infrastructure/Mods/ModrinthClient.cs index 03b7883..cf87f9d 100644 --- a/src/MinecraftServerManager.Infrastructure/Mods/ModrinthClient.cs +++ b/src/MinecraftServerManager.Infrastructure/Mods/ModrinthClient.cs @@ -93,7 +93,7 @@ private async Task> FetchVersionNumbersBatchAsync( continue; } - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); if (doc.RootElement.ValueKind == JsonValueKind.Array) { foreach (var elem in doc.RootElement.EnumerateArray()) @@ -169,7 +169,7 @@ public async Task> GetProjectVersionsAsync( try { - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); var version = ParseSingleVersion(doc.RootElement); string projectId = doc.RootElement.TryGetProperty("project_id", out var pElem) ? pElem.GetString() ?? string.Empty : string.Empty; return new ModrinthVersionLookupResult(cleanHash, algorithm, projectId, version); @@ -209,7 +209,7 @@ public async Task> Look try { - using var doc = JsonDocument.Parse(responseJson); + using var doc = JsonCodec.ParseDocument(responseJson); foreach (var prop in doc.RootElement.EnumerateObject()) { string fileHash = prop.Name; @@ -231,7 +231,7 @@ private static List ParseSearchResults(string json) var list = new List(); try { - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); if (!doc.RootElement.TryGetProperty("hits", out var hitsElem)) { return list; @@ -292,7 +292,7 @@ private static List ParseVersionList(string json) var list = new List(); try { - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); if (doc.RootElement.ValueKind != JsonValueKind.Array) { return list; diff --git a/src/MinecraftServerManager.Infrastructure/Process/ProcessRunner.cs b/src/MinecraftServerManager.Infrastructure/Process/ProcessRunner.cs index dab2bc0..448d3fc 100644 --- a/src/MinecraftServerManager.Infrastructure/Process/ProcessRunner.cs +++ b/src/MinecraftServerManager.Infrastructure/Process/ProcessRunner.cs @@ -20,7 +20,7 @@ public sealed partial class ProcessRunner : IProcessRunner, IExternalLauncher public bool OpenFolder(string folderPath, bool isTrusted = false) { - if (string.IsNullOrWhiteSpace(folderPath) || !Directory.Exists(folderPath)) + if (string.IsNullOrWhiteSpace(folderPath) || !SafeFileSystem.DirectoryExists(folderPath)) { Logger.Warning("開啟資料夾失敗,路徑不存在或為空: {Path}", folderPath); return false; @@ -38,7 +38,7 @@ public bool OpenFolder(string folderPath, bool isTrusted = false) public bool ShowInFolder(string filePath, bool isTrusted = false) { - if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + if (string.IsNullOrWhiteSpace(filePath) || !SafeFileSystem.FileExists(filePath)) { Logger.Warning("在資料夾中顯示失敗,檔案不存在或為空: {Path}", filePath); return false; @@ -66,7 +66,7 @@ public bool ShowInFolder(string filePath, bool isTrusted = false) public bool OpenFile(string filePath, bool isTrusted = false) { - if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + if (string.IsNullOrWhiteSpace(filePath) || !SafeFileSystem.FileExists(filePath)) { Logger.Warning("開啟檔案失敗,檔案不存在或為空: {Path}", filePath); return false; @@ -101,7 +101,7 @@ public bool OpenUrl(string url, bool isTrusted = false) return false; } - // 若為程式內建或受信任的連結,不需進行協定拒絕判斷 + // 若為程式內建或受信任的連結,不需協定拒絕判斷 if (!isTrusted && uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) { Logger.Warning("開啟網址被拒絕,非 HTTP/HTTPS 協定: {Url}", url); @@ -164,7 +164,7 @@ public Task StartAsync( { string comSpec = Environment.GetEnvironmentVariable("ComSpec") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe"); - if (!File.Exists(comSpec)) + if (!SafeFileSystem.FileExists(comSpec)) { throw new FileNotFoundException("找不到系統命令解譯器 (cmd.exe)", comSpec); } @@ -227,6 +227,53 @@ public Task StartAsync( } } + public void StartDetached(ProcessStartSpec specification) + { + ArgumentNullException.ThrowIfNull(specification); + if (specification.UseShellExecute) + { + throw new ArgumentException("脫離管理啟動不支援殼層執行", nameof(specification)); + } + + string executable = ResolveAndValidateExecutable(specification); + var startInfo = new ProcessStartInfo + { + FileName = executable, + WorkingDirectory = specification.WorkingDirectory ?? Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = specification.CreateNoWindow, + }; + foreach (string argument in specification.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + if (specification.Environment is not null) + { + foreach (var (key, value) in specification.Environment) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("環境變數名稱不得為空", nameof(specification)); + } + + if (value is null) + { + startInfo.Environment.Remove(key); + } + else + { + startInfo.Environment[key] = value; + } + } + } + + using var process = System.Diagnostics.Process.Start(startInfo) + ?? throw new InvalidOperationException($"無法啟動脫離管理的行程:{executable}"); + + Logger.Information("已啟動脫離管理的子行程,PID: {Pid} ({FileName})", process.Id, executable); + } + private static string ResolveAndValidateExecutable(ProcessStartSpec specification) { if (string.IsNullOrWhiteSpace(specification.FileName)) @@ -236,7 +283,7 @@ private static string ResolveAndValidateExecutable(ProcessStartSpec specificatio string fileName = specification.FileName; - if (specification.WorkingDirectory is not null && !Directory.Exists(specification.WorkingDirectory)) + if (specification.WorkingDirectory is not null && !SafeFileSystem.DirectoryExists(specification.WorkingDirectory)) { throw new DirectoryNotFoundException($"找不到工作目錄:{specification.WorkingDirectory}"); } @@ -247,9 +294,9 @@ private static string ResolveAndValidateExecutable(ProcessStartSpec specificatio if (Path.IsPathRooted(fileName) || fileName.Contains(Path.DirectorySeparatorChar) || fileName.Contains(Path.AltDirectorySeparatorChar) || - File.Exists(candidatePath)) + SafeFileSystem.FileExists(candidatePath)) { - if (!File.Exists(candidatePath)) + if (!SafeFileSystem.FileExists(candidatePath)) { throw new FileNotFoundException("找不到指定的執行檔", fileName); } @@ -270,7 +317,7 @@ private static string ResolveAndValidateExecutable(ProcessStartSpec specificatio if (!string.IsNullOrEmpty(localAppData)) { string wingetDefault = Path.Combine(localAppData, "Microsoft", "WindowsApps", "winget.exe"); - if (File.Exists(wingetDefault)) + if (SafeFileSystem.FileExists(wingetDefault)) { return wingetDefault; } @@ -387,6 +434,24 @@ public async Task WriteLineAsync(string line, CancellationToken cancellationToke await _process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); } + public long? GetWorkingSet64() + { + try + { + if (_disposed || _process.HasExited) + { + return null; + } + + _process.Refresh(); + return _process.WorkingSet64; + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + return null; + } + } + public async Task WaitForExitAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/src/MinecraftServerManager.Infrastructure/Serialization/AppJsonSerializerContext.cs b/src/MinecraftServerManager.Infrastructure/Serialization/AppJsonSerializerContext.cs index ba10707..95176b1 100644 --- a/src/MinecraftServerManager.Infrastructure/Serialization/AppJsonSerializerContext.cs +++ b/src/MinecraftServerManager.Infrastructure/Serialization/AppJsonSerializerContext.cs @@ -12,7 +12,7 @@ namespace MinecraftServerManager.Infrastructure.Serialization; /// /// 應用程式集中管理的 System.Text.Json 源產生器(Source Generator)內容, -/// 編譯期生成序列化元資料以達成零反射、低記憶體與高吞吐 JSON 處理。 +/// 編譯期生成序列化中繼資料以達成零反射、低記憶體與高吞吐 JSON 處理。 /// [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified, diff --git a/src/MinecraftServerManager.Infrastructure/Servers/SafeScriptValidator.cs b/src/MinecraftServerManager.Infrastructure/Servers/SafeScriptValidator.cs index 0de5af0..d99057c 100644 --- a/src/MinecraftServerManager.Infrastructure/Servers/SafeScriptValidator.cs +++ b/src/MinecraftServerManager.Infrastructure/Servers/SafeScriptValidator.cs @@ -42,7 +42,7 @@ internal static bool IsSafeJavaExecutable(string javaPath) try { string fullPath = Path.GetFullPath(javaPath); - if (!File.Exists(fullPath)) + if (!SafeFileSystem.FileExists(fullPath)) { Logger.Warning("Java 執行檔不存在:{Path}", fullPath); return false; @@ -110,7 +110,7 @@ public static bool ValidateCommandInputs(string serverDirectory, IReadOnlyList> ListBackupsAsync( ArgumentException.ThrowIfNullOrWhiteSpace(serverName); cancellationToken.ThrowIfCancellationRequested(); - string backupsDir = ResolveBackupDirectory(serverName, backupDirectory); - if (!Directory.Exists(backupsDir)) + if (string.IsNullOrWhiteSpace(backupDirectory)) + { + return Task.FromResult>([]); + } + + string backupsDir = ResolveExternalBackupDirectory(backupDirectory); + if (!SafeFileSystem.DirectoryExists(backupsDir)) { return Task.FromResult>([]); } @@ -39,7 +44,7 @@ public Task> ListBackupsAsync( string.Equals(fn, $"backup-{serverName}.zip", StringComparison.OrdinalIgnoreCase) || string.Equals(fn, $"{serverName}.zip", StringComparison.OrdinalIgnoreCase)) { - var fileInfo = new FileInfo(entry.FullPath); + FileSystemMetadata fileInfo = SafeFileSystem.GetFileMetadata(entry.FullPath); list.Add(new ServerBackupInfo( FileName: fn, FullPath: entry.FullPath, @@ -63,21 +68,21 @@ public async Task CreateBackupAsync( cancellationToken.ThrowIfCancellationRequested(); string serverDir = GetServerDirectory(serverName); - if (!Directory.Exists(serverDir)) + if (!SafeFileSystem.DirectoryExists(serverDir)) { throw new DirectoryNotFoundException($"找不到伺服器目錄:{serverDir}"); } - // 驗證備份路徑不在 servers 根目錄內(安全邊界防護) - string backupsDir = GetBackupsDirectory(serverName); - if (!string.IsNullOrWhiteSpace(destinationPath)) + if (string.IsNullOrWhiteSpace(destinationPath)) { - string normalizedDest = Path.GetFullPath(destinationPath); - string normalizedServersRoot = Path.GetFullPath(_resolvedServersRoot); - if (SafeFileSystem.IsPathWithin(normalizedServersRoot, normalizedDest, strict: false)) - { - throw new InvalidOperationException("備份路徑不得位於伺服器根目錄 (servers/) 內,請選擇其他儲存位置"); - } + throw new InvalidOperationException("未指定備份目標路徑。備份必須存放於伺服器目錄以外之外部資料夾。"); + } + + string normalizedDest = Path.GetFullPath(destinationPath); + string normalizedServersRoot = Path.GetFullPath(_resolvedServersRoot); + if (SafeFileSystem.IsPathWithin(normalizedServersRoot, normalizedDest, strict: false)) + { + throw new InvalidOperationException("備份路徑不得位於伺服器根目錄 (servers/) 或其子目錄內,請選擇其他儲存位置"); } string timestamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); @@ -86,34 +91,26 @@ public async Task CreateBackupAsync( string backupDirectory; string backupFilePath; - if (!string.IsNullOrWhiteSpace(destinationPath)) + if (destinationPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) { - if (destinationPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + backupFilePath = Path.GetFullPath(destinationPath); + backupFileName = Path.GetFileName(backupFilePath); + string? parentDir = Path.GetDirectoryName(backupFilePath); + if (string.IsNullOrEmpty(parentDir)) { - backupFilePath = Path.GetFullPath(destinationPath); - backupFileName = Path.GetFileName(backupFilePath); - string? parentDir = Path.GetDirectoryName(backupFilePath); - if (string.IsNullOrEmpty(parentDir)) - { - throw new InvalidOperationException("無法解析備份檔案的父資料夾"); - } - - backupDirectory = ResolveExternalBackupDirectory(parentDir); - } - else - { - backupDirectory = ResolveExternalBackupDirectory(destinationPath); - backupFilePath = Path.Combine(backupDirectory, backupFileName); + throw new InvalidOperationException("無法解析備份檔案的父資料夾"); } + + backupDirectory = ResolveExternalBackupDirectory(parentDir); } else { - backupDirectory = SafeFileSystem.ResolveStableDirectory(backupsDir, create: true); + backupDirectory = ResolveExternalBackupDirectory(destinationPath); backupFilePath = Path.Combine(backupDirectory, backupFileName); } - string normalizedBackupsDir = Path.GetFullPath(backupsDir); - string backupsDirPrefix = Path.TrimEndingDirectorySeparator(normalizedBackupsDir) + Path.DirectorySeparatorChar; + string legacyBackupsDir = Path.Combine(serverDir, "backups"); + string legacyBackupsPrefix = Path.TrimEndingDirectorySeparator(Path.GetFullPath(legacyBackupsDir)) + Path.DirectorySeparatorChar; await Task.Run(() => { @@ -133,8 +130,8 @@ await Task.Run(() => return false; } - bool isUnderBackups = filePath.StartsWith(backupsDirPrefix, StringComparison.OrdinalIgnoreCase) || - filePath.Equals(normalizedBackupsDir, StringComparison.OrdinalIgnoreCase); + bool isUnderBackups = filePath.StartsWith(legacyBackupsPrefix, StringComparison.OrdinalIgnoreCase) || + filePath.Equals(legacyBackupsDir, StringComparison.OrdinalIgnoreCase); return !isUnderBackups && !filePath.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) && @@ -176,14 +173,14 @@ await Task.Run(() => }); }, cancellationToken).ConfigureAwait(false); - var fileInfo = new FileInfo(backupFilePath); + FileSystemMetadata fileInfo = SafeFileSystem.GetFileMetadata(backupFilePath); // 依備份保留政策自動清理最舊備份 await PruneOldBackupsAsync(serverName, backupDirectory, cancellationToken).ConfigureAwait(false); return new ServerBackupInfo( FileName: fileInfo.Name, - FullPath: fileInfo.FullName, + FullPath: fileInfo.FullPath, SizeBytes: fileInfo.Length, CreatedAt: fileInfo.CreationTimeUtc); } @@ -199,11 +196,16 @@ public async Task RestoreBackupAsync( ArgumentException.ThrowIfNullOrWhiteSpace(backupFileName); cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(backupDirectory)) + { + throw new InvalidOperationException("未指定備份資料夾路徑。備份必須位於伺服器目錄以外之外部資料夾。"); + } + string serverDir = GetServerDirectory(serverName); - string backupsDir = ResolveBackupDirectory(serverName, backupDirectory); + string backupsDir = ResolveExternalBackupDirectory(backupDirectory); string backupFilePath = Path.Combine(backupsDir, Path.GetFileName(backupFileName)); - if (!File.Exists(backupFilePath)) + if (!SafeFileSystem.FileExists(backupFilePath)) { return false; } @@ -220,15 +222,15 @@ public async Task RestoreBackupAsync( try { // 階段零:暫存現場之 logs 與 crash-reports,防止災難復原時遺失關鍵診斷記錄檔 - if (Directory.Exists(serverDir)) + if (SafeFileSystem.DirectoryExists(serverDir)) { SafeFileSystem.RemoveReadOnlyAttributesRecursive(serverDir); PreserveLiveDiagnostics(serverDir, preservedLogsDir); - hasPreservedLogs = Directory.Exists(preservedLogsDir) && Directory.EnumerateFileSystemEntries(preservedLogsDir).Any(); + hasPreservedLogs = SafeFileSystem.DirectoryExists(preservedLogsDir) && SafeFileSystem.EnumerateFileSystemEntries(preservedLogsDir).Any(); } - // 階段一:在暫存 staging 目錄進行解壓與完整性校驗 - Directory.CreateDirectory(stagingDir); + // 階段一:在暫存 staging 目錄解壓並校驗完整性 + SafeFileSystem.CreateDirectory(stagingDir); progress?.Report(new ServerBackupProgressReport(10, "正在解壓縮備份檔案...", 0, 0)); SafeZipArchive.Extract(backupFilePath, stagingDir, (extracted, total) => { @@ -239,12 +241,13 @@ public async Task RestoreBackupAsync( // 階段二:建立既有伺服器檔案之 rollback 快照 progress?.Report(new ServerBackupProgressReport(65, "正在建立伺服器現況快照...", 0, 0)); - Directory.CreateDirectory(rollbackDir); + SafeFileSystem.CreateDirectory(rollbackDir); var currentEntries = SafeFileSystem.ListBoundedDirectory(serverDir, rejectReparse: false); + string legacyBackupsDir = Path.Combine(serverDir, "backups"); foreach (var entry in currentEntries) { - // 保留 backups 目錄不移動 - if (string.Equals(entry.FullPath, backupsDir, StringComparison.OrdinalIgnoreCase)) + // 保留歷史 backups 目錄不移動 + if (string.Equals(entry.FullPath, legacyBackupsDir, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -253,14 +256,7 @@ public async Task RestoreBackupAsync( string targetRollback = Path.Combine(rollbackDir, relName); SafeFileSystem.ClearReadOnlyAttribute(entry.FullPath); - if (entry.IsDirectory) - { - Directory.Move(entry.FullPath, targetRollback); - } - else - { - File.Move(entry.FullPath, targetRollback); - } + SafeFileSystem.MovePath(entry.FullPath, targetRollback); } // 階段三:將 staging 中的還原內容搬移至伺服器目錄 @@ -272,15 +268,7 @@ public async Task RestoreBackupAsync( string targetLive = Path.Combine(serverDir, relName); SafeFileSystem.ClearReadOnlyAttribute(sEntry.FullPath); - - if (sEntry.IsDirectory) - { - Directory.Move(sEntry.FullPath, targetLive); - } - else - { - File.Move(sEntry.FullPath, targetLive); - } + SafeFileSystem.MovePath(sEntry.FullPath, targetLive); } // 階段四:將現場保留之診斷記錄檔安全合併移回伺服器目錄 @@ -304,7 +292,7 @@ public async Task RestoreBackupAsync( // 發生異常時自動執行交易復原 try { - if (Directory.Exists(rollbackDir)) + if (SafeFileSystem.DirectoryExists(rollbackDir)) { var rollbackEntries = SafeFileSystem.ListBoundedDirectory(rollbackDir, rejectReparse: false); foreach (var rbEntry in rollbackEntries) @@ -312,23 +300,16 @@ public async Task RestoreBackupAsync( string relName = Path.GetFileName(rbEntry.FullPath); string targetLive = Path.Combine(serverDir, relName); - if (File.Exists(targetLive)) + if (SafeFileSystem.FileExists(targetLive)) { - File.Delete(targetLive); + SafeFileSystem.DeleteFile(targetLive); } - else if (Directory.Exists(targetLive)) + else if (SafeFileSystem.DirectoryExists(targetLive)) { - Directory.Delete(targetLive, recursive: true); + SafeFileSystem.DeleteDirectory(targetLive, recursive: true); } - if (rbEntry.IsDirectory) - { - Directory.Move(rbEntry.FullPath, targetLive); - } - else - { - File.Move(rbEntry.FullPath, targetLive); - } + SafeFileSystem.MovePath(rbEntry.FullPath, targetLive); } } } @@ -357,12 +338,17 @@ public Task DeleteBackupAsync( ArgumentException.ThrowIfNullOrWhiteSpace(backupFileName); cancellationToken.ThrowIfCancellationRequested(); - string backupsDir = ResolveBackupDirectory(serverName, backupDirectory); + if (string.IsNullOrWhiteSpace(backupDirectory)) + { + return Task.FromResult(false); + } + + string backupsDir = ResolveExternalBackupDirectory(backupDirectory); string targetPath = Path.Combine(backupsDir, Path.GetFileName(backupFileName)); - if (File.Exists(targetPath)) + if (SafeFileSystem.FileExists(targetPath)) { - File.Delete(targetPath); + SafeFileSystem.DeleteFile(targetPath); return Task.FromResult(true); } @@ -390,21 +376,6 @@ private async Task PruneOldBackupsAsync( private string GetServerDirectory(string serverName) => SafeFileSystem.ResolveStableDirectory(Path.Combine(_resolvedServersRoot, serverName)); - private string GetBackupsDirectory(string serverName) => - Path.Combine(GetServerDirectory(serverName), "backups"); - - private string ResolveBackupDirectory(string serverName, string? backupDirectory) - { - string defaultBackupsDir = GetBackupsDirectory(serverName); - if (string.IsNullOrWhiteSpace(backupDirectory) || - string.Equals(Path.GetFullPath(backupDirectory), Path.GetFullPath(defaultBackupsDir), StringComparison.OrdinalIgnoreCase)) - { - return defaultBackupsDir; - } - - return ResolveExternalBackupDirectory(backupDirectory); - } - private string ResolveExternalBackupDirectory(string backupDirectory) { string resolved = SafeFileSystem.ResolveStableDirectory(backupDirectory, create: true); @@ -412,7 +383,7 @@ private string ResolveExternalBackupDirectory(string backupDirectory) string normalizedBackup = Path.GetFullPath(resolved); if (SafeFileSystem.IsPathWithin(normalizedRoot, normalizedBackup, strict: false)) { - throw new InvalidOperationException("外部備份資料夾不得位於伺服器根目錄 (servers/) 內"); + throw new InvalidOperationException("備份資料夾不得位於伺服器根目錄 (servers/) 或其子目錄內"); } return resolved; @@ -422,10 +393,10 @@ private static void CleanDirectorySafe(string dirPath) { try { - if (Directory.Exists(dirPath)) + if (SafeFileSystem.DirectoryExists(dirPath)) { SafeFileSystem.RemoveReadOnlyAttributesRecursive(dirPath); - Directory.Delete(dirPath, recursive: true); + SafeFileSystem.DeleteDirectory(dirPath, recursive: true); } } catch @@ -438,11 +409,11 @@ private static void PreserveLiveDiagnostics(string serverDir, string destination string liveLogs = Path.Combine(serverDir, "logs"); string liveCrash = Path.Combine(serverDir, "crash-reports"); - if (Directory.Exists(liveLogs)) + if (SafeFileSystem.DirectoryExists(liveLogs)) { SafeFileSystem.CopyDirectoryTreeSafe(liveLogs, Path.Combine(destinationDir, "logs")); } - if (Directory.Exists(liveCrash)) + if (SafeFileSystem.DirectoryExists(liveCrash)) { SafeFileSystem.CopyDirectoryTreeSafe(liveCrash, Path.Combine(destinationDir, "crash-reports")); } @@ -453,38 +424,38 @@ private static void RestorePreservedDiagnostics(string sourceDir, string serverD string preservedCrash = Path.Combine(sourceDir, "crash-reports"); string preservedLogs = Path.Combine(sourceDir, "logs"); - if (Directory.Exists(preservedCrash)) + if (SafeFileSystem.DirectoryExists(preservedCrash)) { string targetCrash = Path.Combine(serverDir, "crash-reports"); - Directory.CreateDirectory(targetCrash); - foreach (string file in Directory.EnumerateFiles(preservedCrash)) + SafeFileSystem.CreateDirectory(targetCrash); + foreach (string file in SafeFileSystem.EnumerateFiles(preservedCrash, "*")) { string targetFile = Path.Combine(targetCrash, Path.GetFileName(file)); - if (!File.Exists(targetFile)) + if (!SafeFileSystem.FileExists(targetFile)) { - File.Copy(file, targetFile); + SafeFileSystem.CopyFile(file, targetFile); } } } - if (Directory.Exists(preservedLogs)) + if (SafeFileSystem.DirectoryExists(preservedLogs)) { string targetLogs = Path.Combine(serverDir, "logs"); - Directory.CreateDirectory(targetLogs); - foreach (string file in Directory.EnumerateFiles(preservedLogs)) + SafeFileSystem.CreateDirectory(targetLogs); + foreach (string file in SafeFileSystem.EnumerateFiles(preservedLogs, "*")) { string fileName = Path.GetFileName(file); string targetFile = Path.Combine(targetLogs, fileName); - if (!File.Exists(targetFile)) + if (!SafeFileSystem.FileExists(targetFile)) { - File.Copy(file, targetFile); + SafeFileSystem.CopyFile(file, targetFile); } else if (string.Equals(fileName, "latest.log", StringComparison.OrdinalIgnoreCase)) { string preRestoreFile = Path.Combine(targetLogs, "latest.log.pre-restore"); - if (!File.Exists(preRestoreFile)) + if (!SafeFileSystem.FileExists(preRestoreFile)) { - File.Copy(file, preRestoreFile); + SafeFileSystem.CopyFile(file, preRestoreFile); } } } diff --git a/src/MinecraftServerManager.Infrastructure/Servers/ServerDirectorySizeService.cs b/src/MinecraftServerManager.Infrastructure/Servers/ServerDirectorySizeService.cs new file mode 100644 index 0000000..8b25b04 --- /dev/null +++ b/src/MinecraftServerManager.Infrastructure/Servers/ServerDirectorySizeService.cs @@ -0,0 +1,136 @@ +using System.Collections.Concurrent; +using System.Collections.Frozen; +using MinecraftServerManager.Core.Ports; +using MinecraftServerManager.Core.Utilities; +using MinecraftServerManager.Infrastructure.FileSystem; +using MinecraftServerManager.Infrastructure.Logging; + +namespace MinecraftServerManager.Infrastructure.Servers; + +public sealed class ServerDirectorySizeService : IServerDirectorySizeService +{ + private static readonly ComponentLogger Logger = AppLogging.CreateComponentLogger("ServerDirectorySize"); + + private static readonly string[] ExcludedDirectoryNamesSource = + [ + "backups" + ]; + + private static readonly FrozenSet ExcludedDirectoryNames = + ExcludedDirectoryNamesSource.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromSeconds(60); + + private readonly TimeSpan _cacheDuration; + private readonly ConcurrentDictionary _cache = + new(StringComparer.OrdinalIgnoreCase); + + public ServerDirectorySizeService(TimeSpan? cacheDuration = null) + { + _cacheDuration = cacheDuration ?? DefaultCacheDuration; + } + + public async Task CalculateServerSizeAsync(string serverPath, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(serverPath)) + { + return 0; + } + + string normalizedPath = Path.GetFullPath(serverPath); + DateTime now = DateTime.UtcNow; + + if (_cache.TryGetValue(normalizedPath, out var cached) && cached.ExpireAt > now) + { + return cached.SizeBytes; + } + + long calculatedSize = await Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!SafeFileSystem.DirectoryExists(normalizedPath)) + { + return 0L; + } + + try + { + var entries = SafeFileSystem.WalkBoundedTree(normalizedPath, rejectReparse: false); + long totalBytes = 0; + + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (entry.IsDirectory) + { + continue; + } + + string relative = Path.GetRelativePath(normalizedPath, entry.FullPath); + if (IsUnderExcludedDirectory(relative)) + { + continue; + } + + totalBytes += entry.Length; + } + + return totalBytes; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.Warning("計算伺服器容量失敗 ({Path}): {Message}", normalizedPath, ex.Message); + return 0L; + } + }, cancellationToken).ConfigureAwait(false); + + _cache[normalizedPath] = (calculatedSize, now.Add(_cacheDuration)); + return calculatedSize; + } + + public string FormatServerSize(long bytes) => bytes <= 0 ? "-" : UnitFormatter.FormatBytes(bytes); + + public void InvalidateCache(string serverPath) + { + if (string.IsNullOrWhiteSpace(serverPath)) + { + return; + } + + string normalizedPath = Path.GetFullPath(serverPath); + _cache.TryRemove(normalizedPath, out _); + } + + public void ClearAllCache() => _cache.Clear(); + + private static bool IsUnderExcludedDirectory(string relativePath) + { + ReadOnlySpan span = relativePath.AsSpan(); + + while (!span.IsEmpty) + { + int sepIndex = span.IndexOfAny('\\', '/'); + ReadOnlySpan segment = sepIndex >= 0 ? span[..sepIndex] : span; + + if (ExcludedDirectoryNames.Contains(segment.ToString())) + { + return true; + } + + if (sepIndex < 0) + { + break; + } + + span = span[(sepIndex + 1)..]; + } + + return false; + } +} diff --git a/src/MinecraftServerManager.Infrastructure/Servers/ServerInspector.cs b/src/MinecraftServerManager.Infrastructure/Servers/ServerInspector.cs index 2cda5d2..1a942b8 100644 --- a/src/MinecraftServerManager.Infrastructure/Servers/ServerInspector.cs +++ b/src/MinecraftServerManager.Infrastructure/Servers/ServerInspector.cs @@ -1,5 +1,3 @@ -using System.Collections.Concurrent; -using System.IO.Compression; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -15,7 +13,13 @@ namespace MinecraftServerManager.Infrastructure.Servers; public sealed partial class ServerInspector : IServerInspector { private static readonly ComponentLogger Logger = AppLogging.CreateComponentLogger("ServerInspector"); - private static readonly ConcurrentDictionary s_dirSizeCache = new(StringComparer.OrdinalIgnoreCase); + private static readonly IServerDirectorySizeService s_defaultDirectorySizeService = new ServerDirectorySizeService(); + private readonly IServerDirectorySizeService _directorySizeService; + + public ServerInspector(IServerDirectorySizeService? directorySizeService = null) + { + _directorySizeService = directorySizeService ?? s_defaultDirectorySizeService; + } private static readonly string[] ServerJarCandidates = [ @@ -38,7 +42,7 @@ public async Task InspectAsync( try { string stableDir = SafeFileSystem.ResolveStableDirectory(serverDirectory); - if (!Directory.Exists(stableDir)) + if (!SafeFileSystem.DirectoryExists(stableDir)) { return new ServerInspection( path: serverDirectory, @@ -55,7 +59,7 @@ public async Task InspectAsync( var (loaderType, loaderVersion, mcVersion, loaderTypeSource, loaderVersionSource, mcVersionSource) = InspectLoaderAndVersion(stableDir, files, launchTarget); var (maxMb, minMb) = InspectMemory(stableDir); - long totalSizeBytes = await Task.Run(() => CalculateDirectorySize(stableDir), cancellationToken).ConfigureAwait(false); + long totalSizeBytes = await _directorySizeService.CalculateServerSizeAsync(stableDir, cancellationToken).ConfigureAwait(false); bool isCandidate = launchTarget.Kind != LaunchTargetKind.None || files.Contains("server.properties"); bool launchable = launchTarget.Kind != LaunchTargetKind.None && eulaState == EulaState.Accepted; @@ -118,57 +122,6 @@ public async Task InspectAsync( } } - private static long CalculateDirectorySize(string directory) - { - try - { - var dirInfo = new DirectoryInfo(directory); - if (!dirInfo.Exists) - { - return 0L; - } - - long lastWriteTicks = dirInfo.LastWriteTimeUtc.Ticks; - if (s_dirSizeCache.TryGetValue(directory, out var cached) && cached.LastWriteTicks == lastWriteTicks) - { - return cached.Size; - } - - long totalBytes = 0L; - const int maxScannedFiles = 20000; - int count = 0; - - foreach (var file in dirInfo.EnumerateFiles("*", new EnumerationOptions - { - RecurseSubdirectories = true, - IgnoreInaccessible = true, - AttributesToSkip = FileAttributes.ReparsePoint - })) - { - string relativePath = Path.GetRelativePath(directory, file.FullName); - if (relativePath.StartsWith("backups" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || - relativePath.StartsWith("backups" + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - totalBytes += file.Length; - if (++count >= maxScannedFiles) - { - break; - } - } - - s_dirSizeCache[directory] = (lastWriteTicks, totalBytes); - return totalBytes; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - Logger.Warning("計算目錄大小時發生例外: {Message}", ex.Message); - return 0L; - } - } - private static EulaState InspectEula(string directory, HashSet files) { if (!files.Contains("eula.txt")) @@ -179,12 +132,12 @@ private static EulaState InspectEula(string directory, HashSet files) try { string path = Path.Combine(directory, "eula.txt"); - if (new FileInfo(path).Length > MaxMetadataBytes) + if (SafeFileSystem.GetFileLength(path) > MaxMetadataBytes) { return EulaState.Missing; } - string content = File.ReadAllText(path, Encoding.UTF8); + string content = SafeFileSystem.ReadAllText(path, Encoding.UTF8); if (content.Contains("eula=true", StringComparison.OrdinalIgnoreCase)) { return EulaState.Accepted; @@ -219,12 +172,12 @@ private static ServerLaunchTarget InspectLaunchTarget(string directory, HashSet< // 檢查 Forge / NeoForge libraries 中的 win_args.txt string forgeLib = Path.Combine(directory, "libraries", "net", "minecraftforge", "forge"); - if (Directory.Exists(forgeLib)) + if (SafeFileSystem.DirectoryExists(forgeLib)) { - foreach (string sub in Directory.GetDirectories(forgeLib)) + foreach (string sub in SafeFileSystem.EnumerateDirectories(forgeLib)) { string winArgs = Path.Combine(sub, "win_args.txt"); - if (File.Exists(winArgs)) + if (SafeFileSystem.FileExists(winArgs)) { string target = "@" + Path.GetRelativePath(directory, winArgs).Replace('\\', '/'); if (!candidates.Contains(target)) @@ -236,12 +189,12 @@ private static ServerLaunchTarget InspectLaunchTarget(string directory, HashSet< } string neoLib = Path.Combine(directory, "libraries", "net", "neoforged", "neoforge"); - if (Directory.Exists(neoLib)) + if (SafeFileSystem.DirectoryExists(neoLib)) { - foreach (string sub in Directory.GetDirectories(neoLib)) + foreach (string sub in SafeFileSystem.EnumerateDirectories(neoLib)) { string winArgs = Path.Combine(sub, "win_args.txt"); - if (File.Exists(winArgs)) + if (SafeFileSystem.FileExists(winArgs)) { string target = "@" + Path.GetRelativePath(directory, winArgs).Replace('\\', '/'); if (!candidates.Contains(target)) @@ -310,7 +263,7 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion string mcVersionSource = "未偵測到"; string librariesDir = Path.Combine(directory, "libraries"); - bool hasLibraries = Directory.Exists(librariesDir); + bool hasLibraries = SafeFileSystem.DirectoryExists(librariesDir); // 1. 檢查伺服器根目錄下的 version.json string? rootJsonMc = TryExtractMinecraftVersionFromRootJson(directory); @@ -333,7 +286,7 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion primaryJarPath = Path.Combine(directory, target.Value); } - if (primaryJarPath is not null && File.Exists(primaryJarPath)) + if (primaryJarPath is not null && SafeFileSystem.FileExists(primaryJarPath)) { string? jarMc = TryExtractMinecraftVersionFromJar(primaryJarPath); if (!string.IsNullOrEmpty(jarMc)) @@ -349,7 +302,7 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion { // NeoForge string neoLib = Path.Combine(librariesDir, "net", "neoforged", "neoforge"); - if (Directory.Exists(neoLib)) + if (SafeFileSystem.DirectoryExists(neoLib)) { loaderType = "neoforge"; loaderTypeSource = "libraries/net/neoforged/neoforge"; @@ -379,7 +332,7 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion // Forge string forgeLib = Path.Combine(librariesDir, "net", "minecraftforge", "forge"); - if (Directory.Exists(forgeLib)) + if (SafeFileSystem.DirectoryExists(forgeLib)) { loaderType = "forge"; loaderTypeSource = "libraries/net/minecraftforge/forge"; @@ -415,7 +368,7 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion // Fabric string fabricLib = Path.Combine(librariesDir, "net", "fabricmc", "fabric-loader"); - if (Directory.Exists(fabricLib)) + if (SafeFileSystem.DirectoryExists(fabricLib)) { loaderType = "fabric"; loaderTypeSource = "libraries/net/fabricmc/fabric-loader"; @@ -439,7 +392,7 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion // Quilt string quiltLib = Path.Combine(librariesDir, "org", "quiltmc", "quilt-loader"); - if (Directory.Exists(quiltLib)) + if (SafeFileSystem.DirectoryExists(quiltLib)) { loaderType = "quilt"; loaderTypeSource = "libraries/org/quiltmc/quilt-loader"; @@ -543,12 +496,12 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion { string runBat = Path.Combine(directory, "run.bat"); string winArgs = Path.Combine(directory, "win_args.txt"); - string toCheck = File.Exists(runBat) ? runBat : (File.Exists(winArgs) ? winArgs : string.Empty); - if (!string.IsNullOrEmpty(toCheck) && new FileInfo(toCheck).Length <= MaxMetadataBytes) + string toCheck = SafeFileSystem.FileExists(runBat) ? runBat : (SafeFileSystem.FileExists(winArgs) ? winArgs : string.Empty); + if (!string.IsNullOrEmpty(toCheck) && SafeFileSystem.GetFileLength(toCheck) <= MaxMetadataBytes) { try { - string content = File.ReadAllText(toCheck); + string content = SafeFileSystem.ReadAllText(toCheck); var m = ForgeJarRegex().Match(content); if (m.Success) { @@ -580,11 +533,11 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion if (mcVersion == "unknown" || loaderVersion == "unknown") { string? logFile = FindLatestLogFile(directory); - if (logFile is not null && File.Exists(logFile)) + if (logFile is not null && SafeFileSystem.FileExists(logFile)) { try { - using var sr = new StreamReader(logFile, Encoding.UTF8); + using var sr = new StreamReader(SafeFileSystem.OpenRead(logFile), Encoding.UTF8); int lineCount = 0; string? line; while ((line = sr.ReadLine()) is not null && lineCount++ < 2000) @@ -656,23 +609,22 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion private static string? FindLatestLogFile(string serverDirectory) { string logsDir = Path.Combine(serverDirectory, "logs"); - if (Directory.Exists(logsDir)) + if (SafeFileSystem.DirectoryExists(logsDir)) { string latestLog = Path.Combine(logsDir, "latest.log"); - if (File.Exists(latestLog)) + if (SafeFileSystem.FileExists(latestLog)) { return latestLog; } try { - var dirInfo = new DirectoryInfo(logsDir); - var newest = dirInfo.EnumerateFiles("*.log") - .OrderByDescending(static f => f.LastWriteTimeUtc) + string? newest = SafeFileSystem.EnumerateFiles(logsDir, "*.log") + .OrderByDescending(SafeFileSystem.GetFileLastWriteTimeUtc) .FirstOrDefault(); if (newest is not null) { - return newest.FullName; + return newest; } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) @@ -682,7 +634,7 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion } string rootLog = Path.Combine(serverDirectory, "server.log"); - return File.Exists(rootLog) ? rootLog : null; + return SafeFileSystem.FileExists(rootLog) ? rootLog : null; } private const long MaxMetadataBytes = 2L * 1024 * 1024; @@ -690,28 +642,28 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion private static string? TryExtractMinecraftVersionFromRootJson(string directory) { string jsonPath = Path.Combine(directory, "version.json"); - if (!File.Exists(jsonPath)) + if (!SafeFileSystem.FileExists(jsonPath)) { return null; } try { - var fileInfo = new FileInfo(jsonPath); - if (fileInfo.Length > MaxMetadataBytes) + long fileLength = SafeFileSystem.GetFileLength(jsonPath); + if (fileLength > MaxMetadataBytes) { - Logger.Warning("version.json 檔案超過安全大小上限 ({Size} bytes),略過解析", fileInfo.Length); + Logger.Warning("version.json 檔案超過安全大小上限 ({Size} bytes),略過解析", fileLength); return null; } - using var stream = File.OpenRead(jsonPath); + using var stream = SafeFileSystem.OpenRead(jsonPath); byte[]? payload = ReadBoundedBytes(stream, MaxMetadataBytes); if (payload is null) { return null; } - using var doc = JsonDocument.Parse(payload); + using var doc = JsonCodec.ParseDocument(payload); var root = doc.RootElement; foreach (string prop in VersionJsonCandidateProperties) { @@ -740,7 +692,7 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion { try { - using var zip = ZipFile.OpenRead(jarPath); + using var zip = SafeZipArchive.OpenRead(jarPath); var versionEntry = zip.GetEntry("version.json"); if (versionEntry is not null && versionEntry.Length <= MaxMetadataBytes) { @@ -748,7 +700,7 @@ private static (string LoaderType, string LoaderVersion, string MinecraftVersion byte[]? versionBytes = ReadBoundedBytes(stream, MaxMetadataBytes); if (versionBytes is not null) { - using var doc = JsonDocument.Parse(versionBytes); + using var doc = JsonCodec.ParseDocument(versionBytes); var root = doc.RootElement; foreach (string prop in VersionJsonCandidateProperties) { @@ -815,11 +767,11 @@ private static (int MaxMb, int? MinMb) InspectMemory(string directory) int? minMb = null; string argsFile = Path.Combine(directory, "user_jvm_args.txt"); - if (File.Exists(argsFile)) + if (SafeFileSystem.FileExists(argsFile)) { try { - foreach (string line in File.ReadAllLines(argsFile)) + foreach (string line in SafeFileSystem.ReadAllLines(argsFile)) { string trimmed = line.Trim(); if (trimmed.StartsWith('#')) diff --git a/src/MinecraftServerManager.Infrastructure/Servers/ServerIntegrityService.cs b/src/MinecraftServerManager.Infrastructure/Servers/ServerIntegrityService.cs new file mode 100644 index 0000000..0b11365 --- /dev/null +++ b/src/MinecraftServerManager.Infrastructure/Servers/ServerIntegrityService.cs @@ -0,0 +1,132 @@ +using MinecraftServerManager.Core.Ports; +using MinecraftServerManager.Domain.Servers; +using MinecraftServerManager.Infrastructure.FileSystem; + +namespace MinecraftServerManager.Infrastructure.Servers; + +public sealed class ServerIntegrityService : IServerIntegrityService +{ + private static readonly string[] EmptyList = []; + private static readonly string[] DirectoryNotFoundMissingList = ["伺服器目錄不存在"]; + private static readonly string[] PaperMissingList = ["paper.jar"]; + private static readonly string[] QuiltMissingList = ["quilt-server-launch.jar"]; + private static readonly string[] ServerJarMissingList = ["server.jar"]; + private static readonly string[] UnexpectedErrorMissingList = ["未預期的檔案系統錯誤"]; + + public ServerIntegrityResult CheckImportantFiles(string serverPath, LoaderKind loader) + { + if (string.IsNullOrWhiteSpace(serverPath) || !SafeFileSystem.DirectoryExists(serverPath)) + { + return new ServerIntegrityResult(true, DirectoryNotFoundMissingList); + } + + try + { + switch (loader) + { + case LoaderKind.Paper: + { + bool hasPaper = SafeFileSystem.FileExists(Path.Combine(serverPath, "paper.jar")) || + SafeFileSystem.FileExists(Path.Combine(serverPath, "server.jar")) || + SafeFileSystem.EnumerateFiles(serverPath, "paper*.jar", SearchOption.TopDirectoryOnly).Count > 0; + return hasPaper + ? new ServerIntegrityResult(false, EmptyList) + : new ServerIntegrityResult(true, PaperMissingList); + } + + case LoaderKind.Fabric: + { + var missing = new List(2); + if (!SafeFileSystem.FileExists(Path.Combine(serverPath, "fabric-server-launch.jar"))) + { + missing.Add("fabric-server-launch.jar"); + } + if (!SafeFileSystem.FileExists(Path.Combine(serverPath, "server.jar"))) + { + missing.Add("server.jar"); + } + return missing.Count == 0 + ? new ServerIntegrityResult(false, EmptyList) + : new ServerIntegrityResult(true, missing); + } + + case LoaderKind.Quilt: + { + bool hasQuiltLaunch = SafeFileSystem.FileExists(Path.Combine(serverPath, "quilt-server-launch.jar")) || + SafeFileSystem.FileExists(Path.Combine(serverPath, "server", "quilt-server-launch.jar")); + bool hasVanillaServer = SafeFileSystem.FileExists(Path.Combine(serverPath, "server.jar")); + + return (hasQuiltLaunch || hasVanillaServer) + ? new ServerIntegrityResult(false, EmptyList) + : new ServerIntegrityResult(true, QuiltMissingList); + } + + case LoaderKind.NeoForge: + { + bool hasRunScript = SafeFileSystem.FileExists(Path.Combine(serverPath, "start_server.bat")) || + SafeFileSystem.FileExists(Path.Combine(serverPath, "run.bat")) || + SafeFileSystem.FileExists(Path.Combine(serverPath, "run.sh")); + string neoLib = Path.Combine(serverPath, "libraries", "net", "neoforged", "neoforge"); + bool hasWinArgs = SafeFileSystem.DirectoryExists(neoLib) && SafeFileSystem.EnumerateFiles(neoLib, "*args.txt", SearchOption.AllDirectories).Count > 0; + + var missing = new List(2); + if (!hasRunScript) + { + missing.Add("start_server.bat / run.bat"); + } + if (!hasWinArgs) + { + missing.Add("win_args.txt"); + } + + return missing.Count == 0 + ? new ServerIntegrityResult(false, EmptyList) + : new ServerIntegrityResult(true, missing); + } + + case LoaderKind.Forge: + { + bool hasLegacyForge = SafeFileSystem.EnumerateFiles(serverPath, "forge*.jar", SearchOption.TopDirectoryOnly).Count > 0; + if (hasLegacyForge) + { + return new ServerIntegrityResult(false, EmptyList); + } + + bool hasRunScript = SafeFileSystem.FileExists(Path.Combine(serverPath, "start_server.bat")) || + SafeFileSystem.FileExists(Path.Combine(serverPath, "run.bat")) || + SafeFileSystem.FileExists(Path.Combine(serverPath, "run.sh")); + string forgeLib = Path.Combine(serverPath, "libraries", "net", "minecraftforge", "forge"); + bool hasForgeArgs = SafeFileSystem.DirectoryExists(forgeLib) && SafeFileSystem.EnumerateFiles(forgeLib, "*args.txt", SearchOption.AllDirectories).Count > 0; + + var missing = new List(2); + if (!hasRunScript) + { + missing.Add("start_server.bat / run.bat"); + } + if (!hasForgeArgs) + { + missing.Add("win_args.txt"); + } + + return missing.Count == 0 + ? new ServerIntegrityResult(false, EmptyList) + : new ServerIntegrityResult(true, missing); + } + + default: + { + bool hasAnyServer = SafeFileSystem.FileExists(Path.Combine(serverPath, "server.jar")) || + SafeFileSystem.FileExists(Path.Combine(serverPath, "run.bat")) || + SafeFileSystem.EnumerateFiles(serverPath, "*.jar", SearchOption.TopDirectoryOnly).Count > 0; + return hasAnyServer + ? new ServerIntegrityResult(false, EmptyList) + : new ServerIntegrityResult(true, ServerJarMissingList); + } + } + } + catch + { + return new ServerIntegrityResult(true, UnexpectedErrorMissingList); + } + } +} diff --git a/src/MinecraftServerManager.Infrastructure/Servers/ServerLaunchPlanner.cs b/src/MinecraftServerManager.Infrastructure/Servers/ServerLaunchPlanner.cs index 2419044..f252da2 100644 --- a/src/MinecraftServerManager.Infrastructure/Servers/ServerLaunchPlanner.cs +++ b/src/MinecraftServerManager.Infrastructure/Servers/ServerLaunchPlanner.cs @@ -36,7 +36,7 @@ public ServerLaunchPlan CreatePlan(ServerConfig config, ServerInspection inspect throw new InvalidOperationException($"啟動腳本「{targetFile}」未通過安全性檢驗(可能包含未受信任的指令或批次注入字元)"); } - if (config.LoaderType is LoaderKind.Forge or LoaderKind.NeoForge || File.Exists(Path.Combine(stableDir, "user_jvm_args.txt"))) + if (config.LoaderType is LoaderKind.Forge or LoaderKind.NeoForge || SafeFileSystem.FileExists(Path.Combine(stableDir, "user_jvm_args.txt"))) { ServerManager.UpdateForgeUserJvmArgs(stableDir, config); } diff --git a/src/MinecraftServerManager.Infrastructure/Servers/ServerManager.cs b/src/MinecraftServerManager.Infrastructure/Servers/ServerManager.cs index d1a99e3..10f8f50 100644 --- a/src/MinecraftServerManager.Infrastructure/Servers/ServerManager.cs +++ b/src/MinecraftServerManager.Infrastructure/Servers/ServerManager.cs @@ -146,7 +146,7 @@ public async Task CreateServerAsync( } string serverDir = Path.Combine(_serversRoot, plan.Name.Value); - if (Directory.Exists(serverDir) && Directory.EnumerateFileSystemEntries(serverDir).Any()) + if (SafeFileSystem.DirectoryExists(serverDir) && SafeFileSystem.EnumerateFileSystemEntries(serverDir).Any()) { return ServerCreationResult.Failed($"目標目錄已存在且非空:{serverDir}"); } @@ -246,11 +246,11 @@ await _loaderInstaller.InstallLoaderAsync( progress?.Report(new ServerCreationProgressReport(ServerCreationPhase.LaunchScript, 95, "啟動腳本已生成並通過完整性驗證")); // ── Phase 5: Commit (95% ~ 100%) ── - progress?.Report(new ServerCreationProgressReport(ServerCreationPhase.Commit, 95, "正在寫入伺服器註冊表...")); + progress?.Report(new ServerCreationProgressReport(ServerCreationPhase.Commit, 95, "正在登錄伺服器設定...")); - if (Directory.Exists(serverDir)) + if (SafeFileSystem.DirectoryExists(serverDir)) { - Directory.Delete(serverDir, recursive: false); + SafeFileSystem.DeleteDirectory(serverDir, recursive: false); } if (!SafeFileSystem.MoveWithin(_serversRoot, stableStaging, serverDir)) @@ -260,8 +260,8 @@ await _loaderInstaller.InstallLoaderAsync( var config = plan.BuildConfig(serverDir); if (plan.LoaderType is LoaderKind.Forge or LoaderKind.NeoForge || - File.Exists(Path.Combine(serverDir, "user_jvm_args.txt")) || - File.Exists(Path.Combine(serverDir, "run.bat"))) + SafeFileSystem.FileExists(Path.Combine(serverDir, "user_jvm_args.txt")) || + SafeFileSystem.FileExists(Path.Combine(serverDir, "run.bat"))) { UpdateForgeUserJvmArgs(serverDir, config); } @@ -287,7 +287,7 @@ await _loaderInstaller.InstallLoaderAsync( { try { - if (Directory.Exists(cleanupTarget)) + if (SafeFileSystem.DirectoryExists(cleanupTarget)) { SafeFileSystem.DeleteWithin(_serversRoot, cleanupTarget); } @@ -304,7 +304,7 @@ await _loaderInstaller.InstallLoaderAsync( // 清理殘留暫存目錄 try { - if (createdDir && Directory.Exists(stagingDir)) + if (createdDir && SafeFileSystem.DirectoryExists(stagingDir)) { SafeFileSystem.DeleteWithin(_serversRoot, stagingDir); } @@ -320,22 +320,11 @@ await _loaderInstaller.InstallLoaderAsync( /// private static string? CheckFreeSpace(string directory, long requiredBytes) { - try + long? freeSpace = SafeFileSystem.GetAvailableFreeSpace(directory); + if (freeSpace.HasValue && freeSpace.Value < requiredBytes) { string? root = Path.GetPathRoot(Path.GetFullPath(directory)); - if (string.IsNullOrWhiteSpace(root)) - { - return null; - } - - var drive = new DriveInfo(root); - if (drive.IsReady && drive.AvailableFreeSpace < requiredBytes) - { - return $"磁碟 {root} 可用空間不足(需要至少 {requiredBytes / (1024 * 1024)} MB)"; - } - } - catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException) - { + return $"磁碟 {root} 可用空間不足(需要至少 {requiredBytes / (1024 * 1024)} MB)"; } return null; @@ -348,11 +337,11 @@ await _loaderInstaller.InstallLoaderAsync( { // 優先從 Forge / NeoForge 的 run.bat 提取 win_args.txt string runBat = Path.Combine(directory, "run.bat"); - if (File.Exists(runBat)) + if (SafeFileSystem.FileExists(runBat)) { try { - string runBatContent = File.ReadAllText(runBat); + string runBatContent = SafeFileSystem.ReadAllText(runBat); var match = WinArgsRegex().Match(runBatContent); if (match.Success) { @@ -367,12 +356,12 @@ await _loaderInstaller.InstallLoaderAsync( // 檢查 Forge / NeoForge libraries 中的 win_args.txt string forgeLib = Path.Combine(directory, "libraries", "net", "minecraftforge", "forge"); - if (Directory.Exists(forgeLib)) + if (SafeFileSystem.DirectoryExists(forgeLib)) { - foreach (string sub in Directory.GetDirectories(forgeLib)) + foreach (string sub in SafeFileSystem.EnumerateDirectories(forgeLib)) { string winArgs = Path.Combine(sub, "win_args.txt"); - if (File.Exists(winArgs)) + if (SafeFileSystem.FileExists(winArgs)) { return "@" + Path.GetRelativePath(directory, winArgs).Replace('\\', '/'); } @@ -380,19 +369,19 @@ await _loaderInstaller.InstallLoaderAsync( } string neoLib = Path.Combine(directory, "libraries", "net", "neoforged", "neoforge"); - if (Directory.Exists(neoLib)) + if (SafeFileSystem.DirectoryExists(neoLib)) { - foreach (string sub in Directory.GetDirectories(neoLib)) + foreach (string sub in SafeFileSystem.EnumerateDirectories(neoLib)) { string winArgs = Path.Combine(sub, "win_args.txt"); - if (File.Exists(winArgs)) + if (SafeFileSystem.FileExists(winArgs)) { return "@" + Path.GetRelativePath(directory, winArgs).Replace('\\', '/'); } } } - string? paperJar = Directory.GetFiles(directory, "paper*.jar") + string? paperJar = SafeFileSystem.EnumerateFiles(directory, "paper*.jar") .Select(Path.GetFileName) .FirstOrDefault(); if (paperJar is not null) @@ -402,13 +391,13 @@ await _loaderInstaller.InstallLoaderAsync( foreach (string candidate in PreferredLaunchJars) { - if (File.Exists(Path.Combine(directory, candidate))) + if (SafeFileSystem.FileExists(Path.Combine(directory, candidate))) { return candidate; } } - string? anyJar = Directory.EnumerateFiles(directory, "*.jar", SearchOption.TopDirectoryOnly) + string? anyJar = SafeFileSystem.EnumerateFiles(directory, "*.jar", SearchOption.TopDirectoryOnly) .Select(Path.GetFileName) .FirstOrDefault(static name => name is not null && !name.Contains("installer", StringComparison.OrdinalIgnoreCase)); @@ -417,7 +406,7 @@ await _loaderInstaller.InstallLoaderAsync( return anyJar; } - if (File.Exists(runBat)) + if (SafeFileSystem.FileExists(runBat)) { return "run.bat"; } @@ -514,7 +503,7 @@ public static void UpdateForgeUserJvmArgs(string serverDirectory, ServerConfig c if (!string.IsNullOrWhiteSpace(configuredJava) && !string.Equals(configuredJava.Trim(), "java", StringComparison.OrdinalIgnoreCase)) { string trimmed = configuredJava.Trim(); - if (File.Exists(trimmed)) + if (SafeFileSystem.FileExists(trimmed)) { return trimmed; } @@ -527,7 +516,7 @@ public static void UpdateForgeUserJvmArgs(string serverDirectory, ServerConfig c { int targetMajor = await _javaRequirementService.GetRequiredJavaMajorAsync(minecraftVersion, loader, cancellationToken).ConfigureAwait(false); var match = await _javaDetector.FindBestMatchAsync(targetMajor, cancellationToken).ConfigureAwait(false); - if (match is not null && File.Exists(match.ExecutablePath)) + if (match is not null && SafeFileSystem.FileExists(match.ExecutablePath)) { return match.ExecutablePath; } @@ -545,7 +534,7 @@ public static void UpdateForgeUserJvmArgs(string serverDirectory, ServerConfig c var list = await _javaDetector.DetectAsync(forceRefresh: false, cancellationToken: cancellationToken).ConfigureAwait(false); var best = list.Where(static j => j.Is64Bit).OrderByDescending(static j => j.MajorVersion).FirstOrDefault() ?? list.OrderByDescending(static j => j.MajorVersion).FirstOrDefault(); - if (best is not null && File.Exists(best.ExecutablePath)) + if (best is not null && SafeFileSystem.FileExists(best.ExecutablePath)) { return best.ExecutablePath; } @@ -557,7 +546,7 @@ public static void UpdateForgeUserJvmArgs(string serverDirectory, ServerConfig c // 搜尋系統 PATH 中的 java.exe 完整絕對路徑 string? sysJava = FindSystemJavaExecutable(); - if (!string.IsNullOrWhiteSpace(sysJava) && File.Exists(sysJava)) + if (!string.IsNullOrWhiteSpace(sysJava) && SafeFileSystem.FileExists(sysJava)) { return sysJava; } @@ -588,7 +577,7 @@ public static void UpdateForgeUserJvmArgs(string serverDirectory, ServerConfig c try { string candidate = Path.Combine(folderSpan.ToString(), "java.exe"); - if (File.Exists(candidate)) + if (SafeFileSystem.FileExists(candidate)) { return candidate; } @@ -611,7 +600,7 @@ public static void UpdateForgeUserJvmArgs(string serverDirectory, ServerConfig c ///
private static void CleanupRedundantStartupScripts(string directory) { - if (!Directory.Exists(directory)) + if (!SafeFileSystem.DirectoryExists(directory)) { return; } @@ -682,7 +671,7 @@ public async Task DeleteServerAsync(string name, Cancella try { // 兩階段安全刪除 - if (Directory.Exists(serverDir)) + if (SafeFileSystem.DirectoryExists(serverDir)) { var (tombstonePath, journalPath) = ServerTombstoneManager.PrepareDeleteTombstone(_serversRoot, name, serverDir); lock (_lock) @@ -732,11 +721,11 @@ public async Task UpdateServerAsync(ServerConfig config, SaveRegistry(registry); } - if (Directory.Exists(config.Path)) + if (SafeFileSystem.DirectoryExists(config.Path)) { if (config.LoaderType is LoaderKind.Forge or LoaderKind.NeoForge || - File.Exists(Path.Combine(config.Path, "user_jvm_args.txt")) || - File.Exists(Path.Combine(config.Path, "run.bat"))) + SafeFileSystem.FileExists(Path.Combine(config.Path, "user_jvm_args.txt")) || + SafeFileSystem.FileExists(Path.Combine(config.Path, "run.bat"))) { UpdateForgeUserJvmArgs(config.Path, config); } @@ -773,7 +762,7 @@ public async Task ImportServerAsync( return ServerImportResult.Failed(targetName, $"已存在同名的伺服器:{targetName}"); } - bool isZip = File.Exists(sourceDirectory) && sourceDirectory.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + bool isZip = SafeFileSystem.FileExists(sourceDirectory) && sourceDirectory.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); string targetDir = Path.Combine(_serversRoot, targetName); string stagingDir = Path.Combine(_serversRoot, $".import-staging-{Guid.NewGuid():N}"); bool completedSuccessfully = false; @@ -782,7 +771,7 @@ public async Task ImportServerAsync( try { - Directory.CreateDirectory(stagingDir); + SafeFileSystem.CreateDirectory(stagingDir); if (isZip) { @@ -820,18 +809,18 @@ await Task.Run(() => // 確保 eula.txt 存在 string eulaFile = Path.Combine(stableStaging, "eula.txt"); - if (!File.Exists(eulaFile)) + if (!SafeFileSystem.FileExists(eulaFile)) { AtomicFileWriter.WriteText(eulaFile, "eula=true\n"); } // 自動遷移舊版 server.properties 屬性 string propsPath = Path.Combine(stableStaging, "server.properties"); - if (File.Exists(propsPath)) + if (SafeFileSystem.FileExists(propsPath)) { try { - string propsContent = await File.ReadAllTextAsync(propsPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); + string propsContent = await SafeFileSystem.ReadAllTextAsync(propsPath, Encoding.UTF8, cancellationToken: cancellationToken).ConfigureAwait(false); var rawProps = PropertiesDocumentCodec.Parse(propsContent); var migrationPlan = ServerPropertiesMigrationService.PlanMigration(rawProps, inspection.MinecraftVersion); if (migrationPlan.NeedsMigration) @@ -852,12 +841,12 @@ await Task.Run(() => : (DetectLaunchJar(stableStaging) ?? "server.jar"); string existingBat = Path.Combine(stableStaging, "start_server.bat"); - if (File.Exists(existingBat)) + if (SafeFileSystem.FileExists(existingBat)) { try { string backupBat = Path.Combine(stableStaging, "start_server.bat.bak"); - File.Copy(existingBat, backupBat, overwrite: true); + SafeFileSystem.CopyFile(existingBat, backupBat, overwrite: true); } catch { @@ -881,11 +870,11 @@ await Task.Run(() => string dest = Path.Combine(targetDir, Path.GetFileName(entry.FullPath)); if (entry.IsDirectory) { - Directory.Move(entry.FullPath, dest); + SafeFileSystem.MovePath(entry.FullPath, dest); } else { - File.Move(entry.FullPath, dest); + SafeFileSystem.MovePath(entry.FullPath, dest); } } @@ -916,7 +905,7 @@ await Task.Run(() => catch (Exception ex) { Logger.Error(ex, "匯入伺服器「{Name}」失敗: {Message}", targetName, ex.Message); - if (!completedSuccessfully && Directory.Exists(targetDir)) + if (!completedSuccessfully && SafeFileSystem.DirectoryExists(targetDir)) { try { @@ -930,7 +919,7 @@ await Task.Run(() => } finally { - if (Directory.Exists(stagingDir)) + if (SafeFileSystem.DirectoryExists(stagingDir)) { try { @@ -968,7 +957,7 @@ public async Task ScanAndRegisterAsync(CancellationToken cance .Select(static p => Path.GetFullPath(p).TrimEnd(Path.DirectorySeparatorChar)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (string directory in Directory.EnumerateDirectories(_serversRoot)) + foreach (string directory in SafeFileSystem.EnumerateDirectories(_serversRoot)) { cancellationToken.ThrowIfCancellationRequested(); @@ -1049,7 +1038,7 @@ public async Task ScanAndRegisterAsync(CancellationToken cance private Dictionary LoadRegistry() { - if (!File.Exists(_registryFilePath)) + if (!SafeFileSystem.FileExists(_registryFilePath)) { _cachedRegistry = new Dictionary(StringComparer.OrdinalIgnoreCase); return new Dictionary(_cachedRegistry, StringComparer.OrdinalIgnoreCase); @@ -1057,13 +1046,13 @@ private Dictionary LoadRegistry() try { - DateTime lastWrite = File.GetLastWriteTimeUtc(_registryFilePath); + DateTime lastWrite = SafeFileSystem.GetFileLastWriteTimeUtc(_registryFilePath); if (_cachedRegistry is not null && lastWrite == _lastRegistryWriteTimeUtc) { return new Dictionary(_cachedRegistry, StringComparer.OrdinalIgnoreCase); } - string json = File.ReadAllText(_registryFilePath, Encoding.UTF8); + string json = SafeFileSystem.ReadAllText(_registryFilePath, Encoding.UTF8); var loaded = JsonCodec.Deserialize>(json) ?? new Dictionary(StringComparer.OrdinalIgnoreCase); _cachedRegistry = new Dictionary(loaded, StringComparer.OrdinalIgnoreCase); @@ -1081,9 +1070,9 @@ private void SaveRegistry(Dictionary registry) string json = JsonCodec.Serialize(registry, indented: true); AtomicFileWriter.WriteText(_registryFilePath, json); _cachedRegistry = new Dictionary(registry, StringComparer.OrdinalIgnoreCase); - if (File.Exists(_registryFilePath)) + if (SafeFileSystem.FileExists(_registryFilePath)) { - _lastRegistryWriteTimeUtc = File.GetLastWriteTimeUtc(_registryFilePath); + _lastRegistryWriteTimeUtc = SafeFileSystem.GetFileLastWriteTimeUtc(_registryFilePath); } } @@ -1107,15 +1096,15 @@ private static void FlattenSingleWrapper(string stagingDir) string destination = Path.Combine(stagingDir, Path.GetFileName(child.FullPath)); if (child.IsDirectory) { - Directory.Move(child.FullPath, destination); + SafeFileSystem.MovePath(child.FullPath, destination); } else { - File.Move(child.FullPath, destination); + SafeFileSystem.MovePath(child.FullPath, destination); } } - Directory.Delete(wrapperPath, recursive: true); + SafeFileSystem.DeleteDirectory(wrapperPath, recursive: true); } else { diff --git a/src/MinecraftServerManager.Infrastructure/Servers/ServerPlayerConfigStore.cs b/src/MinecraftServerManager.Infrastructure/Servers/ServerPlayerConfigStore.cs index ab4a0a3..b3c2846 100644 --- a/src/MinecraftServerManager.Infrastructure/Servers/ServerPlayerConfigStore.cs +++ b/src/MinecraftServerManager.Infrastructure/Servers/ServerPlayerConfigStore.cs @@ -1,9 +1,6 @@ -using System.Text.Encodings.Web; -using System.Text.Json; using MinecraftServerManager.Core.Ports; using MinecraftServerManager.Domain.Servers; using MinecraftServerManager.Infrastructure.FileSystem; -using MinecraftServerManager.Infrastructure.Serialization; using MinecraftServerManager.Infrastructure.Utilities; namespace MinecraftServerManager.Infrastructure.Servers; @@ -19,13 +16,6 @@ public sealed class ServerPlayerConfigStore : IServerPlayerConfigStore private const string BannedIpsFileName = ServerDefaults.BannedIpsFileName; private const string UserCacheFileName = ServerDefaults.UserCacheFileName; - private static readonly JsonSerializerOptions IndentedJsonOptions = new() - { - WriteIndented = true, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - TypeInfoResolver = AppJsonSerializerContext.Default, - }; - /// public async Task LoadBundleAsync(string serverPath, CancellationToken cancellationToken = default) { @@ -34,11 +24,11 @@ public async Task LoadBundleAsync(string serverPath, C return await Task.Run(() => { - var userCache = ReadJsonFile(serverPath, UserCacheFileName, AppJsonSerializerContext.Default.ListServerUserCacheEntry); - var ops = ReadJsonFile(serverPath, OpsFileName, AppJsonSerializerContext.Default.ListServerOperatorEntry); - var whitelist = ReadJsonFile(serverPath, WhitelistFileName, AppJsonSerializerContext.Default.ListServerWhitelistEntry); - var bannedPlayers = ReadJsonFile(serverPath, BannedPlayersFileName, AppJsonSerializerContext.Default.ListServerBannedPlayerEntry); - var bannedIps = ReadJsonFile(serverPath, BannedIpsFileName, AppJsonSerializerContext.Default.ListServerBannedIpEntry); + var userCache = ReadJsonFile(serverPath, UserCacheFileName); + var ops = ReadJsonFile(serverPath, OpsFileName); + var whitelist = ReadJsonFile(serverPath, WhitelistFileName); + var bannedPlayers = ReadJsonFile(serverPath, BannedPlayersFileName); + var bannedIps = ReadJsonFile(serverPath, BannedIpsFileName); return new ServerPlayerConfigBundle( UserCache: userCache ?? [], @@ -65,12 +55,12 @@ await Task.Run(() => }, cancellationToken).ConfigureAwait(false); } - private static List? ReadJsonFile(string serverPath, string fileName, System.Text.Json.Serialization.Metadata.JsonTypeInfo> typeInfo) + private static List? ReadJsonFile(string serverPath, string fileName) { try { string targetPath = Path.Combine(serverPath, fileName); - if (!File.Exists(targetPath)) + if (!SafeFileSystem.FileExists(targetPath)) { return null; } @@ -81,7 +71,7 @@ await Task.Run(() => return null; } - return JsonSerializer.Deserialize(bytes, typeInfo); + return JsonCodec.Deserialize>(bytes); } catch { @@ -93,8 +83,8 @@ private static void WriteJsonFile(string serverPath, string fileName, IReadOn { string targetPath = Path.Combine(serverPath, fileName); var list = items is List concreteList ? concreteList : [.. items]; - byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(list, IndentedJsonOptions); - if (!AtomicFileWriter.WriteBytes(targetPath, bytes)) + byte[]? bytes = JsonCodec.SerializeToUtf8Bytes(list, indented: true); + if (bytes is null || !AtomicFileWriter.WriteBytes(targetPath, bytes)) { throw new IOException($"無法以不可分割方式寫入伺服器玩家設定檔:{fileName}"); } diff --git a/src/MinecraftServerManager.Infrastructure/Servers/ServerPropertiesMigrationService.cs b/src/MinecraftServerManager.Infrastructure/Servers/ServerPropertiesMigrationService.cs index 3fc2f79..55038d9 100644 --- a/src/MinecraftServerManager.Infrastructure/Servers/ServerPropertiesMigrationService.cs +++ b/src/MinecraftServerManager.Infrastructure/Servers/ServerPropertiesMigrationService.cs @@ -131,7 +131,7 @@ public static bool ApplyMigrationToDirectory( string stableDir = SafeFileSystem.ResolveStableDirectory(directoryPath); string propsPath = Path.Combine(stableDir, "server.properties"); - if (!File.Exists(propsPath)) + if (!SafeFileSystem.FileExists(propsPath)) { return false; } @@ -141,7 +141,7 @@ public static bool ApplyMigrationToDirectory( string backupPath = Path.Combine(stableDir, "server.properties.backup"); try { - File.Copy(propsPath, backupPath, overwrite: true); + SafeFileSystem.CopyFile(propsPath, backupPath, overwrite: true); Logger.Information("已建立 server.properties 備份:{BackupPath}", backupPath); } catch (Exception ex) diff --git a/src/MinecraftServerManager.Infrastructure/Servers/ServerPropertiesStore.cs b/src/MinecraftServerManager.Infrastructure/Servers/ServerPropertiesStore.cs index f56004f..72bd711 100644 --- a/src/MinecraftServerManager.Infrastructure/Servers/ServerPropertiesStore.cs +++ b/src/MinecraftServerManager.Infrastructure/Servers/ServerPropertiesStore.cs @@ -32,7 +32,7 @@ public async Task ReadAsync( try { string path = GetPropertiesFilePath(serverName); - if (!File.Exists(path)) + if (!SafeFileSystem.FileExists(path)) { return new ServerPropertiesSnapshot( ServerName: serverName, @@ -42,8 +42,8 @@ public async Task ReadAsync( Message: "找不到 server.properties,已套用預設值"); } - var fileInfo = new FileInfo(path); - if (fileInfo.Length == 0) + long fileLength = SafeFileSystem.GetFileLength(path); + if (fileLength == 0) { string emptyHash = HashCalculator.DigestBytes([]); return new ServerPropertiesSnapshot( @@ -54,7 +54,7 @@ public async Task ReadAsync( Message: "server.properties 為空檔案,已套用預設值"); } - if (fileInfo.Length > MaxPropertiesFileBytes) + if (fileLength > MaxPropertiesFileBytes) { return new ServerPropertiesSnapshot( ServerName: serverName, @@ -65,7 +65,7 @@ public async Task ReadAsync( } byte[] bytes; - var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, bufferSize: 4096, useAsync: true); + var stream = SafeFileSystem.OpenRead(path, FileShare.ReadWrite, FileOptions.Asynchronous | FileOptions.SequentialScan); await using (stream.ConfigureAwait(false)) { bytes = new byte[stream.Length]; diff --git a/src/MinecraftServerManager.Infrastructure/Servers/ServerRuntime.cs b/src/MinecraftServerManager.Infrastructure/Servers/ServerRuntime.cs index e7cca8a..5977967 100644 --- a/src/MinecraftServerManager.Infrastructure/Servers/ServerRuntime.cs +++ b/src/MinecraftServerManager.Infrastructure/Servers/ServerRuntime.cs @@ -18,6 +18,7 @@ public sealed class ServerRuntime(IProcessRunner processRunner) : IServerRuntime public bool IsReady { get; private set; } public int? ExitCode => _process?.ExitCode; public string? ActiveServerName { get; private set; } + public long? GetWorkingSet64() => _process?.GetWorkingSet64(); public event Action? OutputLineReceived; public event Action? ServerStarted; diff --git a/src/MinecraftServerManager.Infrastructure/Servers/ServerStartupTargetResolver.cs b/src/MinecraftServerManager.Infrastructure/Servers/ServerStartupTargetResolver.cs index 4a5bc0a..50ebd69 100644 --- a/src/MinecraftServerManager.Infrastructure/Servers/ServerStartupTargetResolver.cs +++ b/src/MinecraftServerManager.Infrastructure/Servers/ServerStartupTargetResolver.cs @@ -1,3 +1,5 @@ +using MinecraftServerManager.Infrastructure.FileSystem; + namespace MinecraftServerManager.Infrastructure.Servers; /// @@ -16,22 +18,22 @@ public static string Resolve(string serverDirectory) { ArgumentException.ThrowIfNullOrWhiteSpace(serverDirectory); - if (File.Exists(Path.Combine(serverDirectory, UnifiedScript))) + if (SafeFileSystem.FileExists(Path.Combine(serverDirectory, UnifiedScript))) { return UnifiedScript; } - if (File.Exists(Path.Combine(serverDirectory, FallbackScript))) + if (SafeFileSystem.FileExists(Path.Combine(serverDirectory, FallbackScript))) { return FallbackScript; } - if (File.Exists(Path.Combine(serverDirectory, DefaultCoreJar))) + if (SafeFileSystem.FileExists(Path.Combine(serverDirectory, DefaultCoreJar))) { return DefaultCoreJar; } - string? firstJar = Directory.EnumerateFiles(serverDirectory, "*.jar").FirstOrDefault(); - return firstJar is not null ? Path.GetFileName(firstJar) : DefaultCoreJar; + IReadOnlyList jars = SafeFileSystem.EnumerateFiles(serverDirectory, "*.jar"); + return jars.Count > 0 ? Path.GetFileName(jars[0]) : DefaultCoreJar; } } diff --git a/src/MinecraftServerManager.Infrastructure/Services/AppUpdateService.cs b/src/MinecraftServerManager.Infrastructure/Services/AppUpdateService.cs index 6feeb77..46b6632 100644 --- a/src/MinecraftServerManager.Infrastructure/Services/AppUpdateService.cs +++ b/src/MinecraftServerManager.Infrastructure/Services/AppUpdateService.cs @@ -1,8 +1,7 @@ -using System.Buffers; -using System.Diagnostics; using System.Text.RegularExpressions; using MinecraftServerManager.Core.Ports; using MinecraftServerManager.Core.Utilities; +using MinecraftServerManager.Infrastructure.FileSystem; using MinecraftServerManager.Infrastructure.Logging; using MinecraftServerManager.Infrastructure.Utilities; @@ -11,16 +10,29 @@ namespace MinecraftServerManager.Infrastructure.Services; /// /// 應用程式自動更新下載、安全性驗證與重啟安裝服務實作 /// -public sealed partial class AppUpdateService( - IHttpPort httpPort, - string? updatesDirectory = null) : IAppUpdateService +public sealed partial class AppUpdateService : IAppUpdateService { private static readonly ComponentLogger Logger = AppLogging.CreateComponentLogger("AppUpdateService"); - private static readonly SearchValues InvalidBatchPathChars = SearchValues.Create("%\"\r\n"); [GeneratedRegex(@"\b[a-fA-F0-9]{64}\b")] private static partial Regex Sha256Regex(); + private readonly IHttpPort _httpPort; + private readonly string? _updatesDirectory; + private readonly IProcessRunner? _processRunner; + + public AppUpdateService( + IHttpPort httpPort, + string? updatesDirectory = null, + IProcessRunner? processRunner = null) + { + _httpPort = httpPort ?? throw new ArgumentNullException(nameof(httpPort)); + _updatesDirectory = updatesDirectory; + _processRunner = processRunner; + + TryCleanupPreviousUpdateBackup(); + } + public async Task DownloadAndVerifyUpdateAsync( UpdateCheckResult update, IProgress<(int Percentage, string StatusMessage)>? progress = null, @@ -40,7 +52,7 @@ public async Task DownloadAndVerifyUpdateAsync( } progress?.Report((5, "正在取得安全驗證資訊 (SHA-256)...")); - string? checksumText = await httpPort.GetTextAsync(checksumUri, cancellationToken).ConfigureAwait(false); + string? checksumText = await _httpPort.GetTextAsync(checksumUri, cancellationToken).ConfigureAwait(false); Match match = string.IsNullOrWhiteSpace(checksumText) ? Match.Empty : Sha256Regex().Match(checksumText); if (!match.Success) { @@ -50,8 +62,8 @@ public async Task DownloadAndVerifyUpdateAsync( string expectedSha256 = match.Value.ToLowerInvariant(); Logger.Information("成功取得預期 SHA-256 校驗碼: {Hash}", expectedSha256); - string targetDir = updatesDirectory ?? Path.Combine(RuntimePaths.GetUserDataDir(), "updates"); - Directory.CreateDirectory(targetDir); + string targetDir = _updatesDirectory ?? Path.Combine(RuntimePaths.GetUserDataDir(), "updates"); + SafeFileSystem.CreateDirectory(targetDir); string rawFileName = Path.GetFileName(downloadUri.LocalPath); if (string.IsNullOrWhiteSpace(rawFileName) || !rawFileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) @@ -62,7 +74,7 @@ public async Task DownloadAndVerifyUpdateAsync( string targetFilePath = Path.Combine(targetDir, rawFileName); // 若本地已存在且符合 SHA-256 與 PE 結構則直接複用 - if (File.Exists(targetFilePath)) + if (SafeFileSystem.FileExists(targetFilePath)) { string existingHash = HashCalculator.Shared.ComputeFileHash(targetFilePath, "sha256"); if (string.Equals(existingHash, expectedSha256, StringComparison.OrdinalIgnoreCase) && VerifyPeHeader(targetFilePath)) @@ -84,7 +96,7 @@ public async Task DownloadAndVerifyUpdateAsync( }); // 下載端已於寫入時以 expectedHash 驗證 SHA-256,失敗即視為整體失敗,不再重複計算雜湊 - var downloadResult = await httpPort.DownloadAsync( + var downloadResult = await _httpPort.DownloadAsync( downloadUri, targetFilePath, httpProgress, @@ -114,90 +126,97 @@ public void ApplyUpdateAndRestart(string verifiedInstallerPath) { ArgumentException.ThrowIfNullOrWhiteSpace(verifiedInstallerPath); - if (!File.Exists(verifiedInstallerPath)) + if (!SafeFileSystem.FileExists(verifiedInstallerPath)) { throw new FileNotFoundException("找不到已驗證的更新檔案。", verifiedInstallerPath); } string? currentExePath = Environment.ProcessPath; - if (string.IsNullOrWhiteSpace(currentExePath) || !File.Exists(currentExePath)) + if (string.IsNullOrWhiteSpace(currentExePath) || !SafeFileSystem.FileExists(currentExePath)) { throw new InvalidOperationException("無法判斷目前應用程式的執行檔路徑,無法套用自動更新。"); } - int pid = Environment.ProcessId; - string dir = Path.GetDirectoryName(verifiedInstallerPath) ?? Path.GetTempPath(); - string scriptPath = Path.Combine(dir, "apply_update.cmd"); - ValidateBatchPath(currentExePath); - ValidateBatchPath(verifiedInstallerPath); - - // 替換前先備份舊版:複製失敗時還原並重新啟動舊版,同時留下失敗記錄,避免程式消失而使用者毫無提示 - string scriptContent = $""" - @echo off - chcp 65001 >nul - set "TARGET={currentExePath}" - set "SOURCE={verifiedInstallerPath}" - set "BACKUP=%TARGET%.bak" - set "FAILLOG=%~dp0update_failed.txt" - set "PID={pid}" - set /a WAITED=0 - - :wait_loop - tasklist /fi "pid eq %PID%" /nh 2>nul | findstr /r /c:"[ ]%PID%[ ]" >nul - if errorlevel 1 goto do_replace - set /a WAITED+=1 - if %WAITED% gtr 60 goto fail_wait - timeout /t 1 /nobreak >nul - goto wait_loop - - :do_replace - if exist "%BACKUP%" del /f /q "%BACKUP%" >nul 2>&1 - move /y "%TARGET%" "%BACKUP%" >nul - if errorlevel 1 goto fail_backup - copy /y "%SOURCE%" "%TARGET%" >nul - if errorlevel 1 goto fail_copy - start "" "%TARGET%" - del /f /q "%BACKUP%" >nul 2>&1 - del /f /q "%SOURCE%" >nul 2>&1 - (goto) 2>nul & del "%~f0" >nul 2>&1 - exit /b 0 - - :fail_copy - move /y "%BACKUP%" "%TARGET%" >nul - echo 自動更新失敗:無法寫入 %TARGET%,已還原舊版。請確認檔案位置具有寫入權限,或手動下載更新。> "%FAILLOG%" - start "" "%TARGET%" - exit /b 1 - - :fail_backup - echo 自動更新失敗:無法備份舊版 %TARGET%。請確認檔案位置具有寫入權限,或手動下載更新。> "%FAILLOG%" - start "" "%TARGET%" - exit /b 1 - - :fail_wait - echo 自動更新失敗:等待舊版程式結束逾時。請手動下載更新。> "%FAILLOG%" - exit /b 2 - """; - - File.WriteAllText(scriptPath, scriptContent, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - - var startInfo = new ProcessStartInfo - { - FileName = "cmd.exe", - Arguments = $"/c \"\"{scriptPath}\"\"", - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = AppDomain.CurrentDomain.BaseDirectory, - }; - - System.Diagnostics.Process.Start(startInfo); - Logger.Information("已啟動更新替換腳本,準備重啟應用程式: PID={Pid}, Target={Target}", pid, currentExePath); + if (_processRunner is null) + { + throw new InvalidOperationException("未設定共用行程管理服務,無法啟動新版應用程式。"); + } + + string oldExePath = currentExePath + ".old"; + + try + { + if (SafeFileSystem.FileExists(oldExePath)) + { + SafeFileSystem.DeleteFile(oldExePath); + } + + // Windows 支援將執行中的檔案重新命名(原檔移至 .old) + SafeFileSystem.MovePath(currentExePath, oldExePath, overwrite: true); + + try + { + // 將已驗證的更新檔複製為正式執行檔 + SafeFileSystem.CopyFile(verifiedInstallerPath, currentExePath, overwrite: true); + } + catch (Exception copyEx) + { + // 若複製失敗,立即復原舊版執行檔 + try + { + if (SafeFileSystem.FileExists(oldExePath) && !SafeFileSystem.FileExists(currentExePath)) + { + SafeFileSystem.MovePath(oldExePath, currentExePath, overwrite: true); + } + } + catch (Exception rollbackEx) + { + Logger.Error("復原舊版執行檔失敗: {Message}", rollbackEx.Message); + } + + throw new InvalidOperationException($"替換新版執行檔失敗,已嘗試復原: {copyEx.Message}", copyEx); + } + + // 啟動新版執行檔 + _processRunner.StartDetached(new ProcessStartSpec( + currentExePath, + [], + WorkingDirectory: AppDomain.CurrentDomain.BaseDirectory, + CreateNoWindow: false)); + + Logger.Information("新版主程式已成功替換並啟動: Target={Target}, OldBackup={Backup}", currentExePath, oldExePath); + + // 嘗試清理已複製的下載更新暫存檔 + TryDelete(verifiedInstallerPath); + } + catch (Exception ex) when (ex is not InvalidOperationException and not FileNotFoundException) + { + Logger.Error("套用自動更新失敗: {Message}", ex.Message); + throw new InvalidOperationException($"套用自動更新發生錯誤: {ex.Message}", ex); + } } - private static void ValidateBatchPath(string path) + /// + /// 清理前次更新時留下的 .old 備份檔案 + /// + public static void TryCleanupPreviousUpdateBackup() { - if (path.AsSpan().IndexOfAny(InvalidBatchPathChars) >= 0) + try + { + string? currentExePath = Environment.ProcessPath; + if (!string.IsNullOrWhiteSpace(currentExePath)) + { + string oldExePath = currentExePath + ".old"; + if (SafeFileSystem.FileExists(oldExePath)) + { + SafeFileSystem.DeleteFile(oldExePath); + Logger.Information("已清理前次更新備份檔: {Path}", oldExePath); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - throw new InvalidOperationException("更新路徑含有批次指令無法安全處理的字元,已拒絕自動更新。"); + Logger.Warning("暫時無法清理前次更新備份檔: {Message}", ex.Message); } } @@ -205,9 +224,9 @@ private static void TryDelete(string path) { try { - if (File.Exists(path)) + if (SafeFileSystem.FileExists(path)) { - File.Delete(path); + SafeFileSystem.DeleteFile(path); } } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) @@ -223,7 +242,7 @@ private static bool VerifyPeHeader(string filePath) { try { - using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var fs = SafeFileSystem.OpenRead(filePath, FileShare.Read); if (fs.Length < 64) { return false; diff --git a/src/MinecraftServerManager.Infrastructure/Services/UpdateCheckerService.cs b/src/MinecraftServerManager.Infrastructure/Services/UpdateCheckerService.cs index 977bf7b..52cf36b 100644 --- a/src/MinecraftServerManager.Infrastructure/Services/UpdateCheckerService.cs +++ b/src/MinecraftServerManager.Infrastructure/Services/UpdateCheckerService.cs @@ -2,6 +2,8 @@ using MinecraftServerManager.Core; using MinecraftServerManager.Core.Ports; using MinecraftServerManager.Core.Utilities; +using MinecraftServerManager.Infrastructure.FileSystem; +using MinecraftServerManager.Infrastructure.Utilities; namespace MinecraftServerManager.Infrastructure.Services; @@ -30,14 +32,14 @@ public bool IsNet10DesktopRuntimeInstalled() static bool CheckRuntimeDirectory(string path) { - if (!Directory.Exists(path)) + if (!SafeFileSystem.DirectoryExists(path)) { return false; } try { - return Directory.EnumerateDirectories(path, "10.*").Any(); + return SafeFileSystem.EnumerateDirectories(path, "10.*").Any(); } catch { @@ -49,7 +51,7 @@ static bool CheckRuntimeDirectory(string path) private static bool? s_isFrameworkDependent; /// - /// 發布時由 App.csproj 寫入 DistributionMode 組件中繼資料;不依賴執行檔檔名,使用者重新命名檔案亦不影響判斷。 + /// 發布時由 App.csproj 寫入 DistributionMode 組件中繼資料;不仰賴執行檔檔名,使用者重新命名檔案亦不影響判斷。 /// 未標記(如本機開發建置)一律視為框架相依。 /// public bool IsCurrentProcessFrameworkDependent() @@ -120,7 +122,7 @@ public async Task CheckForUpdateAsync(CancellationToken cance ReleasePageUrl: defaultPageUrl); } - using var doc = JsonDocument.Parse(json); + using var doc = JsonCodec.ParseDocument(json); var root = doc.RootElement; string tagName = root.TryGetProperty("tag_name", out var tagElem) ? tagElem.GetString() ?? "" : ""; diff --git a/src/MinecraftServerManager.Infrastructure/Settings/SettingsManager.cs b/src/MinecraftServerManager.Infrastructure/Settings/SettingsManager.cs index 6d26bec..dfcd1ef 100644 --- a/src/MinecraftServerManager.Infrastructure/Settings/SettingsManager.cs +++ b/src/MinecraftServerManager.Infrastructure/Settings/SettingsManager.cs @@ -1,5 +1,4 @@ using System.Collections.Frozen; -using System.Text.Json; using MinecraftServerManager.Core.Errors; using MinecraftServerManager.Infrastructure.FileSystem; using MinecraftServerManager.Infrastructure.Utilities; @@ -9,12 +8,6 @@ namespace MinecraftServerManager.Infrastructure.Settings; public sealed class SettingsManager { private static readonly FrozenSet ValidThemeModes = FrozenSet.ToFrozenSet(["system", "light", "dark"], StringComparer.Ordinal); - private static readonly JsonSerializerOptions ReadOptions = new() - { - PropertyNameCaseInsensitive = true, - TypeInfoResolver = Serialization.AppJsonSerializerContext.Default, - }; - private readonly Lock _syncRoot = new(); private readonly string _settingsPath; private UserSettings _settings; @@ -95,12 +88,12 @@ public string GetValidatedServersRootPath(bool create = false) } serversRoot = NormalizeServersRootPath(serversRoot); - if (File.Exists(serversRoot)) + if (SafeFileSystem.FileExists(serversRoot)) { throw new ConfigurationException($"伺服器資料夾路徑無效: {serversRoot}"); } - if (!Directory.Exists(serversRoot) && !create) + if (!SafeFileSystem.DirectoryExists(serversRoot) && !create) { throw new ConfigurationException($"找不到伺服器資料夾: {serversRoot}"); } @@ -144,7 +137,7 @@ public bool IsFirstRunCompleted() public void MarkFirstRunCompleted() => Update(static settings => settings with { FirstRunCompleted = true }); /// - /// 全域 UI 縮放比例(0.5 ~ 3.0,預設 1.0) + /// 全域 UI 縮放比例(0.5~3.0,預設 1.0) /// public double GetUiScale() { @@ -209,22 +202,13 @@ private UserSettings LoadSettings() return defaults; } - try - { - var loaded = result.Document.RootElement.Deserialize(ReadOptions); - var normalized = NormalizeSettings(loaded ?? new UserSettings()); - if (loaded != normalized) - { - Save(normalized); - } - return normalized; - } - catch (JsonException) + UserSettings? loaded = JsonCodec.Deserialize(result.RawBytes); + var normalized = NormalizeSettings(loaded ?? new UserSettings()); + if (loaded != normalized) { - var fallback = new UserSettings(); - Save(fallback); - return fallback; + Save(normalized); } + return normalized; } private void Update(Func update) diff --git a/src/MinecraftServerManager.Infrastructure/Utilities/HashCalculator.cs b/src/MinecraftServerManager.Infrastructure/Utilities/HashCalculator.cs index 2955f0a..ed37ff6 100644 --- a/src/MinecraftServerManager.Infrastructure/Utilities/HashCalculator.cs +++ b/src/MinecraftServerManager.Infrastructure/Utilities/HashCalculator.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; +using MinecraftServerManager.Infrastructure.FileSystem; namespace MinecraftServerManager.Infrastructure.Utilities; @@ -49,29 +50,26 @@ public string ComputeFileHash( try { - var fileInfo = new FileInfo(filePath); - if (!fileInfo.Exists || fileInfo.Length > maxBytes) + FileSystemMetadata metadata = SafeFileSystem.GetFileMetadata(filePath); + if (metadata.Length > maxBytes) { return string.Empty; } var key = new CacheKey( - fileInfo.FullName, + metadata.FullPath, normalizedAlgorithm, - fileInfo.Length, - fileInfo.LastWriteTimeUtc.Ticks, + metadata.Length, + metadata.LastWriteTimeUtc.Ticks, maxBytes); if (useCache && cache.TryGetValue(key, out string? cached)) { return cached; } - using var stream = new FileStream( - fileInfo.FullName, - FileMode.Open, - FileAccess.Read, + using var stream = SafeFileSystem.OpenRead( + metadata.FullPath, FileShare.Read, - 64 * 1024, FileOptions.SequentialScan); string digest = HashStream(stream, normalizedAlgorithm); if (useCache) diff --git a/src/MinecraftServerManager.Infrastructure/Utilities/JsonCodec.cs b/src/MinecraftServerManager.Infrastructure/Utilities/JsonCodec.cs index 739ed50..365d60a 100644 --- a/src/MinecraftServerManager.Infrastructure/Utilities/JsonCodec.cs +++ b/src/MinecraftServerManager.Infrastructure/Utilities/JsonCodec.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Encodings.Web; using MinecraftServerManager.Infrastructure.FileSystem; namespace MinecraftServerManager.Infrastructure.Utilities; @@ -12,6 +13,21 @@ public static class JsonCodec { public const long DefaultMaxBytes = 2L * 1024 * 1024; + public static JsonDocument ParseDocument(string json, JsonDocumentOptions options = default) => + JsonDocument.Parse(json, options); + + public static JsonDocument ParseDocument(ReadOnlyMemory utf8Json, JsonDocumentOptions options = default) => + JsonDocument.Parse(utf8Json, options); + + public static JsonDocument ParseDocument(Stream utf8Json, JsonDocumentOptions options = default) => + JsonDocument.Parse(utf8Json, options); + + public static Task ParseDocumentAsync( + Stream utf8Json, + JsonDocumentOptions options = default, + CancellationToken cancellationToken = default) => + JsonDocument.ParseAsync(utf8Json, options, cancellationToken); + private static readonly JsonSerializerOptions CompactOptions = new() { PropertyNamingPolicy = null, @@ -24,6 +40,11 @@ public static class JsonCodec WriteIndented = true, }; + private static readonly JsonSerializerOptions RelaxedIndentedOptions = new(IndentedOptions) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + public static JsonReadResult? ReadJsonWithBytes(string filePath, long maxBytes = DefaultMaxBytes) { if (string.IsNullOrWhiteSpace(filePath) || maxBytes < 0) @@ -36,7 +57,7 @@ public static class JsonCodec byte[]? bytes = SafeFileSystem.ReadRegularFile(filePath, maxBytes); return bytes is null ? null - : new JsonReadResult(JsonDocument.Parse(bytes), bytes); + : new JsonReadResult(ParseDocument(bytes), bytes); } catch (JsonException) { @@ -62,7 +83,7 @@ public static class JsonCodec try { byte[]? bytes = SafeFileSystem.ReadRegularFile(filePath, maxBytes); - return bytes is null ? null : JsonDocument.Parse(bytes); + return bytes is null ? null : ParseDocument(bytes); } catch (JsonException) { @@ -94,6 +115,22 @@ public static string Serialize(T? value, bool indented = false) } } + public static string SerializeRelaxed(T? value) + { + try + { + return JsonSerializer.Serialize(value, RelaxedIndentedOptions); + } + catch (JsonException) + { + return string.Empty; + } + catch (NotSupportedException) + { + return string.Empty; + } + } + public static byte[]? SerializeToUtf8Bytes(T? value, bool indented = false) { try @@ -110,6 +147,28 @@ public static string Serialize(T? value, bool indented = false) } } + public static T? Deserialize(ReadOnlySpan utf8Json, JsonSerializerOptions? options = null) + { + try + { + return JsonSerializer.Deserialize(utf8Json, options ?? CompactOptions); + } + catch (JsonException) + { + return default; + } + catch (NotSupportedException) + { + return default; + } + } + + public static T? Deserialize(byte[] utf8Json, JsonSerializerOptions? options = null) + { + ArgumentNullException.ThrowIfNull(utf8Json); + return Deserialize(utf8Json.AsSpan(), options); + } + public static T? Deserialize(string? json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/tests/MinecraftServerManager.IntegrationTests/ServerWorkflowIntegrationTests.cs b/tests/MinecraftServerManager.IntegrationTests/ServerWorkflowIntegrationTests.cs index 9a96c2d..1db95eb 100644 --- a/tests/MinecraftServerManager.IntegrationTests/ServerWorkflowIntegrationTests.cs +++ b/tests/MinecraftServerManager.IntegrationTests/ServerWorkflowIntegrationTests.cs @@ -96,10 +96,12 @@ public async Task ServerBackupAndTransactionalRestoreWorkflowSucceeds() string dataFile = Path.Combine(serverDir, "world.txt"); File.WriteAllText(dataFile, "original world data"); + string externalBackupsDir = Path.Combine(_testRoot, "ExternalBackups"); + SafeFileSystem.ResolveStableDirectory(externalBackupsDir, create: true); var backupService = new ServerBackupService(_serversRoot, maxRetentionCount: 2); // 1. 建立備份 - var backup1 = await backupService.CreateBackupAsync("BackupServer", comment: "第一個測試備份"); + var backup1 = await backupService.CreateBackupAsync("BackupServer", destinationPath: externalBackupsDir, comment: "第一個測試備份"); Assert.NotNull(backup1); Assert.True(File.Exists(backup1.FullPath)); @@ -109,7 +111,7 @@ public async Task ServerBackupAndTransactionalRestoreWorkflowSucceeds() File.WriteAllText(extraFile, "unexpected content"); // 3. 交易式安全還原 - bool restoreSuccess = await backupService.RestoreBackupAsync("BackupServer", backup1.FileName); + bool restoreSuccess = await backupService.RestoreBackupAsync("BackupServer", backup1.FileName, externalBackupsDir); Assert.True(restoreSuccess); // 驗證原本資料已完全復原 @@ -117,10 +119,10 @@ public async Task ServerBackupAndTransactionalRestoreWorkflowSucceeds() Assert.False(File.Exists(extraFile)); // 4. 驗證 Retention 清理政策 (maxRetentionCount = 2) - var backup2 = await backupService.CreateBackupAsync("BackupServer"); - var backup3 = await backupService.CreateBackupAsync("BackupServer"); + var backup2 = await backupService.CreateBackupAsync("BackupServer", destinationPath: externalBackupsDir); + var backup3 = await backupService.CreateBackupAsync("BackupServer", destinationPath: externalBackupsDir); - var list = await backupService.ListBackupsAsync("BackupServer"); + var list = await backupService.ListBackupsAsync("BackupServer", externalBackupsDir); Assert.Equal(2, list.Count); Assert.Contains(list, b => b.FileName == backup3.FileName); Assert.Contains(list, b => b.FileName == backup2.FileName); diff --git a/tests/MinecraftServerManager.UnitTests/DomainModelTests.cs b/tests/MinecraftServerManager.UnitTests/DomainModelTests.cs index ccc4904..e9433c1 100644 --- a/tests/MinecraftServerManager.UnitTests/DomainModelTests.cs +++ b/tests/MinecraftServerManager.UnitTests/DomainModelTests.cs @@ -18,14 +18,6 @@ public void ServerNameRejectsWindowsReservedNames() Assert.Throws(static () => ServerName.Parse(".msm-delete-test")); } - [Fact] - public void ProgressEventClampsPhasePercent() - { - var progress = new ProgressEvent("download", "完成", 12, 10); - - Assert.Equal(100, progress.PhasePercent); - } - [Fact] public void OnlineModVersionSelectsPrimaryJarBeforeFallback() { @@ -185,16 +177,5 @@ public void OnlineModCompatibilityReportDeterminesCompatibility() Assert.False(incompatibleReport.IsCompatible); Assert.True(incompatibleReport.HasHardErrors); } - - [Fact] - public void LocalModUpdateCandidateReflectsUpdateAvailability() - { - var localMod = new LocalModInfo("sodium", "Sodium", "sodium.jar", "0.5.8", "1.20.4", "fabric"); - var candidateWithUpdate = new LocalModUpdateCandidate(localMod, new OnlineModVersion("v2", "0.5.9", "Sodium 0.5.9")); - var candidateWithoutUpdate = new LocalModUpdateCandidate(localMod, null); - - Assert.True(candidateWithUpdate.HasUpdate); - Assert.False(candidateWithoutUpdate.HasUpdate); - } } diff --git a/tests/MinecraftServerManager.UnitTests/JavaWingetInstallerTests.cs b/tests/MinecraftServerManager.UnitTests/JavaWingetInstallerTests.cs index 53cb94b..da0f6e7 100644 --- a/tests/MinecraftServerManager.UnitTests/JavaWingetInstallerTests.cs +++ b/tests/MinecraftServerManager.UnitTests/JavaWingetInstallerTests.cs @@ -94,5 +94,6 @@ public Task StopAsync(TimeSpan gracefulTimeout, CancellationT Task.FromResult(new ProcessExitResult(exitCode, false)); public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public long? GetWorkingSet64() => throw new NotImplementedException(); } } diff --git a/tests/MinecraftServerManager.UnitTests/LoaderInstallerServiceTests.cs b/tests/MinecraftServerManager.UnitTests/LoaderInstallerServiceTests.cs index 2508912..06a1307 100644 --- a/tests/MinecraftServerManager.UnitTests/LoaderInstallerServiceTests.cs +++ b/tests/MinecraftServerManager.UnitTests/LoaderInstallerServiceTests.cs @@ -301,5 +301,6 @@ public Task StopAsync(TimeSpan gracefulTimeout, CancellationT Task.FromResult(new ProcessExitResult(exitCode, false)); public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public long? GetWorkingSet64() => throw new NotImplementedException(); } } diff --git a/tests/MinecraftServerManager.UnitTests/ModManagerTests.cs b/tests/MinecraftServerManager.UnitTests/ModManagerTests.cs index a90725a..1a3ee67 100644 --- a/tests/MinecraftServerManager.UnitTests/ModManagerTests.cs +++ b/tests/MinecraftServerManager.UnitTests/ModManagerTests.cs @@ -137,8 +137,8 @@ public async Task ModManagerDelegatesToScannerAndInstaller() var manager = new ModManager(fakeScanner, fakeInstaller); var mods = await manager.GetModsAsync(_testDir); - Assert.Single(mods); - Assert.Equal("testmod", mods[0].Id); + var item = Assert.Single(mods); + Assert.Equal("testmod", item.Id); } private sealed class FakeScanner(IReadOnlyList mods) : ILocalModScanner diff --git a/tests/MinecraftServerManager.UnitTests/Mods/FlexVersionTests.cs b/tests/MinecraftServerManager.UnitTests/Mods/FlexVersionTests.cs index b16e7b7..23846f5 100644 --- a/tests/MinecraftServerManager.UnitTests/Mods/FlexVersionTests.cs +++ b/tests/MinecraftServerManager.UnitTests/Mods/FlexVersionTests.cs @@ -14,7 +14,7 @@ public sealed class FlexVersionTests [InlineData("7.0.0", "7.0.0-fabric", 1)] // 實務案例 4:Lithium 當前 0.13.1 vs 線上新版 mc1.21.1-0.15.2-fabric [InlineData("mc1.21.1-0.15.2-fabric", "0.13.1", 1)] - // 忽略建置元資料 (+ 號後忽略) + // 忽略建置中繼資料 (+ 號後忽略) [InlineData("1.0.0+fabric", "1.0.0+forge", 0)] // 標準語意版本 [InlineData("1.10.0", "1.9.0", 1)] diff --git a/tests/MinecraftServerManager.UnitTests/ServerBackupServiceTests.cs b/tests/MinecraftServerManager.UnitTests/ServerBackupServiceTests.cs index b7667f5..e5e0dbc 100644 --- a/tests/MinecraftServerManager.UnitTests/ServerBackupServiceTests.cs +++ b/tests/MinecraftServerManager.UnitTests/ServerBackupServiceTests.cs @@ -7,13 +7,16 @@ public sealed class ServerBackupServiceTests : IDisposable { private readonly string _testRoot; private readonly string _serverDir; + private readonly string _externalBackupsDir; private readonly ServerBackupService _backupService; public ServerBackupServiceTests() { _testRoot = Path.Combine(Path.GetTempPath(), "msm-backup-tests-" + Guid.NewGuid().ToString("N")); _serverDir = Path.Combine(_testRoot, "TestServer"); + _externalBackupsDir = Path.Combine(Path.GetTempPath(), "msm-backup-ext-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_serverDir); + Directory.CreateDirectory(_externalBackupsDir); File.WriteAllText(Path.Combine(_serverDir, "server.properties"), "motd=Initial Server"); File.WriteAllText(Path.Combine(_serverDir, "world.txt"), "world-data"); @@ -33,18 +36,39 @@ public void Dispose() catch { } + + try + { + if (Directory.Exists(_externalBackupsDir)) + { + Directory.Delete(_externalBackupsDir, recursive: true); + } + } + catch + { + } + } + + [Fact] + public async Task RejectsCreateBackupWithoutDestinationPath() + { + await Assert.ThrowsAsync(() => + _backupService.CreateBackupAsync("TestServer", destinationPath: null)); + + await Assert.ThrowsAsync(() => + _backupService.CreateBackupAsync("TestServer", destinationPath: " ")); } [Fact] public async Task CreatesAndListsBackupsSuccessfully() { - var backup = await _backupService.CreateBackupAsync("TestServer", comment: "Test Backup 1"); + var backup = await _backupService.CreateBackupAsync("TestServer", destinationPath: _externalBackupsDir, comment: "Test Backup 1"); Assert.NotNull(backup); Assert.True(File.Exists(backup.FullPath)); Assert.True(backup.SizeBytes > 0); - var list = await _backupService.ListBackupsAsync("TestServer"); + var list = await _backupService.ListBackupsAsync("TestServer", _externalBackupsDir); var item = Assert.Single(list); Assert.Equal(backup.FileName, item.FileName); } @@ -52,13 +76,13 @@ public async Task CreatesAndListsBackupsSuccessfully() [Fact] public async Task RestoresBackupOverExistingFiles() { - var backup = await _backupService.CreateBackupAsync("TestServer"); + var backup = await _backupService.CreateBackupAsync("TestServer", destinationPath: _externalBackupsDir); // 修改原始檔案 File.WriteAllText(Path.Combine(_serverDir, "server.properties"), "motd=Modified Content"); File.Delete(Path.Combine(_serverDir, "world.txt")); - bool restored = await _backupService.RestoreBackupAsync("TestServer", backup.FileName); + bool restored = await _backupService.RestoreBackupAsync("TestServer", backup.FileName, _externalBackupsDir); Assert.True(restored); Assert.True(File.Exists(Path.Combine(_serverDir, "world.txt"))); @@ -68,37 +92,37 @@ public async Task RestoresBackupOverExistingFiles() [Fact] public async Task DeletesBackupSuccessfully() { - var backup = await _backupService.CreateBackupAsync("TestServer"); + var backup = await _backupService.CreateBackupAsync("TestServer", destinationPath: _externalBackupsDir); Assert.True(File.Exists(backup.FullPath)); - bool deleted = await _backupService.DeleteBackupAsync("TestServer", backup.FileName); + bool deleted = await _backupService.DeleteBackupAsync("TestServer", backup.FileName, _externalBackupsDir); Assert.True(deleted); Assert.False(File.Exists(backup.FullPath)); - var list = await _backupService.ListBackupsAsync("TestServer"); + var list = await _backupService.ListBackupsAsync("TestServer", _externalBackupsDir); Assert.Empty(list); } [Fact] public async Task SupportsExternalBackupDirectoryForListRestoreAndDelete() { - string externalDir = Path.Combine(Path.GetTempPath(), "msm-external-backups-" + Guid.NewGuid().ToString("N")); + string extraDir = Path.Combine(Path.GetTempPath(), "msm-extra-backups-" + Guid.NewGuid().ToString("N")); try { - var backup = await _backupService.CreateBackupAsync("TestServer", destinationPath: externalDir); + var backup = await _backupService.CreateBackupAsync("TestServer", destinationPath: extraDir); Assert.True(File.Exists(backup.FullPath)); - Assert.StartsWith(Path.GetFullPath(externalDir), backup.FullPath, StringComparison.OrdinalIgnoreCase); + Assert.StartsWith(Path.GetFullPath(extraDir), backup.FullPath, StringComparison.OrdinalIgnoreCase); - var listed = await _backupService.ListBackupsAsync("TestServer", externalDir); + var listed = await _backupService.ListBackupsAsync("TestServer", extraDir); var item = Assert.Single(listed); Assert.Equal(backup.FileName, item.FileName); File.WriteAllText(Path.Combine(_serverDir, "server.properties"), "motd=Modified Content"); - bool restored = await _backupService.RestoreBackupAsync("TestServer", backup.FileName, externalDir); + bool restored = await _backupService.RestoreBackupAsync("TestServer", backup.FileName, extraDir); Assert.True(restored); Assert.Equal("motd=Initial Server", File.ReadAllText(Path.Combine(_serverDir, "server.properties"))); - bool deleted = await _backupService.DeleteBackupAsync("TestServer", backup.FileName, externalDir); + bool deleted = await _backupService.DeleteBackupAsync("TestServer", backup.FileName, extraDir); Assert.True(deleted); Assert.False(File.Exists(backup.FullPath)); } @@ -106,9 +130,9 @@ public async Task SupportsExternalBackupDirectoryForListRestoreAndDelete() { try { - if (Directory.Exists(externalDir)) + if (Directory.Exists(extraDir)) { - Directory.Delete(externalDir, recursive: true); + Directory.Delete(extraDir, recursive: true); } } catch @@ -128,7 +152,7 @@ await Assert.ThrowsAsync(() => [Fact] public async Task RestoresBackup_PreservesLiveDiagnosticsAndCrashReports() { - var backup = await _backupService.CreateBackupAsync("TestServer"); + var backup = await _backupService.CreateBackupAsync("TestServer", destinationPath: _externalBackupsDir); // 模擬伺服器運作後產生了當機報告與現場最新記錄檔 string logsDir = Path.Combine(_serverDir, "logs"); @@ -139,7 +163,7 @@ public async Task RestoresBackup_PreservesLiveDiagnosticsAndCrashReports() File.WriteAllText(Path.Combine(logsDir, "latest.log"), "Live latest log before disaster"); File.WriteAllText(Path.Combine(crashDir, "crash-2026-09-24.txt"), "Live crash traceback"); - bool restored = await _backupService.RestoreBackupAsync("TestServer", backup.FileName); + bool restored = await _backupService.RestoreBackupAsync("TestServer", backup.FileName, _externalBackupsDir); Assert.True(restored); // 驗證當機報告被妥善保留移回 @@ -154,13 +178,13 @@ public async Task RestoresBackup_PreservesLiveDiagnosticsAndCrashReports() [Fact] public async Task RestoresBackup_ClearsReadOnlyAttribute_SuccessfullyRestores() { - var backup = await _backupService.CreateBackupAsync("TestServer"); + var backup = await _backupService.CreateBackupAsync("TestServer", destinationPath: _externalBackupsDir); // 將既有檔案設為 ReadOnly 唯讀屬性,模擬鎖定情況 string propsPath = Path.Combine(_serverDir, "server.properties"); File.SetAttributes(propsPath, FileAttributes.ReadOnly); - bool restored = await _backupService.RestoreBackupAsync("TestServer", backup.FileName); + bool restored = await _backupService.RestoreBackupAsync("TestServer", backup.FileName, _externalBackupsDir); Assert.True(restored); // 還原後唯讀屬性應被清除且內容成功復原 diff --git a/tests/MinecraftServerManager.UnitTests/ServerPlayerConfigStoreTests.cs b/tests/MinecraftServerManager.UnitTests/ServerPlayerConfigStoreTests.cs index 65f51f6..2e0cb88 100644 --- a/tests/MinecraftServerManager.UnitTests/ServerPlayerConfigStoreTests.cs +++ b/tests/MinecraftServerManager.UnitTests/ServerPlayerConfigStoreTests.cs @@ -72,21 +72,21 @@ public async Task SaveBundleAsync_AndLoadBundleAsync_ShouldRoundTripCorrectly() var loaded = await _store.LoadBundleAsync(_testDir); - Assert.Single(loaded.Operators); - Assert.Equal("PlayerA", loaded.Operators[0].Name); + var item = Assert.Single(loaded.Operators); + Assert.Equal("PlayerA", item.Name); Assert.Equal("11111111-2222-3333-4444-555555555555", loaded.Operators[0].Uuid); Assert.Equal(4, loaded.Operators[0].Level); Assert.False(loaded.Operators[0].BypassesPlayerLimit); - Assert.Single(loaded.Whitelist); - Assert.Equal("PlayerA", loaded.Whitelist[0].Name); + var item_2 = Assert.Single(loaded.Whitelist); + Assert.Equal("PlayerA", item_2.Name); - Assert.Single(loaded.BannedPlayers); - Assert.Equal("GrieferB", loaded.BannedPlayers[0].Name); + var item_3 = Assert.Single(loaded.BannedPlayers); + Assert.Equal("GrieferB", item_3.Name); Assert.Equal("Cheat", loaded.BannedPlayers[0].Reason); - Assert.Single(loaded.BannedIps); - Assert.Equal("192.168.1.100", loaded.BannedIps[0].Ip); + var item_4 = Assert.Single(loaded.BannedIps); + Assert.Equal("192.168.1.100", item_4.Ip); Assert.Equal("Spam", loaded.BannedIps[0].Reason); // 驗證 ops.json 檔案存在且為合法 JSON 陣列 diff --git a/tests/MinecraftServerManager.UnitTests/ServerRuntimeTests.cs b/tests/MinecraftServerManager.UnitTests/ServerRuntimeTests.cs index 63395a3..4d6afab 100644 --- a/tests/MinecraftServerManager.UnitTests/ServerRuntimeTests.cs +++ b/tests/MinecraftServerManager.UnitTests/ServerRuntimeTests.cs @@ -271,5 +271,7 @@ public ValueTask DisposeAsync() HasExited = true; return ValueTask.CompletedTask; } + + public long? GetWorkingSet64() => throw new NotImplementedException(); } } diff --git a/tests/MinecraftServerManager.UnitTests/Servers/ServerDirectorySizeServiceTests.cs b/tests/MinecraftServerManager.UnitTests/Servers/ServerDirectorySizeServiceTests.cs new file mode 100644 index 0000000..d1a7b91 --- /dev/null +++ b/tests/MinecraftServerManager.UnitTests/Servers/ServerDirectorySizeServiceTests.cs @@ -0,0 +1,94 @@ +using MinecraftServerManager.Infrastructure.Servers; +using Xunit; + +namespace MinecraftServerManager.UnitTests.Servers; + +public sealed class ServerDirectorySizeServiceTests : IDisposable +{ + private readonly string _testDir; + private readonly ServerDirectorySizeService _sizeService; + + public ServerDirectorySizeServiceTests() + { + _testDir = Path.Combine(Path.GetTempPath(), "msm-dirsize-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testDir); + _sizeService = new ServerDirectorySizeService(cacheDuration: TimeSpan.FromSeconds(5)); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_testDir)) + { + Directory.Delete(_testDir, recursive: true); + } + } + catch + { + } + } + + [Fact] + public async Task CalculateServerSizeAsync_NonExistentDirectory_ReturnsZero() + { + string nonExistent = Path.Combine(_testDir, "DoesNotExist"); + long size = await _sizeService.CalculateServerSizeAsync(nonExistent); + Assert.Equal(0, size); + } + + [Fact] + public async Task CalculateServerSizeAsync_CalculatesFilesAndExcludesBackups() + { + // 建立常規檔案 + File.WriteAllText(Path.Combine(_testDir, "server.jar"), new string('a', 100)); + File.WriteAllText(Path.Combine(_testDir, "server.properties"), new string('b', 50)); + + // 建立子目錄與檔案 + string worldDir = Path.Combine(_testDir, "world"); + Directory.CreateDirectory(worldDir); + File.WriteAllText(Path.Combine(worldDir, "level.dat"), new string('c', 200)); + + // 建立 backups 目錄與檔案(應被排除) + string backupsDir = Path.Combine(_testDir, "backups"); + Directory.CreateDirectory(backupsDir); + File.WriteAllText(Path.Combine(backupsDir, "backup-1.zip"), new string('d', 1000)); + + long totalSize = await _sizeService.CalculateServerSizeAsync(_testDir); + + // 預期為 100 + 50 + 200 = 350 位元組(排除 backups 的 1000 位元組) + Assert.Equal(350, totalSize); + } + + [Fact] + public async Task CalculateServerSizeAsync_UsesCacheOnSubsequentCalls() + { + string file = Path.Combine(_testDir, "data.bin"); + File.WriteAllBytes(file, new byte[500]); + + long initialSize = await _sizeService.CalculateServerSizeAsync(_testDir); + Assert.Equal(500, initialSize); + + // 在目錄中新增檔案(不清除快取的情況下呼叫) + File.WriteAllBytes(Path.Combine(_testDir, "extra.bin"), new byte[300]); + + long cachedSize = await _sizeService.CalculateServerSizeAsync(_testDir); + // 應該命中快取,仍為 500 + Assert.Equal(500, cachedSize); + + // 失效快取後重新計算 + _sizeService.InvalidateCache(_testDir); + long refreshedSize = await _sizeService.CalculateServerSizeAsync(_testDir); + Assert.Equal(800, refreshedSize); + } + + [Fact] + public async Task CalculateServerSizeAsync_HonorsCancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + _sizeService.CalculateServerSizeAsync(_testDir, cts.Token)); + } +} diff --git a/tests/MinecraftServerManager.UnitTests/Servers/ServerIntegrityServiceTests.cs b/tests/MinecraftServerManager.UnitTests/Servers/ServerIntegrityServiceTests.cs new file mode 100644 index 0000000..1453373 --- /dev/null +++ b/tests/MinecraftServerManager.UnitTests/Servers/ServerIntegrityServiceTests.cs @@ -0,0 +1,110 @@ +using MinecraftServerManager.Domain.Servers; +using MinecraftServerManager.Infrastructure.Servers; +using Xunit; + +namespace MinecraftServerManager.UnitTests.Servers; + +public sealed class ServerIntegrityServiceTests : IDisposable +{ + private readonly string _testDir; + private readonly ServerIntegrityService _integrityService; + + public ServerIntegrityServiceTests() + { + _testDir = Path.Combine(Path.GetTempPath(), "msm-integrity-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testDir); + _integrityService = new ServerIntegrityService(); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_testDir)) + { + Directory.Delete(_testDir, recursive: true); + } + } + catch + { + } + } + + [Fact] + public void CheckImportantFiles_NonExistentDirectory_ReturnsMissing() + { + string nonExistent = Path.Combine(_testDir, "DoesNotExist"); + var result = _integrityService.CheckImportantFiles(nonExistent, LoaderKind.Vanilla); + + Assert.True(result.HasMissing); + Assert.Contains(result.MissingFiles, static m => m.Contains("不存在")); + } + + [Fact] + public void CheckImportantFiles_VanillaServer_DetectsMissingAndValid() + { + // 缺少 server.jar + var missingResult = _integrityService.CheckImportantFiles(_testDir, LoaderKind.Vanilla); + Assert.True(missingResult.HasMissing); + Assert.Contains("server.jar", missingResult.MissingFiles); + + // 建立 server.jar + File.WriteAllText(Path.Combine(_testDir, "server.jar"), "dummy"); + var validResult = _integrityService.CheckImportantFiles(_testDir, LoaderKind.Vanilla); + Assert.False(validResult.HasMissing); + Assert.Empty(validResult.MissingFiles); + } + + [Fact] + public void CheckImportantFiles_PaperServer_DetectsPaperJar() + { + // 缺少 paper.jar + var missingResult = _integrityService.CheckImportantFiles(_testDir, LoaderKind.Paper); + Assert.True(missingResult.HasMissing); + + // 建立 paper-1.20.4-496.jar + File.WriteAllText(Path.Combine(_testDir, "paper-1.20.4-496.jar"), "dummy paper"); + var validResult = _integrityService.CheckImportantFiles(_testDir, LoaderKind.Paper); + Assert.False(validResult.HasMissing); + } + + [Fact] + public void CheckImportantFiles_FabricServer_DetectsLaunchJar() + { + // 缺少檔案 + var missingResult = _integrityService.CheckImportantFiles(_testDir, LoaderKind.Fabric); + Assert.True(missingResult.HasMissing); + Assert.Contains("fabric-server-launch.jar", missingResult.MissingFiles); + + // 同時具備啟動 JAR 與原版伺服器 JAR + File.WriteAllText(Path.Combine(_testDir, "fabric-server-launch.jar"), "dummy fabric"); + File.WriteAllText(Path.Combine(_testDir, "server.jar"), "dummy server"); + var validResult = _integrityService.CheckImportantFiles(_testDir, LoaderKind.Fabric); + Assert.False(validResult.HasMissing); + Assert.Empty(validResult.MissingFiles); + } + + [Fact] + public void CheckImportantFiles_ForgeServer_DetectsLegacyOrModernFiles() + { + // 空目錄回傳缺少 + var missingResult = _integrityService.CheckImportantFiles(_testDir, LoaderKind.Forge); + Assert.True(missingResult.HasMissing); + + // 測試舊版 Forge 檔案 (forge*.jar) + File.WriteAllText(Path.Combine(_testDir, "forge-1.12.2.jar"), "dummy forge jar"); + var legacyResult = _integrityService.CheckImportantFiles(_testDir, LoaderKind.Forge); + Assert.False(legacyResult.HasMissing); + + // 移除舊版檔案,建立現代版 Forge 結構 (run.bat + forge args.txt) + File.Delete(Path.Combine(_testDir, "forge-1.12.2.jar")); + File.WriteAllText(Path.Combine(_testDir, "run.bat"), "dummy script"); + string forgeLibDir = Path.Combine(_testDir, "libraries", "net", "minecraftforge", "forge"); + Directory.CreateDirectory(forgeLibDir); + File.WriteAllText(Path.Combine(forgeLibDir, "win_args.txt"), "dummy args"); + + var modernResult = _integrityService.CheckImportantFiles(_testDir, LoaderKind.Forge); + Assert.False(modernResult.HasMissing); + Assert.Empty(modernResult.MissingFiles); + } +} diff --git a/tests/MinecraftServerManager.UnitTests/UI/ManageServerViewModelTests.cs b/tests/MinecraftServerManager.UnitTests/UI/ManageServerViewModelTests.cs index 5d9e7af..fe5c36c 100644 --- a/tests/MinecraftServerManager.UnitTests/UI/ManageServerViewModelTests.cs +++ b/tests/MinecraftServerManager.UnitTests/UI/ManageServerViewModelTests.cs @@ -101,6 +101,38 @@ public void MissingImportantFilesShouldDisableStartAndDisplayDetail() Assert.Contains("win_args.txt", server.Status); } + [Fact] + public void SelectedServerMemory_WhenMinMemoryNotSet_ShouldOnlyDisplayMaxMemory() + { + var vm = new ManageServerViewModel(); + var server = new ServerRowItem + { + Name = "ServerNoMin", + MemoryMaxMb = 4096, + MemoryMinMb = null, + }; + vm.SelectedServer = server; + + Assert.Equal("4096 MB", vm.SelectedServerMemory); + Assert.Contains("記憶體配置:4096 MB", vm.SelectedServerInfo); + } + + [Fact] + public void SelectedServerMemory_WhenMinMemorySet_ShouldDisplayRange() + { + var vm = new ManageServerViewModel(); + var server = new ServerRowItem + { + Name = "ServerWithMin", + MemoryMaxMb = 4096, + MemoryMinMb = 2048, + }; + vm.SelectedServer = server; + + Assert.Equal("2048 MB ~ 4096 MB", vm.SelectedServerMemory); + Assert.Contains("記憶體配置:2048 MB ~ 4096 MB", vm.SelectedServerInfo); + } + private sealed class FakeExternalLauncher : Core.Ports.IExternalLauncher { public List OpenedFolders { get; } = []; diff --git a/tests/MinecraftServerManager.UnitTests/UI/PlayerManagementViewModelTests.cs b/tests/MinecraftServerManager.UnitTests/UI/PlayerManagementViewModelTests.cs index 0c58d60..2b583d4 100644 --- a/tests/MinecraftServerManager.UnitTests/UI/PlayerManagementViewModelTests.cs +++ b/tests/MinecraftServerManager.UnitTests/UI/PlayerManagementViewModelTests.cs @@ -47,8 +47,8 @@ public async Task AddOperator_WhenValidCandidate_AddsAndSetsHasUnsavedChanges() vm.AddOperatorCommand.Execute(null); - Assert.Single(vm.Operators); - Assert.Equal("Alex", vm.Operators[0].Name); + var item = Assert.Single(vm.Operators); + Assert.Equal("Alex", item.Name); Assert.Equal(3, vm.Operators[0].Level); Assert.True(vm.Operators[0].BypassesPlayerLimit); Assert.True(vm.HasUnsavedChanges); @@ -154,8 +154,8 @@ public async Task SaveAsync_SavesBundleAndClosesWithTrue() Assert.True(closeResult); Assert.False(vm.HasUnsavedChanges); Assert.NotNull(fakeStore.SavedBundle); - Assert.Single(fakeStore.SavedBundle.BannedIps); - Assert.Equal("192.168.1.50", fakeStore.SavedBundle.BannedIps[0].Ip); + var item = Assert.Single(fakeStore.SavedBundle.BannedIps); + Assert.Equal("192.168.1.50", item.Ip); } [Theory] @@ -214,8 +214,8 @@ public async Task OperatorRows_ToggleAndSave_OnlySavesActiveOperators() await vm.SaveCommand.ExecuteAsync(null); Assert.NotNull(fakeStore.SavedBundle); - Assert.Single(fakeStore.SavedBundle.Operators); - Assert.Equal("uuid-b", fakeStore.SavedBundle.Operators[0].Uuid); + var item = Assert.Single(fakeStore.SavedBundle.Operators); + Assert.Equal("uuid-b", item.Uuid); Assert.Equal(3, fakeStore.SavedBundle.Operators[0].Level); Assert.True(fakeStore.SavedBundle.Operators[0].BypassesPlayerLimit); } @@ -238,8 +238,8 @@ public async Task ApplyUserCacheFilter_OnlyFiltersByName_NotByUuid() // 搜尋玩家名稱 vm.PlayerSearchText = "Ste"; - Assert.Single(vm.FilteredUserCache); - Assert.Equal("Steve", vm.FilteredUserCache[0].Name); + var item = Assert.Single(vm.FilteredUserCache); + Assert.Equal("Steve", item.Name); // 搜尋 UUID(以前會匹配,現在僅搜尋 Name,故不匹配) vm.PlayerSearchText = "1111"; diff --git a/tests/MinecraftServerManager.UnitTests/UI/ServerMonitorViewModelTests.cs b/tests/MinecraftServerManager.UnitTests/UI/ServerMonitorViewModelTests.cs index 846e48a..cfa0069 100644 --- a/tests/MinecraftServerManager.UnitTests/UI/ServerMonitorViewModelTests.cs +++ b/tests/MinecraftServerManager.UnitTests/UI/ServerMonitorViewModelTests.cs @@ -262,5 +262,6 @@ public Task StopAsync(TimeSpan gracefulTimeout, CancellationT } public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public long? GetWorkingSet64() => throw new NotImplementedException(); } } From 368585565d6c2b8bc718f705baa6055975393be3 Mon Sep 17 00:00:00 2001 From: Colin955023 <123829404+Colin955023@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:25:16 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E4=BC=BA=E6=9C=8D=E5=99=A8=E5=95=9F?= =?UTF-8?q?=E5=8B=95=E8=85=B3=E6=9C=AC=E8=A8=98=E6=86=B6=E9=AB=94=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E4=BF=AE=E5=BE=A9=E8=88=87=E7=AE=A1=E7=90=86=E4=BB=8B?= =?UTF-8?q?=E9=9D=A2=E9=80=A3=E5=8B=95=E6=94=B9=E5=96=84=20(v2.1.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修復記憶體設定未寫入 start_server.bat 與匯入 20G 誤判為 2G 的問題 - 修正管理頁面記憶體顯示未同步刷新,並新增執行中動態切換停止按鈕 - 修復建立伺服器進度條未等比推移至 100% 的顯示問題 - 改善伺服器監控按鈕啟用狀態與停用半透明視覺回饋 --- .github/CODEOWNERS | 3 +- .github/codeql/codeql-config.yml | 10 +- .github/renovate.json | 55 +- .github/workflows/build-release.yml | 185 ++++++- .github/workflows/ci-test.yml | 18 +- .github/workflows/codeql.yml | 26 +- .github/workflows/oss-scorecard.yml | 10 +- .github/workflows/validate-renovate.yml | 14 +- .gitignore | 2 +- .vscode/extensions.json | 2 +- .vscode/settings.json | 8 +- CHANGELOG.md | 14 + Directory.Build.props | 6 +- docs/TECHNICAL_OVERVIEW.md | 4 +- docs/USER_GUIDE.md | 5 +- scripts/dotnet_publish.ps1 | 24 +- scripts/dotnet_quality_gate.ps1 | 51 +- scripts/extract_release_notes.ps1 | 23 +- scripts/lib/Common.ps1 | 29 + src/MinecraftServerManager.App/App.xaml | 14 +- src/MinecraftServerManager.App/App.xaml.cs | 20 +- .../AppCompositionRoot.cs | 53 +- .../Behaviors/DataGridNaturalSortBehavior.cs | 5 +- .../MainWindow.xaml.cs | 6 +- .../MinecraftServerManager.App.csproj | 23 +- .../Services/NoOpUiService.cs | 8 +- .../Services/WpfUiService.cs | 10 +- .../ViewModels/CreateServerViewModel.cs | 11 +- .../ViewModels/MainViewModel.cs | 108 ++-- .../ViewModels/MainViewModelDependencies.cs | 33 ++ .../ViewModels/ManageJavaViewModel.cs | 4 +- .../ViewModels/ManageServerViewModel.cs | 76 ++- .../ViewModels/ModVersionPickerViewModel.cs | 6 +- .../ViewModels/ModsViewModel.cs | 16 +- .../ViewModels/RestoreBackupViewModel.cs | 6 +- .../ViewModels/ServerMonitorViewModel.cs | 11 +- .../ViewModels/ServerPropertiesViewModel.cs | 10 +- .../ViewModels/ViewModelBase.cs | 62 +++ .../Views/ExportModListDialog.xaml.cs | 5 +- .../Views/ManageServerView.xaml | 36 +- .../Views/ModsView.xaml.cs | 2 +- .../Views/NotificationDialog.xaml.cs | 8 +- .../Views/OperationProgressDialog.xaml | 30 +- .../Views/OperationProgressDialog.xaml.cs | 39 +- .../Views/ServerInitializationDialog.xaml.cs | 25 +- src/MinecraftServerManager.Core/AppInfo.cs | 24 +- .../Mods/ModHelpers.cs | 7 + .../Utilities/MemoryUtils.cs | 16 +- .../Common/CollectionUtilities.cs | 27 - .../Mods/LocalModInfo.cs | 2 +- .../Mods/OnlineModCompatibilityReport.cs | 18 +- .../Servers/ServerConfig.cs | 2 +- .../FileSystem/SafeFileSystem.cs | 88 +++- .../Http/HttpDownloadClient.cs | 8 +- .../Java/JavaRuntimeDetector.cs | 17 +- .../Java/MinecraftJavaRequirementService.cs | 9 +- .../Loaders/LoaderCatalogService.cs | 12 +- .../Loaders/LoaderInstallerService.cs | 8 +- .../Logging/ComponentLogger.cs | 6 +- .../Mods/LocalModScanner.cs | 72 ++- .../Mods/ModDependencyPlanner.cs | 11 +- .../Mods/ModFileInstaller.cs | 11 +- .../Mods/ModIndexPersistence.cs | 11 +- .../Mods/ModrinthClient.cs | 17 +- .../Servers/ServerBackupService.cs | 67 ++- .../Servers/ServerDirectorySizeService.cs | 35 +- .../Servers/ServerInspector.cs | 164 ++++-- .../Servers/ServerManager.cs | 77 ++- .../Servers/ServerStartupTargetResolver.cs | 39 -- .../Settings/SystemThemeService.cs | 4 +- .../Utilities/OpenXmlSpreadsheetBuilder.cs | 2 +- .../ServerWorkflowIntegrationTests.cs | 77 ++- .../AppUpdateServiceTests.cs | 4 +- .../SharedEntryArchitectureTests.cs | 329 ++++++++++++ .../Architecture/SourceScanner.cs | 495 ++++++++++++++++++ .../Composition/AppCompositionRootTests.cs | 86 +++ .../LoaderCatalogServiceTests.cs | 4 +- .../LoaderInstallerServiceTests.cs | 4 +- .../LocalModScannerTests.cs | 43 +- .../MinecraftJavaRequirementServiceTests.cs | 4 +- .../ModFileInstallerTests.cs | 4 +- .../ModIndexPersistenceTests.cs | 4 +- .../ModManagerTests.cs | 4 +- .../ModrinthClientTests.cs | 21 + .../RotatingFileLoggerTests.cs | 4 +- .../SafeFileSystemTests.cs | 69 +++ .../ServerBackupServiceTests.cs | 45 +- .../ServerInspectorTests.cs | 33 +- .../ServerManagerTests.cs | 55 +- .../ServerPlayerConfigStoreTests.cs | 4 +- .../ServerPropertiesTests.cs | 4 +- .../ServerRuntimeTests.cs | 4 +- .../ServerDirectorySizeServiceTests.cs | 41 +- .../ServerInitializationWorkflowTests.cs | 104 ++++ .../Servers/ServerIntegrityServiceTests.cs | 4 +- .../Settings/SystemThemeServiceTests.cs | 17 + .../UI/AboutPreferencesViewModelTests.cs | 4 +- .../UI/ExportModListViewModelTests.cs | 109 ++++ .../UI/ImportServerViewModelTests.cs | 88 ++++ .../UI/JvmArgsViewModelTests.cs | 53 ++ .../UI/MainViewModelTests.cs | 44 +- .../UI/ManageJavaViewModelTests.cs | 4 +- .../UI/ManageServerViewModelTests.cs | 127 ++++- .../UI/ModVersionPickerViewModelTests.cs | 107 ++++ .../UI/ModsViewModelTests.cs | 18 +- .../UI/RestoreBackupViewModelTests.cs | 105 ++++ .../UI/ServerMemoryViewModelTests.cs | 102 ++++ .../UI/ServerMonitorViewModelTests.cs | 4 +- .../UI/ServerPropertiesViewModelTests.cs | 102 ++++ .../ViewModels/ViewModelBaseTests.cs | 135 +++++ 110 files changed, 3612 insertions(+), 723 deletions(-) create mode 100644 scripts/lib/Common.ps1 create mode 100644 src/MinecraftServerManager.App/ViewModels/MainViewModelDependencies.cs delete mode 100644 src/MinecraftServerManager.Domain/Common/CollectionUtilities.cs delete mode 100644 src/MinecraftServerManager.Infrastructure/Servers/ServerStartupTargetResolver.cs create mode 100644 tests/MinecraftServerManager.UnitTests/Architecture/SharedEntryArchitectureTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/Architecture/SourceScanner.cs create mode 100644 tests/MinecraftServerManager.UnitTests/Composition/AppCompositionRootTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/Servers/ServerInitializationWorkflowTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/Settings/SystemThemeServiceTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/UI/ExportModListViewModelTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/UI/ImportServerViewModelTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/UI/JvmArgsViewModelTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/UI/ModVersionPickerViewModelTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/UI/RestoreBackupViewModelTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/UI/ServerMemoryViewModelTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/UI/ServerPropertiesViewModelTests.cs create mode 100644 tests/MinecraftServerManager.UnitTests/ViewModels/ViewModelBaseTests.cs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index dbd5a7d..e5abc59 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,2 @@ -* @Colin955023 \ No newline at end of file +* @Colin955023 +* @colin04327 \ No newline at end of file diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 414b85a..f342e54 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -1,6 +1,8 @@ name: "Minecraft Server Manager CodeQL Config" paths-ignore: - - 'docs/**' - - 'assets/**' - - 'output/**' - - 'tests/**' + - "docs/**" + - "assets/**" + - "output/**" + - "tests/**" +queries: + - uses: security-extended diff --git a/.github/renovate.json b/.github/renovate.json index cb0e907..044c305 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,38 +1,57 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:recommended" + "config:recommended", + "helpers:pinGitHubActionDigests" ], "prHourlyLimit": 10, "prConcurrentLimit": 5, "packageRules": [ { - "matchUpdateTypes": ["minor", "patch"], - "matchCurrentVersion": "!/^0/", - "automerge": true, - "labels": ["dependencies", "automerge"] + "matchUpdateTypes": [ + "minor", + "patch" + ], + "labels": [ + "dependencies" + ] }, { - "matchUpdateTypes": ["major"], - "automerge": false, - "labels": ["dependencies", "breaking-change"] + "matchUpdateTypes": [ + "major" + ], + "labels": [ + "dependencies", + "breaking-change" + ] }, { "groupName": ".NET ecosystem", - "matchManagers": ["nuget"], - "matchFileNames": ["Directory.Packages.props", "src/**", "tests/**"] + "matchManagers": [ + "nuget" + ], + "matchFileNames": [ + "Directory.Packages.props", + "src/**", + "tests/**" + ] }, { "groupName": "GitHub Actions", - "matchManagers": ["github-actions"] + "matchManagers": [ + "github-actions" + ] } ], + "vulnerabilityAlerts": { + "labels": [ + "dependencies", + "security" + ], + "prConcurrentLimit": 3, + "vulnerabilityFixStrategy": "lowest" + }, "dependencyDashboard": true, "prCreation": "not-pending", - "timezone": "Asia/Taipei", - "lockFileMaintenance": { - "enabled": true, - "automerge": true, - "schedule": ["before 4am on monday"] - } -} + "timezone": "Asia/Taipei" +} \ No newline at end of file diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 00c4354..3cfcb00 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -10,25 +10,70 @@ permissions: contents: read jobs: + preflight: + name: 發布前置輕量預檢 (Preflight) + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + tag_name: ${{ steps.extract_notes.outputs.tag_name }} + version: ${{ steps.extract_notes.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Extract version and verify CHANGELOG + id: extract_notes + shell: pwsh + run: ./scripts/extract_release_notes.ps1 -Strict + + - name: Verify Tag Availability + shell: pwsh + env: + RELEASE_TAG: ${{ steps.extract_notes.outputs.tag_name }} + run: | + git fetch --tags --force + $tag = $env:RELEASE_TAG + $headCommit = (git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or -not $headCommit) { + Write-Error "Preflight 失敗:無法取得當前 HEAD commit" + exit 1 + } + + $existingTagCommit = git rev-parse -q --verify "refs/tags/$tag^{commit}" + if ($LASTEXITCODE -eq 0) { + if ($existingTagCommit.Trim() -ne $headCommit) { + Write-Error "Preflight 失敗:Tag $tag 已存在且指向不同 commit ($($existingTagCommit.Trim())),請更新版本號!" + exit 1 + } + Write-Host "Tag $tag 已存在且指向當前 HEAD,允許發布覆蓋/更新。" + } else { + Write-Host "Tag $tag 尚未建立,可安全建立新標籤發布。" + } + gitleaks: name: 隱私與機密資訊檢查 (Gitleaks) + needs: preflight if: github.ref == 'refs/heads/master' runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: 隱私與機密資訊檢查 (Gitleaks) - uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3 + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} build: name: Publish and Attest (.NET 10) - needs: gitleaks + needs: [preflight, gitleaks] if: github.ref == 'refs/heads/master' runs-on: windows-latest timeout-minutes: 30 @@ -36,8 +81,8 @@ jobs: tag_name: ${{ steps.release_info.outputs.tag_name }} version: ${{ steps.release_info.outputs.version }} env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' - DOTNET_CLI_FORCE_UTF8_ENCODING: '1' + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + DOTNET_CLI_FORCE_UTF8_ENCODING: "1" permissions: contents: read @@ -47,13 +92,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - name: Setup .NET 10 SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json cache: true @@ -89,7 +134,7 @@ jobs: - name: Attest release executable provenance uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: - subject-path: 'output/*.exe' + subject-path: "output/*.exe" - name: Verify generated attestation env: @@ -138,26 +183,25 @@ jobs: - name: Generate SBOM (SPDX 2.2) shell: pwsh env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.release_info.outputs.version }} + SBOM_TOOL_VERSION: v4.1.5 + SBOM_TOOL_SHA256: 625767b371b7fdd58f40f618b8a86da0247a33c89e419039c86b4edba1dad4b5 run: | - # 動態取得 Microsoft SBOM Tool 最新發行版;下載檔以 GitHub 回報的 asset digest 校驗,不符即中止 - $release = gh api repos/microsoft/sbom-tool/releases/latest | ConvertFrom-Json - $asset = $release.assets | Where-Object { $_.name -eq "sbom-tool-win-x64.exe" } | Select-Object -First 1 - if ($null -eq $asset -or [string]::IsNullOrWhiteSpace($asset.digest) -or -not $asset.digest.StartsWith("sha256:")) { - throw "無法取得 SBOM Tool 的 SHA-256 digest,為避免執行未驗證的下載檔,已中止發布" - } - $expectedSha = $asset.digest.Substring(7).ToUpperInvariant() + # 釘選 Microsoft SBOM Tool 固定發行版本與 SHA-256 雜湊,確保建置可重現性與防篡改 + $versionTag = $env:SBOM_TOOL_VERSION + $expectedSha = $env:SBOM_TOOL_SHA256.ToUpperInvariant() + $downloadUrl = "https://github.com/microsoft/sbom-tool/releases/download/$versionTag/sbom-tool-win-x64.exe" $sbomToolPath = Join-Path $env:RUNNER_TEMP "sbom-tool-win-x64.exe" - Write-Host "正在下載 Microsoft SBOM Tool $($release.tag_name)..." - Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $sbomToolPath + Write-Host "正在下載 Microsoft SBOM Tool $versionTag..." + Invoke-WebRequest -Uri $downloadUrl -OutFile $sbomToolPath $actualSha = (Get-FileHash -LiteralPath $sbomToolPath -Algorithm SHA256).Hash if ($actualSha -ne $expectedSha) { Remove-Item -LiteralPath $sbomToolPath -Force throw "SBOM Tool SHA-256 不符:預期 $expectedSha,實際 $actualSha" } - $version = "${{ steps.release_info.outputs.version }}" + $version = $env:RELEASE_VERSION Write-Host "正在為版本 $version 產生 SPDX 2.2 SBOM..." & $sbomToolPath generate -b "output" -bc "src" -pn "MinecraftServerManager" -pv $version -ps "Colin955023" -nsb "https://github.com/Colin955023/MinecraftServerManager" -mi "SPDX:2.2" if ($LASTEXITCODE -ne 0) { @@ -179,11 +223,11 @@ jobs: Remove-Item -Path "output/_manifest" -Recurse -Force $hash = Get-FileHash -LiteralPath $destSbom -Algorithm SHA256 Set-Content -LiteralPath "$destSbom.sha256" -Value "$($hash.Hash) MinecraftServerManager.spdx.json" -Encoding UTF8 - Write-Host "SBOM 與 SHA-256 產生完成: $destSbom (SBOM Tool $($release.tag_name))" + Write-Host "SBOM 與 SHA-256 產生完成: $destSbom (SBOM Tool $versionTag)" - name: Upload release payload if: success() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release_payload path: | @@ -201,12 +245,13 @@ jobs: permissions: contents: write actions: read + attestations: read env: RELEASE_TAG: ${{ needs.build.outputs.tag_name }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -307,3 +352,99 @@ jobs: Write-Error "GitHub CLI failed with exit code $LASTEXITCODE" exit $LASTEXITCODE } + + - name: Verify Published Release Assets End-to-End + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $tag = $env:RELEASE_TAG + Write-Host "=== 開始對已發布 Release $tag 進行端對端資產驗證 ===" + $verifyDir = Join-Path $env:RUNNER_TEMP "release-verify" + if (Test-Path -LiteralPath $verifyDir) { + Remove-Item -LiteralPath $verifyDir -Recurse -Force + } + New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null + + try { + Write-Host "正在自 GitHub Release 下載資產至驗證目錄..." + $downloadSuccess = $false + for ($attempt = 1; $attempt -le 3; $attempt++) { + gh release download $tag --dir $verifyDir --repo $env:GITHUB_REPOSITORY + if ($LASTEXITCODE -eq 0) { + $downloadSuccess = $true + break + } + Write-Warning "下載 Release $tag 資產失敗 (第 $attempt 次嘗試),等候重試..." + Start-Sleep -Seconds ($attempt * 2) + } + if (-not $downloadSuccess) { + throw "下載 Release $tag 資產失敗" + } + + $downloadedFiles = Get-ChildItem -LiteralPath $verifyDir -File + Write-Host "成功下載 $($downloadedFiles.Count) 個發布資產:" + $downloadedFiles | Select-Object Name, Length | Format-Table -AutoSize + + $exeFiles = @(Get-ChildItem -LiteralPath $verifyDir -Filter '*.exe' -File) + if ($exeFiles.Count -eq 0) { + throw "驗證失敗:Release 中未找到任何 .exe 執行檔" + } + + $shaFiles = @(Get-ChildItem -LiteralPath $verifyDir -Filter '*.sha256' -File) + if ($shaFiles.Count -eq 0) { + throw "驗證失敗:Release 中未找到任何 .sha256 雜湊檔" + } + + Write-Host "--- 步驟 1:比對 SHA-256 雜湊值 ---" + foreach ($shaFile in $shaFiles) { + $shaContent = (Get-Content -LiteralPath $shaFile.FullName -Raw).Trim() + if ($shaContent -match '^([0-9a-fA-F]{64})\s+[\*]?(.*)$') { + $expectedHash = $Matches[1].ToUpperInvariant() + $targetFileName = $Matches[2].Trim() + $targetFile = Join-Path $verifyDir $targetFileName + + if (-not (Test-Path -LiteralPath $targetFile -PathType Leaf)) { + throw "雜湊校驗失敗:$($shaFile.Name) 指定的檔案不存在 ($targetFileName)" + } + + $actualHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToUpperInvariant() + if ($actualHash -ne $expectedHash) { + throw "雜湊校驗不符:$targetFileName 預期 $expectedHash,實際 $actualHash" + } + Write-Host " [SHA256 驗證通過] $targetFileName ($actualHash)" + } else { + throw "無法解析雜湊檔格式: $($shaFile.Name) 內容為: $shaContent" + } + } + + Write-Host "--- 步驟 2:驗證 .exe 的 GitHub SLSA Attestation ---" + foreach ($exe in $exeFiles) { + Write-Host "正在驗證 $($exe.Name) 的簽章證明..." + gh attestation verify $exe.FullName --repo $env:GITHUB_REPOSITORY + if ($LASTEXITCODE -ne 0) { + throw "Release 資產 Attestation 驗證失敗: $($exe.Name)" + } + Write-Host " [Attestation 驗證通過] $($exe.Name)" + } + + Write-Host "--- 步驟 3:比對建置產物一致性(本地 output/ vs 遠端 Release) ---" + if (Test-Path -LiteralPath 'output') { + foreach ($exe in $exeFiles) { + $localExe = Join-Path 'output' $exe.Name + if (Test-Path -LiteralPath $localExe -PathType Leaf) { + $localHash = (Get-FileHash -LiteralPath $localExe -Algorithm SHA256).Hash.ToUpperInvariant() + $remoteHash = (Get-FileHash -LiteralPath $exe.FullName -Algorithm SHA256).Hash.ToUpperInvariant() + if ($localHash -ne $remoteHash) { + throw "一致性校驗失敗:$($exe.Name) 本地建置雜湊 ($localHash) 與 Release 遠端雜湊 ($remoteHash) 不符!" + } + Write-Host " [工作區一致性驗證通過] $($exe.Name)" + } + } + } + + Write-Host "=== 發布後端對端資產驗證全數通過!===" + } + finally { + Remove-Item -LiteralPath $verifyDir -Recurse -Force -ErrorAction SilentlyContinue + } diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 5314b74..fcca4f6 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -2,7 +2,7 @@ name: CI concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} on: push: @@ -12,8 +12,6 @@ on: branches: - master workflow_dispatch: - merge_group: - types: [checks_requested] permissions: contents: read @@ -25,12 +23,12 @@ jobs: timeout-minutes: 10 steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: 隱私與機密資訊檢查 (Gitleaks) - uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3 + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -39,18 +37,18 @@ jobs: runs-on: windows-latest timeout-minutes: 20 env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' - DOTNET_CLI_FORCE_UTF8_ENCODING: '1' + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + DOTNET_CLI_FORCE_UTF8_ENCODING: "1" steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 1 - name: Setup .NET 10 SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json cache: true @@ -65,7 +63,7 @@ jobs: - name: Upload Test Results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dotnet-test-results path: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 562c647..ca9daba 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,18 +2,16 @@ name: "CodeQL Analysis (C#)" concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} on: push: - branches: [ "master" ] - merge_group: - types: [checks_requested] + branches: ["master"] pull_request: - branches: [ "master" ] + branches: ["master"] workflow_dispatch: schedule: - - cron: '0 0 * * 1' + - cron: "0 0 * * 1" permissions: contents: read @@ -23,8 +21,8 @@ jobs: name: CodeQL (C# / .NET 10) runs-on: windows-latest env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' - DOTNET_CLI_FORCE_UTF8_ENCODING: '1' + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + DOTNET_CLI_FORCE_UTF8_ENCODING: "1" permissions: security-events: write contents: read @@ -32,13 +30,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 persist-credentials: false - name: Setup .NET 10 SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json cache: true @@ -49,7 +47,7 @@ jobs: tests/**/*.csproj - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: languages: csharp build-mode: manual @@ -57,12 +55,12 @@ jobs: - name: Build Solution for CodeQL env: - MSBUILDDISABLENODEREUSE: '1' - DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER: '1' + MSBUILDDISABLENODEREUSE: "1" + DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER: "1" run: dotnet build src/MinecraftServerManager.App/MinecraftServerManager.App.csproj -c Release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: category: "/language:csharp" upload: true diff --git a/.github/workflows/oss-scorecard.yml b/.github/workflows/oss-scorecard.yml index 1935177..2ad47c8 100644 --- a/.github/workflows/oss-scorecard.yml +++ b/.github/workflows/oss-scorecard.yml @@ -6,7 +6,7 @@ on: - master workflow_dispatch: schedule: - - cron: '0 0 * * 1' + - cron: "0 0 * * 1" permissions: contents: read @@ -22,7 +22,7 @@ jobs: steps: - name: "檢出儲存庫" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -35,14 +35,14 @@ jobs: publish_results: true - name: "上傳至 GitHub Security Tab" - uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: openossf_scorecard_report.sarif category: "OpenSSF Scorecard" - name: "上傳 Scorecard 結果檔案" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: openossf_scorecard_report path: openossf_scorecard_report.sarif - retention-days: 2 \ No newline at end of file + retention-days: 2 diff --git a/.github/workflows/validate-renovate.yml b/.github/workflows/validate-renovate.yml index 51a98be..c7f865c 100644 --- a/.github/workflows/validate-renovate.yml +++ b/.github/workflows/validate-renovate.yml @@ -3,12 +3,12 @@ name: Validate Renovate config on: push: paths: - - 'renovate.json' - - '.github/renovate.json' + - "renovate.json" + - ".github/renovate.json" pull_request: paths: - - 'renovate.json' - - '.github/renovate.json' + - "renovate.json" + - ".github/renovate.json" permissions: contents: read @@ -17,12 +17,14 @@ jobs: validate-renovate-config: runs-on: ubuntu-latest timeout-minutes: 10 + container: + image: ghcr.io/renovatebot/renovate:44.181.1@sha256:823dc5036b911696c27faacf7664219b28e6e15826405720df29aad208d19f25 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Validate renovate.json run: | - npx --yes --package renovate@44.30.3 -- renovate-config-validator ./.github/renovate.json --strict + renovate-config-validator ./.github/renovate.json --strict diff --git a/.gitignore b/.gitignore index 9eae1f2..75c7fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,4 @@ publish/ output/ *_wpftmp.csproj *_wpftmp.* - +.gitleaks.toml diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 903a069..4414253 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,4 +3,4 @@ "ms-dotnettools.csdevkit", "ms-dotnettools.csharp" ] -} +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index b27875d..12aea49 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,9 +4,5 @@ }, "files.watcherExclude": { "**/*_wpftmp.*": true - }, - "dotnet.projects": [ - "!**/*_wpftmp.csproj" - ], - "dotnet.defaultSolution": "MinecraftServerManager.sln" -} + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e9657a7..4e771bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v2.1.1 - 2026-10-25 + +### 修正與功能改善 +- **伺服器記憶體設定與啟動腳本同步**: + - 修正管理頁面修改記憶體配置時,未同步儲存至伺服器啟動腳本(`start_server.bat`)的問題,確保以該腳本啟動時正確套用修改後的記憶體大小。 + - 修正匯入伺服器時,若既有腳本使用 `G` 單位(如 `20G`)設定記憶體會誤判回退為 2G 的問題,現已精準支援 G 單位的數值辨識。 +- **管理頁面資訊與按鈕狀態連動**: + - 修正修改記憶體設定後,管理頁面底部的「記憶體配置」顯示未跟隨更新的問題。 + - 新增管理按鈕狀態切換:選取的伺服器處於「執行中」時,原「啟動」按鈕自動動態切換為「停止」按鈕,避免誤操作。 +- **建立伺服器進度條即時同步**: + - 修正建立伺服器時進度百分比達到 100% 但進度條元件未跟隨跑滿的問題,進度條現在嚴格跟隨百分比即時推移至 100% 後平順完成。 +- **伺服器監控視窗按鈕狀態改善**: + - 改善監控面板之「啟動」與「停止」按鈕狀態連動,並在停用時維持原主題色搭配半透明與禁止游標,提升視覺辨識度。 + ## v2.1.0 - 2026-10-21 ### 新增功能 diff --git a/Directory.Build.props b/Directory.Build.props index 6c77700..7938c4d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,11 @@ - + $(MSBuildThisFileDirectory) net10.0-windows + true + all + low + 2.1.1 enable enable true diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 5d8867a..e07f8c7 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -111,13 +111,15 @@ Domain (不相依其他任何專案,純粹領域邏輯) ### 8. 雙版本原生單一檔案發布 -- **原生 Single-File 封裝**:直接使用 .NET 官方提供之 Single-File 封裝技術,產出單一可執行檔 +- **原生 Single-File 封裝**:直接於 `MinecraftServerManager.App.csproj` 設定 `true`,確保無論透過發布腳本或於 IDE 中直接發布,皆產出標準原生單一可執行檔 - **雙版本發布策略**: - **`MinecraftServerManager-self-contained.exe`(獨立自包含版)**:包含完整 .NET 10 Runtime 與所有必要原生函式庫,啟用微軟官方原生單檔壓縮(`EnableCompressionInSingleFile=true`)並僅保留繁體中文與英文之衛星資源組件(`SatelliteResourceLanguages`),具備最高穩定性與隨點即開特性 - **`MinecraftServerManager-framework-dependent.exe`(框架相依版)**:僅包含應用程式編譯中繼資料,體積極小,仰賴目標電腦安裝之 .NET 10 Desktop Runtime (x64) ### 9. 執行期功能開關裁切(Feature Switches) +以下安全防護與功能開關全量集中宣告於 `MinecraftServerManager.App.csproj`(單一事實來源),確保所有途徑之發布產出皆維持一致: + - `HttpActivityPropagationSupport=false`:停用 HttpClient 內部 OpenTelemetry 分散式追蹤 - `MetricsSupport=false`:停用度量指標系統以降低資源負擔 - `EnableUnsafeBinaryFormatterSerialization=false`、`EnableUnsafeUTF7Encoding=false`:徹底移除過時且具安全隱患的序列化與編碼邏輯 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 226b18c..7a7867e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -122,5 +122,8 @@ - 請檢查伺服器設定的 Java 版本是否與 Minecraft 版本相符(例如 Minecraft 1.20.5+ 需要 Java 21 以上)。 - 請至主控台視窗檢視當機報告(Crash Report),確認是否因模組相依性缺失或記憶體配置超出實體限制所致。 -### 3. 如何回報問題或建議 +### 3. 初次啟動出現 Windows SmartScreen 提示 +本軟體為免費開源專案,未購買商業數位憑證。若於 Windows 首次啟動時出現「Windows 已保護您的電腦」提示,請點選「**其他資訊**(More info)」→「**仍要執行**(Run anyway)」即可正常啟動;主程式日常更新均內建全自動 SHA-256 完整性檢查,請安心使用。 + +### 4. 如何回報問題或建議 如遇程式異常,請至 [GitHub Issues](https://github.com/Colin955023/MinecraftServerManager/issues) 提出,並附上 Windows 版本、使用的版本類型、操作步驟與記錄檔(位於 `%LOCALAPPDATA%\Programs\MinecraftServerManager\Logs\`)。 diff --git a/scripts/dotnet_publish.ps1 b/scripts/dotnet_publish.ps1 index e20289e..8e8a627 100644 --- a/scripts/dotnet_publish.ps1 +++ b/scripts/dotnet_publish.ps1 @@ -30,23 +30,6 @@ function Publish-App { "-c", "Release", "-r", "win-x64", "--self-contained", $SelfContained, - "-p:PublishSingleFile=true", - "-p:PublishReadyToRun=false", - "-p:HttpActivityPropagationSupport=false", - "-p:MetricsSupport=false", - "-p:EnableUnsafeBinaryFormatterSerialization=false", - "-p:EnableUnsafeUTF7Encoding=false", - "-p:StartupHookSupport=false", - "-p:CustomResourceTypesSupport=false", - "-p:XmlResolverIsNetworkingEnabledByDefault=false", - "-p:DebugType=None", - "-p:DebugSymbols=false", - "-p:PublishDocumentationFiles=false", - "-p:AllowedReferenceRelatedFileExtensions=none", - "-p:DebuggerSupport=false", - "-p:EventSourceSupport=false", - "-p:MetadataUpdaterSupport=false", - "-p:UseSharedCompilation=false", "-nodereuse:false", "-m:$maxCpuCount", "-o", $tempOutput @@ -71,12 +54,7 @@ function Publish-App { $targetExe = Join-Path $outputRoot "MinecraftServerManager-$Name.exe" - if ($SelfContained -eq "true") { - Copy-Item -LiteralPath $sourceExe -Destination $targetExe -Force - } - else { - Move-Item -LiteralPath $sourceExe -Destination $targetExe -Force - } + Copy-Item -LiteralPath $sourceExe -Destination $targetExe -Force $hash = (Get-FileHash -LiteralPath $targetExe -Algorithm SHA256).Hash $targetSha = "$targetExe.sha256" diff --git a/scripts/dotnet_quality_gate.ps1 b/scripts/dotnet_quality_gate.ps1 index 9691161..6769607 100644 --- a/scripts/dotnet_quality_gate.ps1 +++ b/scripts/dotnet_quality_gate.ps1 @@ -85,42 +85,31 @@ function Test-SecretsAndPrivacy { } } -function Test-VersionConsistency { - Write-Host "=== 版本定義一致性檢查 ===" - $appInfoPath = Join-Path $solutionDir "src\MinecraftServerManager.Core\AppInfo.cs" - $appProjectPath = Join-Path $solutionDir "src\MinecraftServerManager.App\MinecraftServerManager.App.csproj" - - if (-not (Test-Path -LiteralPath $appInfoPath) -or -not (Test-Path -LiteralPath $appProjectPath)) { - throw "C# 門禁失敗:找不到版本定義檔案 ($appInfoPath 或 $appProjectPath)" - } - - $content = Get-Content -Raw -Encoding UTF8 $appInfoPath - if ($content -match 'public\s+const\s+string\s+Version\s*=\s*"([^"]+)";') { - $version = $matches[1] - } else { - throw "C# 門禁失敗:無法從 AppInfo.cs 解析 Version" - } +$commonScript = Join-Path $PSScriptRoot "lib\Common.ps1" +if (-not (Test-Path -LiteralPath $commonScript)) { + throw "C# 門禁失敗:找不到共用函式庫 $commonScript" +} +. $commonScript - [xml]$appProject = Get-Content -Raw -Encoding UTF8 $appProjectPath - $versionNode = $appProject.SelectSingleNode("//Version") - $projectVersion = if ($null -ne $versionNode) { $versionNode.InnerText } else { $null } - if ($projectVersion -ne $version) { - throw "C# 門禁失敗:AppInfo.cs 與 App.csproj 版本不一致 ($version != $projectVersion)" - } +function Test-VersionConsistency { + Write-Host "=== 版本定義單一事實來源檢查 (Directory.Build.props) ===" + $version = Get-ProjectVersion -RepoRoot $solutionDir + Write-Host "專案版本 (Directory.Build.props): $version" - $assemblyVersionNode = $appProject.SelectSingleNode("//AssemblyVersion") - $assemblyVersion = if ($null -ne $assemblyVersionNode) { $assemblyVersionNode.InnerText } else { $null } - $fileVersionNode = $appProject.SelectSingleNode("//FileVersion") - $fileVersion = if ($null -ne $fileVersionNode) { $fileVersionNode.InnerText } else { $null } - if ($assemblyVersion -ne "$version.0" -or $fileVersion -ne "$version.0") { - throw "C# 門禁失敗:App.csproj 的 AssemblyVersion ($assemblyVersion) 與 FileVersion ($fileVersion) 必須與 AppInfo 版本 ($version.0) 一致" + $appProjectPath = Join-Path $solutionDir "src\MinecraftServerManager.App\MinecraftServerManager.App.csproj" + if (Test-Path -LiteralPath $appProjectPath) { + [xml]$appProject = Get-Content -Raw -Encoding UTF8 $appProjectPath + $versionNode = $appProject.SelectSingleNode("//Version") + if ($null -ne $versionNode) { + throw "C# 門禁失敗:App.csproj 不得硬編碼重複定義 ,版本應統一由 Directory.Build.props 管理" + } } } function Test-ScriptEncoding { Write-Host "=== 腳本編碼檢查 (UTF-8 with BOM) ===" $scriptsDir = Join-Path $solutionDir "scripts" - $ps1Files = Get-ChildItem -LiteralPath $scriptsDir -Filter "*.ps1" -File + $ps1Files = Get-ChildItem -LiteralPath $scriptsDir -Filter "*.ps1" -Recurse -File foreach ($file in $ps1Files) { $bytes = [System.IO.File]::ReadAllBytes($file.FullName) if ($bytes.Length -lt 3 -or $bytes[0] -ne 0xEF -or $bytes[1] -ne 0xBB -or $bytes[2] -ne 0xBF) { @@ -152,7 +141,11 @@ if ($isCiEnvironment) { Invoke-DotnetStep "Release 建置(品質與未使用定義檢驗)" @("build", $solutionPath, "-c", "Release", "--no-restore") # 6. 單元測試與整合測試 -$testArguments = @("test", $solutionPath, "-c", "Release", "--no-build") +$minimumExpectedTests = 515 +$testArguments = @( + "test", $solutionPath, "-c", "Release", "--no-build", + "--minimum-expected-tests", $minimumExpectedTests +) if ($isCiEnvironment) { $testArguments += @( "--results-directory", (Join-Path $solutionDir "TestResults"), diff --git a/scripts/extract_release_notes.ps1 b/scripts/extract_release_notes.ps1 index c5e8b2e..c5c7c40 100644 --- a/scripts/extract_release_notes.ps1 +++ b/scripts/extract_release_notes.ps1 @@ -7,26 +7,13 @@ param( $ErrorActionPreference = "Stop" $repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) -$appInfoPath = Join-Path $repoRoot "src\MinecraftServerManager.Core\AppInfo.cs" -$appProjectPath = Join-Path $repoRoot "src\MinecraftServerManager.App\MinecraftServerManager.App.csproj" -$content = Get-Content -Raw -Encoding UTF8 $appInfoPath -if ($content -match 'public\s+const\s+string\s+Version\s*=\s*"([^"]+)";') { - $version = $matches[1] -} else { - throw "無法從 AppInfo.cs 解析 Version" -} - -[xml]$appProject = Get-Content -Raw -Encoding UTF8 $appProjectPath -$projectVersion = $appProject.SelectSingleNode("//Version").InnerText -if ($projectVersion -ne $version) { - throw "AppInfo.cs 與 App.csproj 版本不一致:$version != $projectVersion" +$commonScript = Join-Path $PSScriptRoot "lib\Common.ps1" +if (-not (Test-Path -LiteralPath $commonScript)) { + throw "找不到共用函式庫:$commonScript" } +. $commonScript -$assemblyVersion = $appProject.SelectSingleNode("//AssemblyVersion").InnerText -$fileVersion = $appProject.SelectSingleNode("//FileVersion").InnerText -if ($assemblyVersion -ne "$version.0" -or $fileVersion -ne "$version.0") { - throw "App.csproj 的 AssemblyVersion/FileVersion 必須與 AppInfo 版本 $version 一致" -} +$version = Get-ProjectVersion -RepoRoot $repoRoot $tag = "v$version" Write-Host "解析到版本:$version,發行標籤:$tag" diff --git a/scripts/lib/Common.ps1 b/scripts/lib/Common.ps1 new file mode 100644 index 0000000..439dcc5 --- /dev/null +++ b/scripts/lib/Common.ps1 @@ -0,0 +1,29 @@ +<# +.SYNOPSIS + Minecraft Server Manager 共用 PowerShell 函式庫 +#> + +function Get-ProjectVersion { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$RepoRoot + ) + + if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + $RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) + } + + $propsPath = Join-Path $RepoRoot "Directory.Build.props" + if (-not (Test-Path -LiteralPath $propsPath)) { + throw "找不到版本定義檔案 Directory.Build.props ($propsPath)" + } + + [xml]$propsXml = Get-Content -Raw -Encoding UTF8 $propsPath + $versionNode = $propsXml.SelectSingleNode("//Version") + if ($null -eq $versionNode -or [string]::IsNullOrWhiteSpace($versionNode.InnerText)) { + throw "無法從 Directory.Build.props 解析 Version 節點" + } + + return $versionNode.InnerText.Trim() +} diff --git a/src/MinecraftServerManager.App/App.xaml b/src/MinecraftServerManager.App/App.xaml index a343f6c..9a633f1 100644 --- a/src/MinecraftServerManager.App/App.xaml +++ b/src/MinecraftServerManager.App/App.xaml @@ -1,4 +1,4 @@ - @@ -193,10 +193,8 @@ - - - - + + @@ -234,10 +232,8 @@ - - - - + + diff --git a/src/MinecraftServerManager.App/App.xaml.cs b/src/MinecraftServerManager.App/App.xaml.cs index f90b5da..9a5195a 100644 --- a/src/MinecraftServerManager.App/App.xaml.cs +++ b/src/MinecraftServerManager.App/App.xaml.cs @@ -28,6 +28,7 @@ protected override void OnStartup(StartupEventArgs e) // 1. 最先註冊未處理例外事件,確保啟動初期之任何錯誤均被記錄 DispatcherUnhandledException += App_DispatcherUnhandledException; AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; // 2. 單一實體檢查 _singleInstanceMutex = new Mutex(true, "MinecraftServerManagerMutex", out bool createdNew); @@ -147,11 +148,6 @@ private static string EnsureServersRootConfigured(SettingsManager settings) protected override void OnExit(ExitEventArgs e) { try - { - Logger.Information("Minecraft Server Manager 正在結束 (結束代碼: {ExitCode})", e.ApplicationExitCode); - AppLogging.Shutdown(); - } - finally { if (_singleInstanceMutex is not null) { @@ -159,14 +155,20 @@ protected override void OnExit(ExitEventArgs e) { _singleInstanceMutex.ReleaseMutex(); } - catch + catch (Exception ex) { + Logger.Warning("釋放 SingleInstanceMutex 異常: {0}", ex.Message); } _singleInstanceMutex.Dispose(); _singleInstanceMutex = null; } + Logger.Information("Minecraft Server Manager 正在結束 (結束代碼: {ExitCode})", e.ApplicationExitCode); + AppLogging.Shutdown(); + } + finally + { base.OnExit(e); } } @@ -199,6 +201,12 @@ private static void App_DispatcherUnhandledException(object sender, DispatcherUn e.Handled = true; } + private static void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + Logger.Error(e.Exception, "TaskScheduler 攔截到未觀察任務例外: {Message}", e.Exception.Message); + e.SetObserved(); + } + private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) { if (e.ExceptionObject is Exception ex) diff --git a/src/MinecraftServerManager.App/AppCompositionRoot.cs b/src/MinecraftServerManager.App/AppCompositionRoot.cs index 0ccb16a..700c070 100644 --- a/src/MinecraftServerManager.App/AppCompositionRoot.cs +++ b/src/MinecraftServerManager.App/AppCompositionRoot.cs @@ -35,6 +35,7 @@ public sealed class AppCompositionRoot : IAsyncDisposable public Services.IUiService UiService => _uiService; public SettingsManager Settings => _settingsManager; public bool IsServerRunning => _sharedServerRuntime.IsRunning; + public MainViewModelDependencies Dependencies { get; } public AppCompositionRoot(string serversRoot, SettingsManager? settingsManager = null) { @@ -58,14 +59,19 @@ public AppCompositionRoot(string serversRoot, SettingsManager? settingsManager = var javaRequirements = new MinecraftJavaRequirementService(httpPort, RuntimePaths.GetVersionCacheDir()); _loaderCatalog = new LoaderCatalogService(httpPort, RuntimePaths.GetVersionCacheDir()); var loaderInstaller = new LoaderInstallerService(httpPort, _sharedProcessRunner, RuntimePaths.GetInstallerCacheDir()); + var directorySizeService = new ServerDirectorySizeService(); + var serverInspector = new ServerInspector(directorySizeService); var serverManager = new ServerManager( serversRoot, + inspector: serverInspector, loaderInstaller: loaderInstaller, javaDetector: _javaDetector, javaRequirementService: javaRequirements); var backupService = new ServerBackupService(serversRoot); - var directorySizeService = new ServerDirectorySizeService(); var integrityService = new ServerIntegrityService(); + var launchPlanner = new ServerLaunchPlanner(); + var playerConfigStore = new ServerPlayerConfigStore(); + var propertiesStore = new ServerPropertiesStore(serversRoot); var modScanner = new LocalModScanner(); var modInstaller = new ModFileInstaller(httpPort); @@ -78,25 +84,32 @@ public AppCompositionRoot(string serversRoot, SettingsManager? settingsManager = _uiService = new Services.WpfUiService(); - _mainViewModel = new MainViewModel( - _settingsManager, - _javaDetector, - serverManager, - backupService, - _sharedServerRuntime, - _loaderCatalog, - modManager, - modrinthClient, - updateCheckerService, - _sharedProcessRunner, - javaRequirements, - javaInstaller, - uiService: _uiService, - appUpdateService: appUpdateService, - dependencyPlanner: dependencyPlanner, - modListExporter: modListExporter, - directorySizeService: directorySizeService, - integrityService: integrityService); + var dependencies = new MainViewModelDependencies( + SettingsManager: _settingsManager, + JavaDetector: _javaDetector, + ServerManager: serverManager, + BackupService: backupService, + ServerRuntime: _sharedServerRuntime, + LoaderCatalog: _loaderCatalog, + ModManager: modManager, + ModrinthClient: modrinthClient, + UpdateChecker: updateCheckerService, + Launcher: _sharedProcessRunner, + JavaRequirementService: javaRequirements, + JavaInstaller: javaInstaller, + UiService: _uiService, + AppUpdateService: appUpdateService, + DependencyPlanner: dependencyPlanner, + ModListExporter: modListExporter, + DirectorySizeService: directorySizeService, + IntegrityService: integrityService, + ServerInspector: serverInspector, + LaunchPlanner: launchPlanner, + PlayerConfigStore: playerConfigStore, + PropertiesStore: propertiesStore); + + _mainViewModel = new MainViewModel(dependencies); + Dependencies = dependencies; } /// diff --git a/src/MinecraftServerManager.App/Behaviors/DataGridNaturalSortBehavior.cs b/src/MinecraftServerManager.App/Behaviors/DataGridNaturalSortBehavior.cs index 36baca1..47c6255 100644 --- a/src/MinecraftServerManager.App/Behaviors/DataGridNaturalSortBehavior.cs +++ b/src/MinecraftServerManager.App/Behaviors/DataGridNaturalSortBehavior.cs @@ -1,3 +1,4 @@ +using MinecraftServerManager.Infrastructure.Logging; using System.Collections; using System.Collections.Concurrent; using System.ComponentModel; @@ -90,6 +91,7 @@ private static void OnDataGridSorting(object sender, DataGridSortingEventArgs e) /// public sealed class WindowsNaturalSortComparer(string propertyName, ListSortDirection direction) : IComparer { + private static readonly ComponentLogger Logger = AppLogging.CreateComponentLogger("WindowsNaturalSortComparer"); private static readonly ConcurrentDictionary<(Type, string), Func?> GetterCache = new(); public int Compare(T? x, T? y) => Compare(x, (object?)y); @@ -167,8 +169,9 @@ private int CompareValues(object x, object y) { return compX.CompareTo(valY); } - catch + catch (Exception ex) { + Logger.Warning("自然排序物件比較失敗,改用字串比對: {0}", ex.Message); } } diff --git a/src/MinecraftServerManager.App/MainWindow.xaml.cs b/src/MinecraftServerManager.App/MainWindow.xaml.cs index 525a15d..5cfe982 100644 --- a/src/MinecraftServerManager.App/MainWindow.xaml.cs +++ b/src/MinecraftServerManager.App/MainWindow.xaml.cs @@ -114,7 +114,7 @@ private void MainWindow_Loaded(object sender, RoutedEventArgs e) _viewModel.AboutPreferences?.UpdateDisplayInfo(sw, sh, ww, wh); // 啟動時自動檢查更新(判斷邏輯位於 ViewModel,此處僅負責提示) - _ = _viewModel.CheckForUpdateOnStartupAsync(); + _ = ViewModelBase.RunSafeAsync(() => _viewModel.CheckForUpdateOnStartupAsync(), "啟動檢查更新", Logger); } @@ -148,7 +148,7 @@ private void MainWindow_Closing(object? sender, CancelEventArgs e) } _shutdownCompleted = true; - _ = DisposeSafelyAsync(); + _ = ViewModelBase.RunSafeAsync(DisposeSafelyAsync, "非同步釋放視窗資源", Logger); return; } @@ -167,7 +167,7 @@ private void MainWindow_Closing(object? sender, CancelEventArgs e) UnregisterWindowEvents(); } - _ = ShutdownAndCloseAsync(); + _ = ViewModelBase.RunSafeAsync(ShutdownAndCloseAsync, "關閉應用程式與伺服器資源", Logger); } private void UnregisterWindowEvents() diff --git a/src/MinecraftServerManager.App/MinecraftServerManager.App.csproj b/src/MinecraftServerManager.App/MinecraftServerManager.App.csproj index f9a8fc5..73a6d4c 100644 --- a/src/MinecraftServerManager.App/MinecraftServerManager.App.csproj +++ b/src/MinecraftServerManager.App/MinecraftServerManager.App.csproj @@ -3,9 +3,6 @@ WinExe true $(RepoRoot)assets\icon.ico - 2.1.0 - 2.1.0.0 - 2.1.0.0 Minecraft Server Manager Colin955023 GPL-3.0 License @@ -18,6 +15,26 @@ <_Parameter2 Condition="'$(SelfContained)' != 'true'">framework-dependent + + true + false + false + None + false + false + none + false + false + false + false + false + false + false + false + false + false + false + $(DefineConstants);SELF_CONTAINED diff --git a/src/MinecraftServerManager.App/Services/NoOpUiService.cs b/src/MinecraftServerManager.App/Services/NoOpUiService.cs index 8fff19c..788ffaf 100644 --- a/src/MinecraftServerManager.App/Services/NoOpUiService.cs +++ b/src/MinecraftServerManager.App/Services/NoOpUiService.cs @@ -34,11 +34,11 @@ public void PostToUi(Action action, bool afterLayout = false) { } public virtual IUiTimer CreateUiTimer(TimeSpan interval, Action tick) => new NoOpUiTimer(); - public IReadOnlyList? PickFiles(string title, string filter, bool multiselect = false) => null; + public virtual IReadOnlyList? PickFiles(string title, string filter, bool multiselect = false) => null; - public string? PickSaveFile(string title, string fileName, string filter) => null; + public virtual string? PickSaveFile(string title, string fileName, string filter) => null; - public string? PickFolder(string title, string? initialDirectory = null) => null; + public virtual string? PickFolder(string title, string? initialDirectory = null) => null; public bool? ShowManageJava(ManageJavaViewModel viewModel) => null; @@ -51,7 +51,7 @@ public void PostToUi(Action action, bool afterLayout = false) { } public bool? ShowServerMemory(ServerMemoryViewModel viewModel) => null; - public bool? ShowImportServer(ImportServerViewModel viewModel) => null; + public virtual bool? ShowImportServer(ImportServerViewModel viewModel) => null; public bool? ShowModVersionPicker(ModVersionPickerViewModel viewModel) => null; diff --git a/src/MinecraftServerManager.App/Services/WpfUiService.cs b/src/MinecraftServerManager.App/Services/WpfUiService.cs index 30e54e8..afc82b1 100644 --- a/src/MinecraftServerManager.App/Services/WpfUiService.cs +++ b/src/MinecraftServerManager.App/Services/WpfUiService.cs @@ -1,3 +1,4 @@ +using MinecraftServerManager.Infrastructure.Logging; using System.Collections.ObjectModel; using System.Windows; using System.Windows.Media; @@ -17,6 +18,7 @@ namespace MinecraftServerManager.App.Services; /// public sealed class WpfUiService : IUiService { + private static readonly ComponentLogger Logger = AppLogging.CreateComponentLogger("WpfUiService"); private static ImageSource? s_cachedAppIcon; public bool Confirm(string message, string title, UiMessageLevel level = UiMessageLevel.Question) @@ -284,8 +286,9 @@ private static void PrepareDialog(Window dialog, Window? owner) frame.Freeze(); s_cachedAppIcon = frame; } - catch + catch (Exception ex) { + Logger.Warning(ex, "載入應用程式圖示失敗"); } return s_cachedAppIcon; @@ -313,7 +316,10 @@ private sealed class ProgressSession(IProgress progress, Windo public void Complete() { markCompleted(); - RunOnUi(window, window.Close); + RunOnUi(window, () => + { + window.Dispatcher.InvokeAsync(window.Close, DispatcherPriority.Background); + }); } } diff --git a/src/MinecraftServerManager.App/ViewModels/CreateServerViewModel.cs b/src/MinecraftServerManager.App/ViewModels/CreateServerViewModel.cs index 0959ad6..e778abd 100644 --- a/src/MinecraftServerManager.App/ViewModels/CreateServerViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/CreateServerViewModel.cs @@ -132,7 +132,7 @@ public CreateServerViewModel( if (_loaderCatalog is not null) { - _ = InitializeVersionsAsync(); + _ = RunSafeAsync(InitializeVersionsAsync, "初始化 Minecraft 版本清單", Logger); } } @@ -164,7 +164,7 @@ partial void OnSelectedLoaderChanged(string value) { IsMinecraftVersionReloadEnabled = !string.Equals(value, "Paper", StringComparison.OrdinalIgnoreCase); UpdateSynchronizedServerName(); - _ = HandleLoaderChangedAsync(value); + _ = RunSafeAsync(() => HandleLoaderChangedAsync(value), "切換模組載入器", Logger); } private async Task HandleLoaderChangedAsync(string loaderName) @@ -216,8 +216,9 @@ private async Task UpdateMinecraftVersionsForLoaderAsync(LoaderKind loaderKind) } } } - catch + catch (Exception ex) when (ex is not OperationCanceledException) { + Logger.Warning(ex, "載入可用 Minecraft 版本失敗"); } } @@ -232,10 +233,10 @@ partial void OnSelectedMinecraftVersionChanged(string value) { if (_javaRequirementService is not null) { - _ = UpdateJvmArgsSummaryAsync(value); + _ = RunSafeAsync(() => UpdateJvmArgsSummaryAsync(value), "更新 JVM 參數摘要", Logger); } } - _ = RefreshLoaderVersionsAsync(); + _ = RunSafeAsync(RefreshLoaderVersionsAsync, "重新整理載入器版本", Logger); } private async Task UpdateJvmArgsSummaryAsync(string version) diff --git a/src/MinecraftServerManager.App/ViewModels/MainViewModel.cs b/src/MinecraftServerManager.App/ViewModels/MainViewModel.cs index 6f54767..d0a05cb 100644 --- a/src/MinecraftServerManager.App/ViewModels/MainViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/MainViewModel.cs @@ -37,7 +37,6 @@ public sealed partial class MainViewModel : ViewModelBase private readonly IExternalLauncher? _launcher; private readonly IUpdateCheckerService? _updateChecker; - private readonly IAppUpdateService? _appUpdateService; [ObservableProperty] public partial bool IsLightTheme { get; set; } @@ -55,89 +54,76 @@ public sealed partial class MainViewModel : ViewModelBase /// public event Action? UiScaleChanged; - public MainViewModel( - SettingsManager? settingsManager = null, - IJavaRuntimeDetector? javaDetector = null, - IServerManager? serverManager = null, - IServerBackupService? backupService = null, - IServerRuntime? serverRuntime = null, - ILoaderCatalogService? loaderCatalog = null, - IModManager? modManager = null, - IModrinthClient? modrinthClient = null, - IUpdateCheckerService? updateChecker = null, - IExternalLauncher? launcher = null, - IMinecraftJavaRequirementService? javaRequirementService = null, - IJavaInstaller? javaInstaller = null, - IUiService? uiService = null, - IAppUpdateService? appUpdateService = null, - IModDependencyPlanner? dependencyPlanner = null, - IModListExporter? modListExporter = null, - IServerDirectorySizeService? directorySizeService = null, - IServerIntegrityService? integrityService = null) + public MainViewModel(MainViewModelDependencies dependencies) { - _ui = uiService ?? NoOpUiService.Instance; - _settingsManager = settingsManager ?? new SettingsManager(); - _serverManager = serverManager; - _launcher = launcher; - _updateChecker = updateChecker; - _appUpdateService = appUpdateService; + ArgumentNullException.ThrowIfNull(dependencies); + + _ui = dependencies.UiService ?? NoOpUiService.Instance; + _settingsManager = dependencies.SettingsManager; + _serverManager = dependencies.ServerManager; + _launcher = dependencies.Launcher; + _updateChecker = dependencies.UpdateChecker; + var createVm = new CreateServerViewModel( - javaDetector, - serverManager, - loaderCatalog, + dependencies.JavaDetector, + dependencies.ServerManager, + dependencies.LoaderCatalog, ShowNotification, - launcher: launcher, + launcher: dependencies.Launcher, navigateCallback: (pageKey, serverName) => { Navigate(pageKey); if (!string.IsNullOrWhiteSpace(serverName) && GetOrCreatePage("manage") is ManageServerViewModel manageVm) { - _ = manageVm.RefreshAndSelectServerAsync(serverName); + _ = RunSafeAsync(() => manageVm.RefreshAndSelectServerAsync(serverName), "重新整理並選取伺服器", Logger); } }, - javaRequirementService: javaRequirementService, - javaInstaller: javaInstaller, - serverRuntime: serverRuntime, + javaRequirementService: dependencies.JavaRequirementService, + javaInstaller: dependencies.JavaInstaller, + serverRuntime: dependencies.ServerRuntime, uiService: _ui); _pages["create"] = createVm; _pageFactories["manage"] = () => new ManageServerViewModel( - _settingsManager, - serverManager, - backupService, - serverRuntime, - null, - null, - null, + dependencies.SettingsManager, + dependencies.ServerManager, + dependencies.BackupService, + dependencies.ServerRuntime, + dependencies.ServerInspector, + dependencies.LaunchPlanner, + dependencies.PropertiesStore, + dependencies.PlayerConfigStore, + dependencies.DirectorySizeService, + dependencies.IntegrityService, Navigate, ShowNotification, - launcher, - javaDetector, - javaRequirementService, - uiService: _ui, - directorySizeService: directorySizeService, - integrityService: integrityService); + dependencies.Launcher, + dependencies.JavaDetector, + dependencies.JavaRequirementService, + uiService: _ui); + _pageFactories["mods"] = () => new ModsViewModel( - serverManager, - modManager, - modrinthClient, + dependencies.ServerManager, + dependencies.ModManager, + dependencies.ModrinthClient, ShowNotification, - launcher, - dependencyPlanner: dependencyPlanner, - modListExporter: modListExporter, + dependencies.Launcher, + dependencyPlanner: dependencies.DependencyPlanner, + modListExporter: dependencies.ModListExporter, uiService: _ui); + _pageFactories["about_preferences"] = () => new AboutPreferencesViewModel( - _settingsManager, - updateChecker, + dependencies.SettingsManager, + dependencies.UpdateChecker, SetThemeMode, () => ResetWindowSizeRequested?.Invoke(), ShowNotification, - launcher, + dependencies.Launcher, scale => UiScaleChanged?.Invoke(scale), uiService: _ui, - appUpdateService: _appUpdateService); + appUpdateService: dependencies.AppUpdateService); CurrentPage = createVm; @@ -245,10 +231,10 @@ public void Navigate(string pageKey) switch (targetPage) { case ManageServerViewModel manageVm: - _ = manageVm.RefreshServers(); + _ = RunSafeAsync(manageVm.RefreshServers, "重新整理伺服器清單", Logger); break; case ModsViewModel modsVm: - _ = modsVm.RefreshServersAsync(); + _ = RunSafeAsync(modsVm.RefreshServersAsync, "重新整理模組頁面伺服器清單", Logger); break; } } @@ -273,11 +259,11 @@ public void ImportServer() { if (!string.IsNullOrWhiteSpace(vm.ImportedServerName)) { - _ = manageVm.RefreshAndSelectServerAsync(vm.ImportedServerName); + _ = RunSafeAsync(() => manageVm.RefreshAndSelectServerAsync(vm.ImportedServerName), "重新整理並選取匯入伺服器", Logger); } else { - _ = manageVm.RefreshServers(); + _ = RunSafeAsync(manageVm.RefreshServers, "重新整理伺服器清單", Logger); } } } diff --git a/src/MinecraftServerManager.App/ViewModels/MainViewModelDependencies.cs b/src/MinecraftServerManager.App/ViewModels/MainViewModelDependencies.cs new file mode 100644 index 0000000..abba3dc --- /dev/null +++ b/src/MinecraftServerManager.App/ViewModels/MainViewModelDependencies.cs @@ -0,0 +1,33 @@ +using MinecraftServerManager.App.Services; +using MinecraftServerManager.Core.Ports; +using MinecraftServerManager.Core.Servers; +using MinecraftServerManager.Infrastructure.Settings; + +namespace MinecraftServerManager.App.ViewModels; + +/// +/// 主視窗 ViewModel 之不可變相依集合,集中管理組合根所提供的共用服務實例 +/// +public sealed record MainViewModelDependencies( + SettingsManager SettingsManager, + IServerManager ServerManager, + IServerBackupService BackupService, + IServerRuntime ServerRuntime, + IServerInspector ServerInspector, + IServerLaunchPlanner LaunchPlanner, + IServerPropertiesStore PropertiesStore, + IServerPlayerConfigStore PlayerConfigStore, + IServerDirectorySizeService DirectorySizeService, + IServerIntegrityService IntegrityService, + IModDependencyPlanner DependencyPlanner, + IModListExporter ModListExporter, + IJavaRuntimeDetector? JavaDetector = null, + ILoaderCatalogService? LoaderCatalog = null, + IModManager? ModManager = null, + IModrinthClient? ModrinthClient = null, + IUpdateCheckerService? UpdateChecker = null, + IExternalLauncher? Launcher = null, + IMinecraftJavaRequirementService? JavaRequirementService = null, + IJavaInstaller? JavaInstaller = null, + IUiService? UiService = null, + IAppUpdateService? AppUpdateService = null); diff --git a/src/MinecraftServerManager.App/ViewModels/ManageJavaViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ManageJavaViewModel.cs index 922ebb0..cce01dd 100644 --- a/src/MinecraftServerManager.App/ViewModels/ManageJavaViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ManageJavaViewModel.cs @@ -34,7 +34,7 @@ public sealed partial class JavaItemViewModel : ObservableObject public partial string StatusText { get; set; } = string.Empty; } -public sealed partial class ManageJavaViewModel : ObservableObject +public sealed partial class ManageJavaViewModel : ViewModelBase { private readonly IUiService _ui; @@ -101,7 +101,7 @@ public ManageJavaViewModel( : (AvailableInstallMajors.Count > 0 ? AvailableInstallMajors[^1] : 0); ResultJavaPath = currentJavaPath; - _ = LoadInstalledJavasAsync(currentJavaPath); + _ = RunSafeAsync(() => LoadInstalledJavasAsync(currentJavaPath), "載入已安裝 Java 清單", Logger); } [RelayCommand] diff --git a/src/MinecraftServerManager.App/ViewModels/ManageServerViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ManageServerViewModel.cs index 72914b0..fc1dcf5 100644 --- a/src/MinecraftServerManager.App/ViewModels/ManageServerViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ManageServerViewModel.cs @@ -11,7 +11,6 @@ using MinecraftServerManager.Domain.ValueObjects; using MinecraftServerManager.Infrastructure.FileSystem; using MinecraftServerManager.Infrastructure.Logging; -using MinecraftServerManager.Infrastructure.Servers; using MinecraftServerManager.Infrastructure.Settings; namespace MinecraftServerManager.App.ViewModels; @@ -128,6 +127,7 @@ public sealed partial class ManageServerViewModel : PageViewModel, IDisposable public bool CanStartOrMonitorServer => HasSelectedServer && SelectedServer is not null && !SelectedServer.HasMissingFiles; public bool CanManagePlayers => HasSelectedServer && !IsSelectedServerActive; + public bool IsSelectedServerRunning => IsSelectedServerActive; private bool IsSelectedServerActive => SelectedServer is not null && @@ -135,35 +135,46 @@ SelectedServer is not null && string.Equals(SelectedServer.Status, "執行中", StringComparison.OrdinalIgnoreCase)); public ManageServerViewModel( - SettingsManager? settingsManager = null, - IServerManager? serverManager = null, - IServerBackupService? backupService = null, - IServerRuntime? serverRuntime = null, - IServerInspector? serverInspector = null, - IServerLaunchPlanner? launchPlanner = null, - IServerPropertiesStore? propertiesStore = null, + SettingsManager settingsManager, + IServerManager serverManager, + IServerBackupService backupService, + IServerRuntime serverRuntime, + IServerInspector serverInspector, + IServerLaunchPlanner launchPlanner, + IServerPropertiesStore propertiesStore, + IServerPlayerConfigStore playerConfigStore, + IServerDirectorySizeService directorySizeService, + IServerIntegrityService integrityService, Action? navigateCallback = null, Action? notificationSink = null, IExternalLauncher? launcher = null, IJavaRuntimeDetector? javaDetector = null, IMinecraftJavaRequirementService? javaRequirementService = null, - IUiService? uiService = null, - IServerPlayerConfigStore? playerConfigStore = null, - IServerDirectorySizeService? directorySizeService = null, - IServerIntegrityService? integrityService = null) + IUiService? uiService = null) : base("manage", "管理現有伺服器", "檢視已安裝伺服器清單,執行啟動、停止、設定與備份維護") { + ArgumentNullException.ThrowIfNull(settingsManager); + ArgumentNullException.ThrowIfNull(serverManager); + ArgumentNullException.ThrowIfNull(backupService); + ArgumentNullException.ThrowIfNull(serverRuntime); + ArgumentNullException.ThrowIfNull(serverInspector); + ArgumentNullException.ThrowIfNull(launchPlanner); + ArgumentNullException.ThrowIfNull(propertiesStore); + ArgumentNullException.ThrowIfNull(playerConfigStore); + ArgumentNullException.ThrowIfNull(directorySizeService); + ArgumentNullException.ThrowIfNull(integrityService); + _ui = uiService ?? NoOpUiService.Instance; _settingsManager = settingsManager; _serverManager = serverManager; _backupService = backupService; _serverRuntime = serverRuntime; - _serverInspector = serverInspector ?? new ServerInspector(); - _launchPlanner = launchPlanner ?? new ServerLaunchPlanner(); + _serverInspector = serverInspector; + _launchPlanner = launchPlanner; _propertiesStore = propertiesStore; - _playerConfigStore = playerConfigStore ?? new ServerPlayerConfigStore(); - _directorySizeService = directorySizeService ?? new ServerDirectorySizeService(); - _integrityService = integrityService ?? new ServerIntegrityService(); + _playerConfigStore = playerConfigStore; + _directorySizeService = directorySizeService; + _integrityService = integrityService; _navigateCallback = navigateCallback; _notificationSink = notificationSink; _launcher = launcher; @@ -222,7 +233,7 @@ private static string FormatMemoryDisplay(int? minMb, int maxMb) : $"{maxMb} MB"; } - partial void OnSelectedServerChanged(ServerRowItem? value) + private void UpdateSelectedServerDetails(ServerRowItem? value) { HasSelectedServer = value is not null; if (value is not null) @@ -254,6 +265,12 @@ partial void OnSelectedServerChanged(ServerRowItem? value) OnPropertyChanged(nameof(CanStartOrMonitorServer)); OnPropertyChanged(nameof(CanManagePlayers)); + OnPropertyChanged(nameof(IsSelectedServerRunning)); + } + + partial void OnSelectedServerChanged(ServerRowItem? value) + { + UpdateSelectedServerDetails(value); } [RelayCommand] @@ -283,7 +300,7 @@ public async Task DetectServers() /// /// 於 UI 執行緒排入伺服器狀態更新(不阻塞事件來源執行緒) /// - private void RequestStatusRefresh() => _ui.PostToUi(() => { _ = UpdateServerStatusInPlaceAsync(); }); + private void RequestStatusRefresh() => _ui.PostToUi(() => { _ = RunSafeAsync(UpdateServerStatusInPlaceAsync, "更新伺服器就緒狀態", Logger); }); [RelayCommand] public void AddServer() => _navigateCallback?.Invoke("create"); @@ -357,6 +374,9 @@ public async Task RefreshServers() SelectedServer = Servers.FirstOrDefault(); } + // 確保選取項目之詳細資訊與記憶體顯示即時同步 + UpdateSelectedServerDetails(SelectedServer); + // 啟動背景容量非同步回填 TriggerBackgroundSizeBackfill(); @@ -398,7 +418,7 @@ private void TriggerBackgroundSizeBackfill() { token.ThrowIfCancellationRequested(); long bytes = await _directorySizeService.CalculateServerSizeAsync(target.Path, token).ConfigureAwait(false); - string formatted = Core.Utilities.UnitFormatter.FormatBytes(bytes); + string formatted = _directorySizeService.FormatServerSize(bytes); _ui.PostToUi(() => { @@ -410,6 +430,7 @@ private void TriggerBackgroundSizeBackfill() } catch (OperationCanceledException) { + // 正常切換伺服器或重新整理時取消背景容量計算,不寫入日誌以維持記錄檔整潔 } catch (Exception ex) { @@ -602,6 +623,7 @@ public async Task RefreshAndSelectServerAsync(string serverName) { SelectedServer = target; } + UpdateSelectedServerDetails(SelectedServer); } catch (Exception ex) { @@ -655,7 +677,7 @@ public async Task StartServerByNameAsync(string serverName) if (missingTarget is not null) { Logger.Error("啟動目標檔案不存在: {Target}", missingTarget); - _ = UpdateServerStatusInPlaceAsync(); + _ = RunSafeAsync(UpdateServerStatusInPlaceAsync, "更新伺服器就緒狀態", Logger); _notificationSink?.Invoke($"啟動目標檔案不存在:{missingTarget}", true); _ui.ShowError($"啟動目標檔案不存在:\n{missingTarget}", "啟動伺服器失敗"); return false; @@ -675,7 +697,7 @@ public async Task StartServerByNameAsync(string serverName) MonitorServerByName(server.Name.Value, server.Path, server.MinecraftVersion.Value); await _serverRuntime.StartAsync(plan).ConfigureAwait(true); - _ = UpdateServerStatusInPlaceAsync(); + _ = RunSafeAsync(UpdateServerStatusInPlaceAsync, "更新伺服器就緒狀態", Logger); string? earlyExit = await DetectEarlyExitAsync().ConfigureAwait(true); if (earlyExit is not null) @@ -683,7 +705,7 @@ public async Task StartServerByNameAsync(string serverName) Logger.Error("伺服器啟動後異常結束: {Message}", earlyExit); _notificationSink?.Invoke(earlyExit, true); _ui.ShowError($"伺服器啟動後異常結束:\n{earlyExit}", "伺服器異常結束"); - _ = UpdateServerStatusInPlaceAsync(); + _ = RunSafeAsync(UpdateServerStatusInPlaceAsync, "更新伺服器就緒狀態", Logger); return false; } @@ -712,7 +734,7 @@ public async Task StopServer() try { await _serverRuntime.StopAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(true); - _ = UpdateServerStatusInPlaceAsync(); + _ = RunSafeAsync(UpdateServerStatusInPlaceAsync, "更新伺服器就緒狀態", Logger); Logger.Information("伺服器已成功傳送停止請求: {Name}", SelectedServer.Name); return; } @@ -774,7 +796,7 @@ public void ConfigureServer() return; } - var store = _propertiesStore ?? (!string.IsNullOrWhiteSpace(_settingsManager?.GetServersRoot()) ? new ServerPropertiesStore(_settingsManager.GetServersRoot()) : null); + var store = _propertiesStore; if (store is not null) { var vm = new ServerPropertiesViewModel(store, SelectedServer.Name, @@ -1003,8 +1025,9 @@ public async Task DeleteServer() backupCount = backups.Count; backupsToDelete = backups; } - catch + catch (Exception ex) when (ex is not OperationCanceledException) { + Logger.Warning(ex, "取得伺服器「{Name}」關聯備份清單失敗", name); } } @@ -1090,6 +1113,7 @@ public async Task EditServerMemory() { _notificationSink?.Invoke($"已更新伺服器「{SelectedServer.Name}」記憶體設定", false); await RefreshAndSelectServerAsync(SelectedServer.Name); + UpdateSelectedServerDetails(SelectedServer); } } diff --git a/src/MinecraftServerManager.App/ViewModels/ModVersionPickerViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ModVersionPickerViewModel.cs index 96f5c98..6132aff 100644 --- a/src/MinecraftServerManager.App/ViewModels/ModVersionPickerViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ModVersionPickerViewModel.cs @@ -1,3 +1,4 @@ +using MinecraftServerManager.Infrastructure.Logging; using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -36,8 +37,9 @@ public sealed class ModVersionDisplayItem /// /// 線上模組版本選擇對話方塊 ViewModel /// -public sealed partial class ModVersionPickerViewModel : ObservableObject +public sealed partial class ModVersionPickerViewModel : ViewModelBase { + private static readonly ComponentLogger Logger = AppLogging.CreateComponentLogger("ModVersionPicker"); private readonly IModrinthClient _modrinthClient; private readonly string _projectId; private readonly string? _loader; @@ -68,7 +70,7 @@ public ModVersionPickerViewModel( WindowTitle = $"選擇模組版本 — {projectTitle}"; Versions = []; - _ = LoadVersionsAsync(); + _ = RunSafeAsync(LoadVersionsAsync, "載入模組版本清單", Logger); } public string WindowTitle { get; } diff --git a/src/MinecraftServerManager.App/ViewModels/ModsViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ModsViewModel.cs index 88e3b49..67c6919 100644 --- a/src/MinecraftServerManager.App/ViewModels/ModsViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ModsViewModel.cs @@ -242,17 +242,17 @@ public ModsViewModel( _modrinthClient = modrinthClient; _notificationSink = notificationSink; _launcher = launcher; - _dependencyPlanner = dependencyPlanner ?? new ModDependencyPlanner(); - _modListExporter = modListExporter ?? new ModListExporter(); + _dependencyPlanner = dependencyPlanner ?? throw new ArgumentNullException(nameof(dependencyPlanner)); + _modListExporter = modListExporter ?? throw new ArgumentNullException(nameof(modListExporter)); - _ = LoadServersInternalAsync(); + _ = RunSafeAsync(LoadServersInternalAsync, "初始化伺服器清單", Logger); } partial void OnSelectedTabIndexChanged(int value) { if (value == 1 && _allOnlineMods.Count == 0 && !IsSearchingOnline) { - _ = SearchOnlineModsAsync(); + _ = RunSafeAsync(SearchOnlineModsAsync, "搜尋線上模組", Logger); } } @@ -268,7 +268,7 @@ partial void OnSelectedOnlineSortChanged(string value) } else if ((_allOnlineMods.Count > 0 || SelectedTabIndex == 1) && !IsSearchingOnline) { - _ = SearchOnlineModsAsync(); + _ = RunSafeAsync(SearchOnlineModsAsync, "搜尋線上模組", Logger); } else { @@ -291,7 +291,7 @@ partial void OnSelectedPlatformChanged(string value) OnlineResultHint = string.Empty; if (SelectedTabIndex == 1) { - _ = SearchOnlineModsAsync(); + _ = RunSafeAsync(SearchOnlineModsAsync, "搜尋線上模組", Logger); } } } @@ -305,7 +305,7 @@ partial void OnSelectedLocalModChanged(LocalModRowItem? value) partial void OnSelectedServerChanged(string value) { HasSelectedServer = !string.IsNullOrWhiteSpace(value) && value != "請選擇伺服器"; - _ = LoadLocalModsAsync(); + _ = RunSafeAsync(LoadLocalModsAsync, "載入本機模組清單", Logger); } [RelayCommand] @@ -1609,7 +1609,7 @@ public void ViewInstallQueue() if (confirmed == true && InstallQueue.Count > 0) { - _ = InstallQueuedModsAsync(); + _ = RunSafeAsync(() => InstallQueuedModsAsync(), "安裝佇列模組", Logger); } } diff --git a/src/MinecraftServerManager.App/ViewModels/RestoreBackupViewModel.cs b/src/MinecraftServerManager.App/ViewModels/RestoreBackupViewModel.cs index edd2b6e..dc99297 100644 --- a/src/MinecraftServerManager.App/ViewModels/RestoreBackupViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/RestoreBackupViewModel.cs @@ -1,3 +1,4 @@ +using MinecraftServerManager.Infrastructure.Logging; using MinecraftServerManager.App.Collections; using MinecraftServerManager.App.Services; using System.Globalization; @@ -20,8 +21,9 @@ public sealed class BackupDisplayItem(string fileName, long sizeBytes, DateTimeO /// /// 備份還原與管理對話方塊 ViewModel /// -public sealed partial class RestoreBackupViewModel : ObservableObject +public sealed partial class RestoreBackupViewModel : ViewModelBase { + private static readonly ComponentLogger Logger = AppLogging.CreateComponentLogger("RestoreBackup"); private readonly IUiService _ui; private readonly IServerBackupService _backupService; @@ -53,7 +55,7 @@ public RestoreBackupViewModel( WindowTitle = $"備份還原 — {serverName}"; Backups = []; - _ = RefreshBackupsAsync(); + _ = RunSafeAsync(RefreshBackupsAsync, "重新整理備份清單", Logger); } public string WindowTitle { get; } diff --git a/src/MinecraftServerManager.App/ViewModels/ServerMonitorViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ServerMonitorViewModel.cs index 4397175..31998aa 100644 --- a/src/MinecraftServerManager.App/ViewModels/ServerMonitorViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ServerMonitorViewModel.cs @@ -18,7 +18,7 @@ namespace MinecraftServerManager.App.ViewModels; /// /// 伺服器即時監控視窗 ViewModel /// -public sealed partial class ServerMonitorViewModel : ObservableObject, IDisposable +public sealed partial class ServerMonitorViewModel : ViewModelBase, IDisposable { private readonly IUiService _ui; @@ -69,6 +69,9 @@ public sealed partial class ServerMonitorViewModel : ObservableObject, IDisposab public partial string CommandText { get; set; } = string.Empty; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanStartServer))] + [NotifyCanExecuteChangedFor(nameof(StartServerCommand))] + [NotifyCanExecuteChangedFor(nameof(StopServerCommand))] public partial bool IsServerRunning { get; set; } public bool CanStartServer => !IsServerRunning; @@ -409,7 +412,7 @@ public async Task SendCommandAsync() await _serverRuntime.SendCommandAsync(cmd).ConfigureAwait(false); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanStartServer))] public async Task StartServerAsync() { if (_serverRuntime.IsRunning) @@ -432,7 +435,7 @@ public async Task StartServerAsync() RefreshState(); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(IsServerRunning))] public async Task StopServerAsync() { if (!_serverRuntime.IsRunning) @@ -550,7 +553,7 @@ private void OnServerReady() if (!_serverReadyNotified) { _serverReadyNotified = true; - _ = ShowServerReadyNotificationAsync(); + _ = RunSafeAsync(ShowServerReadyNotificationAsync, "伺服器就緒通知", Logger); } } }); diff --git a/src/MinecraftServerManager.App/ViewModels/ServerPropertiesViewModel.cs b/src/MinecraftServerManager.App/ViewModels/ServerPropertiesViewModel.cs index e4b40c0..7c77e4c 100644 --- a/src/MinecraftServerManager.App/ViewModels/ServerPropertiesViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ServerPropertiesViewModel.cs @@ -1,3 +1,4 @@ +using MinecraftServerManager.Infrastructure.Logging; using MinecraftServerManager.App.Collections; using MinecraftServerManager.App.Services; using CommunityToolkit.Mvvm.ComponentModel; @@ -98,8 +99,9 @@ public void ResetToDefault() /// /// 伺服器屬性設定對話方塊 ViewModel /// -public sealed partial class ServerPropertiesViewModel : ObservableObject +public sealed partial class ServerPropertiesViewModel : ViewModelBase { + private static readonly ComponentLogger Logger = AppLogging.CreateComponentLogger("ServerProperties"); private readonly IUiService _ui; private readonly IServerPropertiesStore _propertiesStore; @@ -113,6 +115,10 @@ public sealed partial class ServerPropertiesViewModel : ObservableObject [ObservableProperty] public partial string SelectedCategory { get; set; } = "全部"; + partial void OnSearchTextChanged(string value) => ApplyFilter(); + + partial void OnSelectedCategoryChanged(string value) => ApplyFilter(); + [ObservableProperty] @@ -151,7 +157,7 @@ public ServerPropertiesViewModel(IServerPropertiesStore propertiesStore, string _serverName = serverName; WindowTitle = $"伺服器設定 — {serverName}"; - _ = LoadPropertiesAsync(); + _ = RunSafeAsync(LoadPropertiesAsync, "載入伺服器屬性設定", Logger); } public async Task LoadPropertiesAsync() diff --git a/src/MinecraftServerManager.App/ViewModels/ViewModelBase.cs b/src/MinecraftServerManager.App/ViewModels/ViewModelBase.cs index 95d8e75..d260e63 100644 --- a/src/MinecraftServerManager.App/ViewModels/ViewModelBase.cs +++ b/src/MinecraftServerManager.App/ViewModels/ViewModelBase.cs @@ -1,4 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; +using MinecraftServerManager.App.Services; +using MinecraftServerManager.Infrastructure.Logging; namespace MinecraftServerManager.App.ViewModels; @@ -7,4 +9,64 @@ namespace MinecraftServerManager.App.ViewModels; /// public abstract class ViewModelBase : ObservableObject { + private static readonly ComponentLogger FallbackLogger = AppLogging.CreateComponentLogger("ViewModelBase"); + + /// + /// 安全執行背景非同步工作,統一處理 OperationCanceledException、記錄例外並選擇性提示使用者 + /// + /// 非同步工作委派 + /// 操作名稱描述(用於記錄與提示) + /// 專屬元件記錄器(為空時使用預設記錄器) + /// UI 服務(用於錯誤提示與 UI 派送) + /// 例外回呼處理(可為 null) + /// 發生例外時是否提示使用者 + /// 代表安全執行的 Task + public static async Task RunSafeAsync( + Func work, + string operationName, + ComponentLogger? logger = null, + IUiService? uiService = null, + Action? onException = null, + bool notifyUser = false) + { + ArgumentNullException.ThrowIfNull(work); + var log = logger ?? FallbackLogger; + + try + { + await work().ConfigureAwait(true); + } + catch (OperationCanceledException) + { + log.Information("操作「{Operation}」已被取消", operationName); + } + catch (Exception ex) + { + log.Error(ex, "操作「{Operation}」執行時發生例外錯誤: {Message}", operationName, ex.Message); + + try + { + onException?.Invoke(ex); + } + catch (Exception callbackEx) + { + log.Error(callbackEx, "操作「{Operation}」之例外回呼執行失敗: {Message}", operationName, callbackEx.Message); + } + + if (notifyUser && uiService is not null) + { + try + { + uiService.PostToUi(() => + { + uiService.ShowError($"{operationName} 失敗:\n{ex.Message}", "操作錯誤"); + }); + } + catch + { + // 避免提示過程二度拋出異常 + } + } + } + } } diff --git a/src/MinecraftServerManager.App/Views/ExportModListDialog.xaml.cs b/src/MinecraftServerManager.App/Views/ExportModListDialog.xaml.cs index 1907c8c..5a2ca86 100644 --- a/src/MinecraftServerManager.App/Views/ExportModListDialog.xaml.cs +++ b/src/MinecraftServerManager.App/Views/ExportModListDialog.xaml.cs @@ -1,6 +1,7 @@ using System.Windows; using MinecraftServerManager.App.ViewModels; using MinecraftServerManager.Domain.Mods; +using MinecraftServerManager.Infrastructure.Logging; namespace MinecraftServerManager.App.Views; @@ -9,6 +10,7 @@ namespace MinecraftServerManager.App.Views; /// public partial class ExportModListDialog : Window { + private static readonly ComponentLogger Logger = AppLogging.CreateComponentLogger("ExportModListDialog"); private readonly ExportModListViewModel _viewModel; private bool _isExporting; @@ -40,8 +42,9 @@ private async void Export_Click(object sender, RoutedEventArgs e) Close(); } } - catch + catch (Exception ex) { + Logger.Error(ex, "匯出模組清單失敗: {Message}", ex.Message); } finally { diff --git a/src/MinecraftServerManager.App/Views/ManageServerView.xaml b/src/MinecraftServerManager.App/Views/ManageServerView.xaml index b952010..72a1276 100644 --- a/src/MinecraftServerManager.App/Views/ManageServerView.xaml +++ b/src/MinecraftServerManager.App/Views/ManageServerView.xaml @@ -1,4 +1,4 @@ - @@ -240,17 +240,49 @@ + + + +