Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2ac302ee7 | ||
|
|
c351a8e7f3 | ||
|
|
21e970c5b6 | ||
|
|
17873f0f43 | ||
|
|
4051b5cd74 | ||
|
|
5be4537b2c | ||
|
|
c5e75244af | ||
|
|
6a650873bc |
296
.github/workflows/plonds-comparator.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: PLONDS Comparator
|
name: PLONDS Comparator
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
|
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
|
||||||
@@ -9,7 +9,6 @@ on:
|
|||||||
types:
|
types:
|
||||||
- published
|
- published
|
||||||
- prereleased
|
- prereleased
|
||||||
- edited
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
@@ -17,7 +16,7 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
baseline_tag:
|
baseline_tag:
|
||||||
description: 'Optional baseline tag'
|
description: 'Optional baseline tag (auto-detected if omitted)'
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
channel:
|
channel:
|
||||||
@@ -28,12 +27,28 @@ on:
|
|||||||
options:
|
options:
|
||||||
- stable
|
- stable
|
||||||
- preview
|
- preview
|
||||||
|
compare_method:
|
||||||
|
description: 'Compare method'
|
||||||
|
required: false
|
||||||
|
type: choice
|
||||||
|
default: file-compare
|
||||||
|
options:
|
||||||
|
- file-compare
|
||||||
|
- commit-analyze
|
||||||
|
hash_algorithm:
|
||||||
|
description: 'Hash algorithm (file-compare only)'
|
||||||
|
required: false
|
||||||
|
type: choice
|
||||||
|
default: sha256
|
||||||
|
options:
|
||||||
|
- sha256
|
||||||
|
- md5
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
compare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -48,6 +63,7 @@ jobs:
|
|||||||
- name: Resolve release context
|
- name: Resolve release context
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
if [[ "${{ github.event_name }}" == "release" ]]; then
|
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||||
TAG="${{ github.event.release.tag_name }}"
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
|
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
|
||||||
@@ -55,7 +71,9 @@ jobs:
|
|||||||
else
|
else
|
||||||
CHANNEL="stable"
|
CHANNEL="stable"
|
||||||
fi
|
fi
|
||||||
BASELINE_TAG=""
|
BASELINE_TAG_INPUT=""
|
||||||
|
COMPARE_METHOD="file-compare"
|
||||||
|
HASH_ALGORITHM="sha256"
|
||||||
else
|
else
|
||||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||||
if [[ "${RAW_TAG}" == v* ]]; then
|
if [[ "${RAW_TAG}" == v* ]]; then
|
||||||
@@ -64,18 +82,17 @@ jobs:
|
|||||||
TAG="v${RAW_TAG}"
|
TAG="v${RAW_TAG}"
|
||||||
fi
|
fi
|
||||||
CHANNEL="${{ github.event.inputs.channel }}"
|
CHANNEL="${{ github.event.inputs.channel }}"
|
||||||
BASELINE_TAG="${{ github.event.inputs.baseline_tag }}"
|
BASELINE_TAG_INPUT="${{ github.event.inputs.baseline_tag }}"
|
||||||
|
COMPARE_METHOD="${{ github.event.inputs.compare_method }}"
|
||||||
|
HASH_ALGORITHM="${{ github.event.inputs.hash_algorithm }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||||
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
|
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
|
||||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||||
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
|
echo "BASELINE_TAG_INPUT=${BASELINE_TAG_INPUT}" >> "$GITHUB_ENV"
|
||||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
echo "COMPARE_METHOD=${COMPARE_METHOD}" >> "$GITHUB_ENV"
|
||||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
echo "HASH_ALGORITHM=${HASH_ALGORITHM}" >> "$GITHUB_ENV"
|
||||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
|
||||||
fi
|
|
||||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE%/}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
@@ -83,194 +100,159 @@ jobs:
|
|||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
dotnet-quality: preview
|
dotnet-quality: preview
|
||||||
|
|
||||||
- name: Prepare signing key
|
|
||||||
env:
|
|
||||||
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
|
||||||
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
KEY="${PLONDS_SIGNING_KEY:-}"
|
|
||||||
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
|
|
||||||
if [[ -z "$KEY" ]]; then
|
|
||||||
echo "No signing key is configured."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$KEY" > update-private-key.pem
|
|
||||||
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build PLONDS tool
|
- name: Build PLONDS tool
|
||||||
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||||
|
|
||||||
- name: Resolve baseline plan
|
- name: Resolve baseline
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: pwsh
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
set -euo pipefail
|
||||||
$repo = '${{ github.repository }}'
|
BASELINE_TAG=""
|
||||||
$tag = $env:RELEASE_TAG
|
BASELINE_VERSION=""
|
||||||
$baselineInput = $env:BASELINE_TAG_INPUT
|
|
||||||
$currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json
|
|
||||||
$allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json
|
|
||||||
$platforms = @('windows-x64', 'windows-x86', 'linux-x64')
|
|
||||||
|
|
||||||
$entries = foreach ($platform in $platforms) {
|
if [[ -n "$BASELINE_TAG_INPUT" ]]; then
|
||||||
$assetName = "files-$platform.zip"
|
NORMALIZED="$BASELINE_TAG_INPUT"
|
||||||
$currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
|
if [[ "$NORMALIZED" != v* ]]; then NORMALIZED="v$NORMALIZED"; fi
|
||||||
if (-not $currentAsset) {
|
if gh release view "$NORMALIZED" --repo "${{ github.repository }}" --json tagName >/dev/null 2>&1; then
|
||||||
throw "Current release $tag does not contain required asset $assetName"
|
BASELINE_TAG="$NORMALIZED"
|
||||||
}
|
BASELINE_VERSION="${NORMALIZED#v}"
|
||||||
|
else
|
||||||
|
echo "Specified baseline tag not found: $NORMALIZED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
IS_PRERELEASE="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
|
||||||
|
CANDIDATES="$(gh api "repos/${{ github.repository }}/releases?per_page=50" \
|
||||||
|
--jq ".[] | select(.draft == false and .prerelease == ${IS_PRERELEASE} and .tag_name != \"${RELEASE_TAG}\") | .tag_name")"
|
||||||
|
|
||||||
$baselineRelease = $null
|
for CANDIDATE in $CANDIDATES; do
|
||||||
if (-not [string]::IsNullOrWhiteSpace($baselineInput)) {
|
if gh release download "$CANDIDATE" -p "files-windows-x64.zip" -D /tmp/baseline-check --clobber 2>/dev/null; then
|
||||||
$normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" }
|
BASELINE_TAG="$CANDIDATE"
|
||||||
$baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1
|
BASELINE_VERSION="${CANDIDATE#v}"
|
||||||
if (-not $baselineRelease) {
|
rm -rf /tmp/baseline-check
|
||||||
throw "Specified baseline tag not found: $normalizedBaseline"
|
break
|
||||||
}
|
fi
|
||||||
}
|
done
|
||||||
else {
|
fi
|
||||||
$baselineRelease = $allReleases |
|
|
||||||
Where-Object {
|
|
||||||
$_.tag_name -ne $tag -and
|
|
||||||
-not $_.draft -and
|
|
||||||
[bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and
|
|
||||||
($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0
|
|
||||||
} |
|
|
||||||
Select-Object -First 1
|
|
||||||
}
|
|
||||||
|
|
||||||
[pscustomobject]@{
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
platform = $platform
|
echo "BASELINE_TAG=${BASELINE_TAG}" >> "$GITHUB_ENV"
|
||||||
assetName = $assetName
|
echo "BASELINE_VERSION=${BASELINE_VERSION}" >> "$GITHUB_ENV"
|
||||||
baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null }
|
echo "Resolved baseline: ${BASELINE_TAG}"
|
||||||
baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null }
|
else
|
||||||
isFullPayload = -not $baselineRelease
|
echo "No baseline found. This will be a full update."
|
||||||
}
|
fi
|
||||||
}
|
|
||||||
|
|
||||||
$plan = [pscustomobject]@{
|
|
||||||
tag = $tag
|
|
||||||
version = $env:RELEASE_VERSION
|
|
||||||
channel = $env:RELEASE_CHANNEL
|
|
||||||
platforms = $entries
|
|
||||||
}
|
|
||||||
|
|
||||||
$plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8
|
|
||||||
Get-Content plonds-plan.json
|
|
||||||
|
|
||||||
- name: Download payload zips
|
- name: Download payload zips
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: pwsh
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
set -euo pipefail
|
||||||
$repo = '${{ github.repository }}'
|
mkdir -p plonds-input
|
||||||
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
|
|
||||||
|
|
||||||
foreach ($entry in $plan.platforms) {
|
gh release download "$RELEASE_TAG" -p "files-windows-x64.zip" -D plonds-input
|
||||||
$currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)"
|
mv plonds-input/files-windows-x64.zip plonds-input/current-files-windows-x64.zip
|
||||||
New-Item -ItemType Directory -Path $currentDir -Force | Out-Null
|
|
||||||
gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir
|
|
||||||
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) {
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
$baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)"
|
gh release download "$BASELINE_TAG" -p "files-windows-x64.zip" -D plonds-input
|
||||||
New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null
|
mv plonds-input/files-windows-x64.zip plonds-input/baseline-files-windows-x64.zip
|
||||||
gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir
|
fi
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Build delta assets
|
- name: Run build-delta (file-compare)
|
||||||
shell: pwsh
|
if: env.COMPARE_METHOD == 'file-compare'
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
set -euo pipefail
|
||||||
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
|
mkdir -p plonds-output
|
||||||
foreach ($entry in $plan.platforms) {
|
|
||||||
$currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)"
|
ARGS=(
|
||||||
$args = @(
|
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
|
||||||
'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--',
|
'--configuration' 'Release' '--'
|
||||||
'build-delta',
|
'build-delta'
|
||||||
'--platform', $entry.platform,
|
'--platform' 'windows-x64'
|
||||||
'--current-version', $plan.version,
|
'--current-version' "$RELEASE_VERSION"
|
||||||
'--current-tag', $plan.tag,
|
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
|
||||||
'--current-zip', $currentZip,
|
'--output-dir' "$PWD/plonds-output"
|
||||||
'--output-dir', 'plonds-output',
|
'--channel' "$RELEASE_CHANNEL"
|
||||||
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
|
'--hash-algorithm' "$HASH_ALGORITHM"
|
||||||
'--channel', $plan.channel,
|
|
||||||
'--static-output-dir', 'plonds-output/static',
|
|
||||||
'--update-base-url', $env:S3_PUBLIC_BASE_URL
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if ([bool]$entry.isFullPayload) {
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
$args += @('--is-full-payload', 'true')
|
ARGS+=(
|
||||||
}
|
'--baseline-version' "$BASELINE_VERSION"
|
||||||
else {
|
'--baseline-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
|
||||||
$baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)"
|
)
|
||||||
$args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip)
|
fi
|
||||||
}
|
|
||||||
|
|
||||||
dotnet @args
|
dotnet "${ARGS[@]}"
|
||||||
}
|
|
||||||
|
|
||||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- `
|
- name: Run build-delta-from-commits (commit-analyze)
|
||||||
build-index `
|
if: env.COMPARE_METHOD == 'commit-analyze'
|
||||||
--release-tag $plan.tag `
|
shell: bash
|
||||||
--version $plan.version `
|
run: |
|
||||||
--channel $plan.channel `
|
set -euo pipefail
|
||||||
--platform-summaries-dir plonds-output/platform-summaries `
|
mkdir -p plonds-output
|
||||||
--output-dir plonds-output `
|
|
||||||
--private-key $env:UPDATE_PRIVATE_KEY_PATH
|
|
||||||
|
|
||||||
foreach ($entry in $plan.platforms) {
|
ARGS=(
|
||||||
$summary = Get-Content "plonds-output/platform-summaries/platform-summary-$($entry.platform).json" | ConvertFrom-Json
|
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
|
||||||
$required = @(
|
'--configuration' 'Release' '--'
|
||||||
"plonds-output/static/meta/channels/$($plan.channel)/$($entry.platform)/latest.json",
|
'build-delta-from-commits'
|
||||||
"plonds-output/static/meta/distributions/$($summary.distributionId).json",
|
'--platform' 'windows-x64'
|
||||||
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json",
|
'--current-version' "$RELEASE_VERSION"
|
||||||
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json.sig"
|
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
|
||||||
|
'--output-dir' "$PWD/plonds-output"
|
||||||
|
'--channel' "$RELEASE_CHANNEL"
|
||||||
|
'--baseline-tag' "${BASELINE_TAG:-$RELEASE_TAG}"
|
||||||
|
'--current-tag' "$RELEASE_TAG"
|
||||||
|
'--hash-algorithm' "$HASH_ALGORITHM"
|
||||||
)
|
)
|
||||||
|
|
||||||
foreach ($path in $required) {
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
if (-not (Test-Path $path)) {
|
ARGS+=(
|
||||||
throw "Missing PLONDS static output: $path"
|
'--fallback-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
|
||||||
}
|
)
|
||||||
}
|
fi
|
||||||
}
|
|
||||||
|
|
||||||
$objects = Get-ChildItem -Path "plonds-output/static/repo/sha256" -File -Recurse -ErrorAction SilentlyContinue
|
dotnet "${ARGS[@]}"
|
||||||
if (-not $objects -or $objects.Count -eq 0) {
|
|
||||||
throw "PLONDS static object repository is empty."
|
|
||||||
}
|
|
||||||
|
|
||||||
Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force
|
- name: Validate output
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ ! -f plonds-output/changed.zip ]]; then
|
||||||
|
echo "Missing output: changed.zip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f plonds-output/PLONDS.json ]]; then
|
||||||
|
echo "Missing output: PLONDS.json"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
jq -e . plonds-output/PLONDS.json >/dev/null
|
||||||
|
|
||||||
- name: Upload PLONDS assets to release
|
- name: Upload to GitHub Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber
|
gh release upload "$RELEASE_TAG" plonds-output/changed.zip plonds-output/PLONDS.json --clobber
|
||||||
|
|
||||||
- name: Persist run metadata
|
- name: Persist run metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mkdir -p plonds-run-metadata
|
mkdir -p plonds-run-metadata
|
||||||
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
|
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
|
||||||
|
printf '%s' "$COMPARE_METHOD" > plonds-run-metadata/compare-method.txt
|
||||||
|
|
||||||
- name: Upload run metadata artifact
|
- name: Upload run metadata artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: plonds-run-metadata
|
name: plonds-run-metadata
|
||||||
path: plonds-run-metadata/tag.txt
|
path: |
|
||||||
if-no-files-found: error
|
plonds-run-metadata/tag.txt
|
||||||
retention-days: 7
|
plonds-run-metadata/compare-method.txt
|
||||||
|
|
||||||
- name: Upload PLONDS static artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: plonds-static
|
|
||||||
path: plonds-output/static/**
|
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|||||||
97
.github/workflows/release.yml
vendored
@@ -185,6 +185,29 @@ jobs:
|
|||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Publish AirAppRuntime
|
||||||
|
run: |
|
||||||
|
$arch = "${{ matrix.arch }}"
|
||||||
|
$publishDir = "publish/airapp-runtime-win-$arch"
|
||||||
|
|
||||||
|
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj `
|
||||||
|
-c Release `
|
||||||
|
-o ./$publishDir `
|
||||||
|
--self-contained:false `
|
||||||
|
-r win-$arch `
|
||||||
|
-p:SelfContained=false `
|
||||||
|
-p:PublishAot=false `
|
||||||
|
-p:PublishSingleFile=false `
|
||||||
|
-p:PublishTrimmed=false `
|
||||||
|
-p:PublishReadyToRun=false `
|
||||||
|
-p:DebugType=none `
|
||||||
|
-p:DebugSymbols=false `
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} `
|
||||||
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
- name: Publish AirAppHost
|
- name: Publish AirAppHost
|
||||||
run: |
|
run: |
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
@@ -215,6 +238,7 @@ jobs:
|
|||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$publishDir = "publish/windows-$arch"
|
$publishDir = "publish/windows-$arch"
|
||||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||||
|
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
|
||||||
$appDir = "app-$version"
|
$appDir = "app-$version"
|
||||||
$newStructure = "publish-launcher/windows-$arch"
|
$newStructure = "publish-launcher/windows-$arch"
|
||||||
|
|
||||||
@@ -226,10 +250,15 @@ jobs:
|
|||||||
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Test-Path $runtimePublishDir) {
|
||||||
|
Copy-Item -Path "$runtimePublishDir\*" -Destination $newStructure -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||||
|
|
||||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Path $runtimePublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
@@ -253,6 +282,7 @@ jobs:
|
|||||||
|
|
||||||
$requiredFiles = @(
|
$requiredFiles = @(
|
||||||
(Join-Path $publishDir "LanMountainDesktop.Launcher.exe"),
|
(Join-Path $publishDir "LanMountainDesktop.Launcher.exe"),
|
||||||
|
(Join-Path $publishDir "LanMountainDesktop.AirAppRuntime.exe"),
|
||||||
(Join-Path $appDir "LanMountainDesktop.exe"),
|
(Join-Path $appDir "LanMountainDesktop.exe"),
|
||||||
(Join-Path $appDir "LanMountainDesktop.AirAppHost.exe")
|
(Join-Path $appDir "LanMountainDesktop.AirAppHost.exe")
|
||||||
)
|
)
|
||||||
@@ -462,12 +492,32 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
|
- name: Publish AirAppRuntime
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||||
|
-c Release \
|
||||||
|
-o ./publish/airapp-runtime-linux-x64 \
|
||||||
|
--self-contained false \
|
||||||
|
-r linux-x64 \
|
||||||
|
-p:SelfContained=false \
|
||||||
|
-p:PublishAot=false \
|
||||||
|
-p:PublishSingleFile=false \
|
||||||
|
-p:PublishTrimmed=false \
|
||||||
|
-p:PublishReadyToRun=false \
|
||||||
|
-p:DebugType=none \
|
||||||
|
-p:DebugSymbols=false \
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||||
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
- name: Restructure for Launcher
|
- name: Restructure for Launcher
|
||||||
run: |
|
run: |
|
||||||
version="${{ needs.prepare.outputs.version }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
publishDir="publish/linux-x64"
|
publishDir="publish/linux-x64"
|
||||||
appDir="app-$version"
|
appDir="app-$version"
|
||||||
launcherDir="publish/launcher-linux-x64"
|
launcherDir="publish/launcher-linux-x64"
|
||||||
|
runtimeDir="publish/airapp-runtime-linux-x64"
|
||||||
|
|
||||||
mkdir -p "$publishDir"
|
mkdir -p "$publishDir"
|
||||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||||
@@ -477,8 +527,13 @@ jobs:
|
|||||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$runtimeDir" ]; then
|
||||||
|
cp -r "$runtimeDir"/* "$publishDir/"
|
||||||
|
chmod +x "$publishDir/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
touch "$publishDir/$appDir/.current"
|
touch "$publishDir/$appDir/.current"
|
||||||
rm -rf "$launcherDir"
|
rm -rf "$launcherDir" "$runtimeDir"
|
||||||
|
|
||||||
- name: Package as DEB
|
- name: Package as DEB
|
||||||
run: |
|
run: |
|
||||||
@@ -637,10 +692,10 @@ jobs:
|
|||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/macos-${{ matrix.arch }}-app \
|
-o ./publish/macos-${{ matrix.arch }}-app \
|
||||||
--self-contained \
|
--self-contained:false \
|
||||||
-r osx-${{ matrix.arch }} \
|
-r osx-${{ matrix.arch }} \
|
||||||
|
-p:SelfContained=false \
|
||||||
-p:PublishSingleFile=false \
|
-p:PublishSingleFile=false \
|
||||||
-p:SelfContained=true \
|
|
||||||
-p:DebugType=none \
|
-p:DebugType=none \
|
||||||
-p:DebugSymbols=false \
|
-p:DebugSymbols=false \
|
||||||
-p:SkipAirAppHostBuild=true \
|
-p:SkipAirAppHostBuild=true \
|
||||||
@@ -651,6 +706,36 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
|
- name: Publish AirAppRuntime
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||||
|
-c Release \
|
||||||
|
-o ./publish/airapp-runtime-macos-${{ matrix.arch }} \
|
||||||
|
--self-contained false \
|
||||||
|
-r osx-${{ matrix.arch }} \
|
||||||
|
-p:SelfContained=false \
|
||||||
|
-p:PublishAot=false \
|
||||||
|
-p:PublishSingleFile=false \
|
||||||
|
-p:PublishTrimmed=false \
|
||||||
|
-p:PublishReadyToRun=false \
|
||||||
|
-p:DebugType=none \
|
||||||
|
-p:DebugSymbols=false \
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||||
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
|
||||||
|
- name: Optimize and Guard macOS Payload
|
||||||
|
run: |
|
||||||
|
arch="${{ matrix.arch }}"
|
||||||
|
publishDir="publish/macos-${arch}-app"
|
||||||
|
|
||||||
|
pwsh ./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 \
|
||||||
|
-PublishDir "$publishDir" \
|
||||||
|
-RuntimeIdentifier "osx-${arch}" \
|
||||||
|
-AssertClean
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Package Payload Zip
|
- name: Package Payload Zip
|
||||||
run: |
|
run: |
|
||||||
release_dir="$PWD/release-assets"
|
release_dir="$PWD/release-assets"
|
||||||
@@ -673,6 +758,7 @@ jobs:
|
|||||||
app_name="LanMountainDesktop"
|
app_name="LanMountainDesktop"
|
||||||
package_name="${app_name}-${version}-macos-${arch}"
|
package_name="${app_name}-${version}-macos-${arch}"
|
||||||
launcherDir="publish/launcher-macos-$arch"
|
launcherDir="publish/launcher-macos-$arch"
|
||||||
|
runtimeDir="publish/airapp-runtime-macos-$arch"
|
||||||
appSourceDir="publish/macos-$arch-app"
|
appSourceDir="publish/macos-$arch-app"
|
||||||
|
|
||||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||||
@@ -685,6 +771,11 @@ jobs:
|
|||||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -d "$runtimeDir" ]; then
|
||||||
|
cp -r "$runtimeDir"/* "${app_name}.app/Contents/MacOS/"
|
||||||
|
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||||
mkdir -p "${app_name}.app/Contents/Resources"
|
mkdir -p "${app_name}.app/Contents/Resources"
|
||||||
|
|
||||||
|
|||||||
9
.trae/specs/air-app-runtime-container/checklist.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Checklist
|
||||||
|
|
||||||
|
- [x] `LanMountainDesktop.AirAppRuntime` is included in `LanMountainDesktop.slnx`.
|
||||||
|
- [x] Launcher no longer hosts `IAirAppLifecycleService`.
|
||||||
|
- [x] Host fallback starts `LanMountainDesktop.AirAppRuntime`, not `LanMountainDesktop.Launcher air-app-broker`.
|
||||||
|
- [x] AirApp Runtime is explicitly non-AOT and framework-dependent.
|
||||||
|
- [x] `dotnet build LanMountainDesktop.slnx -c Debug` passes.
|
||||||
|
- [x] Related AirApp Runtime tests pass.
|
||||||
|
- [x] `dotnet test LanMountainDesktop.slnx -c Debug` passes.
|
||||||
21
.trae/specs/air-app-runtime-container/spec.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# AirApp Runtime Container
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Move built-in Air APP lifecycle management out of Launcher into a dedicated framework-dependent JIT process named `LanMountainDesktop.AirAppRuntime`.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- Launcher remains the user-facing entry point and pre-starts AirApp Runtime during normal `launch`.
|
||||||
|
- AirApp Runtime exposes `IAirAppLifecycleService` and `IAirAppRuntimeControlService` on `LanMountainDesktop.AirAppRuntime.v1`.
|
||||||
|
- Desktop host requests Air APP operations through AirApp Runtime IPC.
|
||||||
|
- If the runtime pipe is unavailable, the desktop host starts `LanMountainDesktop.AirAppRuntime` directly and retries.
|
||||||
|
- AirApp Runtime keeps one AirAppHost process per `{appId}:{sourceComponentId}:{sourcePlacementId}` key, with `world-clock` sharing `world-clock:clock-suite:global`.
|
||||||
|
- AirApp Runtime remains alive while Launcher, Host, requester, or any AirAppHost process is alive.
|
||||||
|
- AirApp Runtime exits after Launcher/Host/requester are gone and no Air APP windows remain.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Moving Air APP windows into the runtime process.
|
||||||
|
- Third-party plugin-declared Air APP metadata.
|
||||||
|
- Persisting the Air APP instance table across OS reboot.
|
||||||
11
.trae/specs/air-app-runtime-container/tasks.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] Add shared AirApp Runtime IPC/control contracts.
|
||||||
|
- [x] Add shared AirApp Runtime path resolver and process starter.
|
||||||
|
- [x] Add `LanMountainDesktop.AirAppRuntime` as a framework-dependent JIT process.
|
||||||
|
- [x] Move Air APP lifecycle service out of Launcher.
|
||||||
|
- [x] Make Launcher pre-start AirApp Runtime and attach Host PID after launch.
|
||||||
|
- [x] Make Host fallback start AirApp Runtime instead of Launcher broker.
|
||||||
|
- [x] Remove Launcher `air-app-broker` command handling.
|
||||||
|
- [x] Update packaging scripts and release workflow to include AirApp Runtime.
|
||||||
|
- [x] Update unit tests and architecture/package assertions.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Checklist
|
# Checklist
|
||||||
|
|
||||||
|
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||||
|
|
||||||
- [x] `LanMountainDesktop.Shared.IPC` builds in Debug.
|
- [x] `LanMountainDesktop.Shared.IPC` builds in Debug.
|
||||||
- [x] `LanMountainDesktop.Launcher` builds in Debug.
|
- [x] `LanMountainDesktop.Launcher` builds in Debug.
|
||||||
- [x] `LanMountainDesktop` builds in Debug.
|
- [x] `LanMountainDesktop` builds in Debug.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Launcher Managed Air APP Lifecycle
|
# Launcher Managed Air APP Lifecycle
|
||||||
|
|
||||||
|
> Superseded by `.trae/specs/air-app-runtime-container/`. Launcher no longer hosts the Air APP lifecycle broker; it pre-starts `LanMountainDesktop.AirAppRuntime`, which owns the lifecycle IPC and AirAppHost process table.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes.
|
Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
|
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||||
|
|
||||||
- [x] Add shared Air APP lifecycle IPC contracts.
|
- [x] Add shared Air APP lifecycle IPC contracts.
|
||||||
- [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
|
- [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
|
||||||
- [x] Make Launcher remain alive while desktop or Air APP processes exist.
|
- [x] Make Launcher remain alive while desktop or Air APP processes exist.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
- [ ] New install shows OOBE once.
|
- [ ] New install shows OOBE once.
|
||||||
- [ ] Same-user reinstall does not show OOBE again.
|
- [ ] Same-user reinstall does not show OOBE again.
|
||||||
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
||||||
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
|
- [ ] `plugin-install` does not auto-enter OOBE.
|
||||||
- [ ] Default plugin install does not request UAC.
|
- [ ] Default plugin install does not request UAC.
|
||||||
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
||||||
- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable).
|
- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable).
|
||||||
|
|||||||
@@ -23,12 +23,11 @@ Stabilize the launcher startup path so that:
|
|||||||
- `launchSource` values are treated as:
|
- `launchSource` values are treated as:
|
||||||
- `normal`
|
- `normal`
|
||||||
- `postinstall`
|
- `postinstall`
|
||||||
- `apply-update`
|
|
||||||
- `plugin-install`
|
- `plugin-install`
|
||||||
- `debug-preview`
|
- `debug-preview`
|
||||||
- Automatic OOBE is allowed only for normal user-mode startup.
|
- Automatic OOBE is allowed only for normal user-mode startup.
|
||||||
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
||||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
- `plugin-install` and `debug-preview` must not auto-enter OOBE.
|
||||||
- Allowed elevation paths are limited to:
|
- Allowed elevation paths are limited to:
|
||||||
- the installer itself
|
- the installer itself
|
||||||
- full installer update application
|
- full installer update application
|
||||||
|
|||||||
512
.trae/specs/plonds-comparator-redesign/spec.md
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
# PLONDS Comparator 改造设计
|
||||||
|
|
||||||
|
> 日期:2026-05-30
|
||||||
|
> 状态:待审批
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
PLONDS(Penguin Logistics Online Network Distribution System)是 LanMountainDesktop 的文件驱动式分布式更新系统。当前 Comparator 工作流存在以下问题:
|
||||||
|
|
||||||
|
1. **产出物过于复杂**:生成 `update-{platform}.zip`、`plonds-filemap-{platform}.json`、`plonds-filemap-{platform}.json.sig`、`platform-summary-{platform}.json`、`plonds-static.zip` 等多个文件,客户端消费困难
|
||||||
|
2. **模型定义重复**:`Plonds.Shared`、`Plonds.Core`、宿主侧、Launcher 侧各自定义独立的 DTO,字段名不一致
|
||||||
|
3. **签名机制过重**:RSA 签名增加了 CI 复杂度(需要管理密钥),且对文件驱动式更新系统而言 SHA256 哈希校验已足够
|
||||||
|
4. **平台覆盖不当**:Linux 平台不需要 PLONDS 支持,macOS 尚未接入,但代码中硬编码了三个平台
|
||||||
|
5. **工作流间 artifact 传递脆弱**:Comparator → Publisher 通过 artifact 传递 `plonds-static.zip`,容易断裂
|
||||||
|
|
||||||
|
## 2. 设计目标
|
||||||
|
|
||||||
|
- 产出物精简为两个文件:`changed.zip` + `PLONDS.json`
|
||||||
|
- 去掉 RSA 签名,只用 SHA256/MD5 校验
|
||||||
|
- 只关注 Windows 平台
|
||||||
|
- 统一模型定义,消除 DTO 重复
|
||||||
|
- 保持 Comparator 和 Publisher 两个工作流的职责分离
|
||||||
|
|
||||||
|
## 3. 新产出物定义
|
||||||
|
|
||||||
|
### 3.1 changed.zip
|
||||||
|
|
||||||
|
只包含与上一版本有差异的文件(action 为 `add` 或 `replace` 的文件),目录结构与部署目录一致。
|
||||||
|
|
||||||
|
### 3.2 PLONDS.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"formatVersion": "2.0",
|
||||||
|
"currentVersion": "1.2.0",
|
||||||
|
"previousVersion": "1.1.0",
|
||||||
|
"isFullUpdate": false,
|
||||||
|
"requiresCleanInstall": false,
|
||||||
|
"channel": "stable",
|
||||||
|
"platform": "windows-x64",
|
||||||
|
"updatedAt": "2026-05-30T12:00:00Z",
|
||||||
|
|
||||||
|
"filesMap": {
|
||||||
|
"LanMountainDesktop.exe": {
|
||||||
|
"action": "replace",
|
||||||
|
"sha256": "abc123...",
|
||||||
|
"size": 1024000
|
||||||
|
},
|
||||||
|
"LanMountainDesktop.dll": {
|
||||||
|
"action": "reuse",
|
||||||
|
"sha256": "def456...",
|
||||||
|
"size": 512000
|
||||||
|
},
|
||||||
|
"OldModule.dll": {
|
||||||
|
"action": "delete",
|
||||||
|
"sha256": "",
|
||||||
|
"size": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"changedFilesMap": {
|
||||||
|
"LanMountainDesktop.exe": {
|
||||||
|
"archivePath": "LanMountainDesktop.exe",
|
||||||
|
"sha256": "abc123...",
|
||||||
|
"size": 1024000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"checksums": {
|
||||||
|
"changed.zip": "md5:9a8b7c6d..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 字段语义
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `formatVersion` | string | 协议版本,固定 `"2.0"` |
|
||||||
|
| `currentVersion` | string | 当前发布版本 |
|
||||||
|
| `previousVersion` | string | 基线版本(全量更新时为 `"0.0.0"`) |
|
||||||
|
| `isFullUpdate` | bool | 是否为全量更新(找不到基线版本时为 true) |
|
||||||
|
| `requiresCleanInstall` | bool | 启动器是否也更新了——如果是,客户端不走增量流程,让用户重新运行安装器 |
|
||||||
|
| `channel` | string | 更新通道:`stable` 或 `preview` |
|
||||||
|
| `platform` | string | 平台标识:`windows-x64` |
|
||||||
|
| `updatedAt` | string | ISO 8601 时间戳 |
|
||||||
|
| `filesMap` | object | 全量文件图:每个文件的 action + sha256 + size |
|
||||||
|
| `changedFilesMap` | object | 变更文件图:只包含需要从 changed.zip 解压的文件 |
|
||||||
|
| `checksums` | object | 产出物的 MD5 值 |
|
||||||
|
|
||||||
|
### 3.4 filesMap 中 action 的值
|
||||||
|
|
||||||
|
| Action | 含义 | changed.zip 中是否包含 |
|
||||||
|
|--------|------|----------------------|
|
||||||
|
| `add` | 新增文件 | ✅ |
|
||||||
|
| `replace` | 替换文件 | ✅ |
|
||||||
|
| `reuse` | 复用上一版本文件 | ❌ |
|
||||||
|
| `delete` | 删除文件 | ❌ |
|
||||||
|
|
||||||
|
### 3.5 requiresCleanInstall 判断逻辑
|
||||||
|
|
||||||
|
比较 `LanMountainDesktop.Launcher.exe` 在当前版本和基线版本中的 SHA256:
|
||||||
|
- 如果 SHA256 不同 → `requiresCleanInstall = true`
|
||||||
|
- 如果 SHA256 相同或没有基线版本 → `requiresCleanInstall = false`
|
||||||
|
|
||||||
|
## 4. Plonds.Tool build-delta 命令改造
|
||||||
|
|
||||||
|
### 4.1 新命令签名
|
||||||
|
|
||||||
|
```
|
||||||
|
build-delta --platform <platform>
|
||||||
|
--current-version <version>
|
||||||
|
--current-zip <file>
|
||||||
|
--output-dir <dir>
|
||||||
|
--channel <channel>
|
||||||
|
[--baseline-version <version>]
|
||||||
|
[--baseline-zip <file>]
|
||||||
|
[--launcher-path <relative-path>]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 参数说明
|
||||||
|
|
||||||
|
| 参数 | 必需 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--platform` | 是 | 平台标识,如 `windows-x64` |
|
||||||
|
| `--current-version` | 是 | 当前发布版本号 |
|
||||||
|
| `--current-zip` | 是 | 当前版本的 payload zip 路径 |
|
||||||
|
| `--output-dir` | 是 | 输出目录 |
|
||||||
|
| `--channel` | 是 | 更新通道 |
|
||||||
|
| `--baseline-version` | 否 | 基线版本号(省略则视为全量更新) |
|
||||||
|
| `--baseline-zip` | 否 | 基线版本的 payload zip 路径(省略则视为全量更新) |
|
||||||
|
| `--launcher-path` | 否 | Launcher 可执行文件的相对路径,默认 `LanMountainDesktop.Launcher.exe` |
|
||||||
|
|
||||||
|
### 4.3 删除的参数
|
||||||
|
|
||||||
|
| 参数 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| `--current-tag` | 不再需要,version 就够了 |
|
||||||
|
| `--private-key` | 去掉签名 |
|
||||||
|
| `--is-full-payload` | 自动判断:没有 baseline-zip 就是全量 |
|
||||||
|
| `--static-output-dir` | 不再生成 S3 静态布局 |
|
||||||
|
| `--update-base-url` | 不再生成 S3 URL |
|
||||||
|
| `--baseline-tag` | 不再需要 |
|
||||||
|
|
||||||
|
### 4.4 内部逻辑
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 解压 current-zip → currentDir
|
||||||
|
2. 如果有 baseline-zip → 解压 → baselineDir
|
||||||
|
否则 → baselineDir = 空(全量更新)
|
||||||
|
|
||||||
|
3. 扫描 currentDir → 计算 SHA256
|
||||||
|
4. 扫描 baselineDir → 计算 SHA256(如果有)
|
||||||
|
|
||||||
|
5. 对比生成 filesMap:
|
||||||
|
- 两个版本都有且 SHA256 相同 → reuse
|
||||||
|
- 两个版本都有但 SHA256 不同 → replace
|
||||||
|
- 只在新版本中存在 → add
|
||||||
|
- 只在旧版本中存在 → delete
|
||||||
|
|
||||||
|
6. 从 filesMap 提取 changedFilesMap:
|
||||||
|
- 只包含 action=add/replace 的条目
|
||||||
|
- 添加 archivePath(在 changed.zip 中的路径)
|
||||||
|
|
||||||
|
7. 打包 changed.zip:
|
||||||
|
- 只包含 add/replace 的文件
|
||||||
|
- 保持原始目录结构
|
||||||
|
|
||||||
|
8. 判断 requiresCleanInstall:
|
||||||
|
- 比较 Launcher 可执行文件在两个版本中的 SHA256
|
||||||
|
- 如果不同 → requiresCleanInstall=true
|
||||||
|
|
||||||
|
9. 计算 changed.zip 的 MD5
|
||||||
|
|
||||||
|
10. 生成 PLONDS.json
|
||||||
|
|
||||||
|
11. 输出到 output-dir:
|
||||||
|
- changed.zip
|
||||||
|
- PLONDS.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 不再生成的产物
|
||||||
|
|
||||||
|
| 旧产物 | 处置 |
|
||||||
|
|--------|------|
|
||||||
|
| `update-{platform}.zip` | 被 `changed.zip` 替代 |
|
||||||
|
| `plonds-filemap-{platform}.json` | 被 `PLONDS.json` 替代 |
|
||||||
|
| `plonds-filemap-{platform}.json.sig` | 去掉签名 |
|
||||||
|
| `platform-summary-{platform}.json` | 不再需要 |
|
||||||
|
| `plonds-static.zip` | 不再生成 S3 静态布局 |
|
||||||
|
| `meta/channels/...` | 不再由 Tool 生成,由 Publisher 负责 |
|
||||||
|
|
||||||
|
## 5. Plonds.Shared 模型改造
|
||||||
|
|
||||||
|
### 5.1 删除的模型
|
||||||
|
|
||||||
|
| 模型 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| `PlondsFileMap` | 被新的 `PlondsManifest` 替代 |
|
||||||
|
| `PlondsFileEntry` | 被新的 `PlondsFileEntry` 替代 |
|
||||||
|
| `PlondsComponent` | 不再有组件概念 |
|
||||||
|
| `PlondsDistributionInfo` | 不再生成分发文档 |
|
||||||
|
| `PlondsChannelPointer` | 由 Publisher 用脚本生成 |
|
||||||
|
| `PlondsReleaseManifest` | 不再需要 |
|
||||||
|
| `PlondsReleasePlatformEntry` | 不再需要 |
|
||||||
|
| `PlondsSignatureDescriptor` | 去掉签名 |
|
||||||
|
| `PlondsMirrorAsset` | 由 Publisher 处理 |
|
||||||
|
| `PlondsMirrorEntry` | 由 Publisher 处理 |
|
||||||
|
| `PlondsMetadataCatalog` | 不再需要 |
|
||||||
|
| `PlondsAssetEntry` | 不再需要 |
|
||||||
|
|
||||||
|
### 5.2 新模型定义
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// PlondsManifest — 对应 PLONDS.json
|
||||||
|
public sealed record PlondsManifest(
|
||||||
|
string FormatVersion,
|
||||||
|
string CurrentVersion,
|
||||||
|
string PreviousVersion,
|
||||||
|
bool IsFullUpdate,
|
||||||
|
bool RequiresCleanInstall,
|
||||||
|
string Channel,
|
||||||
|
string Platform,
|
||||||
|
DateTimeOffset UpdatedAt,
|
||||||
|
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
|
||||||
|
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
|
||||||
|
IReadOnlyDictionary<string, string> Checksums);
|
||||||
|
|
||||||
|
// PlondsFileEntry — filesMap 中的条目
|
||||||
|
public sealed record PlondsFileEntry(
|
||||||
|
string Action, // add | replace | reuse | delete
|
||||||
|
string Sha256,
|
||||||
|
long Size);
|
||||||
|
|
||||||
|
// PlondsChangedFileEntry — changedFilesMap 中的条目
|
||||||
|
public sealed record PlondsChangedFileEntry(
|
||||||
|
string ArchivePath, // 在 changed.zip 中的路径
|
||||||
|
string Sha256,
|
||||||
|
long Size);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 设计决策
|
||||||
|
|
||||||
|
- `FilesMap` 和 `ChangedFilesMap` 用 `IReadOnlyDictionary<string, T>` 而非 `IReadOnlyList<T>`,key 就是文件相对路径,查找 O(1)
|
||||||
|
- 去掉 `Component` 概念——当前只有一个 `app` 组件,分层没有实际意义
|
||||||
|
- `FormatVersion` 固定为 `"2.0"`,与旧格式区分
|
||||||
|
|
||||||
|
## 6. Comparator 工作流改造
|
||||||
|
|
||||||
|
### 6.1 保留两个工作流
|
||||||
|
|
||||||
|
- **Comparator**(`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json`
|
||||||
|
- **Publisher**(`plonds-publisher.yml`,原 `plonds-uploader.yml`):发布器,负责上传到 S3 和生成 channel pointer
|
||||||
|
|
||||||
|
### 6.2 Comparator 改造后步骤
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# plonds-comparator.yml
|
||||||
|
触发: release.published / release.prereleased / workflow_dispatch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
compare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- Checkout
|
||||||
|
|
||||||
|
- 解析发布上下文
|
||||||
|
→ RELEASE_TAG, RELEASE_VERSION, RELEASE_CHANNEL
|
||||||
|
|
||||||
|
- Setup .NET
|
||||||
|
|
||||||
|
- 构建 PLONDS Tool
|
||||||
|
|
||||||
|
- 解析基线版本
|
||||||
|
→ 查找上一个同频道 Release
|
||||||
|
→ 如果有 → 记录 baseline_tag, baseline_version
|
||||||
|
→ 如果没有 → is_full_update=true
|
||||||
|
|
||||||
|
- 下载 payload zips
|
||||||
|
→ 下载当前版本 files-windows-x64.zip
|
||||||
|
→ 下载基线版本 files-windows-x64.zip (如果有)
|
||||||
|
|
||||||
|
- 运行 build-delta
|
||||||
|
→ dotnet run Plonds.Tool -- build-delta \
|
||||||
|
--platform windows-x64 \
|
||||||
|
--current-version $VERSION \
|
||||||
|
--current-zip files-windows-x64.zip \
|
||||||
|
--output-dir plonds-output \
|
||||||
|
--channel $CHANNEL \
|
||||||
|
[--baseline-version $BASELINE_VERSION] \
|
||||||
|
[--baseline-zip baseline-files-windows-x64.zip]
|
||||||
|
|
||||||
|
- 上传到 GitHub Release
|
||||||
|
→ gh release upload changed.zip PLONDS.json
|
||||||
|
|
||||||
|
- 传递元数据给 Publisher
|
||||||
|
→ 上传 artifact: plonds-run-metadata (tag.txt)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 与当前步骤的差异
|
||||||
|
|
||||||
|
| 当前步骤 | 改造后 |
|
||||||
|
|---------|--------|
|
||||||
|
| 准备签名密钥 | ❌ 删除 |
|
||||||
|
| 解析基线计划 (pwsh,三平台) | ✅ 简化:只找 Windows,逻辑简化 |
|
||||||
|
| 下载 payload zips (pwsh,三平台) | ✅ 简化:只下载 Windows |
|
||||||
|
| 构建增量资产 (pwsh,含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
|
||||||
|
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
|
||||||
|
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
|
||||||
|
|
||||||
|
## 7. 双模式差分生成
|
||||||
|
|
||||||
|
### 7.1 概述
|
||||||
|
|
||||||
|
Comparator 支持两种差分生成方法,通过 `workflow_dispatch` 的 `compare_method` 输入项选择:
|
||||||
|
|
||||||
|
| 方法 | 标识 | 核心思路 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 方法一 | `file-compare` | 下载两个版本的 files zip,全量文件哈希对比 |
|
||||||
|
| 方法二 | `commit-analyze` | 分析两个版本之间的 git commit,映射源码变更到产物文件 |
|
||||||
|
|
||||||
|
### 7.2 GitHub Actions 触发器新增输入项
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag: ...
|
||||||
|
baseline_tag: ...
|
||||||
|
channel: ...
|
||||||
|
compare_method: # 新增
|
||||||
|
description: '比较方法'
|
||||||
|
type: choice
|
||||||
|
default: file-compare
|
||||||
|
options:
|
||||||
|
- file-compare
|
||||||
|
- commit-analyze
|
||||||
|
hash_algorithm: # 新增(仅方法一)
|
||||||
|
description: '哈希算法'
|
||||||
|
type: choice
|
||||||
|
default: sha256
|
||||||
|
options:
|
||||||
|
- sha256
|
||||||
|
- md5
|
||||||
|
```
|
||||||
|
|
||||||
|
当由 `release` 事件触发时,默认使用 `file-compare` + `sha256`。
|
||||||
|
|
||||||
|
### 7.3 方法一:文件对比模式(file-compare)
|
||||||
|
|
||||||
|
**流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 下载当前版本 files-windows-x64.zip
|
||||||
|
2. 下载基线版本 files-windows-x64.zip(如果有)
|
||||||
|
3. 解压两个 zip 到临时目录
|
||||||
|
4. 用指定哈希算法(sha256/md5)扫描两个目录的所有文件
|
||||||
|
5. 对比哈希值,生成 filesMap(add/replace/reuse/delete)
|
||||||
|
6. 从当前版本目录中提取 add/replace 的文件 → changed.zip
|
||||||
|
7. 生成 PLONDS.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**PlondsDeltaBuildOptions 新增参数:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
string HashAlgorithm = "sha256" // "sha256" | "md5"
|
||||||
|
```
|
||||||
|
|
||||||
|
**哈希算法对 PLONDS.json 的影响:**
|
||||||
|
|
||||||
|
- `sha256`:`filesMap` 和 `changedFilesMap` 中使用 `sha256` 字段
|
||||||
|
- `md5`:`filesMap` 和 `changedFilesMap` 中使用 `md5` 字段
|
||||||
|
|
||||||
|
### 7.4 方法二:Commit 分析模式(commit-analyze)
|
||||||
|
|
||||||
|
**流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 下载当前版本 files-windows-x64.zip
|
||||||
|
2. 解压到临时目录
|
||||||
|
3. git log --name-only baseline_tag..current_tag
|
||||||
|
→ 得到两个版本之间的 commit 列表和涉及的源码文件
|
||||||
|
4. 过滤:只保留源码目录下的文件
|
||||||
|
5. 用简单规则映射源码文件到产物文件
|
||||||
|
6. 从当前版本的解压目录中提取映射到的产物文件 → changed.zip
|
||||||
|
7. 生成 PLONDS.json
|
||||||
|
8. 如果没有源码变更 → 自动回退到方法一
|
||||||
|
```
|
||||||
|
|
||||||
|
**源码目录过滤规则:**
|
||||||
|
|
||||||
|
只分析以下目录下的文件变更:
|
||||||
|
|
||||||
|
| 目录 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `LanMountainDesktop/` | 主宿主应用 |
|
||||||
|
| `LanMountainDesktop.Launcher/` | 启动器 |
|
||||||
|
| `LanMountainDesktop.Shared.Contracts/` | 共享契约 |
|
||||||
|
| `LanMountainDesktop.PluginSdk/` | 插件 SDK |
|
||||||
|
| `LanMountainDesktop.Appearance/` | 外观系统 |
|
||||||
|
| `LanMountainDesktop.Settings.Core/` | 设置核心 |
|
||||||
|
| `LanMountainDesktop.ComponentSystem/` | 组件系统 |
|
||||||
|
|
||||||
|
忽略的目录:`docs/`、`scripts/`、`.github/`、`.trae/`、`PenguinLogisticsOnlineNetworkDistributionSystem/`
|
||||||
|
|
||||||
|
**源码到产物的映射规则:**
|
||||||
|
|
||||||
|
| 源码路径模式 | 映射到产物文件 |
|
||||||
|
|-------------|--------------|
|
||||||
|
| `LanMountainDesktop/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.dll`, `LanMountainDesktop.exe` |
|
||||||
|
| `LanMountainDesktop.Launcher/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.Launcher.exe` |
|
||||||
|
| `LanMountainDesktop.Shared.Contracts/**/*.cs` | `LanMountainDesktop.Shared.Contracts.dll` |
|
||||||
|
| `LanMountainDesktop.PluginSdk/**/*.cs` | `LanMountainDesktop.PluginSdk.dll` |
|
||||||
|
| `LanMountainDesktop.Appearance/**/*.cs` | `LanMountainDesktop.Appearance.dll` |
|
||||||
|
| `LanMountainDesktop.Settings.Core/**/*.cs` | `LanMountainDesktop.Settings.Core.dll` |
|
||||||
|
| `LanMountainDesktop.ComponentSystem/**/*.cs` | `LanMountainDesktop.ComponentSystem.dll` |
|
||||||
|
| `**/*.json`(配置文件) | 同路径的 .json |
|
||||||
|
| 其他无法映射的变更 | 保守标记 → 所有核心 .dll/.exe |
|
||||||
|
|
||||||
|
**方法二在 Plonds.Tool 中的新命令:**
|
||||||
|
|
||||||
|
```
|
||||||
|
build-delta-from-commits --platform <platform>
|
||||||
|
--current-version <version>
|
||||||
|
--current-zip <file>
|
||||||
|
--output-dir <dir>
|
||||||
|
--channel <channel>
|
||||||
|
--baseline-tag <tag>
|
||||||
|
--current-tag <tag>
|
||||||
|
[--source-dirs <dir1,dir2,...>]
|
||||||
|
[--fallback-zip <file>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必需 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--platform` | 是 | 平台标识 |
|
||||||
|
| `--current-version` | 是 | 当前发布版本号 |
|
||||||
|
| `--current-zip` | 是 | 当前版本的 payload zip |
|
||||||
|
| `--output-dir` | 是 | 输出目录 |
|
||||||
|
| `--channel` | 是 | 更新通道 |
|
||||||
|
| `--baseline-tag` | 是 | 基线版本的 git tag |
|
||||||
|
| `--current-tag` | 是 | 当前版本的 git tag |
|
||||||
|
| `--source-dirs` | 否 | 要分析的源码目录列表(逗号分隔) |
|
||||||
|
| `--fallback-zip` | 否 | 回退到方法一时使用的基线 zip |
|
||||||
|
|
||||||
|
**回退逻辑:**
|
||||||
|
|
||||||
|
如果 `git log` 分析后发现没有源码目录下的文件变更(比如只有 docs/ 变更),则自动回退到方法一:
|
||||||
|
1. 如果提供了 `--fallback-zip` → 用方法一对比两个 zip
|
||||||
|
2. 如果没有提供 → 生成全量更新(`isFullUpdate=true`)
|
||||||
|
|
||||||
|
### 7.5 方法二的 PLONDS.json 特殊处理
|
||||||
|
|
||||||
|
方法二无法像方法一那样生成完整的 `filesMap`(因为不知道哪些文件是 reuse 的),因此:
|
||||||
|
|
||||||
|
- `filesMap` 只包含映射到的变更文件(标记为 `add` 或 `replace`)
|
||||||
|
- 不包含 `reuse` 和 `delete` 条目
|
||||||
|
- `isFullUpdate` 始终为 `false`(除非回退到方法一且无基线)
|
||||||
|
- `requiresCleanInstall` 根据 Launcher.exe 是否在映射到的变更文件列表中判断
|
||||||
|
|
||||||
|
### 7.6 工作流中的条件分支
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Run build-delta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "$COMPARE_METHOD" == "commit-analyze" ]]; then
|
||||||
|
# 方法二
|
||||||
|
dotnet run --project ... -- build-delta-from-commits \
|
||||||
|
--platform windows-x64 \
|
||||||
|
--current-version $RELEASE_VERSION \
|
||||||
|
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
|
||||||
|
--output-dir $PWD/plonds-output \
|
||||||
|
--channel $RELEASE_CHANNEL \
|
||||||
|
--baseline-tag $BASELINE_TAG \
|
||||||
|
--current-tag $RELEASE_TAG \
|
||||||
|
--fallback-zip $PWD/plonds-input/baseline-files-windows-x64.zip
|
||||||
|
else
|
||||||
|
# 方法一
|
||||||
|
dotnet run --project ... -- build-delta \
|
||||||
|
--platform windows-x64 \
|
||||||
|
--current-version $RELEASE_VERSION \
|
||||||
|
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
|
||||||
|
--output-dir $PWD/plonds-output \
|
||||||
|
--channel $RELEASE_CHANNEL \
|
||||||
|
--hash-algorithm $HASH_ALGORITHM \
|
||||||
|
--baseline-version $BASELINE_VERSION \
|
||||||
|
--baseline-zip $PWD/plonds-input/baseline-files-windows-x64.zip
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
方法二时,基线 zip 仍然需要下载(用于回退),但不需要解压(除非回退)。
|
||||||
|
|
||||||
|
### 7.7 两种方法的步骤差异
|
||||||
|
|
||||||
|
| 步骤 | 方法一 (file-compare) | 方法二 (commit-analyze) |
|
||||||
|
|------|----------------------|------------------------|
|
||||||
|
| 下载基线 zip | ✅ 需要 | ✅ 需要(用于回退) |
|
||||||
|
| 下载当前 zip | ✅ | ✅ |
|
||||||
|
| 解压两个 zip | ✅ | ✅ 只解压当前(回退时解压基线) |
|
||||||
|
| git diff/log | ❌ | ✅ 需要 fetch-depth:0 |
|
||||||
|
| 哈希对比 | ✅ 两个目录全量扫描 | ❌ 不做(除非回退) |
|
||||||
|
| 源码→产物映射 | ❌ | ✅ |
|
||||||
|
| 回退逻辑 | ❌ | ✅ 无源码变更时回退方法一 |
|
||||||
|
|
||||||
|
## 8. 不在本次改造范围内的事项
|
||||||
|
|
||||||
|
- Publisher 工作流改造(后续单独设计)
|
||||||
|
- Rollback 工作流改造(后续单独设计)
|
||||||
|
- 宿主侧客户端代码改造(PlondsUpdateApplier 等,后续单独设计)
|
||||||
|
- Launcher 侧客户端代码改造(后续单独设计)
|
||||||
|
- Plonds.Api 项目处置(后续决定是否保留)
|
||||||
|
- `build-index`、`build-plonds`、`generate`、`publish`、`sign`、`pack-payload` 等 Tool 命令的清理(后续处理)
|
||||||
@@ -8,7 +8,10 @@ Rebuild the settings window as an independent Fluent shell with a custom titleba
|
|||||||
|
|
||||||
- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry.
|
- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry.
|
||||||
- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer.
|
- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer.
|
||||||
|
- Keep the titlebar and content area on one shared full-window background layer; the custom titlebar must remain transparent and must not paint a contrasting strip.
|
||||||
|
- Avoid a visible titlebar bottom divider that makes the titlebar read as a separate color band.
|
||||||
- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP.
|
- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP.
|
||||||
|
- Keep `FANavigationView` pane and content template backgrounds transparent in the settings shell so the navigation control does not reintroduce a second surface color.
|
||||||
- Move the compact/minimal pane toggle from the navigation footer into the titlebar.
|
- Move the compact/minimal pane toggle from the navigation footer into the titlebar.
|
||||||
- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights.
|
- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights.
|
||||||
- Add `auto` system material mode and make it the default.
|
- Add `auto` system material mode and make it the default.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Make the Settings > Update page the single user-facing control surface for the h
|
|||||||
- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
|
- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
|
||||||
- The page displays whether the current payload is an incremental update or reinstall/full installer.
|
- The page displays whether the current payload is an incremental update or reinstall/full installer.
|
||||||
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
|
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
|
||||||
- Existing PloNDS/FileMap incremental update and Launcher rollback ownership remain unchanged.
|
- Existing PloNDS/FileMap incremental update behavior remains, but update apply and rollback ownership belongs to the Host. Launcher only selects and starts the current app version.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration
|
|||||||
|
|
||||||
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||||
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||||
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
- Host owns update install and rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows. Launcher only selects and starts the current app version.
|
||||||
|
|
||||||
## Migration Note
|
## Migration Note
|
||||||
|
|
||||||
|
|||||||
14
CheckIpcAot/CheckIpcAot.csproj
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="dotnetCampus.Ipc" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
10
CheckIpcAot/Program.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
[IpcPublic]
|
||||||
|
public interface IMyService {
|
||||||
|
Task<MyResult> DoWork(MyRequest req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MyResult { public string Msg {get;set;} }
|
||||||
|
public class MyRequest { public string Data {get;set;} }
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
namespace LanMountainDesktop.Launcher.AirApp;
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
internal sealed class AirAppHostLocator
|
internal sealed class AirAppHostLocator
|
||||||
{
|
{
|
||||||
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
|
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
|
||||||
|
private const string UnixExecutableName = "LanMountainDesktop.AirAppHost";
|
||||||
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
|
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
|
||||||
|
|
||||||
|
private static string ExecutableName => OperatingSystem.IsWindows()
|
||||||
|
? WindowsExecutableName
|
||||||
|
: UnixExecutableName;
|
||||||
|
|
||||||
public string Resolve(string? packageRoot, string? hostPath = null)
|
public string Resolve(string? packageRoot, string? hostPath = null)
|
||||||
{
|
{
|
||||||
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
|
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
|
||||||
@@ -22,18 +27,18 @@ internal sealed class AirAppHostLocator
|
|||||||
{
|
{
|
||||||
foreach (var root in EnumerateRoots(packageRoot, hostPath))
|
foreach (var root in EnumerateRoots(packageRoot, hostPath))
|
||||||
{
|
{
|
||||||
yield return Path.Combine(root, "AirAppHost", WindowsExecutableName);
|
yield return Path.Combine(root, "AirAppHost", ExecutableName);
|
||||||
yield return Path.Combine(root, "AirAppHost", DllName);
|
yield return Path.Combine(root, "AirAppHost", DllName);
|
||||||
yield return Path.Combine(root, WindowsExecutableName);
|
yield return Path.Combine(root, ExecutableName);
|
||||||
yield return Path.Combine(root, DllName);
|
yield return Path.Combine(root, DllName);
|
||||||
|
|
||||||
if (Directory.Exists(root))
|
if (Directory.Exists(root))
|
||||||
{
|
{
|
||||||
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
|
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
|
||||||
{
|
{
|
||||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName);
|
yield return Path.Combine(deploymentDirectory, "AirAppHost", ExecutableName);
|
||||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
|
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
|
||||||
yield return Path.Combine(deploymentDirectory, WindowsExecutableName);
|
yield return Path.Combine(deploymentDirectory, ExecutableName);
|
||||||
yield return Path.Combine(deploymentDirectory, DllName);
|
yield return Path.Combine(deploymentDirectory, DllName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +57,7 @@ internal sealed class AirAppHostLocator
|
|||||||
"Release",
|
"Release",
|
||||||
#endif
|
#endif
|
||||||
"net10.0",
|
"net10.0",
|
||||||
WindowsExecutableName);
|
ExecutableName);
|
||||||
|
|
||||||
yield return Path.Combine(
|
yield return Path.Combine(
|
||||||
current.FullName,
|
current.FullName,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace LanMountainDesktop.Launcher.AirApp;
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
internal static class AirAppInstanceKey
|
internal static class AirAppInstanceKey
|
||||||
{
|
{
|
||||||
@@ -17,8 +17,6 @@ internal static class AirAppInstanceKey
|
|||||||
|
|
||||||
private static string Normalize(string? value, string fallback)
|
private static string Normalize(string? value, string fallback)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(value)
|
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||||
? fallback
|
|
||||||
: value.Trim();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,15 @@ using System.Diagnostics;
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.AirApp;
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
internal sealed class AirAppLifecycleService : IAirAppLifecycleService
|
||||||
{
|
{
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly IAirAppProcessStarter _processStarter;
|
private readonly IAirAppProcessStarter _processStarter;
|
||||||
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
|
public AirAppLifecycleService(IAirAppProcessStarter processStarter)
|
||||||
{
|
{
|
||||||
_processStarter = processStarter;
|
_processStarter = processStarter;
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
var appId = Normalize(request.AppId, "unknown");
|
var appId = Normalize(request.AppId, "unknown");
|
||||||
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
|
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
|
||||||
Logger.Info(
|
AirAppRuntimeLogger.Info(
|
||||||
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
|
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
|
||||||
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
@@ -57,12 +57,12 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
request.SourceComponentId,
|
request.SourceComponentId,
|
||||||
request.SourcePlacementId);
|
request.SourcePlacementId);
|
||||||
_instances[instanceKey] = instance;
|
_instances[instanceKey] = instance;
|
||||||
Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
AirAppRuntimeLogger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
||||||
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
|
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
AirAppRuntimeLogger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
||||||
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
|
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
request.SourceComponentId,
|
request.SourceComponentId,
|
||||||
request.SourcePlacementId);
|
request.SourcePlacementId);
|
||||||
_instances[instanceKey] = instance;
|
_instances[instanceKey] = instance;
|
||||||
Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
AirAppRuntimeLogger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
||||||
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
|
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
(processId <= 0 || instance.ProcessId == processId))
|
(processId <= 0 || instance.ProcessId == processId))
|
||||||
{
|
{
|
||||||
_instances.Remove(instanceKey);
|
_instances.Remove(instanceKey);
|
||||||
Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
AirAppRuntimeLogger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
||||||
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
|
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
foreach (var key in exitedKeys)
|
foreach (var key in exitedKeys)
|
||||||
{
|
{
|
||||||
_instances.Remove(key);
|
_instances.Remove(key);
|
||||||
Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
AirAppRuntimeLogger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsProcessAlive(int processId)
|
internal static bool IsProcessAlive(int processId)
|
||||||
{
|
{
|
||||||
if (processId <= 0)
|
if (processId <= 0)
|
||||||
{
|
{
|
||||||
@@ -257,9 +257,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
|||||||
|
|
||||||
private static string Normalize(string? value, string fallback)
|
private static string Normalize(string? value, string fallback)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(value)
|
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||||
? fallback
|
|
||||||
: value.Trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int SW_SHOWNORMAL = 1;
|
private const int SW_SHOWNORMAL = 1;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal sealed class AirAppRuntimeControlService : IAirAppRuntimeControlService
|
||||||
|
{
|
||||||
|
private readonly AirAppRuntimeLifetime _lifetime;
|
||||||
|
|
||||||
|
public AirAppRuntimeControlService(AirAppRuntimeLifetime lifetime)
|
||||||
|
{
|
||||||
|
_lifetime = lifetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId)
|
||||||
|
{
|
||||||
|
_lifetime.AttachHost(hostProcessId);
|
||||||
|
var status = _lifetime.GetStatus();
|
||||||
|
return Task.FromResult(new AirAppRuntimeControlResult(
|
||||||
|
hostProcessId > 0,
|
||||||
|
hostProcessId > 0 ? "host_attached" : "invalid_host_pid",
|
||||||
|
hostProcessId > 0 ? "AirApp runtime host process attached." : "Host process id must be positive.",
|
||||||
|
status));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AirAppRuntimeStatus> GetStatusAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(_lifetime.GetStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal sealed class AirAppRuntimeIpcHost : IDisposable
|
||||||
|
{
|
||||||
|
private readonly PublicIpcHostService _host;
|
||||||
|
|
||||||
|
public AirAppRuntimeIpcHost(
|
||||||
|
AirAppLifecycleService lifecycleService,
|
||||||
|
AirAppRuntimeControlService controlService)
|
||||||
|
{
|
||||||
|
_host = new PublicIpcHostService(IpcConstants.AirAppRuntimePipeName);
|
||||||
|
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
|
||||||
|
_host.RegisterPublicService<IAirAppRuntimeControlService>(controlService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_host.Start();
|
||||||
|
AirAppRuntimeLogger.Info($"Air APP runtime IPC started. Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_host.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal sealed class AirAppRuntimeLifetime
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
private readonly AirAppLifecycleService _lifecycleService;
|
||||||
|
private readonly int _launcherProcessId;
|
||||||
|
private readonly int _requesterProcessId;
|
||||||
|
private int _hostProcessId;
|
||||||
|
private DateTimeOffset _updatedAtUtc;
|
||||||
|
|
||||||
|
public AirAppRuntimeLifetime(AirAppRuntimeOptions options, AirAppLifecycleService lifecycleService)
|
||||||
|
{
|
||||||
|
_lifecycleService = lifecycleService;
|
||||||
|
_launcherProcessId = options.LauncherProcessId;
|
||||||
|
_requesterProcessId = options.RequesterProcessId;
|
||||||
|
_hostProcessId = options.RequesterProcessId;
|
||||||
|
_updatedAtUtc = _startedAtUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AttachHost(int hostProcessId)
|
||||||
|
{
|
||||||
|
if (hostProcessId <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_hostProcessId = hostProcessId;
|
||||||
|
_updatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
AirAppRuntimeLogger.Info($"Attached host process. HostPid={hostProcessId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShouldKeepAlive()
|
||||||
|
{
|
||||||
|
var status = GetStatus();
|
||||||
|
return status.LauncherProcessAlive ||
|
||||||
|
status.HostProcessAlive ||
|
||||||
|
IsProcessAlive(_requesterProcessId) ||
|
||||||
|
status.HasLiveAirApps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AirAppRuntimeStatus GetStatus()
|
||||||
|
{
|
||||||
|
int hostPid;
|
||||||
|
DateTimeOffset updatedAt;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
hostPid = _hostProcessId;
|
||||||
|
updatedAt = _updatedAtUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
var launcherAlive = IsProcessAlive(_launcherProcessId);
|
||||||
|
var hostAlive = IsProcessAlive(hostPid);
|
||||||
|
var hasLiveAirApps = _lifecycleService.HasLiveAirApps();
|
||||||
|
return new AirAppRuntimeStatus(
|
||||||
|
Environment.ProcessId,
|
||||||
|
_launcherProcessId,
|
||||||
|
hostPid,
|
||||||
|
launcherAlive,
|
||||||
|
hostAlive,
|
||||||
|
hasLiveAirApps,
|
||||||
|
_startedAtUtc,
|
||||||
|
updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsProcessAlive(int processId)
|
||||||
|
{
|
||||||
|
return AirAppLifecycleService.IsProcessAlive(processId);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal static class AirAppRuntimeLogger
|
||||||
|
{
|
||||||
|
public static void Info(string message) => Trace.WriteLine($"[AirAppRuntime] INFO {message}");
|
||||||
|
|
||||||
|
public static void Warn(string message) => Trace.WriteLine($"[AirAppRuntime] WARN {message}");
|
||||||
|
|
||||||
|
public static void Warn(string message, Exception ex) =>
|
||||||
|
Trace.WriteLine($"[AirAppRuntime] WARN {message} {ex}");
|
||||||
|
|
||||||
|
public static void Error(string message, Exception ex) =>
|
||||||
|
Trace.WriteLine($"[AirAppRuntime] ERROR {message} {ex}");
|
||||||
|
}
|
||||||
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal sealed record AirAppRuntimeOptions(
|
||||||
|
string? AppRoot,
|
||||||
|
string? DataRoot,
|
||||||
|
int LauncherProcessId,
|
||||||
|
int RequesterProcessId)
|
||||||
|
{
|
||||||
|
public static AirAppRuntimeOptions Parse(IReadOnlyList<string> args)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var index = 0; index < args.Count; index++)
|
||||||
|
{
|
||||||
|
var current = args[index];
|
||||||
|
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = current[2..];
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var equalsIndex = key.IndexOf('=');
|
||||||
|
if (equalsIndex >= 0)
|
||||||
|
{
|
||||||
|
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
values[key] = args[++index];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
values[key] = "true";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AirAppRuntimeOptions(
|
||||||
|
GetOptionalPath(values, "app-root"),
|
||||||
|
GetOptionalPath(values, "data-root"),
|
||||||
|
GetInt(values, "launcher-pid"),
|
||||||
|
GetInt(values, "requester-pid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetOptionalPath(IReadOnlyDictionary<string, string> values, string key)
|
||||||
|
{
|
||||||
|
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||||
|
? Path.GetFullPath(value)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetInt(IReadOnlyDictionary<string, string> values, string key)
|
||||||
|
{
|
||||||
|
return values.TryGetValue(key, out var value) &&
|
||||||
|
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
namespace LanMountainDesktop.Launcher.AirApp;
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
internal interface IAirAppProcessStarter
|
internal interface IAirAppProcessStarter
|
||||||
{
|
{
|
||||||
@@ -12,20 +14,17 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
private readonly Func<string?> _packageRootProvider;
|
private readonly Func<string?> _packageRootProvider;
|
||||||
private readonly Func<string?> _hostPathProvider;
|
private readonly Func<string?> _hostPathProvider;
|
||||||
private readonly Func<string?> _dataRootProvider;
|
private readonly Func<string?> _dataRootProvider;
|
||||||
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
|
|
||||||
|
|
||||||
public AirAppProcessStarter(
|
public AirAppProcessStarter(
|
||||||
AirAppHostLocator locator,
|
AirAppHostLocator locator,
|
||||||
Func<string?> packageRootProvider,
|
Func<string?> packageRootProvider,
|
||||||
Func<string?> hostPathProvider,
|
Func<string?> hostPathProvider,
|
||||||
Func<string?> dataRootProvider,
|
Func<string?> dataRootProvider)
|
||||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
|
||||||
{
|
{
|
||||||
_locator = locator;
|
_locator = locator;
|
||||||
_packageRootProvider = packageRootProvider;
|
_packageRootProvider = packageRootProvider;
|
||||||
_hostPathProvider = hostPathProvider;
|
_hostPathProvider = hostPathProvider;
|
||||||
_dataRootProvider = dataRootProvider;
|
_dataRootProvider = dataRootProvider;
|
||||||
_runtimeProbeOptions = runtimeProbeOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Process? Start(
|
public Process? Start(
|
||||||
@@ -36,12 +35,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
string? sourcePlacementId)
|
string? sourcePlacementId)
|
||||||
{
|
{
|
||||||
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
||||||
var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions);
|
var startInfo = CreateStartInfo(hostPath);
|
||||||
|
|
||||||
AddArgument(startInfo, "--app-id", appId);
|
AddArgument(startInfo, "--app-id", appId);
|
||||||
AddArgument(startInfo, "--session-id", sessionId);
|
AddArgument(startInfo, "--session-id", sessionId);
|
||||||
AddArgument(startInfo, "--instance-key", instanceKey);
|
AddArgument(startInfo, "--instance-key", instanceKey);
|
||||||
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
|
AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
|
||||||
var dataRoot = _dataRootProvider();
|
var dataRoot = _dataRootProvider();
|
||||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||||
{
|
{
|
||||||
@@ -58,7 +57,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.Info(
|
AirAppRuntimeLogger.Info(
|
||||||
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
|
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
|
||||||
var process = Process.Start(startInfo);
|
var process = Process.Start(startInfo);
|
||||||
if (process is not null)
|
if (process is not null)
|
||||||
@@ -68,12 +67,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Logger.Info(
|
AirAppRuntimeLogger.Info(
|
||||||
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
|
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
AirAppRuntimeLogger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,53 +80,10 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
|||||||
return process;
|
return process;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static ProcessStartInfo CreateStartInfo(
|
internal static ProcessStartInfo CreateStartInfo(string hostPath)
|
||||||
string hostPath,
|
|
||||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
|
||||||
{
|
{
|
||||||
var startInfo = new ProcessStartInfo
|
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
|
||||||
{
|
|
||||||
UseShellExecute = false,
|
|
||||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
|
|
||||||
};
|
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
if (string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
if (DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(hostPath))
|
|
||||||
{
|
|
||||||
var executableRuntime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
|
||||||
if (!executableRuntime.IsAvailable)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
|
||||||
executableRuntime.Message);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
startInfo.FileName = hostPath;
|
|
||||||
return startInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
var runtime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
|
||||||
if (!runtime.IsAvailable || string.IsNullOrWhiteSpace(runtime.DotNetHostPath))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
|
||||||
runtime.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
startInfo.FileName = runtime.DotNetHostPath;
|
|
||||||
startInfo.ArgumentList.Add(hostPath);
|
|
||||||
return startInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
startInfo.FileName = "dotnet";
|
|
||||||
startInfo.ArgumentList.Add(hostPath);
|
|
||||||
return startInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RollForward>LatestMajor</RollForward>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<PublishAot>false</PublishAot>
|
||||||
|
<SelfContained>false</SelfContained>
|
||||||
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<ApplicationIcon>..\LanMountainDesktop\Assets\logo_nightly.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
namespace LanMountainDesktop.AirAppRuntime;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
public static async Task<int> Main(string[] args)
|
||||||
|
{
|
||||||
|
var options = AirAppRuntimeOptions.Parse(args);
|
||||||
|
AirAppRuntimeLogger.Info(
|
||||||
|
$"Starting. AppRoot='{options.AppRoot ?? string.Empty}'; DataRoot='{options.DataRoot ?? string.Empty}'; " +
|
||||||
|
$"LauncherPid={options.LauncherProcessId}; RequesterPid={options.RequesterProcessId}.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lifecycleService = new AirAppLifecycleService(
|
||||||
|
new AirAppProcessStarter(
|
||||||
|
new AirAppHostLocator(),
|
||||||
|
() => options.AppRoot,
|
||||||
|
() => null,
|
||||||
|
() => options.DataRoot));
|
||||||
|
var lifetime = new AirAppRuntimeLifetime(options, lifecycleService);
|
||||||
|
var controlService = new AirAppRuntimeControlService(lifetime);
|
||||||
|
|
||||||
|
using var ipcHost = new AirAppRuntimeIpcHost(lifecycleService, controlService);
|
||||||
|
ipcHost.Start();
|
||||||
|
|
||||||
|
while (lifetime.ShouldKeepAlive())
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
AirAppRuntimeLogger.Info("Exiting because launcher, host, requester, and AirApp windows are gone.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AirAppRuntimeLogger.Error("Unhandled runtime failure.", ex);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using LanMountainDesktop.Shared.IPC;
|
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.AirApp;
|
|
||||||
|
|
||||||
internal sealed class LauncherAirAppLifecycleIpcHost : IDisposable
|
|
||||||
{
|
|
||||||
private readonly PublicIpcHostService _host;
|
|
||||||
|
|
||||||
public LauncherAirAppLifecycleIpcHost(LauncherAirAppLifecycleService lifecycleService)
|
|
||||||
{
|
|
||||||
LifecycleService = lifecycleService;
|
|
||||||
_host = new PublicIpcHostService(IpcConstants.AirAppLifecyclePipeName);
|
|
||||||
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
|
|
||||||
}
|
|
||||||
|
|
||||||
public LauncherAirAppLifecycleService LifecycleService { get; }
|
|
||||||
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
_host.Start();
|
|
||||||
Logger.Info($"Air APP lifecycle IPC started. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_host.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,13 +60,6 @@ public partial class App : Application
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.IsAirAppBrokerCommand)
|
|
||||||
{
|
|
||||||
_ = AirAppBrokerEntryHandler.RunAsync(desktop, context);
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.IsDebugMode && !context.IsPreviewCommand)
|
if (context.IsDebugMode && !context.IsPreviewCommand)
|
||||||
{
|
{
|
||||||
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
|
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
|
||||||
|
|||||||
@@ -11,15 +11,7 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
PropertyNameCaseInsensitive = true)]
|
PropertyNameCaseInsensitive = true)]
|
||||||
[JsonSerializable(typeof(SignedFileMap))]
|
|
||||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
|
||||||
[JsonSerializable(typeof(PlondsUpdateMetadata))]
|
|
||||||
[JsonSerializable(typeof(PlondsFileMap))]
|
|
||||||
[JsonSerializable(typeof(PlondsComponentEntry))]
|
|
||||||
[JsonSerializable(typeof(PlondsFileEntry))]
|
|
||||||
[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))]
|
||||||
@@ -37,6 +29,11 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSerializable(typeof(StartupAttemptRecord))]
|
[JsonSerializable(typeof(StartupAttemptRecord))]
|
||||||
[JsonSerializable(typeof(PrivacyConfig))]
|
[JsonSerializable(typeof(PrivacyConfig))]
|
||||||
[JsonSerializable(typeof(PrivacyAgreementState))]
|
[JsonSerializable(typeof(PrivacyAgreementState))]
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
[JsonSerializable(typeof(AirAppOpenRequest))]
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
[JsonSerializable(typeof(AirAppRegistrationRequest))]
|
||||||
|
[JsonSerializable(typeof(AirAppInstanceInfo))]
|
||||||
|
[JsonSerializable(typeof(AirAppOperationResult))]
|
||||||
|
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
|
||||||
|
[JsonSerializable(typeof(AirAppRuntimeControlResult))]
|
||||||
|
[JsonSerializable(typeof(AirAppRuntimeStatus))]
|
||||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
|
|
||||||
internal sealed class CommandContext
|
internal sealed class CommandContext
|
||||||
{
|
{
|
||||||
public const string AirAppBrokerCommand = "air-app-broker";
|
|
||||||
|
|
||||||
private const string LaunchSourceOptionName = "launch-source";
|
private const string LaunchSourceOptionName = "launch-source";
|
||||||
|
|
||||||
private static readonly string[] GuiCommands =
|
private static readonly string[] GuiCommands =
|
||||||
[
|
[
|
||||||
"launch",
|
"launch",
|
||||||
AirAppBrokerCommand,
|
|
||||||
"preview-splash",
|
"preview-splash",
|
||||||
"preview-error",
|
"preview-error",
|
||||||
"preview-update",
|
"preview-update",
|
||||||
@@ -62,15 +59,11 @@ internal sealed class CommandContext
|
|||||||
public bool IsPreviewCommand =>
|
public bool IsPreviewCommand =>
|
||||||
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsAirAppBrokerCommand =>
|
|
||||||
string.Equals(Command, AirAppBrokerCommand, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public bool IsGuiCommand =>
|
public bool IsGuiCommand =>
|
||||||
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsMaintenanceCommand =>
|
public bool IsMaintenanceCommand =>
|
||||||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public string? ExplicitAppRoot => GetOption("app-root");
|
public string? ExplicitAppRoot => GetOption("app-root");
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
global using LanMountainDesktop.Launcher.AirApp;
|
|
||||||
global using LanMountainDesktop.Launcher.Deployment;
|
global using LanMountainDesktop.Launcher.Deployment;
|
||||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
global using LanMountainDesktop.Launcher.Ipc;
|
global using LanMountainDesktop.Launcher.Ipc;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal static class Commands
|
|||||||
{
|
{
|
||||||
var source = context.GetOption("source") ?? string.Empty;
|
var source = context.GetOption("source") ?? string.Empty;
|
||||||
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
||||||
result = installer.InstallPackage(source, pluginsDir);
|
result = installer.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -91,12 +91,12 @@ internal static class Commands
|
|||||||
{
|
{
|
||||||
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
||||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||||
return pluginInstaller.InstallPackage(source, pluginsDir);
|
return pluginInstaller.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||||
}
|
}
|
||||||
case "update":
|
case "update":
|
||||||
{
|
{
|
||||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir, context.ExplicitAppRoot);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
|
|||||||
@@ -193,8 +193,10 @@ internal sealed class DataLocationResolver
|
|||||||
|
|
||||||
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
||||||
{
|
{
|
||||||
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
|
var targetDataRoot = mode == DataLocationMode.Portable
|
||||||
? Path.GetFullPath(customPath)
|
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
|
||||||
|
? customPath
|
||||||
|
: DefaultPortableDataPath)
|
||||||
: _defaultSystemDataPath;
|
: _defaultSystemDataPath;
|
||||||
|
|
||||||
var config = new DataLocationConfig
|
var config = new DataLocationConfig
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
using System.Buffers;
|
|
||||||
using System.IO.Pipes;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using LanMountainDesktop.Shared.Contracts.Update;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Ipc;
|
|
||||||
|
|
||||||
internal interface IUpdateProgressReporter
|
|
||||||
{
|
|
||||||
void ReportProgress(InstallProgressReport report);
|
|
||||||
void ReportComplete(InstallCompleteReport report);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
|
|
||||||
{
|
|
||||||
private const int LengthPrefixSize = 4;
|
|
||||||
|
|
||||||
private readonly string _pipeName;
|
|
||||||
private readonly CancellationTokenSource _cts = new();
|
|
||||||
private NamedPipeServerStream? _pipe;
|
|
||||||
private Task? _listenTask;
|
|
||||||
private volatile bool _clientConnected;
|
|
||||||
|
|
||||||
public LauncherUpdateProgressIpcServer(int launcherPid)
|
|
||||||
{
|
|
||||||
_pipeName = $"LanMountainDesktop_Update_{launcherPid}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public string PipeName => _pipeName;
|
|
||||||
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
_listenTask = Task.Run(AcceptConnectionAsync, _cts.Token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AcceptConnectionAsync()
|
|
||||||
{
|
|
||||||
while (!_cts.Token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_pipe = new NamedPipeServerStream(
|
|
||||||
_pipeName,
|
|
||||||
PipeDirection.Out,
|
|
||||||
1,
|
|
||||||
PipeTransmissionMode.Byte,
|
|
||||||
PipeOptions.Asynchronous);
|
|
||||||
|
|
||||||
await _pipe.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
|
|
||||||
_clientConnected = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Update progress IPC listen error: {ex.Message}");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(200, _cts.Token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ReportProgress(InstallProgressReport report)
|
|
||||||
{
|
|
||||||
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallProgressReport));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Failed to report progress via IPC: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ReportComplete(InstallCompleteReport report)
|
|
||||||
{
|
|
||||||
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallCompleteReport));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Failed to report completion via IPC: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteMessage(Stream stream, string json)
|
|
||||||
{
|
|
||||||
var payload = Encoding.UTF8.GetBytes(json);
|
|
||||||
var lengthPrefix = BitConverter.GetBytes(payload.Length);
|
|
||||||
stream.Write(lengthPrefix, 0, LengthPrefixSize);
|
|
||||||
stream.Write(payload, 0, payload.Length);
|
|
||||||
stream.Flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_cts.Cancel();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_pipe?.Dispose();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_listenTask?.Wait(TimeSpan.FromSeconds(2));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
_cts.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
|
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||||
<!-- 忽略某些警告 -->
|
<!-- 忽略某些警告 -->
|
||||||
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||||
@@ -60,7 +61,8 @@
|
|||||||
|
|
||||||
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
||||||
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
||||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
<!-- [Fix]: 必须设置为 true 以支持 dotnetCampus.Ipc 内部的反射序列化。相关类型的剪裁保护通过 AppJsonContext 保证 -->
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
|
||||||
<!-- 启用 ISerializable 支持(部分库需要) -->
|
<!-- 启用 ISerializable 支持(部分库需要) -->
|
||||||
<IsAotCompatible>true</IsAotCompatible>
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
|
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
|
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
|
||||||
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
|
<PublicKeyDestDir>$(OutDir).Launcher\update</PublicKeyDestDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<MakeDir Directories="$(PublicKeyDestDir)" />
|
<MakeDir Directories="$(PublicKeyDestDir)" />
|
||||||
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
|
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
|
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
|
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
|
||||||
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
|
<PublishedKeyDestDir>$(PublishDir).Launcher\update</PublishedKeyDestDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<MakeDir Directories="$(PublishedKeyDestDir)" />
|
<MakeDir Directories="$(PublishedKeyDestDir)" />
|
||||||
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
|
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新频道
|
|
||||||
/// </summary>
|
|
||||||
public enum UpdateChannel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 正式版 - 只检查 prerelease=false 的版本
|
|
||||||
/// </summary>
|
|
||||||
Stable,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 预览版 - 检查所有版本(包括 prerelease=true)
|
|
||||||
/// </summary>
|
|
||||||
Preview
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新检查结果
|
|
||||||
/// </summary>
|
|
||||||
public sealed class UpdateCheckResult
|
|
||||||
{
|
|
||||||
public bool HasUpdate { get; init; }
|
|
||||||
public string? LatestVersion { get; init; }
|
|
||||||
public string? CurrentVersion { get; init; }
|
|
||||||
public ReleaseInfo? Release { get; init; }
|
|
||||||
public string? ErrorMessage { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,5 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Models;
|
namespace LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
internal sealed class SignedFileMap
|
|
||||||
{
|
|
||||||
public string? FromVersion { get; set; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; set; }
|
|
||||||
|
|
||||||
public string? Platform { get; set; }
|
|
||||||
|
|
||||||
public string? Arch { get; set; }
|
|
||||||
|
|
||||||
public List<UpdateFileEntry> Files { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class UpdateFileEntry
|
|
||||||
{
|
|
||||||
public string Path { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? ArchivePath { get; set; }
|
|
||||||
|
|
||||||
public string Action { get; set; } = "replace";
|
|
||||||
|
|
||||||
public string? Sha256 { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class SnapshotMetadata
|
internal sealed class SnapshotMetadata
|
||||||
{
|
{
|
||||||
public string SnapshotId { get; set; } = string.Empty;
|
public string SnapshotId { get; set; } = string.Empty;
|
||||||
@@ -40,124 +16,3 @@ 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
|
|
||||||
{
|
|
||||||
public bool Success { get; init; }
|
|
||||||
|
|
||||||
public string Message { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public string? FromVersion { get; init; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; init; }
|
|
||||||
|
|
||||||
public string? RolledBackTo { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsUpdateMetadata
|
|
||||||
{
|
|
||||||
public string? DistributionId { get; set; }
|
|
||||||
|
|
||||||
public string? Channel { get; set; }
|
|
||||||
|
|
||||||
public string? SubChannel { get; set; }
|
|
||||||
|
|
||||||
public string? FromVersion { get; set; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; set; }
|
|
||||||
|
|
||||||
public string? FileMapPath { get; set; }
|
|
||||||
|
|
||||||
public string? FileMapSignaturePath { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsFileMap
|
|
||||||
{
|
|
||||||
public string? DistributionId { get; set; }
|
|
||||||
|
|
||||||
public string? FromVersion { get; set; }
|
|
||||||
|
|
||||||
public string? ToVersion { get; set; }
|
|
||||||
|
|
||||||
public string? Version { get; set; }
|
|
||||||
|
|
||||||
public string? Platform { get; set; }
|
|
||||||
|
|
||||||
public string? Arch { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
|
|
||||||
public List<PlondsComponentEntry> Components { get; set; } = [];
|
|
||||||
|
|
||||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsComponentEntry
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? Version { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
|
|
||||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsFileEntry
|
|
||||||
{
|
|
||||||
public string Path { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? Action { get; set; } = "replace";
|
|
||||||
|
|
||||||
public string? Url { get; set; }
|
|
||||||
|
|
||||||
public string? ObjectUrl { get; set; }
|
|
||||||
|
|
||||||
public string? ObjectPath { get; set; }
|
|
||||||
|
|
||||||
public string? ObjectKey { get; set; }
|
|
||||||
|
|
||||||
public string? ArchivePath { get; set; }
|
|
||||||
|
|
||||||
public string? Sha256 { get; set; }
|
|
||||||
|
|
||||||
public string? Sha512 { get; set; }
|
|
||||||
|
|
||||||
public string? Sha512Base64 { get; set; }
|
|
||||||
|
|
||||||
public byte[]? Sha512Bytes { get; set; }
|
|
||||||
|
|
||||||
public PlondsHashDescriptor? Hash { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class PlondsHashDescriptor
|
|
||||||
{
|
|
||||||
public string? Algorithm { get; set; }
|
|
||||||
|
|
||||||
public string? Value { get; set; }
|
|
||||||
|
|
||||||
public byte[]? Bytes { get; set; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ internal sealed class PluginInstallerService
|
|||||||
TimeSpan.FromMilliseconds(500)
|
TimeSpan.FromMilliseconds(500)
|
||||||
];
|
];
|
||||||
|
|
||||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
|
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory, string? appRoot = null)
|
||||||
{
|
{
|
||||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||||
@@ -32,7 +32,7 @@ internal sealed class PluginInstallerService
|
|||||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
|
if (TryBuildElevationRequiredResult(fullPluginsDirectory, appRoot) is { } elevationRequiredResult)
|
||||||
{
|
{
|
||||||
return elevationRequiredResult;
|
return elevationRequiredResult;
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ internal sealed class PluginInstallerService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
|
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory, string? appRoot)
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
if (!OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
@@ -68,8 +68,10 @@ internal sealed class PluginInstallerService
|
|||||||
string? allowedRoot = null;
|
string? allowedRoot = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
var resolvedAppRoot = !string.IsNullOrWhiteSpace(appRoot)
|
||||||
var resolver = new DataLocationResolver(appRoot);
|
? Path.GetFullPath(appRoot)
|
||||||
|
: Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||||
|
var resolver = new DataLocationResolver(resolvedAppRoot);
|
||||||
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
|
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal sealed class PluginUpgradeQueueService
|
|||||||
_installerService = installerService;
|
_installerService = installerService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
|
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory, string? appRoot = null)
|
||||||
{
|
{
|
||||||
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
||||||
if (!File.Exists(pendingPath))
|
if (!File.Exists(pendingPath))
|
||||||
@@ -43,7 +43,7 @@ internal sealed class PluginUpgradeQueueService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
|
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory, appRoot);
|
||||||
succeeded.Add(item);
|
succeeded.Add(item);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -57,14 +57,6 @@
|
|||||||
"DOTNET_ENVIRONMENT": "Development"
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Launcher (Update Check)": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"commandLineArgs": "update check",
|
|
||||||
"workingDirectory": "$(SolutionDir)",
|
|
||||||
"environmentVariables": {
|
|
||||||
"DOTNET_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Launcher (Plugin Install)": {
|
"Launcher (Plugin Install)": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
||||||
|
|||||||
83
LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Shell;
|
||||||
|
|
||||||
|
internal sealed class AirAppRuntimeBridge
|
||||||
|
{
|
||||||
|
private const int ConnectAttempts = 8;
|
||||||
|
|
||||||
|
private readonly string _appRoot;
|
||||||
|
private readonly string? _dataRoot;
|
||||||
|
|
||||||
|
public AirAppRuntimeBridge(string appRoot, string? dataRoot)
|
||||||
|
{
|
||||||
|
_appRoot = appRoot;
|
||||||
|
_dataRoot = dataRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnsureStartedAsync()
|
||||||
|
{
|
||||||
|
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||||
|
{
|
||||||
|
Logger.Info("AirApp Runtime is already available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
|
||||||
|
_appRoot,
|
||||||
|
Environment.ProcessId,
|
||||||
|
0,
|
||||||
|
_dataRoot));
|
||||||
|
Logger.Info($"AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'.");
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= ConnectAttempts; attempt++)
|
||||||
|
{
|
||||||
|
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||||
|
{
|
||||||
|
Logger.Info("AirApp Runtime IPC is ready.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Warn("AirApp Runtime did not become ready after pre-start; Host fallback remains available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AttachHostAsync(int hostProcessId)
|
||||||
|
{
|
||||||
|
if (hostProcessId <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new LanMountainDesktopIpcClient();
|
||||||
|
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||||
|
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||||
|
var result = await proxy.AttachHostAsync(hostProcessId).ConfigureAwait(false);
|
||||||
|
Logger.Info($"AirApp Runtime host attach completed. Accepted={result.Accepted}; Code='{result.Code}'; HostPid={hostProcessId}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warn($"Failed to attach Host to AirApp Runtime: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AirAppRuntimeStatus?> TryGetStatusAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new LanMountainDesktopIpcClient();
|
||||||
|
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||||
|
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||||
|
return await proxy.GetStatusAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.Launcher.Models;
|
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
||||||
@@ -30,52 +28,3 @@ internal static class LaunchEntryHandler
|
|||||||
SplashWindow splashWindow) =>
|
SplashWindow splashWindow) =>
|
||||||
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static class AirAppBrokerEntryHandler
|
|
||||||
{
|
|
||||||
public static async Task RunAsync(IClassicDesktopStyleApplicationLifetime desktop, CommandContext context)
|
|
||||||
{
|
|
||||||
var appRoot = Commands.ResolveAppRoot(context);
|
|
||||||
var requesterPid = context.GetIntOption("requester-pid", 0);
|
|
||||||
var dataLocationResolver = new DataLocationResolver(appRoot);
|
|
||||||
Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
|
|
||||||
|
|
||||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
|
||||||
new LauncherAirAppLifecycleService(
|
|
||||||
new AirAppProcessStarter(
|
|
||||||
new AirAppHostLocator(),
|
|
||||||
() => appRoot,
|
|
||||||
() => null,
|
|
||||||
() => dataLocationResolver.ResolveDataRoot())));
|
|
||||||
airAppIpcHost.Start();
|
|
||||||
|
|
||||||
while (ShouldKeepAlive(requesterPid, airAppIpcHost.LifecycleService))
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Info("Air APP broker exiting.");
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static bool ShouldKeepAirAppBrokerAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService)
|
|
||||||
{
|
|
||||||
if (requesterPid <= 0)
|
|
||||||
{
|
|
||||||
return lifecycleService.HasLiveAirApps();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var process = System.Diagnostics.Process.GetProcessById(requesterPid);
|
|
||||||
return !process.HasExited || lifecycleService.HasLiveAirApps();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return lifecycleService.HasLiveAirApps();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ShouldKeepAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService) =>
|
|
||||||
ShouldKeepAirAppBrokerAlive(requesterPid, lifecycleService);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ internal static class LauncherGuiCoordinator
|
|||||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||||
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
||||||
|
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
|
||||||
|
await airAppRuntimeBridge.EnsureStartedAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||||
context.LaunchSource,
|
context.LaunchSource,
|
||||||
successPolicy,
|
successPolicy,
|
||||||
@@ -44,15 +47,6 @@ internal static class LauncherGuiCoordinator
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
|
||||||
new LauncherAirAppLifecycleService(
|
|
||||||
new AirAppProcessStarter(
|
|
||||||
new AirAppHostLocator(),
|
|
||||||
() => appRoot,
|
|
||||||
() => null,
|
|
||||||
() => dataLocationResolver.ResolveDataRoot())));
|
|
||||||
airAppIpcHost.Start();
|
|
||||||
|
|
||||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||||
coordinatorPipeName,
|
coordinatorPipeName,
|
||||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||||
@@ -129,7 +123,8 @@ internal static class LauncherGuiCoordinator
|
|||||||
if (result.Success)
|
if (result.Success)
|
||||||
{
|
{
|
||||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||||
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
|
||||||
|
await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||||
@@ -173,17 +168,15 @@ internal static class LauncherGuiCoordinator
|
|||||||
return fallbackHostPid;
|
return fallbackHostPid;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WaitForManagedProcessesToExitAsync(
|
private static async Task WaitForHostProcessToExitAsync(int hostPid)
|
||||||
int hostPid,
|
|
||||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
|
||||||
{
|
{
|
||||||
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
|
Logger.Info($"Launcher entering host background lifetime. HostPid={hostPid}.");
|
||||||
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
|
while (TryGetLiveProcess(hostPid))
|
||||||
{
|
{
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
|
Logger.Info("Launcher host background lifetime completed; host process is gone.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
[IpcPublic(IgnoresIpcException = true)]
|
||||||
|
public interface IAirAppRuntimeControlService
|
||||||
|
{
|
||||||
|
Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId);
|
||||||
|
|
||||||
|
Task<AirAppRuntimeStatus> GetStatusAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AirAppRuntimeControlResult(
|
||||||
|
bool Accepted,
|
||||||
|
string Code,
|
||||||
|
string Message,
|
||||||
|
AirAppRuntimeStatus Status);
|
||||||
|
|
||||||
|
public sealed record AirAppRuntimeStatus(
|
||||||
|
int ProcessId,
|
||||||
|
int LauncherProcessId,
|
||||||
|
int HostProcessId,
|
||||||
|
bool LauncherProcessAlive,
|
||||||
|
bool HostProcessAlive,
|
||||||
|
bool HasLiveAirApps,
|
||||||
|
DateTimeOffset StartedAtUtc,
|
||||||
|
DateTimeOffset UpdatedAtUtc);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public static class AirAppRuntimeDataRootResolver
|
||||||
|
{
|
||||||
|
private const string LauncherDataFolderName = ".Launcher";
|
||||||
|
private const string ConfigFileName = "data-location.config.json";
|
||||||
|
private const string DesktopFolderName = "Desktop";
|
||||||
|
|
||||||
|
public static string ResolveDataRoot(string? appRoot)
|
||||||
|
{
|
||||||
|
var defaultSystemDataPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"LanMountainDesktop");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(appRoot))
|
||||||
|
{
|
||||||
|
return defaultSystemDataPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedAppRoot = Path.GetFullPath(appRoot);
|
||||||
|
var configPath = Path.Combine(normalizedAppRoot, LauncherDataFolderName, ConfigFileName);
|
||||||
|
if (!File.Exists(configPath))
|
||||||
|
{
|
||||||
|
return defaultSystemDataPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(File.ReadAllText(configPath));
|
||||||
|
var root = document.RootElement;
|
||||||
|
var mode = GetString(root, "dataLocationMode");
|
||||||
|
|
||||||
|
if (string.Equals(mode, "Portable", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(
|
||||||
|
GetString(root, "portableDataPath")
|
||||||
|
?? Path.Combine(normalizedAppRoot, DesktopFolderName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(GetString(root, "systemDataPath") ?? defaultSystemDataPath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return defaultSystemDataPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
property.Value.ValueKind is JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var value = property.Value.GetString();
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
LanMountainDesktop.Shared.IPC/AirAppRuntimePathResolver.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public static class AirAppRuntimePathResolver
|
||||||
|
{
|
||||||
|
private const string WindowsExecutableName = "LanMountainDesktop.AirAppRuntime.exe";
|
||||||
|
private const string UnixExecutableName = "LanMountainDesktop.AirAppRuntime";
|
||||||
|
private const string DllName = "LanMountainDesktop.AirAppRuntime.dll";
|
||||||
|
|
||||||
|
private static string ExecutableName => OperatingSystem.IsWindows()
|
||||||
|
? WindowsExecutableName
|
||||||
|
: UnixExecutableName;
|
||||||
|
|
||||||
|
public static string? ResolveExecutablePath(string? appRoot = null, string? hostBaseDirectory = null)
|
||||||
|
{
|
||||||
|
return EnumerateCandidates(appRoot, hostBaseDirectory)
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.FirstOrDefault(File.Exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<string> EnumerateCandidates(string? appRoot = null, string? hostBaseDirectory = null)
|
||||||
|
{
|
||||||
|
foreach (var root in EnumerateRoots(appRoot, hostBaseDirectory))
|
||||||
|
{
|
||||||
|
yield return Path.Combine(root, ExecutableName);
|
||||||
|
yield return Path.Combine(root, DllName);
|
||||||
|
yield return Path.Combine(root, "AirAppRuntime", ExecutableName);
|
||||||
|
yield return Path.Combine(root, "AirAppRuntime", DllName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
for (var depth = 0; depth < 8 && current is not null; depth++, current = current.Parent)
|
||||||
|
{
|
||||||
|
yield return Path.Combine(
|
||||||
|
current.FullName,
|
||||||
|
"LanMountainDesktop.AirAppRuntime",
|
||||||
|
"bin",
|
||||||
|
#if DEBUG
|
||||||
|
"Debug",
|
||||||
|
#else
|
||||||
|
"Release",
|
||||||
|
#endif
|
||||||
|
"net10.0",
|
||||||
|
ExecutableName);
|
||||||
|
|
||||||
|
yield return Path.Combine(
|
||||||
|
current.FullName,
|
||||||
|
"LanMountainDesktop.AirAppRuntime",
|
||||||
|
"bin",
|
||||||
|
#if DEBUG
|
||||||
|
"Debug",
|
||||||
|
#else
|
||||||
|
"Release",
|
||||||
|
#endif
|
||||||
|
"net10.0",
|
||||||
|
DllName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> EnumerateRoots(string? appRoot, string? hostBaseDirectory)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(appRoot))
|
||||||
|
{
|
||||||
|
yield return Path.GetFullPath(appRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(hostBaseDirectory))
|
||||||
|
{
|
||||||
|
var hostDirectory = Path.GetFullPath(hostBaseDirectory);
|
||||||
|
yield return hostDirectory;
|
||||||
|
yield return Path.GetFullPath(Path.Combine(hostDirectory, ".."));
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return AppContext.BaseDirectory;
|
||||||
|
yield return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
|
||||||
|
}
|
||||||
|
}
|
||||||
101
LanMountainDesktop.Shared.IPC/AirAppRuntimeProcessStarter.cs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed record AirAppRuntimeStartRequest(
|
||||||
|
string? AppRoot,
|
||||||
|
int LauncherProcessId,
|
||||||
|
int RequesterProcessId,
|
||||||
|
string? DataRoot);
|
||||||
|
|
||||||
|
public static class AirAppRuntimeProcessStarter
|
||||||
|
{
|
||||||
|
public static Process? Start(AirAppRuntimeStartRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var runtimePath = AirAppRuntimePathResolver.ResolveExecutablePath(
|
||||||
|
request.AppRoot,
|
||||||
|
AppContext.BaseDirectory);
|
||||||
|
if (string.IsNullOrWhiteSpace(runtimePath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startInfo = CreateStartInfo(runtimePath);
|
||||||
|
AddOptionalArgument(startInfo, "--app-root", request.AppRoot);
|
||||||
|
AddOptionalArgument(startInfo, "--data-root", request.DataRoot);
|
||||||
|
AddIntArgument(startInfo, "--launcher-pid", request.LauncherProcessId);
|
||||||
|
AddIntArgument(startInfo, "--requester-pid", request.RequesterProcessId);
|
||||||
|
return Process.Start(startInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProcessStartInfo CreateStartInfo(string runtimePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(runtimePath);
|
||||||
|
var fullPath = Path.GetFullPath(runtimePath);
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(fullPath) ?? AppContext.BaseDirectory
|
||||||
|
};
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(fullPath);
|
||||||
|
if (OperatingSystem.IsWindows() &&
|
||||||
|
string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
startInfo.FileName = ResolveDotNetHostPath();
|
||||||
|
startInfo.ArgumentList.Add(fullPath);
|
||||||
|
return startInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OperatingSystem.IsWindows() &&
|
||||||
|
string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
startInfo.FileName = "dotnet";
|
||||||
|
startInfo.ArgumentList.Add(fullPath);
|
||||||
|
return startInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
startInfo.FileName = fullPath;
|
||||||
|
return startInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveDotNetHostPath()
|
||||||
|
{
|
||||||
|
var programFiles = Environment.GetEnvironmentVariable("ProgramW6432") ??
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||||
|
var programFilesCandidate = Path.Combine(programFiles, "dotnet", "dotnet.exe");
|
||||||
|
if (File.Exists(programFilesCandidate))
|
||||||
|
{
|
||||||
|
return programFilesCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
var perUserCandidate = Path.Combine(localAppData, "dotnet", "dotnet.exe");
|
||||||
|
return File.Exists(perUserCandidate) ? perUserCandidate : "dotnet";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddOptionalArgument(ProcessStartInfo startInfo, string name, string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startInfo.ArgumentList.Add(name);
|
||||||
|
startInfo.ArgumentList.Add(Path.GetFullPath(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddIntArgument(ProcessStartInfo startInfo, string name, int value)
|
||||||
|
{
|
||||||
|
if (value <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startInfo.ArgumentList.Add(name);
|
||||||
|
startInfo.ArgumentList.Add(value.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ public static class IpcConstants
|
|||||||
|
|
||||||
public const string ProtocolVersion = "external-ipc-public-api.v1";
|
public const string ProtocolVersion = "external-ipc-public-api.v1";
|
||||||
|
|
||||||
public const string AirAppLifecyclePipeName = "LanMountainDesktop.Launcher.AirApp.v1";
|
public const string AirAppRuntimePipeName = "LanMountainDesktop.AirAppRuntime.v1";
|
||||||
|
|
||||||
|
[Obsolete("Use AirAppRuntimePipeName. The lifecycle service is now hosted by LanMountainDesktop.AirAppRuntime.")]
|
||||||
|
public const string AirAppLifecyclePipeName = AirAppRuntimePipeName;
|
||||||
|
|
||||||
public const string AirAppLifecycleProtocolVersion = "air-app-lifecycle.v1";
|
public const string AirAppLifecycleProtocolVersion = "air-app-lifecycle.v1";
|
||||||
|
|
||||||
|
|||||||
@@ -96,17 +96,17 @@ public sealed class AirAppLauncherServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CreateBrokerStartInfo_UsesAirAppBrokerCommandAndRequesterPid()
|
public void CreateRuntimeStartInfo_UsesAirAppRuntimeAndRequesterPid()
|
||||||
{
|
{
|
||||||
var startInfo = AirAppLauncherService.CreateBrokerStartInfo(
|
var startInfo = AirAppLauncherService.CreateRuntimeStartInfo(
|
||||||
@"C:\Apps\LanMountainDesktop.Launcher.exe",
|
@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe",
|
||||||
12345);
|
12345);
|
||||||
|
|
||||||
Assert.Equal(@"C:\Apps\LanMountainDesktop.Launcher.exe", startInfo.FileName);
|
Assert.Equal(@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe", startInfo.FileName);
|
||||||
Assert.Equal(@"C:\Apps", startInfo.WorkingDirectory);
|
Assert.Equal(@"C:\Apps", startInfo.WorkingDirectory);
|
||||||
Assert.False(startInfo.UseShellExecute);
|
Assert.False(startInfo.UseShellExecute);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
["air-app-broker", "--requester-pid", "12345"],
|
["--requester-pid", "12345"],
|
||||||
startInfo.ArgumentList);
|
startInfo.ArgumentList);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using LanMountainDesktop.Launcher.AirApp;
|
|
||||||
using LanMountainDesktop.Launcher.Infrastructure;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Tests;
|
namespace LanMountainDesktop.Tests;
|
||||||
@@ -29,38 +27,14 @@ public sealed class AirAppProcessStarterRuntimeTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CreateStartInfo_UsesArchitectureMatchedDotnetHost_ForDllFallbackOnWindows()
|
public void CreateStartInfo_UsesDotnetHost_ForDllFallback()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var programFiles = Path.Combine(_root, "ProgramFiles");
|
|
||||||
var dotnetRoot = Path.Combine(programFiles, "dotnet");
|
|
||||||
Directory.CreateDirectory(dotnetRoot);
|
|
||||||
var dotnetHost = Path.Combine(dotnetRoot, "dotnet.exe");
|
|
||||||
File.WriteAllText(dotnetHost, string.Empty);
|
|
||||||
Directory.CreateDirectory(Path.Combine(
|
|
||||||
dotnetRoot,
|
|
||||||
"shared",
|
|
||||||
DotNetRuntimeProbe.RequiredSharedFrameworkName,
|
|
||||||
"10.0.5"));
|
|
||||||
|
|
||||||
var hostDll = Path.Combine(_root, "LanMountainDesktop.AirAppHost.dll");
|
var hostDll = Path.Combine(_root, "LanMountainDesktop.AirAppHost.dll");
|
||||||
File.WriteAllText(hostDll, string.Empty);
|
File.WriteAllText(hostDll, string.Empty);
|
||||||
var options = new DotNetRuntimeProbeOptions
|
|
||||||
{
|
|
||||||
Architecture = DotNetRuntimeArchitecture.X64,
|
|
||||||
ProgramFilesPath = programFiles,
|
|
||||||
ProgramFilesX86Path = Path.Combine(_root, "ProgramFilesX86"),
|
|
||||||
IncludeRegistry = false,
|
|
||||||
IncludeDotNetCli = false
|
|
||||||
};
|
|
||||||
|
|
||||||
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll, options);
|
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll);
|
||||||
|
|
||||||
Assert.Equal(dotnetHost, startInfo.FileName);
|
Assert.Contains("dotnet", Path.GetFileName(startInfo.FileName), StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Equal(hostDll, startInfo.ArgumentList.Single());
|
Assert.Equal(hostDll, startInfo.ArgumentList.Single());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class AirAppRuntimeDataRootResolverTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop.AirAppRuntimeDataRootResolverTests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveDataRoot_UsesPortableDataLocationConfig()
|
||||||
|
{
|
||||||
|
var portableRoot = Path.Combine(_root, "PortableData");
|
||||||
|
WriteConfig(new
|
||||||
|
{
|
||||||
|
dataLocationMode = "Portable",
|
||||||
|
portableDataPath = portableRoot
|
||||||
|
});
|
||||||
|
|
||||||
|
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
|
||||||
|
|
||||||
|
Assert.Equal(Path.GetFullPath(portableRoot), resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveDataRoot_UsesSystemDataLocationConfig()
|
||||||
|
{
|
||||||
|
var systemRoot = Path.Combine(_root, "SystemData");
|
||||||
|
WriteConfig(new
|
||||||
|
{
|
||||||
|
dataLocationMode = "System",
|
||||||
|
systemDataPath = systemRoot
|
||||||
|
});
|
||||||
|
|
||||||
|
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
|
||||||
|
|
||||||
|
Assert.Equal(Path.GetFullPath(systemRoot), resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveDataRoot_FallsBackToDefaultWhenConfigMissing()
|
||||||
|
{
|
||||||
|
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LanMountainDesktop"),
|
||||||
|
resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteConfig<T>(T config)
|
||||||
|
{
|
||||||
|
var configDirectory = Path.Combine(_root, ".Launcher");
|
||||||
|
Directory.CreateDirectory(configDirectory);
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(configDirectory, "data-location.config.json"),
|
||||||
|
JsonSerializer.Serialize(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
{
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Launcher;
|
|
||||||
using LanMountainDesktop.Launcher.AirApp;
|
|
||||||
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Tests;
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
public sealed class LauncherAirAppLifecycleServiceTests
|
public sealed class AirAppRuntimeLifecycleServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenAsync_ReusesExistingInstanceForSameKey()
|
public async Task OpenAsync_ReusesExistingInstanceForSameKey()
|
||||||
{
|
{
|
||||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||||
var service = new LauncherAirAppLifecycleService(starter);
|
var service = new AirAppLifecycleService(starter);
|
||||||
var request = new AirAppOpenRequest(
|
var request = new AirAppOpenRequest(
|
||||||
"whiteboard",
|
"whiteboard",
|
||||||
BuiltInComponentIds.DesktopWhiteboard,
|
BuiltInComponentIds.DesktopWhiteboard,
|
||||||
@@ -36,7 +33,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
|||||||
public async Task OpenAsync_ReusesGlobalClockSuiteAcrossClockComponents()
|
public async Task OpenAsync_ReusesGlobalClockSuiteAcrossClockComponents()
|
||||||
{
|
{
|
||||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||||
var service = new LauncherAirAppLifecycleService(starter);
|
var service = new AirAppLifecycleService(starter);
|
||||||
|
|
||||||
var first = await service.OpenAsync(new AirAppOpenRequest(
|
var first = await service.OpenAsync(new AirAppOpenRequest(
|
||||||
"world-clock",
|
"world-clock",
|
||||||
@@ -62,7 +59,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
|||||||
public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart()
|
public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart()
|
||||||
{
|
{
|
||||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||||
var service = new LauncherAirAppLifecycleService(starter);
|
var service = new AirAppLifecycleService(starter);
|
||||||
var instanceKey = AirAppInstanceKey.Build(
|
var instanceKey = AirAppInstanceKey.Build(
|
||||||
"whiteboard",
|
"whiteboard",
|
||||||
BuiltInComponentIds.DesktopWhiteboard,
|
BuiltInComponentIds.DesktopWhiteboard,
|
||||||
@@ -92,7 +89,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance()
|
public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance()
|
||||||
{
|
{
|
||||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
|
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
|
||||||
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1");
|
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1");
|
||||||
|
|
||||||
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
||||||
@@ -112,26 +109,35 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AirAppBrokerLifetime_KeepsAliveWhileRequesterIsAlive()
|
public void RuntimeLifetime_KeepsAliveWhileRequesterIsAlive()
|
||||||
{
|
{
|
||||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||||
|
var lifetime = new AirAppRuntimeLifetime(
|
||||||
|
new AirAppRuntimeOptions(null, null, 0, Environment.ProcessId),
|
||||||
|
service);
|
||||||
|
|
||||||
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service));
|
Assert.True(lifetime.ShouldKeepAlive());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AirAppBrokerLifetime_StopsWhenRequesterExitedAndNoAirAppsRemain()
|
public void RuntimeLifetime_StopsWhenNoProcessOrAirAppsRemain()
|
||||||
{
|
{
|
||||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||||
|
var lifetime = new AirAppRuntimeLifetime(
|
||||||
|
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||||
|
service);
|
||||||
|
|
||||||
Assert.False(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
|
Assert.False(lifetime.ShouldKeepAlive());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AirAppBrokerLifetime_KeepsAliveWhileAirAppIsAlive()
|
public async Task RuntimeLifetime_KeepsAliveWhileAirAppIsAlive()
|
||||||
{
|
{
|
||||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||||
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-2");
|
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-2");
|
||||||
|
var lifetime = new AirAppRuntimeLifetime(
|
||||||
|
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||||
|
service);
|
||||||
|
|
||||||
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
||||||
instanceKey,
|
instanceKey,
|
||||||
@@ -142,28 +148,23 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
|||||||
BuiltInComponentIds.DesktopWorldClock,
|
BuiltInComponentIds.DesktopWorldClock,
|
||||||
"clock-2"));
|
"clock-2"));
|
||||||
|
|
||||||
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
|
Assert.True(lifetime.ShouldKeepAlive());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CommandContext_RecognizesAirAppBrokerAsGuiCommandInDebugEnvironment()
|
public async Task RuntimeControl_AttachesHostProcess()
|
||||||
{
|
{
|
||||||
var oldEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||||
try
|
var lifetime = new AirAppRuntimeLifetime(
|
||||||
{
|
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development");
|
service);
|
||||||
|
var control = new AirAppRuntimeControlService(lifetime);
|
||||||
|
|
||||||
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
|
var result = await control.AttachHostAsync(Environment.ProcessId);
|
||||||
|
|
||||||
Assert.True(context.IsGuiCommand);
|
Assert.True(result.Accepted);
|
||||||
Assert.True(context.IsAirAppBrokerCommand);
|
Assert.Equal(Environment.ProcessId, result.Status.HostProcessId);
|
||||||
Assert.True(context.IsDebugMode);
|
Assert.True(result.Status.HostProcessAlive);
|
||||||
Assert.Equal(42, context.GetIntOption("requester-pid", 0));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", oldEnvironment);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class TestAirAppProcessStarter : IAirAppProcessStarter
|
private sealed class TestAirAppProcessStarter : IAirAppProcessStarter
|
||||||
@@ -9,7 +9,6 @@ public sealed class CommandContextTests
|
|||||||
{
|
{
|
||||||
{ [], "normal" },
|
{ [], "normal" },
|
||||||
{ ["preview-oobe"], "debug-preview" },
|
{ ["preview-oobe"], "debug-preview" },
|
||||||
{ ["apply-update"], "normal" },
|
|
||||||
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
|
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
|
||||||
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
|
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
|
||||||
};
|
};
|
||||||
@@ -22,4 +21,12 @@ public sealed class CommandContextTests
|
|||||||
|
|
||||||
Assert.Equal(expectedLaunchSource, context.LaunchSource);
|
Assert.Equal(expectedLaunchSource, context.LaunchSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromArgs_DoesNotTreatAirAppBrokerAsLauncherGuiCommand()
|
||||||
|
{
|
||||||
|
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
|
||||||
|
|
||||||
|
Assert.False(context.IsGuiCommand);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
LanMountainDesktop.Tests/DataLocationResolverTests.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class DataLocationResolverTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _appRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop.Tests",
|
||||||
|
nameof(DataLocationResolverTests),
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyLocationChoice_PortableWithoutCustomPath_UsesAppRootDesktopDirectory()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_appRoot);
|
||||||
|
var resolver = new DataLocationResolver(_appRoot);
|
||||||
|
|
||||||
|
var applied = resolver.ApplyLocationChoice(DataLocationMode.Portable);
|
||||||
|
|
||||||
|
Assert.True(applied);
|
||||||
|
Assert.Equal(
|
||||||
|
Path.Combine(Path.GetFullPath(_appRoot), "Desktop"),
|
||||||
|
resolver.ResolveDataRoot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_appRoot))
|
||||||
|
{
|
||||||
|
Directory.Delete(_appRoot, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
global using LanMountainDesktop.Launcher.AirApp;
|
global using LanMountainDesktop.AirAppRuntime;
|
||||||
global using LanMountainDesktop.Launcher.Deployment;
|
global using LanMountainDesktop.Launcher.Deployment;
|
||||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
global using LanMountainDesktop.Launcher.Ipc;
|
global using LanMountainDesktop.Launcher.Ipc;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ public sealed class HostActivationPolicyTests
|
|||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("launch", "normal", true)]
|
[InlineData("launch", "normal", true)]
|
||||||
[InlineData("launch", "restart", false)]
|
[InlineData("launch", "restart", false)]
|
||||||
[InlineData("apply-update", "normal", false)]
|
|
||||||
public void ShouldProbeExistingHostBeforeLaunch_RespectsLaunchSource(
|
public void ShouldProbeExistingHostBeforeLaunch_RespectsLaunchSource(
|
||||||
string command,
|
string command,
|
||||||
string launchSource,
|
string launchSource,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.AirAppRuntime\LanMountainDesktop.AirAppRuntime.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -47,6 +47,95 @@ public sealed class LauncherArchitectureTests
|
|||||||
Assert.Empty(offenders);
|
Assert.Empty(offenders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LauncherProject_DoesNotOwnUpdateApplyOrRollback()
|
||||||
|
{
|
||||||
|
var launcherFiles = Directory
|
||||||
|
.EnumerateFiles(LauncherProjectRoot, "*.cs", SearchOption.AllDirectories)
|
||||||
|
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var forbiddenTokens = new[]
|
||||||
|
{
|
||||||
|
"LauncherUpdateCommandExecutor",
|
||||||
|
"PlondsUpdateApplier",
|
||||||
|
"UpdateRollbackGateway",
|
||||||
|
"UpdateInstallGateway",
|
||||||
|
"LanMountainDesktop.Services.Update",
|
||||||
|
"apply-update",
|
||||||
|
"rollback --app-root"
|
||||||
|
};
|
||||||
|
|
||||||
|
var offenders = launcherFiles
|
||||||
|
.SelectMany(file => forbiddenTokens
|
||||||
|
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
|
||||||
|
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Empty(offenders);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LauncherProjectFile_DoesNotSourceLinkHostUpdateImplementation()
|
||||||
|
{
|
||||||
|
var project = File.ReadAllText(Path.Combine(LauncherProjectRoot, "LanMountainDesktop.Launcher.csproj"));
|
||||||
|
|
||||||
|
Assert.DoesNotContain(@"..\LanMountainDesktop\Services\Update", project, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("PlondsUpdateApplier", project, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("UpdateRollbackGateway", project, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("UpdateInstallGateway", project, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HostUpdateFlow_DoesNotDelegateApplyOrRollbackToLauncher()
|
||||||
|
{
|
||||||
|
var guardedFiles = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateInstallGateway.cs"),
|
||||||
|
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateOrchestrator.cs")
|
||||||
|
};
|
||||||
|
|
||||||
|
var forbiddenTokens = new[]
|
||||||
|
{
|
||||||
|
"LauncherPathResolver",
|
||||||
|
"ResolveLauncherExecutablePath",
|
||||||
|
"apply-update",
|
||||||
|
"rollback --app-root",
|
||||||
|
"Launched Launcher"
|
||||||
|
};
|
||||||
|
|
||||||
|
var offenders = guardedFiles
|
||||||
|
.SelectMany(file => forbiddenTokens
|
||||||
|
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
|
||||||
|
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Empty(offenders);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HostUpdateFlow_OwnsDeltaApplyAndRollbackExecution()
|
||||||
|
{
|
||||||
|
var installGateway = File.ReadAllText(Path.Combine(
|
||||||
|
RepoRoot,
|
||||||
|
"LanMountainDesktop",
|
||||||
|
"Services",
|
||||||
|
"Update",
|
||||||
|
"UpdateInstallGateway.cs"));
|
||||||
|
var orchestrator = File.ReadAllText(Path.Combine(
|
||||||
|
RepoRoot,
|
||||||
|
"LanMountainDesktop",
|
||||||
|
"Services",
|
||||||
|
"Update",
|
||||||
|
"UpdateOrchestrator.cs"));
|
||||||
|
|
||||||
|
Assert.Contains("new PlondsUpdateApplier", installGateway, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DeploymentLockService.ClearLock", installGateway, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("new UpdateRollbackGateway().RollbackLatest", orchestrator, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("LanMountainDesktop.Launcher", orchestrator, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LauncherCompositionRootStaysThin()
|
public void LauncherCompositionRootStaysThin()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
global using LanMountainDesktop.Launcher.AirApp;
|
global using LanMountainDesktop.AirAppRuntime;
|
||||||
global using LanMountainDesktop.Launcher.Deployment;
|
global using LanMountainDesktop.Launcher.Deployment;
|
||||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
global using LanMountainDesktop.Launcher.Ipc;
|
global using LanMountainDesktop.Launcher.Ipc;
|
||||||
|
|||||||
59
LanMountainDesktop.Tests/LauncherUpdateCommandTests.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Launcher;
|
||||||
|
using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class LauncherUpdateCommandTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop.LauncherUpdateCommandTests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyUpdateCommand_IsNotHandledByLauncherCli()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
var resultPath = Path.Combine(_root, "result.json");
|
||||||
|
var context = CommandContext.FromArgs(["apply-update", "--app-root", _root, "--result", resultPath]);
|
||||||
|
|
||||||
|
var exitCode = await Commands.RunCliCommandAsync(context);
|
||||||
|
var result = ReadResult(resultPath);
|
||||||
|
|
||||||
|
Assert.Equal(1, exitCode);
|
||||||
|
Assert.Equal("command", result.Stage);
|
||||||
|
Assert.Equal("unsupported_command", result.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RollbackCommand_IsNotHandledByLauncherCli()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
var resultPath = Path.Combine(_root, "result.json");
|
||||||
|
var context = CommandContext.FromArgs(["rollback", "--app-root", _root, "--result", resultPath]);
|
||||||
|
|
||||||
|
var exitCode = await Commands.RunCliCommandAsync(context);
|
||||||
|
var result = ReadResult(resultPath);
|
||||||
|
|
||||||
|
Assert.Equal(1, exitCode);
|
||||||
|
Assert.Equal("command", result.Stage);
|
||||||
|
Assert.Equal("unsupported_command", result.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LauncherResult ReadResult(string path)
|
||||||
|
{
|
||||||
|
var result = JsonSerializer.Deserialize<LauncherResult>(File.ReadAllText(path));
|
||||||
|
return result ?? throw new InvalidOperationException("Launcher result was not written.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
{
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using LanMountainDesktop.Appearance;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Settings.Core;
|
||||||
|
using LanMountainDesktop.Shared.Contracts;
|
||||||
|
using LanMountainDesktop.ViewModels;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class MaterialColorSettingsPageViewModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Load_SelectsSavedNoneMaterialMode()
|
||||||
|
{
|
||||||
|
var facade = new FakeSettingsFacade(CreateThemeState(ThemeAppearanceValues.MaterialNone));
|
||||||
|
var materialService = new FakeMaterialColorService(CreateSnapshot(ThemeAppearanceValues.MaterialNone));
|
||||||
|
|
||||||
|
var viewModel = new MaterialColorSettingsPageViewModel(facade, materialService);
|
||||||
|
|
||||||
|
Assert.Equal(ThemeAppearanceValues.MaterialNone, viewModel.SelectedSystemMaterialMode.Value);
|
||||||
|
Assert.Contains(viewModel.SystemMaterialModes, option => option.Value == ThemeAppearanceValues.MaterialAuto);
|
||||||
|
Assert.Contains(viewModel.SystemMaterialModes, option => option.Value == ThemeAppearanceValues.MaterialNone);
|
||||||
|
Assert.Contains(viewModel.SystemMaterialModes, option => option.Value == ThemeAppearanceValues.MaterialMica);
|
||||||
|
Assert.Contains(viewModel.SystemMaterialModes, option => option.Value == ThemeAppearanceValues.MaterialAcrylic);
|
||||||
|
Assert.Equal(0, facade.ThemeSaveCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaterialSnapshotRefresh_KeepsExplicitNoneSelection()
|
||||||
|
{
|
||||||
|
var facade = new FakeSettingsFacade(CreateThemeState(ThemeAppearanceValues.MaterialNone));
|
||||||
|
var materialService = new FakeMaterialColorService(CreateSnapshot(ThemeAppearanceValues.MaterialNone));
|
||||||
|
var viewModel = new MaterialColorSettingsPageViewModel(facade, materialService);
|
||||||
|
|
||||||
|
materialService.RaiseChanged(CreateSnapshot(ThemeAppearanceValues.MaterialAuto));
|
||||||
|
|
||||||
|
Assert.Equal(ThemeAppearanceValues.MaterialNone, viewModel.SelectedSystemMaterialMode.Value);
|
||||||
|
Assert.Equal(0, facade.ThemeSaveCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ThemeAppearanceValues.MaterialNone)]
|
||||||
|
[InlineData(ThemeAppearanceValues.MaterialAuto)]
|
||||||
|
[InlineData(ThemeAppearanceValues.MaterialMica)]
|
||||||
|
[InlineData(ThemeAppearanceValues.MaterialAcrylic)]
|
||||||
|
public void UserSelection_SavesRequestedMaterialMode(string targetMode)
|
||||||
|
{
|
||||||
|
var initialMode = targetMode == ThemeAppearanceValues.MaterialNone
|
||||||
|
? ThemeAppearanceValues.MaterialAuto
|
||||||
|
: ThemeAppearanceValues.MaterialNone;
|
||||||
|
var facade = new FakeSettingsFacade(CreateThemeState(initialMode));
|
||||||
|
var materialService = new FakeMaterialColorService(CreateSnapshot(initialMode));
|
||||||
|
var viewModel = new MaterialColorSettingsPageViewModel(facade, materialService);
|
||||||
|
|
||||||
|
viewModel.SelectedSystemMaterialMode = viewModel.SystemMaterialModes.Single(option =>
|
||||||
|
option.Value == targetMode);
|
||||||
|
|
||||||
|
Assert.Equal(targetMode, facade.ThemeState.SystemMaterialMode);
|
||||||
|
Assert.Equal(1, facade.ThemeSaveCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserSelection_SystemMaterialModeRequestsRestart()
|
||||||
|
{
|
||||||
|
var facade = new FakeSettingsFacade(CreateThemeState(ThemeAppearanceValues.MaterialNone));
|
||||||
|
var materialService = new FakeMaterialColorService(CreateSnapshot(ThemeAppearanceValues.MaterialNone));
|
||||||
|
var viewModel = new MaterialColorSettingsPageViewModel(facade, materialService);
|
||||||
|
string? restartReason = null;
|
||||||
|
viewModel.RestartRequested += reason => restartReason = reason;
|
||||||
|
|
||||||
|
viewModel.SelectedSystemMaterialMode = viewModel.SystemMaterialModes.Single(option =>
|
||||||
|
option.Value == ThemeAppearanceValues.MaterialMica);
|
||||||
|
|
||||||
|
Assert.Equal(viewModel.SystemMaterialRestartMessage, restartReason);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(restartReason));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ThemeAppearanceSettingsState CreateThemeState(string materialMode)
|
||||||
|
{
|
||||||
|
return new ThemeAppearanceSettingsState(
|
||||||
|
IsNightMode: false,
|
||||||
|
ThemeColor: "#FF445566",
|
||||||
|
UseSystemChrome: false,
|
||||||
|
CornerRadiusStyle: GlobalAppearanceSettings.CornerRadiusStyleRounded,
|
||||||
|
ThemeColorMode: ThemeAppearanceValues.ColorModeDefaultNeutral,
|
||||||
|
SystemMaterialMode: materialMode,
|
||||||
|
SelectedWallpaperSeed: null,
|
||||||
|
ThemeMode: ThemeAppearanceValues.ThemeModeLight,
|
||||||
|
ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceAuto,
|
||||||
|
UseNativeWallpaperChangeEvents: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MaterialColorSnapshot CreateSnapshot(string materialMode)
|
||||||
|
{
|
||||||
|
var seed = Color.Parse("#FF3B82F6");
|
||||||
|
var palette = new LanMountainDesktop.Models.MaterialColorPalette(
|
||||||
|
seed,
|
||||||
|
Color.Parse("#FF64748B"),
|
||||||
|
seed,
|
||||||
|
Colors.White,
|
||||||
|
Color.Parse("#FF60A5FA"),
|
||||||
|
Color.Parse("#FF93C5FD"),
|
||||||
|
Color.Parse("#FFBFDBFE"),
|
||||||
|
Color.Parse("#FF2563EB"),
|
||||||
|
Color.Parse("#FF1D4ED8"),
|
||||||
|
Color.Parse("#FF1E40AF"),
|
||||||
|
Color.Parse("#FFF8FAFC"),
|
||||||
|
Color.Parse("#FFFFFFFF"),
|
||||||
|
Color.Parse("#FFF1F5F9"),
|
||||||
|
Color.Parse("#FF0F172A"),
|
||||||
|
Color.Parse("#FF334155"),
|
||||||
|
Color.Parse("#FF64748B"),
|
||||||
|
seed,
|
||||||
|
Color.Parse("#FF0F172A"),
|
||||||
|
Colors.White,
|
||||||
|
seed,
|
||||||
|
Color.Parse("#22000000"),
|
||||||
|
Color.Parse("#33000000"),
|
||||||
|
Color.Parse("#443B82F6"),
|
||||||
|
seed,
|
||||||
|
Color.Parse("#4464748B"),
|
||||||
|
Color.Parse("#663B82F6"));
|
||||||
|
var surface = new MaterialSurfaceSnapshot(
|
||||||
|
MaterialSurfaceRole.SettingsWindowBackground,
|
||||||
|
Color.Parse("#FFF8FAFC"),
|
||||||
|
Color.Parse("#22000000"),
|
||||||
|
0,
|
||||||
|
1);
|
||||||
|
var surfaces = new Dictionary<MaterialSurfaceRole, MaterialSurfaceSnapshot>
|
||||||
|
{
|
||||||
|
[MaterialSurfaceRole.SettingsWindowBackground] = surface,
|
||||||
|
[MaterialSurfaceRole.DockBackground] = surface with { Role = MaterialSurfaceRole.DockBackground },
|
||||||
|
[MaterialSurfaceRole.DesktopComponentHost] = surface with { Role = MaterialSurfaceRole.DesktopComponentHost },
|
||||||
|
[MaterialSurfaceRole.OverlayPanel] = surface with { Role = MaterialSurfaceRole.OverlayPanel }
|
||||||
|
};
|
||||||
|
|
||||||
|
return new MaterialColorSnapshot(
|
||||||
|
IsNightMode: false,
|
||||||
|
ThemeColorMode: ThemeAppearanceValues.ColorModeDefaultNeutral,
|
||||||
|
ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceAuto,
|
||||||
|
ColorSourceKind: MaterialColorSourceKind.Neutral,
|
||||||
|
ResolvedSeedSource: "neutral",
|
||||||
|
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
|
||||||
|
new CornerRadius(2),
|
||||||
|
new CornerRadius(4),
|
||||||
|
new CornerRadius(6),
|
||||||
|
new CornerRadius(8),
|
||||||
|
new CornerRadius(10),
|
||||||
|
new CornerRadius(12),
|
||||||
|
new CornerRadius(14),
|
||||||
|
new CornerRadius(8)),
|
||||||
|
UserThemeColor: seed.ToString(),
|
||||||
|
SelectedWallpaperSeed: null,
|
||||||
|
EffectiveSeedColor: seed,
|
||||||
|
AccentColor: seed,
|
||||||
|
MonetPalette: new MonetPalette([seed], seed, seed, seed, seed, seed, seed),
|
||||||
|
Palette: palette,
|
||||||
|
WallpaperSeedCandidates: [seed],
|
||||||
|
SystemMaterialMode: materialMode,
|
||||||
|
AvailableSystemMaterialModes:
|
||||||
|
[
|
||||||
|
ThemeAppearanceValues.MaterialAuto,
|
||||||
|
ThemeAppearanceValues.MaterialNone,
|
||||||
|
ThemeAppearanceValues.MaterialMica,
|
||||||
|
ThemeAppearanceValues.MaterialAcrylic
|
||||||
|
],
|
||||||
|
CanChangeSystemMaterial: true,
|
||||||
|
UseSystemChrome: false,
|
||||||
|
ResolvedWallpaperPath: null,
|
||||||
|
UseNativeWallpaperChangeEvents: true,
|
||||||
|
NativeWallpaperChangeEventsActive: false,
|
||||||
|
WallpaperPollingActive: false,
|
||||||
|
Surfaces: surfaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSettingsFacade(ThemeAppearanceSettingsState themeState) : ISettingsFacadeService
|
||||||
|
{
|
||||||
|
private readonly FakeThemeAppearanceService _theme = new(themeState);
|
||||||
|
private readonly FakeRegionSettingsService _region = new();
|
||||||
|
private readonly FakeWallpaperSettingsService _wallpaper = new();
|
||||||
|
|
||||||
|
public ThemeAppearanceSettingsState ThemeState => _theme.State;
|
||||||
|
public int ThemeSaveCount => _theme.SaveCount;
|
||||||
|
|
||||||
|
public ISettingsService Settings => throw new NotSupportedException();
|
||||||
|
public ISettingsCatalog Catalog => throw new NotSupportedException();
|
||||||
|
public IGridSettingsService Grid => throw new NotSupportedException();
|
||||||
|
public IWallpaperSettingsService Wallpaper => _wallpaper;
|
||||||
|
public IWallpaperMediaService WallpaperMedia => throw new NotSupportedException();
|
||||||
|
public IThemeAppearanceService Theme => _theme;
|
||||||
|
public IStatusBarSettingsService StatusBar => throw new NotSupportedException();
|
||||||
|
public ITextCapsuleSettingsService TextCapsule => throw new NotSupportedException();
|
||||||
|
public IWeatherSettingsService Weather => throw new NotSupportedException();
|
||||||
|
public IRegionSettingsService Region => _region;
|
||||||
|
public IPrivacySettingsService Privacy => throw new NotSupportedException();
|
||||||
|
public IUpdateSettingsService Update => throw new NotSupportedException();
|
||||||
|
public ILauncherCatalogService LauncherCatalog => throw new NotSupportedException();
|
||||||
|
public ILauncherPolicyService LauncherPolicy => throw new NotSupportedException();
|
||||||
|
public IPluginManagementSettingsService PluginManagement => throw new NotSupportedException();
|
||||||
|
public IPluginCatalogSettingsService PluginCatalog => throw new NotSupportedException();
|
||||||
|
public IApplicationInfoService ApplicationInfo => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeThemeAppearanceService(ThemeAppearanceSettingsState state) : IThemeAppearanceService
|
||||||
|
{
|
||||||
|
public ThemeAppearanceSettingsState State { get; private set; } = state;
|
||||||
|
public int SaveCount { get; private set; }
|
||||||
|
|
||||||
|
public ThemeAppearanceSettingsState Get() => State;
|
||||||
|
|
||||||
|
public void Save(ThemeAppearanceSettingsState state)
|
||||||
|
{
|
||||||
|
SaveCount++;
|
||||||
|
State = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MonetPalette BuildPalette(bool nightMode, string? wallpaperPath, string? preferredSeedColor = null)
|
||||||
|
{
|
||||||
|
var seed = Color.Parse(preferredSeedColor ?? "#FF3B82F6");
|
||||||
|
return new MonetPalette([seed], seed, seed, seed, seed, seed, seed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeRegionSettingsService : IRegionSettingsService
|
||||||
|
{
|
||||||
|
public RegionSettingsState Get() => new("en-US", null);
|
||||||
|
|
||||||
|
public void Save(RegionSettingsState state)
|
||||||
|
{
|
||||||
|
_ = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeZoneService GetTimeZoneService() => new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWallpaperSettingsService : IWallpaperSettingsService
|
||||||
|
{
|
||||||
|
public WallpaperSettingsState Get() => new(null, "SolidColor", "#FFFFFFFF", "Fill", 300);
|
||||||
|
|
||||||
|
public void Save(WallpaperSettingsState state)
|
||||||
|
{
|
||||||
|
_ = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeMaterialColorService(MaterialColorSnapshot snapshot) : IMaterialColorService
|
||||||
|
{
|
||||||
|
private MaterialColorSnapshot _snapshot = snapshot;
|
||||||
|
|
||||||
|
public event EventHandler<MaterialColorSnapshot>? MaterialColorChanged;
|
||||||
|
|
||||||
|
public MaterialColorSnapshot GetMaterialColorSnapshot() => _snapshot;
|
||||||
|
|
||||||
|
public MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState)
|
||||||
|
{
|
||||||
|
_ = pendingState;
|
||||||
|
return _snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyThemeResources(IResourceDictionary resources)
|
||||||
|
{
|
||||||
|
_ = resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role)
|
||||||
|
{
|
||||||
|
return _snapshot.Surfaces[role];
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role)
|
||||||
|
{
|
||||||
|
_ = window;
|
||||||
|
_ = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshWallpaperColors()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RaiseChanged(MaterialColorSnapshot snapshot)
|
||||||
|
{
|
||||||
|
_snapshot = snapshot;
|
||||||
|
MaterialColorChanged?.Invoke(this, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,12 @@ public sealed class PackagingRuntimePolicyTests
|
|||||||
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "package.ps1");
|
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "package.ps1");
|
||||||
|
|
||||||
Assert.Contains("Publish-LauncherPayload", script);
|
Assert.Contains("Publish-LauncherPayload", script);
|
||||||
|
Assert.Contains("Publish-AirAppRuntimePayload", script);
|
||||||
Assert.Contains("\"app-$Version\"", script);
|
Assert.Contains("\"app-$Version\"", script);
|
||||||
Assert.Contains("Publish-MainAppFrameworkDependentPayload", script);
|
Assert.Contains("Publish-MainAppFrameworkDependentPayload", script);
|
||||||
Assert.Contains("\"--self-contained\", \"false\"", script);
|
Assert.Contains("\"--self-contained\", \"false\"", script);
|
||||||
Assert.Contains("\"-p:SelfContained=false\"", script);
|
Assert.Contains("\"-p:SelfContained=false\"", script);
|
||||||
|
Assert.Contains("\"-p:PublishAot=false\"", script);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -28,12 +30,13 @@ public sealed class PackagingRuntimePolicyTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WindowsPayloadGuard_RequiresLauncherMainAndAirAppHost()
|
public void WindowsPayloadGuard_RequiresLauncherRuntimeMainAndAirAppHost()
|
||||||
{
|
{
|
||||||
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1");
|
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1");
|
||||||
|
|
||||||
Assert.Contains("Assert-WindowsPayloadContainsRequiredHosts", script);
|
Assert.Contains("Assert-WindowsPayloadContainsRequiredHosts", script);
|
||||||
Assert.Contains("LanMountainDesktop.Launcher.exe", script);
|
Assert.Contains("LanMountainDesktop.Launcher.exe", script);
|
||||||
|
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", script);
|
||||||
Assert.Contains("LanMountainDesktop.exe", script);
|
Assert.Contains("LanMountainDesktop.exe", script);
|
||||||
Assert.Contains("LanMountainDesktop.AirAppHost.exe", script);
|
Assert.Contains("LanMountainDesktop.AirAppHost.exe", script);
|
||||||
}
|
}
|
||||||
@@ -44,9 +47,21 @@ public sealed class PackagingRuntimePolicyTests
|
|||||||
var workflow = ReadRepositoryFile(".github", "workflows", "release.yml");
|
var workflow = ReadRepositoryFile(".github", "workflows", "release.yml");
|
||||||
|
|
||||||
Assert.Contains("Verify Windows app host payload", workflow);
|
Assert.Contains("Verify Windows app host payload", workflow);
|
||||||
|
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", workflow);
|
||||||
Assert.Contains("LanMountainDesktop.AirAppHost.exe", workflow);
|
Assert.Contains("LanMountainDesktop.AirAppHost.exe", workflow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AirAppRuntimeProject_IsFrameworkDependentJit()
|
||||||
|
{
|
||||||
|
var project = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "LanMountainDesktop.AirAppRuntime.csproj");
|
||||||
|
|
||||||
|
Assert.Contains("<PublishAot>false</PublishAot>", project);
|
||||||
|
Assert.Contains("<SelfContained>false</SelfContained>", project);
|
||||||
|
Assert.Contains("<PublishTrimmed>false</PublishTrimmed>", project);
|
||||||
|
Assert.Contains("<PublishReadyToRun>false</PublishReadyToRun>", project);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Installer_DownloadsArchitectureSpecificDesktopRuntime()
|
public void Installer_DownloadsArchitectureSpecificDesktopRuntime()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using LanMountainDesktop.Launcher.Plugins;
|
using LanMountainDesktop.Launcher.Plugins;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Text.Json;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Tests;
|
namespace LanMountainDesktop.Tests;
|
||||||
@@ -34,10 +35,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
|||||||
Directory.CreateDirectory(_tempRoot);
|
Directory.CreateDirectory(_tempRoot);
|
||||||
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
|
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
|
||||||
|
|
||||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||||
var service = new PluginInstallerService();
|
var service = new PluginInstallerService();
|
||||||
|
|
||||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||||
|
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Equal("ok", result.Code);
|
Assert.Equal("ok", result.Code);
|
||||||
@@ -49,6 +50,42 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
|||||||
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
|
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InstallPackage_AllowsConfiguredPortableDataRootOutsideUserScope()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_tempRoot);
|
||||||
|
var appRoot = Path.Combine(_tempRoot, "PackageRoot");
|
||||||
|
var portableDataRoot = Path.Combine(appRoot, "Desktop");
|
||||||
|
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
|
||||||
|
Directory.CreateDirectory(launcherDataRoot);
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(launcherDataRoot, "data-location.config.json"),
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
DataLocationMode = "Portable",
|
||||||
|
SystemDataPath = Path.Combine(_tempRoot, "System"),
|
||||||
|
PortableDataPath = portableDataRoot
|
||||||
|
}));
|
||||||
|
|
||||||
|
var packagePath = Path.Combine(_tempRoot, "portable.laapp");
|
||||||
|
CreatePluginPackage(packagePath, "plugin.json", "plugin.portable.sample", "Portable Plugin");
|
||||||
|
|
||||||
|
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
|
||||||
|
var service = new PluginInstallerService();
|
||||||
|
|
||||||
|
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal("ok", result.Code);
|
||||||
|
Assert.True(File.Exists(result.InstalledPackagePath));
|
||||||
|
Assert.StartsWith(Path.GetFullPath(portableDataRoot), Path.GetFullPath(result.InstalledPackagePath!), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
|
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
|
||||||
{
|
{
|
||||||
@@ -58,11 +95,11 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
|||||||
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
|
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
|
||||||
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
|
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
|
||||||
|
|
||||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||||
var service = new PluginInstallerService();
|
var service = new PluginInstallerService();
|
||||||
|
|
||||||
var first = service.InstallPackage(firstPackagePath, pluginsDirectory);
|
var first = service.InstallPackage(firstPackagePath, pluginsDirectory, appRoot);
|
||||||
var second = service.InstallPackage(secondPackagePath, pluginsDirectory);
|
var second = service.InstallPackage(secondPackagePath, pluginsDirectory, appRoot);
|
||||||
|
|
||||||
Assert.True(first.Success);
|
Assert.True(first.Success);
|
||||||
Assert.True(second.Success);
|
Assert.True(second.Success);
|
||||||
@@ -77,10 +114,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
|||||||
Directory.CreateDirectory(_tempRoot);
|
Directory.CreateDirectory(_tempRoot);
|
||||||
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
|
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
|
||||||
|
|
||||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||||
var service = new PluginInstallerService();
|
var service = new PluginInstallerService();
|
||||||
|
|
||||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||||
|
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Equal("plugin.legacy.sample", result.ManifestId);
|
Assert.Equal("plugin.legacy.sample", result.ManifestId);
|
||||||
@@ -103,18 +140,24 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CreateUserScopedPluginsDirectory()
|
private string CreateConfiguredPortablePluginsDirectory(out string appRoot)
|
||||||
{
|
{
|
||||||
var root = Path.Combine(
|
appRoot = Path.Combine(_tempRoot, "ConfiguredPackageRoot", Guid.NewGuid().ToString("N"));
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
var portableDataRoot = Path.Combine(appRoot, "Desktop");
|
||||||
"LanMountainDesktop",
|
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
|
||||||
"Tests",
|
Directory.CreateDirectory(launcherDataRoot);
|
||||||
nameof(PluginInstallerServiceTests),
|
File.WriteAllText(
|
||||||
Guid.NewGuid().ToString("N"),
|
Path.Combine(launcherDataRoot, "data-location.config.json"),
|
||||||
"Extensions",
|
JsonSerializer.Serialize(new
|
||||||
"Plugins");
|
{
|
||||||
Directory.CreateDirectory(root);
|
DataLocationMode = "Portable",
|
||||||
return root;
|
SystemDataPath = Path.Combine(_tempRoot, "System"),
|
||||||
|
PortableDataPath = portableDataRoot
|
||||||
|
}));
|
||||||
|
|
||||||
|
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
|
||||||
|
Directory.CreateDirectory(pluginsDirectory);
|
||||||
|
return pluginsDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
40
LanMountainDesktop.Tests/PluginRuntimeDataPathTests.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class PluginRuntimeDataPathTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dataRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop.Tests",
|
||||||
|
nameof(PluginRuntimeDataPathTests),
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PluginRuntime_UsesHostDataRootForPluginsAndMarketData()
|
||||||
|
{
|
||||||
|
AppDataPathProvider.Initialize(["--data-root", _dataRoot]);
|
||||||
|
|
||||||
|
using var runtime = new PluginRuntimeService();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
Path.Combine(Path.GetFullPath(_dataRoot), "Extensions", "Plugins"),
|
||||||
|
runtime.PluginsDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
AppDataPathProvider.ResetForTests();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_dataRoot))
|
||||||
|
{
|
||||||
|
Directory.Delete(_dataRoot, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
LanMountainDesktop.Tests/SettingsWindowShellVisualTests.cs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class SettingsWindowShellVisualTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SettingsWindow_UsesOneFullWindowBackgroundBehindTitlebarAndContent()
|
||||||
|
{
|
||||||
|
var xaml = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsWindow.axaml");
|
||||||
|
|
||||||
|
Assert.Contains("x:Name=\"RootGrid\"", xaml);
|
||||||
|
Assert.Contains("Background=\"Transparent\"", ExtractElementStart(xaml, "<Grid x:Name=\"RootGrid\""));
|
||||||
|
Assert.Contains("Grid.RowSpan=\"2\"", xaml);
|
||||||
|
Assert.Contains("Background=\"{DynamicResource AdaptiveSettingsWindowBackgroundBrush}\"", xaml);
|
||||||
|
Assert.Contains("Background=\"{DynamicResource AdaptiveSettingsWindowTintBrush}\"", xaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettingsWindow_TitlebarDoesNotPaintASeparateSurfaceBand()
|
||||||
|
{
|
||||||
|
var xaml = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsWindow.axaml");
|
||||||
|
var titlebar = ExtractElementStart(xaml, "<Border x:Name=\"WindowTitleBarHost\"");
|
||||||
|
|
||||||
|
Assert.Contains("Background=\"Transparent\"", titlebar);
|
||||||
|
Assert.Contains("BorderBrush=\"Transparent\"", titlebar);
|
||||||
|
Assert.Contains("BorderThickness=\"0\"", titlebar);
|
||||||
|
Assert.DoesNotContain("BorderThickness=\"0,0,0,1\"", titlebar);
|
||||||
|
Assert.DoesNotContain("AdaptiveSettingsWindowBackgroundBrush", titlebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettingsWindow_NavigationShellBackgroundsAreTransparent()
|
||||||
|
{
|
||||||
|
var xaml = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsWindow.axaml");
|
||||||
|
|
||||||
|
Assert.Contains("Classes=\"settings-navigation-view\"", xaml);
|
||||||
|
Assert.Contains("<SolidColorBrush x:Key=\"NavigationViewContentBackground\" Color=\"Transparent\" />", xaml);
|
||||||
|
Assert.Contains("<SolidColorBrush x:Key=\"NavigationViewContentGridBorderBrush\" Color=\"Transparent\" />", xaml);
|
||||||
|
Assert.Contains("<SolidColorBrush x:Key=\"NavigationViewDefaultPaneBackground\" Color=\"Transparent\" />", xaml);
|
||||||
|
Assert.Contains("<SolidColorBrush x:Key=\"NavigationViewExpandedPaneBackground\" Color=\"Transparent\" />", xaml);
|
||||||
|
Assert.Contains("<SolidColorBrush x:Key=\"NavigationViewTopPaneBackground\" Color=\"Transparent\" />", xaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigationStyles_KeepSettingsNavigationTemplateTransparent()
|
||||||
|
{
|
||||||
|
var styles = ReadRepositoryFile("LanMountainDesktop", "Styles", "NavigationStyles.axaml");
|
||||||
|
|
||||||
|
Assert.Contains("ui|FANavigationView.settings-navigation-view", styles);
|
||||||
|
Assert.Contains("Grid#RootGrid", styles);
|
||||||
|
Assert.Contains("Grid#ContentGrid", styles);
|
||||||
|
Assert.Contains("Grid#PaneRoot", styles);
|
||||||
|
Assert.Contains("Border#NavigationViewBorder", styles);
|
||||||
|
Assert.Contains("Border#ContentGridBorder", styles);
|
||||||
|
Assert.Contains("Border#PaneBorder", styles);
|
||||||
|
Assert.Contains("<Setter Property=\"Background\" Value=\"Transparent\" />", styles);
|
||||||
|
Assert.Contains("<Setter Property=\"BorderBrush\" Value=\"Transparent\" />", styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractElementStart(string source, string startToken)
|
||||||
|
{
|
||||||
|
var start = source.IndexOf(startToken, StringComparison.Ordinal);
|
||||||
|
Assert.True(start >= 0, $"Could not find '{startToken}'.");
|
||||||
|
|
||||||
|
var end = source.IndexOf('>', start);
|
||||||
|
Assert.True(end > start, $"Could not find end of '{startToken}'.");
|
||||||
|
|
||||||
|
return source.Substring(start, end - start + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadRepositoryFile(params string[] segments)
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return File.ReadAllText(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
103
LanMountainDesktop.Tests/SystemChromeModeTests.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class SystemChromeModeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SettingsWindow_SystemChromeUsesNativeDecorations()
|
||||||
|
{
|
||||||
|
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsWindow.axaml.cs");
|
||||||
|
var applyChromeMode = ExtractMethodSource(source, "ApplyChromeMode");
|
||||||
|
var onLoaded = ExtractMethodSource(source, "OnLoaded");
|
||||||
|
|
||||||
|
Assert.Contains("_useSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();", applyChromeMode);
|
||||||
|
Assert.Contains("WindowDecorations = WindowDecorations.Full;", applyChromeMode);
|
||||||
|
Assert.Contains("ExtendClientAreaToDecorationsHint = !_useSystemChrome;", applyChromeMode);
|
||||||
|
Assert.Contains("ExtendClientAreaTitleBarHeightHint = _useSystemChrome ? 0d : CustomTitleBarHeight;", applyChromeMode);
|
||||||
|
Assert.Contains("TitleBar.ExtendsContentIntoTitleBar = !_useSystemChrome;", applyChromeMode);
|
||||||
|
Assert.Contains("WindowTitleBarHost.IsVisible = false;", applyChromeMode);
|
||||||
|
Assert.Contains("WindowTitleBarHost.IsVisible = true;", applyChromeMode);
|
||||||
|
Assert.DoesNotContain("TitleBar.ExtendsContentIntoTitleBar = true;", onLoaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComponentEditorWindow_SystemChromeUsesNativeDecorations()
|
||||||
|
{
|
||||||
|
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "ComponentEditorWindow.axaml.cs");
|
||||||
|
var applyChromeMode = ExtractMethodSource(source, "ApplyChromeMode");
|
||||||
|
|
||||||
|
Assert.Contains("var preferSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();", applyChromeMode);
|
||||||
|
Assert.Contains("WindowDecorations = WindowDecorations.Full;", applyChromeMode);
|
||||||
|
Assert.Contains("ExtendClientAreaToDecorationsHint = false;", applyChromeMode);
|
||||||
|
Assert.Contains("ExtendClientAreaTitleBarHeightHint = 0d;", applyChromeMode);
|
||||||
|
Assert.Contains("CustomTitleBarHost.IsVisible = false;", applyChromeMode);
|
||||||
|
Assert.Contains("WindowDecorations = WindowDecorations.BorderOnly;", applyChromeMode);
|
||||||
|
Assert.Contains("ExtendClientAreaToDecorationsHint = true;", applyChromeMode);
|
||||||
|
Assert.Contains("CustomTitleBarHost.IsVisible = true;", applyChromeMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SavingSystemChromeSynchronizesWindowsPatcherState()
|
||||||
|
{
|
||||||
|
var source = ReadRepositoryFile("LanMountainDesktop", "Services", "Settings", "SettingsDomainServices.cs");
|
||||||
|
|
||||||
|
Assert.Contains("if (OperatingSystem.IsWindows())", source);
|
||||||
|
Assert.Contains("LanMountainDesktop.Platform.Windows.ChromePatchState.UseSystemChrome = state.UseSystemChrome;", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadRepositoryFile(params string[] segments)
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return File.ReadAllText(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractMethodSource(string source, string methodName)
|
||||||
|
{
|
||||||
|
var methodIndex = source.IndexOf($"private void {methodName}(", StringComparison.Ordinal);
|
||||||
|
if (methodIndex < 0)
|
||||||
|
{
|
||||||
|
methodIndex = source.IndexOf($"public void {methodName}(", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(methodIndex >= 0, $"Could not locate method '{methodName}'.");
|
||||||
|
|
||||||
|
var braceIndex = source.IndexOf('{', methodIndex);
|
||||||
|
Assert.True(braceIndex >= 0, $"Could not locate method body for '{methodName}'.");
|
||||||
|
|
||||||
|
var depth = 0;
|
||||||
|
for (var i = braceIndex; i < source.Length; i++)
|
||||||
|
{
|
||||||
|
if (source[i] == '{')
|
||||||
|
{
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
else if (source[i] == '}')
|
||||||
|
{
|
||||||
|
depth--;
|
||||||
|
if (depth == 0)
|
||||||
|
{
|
||||||
|
return source.Substring(methodIndex, i - methodIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Could not extract method '{methodName}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,7 +130,7 @@ public sealed class WindowLayerIsolationTests
|
|||||||
{
|
{
|
||||||
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
|
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
|
||||||
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs");
|
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs");
|
||||||
var starterSource = ReadRepositoryFile("LanMountainDesktop.Launcher", "AirApp", "IAirAppProcessStarter.cs");
|
var starterSource = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "IAirAppProcessStarter.cs");
|
||||||
var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
|
var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
|
||||||
|
|
||||||
Assert.Contains("DataRoot", optionsSource);
|
Assert.Contains("DataRoot", optionsSource);
|
||||||
@@ -146,15 +146,10 @@ public sealed class WindowLayerIsolationTests
|
|||||||
public void FusedDesktopWindows_KeepDesktopBottomMostBoundary()
|
public void FusedDesktopWindows_KeepDesktopBottomMostBoundary()
|
||||||
{
|
{
|
||||||
var desktopWidgetWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "DesktopWidgetWindow.axaml.cs");
|
var desktopWidgetWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "DesktopWidgetWindow.axaml.cs");
|
||||||
var transparentOverlayWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "TransparentOverlayWindow.axaml.cs");
|
|
||||||
|
|
||||||
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", desktopWidgetWindow);
|
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", desktopWidgetWindow);
|
||||||
Assert.Contains("RefreshDesktopLayer", desktopWidgetWindow);
|
Assert.Contains("RefreshDesktopLayer", desktopWidgetWindow);
|
||||||
Assert.Contains("SendToBottom", desktopWidgetWindow);
|
Assert.Contains("SendToBottom", desktopWidgetWindow);
|
||||||
|
|
||||||
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", transparentOverlayWindow);
|
|
||||||
Assert.Contains("RefreshDesktopLayer", transparentOverlayWindow);
|
|
||||||
Assert.Contains("SendToBottom", transparentOverlayWindow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
|
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginPackaging/LanMountainDesktop.PluginPackaging.csproj" />
|
<Project Path="LanMountainDesktop.PluginPackaging/LanMountainDesktop.PluginPackaging.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj" />
|
||||||
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
|
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||||
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
||||||
|
|||||||
@@ -75,9 +75,7 @@ public partial class App : Application
|
|||||||
private DispatcherTimer? _shellRecoveryTimer;
|
private DispatcherTimer? _shellRecoveryTimer;
|
||||||
private PluginRuntimeService? _pluginRuntimeService;
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
private MainWindow? _mainWindow;
|
private MainWindow? _mainWindow;
|
||||||
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;
|
||||||
@@ -454,22 +452,10 @@ public partial class App : Application
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate();
|
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
|
||||||
fusedDesktopManager.EnterEditMode();
|
|
||||||
|
|
||||||
EnsureTransparentOverlayWindow();
|
|
||||||
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow.Show();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_fusedComponentLibraryWindow is { } existingWindow)
|
if (_fusedComponentLibraryWindow is { } existingWindow)
|
||||||
{
|
{
|
||||||
if (_transparentOverlayWindow is not null)
|
|
||||||
{
|
|
||||||
existingWindow.SetOverlayWindow(_transparentOverlayWindow);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existingWindow.IsVisible)
|
if (!existingWindow.IsVisible)
|
||||||
{
|
{
|
||||||
existingWindow.Show();
|
existingWindow.Show();
|
||||||
@@ -477,7 +463,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
if (centerInWorkArea)
|
if (centerInWorkArea)
|
||||||
{
|
{
|
||||||
existingWindow.CenterInWorkArea(_transparentOverlayWindow);
|
existingWindow.CenterInWorkArea();
|
||||||
}
|
}
|
||||||
|
|
||||||
existingWindow.Activate();
|
existingWindow.Activate();
|
||||||
@@ -486,16 +472,12 @@ public partial class App : Application
|
|||||||
|
|
||||||
var window = new FusedDesktopComponentLibraryWindow();
|
var window = new FusedDesktopComponentLibraryWindow();
|
||||||
_fusedComponentLibraryWindow = window;
|
_fusedComponentLibraryWindow = window;
|
||||||
if (_transparentOverlayWindow is not null)
|
|
||||||
{
|
|
||||||
window.SetOverlayWindow(_transparentOverlayWindow);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Closed += OnFusedComponentLibraryWindowClosed;
|
window.Closed += OnFusedComponentLibraryWindowClosed;
|
||||||
window.Show();
|
window.Show();
|
||||||
if (centerInWorkArea)
|
if (centerInWorkArea)
|
||||||
{
|
{
|
||||||
window.CenterInWorkArea(_transparentOverlayWindow);
|
window.CenterInWorkArea();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Activate();
|
window.Activate();
|
||||||
@@ -503,7 +485,13 @@ public partial class App : Application
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
|
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||||
|
if (_fusedComponentLibraryWindow is { } libWindow)
|
||||||
|
{
|
||||||
|
_fusedComponentLibraryWindow = null;
|
||||||
|
libWindow.Closed -= OnFusedComponentLibraryWindowClosed;
|
||||||
|
libWindow.Close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,50 +508,13 @@ public partial class App : Application
|
|||||||
_fusedComponentLibraryWindow = null;
|
_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
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow?.SaveLayoutAndHide();
|
|
||||||
}
|
|
||||||
catch (Exception overlayEx)
|
|
||||||
{
|
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay.", overlayEx);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||||
}
|
}
|
||||||
catch (Exception exitEx)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode.", exitEx);
|
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode after library closed.", ex);
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isExitingFusedDesktopEditMode = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,11 +841,6 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
|
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
|
||||||
|
|
||||||
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow.Hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||||
mainWindow.PrepareEnterAnimation();
|
mainWindow.PrepareEnterAnimation();
|
||||||
|
|
||||||
@@ -939,26 +885,6 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureTransparentOverlayWindow()
|
|
||||||
{
|
|
||||||
if (_transparentOverlayWindow is null)
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow = new TransparentOverlayWindow();
|
|
||||||
_transparentOverlayWindow.RestoreMainWindowRequested += (s, e) =>
|
|
||||||
{
|
|
||||||
RestoreOrCreateMainWindow("TransparentOverlay");
|
|
||||||
};
|
|
||||||
_transparentOverlayWindow.ExitEditRequested += (s, e) =>
|
|
||||||
{
|
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
|
||||||
};
|
|
||||||
_transparentOverlayWindow.RestoreComponentLibraryRequested += (s, e) =>
|
|
||||||
{
|
|
||||||
OpenFusedDesktopComponentLibraryFromUi(centerInWorkArea: true);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
|
internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
|
||||||
{
|
{
|
||||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
@@ -1263,31 +1189,16 @@ public partial class App : Application
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_fusedComponentLibraryWindow = null;
|
_fusedComponentLibraryWindow = null;
|
||||||
try
|
|
||||||
{
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode during shutdown.", ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_transparentOverlayWindow is not null)
|
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_transparentOverlayWindow.Close();
|
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("DesktopShell", "Failed to close transparent overlay during exit cleanup.", ex);
|
AppLogger.Warn("FusedDesktop", "Failed to shut down fused desktop manager during exit cleanup.", ex);
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioRecorderServiceFactory.DisposeSharedServices();
|
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||||
@@ -1572,13 +1483,6 @@ public partial class App : Application
|
|||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"DesktopShell",
|
"DesktopShell",
|
||||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||||
|
|
||||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
|
||||||
if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop)
|
|
||||||
{
|
|
||||||
EnsureTransparentOverlayWindow();
|
|
||||||
_transparentOverlayWindow?.Show();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1668,7 +1572,6 @@ public partial class App : Application
|
|||||||
|
|
||||||
if (IsMainWindowDesktopLayerEnabled())
|
if (IsMainWindowDesktopLayerEnabled())
|
||||||
{
|
{
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
||||||
_mainWindow.ShowInTaskbar = false;
|
_mainWindow.ShowInTaskbar = false;
|
||||||
_mainWindowDesktopLayerService.EnableOrRefresh(_mainWindow);
|
_mainWindowDesktopLayerService.EnableOrRefresh(_mainWindow);
|
||||||
@@ -1697,7 +1600,6 @@ public partial class App : Application
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# MiSans 字体说明
|
|
||||||
|
|
||||||
## 中文
|
|
||||||
|
|
||||||
本项目内置 MiSans 字体,用于在不同设备上保持相对一致的文字渲染效果。
|
|
||||||
|
|
||||||
### 包含文件
|
|
||||||
|
|
||||||
- `MiSans-Regular.ttf`
|
|
||||||
- `MiSans-Semibold.ttf`
|
|
||||||
- `MiSans-Bold.ttf`
|
|
||||||
|
|
||||||
### 来源
|
|
||||||
|
|
||||||
- 上游仓库:https://github.com/dsrkafuu/misans
|
|
||||||
- 上游所引用的小米字体页面:https://hyperos.mi.com/font/zh/
|
|
||||||
|
|
||||||
### 许可与使用说明
|
|
||||||
|
|
||||||
- 上游脚本或打包仓库使用 Apache-2.0 许可。
|
|
||||||
- MiSans 字体本身的版权和补充使用条款以小米官方说明为准:
|
|
||||||
- https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
|
||||||
|
|
||||||
在重新分发本项目时,请自行确认并遵守 MiSans 字体的相关条款。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
This project bundles MiSans fonts for more consistent cross-device rendering.
|
|
||||||
|
|
||||||
### Sources
|
|
||||||
|
|
||||||
- Upstream package repository: https://github.com/dsrkafuu/misans
|
|
||||||
- Xiaomi font source page: https://hyperos.mi.com/font/zh/
|
|
||||||
|
|
||||||
Please review and comply with the MiSans font terms before redistributing this application.
|
|
||||||
BIN
LanMountainDesktop/Assets/endfiled/Listen.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
LanMountainDesktop/Assets/endfiled/admire_happy.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
LanMountainDesktop/Assets/endfiled/believe_power.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
LanMountainDesktop/Assets/endfiled/error.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
LanMountainDesktop/Assets/endfiled/find.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
LanMountainDesktop/Assets/endfiled/good.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
LanMountainDesktop/Assets/endfiled/haha.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
LanMountainDesktop/Assets/endfiled/happy_paopao.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
LanMountainDesktop/Assets/endfiled/huh.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
LanMountainDesktop/Assets/endfiled/idea.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
LanMountainDesktop/Assets/endfiled/idontcare.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
LanMountainDesktop/Assets/endfiled/listen_write.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
LanMountainDesktop/Assets/endfiled/love.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
LanMountainDesktop/Assets/endfiled/newest_yes.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
LanMountainDesktop/Assets/endfiled/no_good.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
LanMountainDesktop/Assets/endfiled/nonononono.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |