mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
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:
@@ -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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
106
.github/workflows/ddss-publish.yml
vendored
106
.github/workflows/ddss-publish.yml
vendored
@@ -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
146
.github/workflows/ddss-rollback.yml
vendored
Normal 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"
|
||||
5
.github/workflows/plonds-build.yml
vendored
5
.github/workflows/plonds-build.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
11
LanMountainDesktop.Shared.Contracts/Update/DeploymentLock.cs
Normal file
11
LanMountainDesktop.Shared.Contracts/Update/DeploymentLock.cs
Normal 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);
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,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(用于控制组件层叠顺序)
|
||||
/// </summary>
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -847,7 +847,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
snapshot,
|
||||
changedKeys:
|
||||
[
|
||||
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
|
||||
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
|
||||
nameof(AppSettingsSnapshot.UpdateChannel),
|
||||
nameof(AppSettingsSnapshot.UpdateMode),
|
||||
|
||||
52
LanMountainDesktop/Services/Update/DeploymentLockService.cs
Normal file
52
LanMountainDesktop/Services/Update/DeploymentLockService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,30 +13,13 @@ 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;
|
||||
@@ -57,9 +36,6 @@ public static class WindowBottomMostServiceFactory
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 区域级穿透服务工厂
|
||||
/// </summary>
|
||||
public static class RegionPassthroughServiceFactory
|
||||
{
|
||||
private static IRegionPassthroughService? _instance;
|
||||
@@ -76,103 +52,83 @@ 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;
|
||||
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) =>
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -180,143 +136,212 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
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();
|
||||
}
|
||||
|
||||
private static void SetDesktopChildStyles(IntPtr handle)
|
||||
{
|
||||
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 TryAttachToDesktopIconHost(IntPtr handle, out IntPtr desktopHost)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题4】获取窗口类名
|
||||
/// </summary>
|
||||
private static string GetWindowClassName(IntPtr hWnd)
|
||||
private static void MonitorDesktopHostAttachments()
|
||||
{
|
||||
var buffer = new char[256];
|
||||
var length = GetClassName(hWnd, buffer, buffer.Length);
|
||||
return length > 0 ? new string(buffer, 0, length) : string.Empty;
|
||||
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 bool EnumWindowsCallback(IntPtr handle, ArrayList handles)
|
||||
private static void StopDesktopHostMonitorTimerIfIdle()
|
||||
{
|
||||
handles.Add(handle);
|
||||
return true;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -325,47 +350,8 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
|
||||
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);
|
||||
|
||||
@@ -373,38 +359,27 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -417,23 +392,6 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
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))
|
||||
@@ -442,45 +400,55 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
@@ -490,14 +458,21 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
[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);
|
||||
@@ -505,24 +480,13 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
[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;
|
||||
@@ -546,14 +510,17 @@ internal sealed class WindowsRegionPassthroughService : IRegionPassthroughServic
|
||||
|
||||
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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -53,54 +58,16 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算组件的像素尺寸
|
||||
var (componentWidth, componentHeight) = ResolveComponentSize(componentId);
|
||||
_overlayWindow.AddComponentToCenter(componentId);
|
||||
AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' at fused desktop grid center.");
|
||||
|
||||
// 取覆盖层画布的中心点,减去组件半尺寸,使组件出现在屏幕正中央
|
||||
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}).");
|
||||
|
||||
// 关闭窗口
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -112,9 +79,21 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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="󰛈" 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="󰿔" 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="󰺟" 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="󰛼" 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="󰆻" 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="󰛀" 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="󰔄"
|
||||
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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -7,16 +7,55 @@
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
Background="Transparent"
|
||||
Title="LanMountainDesktop Fused Desktop">
|
||||
<!--
|
||||
融合桌面(负一屏)- 在系统桌面上显示组件
|
||||
<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>
|
||||
|
||||
特性:
|
||||
- 窗口置底(在桌面图标层显示)
|
||||
- 区域级穿透(组件区域可交互,其他区域穿透)
|
||||
- 组件可自由拖拽摆放
|
||||
- 三指/右键左滑回到阗山桌面第一页
|
||||
-->
|
||||
<Canvas x:Name="ComponentCanvas">
|
||||
<!-- 组件将动态添加到这里 -->
|
||||
</Canvas>
|
||||
<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
Reference in New Issue
Block a user