Introduce render gate and chart caching

Replace UI DispatcherTimer polling with a StudySnapshotRenderGate across multiple widgets to queue and apply only the latest analytics snapshot; components updated include StudyDeductionReasonsWidget, StudyEnvironmentWidget, StudyInterruptDensityWidget, StudyNoiseCurveWidget. Add StudySnapshotRenderGate implementation to coordinate rendering and monitoring leases and update subscription/lease lifecycle handling (subscribe/unsubscribe, Acquire/Dispose leases, Clear/Dispose gate). Rewrite chart controls (StudyNoiseCurveChartControl and StudyNoiseDistributionScatterChartControl) to use stable logical-time origins, split series into static vs dynamic tails, add geometry/sample caching, stable jitter/coordinate mapping helpers, and expose internal helpers & counts for testing. Add unit tests (StudyComponentRenderingTests) covering the render gate and chart behaviors (layer counts, logical X mapping, stable jitter, cache rebuild). These changes improve rendering correctness and performance by avoiding redundant renders and enabling deterministic chart layout.
This commit is contained in:
lincube
2026-05-06 16:00:45 +08:00
parent 68ca532dc0
commit b71687cecd
55 changed files with 4529 additions and 1059 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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`.

View File

@@ -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/<hash-prefix>/<hash-object>`
- `lanmountain/update/meta/channels/<channel>/<subchannel>/latest.json`
- `lanmountain/update/meta/distributions/<distributionId>/*.json`
- `lanmountain/update/installers/<platform>/<arch>/*`
- `lanmountain/update/repo/sha256/<hash-prefix>/<hash-object>`
- `lanmountain/update/meta/channels/<channel>/<platform>/latest.json`
- `lanmountain/update/meta/distributions/<distributionId>.json`
- `lanmountain/update/manifests/<distributionId>/plonds-filemap.json`
- `lanmountain/update/manifests/<distributionId>/plonds-filemap.json.sig`
- `lanmountain/update/installers/<platform>/<version>/*`
## 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

View File

@@ -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/<platform>/<arch>/`.
- [ ] 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/<platform>/<version>/`.
- [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.

View File

@@ -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

View File

@@ -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[]

View File

@@ -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<string, double>? CornerRadiusTokens = null,
IReadOnlyDictionary<string, string>? ResourceAliases = null);
IReadOnlyDictionary<string, string>? ResourceAliases = null,
string? SeedColor = null,
string? ColorSource = null,
string? SystemMaterialMode = null,
IReadOnlyDictionary<string, string>? ColorRoles = null,
IReadOnlyDictionary<string, PluginMaterialSurfaceSnapshot>? MaterialSurfaces = null,
IReadOnlyList<string>? WallpaperSeedCandidates = null);
public sealed record PluginAppearanceChangedNotification(PluginAppearanceSnapshot Snapshot);

View File

@@ -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))]

View File

@@ -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<string, string>? ColorRoles = null,
IReadOnlyDictionary<string, PluginMaterialSurfaceSnapshot>? MaterialSurfaces = null,
IReadOnlyList<string>? WallpaperSeedCandidates = null);

View File

@@ -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";

View File

@@ -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<string>();
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<NoiseRealtimePoint> CreateRealtimePoints(int count, TimeSpan step)
{
var start = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
var points = new List<NoiseRealtimePoint>(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<NoiseRealtimePoint> CreateRealtimePoints(IReadOnlyList<(double OffsetSeconds, double DisplayDb)> samples)
{
var start = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
var points = new List<NoiseRealtimePoint>(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<NoiseRealtimePoint>(),
Session: session,
LastSessionReport: null,
SelectedSessionReportId: null,
SessionHistory: Array.Empty<StudySessionHistoryEntry>(),
LastError: marker);
}
}

View File

@@ -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<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(responder(request));
}
}
}

View File

@@ -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();
}
}

View File

@@ -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; }

View File

@@ -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<Color> WallpaperSeedCandidates,
string SystemMaterialMode,
IReadOnlyList<string> AvailableSystemMaterialModes,
bool CanChangeSystemMaterial,
bool UseSystemChrome,
string? ResolvedWallpaperPath,
bool UseNativeWallpaperChangeEvents,
bool NativeWallpaperChangeEventsActive,
bool WallpaperPollingActive,
IReadOnlyDictionary<MaterialSurfaceRole, MaterialSurfaceSnapshot> Surfaces);

View File

@@ -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<string> 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<string, WallpaperSeedExtractionResult> _wallpaperSeedCache = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _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<AppearanceThemeSnapshot>? Changed;
public event EventHandler<MaterialColorSnapshot>? 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<MaterialSurfaceRole>()
.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();
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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<MaterialColorSnapshot>? MaterialColorChanged;
void ApplyThemeResources(IResourceDictionary resources);
MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role);
void ApplyWindowMaterial(Window window, MaterialSurfaceRole role);
void RefreshWallpaperColors();
}

View File

@@ -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<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> 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<UpdateCheckResult> 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<LatestPointerDto>(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<DistributionDto>(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<T?> GetJsonAsync<T>(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<T>(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);
}

View File

@@ -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<string, string>(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<string, string> BuildColorRoles(MaterialColorSnapshot snapshot)
{
return new Dictionary<string, string>(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();
}
}

View File

@@ -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<string> TopStatusComponentIds,
IReadOnlyList<string> PinnedTaskbarActions,

View File

@@ -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);

View File

@@ -179,6 +179,7 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
services.AddSingleton(_settingsFacade.Settings);
services.AddSingleton(_settingsFacade.Catalog);
services.AddSingleton<IAppearanceThemeService>(_ => HostAppearanceThemeProvider.GetOrCreate());
services.AddSingleton<IMaterialColorService>(_ => HostMaterialColorProvider.GetOrCreate());
services.AddSingleton(_hostApplicationLifecycle);
services.AddSingleton(_localizationService);
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());

View File

@@ -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)

View File

@@ -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<string> 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);

View File

@@ -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() ?? [];

View File

@@ -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<IReadOnlyList<UpdateManifest>>([]);
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
private async Task<PlondsChannelPointerDto?> 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<PlondsComponentDto>? Components,
List<PlondsMirrorDto>? InstallerMirrors,
List<PlondsSignatureDto>? Signatures,
string? SourceVersion,
string? Channel,
string? Platform,
DateTimeOffset PublishedAt,
string? FileMapUrl,
string? FileMapSignatureUrl,
List<PlondsComponentDto>? Components,
List<PlondsMirrorDto>? InstallerMirrors,
List<PlondsSignatureDto>? Signatures,
Dictionary<string, string>? 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();
}
}

View File

@@ -69,7 +69,7 @@ public sealed class UpdateOrchestrator : IDisposable
{
manifest = await _manifestProvider.GetLatestAsync(
channel,
"win-x64",
LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform(),
currentVersion,
ct);
}

View File

@@ -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;

View File

@@ -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";

View File

@@ -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<SelectionOption> ColorModes { get; }
public IReadOnlyList<SelectionOption> WallpaperColorSources { get; }
public IReadOnlyList<SelectionOption> RefreshIntervals { get; }
[ObservableProperty]
private SelectionOption _selectedColorMode = new(ThemeAppearanceValues.ColorModeDefaultNeutral, "Default neutral");
[ObservableProperty]
private SelectionOption _selectedWallpaperColorSource = new(ThemeAppearanceValues.WallpaperColorSourceAuto, "Auto");
[ObservableProperty]
private IReadOnlyList<SelectionOption> _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<ThemeSeedCandidateOption> _wallpaperSeedCandidates = [];
[ObservableProperty]
private IReadOnlyList<MaterialColorRolePreviewOption> _colorRolePreviews = [];
[ObservableProperty]
private IReadOnlyList<MaterialSurfacePreviewOption> _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<SelectionOption> CreateColorModes()
{
return
[
new SelectionOption(ThemeAppearanceValues.ColorModeDefaultNeutral, ModeNeutralText),
new SelectionOption(ThemeAppearanceValues.ColorModeSeedMonet, ModeCustomText),
new SelectionOption(ThemeAppearanceValues.ColorModeWallpaperMonet, ModeWallpaperText)
];
}
private IReadOnlyList<SelectionOption> CreateWallpaperColorSources()
{
return
[
new SelectionOption(ThemeAppearanceValues.WallpaperColorSourceAuto, WallpaperSourceAutoText),
new SelectionOption(ThemeAppearanceValues.WallpaperColorSourceApp, WallpaperSourceAppText),
new SelectionOption(ThemeAppearanceValues.WallpaperColorSourceSystem, WallpaperSourceSystemText)
];
}
private IReadOnlyList<SelectionOption> 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);
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
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<NoiseRealtimePoint>? 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<NoiseRealtimePoint> 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<NoiseRealtimePoint> 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<NoiseRealtimePoint> 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<NoiseRealtimePoint> 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));
}

View File

@@ -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;

View File

@@ -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<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
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<NoiseRealtimePoint>? points, double baselineDb)
{
UpdateSeries(points, baselineDb, isStaticSeries: false);
}
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb, bool isStaticSeries)
{
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
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<NoiseRealtimePoint> 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<NoiseRealtimePoint> 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<NoiseRealtimePoint> 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<NoiseRealtimePoint> 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<Point>.Shared.Rent(required);
if (_pointBuffer is not null)
{
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
}
_pointBuffer = next;
}
private void ReleasePointBuffer()
{
if (_pointBuffer is null)
{
return;
}
ArrayPool<Point>.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<NoiseRealtimePoint> 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
}

View File

@@ -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<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
private SampledPoint[] _sampledPoints = Array.Empty<SampledPoint>();
private int _sampledPointCount;
private double _baselineDb = 45;
private Rect _cachedPlot;
private bool _sampleCacheDirty = true;
private int _lastSeriesSignature;
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb)
{
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
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<NoiseRealtimePoint> 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<byte> 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
}

View File

@@ -83,10 +83,10 @@
FontSize="10" />
</Grid>
<local:StudyNoiseDistributionScatterChartControl x:Name="ChartControl"
Grid.Row="0"
Grid.Column="1"
MinHeight="64" />
<local:StudyNoiseDistributionAreaChartControl x:Name="ChartControl"
Grid.Row="0"
Grid.Column="1"
MinHeight="64" />
<Grid Grid.Row="1"
Grid.Column="1"

View File

@@ -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,17 +38,14 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
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 StudySnapshotRenderGate _renderGate;
private double _currentCellSize = 48;
private StudyAnalyticsSnapshot? _pendingSnapshot;
private string _languageCode = "zh-CN";
private bool _dispatchQueued;
private bool _hasPendingSnapshot;
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isDisposed;
@@ -72,6 +68,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
{
InitializeComponent();
_renderGate = new StudySnapshotRenderGate(CanRenderSnapshot, ApplySnapshot);
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
@@ -80,7 +77,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
ApplyCellSize(_currentCellSize);
ApplyDefaultXAxisLabels();
ApplyLocalizedAxisLabels();
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
}
public void ApplyCellSize(double cellSize)
@@ -99,7 +96,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
if (isOnActivePage && !wasOnActivePage)
{
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
}
}
@@ -115,7 +112,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
}
UpdateMonitoringLeaseState();
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
@@ -123,6 +120,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
_isAttached = false;
_monitoringLease?.Dispose();
_monitoringLease = null;
_renderGate.Clear();
if (_isSubscribed)
{
@@ -139,7 +137,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
_renderGate.Queue(_studyAnalyticsService.GetSnapshot());
}
private void UpdateMonitoringLeaseState()
@@ -151,7 +149,8 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
return;
}
if (_isAttached)
var shouldMonitor = _isAttached && _isOnActivePage;
if (shouldMonitor)
{
_monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease();
return;
@@ -164,46 +163,17 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
{
_ = sender;
QueueSnapshotForRender(e.Snapshot);
}
private void QueueSnapshotForRender(StudyAnalyticsSnapshot snapshot)
{
lock (_snapshotSync)
{
_pendingSnapshot = snapshot;
_hasPendingSnapshot = true;
if (_dispatchQueued)
{
return;
}
_dispatchQueued = true;
}
Dispatcher.UIThread.Post(ProcessPendingSnapshot, DispatcherPriority.Background);
}
private void ProcessPendingSnapshot()
{
StudyAnalyticsSnapshot? snapshot = null;
lock (_snapshotSync)
{
_dispatchQueued = false;
if (_hasPendingSnapshot)
{
snapshot = _pendingSnapshot;
_pendingSnapshot = null;
_hasPendingSnapshot = false;
}
}
if (!_isAttached || !_isOnActivePage || snapshot is null)
if (!_isAttached || !_isOnActivePage)
{
return;
}
ApplySnapshot(snapshot);
_renderGate.Queue(e.Snapshot);
}
private bool CanRenderSnapshot()
{
return _isAttached && _isOnActivePage;
}
private void ApplySnapshot(StudyAnalyticsSnapshot snapshot)
@@ -235,7 +205,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
? StudySessionReportProjection.BuildSyntheticRealtimePoints(snapshot.LastSessionReport, snapshot.Config)
: snapshot.RealtimeBuffer;
ChartControl.UpdateSeries(points, snapshot.Config.BaselineDb);
ChartControl.UpdateSeries(points, snapshot.Config.BaselineDb, isSessionReport);
UpdateXAxisLabels(points);
var stats = ComputeDistributionStats(points, snapshot.Config.BaselineDb);
@@ -670,6 +640,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged;
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
_renderGate.Dispose();
if (_isSubscribed)
{

View File

@@ -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;
@@ -43,16 +42,14 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
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 readonly Queue<(DateTimeOffset Timestamp, double Score)> _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);

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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<bool> _canRender;
private readonly Action<StudyAnalyticsSnapshot> _renderSnapshot;
private readonly Action? _afterRender;
private StudyAnalyticsSnapshot? _pendingSnapshot;
private bool _hasPendingSnapshot;
private bool _dispatchQueued;
private bool _isDisposed;
public StudySnapshotRenderGate(
Func<bool> canRender,
Action<StudyAnalyticsSnapshot> 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;
}
}
}

View File

@@ -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,

View File

@@ -1,20 +1,18 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:controls="using:LanMountainDesktop.Controls"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Views.SettingsPages.AppearanceSettingsPage"
x:DataType="vm:AppearanceSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated">
<controls:IconText Icon="Color"
Text="{Binding ThemeHeader}"
Margin="0,0,0,4" />
<ui:FASettingsExpander Header="{Binding ThemeModeLabel}"
Description="{Binding ThemeModeDescription}">
Description="{Binding ThemeModeDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF0504;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
@@ -40,33 +38,15 @@
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding ThemeColorModeLabel}"
Description="{Binding ThemeColorSourceDescription}">
<ui:FASettingsExpander Header="{Binding CornerRadiusStyleLabel}"
Description="{Binding CornerRadiusStyleDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF017C;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ComboBox Width="240"
ItemsSource="{Binding ThemeColorModes}"
SelectedItem="{Binding SelectedThemeColorMode}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding SystemMaterialLabel}"
Description="{Binding SystemMaterialDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF1A48;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
<ui:FAFontIconSource Glyph="&#xF0126;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ComboBox Width="220"
ItemsSource="{Binding SystemMaterialModes}"
SelectedItem="{Binding SelectedSystemMaterialMode}">
ItemsSource="{Binding CornerRadiusStyleOptions}"
SelectedItem="{Binding SelectedCornerRadiusStyle}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
@@ -75,190 +55,6 @@
</ComboBox>
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding ThemeColorLabel}"
Description="{Binding ThemeColorSourceDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF0888;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpanderItem>
<StackPanel Spacing="12">
<StackPanel Orientation="Horizontal"
Spacing="12"
IsVisible="{Binding ShowNeutralPreview}">
<StackPanel Width="96"
Spacing="6">
<Border Height="54"
Background="{Binding NeutralLightPreviewBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
<TextBlock Text="{Binding PreviewNeutralLightLabel}"
HorizontalAlignment="Center"
TextAlignment="Center" />
</StackPanel>
<StackPanel Width="96"
Spacing="6">
<Border Height="54"
Background="{Binding NeutralDarkPreviewBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
<TextBlock Text="{Binding PreviewNeutralDarkLabel}"
HorizontalAlignment="Center"
TextAlignment="Center" />
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal"
Spacing="12"
IsVisible="{Binding ShowMonetPreview}">
<StackPanel Width="92"
Spacing="6">
<Border Height="54"
Background="{Binding PrimarySwatchBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
<TextBlock Text="{Binding PreviewPrimaryLabel}"
HorizontalAlignment="Center"
TextAlignment="Center" />
</StackPanel>
<StackPanel Width="92"
Spacing="6">
<Border Height="54"
Background="{Binding SecondarySwatchBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
<TextBlock Text="{Binding PreviewSecondaryLabel}"
HorizontalAlignment="Center"
TextAlignment="Center" />
</StackPanel>
<StackPanel Width="92"
Spacing="6">
<Border Height="54"
Background="{Binding TertiarySwatchBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
<TextBlock Text="{Binding PreviewTertiaryLabel}"
HorizontalAlignment="Center"
TextAlignment="Center" />
</StackPanel>
<StackPanel Width="92"
Spacing="6">
<Border Height="54"
Background="{Binding NeutralSwatchBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
<TextBlock Text="{Binding PreviewNeutralLabel}"
HorizontalAlignment="Center"
TextAlignment="Center" />
</StackPanel>
<Button x:Name="CustomSeedButton"
Width="92"
Padding="0"
Background="Transparent"
BorderThickness="0"
HorizontalAlignment="Left"
IsVisible="{Binding IsThemeColorEditable}">
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedLeft"
Closed="OnCustomSeedFlyoutClosed">
<StackPanel Width="300"
Spacing="12">
<ColorPicker Color="{Binding CustomSeedPickerValue}" />
<Button Content="{Binding SeedApplyButtonText}"
HorizontalAlignment="Right"
Click="OnApplyCustomSeedClick" />
</StackPanel>
</Flyout>
</Button.Flyout>
<StackPanel Width="92"
Spacing="6">
<Border Height="54"
Background="{Binding SeedSwatchBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
<TextBlock Text="{Binding PreviewSeedLabel}"
HorizontalAlignment="Center"
TextAlignment="Center" />
</StackPanel>
</Button>
<Button x:Name="WallpaperSeedButton"
Width="92"
Padding="0"
Background="Transparent"
BorderThickness="0"
HorizontalAlignment="Left"
IsVisible="{Binding IsWallpaperMode}"
IsEnabled="{Binding IsWallpaperSeedSelectable}">
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedLeft">
<StackPanel Width="280"
Spacing="12">
<TextBlock Text="{Binding WallpaperSeedFlyoutTitle}"
FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding WallpaperSeedCandidates}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ThemeSeedCandidateOption">
<Button Padding="0"
Margin="0,0,8,8"
Background="Transparent"
BorderThickness="0"
Click="OnWallpaperSeedCandidateClick">
<StackPanel Width="76"
Spacing="6">
<Border Height="44"
Background="{Binding Brush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusXs}" />
<TextBlock Text="{Binding Label}"
HorizontalAlignment="Center"
TextAlignment="Center"
TextWrapping="Wrap" />
<TextBlock Text="{Binding $parent[vm:AppearanceSettingsPageViewModel].WallpaperSeedCurrentText}"
HorizontalAlignment="Center"
FontSize="10"
IsVisible="{Binding IsSelected}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Flyout>
</Button.Flyout>
<StackPanel Width="92"
Spacing="6">
<Border Height="54"
Background="{Binding SeedSwatchBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
<TextBlock Text="{Binding PreviewSeedLabel}"
HorizontalAlignment="Center"
TextAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,259 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:controls="using:LanMountainDesktop.Controls"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Views.SettingsPages.MaterialColorSettingsPage"
x:DataType="vm:MaterialColorSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated">
<controls:IconText Icon="Color"
Text="{Binding PageTitle}"
Margin="0,0,0,4" />
<ui:FASettingsExpander Header="{Binding ColorSourceLabel}"
Description="{Binding ColorSourceDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF017C;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ComboBox Width="240"
ItemsSource="{Binding ColorModes}"
SelectedItem="{Binding SelectedColorMode}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding CustomSeedLabel}"
IsVisible="{Binding IsCustomSeedVisible}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF0888;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpanderItem>
<Grid ColumnDefinitions="96,*"
ColumnSpacing="16">
<Border Width="96"
Height="64"
Background="{Binding SeedBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
<ColorPicker Grid.Column="1"
Color="{Binding CustomSeedPickerValue}" />
</Grid>
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding WallpaperColorSourceLabel}"
IsVisible="{Binding IsWallpaperOptionsVisible}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF067C;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ComboBox Width="220"
ItemsSource="{Binding WallpaperColorSources}"
SelectedItem="{Binding SelectedWallpaperColorSource}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpander.Footer>
<ui:FASettingsExpanderItem>
<StackPanel Spacing="12">
<ItemsControl ItemsSource="{Binding WallpaperSeedCandidates}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ThemeSeedCandidateOption">
<Button Padding="0"
Margin="0,0,8,8"
Background="Transparent"
BorderThickness="0"
Click="OnWallpaperSeedCandidateClick">
<StackPanel Width="84"
Spacing="6">
<Border Height="48"
Background="{Binding Brush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusXs}" />
<TextBlock Text="{Binding Label}"
HorizontalAlignment="Center"
TextAlignment="Center"
TextWrapping="Wrap" />
<TextBlock Text="{Binding $parent[vm:MaterialColorSettingsPageViewModel].WallpaperSeedCurrentText}"
HorizontalAlignment="Center"
FontSize="10"
IsVisible="{Binding IsSelected}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding NativeWallpaperEventsLabel}"
Description="{Binding NativeWallpaperEventsDescription}"
IsVisible="{Binding IsWallpaperOptionsVisible}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF0168;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding UseNativeWallpaperChangeEvents}" />
</ui:FASettingsExpander.Footer>
<ui:FASettingsExpanderItem>
<TextBlock Text="{Binding NativeEventStatusText}"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding RefreshIntervalLabel}"
IsVisible="{Binding IsWallpaperOptionsVisible}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF0168;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<StackPanel Orientation="Horizontal"
Spacing="8">
<ComboBox Width="140"
ItemsSource="{Binding RefreshIntervals}"
SelectedItem="{Binding SelectedRefreshInterval}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Classes="settings-accent-button"
Command="{Binding RefreshWallpaperColorsCommand}"
ToolTip.Tip="{Binding RefreshNowText}"
VerticalAlignment="Center"
Padding="12,8">
<fi:SymbolIcon Symbol="ArrowSync"
IconVariant="Regular" />
</Button>
</StackPanel>
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding SystemMaterialLabel}"
Description="{Binding SystemMaterialDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF1A48;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ComboBox Width="220"
ItemsSource="{Binding SystemMaterialModes}"
SelectedItem="{Binding SelectedSystemMaterialMode}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding PreviewHeader}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF0504;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpanderItem>
<StackPanel Spacing="16">
<Grid ColumnDefinitions="120,*"
ColumnSpacing="16">
<Border Height="72"
Background="{Binding AccentBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusMd}" />
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Spacing="4">
<TextBlock Text="{Binding SourceStatusHeader}"
FontWeight="SemiBold" />
<TextBlock Text="{Binding ResolvedSourceText}"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock Text="{Binding ResolvedWallpaperPathText}"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
</StackPanel>
</Grid>
<TextBlock Text="{Binding SemanticColorsHeader}"
FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding ColorRolePreviews}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MaterialColorRolePreviewOption">
<StackPanel Width="112"
Margin="0,0,10,10"
Spacing="6">
<Border Height="48"
Background="{Binding Brush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusXs}" />
<TextBlock Text="{Binding Label}"
TextAlignment="Center" />
<TextBlock Text="{Binding Value}"
TextAlignment="Center"
FontSize="10"
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding SurfacesHeader}"
FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding SurfacePreviews}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MaterialSurfacePreviewOption">
<StackPanel Width="176"
Margin="0,0,10,10"
Spacing="6">
<Border Height="58"
Background="{Binding BackgroundBrush}"
BorderBrush="{Binding BorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusSm}" />
<TextBlock Text="{Binding Label}"
TextAlignment="Center" />
<TextBlock Text="{Binding Detail}"
TextAlignment="Center"
FontSize="10"
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -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);
}
}
}

View File

@@ -336,6 +336,7 @@ public sealed class PluginLoader
RegisterHostService<ISettingsService>(services, hostServices);
RegisterHostService<ISettingsCatalog>(services, hostServices);
RegisterHostService<IAppearanceThemeService>(services, hostServices);
RegisterHostService<IMaterialColorService>(services, hostServices);
RegisterHostService<IExternalIpcNotificationPublisher>(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)
{

View File

@@ -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<LoadedPlugin> _loadedPlugins = [];
private readonly List<PluginLoadResult> _loadResults = [];
private readonly List<PluginCatalogEntry> _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;

View File

@@ -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);

View File

@@ -53,7 +53,13 @@ public sealed class PlondsDeltaBuilder
? new Dictionary<string, PayloadUtilities.FileFingerprint>(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<FileEntryDocument> BuildFileEntries(
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string objectsRoot)
string objectsRoot,
string? repoBaseUrl)
{
var result = new List<FileEntryDocument>();
@@ -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<string, string>(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<string, string>(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<string, string>? Metadata);
private sealed record DistributionDocument(
string DistributionId,
string Version,
string SourceVersion,
string Channel,
string Platform,
string Arch,
DateTimeOffset PublishedAt,
string FileMapUrl,
string FileMapSignatureUrl,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyList<InstallerMirrorDocument> InstallerMirrors,
IReadOnlyList<string> Capabilities,
IReadOnlyDictionary<string, string>? 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);
}

View File

@@ -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 <dir> --output-zip <file>");
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload]");
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload] [--static-output-dir <dir>] [--update-base-url <url>]");
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
Console.WriteLine(" build-ddss --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");