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.
This commit is contained in:
lincube
2026-05-12 08:35:48 +08:00
parent f0319b7deb
commit 563f12caa1
33 changed files with 3231 additions and 4199 deletions

View File

@@ -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\")"
]
}
}

View File

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

146
.github/workflows/ddss-rollback.yml vendored Normal file
View File

@@ -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" <<EOF
{
"schemaVersion": 1,
"channel": "${RELEASE_CHANNEL}",
"releaseTag": "${RELEASE_TAG}",
"version": "${version}",
"updatedAt": "${updated_at}",
"manifest": {
"url": "${manifest_url}",
"signatureUrl": "${sig_url}"
}
}
EOF
jq -e . "$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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,14 @@ public sealed class FusedDesktopComponentPlacementSnapshot
/// 高度(像素)
/// </summary>
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; }
/// <summary>
/// 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
};

View File

@@ -847,7 +847,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot,
changedKeys:
[
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.UpdateChannel),
nameof(AppSettingsSnapshot.UpdateMode),

View File

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

View File

@@ -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<string>(StringComparer.OrdinalIgnoreCase);
var seenHashes = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
var semaphore = new SemaphoreSlim(Math.Max(1, maxConcurrency), Math.Max(1, maxConcurrency));
var errors = new List<string>();
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();

View File

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

View File

@@ -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<UpdatePhase>? PhaseChanged;
public event Action<UpdateProgressReport>? 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<UpdateCheckReport> 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<DownloadResult> 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<InstallResult> 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<DownloadResult> 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<DownloadResult> 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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
/// <summary>
/// 窗口置底服务接口
/// </summary>
public interface IWindowBottomMostService
{
void SetupBottomMost(Window window);
@@ -17,35 +13,18 @@ public interface IWindowBottomMostService
bool IsBottomMostSupported { get; }
}
/// <summary>
/// 区域级穿透服务接口 - 使用 WM_NCHITTEST 实现
/// </summary>
public interface IRegionPassthroughService
{
/// <summary>
/// 设置窗口的可交互区域
/// </summary>
void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions);
/// <summary>
/// 清除所有可交互区域
/// </summary>
void ClearInteractiveRegions(Window window);
/// <summary>
/// 获取当前平台是否支持区域级穿透
/// </summary>
bool IsRegionPassthroughSupported { get; }
}
/// <summary>
/// 窗口置底服务工厂
/// </summary>
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
}
}
/// <summary>
/// 区域级穿透服务工厂
/// </summary>
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
}
}
/// <summary>
/// Windows 平台窗口置底服务
/// </summary>
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<IntPtr, bool> _bottomMostWindows = new();
private static readonly object _staticLock = new();
private static readonly object _timerLock = new();
private static readonly Dictionary<IntPtr, DesktopWindowState> _desktopWindows = new();
private static readonly Dictionary<IntPtr, IntPtr> _originalWndProcs = new();
private static readonly Dictionary<IntPtr, List<Rect>> _interactiveRegions = new();
// 记录每个窗口的屏幕原点(窗口左上角的屏幕坐标),用于将 WM_NCHITTEST 屏幕坐标转成窗口相对坐标
private static readonly Dictionary<IntPtr, Point> _windowScreenOrigins = new();
private static readonly object _staticLock = new();
// 【修复问题1】静态持有委托引用防止 GC 回收导致 CallbackOnCollectedDelegate 崩溃
private static WndProcDelegate? _wndProcDelegate;
// 【修复问题2】记录每个窗口的 DPI 缩放比例
private static readonly Dictionary<IntPtr, double> _windowDpiScales = new();
// 【修复问题5】Z 轴竞争优化 - 记录上次置底时间,避免频繁操作
private static readonly Dictionary<IntPtr, long> _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);
}
/// <summary>
/// 【新增】启动定时器定期强制置底所有窗口
/// </summary>
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);
}
}
/// <summary>
/// 【新增】停止定时器
/// </summary>
private static void StopKeepBottomTimer()
internal static void SetInteractiveRegionsInternal(IntPtr handle, List<Rect> 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();
}
/// <summary>
/// 【修复问题4】获取窗口类名
/// </summary>
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<IntPtr>();
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<IntPtr> 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);
}
/// <summary>
/// 设置窗口的可交互区域(供 WindowsRegionPassthroughService 调用)
/// </summary>
internal static void SetInteractiveRegionsInternal(IntPtr handle, List<Rect> regions)
{
lock (_staticLock)
{
_interactiveRegions[handle] = regions;
// 同步刷新屏幕原点DPI 缩放可能影响坐标,每次更新区域时一并刷新)
UpdateWindowScreenOrigin(handle);
UpdateWindowDpiScale(handle); // 【修复问题2】同步更新 DPI 缩放
}
}
/// <summary>
/// 更新指定窗口的屏幕左上角坐标缓存(用于将 WM_NCHITTEST 屏幕坐标转为窗口相对坐标)
/// </summary>
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);
}
}
/// <summary>
/// 【修复问题2】更新指定窗口的 DPI 缩放比例
/// </summary>
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);
}
/// <summary>
/// Windows 平台区域级穿透服务 - 使用 WM_NCHITTEST
/// </summary>
internal sealed class WindowsRegionPassthroughService : IRegionPassthroughService
{
public bool IsRegionPassthroughSupported => true;
public void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions)
{
var handle = GetWindowHandle(window);
if (handle == IntPtr.Zero) return;
WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, new List<Rect>(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;
}
}
}
/// <summary>
/// 空实现
/// </summary>
internal sealed class NullWindowBottomMostService : IWindowBottomMostService
{
public bool IsBottomMostSupported => false;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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;
/// <summary>
/// 表示一个独立的组件挂载窗口。它不含有任何自己的边窗,仅仅负责包裹组件并将自身植入系统最底层。
/// </summary>
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<Rect>
{
new(0, 0, Bounds.Width, Bounds.Height)

View File

@@ -8,16 +8,23 @@
<UserControl.Styles>
<Style Selector="ListBoxItem.category-item">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0,2"/>
<Setter Property="Margin" Value="0,3"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}"/>
<Setter Property="MinHeight" Value="44"/>
</Style>
<Style Selector="ListBoxItem.category-item:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}"/>
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonHoverBackgroundBrush}"/>
</Style>
<Style Selector="ListBoxItem.category-item:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}"/>
</Style>
<Style Selector="ListBoxItem.category-item Border.category-selection-indicator">
<Setter Property="Opacity" Value="0"/>
</Style>
<Style Selector="ListBoxItem.category-item:selected Border.category-selection-indicator">
<Setter Property="Opacity" Value="1"/>
</Style>
<Style Selector="ListBoxItem.category-item TextBlock.category-text">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
</Style>
@@ -25,27 +32,65 @@
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style Selector="Button.fused-library-link">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="6,4"/>
<Setter Property="MinHeight" Value="28"/>
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextSecondaryBrush}"/>
</Style>
<Style Selector="Button.fused-library-link:pointerover">
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonHoverBackgroundBrush}"/>
</Style>
<Style Selector="Button.fused-library-add-button">
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}"/>
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}"/>
<Setter Property="MinHeight" Value="38"/>
<Setter Property="Padding" Value="22,8"/>
</Style>
<Style Selector="Button.fused-library-add-button:pointerover">
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentLightBrush}"/>
</Style>
<Style Selector="Button.fused-library-add-button TextBlock">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}"/>
</Style>
<Style Selector="Button.fused-library-add-button fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}"/>
</Style>
</UserControl.Styles>
<Grid ColumnDefinitions="Auto,*">
<Border Width="280" Background="Transparent">
<Grid ColumnDefinitions="190,*">
<Border Background="Transparent">
<Grid RowDefinitions="*,Auto">
<ListBox x:Name="CategoryListBox"
Grid.Row="0"
Background="Transparent"
BorderThickness="0"
Margin="8,8,4,0"
Margin="0,0,14,0"
SelectionChanged="OnCategorySelectionChanged"
ItemsSource="{Binding Categories}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12" Margin="12,10">
<Grid ColumnDefinitions="Auto,Auto,*"
ColumnSpacing="10"
Margin="0,2,8,2">
<Border Classes="category-selection-indicator"
Width="3"
Height="22"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}"
Background="{DynamicResource AdaptiveAccentBrush}"
VerticalAlignment="Center"/>
<fi:FluentIcon Icon="{Binding Icon}"
Grid.Column="1"
IconVariant="Regular"
FontSize="18"/>
<TextBlock Grid.Column="1"
FontSize="16"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="2"
VerticalAlignment="Center"
FontSize="14"
TextTrimming="CharacterEllipsis"
Classes="category-text"
Text="{Binding Title}"/>
</Grid>
@@ -53,17 +98,17 @@
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="1" Margin="12,8,8,12">
<StackPanel Grid.Row="1" Margin="0,8,14,4">
<Border Height="1"
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Opacity="0.4"
Margin="0,0,0,8"/>
<Button Classes="hyperlink"
<Button Classes="fused-library-link"
HorizontalAlignment="Left"
Click="OnFindMoreComponentsClick">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:FluentIcon Icon="Globe" IconVariant="Regular" FontSize="14"/>
<TextBlock Text="Find More Components"/>
<TextBlock Text="查找更多组件" FontSize="12"/>
</StackPanel>
</Button>
</StackPanel>
@@ -74,55 +119,73 @@
Width="1"
HorizontalAlignment="Left"
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Opacity="0.5"/>
Opacity="0.35"/>
<ScrollViewer Grid.Column="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,8,12,8">
<StackPanel Margin="28,8,8,10">
<Panel IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}">
<Border Classes="surface-translucent-panel"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="20">
<StackPanel Spacing="16">
<TextBlock FontSize="28"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding SelectedComponent.DisplayName}"/>
<Grid RowDefinitions="Auto,Auto,*,Auto"
MinHeight="330">
<TextBlock FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding SelectedComponent.DisplayName}"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Row="1"
Margin="0,6,0,14"
MaxHeight="44"
FontSize="13"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Opacity="0.82"
Text="{Binding SelectedComponent.Description}"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"/>
<Border Grid.Row="2"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
Width="390"
Height="230"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
Width="420"
Height="300"
HorizontalAlignment="Center">
Padding="12"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<ContentControl x:Name="SelectedComponentPreviewHost"
Margin="16"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"
Focusable="False"/>
</Border>
</Border>
<Button HorizontalAlignment="Center"
Classes="accent"
Padding="24,10"
Tag="{Binding SelectedComponent.ComponentId}"
Click="OnAddComponentClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="Add" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Add Component" FontWeight="SemiBold"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
<Button Grid.Row="3"
HorizontalAlignment="Center"
Margin="0,18,0,0"
Classes="fused-library-add-button"
Tag="{Binding SelectedComponent.ComponentId}"
Click="OnAddComponentClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="Add" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="添加" FontWeight="SemiBold"/>
</StackPanel>
</Button>
</Grid>
</Panel>
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MinHeight="400">
MinHeight="330">
<StackPanel Spacing="16"
HorizontalAlignment="Center"
VerticalAlignment="Center">
@@ -134,7 +197,7 @@
<TextBlock HorizontalAlignment="Center"
FontSize="16"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="Select a component to view its details."/>
Text="选择一个分类以查看可添加组件。"/>
</StackPanel>
</Grid>
</StackPanel>

View File

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

View File

@@ -1,72 +1,62 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:LanMountainDesktop.Views"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
Width="860"
Height="620"
Width="740"
Height="500"
MinWidth="600"
MinHeight="500"
MinHeight="440"
CanResize="True"
WindowStartupLocation="CenterScreen"
WindowDecorations="BorderOnly"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="48"
Background="Transparent"
Title="Add Component">
<Grid x:Name="RootGrid"
Classes="settings-scope"
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
RowDefinitions="Auto,*">
<Border x:Name="WindowTitleBarHost"
Height="48"
Padding="12,0,12,0"
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
BorderBrush="{DynamicResource AdaptiveSettingsWindowBorderBrush}"
BorderThickness="0,0,0,1"
PointerPressed="OnWindowTitleBarPointerPressed">
<Grid ColumnDefinitions="Auto,Auto,*,Auto"
ColumnSpacing="8"
VerticalAlignment="Center">
<fi:FluentIcon x:Name="WindowBrandIcon"
Icon="Apps"
IconVariant="Filled"
FontSize="16"
IsHitTestVisible="False"
VerticalAlignment="Center" />
<TextBlock x:Name="WindowTitleTextBlock"
Grid.Column="1"
FontSize="12"
FontWeight="SemiBold"
IsHitTestVisible="False"
Text="Add Component" />
<TextBlock Grid.Column="2"
FontSize="12"
Opacity="0.6"
IsHitTestVisible="False"
VerticalAlignment="Center"
Text="Browse available widgets and add them to the current fused desktop layout." />
<Button x:Name="CloseWindowButton"
Grid.Column="3"
Width="40"
Height="32"
Padding="0"
Background="Transparent">
<Border x:Name="PanelShell"
Classes="surface-translucent-strong"
Width="720"
MaxWidth="720"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Padding="0"
ClipToBounds="True">
<Grid RowDefinitions="Auto,*,Auto">
<Border Height="64"
Padding="24,0,24,0"
Background="Transparent"
BorderThickness="0"
Click="OnCloseClick">
<fi:FluentIcon Icon="Dismiss"
IconVariant="Regular"
FontSize="16" />
</Button>
PointerPressed="OnWindowTitleBarPointerPressed">
<TextBlock VerticalAlignment="Center"
FontSize="22"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="添加小组件" />
</Border>
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
Grid.Row="1"
Margin="22,0,22,8" />
<Border Grid.Row="2"
Padding="24,16,24,22"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="0,1,0,0">
<Button x:Name="CloseWindowButton"
HorizontalAlignment="Stretch"
MinHeight="32"
Padding="16,7"
Background="{DynamicResource AdaptiveButtonBackgroundBrush}"
BorderThickness="0"
Click="OnCloseClick">
<TextBlock HorizontalAlignment="Center"
FontSize="14"
Text="关闭" />
</Button>
</Border>
</Grid>
</Border>
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
Grid.Row="1"
Margin="12,8,16,8" />
</Grid>
</Window>

View File

@@ -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;
/// <summary>
/// 融合桌面组件库窗口 - 专门用于添加组件到系统桌面(负一屏)
///
/// 注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面
/// </summary>
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);
}
/// <summary>
/// 设置透明覆盖层窗口引用
/// </summary>
public bool PreserveEditModeOnClose { get; private set; }
public void SetOverlayWindow(TransparentOverlayWindow overlayWindow)
{
_overlayWindow = overlayWindow;
}
/// <summary>
/// 添加组件请求处理 - 将组件放置在屏幕(覆盖层画布)中央
/// </summary>
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();
}
/// <summary>
/// 解析组件的默认像素尺寸(基于组件定义的 MinCells * DefaultCellSize
/// </summary>
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);
}

View File

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

View File

@@ -1,285 +1,305 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:controls="using:LanMountainDesktop.Controls"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Views.SettingsPages.UpdateSettingsPage"
x:DataType="vm:UpdateSettingsPageViewModel">
<UserControl.Styles>
<Style Selector="Border.update-status-card">
<Setter Property="Padding" Value="24" />
<Setter Property="Margin" Value="0,0,0,18" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BoxShadow" Value="0 6 18 #15000000" />
</Style>
x:DataType="vm:UpdateSettingsViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated">
<StackPanel Spacing="6">
<TextBlock Classes="settings-section-title"
Text="Update" />
<TextBlock Classes="settings-section-description"
Text="Check for updates, watch download and install progress, and keep the update workflow recoverable from this page." />
</StackPanel>
<Style Selector="TextBlock.update-kv-label">
<Setter Property="FontSize" Value="12" />
<Setter Property="Opacity" Value="0.68" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="MaxWidth" Value="200" />
</Style>
<Border Classes="settings-section-card">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="16">
<Border Classes="settings-section-card-icon-host"
Width="56"
Height="56"
Padding="10">
<fi:SymbolIcon Symbol="ArrowSync"
FontSize="22" />
</Border>
<Style Selector="TextBlock.update-kv-value">
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="MaxWidth" Value="200" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<StackPanel Grid.Column="1"
Spacing="6"
VerticalAlignment="Center">
<TextBlock Classes="settings-card-header"
Text="Current update status" />
<TextBlock Classes="settings-card-description"
Text="The status line below reflects the current update phase and any contextual message returned by the orchestrator." />
<TextBlock Text="{Binding StatusMessage}"
TextWrapping="Wrap"
FontSize="22"
FontWeight="SemiBold"
Margin="0,4,0,0" />
<StackPanel Orientation="Horizontal"
Spacing="8"
Margin="0,4,0,0">
<Border Background="#223D5979"
BorderBrush="#326D8FB7"
BorderThickness="1"
CornerRadius="999"
Padding="10,4">
<TextBlock Text="{Binding PhaseText}"
FontSize="12"
FontWeight="SemiBold" />
</Border>
<Border Background="#223D5979"
BorderBrush="#326D8FB7"
BorderThickness="1"
CornerRadius="999"
Padding="10,4"
IsVisible="{Binding IsUpdateAvailable}">
<TextBlock Text="Update available"
FontSize="12"
FontWeight="SemiBold" />
</Border>
<Border Background="#223D5979"
BorderBrush="#326D8FB7"
BorderThickness="1"
CornerRadius="999"
Padding="10,4"
IsVisible="{Binding IsPaused}">
<TextBlock Text="Paused"
FontSize="12"
FontWeight="SemiBold" />
</Border>
</StackPanel>
</StackPanel>
<Style Selector="TextBlock.update-phase-text">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
</UserControl.Styles>
<StackPanel Grid.Column="2"
Spacing="10"
VerticalAlignment="Center">
<Button Classes="settings-accent-button"
Content="Check for updates"
Command="{Binding CheckCommand}" />
<TextBlock Classes="settings-item-description"
Text="{Binding LastCheckedText}"
TextAlignment="Right"
TextWrapping="Wrap"
Width="220" />
</StackPanel>
</Grid>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated">
<Border Classes="update-status-card">
<StackPanel Spacing="18">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="16">
<Border Classes="settings-section-card-icon-host"
Width="48"
Height="48">
<Viewbox Stretch="Uniform">
<fi:SymbolIcon Symbol="ArrowSync" />
</Viewbox>
</Border>
<Border Classes="settings-section-card">
<StackPanel Spacing="12">
<TextBlock Classes="settings-card-header"
Text="Release facts" />
<TextBlock Classes="settings-card-description"
Text="Keep the current version, published release, and update type visible without collapsing the layout while states change." />
<StackPanel Grid.Column="1"
Spacing="4">
<TextBlock Classes="settings-card-header"
Margin="0"
Text="{Binding StatusCardTitle}" />
<TextBlock Classes="settings-item-description"
Text="{Binding StatusCardDescription}" />
</StackPanel>
<Button Grid.Column="2"
Classes="settings-accent-button"
Command="{Binding CheckForUpdatesCommand}"
Content="{Binding CheckForUpdatesButtonText}" />
</Grid>
<Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto,Auto,Auto"
ColumnSpacing="20"
RowSpacing="16">
<StackPanel Grid.Row="0"
Grid.Column="0"
Spacing="4">
<TextBlock Classes="update-kv-label"
Text="{Binding CurrentVersionLabel}" />
<TextBlock Classes="update-kv-value"
Text="{Binding CurrentVersionText}" />
</StackPanel>
<StackPanel Grid.Row="0"
Grid.Column="1"
Spacing="4"
IsVisible="{Binding IsLatestVersionVisible}">
<TextBlock Classes="update-kv-label"
Text="{Binding LatestVersionLabel}" />
<TextBlock Classes="update-kv-value"
Text="{Binding LatestVersionText}" />
</StackPanel>
<StackPanel Grid.Row="1"
Grid.Column="0"
Spacing="4"
IsVisible="{Binding IsPublishedAtVisible}">
<TextBlock Classes="update-kv-label"
Text="{Binding PublishedAtLabel}" />
<TextBlock Classes="update-kv-value"
Text="{Binding PublishedAtText}" />
</StackPanel>
<StackPanel Grid.Row="1"
Grid.Column="1"
Spacing="4"
IsVisible="{Binding IsLastCheckedVisible}">
<TextBlock Classes="update-kv-label"
Text="{Binding LastCheckedLabel}" />
<TextBlock Classes="update-kv-value"
Text="{Binding LastCheckedText}" />
</StackPanel>
<StackPanel Grid.Row="2"
Grid.Column="0"
Spacing="4"
IsVisible="{Binding HasPendingInstaller}">
<TextBlock Classes="update-kv-label"
Text="{Binding UpdateTypeLabel}" />
<TextBlock Classes="update-kv-value"
Text="{Binding PendingUpdateTypeText}" />
</StackPanel>
<StackPanel Grid.Row="2"
Grid.Column="1"
Spacing="4"
IsVisible="{Binding IsUpdateTypeVisible}">
<TextBlock Classes="update-kv-label"
Text="{Binding UpdateTypeLabel}" />
<TextBlock Classes="update-kv-value"
Text="{Binding UpdateTypeText}" />
</StackPanel>
</Grid>
<StackPanel Spacing="8"
HorizontalAlignment="Left">
<TextBlock Classes="settings-item-description"
Text="{Binding UpdateStatus}"
TextWrapping="Wrap"
HorizontalAlignment="Left"
MaxWidth="500" />
<TextBlock Classes="update-phase-text"
IsVisible="{Binding IsDownloadProgressVisible}"
Text="{Binding UpdatePhaseText}"
TextWrapping="Wrap"
HorizontalAlignment="Left" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding PhaseProgressValue}"
IsVisible="{Binding IsDownloadProgressVisible}"
HorizontalAlignment="Stretch"
Margin="0,4,0,4"
ShowProgressText="True" />
<TextBlock Classes="settings-item-description"
IsVisible="{Binding IsDownloadProgressVisible}"
Text="{Binding DownloadProgressText}"
TextWrapping="Wrap"
HorizontalAlignment="Left"
Margin="0,4,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal"
Spacing="10">
<Button Command="{Binding DownloadLatestReleaseCommand}"
IsVisible="{Binding IsDownloadButtonVisible}">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="ArrowDownload" FontSize="14" />
<TextBlock Text="{Binding DownloadButtonText}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Command="{Binding RedownloadUpdateCommand}"
IsVisible="{Binding IsRedownloadButtonVisible}">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="ArrowUndo" FontSize="14" />
<TextBlock Text="{Binding RedownloadButtonText}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Classes="settings-accent-button"
Command="{Binding InstallPendingUpdateCommand}"
IsVisible="{Binding IsInstallButtonVisible}">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="Play" FontSize="14" />
<TextBlock Text="{Binding InstallNowButtonText}" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
<Grid RowDefinitions="Auto,Auto,Auto"
ColumnDefinitions="*,*"
ColumnSpacing="12"
RowSpacing="12">
<Border Classes="settings-list-item">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="Current version" />
<TextBlock Classes="settings-item-description"
Text="{Binding CurrentVersionText}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<TextBlock Classes="settings-subsection-title"
Text="{Binding PreferencesHeader}" />
<TextBlock Classes="settings-section-description"
Margin="0,0,0,18"
Text="{Binding PreferencesDescription}" />
<Border Grid.Column="1"
Classes="settings-list-item">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="Latest version" />
<TextBlock Classes="settings-item-description"
Text="{Binding LatestVersionText}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="{Binding UpdateChannelLabel}"
Description="{Binding SelectedUpdateChannelDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF06C8;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ComboBox Width="220"
ItemsSource="{Binding UpdateChannelOptions}"
SelectedItem="{Binding SelectedUpdateChannelOption}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpander.Footer>
<ui:FASettingsExpanderItem Content="{Binding ForceCheckUpdateLabel}"
Description="{Binding ForceCheckUpdateDescription}"
IsClickEnabled="True"
Command="{Binding ForceCheckUpdateCommand}">
<ui:FASettingsExpanderItem.IconSource>
<ui:FAFontIconSource Glyph="&#xF0FD4;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpanderItem.IconSource>
</ui:FASettingsExpanderItem>
<ui:FASettingsExpanderItem Content="{Binding ForceFullUpdateLabel}"
Description="{Binding ForceFullUpdateDescription}"
IsClickEnabled="True"
Command="{Binding ForceFullUpdateCommand}">
<ui:FASettingsExpanderItem.IconSource>
<ui:FAFontIconSource Glyph="&#xF0E9F;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpanderItem.IconSource>
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="{Binding UpdateModeLabel}"
Description="{Binding SelectedUpdateModeDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF06FC;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ComboBox Width="260"
ItemsSource="{Binding UpdateModeOptions}"
SelectedItem="{Binding SelectedUpdateModeOption}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="{Binding NetworkAccelerationLabel}"
Description="{Binding NetworkAccelerationDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF01BB;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding UseGhProxyMirror}" />
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="{Binding DownloadThreadsLabel}"
Description="{Binding DownloadThreadsDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF06C0;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ui:FANumberBox Width="160"
Minimum="1"
Maximum="128"
SpinButtonPlacementMode="Inline"
Value="{Binding DownloadThreadsSliderValue}" />
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<Border Grid.Row="1"
Classes="settings-list-item">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="Published at" />
<TextBlock Classes="settings-item-description"
Text="{Binding PublishedAtText}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Grid.Row="1"
Grid.Column="1"
Classes="settings-list-item">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="Last checked" />
<TextBlock Classes="settings-item-description"
Text="{Binding LastCheckedText}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Grid.Row="2"
Grid.ColumnSpan="2"
Classes="settings-list-item">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="Update type" />
<TextBlock Classes="settings-item-description"
Text="{Binding UpdateTypeText}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</Grid>
</StackPanel>
</ScrollViewer>
</Border>
<Border Classes="settings-section-card">
<StackPanel Spacing="12">
<TextBlock Classes="settings-card-header"
Text="Progress" />
<TextBlock Classes="settings-card-description"
Text="Watch download, installation, verification, and recovery progress here." />
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="12">
<TextBlock Classes="settings-item-label"
Text="{Binding PhaseText}" />
<TextBlock Classes="settings-item-description"
Text="{Binding ProgressFraction, StringFormat='{}{0:P0}'}"
HorizontalAlignment="Right" />
</Grid>
<ProgressBar Minimum="0"
Maximum="1"
Value="{Binding ProgressFraction}"
Height="12"
IsVisible="{Binding IsProgressVisible}" />
<TextBlock Classes="settings-item-description"
Text="{Binding ProgressDetail}"
TextWrapping="Wrap" />
<Border Background="#223D5979"
BorderBrush="#326D8FB7"
BorderThickness="1"
CornerRadius="10"
Padding="12"
IsVisible="{Binding IsPaused}">
<TextBlock Classes="settings-item-description"
Text="Paused. Resume to continue from the current state." />
</Border>
</StackPanel>
</StackPanel>
</Border>
<Border Classes="settings-section-card">
<StackPanel Spacing="12">
<TextBlock Classes="settings-card-header"
Text="Actions" />
<TextBlock Classes="settings-card-description"
Text="The buttons below stay in place while the update phase changes, so the page does not jump around." />
<Grid ColumnDefinitions="*,*,*"
RowDefinitions="Auto,Auto,Auto"
ColumnSpacing="12"
RowSpacing="10">
<Button Classes="settings-accent-button"
Content="Check"
Command="{Binding CheckCommand}" />
<Button Grid.Column="1"
Content="Download"
Command="{Binding DownloadCommand}" />
<Button Grid.Column="2"
Content="Install"
Command="{Binding InstallCommand}" />
<Button Grid.Row="1"
Content="Pause"
Command="{Binding PauseCommand}" />
<Button Grid.Row="1"
Grid.Column="1"
Content="Resume"
Command="{Binding ResumeCommand}" />
<Button Grid.Row="1"
Grid.Column="2"
Content="Rollback"
Command="{Binding RollbackCommand}" />
<Button Grid.Row="2"
Grid.ColumnSpan="3"
Content="Cancel"
Command="{Binding CancelCommand}" />
</Grid>
</StackPanel>
</Border>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="Update preferences"
Description="Choose the update channel, download source, mode, and thread count without leaving this page.">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF0504;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpanderItem>
<StackPanel Spacing="12">
<Grid ColumnDefinitions="180,*"
RowDefinitions="Auto,Auto,Auto,Auto"
RowSpacing="10"
ColumnSpacing="12">
<TextBlock Grid.Row="0"
Classes="settings-item-label"
Text="Channel"
VerticalAlignment="Center" />
<TextBox Grid.Row="0"
Grid.Column="1"
Text="{Binding SelectedUpdateChannelValue}"
Watermark="stable / preview" />
<TextBlock Grid.Row="1"
Classes="settings-item-label"
Text="Source"
VerticalAlignment="Center" />
<TextBox Grid.Row="1"
Grid.Column="1"
Text="{Binding SelectedUpdateSourceValue}"
Watermark="plonds / github / proxy" />
<TextBlock Grid.Row="2"
Classes="settings-item-label"
Text="Mode"
VerticalAlignment="Center" />
<TextBox Grid.Row="2"
Grid.Column="1"
Text="{Binding SelectedUpdateModeValue}"
Watermark="manual / confirm / silent" />
<TextBlock Grid.Row="3"
Classes="settings-item-label"
Text="Download threads"
VerticalAlignment="Center" />
<StackPanel Grid.Row="3"
Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<Slider Width="220"
Minimum="1"
Maximum="16"
Value="{Binding DownloadThreadsSliderValue}"
TickFrequency="1"
IsSnapToTickEnabled="True" />
<TextBlock Classes="settings-item-label"
Text="{Binding DownloadThreadsSliderValue, StringFormat='{}{0:F0}'}"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
</StackPanel>
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -1,5 +1,6 @@
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Services.Update;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages;
@@ -15,16 +16,18 @@ namespace LanMountainDesktop.Views.SettingsPages;
public partial class UpdateSettingsPage : SettingsPageBase
{
public UpdateSettingsPage()
: this(new UpdateSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
: this(new UpdateSettingsViewModel(
HostUpdateOrchestratorProvider.GetOrCreate(),
HostSettingsFacadeProvider.GetOrCreate()))
{
}
public UpdateSettingsPage(UpdateSettingsPageViewModel viewModel)
public UpdateSettingsPage(UpdateSettingsViewModel viewModel)
{
ViewModel = viewModel;
DataContext = ViewModel;
InitializeComponent();
}
public UpdateSettingsPageViewModel ViewModel { get; }
public UpdateSettingsViewModel ViewModel { get; }
}

View File

@@ -7,16 +7,55 @@
ExtendClientAreaToDecorationsHint="True"
Background="Transparent"
Title="LanMountainDesktop Fused Desktop">
<!--
融合桌面(负一屏)- 在系统桌面上显示组件
特性:
- 窗口置底(在桌面图标层显示)
- 区域级穿透(组件区域可交互,其他区域穿透)
- 组件可自由拖拽摆放
- 三指/右键左滑回到阗山桌面第一页
-->
<Canvas x:Name="ComponentCanvas">
<!-- 组件将动态添加到这里 -->
</Canvas>
<Window.Styles>
<Style Selector="Border.fused-desktop-component-host">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusComponent}" />
</Style>
<Style Selector="Border.fused-desktop-component-host.selected">
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
<Style Selector="Border.fused-desktop-resize-handle">
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
</Style>
<Style Selector="Border.fused-desktop-edit-toolbar">
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusIsland}" />
</Style>
</Window.Styles>
<Grid x:Name="OverlayRoot">
<Canvas x:Name="ComponentCanvas"
Background="#01000000"
PointerPressed="OnCanvasPointerPressed" />
<Border x:Name="EditToolbar"
Classes="fused-desktop-edit-toolbar"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="0,0,0,20"
Padding="8"
IsHitTestVisible="True">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button MinWidth="112"
Padding="16,8"
Click="OnRestoreComponentLibraryClick">
<TextBlock Text="找回组件库" />
</Button>
<Button MinWidth="96"
Padding="16,8"
Click="OnExitEditClick">
<TextBlock Text="退出编辑" />
</Button>
</StackPanel>
</Border>
</Grid>
</Window>

File diff suppressed because it is too large Load Diff