mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00: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": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(ls -la \"/d/github/LanMountainDesktop/.claude/worktrees/agent-a4c5412322421ab67\" && ls -la \"/d/github/LanMountainDesktop\" && ls -la \"/d/github\")",
|
"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
|
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:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows:
|
workflows:
|
||||||
@@ -31,7 +35,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Resolve release tag
|
- name: Resolve release tag and channel
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -50,6 +54,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
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 }}"
|
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
||||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
if [[ -z "$PUBLIC_BASE" ]]; then
|
||||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
||||||
@@ -213,6 +225,33 @@ jobs:
|
|||||||
--repository "${{ github.repository }}" \
|
--repository "${{ github.repository }}" \
|
||||||
--s3-base-url "$S3_BASE_URL"
|
--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
|
- name: Upload DDSS manifest to release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -221,7 +260,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber
|
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:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||||
@@ -243,6 +282,69 @@ jobs:
|
|||||||
--metadata "sha256=$sha256"
|
--metadata "sha256=$sha256"
|
||||||
done
|
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
|
- name: Verify Rainyun S3 PLONDS output
|
||||||
env:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
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
|
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:
|
on:
|
||||||
release:
|
release:
|
||||||
types:
|
types:
|
||||||
- published
|
- published
|
||||||
- prereleased
|
- prereleased
|
||||||
|
- edited
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSerializable(typeof(PlondsFileEntry))]
|
[JsonSerializable(typeof(PlondsFileEntry))]
|
||||||
[JsonSerializable(typeof(PlondsHashDescriptor))]
|
[JsonSerializable(typeof(PlondsHashDescriptor))]
|
||||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||||
|
[JsonSerializable(typeof(InstallCheckpoint))]
|
||||||
[JsonSerializable(typeof(AppVersionInfo))]
|
[JsonSerializable(typeof(AppVersionInfo))]
|
||||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||||
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
|
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
|
||||||
|
|||||||
@@ -41,6 +41,25 @@ internal sealed class SnapshotMetadata
|
|||||||
public string Status { get; set; } = "pending";
|
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
|
internal sealed class UpdateApplyResult
|
||||||
{
|
{
|
||||||
public bool Success { get; init; }
|
public bool Success { get; init; }
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ internal sealed class UpdateEngineService
|
|||||||
private readonly string _launcherRoot;
|
private readonly string _launcherRoot;
|
||||||
private readonly string _incomingRoot;
|
private readonly string _incomingRoot;
|
||||||
private readonly string _snapshotsRoot;
|
private readonly string _snapshotsRoot;
|
||||||
|
private readonly string _installCheckpointPath;
|
||||||
|
|
||||||
public UpdateEngineService(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null)
|
public UpdateEngineService(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null)
|
||||||
{
|
{
|
||||||
@@ -36,6 +37,7 @@ internal sealed class UpdateEngineService
|
|||||||
_launcherRoot = resolver.ResolveLauncherDataPath();
|
_launcherRoot = resolver.ResolveLauncherDataPath();
|
||||||
_incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName);
|
_incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName);
|
||||||
_snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName);
|
_snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName);
|
||||||
|
_installCheckpointPath = ContractsUpdate.UpdatePaths.GetInstallCheckpointPath(_appRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
public LauncherResult CheckPendingUpdate()
|
public LauncherResult CheckPendingUpdate()
|
||||||
@@ -129,19 +131,274 @@ internal sealed class UpdateEngineService
|
|||||||
Directory.CreateDirectory(_incomingRoot);
|
Directory.CreateDirectory(_incomingRoot);
|
||||||
Directory.CreateDirectory(_snapshotsRoot);
|
Directory.CreateDirectory(_snapshotsRoot);
|
||||||
|
|
||||||
var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName);
|
var stateValidation = ValidateIncomingState();
|
||||||
var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName);
|
if (!stateValidation.Success)
|
||||||
var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName);
|
|
||||||
if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath))
|
|
||||||
{
|
{
|
||||||
return await ApplyPendingPlondsUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath);
|
return stateValidation;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
var applyLockPath = ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(_appRoot);
|
||||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
try
|
||||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
{
|
||||||
|
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
|
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));
|
return new LauncherResult
|
||||||
var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName);
|
|
||||||
if (!verifyResult.Success)
|
|
||||||
{
|
{
|
||||||
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
|
Success = true,
|
||||||
return Failed("update.apply", "signature_failed", verifyResult.Message);
|
Stage = "update.apply",
|
||||||
}
|
Code = "ok",
|
||||||
|
Message = "Incoming update state validated."
|
||||||
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"
|
|
||||||
};
|
};
|
||||||
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(
|
private async Task<LauncherResult> ApplyPendingPlondsUpdateAsync(
|
||||||
@@ -353,11 +467,26 @@ internal sealed class UpdateEngineService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment);
|
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 partialMarker = Path.Combine(targetDeployment, ".partial");
|
||||||
var snapshot = new SnapshotMetadata
|
var snapshot = new SnapshotMetadata
|
||||||
{
|
{
|
||||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"),
|
||||||
SourceVersion = sourceVersion,
|
SourceVersion = sourceVersion,
|
||||||
TargetVersion = targetVersion,
|
TargetVersion = targetVersion,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
@@ -367,35 +496,56 @@ internal sealed class UpdateEngineService
|
|||||||
};
|
};
|
||||||
var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json");
|
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
|
try
|
||||||
{
|
{
|
||||||
SaveSnapshot(snapshotPath, snapshot);
|
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));
|
SaveInstallCheckpoint(checkpoint);
|
||||||
Directory.CreateDirectory(targetDeployment);
|
|
||||||
File.WriteAllText(partialMarker, string.Empty);
|
|
||||||
|
|
||||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, 0, fileEntries.Count));
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, checkpoint.AppliedCount, fileEntries.Count));
|
||||||
var fileIndex = 0;
|
for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileEntries.Count; fileIndex++)
|
||||||
foreach (var entry in fileEntries)
|
|
||||||
{
|
{
|
||||||
|
var entry = fileEntries[fileIndex];
|
||||||
ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment);
|
ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment);
|
||||||
fileIndex++;
|
checkpoint.AppliedCount = fileIndex + 1;
|
||||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (fileIndex * 30 / fileEntries.Count), entry.Path, fileIndex, fileEntries.Count));
|
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));
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, checkpoint.VerifiedCount, fileEntries.Count));
|
||||||
var verifyIndex = 0;
|
for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileEntries.Count; verifyIndex++)
|
||||||
foreach (var entry in fileEntries)
|
|
||||||
{
|
{
|
||||||
|
var entry = fileEntries[verifyIndex];
|
||||||
VerifyPlondsFileEntry(entry, targetDeployment);
|
VerifyPlondsFileEntry(entry, targetDeployment);
|
||||||
verifyIndex++;
|
checkpoint.VerifiedCount = verifyIndex + 1;
|
||||||
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (verifyIndex * 15 / fileEntries.Count), entry.Path, verifyIndex, fileEntries.Count));
|
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)
|
if (isInitialDeployment)
|
||||||
@@ -481,6 +631,10 @@ internal sealed class UpdateEngineService
|
|||||||
RolledBackTo = rollbackResult.Success ? sourceVersion : null
|
RolledBackTo = rollbackResult.Success ? sourceVersion : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteInstallCheckpoint();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyPlondsFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment)
|
private void ApplyPlondsFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment)
|
||||||
@@ -1529,7 +1683,8 @@ internal sealed class UpdateEngineService
|
|||||||
Path.Combine(_incomingRoot, ArchiveFileName),
|
Path.Combine(_incomingRoot, ArchiveFileName),
|
||||||
Path.Combine(_incomingRoot, PlondsFileMapName),
|
Path.Combine(_incomingRoot, PlondsFileMapName),
|
||||||
Path.Combine(_incomingRoot, PlondsSignatureFileName),
|
Path.Combine(_incomingRoot, PlondsSignatureFileName),
|
||||||
Path.Combine(_incomingRoot, PlondsUpdateMetadataName)
|
Path.Combine(_incomingRoot, PlondsUpdateMetadataName),
|
||||||
|
_installCheckpointPath
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -1638,6 +1793,48 @@ internal sealed class UpdateEngineService
|
|||||||
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
|
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)
|
private static LauncherResult Failed(string stage, string code, string message)
|
||||||
{
|
{
|
||||||
return new LauncherResult
|
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)
|
public static string GetPlondsSignaturePath(string launcherRoot)
|
||||||
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsSignatureName());
|
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsSignatureName());
|
||||||
|
|
||||||
public static string GetPlondsUpdateMetadataPath(string launcherRoot)
|
public static string GetDeploymentLockName() => "deployment.lock";
|
||||||
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsUpdateMetadataName());
|
|
||||||
|
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)
|
public static string GetDownloadMarkerContent(string manifestSha256, string targetVersion, int objectCount)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ public enum UpdatePhase
|
|||||||
Checking,
|
Checking,
|
||||||
Checked,
|
Checked,
|
||||||
Downloading,
|
Downloading,
|
||||||
|
PausedDownloading,
|
||||||
Downloaded,
|
Downloaded,
|
||||||
Installing,
|
Installing,
|
||||||
|
PausedInstalling,
|
||||||
Installed,
|
Installed,
|
||||||
Verifying,
|
Verifying,
|
||||||
Completed,
|
Completed,
|
||||||
@@ -64,9 +66,8 @@ public static class UpdatePhaseExtensions
|
|||||||
phase is UpdatePhase.Completed or UpdatePhase.Failed or UpdatePhase.RolledBack;
|
phase is UpdatePhase.Completed or UpdatePhase.Failed or UpdatePhase.RolledBack;
|
||||||
|
|
||||||
public static bool IsBusy(this UpdatePhase phase) =>
|
public static bool IsBusy(this UpdatePhase phase) =>
|
||||||
phase is not (UpdatePhase.Idle or UpdatePhase.Checked or UpdatePhase.Downloaded
|
phase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.Installing
|
||||||
or UpdatePhase.Installed or UpdatePhase.Completed or UpdatePhase.Failed
|
or UpdatePhase.Verifying or UpdatePhase.Recovering or UpdatePhase.RollingBack;
|
||||||
or UpdatePhase.RolledBack);
|
|
||||||
|
|
||||||
public static bool CanCheck(this UpdatePhase phase) =>
|
public static bool CanCheck(this UpdatePhase phase) =>
|
||||||
phase is UpdatePhase.Idle or UpdatePhase.Checked or UpdatePhase.Downloaded
|
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) =>
|
public static bool CanRollback(this UpdatePhase phase) =>
|
||||||
phase is UpdatePhase.Failed;
|
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);
|
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();
|
public void Dispose() => _directory.Dispose();
|
||||||
|
|
||||||
private static string Sha256Hex(byte[] bytes)
|
private static string Sha256Hex(byte[] bytes)
|
||||||
@@ -166,6 +234,81 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable
|
|||||||
var fileMapPath = Path.Combine(IncomingRoot, "plonds-filemap.json");
|
var fileMapPath = Path.Combine(IncomingRoot, "plonds-filemap.json");
|
||||||
File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.PlondsFileMap));
|
File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.PlondsFileMap));
|
||||||
Sign(fileMapPath, Path.Combine(IncomingRoot, "plonds-filemap.sig"));
|
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)
|
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));
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_rsa.Dispose();
|
_rsa.Dispose();
|
||||||
@@ -304,7 +513,7 @@ public sealed class UpdatePathConsistencyTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void HostAndSharedUpdatePathsUseLauncherDirectoryCasing()
|
public void HostAndSharedUpdatePathsUseLauncherDirectoryCasing()
|
||||||
{
|
{
|
||||||
var incoming = UpdateWorkflowService.GetLauncherIncomingDirectory();
|
var incoming = UpdatePaths.GetIncomingDirectory("root");
|
||||||
var sharedIncoming = UpdatePaths.GetIncomingDirectory("root");
|
var sharedIncoming = UpdatePaths.GetIncomingDirectory("root");
|
||||||
|
|
||||||
Assert.Contains($"{Path.DirectorySeparatorChar}.Launcher{Path.DirectorySeparatorChar}", incoming);
|
Assert.Contains($"{Path.DirectorySeparatorChar}.Launcher{Path.DirectorySeparatorChar}", incoming);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ using LanMountainDesktop.Services.ExternalIpc;
|
|||||||
using LanMountainDesktop.Services.Launcher;
|
using LanMountainDesktop.Services.Launcher;
|
||||||
using LanMountainDesktop.Services.Loading;
|
using LanMountainDesktop.Services.Loading;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Services.Update;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
using LanMountainDesktop.Shared.IPC;
|
using LanMountainDesktop.Shared.IPC;
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
@@ -76,6 +77,7 @@ public partial class App : Application
|
|||||||
private MainWindow? _mainWindow;
|
private MainWindow? _mainWindow;
|
||||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
private TransparentOverlayWindow? _transparentOverlayWindow;
|
||||||
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
|
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
|
||||||
|
private bool _isExitingFusedDesktopEditMode;
|
||||||
private bool _mainWindowClosed;
|
private bool _mainWindowClosed;
|
||||||
private DesktopShellHost? _desktopShellHost;
|
private DesktopShellHost? _desktopShellHost;
|
||||||
private PublicIpcHostService? _publicIpcHostService;
|
private PublicIpcHostService? _publicIpcHostService;
|
||||||
@@ -441,88 +443,132 @@ public partial class App : Application
|
|||||||
return;
|
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;
|
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
|
try
|
||||||
{
|
{
|
||||||
if (_fusedComponentLibraryWindow is { } existingWindow)
|
_transparentOverlayWindow?.SaveLayoutAndHide();
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception overlayEx)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
|
AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay.", overlayEx);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}, 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()
|
private void DisableAvaloniaDataAnnotationValidation()
|
||||||
@@ -945,6 +991,14 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TransparentOverlay");
|
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
|
try
|
||||||
{
|
{
|
||||||
HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit();
|
HostUpdateOrchestratorProvider.GetOrCreate().TryApplyOnExit();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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>
|
/// </summary>
|
||||||
public double Height { get; set; } = 200;
|
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>
|
/// <summary>
|
||||||
/// Z-Index(用于控制组件层叠顺序)
|
/// Z-Index(用于控制组件层叠顺序)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -61,6 +69,10 @@ public sealed class FusedDesktopComponentPlacementSnapshot
|
|||||||
Y = Y,
|
Y = Y,
|
||||||
Width = Width,
|
Width = Width,
|
||||||
Height = Height,
|
Height = Height,
|
||||||
|
GridRow = GridRow,
|
||||||
|
GridColumn = GridColumn,
|
||||||
|
GridWidthCells = GridWidthCells,
|
||||||
|
GridHeightCells = GridHeightCells,
|
||||||
ZIndex = ZIndex,
|
ZIndex = ZIndex,
|
||||||
IsLocked = IsLocked
|
IsLocked = IsLocked
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -847,7 +847,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
snapshot,
|
snapshot,
|
||||||
changedKeys:
|
changedKeys:
|
||||||
[
|
[
|
||||||
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
|
|
||||||
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
|
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
|
||||||
nameof(AppSettingsSnapshot.UpdateChannel),
|
nameof(AppSettingsSnapshot.UpdateChannel),
|
||||||
nameof(AppSettingsSnapshot.UpdateMode),
|
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;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -77,7 +78,7 @@ internal sealed class UpdateDownloadEngine
|
|||||||
|
|
||||||
var totalFiles = downloadableFiles.Count + 2;
|
var totalFiles = downloadableFiles.Count + 2;
|
||||||
var completedFiles = 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 semaphore = new SemaphoreSlim(Math.Max(1, maxConcurrency), Math.Max(1, maxConcurrency));
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
long totalBytes = downloadableFiles.Sum(f => f.Size);
|
long totalBytes = downloadableFiles.Sum(f => f.Size);
|
||||||
@@ -89,7 +90,7 @@ internal sealed class UpdateDownloadEngine
|
|||||||
await semaphore.WaitAsync(ct);
|
await semaphore.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!seenHashes.Add(entry.Sha256))
|
if (!seenHashes.TryAdd(entry.Sha256, 0))
|
||||||
{
|
{
|
||||||
lock (lockObj)
|
lock (lockObj)
|
||||||
{
|
{
|
||||||
@@ -146,6 +147,20 @@ internal sealed class UpdateDownloadEngine
|
|||||||
{
|
{
|
||||||
AppLogger.Warn("UpdateDownloadEngine",
|
AppLogger.Warn("UpdateDownloadEngine",
|
||||||
$"Object {entry.Path} hash mismatch after download. Expected: {entry.Sha256}, Actual: {actualHash}");
|
$"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)
|
lock (lockObj)
|
||||||
@@ -274,7 +289,7 @@ internal sealed class UpdateDownloadEngine
|
|||||||
|
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
{
|
{
|
||||||
bool hashVerified;
|
bool hashVerified = true;
|
||||||
if (!string.IsNullOrWhiteSpace(mirror.Sha256))
|
if (!string.IsNullOrWhiteSpace(mirror.Sha256))
|
||||||
{
|
{
|
||||||
var actualHash = await ComputeFileSha256Async(destinationPath, ct);
|
var actualHash = await ComputeFileSha256Async(destinationPath, ct);
|
||||||
@@ -283,12 +298,17 @@ internal sealed class UpdateDownloadEngine
|
|||||||
{
|
{
|
||||||
AppLogger.Warn("UpdateDownloadEngine",
|
AppLogger.Warn("UpdateDownloadEngine",
|
||||||
$"Full installer hash mismatch. Expected: {mirror.Sha256}, Actual: {actualHash}");
|
$"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}");
|
AppLogger.Info("UpdateDownloadEngine", $"Full installer downloaded to {destinationPath}");
|
||||||
return new DownloadResult(true, destinationPath, null, hashVerified);
|
return new DownloadResult(true, destinationPath, null, hashVerified);
|
||||||
@@ -374,6 +394,20 @@ internal sealed class UpdateDownloadEngine
|
|||||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
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)
|
private static string ComputeStringSha256(string content)
|
||||||
{
|
{
|
||||||
using var hasher = SHA256.Create();
|
using var hasher = SHA256.Create();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -9,7 +10,7 @@ using LanMountainDesktop.Shared.Contracts.Update;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Services.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
|
internal sealed class UpdateInstallGateway
|
||||||
{
|
{
|
||||||
@@ -31,12 +32,17 @@ internal sealed class UpdateInstallGateway
|
|||||||
0,
|
0,
|
||||||
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)
|
if (payloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy)
|
||||||
{
|
{
|
||||||
var launched = LaunchLauncherForApplyUpdate(launcherRoot);
|
var launched = LaunchLauncherForApplyUpdate(launcherRoot);
|
||||||
if (!launched)
|
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(
|
progress?.Report(new InstallProgressReport(
|
||||||
@@ -50,10 +56,10 @@ internal sealed class UpdateInstallGateway
|
|||||||
return new InstallResult(true, null, false);
|
return new InstallResult(true, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var installerPath = FindPendingInstaller(launcherRoot);
|
var installerPath = FindPendingInstaller(launcherRoot, payloadKind, ct);
|
||||||
if (installerPath is null)
|
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);
|
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)
|
private bool LaunchLauncherForApplyUpdate(string launcherRoot)
|
||||||
{
|
{
|
||||||
try
|
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);
|
var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot);
|
||||||
if (!Directory.Exists(incomingDir))
|
if (!Directory.Exists(incomingDir))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var executables = Directory.GetFiles(incomingDir, "*.exe");
|
var executables = new DirectoryInfo(incomingDir)
|
||||||
return executables.Length > 0 ? executables[0] : null;
|
.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;
|
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
|
public sealed class UpdateOrchestrator : IDisposable
|
||||||
{
|
{
|
||||||
private readonly IUpdateManifestProvider _manifestProvider;
|
private readonly IUpdateManifestProvider _manifestProvider;
|
||||||
@@ -16,6 +44,8 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
private readonly UpdateInstallGateway _installGateway;
|
private readonly UpdateInstallGateway _installGateway;
|
||||||
private readonly UpdateStateStore _stateStore;
|
private readonly UpdateStateStore _stateStore;
|
||||||
private readonly SemaphoreSlim _operationGate = new(1, 1);
|
private readonly SemaphoreSlim _operationGate = new(1, 1);
|
||||||
|
private readonly object _cancellationSync = new();
|
||||||
|
private CancellationTokenSource? _activeOperationCts;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
internal UpdateOrchestrator(
|
internal UpdateOrchestrator(
|
||||||
@@ -40,9 +70,29 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
public event Action<UpdatePhase>? PhaseChanged;
|
public event Action<UpdatePhase>? PhaseChanged;
|
||||||
public event Action<UpdateProgressReport>? ProgressChanged;
|
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)
|
public async Task<UpdateCheckReport> CheckAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _operationGate.WaitAsync(ct);
|
await _operationGate.WaitAsync(ct);
|
||||||
|
var operationToken = RegisterOperationCancellation(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!CurrentPhase.CanCheck())
|
if (!CurrentPhase.CanCheck())
|
||||||
@@ -59,9 +109,21 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
var currentVersionText = _stateStore.GetSettings().PendingUpdateVersion
|
var currentVersionText = _stateStore.GetSettings().PendingUpdateVersion
|
||||||
?? AppVersionProvider.ResolveForCurrentProcess().Version;
|
?? 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;
|
UpdateManifest? manifest;
|
||||||
@@ -71,7 +133,7 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
channel,
|
channel,
|
||||||
LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform(),
|
LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform(),
|
||||||
currentVersion,
|
currentVersion,
|
||||||
ct);
|
operationToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -114,6 +176,7 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
ClearOperationCancellation();
|
||||||
_operationGate.Release();
|
_operationGate.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,9 +184,10 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
public async Task<DownloadResult> DownloadAsync(CancellationToken ct)
|
public async Task<DownloadResult> DownloadAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _operationGate.WaitAsync(ct);
|
await _operationGate.WaitAsync(ct);
|
||||||
|
var operationToken = RegisterOperationCancellation(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!CurrentPhase.CanDownload())
|
if (CurrentPhase is not (UpdatePhase.Checked or UpdatePhase.PausedDownloading))
|
||||||
{
|
{
|
||||||
return new DownloadResult(false, null, $"Cannot download in phase {CurrentPhase}.", false);
|
return new DownloadResult(false, null, $"Cannot download in phase {CurrentPhase}.", false);
|
||||||
}
|
}
|
||||||
@@ -168,7 +232,7 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
objectsDir,
|
objectsDir,
|
||||||
maxThreads,
|
maxThreads,
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
ct);
|
operationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -183,7 +247,7 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
destinationPath,
|
destinationPath,
|
||||||
maxThreads,
|
maxThreads,
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
ct);
|
operationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
@@ -196,9 +260,19 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
PendingUpdateInstallerPath = result.FilePath,
|
PendingUpdateInstallerPath = result.FilePath,
|
||||||
PendingUpdateVersion = manifest.ToVersion,
|
PendingUpdateVersion = manifest.ToVersion,
|
||||||
PendingUpdatePublishedAtUtcMs = manifest.PublishedAt.ToUnixTimeMilliseconds(),
|
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}");
|
AppLogger.Info("UpdateOrchestrator", $"Update downloaded successfully: {manifest.ToVersion}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -211,7 +285,11 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
_stateStore.TransitionTo(UpdatePhase.Idle);
|
if (CurrentPhase != UpdatePhase.PausedDownloading)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Idle);
|
||||||
|
}
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -223,6 +301,7 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
ClearOperationCancellation();
|
||||||
_operationGate.Release();
|
_operationGate.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,17 +309,18 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
public async Task<InstallResult> InstallAsync(CancellationToken ct)
|
public async Task<InstallResult> InstallAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _operationGate.WaitAsync(ct);
|
await _operationGate.WaitAsync(ct);
|
||||||
|
var operationToken = RegisterOperationCancellation(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!CurrentPhase.CanInstall())
|
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;
|
var manifest = _stateStore.PendingManifest;
|
||||||
if (manifest is null)
|
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);
|
_stateStore.TransitionTo(UpdatePhase.Installing);
|
||||||
@@ -264,7 +344,7 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
manifest.Kind,
|
manifest.Kind,
|
||||||
launcherRoot,
|
launcherRoot,
|
||||||
installProgress,
|
installProgress,
|
||||||
ct);
|
operationToken);
|
||||||
|
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
{
|
{
|
||||||
@@ -282,18 +362,23 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
_stateStore.TransitionTo(UpdatePhase.Failed);
|
if (CurrentPhase != UpdatePhase.PausedInstalling)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Idle);
|
||||||
|
}
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_stateStore.TransitionTo(UpdatePhase.Failed);
|
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||||
_stateStore.RecordFailure(ex.Message);
|
_stateStore.RecordFailure(ex.Message);
|
||||||
return new InstallResult(false, ex.Message, false);
|
return new InstallResult(false, ex.Message, false, "install_exception");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
ClearOperationCancellation();
|
||||||
_operationGate.Release();
|
_operationGate.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,8 +386,11 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
public async Task RollbackAsync(CancellationToken ct)
|
public async Task RollbackAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _operationGate.WaitAsync(ct);
|
await _operationGate.WaitAsync(ct);
|
||||||
|
var operationToken = RegisterOperationCancellation(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
operationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (!CurrentPhase.CanRollback())
|
if (!CurrentPhase.CanRollback())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -330,6 +418,11 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
|
|
||||||
_stateStore.TransitionTo(UpdatePhase.RolledBack);
|
_stateStore.TransitionTo(UpdatePhase.RolledBack);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Idle);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("UpdateOrchestrator", $"Rollback failed: {ex.Message}");
|
AppLogger.Warn("UpdateOrchestrator", $"Rollback failed: {ex.Message}");
|
||||||
@@ -338,21 +431,86 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
ClearOperationCancellation();
|
||||||
_operationGate.Release();
|
_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.TransitionTo(UpdatePhase.Idle);
|
||||||
_stateStore.PendingManifest = null;
|
|
||||||
AppLogger.Info("UpdateOrchestrator", "Update operation cancelled.");
|
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||||
await Task.CompletedTask;
|
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)
|
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)
|
private void OnPhaseChanged(UpdatePhase phase)
|
||||||
{
|
{
|
||||||
PhaseChanged?.Invoke(phase);
|
PhaseChanged?.Invoke(phase);
|
||||||
@@ -478,6 +707,11 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
_disposed = true;
|
_disposed = true;
|
||||||
_stateStore.PhaseChanged -= OnPhaseChanged;
|
_stateStore.PhaseChanged -= OnPhaseChanged;
|
||||||
_stateStore.ProgressChanged -= OnProgressChanged;
|
_stateStore.ProgressChanged -= OnProgressChanged;
|
||||||
|
lock (_cancellationSync)
|
||||||
|
{
|
||||||
|
_activeOperationCts?.Dispose();
|
||||||
|
_activeOperationCts = null;
|
||||||
|
}
|
||||||
_operationGate.Dispose();
|
_operationGate.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
@@ -7,9 +6,6 @@ using Avalonia.Controls;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 窗口置底服务接口
|
|
||||||
/// </summary>
|
|
||||||
public interface IWindowBottomMostService
|
public interface IWindowBottomMostService
|
||||||
{
|
{
|
||||||
void SetupBottomMost(Window window);
|
void SetupBottomMost(Window window);
|
||||||
@@ -17,30 +13,13 @@ public interface IWindowBottomMostService
|
|||||||
bool IsBottomMostSupported { get; }
|
bool IsBottomMostSupported { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 区域级穿透服务接口 - 使用 WM_NCHITTEST 实现
|
|
||||||
/// </summary>
|
|
||||||
public interface IRegionPassthroughService
|
public interface IRegionPassthroughService
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// 设置窗口的可交互区域
|
|
||||||
/// </summary>
|
|
||||||
void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions);
|
void SetInteractiveRegions(Window window, IReadOnlyList<Rect> interactiveRegions);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 清除所有可交互区域
|
|
||||||
/// </summary>
|
|
||||||
void ClearInteractiveRegions(Window window);
|
void ClearInteractiveRegions(Window window);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取当前平台是否支持区域级穿透
|
|
||||||
/// </summary>
|
|
||||||
bool IsRegionPassthroughSupported { get; }
|
bool IsRegionPassthroughSupported { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 窗口置底服务工厂
|
|
||||||
/// </summary>
|
|
||||||
public static class WindowBottomMostServiceFactory
|
public static class WindowBottomMostServiceFactory
|
||||||
{
|
{
|
||||||
private static IWindowBottomMostService? _instance;
|
private static IWindowBottomMostService? _instance;
|
||||||
@@ -57,9 +36,6 @@ public static class WindowBottomMostServiceFactory
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 区域级穿透服务工厂
|
|
||||||
/// </summary>
|
|
||||||
public static class RegionPassthroughServiceFactory
|
public static class RegionPassthroughServiceFactory
|
||||||
{
|
{
|
||||||
private static IRegionPassthroughService? _instance;
|
private static IRegionPassthroughService? _instance;
|
||||||
@@ -76,103 +52,83 @@ public static class RegionPassthroughServiceFactory
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Windows 平台窗口置底服务
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||||
{
|
{
|
||||||
|
private const int GWL_STYLE = -16;
|
||||||
private const int GWL_EXSTYLE = -20;
|
private const int GWL_EXSTYLE = -20;
|
||||||
private const int GWL_HWNDPARENT = -8;
|
|
||||||
private const int GWLP_WNDPROC = -4;
|
private const int GWLP_WNDPROC = -4;
|
||||||
private const int WS_EX_TOOLWINDOW = 0x00000080;
|
|
||||||
private const int WS_EX_APPWINDOW = 0x00040000;
|
private const long WS_CHILD = 0x40000000L;
|
||||||
private const int WS_EX_NOACTIVATE = 0x08000000;
|
private const long WS_POPUP = 0x80000000L;
|
||||||
private const int WS_EX_LAYERED = 0x00080000;
|
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_NOSIZE = 0x0001;
|
||||||
private const uint SWP_NOMOVE = 0x0002;
|
private const uint SWP_NOMOVE = 0x0002;
|
||||||
private const uint SWP_NOACTIVATE = 0x0010;
|
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_NCHITTEST = 0x0084;
|
||||||
private const int WM_ACTIVATEAPP = 0x001C; // 【新增】应用激活消息
|
|
||||||
private const int HTTRANSPARENT = -1;
|
private const int HTTRANSPARENT = -1;
|
||||||
private const int HTCLIENT = 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 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, IntPtr> _originalWndProcs = new();
|
||||||
private static readonly Dictionary<IntPtr, List<Rect>> _interactiveRegions = new();
|
private static readonly Dictionary<IntPtr, List<Rect>> _interactiveRegions = new();
|
||||||
|
|
||||||
// 记录每个窗口的屏幕原点(窗口左上角的屏幕坐标),用于将 WM_NCHITTEST 屏幕坐标转成窗口相对坐标
|
|
||||||
private static readonly Dictionary<IntPtr, Point> _windowScreenOrigins = new();
|
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();
|
private static readonly Dictionary<IntPtr, double> _windowDpiScales = new();
|
||||||
|
|
||||||
// 【修复问题5】Z 轴竞争优化 - 记录上次置底时间,避免频繁操作
|
private static WndProcDelegate? _wndProcDelegate;
|
||||||
private static readonly Dictionary<IntPtr, long> _lastSendToBottomTime = new();
|
private static System.Timers.Timer? _desktopHostMonitorTimer;
|
||||||
private const long MinSendToBottomIntervalMs = 100; // 【修复置底问题】降低到 100ms,提高响应速度
|
|
||||||
|
|
||||||
// 【新增】定时器定期强制置底
|
|
||||||
private static System.Timers.Timer? _keepBottomTimer;
|
|
||||||
private static readonly object _timerLock = new();
|
|
||||||
|
|
||||||
public bool IsBottomMostSupported => true;
|
public bool IsBottomMostSupported => true;
|
||||||
|
|
||||||
public void SetupBottomMost(Window window)
|
public void SetupBottomMost(Window window)
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows()) return;
|
if (!OperatingSystem.IsWindows())
|
||||||
|
|
||||||
window.Opened += (s, e) =>
|
|
||||||
{
|
{
|
||||||
var handle = GetWindowHandle(window);
|
return;
|
||||||
if (handle == IntPtr.Zero) return;
|
}
|
||||||
|
|
||||||
// 设置扩展样式
|
var handle = GetWindowHandle(window);
|
||||||
var exStyle = GetWindowLong(handle, GWL_EXSTYLE);
|
if (handle != IntPtr.Zero)
|
||||||
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);
|
ApplyDesktopAttachment(handle, logSuccess: true);
|
||||||
if (handle != IntPtr.Zero)
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
window.Opened += (_, _) =>
|
||||||
{
|
{
|
||||||
lock (_staticLock)
|
var openedHandle = GetWindowHandle(window);
|
||||||
|
if (openedHandle != IntPtr.Zero)
|
||||||
{
|
{
|
||||||
_bottomMostWindows.Remove(handle);
|
ApplyDesktopAttachment(openedHandle, logSuccess: true);
|
||||||
_originalWndProcs.Remove(handle);
|
|
||||||
_interactiveRegions.Remove(handle);
|
|
||||||
_windowScreenOrigins.Remove(handle);
|
|
||||||
_windowDpiScales.Remove(handle); // 【修复问题2】清理 DPI 缩放记录
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
public void SendToBottom(Window window)
|
||||||
{
|
{
|
||||||
var handle = GetWindowHandle(window);
|
var handle = GetWindowHandle(window);
|
||||||
if (handle != IntPtr.Zero) SendToBottomInternal(handle);
|
if (handle != IntPtr.Zero)
|
||||||
}
|
|
||||||
|
|
||||||
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 (_keepBottomTimer != null) return;
|
ApplyDesktopAttachment(handle, logSuccess: false);
|
||||||
|
|
||||||
_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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
internal static void SetInteractiveRegionsInternal(IntPtr handle, List<Rect> regions)
|
||||||
/// 【新增】停止定时器
|
|
||||||
/// </summary>
|
|
||||||
private static void StopKeepBottomTimer()
|
|
||||||
{
|
{
|
||||||
lock (_timerLock)
|
lock (_staticLock)
|
||||||
{
|
{
|
||||||
_keepBottomTimer?.Stop();
|
_interactiveRegions[handle] = regions;
|
||||||
_keepBottomTimer?.Dispose();
|
UpdateWindowScreenOrigin(handle);
|
||||||
_keepBottomTimer = null;
|
UpdateWindowDpiScale(handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SetAsDesktopChild(IntPtr handle)
|
private static void ApplyDesktopAttachment(IntPtr handle, bool logSuccess)
|
||||||
{
|
{
|
||||||
// 【修复问题4】增强桌面挂载逻辑,支持 Wallpaper Engine 等动态壁纸软件
|
if (handle == IntPtr.Zero || !IsWindow(handle))
|
||||||
|
|
||||||
// 方案1: 尝试找到 WorkerW 层(Wallpaper Engine 创建的层)
|
|
||||||
var workerW = IntPtr.Zero;
|
|
||||||
var hDefView = IntPtr.Zero;
|
|
||||||
|
|
||||||
// 枚举所有顶层窗口
|
|
||||||
var windowHandles = new ArrayList();
|
|
||||||
EnumWindows(EnumWindowsCallback, windowHandles);
|
|
||||||
|
|
||||||
foreach (IntPtr h in windowHandles)
|
|
||||||
{
|
{
|
||||||
// 查找 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方案2: 回退到传统方式,查找 Progman 下的 SHELLDLL_DefView
|
SetDesktopChildStyles(handle);
|
||||||
foreach (IntPtr h in windowHandles)
|
InstallMessageHook(handle);
|
||||||
|
|
||||||
|
var attached = TryAttachToDesktopIconHost(handle, out var desktopHost);
|
||||||
|
lock (_staticLock)
|
||||||
{
|
{
|
||||||
hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
_desktopWindows[handle] = new DesktopWindowState(desktopHost, attached);
|
||||||
if (hDefView != IntPtr.Zero)
|
if (!_interactiveRegions.ContainsKey(handle))
|
||||||
{
|
{
|
||||||
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
_interactiveRegions[handle] = [];
|
||||||
AppLogger.Info("WindowBottomMost", "Mounted to traditional desktop layer");
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
private static void MonitorDesktopHostAttachments()
|
||||||
/// 【修复问题4】获取窗口类名
|
|
||||||
/// </summary>
|
|
||||||
private static string GetWindowClassName(IntPtr hWnd)
|
|
||||||
{
|
{
|
||||||
var buffer = new char[256];
|
List<IntPtr> handles;
|
||||||
var length = GetClassName(hWnd, buffer, buffer.Length);
|
lock (_staticLock)
|
||||||
return length > 0 ? new string(buffer, 0, length) : string.Empty;
|
{
|
||||||
|
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);
|
lock (_timerLock)
|
||||||
return true;
|
{
|
||||||
|
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)
|
private static void InstallMessageHook(IntPtr handle)
|
||||||
{
|
{
|
||||||
|
lock (_staticLock)
|
||||||
|
{
|
||||||
|
if (_originalWndProcs.ContainsKey(handle))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var originalWndProc = GetWindowLongPtr(handle, GWLP_WNDPROC);
|
var originalWndProc = GetWindowLongPtr(handle, GWLP_WNDPROC);
|
||||||
if (originalWndProc == IntPtr.Zero) return;
|
if (originalWndProc == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
lock (_staticLock)
|
lock (_staticLock)
|
||||||
{
|
{
|
||||||
_originalWndProcs[handle] = originalWndProc;
|
_originalWndProcs[handle] = originalWndProc;
|
||||||
|
|
||||||
// 【修复问题1】确保委托实例被静态引用持有,防止 GC 回收
|
|
||||||
_wndProcDelegate ??= SubclassWndProc;
|
_wndProcDelegate ??= SubclassWndProc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,47 +350,8 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
|
|
||||||
private static IntPtr SubclassWndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
|
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)
|
if (msg == WM_NCHITTEST)
|
||||||
{
|
{
|
||||||
// WM_NCHITTEST 的鼠标坐标在 lParam(低16位=X,高16位=Y),且为屏幕坐标
|
|
||||||
var screenX = (short)(lParam.ToInt64() & 0xFFFF);
|
var screenX = (short)(lParam.ToInt64() & 0xFFFF);
|
||||||
var screenY = (short)((lParam.ToInt64() >> 16) & 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)
|
if (_interactiveRegions.TryGetValue(hWnd, out var regions) && regions.Count > 0)
|
||||||
{
|
{
|
||||||
// 【修复问题2】获取窗口原点和 DPI 缩放比例
|
|
||||||
_windowScreenOrigins.TryGetValue(hWnd, out var origin);
|
_windowScreenOrigins.TryGetValue(hWnd, out var origin);
|
||||||
_windowDpiScales.TryGetValue(hWnd, out var dpiScale);
|
_windowDpiScales.TryGetValue(hWnd, out var dpiScale);
|
||||||
if (dpiScale <= 0) dpiScale = 1.0; // 默认缩放为 1.0
|
if (dpiScale <= 0)
|
||||||
|
{
|
||||||
// 将屏幕物理像素坐标转为窗口相对坐标
|
dpiScale = 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);
|
|
||||||
|
|
||||||
|
var point = new Point((screenX - origin.X) / dpiScale, (screenY - origin.Y) / dpiScale);
|
||||||
foreach (var region in regions)
|
foreach (var region in regions)
|
||||||
{
|
{
|
||||||
if (region.Contains(point))
|
if (region.Contains(point))
|
||||||
{
|
{
|
||||||
// 在可交互区域内,返回 HTCLIENT
|
|
||||||
return (IntPtr)HTCLIENT;
|
return (IntPtr)HTCLIENT;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不在可交互区域内,返回 HTTRANSPARENT 让事件穿透
|
|
||||||
return (IntPtr)HTTRANSPARENT;
|
return (IntPtr)HTTRANSPARENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用原始窗口过程
|
|
||||||
CallOriginal:
|
|
||||||
IntPtr originalWndProc;
|
IntPtr originalWndProc;
|
||||||
lock (_staticLock)
|
lock (_staticLock)
|
||||||
{
|
{
|
||||||
@@ -417,23 +392,6 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
return CallWindowProc(originalWndProc, hWnd, msg, wParam, lParam);
|
return CallWindowProc(originalWndProc, hWnd, msg, wParam, lParam);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 设置窗口的可交互区域(供 WindowsRegionPassthroughService 调用)
|
|
||||||
/// </summary>
|
|
||||||
internal static void SetInteractiveRegionsInternal(IntPtr handle, List<Rect> regions)
|
|
||||||
{
|
|
||||||
lock (_staticLock)
|
|
||||||
{
|
|
||||||
_interactiveRegions[handle] = regions;
|
|
||||||
// 同步刷新屏幕原点(DPI 缩放可能影响坐标,每次更新区域时一并刷新)
|
|
||||||
UpdateWindowScreenOrigin(handle);
|
|
||||||
UpdateWindowDpiScale(handle); // 【修复问题2】同步更新 DPI 缩放
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新指定窗口的屏幕左上角坐标缓存(用于将 WM_NCHITTEST 屏幕坐标转为窗口相对坐标)
|
|
||||||
/// </summary>
|
|
||||||
private static void UpdateWindowScreenOrigin(IntPtr handle)
|
private static void UpdateWindowScreenOrigin(IntPtr handle)
|
||||||
{
|
{
|
||||||
if (GetWindowRect(handle, out var rect))
|
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)
|
private static void UpdateWindowDpiScale(IntPtr handle)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 获取窗口所在的显示器 DPI
|
|
||||||
var monitor = MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST);
|
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)
|
_windowDpiScales[handle] = dpiX / 96.0;
|
||||||
{
|
return;
|
||||||
// DPI 缩放比例 = 当前 DPI / 96 (标准 DPI)
|
|
||||||
_windowDpiScales[handle] = dpiX / 96.0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// 如果获取失败,使用默认缩放 1.0
|
// Use the default below.
|
||||||
_windowDpiScales[handle] = 1.0;
|
}
|
||||||
|
|
||||||
|
_windowDpiScales[handle] = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IntPtr GetWindowHandle(Window window)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
[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")]
|
[DllImport("user32.dll")]
|
||||||
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
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")]
|
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
|
||||||
private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
|
private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
|
||||||
|
|
||||||
@@ -490,14 +458,21 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flags);
|
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)]
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
private static extern IntPtr FindWindowEx(IntPtr hParent, IntPtr hChildAfter, string? lpszClass, string? lpszWindow);
|
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)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, ArrayList lParam);
|
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||||
|
|
||||||
private delegate bool EnumWindowsProc(IntPtr handle, ArrayList handles);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);
|
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")]
|
[DllImport("user32.dll")]
|
||||||
private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam);
|
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")]
|
[DllImport("user32.dll")]
|
||||||
private static extern IntPtr MonitorFromWindow(IntPtr hWnd, int dwFlags);
|
private static extern IntPtr MonitorFromWindow(IntPtr hWnd, int dwFlags);
|
||||||
|
|
||||||
[DllImport("shcore.dll")]
|
[DllImport("shcore.dll")]
|
||||||
private static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
|
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
|
internal sealed class WindowsRegionPassthroughService : IRegionPassthroughService
|
||||||
{
|
{
|
||||||
public bool IsRegionPassthroughSupported => true;
|
public bool IsRegionPassthroughSupported => true;
|
||||||
@@ -546,14 +510,17 @@ internal sealed class WindowsRegionPassthroughService : IRegionPassthroughServic
|
|||||||
|
|
||||||
private static IntPtr GetWindowHandle(Window window)
|
private static IntPtr GetWindowHandle(Window window)
|
||||||
{
|
{
|
||||||
try { return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; }
|
try
|
||||||
catch { return IntPtr.Zero; }
|
{
|
||||||
|
return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 空实现
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class NullWindowBottomMostService : IWindowBottomMostService
|
internal sealed class NullWindowBottomMostService : IWindowBottomMostService
|
||||||
{
|
{
|
||||||
public bool IsBottomMostSupported => false;
|
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;
|
[ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads;
|
||||||
|
|
||||||
public bool IsBusy => CurrentPhase.IsBusy();
|
public bool IsBusy => CurrentPhase.IsBusy();
|
||||||
|
public bool IsPaused => CurrentPhase.IsPaused();
|
||||||
public bool CanCheck => CurrentPhase.CanCheck();
|
public bool CanCheck => CurrentPhase.CanCheck();
|
||||||
public bool CanDownload => CurrentPhase.CanDownload();
|
public bool CanDownload => CurrentPhase.CanDownload();
|
||||||
public bool CanInstall => CurrentPhase.CanInstall();
|
public bool CanInstall => CurrentPhase.CanInstall();
|
||||||
public bool CanRollback => CurrentPhase.CanRollback();
|
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)
|
partial void OnCurrentPhaseChanged(UpdatePhase value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsBusy));
|
OnPropertyChanged(nameof(IsBusy));
|
||||||
|
OnPropertyChanged(nameof(IsPaused));
|
||||||
OnPropertyChanged(nameof(CanCheck));
|
OnPropertyChanged(nameof(CanCheck));
|
||||||
OnPropertyChanged(nameof(CanDownload));
|
OnPropertyChanged(nameof(CanDownload));
|
||||||
OnPropertyChanged(nameof(CanInstall));
|
OnPropertyChanged(nameof(CanInstall));
|
||||||
OnPropertyChanged(nameof(CanRollback));
|
OnPropertyChanged(nameof(CanRollback));
|
||||||
|
OnPropertyChanged(nameof(CanPause));
|
||||||
|
OnPropertyChanged(nameof(CanResume));
|
||||||
|
OnPropertyChanged(nameof(CanCancel));
|
||||||
OnPropertyChanged(nameof(IsProgressVisible));
|
OnPropertyChanged(nameof(IsProgressVisible));
|
||||||
|
OnPropertyChanged(nameof(PhaseText));
|
||||||
CheckCommand.NotifyCanExecuteChanged();
|
CheckCommand.NotifyCanExecuteChanged();
|
||||||
DownloadCommand.NotifyCanExecuteChanged();
|
DownloadCommand.NotifyCanExecuteChanged();
|
||||||
InstallCommand.NotifyCanExecuteChanged();
|
InstallCommand.NotifyCanExecuteChanged();
|
||||||
RollbackCommand.NotifyCanExecuteChanged();
|
RollbackCommand.NotifyCanExecuteChanged();
|
||||||
|
PauseCommand.NotifyCanExecuteChanged();
|
||||||
|
ResumeCommand.NotifyCanExecuteChanged();
|
||||||
|
CancelCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedUpdateChannelValueChanged(string value)
|
partial void OnSelectedUpdateChannelValueChanged(string value)
|
||||||
@@ -121,6 +140,10 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
StatusMessage = "Download complete. Ready to install.";
|
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
|
else
|
||||||
{
|
{
|
||||||
StatusMessage = result.ErrorMessage ?? "Download failed.";
|
StatusMessage = result.ErrorMessage ?? "Download failed.";
|
||||||
@@ -138,7 +161,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
else
|
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.";
|
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)
|
private void OnOrchestratorPhaseChanged(UpdatePhase phase)
|
||||||
{
|
{
|
||||||
CurrentPhase = phase;
|
CurrentPhase = phase;
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using LanMountainDesktop.Services;
|
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 表示一个独立的组件挂载窗口。它不含有任何自己的边窗,仅仅负责包裹组件并将自身植入系统最底层。
|
|
||||||
/// </summary>
|
|
||||||
public partial class DesktopWidgetWindow : Window
|
public partial class DesktopWidgetWindow : Window
|
||||||
{
|
{
|
||||||
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
||||||
@@ -18,6 +15,11 @@ public partial class DesktopWidgetWindow : Window
|
|||||||
public DesktopWidgetWindow()
|
public DesktopWidgetWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
_bottomMostService.SetupBottomMost(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public DesktopWidgetWindow(Control componentContent) : this()
|
public DesktopWidgetWindow(Control componentContent) : this()
|
||||||
@@ -48,11 +50,7 @@ public partial class DesktopWidgetWindow : Window
|
|||||||
|
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
// 通过现有的置底服务将独立的小窗口锁定到底层
|
|
||||||
_bottomMostService.SetupBottomMost(this);
|
|
||||||
_bottomMostService.SendToBottom(this);
|
_bottomMostService.SendToBottom(this);
|
||||||
|
|
||||||
// 当窗口展示完毕且有了尺寸后,更新可交互区域,使得整个组件都能被点击
|
|
||||||
Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render);
|
Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +67,6 @@ public partial class DesktopWidgetWindow : Window
|
|||||||
|
|
||||||
private void UpdateInteractiveRegion()
|
private void UpdateInteractiveRegion()
|
||||||
{
|
{
|
||||||
// 既然是一个完全紧贴在组件身上的小窗,它的全部都是可交互的
|
|
||||||
_regionPassthroughService.SetInteractiveRegions(this, new List<Rect>
|
_regionPassthroughService.SetInteractiveRegions(this, new List<Rect>
|
||||||
{
|
{
|
||||||
new(0, 0, Bounds.Width, Bounds.Height)
|
new(0, 0, Bounds.Width, Bounds.Height)
|
||||||
|
|||||||
@@ -8,16 +8,23 @@
|
|||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<Style Selector="ListBoxItem.category-item">
|
<Style Selector="ListBoxItem.category-item">
|
||||||
<Setter Property="Padding" Value="0"/>
|
<Setter Property="Padding" Value="0"/>
|
||||||
<Setter Property="Margin" Value="0,2"/>
|
<Setter Property="Margin" Value="0,3"/>
|
||||||
<Setter Property="Background" Value="Transparent"/>
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}"/>
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}"/>
|
||||||
|
<Setter Property="MinHeight" Value="44"/>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBoxItem.category-item:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="ListBoxItem.category-item:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}"/>
|
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonHoverBackgroundBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBoxItem.category-item:selected /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="ListBoxItem.category-item:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}"/>
|
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}"/>
|
||||||
</Style>
|
</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">
|
<Style Selector="ListBoxItem.category-item TextBlock.category-text">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
@@ -25,27 +32,65 @@
|
|||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
</Style>
|
</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>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="Auto,*">
|
<Grid ColumnDefinitions="190,*">
|
||||||
<Border Width="280" Background="Transparent">
|
<Border Background="Transparent">
|
||||||
<Grid RowDefinitions="*,Auto">
|
<Grid RowDefinitions="*,Auto">
|
||||||
<ListBox x:Name="CategoryListBox"
|
<ListBox x:Name="CategoryListBox"
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
Margin="8,8,4,0"
|
Margin="0,0,14,0"
|
||||||
SelectionChanged="OnCategorySelectionChanged"
|
SelectionChanged="OnCategorySelectionChanged"
|
||||||
ItemsSource="{Binding Categories}">
|
ItemsSource="{Binding Categories}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
|
<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}"
|
<fi:FluentIcon Icon="{Binding Icon}"
|
||||||
|
Grid.Column="1"
|
||||||
IconVariant="Regular"
|
IconVariant="Regular"
|
||||||
FontSize="18"/>
|
FontSize="16"
|
||||||
<TextBlock Grid.Column="1"
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
Classes="category-text"
|
Classes="category-text"
|
||||||
Text="{Binding Title}"/>
|
Text="{Binding Title}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -53,17 +98,17 @@
|
|||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
|
||||||
<StackPanel Grid.Row="1" Margin="12,8,8,12">
|
<StackPanel Grid.Row="1" Margin="0,8,14,4">
|
||||||
<Border Height="1"
|
<Border Height="1"
|
||||||
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||||
Opacity="0.4"
|
Opacity="0.4"
|
||||||
Margin="0,0,0,8"/>
|
Margin="0,0,0,8"/>
|
||||||
<Button Classes="hyperlink"
|
<Button Classes="fused-library-link"
|
||||||
HorizontalAlignment="Left"
|
HorizontalAlignment="Left"
|
||||||
Click="OnFindMoreComponentsClick">
|
Click="OnFindMoreComponentsClick">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<fi:FluentIcon Icon="Globe" IconVariant="Regular" FontSize="14"/>
|
<fi:FluentIcon Icon="Globe" IconVariant="Regular" FontSize="14"/>
|
||||||
<TextBlock Text="Find More Components"/>
|
<TextBlock Text="查找更多组件" FontSize="12"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -74,55 +119,73 @@
|
|||||||
Width="1"
|
Width="1"
|
||||||
HorizontalAlignment="Left"
|
HorizontalAlignment="Left"
|
||||||
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||||
Opacity="0.5"/>
|
Opacity="0.35"/>
|
||||||
|
|
||||||
<ScrollViewer Grid.Column="1"
|
<ScrollViewer Grid.Column="1"
|
||||||
VerticalScrollBarVisibility="Auto"
|
VerticalScrollBarVisibility="Auto"
|
||||||
HorizontalScrollBarVisibility="Disabled">
|
HorizontalScrollBarVisibility="Disabled">
|
||||||
<StackPanel Margin="16,8,12,8">
|
<StackPanel Margin="28,8,8,10">
|
||||||
<Panel IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}">
|
<Panel IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}">
|
||||||
<Border Classes="surface-translucent-panel"
|
<Grid RowDefinitions="Auto,Auto,*,Auto"
|
||||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
MinHeight="330">
|
||||||
Padding="20">
|
<TextBlock FontSize="24"
|
||||||
<StackPanel Spacing="16">
|
FontWeight="SemiBold"
|
||||||
<TextBlock FontSize="28"
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
FontWeight="SemiBold"
|
Text="{Binding SelectedComponent.DisplayName}"
|
||||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
TextTrimming="CharacterEllipsis"/>
|
||||||
Text="{Binding SelectedComponent.DisplayName}"/>
|
|
||||||
|
|
||||||
|
<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}"
|
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
|
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
|
||||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
Width="420"
|
Padding="12"
|
||||||
Height="300"
|
HorizontalAlignment="Center"
|
||||||
HorizontalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<ContentControl x:Name="SelectedComponentPreviewHost"
|
<ContentControl x:Name="SelectedComponentPreviewHost"
|
||||||
Margin="16"
|
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
IsHitTestVisible="False"
|
IsHitTestVisible="False"
|
||||||
Focusable="False"/>
|
Focusable="False"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Button HorizontalAlignment="Center"
|
<Button Grid.Row="3"
|
||||||
Classes="accent"
|
HorizontalAlignment="Center"
|
||||||
Padding="24,10"
|
Margin="0,18,0,0"
|
||||||
Tag="{Binding SelectedComponent.ComponentId}"
|
Classes="fused-library-add-button"
|
||||||
Click="OnAddComponentClick">
|
Tag="{Binding SelectedComponent.ComponentId}"
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
Click="OnAddComponentClick">
|
||||||
<fi:FluentIcon Icon="Add" IconVariant="Regular" FontSize="16"/>
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<TextBlock Text="Add Component" FontWeight="SemiBold"/>
|
<fi:FluentIcon Icon="Add" IconVariant="Regular" FontSize="16"/>
|
||||||
</StackPanel>
|
<TextBlock Text="添加" FontWeight="SemiBold"/>
|
||||||
</Button>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Button>
|
||||||
</Border>
|
</Grid>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
|
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
MinHeight="400">
|
MinHeight="330">
|
||||||
<StackPanel Spacing="16"
|
<StackPanel Spacing="16"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
@@ -134,7 +197,7 @@
|
|||||||
<TextBlock HorizontalAlignment="Center"
|
<TextBlock HorizontalAlignment="Center"
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
Text="Select a component to view its details."/>
|
Text="选择一个分类以查看可添加组件。"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
|||||||
var categoryComponents = _allDefinitions
|
var categoryComponents = _allDefinitions
|
||||||
.Where(definition => string.Equals(definition.Category, category, StringComparison.OrdinalIgnoreCase))
|
.Where(definition => string.Equals(definition.Category, category, StringComparison.OrdinalIgnoreCase))
|
||||||
.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||||
.Select(CreateComponentItem)
|
.Select(definition => CreateComponentItem(definition, languageCode))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
_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, "Weather", StringComparison.OrdinalIgnoreCase)) return Symbol.WeatherSunny;
|
||||||
if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) return Symbol.Edit;
|
if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) return Symbol.Edit;
|
||||||
if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) return Symbol.Play;
|
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, "Calculator", StringComparison.OrdinalIgnoreCase)) return Symbol.Calculator;
|
||||||
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) return Symbol.Hourglass;
|
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) return Symbol.Hourglass;
|
||||||
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) return Symbol.Folder;
|
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);
|
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)
|
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)
|
_viewModel.SelectedComponent = selectedCategory.Components.FirstOrDefault(component => component.ComponentId == firstComponent.Id)
|
||||||
?? CreateComponentItem(firstComponent);
|
?? CreateComponentItem(firstComponent, _settingsFacade.Region.Get().LanguageCode);
|
||||||
SetSelectedPreviewControl(CreateStaticPreviewControl(firstComponent));
|
SetSelectedPreviewControl(CreateStaticPreviewControl(firstComponent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,62 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:controls="using:LanMountainDesktop.Views"
|
xmlns:controls="using:LanMountainDesktop.Views"
|
||||||
xmlns:fi="using:FluentIcons.Avalonia"
|
|
||||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
|
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
|
||||||
Width="860"
|
Width="740"
|
||||||
Height="620"
|
Height="500"
|
||||||
MinWidth="600"
|
MinWidth="600"
|
||||||
MinHeight="500"
|
MinHeight="440"
|
||||||
CanResize="True"
|
CanResize="True"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
WindowDecorations="BorderOnly"
|
WindowDecorations="None"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
ExtendClientAreaTitleBarHeightHint="48"
|
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
Title="Add Component">
|
Title="Add Component">
|
||||||
|
|
||||||
<Grid x:Name="RootGrid"
|
<Grid x:Name="RootGrid"
|
||||||
Classes="settings-scope"
|
Classes="settings-scope"
|
||||||
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
|
Background="Transparent">
|
||||||
RowDefinitions="Auto,*">
|
<Border x:Name="PanelShell"
|
||||||
<Border x:Name="WindowTitleBarHost"
|
Classes="surface-translucent-strong"
|
||||||
Height="48"
|
Width="720"
|
||||||
Padding="12,0,12,0"
|
MaxWidth="720"
|
||||||
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
|
HorizontalAlignment="Center"
|
||||||
BorderBrush="{DynamicResource AdaptiveSettingsWindowBorderBrush}"
|
VerticalAlignment="Center"
|
||||||
BorderThickness="0,0,0,1"
|
Padding="0"
|
||||||
PointerPressed="OnWindowTitleBarPointerPressed">
|
ClipToBounds="True">
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto"
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
ColumnSpacing="8"
|
<Border Height="64"
|
||||||
VerticalAlignment="Center">
|
Padding="24,0,24,0"
|
||||||
<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"
|
Background="Transparent"
|
||||||
BorderThickness="0"
|
PointerPressed="OnWindowTitleBarPointerPressed">
|
||||||
Click="OnCloseClick">
|
<TextBlock VerticalAlignment="Center"
|
||||||
<fi:FluentIcon Icon="Dismiss"
|
FontSize="22"
|
||||||
IconVariant="Regular"
|
FontWeight="SemiBold"
|
||||||
FontSize="16" />
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
</Button>
|
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>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
|
|
||||||
Grid.Row="1"
|
|
||||||
Margin="12,8,16,8" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,50 +1,55 @@
|
|||||||
using System;
|
using System;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 融合桌面组件库窗口 - 专门用于添加组件到系统桌面(负一屏)
|
|
||||||
///
|
|
||||||
/// 注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面
|
|
||||||
/// </summary>
|
|
||||||
public partial class FusedDesktopComponentLibraryWindow : Window
|
public partial class FusedDesktopComponentLibraryWindow : Window
|
||||||
{
|
{
|
||||||
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
|
||||||
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
|
||||||
private TransparentOverlayWindow? _overlayWindow;
|
private TransparentOverlayWindow? _overlayWindow;
|
||||||
|
|
||||||
// 与 TransparentOverlayWindow 保持一致的默认 cellSize
|
|
||||||
private const double DefaultCellSize = 100;
|
|
||||||
|
|
||||||
public FusedDesktopComponentLibraryWindow()
|
public FusedDesktopComponentLibraryWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
LibraryControl.AddComponentRequested += OnAddComponentRequested;
|
LibraryControl.AddComponentRequested += OnAddComponentRequested;
|
||||||
|
KeyDown += OnWindowKeyDown;
|
||||||
|
|
||||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||||
mainWindow?.RegisterFusedLibraryWindow(this);
|
mainWindow?.RegisterFusedLibraryWindow(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public bool PreserveEditModeOnClose { get; private set; }
|
||||||
/// 设置透明覆盖层窗口引用
|
|
||||||
/// </summary>
|
|
||||||
public void SetOverlayWindow(TransparentOverlayWindow overlayWindow)
|
public void SetOverlayWindow(TransparentOverlayWindow overlayWindow)
|
||||||
{
|
{
|
||||||
_overlayWindow = overlayWindow;
|
_overlayWindow = overlayWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public void CenterInWorkArea(Window? referenceWindow = null)
|
||||||
/// 添加组件请求处理 - 将组件放置在屏幕(覆盖层画布)中央
|
{
|
||||||
/// </summary>
|
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)
|
private void OnAddComponentRequested(object? sender, string componentId)
|
||||||
{
|
{
|
||||||
if (_overlayWindow is null)
|
if (_overlayWindow is null)
|
||||||
@@ -53,54 +58,16 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算组件的像素尺寸
|
_overlayWindow.AddComponentToCenter(componentId);
|
||||||
var (componentWidth, componentHeight) = ResolveComponentSize(componentId);
|
AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' at fused desktop grid center.");
|
||||||
|
|
||||||
// 取覆盖层画布的中心点,减去组件半尺寸,使组件出现在屏幕正中央
|
PreserveEditModeOnClose = true;
|
||||||
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}).");
|
|
||||||
|
|
||||||
// 关闭窗口
|
|
||||||
Close();
|
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)
|
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
PreserveEditModeOnClose = false;
|
||||||
Close();
|
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)
|
protected override void OnClosed(EventArgs e)
|
||||||
{
|
{
|
||||||
|
LibraryControl.AddComponentRequested -= OnAddComponentRequested;
|
||||||
|
KeyDown -= OnWindowKeyDown;
|
||||||
base.OnClosed(e);
|
base.OnClosed(e);
|
||||||
|
|
||||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||||
mainWindow?.UnregisterFusedLibraryWindow(this);
|
mainWindow?.UnregisterFusedLibraryWindow(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ using FluentAvalonia.UI.Controls;
|
|||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Update;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
using LanMountainDesktop.Views.Components;
|
using LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
@@ -475,28 +476,14 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void TriggerAutoUpdateCheckIfEnabled()
|
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(
|
DispatcherTimer.RunOnce(
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await HostUpdateWorkflowServiceProvider
|
await HostUpdateOrchestratorProvider
|
||||||
.GetOrCreate()
|
.GetOrCreate()
|
||||||
.AutoCheckIfEnabledAsync(normalizedVersion);
|
.AutoCheckIfEnabledAsync(default);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
xmlns:controls="using:LanMountainDesktop.Controls"
|
|
||||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
xmlns:fi="using:FluentIcons.Avalonia"
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
x:Class="LanMountainDesktop.Views.SettingsPages.UpdateSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.UpdateSettingsPage"
|
||||||
x:DataType="vm:UpdateSettingsPageViewModel">
|
x:DataType="vm:UpdateSettingsViewModel">
|
||||||
<UserControl.Styles>
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<Style Selector="Border.update-status-card">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<Setter Property="Padding" Value="24" />
|
<StackPanel Spacing="6">
|
||||||
<Setter Property="Margin" Value="0,0,0,18" />
|
<TextBlock Classes="settings-section-title"
|
||||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
|
Text="Update" />
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
<TextBlock Classes="settings-section-description"
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
Text="Check for updates, watch download and install progress, and keep the update workflow recoverable from this page." />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
</StackPanel>
|
||||||
<Setter Property="BoxShadow" Value="0 6 18 #15000000" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="TextBlock.update-kv-label">
|
<Border Classes="settings-section-card">
|
||||||
<Setter Property="FontSize" Value="12" />
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
<Setter Property="Opacity" Value="0.68" />
|
ColumnSpacing="16">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
<Border Classes="settings-section-card-icon-host"
|
||||||
<Setter Property="TextWrapping" Value="Wrap" />
|
Width="56"
|
||||||
<Setter Property="MaxWidth" Value="200" />
|
Height="56"
|
||||||
</Style>
|
Padding="10">
|
||||||
|
<fi:SymbolIcon Symbol="ArrowSync"
|
||||||
|
FontSize="22" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Style Selector="TextBlock.update-kv-value">
|
<StackPanel Grid.Column="1"
|
||||||
<Setter Property="FontSize" Value="14" />
|
Spacing="6"
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
VerticalAlignment="Center">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
<TextBlock Classes="settings-card-header"
|
||||||
<Setter Property="TextWrapping" Value="Wrap" />
|
Text="Current update status" />
|
||||||
<Setter Property="MaxWidth" Value="200" />
|
<TextBlock Classes="settings-card-description"
|
||||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
Text="The status line below reflects the current update phase and any contextual message returned by the orchestrator." />
|
||||||
</Style>
|
<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">
|
<StackPanel Grid.Column="2"
|
||||||
<Setter Property="FontSize" Value="13" />
|
Spacing="10"
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
VerticalAlignment="Center">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
<Button Classes="settings-accent-button"
|
||||||
<Setter Property="TextWrapping" Value="Wrap" />
|
Content="Check for updates"
|
||||||
</Style>
|
Command="{Binding CheckCommand}" />
|
||||||
</UserControl.Styles>
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding LastCheckedText}"
|
||||||
|
TextAlignment="Right"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Width="220" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<Border Classes="settings-section-card">
|
||||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
<StackPanel Spacing="12">
|
||||||
<Border Classes="update-status-card">
|
<TextBlock Classes="settings-card-header"
|
||||||
<StackPanel Spacing="18">
|
Text="Release facts" />
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
<TextBlock Classes="settings-card-description"
|
||||||
ColumnSpacing="16">
|
Text="Keep the current version, published release, and update type visible without collapsing the layout while states change." />
|
||||||
<Border Classes="settings-section-card-icon-host"
|
|
||||||
Width="48"
|
|
||||||
Height="48">
|
|
||||||
<Viewbox Stretch="Uniform">
|
|
||||||
<fi:SymbolIcon Symbol="ArrowSync" />
|
|
||||||
</Viewbox>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<StackPanel Grid.Column="1"
|
<Grid RowDefinitions="Auto,Auto,Auto"
|
||||||
Spacing="4">
|
ColumnDefinitions="*,*"
|
||||||
<TextBlock Classes="settings-card-header"
|
ColumnSpacing="12"
|
||||||
Margin="0"
|
RowSpacing="12">
|
||||||
Text="{Binding StatusCardTitle}" />
|
<Border Classes="settings-list-item">
|
||||||
<TextBlock Classes="settings-item-description"
|
<StackPanel Classes="settings-item">
|
||||||
Text="{Binding StatusCardDescription}" />
|
<TextBlock Classes="settings-item-label"
|
||||||
</StackPanel>
|
Text="Current version" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
<Button Grid.Column="2"
|
Text="{Binding CurrentVersionText}"
|
||||||
Classes="settings-accent-button"
|
TextWrapping="Wrap" />
|
||||||
Command="{Binding CheckForUpdatesCommand}"
|
</StackPanel>
|
||||||
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>
|
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Classes="settings-subsection-title"
|
<Border Grid.Column="1"
|
||||||
Text="{Binding PreferencesHeader}" />
|
Classes="settings-list-item">
|
||||||
<TextBlock Classes="settings-section-description"
|
<StackPanel Classes="settings-item">
|
||||||
Margin="0,0,0,18"
|
<TextBlock Classes="settings-item-label"
|
||||||
Text="{Binding PreferencesDescription}" />
|
Text="Latest version" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding LatestVersionText}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<ui:FASettingsExpander Classes="settings-expander-card"
|
<Border Grid.Row="1"
|
||||||
Header="{Binding UpdateChannelLabel}"
|
Classes="settings-list-item">
|
||||||
Description="{Binding SelectedUpdateChannelDescription}">
|
<StackPanel Classes="settings-item">
|
||||||
<ui:FASettingsExpander.IconSource>
|
<TextBlock Classes="settings-item-label"
|
||||||
<ui:FAFontIconSource Glyph="󰛈" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
Text="Published at" />
|
||||||
</ui:FASettingsExpander.IconSource>
|
<TextBlock Classes="settings-item-description"
|
||||||
<ui:FASettingsExpander.Footer>
|
Text="{Binding PublishedAtText}"
|
||||||
<ComboBox Width="220"
|
TextWrapping="Wrap" />
|
||||||
ItemsSource="{Binding UpdateChannelOptions}"
|
</StackPanel>
|
||||||
SelectedItem="{Binding SelectedUpdateChannelOption}">
|
</Border>
|
||||||
<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"
|
||||||
|
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>
|
</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>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Services.Update;
|
||||||
using LanMountainDesktop.ViewModels;
|
using LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views.SettingsPages;
|
namespace LanMountainDesktop.Views.SettingsPages;
|
||||||
@@ -15,16 +16,18 @@ namespace LanMountainDesktop.Views.SettingsPages;
|
|||||||
public partial class UpdateSettingsPage : SettingsPageBase
|
public partial class UpdateSettingsPage : SettingsPageBase
|
||||||
{
|
{
|
||||||
public UpdateSettingsPage()
|
public UpdateSettingsPage()
|
||||||
: this(new UpdateSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
|
: this(new UpdateSettingsViewModel(
|
||||||
|
HostUpdateOrchestratorProvider.GetOrCreate(),
|
||||||
|
HostSettingsFacadeProvider.GetOrCreate()))
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public UpdateSettingsPage(UpdateSettingsPageViewModel viewModel)
|
public UpdateSettingsPage(UpdateSettingsViewModel viewModel)
|
||||||
{
|
{
|
||||||
ViewModel = viewModel;
|
ViewModel = viewModel;
|
||||||
DataContext = ViewModel;
|
DataContext = ViewModel;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public UpdateSettingsPageViewModel ViewModel { get; }
|
public UpdateSettingsViewModel ViewModel { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,55 @@
|
|||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
Title="LanMountainDesktop Fused Desktop">
|
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>
|
||||||
|
|
||||||
特性:
|
<Grid x:Name="OverlayRoot">
|
||||||
- 窗口置底(在桌面图标层显示)
|
<Canvas x:Name="ComponentCanvas"
|
||||||
- 区域级穿透(组件区域可交互,其他区域穿透)
|
Background="#01000000"
|
||||||
- 组件可自由拖拽摆放
|
PointerPressed="OnCanvasPointerPressed" />
|
||||||
- 三指/右键左滑回到阗山桌面第一页
|
|
||||||
-->
|
<Border x:Name="EditToolbar"
|
||||||
<Canvas x:Name="ComponentCanvas">
|
Classes="fused-desktop-edit-toolbar"
|
||||||
<!-- 组件将动态添加到这里 -->
|
HorizontalAlignment="Center"
|
||||||
</Canvas>
|
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>
|
</Window>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user