diff --git a/.editorconfig b/.editorconfig index fc17481..672d1aa 100644 --- a/.editorconfig +++ b/.editorconfig @@ -84,10 +84,6 @@ dotnet_diagnostic.IDE0150.severity = warning dotnet_diagnostic.CS0168.severity = error dotnet_diagnostic.CS0219.severity = error dotnet_diagnostic.CS0414.severity = error -dotnet_diagnostic.CS8019.severity = error - -# 啟用 IDE analyzer -dotnet_diagnostic.IDE0000.severity = warning # 命名 / 可維護性 dotnet_diagnostic.IDE0001.severity = warning 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..4e1f2be 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 @@ -19,10 +19,10 @@ jobs: timeout-minutes: 10 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 + npx --yes --package renovate@44.181.1 -- renovate-config-validator ./.github/renovate.json --strict diff --git a/.gitignore b/.gitignore index a04a1d0..75c7fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -46,9 +46,7 @@ TestResults/ *.sln.docstates .artifacts/ publish/ -coverage/ output/ -.codebase-memory/ *_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 a0a7061..4e771bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,24 +1,38 @@ # Changelog +## v2.1.1 - 2026-10-25 + +### 修正與功能改善 +- **伺服器記憶體設定與啟動腳本同步**: + - 修正管理頁面修改記憶體配置時,未同步儲存至伺服器啟動腳本(`start_server.bat`)的問題,確保以該腳本啟動時正確套用修改後的記憶體大小。 + - 修正匯入伺服器時,若既有腳本使用 `G` 單位(如 `20G`)設定記憶體會誤判回退為 2G 的問題,現已精準支援 G 單位的數值辨識。 +- **管理頁面資訊與按鈕狀態連動**: + - 修正修改記憶體設定後,管理頁面底部的「記憶體配置」顯示未跟隨更新的問題。 + - 新增管理按鈕狀態切換:選取的伺服器處於「執行中」時,原「啟動」按鈕自動動態切換為「停止」按鈕,避免誤操作。 +- **建立伺服器進度條即時同步**: + - 修正建立伺服器時進度百分比達到 100% 但進度條元件未跟隨跑滿的問題,進度條現在嚴格跟隨百分比即時推移至 100% 後平順完成。 +- **伺服器監控視窗按鈕狀態改善**: + - 改善監控面板之「啟動」與「停止」按鈕狀態連動,並在停用時維持原主題色搭配半透明與禁止游標,提升視覺辨識度。 + ## v2.1.0 - 2026-10-21 ### 新增功能 - **伺服器名單與權限管理**: - 管理伺服器頁面新增「名單管理」按鈕,集中視覺化維護管理員名冊(`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 +44,16 @@ - 修復執行伺服器備份壓縮與還原解壓作業時,因同步 I/O 導致主視窗與進度視窗卡死未回應的問題;相關檔案走訪與 ZIP 讀寫全面轉入背景執行緒集區執行,維持介面順暢更新。 - **作業進度視窗動態流光光效與平滑過渡**: - 全新設計作業進度條視覺效果,引入 Shimmer 動態流光循環光效;無論背景處於下載、解壓或長耗時安裝程式執行中,進度條始終持續流動運作,徹底解決畫面凍結的視覺體驗問題。 - - 進度百分比更新時導入平滑過渡動畫,消除數值跳動的生硬突變感。 + - 進度百分比更新時引入平滑過渡動畫,消除數值跳動的生硬突變感。 - Java Installer 伺服器安裝程序擴充動態關鍵字進度估算,維持動畫流暢推進且完全不影響實際執行效能。 - **NeoForge 與 Forge 伺服器缺失重要檔案判定修正**: - 修復當伺服器根目錄檔案被清空(僅剩餘資料夾)時,清單仍誤判為正常而未顯示「缺少重要檔案」的問題;補充對根目錄啟動腳本與主程式庫的完整性驗證。 - **伺服器備份關聯比對去重修復**: - 修正管理伺服器頁面與備份歷史清單因檔名模糊比對導致 NeoForge 與 Forge 備份互相混淆的問題,改採一致之精確伺服器名稱前綴匹配,杜絕誤計備份。 -- **初次啟動伺服器初始化強制中斷通知優化**: +- **初次啟動伺服器初始化強制中斷通知最佳化**: - 初次建立伺服器於啟動初始化視窗若遭強制中斷關閉,即時跳出單一警告對話方塊,明確告知檔案已保留並導引至管理頁面,避免重複跳出多個通知視窗。 - **伺服器建立表單並行鎖定**: - - 建立伺服器執行期間自動停用「建立伺服器」與「重設表單」按鈕,防範重複點擊與競態衝突。 + - 建立伺服器執行期間自動停用「建立伺服器」與「重設表單」按鈕,防範重複點選與競態衝突。 - **伺服器 ZIP 匯入進度與動畫修復**: - 修復匯入 ZIP 壓縮檔時主視窗介面凍結未回應的問題,解壓縮改由背景非同步執行,進度條與百分比皆能順暢更新。 - **初次啟動目錄引導持久化修正**: @@ -62,7 +76,7 @@ - **效能與啟動速度提升**:改以 .NET 原生技術重寫,大幅降低記憶體佔用並提供免安裝單一執行檔。 - **清單自然數字排序**:伺服器與模組清單改依自然數字順序排列(如 1, 2, 10,不再是字典序 1, 10, 2)。 - **行程管理強化**:關閉伺服器時優先正常存檔,逾時強制關閉整個行程樹,防止背景殘留 Java 行程。 -- **操作負擔精簡**:一般操作回饋改為 3 秒自動關閉,減少手動點擊「確定」的次數。 +- **操作負擔精簡**:一般操作回饋改為 3 秒自動關閉,減少手動點選「確定」的次數。 ## v1.8.0 - 2026-09-14 @@ -110,10 +124,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 +144,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 +169,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/Directory.Build.props b/Directory.Build.props index 6c77700..c0a5693 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 @@ -14,7 +18,7 @@ All true true - true + true false $(NoWarn);1591 $(WarningsNotAsErrors);NU1900 diff --git a/README.md b/README.md index 0514694..42b2b68 100644 --- a/README.md +++ b/README.md @@ -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 與純文字 diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 088a46b..c72599f 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -1,4 +1,4 @@ -# Minecraft 伺服器管理器技術手冊 +# Minecraft 伺服器管理器技術手冊 本手冊說明 Minecraft 伺服器管理器程式所採用的技術堆疊、分層架構、核心演算法與安全機制。 @@ -90,7 +90,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 的效能開銷;雜湊不符即主動清理暫存檔。 @@ -100,29 +100,31 @@ 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 靜音的問題。 ### 7. 名單管理系統架構(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"`,確保記憶體低駐留與捲動高流暢度。 ### 8. 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 目錄,確保候選路徑之唯一性與真實性。 ### 9. 雙版本原生單一檔案發布 -- **原生 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) ### 10. 執行期功能開關裁切(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 a1c19b3..ccbe543 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -53,15 +53,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. 獨立主控台即時監控 @@ -106,7 +106,7 @@ 點選導覽列底部的「關於與設定」: -- **版本與開發資訊**:檢視目前應用程式版本號碼、技術堆疊與開源授權條款(GPLv3),並可快速點擊前往 GitHub 專案頁面。 +- **版本與開發資訊**:檢視目前應用程式版本號碼、技術堆疊與開源授權條款(GPLv3),並可快速點選前往 GitHub 專案頁面。 - **軟體更新檢查**: - 勾選「自動檢查更新」可在啟動時自動比對遠端版本。 - 點選「檢查更新」立即連線至 GitHub Releases 檢查有無新版本,下載時強制透過官方提供的 SHA-256 digest 雜湊驗證,確保檔案完整與安全。 @@ -123,5 +123,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 0bcfdc3..9a5195a 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 @@ -24,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); @@ -121,18 +126,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; } @@ -143,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) { @@ -155,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); } } @@ -171,8 +177,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); @@ -180,17 +201,38 @@ 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) { 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..700c070 100644 --- a/src/MinecraftServerManager.App/AppCompositionRoot.cs +++ b/src/MinecraftServerManager.App/AppCompositionRoot.cs @@ -29,10 +29,13 @@ 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; + public MainViewModelDependencies Dependencies { get; } public AppCompositionRoot(string serversRoot, SettingsManager? settingsManager = null) { @@ -56,12 +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 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); @@ -70,27 +80,36 @@ 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, - _javaDetector, - serverManager, - backupService, - _sharedServerRuntime, - _loaderCatalog, - modManager, - modrinthClient, - updateCheckerService, - _sharedProcessRunner, - javaRequirements, - javaInstaller, - uiService: uiService, - appUpdateService: appUpdateService, - dependencyPlanner: dependencyPlanner, - modListExporter: modListExporter); + 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 f127143..5cfe982 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) { @@ -104,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); } @@ -138,7 +148,7 @@ private void MainWindow_Closing(object? sender, CancelEventArgs e) } _shutdownCompleted = true; - _ = DisposeSafelyAsync(); + _ = ViewModelBase.RunSafeAsync(DisposeSafelyAsync, "非同步釋放視窗資源", Logger); return; } @@ -157,7 +167,7 @@ private void MainWindow_Closing(object? sender, CancelEventArgs e) UnregisterWindowEvents(); } - _ = ShutdownAndCloseAsync(); + _ = ViewModelBase.RunSafeAsync(ShutdownAndCloseAsync, "關閉應用程式與伺服器資源", Logger); } private void UnregisterWindowEvents() @@ -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/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/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..788ffaf 100644 --- a/src/MinecraftServerManager.App/Services/NoOpUiService.cs +++ b/src/MinecraftServerManager.App/Services/NoOpUiService.cs @@ -28,17 +28,17 @@ 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(); 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) { } 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 528debe..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) @@ -25,18 +27,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 +117,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 +155,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 +170,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 +178,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 +187,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 +202,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 +214,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 +226,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); } @@ -282,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; @@ -311,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/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..f9ad632 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; @@ -131,7 +132,7 @@ public CreateServerViewModel( if (_loaderCatalog is not null) { - _ = InitializeVersionsAsync(); + _ = RunSafeAsync(InitializeVersionsAsync, "初始化 Minecraft 版本清單", Logger); } } @@ -163,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) @@ -215,8 +216,9 @@ private async Task UpdateMinecraftVersionsForLoaderAsync(LoaderKind loaderKind) } } } - catch + catch (Exception ex) when (ex is not OperationCanceledException) { + Logger.Warning(ex, "載入可用 Minecraft 版本失敗"); } } @@ -231,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) @@ -368,7 +370,7 @@ private async Task RefreshLoaderVersionsAsync() } else { - LoaderVersions.ReplaceAll(["無可用版本"]); + LoaderVersions.ReplaceAll(NoAvailableVersions); SelectedLoaderVersion = "無可用版本"; IsLoaderVersionEnabled = false; } @@ -616,7 +618,7 @@ public async Task ReloadLoaderVersions() [RelayCommand] public void ConfigureJvmArgs() { - int maxMem = int.TryParse(MaxMemoryMb.Trim(), out int m) ? m : 2048; + int maxMem = int.TryParse(MaxMemoryMb.Trim(), out int m) ? m : ServerDefaults.DefaultMemoryMaxMb; int? javaMajor = _javaRequirementService?.GetCachedJavaMajor(SelectedMinecraftVersion); var vm = new JvmArgsViewModel(javaMajor, maxMem, SelectedLoader, _customJvmArgs); if (_ui.ShowJvmArgs(vm) == true) @@ -643,8 +645,8 @@ public void ResetForm() SelectedMinecraftVersion = AvailableMinecraftVersions.Count > 0 ? AvailableMinecraftVersions[0] : string.Empty; SelectedLoaderVersion = $"最新穩定版 ({SelectedLoader})"; ServerName = !string.IsNullOrWhiteSpace(SelectedMinecraftVersion) ? $"{SelectedLoader} {SelectedMinecraftVersion}" : SelectedLoader; - MinMemoryMb = "1024"; - MaxMemoryMb = "2048"; + MinMemoryMb = ServerDefaults.DefaultMemoryMinMb.ToString(System.Globalization.CultureInfo.InvariantCulture); + MaxMemoryMb = ServerDefaults.DefaultMemoryMaxMb.ToString(System.Globalization.CultureInfo.InvariantCulture); JavaPath = string.Empty; _customJvmArgs = null; int? javaMajor = _javaRequirementService?.GetCachedJavaMajor(SelectedMinecraftVersion); @@ -710,7 +712,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; } @@ -723,8 +725,8 @@ public async Task CreateServer() return; } - int minMem = int.TryParse(MinMemoryMb.Trim(), out int min) ? min : 1024; - int maxMem = int.TryParse(MaxMemoryMb.Trim(), out int max) ? max : 2048; + int minMem = int.TryParse(MinMemoryMb.Trim(), out int min) ? min : ServerDefaults.DefaultMemoryMinMb; + int maxMem = int.TryParse(MaxMemoryMb.Trim(), out int max) ? max : ServerDefaults.DefaultMemoryMaxMb; string loaderVer = SelectedLoaderVersion; if (loaderVer is "無" or "無可用版本") diff --git a/src/MinecraftServerManager.App/ViewModels/MainViewModel.cs b/src/MinecraftServerManager.App/ViewModels/MainViewModel.cs index d1e547e..d0a05cb 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; @@ -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,85 +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) + 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, + 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; @@ -241,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; } } @@ -269,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); } } } @@ -293,7 +283,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/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 3dbb46a..cce01dd 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; @@ -33,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; @@ -100,7 +101,7 @@ public ManageJavaViewModel( : (AvailableInstallMajors.Count > 0 ? AvailableInstallMajors[^1] : 0); ResultJavaPath = currentJavaPath; - _ = LoadInstalledJavasAsync(currentJavaPath); + _ = RunSafeAsync(() => LoadInstalledJavasAsync(currentJavaPath), "載入已安裝 Java 清單", Logger); } [RelayCommand] @@ -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..9631c8f 100644 --- a/src/MinecraftServerManager.App/ViewModels/ManageServerViewModel.cs +++ b/src/MinecraftServerManager.App/ViewModels/ManageServerViewModel.cs @@ -1,51 +1,60 @@ -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; 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; - public string Path { get; init; } = string.Empty; - public string RelativePath { get; init; } = string.Empty; + [ObservableProperty] + public partial string Status { get; set; } = string.Empty; - public int MemoryMaxMb { get; init; } = 2048; + [ObservableProperty] + public partial string Size { get; set; } = "-"; - public int? MemoryMinMb { get; init; } + [ObservableProperty] + public partial string BackupStatus { get; set; } = string.Empty; - public string BackupPath { get; set; } = string.Empty; + [ObservableProperty] + public partial string Path { get; set; } = string.Empty; [ObservableProperty] - public partial string Status { get; set; } = "已停止"; + public partial string RelativePath { get; set; } = string.Empty; [ObservableProperty] - public partial bool HasMissingFiles { get; set; } + public partial int MemoryMaxMb { get; set; } = ServerDefaults.DefaultMemoryMaxMb; [ObservableProperty] - public partial string Size { get; set; } = "-"; + public partial int? MemoryMinMb { get; set; } + + [ObservableProperty] + 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 +80,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 +97,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; } @@ -109,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 && @@ -116,31 +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) - : base("manage", "管理現有伺服器", "檢視已安裝伺服器清單,進行啟動、停止、設定與備份維護") + 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(); + _playerConfigStore = playerConfigStore; + _directorySizeService = directorySizeService; + _integrityService = integrityService; _navigateCallback = navigateCallback; _notificationSink = notificationSink; _launcher = launcher; @@ -167,6 +201,10 @@ public void Dispose() _serverRuntime.ServerReady -= RequestStatusRefresh; _serverRuntime.ServerExited -= OnServerExitedHandler; } + + _sizeCalculationCts?.Cancel(); + _sizeCalculationCts?.Dispose(); + _sizeCalculationCts = null; } public ObservableCollection Servers { get; } @@ -188,24 +226,51 @@ private string ResolveServersDirectory() } } - partial void OnSelectedServerChanged(ServerRowItem? value) + private static string FormatMemoryDisplay(int? minMb, int maxMb) + { + return minMb is { } min && min > 0 + ? $"{min} MB ~ {maxMb} MB" + : $"{maxMb} MB"; + } + + private void UpdateSelectedServerDetails(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(); + OnPropertyChanged(nameof(IsSelectedServerRunning)); + } + + partial void OnSelectedServerChanged(ServerRowItem? value) + { + UpdateSelectedServerDetails(value); } [RelayCommand] @@ -213,7 +278,7 @@ public async Task DetectServers() { if (_serverManager is null) { - await RefreshServers(); + _notificationSink?.Invoke("伺服器管理員尚未就緒", true); return; } @@ -235,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"); @@ -257,26 +322,64 @@ 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) { - Servers.Add(ToRowItem(snapshot, DetectPath)); + 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--) + { + 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(); } + // 確保選取項目之詳細資訊與記憶體顯示即時同步 + UpdateSelectedServerDetails(SelectedServer); + + // 啟動背景容量非同步回填 + TriggerBackgroundSizeBackfill(); + Logger.Information("成功重新整理伺服器清單,共 {Count} 個伺服器,路徑: {Path}", Servers.Count, DetectPath); return; } @@ -295,6 +398,54 @@ 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 = _directorySizeService.FormatServerSize(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 +456,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 +494,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 +578,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++) @@ -463,6 +623,7 @@ public async Task RefreshAndSelectServerAsync(string serverName) { SelectedServer = target; } + UpdateSelectedServerDetails(SelectedServer); } catch (Exception ex) { @@ -516,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; @@ -533,20 +694,18 @@ 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(); + _ = RunSafeAsync(UpdateServerStatusInPlaceAsync, "更新伺服器就緒狀態", Logger); - // 啟動後偵測是否於 3 秒內異常結束 string? earlyExit = await DetectEarlyExitAsync().ConfigureAwait(true); if (earlyExit is not null) { Logger.Error("伺服器啟動後異常結束: {Message}", earlyExit); _notificationSink?.Invoke(earlyExit, true); _ui.ShowError($"伺服器啟動後異常結束:\n{earlyExit}", "伺服器異常結束"); - _ = UpdateServerStatusInPlaceAsync(); + _ = RunSafeAsync(UpdateServerStatusInPlaceAsync, "更新伺服器就緒狀態", Logger); return false; } @@ -575,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; } @@ -637,11 +796,11 @@ 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, - uiService: _ui); + uiService: _ui); if (_ui.ShowServerProperties(vm) == true) { _notificationSink?.Invoke($"伺服器「{SelectedServer.Name}」屬性設定已更新", false); @@ -684,7 +843,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 +866,42 @@ public async Task BackupServer() } string serverName = SelectedServer.Name; - string destinationPath; - if (!string.IsNullOrWhiteSpace(SelectedServer.BackupPath)) + if (string.IsNullOrWhiteSpace(SelectedServer.BackupPath)) { - try + _ui.ShowToast("伺服器尚未設定外部備份資料夾,請先設定儲存位置", "請設定備份路徑", ToastType.Info); + await EditBackupPath(); + if (string.IsNullOrWhiteSpace(SelectedServer.BackupPath)) { - string backupRoot = SelectedServer.BackupPath; - Directory.CreateDirectory(backupRoot); - } - catch (Exception ex) - { - _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 +913,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 +939,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 +952,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 +990,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 +1012,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 { @@ -886,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); } } @@ -901,7 +1041,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 { @@ -973,6 +1113,7 @@ public async Task EditServerMemory() { _notificationSink?.Invoke($"已更新伺服器「{SelectedServer.Name}」記憶體設定", false); await RefreshAndSelectServerAsync(SelectedServer.Name); + UpdateSelectedServerDetails(SelectedServer); } } @@ -1033,7 +1174,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 +1185,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 +1205,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 +1242,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 +1259,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 +1275,6 @@ private static (bool HasMissing, IReadOnlyList MissingFiles) CheckImport { if (_javaDetector is null || _javaRequirementService is null) { - // 未注入偵測器或需求服務時維持既有行為,交由 ServerLaunchPlanner 處理 return (null, null); } @@ -1325,24 +1356,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/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 cc9016f..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] @@ -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) @@ -1609,7 +1609,7 @@ public void ViewInstallQueue() if (confirmed == true && InstallQueue.Count > 0) { - _ = InstallQueuedModsAsync(); + _ = RunSafeAsync(() => InstallQueuedModsAsync(), "安裝佇列模組", Logger); } } 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..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; } @@ -105,7 +107,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..8a36ed5 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; @@ -10,14 +10,17 @@ using MinecraftServerManager.App.Collections; using MinecraftServerManager.Core.Mods; using MinecraftServerManager.Core.Ports; +using MinecraftServerManager.Core.Servers; +using MinecraftServerManager.Domain.Servers; using MinecraftServerManager.Infrastructure.Logging; +using MinecraftServerManager.Infrastructure.FileSystem; namespace MinecraftServerManager.App.ViewModels; /// /// 伺服器即時監控視窗 ViewModel /// -public sealed partial class ServerMonitorViewModel : ObservableObject, IDisposable +public sealed partial class ServerMonitorViewModel : ViewModelBase, IDisposable { private readonly IUiService _ui; @@ -37,8 +40,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; @@ -70,13 +71,11 @@ 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; } - partial void OnIsServerRunningChanged(bool value) - { - OnPropertyChanged(nameof(CanStartServer)); - } - public bool CanStartServer => !IsServerRunning; [ObservableProperty] @@ -197,7 +196,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 +206,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 +285,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 = "已停止"; } @@ -436,7 +414,7 @@ public async Task SendCommandAsync() await _serverRuntime.SendCommandAsync(cmd).ConfigureAwait(false); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanStartServer))] public async Task StartServerAsync() { if (_serverRuntime.IsRunning) @@ -459,7 +437,7 @@ public async Task StartServerAsync() RefreshState(); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(IsServerRunning))] public async Task StopServerAsync() { if (!_serverRuntime.IsRunning) @@ -577,7 +555,7 @@ private void OnServerReady() if (!_serverReadyNotified) { _serverReadyNotified = true; - _ = ShowServerReadyNotificationAsync(); + _ = RunSafeAsync(ShowServerReadyNotificationAsync, "伺服器就緒通知", Logger); } } }); @@ -612,39 +590,23 @@ private static (string ServerIp, string ServerPort) ReadServerAddress(string? se if (!string.IsNullOrWhiteSpace(serverPath)) { - string propsPath = Path.Combine(serverPath, "server.properties"); - if (File.Exists(propsPath)) + string propsPath = Path.Combine(serverPath, ServerDefaults.ServerPropertiesFileName); + 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) + string content = reader.ReadToEnd(); + var properties = PropertiesDocumentCodec.Parse(content); + if (properties.TryGetValue("server-port", out string? port) && !string.IsNullOrWhiteSpace(port)) { - ReadOnlySpan span = line.AsSpan().Trim(); - if (span.IsEmpty || span.StartsWith("#") || span.StartsWith("!")) - { - continue; - } - - int eq = span.IndexOf('='); - if (eq <= 0) - { - continue; - } - - ReadOnlySpan key = span[..eq].Trim(); - ReadOnlySpan val = span[(eq + 1)..].Trim(); - - if (key.Equals("server-port", StringComparison.OrdinalIgnoreCase) && !val.IsEmpty) - { - serverPort = val.ToString(); - } - else if (key.Equals("server-ip", StringComparison.OrdinalIgnoreCase) && !val.IsEmpty) - { - serverIp = val.ToString(); - } + serverPort = port.Trim(); + } + + if (properties.TryGetValue("server-ip", out string? ip) && !string.IsNullOrWhiteSpace(ip)) + { + serverIp = ip.Trim(); } } catch (Exception ex) @@ -703,12 +665,6 @@ private void TrimDisplayedLogs() } } - private void DisposeMonitoredProcess() - { - _monitoredProcess?.Dispose(); - _monitoredProcess = null; - _monitoredPid = 0; - } public void Dispose() { @@ -718,6 +674,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..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; @@ -83,27 +84,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() { @@ -119,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; @@ -134,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] @@ -172,13 +157,9 @@ public ServerPropertiesViewModel(IServerPropertiesStore propertiesStore, string _serverName = serverName; WindowTitle = $"伺服器設定 — {serverName}"; - _ = LoadPropertiesAsync(); + _ = RunSafeAsync(LoadPropertiesAsync, "載入伺服器屬性設定", Logger); } - partial void OnSearchTextChanged(string value) => ApplyFilter(); - - partial void OnSelectedCategoryChanged(string value) => ApplyFilter(); - public async Task LoadPropertiesAsync() { try 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/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 @@ - + + + +