Compare commits

..

8 Commits

Author SHA1 Message Date
lincube
a2ac302ee7 fix. 插件安装修复 2026-06-01 01:12:52 +08:00
lincube
c351a8e7f3 feat.airapp剥离启动器 2026-05-31 19:41:10 +08:00
lincube
21e970c5b6 fix.修复了窗口问题,以及多次显示圆角调节选项的问题。 2026-05-31 12:12:56 +08:00
lincube
17873f0f43 fix.修复设置页面 2026-05-30 17:15:16 +08:00
lincube
4051b5cd74 qchanged. 修改了Mac OS打包逻辑 2026-05-30 16:11:25 +08:00
lincube
5be4537b2c feat.Arknight endfiled 2026-05-30 13:50:13 +08:00
lincube
c5e75244af feat.PLONDS系统会不断地改进 2026-05-30 13:47:15 +08:00
lincube
6a650873bc feat..去除了冗余的字体文件,又修改了PLONDS系统 2026-05-30 11:56:50 +08:00
195 changed files with 5705 additions and 4642 deletions

View File

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

View File

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

View 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.

View 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.

View 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,512 @@
# PLONDS Comparator 改造设计
> 日期2026-05-30
> 状态:待审批
## 1. 背景与动机
PLONDSPenguin 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. 对比哈希值,生成 filesMapadd/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 命令的清理(后续处理)

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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}");
}

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

View File

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

View File

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

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

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" />

View File

@@ -1,17 +0,0 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 更新频道
/// </summary>
public enum UpdateChannel
{
/// <summary>
/// 正式版 - 只检查 prerelease=false 的版本
/// </summary>
Stable,
/// <summary>
/// 预览版 - 检查所有版本(包括 prerelease=true)
/// </summary>
Preview
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>",

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

View File

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

View File

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

View File

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

View File

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

View 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, ".."));
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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
{
}
}
}

View 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)}'.");
}
}

View 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}'.");
}
}

View File

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

View File

@@ -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" />

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Some files were not shown because too many files have changed in this diff Show More