diff --git a/.github/workflows/ddss-publish.yml b/.github/workflows/ddss-publish.yml index 718407a..cd98db2 100644 --- a/.github/workflows/ddss-publish.yml +++ b/.github/workflows/ddss-publish.yml @@ -50,7 +50,13 @@ jobs: fi echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" - echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$GITHUB_ENV" + PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}" + if [[ -z "$PUBLIC_BASE" ]]; then + PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update" + fi + PUBLIC_BASE="${PUBLIC_BASE%/}" + echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV" + echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV" - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -89,6 +95,25 @@ jobs: gh release download "$RELEASE_TAG" -D release-assets find release-assets -maxdepth 1 -type f | sort + - name: Prepare PLONDS static output + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + rm -rf plonds-static + mkdir -p plonds-static + if [[ "${{ github.event_name }}" == "workflow_run" ]]; then + gh run download "${{ github.event.workflow_run.id }}" -n plonds-static -D plonds-static || true + fi + if [[ ! -d plonds-static/repo/sha256 && -f release-assets/plonds-static.zip ]]; then + unzip -q release-assets/plonds-static.zip -d plonds-static + fi + if [[ ! -d plonds-static/repo/sha256 || ! -d plonds-static/meta/channels || ! -d plonds-static/manifests ]]; then + echo "PLONDS static output is missing. Run the PLONDS workflow for this release first." + exit 1 + fi + - name: Upload release assets to Rainyun S3 env: AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} @@ -121,6 +146,59 @@ jobs: --metadata "sha256=$sha256" done + - name: Upload PLONDS static output to Rainyun S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3 sync \ + plonds-static/ \ + "s3://$S3_BUCKET/lanmountain/update/" \ + --only-show-errors + + - name: Mirror installers to Rainyun S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + for file in release-assets/*; do + [[ -f "$file" ]] || continue + name="$(basename "$file")" + platform="" + case "$name" in + *.exe) + if [[ "$name" == *x86* ]]; then platform="windows-x86"; else platform="windows-x64"; fi + ;; + *.deb) + platform="linux-x64" + ;; + *.dmg) + if [[ "$name" == *arm64* ]]; then platform="macos-arm64"; else platform="macos-x64"; fi + ;; + esac + [[ -n "$platform" ]] || continue + key="lanmountain/update/installers/${platform}/${version}/${name}" + sha256="$(sha256sum "$file" | awk '{print $1}')" + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$key" \ + --body "$file" \ + --metadata "sha256=$sha256" + done + - name: Build DDSS manifest shell: bash run: | @@ -164,3 +242,38 @@ jobs: --body "$file" \ --metadata "sha256=$sha256" done + + - name: Verify Rainyun S3 PLONDS output + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + mapfile -t required < <( + { + find plonds-static/meta/channels -path '*/latest.json' -type f | sort | head -n 1 + find plonds-static/meta/distributions -name '*.json' -type f | sort | head -n 1 + find plonds-static/manifests -name 'plonds-filemap.json' -type f | sort | head -n 1 + find plonds-static/manifests -name 'plonds-filemap.json.sig' -type f | sort | head -n 1 + find plonds-static/repo/sha256 -type f | sort | head -n 1 + } | sed '/^$/d' + ) + + if [[ "${#required[@]}" -lt 5 ]]; then + echo "Not enough PLONDS static files to verify." + exit 1 + fi + + for path in "${required[@]}"; do + rel="${path#plonds-static/}" + key="lanmountain/update/${rel}" + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \ + --bucket "$S3_BUCKET" \ + --key "$key" >/dev/null + curl -fsSI "$S3_PUBLIC_BASE_URL/$rel" >/dev/null + done diff --git a/.github/workflows/plonds-build.yml b/.github/workflows/plonds-build.yml index 3f53ade..7b40c37 100644 --- a/.github/workflows/plonds-build.yml +++ b/.github/workflows/plonds-build.yml @@ -66,6 +66,11 @@ jobs: echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV" echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV" echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV" + PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}" + if [[ -z "$PUBLIC_BASE" ]]; then + PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update" + fi + echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE%/}" >> "$GITHUB_ENV" - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -189,7 +194,9 @@ jobs: '--current-zip', $currentZip, '--output-dir', 'plonds-output', '--private-key', $env:UPDATE_PRIVATE_KEY_PATH, - '--channel', $plan.channel + '--channel', $plan.channel, + '--static-output-dir', 'plonds-output/static', + '--update-base-url', $env:S3_PUBLIC_BASE_URL ) if ([bool]$entry.isFullPayload) { @@ -212,6 +219,29 @@ jobs: --output-dir plonds-output ` --private-key $env:UPDATE_PRIVATE_KEY_PATH + foreach ($entry in $plan.platforms) { + $summary = Get-Content "plonds-output/platform-summaries/platform-summary-$($entry.platform).json" | ConvertFrom-Json + $required = @( + "plonds-output/static/meta/channels/$($plan.channel)/$($entry.platform)/latest.json", + "plonds-output/static/meta/distributions/$($summary.distributionId).json", + "plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json", + "plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json.sig" + ) + + foreach ($path in $required) { + if (-not (Test-Path $path)) { + throw "Missing PLONDS static output: $path" + } + } + } + + $objects = Get-ChildItem -Path "plonds-output/static/repo/sha256" -File -Recurse -ErrorAction SilentlyContinue + if (-not $objects -or $objects.Count -eq 0) { + throw "PLONDS static object repository is empty." + } + + Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force + - name: Upload PLONDS assets to release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -233,3 +263,11 @@ jobs: path: plonds-run-metadata/tag.txt if-no-files-found: error retention-days: 7 + + - name: Upload PLONDS static artifact + uses: actions/upload-artifact@v4 + with: + name: plonds-static + path: plonds-output/static/** + if-no-files-found: error + retention-days: 7 diff --git a/.trae/specs/pdc-incremental-migration/checklist.md b/.trae/specs/pdc-incremental-migration/checklist.md index 9212ae9..d53b8a5 100644 --- a/.trae/specs/pdc-incremental-migration/checklist.md +++ b/.trae/specs/pdc-incremental-migration/checklist.md @@ -1,13 +1,16 @@ # Checklist -- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack. -- [ ] `release.yml` uploads app payload artifacts for PDCC. -- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix). -- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run. -- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized. -- [ ] Host can persist PDC payload into launcher incoming directory. -- [ ] Launcher can apply PDC FileMap payload with signature/hash verification. -- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback. +- [x] `release.yml` does not invoke Velopack. +- [x] `plonds-build.yml` uploads app payload artifacts and generates PloNDS delta/static outputs. +- [x] S3 output path is rooted at `lanmountain/update/` (no system version prefix). +- [x] CI workflow expects `repo/`, `meta/`, `manifests/`, and `installers/` outputs after a release run. +- [x] Host update source keeps compatibility (`pdc`/`stcn` normalize to active PloNDS source). +- [x] Host can persist PloNDS payload into launcher incoming directory. +- [x] Launcher can apply PloNDS FileMap payload with signature/hash verification. +- [x] Legacy signed `files.json + update.zip` path still works as compatibility fallback. +- [x] Launcher keeps rollback-capable deployments after successful update. +- [x] Manual rollback returns a structured failure when the snapshot source directory is missing. - [ ] CI run attached proving all release matrix jobs pass. -- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64. -- [ ] Rollback verification report attached. +- [x] N-1 -> N incremental update verified locally on Windows x64. +- [ ] N-1 -> N incremental update verified on Windows x86 and Linux x64. +- [x] Rollback regression tests attached in `LanMountainDesktop.Tests`. diff --git a/.trae/specs/pdc-incremental-migration/spec.md b/.trae/specs/pdc-incremental-migration/spec.md index b9dfecb..16a549b 100644 --- a/.trae/specs/pdc-incremental-migration/spec.md +++ b/.trae/specs/pdc-incremental-migration/spec.md @@ -12,29 +12,33 @@ Replace VeloPack-based incremental packaging with a unified PDC FileMap + object ## Stage 2 (Current Implementation Target) -- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style). -- Promote PDC-distributed FileMap/object-repo as the primary incremental path. +- Use GitHub Actions PloNDS static publishing as the active incremental path. +- Keep `phainon.yml` for future PDCC parity, but do not rely on PDCC for the current release flow. +- Promote PloNDS-distributed FileMap/object-repo as the primary incremental path. - Keep GitHub Release installers and metadata as parallel distribution. - Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots). -- Update source defaults to `stcn` (S3/PDC), with GitHub fallback. +- Check updates in order: NS3/PloNDS static source, GitHub Release PloNDS assets, then GitHub full installer. - S3 object root is fixed to `lanmountain/update/` with no update-system version prefix. +- Public object URLs come from `S3_PUBLIC_BASE_URL`; do not infer them from `S3_ENDPOINT` and `S3_BUCKET`. Expected S3 layout: - - `lanmountain/update/repo//` - - `lanmountain/update/meta/channels///latest.json` - - `lanmountain/update/meta/distributions//*.json` - - `lanmountain/update/installers///*` + - `lanmountain/update/repo/sha256//` + - `lanmountain/update/meta/channels///latest.json` + - `lanmountain/update/meta/distributions/.json` + - `lanmountain/update/manifests//plonds-filemap.json` + - `lanmountain/update/manifests//plonds-filemap.json.sig` + - `lanmountain/update/installers///*` ## Acceptance -- `release.yml` includes PDCC publish steps and no Velopack steps. +- `release.yml` contains no Velopack steps; PloNDS static publishing is handled by `plonds-build.yml` and `ddss-publish.yml`. - Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS. -- PDC metadata + FileMap + object repo are published under `lanmountain/update/`. -- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable. +- PloNDS metadata + FileMap + object repo are published under `lanmountain/update/`. +- Host can consume the NS3/PloNDS static payload and fallback to GitHub when unavailable. - Launcher can apply both: - legacy signed `files.json + update.zip` - - PDC FileMap object-repo payload. -- Rollback semantics remain unchanged. + - PloNDS FileMap object-repo payload. +- Rollback semantics keep both automatic failure rollback and manual rollback after a successful update. ## Deprecated Notes diff --git a/.trae/specs/pdc-incremental-migration/tasks.md b/.trae/specs/pdc-incremental-migration/tasks.md index 02998af..f195f02 100644 --- a/.trae/specs/pdc-incremental-migration/tasks.md +++ b/.trae/specs/pdc-incremental-migration/tasks.md @@ -3,13 +3,19 @@ - [x] Remove VeloPack packaging from release workflow. - [x] Keep signed FileMap path as interim compatibility fallback. - [x] Remove launcher/runtime Velopack branching. -- [ ] Add `phainon.yml` for PDCC publish configuration. -- [ ] Add PDCC installation + publish steps in `release.yml`. -- [ ] Upload app payload artifacts for PDCC consumption in release build jobs. -- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`. -- [ ] Mirror installers to `lanmountain/update/installers///`. -- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility). -- [ ] Add PDC payload model into host update check result. -- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata). -- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics. -- [ ] Keep old `files.json + update.zip` path behind compatibility fallback. +- [x] Add `phainon.yml` for PDCC publish configuration. +- [ ] Add PDCC installation + publish steps in `release.yml` (deferred; active path is GitHub Actions PloNDS static publish). +- [x] Upload app payload artifacts for PloNDS delta generation in release build jobs. +- [x] Publish PloNDS metadata + sha256 object repo to S3 path root `lanmountain/update/`. +- [x] Mirror installers to `lanmountain/update/installers///`. +- [x] Keep update source compatibility (`pdc`/`stcn` normalize to active PloNDS source). +- [x] Add PloNDS static payload model into host update check result. +- [x] Add host download path for PloNDS payload (`plonds-filemap.json` + signature + object repo). +- [x] Add launcher PloNDS FileMap apply path with rollback-compatible semantics. +- [x] Keep old `files.json + update.zip` path behind compatibility fallback. +- [x] Keep rollback deployment directories after successful apply and prune by bounded retention. +- [x] Return structured failure when manual rollback snapshot source is missing. +- [x] Verify static S3 layout, filemap/signature, distribution, latest pointer, and at least one object in CI workflows. +- [x] Add regression tests for PloNDS success rollback, hash-failure auto rollback, missing rollback source, static NS3 manifest, and manifest field mapping. +- [ ] Attach live CI run proving the full release matrix passes. +- [ ] Verify N-1 -> N incremental update on Windows x86 and Linux x64 in release artifacts. diff --git a/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs index 7ba3430..0ef085c 100644 --- a/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs +++ b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs @@ -503,7 +503,11 @@ internal sealed class DeploymentLocator { try { - var snapshotFiles = Directory.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly); + var snapshotFiles = Directory + .GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(File.GetCreationTimeUtc) + .Take(Math.Max(1, minVersionsToKeep)) + .ToArray(); foreach (var snapshotFile in snapshotFiles) { try diff --git a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs index 67cdb1c..2d19bde 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs @@ -251,7 +251,7 @@ internal sealed class UpdateEngineService snapshot.Status = "applied"; SaveSnapshot(snapshotPath, snapshot); CleanupIncomingArtifacts(); - CleanupDestroyedDeployments(); + RetainDeploymentsForRollback(); _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileMap.Files.Count, fileMap.Files.Count)); _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, currentVersion, targetVersion, null, false)); @@ -269,19 +269,24 @@ internal sealed class UpdateEngineService catch (Exception ex) { _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); - TryRollbackOnFailure(snapshot); - snapshot.Status = "rolled_back"; + var rollbackResult = TryRollbackOnFailure(snapshot); + snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; SaveSnapshot(snapshotPath, snapshot); - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, ex.Message, true)); + var errorMessage = rollbackResult.Success + ? ex.Message + : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}"; + _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, errorMessage, rollbackResult.Success)); return new LauncherResult { Success = false, Stage = "update.apply", - Code = "apply_failed", - Message = "Failed to apply update. Rolled back to previous version.", - ErrorMessage = ex.Message, + Code = rollbackResult.Success ? "apply_failed" : "rollback_failed", + Message = rollbackResult.Success + ? "Failed to apply update. Rolled back to previous version." + : "Failed to apply update and rollback failed.", + ErrorMessage = errorMessage, CurrentVersion = currentVersion, - RolledBackTo = currentVersion + RolledBackTo = rollbackResult.Success ? currentVersion : null }; } finally @@ -410,7 +415,7 @@ internal sealed class UpdateEngineService snapshot.Status = "applied"; SaveSnapshot(snapshotPath, snapshot); CleanupIncomingArtifacts(); - CleanupDestroyedDeployments(); + RetainDeploymentsForRollback(); _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count)); _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false)); @@ -456,19 +461,24 @@ internal sealed class UpdateEngineService } _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); - TryRollbackOnFailure(snapshot); - snapshot.Status = "rolled_back"; + var rollbackResult = TryRollbackOnFailure(snapshot); + snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; SaveSnapshot(snapshotPath, snapshot); - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, ex.Message, true)); + var errorMessage = rollbackResult.Success + ? ex.Message + : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}"; + _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success)); return new LauncherResult { Success = false, Stage = "update.apply", - Code = "apply_failed", - Message = "Failed to apply PLONDS update. Rolled back to previous version.", - ErrorMessage = ex.Message, + Code = rollbackResult.Success ? "apply_failed" : "rollback_failed", + Message = rollbackResult.Success + ? "Failed to apply PLONDS update. Rolled back to previous version." + : "Failed to apply PLONDS update and rollback failed.", + ErrorMessage = errorMessage, CurrentVersion = sourceVersion, - RolledBackTo = sourceVersion + RolledBackTo = rollbackResult.Success ? sourceVersion : null }; } } @@ -1375,6 +1385,11 @@ internal sealed class UpdateEngineService return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata."); } + if (!Directory.Exists(snapshot.SourceDirectory)) + { + return Failed("update.rollback", "source_missing", $"Rollback source deployment is missing: {snapshot.SourceDirectory}"); + } + var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); if (string.IsNullOrWhiteSpace(currentDeployment)) { @@ -1397,21 +1412,7 @@ internal sealed class UpdateEngineService public void CleanupDestroyedDeployments() { - foreach (var dir in Directory.EnumerateDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)) - { - if (!File.Exists(Path.Combine(dir, ".destroy"))) - { - continue; - } - - try - { - Directory.Delete(dir, true); - } - catch - { - } - } + RetainDeploymentsForRollback(); } private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment, string extractRoot) @@ -1459,9 +1460,15 @@ internal sealed class UpdateEngineService var toCurrent = Path.Combine(toDeployment, ".current"); var fromCurrent = Path.Combine(fromDeployment, ".current"); var fromDestroy = Path.Combine(fromDeployment, ".destroy"); + var toDestroy = Path.Combine(toDeployment, ".destroy"); var toPartial = Path.Combine(toDeployment, ".partial"); File.WriteAllText(toCurrent, string.Empty); + if (File.Exists(toDestroy)) + { + File.Delete(toDestroy); + } + if (File.Exists(fromCurrent)) { File.Delete(fromCurrent); @@ -1474,7 +1481,7 @@ internal sealed class UpdateEngineService } } - private void TryRollbackOnFailure(SnapshotMetadata snapshot) + private RollbackAttemptResult TryRollbackOnFailure(SnapshotMetadata snapshot) { try { @@ -1483,6 +1490,11 @@ internal sealed class UpdateEngineService Directory.Delete(snapshot.TargetDirectory, true); } + if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory) || !Directory.Exists(snapshot.SourceDirectory)) + { + return new RollbackAttemptResult(false, "Source deployment is missing."); + } + if (File.Exists(Path.Combine(snapshot.SourceDirectory, ".destroy"))) { File.Delete(Path.Combine(snapshot.SourceDirectory, ".destroy")); @@ -1492,12 +1504,22 @@ internal sealed class UpdateEngineService { File.WriteAllText(Path.Combine(snapshot.SourceDirectory, ".current"), string.Empty); } + + return new RollbackAttemptResult(true, null); } - catch + catch (Exception ex) { + return new RollbackAttemptResult(false, ex.Message); } } + private void RetainDeploymentsForRollback() + { + _deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); + } + + private sealed record RollbackAttemptResult(bool Success, string? ErrorMessage); + internal void CleanupIncomingArtifacts() { foreach (var path in new[] diff --git a/LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs b/LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs index 4bc96ad..b7281a8 100644 --- a/LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs +++ b/LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs @@ -2,11 +2,23 @@ namespace LanMountainDesktop.PluginIsolation.Contracts; public sealed record PluginAppearanceSnapshotRequest(string SessionId); +public sealed record PluginMaterialSurfaceSnapshot( + string BackgroundColor, + string BorderColor, + double BlurRadius, + double Opacity); + public sealed record PluginAppearanceSnapshot( string ThemeVariant, string? AccentColor = null, double CornerRadiusScale = 1.0, IReadOnlyDictionary? CornerRadiusTokens = null, - IReadOnlyDictionary? ResourceAliases = null); + IReadOnlyDictionary? ResourceAliases = null, + string? SeedColor = null, + string? ColorSource = null, + string? SystemMaterialMode = null, + IReadOnlyDictionary? ColorRoles = null, + IReadOnlyDictionary? MaterialSurfaces = null, + IReadOnlyList? WallpaperSeedCandidates = null); public sealed record PluginAppearanceChangedNotification(PluginAppearanceSnapshot Snapshot); diff --git a/LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationJsonContext.cs b/LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationJsonContext.cs index 83fdbc7..3e8951d 100644 --- a/LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationJsonContext.cs +++ b/LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationJsonContext.cs @@ -22,6 +22,7 @@ namespace LanMountainDesktop.PluginIsolation.Contracts; [JsonSerializable(typeof(PluginSettingsWriteResponse))] [JsonSerializable(typeof(PluginSettingsChangedNotification))] [JsonSerializable(typeof(PluginAppearanceSnapshotRequest))] +[JsonSerializable(typeof(PluginMaterialSurfaceSnapshot))] [JsonSerializable(typeof(PluginAppearanceSnapshot))] [JsonSerializable(typeof(PluginAppearanceChangedNotification))] [JsonSerializable(typeof(PluginUiSurfaceDescriptor))] diff --git a/LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs b/LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs index e5f781b..7c13ef1 100644 --- a/LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs +++ b/LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs @@ -1,5 +1,18 @@ namespace LanMountainDesktop.PluginSdk; +public sealed record PluginMaterialSurfaceSnapshot( + string BackgroundColor, + string BorderColor, + double BlurRadius, + double Opacity); + public sealed record PluginAppearanceSnapshot( PluginCornerRadiusTokens CornerRadiusTokens, - string ThemeVariant); + string ThemeVariant, + string? AccentColor = null, + string? SeedColor = null, + string? ColorSource = null, + string? SystemMaterialMode = null, + IReadOnlyDictionary? ColorRoles = null, + IReadOnlyDictionary? MaterialSurfaces = null, + IReadOnlyList? WallpaperSeedCandidates = null); diff --git a/LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs b/LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs index dc7d6e1..95eae40 100644 --- a/LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs +++ b/LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs @@ -2,7 +2,7 @@ namespace LanMountainDesktop.Shared.Contracts.Update; public static class UpdatePaths { - private const string LauncherDirectoryName = ".launcher"; + private const string LauncherDirectoryName = ".Launcher"; private const string UpdateDirectoryName = "update"; private const string IncomingDirectoryName = "incoming"; private const string ObjectsDirectoryName = "objects"; diff --git a/LanMountainDesktop.Tests/StudyComponentRenderingTests.cs b/LanMountainDesktop.Tests/StudyComponentRenderingTests.cs new file mode 100644 index 0000000..05bd45c --- /dev/null +++ b/LanMountainDesktop.Tests/StudyComponentRenderingTests.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using LanMountainDesktop.Models; +using LanMountainDesktop.Views.Components; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class StudyComponentRenderingTests +{ + [Fact] + public void RenderGate_ProcessesOnlyLatestSnapshot() + { + var rendered = new List(); + using var gate = new StudySnapshotRenderGate( + canRender: () => true, + renderSnapshot: snapshot => rendered.Add(snapshot.LastError)); + + gate.Queue(CreateSnapshot("first")); + gate.Queue(CreateSnapshot("second")); + + Assert.True(gate.ProcessPending()); + Assert.Equal(["second"], rendered); + Assert.False(gate.HasPendingSnapshot); + } + + [Fact] + public void RenderGate_DropsPendingSnapshot_WhenRenderIsBlocked() + { + var renderCount = 0; + using var gate = new StudySnapshotRenderGate( + canRender: () => false, + renderSnapshot: _ => renderCount++); + + gate.Queue(CreateSnapshot("blocked")); + + Assert.False(gate.ProcessPending()); + Assert.Equal(0, renderCount); + Assert.False(gate.HasPendingSnapshot); + } + + [Fact] + public void CurveChart_SplitsStableHistoryFromDynamicTail() + { + var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1)); + var counts = StudyNoiseCurveChartControl.ResolveLayerSourceCounts(points, TimeSpan.FromSeconds(4)); + + Assert.Equal(5, StudyNoiseCurveChartControl.ResolveFirstTailIndex(points, TimeSpan.FromSeconds(4))); + Assert.Equal(5, counts.StaticSourceCount); + Assert.Equal(6, counts.DynamicSourceCount); + } + + [Fact] + public void CurveChart_UsesStableLogicalTimeCoordinates() + { + var origin = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero); + + var x = StudyNoiseCurveChartControl.MapTimestampToLogicalX( + origin.AddSeconds(3), + origin, + pixelsPerSecond: 12); + + Assert.Equal(36, x); + } + + [Fact] + public void DistributionAreaChart_BuildsAreaPathCache() + { + var points = CreateRealtimePoints(count: 24, step: TimeSpan.FromMilliseconds(500)); + var control = new StudyNoiseDistributionAreaChartControl(); + + control.UpdateSeries(points, baselineDb: 45); + control.RebuildCacheForTesting(new Rect(1, 1, 320, 160)); + + Assert.True(control.CachedPathCount > 0); + Assert.True(control.CachedPathCount <= 4); + Assert.True(control.StaticSourceCount > 0); + Assert.True(control.DynamicSourceCount > 0); + } + + [Fact] + public void DistributionAreaChart_UsesStableLogicalTimeCoordinates_WhenNewPointArrives() + { + var origin = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero); + var oldPointTimestamp = origin.AddSeconds(3); + + var before = StudyNoiseDistributionAreaChartControl.MapTimestampToLogicalX( + oldPointTimestamp, + origin, + pixelsPerSecond: 20); + var after = StudyNoiseDistributionAreaChartControl.MapTimestampToLogicalX( + oldPointTimestamp, + origin, + pixelsPerSecond: 20); + + Assert.Equal(before, after); + Assert.Equal(60, after); + } + + [Fact] + public void DistributionAreaChart_ReusesStaticAreaPath_WhenOnlyDynamicTailChanges() + { + var firstSeries = CreateRealtimePoints( + new[] + { + (0d, 40d), + (1d, 43d), + (2d, 45d), + (3d, 47d), + (8d, 52d) + }); + var secondSeries = CreateRealtimePoints( + new[] + { + (0d, 40d), + (1d, 43d), + (2d, 45d), + (3d, 47d), + (8d, 52d), + (8.05d, 54d) + }); + var control = new StudyNoiseDistributionAreaChartControl(); + var plot = new Rect(1, 1, 320, 160); + + control.UpdateSeries(firstSeries, baselineDb: 45); + control.RebuildCacheForTesting(plot); + var staticBuildVersion = control.StaticPathBuildVersion; + + control.UpdateSeries(secondSeries, baselineDb: 45); + control.RebuildCacheForTesting(plot); + + Assert.Equal(staticBuildVersion, control.StaticPathBuildVersion); + Assert.True(control.DynamicPathBuildVersion > 1); + } + + [Fact] + public void DistributionAreaChart_SplitsStaticHistoryFromDynamicTail() + { + var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1)); + var counts = StudyNoiseDistributionAreaChartControl.ResolveLayerSourceCounts( + points, + TimeSpan.FromSeconds(4)); + + Assert.Equal(5, counts.StaticSourceCount); + Assert.Equal(6, counts.DynamicSourceCount); + } + + [Fact] + public void DistributionAreaChart_StaticReportKeepsWholeSeriesStatic() + { + var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1)); + var counts = StudyNoiseDistributionAreaChartControl.ResolveLayerSourceCounts( + points, + TimeSpan.FromSeconds(4), + isStaticSeries: true); + + Assert.Equal(10, counts.StaticSourceCount); + Assert.Equal(0, counts.DynamicSourceCount); + } + + [Fact] + public void DistributionAreaChart_ResolvesLevelsFromBaseline() + { + Assert.Equal(NoiseDistributionLevel.Quiet, StudyNoiseDistributionAreaChartControl.ResolveLevel(44.9, 45)); + Assert.Equal(NoiseDistributionLevel.Normal, StudyNoiseDistributionAreaChartControl.ResolveLevel(45, 45)); + Assert.Equal(NoiseDistributionLevel.Noisy, StudyNoiseDistributionAreaChartControl.ResolveLevel(55, 45)); + Assert.Equal(NoiseDistributionLevel.Extreme, StudyNoiseDistributionAreaChartControl.ResolveLevel(65, 45)); + } + + private static IReadOnlyList CreateRealtimePoints(int count, TimeSpan step) + { + var start = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero); + var points = new List(count); + for (var i = 0; i < count; i++) + { + var displayDb = 38 + i; + points.Add(new NoiseRealtimePoint( + Timestamp: start + TimeSpan.FromTicks(step.Ticks * i), + Rms: 0.2, + Dbfs: -60 + i, + DisplayDb: displayDb, + Peak: 0.3, + IsOverThreshold: displayDb > 50)); + } + + return points; + } + + private static IReadOnlyList CreateRealtimePoints(IReadOnlyList<(double OffsetSeconds, double DisplayDb)> samples) + { + var start = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero); + var points = new List(samples.Count); + for (var i = 0; i < samples.Count; i++) + { + var sample = samples[i]; + points.Add(new NoiseRealtimePoint( + Timestamp: start + TimeSpan.FromSeconds(sample.OffsetSeconds), + Rms: 0.2, + Dbfs: -60 + i, + DisplayDb: sample.DisplayDb, + Peak: 0.3, + IsOverThreshold: sample.DisplayDb > 50)); + } + + return points; + } + + private static StudyAnalyticsSnapshot CreateSnapshot(string marker) + { + var config = new StudyAnalyticsConfig(); + var session = new StudySessionSnapshot( + State: StudySessionRuntimeState.Idle, + SessionId: null, + Label: string.Empty, + StartedAt: null, + EndedAt: null, + Elapsed: TimeSpan.Zero, + Metrics: new StudySessionMetrics( + CurrentScore: 0, + AvgScore: 0, + MinScore: 0, + MaxScore: 0, + WeightedOverRatioDbfs: 0, + TotalSegmentCount: 0, + EffectiveDuration: TimeSpan.Zero, + SliceCount: 0), + LastError: string.Empty); + + return new StudyAnalyticsSnapshot( + State: StudyAnalyticsRuntimeState.Ready, + StreamStatus: NoiseStreamStatus.Initializing, + DataMode: StudyDataMode.Realtime, + Config: config, + LatestRealtimePoint: null, + LatestSlice: null, + RealtimeBuffer: Array.Empty(), + Session: session, + LastSessionReport: null, + SelectedSessionReportId: null, + SessionHistory: Array.Empty(), + LastError: marker); + } +} diff --git a/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs b/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs new file mode 100644 index 0000000..6a2a34b --- /dev/null +++ b/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs @@ -0,0 +1,404 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using LanMountainDesktop; +using LanMountainDesktop.Launcher; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Update; +using LanMountainDesktop.Shared.Contracts.Update; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class UpdateEngineRollbackRegressionTests : IDisposable +{ + private readonly UpdateTestDirectory _directory = new(); + + [Fact] + public async Task ApplyPlondsUpdate_KeepsPreviousDeploymentForManualRollback() + { + var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true); + var newState = Encoding.UTF8.GetBytes("new-state"); + + _directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState)); + + var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var result = await service.ApplyPendingUpdateAsync(); + + Assert.True(result.Success, result.ErrorMessage); + Assert.True(Directory.Exists(current)); + Assert.False(File.Exists(Path.Combine(current, ".current"))); + + var rollback = service.RollbackLatest(); + + Assert.True(rollback.Success, rollback.ErrorMessage); + Assert.Equal("1.0.0", rollback.RolledBackTo); + Assert.True(File.Exists(Path.Combine(current, ".current"))); + Assert.False(File.Exists(Path.Combine(current, ".destroy"))); + Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt"))); + } + + [Fact] + public async Task ApplyPlondsUpdate_WhenObjectHashMismatches_RollsBackToPreviousDeployment() + { + var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true); + var newState = Encoding.UTF8.GetBytes("new-state"); + + _directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, new string('0', 64)); + + var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var result = await service.ApplyPendingUpdateAsync(); + + Assert.False(result.Success); + Assert.Equal("apply_failed", result.Code); + Assert.Equal("1.0.0", result.RolledBackTo); + Assert.True(File.Exists(Path.Combine(current, ".current"))); + Assert.False(File.Exists(Path.Combine(current, ".destroy"))); + Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt"))); + Assert.Empty(Directory.GetDirectories(_directory.AppRoot, "app-1.1.0-*")); + } + + [Fact] + public void RollbackLatest_WhenSnapshotSourceDirectoryIsMissing_ReturnsStructuredFailure() + { + _directory.CreateDeployment("1.1.0", "new-state", isCurrent: true); + _directory.WriteSnapshot( + sourceVersion: "1.0.0", + sourceDirectory: Path.Combine(_directory.AppRoot, "app-1.0.0-0"), + targetVersion: "1.1.0", + targetDirectory: Path.Combine(_directory.AppRoot, "app-1.1.0-0")); + + var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var result = service.RollbackLatest(); + + Assert.False(result.Success); + Assert.Equal("source_missing", result.Code); + Assert.Contains("app-1.0.0-0", result.ErrorMessage); + } + + public void Dispose() => _directory.Dispose(); + + private static string Sha256Hex(byte[] bytes) + { + return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + } + + private sealed class UpdateTestDirectory : IDisposable + { + private readonly string _root; + private readonly RSA _rsa = RSA.Create(2048); + + public UpdateTestDirectory() + { + _root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.UpdateRegression", Guid.NewGuid().ToString("N")); + AppRoot = Path.Combine(_root, "app-root"); + Directory.CreateDirectory(AppRoot); + + var resolver = new DataLocationResolver(AppRoot); + LauncherRoot = resolver.ResolveLauncherDataPath(); + IncomingRoot = Path.Combine(LauncherRoot, "update", "incoming"); + SnapshotsRoot = Path.Combine(LauncherRoot, "snapshots"); + + Directory.CreateDirectory(Path.Combine(LauncherRoot, "update")); + File.WriteAllText(Path.Combine(LauncherRoot, "update", "public-key.pem"), _rsa.ExportSubjectPublicKeyInfoPem()); + } + + public string AppRoot { get; } + + private string LauncherRoot { get; } + + private string IncomingRoot { get; } + + private string SnapshotsRoot { get; } + + public string CreateDeployment(string version, string state, bool isCurrent) + { + var deployment = Path.Combine(AppRoot, $"app-{version}-0"); + Directory.CreateDirectory(deployment); + File.WriteAllText(Path.Combine(deployment, ExecutableName), $"exe-{version}"); + File.WriteAllText(Path.Combine(deployment, "state.txt"), state); + + if (isCurrent) + { + File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty); + } + + return deployment; + } + + public void StagePlondsUpdate(string fromVersion, string toVersion, byte[] statePayload, string expectedStateSha256) + { + Directory.CreateDirectory(IncomingRoot); + var objectsRoot = Path.Combine(IncomingRoot, "objects"); + Directory.CreateDirectory(objectsRoot); + + var objectHash = Convert.ToHexString(SHA256.HashData(statePayload)).ToLowerInvariant(); + File.WriteAllBytes(Path.Combine(objectsRoot, objectHash), statePayload); + + var currentExecutable = Path.Combine(AppRoot, $"app-{fromVersion}-0", ExecutableName); + var fileMap = new PlondsFileMap + { + DistributionId = $"stable-{PlondsStaticUpdateService.ResolveCurrentPlatform()}-{toVersion}", + FromVersion = fromVersion, + ToVersion = toVersion, + Platform = PlondsStaticUpdateService.ResolveCurrentPlatform(), + Files = + [ + new PlondsFileEntry + { + Path = ExecutableName, + Action = "reuse", + Sha256 = Sha256File(currentExecutable) + }, + new PlondsFileEntry + { + Path = "state.txt", + Action = "replace", + Sha256 = expectedStateSha256, + ObjectUrl = $"https://static.example/lanmountain/update/repo/sha256/{objectHash[..2]}/{objectHash}" + } + ] + }; + + var fileMapPath = Path.Combine(IncomingRoot, "plonds-filemap.json"); + File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.PlondsFileMap)); + Sign(fileMapPath, Path.Combine(IncomingRoot, "plonds-filemap.sig")); + } + + public void WriteSnapshot(string sourceVersion, string sourceDirectory, string targetVersion, string targetDirectory) + { + Directory.CreateDirectory(SnapshotsRoot); + var snapshot = new SnapshotMetadata + { + SnapshotId = Guid.NewGuid().ToString("N"), + SourceVersion = sourceVersion, + TargetVersion = targetVersion, + CreatedAt = DateTimeOffset.UtcNow, + SourceDirectory = sourceDirectory, + TargetDirectory = targetDirectory, + Status = "applied" + }; + + File.WriteAllText( + Path.Combine(SnapshotsRoot, $"{snapshot.SnapshotId}.json"), + JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata)); + } + + public void Dispose() + { + _rsa.Dispose(); + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private void Sign(string payloadPath, string signaturePath) + { + var signature = _rsa.SignData(File.ReadAllBytes(payloadPath), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + File.WriteAllText(signaturePath, Convert.ToBase64String(signature)); + } + + private static string Sha256File(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + + private static string ExecutableName => OperatingSystem.IsWindows() + ? "LanMountainDesktop.exe" + : "LanMountainDesktop"; + } +} + +public sealed class PlondsStaticUpdateServiceTests +{ + [Fact] + public async Task CheckForUpdatesAsync_ReadsStaticLatestDistributionAndBuildsPayloadUrls() + { + var platform = PlondsStaticUpdateService.ResolveCurrentPlatform(); + var handler = new StaticManifestHandler(request => + { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + if (path.EndsWith($"/meta/channels/stable/{platform}/latest.json", StringComparison.Ordinal)) + { + return Json("""{"distributionId":"dist-1","version":"1.2.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z"}""" + .Replace("PLATFORM", platform)); + } + + if (path.EndsWith("/meta/distributions/dist-1.json", StringComparison.Ordinal)) + { + return Json("""{"distributionId":"dist-1","version":"1.2.0","sourceVersion":"1.0.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z","fileMapUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json","fileMapSignatureUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig"}""" + .Replace("PLATFORM", platform)); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + using var client = new HttpClient(handler); + using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client); + + var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false); + + Assert.True(result.Success, result.ErrorMessage); + Assert.True(result.IsUpdateAvailable); + Assert.Equal("1.2.0", result.LatestVersionText); + Assert.NotNull(result.PlondsPayload); + Assert.Equal("dist-1", result.PlondsPayload.DistributionId); + Assert.Equal(platform, result.PlondsPayload.SubChannel); + Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json", result.PlondsPayload.FileMapJsonUrl); + Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig", result.PlondsPayload.FileMapSignatureUrl); + } + + [Fact] + public async Task CheckForUpdatesAsync_WhenLatestIsMissing_ReturnsFailureForFallback() + { + using var client = new HttpClient(new StaticManifestHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))); + using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client); + + var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false); + + Assert.False(result.Success); + Assert.False(result.IsUpdateAvailable); + Assert.Contains("latest manifest", result.ErrorMessage); + } + + [Fact] + public void ResolveCurrentPlatform_UsesCanonicalNames() + { + var platform = PlondsStaticUpdateService.ResolveCurrentPlatform(); + + Assert.DoesNotContain("win-", platform, StringComparison.OrdinalIgnoreCase); + if (OperatingSystem.IsWindows()) + { + Assert.StartsWith("windows-", platform, StringComparison.Ordinal); + } + else if (OperatingSystem.IsLinux()) + { + Assert.StartsWith("linux-", platform, StringComparison.Ordinal); + } + } + + private static HttpResponseMessage Json(string json) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + + private sealed class StaticManifestHandler(Func responder) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(responder(request)); + } + } +} + +public sealed class UpdatePathConsistencyTests +{ + [Fact] + public void HostAndSharedUpdatePathsUseLauncherDirectoryCasing() + { + var incoming = UpdateWorkflowService.GetLauncherIncomingDirectory(); + var sharedIncoming = UpdatePaths.GetIncomingDirectory("root"); + + Assert.Contains($"{Path.DirectorySeparatorChar}.Launcher{Path.DirectorySeparatorChar}", incoming); + Assert.Equal( + Path.Combine("root", ".Launcher", "update", "incoming"), + sharedIncoming); + } +} + +public sealed class PlondsApiManifestProviderTests +{ + [Fact] + public async Task GetLatestAsync_MapsCanonicalAndLegacyFileFields() + { + using var client = new HttpClient(new StaticManifestHandler(request => + { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + if (path.EndsWith("/api/plonds/v1/channels/stable/windows-x64/latest", StringComparison.Ordinal)) + { + return Json("""{"distributionId":"dist-2","version":"1.2.0","publishedAt":"2026-05-06T00:00:00Z"}"""); + } + + if (path.EndsWith("/api/plonds/v1/distributions/dist-2", StringComparison.Ordinal)) + { + return Json(""" + { + "distributionId": "dist-2", + "version": "1.2.0", + "sourceVersion": "1.1.0", + "publishedAt": "2026-05-06T00:00:00Z", + "fileMapUrl": "https://static.example/filemap.json", + "signatures": [{ "signature": "https://static.example/filemap.json.sig" }], + "components": [ + { + "files": [ + { + "path": "LanMountainDesktop.exe", + "action": "replace", + "sha256": "abc123", + "size": 42, + "objectUrl": "https://static.example/repo/sha256/ab/abc123", + "archiveSha256": "archive123" + }, + { + "path": "legacy.dll", + "op": "add", + "contentHash": "def456", + "size": 7 + } + ] + } + ] + } + """); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + })); + var provider = new PlondsApiManifestProvider("https://static.example", client); + + var manifest = await provider.GetLatestAsync("stable", "windows-x64", new Version(1, 1, 0), CancellationToken.None); + + Assert.NotNull(manifest); + Assert.Equal(UpdatePayloadKind.DeltaPlonds, manifest.Kind); + Assert.Equal("https://static.example/filemap.json.sig", manifest.FileMapSignatureUrl); + Assert.Collection( + manifest.Files, + first => + { + Assert.Equal("replace", first.Action); + Assert.Equal("abc123", first.Sha256); + Assert.Equal("https://static.example/repo/sha256/ab/abc123", first.ObjectUrl); + Assert.Equal("archive123", first.ArchiveSha256); + }, + second => + { + Assert.Equal("add", second.Action); + Assert.Equal("def456", second.Sha256); + }); + } + + private static HttpResponseMessage Json(string json) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + + private sealed class StaticManifestHandler(Func responder) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(responder(request)); + } + } +} diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index fc6537c..429b91d 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -1074,7 +1074,10 @@ public partial class App : Application (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) || changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))); + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.SystemWallpaperRefreshIntervalSeconds), StringComparer.OrdinalIgnoreCase))); var languageChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase); @@ -1881,4 +1884,3 @@ public partial class App : Application .ToArray(); } } - diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index be07ab9..026929e 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -27,6 +27,10 @@ public sealed class AppSettingsSnapshot public string? SelectedWallpaperSeed { get; set; } + public string ThemeWallpaperColorSource { get; set; } = "auto"; + + public bool UseNativeWallpaperChangeEvents { get; set; } = true; + public string ThemeMode { get; set; } = "light"; public string? WallpaperPath { get; set; } diff --git a/LanMountainDesktop/Models/MaterialColorModels.cs b/LanMountainDesktop/Models/MaterialColorModels.cs new file mode 100644 index 0000000..5cfeefb --- /dev/null +++ b/LanMountainDesktop/Models/MaterialColorModels.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using Avalonia.Media; +using LanMountainDesktop.Services; +using LanMountainDesktop.Shared.Contracts; + +namespace LanMountainDesktop.Models; + +public enum MaterialColorSourceKind +{ + Neutral = 0, + CustomSeed = 1, + WallpaperAuto = 2, + AppWallpaper = 3, + SystemWallpaper = 4, + Fallback = 5 +} + +public sealed record MaterialColorPalette( + Color Primary, + Color Secondary, + Color Accent, + Color OnAccent, + Color AccentLight1, + Color AccentLight2, + Color AccentLight3, + Color AccentDark1, + Color AccentDark2, + Color AccentDark3, + Color SurfaceBase, + Color SurfaceRaised, + Color SurfaceOverlay, + Color TextPrimary, + Color TextSecondary, + Color TextMuted, + Color TextAccent, + Color NavText, + Color NavSelectedText, + Color NavSelectionIndicator, + Color NavItemBackground, + Color NavItemHoverBackground, + Color NavItemSelectedBackground, + Color ToggleOn, + Color ToggleOff, + Color ToggleBorder); + +public sealed record MaterialSurfaceSnapshot( + MaterialSurfaceRole Role, + Color BackgroundColor, + Color BorderColor, + double BlurRadius, + double Opacity); + +public sealed record MaterialColorSnapshot( + bool IsNightMode, + string ThemeColorMode, + string ThemeWallpaperColorSource, + MaterialColorSourceKind ColorSourceKind, + string ResolvedSeedSource, + AppearanceCornerRadiusTokens CornerRadiusTokens, + string? UserThemeColor, + string? SelectedWallpaperSeed, + Color EffectiveSeedColor, + Color AccentColor, + MonetPalette MonetPalette, + MaterialColorPalette Palette, + IReadOnlyList WallpaperSeedCandidates, + string SystemMaterialMode, + IReadOnlyList AvailableSystemMaterialModes, + bool CanChangeSystemMaterial, + bool UseSystemChrome, + string? ResolvedWallpaperPath, + bool UseNativeWallpaperChangeEvents, + bool NativeWallpaperChangeEventsActive, + bool WallpaperPollingActive, + IReadOnlyDictionary Surfaces); diff --git a/LanMountainDesktop/Services/AppearanceThemeService.cs b/LanMountainDesktop/Services/AppearanceThemeService.cs index aac58db..96f5fd7 100644 --- a/LanMountainDesktop/Services/AppearanceThemeService.cs +++ b/LanMountainDesktop/Services/AppearanceThemeService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using System.Runtime.Versioning; using System.Threading; using System.Threading.Tasks; using Avalonia; @@ -55,7 +56,9 @@ public sealed record AppearanceThemeSnapshot( IReadOnlyList AvailableSystemMaterialModes, bool CanChangeSystemMaterial, bool UseSystemChrome, - string? ResolvedWallpaperPath); + string? ResolvedWallpaperPath, + string ThemeWallpaperColorSource = ThemeAppearanceValues.WallpaperColorSourceAuto, + bool UseNativeWallpaperChangeEvents = true); public interface IAppearanceThemeService { @@ -465,7 +468,7 @@ internal sealed class MaterialSurfaceService : IMaterialSurfaceService } } -internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposable +internal sealed class AppearanceThemeService : IAppearanceThemeService, IMaterialColorService, IDisposable { private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6"); private static readonly Color NeutralFallbackSeedColor = Color.Parse("#FF8A8A8A"); @@ -477,9 +480,15 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa private string _liveThemeColorMode; private string _liveSystemMaterialMode; private string? _liveSelectedWallpaperSeed; + private string _liveThemeWallpaperColorSource; + private bool _liveUseNativeWallpaperChangeEvents; private readonly object _paletteGate = new(); private readonly Dictionary _wallpaperSeedCache = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _pendingWallpaperSeedKeys = new(StringComparer.OrdinalIgnoreCase); + private Timer? _systemWallpaperPollTimer; + private string? _lastObservedWallpaperSourceKey; + private bool _nativeWallpaperEventsActive; + private bool _wallpaperPollingActive; public AppearanceThemeService( ISettingsFacadeService settingsFacade, @@ -497,11 +506,16 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa initialThemeState.ThemeColor); _liveSystemMaterialMode = ResolveSupportedMaterialMode(initialThemeState.SystemMaterialMode); _liveSelectedWallpaperSeed = initialThemeState.SelectedWallpaperSeed; + _liveThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(initialThemeState.ThemeWallpaperColorSource); + _liveUseNativeWallpaperChangeEvents = initialThemeState.UseNativeWallpaperChangeEvents; _settingsFacade.Settings.Changed += OnSettingsChanged; + ConfigureSystemWallpaperMonitoring(initialThemeState); } public event EventHandler? Changed; + public event EventHandler? MaterialColorChanged; + public AppearanceThemeSnapshot GetCurrent() { return BuildCurrentSnapshot(queueWallpaperPaletteBuild: true); @@ -527,6 +541,39 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa queueWallpaperPaletteBuild: true); } + public MaterialColorSnapshot GetMaterialColorSnapshot() + { + return CreateMaterialColorSnapshot(GetCurrent()); + } + + public MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState) + { + return CreateMaterialColorSnapshot(BuildPreview(pendingState)); + } + + public MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role) + { + var surface = GetMaterialSurface(role); + return new MaterialSurfaceSnapshot( + role, + surface.BackgroundColor, + surface.BorderColor, + surface.BlurRadius, + surface.Opacity); + } + + public void RefreshWallpaperColors() + { + lock (_paletteGate) + { + _wallpaperSeedCache.Clear(); + _pendingWallpaperSeedKeys.Clear(); + _lastObservedWallpaperSourceKey = null; + } + + RaiseChanged(queueWallpaperPaletteBuild: true); + } + public void ApplyThemeResources(IResourceDictionary resources) { ArgumentNullException.ThrowIfNull(resources); @@ -582,6 +629,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa public void Dispose() { _settingsFacade.Settings.Changed -= OnSettingsChanged; + StopSystemWallpaperMonitoring(); + _systemWallpaperPollTimer?.Dispose(); + _systemWallpaperPollTimer = null; } private AppearanceThemeSnapshot BuildCurrentSnapshot(bool queueWallpaperPaletteBuild) @@ -622,6 +672,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa !changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColorMode), StringComparer.OrdinalIgnoreCase) && !changedKeys.Contains(nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparer.OrdinalIgnoreCase) && !changedKeys.Contains(nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.SystemWallpaperRefreshIntervalSeconds), StringComparer.OrdinalIgnoreCase) && !(respondsToThemeColor && changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) && !(respondsToWallpaper && @@ -638,6 +691,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa latestThemeState.ThemeColor); _liveSystemMaterialMode = ResolveSupportedMaterialMode(latestThemeState.SystemMaterialMode); _liveSelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed; + _liveThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(latestThemeState.ThemeWallpaperColorSource); + _liveUseNativeWallpaperChangeEvents = latestThemeState.UseNativeWallpaperChangeEvents; + ConfigureSystemWallpaperMonitoring(latestThemeState); RaiseChanged(queueWallpaperPaletteBuild: true); } @@ -663,6 +719,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa var wallpaperResolution = ResolveWallpaperPalette( themeState.IsNightMode, wallpaperState, + ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource), selectedWallpaperSeed, queueWallpaperPaletteBuild); palette = wallpaperResolution.Palette; @@ -701,7 +758,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa availableModes, _windowMaterialService.CanChangeMode, themeState.UseSystemChrome, - resolvedWallpaperPath); + resolvedWallpaperPath, + ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource), + themeState.UseNativeWallpaperChangeEvents); } private ThemeColorContext CreateThemeContext(AppearanceThemeSnapshot snapshot) @@ -729,10 +788,11 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa private WallpaperPaletteResolution ResolveWallpaperPalette( bool nightMode, WallpaperSettingsState wallpaperState, + string wallpaperColorSource, string? selectedWallpaperSeed, bool queueWallpaperPaletteBuild) { - var source = ResolveWallpaperSeedSource(wallpaperState); + var source = ResolveWallpaperSeedSource(wallpaperState, wallpaperColorSource); if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase)) { return BuildFallbackWallpaperPaletteResolution(nightMode, source.ResolvedWallpaperPath); @@ -935,9 +995,14 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa } } - private WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource(WallpaperSettingsState wallpaperState) + private WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource( + WallpaperSettingsState wallpaperState, + string wallpaperColorSource) { - if (string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) && + var normalizedWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(wallpaperColorSource); + + if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceSystem && + string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(wallpaperState.Color) && Color.TryParse(wallpaperState.Color, out var solidColor)) { @@ -954,7 +1019,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa ? null : wallpaperState.WallpaperPath.Trim(); var appWallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperPath); - if (!string.IsNullOrWhiteSpace(wallpaperPath) && File.Exists(wallpaperPath)) + if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceSystem && + !string.IsNullOrWhiteSpace(wallpaperPath) && + File.Exists(wallpaperPath)) { if (appWallpaperMediaType == WallpaperMediaType.Image) { @@ -967,8 +1034,19 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa } } + if (normalizedWallpaperColorSource == ThemeAppearanceValues.WallpaperColorSourceApp) + { + return new WallpaperSeedSourceDescriptor( + "fallback", + "fallback", + null, + null, + null); + } + var systemWallpaper = _systemWallpaperService.GetWallpaperPath(); - if (!string.IsNullOrWhiteSpace(systemWallpaper) && + if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceApp && + !string.IsNullOrWhiteSpace(systemWallpaper) && File.Exists(systemWallpaper) && _settingsFacade.WallpaperMedia.DetectMediaType(systemWallpaper) == WallpaperMediaType.Image) { @@ -991,13 +1069,269 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa private void RaiseChanged(bool queueWallpaperPaletteBuild) { var snapshot = BuildCurrentSnapshot(queueWallpaperPaletteBuild); + var materialSnapshot = CreateMaterialColorSnapshot(snapshot); if (Dispatcher.UIThread.CheckAccess()) { Changed?.Invoke(this, snapshot); + MaterialColorChanged?.Invoke(this, materialSnapshot); return; } - Dispatcher.UIThread.Post(() => Changed?.Invoke(this, snapshot), DispatcherPriority.Background); + Dispatcher.UIThread.Post(() => + { + Changed?.Invoke(this, snapshot); + MaterialColorChanged?.Invoke(this, materialSnapshot); + }, DispatcherPriority.Background); + } + + private MaterialColorSnapshot CreateMaterialColorSnapshot(AppearanceThemeSnapshot snapshot) + { + var context = CreateThemeContext(snapshot); + var appPalette = ThemeColorSystemService.BuildPalette(context); + var palette = new LanMountainDesktop.Models.MaterialColorPalette( + appPalette.Primary, + appPalette.Secondary, + appPalette.Accent, + appPalette.OnAccent, + appPalette.AccentLight1, + appPalette.AccentLight2, + appPalette.AccentLight3, + appPalette.AccentDark1, + appPalette.AccentDark2, + appPalette.AccentDark3, + appPalette.SurfaceBase, + appPalette.SurfaceRaised, + appPalette.SurfaceOverlay, + appPalette.TextPrimary, + appPalette.TextSecondary, + appPalette.TextMuted, + appPalette.TextAccent, + appPalette.NavText, + appPalette.NavSelectedText, + appPalette.NavSelectionIndicator, + appPalette.NavItemBackground, + appPalette.NavItemHoverBackground, + appPalette.NavItemSelectedBackground, + appPalette.ToggleOn, + appPalette.ToggleOff, + appPalette.ToggleBorder); + var surfaces = Enum.GetValues() + .Select(role => + { + var surface = _materialSurfaceService.GetSurface(context, role); + return new MaterialSurfaceSnapshot( + role, + surface.BackgroundColor, + surface.BorderColor, + surface.BlurRadius, + surface.Opacity); + }) + .ToDictionary(surface => surface.Role); + + return new MaterialColorSnapshot( + snapshot.IsNightMode, + snapshot.ThemeColorMode, + snapshot.ThemeWallpaperColorSource, + ResolveMaterialColorSourceKind(snapshot), + snapshot.ResolvedSeedSource, + snapshot.CornerRadiusTokens, + snapshot.UserThemeColor, + snapshot.SelectedWallpaperSeed, + snapshot.EffectiveSeedColor, + snapshot.AccentColor, + snapshot.MonetPalette, + palette, + snapshot.WallpaperSeedCandidates, + snapshot.SystemMaterialMode, + snapshot.AvailableSystemMaterialModes, + snapshot.CanChangeSystemMaterial, + snapshot.UseSystemChrome, + snapshot.ResolvedWallpaperPath, + snapshot.UseNativeWallpaperChangeEvents, + _nativeWallpaperEventsActive, + _wallpaperPollingActive, + surfaces); + } + + private static MaterialColorSourceKind ResolveMaterialColorSourceKind(AppearanceThemeSnapshot snapshot) + { + if (string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase)) + { + return MaterialColorSourceKind.Neutral; + } + + if (string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase)) + { + return MaterialColorSourceKind.CustomSeed; + } + + if (!string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase)) + { + return MaterialColorSourceKind.Fallback; + } + + if (string.Equals(snapshot.ResolvedSeedSource, "app_wallpaper", StringComparison.OrdinalIgnoreCase) || + string.Equals(snapshot.ResolvedSeedSource, "app_solid", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(snapshot.ThemeWallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase) + ? MaterialColorSourceKind.AppWallpaper + : MaterialColorSourceKind.WallpaperAuto; + } + + if (string.Equals(snapshot.ResolvedSeedSource, "system_wallpaper", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(snapshot.ThemeWallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceSystem, StringComparison.OrdinalIgnoreCase) + ? MaterialColorSourceKind.SystemWallpaper + : MaterialColorSourceKind.WallpaperAuto; + } + + return MaterialColorSourceKind.Fallback; + } + + private void ConfigureSystemWallpaperMonitoring(ThemeAppearanceSettingsState themeState) + { + var colorMode = ThemeAppearanceValues.NormalizeThemeColorMode(themeState.ThemeColorMode, themeState.ThemeColor); + var wallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource); + var shouldMonitor = + string.Equals(colorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && + !string.Equals(wallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase); + + if (!shouldMonitor) + { + StopSystemWallpaperMonitoring(); + return; + } + + ConfigureNativeWallpaperEvents(themeState.UseNativeWallpaperChangeEvents); + ConfigureWallpaperPolling(_settingsFacade.Wallpaper.Get().SystemWallpaperRefreshIntervalSeconds); + UpdateObservedWallpaperSourceKey(); + } + + private void ConfigureNativeWallpaperEvents(bool enabled) + { + if (!enabled || !OperatingSystem.IsWindows()) + { + UnregisterNativeWallpaperEvents(); + return; + } + + if (_nativeWallpaperEventsActive) + { + return; + } + + RegisterNativeWallpaperEvents(); + } + + private void UnregisterNativeWallpaperEvents() + { + if (!_nativeWallpaperEventsActive) + { + return; + } + + if (OperatingSystem.IsWindows()) + { + UnregisterNativeWallpaperEventsCore(); + } + + _nativeWallpaperEventsActive = false; + } + + [SupportedOSPlatform("windows")] + private void RegisterNativeWallpaperEvents() + { + try + { + SystemEvents.UserPreferenceChanged += OnNativeWallpaperPreferenceChanged; + _nativeWallpaperEventsActive = true; + } + catch (Exception ex) + { + _nativeWallpaperEventsActive = false; + AppLogger.Warn("Appearance.WallpaperMonitor", "Failed to subscribe to native wallpaper change events; polling will remain active.", ex); + } + } + + [SupportedOSPlatform("windows")] + private void UnregisterNativeWallpaperEventsCore() + { + try + { + SystemEvents.UserPreferenceChanged -= OnNativeWallpaperPreferenceChanged; + } + catch + { + // Ignore shutdown-time native event cleanup failures. + } + } + + private void ConfigureWallpaperPolling(int intervalSeconds) + { + var normalizedInterval = Math.Clamp(intervalSeconds <= 0 ? 300 : intervalSeconds, 30, 86400); + var interval = TimeSpan.FromSeconds(normalizedInterval); + _systemWallpaperPollTimer ??= new Timer(OnSystemWallpaperPollTimer); + _systemWallpaperPollTimer.Change(interval, interval); + _wallpaperPollingActive = true; + } + + private void StopSystemWallpaperMonitoring() + { + UnregisterNativeWallpaperEvents(); + _systemWallpaperPollTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _wallpaperPollingActive = false; + _lastObservedWallpaperSourceKey = null; + } + + private void OnNativeWallpaperPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) + { + _ = sender; + + if (!OperatingSystem.IsWindows()) + { + return; + } + + if (e.Category is UserPreferenceCategory.Desktop or UserPreferenceCategory.General) + { + RefreshWallpaperColors(); + } + } + + private void OnSystemWallpaperPollTimer(object? state) + { + _ = state; + + try + { + var source = ResolveWallpaperSeedSource(_settingsFacade.Wallpaper.Get(), _liveThemeWallpaperColorSource); + var sourceKey = source.SourceKey; + if (string.Equals(_lastObservedWallpaperSourceKey, sourceKey, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _lastObservedWallpaperSourceKey = sourceKey; + RefreshWallpaperColors(); + } + catch (Exception ex) + { + AppLogger.Warn("Appearance.WallpaperMonitor", "Failed to poll wallpaper color source.", ex); + } + } + + private void UpdateObservedWallpaperSourceKey() + { + try + { + _lastObservedWallpaperSourceKey = ResolveWallpaperSeedSource( + _settingsFacade.Wallpaper.Get(), + _liveThemeWallpaperColorSource).SourceKey; + } + catch + { + _lastObservedWallpaperSourceKey = null; + } } private static Color? ResolveSelectedWallpaperSeed( @@ -1071,3 +1405,11 @@ internal static class HostAppearanceThemeProvider } } } + +internal static class HostMaterialColorProvider +{ + public static IMaterialColorService GetOrCreate() + { + return (IMaterialColorService)HostAppearanceThemeProvider.GetOrCreate(); + } +} diff --git a/LanMountainDesktop/Services/ComponentEditorWindowService.cs b/LanMountainDesktop/Services/ComponentEditorWindowService.cs index 2b51d45..518f62d 100644 --- a/LanMountainDesktop/Services/ComponentEditorWindowService.cs +++ b/LanMountainDesktop/Services/ComponentEditorWindowService.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using Avalonia.Controls; using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Models; @@ -31,16 +30,15 @@ public interface IComponentEditorWindowService internal sealed class ComponentEditorWindowService : IComponentEditorWindowService { private readonly ISettingsFacadeService _settingsFacade; - private readonly IAppearanceThemeService _appearanceThemeService; + private readonly IMaterialColorService _materialColorService; private ComponentEditorWindow? _window; private string? _currentPlacementId; public ComponentEditorWindowService(ISettingsFacadeService settingsFacade) { _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); - _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate(); - _settingsFacade.Settings.Changed += OnSettingsChanged; - _appearanceThemeService.Changed += OnAppearanceThemeChanged; + _materialColorService = HostMaterialColorProvider.GetOrCreate(); + _materialColorService.MaterialColorChanged += OnMaterialColorChanged; } public bool IsOpen => _window is { IsVisible: true }; @@ -100,60 +98,29 @@ internal sealed class ComponentEditorWindowService : IComponentEditorWindowServi return window; } - private void OnSettingsChanged(object? sender, SettingsChangedEvent e) - { - if (_window is null || e.Scope != SettingsScope.App) - { - return; - } - - var changedKeys = e.ChangedKeys?.ToArray() ?? []; - var liveAppearance = _appearanceThemeService.GetCurrent(); - if (changedKeys.Length > 0 && - !changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) && - !(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) && - changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) && - !(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && - (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase)) - { - return; - } - - ApplyTheme(_window); - } - private void ApplyTheme(ComponentEditorWindow window) { - var appearanceSnapshot = _appearanceThemeService.GetCurrent(); - var themeState = _settingsFacade.Theme.Get(); - var wallpaperState = _settingsFacade.Wallpaper.Get(); - var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType( - appearanceSnapshot.ResolvedWallpaperPath ?? wallpaperState.WallpaperPath); - var palette = ComponentEditorMaterialThemeAdapter.Build( - themeState, - wallpaperState, - appearanceSnapshot.MonetPalette, - wallpaperMediaType); + var snapshot = _materialColorService.GetMaterialColorSnapshot(); + var palette = ComponentEditorMaterialThemeAdapter.Build(snapshot); window.ApplyTheme(palette); - window.ApplyChromeMode(themeState.UseSystemChrome); - _appearanceThemeService.ApplyWindowMaterial(window, MaterialSurfaceRole.WindowBackground); + window.ApplyChromeMode(snapshot.UseSystemChrome); + _materialColorService.ApplyWindowMaterial(window, MaterialSurfaceRole.WindowBackground); } - private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e) + private void OnMaterialColorChanged(object? sender, MaterialColorSnapshot snapshot) { _ = sender; - _ = e; if (_window is null) { return; } - ApplyTheme(_window); + var palette = ComponentEditorMaterialThemeAdapter.Build(snapshot); + _window.ApplyTheme(palette); + _window.ApplyChromeMode(snapshot.UseSystemChrome); + _materialColorService.ApplyWindowMaterial(_window, MaterialSurfaceRole.WindowBackground); } private sealed class HostContext : IComponentEditorHostContext diff --git a/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs index 7c76f7a..047df80 100644 --- a/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs +++ b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs @@ -127,10 +127,9 @@ public static class DesktopComponentRegistryFactory var pluginSettings = new PluginScopedSettingsService( contribution.Plugin.Manifest.Id, settingsService); - var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent(); - var pluginAppearance = new PluginAppearanceContext(new PluginAppearanceSnapshot( - CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(appearanceSnapshot.CornerRadiusTokens), - ThemeVariant: appearanceSnapshot.IsNightMode ? "Dark" : "Light")); + var pluginAppearance = new PluginAppearanceContext( + PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot( + HostMaterialColorProvider.GetOrCreate().GetMaterialColorSnapshot())); var pluginContext = new PluginDesktopComponentContext( contribution.Plugin.Manifest, contribution.Plugin.Context.PluginDirectory, diff --git a/LanMountainDesktop/Services/IMaterialColorService.cs b/LanMountainDesktop/Services/IMaterialColorService.cs new file mode 100644 index 0000000..03711d7 --- /dev/null +++ b/LanMountainDesktop/Services/IMaterialColorService.cs @@ -0,0 +1,23 @@ +using System; +using Avalonia.Controls; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services.Settings; + +namespace LanMountainDesktop.Services; + +public interface IMaterialColorService +{ + MaterialColorSnapshot GetMaterialColorSnapshot(); + + MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState); + + event EventHandler? MaterialColorChanged; + + void ApplyThemeResources(IResourceDictionary resources); + + MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role); + + void ApplyWindowMaterial(Window window, MaterialSurfaceRole role); + + void RefreshWallpaperColors(); +} diff --git a/LanMountainDesktop/Services/PlondsStaticUpdateService.cs b/LanMountainDesktop/Services/PlondsStaticUpdateService.cs new file mode 100644 index 0000000..9af5ce9 --- /dev/null +++ b/LanMountainDesktop/Services/PlondsStaticUpdateService.cs @@ -0,0 +1,278 @@ +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LanMountainDesktop.Services; + +internal sealed class PlondsStaticUpdateService : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private readonly string _baseUrl; + + public PlondsStaticUpdateService(string? baseUrl = null, HttpClient? httpClient = null) + { + _baseUrl = NormalizeBaseUrl(baseUrl ?? ResolveConfiguredBaseUrl()); + if (httpClient is null) + { + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + _ownsHttpClient = true; + } + else + { + _httpClient = httpClient; + _ownsHttpClient = false; + } + + if (!_httpClient.DefaultRequestHeaders.UserAgent.Any()) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0"); + } + } + + public Task CheckForUpdatesAsync( + Version currentVersion, + bool includePrerelease, + CancellationToken cancellationToken = default) + { + return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken); + } + + public Task ForceCheckForUpdatesAsync( + Version currentVersion, + bool includePrerelease, + CancellationToken cancellationToken = default) + { + return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken); + } + + public void Dispose() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + internal static string ResolveCurrentPlatform() + { + var os = OperatingSystem.IsWindows() + ? "windows" + : OperatingSystem.IsLinux() + ? "linux" + : OperatingSystem.IsMacOS() + ? "macos" + : "unknown"; + + var arch = RuntimeInformation.OSArchitecture switch + { + Architecture.X86 => "x86", + Architecture.Arm => "arm", + Architecture.Arm64 => "arm64", + _ => "x64" + }; + + return $"{os}-{arch}"; + } + + private async Task CheckForUpdatesCoreAsync( + Version currentVersion, + bool includePrerelease, + bool isForce, + CancellationToken cancellationToken) + { + var currentVersionText = FormatVersion(currentVersion); + var channel = includePrerelease ? UpdateSettingsValues.ChannelPreview : UpdateSettingsValues.ChannelStable; + var platform = ResolveCurrentPlatform(); + + try + { + var latestUrl = BuildUrl($"meta/channels/{Uri.EscapeDataString(channel)}/{Uri.EscapeDataString(platform)}/latest.json"); + var latest = await GetJsonAsync(latestUrl, cancellationToken); + if (latest is null || string.IsNullOrWhiteSpace(latest.DistributionId)) + { + return Failed(currentVersionText, isForce, $"PLONDS static latest manifest is unavailable at {latestUrl}."); + } + + var distributionUrl = BuildUrl($"meta/distributions/{Uri.EscapeDataString(latest.DistributionId)}.json"); + var distribution = await GetJsonAsync(distributionUrl, cancellationToken); + if (distribution is null) + { + return Failed(currentVersionText, isForce, $"PLONDS static distribution manifest is unavailable at {distributionUrl}."); + } + + var latestVersionText = FirstNonEmpty(distribution.Version, latest.Version) ?? "-"; + var isNewer = TryParseVersion(latestVersionText, out var latestVersion) && latestVersion > currentVersion; + var isUpdateAvailable = isForce || isNewer; + var payload = isUpdateAvailable + ? CreatePayload(distribution, latest, channel, platform) + : null; + + return new UpdateCheckResult( + Success: true, + IsUpdateAvailable: isUpdateAvailable, + CurrentVersionText: currentVersionText, + LatestVersionText: latestVersionText, + Release: null, + PreferredAsset: null, + ErrorMessage: null, + ForceMode: isForce, + PlondsPayload: payload); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return Failed(currentVersionText, isForce, ex.Message); + } + } + + private PlondsUpdatePayload CreatePayload( + DistributionDto distribution, + LatestPointerDto latest, + string channel, + string platform) + { + var distributionId = FirstNonEmpty(distribution.DistributionId, latest.DistributionId) ?? string.Empty; + var fileMapUrl = FirstNonEmpty(distribution.FileMapUrl, BuildUrl($"manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json")); + var signatureUrl = FirstNonEmpty(distribution.FileMapSignatureUrl, fileMapUrl + ".sig"); + + return new PlondsUpdatePayload( + DistributionId: distributionId, + ChannelId: FirstNonEmpty(distribution.Channel, latest.Channel, channel) ?? channel, + SubChannel: FirstNonEmpty(distribution.Platform, latest.Platform, platform) ?? platform, + FileMapJson: null, + FileMapSignature: null, + FileMapJsonUrl: fileMapUrl, + FileMapSignatureUrl: signatureUrl); + } + + private async Task GetJsonAsync(string url, CancellationToken cancellationToken) + { + using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return default; + } + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException($"HTTP {(int)response.StatusCode} from {url}: {Truncate(body, 256)}"); + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken); + } + + private static UpdateCheckResult Failed(string currentVersionText, bool isForce, string message) + { + return new UpdateCheckResult( + Success: false, + IsUpdateAvailable: false, + CurrentVersionText: currentVersionText, + LatestVersionText: "-", + Release: null, + PreferredAsset: null, + ErrorMessage: message, + ForceMode: isForce); + } + + private string BuildUrl(string relativePath) + { + return $"{_baseUrl}/{relativePath.TrimStart('/')}"; + } + + private static string ResolveConfiguredBaseUrl() + { + var environmentValue = Environment.GetEnvironmentVariable(UpdateSettingsValues.PlondsStaticBaseUrlEnvironmentVariable); + return string.IsNullOrWhiteSpace(environmentValue) + ? UpdateSettingsValues.DefaultPlondsStaticBaseUrl + : environmentValue; + } + + private static string NormalizeBaseUrl(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return UpdateSettingsValues.DefaultPlondsStaticBaseUrl; + } + + return value.Trim().TrimEnd('/'); + } + + private static bool TryParseVersion(string? value, out Version version) + { + version = new Version(0, 0, 0); + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + if (!Version.TryParse(value.Trim().TrimStart('v', 'V'), out var parsed)) + { + return false; + } + + version = parsed; + return true; + } + + private static string FormatVersion(Version version) + { + if (version.Revision >= 0) + { + return version.ToString(); + } + + return version.Build >= 0 + ? $"{version.Major}.{version.Minor}.{version.Build}" + : $"{version.Major}.{version.Minor}"; + } + + private static string? FirstNonEmpty(params string?[] values) + { + return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim(); + } + + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) + { + return value; + } + + return value[..maxLength]; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + private sealed record LatestPointerDto( + string? DistributionId, + string? Version, + string? Channel, + string? Platform, + DateTimeOffset PublishedAt); + + private sealed record DistributionDto( + string? DistributionId, + string? Version, + string? SourceVersion, + string? Channel, + string? Platform, + DateTimeOffset PublishedAt, + string? FileMapUrl, + string? FileMapSignatureUrl); +} diff --git a/LanMountainDesktop/Services/PluginAppearanceSnapshotMapper.cs b/LanMountainDesktop/Services/PluginAppearanceSnapshotMapper.cs new file mode 100644 index 0000000..d19397b --- /dev/null +++ b/LanMountainDesktop/Services/PluginAppearanceSnapshotMapper.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Media; +using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Services; + +internal static class PluginAppearanceSnapshotMapper +{ + public static PluginAppearanceSnapshot FromMaterialColorSnapshot(MaterialColorSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + return new PluginAppearanceSnapshot( + PluginCornerRadiusTokens.FromShared(snapshot.CornerRadiusTokens), + snapshot.IsNightMode ? "Dark" : "Light", + ToText(snapshot.AccentColor), + ToText(snapshot.EffectiveSeedColor), + snapshot.ColorSourceKind.ToString(), + snapshot.SystemMaterialMode, + BuildColorRoles(snapshot), + snapshot.Surfaces.ToDictionary( + pair => pair.Key.ToString(), + pair => new PluginMaterialSurfaceSnapshot( + ToText(pair.Value.BackgroundColor), + ToText(pair.Value.BorderColor), + pair.Value.BlurRadius, + pair.Value.Opacity), + StringComparer.OrdinalIgnoreCase), + snapshot.WallpaperSeedCandidates.Select(ToText).ToArray()); + } + + public static PluginAppearanceSnapshot FromAppearanceSnapshot(AppearanceThemeSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + return new PluginAppearanceSnapshot( + PluginCornerRadiusTokens.FromShared(snapshot.CornerRadiusTokens), + snapshot.IsNightMode ? "Dark" : "Light", + ToText(snapshot.AccentColor), + ToText(snapshot.EffectiveSeedColor), + snapshot.ResolvedSeedSource, + snapshot.SystemMaterialMode, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["primary"] = ToText(snapshot.MonetPalette.Primary), + ["secondary"] = ToText(snapshot.MonetPalette.Secondary), + ["tertiary"] = ToText(snapshot.MonetPalette.Tertiary), + ["neutral"] = ToText(snapshot.MonetPalette.Neutral), + ["neutralVariant"] = ToText(snapshot.MonetPalette.NeutralVariant), + ["accent"] = ToText(snapshot.AccentColor) + }, + null, + snapshot.WallpaperSeedCandidates.Select(ToText).ToArray()); + } + + private static IReadOnlyDictionary BuildColorRoles(MaterialColorSnapshot snapshot) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["primary"] = ToText(snapshot.Palette.Primary), + ["secondary"] = ToText(snapshot.Palette.Secondary), + ["accent"] = ToText(snapshot.Palette.Accent), + ["onAccent"] = ToText(snapshot.Palette.OnAccent), + ["surfaceBase"] = ToText(snapshot.Palette.SurfaceBase), + ["surfaceRaised"] = ToText(snapshot.Palette.SurfaceRaised), + ["surfaceOverlay"] = ToText(snapshot.Palette.SurfaceOverlay), + ["textPrimary"] = ToText(snapshot.Palette.TextPrimary), + ["textSecondary"] = ToText(snapshot.Palette.TextSecondary), + ["textMuted"] = ToText(snapshot.Palette.TextMuted), + ["textAccent"] = ToText(snapshot.Palette.TextAccent), + ["toggleOn"] = ToText(snapshot.Palette.ToggleOn), + ["toggleOff"] = ToText(snapshot.Palette.ToggleOff), + ["toggleBorder"] = ToText(snapshot.Palette.ToggleBorder) + }; + } + + private static string ToText(Color color) + { + return color.ToString(); + } +} diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index 67479d6..97c4bb3 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -34,7 +34,9 @@ public sealed record ThemeAppearanceSettingsState( string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral, string SystemMaterialMode = ThemeAppearanceValues.MaterialNone, string? SelectedWallpaperSeed = null, - string ThemeMode = ThemeAppearanceValues.ThemeModeLight); + string ThemeMode = ThemeAppearanceValues.ThemeModeLight, + string ThemeWallpaperColorSource = ThemeAppearanceValues.WallpaperColorSourceAuto, + bool UseNativeWallpaperChangeEvents = true); public sealed record StatusBarSettingsState( IReadOnlyList TopStatusComponentIds, IReadOnlyList PinnedTaskbarActions, diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 4576e52..f5916ea 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -267,7 +267,9 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor), ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode), snapshot.SelectedWallpaperSeed, - NormalizeThemeMode(snapshot.ThemeMode)); + NormalizeThemeMode(snapshot.ThemeMode), + ThemeAppearanceValues.NormalizeWallpaperColorSource(snapshot.ThemeWallpaperColorSource), + snapshot.UseNativeWallpaperChangeEvents); } private static string NormalizeThemeMode(string? value) @@ -294,6 +296,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed) ? null : state.SelectedWallpaperSeed; + var normalizedWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(state.ThemeWallpaperColorSource); if ((snapshot.IsNightMode ?? false) != state.IsNightMode) { @@ -337,6 +340,18 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService changedKeys.Add(nameof(AppSettingsSnapshot.SelectedWallpaperSeed)); } + if (!string.Equals(snapshot.ThemeWallpaperColorSource, normalizedWallpaperColorSource, StringComparison.OrdinalIgnoreCase)) + { + snapshot.ThemeWallpaperColorSource = normalizedWallpaperColorSource; + changedKeys.Add(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource)); + } + + if (snapshot.UseNativeWallpaperChangeEvents != state.UseNativeWallpaperChangeEvents) + { + snapshot.UseNativeWallpaperChangeEvents = state.UseNativeWallpaperChangeEvents; + changedKeys.Add(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents)); + } + var normalizedThemeMode = NormalizeThemeMode(state.ThemeMode); if (!string.Equals(snapshot.ThemeMode, normalizedThemeMode, StringComparison.OrdinalIgnoreCase)) { @@ -770,6 +785,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl { private readonly ISettingsService _settingsService; private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop"); + private readonly PlondsStaticUpdateService _plondsStaticUpdateService = new(); private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new(); public UpdateSettingsService(ISettingsService settingsService) @@ -869,6 +885,14 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl bool isForce = false, CancellationToken cancellationToken = default) { + var staticResult = isForce + ? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) + : await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); + if (staticResult.Success && staticResult.PlondsPayload is not null) + { + return staticResult.PlondsPayload; + } + var result = isForce ? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) : await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); @@ -912,6 +936,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl public void Dispose() { _githubReleaseUpdateService.Dispose(); + _plondsStaticUpdateService.Dispose(); _plondsReleaseUpdateService.Dispose(); } @@ -921,6 +946,19 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl bool isForce, CancellationToken cancellationToken) { + var staticResult = isForce + ? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) + : await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); + + if (staticResult.Success) + { + return staticResult; + } + + AppLogger.Warn( + "UpdateSettings", + $"PLONDS static update check failed and will fallback to GitHub release PLONDS. Error: {staticResult.ErrorMessage}"); + var plondsResult = isForce ? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) : await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); diff --git a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs index 4056516..4abff3b 100644 --- a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs +++ b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs @@ -179,6 +179,7 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable services.AddSingleton(_settingsFacade.Settings); services.AddSingleton(_settingsFacade.Catalog); services.AddSingleton(_ => HostAppearanceThemeProvider.GetOrCreate()); + services.AddSingleton(_ => HostMaterialColorProvider.GetOrCreate()); services.AddSingleton(_hostApplicationLifecycle); services.AddSingleton(_localizationService); services.AddSingleton(_ => HostLocationServiceProvider.GetOrCreate()); diff --git a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs index 94f3044..f18d9cb 100644 --- a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs +++ b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs @@ -242,7 +242,10 @@ internal sealed class SettingsWindowService : ISettingsWindowService (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) || changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.SystemWallpaperRefreshIntervalSeconds), StringComparer.OrdinalIgnoreCase))) || changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase); if (languageChanged || devModeChanged) diff --git a/LanMountainDesktop/Services/ThemeAppearanceValues.cs b/LanMountainDesktop/Services/ThemeAppearanceValues.cs index ddcf70d..d6d1ca9 100644 --- a/LanMountainDesktop/Services/ThemeAppearanceValues.cs +++ b/LanMountainDesktop/Services/ThemeAppearanceValues.cs @@ -10,6 +10,10 @@ public static class ThemeAppearanceValues public const string ColorModeSeedMonet = "seed_monet"; public const string ColorModeWallpaperMonet = "wallpaper_monet"; + public const string WallpaperColorSourceAuto = "auto"; + public const string WallpaperColorSourceApp = "app"; + public const string WallpaperColorSourceSystem = "system"; + public const string ColorSchemeFollowSystem = "follow_system"; public const string ColorSchemeNative = "native"; @@ -37,6 +41,13 @@ public static class ThemeAppearanceValues MaterialAcrylic ]; + public static readonly IReadOnlyList AllWallpaperColorSources = + [ + WallpaperColorSourceAuto, + WallpaperColorSourceApp, + WallpaperColorSourceSystem + ]; + public static string NormalizeThemeColorMode(string? value, string? themeColor = null) { if (string.Equals(value, ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase)) @@ -79,6 +90,21 @@ public static class ThemeAppearanceValues return MaterialNone; } + public static string NormalizeWallpaperColorSource(string? value) + { + if (string.Equals(value, WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase)) + { + return WallpaperColorSourceApp; + } + + if (string.Equals(value, WallpaperColorSourceSystem, StringComparison.OrdinalIgnoreCase)) + { + return WallpaperColorSourceSystem; + } + + return WallpaperColorSourceAuto; + } + public static string ResolveEffectiveSystemMaterialMode(string? value) { var normalized = NormalizeSystemMaterialMode(value); diff --git a/LanMountainDesktop/Services/ThemeColorSystemService.cs b/LanMountainDesktop/Services/ThemeColorSystemService.cs index eec7681..ee009fd 100644 --- a/LanMountainDesktop/Services/ThemeColorSystemService.cs +++ b/LanMountainDesktop/Services/ThemeColorSystemService.cs @@ -67,7 +67,7 @@ public static class ThemeColorSystemService !isLightBackground)); } - private static AppThemePalette BuildPalette(ThemeColorContext context) + public static AppThemePalette BuildPalette(ThemeColorContext context) { var monetPalette = context.MonetPalette; var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? []; diff --git a/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs b/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs index 922daa3..179552a 100644 --- a/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs +++ b/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs @@ -6,7 +6,7 @@ using LanMountainDesktop.Shared.Contracts.Update; namespace LanMountainDesktop.Services.Update; -internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider +internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider, IDisposable { private const string ApiBasePath = "/api/plonds/v1"; @@ -51,6 +51,12 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider return null; } + if (string.IsNullOrWhiteSpace(pointer.DistributionId) || + string.IsNullOrWhiteSpace(pointer.Version)) + { + return null; + } + return await FetchDistributionManifestAsync(pointer.DistributionId, pointer.Version, channel, platform, ct); } @@ -74,6 +80,14 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider return Task.FromResult>([]); } + public void Dispose() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + private async Task GetChannelPointerAsync( string channel, string platform, @@ -142,15 +156,17 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider foreach (var f in component.Files) { + var action = FirstNonEmpty(f.Action, f.Op) ?? "add"; + var sha256 = FirstNonEmpty(f.Sha256, f.ContentHash) ?? string.Empty; files.Add(new UpdateFileEntry( Path: f.Path ?? string.Empty, - Action: f.Op ?? "add", - Sha256: f.ContentHash ?? string.Empty, + Action: action, + Sha256: sha256, Size: f.Size, Mode: f.Mode ?? "file-object", ObjectKey: f.ObjectKey, - ObjectUrl: null, - ArchiveSha256: null, + ObjectUrl: f.ObjectUrl, + ArchiveSha256: f.ArchiveSha256, Metadata: null)); } } @@ -163,7 +179,7 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider Sha256: m.Sha256, Size: m.Size)).ToArray(); - var fileMapSignatureUrl = dto.Signatures?.FirstOrDefault()?.Signature; + var fileMapSignatureUrl = FirstNonEmpty(dto.FileMapSignatureUrl, dto.Signatures?.FirstOrDefault()?.Signature); return new UpdateManifest( DistributionId: dto.DistributionId ?? string.Empty, @@ -209,14 +225,15 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider private sealed record PlondsDistributionDto( string? DistributionId, string? Version, - string? SourceVersion, - string? Channel, - string? Platform, - DateTimeOffset PublishedAt, - string? FileMapUrl, - List? Components, - List? InstallerMirrors, - List? Signatures, + string? SourceVersion, + string? Channel, + string? Platform, + DateTimeOffset PublishedAt, + string? FileMapUrl, + string? FileMapSignatureUrl, + List? Components, + List? InstallerMirrors, + List? Signatures, Dictionary? Metadata); private sealed record PlondsComponentDto( @@ -228,10 +245,14 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider private sealed record PlondsFileDto( string? Path, string? Op, + string? Action, string? ContentHash, + string? Sha256, long Size, string? Mode, - string? ObjectKey); + string? ObjectKey, + string? ObjectUrl, + string? ArchiveSha256); private sealed record PlondsMirrorDto( string? Platform, @@ -244,4 +265,9 @@ internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider string? Algorithm, string? KeyId, string? Signature); + + private static string? FirstNonEmpty(params string?[] values) + { + return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim(); + } } diff --git a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs index 0747559..2705e9d 100644 --- a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs +++ b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs @@ -69,7 +69,7 @@ public sealed class UpdateOrchestrator : IDisposable { manifest = await _manifestProvider.GetLatestAsync( channel, - "win-x64", + LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform(), currentVersion, ct); } diff --git a/LanMountainDesktop/Services/UpdateSettingsValues.cs b/LanMountainDesktop/Services/UpdateSettingsValues.cs index 7af27a0..6877ca5 100644 --- a/LanMountainDesktop/Services/UpdateSettingsValues.cs +++ b/LanMountainDesktop/Services/UpdateSettingsValues.cs @@ -20,6 +20,8 @@ public static class UpdateSettingsValues public const string LegacyDownloadSourceStcn = "stcn"; public const string DownloadSourceGitHub = "github"; public const string DownloadSourceGhProxy = "gh-proxy"; + public const string PlondsStaticBaseUrlEnvironmentVariable = "LANMOUNTAIN_UPDATE_BASE_URL"; + public const string DefaultPlondsStaticBaseUrl = "https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"; public const int DefaultDownloadThreads = 4; public const int MinDownloadThreads = 1; diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs index 79e3eaf..03dd1f3 100644 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -55,7 +55,7 @@ public sealed class UpdateWorkflowService private readonly ISettingsFacadeService _settingsFacade; private readonly string _updatesDirectory; - private const string LauncherDirectoryName = ".launcher"; + private const string LauncherDirectoryName = ".Launcher"; private const string UpdateDirectoryName = "update"; private const string IncomingDirectoryName = "incoming"; private const string IncomingObjectsDirectoryName = "objects"; diff --git a/LanMountainDesktop/ViewModels/MaterialColorSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/MaterialColorSettingsPageViewModel.cs new file mode 100644 index 0000000..c214cb0 --- /dev/null +++ b/LanMountainDesktop/ViewModels/MaterialColorSettingsPageViewModel.cs @@ -0,0 +1,560 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; + +namespace LanMountainDesktop.ViewModels; + +public sealed class MaterialSurfacePreviewOption +{ + public MaterialSurfacePreviewOption(string label, MaterialSurfaceSnapshot surface) + { + Label = label; + BackgroundBrush = new SolidColorBrush(surface.BackgroundColor); + BorderBrush = new SolidColorBrush(surface.BorderColor); + Detail = $"A={surface.BackgroundColor.A:X2} Blur={surface.BlurRadius:0}"; + } + + public string Label { get; } + + public IBrush BackgroundBrush { get; } + + public IBrush BorderBrush { get; } + + public string Detail { get; } +} + +public sealed class MaterialColorRolePreviewOption +{ + public MaterialColorRolePreviewOption(string label, Color color) + { + Label = label; + Value = color.ToString(); + Brush = new SolidColorBrush(color); + } + + public string Label { get; } + + public string Value { get; } + + public IBrush Brush { get; } +} + +public sealed partial class MaterialColorSettingsPageViewModel : ViewModelBase +{ + private static readonly Color DefaultSeedColor = Color.Parse("#FF3B82F6"); + private readonly ISettingsFacadeService _settingsFacade; + private readonly IMaterialColorService _materialColorService; + private readonly LocalizationService _localizationService = new(); + private readonly string _languageCode; + private bool _isInitializing; + private string? _selectedWallpaperSeed; + + public MaterialColorSettingsPageViewModel( + ISettingsFacadeService settingsFacade, + IMaterialColorService materialColorService) + { + _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); + _materialColorService = materialColorService ?? throw new ArgumentNullException(nameof(materialColorService)); + _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); + + _isInitializing = true; + try + { + RefreshLocalizedText(); + ColorModes = CreateColorModes(); + WallpaperColorSources = CreateWallpaperColorSources(); + RefreshIntervals = CreateRefreshIntervals(); + RefreshMaterialModes(_materialColorService.GetMaterialColorSnapshot()); + Load(); + } + finally + { + _isInitializing = false; + } + + _materialColorService.MaterialColorChanged += OnMaterialColorChanged; + } + + public IReadOnlyList ColorModes { get; } + + public IReadOnlyList WallpaperColorSources { get; } + + public IReadOnlyList RefreshIntervals { get; } + + [ObservableProperty] + private SelectionOption _selectedColorMode = new(ThemeAppearanceValues.ColorModeDefaultNeutral, "Default neutral"); + + [ObservableProperty] + private SelectionOption _selectedWallpaperColorSource = new(ThemeAppearanceValues.WallpaperColorSourceAuto, "Auto"); + + [ObservableProperty] + private IReadOnlyList _systemMaterialModes = []; + + [ObservableProperty] + private SelectionOption _selectedSystemMaterialMode = new(ThemeAppearanceValues.MaterialAuto, "Auto"); + + [ObservableProperty] + private SelectionOption _selectedRefreshInterval = new("5m", "5 minutes"); + + [ObservableProperty] + private bool _useNativeWallpaperChangeEvents = true; + + [ObservableProperty] + private Color _customSeedPickerValue = DefaultSeedColor; + + [ObservableProperty] + private IBrush _seedBrush = new SolidColorBrush(DefaultSeedColor); + + [ObservableProperty] + private IBrush _accentBrush = new SolidColorBrush(DefaultSeedColor); + + [ObservableProperty] + private IBrush _surfaceBrush = new SolidColorBrush(Color.Parse("#FFF7F8FA")); + + [ObservableProperty] + private IReadOnlyList _wallpaperSeedCandidates = []; + + [ObservableProperty] + private IReadOnlyList _colorRolePreviews = []; + + [ObservableProperty] + private IReadOnlyList _surfacePreviews = []; + + [ObservableProperty] + private string _resolvedSourceText = string.Empty; + + [ObservableProperty] + private string _resolvedWallpaperPathText = string.Empty; + + [ObservableProperty] + private bool _isCustomSeedVisible; + + [ObservableProperty] + private bool _isWallpaperOptionsVisible; + + [ObservableProperty] + private bool _isWallpaperSeedSelectable; + + [ObservableProperty] + private bool _isNativeEventStatusVisible; + + [ObservableProperty] + private string _nativeEventStatusText = string.Empty; + + [ObservableProperty] + private string _pageTitle = string.Empty; + + [ObservableProperty] + private string _pageDescription = string.Empty; + + [ObservableProperty] + private string _colorSourceLabel = string.Empty; + + [ObservableProperty] + private string _colorSourceDescription = string.Empty; + + [ObservableProperty] + private string _customSeedLabel = string.Empty; + + [ObservableProperty] + private string _wallpaperColorSourceLabel = string.Empty; + + [ObservableProperty] + private string _wallpaperSeedLabel = string.Empty; + + [ObservableProperty] + private string _systemMaterialLabel = string.Empty; + + [ObservableProperty] + private string _systemMaterialDescription = string.Empty; + + [ObservableProperty] + private string _nativeWallpaperEventsLabel = string.Empty; + + [ObservableProperty] + private string _nativeWallpaperEventsDescription = string.Empty; + + [ObservableProperty] + private string _refreshIntervalLabel = string.Empty; + + [ObservableProperty] + private string _refreshNowText = string.Empty; + + [ObservableProperty] + private string _previewHeader = string.Empty; + + [ObservableProperty] + private string _sourceStatusHeader = string.Empty; + + [ObservableProperty] + private string _semanticColorsHeader = string.Empty; + + [ObservableProperty] + private string _surfacesHeader = string.Empty; + + [ObservableProperty] + private string _wallpaperSeedCurrentText = string.Empty; + + [ObservableProperty] + private string _modeNeutralText = string.Empty; + + [ObservableProperty] + private string _modeCustomText = string.Empty; + + [ObservableProperty] + private string _modeWallpaperText = string.Empty; + + [ObservableProperty] + private string _wallpaperSourceAutoText = string.Empty; + + [ObservableProperty] + private string _wallpaperSourceAppText = string.Empty; + + [ObservableProperty] + private string _wallpaperSourceSystemText = string.Empty; + + [ObservableProperty] + private string _materialNoneText = string.Empty; + + [ObservableProperty] + private string _materialAutoText = string.Empty; + + [ObservableProperty] + private string _materialMicaText = string.Empty; + + [ObservableProperty] + private string _materialAcrylicText = string.Empty; + + public void Load() + { + var theme = _settingsFacade.Theme.Get(); + var snapshot = _materialColorService.GetMaterialColorSnapshot(); + RefreshMaterialModes(snapshot); + + SelectedColorMode = ColorModes.FirstOrDefault(option => + string.Equals(option.Value, theme.ThemeColorMode, StringComparison.OrdinalIgnoreCase)) + ?? ColorModes[0]; + SelectedWallpaperColorSource = WallpaperColorSources.FirstOrDefault(option => + string.Equals(option.Value, theme.ThemeWallpaperColorSource, StringComparison.OrdinalIgnoreCase)) + ?? WallpaperColorSources[0]; + SelectedSystemMaterialMode = SystemMaterialModes.FirstOrDefault(option => + string.Equals(option.Value, theme.SystemMaterialMode, StringComparison.OrdinalIgnoreCase)) + ?? SystemMaterialModes[0]; + SelectedRefreshInterval = RefreshIntervals.FirstOrDefault(option => + GetIntervalSeconds(option.Value) == _settingsFacade.Wallpaper.Get().SystemWallpaperRefreshIntervalSeconds) + ?? RefreshIntervals[2]; + UseNativeWallpaperChangeEvents = theme.UseNativeWallpaperChangeEvents; + _selectedWallpaperSeed = theme.SelectedWallpaperSeed; + CustomSeedPickerValue = !string.IsNullOrWhiteSpace(theme.ThemeColor) && Color.TryParse(theme.ThemeColor, out var parsed) + ? parsed + : DefaultSeedColor; + + UpdateVisibility(); + UpdatePreview(snapshot); + } + + partial void OnSelectedColorModeChanged(SelectionOption value) + { + if (_isInitializing || value is null) + { + return; + } + + UpdateVisibility(); + SaveTheme(); + } + + partial void OnSelectedWallpaperColorSourceChanged(SelectionOption value) + { + if (_isInitializing || value is null) + { + return; + } + + SaveTheme(); + } + + partial void OnSelectedSystemMaterialModeChanged(SelectionOption value) + { + if (_isInitializing || value is null) + { + return; + } + + SaveTheme(); + } + + partial void OnSelectedRefreshIntervalChanged(SelectionOption value) + { + if (_isInitializing || value is null) + { + return; + } + + SaveWallpaperRefreshInterval(); + } + + partial void OnUseNativeWallpaperChangeEventsChanged(bool value) + { + if (_isInitializing) + { + return; + } + + SaveTheme(); + } + + partial void OnCustomSeedPickerValueChanged(Color value) + { + SeedBrush = new SolidColorBrush(value); + if (_isInitializing || !IsCustomSeedVisible) + { + return; + } + + SaveTheme(); + } + + public void SelectWallpaperSeed(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + _selectedWallpaperSeed = value; + SaveTheme(); + } + + [RelayCommand] + private void RefreshWallpaperColors() + { + _materialColorService.RefreshWallpaperColors(); + } + + private void SaveTheme() + { + var current = _settingsFacade.Theme.Get(); + var colorMode = ThemeAppearanceValues.NormalizeThemeColorMode(SelectedColorMode?.Value, current.ThemeColor); + var themeColor = colorMode == ThemeAppearanceValues.ColorModeSeedMonet + ? CustomSeedPickerValue.ToString() + : current.ThemeColor; + + _settingsFacade.Theme.Save(current with + { + ThemeColorMode = colorMode, + ThemeColor = themeColor, + SystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value), + SelectedWallpaperSeed = _selectedWallpaperSeed, + ThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(SelectedWallpaperColorSource?.Value), + UseNativeWallpaperChangeEvents = UseNativeWallpaperChangeEvents + }); + } + + private void SaveWallpaperRefreshInterval() + { + var wallpaper = _settingsFacade.Wallpaper.Get(); + _settingsFacade.Wallpaper.Save(wallpaper with + { + SystemWallpaperRefreshIntervalSeconds = GetIntervalSeconds(SelectedRefreshInterval?.Value) + }); + } + + private void OnMaterialColorChanged(object? sender, MaterialColorSnapshot snapshot) + { + _ = sender; + UpdatePreview(snapshot); + RefreshMaterialModes(snapshot); + } + + private void UpdatePreview(MaterialColorSnapshot snapshot) + { + AccentBrush = new SolidColorBrush(snapshot.AccentColor); + SeedBrush = new SolidColorBrush(snapshot.EffectiveSeedColor); + SurfaceBrush = new SolidColorBrush(snapshot.Palette.SurfaceRaised); + ResolvedSourceText = ResolveSourceLabel(snapshot); + ResolvedWallpaperPathText = string.IsNullOrWhiteSpace(snapshot.ResolvedWallpaperPath) + ? "-" + : snapshot.ResolvedWallpaperPath; + NativeEventStatusText = snapshot.NativeWallpaperChangeEventsActive + ? L("settings.material_color.native_events.active", "Native wallpaper events active") + : snapshot.WallpaperPollingActive + ? L("settings.material_color.native_events.polling", "Polling fallback active") + : L("settings.material_color.native_events.inactive", "Wallpaper monitoring inactive"); + IsNativeEventStatusVisible = IsWallpaperOptionsVisible; + + WallpaperSeedCandidates = snapshot.WallpaperSeedCandidates + .Select((color, index) => new ThemeSeedCandidateOption( + color.ToString(), + string.Format(CultureInfo.CurrentCulture, "{0} {1}", WallpaperSeedLabel, index + 1), + color, + string.Equals(color.ToString(), snapshot.EffectiveSeedColor.ToString(), StringComparison.OrdinalIgnoreCase))) + .ToArray(); + IsWallpaperSeedSelectable = WallpaperSeedCandidates.Count > 1; + + ColorRolePreviews = + [ + new MaterialColorRolePreviewOption("Accent", snapshot.Palette.Accent), + new MaterialColorRolePreviewOption("Primary", snapshot.Palette.Primary), + new MaterialColorRolePreviewOption("Secondary", snapshot.Palette.Secondary), + new MaterialColorRolePreviewOption("Surface", snapshot.Palette.SurfaceRaised), + new MaterialColorRolePreviewOption("Text", snapshot.Palette.TextPrimary), + new MaterialColorRolePreviewOption("Toggle", snapshot.Palette.ToggleOn) + ]; + + SurfacePreviews = snapshot.Surfaces.Values + .Where(surface => surface.Role is + MaterialSurfaceRole.SettingsWindowBackground or + MaterialSurfaceRole.DockBackground or + MaterialSurfaceRole.DesktopComponentHost or + MaterialSurfaceRole.OverlayPanel) + .Select(surface => new MaterialSurfacePreviewOption(surface.Role.ToString(), surface)) + .ToArray(); + } + + private void UpdateVisibility() + { + var colorMode = ThemeAppearanceValues.NormalizeThemeColorMode(SelectedColorMode?.Value); + IsCustomSeedVisible = colorMode == ThemeAppearanceValues.ColorModeSeedMonet; + IsWallpaperOptionsVisible = colorMode == ThemeAppearanceValues.ColorModeWallpaperMonet; + } + + private void RefreshMaterialModes(MaterialColorSnapshot snapshot) + { + SystemMaterialModes = snapshot.AvailableSystemMaterialModes + .Select(value => new SelectionOption(value, ResolveMaterialLabel(value))) + .ToArray(); + + if (!SystemMaterialModes.Any(option => + string.Equals(option.Value, SelectedSystemMaterialMode?.Value, StringComparison.OrdinalIgnoreCase))) + { + SelectedSystemMaterialMode = SystemMaterialModes.FirstOrDefault() + ?? new SelectionOption(ThemeAppearanceValues.MaterialNone, MaterialNoneText); + } + } + + private string ResolveSourceLabel(MaterialColorSnapshot snapshot) + { + return snapshot.ColorSourceKind switch + { + MaterialColorSourceKind.Neutral => ModeNeutralText, + MaterialColorSourceKind.CustomSeed => ModeCustomText, + MaterialColorSourceKind.AppWallpaper => WallpaperSourceAppText, + MaterialColorSourceKind.SystemWallpaper => WallpaperSourceSystemText, + MaterialColorSourceKind.WallpaperAuto => WallpaperSourceAutoText, + _ => L("settings.material_color.source.fallback", "Fallback") + }; + } + + private string ResolveMaterialLabel(string value) + { + return ThemeAppearanceValues.NormalizeSystemMaterialMode(value) switch + { + ThemeAppearanceValues.MaterialAuto => MaterialAutoText, + ThemeAppearanceValues.MaterialMica => MaterialMicaText, + ThemeAppearanceValues.MaterialAcrylic => MaterialAcrylicText, + _ => MaterialNoneText + }; + } + + private IReadOnlyList CreateColorModes() + { + return + [ + new SelectionOption(ThemeAppearanceValues.ColorModeDefaultNeutral, ModeNeutralText), + new SelectionOption(ThemeAppearanceValues.ColorModeSeedMonet, ModeCustomText), + new SelectionOption(ThemeAppearanceValues.ColorModeWallpaperMonet, ModeWallpaperText) + ]; + } + + private IReadOnlyList CreateWallpaperColorSources() + { + return + [ + new SelectionOption(ThemeAppearanceValues.WallpaperColorSourceAuto, WallpaperSourceAutoText), + new SelectionOption(ThemeAppearanceValues.WallpaperColorSourceApp, WallpaperSourceAppText), + new SelectionOption(ThemeAppearanceValues.WallpaperColorSourceSystem, WallpaperSourceSystemText) + ]; + } + + private IReadOnlyList CreateRefreshIntervals() + { + return + [ + new SelectionOption("30s", L("settings.wallpaper.refresh.30s", "30 seconds")), + new SelectionOption("1m", L("settings.wallpaper.refresh.1m", "1 minute")), + new SelectionOption("5m", L("settings.wallpaper.refresh.5m", "5 minutes")), + new SelectionOption("10m", L("settings.wallpaper.refresh.10m", "10 minutes")), + new SelectionOption("15m", L("settings.wallpaper.refresh.15m", "15 minutes")), + new SelectionOption("30m", L("settings.wallpaper.refresh.30m", "30 minutes")), + new SelectionOption("1h", L("settings.wallpaper.refresh.1h", "1 hour")), + new SelectionOption("2h", L("settings.wallpaper.refresh.2h", "2 hours")), + new SelectionOption("4h", L("settings.wallpaper.refresh.4h", "4 hours")), + new SelectionOption("8h", L("settings.wallpaper.refresh.8h", "8 hours")), + new SelectionOption("12h", L("settings.wallpaper.refresh.12h", "12 hours")), + new SelectionOption("24h", L("settings.wallpaper.refresh.24h", "24 hours")) + ]; + } + + private static int GetIntervalSeconds(string? value) + { + return value switch + { + "30s" => 30, + "1m" => 60, + "5m" => 300, + "10m" => 600, + "15m" => 900, + "30m" => 1800, + "1h" => 3600, + "2h" => 7200, + "4h" => 14400, + "8h" => 28800, + "12h" => 43200, + "24h" => 86400, + _ => 300 + }; + } + + private void RefreshLocalizedText() + { + PageTitle = L("settings.material_color.title", "Material & Color"); + PageDescription = L("settings.material_color.description", "Unify Monet, wallpaper colors, semantic roles, and material surfaces."); + ColorSourceLabel = L("settings.material_color.source.label", "Color source"); + ColorSourceDescription = L("settings.material_color.source.description", "Choose the single source used by app surfaces, components, and plugins."); + CustomSeedLabel = L("settings.material_color.custom_seed.label", "Custom Monet seed"); + WallpaperColorSourceLabel = L("settings.material_color.wallpaper_source.label", "Wallpaper color source"); + WallpaperSeedLabel = L("settings.material_color.wallpaper_seed.label", "Seed"); + SystemMaterialLabel = L("settings.material_color.system_material.label", "System material"); + SystemMaterialDescription = L("settings.material_color.system_material.description", "Apply the selected material mode to windows and host surfaces."); + NativeWallpaperEventsLabel = L("settings.material_color.native_events.label", "Native wallpaper change events"); + NativeWallpaperEventsDescription = L("settings.material_color.native_events.description", "Use OS wallpaper notifications first and keep polling as fallback."); + RefreshIntervalLabel = L("settings.material_color.refresh_interval.label", "Polling interval"); + RefreshNowText = L("settings.material_color.refresh_now", "Refresh colors"); + PreviewHeader = L("settings.material_color.preview.header", "Unified preview"); + SourceStatusHeader = L("settings.material_color.source_status.header", "Resolved source"); + SemanticColorsHeader = L("settings.material_color.semantic.header", "Semantic colors"); + SurfacesHeader = L("settings.material_color.surfaces.header", "Material surfaces"); + WallpaperSeedCurrentText = L("settings.appearance.preview.wallpaper_current", "Current"); + ModeNeutralText = L("settings.appearance.theme_color_mode.neutral", "Default neutral"); + ModeCustomText = L("settings.appearance.theme_color_mode.user", "User theme color Monet"); + ModeWallpaperText = L("settings.appearance.theme_color_mode.wallpaper", "Wallpaper Monet"); + WallpaperSourceAutoText = L("settings.material_color.wallpaper_source.auto", "Auto"); + WallpaperSourceAppText = L("settings.material_color.wallpaper_source.app", "App wallpaper"); + WallpaperSourceSystemText = L("settings.material_color.wallpaper_source.system", "System wallpaper"); + MaterialNoneText = L("settings.appearance.system_material.none", "None"); + MaterialAutoText = L("settings.appearance.system_material.auto", "Auto"); + MaterialMicaText = L("settings.appearance.system_material.mica", "Mica"); + MaterialAcrylicText = L("settings.appearance.system_material.acrylic", "Acrylic"); + } + + private string L(string key, string fallback) + => _localizationService.GetString(_languageCode, key, fallback); +} diff --git a/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs index e043008..80dbd37 100644 --- a/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs @@ -5,7 +5,6 @@ using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Media; -using Avalonia.Threading; using LanMountainDesktop.Models; using LanMountainDesktop.Services; using LanMountainDesktop.Theme; @@ -39,20 +38,20 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA"); private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); + private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings; private readonly LocalizationService _localizationService = new(); - private readonly DispatcherTimer _uiTimer = new() - { - Interval = TimeSpan.FromMilliseconds(250) - }; + private readonly StudySnapshotRenderGate _renderGate; private double _currentCellSize = 48; private bool _isAttached; private bool _isOnActivePage = true; + private bool _isSubscribed; private bool _isCompactMode; private bool _isUltraCompactMode; private bool _studyEnabled = true; private string _languageCode = "zh-CN"; + private IDisposable? _monitoringLease; private readonly record struct DeductionMetrics( double SustainedPenalty, @@ -68,14 +67,14 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen { InitializeComponent(); - _uiTimer.Tick += OnUiTimerTick; + _renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot); AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; SizeChanged += OnSizeChanged; ActualThemeVariantChanged += OnActualThemeVariantChanged; ReloadLanguageCode(); ApplyCellSize(_currentCellSize); - RefreshVisual(); + ApplySnapshot(_studyAnalyticsService.GetSnapshot()); } public void ApplyCellSize(double cellSize) @@ -87,26 +86,41 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) { _ = isEditMode; + var wasOnActivePage = _isOnActivePage; _isOnActivePage = isOnActivePage; - UpdateTimerState(); + UpdateMonitoringLeaseState(); + if (isOnActivePage && !wasOnActivePage) + { + RefreshVisual(); + } } private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = true; ReloadLanguageCode(); - if (_studyEnabled) + if (!_isSubscribed) { - _ = _studyAnalyticsService.StartOrResumeMonitoring(); + _studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated; + _isSubscribed = true; } - UpdateTimerState(); + + UpdateMonitoringLeaseState(); RefreshVisual(); } private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = false; - _uiTimer.Stop(); + _monitoringLease?.Dispose(); + _monitoringLease = null; + _renderGate.Clear(); + + if (_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated; + _isSubscribed = false; + } } private void OnSizeChanged(object? sender, SizeChangedEventArgs e) @@ -120,27 +134,47 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen RefreshVisual(); } - private void OnUiTimerTick(object? sender, EventArgs e) + private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e) { - RefreshVisual(); - } - - private void UpdateTimerState() - { - if (_isAttached && _isOnActivePage) + if (!_isAttached || !_isOnActivePage) { - if (!_uiTimer.IsEnabled) - { - _uiTimer.Start(); - } - return; } - _uiTimer.Stop(); + _renderGate.Queue(e.Snapshot); + } + + private bool CanRenderSnapshot() + { + return _isAttached && _isOnActivePage; + } + + private void UpdateMonitoringLeaseState() + { + if (!_studyEnabled) + { + _monitoringLease?.Dispose(); + _monitoringLease = null; + return; + } + + var shouldMonitor = _isAttached && _isOnActivePage; + if (shouldMonitor) + { + _monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease(); + return; + } + + _monitoringLease?.Dispose(); + _monitoringLease = null; } private void RefreshVisual() + { + _renderGate.Queue(_studyAnalyticsService.GetSnapshot()); + } + + private void ApplySnapshot(StudyAnalyticsSnapshot snapshot) { var panelColor = ResolvePanelBackgroundColor(); ApplyTypographyByBackground(panelColor); @@ -154,8 +188,6 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen return; } - var snapshot = _studyAnalyticsService.GetSnapshot(); - var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running; var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; var isSessionView = isSessionRunning || isSessionReport; diff --git a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs index 9212264..003d209 100644 --- a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs @@ -3,7 +3,6 @@ using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Media; -using Avalonia.Threading; using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Models; using LanMountainDesktop.Services; @@ -17,10 +16,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg private LanMountainDesktop.PluginSdk.ISettingsService _appSettingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings; private IComponentInstanceSettingsStore _componentSettingsService = HostComponentSettingsStoreProvider.GetOrCreate(); private readonly LocalizationService _localizationService = new(); - private readonly DispatcherTimer _uiTimer = new() - { - Interval = TimeSpan.FromMilliseconds(250) - }; + private readonly StudySnapshotRenderGate _renderGate; private double _currentCellSize = 48; private bool _showDisplayDb = true; @@ -29,6 +25,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg private string _languageCode = "zh-CN"; private bool _isAttached; private bool _isOnActivePage = true; + private bool _isSubscribed; private bool _isDisposed; private bool _studyEnabled = true; private IDisposable? _monitoringLease; @@ -37,7 +34,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg { InitializeComponent(); - _uiTimer.Tick += OnUiTimerTick; + _renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot); AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; SizeChanged += OnSizeChanged; @@ -45,7 +42,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg ReloadDisplaySettings(); ApplyCellSize(_currentCellSize); - RefreshVisual(); + ApplySnapshot(_studyAnalyticsService.GetSnapshot()); } public void ApplyCellSize(double cellSize) @@ -77,13 +74,12 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg { RefreshVisual(); } - - UpdateTimerState(); } public void RefreshFromSettings() { ReloadDisplaySettings(); + UpdateMonitoringLeaseState(); RefreshVisual(); } @@ -91,8 +87,13 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg { _isAttached = true; ReloadDisplaySettings(); + if (!_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated; + _isSubscribed = true; + } + UpdateMonitoringLeaseState(); - UpdateTimerState(); RefreshVisual(); } @@ -101,7 +102,13 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg _isAttached = false; _monitoringLease?.Dispose(); _monitoringLease = null; - _uiTimer.Stop(); + _renderGate.Clear(); + + if (_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated; + _isSubscribed = false; + } } private void OnSizeChanged(object? sender, SizeChangedEventArgs e) @@ -115,20 +122,19 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg RefreshVisual(); } - private void OnUiTimerTick(object? sender, EventArgs e) + private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e) { - RefreshVisual(); - } - - private void UpdateTimerState() - { - if (_isAttached && _isOnActivePage) + if (!_isAttached || !_isOnActivePage) { - _uiTimer.Start(); return; } - _uiTimer.Stop(); + _renderGate.Queue(e.Snapshot); + } + + private bool CanRenderSnapshot() + { + return _isAttached && _isOnActivePage; } private void UpdateMonitoringLeaseState() @@ -140,7 +146,8 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg return; } - if (_isAttached) + var shouldMonitor = _isAttached && _isOnActivePage; + if (shouldMonitor) { _monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease(); return; @@ -166,6 +173,11 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg } private void RefreshVisual() + { + _renderGate.Queue(_studyAnalyticsService.GetSnapshot()); + } + + private void ApplySnapshot(StudyAnalyticsSnapshot snapshot) { if (!_studyEnabled) { @@ -178,7 +190,6 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg return; } - var snapshot = _studyAnalyticsService.GetSnapshot(); var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; StatusTitleTextBlock.Text = L("study.environment.status_label", "Environment"); @@ -380,13 +391,18 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg _isDisposed = true; - _uiTimer.Stop(); - _uiTimer.Tick -= OnUiTimerTick; + _renderGate.Dispose(); AttachedToVisualTree -= OnAttachedToVisualTree; DetachedFromVisualTree -= OnDetachedFromVisualTree; SizeChanged -= OnSizeChanged; ActualThemeVariantChanged -= OnActualThemeVariantChanged; + if (_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated; + _isSubscribed = false; + } + _monitoringLease?.Dispose(); _monitoringLease = null; } diff --git a/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs index 2bcdae0..7433d56 100644 --- a/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs @@ -4,7 +4,6 @@ using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Media; -using Avalonia.Threading; using LanMountainDesktop.Models; using LanMountainDesktop.Services; using LanMountainDesktop.Theme; @@ -41,14 +40,12 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings; private readonly LocalizationService _localizationService = new(); - private readonly DispatcherTimer _uiTimer = new() - { - Interval = TimeSpan.FromMilliseconds(250) - }; + private readonly StudySnapshotRenderGate _renderGate; private double _currentCellSize = 48; private bool _isAttached; private bool _isOnActivePage = true; + private bool _isSubscribed; private bool _isCompactMode; private bool _isUltraCompactMode; private bool _studyEnabled = true; @@ -79,14 +76,14 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen { InitializeComponent(); - _uiTimer.Tick += OnUiTimerTick; + _renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot); AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; SizeChanged += OnSizeChanged; ActualThemeVariantChanged += OnActualThemeVariantChanged; ReloadLanguageCode(); ApplyCellSize(_currentCellSize); - RefreshVisual(); + ApplySnapshot(_studyAnalyticsService.GetSnapshot()); } public void ApplyCellSize(double cellSize) @@ -98,17 +95,26 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) { _ = isEditMode; + var wasOnActivePage = _isOnActivePage; _isOnActivePage = isOnActivePage; UpdateMonitoringLeaseState(); - UpdateTimerState(); + if (isOnActivePage && !wasOnActivePage) + { + RefreshVisual(); + } } private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = true; ReloadLanguageCode(); + if (!_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated; + _isSubscribed = true; + } + UpdateMonitoringLeaseState(); - UpdateTimerState(); RefreshVisual(); } @@ -117,7 +123,13 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen _isAttached = false; _monitoringLease?.Dispose(); _monitoringLease = null; - _uiTimer.Stop(); + _renderGate.Clear(); + + if (_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated; + _isSubscribed = false; + } } private void OnSizeChanged(object? sender, SizeChangedEventArgs e) @@ -131,24 +143,19 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen RefreshVisual(); } - private void OnUiTimerTick(object? sender, EventArgs e) + private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e) { - RefreshVisual(); - } - - private void UpdateTimerState() - { - if (_isAttached && _isOnActivePage) + if (!_isAttached || !_isOnActivePage) { - if (!_uiTimer.IsEnabled) - { - _uiTimer.Start(); - } - return; } - _uiTimer.Stop(); + _renderGate.Queue(e.Snapshot); + } + + private bool CanRenderSnapshot() + { + return _isAttached && _isOnActivePage; } private void UpdateMonitoringLeaseState() @@ -172,6 +179,11 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen } private void RefreshVisual() + { + _renderGate.Queue(_studyAnalyticsService.GetSnapshot()); + } + + private void ApplySnapshot(StudyAnalyticsSnapshot snapshot) { var panelColor = ResolvePanelBackgroundColor(); ApplyTypographyByBackground(panelColor); @@ -186,8 +198,6 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen return; } - var snapshot = _studyAnalyticsService.GetSnapshot(); - var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running; var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; var isSessionView = isSessionRunning || isSessionReport; diff --git a/LanMountainDesktop/Views/Components/StudyNoiseCurveChartControl.cs b/LanMountainDesktop/Views/Components/StudyNoiseCurveChartControl.cs index 87056ae..92e8e19 100644 --- a/LanMountainDesktop/Views/Components/StudyNoiseCurveChartControl.cs +++ b/LanMountainDesktop/Views/Components/StudyNoiseCurveChartControl.cs @@ -10,6 +10,10 @@ namespace LanMountainDesktop.Views.Components; public sealed class StudyNoiseCurveChartControl : Control { + private const double MinDisplayDb = 20; + private const double MaxDisplayDb = 100; + private const double DynamicTailSeconds = 4; + private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#324E6780")); private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1")); private static readonly IBrush LineBrush = new SolidColorBrush(Color.Parse("#FF52AEEA")); @@ -20,11 +24,24 @@ public sealed class StudyNoiseCurveChartControl : Control private IReadOnlyList _points = Array.Empty(); private Point[]? _pointBuffer; - private StreamGeometry? _lineGeometry; - private StreamGeometry? _fillGeometry; + private StreamGeometry? _gridGeometry; + private StreamGeometry? _axisGeometry; + private StreamGeometry? _staticLineGeometry; + private StreamGeometry? _staticFillGeometry; + private StreamGeometry? _dynamicLineGeometry; + private StreamGeometry? _dynamicFillGeometry; private Rect _cachedPlot; + private Rect _cachedGridPlot; + private DateTimeOffset _logicalOrigin; + private DateTimeOffset _lastSeriesStart; + private DateTimeOffset _lastSeriesEnd; + private double _cachedPixelsPerSecond; + private double _viewportTranslateX; + private bool _hasLogicalOrigin; private bool _geometryDirty = true; private int _lastSeriesSignature; + private int _staticSourceCount; + private int _dynamicSourceCount; public void UpdateSeries(IReadOnlyList? points) { @@ -35,6 +52,7 @@ public sealed class StudyNoiseCurveChartControl : Control return; } + UpdateLogicalOrigin(nextPoints); _points = nextPoints; _lastSeriesSignature = nextSignature; _geometryDirty = true; @@ -49,16 +67,90 @@ public sealed class StudyNoiseCurveChartControl : Control _pointBuffer = null; } - _lineGeometry = null; - _fillGeometry = null; + _staticLineGeometry = null; + _staticFillGeometry = null; + _dynamicLineGeometry = null; + _dynamicFillGeometry = null; _geometryDirty = true; } + internal int StaticSourceCount => _staticSourceCount; + + internal int DynamicSourceCount => _dynamicSourceCount; + + internal void RebuildCacheForTesting(Rect plot) + { + if (_points.Count >= 2) + { + EnsureGeometry(plot); + } + } + + internal static double ResolveVisibleDurationSeconds(IReadOnlyList points) + { + if (points.Count < 2) + { + return 12; + } + + var duration = (points[^1].Timestamp - points[0].Timestamp).TotalSeconds; + if (double.IsNaN(duration) || double.IsInfinity(duration) || duration <= 1) + { + duration = 12; + } + + return Math.Clamp(duration, 4, 60); + } + + internal static int ResolveFirstTailIndex(IReadOnlyList points, TimeSpan tailDuration) + { + if (points.Count <= 1) + { + return 0; + } + + var cutoff = points[^1].Timestamp - tailDuration; + for (var i = 0; i < points.Count; i++) + { + if (points[i].Timestamp >= cutoff) + { + return i; + } + } + + return points.Count - 1; + } + + internal static (int StaticSourceCount, int DynamicSourceCount) ResolveLayerSourceCounts( + IReadOnlyList points, + TimeSpan tailDuration) + { + if (points.Count < 2) + { + return (0, 0); + } + + var firstTailIndex = ResolveFirstTailIndex(points, tailDuration); + var dynamicStartIndex = Math.Max(0, firstTailIndex - 1); + var staticCount = firstTailIndex >= 2 ? firstTailIndex : 0; + var dynamicCount = points.Count - dynamicStartIndex >= 2 ? points.Count - dynamicStartIndex : 0; + return (staticCount, dynamicCount); + } + + internal static double MapTimestampToLogicalX(DateTimeOffset timestamp, DateTimeOffset origin, double pixelsPerSecond) + { + return Math.Max(0, (timestamp - origin).TotalSeconds * pixelsPerSecond); + } + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { ReleasePointBuffer(); - _lineGeometry = null; - _fillGeometry = null; + _gridGeometry = null; + _axisGeometry = null; + _staticLineGeometry = null; + _staticFillGeometry = null; + _dynamicLineGeometry = null; + _dynamicFillGeometry = null; _geometryDirty = true; base.OnDetachedFromVisualTree(e); } @@ -86,53 +178,194 @@ public sealed class StudyNoiseCurveChartControl : Control } EnsureGeometry(plot); - if (_lineGeometry is null || _fillGeometry is null) + if (_staticLineGeometry is null && + _staticFillGeometry is null && + _dynamicLineGeometry is null && + _dynamicFillGeometry is null) { return; } - context.DrawGeometry(FillBrush, pen: null, _fillGeometry); - context.DrawGeometry(brush: null, pen: LinePen, _lineGeometry); + using (context.PushClip(plot)) + using (context.PushTransform(Matrix.CreateTranslation(_viewportTranslateX, 0))) + { + if (_staticFillGeometry is not null) + { + context.DrawGeometry(FillBrush, pen: null, _staticFillGeometry); + } + + if (_dynamicFillGeometry is not null) + { + context.DrawGeometry(FillBrush, pen: null, _dynamicFillGeometry); + } + + if (_staticLineGeometry is not null) + { + context.DrawGeometry(brush: null, pen: LinePen, _staticLineGeometry); + } + + if (_dynamicLineGeometry is not null) + { + context.DrawGeometry(brush: null, pen: LinePen, _dynamicLineGeometry); + } + } } - private static void DrawGrid(DrawingContext context, Rect plot) + private void UpdateLogicalOrigin(IReadOnlyList nextPoints) + { + if (nextPoints.Count == 0) + { + _hasLogicalOrigin = false; + _lastSeriesStart = default; + _lastSeriesEnd = default; + return; + } + + var nextStart = nextPoints[0].Timestamp; + var nextEnd = nextPoints[^1].Timestamp; + if (!_hasLogicalOrigin) + { + ResetLogicalOrigin(nextStart); + } + else + { + var overlapsPreviousSeries = nextStart <= _lastSeriesEnd && nextEnd >= _lastSeriesStart; + if (!overlapsPreviousSeries || nextStart < _logicalOrigin) + { + ResetLogicalOrigin(nextStart); + } + } + + _lastSeriesStart = nextStart; + _lastSeriesEnd = nextEnd; + } + + private void ResetLogicalOrigin(DateTimeOffset origin) + { + _logicalOrigin = origin; + _hasLogicalOrigin = true; + _staticLineGeometry = null; + _staticFillGeometry = null; + _dynamicLineGeometry = null; + _dynamicFillGeometry = null; + _geometryDirty = true; + } + + private void DrawGrid(DrawingContext context, Rect plot) + { + if (_gridGeometry is null || _axisGeometry is null || _cachedGridPlot != plot) + { + _cachedGridPlot = plot; + (_gridGeometry, _axisGeometry) = BuildGridGeometry(plot); + } + + context.DrawGeometry(brush: null, pen: GridPen, _gridGeometry); + context.DrawGeometry(brush: null, pen: AxisPen, _axisGeometry); + } + + private static (StreamGeometry Grid, StreamGeometry Axis) BuildGridGeometry(Rect plot) { const int horizontalDivisions = 4; const int verticalDivisions = 4; - for (var i = 0; i <= horizontalDivisions; i++) + var grid = new StreamGeometry(); + using (var builder = grid.Open()) { - var y = plot.Top + plot.Height * (i / (double)horizontalDivisions); - context.DrawLine(GridPen, new Point(plot.Left, y), new Point(plot.Right, y)); + for (var i = 0; i <= horizontalDivisions; i++) + { + var y = plot.Top + plot.Height * (i / (double)horizontalDivisions); + AddLine(builder, new Point(plot.Left, y), new Point(plot.Right, y)); + } + + for (var i = 0; i <= verticalDivisions; i++) + { + var x = plot.Left + plot.Width * (i / (double)verticalDivisions); + AddLine(builder, new Point(x, plot.Top), new Point(x, plot.Bottom)); + } } - for (var i = 0; i <= verticalDivisions; i++) + var axis = new StreamGeometry(); + using (var builder = axis.Open()) { - var x = plot.Left + plot.Width * (i / (double)verticalDivisions); - context.DrawLine(GridPen, new Point(x, plot.Top), new Point(x, plot.Bottom)); + AddLine(builder, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom)); + AddLine(builder, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom)); } - context.DrawLine(AxisPen, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom)); - context.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom)); + return (grid, axis); + } + + private static void AddLine(StreamGeometryContext builder, Point start, Point end) + { + builder.BeginFigure(start, isFilled: false); + builder.LineTo(end); + builder.EndFigure(isClosed: false); } private void EnsureGeometry(Rect plot) { - if (!_geometryDirty && _cachedPlot == plot) + var visibleDurationSeconds = ResolveVisibleDurationSeconds(_points); + var pixelsPerSecond = plot.Width / visibleDurationSeconds; + var latestLogicalX = MapTimestampToLogicalX(_points[^1].Timestamp, _logicalOrigin, pixelsPerSecond); + _viewportTranslateX = plot.Right - latestLogicalX; + + if (!_geometryDirty && + _cachedPlot == plot && + Math.Abs(_cachedPixelsPerSecond - pixelsPerSecond) < 0.001) { return; } _cachedPlot = plot; - _lineGeometry = null; - _fillGeometry = null; + _cachedPixelsPerSecond = pixelsPerSecond; + _staticLineGeometry = null; + _staticFillGeometry = null; + _dynamicLineGeometry = null; + _dynamicFillGeometry = null; + _staticSourceCount = 0; + _dynamicSourceCount = 0; + + var firstTailIndex = ResolveFirstTailIndex(_points, TimeSpan.FromSeconds(DynamicTailSeconds)); + var dynamicStartIndex = Math.Max(0, firstTailIndex - 1); + var staticEndExclusive = firstTailIndex; + + if (staticEndExclusive >= 2) + { + (_staticLineGeometry, _staticFillGeometry, _staticSourceCount) = BuildLayerGeometry( + startIndex: 0, + endExclusive: staticEndExclusive, + plot, + pixelsPerSecond); + } + + if (_points.Count - dynamicStartIndex >= 2) + { + (_dynamicLineGeometry, _dynamicFillGeometry, _dynamicSourceCount) = BuildLayerGeometry( + dynamicStartIndex, + _points.Count, + plot, + pixelsPerSecond); + } + + _geometryDirty = false; + } + + private (StreamGeometry? Line, StreamGeometry? Fill, int SourceCount) BuildLayerGeometry( + int startIndex, + int endExclusive, + Rect plot, + double pixelsPerSecond) + { + var sourceCount = endExclusive - startIndex; + if (sourceCount < 2) + { + return (null, null, sourceCount); + } var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360); - var pointCount = BuildPlotPoints(plot, maxSamples); + var pointCount = BuildPlotPoints(startIndex, endExclusive, plot, pixelsPerSecond, maxSamples); if (pointCount < 2 || _pointBuffer is null) { - _geometryDirty = false; - return; + return (null, null, sourceCount); } var lineGeometry = new StreamGeometry(); @@ -163,14 +396,17 @@ public sealed class StudyNoiseCurveChartControl : Control builder.EndFigure(true); } - _lineGeometry = lineGeometry; - _fillGeometry = fillGeometry; - _geometryDirty = false; + return (lineGeometry, fillGeometry, sourceCount); } - private int BuildPlotPoints(Rect plot, int maxSamples) + private int BuildPlotPoints( + int startIndex, + int endExclusive, + Rect plot, + double pixelsPerSecond, + int maxSamples) { - var sourceCount = _points.Count; + var sourceCount = endExclusive - startIndex; if (sourceCount <= 1) { return 0; @@ -186,7 +422,7 @@ public sealed class StudyNoiseCurveChartControl : Control for (var i = 0; i < sourceCount; i++) { - _pointBuffer[i] = MapToPlot(plot, _points[i], _points[0].Timestamp, _points[^1].Timestamp); + _pointBuffer[i] = MapToPlot(plot, _points[startIndex + i], pixelsPerSecond); } return sourceCount; @@ -201,25 +437,23 @@ public sealed class StudyNoiseCurveChartControl : Control } var outputIndex = 0; - var startTimestamp = _points[0].Timestamp; - var endTimestamp = _points[^1].Timestamp; - _pointBuffer[outputIndex++] = MapToPlot(plot, _points[0], startTimestamp, endTimestamp); + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[startIndex], pixelsPerSecond); var middleCount = sourceCount - 2; var bucketWidth = middleCount / (double)bucketCount; - var lastSourceIndex = 0; + var lastSourceIndex = startIndex; for (var bucket = 0; bucket < bucketCount; bucket++) { - var rangeStart = 1 + (int)Math.Floor(bucket * bucketWidth); - var rangeEnd = 1 + (int)Math.Floor((bucket + 1) * bucketWidth); + var rangeStart = startIndex + 1 + (int)Math.Floor(bucket * bucketWidth); + var rangeEnd = startIndex + 1 + (int)Math.Floor((bucket + 1) * bucketWidth); if (bucket == bucketCount - 1) { - rangeEnd = sourceCount - 1; + rangeEnd = endExclusive - 1; } - rangeStart = Math.Clamp(rangeStart, 1, sourceCount - 2); - rangeEnd = Math.Clamp(rangeEnd, rangeStart + 1, sourceCount - 1); + rangeStart = Math.Clamp(rangeStart, startIndex + 1, endExclusive - 2); + rangeEnd = Math.Clamp(rangeEnd, rangeStart + 1, endExclusive - 1); var minIndex = rangeStart; var maxIndex = rangeStart; @@ -246,7 +480,7 @@ public sealed class StudyNoiseCurveChartControl : Control { if (minIndex != lastSourceIndex) { - _pointBuffer[outputIndex++] = MapToPlot(plot, _points[minIndex], startTimestamp, endTimestamp); + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[minIndex], pixelsPerSecond); lastSourceIndex = minIndex; } @@ -258,41 +492,31 @@ public sealed class StudyNoiseCurveChartControl : Control if (first != lastSourceIndex) { - _pointBuffer[outputIndex++] = MapToPlot(plot, _points[first], startTimestamp, endTimestamp); + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[first], pixelsPerSecond); lastSourceIndex = first; } if (second != lastSourceIndex) { - _pointBuffer[outputIndex++] = MapToPlot(plot, _points[second], startTimestamp, endTimestamp); + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[second], pixelsPerSecond); lastSourceIndex = second; } } - var finalIndex = sourceCount - 1; + var finalIndex = endExclusive - 1; if (finalIndex != lastSourceIndex) { - _pointBuffer[outputIndex++] = MapToPlot(plot, _points[finalIndex], startTimestamp, endTimestamp); + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[finalIndex], pixelsPerSecond); } return outputIndex; } - private static Point MapToPlot( - Rect plot, - NoiseRealtimePoint point, - DateTimeOffset start, - DateTimeOffset end) + private Point MapToPlot(Rect plot, NoiseRealtimePoint point, double pixelsPerSecond) { - const double minDisplayDb = 20; - const double maxDisplayDb = 100; - - var rangeTicks = Math.Max(1, (end - start).Ticks); - var offsetTicks = Math.Clamp((point.Timestamp - start).Ticks, 0, rangeTicks); - var x = plot.Left + plot.Width * (offsetTicks / (double)rangeTicks); - - var clampedDb = Math.Clamp(point.DisplayDb, minDisplayDb, maxDisplayDb); - var normalized = (clampedDb - minDisplayDb) / (maxDisplayDb - minDisplayDb); + var x = MapTimestampToLogicalX(point.Timestamp, _logicalOrigin, pixelsPerSecond); + var clampedDb = Math.Clamp(point.DisplayDb, MinDisplayDb, MaxDisplayDb); + var normalized = (clampedDb - MinDisplayDb) / (MaxDisplayDb - MinDisplayDb); var y = plot.Bottom - normalized * plot.Height; return new Point(x, y); } @@ -341,6 +565,7 @@ public sealed class StudyNoiseCurveChartControl : Control return HashCode.Combine( points.Count, first.Timestamp.UtcTicks, + Math.Round(first.DisplayDb, 2), last.Timestamp.UtcTicks, Math.Round(last.DisplayDb, 2)); } diff --git a/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs index fd40738..a0c6d06 100644 --- a/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs @@ -4,7 +4,6 @@ using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Media; -using Avalonia.Threading; using LanMountainDesktop.Models; using LanMountainDesktop.Services; using LanMountainDesktop.Theme; @@ -52,18 +51,12 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220"); private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA"); - private readonly object _snapshotSync = new(); private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings; private readonly LocalizationService _localizationService = new(); - private readonly DispatcherTimer _renderTimer = new() - { - Interval = TimeSpan.FromMilliseconds(33) - }; + private readonly StudySnapshotRenderGate _renderGate; - private StudyAnalyticsSnapshot? _pendingSnapshot; - private bool _hasPendingSnapshot; private double _currentCellSize = 48; private string _languageCode = "zh-CN"; private bool _isAttached; @@ -86,7 +79,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge { InitializeComponent(); - _renderTimer.Tick += OnRenderTimerTick; + _renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot, AfterSnapshotRendered); AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; SizeChanged += OnSizeChanged; @@ -141,14 +134,8 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge if (isOnActivePage && !wasOnActivePage) { - lock (_snapshotSync) - { - _pendingSnapshot = _studyAnalyticsService.GetSnapshot(); - _hasPendingSnapshot = true; - } + _renderGate.Queue(_studyAnalyticsService.GetSnapshot()); } - - UpdateRenderLoopState(); } private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) @@ -164,13 +151,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge UpdateMonitoringLeaseState(); - lock (_snapshotSync) - { - _pendingSnapshot = _studyAnalyticsService.GetSnapshot(); - _hasPendingSnapshot = true; - } - - UpdateRenderLoopState(); + _renderGate.Queue(_studyAnalyticsService.GetSnapshot()); } private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) @@ -178,7 +159,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge _isAttached = false; _monitoringLease?.Dispose(); _monitoringLease = null; - _renderTimer.Stop(); + _renderGate.Clear(); if (_isSubscribed) { @@ -200,45 +181,26 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge ApplyTypographyByBackground(panelColor); ApplyStatusBadgeStyle(StatusVisualKind.Default, panelColor); - lock (_snapshotSync) - { - _pendingSnapshot = _studyAnalyticsService.GetSnapshot(); - _hasPendingSnapshot = true; - } - - if (!_renderTimer.IsEnabled) - { - OnRenderTimerTick(this, EventArgs.Empty); - } + _renderGate.Queue(_studyAnalyticsService.GetSnapshot()); } private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e) { - lock (_snapshotSync) - { - _pendingSnapshot = e.Snapshot; - _hasPendingSnapshot = true; - } - } - - private void OnRenderTimerTick(object? sender, EventArgs e) - { - StudyAnalyticsSnapshot? snapshot = null; - lock (_snapshotSync) - { - if (_hasPendingSnapshot) - { - snapshot = _pendingSnapshot; - _hasPendingSnapshot = false; - } - } - - if (snapshot is null) + if (!_isAttached || !_isOnActivePage) { return; } - ApplySnapshot(snapshot); + _renderGate.Queue(e.Snapshot); + } + + private bool CanRenderSnapshot() + { + return _isAttached && _isOnActivePage; + } + + private void AfterSnapshotRendered() + { _framesSinceCompaction++; if (_framesSinceCompaction >= 900) { @@ -247,21 +209,6 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge } } - private void UpdateRenderLoopState() - { - if (_isAttached && _isOnActivePage) - { - if (!_renderTimer.IsEnabled) - { - _renderTimer.Start(); - } - - return; - } - - _renderTimer.Stop(); - } - private void UpdateMonitoringLeaseState() { if (!_studyEnabled) @@ -271,7 +218,8 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge return; } - if (_isAttached) + var shouldMonitor = _isAttached && _isOnActivePage; + if (shouldMonitor) { _monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease(); return; @@ -612,8 +560,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge _isDisposed = true; - _renderTimer.Stop(); - _renderTimer.Tick -= OnRenderTimerTick; + _renderGate.Dispose(); AttachedToVisualTree -= OnAttachedToVisualTree; DetachedFromVisualTree -= OnDetachedFromVisualTree; SizeChanged -= OnSizeChanged; diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionAreaChartControl.cs b/LanMountainDesktop/Views/Components/StudyNoiseDistributionAreaChartControl.cs new file mode 100644 index 0000000..0d8d85d --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudyNoiseDistributionAreaChartControl.cs @@ -0,0 +1,896 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Views.Components; + +public sealed class StudyNoiseDistributionAreaChartControl : Control +{ + private const double DynamicTailSeconds = 4; + private const double RealtimeVisibleSeconds = 12; + + private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#304E6780")); + private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1")); + private static readonly Pen GridPen = new(GridBrush, 1); + private static readonly Pen AxisPen = new(AxisBrush, 1.1); + + private static readonly IBrush QuietBandBrush = new SolidColorBrush(Color.Parse("#1834D399")); + private static readonly IBrush NormalBandBrush = new SolidColorBrush(Color.Parse("#1760A5FA")); + private static readonly IBrush NoisyBandBrush = new SolidColorBrush(Color.Parse("#16F59E0B")); + private static readonly IBrush ExtremeBandBrush = new SolidColorBrush(Color.Parse("#18EF4444")); + private static readonly IBrush AreaFillBrush = CreateAreaGradientBrush(0x56); + private static readonly IBrush DynamicAreaFillBrush = CreateAreaGradientBrush(0x78); + private static readonly IBrush LatestGlowBrush = new SolidColorBrush(Color.Parse("#5852D6FF")); + private static readonly IBrush LatestPointBrush = new SolidColorBrush(Color.Parse("#FFFFFFFF")); + private static readonly Pen StaticLinePen = new(new SolidColorBrush(Color.Parse("#C452AEEA")), 1.35); + private static readonly Pen DynamicLinePen = new(new SolidColorBrush(Color.Parse("#FF8BE8FF")), 1.9); + private static readonly Pen LatestPointPen = new(new SolidColorBrush(Color.Parse("#FF52D6FF")), 1.4); + + private IReadOnlyList _points = Array.Empty(); + private Point[]? _pointBuffer; + private StreamGeometry? _gridGeometry; + private StreamGeometry? _axisGeometry; + private StreamGeometry? _staticLineGeometry; + private StreamGeometry? _staticFillGeometry; + private StreamGeometry? _dynamicLineGeometry; + private StreamGeometry? _dynamicFillGeometry; + private Rect _cachedGridPlot; + private Rect _cachedPlot; + private DateTimeOffset _logicalOrigin; + private DateTimeOffset _lastSeriesStart; + private DateTimeOffset _lastSeriesEnd; + private double _baselineDb = 45; + private double _cachedBaselineDb = 45; + private double _cachedPixelsPerSecond; + private double _viewportTranslateX; + private bool _hasLogicalOrigin; + private bool _isStaticSeries; + private bool _staticGeometryDirty = true; + private bool _dynamicGeometryDirty = true; + private int _lastSeriesSignature; + private int _cachedStaticEndExclusive = -1; + private int _cachedDynamicStartIndex = -1; + private int _staticSourceCount; + private int _dynamicSourceCount; + private int _staticPathBuildVersion; + private int _dynamicPathBuildVersion; + private int _cachedPathCountForTesting; + + public void UpdateSeries(IReadOnlyList? points, double baselineDb) + { + UpdateSeries(points, baselineDb, isStaticSeries: false); + } + + public void UpdateSeries(IReadOnlyList? points, double baselineDb, bool isStaticSeries) + { + var nextPoints = points ?? Array.Empty(); + var nextBaselineDb = Math.Clamp(baselineDb, 20, 85); + var nextSignature = ComputeSeriesSignature(nextPoints, nextBaselineDb, isStaticSeries); + if (ReferenceEquals(_points, nextPoints) && + Math.Abs(_baselineDb - nextBaselineDb) < 0.001 && + _isStaticSeries == isStaticSeries && + _lastSeriesSignature == nextSignature) + { + return; + } + + var baselineChanged = Math.Abs(_baselineDb - nextBaselineDb) >= 0.001; + var modeChanged = _isStaticSeries != isStaticSeries; + UpdateLogicalOrigin(nextPoints, baselineChanged || modeChanged); + + _points = nextPoints; + _baselineDb = nextBaselineDb; + _isStaticSeries = isStaticSeries; + _lastSeriesSignature = nextSignature; + _dynamicGeometryDirty = true; + if (baselineChanged || modeChanged || nextPoints.Count < 2) + { + _staticGeometryDirty = true; + } + + InvalidateVisual(); + } + + public void CompactCaches() + { + ReleasePointBuffer(); + _staticLineGeometry = null; + _staticFillGeometry = null; + _dynamicLineGeometry = null; + _dynamicFillGeometry = null; + _staticGeometryDirty = true; + _dynamicGeometryDirty = true; + _cachedStaticEndExclusive = -1; + _cachedDynamicStartIndex = -1; + _cachedPathCountForTesting = 0; + } + + internal int StaticSourceCount => _staticSourceCount; + + internal int DynamicSourceCount => _dynamicSourceCount; + + internal int CachedPathCount + { + get + { + var count = 0; + if (_staticLineGeometry is not null) + { + count++; + } + + if (_staticFillGeometry is not null) + { + count++; + } + + if (_dynamicLineGeometry is not null) + { + count++; + } + + if (_dynamicFillGeometry is not null) + { + count++; + } + + return count > 0 ? count : _cachedPathCountForTesting; + } + } + + internal int StaticPathBuildVersion => _staticPathBuildVersion; + + internal int DynamicPathBuildVersion => _dynamicPathBuildVersion; + + internal void RebuildCacheForTesting(Rect plot) + { + if (_points.Count >= 2) + { + EnsureGeometryPlanForTesting(plot); + } + } + + internal static double ResolveVisibleDurationSeconds(IReadOnlyList points) + { + if (points.Count < 2) + { + return RealtimeVisibleSeconds; + } + + var duration = (points[^1].Timestamp - points[0].Timestamp).TotalSeconds; + if (double.IsNaN(duration) || double.IsInfinity(duration) || duration <= 1) + { + duration = RealtimeVisibleSeconds; + } + + return Math.Clamp(duration, 4, 60); + } + + internal static int ResolveFirstTailIndex(IReadOnlyList points, TimeSpan tailDuration) + { + if (points.Count <= 1) + { + return 0; + } + + var cutoff = points[^1].Timestamp - tailDuration; + for (var i = 0; i < points.Count; i++) + { + if (points[i].Timestamp >= cutoff) + { + return i; + } + } + + return points.Count - 1; + } + + internal static (int StaticSourceCount, int DynamicSourceCount) ResolveLayerSourceCounts( + IReadOnlyList points, + TimeSpan tailDuration, + bool isStaticSeries = false) + { + if (points.Count < 2) + { + return (0, 0); + } + + if (isStaticSeries) + { + return (points.Count, 0); + } + + var firstTailIndex = ResolveFirstTailIndex(points, tailDuration); + var dynamicStartIndex = Math.Max(0, firstTailIndex - 1); + var staticCount = firstTailIndex >= 2 ? firstTailIndex : 0; + var dynamicCount = points.Count - dynamicStartIndex >= 2 ? points.Count - dynamicStartIndex : 0; + return (staticCount, dynamicCount); + } + + internal static double MapTimestampToLogicalX(DateTimeOffset timestamp, DateTimeOffset origin, double pixelsPerSecond) + { + return Math.Max(0, (timestamp - origin).TotalSeconds * pixelsPerSecond); + } + + internal static NoiseDistributionLevel ResolveLevel(double displayDb, double baselineDb) + { + var quietUpper = baselineDb; + var normalUpper = baselineDb + 10d; + var noisyUpper = baselineDb + 20d; + + if (displayDb < quietUpper) + { + return NoiseDistributionLevel.Quiet; + } + + if (displayDb < normalUpper) + { + return NoiseDistributionLevel.Normal; + } + + if (displayDb < noisyUpper) + { + return NoiseDistributionLevel.Noisy; + } + + return NoiseDistributionLevel.Extreme; + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + ReleasePointBuffer(); + _gridGeometry = null; + _axisGeometry = null; + _staticLineGeometry = null; + _staticFillGeometry = null; + _dynamicLineGeometry = null; + _dynamicFillGeometry = null; + _staticGeometryDirty = true; + _dynamicGeometryDirty = true; + base.OnDetachedFromVisualTree(e); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var bounds = Bounds; + if (bounds.Width <= 2 || bounds.Height <= 2) + { + return; + } + + var plot = new Rect( + x: 1, + y: 1, + width: Math.Max(1, bounds.Width - 2), + height: Math.Max(1, bounds.Height - 2)); + + DrawLevelBands(context, plot); + DrawGrid(context, plot); + + if (_points.Count < 2) + { + return; + } + + EnsureGeometry(plot); + + using (context.PushClip(plot)) + using (context.PushTransform(Matrix.CreateTranslation(_viewportTranslateX, 0))) + { + if (_staticFillGeometry is not null) + { + context.DrawGeometry(AreaFillBrush, pen: null, _staticFillGeometry); + } + + if (_dynamicFillGeometry is not null) + { + context.DrawGeometry(DynamicAreaFillBrush, pen: null, _dynamicFillGeometry); + } + + if (_staticLineGeometry is not null) + { + context.DrawGeometry(brush: null, pen: StaticLinePen, _staticLineGeometry); + } + + if (_dynamicLineGeometry is not null) + { + context.DrawGeometry(brush: null, pen: DynamicLinePen, _dynamicLineGeometry); + } + + DrawLatestPoint(context, plot); + } + } + + private void UpdateLogicalOrigin(IReadOnlyList nextPoints, bool forceReset) + { + if (nextPoints.Count == 0) + { + ResetSeriesState(); + return; + } + + var nextStart = nextPoints[0].Timestamp; + var nextEnd = nextPoints[^1].Timestamp; + if (!_hasLogicalOrigin || forceReset) + { + ResetLogicalOrigin(nextStart); + } + else + { + var overlapsPreviousSeries = nextStart <= _lastSeriesEnd && nextEnd >= _lastSeriesStart; + if (!overlapsPreviousSeries || nextStart < _logicalOrigin) + { + ResetLogicalOrigin(nextStart); + } + } + + _lastSeriesStart = nextStart; + _lastSeriesEnd = nextEnd; + } + + private void ResetLogicalOrigin(DateTimeOffset origin) + { + _logicalOrigin = origin; + _hasLogicalOrigin = true; + _staticLineGeometry = null; + _staticFillGeometry = null; + _dynamicLineGeometry = null; + _dynamicFillGeometry = null; + _staticSourceCount = 0; + _dynamicSourceCount = 0; + _cachedStaticEndExclusive = -1; + _cachedDynamicStartIndex = -1; + _cachedPathCountForTesting = 0; + _staticGeometryDirty = true; + _dynamicGeometryDirty = true; + } + + private void ResetSeriesState() + { + _hasLogicalOrigin = false; + _lastSeriesStart = default; + _lastSeriesEnd = default; + _staticLineGeometry = null; + _staticFillGeometry = null; + _dynamicLineGeometry = null; + _dynamicFillGeometry = null; + _staticSourceCount = 0; + _dynamicSourceCount = 0; + _cachedStaticEndExclusive = -1; + _cachedDynamicStartIndex = -1; + _cachedPathCountForTesting = 0; + _staticGeometryDirty = true; + _dynamicGeometryDirty = true; + } + + private void DrawLevelBands(DrawingContext context, Rect plot) + { + var quietTop = MapDbToY(plot, _baselineDb); + var normalTop = MapDbToY(plot, _baselineDb + 10d); + var noisyTop = MapDbToY(plot, _baselineDb + 20d); + + context.DrawRectangle(ExtremeBandBrush, null, new Rect(plot.Left, plot.Top, plot.Width, Math.Max(0, noisyTop - plot.Top))); + context.DrawRectangle(NoisyBandBrush, null, new Rect(plot.Left, noisyTop, plot.Width, Math.Max(0, normalTop - noisyTop))); + context.DrawRectangle(NormalBandBrush, null, new Rect(plot.Left, normalTop, plot.Width, Math.Max(0, quietTop - normalTop))); + context.DrawRectangle(QuietBandBrush, null, new Rect(plot.Left, quietTop, plot.Width, Math.Max(0, plot.Bottom - quietTop))); + } + + private void DrawGrid(DrawingContext context, Rect plot) + { + if (_gridGeometry is null || _axisGeometry is null || _cachedGridPlot != plot) + { + _cachedGridPlot = plot; + (_gridGeometry, _axisGeometry) = BuildGridGeometry(plot); + } + + context.DrawGeometry(brush: null, pen: GridPen, _gridGeometry); + context.DrawGeometry(brush: null, pen: AxisPen, _axisGeometry); + } + + private static (StreamGeometry Grid, StreamGeometry Axis) BuildGridGeometry(Rect plot) + { + const int horizontalDivisions = 4; + const int verticalDivisions = 4; + + var grid = new StreamGeometry(); + using (var builder = grid.Open()) + { + for (var i = 0; i <= horizontalDivisions; i++) + { + var y = plot.Top + plot.Height * (i / (double)horizontalDivisions); + AddLine(builder, new Point(plot.Left, y), new Point(plot.Right, y)); + } + + for (var i = 0; i <= verticalDivisions; i++) + { + var x = plot.Left + plot.Width * (i / (double)verticalDivisions); + AddLine(builder, new Point(x, plot.Top), new Point(x, plot.Bottom)); + } + } + + var axis = new StreamGeometry(); + using (var builder = axis.Open()) + { + AddLine(builder, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom)); + AddLine(builder, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom)); + } + + return (grid, axis); + } + + private static void AddLine(StreamGeometryContext builder, Point start, Point end) + { + builder.BeginFigure(start, isFilled: false); + builder.LineTo(end); + builder.EndFigure(isClosed: false); + } + + private void EnsureGeometry(Rect plot) + { + var visibleDurationSeconds = _isStaticSeries + ? ResolveVisibleDurationSeconds(_points) + : RealtimeVisibleSeconds; + var pixelsPerSecond = plot.Width / Math.Max(0.001, visibleDurationSeconds); + var latestLogicalX = MapTimestampToLogicalX(_points[^1].Timestamp, _logicalOrigin, pixelsPerSecond); + _viewportTranslateX = plot.Right - latestLogicalX; + + var metricsChanged = _cachedPlot != plot || + Math.Abs(_cachedPixelsPerSecond - pixelsPerSecond) >= 0.001 || + Math.Abs(_cachedBaselineDb - _baselineDb) >= 0.001; + if (metricsChanged) + { + _cachedPlot = plot; + _cachedPixelsPerSecond = pixelsPerSecond; + _cachedBaselineDb = _baselineDb; + _staticGeometryDirty = true; + _dynamicGeometryDirty = true; + } + + var firstTailIndex = _isStaticSeries + ? _points.Count + : ResolveFirstTailIndex(_points, TimeSpan.FromSeconds(DynamicTailSeconds)); + var dynamicStartIndex = _isStaticSeries ? _points.Count : Math.Max(0, firstTailIndex - 1); + var staticEndExclusive = firstTailIndex; + + if (staticEndExclusive != _cachedStaticEndExclusive) + { + _staticGeometryDirty = true; + } + + if (dynamicStartIndex != _cachedDynamicStartIndex) + { + _dynamicGeometryDirty = true; + } + + if (_staticGeometryDirty) + { + RebuildStaticGeometry(plot, pixelsPerSecond, staticEndExclusive); + } + + if (_dynamicGeometryDirty) + { + RebuildDynamicGeometry(plot, pixelsPerSecond, dynamicStartIndex); + } + } + + private void EnsureGeometryPlanForTesting(Rect plot) + { + var visibleDurationSeconds = _isStaticSeries + ? ResolveVisibleDurationSeconds(_points) + : RealtimeVisibleSeconds; + var pixelsPerSecond = plot.Width / Math.Max(0.001, visibleDurationSeconds); + var latestLogicalX = MapTimestampToLogicalX(_points[^1].Timestamp, _logicalOrigin, pixelsPerSecond); + _viewportTranslateX = plot.Right - latestLogicalX; + + var metricsChanged = _cachedPlot != plot || + Math.Abs(_cachedPixelsPerSecond - pixelsPerSecond) >= 0.001 || + Math.Abs(_cachedBaselineDb - _baselineDb) >= 0.001; + if (metricsChanged) + { + _cachedPlot = plot; + _cachedPixelsPerSecond = pixelsPerSecond; + _cachedBaselineDb = _baselineDb; + _staticGeometryDirty = true; + _dynamicGeometryDirty = true; + } + + var firstTailIndex = _isStaticSeries + ? _points.Count + : ResolveFirstTailIndex(_points, TimeSpan.FromSeconds(DynamicTailSeconds)); + var dynamicStartIndex = _isStaticSeries ? _points.Count : Math.Max(0, firstTailIndex - 1); + var staticEndExclusive = firstTailIndex; + + if (staticEndExclusive != _cachedStaticEndExclusive) + { + _staticGeometryDirty = true; + } + + if (dynamicStartIndex != _cachedDynamicStartIndex) + { + _dynamicGeometryDirty = true; + } + + if (_staticGeometryDirty) + { + _staticSourceCount = staticEndExclusive >= 2 ? staticEndExclusive : 0; + _cachedStaticEndExclusive = staticEndExclusive; + _staticGeometryDirty = false; + _staticPathBuildVersion++; + } + + if (_dynamicGeometryDirty) + { + _dynamicSourceCount = !_isStaticSeries && _points.Count - dynamicStartIndex >= 2 + ? _points.Count - dynamicStartIndex + : 0; + _cachedDynamicStartIndex = dynamicStartIndex; + _dynamicGeometryDirty = false; + _dynamicPathBuildVersion++; + } + + _cachedPathCountForTesting = (_staticSourceCount >= 2 ? 2 : 0) + (_dynamicSourceCount >= 2 ? 2 : 0); + } + + private void RebuildStaticGeometry(Rect plot, double pixelsPerSecond, int staticEndExclusive) + { + _staticLineGeometry = null; + _staticFillGeometry = null; + _staticSourceCount = 0; + + if (staticEndExclusive >= 2) + { + (_staticLineGeometry, _staticFillGeometry, _staticSourceCount) = BuildLayerGeometry( + startIndex: 0, + endExclusive: staticEndExclusive, + plot, + pixelsPerSecond); + } + + _cachedStaticEndExclusive = staticEndExclusive; + _staticGeometryDirty = false; + _staticPathBuildVersion++; + } + + private void RebuildDynamicGeometry(Rect plot, double pixelsPerSecond, int dynamicStartIndex) + { + _dynamicLineGeometry = null; + _dynamicFillGeometry = null; + _dynamicSourceCount = 0; + + if (!_isStaticSeries && _points.Count - dynamicStartIndex >= 2) + { + (_dynamicLineGeometry, _dynamicFillGeometry, _dynamicSourceCount) = BuildLayerGeometry( + dynamicStartIndex, + _points.Count, + plot, + pixelsPerSecond); + } + + _cachedDynamicStartIndex = dynamicStartIndex; + _dynamicGeometryDirty = false; + _dynamicPathBuildVersion++; + } + + private (StreamGeometry? Line, StreamGeometry? Fill, int SourceCount) BuildLayerGeometry( + int startIndex, + int endExclusive, + Rect plot, + double pixelsPerSecond) + { + var sourceCount = endExclusive - startIndex; + if (sourceCount < 2) + { + return (null, null, sourceCount); + } + + var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 64, 420); + var pointCount = BuildPlotPoints(startIndex, endExclusive, plot, pixelsPerSecond, maxSamples); + if (pointCount < 2 || _pointBuffer is null) + { + return (null, null, sourceCount); + } + + var lineGeometry = new StreamGeometry(); + using (var builder = lineGeometry.Open()) + { + AddSmoothedPath(builder, _pointBuffer, pointCount, isFilled: false); + } + + var fillGeometry = new StreamGeometry(); + using (var builder = fillGeometry.Open()) + { + var first = _pointBuffer[0]; + builder.BeginFigure(new Point(first.X, plot.Bottom), true); + builder.LineTo(first); + AddSmoothedSegments(builder, _pointBuffer, pointCount); + + var last = _pointBuffer[pointCount - 1]; + builder.LineTo(new Point(last.X, plot.Bottom)); + builder.LineTo(new Point(first.X, plot.Bottom)); + builder.EndFigure(true); + } + + return (lineGeometry, fillGeometry, sourceCount); + } + + private static void AddSmoothedPath(StreamGeometryContext builder, Point[] points, int pointCount, bool isFilled) + { + builder.BeginFigure(points[0], isFilled); + AddSmoothedSegments(builder, points, pointCount); + builder.EndFigure(isClosed: false); + } + + private static void AddSmoothedSegments(StreamGeometryContext builder, Point[] points, int pointCount) + { + if (pointCount < 2) + { + return; + } + + if (pointCount == 2) + { + builder.LineTo(points[1]); + return; + } + + for (var i = 0; i < pointCount - 1; i++) + { + var p0 = i == 0 ? points[i] : points[i - 1]; + var p1 = points[i]; + var p2 = points[i + 1]; + var p3 = i + 2 < pointCount ? points[i + 2] : p2; + + var control1 = new Point( + p1.X + (p2.X - p0.X) / 6d, + p1.Y + (p2.Y - p0.Y) / 6d); + var control2 = new Point( + p2.X - (p3.X - p1.X) / 6d, + p2.Y - (p3.Y - p1.Y) / 6d); + + builder.CubicBezierTo(control1, control2, p2); + } + } + + private int BuildPlotPoints( + int startIndex, + int endExclusive, + Rect plot, + double pixelsPerSecond, + int maxSamples) + { + var sourceCount = endExclusive - startIndex; + if (sourceCount <= 1) + { + return 0; + } + + if (sourceCount <= maxSamples) + { + EnsurePointBufferCapacity(sourceCount); + if (_pointBuffer is null) + { + return 0; + } + + for (var i = 0; i < sourceCount; i++) + { + _pointBuffer[i] = MapToPlot(plot, _points[startIndex + i], pixelsPerSecond); + } + + return sourceCount; + } + + var bucketCount = Math.Max(1, (maxSamples - 2) / 2); + var targetCapacity = 2 + bucketCount * 2; + EnsurePointBufferCapacity(targetCapacity); + if (_pointBuffer is null) + { + return 0; + } + + var outputIndex = 0; + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[startIndex], pixelsPerSecond); + + var middleCount = sourceCount - 2; + var bucketWidth = middleCount / (double)bucketCount; + var lastSourceIndex = startIndex; + + for (var bucket = 0; bucket < bucketCount; bucket++) + { + var rangeStart = startIndex + 1 + (int)Math.Floor(bucket * bucketWidth); + var rangeEnd = startIndex + 1 + (int)Math.Floor((bucket + 1) * bucketWidth); + if (bucket == bucketCount - 1) + { + rangeEnd = endExclusive - 1; + } + + rangeStart = Math.Clamp(rangeStart, startIndex + 1, endExclusive - 2); + rangeEnd = Math.Clamp(rangeEnd, rangeStart + 1, endExclusive - 1); + + var minIndex = rangeStart; + var maxIndex = rangeStart; + var minValue = _points[rangeStart].DisplayDb; + var maxValue = minValue; + + for (var i = rangeStart + 1; i < rangeEnd; i++) + { + var value = _points[i].DisplayDb; + if (value < minValue) + { + minValue = value; + minIndex = i; + } + + if (value > maxValue) + { + maxValue = value; + maxIndex = i; + } + } + + if (minIndex == maxIndex) + { + if (minIndex != lastSourceIndex) + { + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[minIndex], pixelsPerSecond); + lastSourceIndex = minIndex; + } + + continue; + } + + var first = minIndex < maxIndex ? minIndex : maxIndex; + var second = minIndex < maxIndex ? maxIndex : minIndex; + + if (first != lastSourceIndex) + { + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[first], pixelsPerSecond); + lastSourceIndex = first; + } + + if (second != lastSourceIndex) + { + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[second], pixelsPerSecond); + lastSourceIndex = second; + } + } + + var finalIndex = endExclusive - 1; + if (finalIndex != lastSourceIndex) + { + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[finalIndex], pixelsPerSecond); + } + + return outputIndex; + } + + private Point MapToPlot(Rect plot, NoiseRealtimePoint point, double pixelsPerSecond) + { + var x = MapTimestampToLogicalX(point.Timestamp, _logicalOrigin, pixelsPerSecond); + var y = MapDbToY(plot, point.DisplayDb); + return new Point(x, y); + } + + private double MapDbToY(Rect plot, double displayDb) + { + var minDb = _baselineDb - 5d; + var maxDb = _baselineDb + 25d; + var normalized = (displayDb - minDb) / Math.Max(0.001, maxDb - minDb); + normalized = Math.Clamp(normalized, 0, 1); + return plot.Bottom - normalized * plot.Height; + } + + private void DrawLatestPoint(DrawingContext context, Rect plot) + { + if (_points.Count == 0) + { + return; + } + + var latest = _points[^1]; + var center = MapToPlot(plot, latest, _cachedPixelsPerSecond); + var level = ResolveLevel(latest.DisplayDb, _baselineDb); + var levelBrush = GetLevelBrush(level); + var radius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 34d, 3.5, 8); + + context.DrawEllipse(LatestGlowBrush, null, center, radius * 2.8, radius * 2.8); + context.DrawEllipse(levelBrush, LatestPointPen, center, radius, radius); + context.DrawEllipse(LatestPointBrush, null, center, radius * 0.36, radius * 0.36); + } + + private static IBrush GetLevelBrush(NoiseDistributionLevel level) + { + return level switch + { + NoiseDistributionLevel.Quiet => new SolidColorBrush(Color.Parse("#FF34D399")), + NoiseDistributionLevel.Normal => new SolidColorBrush(Color.Parse("#FF60A5FA")), + NoiseDistributionLevel.Noisy => new SolidColorBrush(Color.Parse("#FFF59E0B")), + NoiseDistributionLevel.Extreme => new SolidColorBrush(Color.Parse("#FFEF4444")), + _ => new SolidColorBrush(Color.Parse("#FF60A5FA")) + }; + } + + private void EnsurePointBufferCapacity(int required) + { + if (required <= 0) + { + return; + } + + if (_pointBuffer is not null && _pointBuffer.Length >= required) + { + return; + } + + var next = ArrayPool.Shared.Rent(required); + if (_pointBuffer is not null) + { + ArrayPool.Shared.Return(_pointBuffer, clearArray: false); + } + + _pointBuffer = next; + } + + private void ReleasePointBuffer() + { + if (_pointBuffer is null) + { + return; + } + + ArrayPool.Shared.Return(_pointBuffer, clearArray: false); + _pointBuffer = null; + } + + private static IBrush CreateAreaGradientBrush(byte alpha) + { + return new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.FromArgb(alpha, 0xEF, 0x44, 0x44), 0.00), + new GradientStop(Color.FromArgb(alpha, 0xF5, 0x9E, 0x0B), 0.28), + new GradientStop(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA), 0.62), + new GradientStop(Color.FromArgb(alpha, 0x34, 0xD3, 0x99), 1.00) + } + }; + } + + private static int ComputeSeriesSignature( + IReadOnlyList points, + double baselineDb, + bool isStaticSeries) + { + if (points.Count == 0) + { + return HashCode.Combine(0, Math.Round(baselineDb, 2), isStaticSeries); + } + + var first = points[0]; + var last = points[^1]; + return HashCode.Combine( + points.Count, + first.Timestamp.UtcTicks, + Math.Round(first.DisplayDb, 2), + last.Timestamp.UtcTicks, + Math.Round(last.DisplayDb, 2), + Math.Round(baselineDb, 2), + isStaticSeries); + } +} + +public enum NoiseDistributionLevel +{ + Quiet = 0, + Normal = 1, + Noisy = 2, + Extreme = 3 +} diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs b/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs deleted file mode 100644 index e74cf9d..0000000 --- a/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs +++ /dev/null @@ -1,376 +0,0 @@ -using System; -using System.Collections.Generic; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Media; -using LanMountainDesktop.Models; - -namespace LanMountainDesktop.Views.Components; - -public sealed class StudyNoiseDistributionScatterChartControl : Control -{ - private readonly record struct SampledPoint(double X, double Y, NoiseDistributionLevel Level); - - private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#2E5E7A96")); - private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1")); - private static readonly Pen GridPen = new(GridBrush, 1); - private static readonly Pen AxisPen = new(AxisBrush, 1.1); - - private static readonly IBrush QuietBrush = new SolidColorBrush(Color.Parse("#FF34D399")); - private static readonly IBrush NormalBrush = new SolidColorBrush(Color.Parse("#FF60A5FA")); - private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B")); - private static readonly IBrush ExtremeBrush = new SolidColorBrush(Color.Parse("#FFEF4444")); - private static readonly byte[] CloudAlphas = [44, 58, 72, 86]; - private static readonly byte[] GlowAlphas = [26, 36]; - private static readonly IBrush[][] CloudBrushes = CreateBrushTable(CloudAlphas); - private static readonly IBrush[][] GlowBrushes = CreateBrushTable(GlowAlphas); - - private IReadOnlyList _points = Array.Empty(); - private SampledPoint[] _sampledPoints = Array.Empty(); - private int _sampledPointCount; - private double _baselineDb = 45; - private Rect _cachedPlot; - private bool _sampleCacheDirty = true; - private int _lastSeriesSignature; - - public void UpdateSeries(IReadOnlyList? points, double baselineDb) - { - var nextPoints = points ?? Array.Empty(); - var nextBaselineDb = Math.Clamp(baselineDb, 20, 85); - var nextSignature = ComputeSeriesSignature(nextPoints, nextBaselineDb); - if (ReferenceEquals(_points, nextPoints) && - Math.Abs(_baselineDb - nextBaselineDb) < 0.001 && - _lastSeriesSignature == nextSignature) - { - return; - } - - _points = nextPoints; - _baselineDb = nextBaselineDb; - _lastSeriesSignature = nextSignature; - _sampleCacheDirty = true; - InvalidateVisual(); - } - - public override void Render(DrawingContext context) - { - base.Render(context); - - var bounds = Bounds; - if (bounds.Width <= 2 || bounds.Height <= 2) - { - return; - } - - var plot = new Rect( - x: 1, - y: 1, - width: Math.Max(1, bounds.Width - 2), - height: Math.Max(1, bounds.Height - 2)); - - DrawGrid(context, plot); - - if (_points.Count < 2) - { - return; - } - - EnsureSampleCache(plot); - if (_sampledPointCount < 2) - { - return; - } - - DrawElectronCloud(context, plot); - } - - private void DrawElectronCloud(DrawingContext context, Rect plot) - { - var cloudLayers = CloudAlphas.Length; - var baseRadius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 45d, 3, 12); - - for (var layer = cloudLayers - 1; layer >= 0; layer--) - { - var layerRatio = cloudLayers == 1 ? 0d : layer / (double)(cloudLayers - 1); - var layerRadius = baseRadius * (1.2 + layerRatio * 0.8); - var layerBrushes = CloudBrushes[layer]; - - for (var i = 0; i < _sampledPointCount; i++) - { - var pt = _sampledPoints[i]; - var jitterX = ComputeJitter(pt.X * 1000 + layer) * layerRadius * 0.3; - var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3; - - context.DrawEllipse( - layerBrushes[(int)pt.Level], - pen: null, - center: new Point(pt.X + jitterX, pt.Y + jitterY), - radiusX: layerRadius, - radiusY: layerRadius * 0.7); - } - } - - var glowLayers = GlowAlphas.Length; - for (var layer = glowLayers - 1; layer >= 0; layer--) - { - var layerRatio = glowLayers == 1 ? 0d : layer / (double)(glowLayers - 1); - var layerRadius = baseRadius * (0.8 + layerRatio * 0.6); - var layerBrushes = GlowBrushes[layer]; - for (var i = 0; i < _sampledPointCount; i++) - { - var pt = _sampledPoints[i]; - context.DrawEllipse( - layerBrushes[(int)pt.Level], - pen: null, - center: new Point(pt.X, pt.Y), - radiusX: layerRadius, - radiusY: layerRadius * 0.6); - } - } - - var latest = _sampledPoints[_sampledPointCount - 1]; - for (var i = 3; i >= 0; i--) - { - var radius = baseRadius * (1.5 + i * 0.8); - var alpha = (byte)(30 - i * 6); - var glowBrush = GetAlphaBrush(latest.Level, alpha); - context.DrawEllipse(glowBrush, null, new Point(latest.X, latest.Y), radius, radius * 0.6); - } - - context.DrawEllipse( - GetLevelBrush(latest.Level), - new Pen(Brushes.White, 1.5), - new Point(latest.X, latest.Y), - baseRadius + 1, - baseRadius * 0.7 + 1); - - context.DrawEllipse( - Brushes.White, - null, - new Point(latest.X, latest.Y), - 2, - 2); - } - - private void EnsureSampleCache(Rect plot) - { - if (!_sampleCacheDirty && _cachedPlot == plot) - { - return; - } - - _cachedPlot = plot; - _sampledPointCount = BuildSampledPoints(plot); - _sampleCacheDirty = false; - } - - private static void DrawGrid(DrawingContext context, Rect plot) - { - const int verticalDivisions = 4; - - for (var i = 0; i <= verticalDivisions; i++) - { - var x = plot.Left + plot.Width * (i / (double)verticalDivisions); - context.DrawLine(GridPen, new Point(x, plot.Top), new Point(x, plot.Bottom)); - } - - for (var i = 0; i <= 4; i++) - { - var y = plot.Top + plot.Height * (i / 4d); - context.DrawLine(GridPen, new Point(plot.Left, y), new Point(plot.Right, y)); - } - - context.DrawLine(AxisPen, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom)); - context.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom)); - } - - private static double MapX(Rect plot, DateTimeOffset timestamp, DateTimeOffset start, long totalTicks) - { - var offsetTicks = Math.Clamp((timestamp - start).Ticks, 0, totalTicks); - return plot.Left + plot.Width * (offsetTicks / (double)totalTicks); - } - - private double MapYContinuous(Rect plot, double displayDb) - { - var minDb = _baselineDb - 5; - var maxDb = _baselineDb + 25; - var dbRange = maxDb - minDb; - if (dbRange <= 0) - { - dbRange = 30; - } - - var normalizedDb = (displayDb - minDb) / dbRange; - normalizedDb = Math.Clamp(normalizedDb, 0, 1); - - return plot.Bottom - (normalizedDb * plot.Height); - } - - private static double ComputeJitter(double value) - { - var hash = (ulong)(value * 1000000); - hash ^= hash >> 33; - hash *= 0xff51afd7ed558ccdUL; - hash ^= hash >> 33; - hash *= 0xc4ceb9fe1a85ec53UL; - hash ^= hash >> 33; - var normalized = (hash & 0xFFFF) / 65535d; - return (normalized * 2d) - 1d; - } - - private static NoiseDistributionLevel ResolveLevel(double displayDb, double baselineDb) - { - var quietUpper = baselineDb; - var normalUpper = baselineDb + 10d; - var noisyUpper = baselineDb + 20d; - - if (displayDb < quietUpper) - { - return NoiseDistributionLevel.Quiet; - } - - if (displayDb < normalUpper) - { - return NoiseDistributionLevel.Normal; - } - - if (displayDb < noisyUpper) - { - return NoiseDistributionLevel.Noisy; - } - - return NoiseDistributionLevel.Extreme; - } - - private static IBrush GetLevelBrush(NoiseDistributionLevel level) - { - return level switch - { - NoiseDistributionLevel.Quiet => QuietBrush, - NoiseDistributionLevel.Normal => NormalBrush, - NoiseDistributionLevel.Noisy => NoisyBrush, - NoiseDistributionLevel.Extreme => ExtremeBrush, - _ => NormalBrush - }; - } - - private static IBrush GetLevelBrushWithAlpha(NoiseDistributionLevel level, byte alpha) - { - return level switch - { - NoiseDistributionLevel.Quiet => new SolidColorBrush(Color.FromArgb(alpha, 0x34, 0xD3, 0x99)), - NoiseDistributionLevel.Normal => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA)), - NoiseDistributionLevel.Noisy => new SolidColorBrush(Color.FromArgb(alpha, 0xF5, 0x9E, 0x0B)), - NoiseDistributionLevel.Extreme => new SolidColorBrush(Color.FromArgb(alpha, 0xEF, 0x44, 0x44)), - _ => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA)) - }; - } - - private int BuildSampledPoints(Rect plot) - { - if (_points.Count < 2) - { - return 0; - } - - var maxSamples = Math.Clamp((int)Math.Ceiling(plot.Width / 2d), 48, 144); - var targetCount = Math.Min(_points.Count, maxSamples); - if (_sampledPoints.Length < targetCount) - { - _sampledPoints = new SampledPoint[targetCount]; - } - - var start = _points[0].Timestamp; - var end = _points[^1].Timestamp; - var totalTicks = Math.Max(1, (end - start).Ticks); - var step = _points.Count <= targetCount - ? 1d - : (_points.Count - 1d) / Math.Max(1d, targetCount - 1d); - - var outputIndex = 0; - var lastSourceIndex = -1; - for (var i = 0; i < targetCount; i++) - { - var sourceIndex = i == targetCount - 1 - ? _points.Count - 1 - : (int)Math.Round(i * step); - sourceIndex = Math.Clamp(sourceIndex, 0, _points.Count - 1); - if (sourceIndex == lastSourceIndex) - { - continue; - } - - var point = _points[sourceIndex]; - _sampledPoints[outputIndex++] = new SampledPoint( - MapX(plot, point.Timestamp, start, totalTicks), - MapYContinuous(plot, point.DisplayDb), - ResolveLevel(point.DisplayDb, _baselineDb)); - lastSourceIndex = sourceIndex; - } - - return outputIndex; - } - - private static int ComputeSeriesSignature(IReadOnlyList points, double baselineDb) - { - if (points.Count == 0) - { - return HashCode.Combine(0, baselineDb); - } - - var first = points[0]; - var last = points[^1]; - return HashCode.Combine( - points.Count, - first.Timestamp.UtcTicks, - last.Timestamp.UtcTicks, - Math.Round(last.DisplayDb, 2), - Math.Round(baselineDb, 2)); - } - - private static IBrush[][] CreateBrushTable(IReadOnlyList alphas) - { - var table = new IBrush[alphas.Count][]; - for (var i = 0; i < alphas.Count; i++) - { - table[i] = - [ - GetLevelBrushWithAlpha(NoiseDistributionLevel.Quiet, alphas[i]), - GetLevelBrushWithAlpha(NoiseDistributionLevel.Normal, alphas[i]), - GetLevelBrushWithAlpha(NoiseDistributionLevel.Noisy, alphas[i]), - GetLevelBrushWithAlpha(NoiseDistributionLevel.Extreme, alphas[i]) - ]; - } - - return table; - } - - private static IBrush GetAlphaBrush(NoiseDistributionLevel level, byte alpha) - { - for (var i = 0; i < CloudAlphas.Length; i++) - { - if (CloudAlphas[i] == alpha) - { - return CloudBrushes[i][(int)level]; - } - } - - for (var i = 0; i < GlowAlphas.Length; i++) - { - if (GlowAlphas[i] == alpha) - { - return GlowBrushes[i][(int)level]; - } - } - - return GetLevelBrushWithAlpha(level, alpha); - } -} - -public enum NoiseDistributionLevel -{ - Quiet = 0, - Normal = 1, - Noisy = 2, - Extreme = 3 -} diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml index 48de938..7a75e42 100644 --- a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml +++ b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml @@ -83,10 +83,10 @@ FontSize="10" /> - + _realtimeHistory = new(); private double _currentCellSize = 48; private bool _isAttached; private bool _isOnActivePage = true; + private bool _isSubscribed; private bool _isCompactMode; private bool _isUltraCompactMode; private bool _isExpandedMode; @@ -64,14 +61,14 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi { InitializeComponent(); - _uiTimer.Tick += OnUiTimerTick; + _renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot); AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; SizeChanged += OnSizeChanged; ActualThemeVariantChanged += OnActualThemeVariantChanged; ReloadLanguageCode(); ApplyCellSize(_currentCellSize); - RefreshVisual(); + ApplySnapshot(_studyAnalyticsService.GetSnapshot()); } public void ApplyCellSize(double cellSize) @@ -83,17 +80,26 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) { _ = isEditMode; + var wasOnActivePage = _isOnActivePage; _isOnActivePage = isOnActivePage; UpdateMonitoringLeaseState(); - UpdateTimerState(); + if (isOnActivePage && !wasOnActivePage) + { + RefreshVisual(); + } } private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = true; ReloadLanguageCode(); + if (!_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated; + _isSubscribed = true; + } + UpdateMonitoringLeaseState(); - UpdateTimerState(); RefreshVisual(); } @@ -102,7 +108,13 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi _isAttached = false; _monitoringLease?.Dispose(); _monitoringLease = null; - _uiTimer.Stop(); + _renderGate.Clear(); + + if (_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated; + _isSubscribed = false; + } } private void OnSizeChanged(object? sender, SizeChangedEventArgs e) @@ -116,24 +128,19 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi RefreshVisual(); } - private void OnUiTimerTick(object? sender, EventArgs e) + private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e) { - RefreshVisual(); - } - - private void UpdateTimerState() - { - if (_isAttached && _isOnActivePage) + if (!_isAttached || !_isOnActivePage) { - if (!_uiTimer.IsEnabled) - { - _uiTimer.Start(); - } - return; } - _uiTimer.Stop(); + _renderGate.Queue(e.Snapshot); + } + + private bool CanRenderSnapshot() + { + return _isAttached && _isOnActivePage; } private void UpdateMonitoringLeaseState() @@ -157,6 +164,11 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi } private void RefreshVisual() + { + _renderGate.Queue(_studyAnalyticsService.GetSnapshot()); + } + + private void ApplySnapshot(StudyAnalyticsSnapshot snapshot) { ApplyLocalizedLabels(); @@ -172,8 +184,6 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi return; } - var snapshot = _studyAnalyticsService.GetSnapshot(); - var realtimeScore = ComputeRealtimeScore(snapshot); if (snapshot.DataMode == StudyDataMode.Realtime && realtimeScore is { } score) { @@ -181,6 +191,13 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi } var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running; + var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; + if (isSessionReport) + { + ApplySessionReportMode(snapshot, panelColor); + return; + } + if (isSessionRunning) { ApplySessionMode(snapshot, realtimeScore, panelColor); diff --git a/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs index 7cf23c6..3826692 100644 --- a/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs @@ -52,15 +52,17 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings; private readonly LocalizationService _localizationService = new(); + private readonly StudySnapshotRenderGate _renderGate; private readonly DispatcherTimer _uiTimer = new() { - Interval = TimeSpan.FromMilliseconds(250) + Interval = TimeSpan.FromSeconds(1) }; private double _currentCellSize = 48; private string _languageCode = "zh-CN"; private bool _isAttached; private bool _isOnActivePage = true; + private bool _isSubscribed; private bool _isDisposed; private bool _isCompactMode; private bool _isUltraCompactMode; @@ -73,6 +75,7 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW { InitializeComponent(); + _renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot); _uiTimer.Tick += OnUiTimerTick; AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; @@ -81,7 +84,7 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW ReloadLanguageCode(); ApplyCellSize(_currentCellSize); - RefreshVisual(); + ApplySnapshot(_studyAnalyticsService.GetSnapshot()); } public void ApplyCellSize(double cellSize) @@ -93,15 +96,26 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) { _ = isEditMode; + var wasOnActivePage = _isOnActivePage; _isOnActivePage = isOnActivePage; UpdateMonitoringLeaseState(); UpdateTimerState(); + if (isOnActivePage && !wasOnActivePage) + { + RefreshVisual(); + } } private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = true; ReloadLanguageCode(); + if (!_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated; + _isSubscribed = true; + } + UpdateMonitoringLeaseState(); UpdateTimerState(); RefreshVisual(); @@ -113,6 +127,13 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW _monitoringLease?.Dispose(); _monitoringLease = null; _uiTimer.Stop(); + _renderGate.Clear(); + + if (_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated; + _isSubscribed = false; + } } private void OnSizeChanged(object? sender, SizeChangedEventArgs e) @@ -131,6 +152,21 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW RefreshVisual(); } + private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e) + { + if (!_isAttached || !_isOnActivePage) + { + return; + } + + _renderGate.Queue(e.Snapshot); + } + + private bool CanRenderSnapshot() + { + return _isAttached && _isOnActivePage; + } + private void UpdateTimerState() { if (_isAttached && _isOnActivePage) @@ -191,6 +227,11 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW } private void RefreshVisual() + { + _renderGate.Queue(_studyAnalyticsService.GetSnapshot()); + } + + private void ApplySnapshot(StudyAnalyticsSnapshot snapshot) { var now = DateTimeOffset.UtcNow; var panelColor = ResolvePanelBackgroundColor(); @@ -205,8 +246,6 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW return; } - var snapshot = _studyAnalyticsService.GetSnapshot(); - if (_transientMessage is not null && now > _transientMessageExpireAt) { _transientMessage = null; @@ -486,9 +525,16 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW _uiTimer.Stop(); _uiTimer.Tick -= OnUiTimerTick; + _renderGate.Dispose(); AttachedToVisualTree -= OnAttachedToVisualTree; DetachedFromVisualTree -= OnDetachedFromVisualTree; SizeChanged -= OnSizeChanged; ActualThemeVariantChanged -= OnActualThemeVariantChanged; + + if (_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated; + _isSubscribed = false; + } } } diff --git a/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs index 43b054b..e79bef2 100644 --- a/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs @@ -6,7 +6,6 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; -using Avalonia.Threading; using FluentIcons.Avalonia; using FluentIcons.Common; using LanMountainDesktop.Models; @@ -49,6 +48,7 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings; private readonly LocalizationService _localizationService = new(); + private readonly StudySnapshotRenderGate _renderGate; private double _currentCellSize = 48; private string _languageCode = "zh-CN"; @@ -70,6 +70,7 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW public StudySessionHistoryWidget() { InitializeComponent(); + _renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshotFromGate); AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; SizeChanged += OnSizeChanged; @@ -145,26 +146,30 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e) { - Dispatcher.UIThread.Post(() => + if (!_isAttached || !_isOnActivePage) { - if (!_isAttached) - { - return; - } + return; + } - if (!string.IsNullOrWhiteSpace(_loadingSessionId) && - string.Equals(e.Snapshot.SelectedSessionReportId, _loadingSessionId, StringComparison.OrdinalIgnoreCase)) - { - _loadingSessionId = null; - SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.5); - } + _renderGate.Queue(e.Snapshot); + } - _currentSnapshot = e.Snapshot; - if (_isOnActivePage) - { - RenderSnapshot(e.Snapshot); - } - }, DispatcherPriority.Background); + private bool CanRenderSnapshot() + { + return _isAttached && _isOnActivePage; + } + + private void ApplySnapshotFromGate(StudyAnalyticsSnapshot snapshot) + { + if (!string.IsNullOrWhiteSpace(_loadingSessionId) && + string.Equals(snapshot.SelectedSessionReportId, _loadingSessionId, StringComparison.OrdinalIgnoreCase)) + { + _loadingSessionId = null; + SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.5); + } + + _currentSnapshot = snapshot; + RenderSnapshot(snapshot); } private void RefreshFromService() @@ -793,6 +798,7 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW DetachedFromVisualTree -= OnDetachedFromVisualTree; SizeChanged -= OnSizeChanged; ActualThemeVariantChanged -= OnActualThemeVariantChanged; + _renderGate.Dispose(); DialogCancelButton.Click -= (_, _) => CloseDialog(); DialogConfirmButton.Click -= (_, _) => ConfirmDialog(); DialogRenameTextBox.KeyDown -= OnDialogRenameTextBoxKeyDown; diff --git a/LanMountainDesktop/Views/Components/StudySnapshotRenderGate.cs b/LanMountainDesktop/Views/Components/StudySnapshotRenderGate.cs new file mode 100644 index 0000000..54b2259 --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudySnapshotRenderGate.cs @@ -0,0 +1,106 @@ +using System; +using Avalonia.Threading; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Views.Components; + +internal sealed class StudySnapshotRenderGate : IDisposable +{ + private readonly object _syncRoot = new(); + private readonly Func _canRender; + private readonly Action _renderSnapshot; + private readonly Action? _afterRender; + + private StudyAnalyticsSnapshot? _pendingSnapshot; + private bool _hasPendingSnapshot; + private bool _dispatchQueued; + private bool _isDisposed; + + public StudySnapshotRenderGate( + Func canRender, + Action renderSnapshot, + Action? afterRender = null) + { + _canRender = canRender ?? throw new ArgumentNullException(nameof(canRender)); + _renderSnapshot = renderSnapshot ?? throw new ArgumentNullException(nameof(renderSnapshot)); + _afterRender = afterRender; + } + + internal bool HasPendingSnapshot + { + get + { + lock (_syncRoot) + { + return _hasPendingSnapshot; + } + } + } + + public void Queue(StudyAnalyticsSnapshot snapshot) + { + lock (_syncRoot) + { + if (_isDisposed) + { + return; + } + + _pendingSnapshot = snapshot; + _hasPendingSnapshot = true; + if (_dispatchQueued) + { + return; + } + + _dispatchQueued = true; + } + + Dispatcher.UIThread.Post(() => ProcessPending(), DispatcherPriority.Background); + } + + public bool ProcessPending() + { + StudyAnalyticsSnapshot? snapshot; + lock (_syncRoot) + { + _dispatchQueued = false; + if (_isDisposed || !_hasPendingSnapshot) + { + return false; + } + + snapshot = _pendingSnapshot; + _pendingSnapshot = null; + _hasPendingSnapshot = false; + } + + if (snapshot is null || !_canRender()) + { + return false; + } + + _renderSnapshot(snapshot); + _afterRender?.Invoke(); + return true; + } + + public void Clear() + { + lock (_syncRoot) + { + _pendingSnapshot = null; + _hasPendingSnapshot = false; + } + } + + public void Dispose() + { + lock (_syncRoot) + { + _pendingSnapshot = null; + _hasPendingSnapshot = false; + _isDisposed = true; + } + } +} diff --git a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs index 2c3edfe..b09a607 100644 --- a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs +++ b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs @@ -632,6 +632,8 @@ public partial class MainWindow : Window ThemeColorMode = latestThemeState.ThemeColorMode, SystemMaterialMode = latestThemeState.SystemMaterialMode, SelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed, + ThemeWallpaperColorSource = latestThemeState.ThemeWallpaperColorSource, + UseNativeWallpaperChangeEvents = latestThemeState.UseNativeWallpaperChangeEvents, UseSystemChrome = latestThemeState.UseSystemChrome, CornerRadiusStyle = latestThemeState.CornerRadiusStyle, WallpaperPath = latestWallpaperState.WallpaperPath, diff --git a/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml index 6ae5d35..4a32da1 100644 --- a/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml @@ -1,20 +1,18 @@ - - + Description="{Binding ThemeModeDescription}"> @@ -40,33 +38,15 @@ - + - - - - - - - - - - - - - - - - + + ItemsSource="{Binding CornerRadiusStyleOptions}" + SelectedItem="{Binding SelectedCornerRadiusStyle}"> @@ -75,190 +55,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml new file mode 100644 index 0000000..b77c8eb --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml.cs new file mode 100644 index 0000000..e9799ba --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml.cs @@ -0,0 +1,45 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.ViewModels; + +namespace LanMountainDesktop.Views.SettingsPages; + +[SettingsPageInfo( + "material-color", + "Material & Color", + SettingsPageCategory.Appearance, + IconKey = "Color", + SortOrder = 8, + TitleLocalizationKey = "settings.material_color.title", + DescriptionLocalizationKey = "settings.material_color.description")] +public partial class MaterialColorSettingsPage : SettingsPageBase +{ + public MaterialColorSettingsPage() + : this(new MaterialColorSettingsPageViewModel( + HostSettingsFacadeProvider.GetOrCreate(), + HostMaterialColorProvider.GetOrCreate())) + { + } + + public MaterialColorSettingsPage(MaterialColorSettingsPageViewModel viewModel) + { + ViewModel = viewModel; + DataContext = ViewModel; + InitializeComponent(); + } + + public MaterialColorSettingsPageViewModel ViewModel { get; } + + private void OnWallpaperSeedCandidateClick(object? sender, RoutedEventArgs e) + { + _ = e; + + if (sender is Button { DataContext: ThemeSeedCandidateOption option }) + { + ViewModel.SelectWallpaperSeed(option.Value); + } + } +} diff --git a/LanMountainDesktop/plugins/PluginLoader.cs b/LanMountainDesktop/plugins/PluginLoader.cs index 61c0946..74bf1de 100644 --- a/LanMountainDesktop/plugins/PluginLoader.cs +++ b/LanMountainDesktop/plugins/PluginLoader.cs @@ -336,6 +336,7 @@ public sealed class PluginLoader RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); + RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); return services; @@ -347,17 +348,19 @@ public sealed class PluginLoader CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24), ThemeVariant: "Unknown"); - if (hostServices?.GetService(typeof(IAppearanceThemeService)) is not IAppearanceThemeService appearanceThemeService) - { - return defaultSnapshot; - } - try { - var hostSnapshot = appearanceThemeService.GetCurrent(); - return new PluginAppearanceSnapshot( - CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(hostSnapshot.CornerRadiusTokens), - ThemeVariant: hostSnapshot.IsNightMode ? "Dark" : "Light"); + if (hostServices?.GetService(typeof(IMaterialColorService)) is IMaterialColorService materialColorService) + { + return PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(materialColorService.GetMaterialColorSnapshot()); + } + + if (hostServices?.GetService(typeof(IAppearanceThemeService)) is IAppearanceThemeService appearanceThemeService) + { + return PluginAppearanceSnapshotMapper.FromAppearanceSnapshot(appearanceThemeService.GetCurrent()); + } + + return defaultSnapshot; } catch (Exception ex) { diff --git a/LanMountainDesktop/plugins/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs index 911cdf1..d73c867 100644 --- a/LanMountainDesktop/plugins/PluginRuntimeService.cs +++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs @@ -33,6 +33,7 @@ public sealed class PluginRuntimeService : IDisposable private readonly ISettingsFacadeService _settingsFacade; private readonly SettingsCatalogService _settingsCatalogService; private readonly PublicIpcHostService? _publicIpcHostService; + private readonly IMaterialColorService _materialColorService; private readonly List _loadedPlugins = []; private readonly List _loadResults = []; private readonly List _catalog = []; @@ -51,6 +52,7 @@ public sealed class PluginRuntimeService : IDisposable _packageManager = new PluginRuntimePackageManager(this); _settingsFacade = settingsFacade ?? new SettingsFacadeService(); _publicIpcHostService = publicIpcHostService; + _materialColorService = HostMaterialColorProvider.GetOrCreate(); _settingsCatalogService = _settingsFacade.Catalog as SettingsCatalogService ?? new SettingsCatalogService(); if (_settingsFacade is SettingsFacadeService concreteFacade) @@ -67,6 +69,7 @@ public sealed class PluginRuntimeService : IDisposable _publicIpcHostService); _loaderOptions = CreateOptions(); _loader = new PluginLoader(_loaderOptions); + _materialColorService.MaterialColorChanged += OnMaterialColorChanged; } public string PluginsDirectory { get; } @@ -423,6 +426,7 @@ public sealed class PluginRuntimeService : IDisposable public void Dispose() { + _materialColorService.MaterialColorChanged -= OnMaterialColorChanged; UnloadInstalledPlugins(); _sharedContractManager.Dispose(); if (_settingsFacade is IDisposable disposable && !ReferenceEquals(_settingsFacade, HostSettingsFacadeProvider.GetOrCreate())) @@ -431,6 +435,32 @@ public sealed class PluginRuntimeService : IDisposable } } + private void OnMaterialColorChanged(object? sender, MaterialColorSnapshot snapshot) + { + _ = sender; + + var pluginSnapshot = PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(snapshot); + var changedProperties = new[] + { + AppearanceProperty.ThemeVariant, + AppearanceProperty.AccentColor, + AppearanceProperty.Wallpaper, + AppearanceProperty.SystemMaterialMode, + AppearanceProperty.ColorSource, + AppearanceProperty.ColorRoles, + AppearanceProperty.MaterialSurfaces, + AppearanceProperty.WallpaperSeedCandidates + }; + + foreach (var loadedPlugin in _loadedPlugins) + { + if (loadedPlugin.RuntimeContext.Appearance is PluginAppearanceContext appearanceContext) + { + appearanceContext.UpdateSnapshot(pluginSnapshot, changedProperties); + } + } + } + private void UnloadInstalledPlugins() { for (var i = _loadedPlugins.Count - 1; i >= 0; i--) @@ -1016,6 +1046,7 @@ public sealed class PluginRuntimeService : IDisposable private readonly ISettingsService _settingsService; private readonly ISettingsCatalog _settingsCatalog; private readonly IAppearanceThemeService _appearanceThemeService; + private readonly IMaterialColorService _materialColorService; private readonly IExternalIpcNotificationPublisher? _externalIpcNotificationPublisher; public PluginHostServiceProvider( @@ -1034,6 +1065,7 @@ public sealed class PluginRuntimeService : IDisposable _settingsService = settingsService; _settingsCatalog = settingsCatalog; _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate(); + _materialColorService = HostMaterialColorProvider.GetOrCreate(); _externalIpcNotificationPublisher = externalIpcNotificationPublisher; } @@ -1074,6 +1106,11 @@ public sealed class PluginRuntimeService : IDisposable return _appearanceThemeService; } + if (serviceType == typeof(IMaterialColorService)) + { + return _materialColorService; + } + if (serviceType == typeof(IExternalIpcNotificationPublisher)) { return _externalIpcNotificationPublisher; diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs index c86bb0d..12f492d 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs @@ -11,4 +11,6 @@ public sealed record PlondsDeltaBuildOptions( string? BaselineVersion = null, string? BaselineTag = null, string? BaselinePayloadZip = null, - bool IsFullPayload = false); + bool IsFullPayload = false, + string? StaticOutputRoot = null, + string? UpdateBaseUrl = null); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs index 4164dcf..ec7dc6b 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs @@ -53,7 +53,13 @@ public sealed class PlondsDeltaBuilder ? new Dictionary(StringComparer.OrdinalIgnoreCase) : PayloadUtilities.ScanDirectory(baselineExtractRoot); var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot); - var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot); + var updateBaseUrl = string.IsNullOrWhiteSpace(options.UpdateBaseUrl) + ? null + : options.UpdateBaseUrl.TrimEnd('/'); + var repoBaseUrl = string.IsNullOrWhiteSpace(updateBaseUrl) + ? null + : $"{updateBaseUrl}/repo/sha256"; + var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot, repoBaseUrl); var updateAssetName = $"update-{options.Platform}.zip"; var fileMapAssetName = $"plonds-filemap-{options.Platform}.json"; @@ -76,6 +82,7 @@ public sealed class PlondsDeltaBuilder ["isFullPayload"] = useFullPayload ? "true" : "false" }; + var generatedAt = DateTimeOffset.UtcNow; var component = new ComponentDocument( Name: "app", Version: options.CurrentVersion, @@ -95,7 +102,7 @@ public sealed class PlondsDeltaBuilder Platform: options.Platform, Arch: PayloadUtilities.ResolveArch(options.Platform), Channel: options.Channel, - GeneratedAt: DateTimeOffset.UtcNow, + GeneratedAt: generatedAt, Metadata: metadata, Components: [component], Files: fileEntries); @@ -103,6 +110,20 @@ public sealed class PlondsDeltaBuilder PayloadUtilities.WriteJson(fileMapPath, fileMap); _signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath); + if (!string.IsNullOrWhiteSpace(options.StaticOutputRoot) && !string.IsNullOrWhiteSpace(updateBaseUrl)) + { + WriteStaticLayout( + options, + component, + objectsRoot, + distributionId, + fileMapPath, + fileMapSignaturePath, + Path.GetFullPath(options.StaticOutputRoot), + updateBaseUrl, + generatedAt); + } + var summary = new PlondsReleasePlatformEntry( Platform: options.Platform, DistributionId: distributionId, @@ -135,7 +156,8 @@ public sealed class PlondsDeltaBuilder private static List BuildFileEntries( IReadOnlyDictionary previousManifest, IReadOnlyDictionary currentManifest, - string objectsRoot) + string objectsRoot, + string? repoBaseUrl) { var result = new List(); @@ -152,12 +174,16 @@ public sealed class PlondsDeltaBuilder Size: current.Size, ObjectPath: null, ObjectKey: null, + ObjectUrl: null, Metadata: null)); continue; } var action = previousManifest.ContainsKey(path) ? "replace" : "add"; var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256); + var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl) + ? null + : $"{repoBaseUrl.TrimEnd('/')}/{objectPath}"; var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["mode"] = "file-object" @@ -174,6 +200,7 @@ public sealed class PlondsDeltaBuilder Size: current.Size, ObjectPath: objectPath, ObjectKey: objectPath, + ObjectUrl: objectUrl, Metadata: metadata)); } @@ -191,12 +218,87 @@ public sealed class PlondsDeltaBuilder Size: 0, ObjectPath: null, ObjectKey: null, + ObjectUrl: null, Metadata: null)); } return result; } + private static void WriteStaticLayout( + PlondsDeltaBuildOptions options, + ComponentDocument component, + string objectsRoot, + string distributionId, + string fileMapPath, + string fileMapSignaturePath, + string staticOutputRoot, + string updateBaseUrl, + DateTimeOffset generatedAt) + { + var repoRoot = Path.Combine(staticOutputRoot, "repo", "sha256"); + var manifestRoot = Path.Combine(staticOutputRoot, "manifests", distributionId); + var distributionRoot = Path.Combine(staticOutputRoot, "meta", "distributions"); + var channelRoot = Path.Combine(staticOutputRoot, "meta", "channels", options.Channel, options.Platform); + + CopyDirectory(objectsRoot, repoRoot); + Directory.CreateDirectory(manifestRoot); + File.Copy(fileMapPath, Path.Combine(manifestRoot, "plonds-filemap.json"), overwrite: true); + File.Copy(fileMapSignaturePath, Path.Combine(manifestRoot, "plonds-filemap.json.sig"), overwrite: true); + + var fileMapUrl = $"{updateBaseUrl}/manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json"; + var distribution = new DistributionDocument( + DistributionId: distributionId, + Version: options.CurrentVersion, + SourceVersion: options.BaselineVersion ?? "0.0.0", + Channel: options.Channel, + Platform: options.Platform, + Arch: PayloadUtilities.ResolveArch(options.Platform), + PublishedAt: generatedAt, + FileMapUrl: fileMapUrl, + FileMapSignatureUrl: fileMapUrl + ".sig", + Components: [component], + InstallerMirrors: [], + Capabilities: ["file-object"], + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["protocol"] = "PLONDS", + ["releaseTag"] = options.CurrentTag, + ["baselineTag"] = options.BaselineTag ?? string.Empty, + ["baselineVersion"] = options.BaselineVersion ?? "0.0.0", + ["targetVersion"] = options.CurrentVersion, + ["isFullPayload"] = options.IsFullPayload ? "true" : "false" + }); + + var latest = new LatestPointerDocument( + DistributionId: distributionId, + Version: options.CurrentVersion, + Channel: options.Channel, + Platform: options.Platform, + PublishedAt: generatedAt); + + PayloadUtilities.WriteJson(Path.Combine(distributionRoot, distributionId + ".json"), distribution); + PayloadUtilities.WriteJson(Path.Combine(channelRoot, "latest.json"), latest); + } + + private static void CopyDirectory(string sourceDir, string destinationDir) + { + Directory.CreateDirectory(destinationDir); + foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDir, directory); + Directory.CreateDirectory(Path.Combine(destinationDir, relativePath)); + } + + foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDir, file); + var destinationPath = Path.Combine(destinationDir, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + File.Copy(file, destinationPath, overwrite: true); + } + } + private sealed record FileMapDocument( string FormatVersion, string DistributionId, @@ -224,5 +326,35 @@ public sealed class PlondsDeltaBuilder long Size, string? ObjectPath, string? ObjectKey, + string? ObjectUrl, IReadOnlyDictionary? Metadata); + + private sealed record DistributionDocument( + string DistributionId, + string Version, + string SourceVersion, + string Channel, + string Platform, + string Arch, + DateTimeOffset PublishedAt, + string FileMapUrl, + string FileMapSignatureUrl, + IReadOnlyList Components, + IReadOnlyList InstallerMirrors, + IReadOnlyList Capabilities, + IReadOnlyDictionary? Metadata); + + private sealed record LatestPointerDocument( + string DistributionId, + string Version, + string Channel, + string Platform, + DateTimeOffset PublishedAt); + + private sealed record InstallerMirrorDocument( + string Platform, + string? Url, + string? FileName, + string? Sha256, + long Size); } diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs index 05bd276..871f47d 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs @@ -135,7 +135,9 @@ internal static class PlondsCli BaselineVersion: Get(options, "baseline-version"), BaselineTag: Get(options, "baseline-tag"), BaselinePayloadZip: Get(options, "baseline-zip"), - IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload)); + IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload, + StaticOutputRoot: Get(options, "static-output-dir"), + UpdateBaseUrl: Get(options, "update-base-url"))); Console.WriteLine($"Built PLONDS delta for {result.Platform}: {result.UpdateArchivePath}"); Console.WriteLine(result.FileMapPath); @@ -211,7 +213,7 @@ internal static class PlondsCli { Console.WriteLine("PLONDS Tool"); Console.WriteLine(" pack-payload --source-dir --output-zip "); - Console.WriteLine(" build-delta --platform --current-version --current-tag --current-zip --output-dir --private-key [--baseline-tag ] [--baseline-version ] [--baseline-zip ] [--is-full-payload]"); + Console.WriteLine(" build-delta --platform --current-version --current-tag --current-zip --output-dir --private-key [--baseline-tag ] [--baseline-version ] [--baseline-zip ] [--is-full-payload] [--static-output-dir ] [--update-base-url ]"); Console.WriteLine(" build-index --release-tag --version --platform-summaries-dir --output-dir --private-key [--channel ]"); Console.WriteLine(" build-ddss --release-tag --assets-dir --output-dir --private-key --repository [--s3-base-url ]"); Console.WriteLine(" sign --manifest --private-key [--output ]");