From 563f12caa1341d81e1eb3f7d566dc441de0d34bb Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 12 May 2026 08:35:48 +0800 Subject: [PATCH] Add install checkpoint/resume and DDSS workflows Introduce install checkpoint support and resume logic for updates, plus related locking and validation. Adds InstallCheckpoint model, AppJsonContext serialization, and UpdatePaths helpers for deployment lock, apply-in-progress lock and install-checkpoint path. UpdateEngineService gains checkpoint load/save/delete, incoming-state validation, resume logic for PLONDS and legacy updates, apply lock handling, and safer cleanup; ApplyPendingPlondsUpdateAsync and ApplyPendingUpdate flow updated accordingly. Add DeploymentLock contract and extend UpdateState with pause/resume/cancel helpers. Tests updated to cover stale/valid checkpoint resume and legacy/PLONDS flows. CI: enhance ddss-publish to detect release channel, validate S3 assets, prepare and atomically publish channel pointer; add ddss-rollback workflow to publish rollbacks; adjust plonds-build concurrency and release events. --- .claude/settings.local.json | 7 +- .github/workflows/ddss-publish.yml | 106 +- .github/workflows/ddss-rollback.yml | 146 ++ .github/workflows/plonds-build.yml | 5 + LanMountainDesktop.Launcher/AppJsonContext.cs | 1 + .../Models/UpdateModels.cs | 19 + .../Services/UpdateEngineService.cs | 547 ++++-- .../Update/DeploymentLock.cs | 11 + .../Update/UpdatePaths.cs | 16 +- .../Update/UpdateState.cs | 19 +- .../UpdateSystemRegressionTests.cs | 211 ++- LanMountainDesktop/App.axaml.cs | 200 ++- .../FusedDesktopEditGridAdapter.cs | 73 + .../Models/FusedDesktopLayoutSnapshot.cs | 12 + .../Settings/SettingsDomainServices.cs | 1 - .../Services/Update/DeploymentLockService.cs | 52 + .../Services/Update/UpdateDownloadEngine.cs | 48 +- .../Services/Update/UpdateInstallGateway.cs | 69 +- .../Services/Update/UpdateOrchestrator.cs | 272 ++- .../Services/UpdateWorkflowService.cs | 1572 ----------------- .../Services/WindowPassthroughService.cs | 665 ++++--- .../ViewModels/SettingsViewModels.cs | 1033 ----------- .../ViewModels/UpdateSettingsViewModel.cs | 58 +- .../Views/DesktopWidgetWindow.axaml.cs | 17 +- .../FusedDesktopComponentLibraryControl.axaml | 143 +- ...sedDesktopComponentLibraryControl.axaml.cs | 12 +- .../FusedDesktopComponentLibraryWindow.axaml | 98 +- ...usedDesktopComponentLibraryWindow.axaml.cs | 119 +- .../Views/MainWindow.SettingsHardCut.Stubs.cs | 19 +- .../SettingsPages/UpdateSettingsPage.axaml | 556 +++--- .../SettingsPages/UpdateSettingsPage.axaml.cs | 9 +- .../Views/TransparentOverlayWindow.axaml | 63 +- .../Views/TransparentOverlayWindow.axaml.cs | 1251 ++++++++----- 33 files changed, 3231 insertions(+), 4199 deletions(-) create mode 100644 .github/workflows/ddss-rollback.yml create mode 100644 LanMountainDesktop.Shared.Contracts/Update/DeploymentLock.cs create mode 100644 LanMountainDesktop/DesktopEditing/FusedDesktopEditGridAdapter.cs create mode 100644 LanMountainDesktop/Services/Update/DeploymentLockService.cs delete mode 100644 LanMountainDesktop/Services/UpdateWorkflowService.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d7300de..b378090 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,12 @@ "permissions": { "allow": [ "Bash(ls -la \"/d/github/LanMountainDesktop/.claude/worktrees/agent-a4c5412322421ab67\" && ls -la \"/d/github/LanMountainDesktop\" && ls -la \"/d/github\")", - "Read(//d/github/**)" + "Read(//d/github/**)", + "Bash(dotnet build *)", + "Bash(dotnet test *)", + "Bash(python -)", + "Bash(py -3 -c \"from pathlib import Path; p=Path\\(r'd:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs'\\); t=p.read_text\\(encoding='utf-8'\\); s=t.find\\('public sealed partial class UpdateSettingsPageViewModel : ViewModelBase'\\); e=t.find\\('public sealed partial class StudySettingsPageViewModel : ViewModelBase', s\\); assert s!=-1 and e!=-1; p.write_text\\(t[:s]+t[e:], encoding='utf-8'\\); print\\('ok'\\)\")", + "Bash(perl -0777 -i -pe \"s/public sealed partial class UpdateSettingsPageViewModel : ViewModelBase\\\\R\\\\{.*?\\\\R\\\\}\\\\R\\\\Rpublic sealed partial class StudySettingsPageViewModel : ViewModelBase/public sealed partial class StudySettingsPageViewModel : ViewModelBase/s\" \"d:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs\")" ] } } diff --git a/.github/workflows/ddss-publish.yml b/.github/workflows/ddss-publish.yml index cd98db2..ee76ab6 100644 --- a/.github/workflows/ddss-publish.yml +++ b/.github/workflows/ddss-publish.yml @@ -1,5 +1,9 @@ name: DDSS +concurrency: + group: ddss-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }} + cancel-in-progress: false + on: workflow_run: workflows: @@ -31,7 +35,7 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Resolve release tag + - name: Resolve release tag and channel env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash @@ -50,6 +54,14 @@ jobs: fi echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" + IS_PRERELEASE="$(gh release view "$TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')" + if [[ "$IS_PRERELEASE" == "true" ]]; then + CHANNEL="preview" + else + CHANNEL="stable" + fi + echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV" + echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV" PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}" if [[ -z "$PUBLIC_BASE" ]]; then PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update" @@ -213,6 +225,33 @@ jobs: --repository "${{ github.repository }}" \ --s3-base-url "$S3_BASE_URL" + - name: Validate DDSS asset references in 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 + keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' ddss-output/ddss.json \ + | sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \ + | sort -u) + + if [[ -z "$keys" ]]; then + echo "No S3-backed asset URLs found in ddss.json" + exit 1 + fi + + while IFS= read -r key; do + [[ -n "$key" ]] || continue + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \ + --bucket "$S3_BUCKET" \ + --key "$key" >/dev/null + done <<< "$keys" + - name: Upload DDSS manifest to release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -221,7 +260,7 @@ jobs: set -euo pipefail gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber - - name: Upload DDSS manifest to Rainyun S3 + - name: Upload DDSS manifest to Rainyun S3 staging env: AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} @@ -243,6 +282,69 @@ jobs: --metadata "sha256=$sha256" done + - name: Prepare DDSS channel pointer + shell: bash + run: | + set -euo pipefail + pointer_file="ddss-output/ddss-latest.json" + cat > "$pointer_file" <<'JSON' + { + "schemaVersion": 1, + "channel": "__CHANNEL__", + "releaseTag": "__TAG__", + "version": "__VERSION__", + "updatedAt": "__UPDATED_AT__", + "manifest": { + "url": "__MANIFEST_URL__", + "signatureUrl": "__SIG_URL__" + } + } + JSON + + manifest_url="${S3_BASE_URL}/ddss.json" + sig_url="${S3_BASE_URL}/ddss.json.sig" + version="${RELEASE_TAG#v}" + updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + sed -i "s|__CHANNEL__|${RELEASE_CHANNEL}|g" "$pointer_file" + sed -i "s|__TAG__|${RELEASE_TAG}|g" "$pointer_file" + sed -i "s|__VERSION__|${version}|g" "$pointer_file" + sed -i "s|__UPDATED_AT__|${updated_at}|g" "$pointer_file" + sed -i "s|__MANIFEST_URL__|${manifest_url}|g" "$pointer_file" + sed -i "s|__SIG_URL__|${sig_url}|g" "$pointer_file" + + jq -e . "$pointer_file" >/dev/null + + - name: Atomically publish DDSS channel pointer + 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 + pointer_file="ddss-output/ddss-latest.json" + staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/ddss-latest.json" + + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$staging_key" \ + --body "$pointer_file" + + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$DDSS_CHANNEL_POINTER_KEY" \ + --body "$pointer_file" + + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \ + --bucket "$S3_BUCKET" \ + --key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null + + curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null + - name: Verify Rainyun S3 PLONDS output env: AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} diff --git a/.github/workflows/ddss-rollback.yml b/.github/workflows/ddss-rollback.yml new file mode 100644 index 0000000..c8dacd7 --- /dev/null +++ b/.github/workflows/ddss-rollback.yml @@ -0,0 +1,146 @@ +name: DDSS Rollback + +on: + workflow_dispatch: + inputs: + channel: + description: 'Target channel to rollback' + required: true + type: choice + default: stable + options: + - stable + - preview + target_tag: + description: 'Release tag to rollback to (e.g. v1.2.3)' + required: true + type: string + +env: + DOTNET_VERSION: '10.0.x' + +jobs: + rollback: + runs-on: ubuntu-latest + permissions: + contents: read + + concurrency: + group: ddss-rollback-${{ github.event.inputs.channel }} + cancel-in-progress: false + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve rollback context + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + + RAW_TAG="${{ github.event.inputs.target_tag }}" + if [[ "$RAW_TAG" == v* ]]; then + TAG="$RAW_TAG" + else + TAG="v$RAW_TAG" + fi + + CHANNEL="${{ github.event.inputs.channel }}" + + gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null + + 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 "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" + echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV" + echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV" + echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV" + echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV" + + - name: Validate rollback target assets + 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 + + for name in ddss.json ddss.json.sig; do + key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}" + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \ + --bucket "$S3_BUCKET" \ + --key "$key" >/dev/null + done + + - name: Build rollback pointer + shell: bash + run: | + set -euo pipefail + + mkdir -p rollback-output + pointer_file="rollback-output/ddss-latest.json" + + manifest_url="${S3_BASE_URL}/ddss.json" + sig_url="${S3_BASE_URL}/ddss.json.sig" + version="${RELEASE_TAG#v}" + updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + cat > "$pointer_file" </dev/null + + - name: Publish rollback pointer + 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 + + pointer_file="rollback-output/ddss-latest.json" + + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$DDSS_CHANNEL_POINTER_KEY" \ + --body "$pointer_file" + + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \ + --bucket "$S3_BUCKET" \ + --key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null + + curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null + + - name: Print rollback summary + shell: bash + run: | + set -euo pipefail + echo "Rolled back channel '${RELEASE_CHANNEL}' to '${RELEASE_TAG}'." + echo "Pointer: ${S3_PUBLIC_BASE_URL}/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" diff --git a/.github/workflows/plonds-build.yml b/.github/workflows/plonds-build.yml index 7b40c37..8eb99ff 100644 --- a/.github/workflows/plonds-build.yml +++ b/.github/workflows/plonds-build.yml @@ -1,10 +1,15 @@ name: PLONDS +concurrency: + group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }} + cancel-in-progress: false + on: release: types: - published - prereleased + - edited workflow_dispatch: inputs: tag: diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs index 5de2b5b..ff2105f 100644 --- a/LanMountainDesktop.Launcher/AppJsonContext.cs +++ b/LanMountainDesktop.Launcher/AppJsonContext.cs @@ -19,6 +19,7 @@ namespace LanMountainDesktop.Launcher; [JsonSerializable(typeof(PlondsFileEntry))] [JsonSerializable(typeof(PlondsHashDescriptor))] [JsonSerializable(typeof(SnapshotMetadata))] +[JsonSerializable(typeof(InstallCheckpoint))] [JsonSerializable(typeof(AppVersionInfo))] [JsonSerializable(typeof(StartupProgressMessage))] [JsonSerializable(typeof(LauncherCoordinatorRequest))] diff --git a/LanMountainDesktop.Launcher/Models/UpdateModels.cs b/LanMountainDesktop.Launcher/Models/UpdateModels.cs index e10c25b..264d912 100644 --- a/LanMountainDesktop.Launcher/Models/UpdateModels.cs +++ b/LanMountainDesktop.Launcher/Models/UpdateModels.cs @@ -41,6 +41,25 @@ internal sealed class SnapshotMetadata public string Status { get; set; } = "pending"; } +internal sealed class InstallCheckpoint +{ + public string SnapshotId { get; set; } = string.Empty; + + public string SourceVersion { get; set; } = string.Empty; + + public string? TargetVersion { get; set; } + + public string? SourceDirectory { get; set; } + + public string TargetDirectory { get; set; } = string.Empty; + + public bool IsInitialDeployment { get; set; } + + public int AppliedCount { get; set; } + + public int VerifiedCount { get; set; } +} + internal sealed class UpdateApplyResult { public bool Success { get; init; } diff --git a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs index 2d19bde..312bdb6 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs @@ -26,6 +26,7 @@ internal sealed class UpdateEngineService private readonly string _launcherRoot; private readonly string _incomingRoot; private readonly string _snapshotsRoot; + private readonly string _installCheckpointPath; public UpdateEngineService(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null) { @@ -36,6 +37,7 @@ internal sealed class UpdateEngineService _launcherRoot = resolver.ResolveLauncherDataPath(); _incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName); _snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName); + _installCheckpointPath = ContractsUpdate.UpdatePaths.GetInstallCheckpointPath(_appRoot); } public LauncherResult CheckPendingUpdate() @@ -129,19 +131,274 @@ internal sealed class UpdateEngineService Directory.CreateDirectory(_incomingRoot); Directory.CreateDirectory(_snapshotsRoot); - var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName); - var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName); - var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName); - if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath)) + var stateValidation = ValidateIncomingState(); + if (!stateValidation.Success) { - return await ApplyPendingPlondsUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath); + return stateValidation; } - var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName); - var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); - var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); + var applyLockPath = ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(_appRoot); + try + { + File.WriteAllText(applyLockPath, DateTimeOffset.UtcNow.ToString("O")); + } + catch (Exception ex) + { + return Failed("update.apply", "lock_conflict", $"Failed to acquire apply lock: {ex.Message}"); + } - if (!File.Exists(fileMapPath) || !File.Exists(archivePath)) + try + { + var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName); + var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName); + var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName); + if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath)) + { + return await ApplyPendingPlondsUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath); + } + + var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName); + var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); + var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); + + if (!File.Exists(fileMapPath) || !File.Exists(archivePath)) + { + return new LauncherResult + { + Success = true, + Stage = "update.apply", + Code = "noop", + Message = "No update payload found." + }; + } + + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0)); + var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName); + if (!verifyResult.Success) + { + _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false)); + return Failed("update.apply", "signature_failed", verifyResult.Message); + } + + var fileMapText = await File.ReadAllTextAsync(fileMapPath); + var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap); + if (fileMap is null || fileMap.Files.Count == 0) + { + _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false)); + return Failed("update.apply", "invalid_manifest", "No update file entries were found."); + } + + var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); + if (string.IsNullOrWhiteSpace(currentDeployment)) + { + // Initial install path: no current deployment exists, so apply the staged package directly. + } + + var currentVersion = _deploymentLocator.GetCurrentVersion(); + if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) && + !string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase)) + { + return Failed( + "update.apply", + "version_mismatch", + $"Update requires source version {fileMap.FromVersion} but current is {currentVersion}."); + } + + var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!; + var existingCheckpoint = LoadInstallCheckpoint(); + var canResume = existingCheckpoint is not null + && string.Equals(existingCheckpoint.SourceVersion, currentVersion, StringComparison.OrdinalIgnoreCase) + && string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase) + && string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(existingCheckpoint.TargetDirectory) + && File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial")); + + if (existingCheckpoint is not null && !canResume) + { + return Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload."); + } + + var targetDeployment = canResume + ? existingCheckpoint!.TargetDirectory + : _deploymentLocator.BuildNextDeploymentDirectory(targetVersion); + var partialMarker = Path.Combine(targetDeployment, ".partial"); + var snapshot = new SnapshotMetadata + { + SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"), + SourceVersion = currentVersion, + TargetVersion = targetVersion, + CreatedAt = DateTimeOffset.UtcNow, + SourceDirectory = currentDeployment, + TargetDirectory = targetDeployment, + Status = "pending" + }; + var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json"); + var checkpoint = canResume + ? existingCheckpoint! + : new InstallCheckpoint + { + SnapshotId = snapshot.SnapshotId, + SourceVersion = currentVersion, + TargetVersion = targetVersion, + SourceDirectory = currentDeployment, + TargetDirectory = targetDeployment, + IsInitialDeployment = false, + AppliedCount = 0, + VerifiedCount = 0 + }; + + var extractRoot = Path.Combine(_incomingRoot, "extracted"); + try + { + SaveSnapshot(snapshotPath, snapshot); + + if (Directory.Exists(extractRoot)) + { + Directory.Delete(extractRoot, true); + } + + Directory.CreateDirectory(extractRoot); + ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true); + + if (!canResume) + { + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count)); + Directory.CreateDirectory(targetDeployment); + File.WriteAllText(partialMarker, string.Empty); + } + + SaveInstallCheckpoint(checkpoint); + + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, checkpoint.AppliedCount, fileMap.Files.Count)); + for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileMap.Files.Count; fileIndex++) + { + var file = fileMap.Files[fileIndex]; + ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot); + checkpoint.AppliedCount = fileIndex + 1; + SaveInstallCheckpoint(checkpoint); + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (checkpoint.AppliedCount * 30 / fileMap.Files.Count), file.Path, checkpoint.AppliedCount, fileMap.Files.Count)); + } + + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, checkpoint.VerifiedCount, fileMap.Files.Count)); + for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileMap.Files.Count; verifyIndex++) + { + var file = fileMap.Files[verifyIndex]; + if (!NeedsVerification(file)) + { + checkpoint.VerifiedCount = verifyIndex + 1; + SaveInstallCheckpoint(checkpoint); + continue; + } + + var fullPath = Path.Combine(targetDeployment, file.Path); + var actualHash = ComputeSha256Hex(fullPath); + if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"File hash mismatch for '{file.Path}'."); + } + + checkpoint.VerifiedCount = verifyIndex + 1; + SaveInstallCheckpoint(checkpoint); + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileMap.Files.Count), file.Path, checkpoint.VerifiedCount, fileMap.Files.Count)); + } + + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count)); + ActivateDeployment(currentDeployment, targetDeployment); + + snapshot.Status = "applied"; + SaveSnapshot(snapshotPath, snapshot); + CleanupIncomingArtifacts(); + 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)); + + return new LauncherResult + { + Success = true, + Stage = "update.apply", + Code = "ok", + Message = $"Updated to {targetVersion}.", + CurrentVersion = currentVersion, + TargetVersion = targetVersion + }; + } + catch (Exception ex) + { + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); + var rollbackResult = TryRollbackOnFailure(snapshot); + snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; + SaveSnapshot(snapshotPath, snapshot); + 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 = 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 = rollbackResult.Success ? currentVersion : null + }; + } + finally + { + DeleteInstallCheckpoint(); + try + { + if (Directory.Exists(extractRoot)) + { + Directory.Delete(extractRoot, true); + } + } + catch + { + } + } + } + finally + { + try + { + if (File.Exists(applyLockPath)) + { + File.Delete(applyLockPath); + } + } + catch + { + } + } + } + + private LauncherResult ValidateIncomingState() + { + var applyLockPath = ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(_appRoot); + if (File.Exists(applyLockPath)) + { + return Failed("update.apply", "lock_conflict", "Another update apply operation is already in progress."); + } + + var deploymentLockPath = ContractsUpdate.UpdatePaths.GetDeploymentLockPath(_appRoot); + if (!File.Exists(deploymentLockPath)) + { + return Failed("update.apply", "staging_incomplete", "Deployment lock is missing. Please redownload the update."); + } + + var markerPath = ContractsUpdate.UpdatePaths.GetDownloadMarkerPath(_appRoot); + var hasPlondsMap = File.Exists(Path.Combine(_incomingRoot, PlondsFileMapName)); + var hasLegacyMap = File.Exists(Path.Combine(_incomingRoot, SignedFileMapName)); + if (hasPlondsMap && !File.Exists(markerPath)) + { + return Failed("update.apply", "staging_incomplete", "Download marker is missing for pending PLONDS update."); + } + + if (!hasPlondsMap && !hasLegacyMap) { return new LauncherResult { @@ -152,156 +409,13 @@ internal sealed class UpdateEngineService }; } - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0)); - var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName); - if (!verifyResult.Success) + return new LauncherResult { - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false)); - return Failed("update.apply", "signature_failed", verifyResult.Message); - } - - var fileMapText = await File.ReadAllTextAsync(fileMapPath); - var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap); - if (fileMap is null || fileMap.Files.Count == 0) - { - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false)); - return Failed("update.apply", "invalid_manifest", "No update file entries were found."); - } - - var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); - if (string.IsNullOrWhiteSpace(currentDeployment)) - { - // Initial install path: no current deployment exists, so apply the staged package directly. - } - - var currentVersion = _deploymentLocator.GetCurrentVersion(); - if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) && - !string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase)) - { - return Failed( - "update.apply", - "version_mismatch", - $"Update requires source version {fileMap.FromVersion} but current is {currentVersion}."); - } - - var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!; - var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion); - var partialMarker = Path.Combine(targetDeployment, ".partial"); - var snapshot = new SnapshotMetadata - { - SnapshotId = Guid.NewGuid().ToString("N"), - SourceVersion = currentVersion, - TargetVersion = targetVersion, - CreatedAt = DateTimeOffset.UtcNow, - SourceDirectory = currentDeployment, - TargetDirectory = targetDeployment, - Status = "pending" + Success = true, + Stage = "update.apply", + Code = "ok", + Message = "Incoming update state validated." }; - var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json"); - - var extractRoot = Path.Combine(_incomingRoot, "extracted"); - try - { - SaveSnapshot(snapshotPath, snapshot); - - if (Directory.Exists(extractRoot)) - { - Directory.Delete(extractRoot, true); - } - - Directory.CreateDirectory(extractRoot); - ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true); - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count)); - Directory.CreateDirectory(targetDeployment); - File.WriteAllText(partialMarker, string.Empty); - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, 0, fileMap.Files.Count)); - var fileIndex = 0; - foreach (var file in fileMap.Files) - { - ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot); - fileIndex++; - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (fileIndex * 30 / fileMap.Files.Count), file.Path, fileIndex, fileMap.Files.Count)); - } - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, 0, fileMap.Files.Count)); - var verifyIndex = 0; - foreach (var file in fileMap.Files) - { - if (!NeedsVerification(file)) - { - continue; - } - - var fullPath = Path.Combine(targetDeployment, file.Path); - var actualHash = ComputeSha256Hex(fullPath); - if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"File hash mismatch for '{file.Path}'."); - } - - verifyIndex++; - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (verifyIndex * 15 / fileMap.Files.Count), file.Path, verifyIndex, fileMap.Files.Count)); - } - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count)); - ActivateDeployment(currentDeployment, targetDeployment); - - snapshot.Status = "applied"; - SaveSnapshot(snapshotPath, snapshot); - CleanupIncomingArtifacts(); - 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)); - - return new LauncherResult - { - Success = true, - Stage = "update.apply", - Code = "ok", - Message = $"Updated to {targetVersion}.", - CurrentVersion = currentVersion, - TargetVersion = targetVersion - }; - } - catch (Exception ex) - { - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); - var rollbackResult = TryRollbackOnFailure(snapshot); - snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; - SaveSnapshot(snapshotPath, snapshot); - 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 = 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 = rollbackResult.Success ? currentVersion : null - }; - } - finally - { - try - { - if (Directory.Exists(extractRoot)) - { - Directory.Delete(extractRoot, true); - } - } - catch - { - } - } } private async Task ApplyPendingPlondsUpdateAsync( @@ -353,11 +467,26 @@ internal sealed class UpdateEngineService } var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment); - var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion!); + var existingCheckpoint = LoadInstallCheckpoint(); + var canResume = existingCheckpoint is not null + && string.Equals(existingCheckpoint.SourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase) + && string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase) + && string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(existingCheckpoint.TargetDirectory) + && File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial")); + + if (existingCheckpoint is not null && !canResume) + { + return Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload."); + } + + var targetDeployment = canResume + ? existingCheckpoint!.TargetDirectory + : _deploymentLocator.BuildNextDeploymentDirectory(targetVersion!); var partialMarker = Path.Combine(targetDeployment, ".partial"); var snapshot = new SnapshotMetadata { - SnapshotId = Guid.NewGuid().ToString("N"), + SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"), SourceVersion = sourceVersion, TargetVersion = targetVersion, CreatedAt = DateTimeOffset.UtcNow, @@ -367,35 +496,56 @@ internal sealed class UpdateEngineService }; var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json"); + var checkpoint = canResume + ? existingCheckpoint! + : new InstallCheckpoint + { + SnapshotId = snapshot.SnapshotId, + SourceVersion = sourceVersion, + TargetVersion = targetVersion, + SourceDirectory = currentDeployment, + TargetDirectory = targetDeployment, + IsInitialDeployment = isInitialDeployment, + AppliedCount = 0, + VerifiedCount = 0 + }; + try { SaveSnapshot(snapshotPath, snapshot); - if (Directory.Exists(targetDeployment)) + if (!canResume) { - Directory.Delete(targetDeployment, true); + if (Directory.Exists(targetDeployment)) + { + Directory.Delete(targetDeployment, true); + } + + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count)); + Directory.CreateDirectory(targetDeployment); + File.WriteAllText(partialMarker, string.Empty); } - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count)); - Directory.CreateDirectory(targetDeployment); - File.WriteAllText(partialMarker, string.Empty); + SaveInstallCheckpoint(checkpoint); - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, 0, fileEntries.Count)); - var fileIndex = 0; - foreach (var entry in fileEntries) + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, checkpoint.AppliedCount, fileEntries.Count)); + for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileEntries.Count; fileIndex++) { + var entry = fileEntries[fileIndex]; ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment); - fileIndex++; - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (fileIndex * 30 / fileEntries.Count), entry.Path, fileIndex, fileEntries.Count)); + checkpoint.AppliedCount = fileIndex + 1; + SaveInstallCheckpoint(checkpoint); + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (checkpoint.AppliedCount * 30 / fileEntries.Count), entry.Path, checkpoint.AppliedCount, fileEntries.Count)); } - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, 0, fileEntries.Count)); - var verifyIndex = 0; - foreach (var entry in fileEntries) + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, checkpoint.VerifiedCount, fileEntries.Count)); + for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileEntries.Count; verifyIndex++) { + var entry = fileEntries[verifyIndex]; VerifyPlondsFileEntry(entry, targetDeployment); - verifyIndex++; - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (verifyIndex * 15 / fileEntries.Count), entry.Path, verifyIndex, fileEntries.Count)); + checkpoint.VerifiedCount = verifyIndex + 1; + SaveInstallCheckpoint(checkpoint); + _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileEntries.Count), entry.Path, checkpoint.VerifiedCount, fileEntries.Count)); } if (isInitialDeployment) @@ -481,6 +631,10 @@ internal sealed class UpdateEngineService RolledBackTo = rollbackResult.Success ? sourceVersion : null }; } + finally + { + DeleteInstallCheckpoint(); + } } private void ApplyPlondsFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment) @@ -1529,7 +1683,8 @@ internal sealed class UpdateEngineService Path.Combine(_incomingRoot, ArchiveFileName), Path.Combine(_incomingRoot, PlondsFileMapName), Path.Combine(_incomingRoot, PlondsSignatureFileName), - Path.Combine(_incomingRoot, PlondsUpdateMetadataName) + Path.Combine(_incomingRoot, PlondsUpdateMetadataName), + _installCheckpointPath }) { try @@ -1638,6 +1793,48 @@ internal sealed class UpdateEngineService File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata)); } + private InstallCheckpoint? LoadInstallCheckpoint() + { + if (!File.Exists(_installCheckpointPath)) + { + return null; + } + + try + { + var text = File.ReadAllText(_installCheckpointPath); + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + return JsonSerializer.Deserialize(text, AppJsonContext.Default.InstallCheckpoint); + } + catch + { + return null; + } + } + + private void SaveInstallCheckpoint(InstallCheckpoint checkpoint) + { + File.WriteAllText(_installCheckpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint)); + } + + private void DeleteInstallCheckpoint() + { + try + { + if (File.Exists(_installCheckpointPath)) + { + File.Delete(_installCheckpointPath); + } + } + catch + { + } + } + private static LauncherResult Failed(string stage, string code, string message) { return new LauncherResult diff --git a/LanMountainDesktop.Shared.Contracts/Update/DeploymentLock.cs b/LanMountainDesktop.Shared.Contracts/Update/DeploymentLock.cs new file mode 100644 index 0000000..6d09a64 --- /dev/null +++ b/LanMountainDesktop.Shared.Contracts/Update/DeploymentLock.cs @@ -0,0 +1,11 @@ +using System; + +namespace LanMountainDesktop.Shared.Contracts.Update; + +public sealed record DeploymentLock( + int SchemaVersion, + string Kind, + string TargetVersion, + string PayloadPath, + string? PayloadSha256, + DateTimeOffset CreatedAtUtc); diff --git a/LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs b/LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs index 95eae40..bb7b4c4 100644 --- a/LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs +++ b/LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs @@ -54,8 +54,20 @@ public static class UpdatePaths public static string GetPlondsSignaturePath(string launcherRoot) => Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsSignatureName()); - public static string GetPlondsUpdateMetadataPath(string launcherRoot) - => Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsUpdateMetadataName()); + public static string GetDeploymentLockName() => "deployment.lock"; + + public static string GetDeploymentLockPath(string launcherRoot) + => Path.Combine(GetIncomingDirectory(launcherRoot), GetDeploymentLockName()); + + public static string GetApplyInProgressLockName() => "apply-in-progress.lock"; + + public static string GetApplyInProgressLockPath(string launcherRoot) + => Path.Combine(GetIncomingDirectory(launcherRoot), GetApplyInProgressLockName()); + + public static string GetInstallCheckpointName() => "install-checkpoint.json"; + + public static string GetInstallCheckpointPath(string launcherRoot) + => Path.Combine(GetIncomingDirectory(launcherRoot), GetInstallCheckpointName()); public static string GetDownloadMarkerContent(string manifestSha256, string targetVersion, int objectCount) { diff --git a/LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs b/LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs index f16e28f..7262b1c 100644 --- a/LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs +++ b/LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs @@ -6,8 +6,10 @@ public enum UpdatePhase Checking, Checked, Downloading, + PausedDownloading, Downloaded, Installing, + PausedInstalling, Installed, Verifying, Completed, @@ -64,9 +66,8 @@ public static class UpdatePhaseExtensions phase is UpdatePhase.Completed or UpdatePhase.Failed or UpdatePhase.RolledBack; public static bool IsBusy(this UpdatePhase phase) => - phase is not (UpdatePhase.Idle or UpdatePhase.Checked or UpdatePhase.Downloaded - or UpdatePhase.Installed or UpdatePhase.Completed or UpdatePhase.Failed - or UpdatePhase.RolledBack); + phase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.Installing + or UpdatePhase.Verifying or UpdatePhase.Recovering or UpdatePhase.RollingBack; public static bool CanCheck(this UpdatePhase phase) => phase is UpdatePhase.Idle or UpdatePhase.Checked or UpdatePhase.Downloaded @@ -80,4 +81,16 @@ public static class UpdatePhaseExtensions public static bool CanRollback(this UpdatePhase phase) => phase is UpdatePhase.Failed; + + public static bool CanPause(this UpdatePhase phase) => + phase is UpdatePhase.Downloading; + + public static bool CanResume(this UpdatePhase phase) => + phase is UpdatePhase.PausedDownloading or UpdatePhase.PausedInstalling; + + public static bool CanCancel(this UpdatePhase phase) => + phase.IsBusy() || phase.CanResume(); + + public static bool IsPaused(this UpdatePhase phase) => + phase is UpdatePhase.PausedDownloading or UpdatePhase.PausedInstalling; } diff --git a/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs b/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs index 6a2a34b..beda754 100644 --- a/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs +++ b/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs @@ -79,6 +79,74 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable Assert.Contains("app-1.0.0-0", result.ErrorMessage); } + [Fact] + public async Task ApplyPlondsUpdate_WhenInstallCheckpointIsStale_ReturnsStructuredFailure() + { + _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)); + _directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0"); + + var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var result = await service.ApplyPendingUpdateAsync(); + + Assert.False(result.Success); + Assert.Equal("resume_state_invalid", result.Code); + } + + [Fact] + public async Task ApplyLegacyUpdate_WhenInstallCheckpointIsStale_ReturnsStructuredFailure() + { + _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true); + _directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state"); + _directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0"); + + var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var result = await service.ApplyPendingUpdateAsync(); + + Assert.False(result.Success); + Assert.Equal("resume_state_invalid", result.Code); + } + + [Fact] + public async Task ApplyPlondsUpdate_WhenCheckpointIsValid_ResumesAndSucceeds() + { + 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)); + _directory.WriteValidPlondsResumeCheckpoint("1.0.0", "1.1.0"); + + var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var result = await service.ApplyPendingUpdateAsync(); + + Assert.True(result.Success, result.ErrorMessage); + Assert.Equal("1.1.0", result.TargetVersion); + Assert.False(File.Exists(Path.Combine(current, ".current"))); + var resumedTarget = Path.Combine(_directory.AppRoot, "app-1.1.0-0"); + Assert.True(File.Exists(Path.Combine(resumedTarget, ".current"))); + Assert.Equal("new-state", File.ReadAllText(Path.Combine(resumedTarget, "state.txt"))); + Assert.False(File.Exists(UpdatePaths.GetInstallCheckpointPath(_directory.AppRoot))); + } + + [Fact] + public async Task ApplyLegacyUpdate_WhenCheckpointIsValid_ResumesAndSucceeds() + { + var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true); + _directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state"); + _directory.WriteValidLegacyResumeCheckpoint("1.0.0", "1.1.0"); + + var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot)); + var result = await service.ApplyPendingUpdateAsync(); + + Assert.True(result.Success, result.ErrorMessage); + Assert.Equal("1.1.0", result.TargetVersion); + Assert.False(File.Exists(Path.Combine(current, ".current"))); + var resumedTarget = Path.Combine(_directory.AppRoot, "app-1.1.0-0"); + Assert.True(File.Exists(Path.Combine(resumedTarget, ".current"))); + Assert.Equal("new-state", File.ReadAllText(Path.Combine(resumedTarget, "state.txt"))); + Assert.False(File.Exists(UpdatePaths.GetInstallCheckpointPath(_directory.AppRoot))); + } + public void Dispose() => _directory.Dispose(); private static string Sha256Hex(byte[] bytes) @@ -166,6 +234,81 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable 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")); + + var deploymentLock = new DeploymentLock( + SchemaVersion: 1, + Kind: "delta", + TargetVersion: toVersion, + PayloadPath: fileMapPath, + PayloadSha256: Sha256File(fileMapPath), + CreatedAtUtc: DateTimeOffset.UtcNow); + var deploymentLockPath = UpdatePaths.GetDeploymentLockPath(AppRoot); + Directory.CreateDirectory(Path.GetDirectoryName(deploymentLockPath)!); + File.WriteAllText(deploymentLockPath, JsonSerializer.Serialize(deploymentLock)); + + var markerPath = UpdatePaths.GetDownloadMarkerPath(AppRoot); + File.WriteAllText(markerPath, UpdatePaths.GetDownloadMarkerContent( + manifestSha256: Sha256File(fileMapPath), + targetVersion: toVersion, + objectCount: 1)); + } + + public void StageLegacyUpdate(string fromVersion, string toVersion, string newState) + { + Directory.CreateDirectory(IncomingRoot); + var extractRoot = Path.Combine(IncomingRoot, "legacy-src"); + Directory.CreateDirectory(extractRoot); + + File.WriteAllText(Path.Combine(extractRoot, ExecutableName), $"exe-{toVersion}"); + File.WriteAllText(Path.Combine(extractRoot, "state.txt"), newState); + + var archivePath = Path.Combine(IncomingRoot, "update.zip"); + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + + System.IO.Compression.ZipFile.CreateFromDirectory(extractRoot, archivePath); + + var fileMap = new SignedFileMap + { + FromVersion = fromVersion, + ToVersion = toVersion, + Files = + [ + new LanMountainDesktop.Launcher.Models.UpdateFileEntry + { + Path = ExecutableName, + ArchivePath = ExecutableName, + Action = "replace", + Sha256 = Sha256File(Path.Combine(extractRoot, ExecutableName)) + }, + new LanMountainDesktop.Launcher.Models.UpdateFileEntry + { + Path = "state.txt", + ArchivePath = "state.txt", + Action = "replace", + Sha256 = Sha256File(Path.Combine(extractRoot, "state.txt")) + } + ] + }; + + var fileMapPath = Path.Combine(IncomingRoot, "files.json"); + File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.SignedFileMap)); + Sign(fileMapPath, Path.Combine(IncomingRoot, "files.json.sig")); + + var deploymentLock = new DeploymentLock( + SchemaVersion: 1, + Kind: "delta", + TargetVersion: toVersion, + PayloadPath: fileMapPath, + PayloadSha256: Sha256File(fileMapPath), + CreatedAtUtc: DateTimeOffset.UtcNow); + var deploymentLockPath = UpdatePaths.GetDeploymentLockPath(AppRoot); + Directory.CreateDirectory(Path.GetDirectoryName(deploymentLockPath)!); + File.WriteAllText(deploymentLockPath, JsonSerializer.Serialize(deploymentLock)); + + Directory.Delete(extractRoot, true); } public void WriteSnapshot(string sourceVersion, string sourceDirectory, string targetVersion, string targetDirectory) @@ -187,6 +330,72 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata)); } + public void WriteStaleInstallCheckpoint(string sourceVersion, string targetVersion) + { + var checkpoint = new InstallCheckpoint + { + SnapshotId = Guid.NewGuid().ToString("N"), + SourceVersion = sourceVersion, + TargetVersion = targetVersion, + SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"), + TargetDirectory = Path.Combine(AppRoot, $"app-{targetVersion}-999"), + IsInitialDeployment = false, + AppliedCount = 1, + VerifiedCount = 1 + }; + + var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot); + Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!); + File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint)); + } + + public void WriteValidPlondsResumeCheckpoint(string sourceVersion, string targetVersion) + { + var targetDeployment = Path.Combine(AppRoot, $"app-{targetVersion}-0"); + Directory.CreateDirectory(targetDeployment); + File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty); + File.WriteAllText(Path.Combine(targetDeployment, ExecutableName), $"exe-{sourceVersion}"); + + var checkpoint = new InstallCheckpoint + { + SnapshotId = Guid.NewGuid().ToString("N"), + SourceVersion = sourceVersion, + TargetVersion = targetVersion, + SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"), + TargetDirectory = targetDeployment, + IsInitialDeployment = false, + AppliedCount = 1, + VerifiedCount = 0 + }; + + var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot); + Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!); + File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint)); + } + + public void WriteValidLegacyResumeCheckpoint(string sourceVersion, string targetVersion) + { + var targetDeployment = Path.Combine(AppRoot, $"app-{targetVersion}-0"); + Directory.CreateDirectory(targetDeployment); + File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty); + + var checkpoint = new InstallCheckpoint + { + SnapshotId = Guid.NewGuid().ToString("N"), + SourceVersion = sourceVersion, + TargetVersion = targetVersion, + SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"), + TargetDirectory = targetDeployment, + IsInitialDeployment = false, + AppliedCount = 0, + VerifiedCount = 0 + }; + + var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot); + Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!); + File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint)); + } + public void Dispose() { _rsa.Dispose(); @@ -304,7 +513,7 @@ public sealed class UpdatePathConsistencyTests [Fact] public void HostAndSharedUpdatePathsUseLauncherDirectoryCasing() { - var incoming = UpdateWorkflowService.GetLauncherIncomingDirectory(); + var incoming = UpdatePaths.GetIncomingDirectory("root"); var sharedIncoming = UpdatePaths.GetIncomingDirectory("root"); Assert.Contains($"{Path.DirectorySeparatorChar}.Launcher{Path.DirectorySeparatorChar}", incoming); diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 429b91d..a59d19a 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -23,6 +23,7 @@ using LanMountainDesktop.Services.ExternalIpc; using LanMountainDesktop.Services.Launcher; using LanMountainDesktop.Services.Loading; using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.Services.Update; using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.IPC; using LanMountainDesktop.Shared.IPC.Abstractions.Services; @@ -76,6 +77,7 @@ public partial class App : Application private MainWindow? _mainWindow; private TransparentOverlayWindow? _transparentOverlayWindow; private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow; + private bool _isExitingFusedDesktopEditMode; private bool _mainWindowClosed; private DesktopShellHost? _desktopShellHost; private PublicIpcHostService? _publicIpcHostService; @@ -441,88 +443,132 @@ public partial class App : Application return; } - Dispatcher.UIThread.Post(() => + Dispatcher.UIThread.Post( + () => OpenFusedDesktopComponentLibraryFromUi(centerInWorkArea: false), + DispatcherPriority.Send); + } + + private void OpenFusedDesktopComponentLibraryFromUi(bool centerInWorkArea) + { + if (IsShutdownInProgress) { - if (IsShutdownInProgress) + AppLogger.Info("FusedDesktop", "Deferred Component Library open ignored because shutdown is in progress."); + return; + } + + try + { + var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate(); + fusedDesktopManager.EnterEditMode(); + + EnsureTransparentOverlayWindow(); + if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible) { - AppLogger.Info("FusedDesktop", "Deferred Component Library open ignored because shutdown is in progress."); + _transparentOverlayWindow.Show(); + } + + if (_fusedComponentLibraryWindow is { } existingWindow) + { + if (_transparentOverlayWindow is not null) + { + existingWindow.SetOverlayWindow(_transparentOverlayWindow); + } + + if (!existingWindow.IsVisible) + { + existingWindow.Show(); + } + + if (centerInWorkArea) + { + existingWindow.CenterInWorkArea(_transparentOverlayWindow); + } + + existingWindow.Activate(); return; } + var window = new FusedDesktopComponentLibraryWindow(); + _fusedComponentLibraryWindow = window; + if (_transparentOverlayWindow is not null) + { + window.SetOverlayWindow(_transparentOverlayWindow); + } + + window.Closed += OnFusedComponentLibraryWindowClosed; + window.Show(); + if (centerInWorkArea) + { + window.CenterInWorkArea(_transparentOverlayWindow); + } + + window.Activate(); + } + catch (Exception ex) + { + AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex); + ExitFusedDesktopEditModeFromUi(closeLibrary: true); + } + } + + private void OnFusedComponentLibraryWindowClosed(object? sender, EventArgs e) + { + if (sender is not FusedDesktopComponentLibraryWindow window) + { + return; + } + + window.Closed -= OnFusedComponentLibraryWindowClosed; + if (ReferenceEquals(_fusedComponentLibraryWindow, window)) + { + _fusedComponentLibraryWindow = null; + } + + if (!window.PreserveEditModeOnClose && !_isExitingFusedDesktopEditMode) + { + ExitFusedDesktopEditModeFromUi(closeLibrary: false); + } + } + + private void ExitFusedDesktopEditModeFromUi(bool closeLibrary) + { + if (_isExitingFusedDesktopEditMode) + { + return; + } + + _isExitingFusedDesktopEditMode = true; + try + { + if (closeLibrary && _fusedComponentLibraryWindow is { } libraryWindow) + { + _fusedComponentLibraryWindow = null; + libraryWindow.Closed -= OnFusedComponentLibraryWindowClosed; + libraryWindow.Close(); + } + try { - if (_fusedComponentLibraryWindow is { } existingWindow) - { - if (!existingWindow.IsVisible) - { - existingWindow.Show(); - } - - existingWindow.Activate(); - return; - } - - var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate(); - fusedDesktopManager.EnterEditMode(); - - // 纭繚閫忔槑瑕嗙洊灞傜獥鍙e瓨鍦ㄥ苟鏄剧ず - EnsureTransparentOverlayWindow(); - if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible) - { - _transparentOverlayWindow.Show(); - } - - var window = new FusedDesktopComponentLibraryWindow(); - _fusedComponentLibraryWindow = window; - - if (_transparentOverlayWindow is not null) - { - window.SetOverlayWindow(_transparentOverlayWindow); - } - - window.Closed += (s, ev) => - { - if (_transparentOverlayWindow is not null) - { - // 瑙﹀彂鐢诲竷淇濆瓨锛屽苟闅愯棌鐢诲竷 - _transparentOverlayWindow.SaveLayoutAndHide(); - } - - // 璁╃鐞嗗櫒鏍规嵁宸插瓨鍌ㄧ殑鏈€鏂板揩鐓ч噸寤虹敓鎴愭墍鏈夊疄浣撳皬缁勪欢 - fusedDesktopManager.ExitEditMode(); - if (ReferenceEquals(_fusedComponentLibraryWindow, s)) - { - _fusedComponentLibraryWindow = null; - } - }; - - window.Show(); - window.Activate(); + _transparentOverlayWindow?.SaveLayoutAndHide(); } - catch (Exception ex) + catch (Exception overlayEx) { - AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex); - try - { - _transparentOverlayWindow?.SaveLayoutAndHide(); - } - catch (Exception overlayEx) - { - AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay after library open failure.", overlayEx); - } - - try - { - FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); - } - catch (Exception exitEx) - { - AppLogger.Warn("FusedDesktop", "Failed to exit edit mode after library open failure.", exitEx); - } - - _fusedComponentLibraryWindow = null; + AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay.", overlayEx); } - }, DispatcherPriority.Send); + + try + { + FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); + } + catch (Exception exitEx) + { + AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode.", exitEx); + } + } + finally + { + _isExitingFusedDesktopEditMode = false; + } } private void DisableAvaloniaDataAnnotationValidation() @@ -945,6 +991,14 @@ public partial class App : Application { RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TransparentOverlay"); }; + _transparentOverlayWindow.ExitEditRequested += (s, e) => + { + ExitFusedDesktopEditModeFromUi(closeLibrary: true); + }; + _transparentOverlayWindow.RestoreComponentLibraryRequested += (s, e) => + { + OpenFusedDesktopComponentLibraryFromUi(centerInWorkArea: true); + }; } } @@ -1217,7 +1271,7 @@ public partial class App : Application try { - HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit(); + HostUpdateOrchestratorProvider.GetOrCreate().TryApplyOnExit(); } catch (Exception ex) { diff --git a/LanMountainDesktop/DesktopEditing/FusedDesktopEditGridAdapter.cs b/LanMountainDesktop/DesktopEditing/FusedDesktopEditGridAdapter.cs new file mode 100644 index 0000000..21a098a --- /dev/null +++ b/LanMountainDesktop/DesktopEditing/FusedDesktopEditGridAdapter.cs @@ -0,0 +1,73 @@ +using System; +using Avalonia; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; + +namespace LanMountainDesktop.DesktopEditing; + +internal readonly record struct FusedDesktopEditGridContext( + DesktopGridGeometry Geometry, + DesktopGridMetrics Metrics) +{ + public bool IsValid => Geometry.IsValid && Metrics.CellSize > 0; +} + +internal sealed class FusedDesktopEditGridAdapter +{ + private const int MinShortSideCells = 6; + private const int MaxShortSideCells = 96; + private const int DefaultShortSideCells = 12; + private const int MinEdgeInsetPercent = 0; + private const int MaxEdgeInsetPercent = 30; + + private readonly ISettingsFacadeService _settingsFacade; + + public FusedDesktopEditGridAdapter(ISettingsFacadeService settingsFacade) + { + _settingsFacade = settingsFacade; + } + + public bool TryCreate(Size viewportSize, out FusedDesktopEditGridContext context) + { + context = default; + if (viewportSize.Width <= 1 || viewportSize.Height <= 1) + { + return false; + } + + var state = _settingsFacade.Grid.Get(); + var shortSideCells = Math.Clamp( + state.ShortSideCells > 0 ? state.ShortSideCells : DefaultShortSideCells, + MinShortSideCells, + MaxShortSideCells); + var spacingPreset = _settingsFacade.Grid.NormalizeSpacingPreset(state.SpacingPreset); + var gapRatio = _settingsFacade.Grid.ResolveGapRatio(spacingPreset); + var edgeInsetPercent = Math.Clamp(state.EdgeInsetPercent, MinEdgeInsetPercent, MaxEdgeInsetPercent); + var edgeInset = _settingsFacade.Grid.CalculateEdgeInset( + viewportSize.Width, + viewportSize.Height, + shortSideCells, + edgeInsetPercent); + var metrics = _settingsFacade.Grid.CalculateGridMetrics( + viewportSize.Width, + viewportSize.Height, + shortSideCells, + gapRatio, + edgeInset); + + if (metrics.CellSize <= 0 || metrics.ColumnCount <= 0 || metrics.RowCount <= 0) + { + return false; + } + + var geometry = new DesktopGridGeometry( + Origin: new Point(metrics.EdgeInsetPx, metrics.EdgeInsetPx), + CellSize: metrics.CellSize, + CellGap: metrics.GapPx, + ColumnCount: metrics.ColumnCount, + RowCount: metrics.RowCount); + + context = new FusedDesktopEditGridContext(geometry, metrics); + return context.IsValid; + } +} diff --git a/LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs b/LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs index df071be..99d4767 100644 --- a/LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs +++ b/LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs @@ -37,6 +37,14 @@ public sealed class FusedDesktopComponentPlacementSnapshot /// 高度(像素) /// public double Height { get; set; } = 200; + + public int? GridRow { get; set; } + + public int? GridColumn { get; set; } + + public int? GridWidthCells { get; set; } + + public int? GridHeightCells { get; set; } /// /// Z-Index(用于控制组件层叠顺序) @@ -61,6 +69,10 @@ public sealed class FusedDesktopComponentPlacementSnapshot Y = Y, Width = Width, Height = Height, + GridRow = GridRow, + GridColumn = GridColumn, + GridWidthCells = GridWidthCells, + GridHeightCells = GridHeightCells, ZIndex = ZIndex, IsLocked = IsLocked }; diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 7aeba68..b223a5f 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -847,7 +847,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl snapshot, changedKeys: [ - nameof(AppSettingsSnapshot.IncludePrereleaseUpdates), nameof(AppSettingsSnapshot.IncludePrereleaseUpdates), nameof(AppSettingsSnapshot.UpdateChannel), nameof(AppSettingsSnapshot.UpdateMode), diff --git a/LanMountainDesktop/Services/Update/DeploymentLockService.cs b/LanMountainDesktop/Services/Update/DeploymentLockService.cs new file mode 100644 index 0000000..f0be94f --- /dev/null +++ b/LanMountainDesktop/Services/Update/DeploymentLockService.cs @@ -0,0 +1,52 @@ +using System; +using System.IO; +using System.Text.Json; +using LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Services.Update; + +internal static class DeploymentLockService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true + }; + + public static void WriteLock(string launcherRoot, DeploymentLock deploymentLock) + { + var lockPath = UpdatePaths.GetDeploymentLockPath(launcherRoot); + Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + var tempPath = lockPath + ".tmp"; + File.WriteAllText(tempPath, JsonSerializer.Serialize(deploymentLock, JsonOptions)); + File.Move(tempPath, lockPath, true); + } + + public static DeploymentLock? ReadLock(string launcherRoot) + { + var lockPath = UpdatePaths.GetDeploymentLockPath(launcherRoot); + if (!File.Exists(lockPath)) + { + return null; + } + + try + { + var json = File.ReadAllText(lockPath); + return JsonSerializer.Deserialize(json); + } + catch (Exception ex) + { + AppLogger.Warn("UpdateLock", $"Failed to parse deployment lock: {ex.Message}"); + return null; + } + } + + public static void ClearLock(string launcherRoot) + { + var lockPath = UpdatePaths.GetDeploymentLockPath(launcherRoot); + if (File.Exists(lockPath)) + { + File.Delete(lockPath); + } + } +} diff --git a/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs b/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs index 1aace67..45fefce 100644 --- a/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs +++ b/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -77,7 +78,7 @@ internal sealed class UpdateDownloadEngine var totalFiles = downloadableFiles.Count + 2; var completedFiles = 2; - var seenHashes = new HashSet(StringComparer.OrdinalIgnoreCase); + var seenHashes = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); var semaphore = new SemaphoreSlim(Math.Max(1, maxConcurrency), Math.Max(1, maxConcurrency)); var errors = new List(); long totalBytes = downloadableFiles.Sum(f => f.Size); @@ -89,7 +90,7 @@ internal sealed class UpdateDownloadEngine await semaphore.WaitAsync(ct); try { - if (!seenHashes.Add(entry.Sha256)) + if (!seenHashes.TryAdd(entry.Sha256, 0)) { lock (lockObj) { @@ -146,6 +147,20 @@ internal sealed class UpdateDownloadEngine { AppLogger.Warn("UpdateDownloadEngine", $"Object {entry.Path} hash mismatch after download. Expected: {entry.Sha256}, Actual: {actualHash}"); + SafeDeleteFile(objectPath); + + if (attempt < MaxRetryAttempts) + { + await Task.Delay(RetryDelayMs * attempt, ct); + continue; + } + + lock (lockObj) + { + errors.Add($"Hash mismatch for {entry.Path}: expected {entry.Sha256}, actual {actualHash}"); + } + + return; } lock (lockObj) @@ -274,7 +289,7 @@ internal sealed class UpdateDownloadEngine if (result.Success) { - bool hashVerified; + bool hashVerified = true; if (!string.IsNullOrWhiteSpace(mirror.Sha256)) { var actualHash = await ComputeFileSha256Async(destinationPath, ct); @@ -283,12 +298,17 @@ internal sealed class UpdateDownloadEngine { AppLogger.Warn("UpdateDownloadEngine", $"Full installer hash mismatch. Expected: {mirror.Sha256}, Actual: {actualHash}"); + SafeDeleteFile(destinationPath); + + if (attempt < MaxRetryAttempts) + { + await Task.Delay(RetryDelayMs * attempt, ct); + continue; + } + + return new DownloadResult(false, null, $"Full installer hash mismatch. Expected: {mirror.Sha256}, Actual: {actualHash}", false); } } - else - { - hashVerified = false; - } AppLogger.Info("UpdateDownloadEngine", $"Full installer downloaded to {destinationPath}"); return new DownloadResult(true, destinationPath, null, hashVerified); @@ -374,6 +394,20 @@ internal sealed class UpdateDownloadEngine return Convert.ToHexString(hash).ToLowerInvariant(); } + private static void SafeDeleteFile(string filePath) + { + try + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + catch + { + } + } + private static string ComputeStringSha256(string content) { using var hasher = SHA256.Create(); diff --git a/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs b/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs index ebea190..122ace8 100644 --- a/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs +++ b/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel; using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -9,7 +10,7 @@ using LanMountainDesktop.Shared.Contracts.Update; namespace LanMountainDesktop.Services.Update; -public sealed record InstallResult(bool Success, string? ErrorMessage, bool UserCancelledElevation); +public sealed record InstallResult(bool Success, string? ErrorMessage, bool UserCancelledElevation, string? ErrorCode = null); internal sealed class UpdateInstallGateway { @@ -31,12 +32,17 @@ internal sealed class UpdateInstallGateway 0, 0)); + if (!VerifyDeploymentLock(payloadKind, launcherRoot, out var lockErrorCode, out var lockError)) + { + return new InstallResult(false, lockError, false, lockErrorCode); + } + if (payloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy) { var launched = LaunchLauncherForApplyUpdate(launcherRoot); if (!launched) { - return new InstallResult(false, "Failed to launch Launcher for delta update application.", false); + return new InstallResult(false, "Failed to launch Launcher for delta update application.", false, "apply_failed"); } progress?.Report(new InstallProgressReport( @@ -50,10 +56,10 @@ internal sealed class UpdateInstallGateway return new InstallResult(true, null, false); } - var installerPath = FindPendingInstaller(launcherRoot); + var installerPath = FindPendingInstaller(launcherRoot, payloadKind, ct); if (installerPath is null) { - return new InstallResult(false, "No pending installer found.", false); + return new InstallResult(false, "No pending installer found.", false, "staging_incomplete"); } var installerLaunched = LaunchFullInstaller(installerPath); @@ -83,6 +89,43 @@ internal sealed class UpdateInstallGateway } } + private static bool VerifyDeploymentLock(UpdatePayloadKind payloadKind, string launcherRoot, out string? errorCode, out string? error) + { + errorCode = null; + error = null; + var deploymentLock = DeploymentLockService.ReadLock(launcherRoot); + if (deploymentLock is null) + { + errorCode = "lock_conflict"; + error = "Deployment lock is missing. Please redownload the update."; + return false; + } + + if (deploymentLock.SchemaVersion != 1) + { + errorCode = "lock_conflict"; + error = "Deployment lock schema is unsupported. Please redownload the update."; + return false; + } + + var expectedKind = payloadKind is UpdatePayloadKind.DeltaLegacy or UpdatePayloadKind.DeltaPlonds ? "delta" : "full"; + if (!string.Equals(deploymentLock.Kind, expectedKind, StringComparison.OrdinalIgnoreCase)) + { + errorCode = "lock_conflict"; + error = "Deployment lock payload type mismatch. Please redownload the update."; + return false; + } + + if (string.IsNullOrWhiteSpace(deploymentLock.PayloadPath) || !File.Exists(deploymentLock.PayloadPath)) + { + errorCode = "staging_incomplete"; + error = "Deployment lock payload path is missing. Please redownload the update."; + return false; + } + + return true; + } + private bool LaunchLauncherForApplyUpdate(string launcherRoot) { try @@ -145,15 +188,27 @@ internal sealed class UpdateInstallGateway } } - private static string? FindPendingInstaller(string launcherRoot) + private static string? FindPendingInstaller(string launcherRoot, UpdatePayloadKind payloadKind, CancellationToken ct) { + ct.ThrowIfCancellationRequested(); + var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot); if (!Directory.Exists(incomingDir)) { return null; } - var executables = Directory.GetFiles(incomingDir, "*.exe"); - return executables.Length > 0 ? executables[0] : null; + var executables = new DirectoryInfo(incomingDir) + .EnumerateFiles("*.exe", SearchOption.TopDirectoryOnly) + .OrderByDescending(file => file.LastWriteTimeUtc) + .ThenBy(file => file.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (executables.Length == 0) + { + return null; + } + + return executables[0].FullName; } } diff --git a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs index 2705e9d..32ef908 100644 --- a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs +++ b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs @@ -9,6 +9,34 @@ using SettingsUpdateSettingsState = LanMountainDesktop.Services.Settings.UpdateS namespace LanMountainDesktop.Services.Update; +internal static class HostUpdateOrchestratorProvider +{ + private static readonly object Gate = new(); + private static UpdateOrchestrator? _instance; + + public static UpdateOrchestrator GetOrCreate() + { + lock (Gate) + { + if (_instance is not null) + { + return _instance; + } + + var settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); + var githubProvider = new GithubReleaseManifestProvider("wwiinnddyy", "LanMountainDesktop"); + var staticProvider = new PlondsApiManifestProvider("https://api.classisland.tech"); + var compositeProvider = new CompositeManifestProvider(staticProvider, githubProvider); + var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + var downloadEngine = new UpdateDownloadEngine(compositeProvider, new ResumableDownloadService(httpClient)); + var installGateway = new UpdateInstallGateway(); + var stateStore = new UpdateStateStore(settingsFacade); + _instance = new UpdateOrchestrator(compositeProvider, downloadEngine, installGateway, stateStore); + return _instance; + } + } +} + public sealed class UpdateOrchestrator : IDisposable { private readonly IUpdateManifestProvider _manifestProvider; @@ -16,6 +44,8 @@ public sealed class UpdateOrchestrator : IDisposable private readonly UpdateInstallGateway _installGateway; private readonly UpdateStateStore _stateStore; private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly object _cancellationSync = new(); + private CancellationTokenSource? _activeOperationCts; private bool _disposed; internal UpdateOrchestrator( @@ -40,9 +70,29 @@ public sealed class UpdateOrchestrator : IDisposable public event Action? PhaseChanged; public event Action? ProgressChanged; + private CancellationToken RegisterOperationCancellation(CancellationToken ct) + { + lock (_cancellationSync) + { + _activeOperationCts?.Dispose(); + _activeOperationCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + return _activeOperationCts.Token; + } + } + + private void ClearOperationCancellation() + { + lock (_cancellationSync) + { + _activeOperationCts?.Dispose(); + _activeOperationCts = null; + } + } + public async Task CheckAsync(CancellationToken ct) { await _operationGate.WaitAsync(ct); + var operationToken = RegisterOperationCancellation(ct); try { if (!CurrentPhase.CanCheck()) @@ -59,9 +109,21 @@ public sealed class UpdateOrchestrator : IDisposable var currentVersionText = _stateStore.GetSettings().PendingUpdateVersion ?? AppVersionProvider.ResolveForCurrentProcess().Version; - if (!Version.TryParse(currentVersionText, out var currentVersion)) + if (!TryParseVersion(currentVersionText, out var currentVersion)) { - currentVersion = new Version(0, 0, 0); + _stateStore.TransitionTo(UpdatePhase.Failed); + _stateStore.RecordFailure($"Invalid current version text: {currentVersionText}"); + return new UpdateCheckReport( + false, + null, + currentVersionText, + null, + null, + null, + null, + null, + null, + $"Invalid current version text: {currentVersionText}"); } UpdateManifest? manifest; @@ -71,7 +133,7 @@ public sealed class UpdateOrchestrator : IDisposable channel, LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform(), currentVersion, - ct); + operationToken); } catch (OperationCanceledException) { @@ -114,6 +176,7 @@ public sealed class UpdateOrchestrator : IDisposable } finally { + ClearOperationCancellation(); _operationGate.Release(); } } @@ -121,9 +184,10 @@ public sealed class UpdateOrchestrator : IDisposable public async Task DownloadAsync(CancellationToken ct) { await _operationGate.WaitAsync(ct); + var operationToken = RegisterOperationCancellation(ct); try { - if (!CurrentPhase.CanDownload()) + if (CurrentPhase is not (UpdatePhase.Checked or UpdatePhase.PausedDownloading)) { return new DownloadResult(false, null, $"Cannot download in phase {CurrentPhase}.", false); } @@ -168,7 +232,7 @@ public sealed class UpdateOrchestrator : IDisposable objectsDir, maxThreads, downloadProgress, - ct); + operationToken); } else { @@ -183,7 +247,7 @@ public sealed class UpdateOrchestrator : IDisposable destinationPath, maxThreads, downloadProgress, - ct); + operationToken); } if (result.Success) @@ -196,9 +260,19 @@ public sealed class UpdateOrchestrator : IDisposable PendingUpdateInstallerPath = result.FilePath, PendingUpdateVersion = manifest.ToVersion, PendingUpdatePublishedAtUtcMs = manifest.PublishedAt.ToUnixTimeMilliseconds(), - PendingUpdateSha256 = null + PendingUpdateSha256 = null, + LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + var payloadKind = manifest.IsDelta ? "delta" : "full"; + DeploymentLockService.WriteLock(launcherRoot, new DeploymentLock( + SchemaVersion: 1, + Kind: payloadKind, + TargetVersion: manifest.ToVersion, + PayloadPath: result.FilePath ?? string.Empty, + PayloadSha256: null, + CreatedAtUtc: DateTimeOffset.UtcNow)); + AppLogger.Info("UpdateOrchestrator", $"Update downloaded successfully: {manifest.ToVersion}"); } else @@ -211,7 +285,11 @@ public sealed class UpdateOrchestrator : IDisposable } catch (OperationCanceledException) { - _stateStore.TransitionTo(UpdatePhase.Idle); + if (CurrentPhase != UpdatePhase.PausedDownloading) + { + _stateStore.TransitionTo(UpdatePhase.Idle); + } + throw; } catch (Exception ex) @@ -223,6 +301,7 @@ public sealed class UpdateOrchestrator : IDisposable } finally { + ClearOperationCancellation(); _operationGate.Release(); } } @@ -230,17 +309,18 @@ public sealed class UpdateOrchestrator : IDisposable public async Task InstallAsync(CancellationToken ct) { await _operationGate.WaitAsync(ct); + var operationToken = RegisterOperationCancellation(ct); try { if (!CurrentPhase.CanInstall()) { - return new InstallResult(false, $"Cannot install in phase {CurrentPhase}.", false); + return new InstallResult(false, $"Cannot install in phase {CurrentPhase}.", false, "invalid_phase"); } var manifest = _stateStore.PendingManifest; if (manifest is null) { - return new InstallResult(false, "No manifest available for install.", false); + return new InstallResult(false, "No manifest available for install.", false, "staging_incomplete"); } _stateStore.TransitionTo(UpdatePhase.Installing); @@ -264,7 +344,7 @@ public sealed class UpdateOrchestrator : IDisposable manifest.Kind, launcherRoot, installProgress, - ct); + operationToken); if (result.Success) { @@ -282,18 +362,23 @@ public sealed class UpdateOrchestrator : IDisposable } catch (OperationCanceledException) { - _stateStore.TransitionTo(UpdatePhase.Failed); + if (CurrentPhase != UpdatePhase.PausedInstalling) + { + _stateStore.TransitionTo(UpdatePhase.Idle); + } + throw; } catch (Exception ex) { _stateStore.TransitionTo(UpdatePhase.Failed); _stateStore.RecordFailure(ex.Message); - return new InstallResult(false, ex.Message, false); + return new InstallResult(false, ex.Message, false, "install_exception"); } } finally { + ClearOperationCancellation(); _operationGate.Release(); } } @@ -301,8 +386,11 @@ public sealed class UpdateOrchestrator : IDisposable public async Task RollbackAsync(CancellationToken ct) { await _operationGate.WaitAsync(ct); + var operationToken = RegisterOperationCancellation(ct); try { + operationToken.ThrowIfCancellationRequested(); + if (!CurrentPhase.CanRollback()) { return; @@ -330,6 +418,11 @@ public sealed class UpdateOrchestrator : IDisposable _stateStore.TransitionTo(UpdatePhase.RolledBack); } + catch (OperationCanceledException) + { + _stateStore.TransitionTo(UpdatePhase.Idle); + throw; + } catch (Exception ex) { AppLogger.Warn("UpdateOrchestrator", $"Rollback failed: {ex.Message}"); @@ -338,21 +431,86 @@ public sealed class UpdateOrchestrator : IDisposable } finally { + ClearOperationCancellation(); _operationGate.Release(); } } - public async Task CancelAsync() + public Task CancelAsync() { - if (!CurrentPhase.IsBusy()) + if (!CurrentPhase.CanCancel()) { - return; + return Task.CompletedTask; + } + + lock (_cancellationSync) + { + _activeOperationCts?.Cancel(); } _stateStore.TransitionTo(UpdatePhase.Idle); - _stateStore.PendingManifest = null; - AppLogger.Info("UpdateOrchestrator", "Update operation cancelled."); - await Task.CompletedTask; + + var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory); + CleanupIncomingArtifacts(launcherRoot); + DeploymentLockService.ClearLock(launcherRoot); + + var state = _stateStore.GetSettings(); + _stateStore.SaveSettings(state with + { + PendingUpdateInstallerPath = null, + PendingUpdateVersion = null, + PendingUpdateSha256 = null + }); + + AppLogger.Info("UpdateOrchestrator", "Cancellation requested for active update operation."); + return Task.CompletedTask; + } + + public Task PauseAsync() + { + if (!CurrentPhase.CanPause()) + { + return Task.CompletedTask; + } + + var pausedPhase = CurrentPhase switch + { + UpdatePhase.Downloading => UpdatePhase.PausedDownloading, + UpdatePhase.Installing => UpdatePhase.PausedInstalling, + _ => UpdatePhase.Idle + }; + + _stateStore.TransitionTo(pausedPhase); + + lock (_cancellationSync) + { + _activeOperationCts?.Cancel(); + } + + AppLogger.Info("UpdateOrchestrator", $"Pause requested in phase {pausedPhase}."); + return Task.CompletedTask; + } + + public async Task ResumeAsync(CancellationToken ct) + { + return CurrentPhase switch + { + UpdatePhase.PausedDownloading => await DownloadAsync(ct), + UpdatePhase.PausedInstalling => await ResumeInstallAsync(ct), + _ => new DownloadResult(false, null, $"Cannot resume in phase {CurrentPhase}.", false) + }; + } + + private async Task ResumeInstallAsync(CancellationToken ct) + { + _stateStore.TransitionTo(UpdatePhase.Recovering); + var installResult = await InstallAsync(ct); + if (installResult.Success) + { + return new DownloadResult(true, null, null, false); + } + + return new DownloadResult(false, null, installResult.ErrorMessage ?? installResult.ErrorCode ?? "Install resume failed.", false); } public async Task AutoCheckIfEnabledAsync(CancellationToken ct) @@ -458,6 +616,77 @@ public sealed class UpdateOrchestrator : IDisposable } } + private static void CleanupIncomingArtifacts(string launcherRoot) + { + var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot); + + foreach (var path in new[] + { + Path.Combine(incomingDir, UpdatePaths.GetLegacyFileMapName()), + Path.Combine(incomingDir, UpdatePaths.GetLegacySignatureName()), + Path.Combine(incomingDir, UpdatePaths.GetLegacyArchiveName()), + Path.Combine(incomingDir, UpdatePaths.GetPlondsFileMapName()), + Path.Combine(incomingDir, UpdatePaths.GetPlondsSignatureName()), + Path.Combine(incomingDir, UpdatePaths.GetPlondsUpdateMetadataName()), + UpdatePaths.GetDownloadMarkerPath(launcherRoot) + }) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + } + } + + try + { + var objectsDir = UpdatePaths.GetObjectsDirectory(launcherRoot); + if (Directory.Exists(objectsDir)) + { + Directory.Delete(objectsDir, true); + } + } + catch + { + } + } + + private static bool TryParseVersion(string? value, out Version version) + { + version = new Version(0, 0, 0); + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var normalized = value.Trim().TrimStart('v', 'V'); + var dashIndex = normalized.IndexOf('-'); + if (dashIndex >= 0) + { + normalized = normalized[..dashIndex]; + } + + var plusIndex = normalized.IndexOf('+'); + if (plusIndex >= 0) + { + normalized = normalized[..plusIndex]; + } + + if (!Version.TryParse(normalized, out var parsed)) + { + return false; + } + + version = new Version(parsed.Major, parsed.Minor, Math.Max(0, parsed.Build), Math.Max(0, parsed.Revision)); + return true; + } + private void OnPhaseChanged(UpdatePhase phase) { PhaseChanged?.Invoke(phase); @@ -478,6 +707,11 @@ public sealed class UpdateOrchestrator : IDisposable _disposed = true; _stateStore.PhaseChanged -= OnPhaseChanged; _stateStore.ProgressChanged -= OnProgressChanged; + lock (_cancellationSync) + { + _activeOperationCts?.Dispose(); + _activeOperationCts = null; + } _operationGate.Dispose(); } } diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs deleted file mode 100644 index 03dd1f3..0000000 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ /dev/null @@ -1,1572 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net.Http; -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using LanMountainDesktop.PluginSdk; -using LanMountainDesktop.Services.Settings; - -namespace LanMountainDesktop.Services; - -public sealed record UpdatePendingInfo( - string InstallerPath, - string VersionText, - DateTimeOffset? PublishedAt, - string? Sha256 = null); - -public sealed record UpdateVerifyResult( - bool Success, - bool HashMatched, - string? ExpectedHash, - string? ActualHash, - string? ErrorMessage); - -public sealed record UpdateInstallerLaunchResult( - bool Success, - bool UserCancelledElevation, - string? ErrorMessage); - -internal static class HostUpdateWorkflowServiceProvider -{ - private static readonly object Gate = new(); - private static UpdateWorkflowService? _instance; - - public static UpdateWorkflowService GetOrCreate() - { - lock (Gate) - { - return _instance ??= new UpdateWorkflowService(HostSettingsFacadeProvider.GetOrCreate()); - } - } -} - -public sealed class UpdateWorkflowService -{ - private readonly ISettingsFacadeService _settingsFacade; - private readonly string _updatesDirectory; - - private const string LauncherDirectoryName = ".Launcher"; - private const string UpdateDirectoryName = "update"; - private const string IncomingDirectoryName = "incoming"; - private const string IncomingObjectsDirectoryName = "objects"; - private const string SignedFileMapName = "files.json"; - private const string SignedFileMapSignatureName = "files.json.sig"; - private const string UpdateArchiveName = "update.zip"; - private const string PlondsFileMapName = "plonds-filemap.json"; - private const string PlondsFileMapSignatureName = "plonds-filemap.sig"; - private const string PlondsUpdateStateName = "plonds-update.json"; - private const string PlondsUpdateArchiveName = "plonds-update.zip"; - - private static readonly HttpClient PlondsHttpClient = new() - { - Timeout = TimeSpan.FromMinutes(5) - }; - - private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient); - private const int MaxPlondsOuterRetryAttempts = 3; - - public UpdateWorkflowService(ISettingsFacadeService settingsFacade) - { - _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); - _updatesDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LanMountainDesktop", - "Updates"); - } - - /// - /// Gets the path to the Launcher's incoming update directory where delta packages should be placed. - /// - public static string GetLauncherIncomingDirectory() - { - // The app runs from app-{version}/ subdirectory; Launcher root is one level up. - var appBaseDir = AppContext.BaseDirectory; - var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - if (string.IsNullOrWhiteSpace(launcherRoot)) - { - launcherRoot = appBaseDir; - } - return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName); - } - - public static string GetLauncherIncomingObjectsDirectory() - { - return Path.Combine(GetLauncherIncomingDirectory(), IncomingObjectsDirectoryName); - } - - /// - /// Checks whether a GitHub Release contains signed file-map assets needed for incremental updates. - /// - public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release) - { - if (release is null || release.Assets is null || release.Assets.Count == 0) - { - return false; - } - - return TryResolveDeltaAssets(release.Assets, out _, out _, out _); - } - - public static bool IsDeltaUpdateAvailable(UpdateCheckResult checkResult) - { - if (checkResult.PlondsPayload is not null) - { - return true; - } - - return checkResult.Release is not null && IsDeltaUpdateAvailable(checkResult.Release); - } - - /// - /// Downloads signed file-map assets to the Launcher's incoming directory. - /// - public async Task DownloadDeltaUpdateAsync( - UpdateCheckResult checkResult, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(checkResult); - - if (!checkResult.Success || !checkResult.IsUpdateAvailable) - { - return new UpdateDownloadResult(false, null, "No update available for delta download."); - } - - if (checkResult.PlondsPayload is null && checkResult.Release is null) - { - return new UpdateDownloadResult(false, null, "No update payload is available for delta download."); - } - - if (checkResult.PlondsPayload is not null) - { - return await DownloadPlondsDeltaUpdateAsync(checkResult, progress, cancellationToken); - } - - var release = checkResult.Release; - if (release is null || - !TryResolveDeltaAssets(release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset)) - { - return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets."); - } - - var incomingDir = GetLauncherIncomingDirectory(); - - try - { - Directory.CreateDirectory(incomingDir); - } - catch (Exception ex) - { - return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}"); - } - - var state = _settingsFacade.Update.Get(); - var downloadSource = state.UseGhProxyMirror - ? UpdateSettingsValues.DownloadSourceGhProxy - : UpdateSettingsValues.DownloadSourceGitHub; - var downloadThreads = state.UpdateDownloadThreads; - - var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)> - { - (manifestAsset, SignedFileMapName), - (signatureAsset, SignedFileMapSignatureName), - (archiveAsset, UpdateArchiveName) - }; - - var totalAssets = requiredAssets.Count; - var completedAssets = 0; - - foreach (var (asset, destinationFileName) in requiredAssets) - { - var destinationPath = Path.Combine(incomingDir, destinationFileName); - - // Skip if already downloaded and file exists - if (File.Exists(destinationPath)) - { - var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken); - if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase)) - { - AppLogger.Info("UpdateWorkflow", $"Update asset {asset.Name} already downloaded with matching hash, skipping."); - completedAssets++; - progress?.Report((double)completedAssets / totalAssets); - continue; - } - } - - var assetProgress = progress is null ? null : new Progress(p => - { - var overallProgress = ((double)completedAssets + p) / totalAssets; - progress.Report(overallProgress); - }); - - var result = await _settingsFacade.Update.DownloadAssetAsync( - asset, - destinationPath, - downloadSource, - downloadThreads, - assetProgress, - cancellationToken); - - if (!result.Success) - { - // Clean up partially downloaded files - foreach (var file in requiredAssets.Select(a => a.DestinationFileName)) - { - try { File.Delete(Path.Combine(incomingDir, file)); } catch { } - } - return new UpdateDownloadResult(false, null, $"Failed to download update asset {asset.Name}: {result.ErrorMessage}"); - } - - completedAssets++; - progress?.Report((double)completedAssets / totalAssets); - } - - // Save state indicating a signed file-map update is pending. - SaveState(state with - { - PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName), - PendingUpdateVersion = checkResult.LatestVersionText, - PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue - ? publishedAt.ToUnixTimeMilliseconds() - : null, - LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - PendingUpdateSha256 = null - }); - - AppLogger.Info("UpdateWorkflow", $"Signed file-map update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup."); - - return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null); - } - - private async Task DownloadPlondsDeltaUpdateAsync( - UpdateCheckResult checkResult, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - var payload = checkResult.PlondsPayload; - if (payload is null) - { - return await HandlePlondsDeltaFailureAsync( - checkResult, - "payload-parse", - "PLONDS payload is missing.", - progress, - cancellationToken); - } - - var incomingDir = GetLauncherIncomingDirectory(); - var objectsDir = GetLauncherIncomingObjectsDirectory(); - - try - { - Directory.CreateDirectory(incomingDir); - Directory.CreateDirectory(objectsDir); - } - catch (Exception ex) - { - return await HandlePlondsDeltaFailureAsync( - checkResult, - "payload-parse", - $"Failed to create incoming directory: {ex.Message}", - progress, - cancellationToken); - } - - try - { - var state = _settingsFacade.Update.Get(); - var downloadThreads = Math.Max(1, state.UpdateDownloadThreads); - var fileMapPath = Path.Combine(incomingDir, PlondsFileMapName); - var signaturePath = Path.Combine(incomingDir, PlondsFileMapSignatureName); - var updateStatePath = Path.Combine(incomingDir, PlondsUpdateStateName); - - var fileMapJson = await EnsurePlondsTextResourceAsync( - payload.FileMapJson, - payload.FileMapJsonUrl, - fileMapPath, - "file map", - "filemap-download", - cancellationToken); - - var fileMapSignature = await EnsurePlondsTextResourceAsync( - payload.FileMapSignature, - payload.FileMapSignatureUrl, - signaturePath, - "file map signature", - "filemap-download", - cancellationToken); - - IReadOnlyList objectResults; - if (!string.IsNullOrWhiteSpace(payload.UpdateArchiveUrl)) - { - progress?.Report(2d / 3d); - objectResults = await EnsurePlondsArchiveObjectsAsync( - payload, - incomingDir, - objectsDir, - state.UseGhProxyMirror - ? UpdateSettingsValues.DownloadSourceGhProxy - : UpdateSettingsValues.DownloadSourceGitHub, - downloadThreads, - progress, - cancellationToken); - } - else - { - IReadOnlyList downloadEntries; - try - { - downloadEntries = ParsePlondsDownloadEntries(fileMapJson); - } - catch (JsonException ex) - { - throw new PlondsDownloadException("payload-parse", $"PLONDS file map JSON is invalid: {ex.Message}", ex); - } - - if (downloadEntries.Count == 0) - { - throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects."); - } - - var expectedObjectCount = downloadEntries.Count; - var completedItems = 2; - progress?.Report(expectedObjectCount == 0 ? 1d : (double)completedItems / (expectedObjectCount + 2)); - - var downloadResults = new List(expectedObjectCount); - var objectTargets = new HashSet(StringComparer.OrdinalIgnoreCase); - var totalSteps = expectedObjectCount + 2; - - foreach (var entry in downloadEntries) - { - if (!objectTargets.Add(entry.ObjectHashHex)) - { - completedItems++; - progress?.Report((double)completedItems / totalSteps); - continue; - } - - var objectInfo = await EnsurePlondsObjectAsync( - entry, - objectsDir, - downloadThreads, - cancellationToken); - - downloadResults.Add(objectInfo); - completedItems++; - progress?.Report((double)completedItems / totalSteps); - } - - objectResults = downloadResults; - } - - var updateState = new PlondsUpdateState( - checkResult.LatestVersionText, - payload.DistributionId, - payload.ChannelId, - payload.SubChannel, - fileMapPath, - signaturePath, - objectsDir, - DateTimeOffset.UtcNow, - fileMapJson, - fileMapSignature, - objectResults); - - await File.WriteAllTextAsync(updateStatePath, JsonSerializer.Serialize(updateState, UpdateJsonOptions), cancellationToken); - - SaveState(state with - { - PendingUpdateInstallerPath = updateStatePath, - PendingUpdateVersion = checkResult.LatestVersionText, - PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue - ? publishedAt.ToUnixTimeMilliseconds() - : null, - LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - PendingUpdateSha256 = null - }); - - progress?.Report(1d); - AppLogger.Info("UpdateWorkflow", $"PLONDS update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup."); - return new UpdateDownloadResult(true, updateStatePath, null); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - var stage = ex is PlondsDownloadException plondsException - ? plondsException.Stage - : "payload-parse"; - var message = ex is PlondsDownloadException - ? ex.Message - : $"PLONDS incremental payload failed unexpectedly: {ex.Message}"; - - AppLogger.Warn("UpdateWorkflow", $"Failed to download PLONDS incremental payload at stage '{stage}'.", ex); - return await HandlePlondsDeltaFailureAsync( - checkResult, - stage, - message, - progress, - cancellationToken); - } - } - - private static readonly JsonSerializerOptions UpdateJsonOptions = new() - { - WriteIndented = true - }; - - /// - /// Checks whether the pending update is managed by Launcher incoming payload. - /// - public bool IsPendingDeltaUpdate() - { - var state = _settingsFacade.Update.Get(); - var pendingPath = state.PendingUpdateInstallerPath?.Trim(); - if (string.IsNullOrWhiteSpace(pendingPath)) - { - return false; - } - - // Incoming payload updates are identified by the local manifest or incoming directory path. - return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase) - || pendingPath.EndsWith(PlondsUpdateStateName, StringComparison.OrdinalIgnoreCase) - || pendingPath.EndsWith(PlondsFileMapName, StringComparison.OrdinalIgnoreCase) - || pendingPath.EndsWith(PlondsFileMapSignatureName, StringComparison.OrdinalIgnoreCase) - || pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase); - } - - private async Task DownloadFullInstallerAsync( - UpdateCheckResult checkResult, - IProgress? progress, - CancellationToken cancellationToken, - bool forceRedownload) - { - if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null) - { - return new UpdateDownloadResult(false, null, "No compatible update asset is available."); - } - - var state = _settingsFacade.Update.Get(); - var existingPending = GetPendingUpdate(state); - - if (!forceRedownload && - existingPending is not null && - string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) && - File.Exists(existingPending.InstallerPath)) - { - var verifyResult = await VerifyPendingUpdateAsync(); - if (verifyResult.Success) - { - return new UpdateDownloadResult( - true, - existingPending.InstallerPath, - null, - verifyResult.HashMatched, - verifyResult.ExpectedHash, - verifyResult.ActualHash); - } - - AppLogger.Warn( - "UpdateWorkflow", - $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}"); - } - - if (forceRedownload && existingPending is not null && File.Exists(existingPending.InstallerPath)) - { - try - { - File.Delete(existingPending.InstallerPath); - AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}"); - } - catch (Exception ex) - { - AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex); - } - - ClearPendingUpdate(); - state = _settingsFacade.Update.Get(); - } - - Directory.CreateDirectory(_updatesDirectory); - var fileName = SanitizeFileName(checkResult.PreferredAsset.Name); - var destinationPath = Path.Combine(_updatesDirectory, fileName); - - var result = await _settingsFacade.Update.DownloadAssetAsync( - checkResult.PreferredAsset, - destinationPath, - state.UseGhProxyMirror - ? UpdateSettingsValues.DownloadSourceGhProxy - : UpdateSettingsValues.DownloadSourceGitHub, - state.UpdateDownloadThreads, - progress, - cancellationToken); - - if (result.Success) - { - SaveState(state with - { - PendingUpdateInstallerPath = result.FilePath ?? destinationPath, - PendingUpdateVersion = checkResult.LatestVersionText, - PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue - ? publishedAt.ToUnixTimeMilliseconds() - : null, - LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - PendingUpdateSha256 = result.ActualHash - }); - } - - return result; - } - - private async Task HandlePlondsDeltaFailureAsync( - UpdateCheckResult checkResult, - string stage, - string errorMessage, - IProgress? progress, - CancellationToken cancellationToken) - { - var normalizedMessage = string.IsNullOrWhiteSpace(errorMessage) - ? $"PLONDS {stage} failed." - : $"PLONDS {stage} failed: {errorMessage}"; - - if (checkResult.Release is null || checkResult.PreferredAsset is null) - { - return new UpdateDownloadResult(false, null, normalizedMessage); - } - - AppLogger.Warn( - "UpdateWorkflow", - $"PLONDS delta download failed at stage '{stage}'. Falling back to full installer download. Details: {errorMessage}"); - - var fallbackResult = await DownloadFullInstallerAsync( - checkResult, - progress, - cancellationToken, - forceRedownload: false); - - if (fallbackResult.Success) - { - return fallbackResult; - } - - var combinedMessage = string.IsNullOrWhiteSpace(fallbackResult.ErrorMessage) - ? normalizedMessage - : $"{normalizedMessage} Full installer fallback failed: {fallbackResult.ErrorMessage}"; - - return new UpdateDownloadResult(false, null, combinedMessage); - } - - private static string GetPlondsObjectDestinationPath(string objectsDirectory, string objectHashHex) - { - var normalizedHash = objectHashHex.Trim().ToLowerInvariant(); - var shard = normalizedHash.Length >= 2 ? normalizedHash[..2] : normalizedHash; - return Path.Combine(objectsDirectory, shard, normalizedHash); - } - - private static async Task EnsurePlondsTextResourceAsync( - string? inlineContent, - string? sourceUrl, - string destinationPath, - string resourceName, - string stage, - CancellationToken cancellationToken) - { - if (!string.IsNullOrWhiteSpace(inlineContent)) - { - await File.WriteAllTextAsync(destinationPath, inlineContent, cancellationToken); - return inlineContent; - } - - if (string.IsNullOrWhiteSpace(sourceUrl)) - { - throw new PlondsDownloadException(stage, $"PLONDS payload does not contain a {resourceName} source."); - } - - Exception? lastError = null; - for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++) - { - var downloadResult = await PlondsDownloadService.DownloadAsync( - sourceUrl, - destinationPath, - cancellationToken: cancellationToken); - - if (downloadResult.Success) - { - try - { - return await File.ReadAllTextAsync(destinationPath, cancellationToken); - } - catch (Exception ex) when (attempt < MaxPlondsOuterRetryAttempts) - { - lastError = ex; - } - } - else - { - lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS {resourceName}."); - } - - if (attempt < MaxPlondsOuterRetryAttempts) - { - AppLogger.Warn( - "UpdateWorkflow", - $"PLONDS {resourceName} download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed. Retrying same URL."); - await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken); - } - } - - throw new PlondsDownloadException( - stage, - $"Failed to download PLONDS {resourceName} from {sourceUrl}.", - lastError); - } - - private static async Task EnsurePlondsObjectAsync( - PlondsDownloadEntry entry, - string objectsDirectory, - int downloadThreads, - CancellationToken cancellationToken) - { - var destinationPath = GetPlondsObjectDestinationPath(objectsDirectory, entry.ObjectHashHex); - var destinationDirectory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrWhiteSpace(destinationDirectory)) - { - Directory.CreateDirectory(destinationDirectory); - } - - var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken); - if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase)) - { - return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath); - } - - if (!string.IsNullOrWhiteSpace(existingHash)) - { - DeleteFileIfExists(destinationPath); - } - - var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads); - var allowForcedRedownload = true; - Exception? lastError = null; - - for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++) - { - var downloadResult = await PlondsDownloadService.DownloadAsync( - entry.DownloadUrl, - destinationPath, - downloadOptions, - null, - cancellationToken); - - if (!downloadResult.Success) - { - lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS object {entry.RelativePath}."); - if (attempt < MaxPlondsOuterRetryAttempts) - { - AppLogger.Warn( - "UpdateWorkflow", - $"PLONDS object download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed for {entry.RelativePath}. Retrying."); - await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken); - continue; - } - - throw new PlondsDownloadException( - "object-download", - $"Failed to download PLONDS object {entry.RelativePath}.", - lastError); - } - - var actualHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken); - if (!string.IsNullOrWhiteSpace(actualHash) && - string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase)) - { - return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath); - } - - DeleteFileIfExists(destinationPath); - var mismatchMessage = $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash ?? ""}"; - lastError = new InvalidOperationException(mismatchMessage); - - if (allowForcedRedownload) - { - allowForcedRedownload = false; - AppLogger.Warn( - "UpdateWorkflow", - $"{mismatchMessage}. Removing the bad object and forcing one clean re-download."); - await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken); - continue; - } - - throw new PlondsDownloadException("object-verify", mismatchMessage, lastError); - } - - throw new PlondsDownloadException( - "object-download", - $"Failed to download PLONDS object {entry.RelativePath}.", - lastError); - } - - private async Task> EnsurePlondsArchiveObjectsAsync( - PlondsUpdatePayload payload, - string incomingDirectory, - string objectsDirectory, - string downloadSource, - int downloadThreads, - IProgress? progress, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(payload.UpdateArchiveUrl)) - { - throw new PlondsDownloadException("payload-parse", "PLONDS payload does not contain an update archive URL."); - } - - var archiveAsset = new GitHubReleaseAsset( - Name: Path.GetFileName(payload.UpdateArchiveUrl) ?? PlondsUpdateArchiveName, - BrowserDownloadUrl: payload.UpdateArchiveUrl, - SizeBytes: payload.UpdateArchiveSizeBytes ?? 0, - Sha256: payload.UpdateArchiveSha256); - var archivePath = Path.Combine(incomingDirectory, PlondsUpdateArchiveName); - var archiveProgress = progress is null - ? null - : new Progress(p => progress.Report((2d + p) / 3d)); - - var downloadResult = await _settingsFacade.Update.DownloadAssetAsync( - archiveAsset, - archivePath, - downloadSource, - downloadThreads, - archiveProgress, - cancellationToken); - - if (!downloadResult.Success) - { - downloadResult = await _settingsFacade.Update.RedownloadAssetAsync( - archiveAsset, - archivePath, - downloadSource, - downloadThreads, - archiveProgress, - cancellationToken); - } - - if (!downloadResult.Success) - { - throw new PlondsDownloadException( - "object-download", - $"Failed to download PLONDS update archive: {downloadResult.ErrorMessage}"); - } - - try - { - if (Directory.Exists(objectsDirectory)) - { - Directory.Delete(objectsDirectory, recursive: true); - } - - Directory.CreateDirectory(objectsDirectory); - ZipFile.ExtractToDirectory(archivePath, objectsDirectory, overwriteFiles: true); - } - catch (Exception ex) - { - throw new PlondsDownloadException( - "payload-parse", - $"Failed to extract PLONDS update archive: {ex.Message}", - ex); - } - finally - { - DeleteFileIfExists(archivePath); - } - - var objectResults = Directory.EnumerateFiles(objectsDirectory, "*", SearchOption.AllDirectories) - .Select(path => new PlondsDownloadedObjectInfo( - ComponentId: "app", - RelativePath: Path.GetRelativePath(objectsDirectory, path).Replace('\\', '/'), - SourceUrl: payload.UpdateArchiveUrl, - ObjectHashHex: Path.GetFileName(path), - LocalPath: path)) - .ToArray(); - - progress?.Report(1d); - return objectResults; - } - - private static IReadOnlyList ParsePlondsDownloadEntries(string fileMapJson) - { - var entries = new List(); - if (string.IsNullOrWhiteSpace(fileMapJson)) - { - return entries; - } - - using var document = JsonDocument.Parse(fileMapJson); - var root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object) - { - return entries; - } - - if (!TryGetPropertyIgnoreCase(root, "components", out var componentsNode)) - { - return entries; - } - - if (componentsNode.ValueKind == JsonValueKind.Object) - { - foreach (var component in componentsNode.EnumerateObject()) - { - if (component.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - - if (!TryGetPropertyIgnoreCase(component.Value, "files", out var filesNode)) - { - continue; - } - - AppendDownloadEntries(entries, component.Name, filesNode); - } - } - else if (componentsNode.ValueKind == JsonValueKind.Array) - { - foreach (var component in componentsNode.EnumerateArray()) - { - if (component.ValueKind != JsonValueKind.Object) - { - continue; - } - - var componentId = ReadStringIgnoreCase(component, "id") - ?? ReadStringIgnoreCase(component, "name") - ?? "app"; - if (!TryGetPropertyIgnoreCase(component, "files", out var filesNode)) - { - continue; - } - - AppendDownloadEntries(entries, componentId, filesNode); - } - } - - return entries; - } - - private static void AppendDownloadEntries(ICollection entries, string componentId, JsonElement filesNode) - { - if (filesNode.ValueKind == JsonValueKind.Object) - { - foreach (var fileEntry in filesNode.EnumerateObject()) - { - if (fileEntry.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - - if (TryCreateDownloadEntry(componentId, fileEntry.Name, fileEntry.Value, out var entry)) - { - entries.Add(entry); - } - } - - return; - } - - if (filesNode.ValueKind != JsonValueKind.Array) - { - return; - } - - foreach (var fileEntry in filesNode.EnumerateArray()) - { - if (fileEntry.ValueKind != JsonValueKind.Object) - { - continue; - } - - var relativePath = ReadStringIgnoreCase(fileEntry, "path"); - if (TryCreateDownloadEntry(componentId, relativePath, fileEntry, out var entry)) - { - entries.Add(entry); - } - } - } - - private static bool TryCreateDownloadEntry( - string componentId, - string? relativePath, - JsonElement fileNode, - out PlondsDownloadEntry entry) - { - entry = default!; - - var normalizedPath = string.IsNullOrWhiteSpace(relativePath) - ? null - : relativePath.Trim(); - var downloadUrl = ReadStringIgnoreCase(fileNode, "objecturl") - ?? ReadStringIgnoreCase(fileNode, "downloadurl") - ?? ReadStringIgnoreCase(fileNode, "archivedownloadurl") - ?? ReadStringIgnoreCase(fileNode, "url"); - var hashHex = ReadStringIgnoreCase(fileNode, "sha256") - ?? ReadStringIgnoreCase(fileNode, "filesha256") - ?? ReadStringIgnoreCase(fileNode, "contenthash"); - - if ((string.IsNullOrWhiteSpace(hashHex) || string.IsNullOrWhiteSpace(downloadUrl)) && - TryGetPropertyIgnoreCase(fileNode, "hash", out var hashNode) && - hashNode.ValueKind == JsonValueKind.Object) - { - var algorithm = ReadStringIgnoreCase(hashNode, "algorithm"); - if (string.IsNullOrWhiteSpace(algorithm) || - algorithm.Contains("sha256", StringComparison.OrdinalIgnoreCase)) - { - hashHex ??= ReadStringIgnoreCase(hashNode, "value"); - } - } - - if (string.IsNullOrWhiteSpace(normalizedPath) || - string.IsNullOrWhiteSpace(downloadUrl) || - string.IsNullOrWhiteSpace(hashHex)) - { - return false; - } - - entry = new PlondsDownloadEntry( - componentId, - normalizedPath, - downloadUrl, - NormalizeHashText(hashHex)); - return true; - } - - private static async Task ComputeFileSha256HexAsync(string filePath, CancellationToken cancellationToken) - { - if (!File.Exists(filePath)) - { - return null; - } - - await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken); - return Convert.ToHexString(hashBytes).ToLowerInvariant(); - } - - private static string NormalizeHashText(string hash) - { - var normalized = hash.Trim(); - var separator = normalized.IndexOf(':'); - if (separator >= 0 && separator < normalized.Length - 1) - { - normalized = normalized[(separator + 1)..]; - } - - return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant(); - } - - private static void DeleteFileIfExists(string path) - { - try - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - catch - { - // Best effort cleanup only. The caller still verifies the resulting payload before it is applied. - } - } - - private static TimeSpan GetPlondsRetryDelay(int attempt) - { - return attempt switch - { - 1 => TimeSpan.FromMilliseconds(350), - 2 => TimeSpan.FromMilliseconds(900), - _ => TimeSpan.FromMilliseconds(1500) - }; - } - - private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value) - { - if (node.ValueKind == JsonValueKind.Object) - { - foreach (var property in node.EnumerateObject()) - { - if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) - { - value = property.Value; - return true; - } - } - } - - value = default; - return false; - } - - private static string? ReadStringIgnoreCase(JsonElement node, string propertyName) - { - return TryGetPropertyIgnoreCase(node, propertyName, out var value) - ? value.ValueKind == JsonValueKind.String - ? value.GetString() - : value.ToString() - : null; - } - - private static byte[]? ReadByteArrayIgnoreCase(JsonElement node, string propertyName) - { - if (!TryGetPropertyIgnoreCase(node, propertyName, out var value)) - { - return null; - } - - return ReadByteArray(value); - } - - private static byte[]? ReadByteArray(JsonElement value) - { - switch (value.ValueKind) - { - case JsonValueKind.String: - { - var text = value.GetString()?.Trim(); - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - if (IsHexString(text)) - { - try - { - return Convert.FromHexString(text); - } - catch - { - // fall through to base64 - } - } - - try - { - return Convert.FromBase64String(text); - } - catch - { - return null; - } - } - case JsonValueKind.Array: - { - var bytes = new List(); - foreach (var item in value.EnumerateArray()) - { - if (!item.TryGetInt32(out var number) || number is < byte.MinValue or > byte.MaxValue) - { - return null; - } - - bytes.Add((byte)number); - } - - return bytes.ToArray(); - } - default: - return null; - } - } - - private static bool IsHexString(string value) - { - if (string.IsNullOrWhiteSpace(value) || value.Length % 2 != 0) - { - return false; - } - - foreach (var ch in value) - { - if (!Uri.IsHexDigit(ch)) - { - return false; - } - } - - return true; - } - - private sealed record PlondsDownloadEntry( - string ComponentId, - string RelativePath, - string DownloadUrl, - string ObjectHashHex); - - private sealed class PlondsDownloadException : Exception - { - public PlondsDownloadException(string stage, string message, Exception? innerException = null) - : base(message, innerException) - { - Stage = stage; - } - - public string Stage { get; } - } - - private sealed record PlondsDownloadedObjectInfo( - string ComponentId, - string RelativePath, - string SourceUrl, - string ObjectHashHex, - string LocalPath); - - private sealed record PlondsUpdateState( - string VersionText, - string DistributionId, - string ChannelId, - string SubChannel, - string FileMapPath, - string FileMapSignaturePath, - string ObjectsDirectory, - DateTimeOffset DownloadedAtUtc, - string FileMapJson, - string FileMapSignature, - IReadOnlyList Objects); - - private static bool TryResolveDeltaAssets( - IReadOnlyList assets, - out GitHubReleaseAsset manifestAsset, - out GitHubReleaseAsset signatureAsset, - out GitHubReleaseAsset archiveAsset) - { - manifestAsset = default!; - signatureAsset = default!; - archiveAsset = default!; - - if (assets is null || assets.Count == 0) - { - return false; - } - - var platformSuffix = GetPlatformAssetSuffix(); - var platformManifest = $"files-{platformSuffix}.json"; - var platformSignature = $"files-{platformSuffix}.json.sig"; - var platformArchive = $"update-{platformSuffix}.zip"; - - var manifestCandidate = FindAsset(assets, platformManifest) ?? FindAsset(assets, SignedFileMapName); - var signatureCandidate = FindAsset(assets, platformSignature) ?? FindAsset(assets, SignedFileMapSignatureName); - var archiveCandidate = FindAsset(assets, platformArchive) ?? FindAsset(assets, UpdateArchiveName); - if (manifestCandidate is null || signatureCandidate is null || archiveCandidate is null) - { - return false; - } - - manifestAsset = manifestCandidate; - signatureAsset = signatureCandidate; - archiveAsset = archiveCandidate; - return true; - } - - private static GitHubReleaseAsset? FindAsset(IReadOnlyList assets, string name) - { - return assets.FirstOrDefault(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)); - } - - private static string GetPlatformAssetSuffix() - { - 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}"; - } - - public UpdatePendingInfo? GetPendingUpdate() - { - var state = _settingsFacade.Update.Get(); - return GetPendingUpdate(state); - } - - public async Task CheckForUpdatesAsync( - Version currentVersion, - bool isForce = false, - CancellationToken cancellationToken = default) - { - var state = _settingsFacade.Update.Get(); - var includePrerelease = string.Equals( - UpdateSettingsValues.NormalizeChannel(state.UpdateChannel, state.IncludePrereleaseUpdates), - UpdateSettingsValues.ChannelPreview, - StringComparison.OrdinalIgnoreCase); - - var result = isForce - ? await _settingsFacade.Update.ForceCheckForUpdatesAsync( - currentVersion, - includePrerelease, - cancellationToken) - : await _settingsFacade.Update.CheckForUpdatesAsync( - currentVersion, - includePrerelease, - cancellationToken); - - SaveState(state with - { - LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - }); - - return result; - } - - public async Task ForceCheckForUpdatesAsync( - Version currentVersion, - CancellationToken cancellationToken = default) - { - return await CheckForUpdatesAsync(currentVersion, true, cancellationToken); - } - - public async Task DownloadReleaseAsync( - UpdateCheckResult checkResult, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(checkResult); - - if (checkResult.PlondsPayload is not null) - { - return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken); - } - - return await DownloadFullInstallerAsync( - checkResult, - progress, - cancellationToken, - forceRedownload: false); - } - - public async Task RedownloadReleaseAsync( - UpdateCheckResult checkResult, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(checkResult); - - if (checkResult.PlondsPayload is not null) - { - ClearPendingUpdate(); - return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken); - } - - return await DownloadFullInstallerAsync( - checkResult, - progress, - cancellationToken, - forceRedownload: true); - } - - public async Task VerifyPendingUpdateAsync() - { - var state = _settingsFacade.Update.Get(); - var pending = GetPendingUpdate(state); - - if (pending is null) - { - return new UpdateVerifyResult(false, false, null, null, "No pending update available."); - } - - if (!File.Exists(pending.InstallerPath)) - { - if (IsPendingDeltaUpdate()) - { - var pdcUpdatePath = pending.InstallerPath; - var pdcFileMapPath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PlondsFileMapName); - var pdcSignaturePath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PlondsFileMapSignatureName); - if (File.Exists(pdcUpdatePath) && File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath)) - { - return new UpdateVerifyResult(true, true, null, null, null); - } - - return new UpdateVerifyResult(false, false, null, null, "PLONDS update payload is incomplete."); - } - - return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist."); - } - - if (IsPendingDeltaUpdate()) - { - return new UpdateVerifyResult(true, true, null, null, null); - } - - var expectedHash = pending.Sha256; - var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath); - - if (string.IsNullOrEmpty(expectedHash)) - { - return new UpdateVerifyResult(true, true, null, actualHash, null); - } - - var hashMatched = string.Equals( - expectedHash?.Trim().ToLowerInvariant(), - actualHash?.Trim().ToLowerInvariant(), - StringComparison.OrdinalIgnoreCase); - - return new UpdateVerifyResult( - hashMatched, - hashMatched, - expectedHash, - actualHash, - hashMatched ? null : $"Hash mismatch. Expected: {expectedHash}, Actual: {actualHash}"); - } - - public async Task AutoCheckIfEnabledAsync( - Version currentVersion, - CancellationToken cancellationToken = default) - { - var state = _settingsFacade.Update.Get(); - - try - { - // Always check for updates on startup (removed AutoCheckUpdates check) - var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken); - if (!result.Success || !result.IsUpdateAvailable || (result.Release is null && result.PlondsPayload is null)) - { - return; - } - - var normalizedMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode); - - // For "Silent Download" and "Silent Install" modes, automatically download the update - if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) || - string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase)) - { - // Prefer delta update if available (smaller download, faster) - if (IsDeltaUpdateAvailable(result)) - { - AppLogger.Info("UpdateWorkflow", "Delta update available, downloading incremental package."); - await DownloadDeltaUpdateAsync(result, cancellationToken: cancellationToken); - } - else if (result.PreferredAsset is not null) - { - await DownloadReleaseAsync(result, cancellationToken: cancellationToken); - } - } - // For "Manual" mode, just check but don't download - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - AppLogger.Warn("UpdateWorkflow", "Automatic update check failed.", ex); - } - } - - public UpdateInstallerLaunchResult LaunchPendingInstallerNow() - { - if (IsPendingDeltaUpdate()) - { - var launchResult = LaunchLauncherForApplyUpdate(); - return launchResult - ? new UpdateInstallerLaunchResult(true, false, null) - : new UpdateInstallerLaunchResult(false, false, "Failed to launch updater for incremental update."); - } - - return LaunchPendingInstaller(silent: false, exitApplicationAfterLaunch: true); - } - - public bool TryApplyPendingUpdateOnExit() - { - var state = _settingsFacade.Update.Get(); - if (!string.Equals( - UpdateSettingsValues.NormalizeMode(state.UpdateMode), - UpdateSettingsValues.ModeSilentOnExit, - StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - // For delta updates, launch the Launcher with apply-update command so it can - // apply the update immediately with a progress UI, matching the full installer experience. - if (IsPendingDeltaUpdate()) - { - AppLogger.Info("UpdateWorkflow", "Delta update pending. Launching Launcher to apply update with progress UI."); - var launchResult = LaunchLauncherForApplyUpdate(); - if (launchResult) - { - ClearPendingUpdate(); - } - return launchResult; - } - - var result = LaunchPendingInstaller(silent: true, exitApplicationAfterLaunch: false); - if (!result.Success && !string.IsNullOrWhiteSpace(result.ErrorMessage)) - { - AppLogger.Warn("UpdateWorkflow", $"Silent update on exit failed: {result.ErrorMessage}"); - } - - return result.Success; - } - - /// - /// Launches the Launcher process with the apply-update command to apply a pending delta update - /// with a progress UI, providing an experience similar to a full installer. - /// - public bool LaunchLauncherForApplyUpdate() - { - try - { - var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath(); - if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath)) - { - AppLogger.Warn("UpdateWorkflow", "Launcher executable not found. Falling back to next-startup apply."); - return false; - } - - var launcherRoot = Path.GetDirectoryName(launcherPath)!; - - var startInfo = new ProcessStartInfo - { - FileName = launcherPath, - Arguments = $"apply-update --app-root \"{launcherRoot}\" --launch-source apply-update", - UseShellExecute = false, - WorkingDirectory = launcherRoot - }; - - Process.Start(startInfo); - AppLogger.Info("UpdateWorkflow", $"Launched Launcher for apply-update: {launcherPath}"); - return true; - } - catch (Exception ex) - { - AppLogger.Warn("UpdateWorkflow", $"Failed to launch Launcher for apply-update: {ex.Message}"); - return false; - } - } - - public void ClearPendingUpdate() - { - var state = _settingsFacade.Update.Get(); - SaveState(state with - { - PendingUpdateInstallerPath = null, - PendingUpdateVersion = null, - PendingUpdatePublishedAtUtcMs = null, - PendingUpdateSha256 = null - }); - } - - private UpdateInstallerLaunchResult LaunchPendingInstaller(bool silent, bool exitApplicationAfterLaunch) - { - var state = _settingsFacade.Update.Get(); - var pending = GetPendingUpdate(state); - if (pending is null) - { - return new UpdateInstallerLaunchResult(false, false, "No pending installer is available."); - } - - try - { - AppLogger.Info("UpdateWorkflow", "Launching pending full installer with elevation reason 'full_update_apply'."); - var startInfo = new ProcessStartInfo - { - FileName = pending.InstallerPath, - WorkingDirectory = Path.GetDirectoryName(pending.InstallerPath) ?? _updatesDirectory, - UseShellExecute = true, - Verb = OperatingSystem.IsWindows() ? "runas" : string.Empty, - Arguments = silent ? "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" : string.Empty - }; - - Process.Start(startInfo); - ClearPendingUpdate(); - - if (exitApplicationAfterLaunch) - { - App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest( - Source: "Update", - Reason: silent - ? "Silent installer launched." - : "Installer launched from update page.")); - } - - return new UpdateInstallerLaunchResult(true, false, null); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) - { - return new UpdateInstallerLaunchResult(false, true, ex.Message); - } - catch (Exception ex) - { - return new UpdateInstallerLaunchResult(false, false, ex.Message); - } - } - - private UpdatePendingInfo? GetPendingUpdate(UpdateSettingsState state) - { - var installerPath = state.PendingUpdateInstallerPath?.Trim(); - if (string.IsNullOrWhiteSpace(installerPath)) - { - return null; - } - - if (!File.Exists(installerPath)) - { - ClearPendingUpdate(); - return null; - } - - DateTimeOffset? publishedAt = state.PendingUpdatePublishedAtUtcMs is > 0 - ? DateTimeOffset.FromUnixTimeMilliseconds(state.PendingUpdatePublishedAtUtcMs.Value) - : null; - - return new UpdatePendingInfo( - installerPath, - string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion, - publishedAt, - state.PendingUpdateSha256); - } - - private void SaveState(UpdateSettingsState state) - { - _settingsFacade.Update.Save(state); - } - - private static string SanitizeFileName(string? fileName) - { - if (string.IsNullOrWhiteSpace(fileName)) - { - return FormattableString.Invariant($"LanMountainDesktop-update-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.exe"); - } - - var invalid = Path.GetInvalidFileNameChars(); - Span buffer = stackalloc char[fileName.Length]; - var index = 0; - foreach (var ch in fileName) - { - buffer[index++] = Array.IndexOf(invalid, ch) >= 0 ? '_' : ch; - } - - return new string(buffer[..index]); - } -} diff --git a/LanMountainDesktop/Services/WindowPassthroughService.cs b/LanMountainDesktop/Services/WindowPassthroughService.cs index 34af4c5..44c2be4 100644 --- a/LanMountainDesktop/Services/WindowPassthroughService.cs +++ b/LanMountainDesktop/Services/WindowPassthroughService.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Runtime.InteropServices; using Avalonia; @@ -7,9 +6,6 @@ using Avalonia.Controls; namespace LanMountainDesktop.Services; -/// -/// 窗口置底服务接口 -/// public interface IWindowBottomMostService { void SetupBottomMost(Window window); @@ -17,35 +13,18 @@ public interface IWindowBottomMostService bool IsBottomMostSupported { get; } } -/// -/// 区域级穿透服务接口 - 使用 WM_NCHITTEST 实现 -/// public interface IRegionPassthroughService { - /// - /// 设置窗口的可交互区域 - /// void SetInteractiveRegions(Window window, IReadOnlyList interactiveRegions); - - /// - /// 清除所有可交互区域 - /// void ClearInteractiveRegions(Window window); - - /// - /// 获取当前平台是否支持区域级穿透 - /// bool IsRegionPassthroughSupported { get; } } -/// -/// 窗口置底服务工厂 -/// public static class WindowBottomMostServiceFactory { private static IWindowBottomMostService? _instance; private static readonly object _lock = new(); - + public static IWindowBottomMostService GetOrCreate() { lock (_lock) @@ -57,14 +36,11 @@ public static class WindowBottomMostServiceFactory } } -/// -/// 区域级穿透服务工厂 -/// public static class RegionPassthroughServiceFactory { private static IRegionPassthroughService? _instance; private static readonly object _lock = new(); - + public static IRegionPassthroughService GetOrCreate() { lock (_lock) @@ -76,335 +52,334 @@ public static class RegionPassthroughServiceFactory } } -/// -/// Windows 平台窗口置底服务 -/// internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService { + private const int GWL_STYLE = -16; private const int GWL_EXSTYLE = -20; - private const int GWL_HWNDPARENT = -8; private const int GWLP_WNDPROC = -4; - private const int WS_EX_TOOLWINDOW = 0x00000080; - private const int WS_EX_APPWINDOW = 0x00040000; - private const int WS_EX_NOACTIVATE = 0x08000000; - private const int WS_EX_LAYERED = 0x00080000; + + private const long WS_CHILD = 0x40000000L; + private const long WS_POPUP = 0x80000000L; + private const long WS_CAPTION = 0x00C00000L; + private const long WS_THICKFRAME = 0x00040000L; + private const long WS_MINIMIZEBOX = 0x00020000L; + private const long WS_MAXIMIZEBOX = 0x00010000L; + private const long WS_SYSMENU = 0x00080000L; + + private const long WS_EX_TOOLWINDOW = 0x00000080L; + private const long WS_EX_APPWINDOW = 0x00040000L; + private const long WS_EX_NOACTIVATE = 0x08000000L; + private const long WS_EX_LAYERED = 0x00080000L; + private const uint SWP_NOSIZE = 0x0001; private const uint SWP_NOMOVE = 0x0002; private const uint SWP_NOACTIVATE = 0x0010; - private const int WM_WINDOWPOSCHANGING = 0x0046; + private const uint SWP_SHOWWINDOW = 0x0040; + private const int WM_NCHITTEST = 0x0084; - private const int WM_ACTIVATEAPP = 0x001C; // 【新增】应用激活消息 private const int HTTRANSPARENT = -1; private const int HTCLIENT = 1; - + + private const int MONITOR_DEFAULTTONEAREST = 2; + private const int MDT_EFFECTIVE_DPI = 0; + + private static readonly IntPtr HWND_TOP = IntPtr.Zero; private static readonly IntPtr HWND_BOTTOM = new(1); - private static readonly Dictionary _bottomMostWindows = new(); + private static readonly object _staticLock = new(); + private static readonly object _timerLock = new(); + + private static readonly Dictionary _desktopWindows = new(); private static readonly Dictionary _originalWndProcs = new(); private static readonly Dictionary> _interactiveRegions = new(); - - // 记录每个窗口的屏幕原点(窗口左上角的屏幕坐标),用于将 WM_NCHITTEST 屏幕坐标转成窗口相对坐标 private static readonly Dictionary _windowScreenOrigins = new(); - private static readonly object _staticLock = new(); - - // 【修复问题1】静态持有委托引用,防止 GC 回收导致 CallbackOnCollectedDelegate 崩溃 - private static WndProcDelegate? _wndProcDelegate; - - // 【修复问题2】记录每个窗口的 DPI 缩放比例 private static readonly Dictionary _windowDpiScales = new(); - - // 【修复问题5】Z 轴竞争优化 - 记录上次置底时间,避免频繁操作 - private static readonly Dictionary _lastSendToBottomTime = new(); - private const long MinSendToBottomIntervalMs = 100; // 【修复置底问题】降低到 100ms,提高响应速度 - - // 【新增】定时器定期强制置底 - private static System.Timers.Timer? _keepBottomTimer; - private static readonly object _timerLock = new(); - + + private static WndProcDelegate? _wndProcDelegate; + private static System.Timers.Timer? _desktopHostMonitorTimer; + public bool IsBottomMostSupported => true; - + public void SetupBottomMost(Window window) { - if (!OperatingSystem.IsWindows()) return; - - window.Opened += (s, e) => + if (!OperatingSystem.IsWindows()) { - var handle = GetWindowHandle(window); - if (handle == IntPtr.Zero) return; - - // 设置扩展样式 - var exStyle = GetWindowLong(handle, GWL_EXSTYLE); - exStyle = (exStyle | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_LAYERED) & ~WS_EX_APPWINDOW; - SetWindowLong(handle, GWL_EXSTYLE, exStyle); - - // 设置为桌面子窗口 - SetAsDesktopChild(handle); - - // 注册置底状态 & 记录窗口屏幕原点 - lock (_staticLock) - { - _bottomMostWindows[handle] = true; - _interactiveRegions[handle] = []; - UpdateWindowScreenOrigin(handle); - UpdateWindowDpiScale(handle); // 【修复问题2】初始化 DPI 缩放 - } - - // 注入消息钩子 - InstallMessageHook(handle); - - // 初始置底 - SendToBottomInternal(handle); - - // 【新增】启动定时器定期强制置底 - StartKeepBottomTimer(); - - AppLogger.Info("WindowBottomMost", $"Window setup as bottom-most: {handle}"); - }; - - window.Closed += (s, e) => + return; + } + + var handle = GetWindowHandle(window); + if (handle != IntPtr.Zero) { - var handle = GetWindowHandle(window); - if (handle != IntPtr.Zero) + ApplyDesktopAttachment(handle, logSuccess: true); + } + else + { + window.Opened += (_, _) => { - lock (_staticLock) + var openedHandle = GetWindowHandle(window); + if (openedHandle != IntPtr.Zero) { - _bottomMostWindows.Remove(handle); - _originalWndProcs.Remove(handle); - _interactiveRegions.Remove(handle); - _windowScreenOrigins.Remove(handle); - _windowDpiScales.Remove(handle); // 【修复问题2】清理 DPI 缩放记录 + ApplyDesktopAttachment(openedHandle, logSuccess: true); } + }; + } + + window.Closed += (_, _) => + { + var closedHandle = GetWindowHandle(window); + if (closedHandle != IntPtr.Zero) + { + CleanupWindow(closedHandle); } }; } - + public void SendToBottom(Window window) { var handle = GetWindowHandle(window); - if (handle != IntPtr.Zero) SendToBottomInternal(handle); - } - - private static IntPtr GetWindowHandle(Window window) - { - try { return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; } - catch { return IntPtr.Zero; } - } - - private static void SendToBottomInternal(IntPtr handle) - { - SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE); - } - - /// - /// 【新增】启动定时器定期强制置底所有窗口 - /// - private static void StartKeepBottomTimer() - { - lock (_timerLock) + if (handle != IntPtr.Zero) { - if (_keepBottomTimer != null) return; - - _keepBottomTimer = new System.Timers.Timer(200); // 每 200ms 检查一次 - _keepBottomTimer.Elapsed += (s, e) => - { - try - { - lock (_staticLock) - { - foreach (var kvp in _bottomMostWindows) - { - if (kvp.Value) // 如果标记为置底 - { - SendToBottomInternal(kvp.Key); - } - } - } - } - catch - { - // 忽略定时器错误 - } - }; - _keepBottomTimer.Start(); + ApplyDesktopAttachment(handle, logSuccess: false); } } - - /// - /// 【新增】停止定时器 - /// - private static void StopKeepBottomTimer() + + internal static void SetInteractiveRegionsInternal(IntPtr handle, List regions) { - lock (_timerLock) + lock (_staticLock) { - _keepBottomTimer?.Stop(); - _keepBottomTimer?.Dispose(); - _keepBottomTimer = null; + _interactiveRegions[handle] = regions; + UpdateWindowScreenOrigin(handle); + UpdateWindowDpiScale(handle); } } - - private static void SetAsDesktopChild(IntPtr handle) + + private static void ApplyDesktopAttachment(IntPtr handle, bool logSuccess) { - // 【修复问题4】增强桌面挂载逻辑,支持 Wallpaper Engine 等动态壁纸软件 - - // 方案1: 尝试找到 WorkerW 层(Wallpaper Engine 创建的层) - var workerW = IntPtr.Zero; - var hDefView = IntPtr.Zero; - - // 枚举所有顶层窗口 - var windowHandles = new ArrayList(); - EnumWindows(EnumWindowsCallback, windowHandles); - - foreach (IntPtr h in windowHandles) + if (handle == IntPtr.Zero || !IsWindow(handle)) { - // 查找 WorkerW 窗口(Wallpaper Engine 创建) - var className = GetWindowClassName(h); - if (className == "WorkerW") - { - // 在 WorkerW 下查找 SHELLDLL_DefView - var defView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null); - if (defView != IntPtr.Zero) - { - workerW = h; - hDefView = defView; - break; - } - } - } - - // 如果找到了 WorkerW 层,使用它作为父窗口 - if (workerW != IntPtr.Zero && hDefView != IntPtr.Zero) - { - SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32()); - AppLogger.Info("WindowBottomMost", "Mounted to WorkerW layer (Wallpaper Engine detected)"); return; } - - // 方案2: 回退到传统方式,查找 Progman 下的 SHELLDLL_DefView - foreach (IntPtr h in windowHandles) + + SetDesktopChildStyles(handle); + InstallMessageHook(handle); + + var attached = TryAttachToDesktopIconHost(handle, out var desktopHost); + lock (_staticLock) { - hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null); - if (hDefView != IntPtr.Zero) + _desktopWindows[handle] = new DesktopWindowState(desktopHost, attached); + if (!_interactiveRegions.ContainsKey(handle)) { - SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32()); - AppLogger.Info("WindowBottomMost", "Mounted to traditional desktop layer"); - break; + _interactiveRegions[handle] = []; + } + + UpdateWindowScreenOrigin(handle); + UpdateWindowDpiScale(handle); + } + + if (attached) + { + SetWindowPos(handle, HWND_TOP, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_SHOWWINDOW); + if (logSuccess) + { + AppLogger.Info("WindowBottomMost", $"Mounted window to desktop icon host. Window={handle}; Host={desktopHost}"); } } + else + { + SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE); + if (logSuccess) + { + AppLogger.Warn("WindowBottomMost", $"Desktop icon host not found. Falling back to HWND_BOTTOM. Window={handle}"); + } + } + + StartDesktopHostMonitorTimer(); } - - /// - /// 【修复问题4】获取窗口类名 - /// - private static string GetWindowClassName(IntPtr hWnd) + + private static void SetDesktopChildStyles(IntPtr handle) { - var buffer = new char[256]; - var length = GetClassName(hWnd, buffer, buffer.Length); - return length > 0 ? new string(buffer, 0, length) : string.Empty; + var style = GetWindowLongPtr(handle, GWL_STYLE).ToInt64(); + style |= WS_CHILD; + style &= ~(WS_POPUP | WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU); + SetWindowLongPtr(handle, GWL_STYLE, new IntPtr(style)); + + var exStyle = GetWindowLongPtr(handle, GWL_EXSTYLE).ToInt64(); + exStyle = (exStyle | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_LAYERED) & ~WS_EX_APPWINDOW; + SetWindowLongPtr(handle, GWL_EXSTYLE, new IntPtr(exStyle)); } - - private static bool EnumWindowsCallback(IntPtr handle, ArrayList handles) + + private static bool TryAttachToDesktopIconHost(IntPtr handle, out IntPtr desktopHost) { - handles.Add(handle); + desktopHost = ResolveDesktopIconHost(); + if (desktopHost == IntPtr.Zero || !IsWindow(desktopHost)) + { + return false; + } + + if (GetParent(handle) != desktopHost) + { + _ = SetParent(handle, desktopHost); + if (GetParent(handle) != desktopHost) + { + return false; + } + } + return true; } - + + private static IntPtr ResolveDesktopIconHost() + { + var topLevelWindows = new List(); + EnumWindows((handle, _) => + { + topLevelWindows.Add(handle); + return true; + }, IntPtr.Zero); + + foreach (var topLevelWindow in topLevelWindows) + { + var defView = FindWindowEx(topLevelWindow, IntPtr.Zero, "SHELLDLL_DefView", null); + if (defView != IntPtr.Zero) + { + return defView; + } + } + + return IntPtr.Zero; + } + + private static void StartDesktopHostMonitorTimer() + { + lock (_timerLock) + { + if (_desktopHostMonitorTimer != null) + { + return; + } + + _desktopHostMonitorTimer = new System.Timers.Timer(TimeSpan.FromSeconds(2)); + _desktopHostMonitorTimer.Elapsed += (_, _) => MonitorDesktopHostAttachments(); + _desktopHostMonitorTimer.Start(); + } + } + + private static void MonitorDesktopHostAttachments() + { + List handles; + lock (_staticLock) + { + handles = [.. _desktopWindows.Keys]; + } + + foreach (var handle in handles) + { + if (!IsWindow(handle)) + { + CleanupWindow(handle); + continue; + } + + ApplyDesktopAttachment(handle, logSuccess: false); + } + } + + private static void StopDesktopHostMonitorTimerIfIdle() + { + lock (_timerLock) + { + lock (_staticLock) + { + if (_desktopWindows.Count > 0) + { + return; + } + } + + _desktopHostMonitorTimer?.Stop(); + _desktopHostMonitorTimer?.Dispose(); + _desktopHostMonitorTimer = null; + } + } + + private static void CleanupWindow(IntPtr handle) + { + IntPtr originalWndProc; + lock (_staticLock) + { + if (_originalWndProcs.TryGetValue(handle, out originalWndProc) && + originalWndProc != IntPtr.Zero && + IsWindow(handle)) + { + SetWindowLongPtr(handle, GWLP_WNDPROC, originalWndProc); + } + + _desktopWindows.Remove(handle); + _originalWndProcs.Remove(handle); + _interactiveRegions.Remove(handle); + _windowScreenOrigins.Remove(handle); + _windowDpiScales.Remove(handle); + } + + StopDesktopHostMonitorTimerIfIdle(); + } + private static void InstallMessageHook(IntPtr handle) { + lock (_staticLock) + { + if (_originalWndProcs.ContainsKey(handle)) + { + return; + } + } + var originalWndProc = GetWindowLongPtr(handle, GWLP_WNDPROC); - if (originalWndProc == IntPtr.Zero) return; - + if (originalWndProc == IntPtr.Zero) + { + return; + } + lock (_staticLock) { _originalWndProcs[handle] = originalWndProc; - - // 【修复问题1】确保委托实例被静态引用持有,防止 GC 回收 _wndProcDelegate ??= SubclassWndProc; } - + SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_wndProcDelegate)); } - + private static IntPtr SubclassWndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam) { - // 【新增】处理应用激活消息 - 当其他应用激活时立即置底 - if (msg == WM_ACTIVATEAPP) - { - lock (_staticLock) - { - if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost) - { - // 立即置底,不进行频率限制 - SendToBottomInternal(hWnd); - } - } - } - - // 处理 WM_WINDOWPOSCHANGING - 保持置底 - if (msg == WM_WINDOWPOSCHANGING) - { - lock (_staticLock) - { - if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost) - { - // 【修复问题5】优化 Z 轴竞争 - 限制置底操作频率 - var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - if (_lastSendToBottomTime.TryGetValue(hWnd, out var lastTime)) - { - if (now - lastTime < MinSendToBottomIntervalMs) - { - // 跳过过于频繁的置底操作 - goto CallOriginal; - } - } - - SendToBottomInternal(hWnd); - _lastSendToBottomTime[hWnd] = now; - } - } - } - - // 处理 WM_NCHITTEST - 区域级穿透 if (msg == WM_NCHITTEST) { - // WM_NCHITTEST 的鼠标坐标在 lParam(低16位=X,高16位=Y),且为屏幕坐标 var screenX = (short)(lParam.ToInt64() & 0xFFFF); var screenY = (short)((lParam.ToInt64() >> 16) & 0xFFFF); - + lock (_staticLock) { if (_interactiveRegions.TryGetValue(hWnd, out var regions) && regions.Count > 0) { - // 【修复问题2】获取窗口原点和 DPI 缩放比例 _windowScreenOrigins.TryGetValue(hWnd, out var origin); _windowDpiScales.TryGetValue(hWnd, out var dpiScale); - if (dpiScale <= 0) dpiScale = 1.0; // 默认缩放为 1.0 - - // 将屏幕物理像素坐标转为窗口相对坐标 - var clientX = screenX - origin.X; - var clientY = screenY - origin.Y; - - // 【修复问题2】将物理像素坐标转换为逻辑 DIP 坐标 - // _interactiveRegions 存储的是 Avalonia UI 的逻辑 DIP 坐标 - var logicalX = clientX / dpiScale; - var logicalY = clientY / dpiScale; - var point = new Point(logicalX, logicalY); - + if (dpiScale <= 0) + { + dpiScale = 1.0; + } + + var point = new Point((screenX - origin.X) / dpiScale, (screenY - origin.Y) / dpiScale); foreach (var region in regions) { if (region.Contains(point)) { - // 在可交互区域内,返回 HTCLIENT return (IntPtr)HTCLIENT; } } } } - - // 不在可交互区域内,返回 HTTRANSPARENT 让事件穿透 + return (IntPtr)HTTRANSPARENT; } - - // 调用原始窗口过程 - CallOriginal: + IntPtr originalWndProc; lock (_staticLock) { @@ -413,27 +388,10 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService return DefWindowProc(hWnd, msg, wParam, lParam); } } - + return CallWindowProc(originalWndProc, hWnd, msg, wParam, lParam); } - - /// - /// 设置窗口的可交互区域(供 WindowsRegionPassthroughService 调用) - /// - internal static void SetInteractiveRegionsInternal(IntPtr handle, List regions) - { - lock (_staticLock) - { - _interactiveRegions[handle] = regions; - // 同步刷新屏幕原点(DPI 缩放可能影响坐标,每次更新区域时一并刷新) - UpdateWindowScreenOrigin(handle); - UpdateWindowDpiScale(handle); // 【修复问题2】同步更新 DPI 缩放 - } - } - - /// - /// 更新指定窗口的屏幕左上角坐标缓存(用于将 WM_NCHITTEST 屏幕坐标转为窗口相对坐标) - /// + private static void UpdateWindowScreenOrigin(IntPtr handle) { if (GetWindowRect(handle, out var rect)) @@ -441,119 +399,128 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService _windowScreenOrigins[handle] = new Point(rect.Left, rect.Top); } } - - /// - /// 【修复问题2】更新指定窗口的 DPI 缩放比例 - /// + private static void UpdateWindowDpiScale(IntPtr handle) { try { - // 获取窗口所在的显示器 DPI var monitor = MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST); - if (monitor != IntPtr.Zero) + if (monitor != IntPtr.Zero && + GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out var dpiX, out _) == 0) { - if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out var dpiX, out var _) == 0) - { - // DPI 缩放比例 = 当前 DPI / 96 (标准 DPI) - _windowDpiScales[handle] = dpiX / 96.0; - } + _windowDpiScales[handle] = dpiX / 96.0; + return; } } catch { - // 如果获取失败,使用默认缩放 1.0 - _windowDpiScales[handle] = 1.0; + // Use the default below. + } + + _windowDpiScales[handle] = 1.0; + } + + private static IntPtr GetWindowHandle(Window window) + { + try + { + return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; + } + catch + { + return IntPtr.Zero; } } - + [StructLayout(LayoutKind.Sequential)] - private struct RECT { public int Left, Top, Right, Bottom; } - + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + private sealed record DesktopWindowState(IntPtr DesktopHost, bool IsDesktopAttached); + + private delegate IntPtr WndProcDelegate(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); + private delegate bool EnumWindowsProc(IntPtr handle, IntPtr lParam); + [DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); - - private delegate IntPtr WndProcDelegate(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); - - [DllImport("user32.dll", SetLastError = true)] - private static extern int GetWindowLong(IntPtr hWnd, int nIndex); - - [DllImport("user32.dll")] - private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); - + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")] private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex); - + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")] private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); - + [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flags); - + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent); + + [DllImport("user32.dll")] + private static extern IntPtr GetParent(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool IsWindow(IntPtr hWnd); + [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr FindWindowEx(IntPtr hParent, IntPtr hChildAfter, string? lpszClass, string? lpszWindow); - - [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + + [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, ArrayList lParam); - - private delegate bool EnumWindowsProc(IntPtr handle, ArrayList handles); - + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + [DllImport("user32.dll")] private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam); - + [DllImport("user32.dll")] private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam); - - // 【修复问题2】DPI 相关的 P/Invoke 声明 - private const int MONITOR_DEFAULTTONEAREST = 2; - private const int MDT_EFFECTIVE_DPI = 0; - + [DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr hWnd, int dwFlags); - + [DllImport("shcore.dll")] private static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY); - - // 【修复问题4】获取窗口类名的 P/Invoke - [DllImport("user32.dll", CharSet = CharSet.Auto)] - private static extern int GetClassName(IntPtr hWnd, char[] lpClassName, int nMaxCount); } -/// -/// Windows 平台区域级穿透服务 - 使用 WM_NCHITTEST -/// internal sealed class WindowsRegionPassthroughService : IRegionPassthroughService { public bool IsRegionPassthroughSupported => true; - + public void SetInteractiveRegions(Window window, IReadOnlyList interactiveRegions) { var handle = GetWindowHandle(window); if (handle == IntPtr.Zero) return; - + WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, new List(interactiveRegions)); AppLogger.Info("RegionPassthrough", $"Set {interactiveRegions.Count} interactive regions."); } - + public void ClearInteractiveRegions(Window window) { var handle = GetWindowHandle(window); if (handle == IntPtr.Zero) return; - + WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, []); } - + private static IntPtr GetWindowHandle(Window window) { - try { return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; } - catch { return IntPtr.Zero; } + try + { + return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; + } + catch + { + return IntPtr.Zero; + } } } -/// -/// 空实现 -/// internal sealed class NullWindowBottomMostService : IWindowBottomMostService { public bool IsBottomMostSupported => false; diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index fd014ca..2a3ba5b 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -1325,1039 +1325,6 @@ public sealed partial class AboutSettingsPageViewModel : ViewModelBase => _localizationService.GetString(_languageCode, key, fallback); } -public sealed partial class UpdateSettingsPageViewModel : ViewModelBase -{ - private readonly ISettingsFacadeService _settingsFacade; - private readonly UpdateWorkflowService _updateWorkflowService; - private readonly LocalizationService _localizationService = new(); - private readonly string _languageCode; - private readonly Version _currentVersion; - private bool _isInitializing; - private UpdateCheckResult? _lastCheckResult; - - public IReadOnlyList UpdateChannelOptions { get; } - - public IReadOnlyList UpdateModeOptions { get; } - - public IReadOnlyList DownloadThreadOptions { get; } - - public UpdateSettingsPageViewModel( - ISettingsFacadeService settingsFacade, - UpdateWorkflowService? updateWorkflowService = null) - { - _settingsFacade = settingsFacade; - _updateWorkflowService = updateWorkflowService ?? HostUpdateWorkflowServiceProvider.GetOrCreate(); - _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); - RefreshLocalizedText(); - UpdateChannelOptions = CreateUpdateChannelOptions(); - UpdateModeOptions = CreateUpdateModeOptions(); - DownloadThreadOptions = CreateDownloadThreadOptions(); - - var versionText = _settingsFacade.ApplicationInfo.GetAppVersionText(); - _currentVersion = Version.TryParse(versionText, out var parsedVersion) - ? parsedVersion - : new Version(0, 0, 0); - - CurrentVersionText = versionText; - LoadStateFromSettings(); - } - - [ObservableProperty] - private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable; - - [ObservableProperty] - private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm; - - [ObservableProperty] - private string _currentVersionText = "-"; - - [ObservableProperty] - private string _updateStatus = string.Empty; - - [ObservableProperty] - private bool _isCheckingForUpdates; - - [ObservableProperty] - private bool _isDownloading; - - [ObservableProperty] - private double _downloadProgressValue; - - [ObservableProperty] - private bool _isDownloadProgressVisible; - - [ObservableProperty] - private string _downloadProgressText = string.Empty; - - [ObservableProperty] - private string _updatePhaseText = string.Empty; - - [ObservableProperty] - private double _phaseProgressValue; - - [ObservableProperty] - private string _updateTypeText = string.Empty; - - [ObservableProperty] - private bool _useGhProxyMirror; - - [ObservableProperty] - private string _pageTitle = string.Empty; - - [ObservableProperty] - private string _pageDescription = string.Empty; - - [ObservableProperty] - private string _statusCardTitle = string.Empty; - - [ObservableProperty] - private string _statusCardDescription = string.Empty; - - [ObservableProperty] - private string _preferencesHeader = string.Empty; - - [ObservableProperty] - private string _preferencesDescription = string.Empty; - - [ObservableProperty] - private string _updateChannelLabel = string.Empty; - - [ObservableProperty] - private string _updateModeLabel = string.Empty; - - [ObservableProperty] - private string _currentVersionLabel = string.Empty; - - [ObservableProperty] - private string _latestVersionLabel = string.Empty; - - [ObservableProperty] - private string _publishedAtLabel = string.Empty; - - [ObservableProperty] - private string _lastCheckedLabel = string.Empty; - - [ObservableProperty] - private string _updateTypeLabel = string.Empty; - - [ObservableProperty] - private string _checkForUpdatesButtonText = string.Empty; - - [ObservableProperty] - private string _downloadButtonText = string.Empty; - - [ObservableProperty] - private string _installNowButtonText = string.Empty; - - [ObservableProperty] - private string _redownloadButtonText = string.Empty; - - [ObservableProperty] - private string _latestVersionText = string.Empty; - - [ObservableProperty] - private string _publishedAtText = string.Empty; - - [ObservableProperty] - private string _lastCheckedText = string.Empty; - - [ObservableProperty] - private bool _isLatestVersionVisible; - - [ObservableProperty] - private bool _isPublishedAtVisible; - - [ObservableProperty] - private bool _isLastCheckedVisible; - - [ObservableProperty] - private bool _hasPendingInstaller; - - [ObservableProperty] - private string _pendingUpdateTypeText = string.Empty; - - [ObservableProperty] - private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads; - - [ObservableProperty] - private string _selectedUpdateChannelDescription = string.Empty; - - [ObservableProperty] - private string _selectedUpdateModeDescription = string.Empty; - - [ObservableProperty] - private string _downloadThreadsLabel = string.Empty; - - [ObservableProperty] - private string _downloadThreadsDescription = string.Empty; - - [ObservableProperty] - private string _forceCheckUpdateLabel = string.Empty; - - [ObservableProperty] - private string _forceCheckUpdateDescription = string.Empty; - - [ObservableProperty] - private string _forceFullUpdateLabel = string.Empty; - - [ObservableProperty] - private string _forceFullUpdateDescription = string.Empty; - - [ObservableProperty] - private string _networkAccelerationLabel = string.Empty; - - [ObservableProperty] - private string _networkAccelerationDescription = string.Empty; - - [ObservableProperty] - private string _stableChannelText = string.Empty; - - [ObservableProperty] - private string _previewChannelText = string.Empty; - - [ObservableProperty] - private string _manualModeText = string.Empty; - - [ObservableProperty] - private string _downloadThenConfirmModeText = string.Empty; - - [ObservableProperty] - private string _silentOnExitModeText = string.Empty; - - [ObservableProperty] - private SelectionOption? _selectedUpdateChannelOption; - - [ObservableProperty] - private SelectionOption? _selectedUpdateModeOption; - - [ObservableProperty] - private SelectionOption? _selectedDownloadThreadsOption; - - [ObservableProperty] - private string _downloadThreadsText = UpdateSettingsValues.DefaultDownloadThreads.ToString(CultureInfo.CurrentCulture); - - public bool IsStableChannelSelected => - string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelStable, StringComparison.OrdinalIgnoreCase); - - public bool IsPreviewChannelSelected => - string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase); - - public bool IsManualModeSelected => - string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase); - - public bool IsDownloadThenConfirmModeSelected => - string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase); - - public bool IsSilentOnExitModeSelected => - string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase); - - public bool IsDownloadButtonVisible => - !HasPendingInstaller && - _lastCheckResult is { Success: true, IsUpdateAvailable: true, PreferredAsset: not null }; - - public bool IsInstallButtonVisible => HasPendingInstaller; - - public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading; - - public bool IsUpdateTypeVisible => !string.IsNullOrEmpty(UpdateTypeText) && !HasPendingInstaller; - - public string DownloadThreadsValueText => - UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture); - - private bool IsBusy => IsCheckingForUpdates || IsDownloading; - - partial void OnSelectedUpdateChannelOptionChanged(SelectionOption? value) - { - if (value is not null && - !string.Equals(SelectedUpdateChannelValue, value.Value, StringComparison.OrdinalIgnoreCase)) - { - SelectedUpdateChannelValue = value.Value; - } - } - - partial void OnSelectedUpdateModeOptionChanged(SelectionOption? value) - { - if (value is not null && - !string.Equals(SelectedUpdateModeValue, value.Value, StringComparison.OrdinalIgnoreCase)) - { - SelectedUpdateModeValue = value.Value; - } - } - - partial void OnSelectedDownloadThreadsOptionChanged(SelectionOption? value) - { - if (value is null || !int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) - { - return; - } - - ApplyDownloadThreadsValue(parsed, !_isInitializing); - } - - partial void OnSelectedUpdateChannelValueChanged(string value) - { - if (_isInitializing) - { - return; - } - - _lastCheckResult = null; - if (!HasPendingInstaller) - { - LatestVersionText = string.Empty; - PublishedAtText = string.Empty; - IsLatestVersionVisible = false; - IsPublishedAtVisible = false; - } - - SaveUpdateSettings(); - UpdateStatus = string.Format( - CultureInfo.CurrentCulture, - L("settings.update.status_channel_changed_format", "Update channel switched to {0}. Please check again."), - string.Equals(value, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase) - ? L("settings.update.channel_preview", "Preview") - : L("settings.update.channel_stable", "Stable")); - SelectedUpdateChannelDescription = BuildUpdateChannelDescription(value); - SyncSelectedOptions(); - RefreshActionState(); - } - - partial void OnSelectedUpdateModeValueChanged(string value) - { - if (_isInitializing) - { - return; - } - - SaveUpdateSettings(); - SelectedUpdateModeDescription = BuildUpdateModeDescription(value); - UpdateStatus = HasPendingInstaller - ? BuildPendingReadyStatus() - : L("settings.update.status_preferences_saved", "Update preferences saved."); - SyncSelectedOptions(); - RefreshActionState(); - } - - partial void OnDownloadThreadsSliderValueChanged(double value) - { - var normalized = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(value)); - if (Math.Abs(value - normalized) > double.Epsilon) - { - DownloadThreadsSliderValue = normalized; - return; - } - - OnPropertyChanged(nameof(DownloadThreadsValueText)); - if (_isInitializing) - { - return; - } - - SaveUpdateSettings(); - UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved."); - SyncSelectedOptions(); - } - - partial void OnDownloadThreadsTextChanged(string value) - { - if (_isInitializing) - { - return; - } - - if (!TryParseDownloadThreads(value, out var parsed)) - { - return; - } - - ApplyDownloadThreadsValue(parsed, true); - } - - partial void OnHasPendingInstallerChanged(bool value) - { - RefreshActionState(); - if (!value) - { - UpdateStatus = L("settings.update.status_ready", "Ready to check for updates."); - } - } - - partial void OnIsCheckingForUpdatesChanged(bool value) - { - CheckForUpdatesCommand.NotifyCanExecuteChanged(); - DownloadLatestReleaseCommand.NotifyCanExecuteChanged(); - InstallPendingUpdateCommand.NotifyCanExecuteChanged(); - ForceFullUpdateCommand.NotifyCanExecuteChanged(); - } - - partial void OnIsDownloadingChanged(bool value) - { - CheckForUpdatesCommand.NotifyCanExecuteChanged(); - DownloadLatestReleaseCommand.NotifyCanExecuteChanged(); - InstallPendingUpdateCommand.NotifyCanExecuteChanged(); - ForceFullUpdateCommand.NotifyCanExecuteChanged(); - } - - partial void OnUseGhProxyMirrorChanged(bool value) - { - if (_isInitializing) - { - return; - } - - SaveUpdateSettings(); - UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved."); - } - - [RelayCommand] - private void SelectStableChannel() - { - SelectedUpdateChannelValue = UpdateSettingsValues.ChannelStable; - } - - [RelayCommand] - private void SelectPreviewChannel() - { - SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview; - } - - [RelayCommand] - private void SelectManualMode() - { - SelectedUpdateModeValue = UpdateSettingsValues.ModeManual; - } - - [RelayCommand] - private void SelectDownloadThenConfirmMode() - { - SelectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm; - } - - [RelayCommand] - private void SelectSilentOnExitMode() - { - SelectedUpdateModeValue = UpdateSettingsValues.ModeSilentOnExit; - } - - private void SaveUpdateSettings() - { - var current = _settingsFacade.Update.Get(); - _settingsFacade.Update.Save(current with - { - IncludePrereleaseUpdates = string.Equals( - SelectedUpdateChannelValue, - UpdateSettingsValues.ChannelPreview, - StringComparison.OrdinalIgnoreCase), - UpdateChannel = SelectedUpdateChannelValue, - UpdateMode = SelectedUpdateModeValue, - UseGhProxyMirror = UseGhProxyMirror, - UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)) - }); - } - - private bool CanCheckForUpdates() => !IsBusy; - - [RelayCommand(CanExecute = nameof(CanCheckForUpdates))] - private async Task CheckForUpdatesAsync() - { - await CheckForUpdatesCoreAsync(isForce: false); - } - - private bool CanForceCheckUpdate() => !IsBusy; - - [RelayCommand(CanExecute = nameof(CanForceCheckUpdate))] - private async Task ForceCheckUpdateAsync() - { - await CheckForUpdatesCoreAsync(isForce: true); - } - - private bool CanForceFullUpdate() => !IsBusy; - - [RelayCommand(CanExecute = nameof(CanForceFullUpdate))] - private async Task ForceFullUpdateAsync() - { - try - { - IsCheckingForUpdates = true; - IsDownloadProgressVisible = true; - UpdatePhaseText = L("settings.update.phase_force_full", "Forcing full update..."); - PhaseProgressValue = 0; - DownloadProgressValue = 0; - DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); - UpdateStatus = L("settings.update.status_force_full_checking", "Checking for full installer..."); - - var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce: true); - _lastCheckResult = result.Success ? result : null; - - if (!result.Success || result.PreferredAsset is null) - { - UpdateStatus = L("settings.update.status_force_full_failed", "No full installer available."); - return; - } - - UpdateTypeText = L("settings.update.type_full", "Full Update"); - await DownloadFullInstallerCoreAsync(result); - } - finally - { - IsCheckingForUpdates = false; - IsDownloadProgressVisible = false; - } - } - - private async Task DownloadFullInstallerCoreAsync(UpdateCheckResult result) - { - try - { - IsDownloading = true; - IsDownloadProgressVisible = true; - UpdatePhaseText = L("settings.update.phase_downloading_full", "Downloading full installer..."); - DownloadProgressValue = 0; - PhaseProgressValue = 0; - DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); - UpdateStatus = L("settings.update.status_downloading_full", "Downloading full installer..."); - - var progress = new Progress(value => - { - DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d); - PhaseProgressValue = DownloadProgressValue; - DownloadProgressText = string.Format( - CultureInfo.CurrentCulture, - L("settings.update.download_progress_format", "Download progress: {0:F0}%"), - DownloadProgressValue); - }); - - var downloadResult = await _updateWorkflowService.DownloadReleaseAsync(result, progress, CancellationToken.None); - if (!downloadResult.Success) - { - UpdateStatus = string.Format( - CultureInfo.CurrentCulture, - L("settings.update.status_download_failed_format", "Download failed: {0}"), - downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates.")); - return; - } - - ApplyPendingState(_settingsFacade.Update.Get()); - UpdateStatus = downloadResult.HashVerified - ? BuildPendingReadyStatus() - : string.Format( - CultureInfo.CurrentCulture, - L("settings.update.status_downloaded_no_hash_format", "Update downloaded. Hash: {0}"), - downloadResult.ActualHash ?? "N/A"); - } - finally - { - IsDownloading = false; - } - } - - private async Task CheckForUpdatesCoreAsync(bool isForce) - { - try - { - IsCheckingForUpdates = true; - IsDownloadProgressVisible = false; - DownloadProgressValue = 0; - DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); - UpdatePhaseText = isForce - ? L("settings.update.phase_force_scanning", "Force scanning update source...") - : L("settings.update.phase_scanning", "Scanning update source..."); - PhaseProgressValue = 0; - UpdateStatus = isForce - ? L("settings.update.status_force_checking", "Force checking update source...") - : L("settings.update.status_checking", "Checking update source..."); - - var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce); - _lastCheckResult = result.Success ? result : null; - RefreshLastCheckedFromSettings(); - - UpdatePhaseText = L("settings.update.phase_locating_resources", "Locating update resources..."); - PhaseProgressValue = 10; - - if (!result.Success) - { - UpdateStatus = string.IsNullOrWhiteSpace(result.ErrorMessage) - ? L("settings.update.status_check_failed", "Failed to check for updates.") - : string.Format( - CultureInfo.CurrentCulture, - L("settings.update.status_check_failed_format", "Update check failed: {0}"), - result.ErrorMessage); - return; - } - - ApplyCheckResultDisplay(result); - UpdateTypeText = UpdateWorkflowService.IsDeltaUpdateAvailable(result) - ? L("settings.update.type_delta", "Incremental Update") - : L("settings.update.type_full", "Full Update"); - if (!result.IsUpdateAvailable && !isForce) - { - return; - } - - if (result.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(result)) - { - UpdateStatus = isForce - ? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.") - : L("settings.update.status_asset_missing", "A new release is available, but no compatible installer was found."); - return; - } - - if (!string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase)) - { - await DownloadLatestReleaseCoreAsync(result, invokedFromCheck: true); - return; - } - - UpdateStatus = string.Format( - CultureInfo.CurrentCulture, - isForce - ? L("settings.update.status_force_available_format", "Release {0} is available. Click Download & Install.") - : L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."), - result.LatestVersionText); - } - finally - { - IsCheckingForUpdates = false; - } - } - - private bool CanDownloadLatestRelease() => !IsBusy && IsDownloadButtonVisible; - - [RelayCommand(CanExecute = nameof(CanDownloadLatestRelease))] - private async Task DownloadLatestReleaseAsync() - { - await DownloadLatestReleaseCoreAsync(_lastCheckResult, invokedFromCheck: false); - } - - private bool CanInstallPendingUpdate() => !IsBusy && HasPendingInstaller; - - [RelayCommand(CanExecute = nameof(CanInstallPendingUpdate))] - private void InstallPendingUpdate() - { - // For delta updates, launch the Launcher with apply-update command - if (_updateWorkflowService.IsPendingDeltaUpdate()) - { - var launchResult = _updateWorkflowService.LaunchLauncherForApplyUpdate(); - if (launchResult) - { - UpdateStatus = L( - "settings.update.status_delta_applying", - "Applying incremental update. The app will close for update."); - HasPendingInstaller = false; - return; - } - - UpdateStatus = L( - "settings.update.status_delta_launch_failed", - "Failed to launch updater for incremental update."); - return; - } - - // For full installer, launch the installer executable - var result = _updateWorkflowService.LaunchPendingInstallerNow(); - if (result.Success) - { - UpdateStatus = L( - "settings.update.status_installer_started", - "Installer started. The app will close for update."); - HasPendingInstaller = false; - return; - } - - UpdateStatus = result.UserCancelledElevation - ? L( - "settings.update.status_elevation_cancelled", - "Administrator permission was not granted. Update was cancelled.") - : string.Format( - CultureInfo.CurrentCulture, - L("settings.update.status_launch_failed_format", "Failed to start installer: {0}"), - result.ErrorMessage ?? L("settings.update.status_installer_missing", "Installer file was not found after download.")); - } - - private bool CanRedownloadUpdate() => !IsBusy && HasPendingInstaller && _lastCheckResult is not null; - - [RelayCommand(CanExecute = nameof(CanRedownloadUpdate))] - private async Task RedownloadUpdateAsync() - { - if (_lastCheckResult is null || - !_lastCheckResult.Success || - !_lastCheckResult.IsUpdateAvailable || - (_lastCheckResult.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(_lastCheckResult))) - { - UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading."); - return; - } - - try - { - IsDownloading = true; - IsDownloadProgressVisible = true; - DownloadProgressValue = 0; - DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); - UpdateStatus = L("settings.update.status_redownloading", "Redownloading installer..."); - - var progress = new Progress(value => - { - DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d); - DownloadProgressText = string.Format( - CultureInfo.CurrentCulture, - L("settings.update.download_progress_format", "Download progress: {0:F0}%"), - DownloadProgressValue); - }); - - var downloadResult = await _updateWorkflowService.RedownloadReleaseAsync(_lastCheckResult, progress); - if (!downloadResult.Success) - { - UpdateStatus = string.Format( - CultureInfo.CurrentCulture, - L("settings.update.status_redownload_failed_format", "Redownload failed: {0}"), - downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates.")); - return; - } - - ApplyPendingState(_settingsFacade.Update.Get()); - UpdateStatus = downloadResult.HashVerified - ? BuildPendingReadyStatus() - : string.Format( - CultureInfo.CurrentCulture, - L("settings.update.status_downloaded_no_hash_format", "Update downloaded. Hash: {0}"), - downloadResult.ActualHash ?? "N/A"); - } - finally - { - IsDownloading = false; - IsDownloadProgressVisible = false; - } - } - - private void RefreshLocalizedText() - { - PageTitle = L("settings.update.title", "Update"); - PageDescription = L("settings.update.description", "Update checks and release channel preferences."); - StatusCardTitle = L("settings.update.status_card_title", "Update Status"); - StatusCardDescription = L("settings.update.status_card_description", "Check for updates and review the latest release information."); - PreferencesHeader = L("settings.update.preferences_header", "Update Preferences"); - PreferencesDescription = L("settings.update.preferences_description", "Choose your release channel, download source, behavior, and download speed."); - UpdateChannelLabel = L("settings.update.channel_label", "Update Channel"); - UpdateModeLabel = L("settings.update.mode_label", "Update Mode"); - DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads"); - DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates."); - ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update"); - ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison."); - ForceFullUpdateLabel = L("settings.update.force_full_label", "Force Full Update"); - ForceFullUpdateDescription = L("settings.update.force_full_desc", "Skip incremental update and force download the full installer. Use this if incremental update fails repeatedly."); - NetworkAccelerationLabel = L("settings.update.network_accel_label", "Network Acceleration"); - NetworkAccelerationDescription = L("settings.update.network_accel_desc", "Use gh-proxy mirror to accelerate GitHub downloads. Only applies when falling back to GitHub for full updates."); - CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates"); - DownloadButtonText = L("settings.update.download_install_button", "Download & Install"); - InstallNowButtonText = L("settings.update.install_now_button", "Install Now"); - RedownloadButtonText = L("settings.update.redownload_button", "Redownload"); - CurrentVersionLabel = L("settings.update.current_version_label", "Current Version"); - LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release"); - PublishedAtLabel = L("settings.update.published_at_label", "Published At"); - LastCheckedLabel = L("settings.update.last_checked_label", "Last Checked"); - UpdateTypeLabel = L("settings.update.type_label", "Update Type"); - StableChannelText = L("settings.update.channel_stable", "Stable"); - PreviewChannelText = L("settings.update.channel_preview", "Preview"); - ManualModeText = L("settings.update.mode_manual", "Manual Update"); - DownloadThenConfirmModeText = L("settings.update.mode_download_then_confirm", "Silent Download"); - SilentOnExitModeText = L("settings.update.mode_silent_on_exit", "Silent Install"); - SelectedUpdateChannelDescription = BuildUpdateChannelDescription(SelectedUpdateChannelValue); - SelectedUpdateModeDescription = BuildUpdateModeDescription(SelectedUpdateModeValue); - } - - private void LoadStateFromSettings() - { - var update = _settingsFacade.Update.Get(); - _isInitializing = true; - SelectedUpdateChannelValue = UpdateSettingsValues.NormalizeChannel(update.UpdateChannel, update.IncludePrereleaseUpdates); - UseGhProxyMirror = update.UseGhProxyMirror; - SelectedUpdateModeValue = UpdateSettingsValues.NormalizeMode(update.UpdateMode); - DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(update.UpdateDownloadThreads); - DownloadThreadsText = ((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture); - _isInitializing = false; - - SyncSelectedOptions(); - RefreshLastCheckedFromSettings(); - ApplyPendingState(update); - if (!HasPendingInstaller) - { - UpdateStatus = L("settings.update.status_idle", "No update check has been performed yet."); - } - - RefreshActionState(); - } - - private void RefreshLastCheckedFromSettings() - { - var update = _settingsFacade.Update.Get(); - LastCheckedText = FormatTimestamp(update.LastUpdateCheckUtcMs); - IsLastCheckedVisible = !string.IsNullOrWhiteSpace(LastCheckedText); - } - - private void ApplyPendingState(UpdateSettingsState update) - { - var pending = _updateWorkflowService.GetPendingUpdate(); - HasPendingInstaller = pending is not null; - if (pending is null) - { - PendingUpdateTypeText = string.Empty; - return; - } - - LatestVersionText = pending.VersionText; - IsLatestVersionVisible = !string.IsNullOrWhiteSpace(LatestVersionText); - PublishedAtText = pending.PublishedAt is null ? string.Empty : FormatTimestamp(pending.PublishedAt.Value.ToUnixTimeMilliseconds()); - IsPublishedAtVisible = !string.IsNullOrWhiteSpace(PublishedAtText); - PendingUpdateTypeText = _updateWorkflowService.IsPendingDeltaUpdate() - ? L("settings.update.type_delta", "Incremental Update") - : L("settings.update.type_full", "Full Installer"); - UpdateStatus = BuildPendingReadyStatus(); - } - - private void ApplyCheckResultDisplay(UpdateCheckResult result) - { - if (result.IsUpdateAvailable) - { - LatestVersionText = result.LatestVersionText; - IsLatestVersionVisible = !string.IsNullOrWhiteSpace(LatestVersionText); - PublishedAtText = result.Release is null || result.Release.PublishedAt == DateTimeOffset.MinValue - ? string.Empty - : FormatTimestamp(result.Release.PublishedAt.ToUnixTimeMilliseconds()); - IsPublishedAtVisible = !string.IsNullOrWhiteSpace(PublishedAtText); - return; - } - - LatestVersionText = string.Empty; - PublishedAtText = string.Empty; - IsLatestVersionVisible = false; - IsPublishedAtVisible = false; - UpdateStatus = string.Format( - CultureInfo.CurrentCulture, - L("settings.update.status_up_to_date_format", "You are up to date ({0})."), - result.CurrentVersionText); - } - - private async Task DownloadLatestReleaseCoreAsync(UpdateCheckResult? result, bool invokedFromCheck) - { - if (result is null || !result.Success || !result.IsUpdateAvailable) - { - return; - } - - try - { - IsDownloading = true; - IsDownloadProgressVisible = true; - DownloadProgressValue = 0; - DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); - UpdatePhaseText = UpdateWorkflowService.IsDeltaUpdateAvailable(result) - ? L("settings.update.phase_downloading_delta", "Downloading incremental update...") - : L("settings.update.phase_downloading_full", "Downloading full installer..."); - - var progress = new Progress(value => - { - DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d); - PhaseProgressValue = DownloadProgressValue; - DownloadProgressText = string.Format( - CultureInfo.CurrentCulture, - L("settings.update.download_progress_format", "Download progress: {0:F0}%"), - DownloadProgressValue); - }); - - UpdateDownloadResult downloadResult; - - // Prefer delta update if available (smaller download, faster) - if (UpdateWorkflowService.IsDeltaUpdateAvailable(result)) - { - UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update..."); - downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress); - if (!downloadResult.Success && result.PlondsPayload is null) - { - // Delta download failed, fall back to full installer - AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer."); - if (result.PreferredAsset is not null) - { - UpdateStatus = L("settings.update.status_downloading", "Downloading installer..."); - downloadResult = await _updateWorkflowService.DownloadReleaseAsync(result, progress); - } - } - } - else if (result.PreferredAsset is not null) - { - UpdateStatus = L("settings.update.status_downloading", "Downloading installer..."); - downloadResult = await _updateWorkflowService.DownloadReleaseAsync(result, progress); - } - else - { - UpdateStatus = L("settings.update.status_asset_missing", "A new release is available, but no compatible installer was found."); - return; - } - - if (!downloadResult.Success) - { - UpdateStatus = string.Format( - CultureInfo.CurrentCulture, - L("settings.update.status_download_failed_format", "Download failed: {0}"), - downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates.")); - return; - } - - ApplyPendingState(_settingsFacade.Update.Get()); - UpdateStatus = BuildPendingReadyStatus(); - if (!invokedFromCheck) - { - _lastCheckResult = result; - } - } - finally - { - IsDownloading = false; - IsDownloadProgressVisible = false; - } - } - - private string BuildPendingReadyStatus() - { - return string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase) - ? L("settings.update.status_downloaded_exit", "Update downloaded. It will be installed when you exit the app.") - : L("settings.update.status_downloaded_confirm", "Update downloaded. Review it and choose when to install."); - } - - private string BuildUpdateModeDescription(string? value) - { - return UpdateSettingsValues.NormalizeMode(value) switch - { - UpdateSettingsValues.ModeManual => L( - "settings.update.mode_manual_desc", - "Only check for updates. You decide when downloads and installation happen."), - UpdateSettingsValues.ModeSilentOnExit => L( - "settings.update.mode_silent_on_exit_desc", - "Download updates in the background and install them the next time you exit the app."), - _ => L( - "settings.update.mode_download_then_confirm_desc", - "Download updates in the background and ask for confirmation before installing them.") - }; - } - - private string BuildUpdateChannelDescription(string? value) - { - return UpdateSettingsValues.NormalizeChannel(value) switch - { - UpdateSettingsValues.ChannelPreview => L( - "settings.update.channel_preview_desc", - "Preview builds may contain newer features but can be less stable."), - _ => L( - "settings.update.channel_stable_desc", - "Stable builds prioritize reliability and are recommended for most users.") - }; - } - - private string FormatTimestamp(long? utcMs) - { - if (utcMs is not > 0) - { - return string.Empty; - } - - try - { - return DateTimeOffset - .FromUnixTimeMilliseconds(utcMs.Value) - .ToLocalTime() - .ToString("g", CultureInfo.CurrentCulture); - } - catch (ArgumentOutOfRangeException) - { - return string.Empty; - } - } - - private void RefreshActionState() - { - OnPropertyChanged(nameof(IsDownloadButtonVisible)); - OnPropertyChanged(nameof(IsInstallButtonVisible)); - OnPropertyChanged(nameof(IsRedownloadButtonVisible)); - OnPropertyChanged(nameof(DownloadThreadsValueText)); - RedownloadUpdateCommand.NotifyCanExecuteChanged(); - ForceFullUpdateCommand.NotifyCanExecuteChanged(); - } - - private IReadOnlyList CreateUpdateChannelOptions() - { - return - [ - new SelectionOption(UpdateSettingsValues.ChannelStable, StableChannelText), - new SelectionOption(UpdateSettingsValues.ChannelPreview, PreviewChannelText) - ]; - } - - private IReadOnlyList CreateUpdateModeOptions() - { - return - [ - new SelectionOption(UpdateSettingsValues.ModeManual, ManualModeText), - new SelectionOption(UpdateSettingsValues.ModeDownloadThenConfirm, DownloadThenConfirmModeText), - new SelectionOption(UpdateSettingsValues.ModeSilentOnExit, SilentOnExitModeText) - ]; - } - - private IReadOnlyList CreateDownloadThreadOptions() - { - return Enumerable - .Range(UpdateSettingsValues.MinDownloadThreads, UpdateSettingsValues.MaxDownloadThreads) - .Select(value => new SelectionOption( - value.ToString(CultureInfo.InvariantCulture), - value.ToString(CultureInfo.CurrentCulture))) - .ToList(); - } - - private void SyncSelectedOptions() - { - SelectedUpdateChannelOption = UpdateChannelOptions.FirstOrDefault(option => - string.Equals(option.Value, SelectedUpdateChannelValue, StringComparison.OrdinalIgnoreCase)); - SelectedUpdateModeOption = UpdateModeOptions.FirstOrDefault(option => - string.Equals(option.Value, SelectedUpdateModeValue, StringComparison.OrdinalIgnoreCase)); - SelectedDownloadThreadsOption = DownloadThreadOptions.FirstOrDefault(option => - string.Equals( - option.Value, - UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.InvariantCulture), - StringComparison.OrdinalIgnoreCase)); - } - - private void ApplyDownloadThreadsValue(int value, bool saveChanges) - { - var normalized = UpdateSettingsValues.NormalizeDownloadThreads(value); - var normalizedText = normalized.ToString(CultureInfo.CurrentCulture); - - var previousInitializing = _isInitializing; - _isInitializing = true; - DownloadThreadsSliderValue = normalized; - DownloadThreadsText = normalizedText; - _isInitializing = previousInitializing; - SyncSelectedOptions(); - - if (saveChanges) - { - SaveUpdateSettings(); - UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved."); - } - } - - private static bool TryParseDownloadThreads(string? value, out int parsed) - { - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.CurrentCulture, out parsed)) - { - return true; - } - - return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed); - } - - private string L(string key, string fallback) - => _localizationService.GetString(_languageCode, key, fallback); -} - public sealed partial class StudySettingsPageViewModel : ViewModelBase { private readonly ISettingsFacadeService _settingsFacade; diff --git a/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs b/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs index 04bdd03..e755330 100644 --- a/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs +++ b/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs @@ -48,24 +48,43 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable [ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads; public bool IsBusy => CurrentPhase.IsBusy(); + public bool IsPaused => CurrentPhase.IsPaused(); public bool CanCheck => CurrentPhase.CanCheck(); public bool CanDownload => CurrentPhase.CanDownload(); public bool CanInstall => CurrentPhase.CanInstall(); public bool CanRollback => CurrentPhase.CanRollback(); - public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack; + public bool CanPause => CurrentPhase.CanPause(); + public bool CanResume => CurrentPhase.CanResume(); + public bool CanCancel => CurrentPhase.CanCancel(); + public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.PausedDownloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack; + public string PhaseText => CurrentPhase switch + { + UpdatePhase.PausedDownloading => "Paused (Download)", + UpdatePhase.PausedInstalling => "Paused (Install)", + UpdatePhase.Recovering => "Recovering Install", + _ => CurrentPhase.ToString() + }; partial void OnCurrentPhaseChanged(UpdatePhase value) { OnPropertyChanged(nameof(IsBusy)); + OnPropertyChanged(nameof(IsPaused)); OnPropertyChanged(nameof(CanCheck)); OnPropertyChanged(nameof(CanDownload)); OnPropertyChanged(nameof(CanInstall)); OnPropertyChanged(nameof(CanRollback)); + OnPropertyChanged(nameof(CanPause)); + OnPropertyChanged(nameof(CanResume)); + OnPropertyChanged(nameof(CanCancel)); OnPropertyChanged(nameof(IsProgressVisible)); + OnPropertyChanged(nameof(PhaseText)); CheckCommand.NotifyCanExecuteChanged(); DownloadCommand.NotifyCanExecuteChanged(); InstallCommand.NotifyCanExecuteChanged(); RollbackCommand.NotifyCanExecuteChanged(); + PauseCommand.NotifyCanExecuteChanged(); + ResumeCommand.NotifyCanExecuteChanged(); + CancelCommand.NotifyCanExecuteChanged(); } partial void OnSelectedUpdateChannelValueChanged(string value) @@ -121,6 +140,10 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable { StatusMessage = "Download complete. Ready to install."; } + else if (result.ErrorMessage is not null && result.ErrorMessage.Contains("stale or invalid", StringComparison.OrdinalIgnoreCase)) + { + StatusMessage = "Install resume state is invalid. Cancel and redownload, then retry."; + } else { StatusMessage = result.ErrorMessage ?? "Download failed."; @@ -138,7 +161,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable } else { - StatusMessage = result.ErrorMessage ?? "Install failed."; + StatusMessage = result.ErrorMessage ?? result.ErrorCode ?? "Install failed."; } } @@ -150,6 +173,37 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable StatusMessage = "Rollback complete."; } + [RelayCommand(CanExecute = nameof(CanPause))] + private async Task PauseAsync() + { + await _orchestrator.PauseAsync(); + StatusMessage = "Update paused."; + } + + [RelayCommand(CanExecute = nameof(CanResume))] + private async Task ResumeAsync() + { + StatusMessage = "Resuming update..."; + var result = await _orchestrator.ResumeAsync(CancellationToken.None); + if (result.Success) + { + StatusMessage = "Download complete. Ready to install."; + } + else + { + StatusMessage = result.ErrorMessage ?? "Resume failed."; + } + } + + [RelayCommand(CanExecute = nameof(CanCancel))] + private async Task CancelAsync() + { + await _orchestrator.CancelAsync(); + StatusMessage = "Update canceled."; + ProgressDetail = string.Empty; + ProgressFraction = 0; + } + private void OnOrchestratorPhaseChanged(UpdatePhase phase) { CurrentPhase = phase; diff --git a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs index 0820935..d7d03a3 100644 --- a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs +++ b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs @@ -2,14 +2,11 @@ using System; using System.Collections.Generic; using Avalonia; using Avalonia.Controls; -using LanMountainDesktop.Services; using Avalonia.Threading; +using LanMountainDesktop.Services; namespace LanMountainDesktop.Views; -/// -/// 表示一个独立的组件挂载窗口。它不含有任何自己的边窗,仅仅负责包裹组件并将自身植入系统最底层。 -/// public partial class DesktopWidgetWindow : Window { private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate(); @@ -18,6 +15,11 @@ public partial class DesktopWidgetWindow : Window public DesktopWidgetWindow() { InitializeComponent(); + + if (OperatingSystem.IsWindows()) + { + _bottomMostService.SetupBottomMost(this); + } } public DesktopWidgetWindow(Control componentContent) : this() @@ -48,11 +50,7 @@ public partial class DesktopWidgetWindow : Window if (OperatingSystem.IsWindows()) { - // 通过现有的置底服务将独立的小窗口锁定到底层 - _bottomMostService.SetupBottomMost(this); _bottomMostService.SendToBottom(this); - - // 当窗口展示完毕且有了尺寸后,更新可交互区域,使得整个组件都能被点击 Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render); } } @@ -60,7 +58,7 @@ public partial class DesktopWidgetWindow : Window protected override void OnSizeChanged(SizeChangedEventArgs e) { base.OnSizeChanged(e); - + if (OperatingSystem.IsWindows() && IsVisible) { UpdateInteractiveRegion(); @@ -69,7 +67,6 @@ public partial class DesktopWidgetWindow : Window private void UpdateInteractiveRegion() { - // 既然是一个完全紧贴在组件身上的小窗,它的全部都是可交互的 _regionPassthroughService.SetInteractiveRegions(this, new List { new(0, 0, Bounds.Width, Bounds.Height) diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml index 811f83a..8e0e1ef 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml @@ -8,16 +8,23 @@ + + @@ -25,27 +32,65 @@ + + + + + + - - + + - + + - + @@ -53,17 +98,17 @@ - + - @@ -74,55 +119,73 @@ Width="1" HorizontalAlignment="Left" Background="{DynamicResource AdaptiveGlassPanelBorderBrush}" - Opacity="0.5"/> + Opacity="0.35"/> - + - - - + + + + + + Padding="12" + HorizontalAlignment="Center" + VerticalAlignment="Center"> + - - - + + + MinHeight="330"> @@ -134,7 +197,7 @@ + Text="选择一个分类以查看可添加组件。"/> diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs index bbca695..79f757e 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs @@ -94,7 +94,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl var categoryComponents = _allDefinitions .Where(definition => string.Equals(definition.Category, category, StringComparison.OrdinalIgnoreCase)) .OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase) - .Select(CreateComponentItem) + .Select(definition => CreateComponentItem(definition, languageCode)) .ToArray(); _viewModel.Categories.Add(new ComponentLibraryCategoryViewModel( @@ -112,7 +112,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) return Symbol.WeatherSunny; if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) return Symbol.Edit; if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) return Symbol.Play; - if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) return Symbol.Apps; + if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) return Symbol.Info; if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) return Symbol.Calculator; if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) return Symbol.Hourglass; if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) return Symbol.Folder; @@ -138,9 +138,11 @@ public partial class FusedDesktopComponentLibraryControl : UserControl return LocalizationService.GetString(languageCode, key, fallback); } - private static ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition) + private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition, string languageCode) { - return new ComponentLibraryItemViewModel(definition.Id, definition.DisplayName); + var categoryTitle = GetLocalizedCategoryTitle(languageCode, definition.Category); + var description = $"{categoryTitle} - {Math.Max(1, definition.MinWidthCells)} x {Math.Max(1, definition.MinHeightCells)}"; + return new ComponentLibraryItemViewModel(definition.Id, definition.DisplayName, description); } private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e) @@ -174,7 +176,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl } _viewModel.SelectedComponent = selectedCategory.Components.FirstOrDefault(component => component.ComponentId == firstComponent.Id) - ?? CreateComponentItem(firstComponent); + ?? CreateComponentItem(firstComponent, _settingsFacade.Region.Get().LanguageCode); SetSelectedPreviewControl(CreateStaticPreviewControl(firstComponent)); } diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml index 0630960..3303c4c 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml @@ -1,72 +1,62 @@ - - - - - - - - - + PointerPressed="OnWindowTitleBarPointerPressed"> + + + + + + + + - - diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs index 7261d48..d531b28 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs @@ -1,50 +1,55 @@ using System; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Input; using Avalonia.Interactivity; -using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Services; -using LanMountainDesktop.Services.Settings; -using Avalonia.Controls.ApplicationLifetimes; namespace LanMountainDesktop.Views; -/// -/// 融合桌面组件库窗口 - 专门用于添加组件到系统桌面(负一屏) -/// -/// 注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面 -/// public partial class FusedDesktopComponentLibraryWindow : Window { - private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate(); - private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); private TransparentOverlayWindow? _overlayWindow; - - // 与 TransparentOverlayWindow 保持一致的默认 cellSize - private const double DefaultCellSize = 100; - + public FusedDesktopComponentLibraryWindow() { InitializeComponent(); - + LibraryControl.AddComponentRequested += OnAddComponentRequested; - + KeyDown += OnWindowKeyDown; + var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow; mainWindow?.RegisterFusedLibraryWindow(this); } - - /// - /// 设置透明覆盖层窗口引用 - /// + + public bool PreserveEditModeOnClose { get; private set; } + public void SetOverlayWindow(TransparentOverlayWindow overlayWindow) { _overlayWindow = overlayWindow; } - - /// - /// 添加组件请求处理 - 将组件放置在屏幕(覆盖层画布)中央 - /// + + public void CenterInWorkArea(Window? referenceWindow = null) + { + var screen = referenceWindow is not null + ? Screens.ScreenFromWindow(referenceWindow) + : Screens.Primary; + screen ??= Screens.Primary; + if (screen is null) + { + return; + } + + var scaling = screen.Scaling; + var workArea = screen.WorkingArea; + var widthPx = (int)Math.Round(Math.Max(MinWidth, Width) * scaling); + var heightPx = (int)Math.Round(Math.Max(MinHeight, Height) * scaling); + var x = workArea.X + Math.Max(0, (workArea.Width - widthPx) / 2); + var y = workArea.Y + Math.Max(0, (workArea.Height - heightPx) / 2); + Position = new PixelPoint(x, y); + } + private void OnAddComponentRequested(object? sender, string componentId) { if (_overlayWindow is null) @@ -52,55 +57,17 @@ public partial class FusedDesktopComponentLibraryWindow : Window AppLogger.Warn("FusedDesktopLibrary", "Overlay window is not set."); return; } - - // 计算组件的像素尺寸 - var (componentWidth, componentHeight) = ResolveComponentSize(componentId); - - // 取覆盖层画布的中心点,减去组件半尺寸,使组件出现在屏幕正中央 - var overlayBounds = _overlayWindow.Bounds; - var centerX = overlayBounds.Width / 2.0 - componentWidth / 2.0; - var centerY = overlayBounds.Height / 2.0 - componentHeight / 2.0; - - // 边界保护:确保组件不超出屏幕边界 - centerX = Math.Max(0, Math.Min(centerX, overlayBounds.Width - componentWidth)); - centerY = Math.Max(0, Math.Min(centerY, overlayBounds.Height - componentHeight)); - - _overlayWindow.AddComponent(componentId, centerX, centerY, componentWidth, componentHeight); - - AppLogger.Info("FusedDesktopLibrary", - $"Added component '{componentId}' at center ({centerX:F0}, {centerY:F0}) size ({componentWidth}x{componentHeight})."); - - // 关闭窗口 + + _overlayWindow.AddComponentToCenter(componentId); + AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' at fused desktop grid center."); + + PreserveEditModeOnClose = true; Close(); } - - /// - /// 解析组件的默认像素尺寸(基于组件定义的 MinCells * DefaultCellSize) - /// - private (double Width, double Height) ResolveComponentSize(string componentId) - { - try - { - var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService; - var registry = DesktopComponentRegistryFactory.Create(pluginRuntimeService); - if (registry.TryGetDefinition(componentId, out var definition)) - { - var w = Math.Max(1, definition.MinWidthCells) * DefaultCellSize; - var h = Math.Max(1, definition.MinHeightCells) * DefaultCellSize; - return (w, h); - } - } - catch (Exception ex) - { - AppLogger.Warn("FusedDesktopLibrary", $"Failed to resolve component size for '{componentId}'.", ex); - } - - // 回退为 2×2 格子的默认尺寸 - return (DefaultCellSize * 2, DefaultCellSize * 2); - } - + private void OnCloseClick(object? sender, RoutedEventArgs e) { + PreserveEditModeOnClose = false; Close(); } @@ -111,10 +78,22 @@ public partial class FusedDesktopComponentLibraryWindow : Window BeginMoveDrag(e); } } - + + private void OnWindowKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + PreserveEditModeOnClose = false; + Close(); + } + } + protected override void OnClosed(EventArgs e) { + LibraryControl.AddComponentRequested -= OnAddComponentRequested; + KeyDown -= OnWindowKeyDown; base.OnClosed(e); + var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow; mainWindow?.UnregisterFusedLibraryWindow(this); } diff --git a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs index c046a1a..70d4ce8 100644 --- a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs +++ b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs @@ -15,6 +15,7 @@ using FluentAvalonia.UI.Controls; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Update; using LanMountainDesktop.Theme; using LanMountainDesktop.Views.Components; @@ -475,28 +476,14 @@ public partial class MainWindow : Window private void TriggerAutoUpdateCheckIfEnabled() { - var versionText = _settingsFacade.ApplicationInfo.GetAppVersionText(); - if (!Version.TryParse(versionText, out var currentVersion)) - { - currentVersion = new Version(0, 0, 0); - } - - var major = Math.Max(0, currentVersion.Major); - var minor = Math.Max(0, currentVersion.Minor); - var build = Math.Max(0, currentVersion.Build >= 0 ? currentVersion.Build : 0); - var revision = Math.Max(0, currentVersion.Revision >= 0 ? currentVersion.Revision : 0); - var normalizedVersion = revision > 0 - ? new Version(major, minor, build, revision) - : new Version(major, minor, build); - DispatcherTimer.RunOnce( async () => { try { - await HostUpdateWorkflowServiceProvider + await HostUpdateOrchestratorProvider .GetOrCreate() - .AutoCheckIfEnabledAsync(normalizedVersion); + .AutoCheckIfEnabledAsync(default); } catch (Exception ex) { diff --git a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml index 5af4903..7380c29 100644 --- a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml @@ -1,285 +1,305 @@ - - - + x:DataType="vm:UpdateSettingsViewModel"> + + + + + + - + + + + + - + + + + + + + + + + + + + + + + - - + + - - - - + + + + + + - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs index 1ca61b6..83a84e9 100644 --- a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs +++ b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs @@ -3,30 +3,61 @@ using System.Collections.Generic; using System.Diagnostics; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.DesktopEditing; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Settings; -using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Views.Components; namespace LanMountainDesktop.Views; -/// -/// 透明覆盖层窗口 - 作为"负一屏"显示在 Windows 桌面上 -/// 支持在系统桌面上自由摆放组件 -/// public partial class TransparentOverlayWindow : Window { + private const double DefaultCellSize = 100; + private const string ResizeHandleTag = "fused-desktop-resize-handle"; + private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate(); private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate(); private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate(); - - // 滑动状态 + private readonly ISettingsFacadeService _settingsFacade; + private readonly FusedDesktopEditGridAdapter _gridAdapter; + + private readonly IWeatherInfoService _weatherDataService; + private readonly TimeZoneService _timeZoneService; + private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService(); + private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService(); + + private readonly Dictionary _componentHosts = []; + private readonly List _interactiveRegions = []; + private FusedDesktopLayoutSnapshot _layout = new(); + private ComponentRegistry? _componentRegistry; + private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry; + private FusedDesktopEditGridContext _gridContext; + private double _currentDesktopCellSize = DefaultCellSize; + + private DesktopEditSession _editSession; + private Border? _interactionHost; + private string? _interactionPlacementId; + private Rect _interactionOriginalRect; + private int _interactionStartRow; + private int _interactionStartColumn; + private int _interactionStartWidthCells; + private int _interactionStartHeightCells; + private int _interactionMinWidthCells; + private int _interactionMinHeightCells; + private int _interactionMaxWidthCells; + private int _interactionMaxHeightCells; + private DesktopComponentResizeMode _interactionResizeMode = DesktopComponentResizeMode.Proportional; + + private Border? _selectedHost; + private bool _isSwipeActive; private bool _isSwipeDirectionLocked; private Point _swipeStartPoint; @@ -35,125 +66,229 @@ public partial class TransparentOverlayWindow : Window private double _swipeVelocityX; private long _swipeLastTimestamp; private int? _swipePointerId; - - // 三指/右键拖动状态 private bool _isThreeFingerOrRightDragSwipeActive; private readonly HashSet _activePointerIds = []; - - // 组件管理 - private readonly Dictionary _componentHosts = []; - private readonly List _interactiveRegions = []; - private FusedDesktopLayoutSnapshot _layout = new(); - private ComponentRegistry? _componentRegistry; - private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry; - - // 基础服务 - private readonly IWeatherInfoService _weatherDataService; - private readonly TimeZoneService _timeZoneService; - private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService(); - private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService(); - - // 渲染参数 - private const double DefaultCellSize = 100; - private double _currentDesktopCellSize; - - // 拖拽与缩放状态 - private bool _isDragging; - private bool _isResizing; - private string? _interactionPlacementId; - private Point _interactionStartPoint; - private double _interactionOriginalX; - private double _interactionOriginalY; - private double _interactionOriginalWidth; - private double _interactionOriginalHeight; - private Border? _interactionHost; - - // 选中状态 - private Border? _selectedHost; - + public event EventHandler? RestoreMainWindowRequested; - + public event EventHandler? ExitEditRequested; + public event EventHandler? RestoreComponentLibraryRequested; + public TransparentOverlayWindow() { InitializeComponent(); + var facade = HostSettingsFacadeProvider.GetOrCreate(); + _settingsFacade = facade; + _gridAdapter = new FusedDesktopEditGridAdapter(_settingsFacade); _weatherDataService = facade.Weather.GetWeatherInfoService(); _timeZoneService = facade.Region.GetTimeZoneService(); - _settingsFacade = facade; + + SizeChanged += OnOverlaySizeChanged; if (OperatingSystem.IsWindows()) { _bottomMostService.SetupBottomMost(this); } } - - private readonly ISettingsFacadeService _settingsFacade; public void SaveLayoutAndHide() { SaveLayout(); _regionPassthroughService.ClearInteractiveRegions(this); Hide(); - - // Remove all components so that next time we open it builds fresh from snapshot - if (Content is Canvas canvas) - { - canvas.Children.Clear(); - } + ComponentCanvas.Children.Clear(); _componentHosts.Clear(); + _selectedHost = null; + _editSession = default; } - + + public void AddComponentToCenter(string componentId) + { + AddComponent(componentId, double.NaN, double.NaN); + } + + public void AddComponent(string componentId, double x, double y, double? width = null, double? height = null) + { + EnsureRegistries(); + + if (_componentRuntimeRegistry is null || + !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor)) + { + AppLogger.Warn("TransparentOverlay", $"Cannot add unknown component: {componentId}"); + return; + } + + EnsureGridContext(); + var (widthCells, heightCells) = ResolveRequestedSpan(descriptor.Definition, width, height); + var (column, row) = ResolveRequestedCell(x, y, widthCells, heightCells); + var placement = new FusedDesktopComponentPlacementSnapshot + { + PlacementId = Guid.NewGuid().ToString("N"), + ComponentId = componentId, + GridColumn = column, + GridRow = row, + GridWidthCells = widthCells, + GridHeightCells = heightCells, + ZIndex = _layout.ComponentPlacements.Count + }; + ApplyGridPlacementToPixelPlacement(placement); + + _layout.ComponentPlacements.Add(placement); + try + { + RenderComponentInternal(placement); + UpdateInteractiveRegions(); + SaveLayout(); + AppLogger.Info( + "TransparentOverlay", + $"Added component: {componentId} at cell ({column}, {row}) span ({widthCells}x{heightCells})"); + } + catch (Exception ex) + { + AppLogger.Warn("TransparentOverlay", $"Failed to add component {componentId}", ex); + _layout.ComponentPlacements.Remove(placement); + } + } + + public void RemoveComponent(string placementId) + { + if (_componentHosts.Remove(placementId, out var host)) + { + ComponentCanvas.Children.Remove(host); + } + + _layout.ComponentPlacements.RemoveAll(p => string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)); + UpdateInteractiveRegions(); + SaveLayout(); + } + + public void RenderComponent(string placementId, Control component, double x, double y, double width, double height) + { + if (_componentHosts.Remove(placementId, out var existingHost)) + { + ComponentCanvas.Children.Remove(existingHost); + } + + component.Width = width; + component.Height = height; + + var contentGrid = new Grid(); + contentGrid.Children.Add(component); + + var resizeHandle = new Border + { + Width = 22, + Height = 22, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Bottom, + Margin = new Thickness(0, 0, -11, -11), + Cursor = new Cursor(StandardCursorType.BottomRightCorner), + Tag = ResizeHandleTag, + IsVisible = false, + IsHitTestVisible = false, + Classes = { "fused-desktop-resize-handle" } + }; + contentGrid.Children.Add(resizeHandle); + + var host = new Border + { + Tag = placementId, + Width = width, + Height = height, + ClipToBounds = false, + Child = contentGrid, + Classes = { "fused-desktop-component-host" } + }; + + Canvas.SetLeft(host, x); + Canvas.SetTop(host, y); + + host.PointerPressed += OnComponentPointerPressed; + host.PointerMoved += OnInteractionPointerMoved; + host.PointerReleased += OnInteractionPointerReleased; + host.PointerCaptureLost += OnInteractionPointerCaptureLost; + host.ContextRequested += OnComponentContextRequested; + + ComponentCanvas.Children.Add(host); + _componentHosts[placementId] = host; + } + protected override void OnOpened(EventArgs e) { base.OnOpened(e); - - if (Screens.Primary is { } primaryScreen) - { - // 避开系统任务栏 - var workArea = primaryScreen.WorkingArea; - var scaling = primaryScreen.Scaling; - Position = new PixelPoint(workArea.X, workArea.Y); - Width = workArea.Width / scaling; - Height = workArea.Height / scaling; - - // 基于设置计算单元格尺寸 - var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); - var shortCells = Math.Clamp(appSnapshot.GridShortSideCells > 0 ? appSnapshot.GridShortSideCells : 12, 6, 96); - _currentDesktopCellSize = Height / shortCells; - } - else - { - _currentDesktopCellSize = DefaultCellSize; - } - if (Content is Canvas canvas) - { - // 保证透明区域也能被抓取事件 - canvas.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0)); - } - - // 确保注册表已初始化 + ApplyWorkAreaBounds(); + EnsureGridContext(); EnsureRegistries(); - - // 加载布局并渲染 + _layout = _layoutService.Load(); RenderAllComponents(); - + AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components."); if (OperatingSystem.IsWindows()) { _bottomMostService.SendToBottom(this); } + + Dispatcher.UIThread.Post(UpdateInteractiveRegions, DispatcherPriority.Background); } - - /// - /// 确保组件运行时注册表已初始化 - /// + + protected override void OnClosed(EventArgs e) + { + SaveLayout(); + base.OnClosed(e); + } + + private void OnOverlaySizeChanged(object? sender, SizeChangedEventArgs e) + { + if (!IsVisible) + { + return; + } + + EnsureGridContext(); + RenderAllComponents(saveIfMigrated: false); + Dispatcher.UIThread.Post(UpdateInteractiveRegions, DispatcherPriority.Background); + } + + private void ApplyWorkAreaBounds() + { + if (Screens.Primary is not { } primaryScreen) + { + return; + } + + var workArea = primaryScreen.WorkingArea; + var scaling = primaryScreen.Scaling; + Position = new PixelPoint(workArea.X, workArea.Y); + Width = workArea.Width / scaling; + Height = workArea.Height / scaling; + } + + private void EnsureGridContext() + { + var viewport = new Size(Math.Max(1, Width), Math.Max(1, Height)); + if (_gridAdapter.TryCreate(viewport, out var context)) + { + _gridContext = context; + _currentDesktopCellSize = context.Geometry.CellSize; + return; + } + + _gridContext = new FusedDesktopEditGridContext( + new DesktopGridGeometry(default, DefaultCellSize, 0, 1, 1), + new DesktopGridMetrics(1, 1, DefaultCellSize, 0, 0, DefaultCellSize, DefaultCellSize)); + _currentDesktopCellSize = DefaultCellSize; + } + private void EnsureRegistries() { - if (_componentRuntimeRegistry is not null) return; - + if (_componentRuntimeRegistry is not null) + { + return; + } + var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService; _componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService); _componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry( @@ -161,22 +296,19 @@ public partial class TransparentOverlayWindow : Window pluginRuntimeService, _settingsFacade); } - - /// - /// 渲染所有布局中的组件 - /// - private void RenderAllComponents() + + private void RenderAllComponents(bool saveIfMigrated = true) { - if (Content is not Canvas canvas) return; - - canvas.Children.Clear(); + ComponentCanvas.Children.Clear(); _componentHosts.Clear(); _selectedHost = null; - + + var migrated = false; foreach (var placement in _layout.ComponentPlacements) { try { + migrated |= EnsurePlacementGridFields(placement); RenderComponentInternal(placement); } catch (Exception ex) @@ -184,19 +316,142 @@ public partial class TransparentOverlayWindow : Window AppLogger.Warn("TransparentOverlay", $"Failed to render component {placement.ComponentId}", ex); } } - + + if (migrated && saveIfMigrated) + { + SaveLayout(); + } + UpdateInteractiveRegions(); } - - protected override void OnClosed(EventArgs e) + + private void RenderComponentInternal(FusedDesktopComponentPlacementSnapshot placement) { - SaveLayout(); - base.OnClosed(e); + if (_componentRuntimeRegistry is null || + !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) + { + AppLogger.Warn("TransparentOverlay", $"Unknown component: {placement.ComponentId}"); + return; + } + + EnsurePlacementGridFields(placement); + ApplyGridPlacementToPixelPlacement(placement); + + var control = descriptor.CreateControl( + _currentDesktopCellSize, + _timeZoneService, + _weatherDataService, + _recommendationInfoService, + _calculatorDataService, + _settingsFacade, + placement.PlacementId); + + RenderComponent(placement.PlacementId, control, placement.X, placement.Y, placement.Width, placement.Height); } - - /// - /// 更新可交互区域 - /// + + private bool EnsurePlacementGridFields(FusedDesktopComponentPlacementSnapshot placement) + { + if (_componentRuntimeRegistry is null || + !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) + { + return false; + } + + var grid = _gridContext.Geometry; + var oldRow = placement.GridRow; + var oldColumn = placement.GridColumn; + var oldWidthCells = placement.GridWidthCells; + var oldHeightCells = placement.GridHeightCells; + + var widthCells = placement.GridWidthCells ?? PixelSizeToCellSpan(placement.Width); + var heightCells = placement.GridHeightCells ?? PixelSizeToCellSpan(placement.Height); + (widthCells, heightCells) = ComponentPlacementRules.EnsureMinimumSize( + descriptor.Definition, + widthCells, + heightCells); + widthCells = Math.Clamp(widthCells, 1, Math.Max(1, grid.ColumnCount)); + heightCells = Math.Clamp(heightCells, 1, Math.Max(1, grid.RowCount)); + + var column = placement.GridColumn ?? PixelPositionToCell(placement.X, grid.Origin.X); + var row = placement.GridRow ?? PixelPositionToCell(placement.Y, grid.Origin.Y); + column = Math.Clamp(column, 0, Math.Max(0, grid.ColumnCount - widthCells)); + row = Math.Clamp(row, 0, Math.Max(0, grid.RowCount - heightCells)); + + placement.GridColumn = column; + placement.GridRow = row; + placement.GridWidthCells = widthCells; + placement.GridHeightCells = heightCells; + ApplyGridPlacementToPixelPlacement(placement); + + return oldRow != placement.GridRow || + oldColumn != placement.GridColumn || + oldWidthCells != placement.GridWidthCells || + oldHeightCells != placement.GridHeightCells; + } + + private void ApplyGridPlacementToPixelPlacement(FusedDesktopComponentPlacementSnapshot placement) + { + var grid = _gridContext.Geometry; + var widthCells = Math.Clamp(placement.GridWidthCells ?? 1, 1, Math.Max(1, grid.ColumnCount)); + var heightCells = Math.Clamp(placement.GridHeightCells ?? 1, 1, Math.Max(1, grid.RowCount)); + var column = Math.Clamp(placement.GridColumn ?? 0, 0, Math.Max(0, grid.ColumnCount - widthCells)); + var row = Math.Clamp(placement.GridRow ?? 0, 0, Math.Max(0, grid.RowCount - heightCells)); + var rect = DesktopPlacementMath.GetCellRect(grid, column, row, widthCells, heightCells); + + placement.GridColumn = column; + placement.GridRow = row; + placement.GridWidthCells = widthCells; + placement.GridHeightCells = heightCells; + placement.X = rect.X; + placement.Y = rect.Y; + placement.Width = rect.Width; + placement.Height = rect.Height; + } + + private (int WidthCells, int HeightCells) ResolveRequestedSpan( + DesktopComponentDefinition definition, + double? requestedWidth, + double? requestedHeight) + { + var widthCells = requestedWidth.HasValue ? PixelSizeToCellSpan(requestedWidth.Value) : definition.MinWidthCells; + var heightCells = requestedHeight.HasValue ? PixelSizeToCellSpan(requestedHeight.Value) : definition.MinHeightCells; + (widthCells, heightCells) = ComponentPlacementRules.EnsureMinimumSize(definition, widthCells, heightCells); + widthCells = Math.Clamp(widthCells, 1, Math.Max(1, _gridContext.Geometry.ColumnCount)); + heightCells = Math.Clamp(heightCells, 1, Math.Max(1, _gridContext.Geometry.RowCount)); + return (widthCells, heightCells); + } + + private (int Column, int Row) ResolveRequestedCell(double x, double y, int widthCells, int heightCells) + { + var grid = _gridContext.Geometry; + if (double.IsNaN(x) || double.IsNaN(y)) + { + return ( + Math.Max(0, (grid.ColumnCount - widthCells) / 2), + Math.Max(0, (grid.RowCount - heightCells) / 2)); + } + + var column = PixelPositionToCell(x, grid.Origin.X); + var row = PixelPositionToCell(y, grid.Origin.Y); + return ( + Math.Clamp(column, 0, Math.Max(0, grid.ColumnCount - widthCells)), + Math.Clamp(row, 0, Math.Max(0, grid.RowCount - heightCells))); + } + + private int PixelSizeToCellSpan(double pixels) + { + var grid = _gridContext.Geometry; + var pitch = Math.Max(1, grid.Pitch); + var span = (int)Math.Round((Math.Max(1, pixels) + grid.CellGap) / pitch); + return Math.Max(1, span); + } + + private int PixelPositionToCell(double position, double origin) + { + var pitch = Math.Max(1, _gridContext.Geometry.Pitch); + return (int)Math.Round((position - origin) / pitch); + } + private void UpdateInteractiveRegions() { _interactiveRegions.Clear(); @@ -207,386 +462,434 @@ public partial class TransparentOverlayWindow : Window var top = Canvas.GetTop(host); var width = host.Width > 0 ? host.Width : host.Bounds.Width; var height = host.Height > 0 ? host.Height : host.Bounds.Height; - - if (width <= 0 || height <= 0) + if (width > 0 && height > 0) { - continue; + _interactiveRegions.Add(new Rect(left - 14, top - 14, width + 28, height + 28)); } + } - // 稍微向外扩一圈,确保拖拽和右下角缩放手柄也能命中。 - _interactiveRegions.Add(new Rect(left - 12, top - 12, width + 24, height + 24)); + if (EditToolbar.IsVisible && + EditToolbar.Bounds.Width > 0 && + EditToolbar.Bounds.Height > 0 && + EditToolbar.TranslatePoint(default, this) is { } toolbarOrigin) + { + _interactiveRegions.Add(new Rect(toolbarOrigin, EditToolbar.Bounds.Size)); } _regionPassthroughService.SetInteractiveRegions(this, _interactiveRegions); } - - /// - /// 保存布局 - /// + private void SaveLayout() { _layoutService.Save(_layout); } - - /// - /// 添加组件(供外部调用) - /// - public void AddComponent(string componentId, double x, double y, double? width = null, double? height = null) + + private void OnCanvasPointerPressed(object? sender, PointerPressedEventArgs e) { - EnsureRegistries(); - - if (_componentRegistry == null || !_componentRegistry.TryGetDefinition(componentId, out var definition)) + if (e.Source == ComponentCanvas) + { + DeselectComponent(); + } + } + + private void OnExitEditClick(object? sender, RoutedEventArgs e) + { + ExitEditRequested?.Invoke(this, EventArgs.Empty); + e.Handled = true; + } + + private void OnRestoreComponentLibraryClick(object? sender, RoutedEventArgs e) + { + RestoreComponentLibraryRequested?.Invoke(this, EventArgs.Empty); + e.Handled = true; + } + + private void SelectComponent(Border host) + { + if (_selectedHost == host) { - AppLogger.Warn("TransparentOverlay", $"Cannot add unknown component: {componentId}"); return; } - var finalWidth = width ?? (definition.MinWidthCells * _currentDesktopCellSize); - var finalHeight = height ?? (definition.MinHeightCells * _currentDesktopCellSize); - - // 对齐网格 - x = Math.Round(x / _currentDesktopCellSize) * _currentDesktopCellSize; - y = Math.Round(y / _currentDesktopCellSize) * _currentDesktopCellSize; - finalWidth = Math.Round(finalWidth / _currentDesktopCellSize) * _currentDesktopCellSize; - finalHeight = Math.Round(finalHeight / _currentDesktopCellSize) * _currentDesktopCellSize; - - var placementId = Guid.NewGuid().ToString("N"); - var placement = new FusedDesktopComponentPlacementSnapshot - { - PlacementId = placementId, - ComponentId = componentId, - X = x, - Y = y, - Width = finalWidth, - Height = finalHeight, - ZIndex = _layout.ComponentPlacements.Count - }; - - _layout.ComponentPlacements.Add(placement); - - // 立即渲染 - try - { - RenderComponentInternal(placement); - UpdateInteractiveRegions(); - SaveLayout(); - AppLogger.Info("TransparentOverlay", $"Added component: {componentId} at ({x}, {y}) size ({finalWidth}x{finalHeight})"); - } - catch (Exception ex) - { - AppLogger.Warn("TransparentOverlay", $"Failed to add component {componentId}", ex); - _layout.ComponentPlacements.Remove(placement); - } + DeselectComponent(); + _selectedHost = host; + host.Classes.Add("selected"); + SetResizeHandleVisible(host, true); } - - /// - /// 内部渲染单个组件 - /// - private void RenderComponentInternal(FusedDesktopComponentPlacementSnapshot placement) + + private void DeselectComponent() { - if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) + if (_selectedHost is null) { - AppLogger.Warn("TransparentOverlay", $"Unknown component: {placement.ComponentId}"); return; } - - // 【修复问题3】尝试从现有窗口中获取组件实例,避免重新创建导致状态丢失 - var control = TryGetExistingControl(placement.PlacementId); - if (control is null) - { - // 如果没有现有实例,才创建新的 - control = descriptor.CreateControl( - _currentDesktopCellSize, - _timeZoneService, - _weatherDataService, - _recommendationInfoService, - _calculatorDataService, - _settingsFacade, - placement.PlacementId); - } - - RenderComponent(placement.PlacementId, control, placement.X, placement.Y, placement.Width, placement.Height); + + _selectedHost.Classes.Remove("selected"); + SetResizeHandleVisible(_selectedHost, false); + _selectedHost = null; } - - /// - /// 【修复问题3】尝试从现有的小窗口中获取组件控件实例 - /// - private Control? TryGetExistingControl(string placementId) + + private static void SetResizeHandleVisible(Border host, bool isVisible) { - try + if (host.Child is not Grid grid) { - var manager = FusedDesktopManagerServiceFactory.GetOrCreate(); - // 通过反射或公共 API 获取现有窗口中的控件 - // 这里需要 FusedDesktopManagerService 提供获取控件的方法 - // 暂时返回 null,后续需要扩展接口 - return null; + return; } - catch + + foreach (var child in grid.Children) { - return null; - } - } - - /// - /// 移除组件 - /// - public void RemoveComponent(string placementId) - { - if (_componentHosts.TryGetValue(placementId, out var host)) - { - if (Content is Canvas canvas) + if (child is Control control && control.Tag as string == ResizeHandleTag) { - canvas.Children.Remove(host); + control.IsVisible = isVisible; + control.IsHitTestVisible = isVisible; + return; } - _componentHosts.Remove(placementId); } - - _layout.ComponentPlacements.RemoveAll(p => p.PlacementId == placementId); - UpdateInteractiveRegions(); - SaveLayout(); } - - /// - /// 渲染组件(从外部传入控件) - /// - public void RenderComponent(string placementId, Control component, double x, double y, double width, double height) - { - var grid = new Grid(); - grid.Children.Add(component); - - var resizeHandle = new Border - { - Width = 24, - Height = 24, - Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#3B82F6")), - CornerRadius = new Avalonia.CornerRadius(12), - HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Bottom, - Margin = new Avalonia.Thickness(0, 0, -12, -12), - Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.BottomRightCorner), - Tag = "desktop-component-resize-handle", - IsVisible = false - }; - grid.Children.Add(resizeHandle); - - var host = new Border - { - Tag = placementId, - Width = width, - Height = height, - Background = Avalonia.Media.Brushes.Transparent, - CornerRadius = new Avalonia.CornerRadius(12), - ClipToBounds = false, // 允许把手溢出 - BorderBrush = Avalonia.Media.Brushes.Transparent, - BorderThickness = new Avalonia.Thickness(3), - Child = grid, - Classes = { "desktop-component-host" } - }; - - Canvas.SetLeft(host, x); - Canvas.SetTop(host, y); - - host.PointerPressed += OnComponentPointerPressed; - host.PointerMoved += OnInteractionPointerMoved; - host.PointerReleased += OnInteractionPointerReleased; - - // 右键上下文菜单(删除组件) - host.ContextRequested += OnComponentContextRequested; - - if (Content is Canvas canvas) - { - canvas.Children.Add(host); - } - - _componentHosts[placementId] = host; - UpdateInteractiveRegions(); - } - - // 组件右键上下文菜单(删除) + private void OnComponentContextRequested(object? sender, ContextRequestedEventArgs e) { - if (sender is not Border host || host.Tag is not string placementId) return; - - // 构建上下文菜单 + if (sender is not Border host || host.Tag is not string placementId) + { + return; + } + var deleteItem = new MenuItem { - Header = "移除组件", - Icon = new Avalonia.Controls.TextBlock { Text = "🗑" } + Header = "移除组件" }; - deleteItem.Click += (_, _) => - { - RemoveComponent(placementId); - AppLogger.Info("TransparentOverlay", $"Component removed via context menu: {placementId}"); - }; - + deleteItem.Click += (_, _) => RemoveComponent(placementId); + var menu = new ContextMenu { Items = { deleteItem } }; - - // 显示在当前控件上 menu.Open(host); e.Handled = true; } - - // 取消选中 - private void OnCanvasPointerPressed(object? sender, PointerPressedEventArgs e) - { - DeselectComponent(); - } - - // 选中组件 - private void SelectComponent(Border host) - { - if (_selectedHost == host) return; - DeselectComponent(); - - _selectedHost = host; - - // 渲染选中边框和把手 - host.BorderBrush = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#3B82F6")); - host.Classes.Add("desktop-component-host-selected"); - - if (host.Child is Grid grid) - { - foreach (var child in grid.Children) - { - if (child is Control c && c.Tag is string tg && tg == "desktop-component-resize-handle") - { - c.IsVisible = true; - break; - } - } - } - } - - private void DeselectComponent() - { - if (_selectedHost != null) - { - _selectedHost.BorderBrush = Avalonia.Media.Brushes.Transparent; - _selectedHost.Classes.Remove("desktop-component-host-selected"); - - if (_selectedHost.Child is Grid grid) - { - foreach (var child in grid.Children) - { - if (child is Control c && c.Tag is string tg && tg == "desktop-component-resize-handle") - { - c.IsVisible = false; - break; - } - } - } - } - _selectedHost = null; - } - - // 组件拖拽与缩放处理 + private void OnComponentPointerPressed(object? sender, PointerPressedEventArgs e) { - if (sender is not Border host || host.Tag is not string placementId) return; - - var point = e.GetCurrentPoint(this); - if (!point.Properties.IsLeftButtonPressed) return; - - SelectComponent(host); - - _interactionPlacementId = placementId; - _interactionHost = host; - _interactionStartPoint = e.GetPosition(this); - - // 这里必须用未吸附的原始屏幕位置计算 delta - _interactionOriginalX = Canvas.GetLeft(host); - _interactionOriginalY = Canvas.GetTop(host); - _interactionOriginalWidth = host.Width; - _interactionOriginalHeight = host.Height; - - if (e.Source is Control sourceControl && sourceControl.Tag is string tag && tag == "desktop-component-resize-handle") + if (sender is not Border host || + host.Tag is not string placementId || + !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { - _isResizing = true; - _isDragging = false; + return; + } + + var placement = _layout.ComponentPlacements.Find(p => + string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)); + if (placement is null || placement.IsLocked) + { + return; + } + + EnsurePlacementGridFields(placement); + SelectComponent(host); + + if (e.Source is Control sourceControl && sourceControl.Tag as string == ResizeHandleTag) + { + BeginResizeInteraction(host, placement, e); } else { - _isDragging = true; - _isResizing = false; + BeginMoveInteraction(host, placement, e); + } + + if (_editSession.IsActive) + { + e.Pointer.Capture(host); + e.Handled = true; } - - e.Pointer.Capture(host); - e.Handled = true; } - - private void OnInteractionPointerMoved(object? sender, PointerEventArgs e) + + private void BeginMoveInteraction(Border host, FusedDesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e) { - if ((!_isDragging && !_isResizing) || _interactionHost is null) return; - - var currentPoint = e.GetPosition(this); - var deltaX = currentPoint.X - _interactionStartPoint.X; - var deltaY = currentPoint.Y - _interactionStartPoint.Y; - - if (_isDragging) - { - var rawX = _interactionOriginalX + deltaX; - var rawY = _interactionOriginalY + deltaY; - - var snapX = Math.Round(rawX / _currentDesktopCellSize) * _currentDesktopCellSize; - var snapY = Math.Round(rawY / _currentDesktopCellSize) * _currentDesktopCellSize; - - Canvas.SetLeft(_interactionHost, snapX); - Canvas.SetTop(_interactionHost, snapY); - } - else if (_isResizing) - { - var rawWidth = _interactionOriginalWidth + deltaX; - var rawHeight = _interactionOriginalHeight + deltaY; - - var snapWidth = Math.Round(rawWidth / _currentDesktopCellSize) * _currentDesktopCellSize; - var snapHeight = Math.Round(rawHeight / _currentDesktopCellSize) * _currentDesktopCellSize; - - // 防溢出与极小值保护 - snapWidth = Math.Max(_currentDesktopCellSize, snapWidth); - snapHeight = Math.Max(_currentDesktopCellSize, snapHeight); - - _interactionHost.Width = snapWidth; - _interactionHost.Height = snapHeight; - } - - e.Handled = true; + var pointer = e.GetPosition(this); + _interactionHost = host; + _interactionPlacementId = placement.PlacementId; + _interactionStartRow = placement.GridRow ?? 0; + _interactionStartColumn = placement.GridColumn ?? 0; + _interactionOriginalRect = DesktopPlacementMath.GetCellRect( + _gridContext.Geometry, + _interactionStartColumn, + _interactionStartRow, + placement.GridWidthCells ?? 1, + placement.GridHeightCells ?? 1); + + var pointerOffset = DesktopPlacementMath.Subtract( + pointer, + new Point(_interactionOriginalRect.X, _interactionOriginalRect.Y)); + _editSession = DesktopEditSession.CreateDraggingExisting( + placement.ComponentId, + placement.PlacementId, + pageIndex: 0, + placement.GridWidthCells ?? 1, + placement.GridHeightCells ?? 1, + pointer, + pointerOffset, + componentLibraryBounds: null); } - - private void OnInteractionPointerReleased(object? sender, PointerReleasedEventArgs e) + + private void BeginResizeInteraction(Border host, FusedDesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e) { - if ((!_isDragging && !_isResizing) || _interactionHost is null || _interactionPlacementId is null) + if (_componentRuntimeRegistry is null || + !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) { - _isDragging = false; - _isResizing = false; return; } - - // 更新布局中的位置与尺寸 - var placement = _layout.ComponentPlacements.Find(p => p.PlacementId == _interactionPlacementId); - if (placement is not null) + + var startSpan = ComponentPlacementRules.EnsureMinimumSize( + descriptor.Definition, + placement.GridWidthCells ?? 1, + placement.GridHeightCells ?? 1); + var minSpan = ComponentPlacementRules.EnsureMinimumSize( + descriptor.Definition, + descriptor.Definition.MinWidthCells, + descriptor.Definition.MinHeightCells); + var column = placement.GridColumn ?? 0; + var row = placement.GridRow ?? 0; + var maxWidthCells = Math.Max(startSpan.WidthCells, _gridContext.Geometry.ColumnCount - column); + var maxHeightCells = Math.Max(startSpan.HeightCells, _gridContext.Geometry.RowCount - row); + + _interactionHost = host; + _interactionPlacementId = placement.PlacementId; + _interactionStartRow = row; + _interactionStartColumn = column; + _interactionStartWidthCells = startSpan.WidthCells; + _interactionStartHeightCells = startSpan.HeightCells; + _interactionMinWidthCells = Math.Max(1, Math.Min(minSpan.WidthCells, maxWidthCells)); + _interactionMinHeightCells = Math.Max(1, Math.Min(minSpan.HeightCells, maxHeightCells)); + _interactionMaxWidthCells = Math.Max(_interactionMinWidthCells, maxWidthCells); + _interactionMaxHeightCells = Math.Max(_interactionMinHeightCells, maxHeightCells); + _interactionResizeMode = descriptor.Definition.ResizeMode; + _interactionOriginalRect = DesktopPlacementMath.GetCellRect( + _gridContext.Geometry, + column, + row, + startSpan.WidthCells, + startSpan.HeightCells); + + _editSession = DesktopEditSession.CreateResizingExisting( + placement.ComponentId, + placement.PlacementId, + pageIndex: 0, + startSpan.WidthCells, + startSpan.HeightCells, + e.GetPosition(this), + componentLibraryBounds: null) with { - placement.X = Canvas.GetLeft(_interactionHost); - placement.Y = Canvas.GetTop(_interactionHost); - placement.Width = _interactionHost.Width; - placement.Height = _interactionHost.Height; + TargetRow = row, + TargetColumn = column + }; + } + + private void OnInteractionPointerMoved(object? sender, PointerEventArgs e) + { + if (!_editSession.IsActive || _interactionHost is null) + { + return; } - + + _editSession = _editSession.WithCurrentPointer(e.GetPosition(this)); + if (_editSession.IsDraggingExisting) + { + UpdateMoveInteraction(); + } + else if (_editSession.IsResizingExisting) + { + UpdateResizeInteraction(); + } + + e.Handled = true; + } + + private void UpdateMoveInteraction() + { + if (_interactionHost is null) + { + return; + } + + var hasSnap = DesktopPlacementMath.TryGetSnappedCell( + _gridContext.Geometry, + _editSession.CurrentPointerInViewport, + _editSession.PointerOffsetInViewport, + _editSession.WidthCells, + _editSession.HeightCells, + out var column, + out var row); + if (!hasSnap) + { + return; + } + + _editSession = _editSession.WithTargetCell(row, column); + var rect = DesktopPlacementMath.GetCellRect( + _gridContext.Geometry, + column, + row, + _editSession.WidthCells, + _editSession.HeightCells); + ApplyHostRect(_interactionHost, rect); UpdateInteractiveRegions(); - SaveLayout(); - - _isDragging = false; - _isResizing = false; - _interactionPlacementId = null; - _interactionHost = null; - + } + + private void UpdateResizeInteraction() + { + if (_interactionHost is null) + { + return; + } + + var deltaX = _editSession.CurrentPointerInViewport.X - _editSession.StartPointerInViewport.X; + var deltaY = _editSession.CurrentPointerInViewport.Y - _editSession.StartPointerInViewport.Y; + int widthCells; + int heightCells; + + if (_interactionResizeMode == DesktopComponentResizeMode.Free) + { + widthCells = Math.Clamp( + (int)Math.Round(_interactionStartWidthCells + deltaX / _gridContext.Geometry.Pitch), + _interactionMinWidthCells, + _interactionMaxWidthCells); + heightCells = Math.Clamp( + (int)Math.Round(_interactionStartHeightCells + deltaY / _gridContext.Geometry.Pitch), + _interactionMinHeightCells, + _interactionMaxHeightCells); + } + else + { + var widthScale = (_interactionOriginalRect.Width + deltaX) / Math.Max(1, _interactionOriginalRect.Width); + var heightScale = (_interactionOriginalRect.Height + deltaY) / Math.Max(1, _interactionOriginalRect.Height); + var proposedScale = Math.Max(widthScale, heightScale); + if (double.IsNaN(proposedScale) || double.IsInfinity(proposedScale)) + { + proposedScale = 1; + } + + var minScale = Math.Max( + (double)_interactionMinWidthCells / Math.Max(1, _interactionStartWidthCells), + (double)_interactionMinHeightCells / Math.Max(1, _interactionStartHeightCells)); + var maxScale = Math.Min( + (double)_interactionMaxWidthCells / Math.Max(1, _interactionStartWidthCells), + (double)_interactionMaxHeightCells / Math.Max(1, _interactionStartHeightCells)); + if (maxScale < minScale) + { + maxScale = minScale; + } + + var scale = Math.Clamp(proposedScale, minScale, maxScale); + widthCells = Math.Clamp( + (int)Math.Round(_interactionStartWidthCells * scale), + _interactionMinWidthCells, + _interactionMaxWidthCells); + heightCells = Math.Clamp( + (int)Math.Round(_interactionStartHeightCells * scale), + _interactionMinHeightCells, + _interactionMaxHeightCells); + } + + _editSession = _editSession with + { + WidthCells = Math.Max(1, widthCells), + HeightCells = Math.Max(1, heightCells), + TargetRow = _interactionStartRow, + TargetColumn = _interactionStartColumn + }; + + var rect = DesktopPlacementMath.GetCellRect( + _gridContext.Geometry, + _interactionStartColumn, + _interactionStartRow, + _editSession.WidthCells, + _editSession.HeightCells); + ApplyHostRect(_interactionHost, rect); + UpdateInteractiveRegions(); + } + + private void OnInteractionPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_editSession.IsActive || _interactionHost is null || _interactionPlacementId is null) + { + ResetInteraction(); + return; + } + + CompleteInteraction(); e.Pointer.Capture(null); e.Handled = true; } - - // 三指滑动处理 + + private void OnInteractionPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) + { + if (!_editSession.IsActive || _interactionHost is null) + { + return; + } + + CompleteInteraction(); + } + + private void CompleteInteraction() + { + if (_interactionPlacementId is null) + { + ResetInteraction(); + return; + } + + var placement = _layout.ComponentPlacements.Find(p => + string.Equals(p.PlacementId, _interactionPlacementId, StringComparison.OrdinalIgnoreCase)); + if (placement is not null && _editSession.HasTargetCell) + { + placement.GridRow = _editSession.TargetRow; + placement.GridColumn = _editSession.TargetColumn; + placement.GridWidthCells = Math.Max(1, _editSession.WidthCells); + placement.GridHeightCells = Math.Max(1, _editSession.HeightCells); + ApplyGridPlacementToPixelPlacement(placement); + if (_interactionHost is not null) + { + ApplyHostRect(_interactionHost, new Rect(placement.X, placement.Y, placement.Width, placement.Height)); + } + + SaveLayout(); + } + + UpdateInteractiveRegions(); + ResetInteraction(); + } + + private void ResetInteraction() + { + _editSession = default; + _interactionHost = null; + _interactionPlacementId = null; + _interactionOriginalRect = default; + _interactionStartRow = 0; + _interactionStartColumn = 0; + _interactionStartWidthCells = 0; + _interactionStartHeightCells = 0; + _interactionMinWidthCells = 0; + _interactionMinHeightCells = 0; + _interactionMaxWidthCells = 0; + _interactionMaxHeightCells = 0; + _interactionResizeMode = DesktopComponentResizeMode.Proportional; + } + + private static void ApplyHostRect(Border host, Rect rect) + { + Canvas.SetLeft(host, rect.X); + Canvas.SetTop(host, rect.Y); + host.Width = Math.Max(1, rect.Width); + host.Height = Math.Max(1, rect.Height); + if (host.Child is Grid grid && grid.Children.Count > 0 && grid.Children[0] is Control component) + { + component.Width = host.Width; + component.Height = host.Height; + } + } + protected override void OnPointerPressed(PointerPressedEventArgs e) { var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); @@ -595,26 +898,26 @@ public partial class TransparentOverlayWindow : Window base.OnPointerPressed(e); return; } - + if (!TryGetPointerPosition(e, out var pointerPos)) { base.OnPointerPressed(e); return; } - + var currentPoint = e.GetCurrentPoint(this); var pointerId = e.Pointer?.Id ?? 0; var isRightButtonPressed = currentPoint.Properties.IsRightButtonPressed; var isLeftButtonPressed = currentPoint.Properties.IsLeftButtonPressed; - + if (isLeftButtonPressed || isRightButtonPressed) { _activePointerIds.Add(pointerId); } - + var isThreeFinger = _activePointerIds.Count >= 3; var isRightDrag = isRightButtonPressed; - + if (isThreeFinger || isRightDrag) { _isSwipeActive = true; @@ -633,7 +936,7 @@ public partial class TransparentOverlayWindow : Window base.OnPointerPressed(e); } } - + protected override void OnPointerMoved(PointerEventArgs e) { if (_isSwipeActive && !IsSwipePointer(e.Pointer)) @@ -647,49 +950,49 @@ public partial class TransparentOverlayWindow : Window base.OnPointerMoved(e); return; } - + if (!TryGetPointerPosition(e, out var pointerPos)) { base.OnPointerMoved(e); return; } - + _swipeCurrentPoint = pointerPos; UpdateSwipeVelocity(pointerPos); - + var deltaX = _swipeCurrentPoint.X - _swipeStartPoint.X; var deltaY = _swipeCurrentPoint.Y - _swipeStartPoint.Y; - + if (!_isSwipeDirectionLocked) { const double activationThreshold = 14; const double horizontalBias = 1.15; var absDeltaX = Math.Abs(deltaX); var absDeltaY = Math.Abs(deltaY); - + if (absDeltaY >= activationThreshold && absDeltaY > absDeltaX * horizontalBias) { CancelSwipeInteraction(e.Pointer); base.OnPointerMoved(e); return; } - + if (absDeltaX < activationThreshold || absDeltaX <= absDeltaY * horizontalBias) { base.OnPointerMoved(e); return; } - + _isSwipeDirectionLocked = true; if (e.Pointer?.Captured != this) { e.Pointer?.Capture(this); } } - + e.Handled = true; } - + protected override void OnPointerReleased(PointerReleasedEventArgs e) { var pointerId = e.Pointer?.Id ?? 0; @@ -700,19 +1003,16 @@ public partial class TransparentOverlayWindow : Window base.OnPointerReleased(e); return; } - - if (_isSwipeActive) + + if (_isSwipeActive && EndSwipeInteraction(e.Pointer)) { - if (EndSwipeInteraction(e.Pointer)) - { - e.Handled = true; - return; - } + e.Handled = true; + return; } - + base.OnPointerReleased(e); } - + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) { var pointerId = e.Pointer?.Id ?? 0; @@ -734,10 +1034,10 @@ public partial class TransparentOverlayWindow : Window { EndSwipeInteraction(e.Pointer); } - + base.OnPointerCaptureLost(e); } - + private bool TryGetPointerPosition(PointerEventArgs e, out Point point) { try @@ -757,31 +1057,32 @@ public partial class TransparentOverlayWindow : Window return !_swipePointerId.HasValue || pointer is not null && pointer.Id == _swipePointerId.Value; } - + private void UpdateSwipeVelocity(Point currentPoint) { var now = Stopwatch.GetTimestamp(); var elapsed = Stopwatch.GetElapsedTime(_swipeLastTimestamp, now).TotalSeconds; - if (elapsed > 0) { - var dx = currentPoint.X - _swipeLastPoint.X; - _swipeVelocityX = dx / elapsed; + _swipeVelocityX = (currentPoint.X - _swipeLastPoint.X) / elapsed; } - + _swipeLastPoint = currentPoint; _swipeLastTimestamp = now; } - + private void CancelSwipeInteraction(IPointer? pointer) { - if (!_isSwipeActive) return; - + if (!_isSwipeActive) + { + return; + } + if (pointer?.Captured == this) { - pointer?.Capture(null); + pointer.Capture(null); } - + _isSwipeActive = false; _isSwipeDirectionLocked = false; _isThreeFingerOrRightDragSwipeActive = false; @@ -790,49 +1091,51 @@ public partial class TransparentOverlayWindow : Window _swipeVelocityX = 0; _swipeLastTimestamp = 0; } - + private bool EndSwipeInteraction(IPointer? pointer) { - if (!_isSwipeActive) return false; - + if (!_isSwipeActive) + { + return false; + } + var wasDirectionLocked = _isSwipeDirectionLocked; var wasThreeFingerOrRightDrag = _isThreeFingerOrRightDragSwipeActive; - + _isSwipeActive = false; _isSwipeDirectionLocked = false; _isThreeFingerOrRightDragSwipeActive = false; _activePointerIds.Clear(); _swipePointerId = null; - + if (pointer?.Captured == this) { - pointer?.Capture(null); + pointer.Capture(null); } - + _swipeLastTimestamp = 0; - + if (!wasDirectionLocked) { _swipeVelocityX = 0; return false; } - + var deltaX = _swipeCurrentPoint.X - _swipeStartPoint.X; var deltaY = _swipeCurrentPoint.Y - _swipeStartPoint.Y; var absDeltaX = Math.Abs(deltaX); - var distanceThreshold = Math.Max(48, this.Bounds.Width * 0.14); - var velocityThreshold = Math.Max(860, this.Bounds.Width * 1.08); + var distanceThreshold = Math.Max(48, Bounds.Width * 0.14); + var velocityThreshold = Math.Max(860, Bounds.Width * 1.08); var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > Math.Abs(deltaY) * 1.05; var hasVelocityIntent = Math.Abs(_swipeVelocityX) >= velocityThreshold; - - // 向左滑动回到第一页 + if (wasThreeFingerOrRightDrag && deltaX < 0 && (hasDistanceIntent || hasVelocityIntent)) { RestoreMainWindowRequested?.Invoke(this, EventArgs.Empty); _swipeVelocityX = 0; return true; } - + _swipeVelocityX = 0; return hasDistanceIntent || hasVelocityIntent; }