Compare commits

..

14 Commits

Author SHA1 Message Date
lincube
4f9feafbbe fix.继续修ci,ci怎么天天炸 2026-04-19 02:12:34 +08:00
lincube
9cf3a15c89 fix.我们试验性地修复了启动器无法正常启动的问题,原因可能是这个画面没有启动,就GUI没显示。然后还把编译问题修了一下。 2026-04-18 23:36:31 +08:00
lincube
e8d2575bc1 feat.依旧试增量更新这一块,看看velopack 2026-04-18 19:50:33 +08:00
lincube
4b897831de changed.优化了更新体验 2026-04-18 00:49:03 +08:00
lincube
9283da5940 changed.调整了启动逻辑,优化了更新页面。 2026-04-17 22:33:41 +08:00
lincube
9efa43d92b Update LanMountainDesktop.csproj 2026-04-17 18:30:44 +08:00
lincube
53ff98f66d Update build.yml 2026-04-17 17:50:02 +08:00
lincube
6c526ffdd2 fix.ci难修,为什么liunx跑不起来呢? 2026-04-17 17:39:31 +08:00
lincube
3957d81948 fix.修CI,好像是因为Linux那边有个问题,反正修就对了。 2026-04-17 17:03:13 +08:00
lincube
81ee19f360 feat.尝试弄了AOT的启动器。 2026-04-17 15:16:01 +08:00
lincube
59c4824425 fix.启动器一定要能够启动 2026-04-16 19:28:58 +08:00
lincube
e9ff590d79 fix.可爱的我一直在修CI( 2026-04-16 14:45:44 +08:00
lincube
1aaf6cd0e9 试试 2026-04-16 14:17:46 +08:00
lincube
2f0c178df2 激进的更新 2026-04-16 01:59:21 +08:00
200 changed files with 1929 additions and 12411 deletions

View File

@@ -67,14 +67,9 @@ jobs:
libx11-6 libxrandr2 libxinerama1 \ libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \ libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0 \ libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev clang zlib1g-dev \
libportaudio2 libasound2 \
# Ubuntu 24.04+ moved several packages to t64 names. libwebkit2gtk-4.1-dev
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
# Prefer modern WebKit package, fallback for older images.
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4

View File

@@ -25,7 +25,7 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4

View File

@@ -1,166 +0,0 @@
name: DDSS
on:
workflow_run:
workflows:
- PLONDS
types:
- completed
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
env:
DOTNET_VERSION: '10.0.x'
jobs:
publish:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "$RAW_TAG" == v* ]]; then
TAG="$RAW_TAG"
else
TAG="v$RAW_TAG"
fi
else
gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Prepare signing key
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_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 KEY="${PDC_SIGNING_KEY:-}"; 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
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir -p release-assets
gh release download "$RELEASE_TAG" -D release-assets
find release-assets -maxdepth 1 -type f | sort
- name: Upload release assets to Rainyun S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_REGION: ${{ vars.S3_REGION }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
shell: bash
run: |
set -euo pipefail
aws --version
for file in release-assets/*; do
[[ -f "$file" ]] || continue
name="$(basename "$file")"
if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then
continue
fi
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)"
if [[ "$existing_sha" == "$sha256" ]]; then
echo "Skip existing asset: $name"
continue
fi
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$key" \
--body "$file" \
--metadata "sha256=$sha256"
done
- name: Build DDSS manifest
shell: bash
run: |
set -euo pipefail
mkdir -p ddss-output
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
build-ddss \
--release-tag "$RELEASE_TAG" \
--assets-dir release-assets \
--output-dir ddss-output \
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
--repository "${{ github.repository }}" \
--s3-base-url "$S3_BASE_URL"
- name: Upload DDSS manifest to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber
- name: Upload DDSS manifest to Rainyun S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_REGION: ${{ vars.S3_REGION }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
shell: bash
run: |
set -euo pipefail
for file in ddss-output/ddss.json ddss-output/ddss.json.sig; do
name="$(basename "$file")"
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$key" \
--body "$file" \
--metadata "sha256=$sha256"
done

View File

@@ -1,235 +0,0 @@
name: PLONDS
on:
release:
types:
- published
- prereleased
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
baseline_tag:
description: 'Optional baseline tag'
required: false
type: string
channel:
description: 'Update channel'
required: false
type: choice
default: stable
options:
- stable
- preview
env:
DOTNET_VERSION: '10.0.x'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release context
shell: bash
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
TAG="${{ github.event.release.tag_name }}"
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
CHANNEL="preview"
else
CHANNEL="stable"
fi
BASELINE_TAG=""
else
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == v* ]]; then
TAG="${RAW_TAG}"
else
TAG="v${RAW_TAG}"
fi
CHANNEL="${{ github.event.inputs.channel }}"
BASELINE_TAG="${{ github.event.inputs.baseline_tag }}"
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Prepare signing key
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_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 KEY="${PDC_SIGNING_KEY:-}"; 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
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Resolve baseline plan
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$tag = $env:RELEASE_TAG
$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) {
$assetName = "files-$platform.zip"
$currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
if (-not $currentAsset) {
throw "Current release $tag does not contain required asset $assetName"
}
$baselineRelease = $null
if (-not [string]::IsNullOrWhiteSpace($baselineInput)) {
$normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" }
$baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1
if (-not $baselineRelease) {
throw "Specified baseline tag not found: $normalizedBaseline"
}
}
else {
$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]@{
platform = $platform
assetName = $assetName
baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null }
baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null }
isFullPayload = -not $baselineRelease
}
}
$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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
foreach ($entry in $plan.platforms) {
$currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)"
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)) {
$baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)"
New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null
gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir
}
}
- name: Build delta assets
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
foreach ($entry in $plan.platforms) {
$currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)"
$args = @(
'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--',
'build-delta',
'--platform', $entry.platform,
'--current-version', $plan.version,
'--current-tag', $plan.tag,
'--current-zip', $currentZip,
'--output-dir', 'plonds-output',
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
'--channel', $plan.channel
)
if ([bool]$entry.isFullPayload) {
$args += @('--is-full-payload', 'true')
}
else {
$baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)"
$args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip)
}
dotnet @args
}
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- `
build-index `
--release-tag $plan.tag `
--version $plan.version `
--channel $plan.channel `
--platform-summaries-dir plonds-output/platform-summaries `
--output-dir plonds-output `
--private-key $env:UPDATE_PRIVATE_KEY_PATH
- name: Upload PLONDS assets to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber
- name: Persist run metadata
shell: bash
run: |
mkdir -p plonds-run-metadata
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
- name: Upload run metadata artifact
uses: actions/upload-artifact@v4
with:
name: plonds-run-metadata
path: plonds-run-metadata/tag.txt
if-no-files-found: error
retention-days: 7

View File

@@ -30,22 +30,14 @@ jobs:
informational_version: ${{ steps.version.outputs.informational_version }} informational_version: ${{ steps.version.outputs.informational_version }}
tag: ${{ steps.version.outputs.tag }} tag: ${{ steps.version.outputs.tag }}
checkout_ref: ${{ steps.version.outputs.checkout_ref }} checkout_ref: ${{ steps.version.outputs.checkout_ref }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
release_channel: ${{ steps.version.outputs.release_channel }}
steps: steps:
- name: Checkout repository metadata
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get release info - name: Get release info
id: version id: version
run: | run: |
if [[ "${{ github.event_name }}" == "push" ]]; then if [[ "${{ github.event_name }}" == "push" ]]; then
TAG="${GITHUB_REF#refs/tags/}" TAG="${GITHUB_REF#refs/tags/}"
CHECKOUT_REF="${GITHUB_REF}" CHECKOUT_REF="${GITHUB_REF}"
IS_PRERELEASE="false"
else else
RAW_TAG="${{ github.event.inputs.tag }}" RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == refs/tags/* ]]; then if [[ "${RAW_TAG}" == refs/tags/* ]]; then
@@ -55,40 +47,19 @@ jobs:
else else
TAG="v${RAW_TAG}" TAG="v${RAW_TAG}"
fi fi
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
CHECKOUT_REF="refs/tags/${TAG}"
else
CHECKOUT_REF="${GITHUB_SHA}" CHECKOUT_REF="${GITHUB_SHA}"
fi fi
if [[ "${{ github.event.inputs.is_prerelease }}" == "true" ]]; then
IS_PRERELEASE="true"
else
IS_PRERELEASE="false"
fi
fi
VERSION="${TAG#v}" VERSION="${TAG#v}"
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}" IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
VERSION_PARTS+=("0") VERSION_PARTS+=("0")
done done
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}" ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
if [[ "${IS_PRERELEASE}" == "true" ]]; then echo "version=${VERSION}" >> $GITHUB_OUTPUT
RELEASE_CHANNEL="preview" echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT
else echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT
RELEASE_CHANNEL="stable" echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "assembly_version=${ASSEMBLY_VERSION}" >> "$GITHUB_OUTPUT"
echo "informational_version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "checkout_ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT"
echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT"
echo "release_channel=${RELEASE_CHANNEL}" >> "$GITHUB_OUTPUT"
build-windows: build-windows:
needs: prepare needs: prepare
@@ -136,6 +107,9 @@ jobs:
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
$launcherPublishDir = "publish/launcher-win-$arch" $launcherPublishDir = "publish/launcher-win-$arch"
Write-Host "Publishing Launcher with AOT for Windows $arch..."
# AOT 单文件发布
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj ` dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release ` -c Release `
-o ./$launcherPublishDir ` -o ./$launcherPublishDir `
@@ -146,16 +120,27 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true ` -p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true ` -p:EnableCompressionInSingleFile=true `
-p:DebugType=none ` -p:DebugType=none `
-p:DebugSymbols=false ` -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 }}
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Error "Launcher AOT publish failed" Write-Error "Launcher AOT publish failed"
exit 1 exit 1
} }
# 显示发布结果
Write-Host "Launcher published to: $launcherPublishDir"
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
$size = [Math]::Round($exeFile.Length / 1MB, 2)
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
}
# 清理不必要的文件AOT 单文件应该只有一个 exe
$files = Get-ChildItem -Path $launcherPublishDir -File
if ($files.Count -gt 1) {
Write-Host "Warning: Expected single file but found $($files.Count) files"
$files | ForEach-Object { Write-Host " - $($_.Name)" }
}
shell: pwsh shell: pwsh
- name: Publish Main App - name: Publish Main App
@@ -193,6 +178,9 @@ 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 }}
} }
Write-Host "Published to: $publishDir"
Write-Host "Self-contained: $selfContained"
shell: pwsh shell: pwsh
- name: Restructure for Launcher - name: Restructure for Launcher
@@ -203,18 +191,30 @@ jobs:
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$launcherPublishDir = "publish/launcher-win-$arch" $launcherPublishDir = "publish/launcher-win-$arch"
$appDir = "app-$version" $appDir = "app-$version"
$newStructure = "publish-launcher/windows-$arch"
Write-Host "Restructuring for Launcher mode..."
Write-Host "Version: $version"
Write-Host "Publish dir: $publishDir"
$newStructure = "publish-launcher/windows-$arch"
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
$appPath = Join-Path $newStructure $appDir $appPath = Join-Path $newStructure $appDir
Move-Item -Path $publishDir -Destination $appPath -Force Move-Item -Path $publishDir -Destination $appPath -Force
if (Test-Path $launcherPublishDir) { $launcherSource = $launcherPublishDir
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force if (Test-Path $launcherSource) {
Write-Host "Copying Launcher to root..."
Copy-Item -Path "$launcherSource\*" -Destination $newStructure -Recurse -Force
} else {
Write-Warning "Launcher publish dir not found: $launcherSource"
} }
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
Write-Host "New directory structure:"
Get-ChildItem -Path $newStructure -Recurse -Depth 2 | Select-Object FullName
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
Move-Item -Path $newStructure -Destination $publishDir -Force Move-Item -Path $newStructure -Destination $publishDir -Force
@@ -230,31 +230,60 @@ jobs:
run: | run: |
$version = "${{ needs.prepare.outputs.version }}" $version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
$suffix = "${{ matrix.suffix }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true" $selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } $suffix = "${{ matrix.suffix }}"
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" }
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
$outputDir = "build-installer" $outputDir = "build-installer"
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
if (-not (Test-Path -Path $publishDir)) {
Write-Error "Publish directory not found: $publishDir"
Get-ChildItem -Path "publish" -Directory -ErrorAction SilentlyContinue | Select-Object Name
exit 1
}
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
$candidatePaths = @( if (-not (Test-Path -Path $installerScript)) {
(Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue), Write-Error "Installer script not found: $installerScript"
"$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe", exit 1
"$env:ProgramFiles\Inno Setup 6\ISCC.exe", }
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
) | Where-Object { $_ -and (Test-Path $_) } $isccPath = $null
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
if ($isccCommand) {
$isccPath = $isccCommand.Source
}
$candidatePaths = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe",
"$env:ChocolateyInstall\bin\ISCC.exe",
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
)
$isccPath = $candidatePaths | Select-Object -First 1
if (-not $isccPath) { if (-not $isccPath) {
foreach ($candidate in $candidatePaths) {
if ($candidate -and (Test-Path -Path $candidate)) {
$isccPath = $candidate
break
}
}
}
if (-not $isccPath) {
Write-Host "ISCC.exe was not found in PATH or known locations."
Write-Host "Checked locations:"
$candidatePaths | ForEach-Object { Write-Host " - $_" }
Write-Host "Chocolatey bin listing (if exists):"
Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName
Write-Error "Inno Setup compiler not found." Write-Error "Inno Setup compiler not found."
exit 1 exit 1
} }
if (-not (Test-Path $installerScript)) { Write-Host "Found Inno Setup at: $isccPath"
Write-Error "Installer script not found: $(Join-Path $PWD $installerScript)"
exit 1 Write-Host "Building installer for Windows $arch with version $version..."
}
$publishDir = (Resolve-Path $publishDir).Path $publishDir = (Resolve-Path $publishDir).Path
$outputDir = (Resolve-Path $outputDir).Path $outputDir = (Resolve-Path $outputDir).Path
@@ -270,6 +299,8 @@ jobs:
$installerScript $installerScript
) )
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
& $isccPath @compileArgs & $isccPath @compileArgs
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE" Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
@@ -281,53 +312,186 @@ jobs:
Write-Error "Failed to create installer" Write-Error "Failed to create installer"
exit 1 exit 1
} }
Write-Host "Successfully created: $($installerFile.Name)"
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
shell: pwsh shell: pwsh
- name: Package Payload Zip - name: Create App Package
if: matrix.self_contained == true && matrix.arch == 'x64'
run: | run: |
$version = "${{ needs.prepare.outputs.version }}" $version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version" $publishDir = "publish/windows-$arch"
if (-not (Test-Path $payloadRoot)) { $appDir = "app-$version"
Write-Error "Payload root not found: $payloadRoot" $currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# 创建 app-{version}-win-{arch}.zip 供后续版本作为旧版本对比
$appZipPath = Join-Path $outputDir "app-$version-win-$arch.zip"
Write-Host "Creating app-$version-win-$arch.zip..."
Compress-Archive -Path "$currentAppPath\*" -DestinationPath $appZipPath -CompressionLevel Optimal
$sizeMB = [Math]::Round((Get-Item $appZipPath).Length / 1MB, 2)
Write-Host "Created app-$version-win-$arch.zip: $sizeMB MB"
shell: pwsh
- name: Generate Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output"
$scriptPath = "scripts/Generate-DeltaPackage.ps1"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# --- Determine previous version and download its app package for diff ---
$previousVersion = $null
$previousAppPath = $null
try {
$headers = @{ "User-Agent" = "LanMountainDesktop-CI"; "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" }
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" -Headers $headers
$previousRelease = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1
if ($previousRelease) {
$previousVersion = $previousRelease.tag_name.TrimStart('v','V')
Write-Host "Previous release version: $previousVersion"
# 下载旧版本的 app-{version}-win-{arch}.zip
$prevAppZip = $previousRelease.assets | Where-Object { $_.name -eq "app-$previousVersion-win-$arch.zip" } | Select-Object -First 1
if ($prevAppZip) {
Write-Host "Found app-$previousVersion-win-$arch.zip in previous release - downloading for diff..."
$prevAppZipDest = Join-Path $outputDir "prev-app.zip"
Invoke-WebRequest -Uri $prevAppZip.browser_download_url -OutFile $prevAppZipDest -Headers $headers
# 解压 app-{version}.zip
$previousAppPath = Join-Path $outputDir "prev-app"
New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null
Expand-Archive -Path $prevAppZipDest -DestinationPath $previousAppPath -Force
Remove-Item -Path $prevAppZipDest -Force -ErrorAction SilentlyContinue
if ($previousAppPath -and (Test-Path $previousAppPath)) {
$prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count
Write-Host "Extracted $prevFileCount files from previous version for diff"
}
} else {
Write-Host "No app-$previousVersion-win-$arch.zip found in previous release - will generate full package"
Write-Host "This is expected for the first release after this fix."
}
}
} catch {
Write-Host "Could not fetch previous release: $_"
}
# --- Generate delta package using the script ---
if ($previousAppPath -and (Test-Path $previousAppPath) -and $previousVersion) {
Write-Host "Generating delta package from $previousVersion to $version..."
& $scriptPath `
-PreviousVersion $previousVersion `
-CurrentVersion $version `
-PreviousDir $previousAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
if ($LASTEXITCODE -ne 0) {
Write-Error "Generate-DeltaPackage.ps1 failed"
exit 1
}
} else {
Write-Host "No previous version available - generating full package..."
# Generate a "full" delta package (all files as "add")
& $scriptPath `
-PreviousVersion "0.0.0" `
-CurrentVersion $version `
-PreviousDir $currentAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
if ($LASTEXITCODE -ne 0) {
Write-Error "Generate-DeltaPackage.ps1 failed"
exit 1
}
}
# Clean up previous version extraction
if ($previousAppPath -and (Test-Path $previousAppPath)) {
Remove-Item -Path $previousAppPath -Recurse -Force -ErrorAction SilentlyContinue
}
# Display results
$updateZipPath = Join-Path $outputDir "update.zip"
if (Test-Path $updateZipPath) {
$sizeMB = [Math]::Round((Get-Item $updateZipPath).Length / 1MB, 2)
Write-Host "Created update.zip: $sizeMB MB"
}
shell: pwsh
- name: Sign File Map
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$outputDir = "delta-output"
$filesJsonPath = Join-Path $outputDir "files.json"
$signaturePath = Join-Path $outputDir "files.json.sig"
if (-not (Test-Path $filesJsonPath)) {
Write-Error "files.json not found at $filesJsonPath"
exit 1 exit 1
} }
$stageDir = Join-Path $PWD "payload-stage/windows-$arch" $privateKeyPem = "${{ secrets.UPDATE_PRIVATE_KEY_PEM }}"
$releaseDir = Join-Path $PWD "release-assets" if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
Remove-Item -Path $stageDir -Recurse -Force -ErrorAction SilentlyContinue Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder"
New-Item -ItemType Directory -Path $stageDir -Force | Out-Null Set-Content -Path $signaturePath -Value "" -Encoding ASCII
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null exit 0
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
return
} }
$destination = Join-Path $stageDir ($relative -replace '/', [System.IO.Path]::DirectorySeparatorChar) $privateKeyPath = Join-Path $env:RUNNER_TEMP "signing-key.pem"
$destinationDir = Split-Path -Path $destination -Parent Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII
if (-not [string]::IsNullOrWhiteSpace($destinationDir)) {
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
}
Copy-Item -Path $_.FullName -Destination $destination -Force Add-Type -ReferencedAssemblies @("System.Security.Cryptography", "System.IO") -TypeDefinition @"
using System;
using System.IO;
using System.Security.Cryptography;
public class RsaSigner {
public static void Sign(string jsonPath, string keyPath, string sigPath) {
var jsonBytes = File.ReadAllBytes(jsonPath);
var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(keyPath));
var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
File.WriteAllText(sigPath, Convert.ToBase64String(sig));
} }
}
"@
$payloadZip = Join-Path $releaseDir "files-windows-$arch.zip" [RsaSigner]::Sign($filesJsonPath, $privateKeyPath, $signaturePath)
if (Test-Path $payloadZip) { Remove-Item -Path $privateKeyPath -Force
Remove-Item $payloadZip -Force
} Write-Host "Signed files.json -> files.json.sig"
Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force
shell: pwsh shell: pwsh
- name: Upload Release Assets - name: Upload Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: release-windows-${{ matrix.arch }} name: release-delta-windows-x64
path: | path: |
release-assets/files-windows-${{ matrix.arch }}.zip delta-output/files.json
build-installer/*.exe delta-output/files.json.sig
delta-output/update.zip
delta-output/app-*.zip
if-no-files-found: error
retention-days: 90
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}${{ matrix.suffix }}
path: build-installer/*.exe
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -352,11 +516,9 @@ jobs:
libx11-6 libxrandr2 libxinerama1 \ libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \ libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0 \ libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev zip rsync clang zlib1g-dev \
libportaudio2 libasound2 \
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2 libwebkit2gtk-4.1-dev
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
@@ -377,6 +539,8 @@ jobs:
- name: Publish Launcher (AOT) - name: Publish Launcher (AOT)
run: | run: |
echo "Publishing Launcher with AOT for Linux x64..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \ -c Release \
-o ./publish/launcher-linux-x64 \ -o ./publish/launcher-linux-x64 \
@@ -387,11 +551,15 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true \ -p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \ -p:EnableCompressionInSingleFile=true \
-p:DebugType=none \ -p:DebugType=none \
-p:DebugSymbols=false \ -p:DebugSymbols=false
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \ if [ $? -ne 0 ]; then
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ echo "Launcher AOT publish failed"
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} exit 1
fi
echo "Launcher published to: ./publish/launcher-linux-x64"
ls -lh ./publish/launcher-linux-x64/
- name: Publish Main App - name: Publish Main App
run: | run: |
@@ -418,15 +586,25 @@ jobs:
appDir="app-$version" appDir="app-$version"
launcherDir="publish/launcher-linux-x64" launcherDir="publish/launcher-linux-x64"
echo "Restructuring for Launcher mode..."
echo "Version: $version"
mkdir -p "$publishDir" mkdir -p "$publishDir"
mv "publish/linux-x64-app" "$publishDir/$appDir" mv "publish/linux-x64-app" "$publishDir/$appDir"
if [ -d "$launcherDir" ]; then if [ -d "$launcherDir" ]; then
echo "Copying Launcher to root..."
cp -r "$launcherDir"/* "$publishDir/" cp -r "$launcherDir"/* "$publishDir/"
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
else
echo "Warning: Launcher publish dir not found: $launcherDir"
fi fi
touch "$publishDir/$appDir/.current" touch "$publishDir/$appDir/.current"
echo "New directory structure:"
find "$publishDir" -maxdepth 2 | head -50
rm -rf "$launcherDir" rm -rf "$launcherDir"
- name: Package as DEB - name: Package as DEB
@@ -439,6 +617,12 @@ jobs:
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop" desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png" icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
if [ ! -d "$source" ]; then
echo "Error: Source directory not found: $source"
ls -la publish/ || echo "publish directory not found"
exit 1
fi
mkdir -p "build-deb/DEBIAN" mkdir -p "build-deb/DEBIAN"
mkdir -p "build-deb/usr/local/bin" mkdir -p "build-deb/usr/local/bin"
mkdir -p "build-deb/usr/share/applications" mkdir -p "build-deb/usr/share/applications"
@@ -447,6 +631,20 @@ jobs:
cp -r "$source"/* "build-deb/usr/local/bin/" cp -r "$source"/* "build-deb/usr/local/bin/"
item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l)
echo "DEB package contains $item_count files"
if [ "$item_count" -eq 0 ]; then
echo "Error: DEB package is empty after copy"
exit 1
fi
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
echo "Error: Linux desktop resources are missing"
ls -la "LanMountainDesktop/packaging/linux" || true
exit 1
fi
sed \ sed \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \ -e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
-e "s|@@ICON@@|lanmountaindesktop|g" \ -e "s|@@ICON@@|lanmountaindesktop|g" \
@@ -470,9 +668,9 @@ jobs:
printf '%s\n' "Package: $package_name" printf '%s\n' "Package: $package_name"
printf '%s\n' "Version: $package_version" printf '%s\n' "Version: $package_version"
printf '%s\n' "Architecture: $arch" printf '%s\n' "Architecture: $arch"
printf '%s\n' 'Maintainer: LanMountain Team <dev@example.com>' printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
printf '%s\n' 'Description: LanMountain Desktop Application' printf '%s\n' "Description: LanMountain Desktop Application"
printf '%s\n' ' A desktop application for LanMountain.' printf '%s\n' " A desktop application for LanMountain."
} > "build-deb/DEBIAN/control" } > "build-deb/DEBIAN/control"
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/* chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
@@ -481,49 +679,26 @@ jobs:
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png" chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
chmod 755 "build-deb/DEBIAN/postinst" chmod 755 "build-deb/DEBIAN/postinst"
dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb" if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
- name: Package Payload Zip ls -lh "${package_name}_${package_version}_${arch}.deb"
run: | else
version="${{ needs.prepare.outputs.version }}" echo "Error: Failed to build DEB package"
payload_root="publish/linux-x64/app-$version"
release_dir="$PWD/release-assets"
stage_dir="$PWD/payload-stage/linux-x64"
if [ ! -d "$payload_root" ]; then
echo "Payload root not found: $payload_root"
exit 1 exit 1
fi fi
rm -rf "$stage_dir" - name: Upload
mkdir -p "$stage_dir" "$release_dir"
rsync -a \
--exclude '.current' \
--exclude '.partial' \
--exclude '.destroy' \
"$payload_root/" "$stage_dir/"
(
cd "$stage_dir"
zip -qr "$release_dir/files-linux-x64.zip" .
)
- name: Upload Release Assets
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: release-linux-x64 name: release-linux
path: | path: "*.deb"
release-assets/files-linux-x64.zip
*.deb
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
build-macos: build-macos:
needs: prepare needs: prepare
runs-on: macos-latest runs-on: macos-latest
continue-on-error: true
strategy: strategy:
fail-fast: false
matrix: matrix:
arch: [x64, arm64] arch: [x64, arm64]
name: Build_macOS_${{ matrix.arch }} name: Build_macOS_${{ matrix.arch }}
@@ -558,6 +733,8 @@ jobs:
- name: Publish Launcher (AOT) - name: Publish Launcher (AOT)
run: | run: |
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \ -c Release \
-o ./publish/launcher-macos-${{ matrix.arch }} \ -o ./publish/launcher-macos-${{ matrix.arch }} \
@@ -568,11 +745,15 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true \ -p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \ -p:EnableCompressionInSingleFile=true \
-p:DebugType=none \ -p:DebugType=none \
-p:DebugSymbols=false \ -p:DebugSymbols=false
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \ if [ $? -ne 0 ]; then
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ echo "Launcher AOT publish failed"
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} exit 1
fi
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
- name: Publish Main App - name: Publish Main App
run: | run: |
@@ -592,22 +773,7 @@ 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: Package Payload Zip
run: |
release_dir="$PWD/release-assets"
stage_dir="$PWD/payload-stage/macos-${{ matrix.arch }}"
payload_root="publish/macos-${{ matrix.arch }}-app"
rm -rf "$stage_dir"
mkdir -p "$stage_dir" "$release_dir"
rsync -a "$payload_root/" "$stage_dir/"
(
cd "$stage_dir"
zip -qr "$release_dir/files-macos-${{ matrix.arch }}.zip" .
)
- name: Restructure and Package as DMG - name: Restructure and Package as DMG
continue-on-error: true
run: | run: |
version="${{ needs.prepare.outputs.version }}" version="${{ needs.prepare.outputs.version }}"
arch="${{ matrix.arch }}" arch="${{ matrix.arch }}"
@@ -616,19 +782,41 @@ jobs:
launcherDir="publish/launcher-macos-$arch" launcherDir="publish/launcher-macos-$arch"
appSourceDir="publish/macos-$arch-app" appSourceDir="publish/macos-$arch-app"
echo "Restructuring for Launcher mode..."
echo "Version: $version"
mkdir -p "${app_name}.app/Contents/MacOS" mkdir -p "${app_name}.app/Contents/MacOS"
appDir="app-$version" appDir="app-$version"
mkdir -p "${app_name}.app/Contents/MacOS/$appDir" mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
if [ -d "$appSourceDir" ]; then
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/" cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
else
echo "Error: Main app source directory not found: $appSourceDir"
exit 1
fi
if [ -d "$launcherDir" ]; then if [ -d "$launcherDir" ]; then
echo "Copying Launcher to root..."
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/" cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
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
else
echo "Warning: Launcher publish dir not found: $launcherDir"
fi 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"
item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l)
echo "App bundle contains $item_count files"
if [ "$item_count" -eq 0 ]; then
echo "Error: App bundle is empty after copy"
exit 1
fi
{ {
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>' printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
@@ -652,96 +840,105 @@ jobs:
mkdir -p dmg-temp mkdir -p dmg-temp
cp -r "${app_name}.app" dmg-temp/ cp -r "${app_name}.app" dmg-temp/
hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg"
- name: Upload Release Assets if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
if: always() echo "Successfully created: ${package_name}.dmg"
ls -lh "${package_name}.dmg"
else
echo "Error: Failed to create DMG"
exit 1
fi
rm -rf dmg-temp "${app_name}.app"
- name: Upload
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: release-macos-${{ matrix.arch }} name: release-macos-${{ matrix.arch }}
path: | path: "*.dmg"
release-assets/files-macos-${{ matrix.arch }}.zip if-no-files-found: error
*.dmg
if-no-files-found: ignore
retention-days: 30 retention-days: 30
github-release: github-release:
needs: [prepare, build-windows, build-linux] needs: [ prepare, build-windows, build-linux, build-macos ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
steps: steps:
- name: Download release artifacts - name: Download artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
path: release-files path: artifacts
pattern: release-* pattern: release-*
merge-multiple: true
- name: Normalize release files - name: List artifacts structure
run: | run: |
mkdir -p release-bundle echo "Artifact directory structure:"
find artifacts -type f -o -type d | sort
echo ""
echo "Files found:"
find artifacts -type f -exec ls -lh {} \;
echo ""
echo "Full tree:"
tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
mapfile -t downloaded_files < <(find release-files -type f) - name: Flatten artifacts for release
if [ "${#downloaded_files[@]}" -eq 0 ]; then
echo "No downloaded release artifacts were found."
exit 1
fi
for file in "${downloaded_files[@]}"; do
base_name="$(basename "$file")"
target_path="release-bundle/$base_name"
if [ -e "$target_path" ]; then
echo "Duplicate release asset name detected: $base_name"
echo "Conflicting file: $file"
exit 1
fi
cp "$file" "$target_path"
done
- name: Validate release files
run: | run: |
echo "Release files:" echo "Organizing artifacts..."
find release-bundle -maxdepth 1 -type f -exec ls -lh {} \; mkdir -p release-files
# Copy installers and packages
if [ ! -f release-bundle/files-windows-x64.zip ] || [ ! -f release-bundle/files-windows-x86.zip ] || [ ! -f release-bundle/files-linux-x64.zip ]; then find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
echo "Required payload zips are missing." # Copy delta update files (files.json, files.json.sig, update.zip)
exit 1 find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \;
fi # Copy app package for future delta generation (app-{version}-win-{arch}.zip)
find artifacts -type f -name "app-*.zip" -exec cp -v {} release-files/ \;
file_count=$(find release-bundle -maxdepth 1 -type f | wc -l) echo ""
echo "Files ready for release:"
ls -lh release-files/ || echo "No files found in release-files"
echo ""
echo "Total files:"
file_count=$(find release-files -type f | wc -l)
echo "$file_count"
if [ "$file_count" -eq 0 ]; then if [ "$file_count" -eq 0 ]; then
echo "No release files were produced." echo "Error: No release files found"
exit 1 exit 1
fi fi
- name: Create or Update Release - name: Create Release
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
with: with:
tag: ${{ needs.prepare.outputs.tag }} tag: ${{ needs.prepare.outputs.tag }}
name: ${{ needs.prepare.outputs.tag }} name: ${{ needs.prepare.outputs.tag }}
commit: ${{ github.sha }}
allowUpdates: true allowUpdates: true
draft: false draft: false
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }} prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
artifacts: 'release-bundle/*' artifacts: "release-files/**"
body: | body: |
## Release ${{ needs.prepare.outputs.version }} ## Release ${{ needs.prepare.outputs.version }}
### Installers ### Windows
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe` - **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime)
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe` - **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime)
- `LanMountainDesktop_${{ needs.prepare.outputs.version }}_amd64.deb`
### Payload Archives **Note:** The Launcher is now built with AOT (Ahead-of-Time) compilation as a single executable file for faster startup and smaller footprint.
- `files-windows-x64.zip`
- `files-windows-x86.zip` Installation: Double-click the .exe file and follow the wizard.
- `files-linux-x64.zip`
### Incremental Update (Windows x64)
- **files.json** - Update manifest listing changed files
- **files.json.sig** - RSA signature of the manifest
- **update.zip** - Archive containing changed files
Existing users: The app will automatically detect and apply the incremental update on next launch.
### Linux
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
### macOS ### macOS
- macOS assets are best-effort and will not block the release. - **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-x64.dmg** - Intel processor
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
Release keeps only the stable installer and payload outputs. PLONDS delta assets and external mirror metadata are generated by follow-up workflows. See commits for changes.
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored
View File

@@ -512,5 +512,3 @@ nul
/*.deb /*.deb
/*.dmg /*.dmg
/*.AppImage /*.AppImage
/velopack-output-local-verify
/velopack-output-local

View File

@@ -1,11 +0,0 @@
# External IPC Public API Checklist
- [x] Host can expose strong-typed public IPC services.
- [x] External .NET client can connect and call built-in services.
- [x] Host publishes launcher startup and loading-state notifications through routed notify.
- [x] Launcher consumes routed notify instead of the old primary custom named-pipe path.
- [x] Plugin SDK exposes public IPC contribution primitives.
- [x] Plugin runtime can discover and register plugin public IPC services.
- [x] Public catalog includes built-in and plugin-contributed services.
- [x] `catalog.changed` is emitted when new services are added after startup.
- [ ] Add example external client sample.

View File

@@ -1,24 +0,0 @@
# External IPC Public API Spec
## Goal
Provide a single `dotnetCampus.Ipc` based external integration layer for:
- Host public APIs
- Launcher/OOBE startup progress and loading-state notifications
- plugin-contributed public services and live event push
## Delivered
- `LanMountainDesktop.Shared.IPC` project
- `[IpcPublic]` based built-in public contracts
- `PublicIpcHostService` and `LanMountainDesktopIpcClient`
- Launcher migrated to Host public IPC notifications
- Plugin SDK public IPC contribution API
- Host runtime integration for plugin public IPC services
## Out of Scope
- plugin process isolation
- non-.NET strong-typed public IPC clients
- live plugin public service removal without restart

View File

@@ -1,12 +0,0 @@
# External IPC Public API Tasks
- [x] Add `LanMountainDesktop.Shared.IPC`
- [x] Expose built-in `[IpcPublic]` services
- [x] Add routed notify constants and public IPC client/host wrappers
- [x] Start Host public IPC during app startup
- [x] Move Launcher startup progress consumption to the new IPC base
- [x] Add plugin public IPC registration/contributor SDK
- [x] Register plugin-contributed public services into Host catalog
- [x] Add integration tests for strong-typed public service access and plugin registration descriptors
- [ ] Expand built-in public service surface beyond the first minimal set
- [ ] Add non-.NET bridge guidance and samples

View File

@@ -1,8 +0,0 @@
# Launcher OOBE and Elevation Hardening Checklist
- [ ] New install shows OOBE once.
- [ ] Same-user reinstall does not show OOBE again.
- [ ] `postinstall` launch path is handled without misclassifying the user state.
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
- [ ] Default plugin install does not request UAC.
- [ ] Logs include OOBE status, suppression reason, and launch source.

View File

@@ -1,43 +0,0 @@
# Launcher OOBE and Elevation Hardening Spec
## Goal
Stabilize the launcher startup path so that:
- OOBE does not reappear for the same Windows user after reinstall/upgrade.
- Normal startup, OOBE, update checks, incremental downloads, and default plugin installs do not trigger unexpected UAC prompts.
- Only the approved elevation paths remain allowed.
## Scope
- Launcher OOBE state handling
- launch source classification
- elevation boundary cleanup
- plugin install default behavior
- diagnostic logging and troubleshooting guidance
## Behavior
- OOBE state is stored as a per-user truth source at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
- `first_run_completed` is treated as a legacy compatibility marker only.
- `launchSource` values are treated as:
- `normal`
- `postinstall`
- `apply-update`
- `plugin-install`
- `debug-preview`
- 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.
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
- Allowed elevation paths are limited to:
- the installer itself
- full installer update application
- user-confirmed legacy uninstall
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
## Acceptance
- Same-user reinstall does not re-enter OOBE.
- Missing or damaged OOBE state does not silently bounce the user back into OOBE loops.
- Default plugin installation path never triggers surprise UAC.
- Logs can explain why OOBE was shown or suppressed and why elevation was or was not requested.

View File

@@ -1,9 +0,0 @@
# Launcher OOBE and Elevation Hardening Tasks
- [ ] Move OOBE state to a single per-user JSON source.
- [ ] Treat `first_run_completed` as legacy migration-only state.
- [ ] Add explicit `launchSource` handling for startup and maintenance flows.
- [ ] Suppress auto-OOBE for maintenance and elevated launch contexts.
- [ ] Remove default elevation from plugin installation into the user data scope.
- [ ] Add structured diagnostics for OOBE decisions and elevation reasons.
- [ ] Update launcher docs and troubleshooting guidance.

View File

@@ -6,6 +6,3 @@
- [x] Legacy plugin install arguments still execute. - [x] Legacy plugin install arguments still execute.
- [x] OOBE and splash are implemented as separate windows. - [x] OOBE and splash are implemented as separate windows.
- [x] Update and rollback logic use version directory markers. - [x] Update and rollback logic use version directory markers.
- [ ] Treat `first_run_completed` as legacy-only compatibility data.
- [ ] Keep the authoritative OOBE state in `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.

View File

@@ -52,9 +52,3 @@ Upgrade `LanMountainDesktop.Launcher` into the unified Launcher for:
- `IOobeStep` for future multi-step OOBE - `IOobeStep` for future multi-step OOBE
- `ISplashStageReporter` for future startup progress visualization - `ISplashStageReporter` for future startup progress visualization
## Compatibility Addendum
- The current production OOBE state format is a per-user JSON file at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
- `first_run_completed` remains legacy compatibility data only.
- Same-user reinstall or upgrade should not re-enter OOBE.

View File

@@ -1,13 +0,0 @@
# Checklist
- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack.
- [ ] `release.yml` uploads app payload artifacts for PDCC.
- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run.
- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized.
- [ ] Host can persist PDC payload into launcher incoming directory.
- [ ] Launcher can apply PDC FileMap payload with signature/hash verification.
- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
- [ ] CI run attached proving all release matrix jobs pass.
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
- [ ] Rollback verification report attached.

View File

@@ -1,44 +0,0 @@
# PDC Incremental Update Migration
## Goal
Replace VeloPack-based incremental packaging with a unified PDC FileMap + object-repo pipeline, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
## Stage 1 (Completed)
- Release workflow removed VeloPack-based release packaging.
- Signed FileMap path was restored as an interim release mechanism.
- Host/Launcher fallback behavior stayed compatible with `files.json + files.json.sig + update.zip`.
## Stage 2 (Current Implementation Target)
- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style).
- Promote PDC-distributed FileMap/object-repo as the primary incremental path.
- Keep GitHub Release installers and metadata as parallel distribution.
- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots).
- Update source defaults to `stcn` (S3/PDC), with GitHub fallback.
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
Expected S3 layout:
- `lanmountain/update/repo/<hash-prefix>/<hash-object>`
- `lanmountain/update/meta/channels/<channel>/<subchannel>/latest.json`
- `lanmountain/update/meta/distributions/<distributionId>/*.json`
- `lanmountain/update/installers/<platform>/<arch>/*`
## Acceptance
- `release.yml` includes PDCC publish steps and no Velopack steps.
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
- PDC metadata + FileMap + object repo are published under `lanmountain/update/`.
- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable.
- Launcher can apply both:
- legacy signed `files.json + update.zip`
- PDC FileMap object-repo payload.
- Rollback semantics remain unchanged.
## Deprecated Notes
- The following interim outputs are compatibility-only (not the long-term primary path):
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`

View File

@@ -1,15 +0,0 @@
# Tasks
- [x] Remove VeloPack packaging from release workflow.
- [x] Keep signed FileMap path as interim compatibility fallback.
- [x] Remove launcher/runtime Velopack branching.
- [ ] Add `phainon.yml` for PDCC publish configuration.
- [ ] Add PDCC installation + publish steps in `release.yml`.
- [ ] Upload app payload artifacts for PDCC consumption in release build jobs.
- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`.
- [ ] Mirror installers to `lanmountain/update/installers/<platform>/<arch>/`.
- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility).
- [ ] Add PDC payload model into host update check result.
- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata).
- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics.
- [ ] Keep old `files.json + update.zip` path behind compatibility fallback.

View File

@@ -1,12 +0,0 @@
# Checklist
- [x] `plugin.json` 缺省时仍默认为 `in-proc`
- [x] 非法 `runtime.mode` 会给出清晰错误
- [x] SDK 中已有 Worker 入口和隔离运行模式的公共接口
- [x] IPC 契约已拆到独立工程,且不引用 Avalonia
- [x] IPC 封装层已集中环境变量、启动参数和通知路由常量
- [x] 架构文档已写明一期 `isolated-background`、二期 `isolated-window`
- [x] 架构文档已写明 `IPluginExportRegistry` / `IPluginMessageBus` 不再作为隔离插件主边界
- [x] 文档已写明 ClassIsland 的借鉴点与取舍
- [ ] Host 在 Worker 崩溃时仅降级插件且不中断主程序
- [ ] `isolated-background` 的组件、编辑器、设置页完成真实 IPC 回路

View File

@@ -1,41 +0,0 @@
# Plugin Process Isolation
## Why
现有插件体系仍是“同进程 + AssemblyLoadContext 隔离”,无法阻止插件 fatal crash 拖垮 Host也无法阻止插件直接访问 Host 进程内对象和内存。
## What Changes
- 增加插件运行模式概念:`in-proc``isolated-background``isolated-window`
- 一期落地 `isolated-background`
- 新建独立 IPC 契约包和 IPC 封装包
-`PluginSdk` 中新增 Worker 入口与 `runtime.mode`
- 明确隔离模式下不再兼容对象实例共享型 API
- 新增正式架构文档说明 UI 方案、迁移策略、残余风险和 ClassIsland 借鉴
## Impact
- `LanMountainDesktop.PluginSdk/`
- `LanMountainDesktop.PluginTemplate/`
- 新增 `LanMountainDesktop.PluginIsolation.Contracts/`
- 新增 `LanMountainDesktop.PluginIsolation.Ipc/`
- `docs/ARCHITECTURE.md`
- `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`
## Requirements
### Requirement 1
宿主必须同时支持存量 `in-proc` 插件与未来的隔离插件,不得以本次改造打断旧插件加载。
### Requirement 2
隔离插件的 Host/Worker 通信必须基于显式 IPC 路由和 DTO而不是 Host 服务对象实例共享。
### Requirement 3
一期必须把后台逻辑隔离为独立 Worker 进程,并显式记录 Host UI 壳层的残余风险。
### Requirement 4
仓库文档必须把 ClassIsland IPC 的借鉴点和不照搬的部分写清楚,避免后续实现阶段误把插件协议做成远程对象模型。

View File

@@ -1,12 +0,0 @@
# Tasks
- [x] 梳理现有插件运行时、组件注册、设置页和共享对象边界
- [x] 形成插件进程隔离架构文档
- [x]`.trae/specs/plugin-process-isolation/` 下补齐 spec、tasks、checklist
- [x]`PluginSdk` 中增加 `runtime.mode`、Worker 入口接口和运行模式枚举
- [x] 新建 `LanMountainDesktop.PluginIsolation.Contracts`,沉淀纯 DTO、路由常量、错误码与 JSON context
- [x] 新建 `LanMountainDesktop.PluginIsolation.Ipc`,沉淀 ClassIsland 风格的 IPC 包装外壳
- [x] 更新插件模板 `plugin.json`,让新插件默认显式声明 `in-proc`
- [ ] 在 Host 侧接入真实 Worker 进程拉起与 dotnetCampus.Ipc 传输绑定
- [ ]`isolated-background` 构建 Host UI 壳层适配器
- [ ] 为故障、心跳、降级与恢复补齐端到端测试

View File

@@ -1,5 +0,0 @@
# Checklist (Deprecated)
- [x] Spec marked as deprecated.
- [x] Active implementation ownership moved to `pdc-incremental-migration`.
- [x] No release workflow dependency remains on VeloPack.

View File

@@ -1,15 +0,0 @@
# VeloPack Update Integration (Deprecated)
## Status
This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration/`.
## Deprecation Reason
- 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.
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
## Migration Note
Use `.trae/specs/pdc-incremental-migration/spec.md` as the active authority for incremental update implementation and acceptance.

View File

@@ -1,6 +0,0 @@
# Tasks (Deprecated)
- [x] Mark VeloPack integration spec as deprecated.
- [x] Remove VeloPack runtime branches from launcher/host update path.
- [x] Remove VeloPack release workflow packaging steps.
- [ ] Keep archive for historical context only (no new implementation tasks here).

View File

@@ -13,14 +13,9 @@ public partial class App : Application
{ {
public override void Initialize() public override void Initialize()
{ {
// 初始化日志记录器
Logger.Initialize(); Logger.Initialize();
var context = LauncherRuntimeContext.Current; Logger.Info("Launcher starting...");
var execution = LauncherExecutionContext.Capture();
Logger.Info(
$"Launcher App initialize. Command='{context.Command}'; IsGuiMode={context.IsGuiCommand}; " +
$"IsPreview={context.IsPreviewCommand}; IsDebugMode={context.IsDebugMode}; " +
$"LaunchSource='{context.LaunchSource}'; IsElevated={execution.IsElevated}; " +
$"UserSid='{execution.UserSid ?? string.Empty}'; ExplicitAppRoot='{context.ExplicitAppRoot ?? "<none>"}'.");
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
@@ -29,31 +24,41 @@ public partial class App : Application
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var context = LauncherRuntimeContext.Current; var context = LauncherRuntimeContext.Current;
var execution = LauncherExecutionContext.Capture();
Logger.Info(
$"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
// 调试模式:显示开发调试窗口
if (context.IsDebugMode)
{
var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show();
// 调试模式下不自动启动正常流程,由开发者通过调试窗口控制
base.OnFrameworkInitializationCompleted();
return;
}
// 处理各界面的预览命令
if (HandlePreviewCommand(context, desktop)) if (HandlePreviewCommand(context, desktop))
{ {
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
return; return;
} }
// apply-update 模式:显示 UpdateWindow执行增量更新 + 插件升级
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase)) if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{ {
// 先显示窗口,再启动后台任务
var updateWindow = new UpdateWindow(); var updateWindow = new UpdateWindow();
updateWindow.Show(); updateWindow.Show();
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow); _ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
} }
else else
{ {
// 先显示 Splash 窗口,确保应用程序不会立即退出
var splashWindow = new SplashWindow(); var splashWindow = new SplashWindow();
splashWindow.Show(); splashWindow.Show();
// 在 try-catch 块中实例化所有服务,确保任何异常都能被捕获
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow); _ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
} }
} }
@@ -61,108 +66,135 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
/// <summary>
/// 处理界面预览命令
/// </summary>
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop) private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
{ {
switch (context.Command.ToLowerInvariant()) var command = context.Command.ToLowerInvariant();
switch (command)
{ {
case "preview-splash": case "preview-splash":
{ Console.WriteLine("[Launcher] Preview mode: SplashWindow");
Logger.Info("Preview command: splash.");
var splashWindow = new SplashWindow(); var splashWindow = new SplashWindow();
splashWindow.SetDebugMode(true); splashWindow.SetDebugMode(true);
splashWindow.Show(); splashWindow.Show();
_ = SimulateSplashPreviewAsync(desktop, splashWindow); _ = SimulateSplashPreviewAsync(desktop, splashWindow);
return true; return true;
}
case "preview-error": case "preview-error":
{ Console.WriteLine("[Launcher] Preview mode: ErrorWindow");
Logger.Info("Preview command: error.");
var errorWindow = new ErrorWindow(); var errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage("[Preview] This is the launcher error window preview."); errorWindow.SetErrorMessage("[预览模式] 这是一个错误页面预览。\n\n用于查看错误页面的样式和布局。");
errorWindow.Show(); errorWindow.Show();
_ = WaitForWindowCloseAsync(desktop, errorWindow); _ = WaitForWindowCloseAsync(desktop, errorWindow);
return true; return true;
}
case "preview-update": case "preview-update":
{ Console.WriteLine("[Launcher] Preview mode: UpdateWindow");
Logger.Info("Preview command: update.");
var updateWindow = new UpdateWindow(); var updateWindow = new UpdateWindow();
updateWindow.SetDebugMode(true); updateWindow.SetDebugMode(true);
updateWindow.Show(); updateWindow.Show();
_ = SimulateUpdatePreviewAsync(desktop, updateWindow); _ = SimulateUpdatePreviewAsync(desktop, updateWindow);
return true; return true;
}
case "preview-oobe": case "preview-oobe":
{ Console.WriteLine("[Launcher] Preview mode: OobeWindow");
Logger.Info("Preview command: oobe.");
var oobeWindow = new OobeWindow(); var oobeWindow = new OobeWindow();
oobeWindow.Show(); oobeWindow.Show();
_ = SimulateOobePreviewAsync(desktop, oobeWindow); _ = SimulateOobePreviewAsync(desktop, oobeWindow);
return true; return true;
}
case "preview-debug": case "preview-debug":
{ Console.WriteLine("[Launcher] Preview mode: DevDebugWindow");
Logger.Info("Preview command: debug window.");
var devDebugWindow = new DevDebugWindow(); var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show(); devDebugWindow.Show();
return true; return true;
}
default: default:
return false; return false;
} }
} }
/// <summary>
/// 模拟 Splash 窗口预览
/// </summary>
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window) private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
{ {
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" }; var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
var messages = new[] { "Initializing...", "Checking updates...", "Checking plugins...", "Launching host...", "Ready" }; var messages = new[] { "初始化...", "检查更新...", "检查插件...", "正在启动...", "就绪" };
var reporter = (ISplashStageReporter)window; var reporter = (ISplashStageReporter)window;
for (var i = 0; i < stages.Length; i++) for (int i = 0; i < stages.Length; i++)
{ {
reporter.Report(stages[i], messages[i]); reporter.Report(stages[i], messages[i]);
await Task.Delay(800).ConfigureAwait(false); await Task.Delay(800);
} }
await Task.Delay(5000).ConfigureAwait(false); // 等待5秒后自动关闭
await Task.Delay(5000);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
} }
/// <summary>
/// 模拟 Update 窗口预览
/// </summary>
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window) private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
{ {
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" }; var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
for (var i = 0; i < stages.Length; i++) for (int i = 0; i < stages.Length; i++)
{ {
window.Report(stages[i], $"Processing {stages[i]}...", (i + 1) * 20); window.Report(stages[i], $"正在{GetStageName(stages[i])}...", (i + 1) * 20);
await Task.Delay(600).ConfigureAwait(false); await Task.Delay(600);
} }
window.ReportComplete(true, null); window.ReportComplete(true, null);
await Task.Delay(3000).ConfigureAwait(false);
// 等待3秒后自动关闭
await Task.Delay(3000);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
string GetStageName(string stage) => stage switch
{
"verify" => "验证",
"extract" => "解压",
"apply" => "应用",
"plugins" => "升级插件",
"cleanup" => "清理",
_ => stage
};
} }
/// <summary>
/// 模拟 OOBE 窗口预览
/// </summary>
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window) private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
{ {
try try
{ {
await window.WaitForEnterAsync().ConfigureAwait(false); // 等待用户点击开始按钮
Logger.Info("OOBE preview completed by user."); await window.WaitForEnterAsync();
Console.WriteLine("[Launcher] OOBE preview completed by user");
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error("OOBE preview failed.", ex); Console.Error.WriteLine($"[Launcher] OOBE preview error: {ex.Message}");
} }
// 用户点击后关闭应用程序
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
} }
/// <summary>
/// 等待窗口关闭
/// </summary>
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window) private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
{ {
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var tcs = new TaskCompletionSource();
window.Closed += (_, _) => tcs.TrySetResult(); window.Closed += (s, e) => tcs.TrySetResult();
await tcs.Task.ConfigureAwait(false); await tcs.Task;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0)); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
} }
@@ -172,106 +204,112 @@ public partial class App : Application
SplashWindow splashWindow) SplashWindow splashWindow)
{ {
LauncherResult result; LauncherResult result;
ErrorWindow? errorWindow = null;
LauncherFlowCoordinator? coordinator = null;
try try
{ {
// 在 try-catch 块中实例化所有服务,确保异常被捕获
var appRoot = Commands.ResolveAppRoot(context); var appRoot = Commands.ResolveAppRoot(context);
Logger.Info(
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
var deploymentLocator = new DeploymentLocator(appRoot); var deploymentLocator = new DeploymentLocator(appRoot);
var coordinator = new LauncherFlowCoordinator(
// TODO: 从配置读取 GitHub 仓库信息
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
coordinator = new LauncherFlowCoordinator(
context, context,
deploymentLocator, deploymentLocator,
new OobeStateService(appRoot), new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator), new UpdateEngineService(deploymentLocator),
updateCheckService,
new PluginInstallerService()); new PluginInstallerService());
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false); result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error("Coordinator threw an unhandled exception.", ex); // 捕获异常并显示错误窗口
result = new LauncherResult result = new LauncherResult
{ {
Success = false, Success = false,
Stage = "launch", Stage = "launch",
Code = "exception", Code = "exception",
Message = $"Launcher failed: {ex.Message}", Message = $"启动器发生错误: {ex.Message}",
ErrorMessage = ex.ToString() ErrorMessage = ex.ToString()
}; };
}
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'."); Console.Error.WriteLine($"[Launcher] Exception caught: {ex}");
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
if (!result.Success &&
result.Code is not "host_not_found" &&
(string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) ||
string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
{
await ShowFailureWindowAsync(result).ConfigureAwait(false);
}
Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
{
var resultPath = context.GetOption("result");
if (string.IsNullOrWhiteSpace(resultPath))
{
return;
}
// 在 UI 线程显示错误窗口 - 使用更健壮的方式
try try
{ {
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
}
catch (Exception ex)
{
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
}
}
private static async Task ShowFailureWindowAsync(LauncherResult result)
{
ErrorWindow? errorWindow = null;
await Dispatcher.UIThread.InvokeAsync(() => await Dispatcher.UIThread.InvokeAsync(() =>
{ {
try try
{ {
errorWindow = new ErrorWindow(); // 安全关闭 Splash 窗口
errorWindow.SetErrorMessage( if (splashWindow.IsVisible && splashWindow.IsLoaded)
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
errorWindow.Show();
}
catch (Exception ex)
{ {
Logger.Error("Failed to show launcher failure window.", ex); splashWindow.Close();
}
}
catch (Exception closeEx)
{
Console.Error.WriteLine($"[Launcher] Error closing splash window: {closeEx.Message}");
}
// 创建并显示错误窗口
try
{
errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage($"启动器发生错误:\n{ex.Message}\n\n请检查应用安装是否完整或尝试重新安装。");
errorWindow.Show();
Console.WriteLine("[Launcher] ErrorWindow shown successfully");
}
catch (Exception windowEx)
{
Console.Error.WriteLine($"[Launcher] Failed to show ErrorWindow: {windowEx.Message}");
} }
}); });
if (errorWindow is null) // 如果错误窗口成功显示,等待它关闭
if (errorWindow != null)
{ {
return;
}
try try
{ {
await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); // 等待用户选择或窗口关闭
var errorResult = await errorWindow.WaitForChoiceAsync();
Console.WriteLine($"[Launcher] ErrorWindow result: {errorResult}");
} }
catch (Exception ex) catch (Exception waitEx)
{ {
Logger.Error("Failure window closed unexpectedly.", ex); Console.Error.WriteLine($"[Launcher] Error waiting for ErrorWindow: {waitEx.Message}");
// 如果等待失败至少给用户5秒时间看到错误信息
await Task.Delay(5000);
}
}
else
{
// 错误窗口未能显示等待5秒让用户看到控制台输出
await Task.Delay(5000);
}
}
catch (Exception uiEx)
{
// 最后的兜底:记录到控制台
Console.Error.WriteLine($"[Launcher] Critical error in UI thread: {uiEx.Message}");
await Task.Delay(3000);
} }
} }
await Commands.WriteResultIfNeededAsync(LauncherRuntimeContext.Current.GetOption("result"), result).ConfigureAwait(false);
Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
/// <summary>
/// apply-update 模式:执行增量更新和插件升级,完成后自动退出
/// </summary>
private static async Task RunApplyUpdateWithWindowAsync( private static async Task RunApplyUpdateWithWindowAsync(
IClassicDesktopStyleApplicationLifetime desktop, IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context, CommandContext context,
@@ -288,7 +326,8 @@ public partial class App : Application
try try
{ {
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "Verifying update...", 10)); // 1. 应用增量更新
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "正在验证更新...", 10));
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success) if (!updateResult.Success)
{ {
@@ -296,20 +335,24 @@ public partial class App : Application
errorMessage = updateResult.Message; errorMessage = updateResult.Message;
} }
// 2. 应用待处理的插件升级
if (success) if (success)
{ {
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "Applying plugin upgrades...", 60)); await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "正在升级插件...", 60));
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins"); var pluginsDir = context.GetOption("plugins-dir")
?? Path.Combine(appRoot, "plugins");
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir); var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success && queueResult.Code != "noop") if (!queueResult.Success && queueResult.Code != "noop")
{ {
Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}"); // 插件升级失败不阻断整体流程,仅记录到控制台
Console.Error.WriteLine($"Plugin upgrade had failures: {queueResult.Message}");
} }
} }
// 3. 清理旧版本保留至少3个版本以支持回滚
if (success) if (success)
{ {
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "Cleaning up old deployments...", 90)); await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "正在清理...", 90));
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
} }
} }
@@ -317,26 +360,33 @@ public partial class App : Application
{ {
success = false; success = false;
errorMessage = ex.Message; errorMessage = ex.Message;
Logger.Error("Apply-update flow failed.", ex);
} }
// 显示完成状态,短暂停留后关闭
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage)); await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
if (success)
{
// 成功:停留 1.5 秒让用户看到"更新完成"
await Task.Delay(1500);
}
else
{
// 失败:停留 5 秒让用户看到错误信息
await Task.Delay(5000);
}
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
{ {
Success = success, Success = success,
Stage = "apply-update", Stage = "apply-update",
Code = success ? "ok" : "failed", Code = success ? "ok" : "failed",
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"), Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error")
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["command"] = context.Command,
["launchSource"] = context.LaunchSource
}
}).ConfigureAwait(false); }).ConfigureAwait(false);
Environment.ExitCode = success ? 0 : 1; Environment.ExitCode = success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
} }
} }

View File

@@ -6,17 +6,9 @@ using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher; namespace LanMountainDesktop.Launcher;
[JsonSourceGenerationOptions( [JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(SignedFileMap))] [JsonSerializable(typeof(SignedFileMap))]
[JsonSerializable(typeof(UpdateFileEntry))] [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(AppVersionInfo))] [JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))] [JsonSerializable(typeof(StartupProgressMessage))]
@@ -25,7 +17,6 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(PluginManifest))] [JsonSerializable(typeof(PluginManifest))]
[JsonSerializable(typeof(PendingUpgrade))] [JsonSerializable(typeof(PendingUpgrade))]
[JsonSerializable(typeof(List<PendingUpgrade>))] [JsonSerializable(typeof(List<PendingUpgrade>))]
[JsonSerializable(typeof(OobeStateFile))]
[JsonSerializable(typeof(GitHubRelease))] [JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(GitHubAsset))] [JsonSerializable(typeof(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))] [JsonSerializable(typeof(List<GitHubRelease>))]

View File

@@ -1,11 +1,8 @@
-----BEGIN RSA PUBLIC KEY----- -----BEGIN RSA PUBLIC KEY-----
MIIBigKCAYEAt3yev3f0D1AZthEmr7ZGeDTcjIOGwQgPGRK/qV1XMlYS96AYiqlQ MIIBCgKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGxbjZT
ToZyA+WrDAXOUHcpaIzei+GdieTs+IE0q64dvBY5+wJShKhGMdcJ+nibt6qfsgvX B+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS17YI
M2jSuR5ubHP9HGqBQNgLYdGFyD/IA7cDG5AsrGTXtVIldbkSzHPJiAp69G3fu9Hi 90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uzay3go
J7o7jE3pzTTPoArpjcCheoK/+9vjZOmEmkw71uWvmtld8KgOYz5Wk+GbQ2mJk6NJ msbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNizr0l
5TNqvlnzbYl946f78XNvHnnguLEU7q4SK0vgE7F92G10xB1A6DCTZQINjz/RrO5s YcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM5iUa
M/r29/jRSZbdrqbDIufxzxSeU80ADd7THSAGTVltynO0prAKW4be7ZtKbZVXgMUO 20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQAB
NMyCZUPCvSZP21Z7FSVyzf3wWYbyn/iBYCogticl5GBlr6ChQ/kfOQCGysCuDRK0
/RJ+ukWQCpl41Sh33B3HltOoKNuVuOkhwiDvJ4ckDoupf+4hzTzqWCuZf3NLAsYf
FQiGowgqx0l5AgMBAAE=
-----END RSA PUBLIC KEY----- -----END RSA PUBLIC KEY-----

View File

@@ -4,19 +4,6 @@ namespace LanMountainDesktop.Launcher;
internal sealed class CommandContext internal sealed class CommandContext
{ {
private const string LaunchSourceOptionName = "launch-source";
private static readonly string[] GuiCommands =
[
"launch",
"apply-update",
"preview-splash",
"preview-error",
"preview-update",
"preview-oobe",
"preview-debug"
];
public string Command { get; } public string Command { get; }
public string SubCommand { get; } public string SubCommand { get; }
@@ -33,8 +20,6 @@ internal sealed class CommandContext
Options.ContainsKey("plugins-dir") && Options.ContainsKey("plugins-dir") &&
Options.ContainsKey("result"); Options.ContainsKey("result");
public string LaunchSource => NormalizeLaunchSource(GetOption(LaunchSourceOptionName)) ?? InferLaunchSource();
/// <summary> /// <summary>
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动) /// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
/// 仅当明确指定 --debug 参数或调试器附加时才启用 /// 仅当明确指定 --debug 参数或调试器附加时才启用
@@ -43,20 +28,6 @@ internal sealed class CommandContext
Options.ContainsKey("debug") || Options.ContainsKey("debug") ||
System.Diagnostics.Debugger.IsAttached; System.Diagnostics.Debugger.IsAttached;
public bool IsPreviewCommand =>
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
public bool IsGuiCommand =>
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
public bool IsMaintenanceCommand =>
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
public string? ExplicitAppRoot => GetOption("app-root");
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs) private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
{ {
Command = command; Command = command;
@@ -91,44 +62,6 @@ internal sealed class CommandContext
: fallback; : fallback;
} }
private string InferLaunchSource()
{
if (IsPreviewCommand)
{
return "debug-preview";
}
if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
return "apply-update";
}
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
{
return "plugin-install";
}
return "normal";
}
private static string? NormalizeLaunchSource(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
return raw.Trim().ToLowerInvariant() switch
{
"normal" => "normal",
"postinstall" => "postinstall",
"apply-update" => "apply-update",
"plugin-install" => "plugin-install",
"debug-preview" => "debug-preview",
_ => null
};
}
private static Dictionary<string, string> ParseOptions(string[] args) private static Dictionary<string, string> ParseOptions(string[] args)
{ {
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

View File

@@ -18,7 +18,6 @@
<ItemGroup> <ItemGroup>
<!-- 只引用 Shared.ContractsIPC 协议) --> <!-- 只引用 Shared.ContractsIPC 协议) -->
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" /> <ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,63 +0,0 @@
namespace LanMountainDesktop.Launcher.Models;
internal enum OobeStateStatus
{
FirstRun,
Completed,
Unavailable,
Suppressed
}
internal sealed class OobeStateFile
{
public int SchemaVersion { get; init; } = 1;
public string CompletedAtUtc { get; init; } = string.Empty;
public string UserName { get; init; } = string.Empty;
public string? UserSid { get; init; }
public string LaunchSource { get; init; } = string.Empty;
}
internal sealed class OobeLaunchDecision
{
public OobeStateStatus Status { get; init; }
public bool ShouldShowOobe { get; init; }
public string StatePath { get; init; } = string.Empty;
public string LaunchSource { get; init; } = "normal";
public bool IsElevated { get; init; }
public string UserName { get; init; } = string.Empty;
public string? UserSid { get; init; }
public string ResultCode { get; init; } = "ok";
public string SuppressionReason { get; init; } = string.Empty;
public string ErrorMessage { get; init; } = string.Empty;
public bool UsedLegacyMarker { get; init; }
public bool MigratedLegacyMarker { get; init; }
}
internal sealed class OobeCompletionResult
{
public bool Success { get; init; }
public string ResultCode { get; init; } = "ok";
public string ErrorMessage { get; init; } = string.Empty;
}
internal sealed record LauncherExecutionSnapshot(
bool IsElevated,
string UserName,
string? UserSid);

View File

@@ -53,92 +53,3 @@ internal sealed class UpdateApplyResult
public string? RolledBackTo { 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

@@ -10,60 +10,32 @@ internal static class Program
private static async Task<int> Main(string[] args) private static async Task<int> Main(string[] args)
{ {
var commandContext = CommandContext.FromArgs(args); var commandContext = CommandContext.FromArgs(args);
var execution = LauncherExecutionContext.Capture();
Logger.Initialize();
Logger.Info(
$"Program entry. Command='{commandContext.Command}'; SubCommand='{commandContext.SubCommand}'; " +
$"IsGuiMode={commandContext.IsGuiCommand}; IsDebugMode={commandContext.IsDebugMode}; " +
$"LaunchSource='{commandContext.LaunchSource}'; IsElevated={execution.IsElevated}; " +
$"UserSid='{execution.UserSid ?? string.Empty}'; " +
$"HasResultPath={!string.IsNullOrWhiteSpace(commandContext.GetOption("result"))}; " +
$"ExplicitAppRoot='{commandContext.ExplicitAppRoot ?? "<none>"}'.");
try // 处理遗留插件安装命令
{
if (commandContext.IsLegacyPluginInstall) if (commandContext.IsLegacyPluginInstall)
{ {
var installer = new PluginInstallerService(); var installer = new PluginInstallerService();
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false); return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
} }
if (!commandContext.IsGuiCommand) // apply-update 命令:启动 Avalonia GUI 显示更新进度窗口
if (string.Equals(commandContext.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{ {
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
}
LauncherRuntimeContext.Current = commandContext; LauncherRuntimeContext.Current = commandContext;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode; return Environment.ExitCode;
} }
catch (Exception ex)
{
Logger.Error("Launcher failed before GUI flow completed.", ex);
var result = new LauncherResult // 处理其他 CLI 命令 (update, plugin, rollback 等)
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
{ {
Success = false, return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
Stage = "launcher",
Code = "launcher_bootstrap_failed",
Message = ex.Message,
ErrorMessage = ex.ToString(),
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["command"] = commandContext.Command,
["subCommand"] = commandContext.SubCommand,
["launchSource"] = commandContext.LaunchSource,
["isGuiMode"] = commandContext.IsGuiCommand.ToString(),
["isDebugMode"] = commandContext.IsDebugMode.ToString(),
["isElevated"] = execution.IsElevated.ToString(),
["userSid"] = execution.UserSid ?? string.Empty,
["explicitAppRoot"] = commandContext.ExplicitAppRoot ?? string.Empty
} }
};
await Commands.WriteResultIfNeededAsync(commandContext.GetOption("result"), result).ConfigureAwait(false); // 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序
return 1; LauncherRuntimeContext.Current = commandContext;
} BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode;
} }
private static AppBuilder BuildAvaloniaApp() private static AppBuilder BuildAvaloniaApp()

View File

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

View File

@@ -91,7 +91,11 @@ internal static class Commands
"check" => updateEngine.CheckPendingUpdate(), "check" => updateEngine.CheckPendingUpdate(),
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false), "apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
"rollback" => updateEngine.RollbackLatest(), "rollback" => updateEngine.RollbackLatest(),
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false), "download" => await updateEngine.DownloadAsync(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false),
_ => new LauncherResult _ => new LauncherResult
{ {
Success = false, Success = false,
@@ -102,15 +106,6 @@ internal static class Commands
}; };
} }
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
{
return await updateEngine.DownloadAsync(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false);
}
private static LauncherResult ExecutePluginCommand( private static LauncherResult ExecutePluginCommand(
CommandContext context, CommandContext context,
PluginInstallerService pluginInstaller, PluginInstallerService pluginInstaller,

View File

@@ -33,6 +33,7 @@ internal sealed class DeploymentLocator
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly); var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories"); Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories");
// ClassIsland 风格的查询:先筛选,后排序
var validInstallations = candidates var validInstallations = candidates
.Where(path => .Where(path =>
{ {
@@ -78,199 +79,38 @@ internal sealed class DeploymentLocator
} }
} }
public HostResolutionResult ResolveHostExecutable(CommandContext context)
{
ArgumentNullException.ThrowIfNull(context);
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var searchedPaths = new List<string>();
var explicitAppRoot = context.ExplicitAppRoot;
var devModeConfigIgnored = !context.IsDebugMode && Views.ErrorWindow.CheckDevModeEnabled();
string? resolvedPath;
string? source;
if (!string.IsNullOrWhiteSpace(explicitAppRoot))
{
var explicitRoot = Path.GetFullPath(explicitAppRoot);
resolvedPath = TryResolveExplicitAppRoot(explicitRoot, executable, searchedPaths, out source);
}
else
{
resolvedPath = TryResolvePublishedOrPortableHost(executable, searchedPaths, out source);
}
if (resolvedPath is null && context.IsDebugMode)
{
resolvedPath = TryResolveDebugHost(executable, searchedPaths, out source);
}
if (resolvedPath is null)
{
resolvedPath = ResolveHostExecutablePathLegacy();
if (!string.IsNullOrWhiteSpace(resolvedPath))
{
searchedPaths.Add(Path.GetFullPath(resolvedPath));
source = "legacy_fallback";
}
}
return new HostResolutionResult
{
Success = !string.IsNullOrWhiteSpace(resolvedPath),
ResolvedHostPath = resolvedPath,
ResolutionSource = source,
AppRoot = _appRoot,
ExplicitAppRoot = explicitAppRoot,
DevModeConfigIgnored = devModeConfigIgnored,
SearchedPaths = searchedPaths
.Where(path => !string.IsNullOrWhiteSpace(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
public string? ResolveHostExecutablePath() public string? ResolveHostExecutablePath()
{ {
// 使用新的灵活定位器
var options = new HostDiscoveryOptions
{
ExecutableName = "LanMountainDesktop",
PreferDevModeConfig = true,
RecursiveSearch = false, // 默认不启用递归搜索以提高性能
AdditionalSearchPaths = new List<string>
{
// 可以通过配置文件或环境变量添加更多路径
"${AppRoot}",
"${AppRoot}/..",
"${BaseDirectory}/../..",
}
};
var locator = new FlexibleHostLocator(_appRoot, options);
var result = locator.ResolveHostExecutablePath();
if (result != null)
{
return result;
}
// 回退到旧逻辑(作为备选)
return ResolveHostExecutablePathLegacy(); return ResolveHostExecutablePathLegacy();
} }
private string? TryResolveExplicitAppRoot( /// <summary>
string explicitRoot, /// 传统的主程序路径解析(作为备选)
string executable, /// </summary>
List<string> searchedPaths,
out string? source)
{
var directPath = Path.Combine(explicitRoot, executable);
searchedPaths.Add(directPath);
if (File.Exists(directPath))
{
source = "explicit_app_root_direct";
return directPath;
}
var deployment = FindBestDeploymentHost(explicitRoot, executable, searchedPaths);
if (deployment is not null)
{
source = "explicit_app_root_deployment";
return deployment;
}
source = null;
return null;
}
private string? TryResolvePublishedOrPortableHost(
string executable,
List<string> searchedPaths,
out string? source)
{
var deployment = FindBestDeploymentHost(_appRoot, executable, searchedPaths);
if (deployment is not null)
{
source = "published_deployment";
return deployment;
}
var portableCandidates = new[]
{
Path.Combine(_appRoot, executable),
Path.Combine(AppContext.BaseDirectory, executable)
};
foreach (var candidate in portableCandidates
.Select(Path.GetFullPath)
.Distinct(StringComparer.OrdinalIgnoreCase))
{
searchedPaths.Add(candidate);
if (File.Exists(candidate))
{
source = "portable_host";
return candidate;
}
}
source = null;
return null;
}
private string? TryResolveDebugHost(
string executable,
List<string> searchedPaths,
out string? source)
{
if (Views.ErrorWindow.CheckDevModeEnabled())
{
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
var fullSavedPath = Path.GetFullPath(savedCustomPath);
searchedPaths.Add(fullSavedPath);
if (File.Exists(fullSavedPath))
{
source = "debug_saved_custom_path";
return fullSavedPath;
}
}
}
foreach (var devPath in GetDevelopmentPaths(executable))
{
var fullPath = Path.GetFullPath(devPath);
searchedPaths.Add(fullPath);
if (File.Exists(fullPath))
{
source = "debug_build_output";
return fullPath;
}
}
source = null;
return null;
}
private static string? FindBestDeploymentHost(
string root,
string executable,
List<string> searchedPaths)
{
if (!Directory.Exists(root))
{
searchedPaths.Add(Path.Combine(root, "app-*", executable));
return null;
}
var appDirs = Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly)
.Where(path => !File.Exists(Path.Combine(path, ".destroy")))
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Select(path => new
{
Path = path,
HostPath = Path.Combine(path, executable),
HasCurrent = File.Exists(Path.Combine(path, ".current")),
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.HasCurrent)
.ThenByDescending(item => item.Version)
.ToList();
foreach (var candidate in appDirs)
{
searchedPaths.Add(candidate.HostPath);
if (File.Exists(candidate.HostPath))
{
return candidate.HostPath;
}
}
if (appDirs.Count == 0)
{
searchedPaths.Add(Path.Combine(root, "app-*", executable));
}
return null;
}
private string? ResolveHostExecutablePathLegacy() private string? ResolveHostExecutablePathLegacy()
{ {
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop"; var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
@@ -286,12 +126,14 @@ internal sealed class DeploymentLocator
} }
} }
// 2. 查找 Launcher 所在目录(开发环境 - 直接运行)
var inRoot = Path.Combine(_appRoot, executable); var inRoot = Path.Combine(_appRoot, executable);
if (File.Exists(inRoot)) if (File.Exists(inRoot))
{ {
return inRoot; return inRoot;
} }
// 3. 查找父目录(开发环境 - 从 Launcher 项目运行)
var parent = Path.GetFullPath(Path.Combine(_appRoot, "..")); var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
var inParent = Path.Combine(parent, executable); var inParent = Path.Combine(parent, executable);
if (File.Exists(inParent)) if (File.Exists(inParent))
@@ -302,12 +144,14 @@ internal sealed class DeploymentLocator
// 4. 开发模式:如果启用了开发模式,优先使用保存的自定义路径 // 4. 开发模式:如果启用了开发模式,优先使用保存的自定义路径
if (Views.ErrorWindow.CheckDevModeEnabled()) if (Views.ErrorWindow.CheckDevModeEnabled())
{ {
// 4.1 首先检查保存的自定义路径
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath(); var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath)) if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
{ {
return savedCustomPath; return savedCustomPath;
} }
// 4.2 扫描开发路径
var devPath = ScanDevelopmentPaths(executable); var devPath = ScanDevelopmentPaths(executable);
if (!string.IsNullOrWhiteSpace(devPath)) if (!string.IsNullOrWhiteSpace(devPath))
{ {
@@ -335,7 +179,7 @@ internal sealed class DeploymentLocator
{ {
var possiblePaths = new[] var possiblePaths = new[]
{ {
// ä»?Launcher 项ç®è¿<EFBFBD>行 // Launcher 项目运行
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable), Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable), Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
@@ -359,14 +203,17 @@ internal sealed class DeploymentLocator
} }
/// <summary> /// <summary>
/// 获å<EFBFBD>å¼€å<EFBFBD>环境å<EFBFBD>¯èƒ½çš„主ç¨åº<EFBFBD>è·¯å¾? /// </summary> /// 获取开发环境可能的主程序路径
/// </summary>
private static IEnumerable<string> GetDevelopmentPaths(string executable) private static IEnumerable<string> GetDevelopmentPaths(string executable)
{ {
// 获取 Launcher 所在目录
var launcherDir = AppContext.BaseDirectory; var launcherDir = AppContext.BaseDirectory;
// 可能的开发目录结构
var possiblePaths = new[] var possiblePaths = new[]
{ {
// ä»?Launcher 项ç®è¿<EFBFBD>行ï¼?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe // Launcher 项目运行:..\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable), Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable), Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
@@ -374,7 +221,7 @@ internal sealed class DeploymentLocator
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable), Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable), Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// ä»?dev-test ç®å½•è¿<EFBFBD>行 // dev-test 目录运行
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable), Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
}; };
@@ -409,8 +256,9 @@ internal sealed class DeploymentLocator
} }
/// <summary> /// <summary>
/// 清ç<EFBFBD>†æ—§ç‰ˆæœ¬éƒ¨ç½²ï¼Œä¿<EFBFBD>留最è¿çš„N个版æœ? /// </summary> /// 清理旧版本部署保留最近的N个版本
/// <param name="minVersionsToKeep">最å°ä¿<C3A4>留版本数,默è®?ä¸?/param> /// </summary>
/// <param name="minVersionsToKeep">最少保留版本数默认3个</param>
public void CleanupOldDeployments(int minVersionsToKeep = 3) public void CleanupOldDeployments(int minVersionsToKeep = 3)
{ {
try try
@@ -424,6 +272,7 @@ internal sealed class DeploymentLocator
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly); var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
// 过滤掉无效部署目录排除partial按版本排序
var validDeployments = candidates var validDeployments = candidates
.Where(path => !File.Exists(Path.Combine(path, ".partial"))) .Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Select(path => new .Select(path => new
@@ -500,6 +349,7 @@ internal sealed class DeploymentLocator
{ {
if (versionsToKeep.Contains(deployment.Path)) if (versionsToKeep.Contains(deployment.Path))
{ {
// 保留此版本如果之前标记了destroy则取消标记
if (deployment.IsDestroyed) if (deployment.IsDestroyed)
{ {
try try
@@ -515,6 +365,7 @@ internal sealed class DeploymentLocator
continue; continue;
} }
// 如果还没标记destroy的先标记
if (!deployment.IsDestroyed) if (!deployment.IsDestroyed)
{ {
try try
@@ -536,7 +387,7 @@ internal sealed class DeploymentLocator
} }
catch catch
{ {
// 忽略删除失败(å<>¯èƒ½æ‡ä»¶è¢«å<C2AB> ç”?,䏿¬¡å<C2A1>¯åЍå†<C3A5>试 // 忽略删除失败(可能文件被占用),下次启动再试
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}"); Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
} }
} }
@@ -549,7 +400,7 @@ internal sealed class DeploymentLocator
} }
/// <summary> /// <summary>
/// 仅清ç<EFBFBD>†å·²æ ‡è®°ä¸?destroyçš„éƒ¨ç½²ï¼ˆå…¼å®¹æ—§æ¹æ³•) /// 仅清理已标记为.destroy的部署兼容旧方法
/// </summary> /// </summary>
[Obsolete("Use CleanupOldDeployments instead")] [Obsolete("Use CleanupOldDeployments instead")]
public void CleanupDestroyedDeployments() public void CleanupDestroyedDeployments()
@@ -581,7 +432,8 @@ internal sealed class DeploymentLocator
} }
/// <summary> /// <summary>
/// 从部署ç®å½•读å<EFBFBD>版本信æ<EFBFBD>? /// </summary> /// 从部署目录读取版本信息
/// </summary>
public AppVersionInfo GetVersionInfo() public AppVersionInfo GetVersionInfo()
{ {
var deploymentDir = FindCurrentDeploymentDirectory(); var deploymentDir = FindCurrentDeploymentDirectory();
@@ -601,16 +453,16 @@ internal sealed class DeploymentLocator
} }
catch catch
{ {
// 忽略读取失败,回退到默认值
} }
} }
} }
// 回退:从目录名解析版本,使用默认开发代号
return new AppVersionInfo return new AppVersionInfo
{ {
Version = GetCurrentVersion(), Version = GetCurrentVersion(),
Codename = "Administrate" Codename = "Administrate" // 默认开发代号
}; };
} }
} }

View File

@@ -1,18 +0,0 @@
namespace LanMountainDesktop.Launcher.Services;
internal sealed class HostResolutionResult
{
public bool Success { get; init; }
public string? ResolvedHostPath { get; init; }
public string? ResolutionSource { get; init; }
public string AppRoot { get; init; } = string.Empty;
public string? ExplicitAppRoot { get; init; }
public bool DevModeConfigIgnored { get; init; }
public List<string> SearchedPaths { get; init; } = [];
}

View File

@@ -1,30 +0,0 @@
using System.Security.Principal;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
internal static class LauncherExecutionContext
{
public static LauncherExecutionSnapshot Capture()
{
var userName = Environment.UserName ?? string.Empty;
if (!OperatingSystem.IsWindows())
{
return new LauncherExecutionSnapshot(false, userName, null);
}
try
{
using var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
return new LauncherExecutionSnapshot(
principal.IsInRole(WindowsBuiltInRole.Administrator),
userName,
identity.User?.Value);
}
catch
{
return new LauncherExecutionSnapshot(false, userName, null);
}
}
}

View File

@@ -262,9 +262,6 @@ internal sealed class LegacyVersionDetector
var parts = info.UninstallCommand.Split(new[] { ' ' }, 2); var parts = info.UninstallCommand.Split(new[] { ' ' }, 2);
var fileName = parts[0].Trim('"'); var fileName = parts[0].Trim('"');
var arguments = parts.Length > 1 ? parts[1] : ""; var arguments = parts.Length > 1 ? parts[1] : "";
Logger.Info(
$"Opening legacy uninstall interface with elevation reason 'legacy_uninstall'. " +
$"InstallPath='{info.InstallPath}'; Version='{info.Version}'.");
Process.Start(new ProcessStartInfo Process.Start(new ProcessStartInfo
{ {

View File

@@ -1,221 +1,104 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services; namespace LanMountainDesktop.Launcher.Services;
internal sealed class OobeStateService internal sealed class OobeStateService
{ {
private const int CurrentSchemaVersion = 1; private readonly string _markerPath;
private readonly string _stateDirectory; public OobeStateService(string appRoot)
private readonly string _statePath;
private readonly string _legacyMarkerPath;
private readonly LauncherExecutionSnapshot _executionSnapshot;
public OobeStateService(
string appRoot,
string? stateRootOverride = null,
LauncherExecutionSnapshot? executionSnapshot = null)
{ {
_ = Path.GetFullPath(appRoot); // 优先使用 LocalApplicationData用户目录普通用户一定有权限
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture(); string? stateDir = null;
Exception? lastException = null;
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride) // 策略1: LocalApplicationData首选用户目录普通用户一定有写权限
? GetDefaultStateRoot()
: Path.GetFullPath(stateRootOverride);
_stateDirectory = Path.Combine(stateRoot, ".launcher", "state");
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
_legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
}
public OobeLaunchDecision Evaluate(CommandContext context)
{
var decision = EvaluateCore(context);
Logger.Info(
$"OOBE decision evaluated. LaunchSource='{decision.LaunchSource}'; Status='{decision.Status}'; " +
$"ShouldShow={decision.ShouldShowOobe}; IsElevated={decision.IsElevated}; " +
$"StatePath='{decision.StatePath}'; SuppressionReason='{decision.SuppressionReason}'; " +
$"ResultCode='{decision.ResultCode}'; UserSid='{decision.UserSid ?? string.Empty}'.");
return decision;
}
public OobeCompletionResult MarkCompleted(CommandContext context)
{
try try
{ {
Directory.CreateDirectory(_stateDirectory); var appDataDir = Path.Combine(
var payload = new OobeStateFile Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
{ "LanMountainDesktop");
SchemaVersion = CurrentSchemaVersion, stateDir = Path.Combine(appDataDir, ".launcher", "state");
CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O"), Directory.CreateDirectory(stateDir);
UserName = _executionSnapshot.UserName, Console.WriteLine($"[OobeStateService] Using LocalApplicationData: {stateDir}");
UserSid = _executionSnapshot.UserSid,
LaunchSource = context.LaunchSource
};
var tempPath = Path.Combine(_stateDirectory, $"oobe-state.{Guid.NewGuid():N}.tmp");
var json = JsonSerializer.Serialize(payload, AppJsonContext.Default.OobeStateFile);
File.WriteAllText(tempPath, json);
File.Move(tempPath, _statePath, overwrite: true);
TryDeleteLegacyMarker();
Logger.Info(
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
$"UserSid='{_executionSnapshot.UserSid ?? string.Empty}'.");
return new OobeCompletionResult
{
Success = true,
ResultCode = "ok"
};
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Warn( lastException = ex;
$"Failed to persist OOBE state. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " + Console.Error.WriteLine($"[OobeStateService] LocalApplicationData failed: {ex.Message}");
$"Error='{ex.Message}'."); stateDir = null;
return new OobeCompletionResult
{
Success = false,
ResultCode = "oobe_state_unavailable",
ErrorMessage = ex.Message
};
}
} }
private OobeLaunchDecision EvaluateCore(CommandContext context) // 策略2: 如果LocalApplicationData不行使用用户的临时目录
if (stateDir == null)
{ {
if (string.Equals(context.LaunchSource, "debug-preview", StringComparison.OrdinalIgnoreCase))
{
return BuildSuppressedDecision(context, "debug_preview", "oobe_suppressed_debug_preview");
}
if (context.IsMaintenanceCommand)
{
return BuildSuppressedDecision(context, "maintenance", "oobe_suppressed_maintenance");
}
try try
{ {
var migratedLegacyMarker = false; var tempDir = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", ".launcher", "state");
if (File.Exists(_statePath)) Directory.CreateDirectory(tempDir);
{ stateDir = tempDir;
using var stream = File.OpenRead(_statePath); Console.WriteLine($"[OobeStateService] Using TempPath: {stateDir}");
var state = JsonSerializer.Deserialize(stream, AppJsonContext.Default.OobeStateFile);
if (state is null || state.SchemaVersion <= 0 || string.IsNullOrWhiteSpace(state.CompletedAtUtc))
{
return BuildUnavailableDecision(context, "OOBE state file is invalid.");
}
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, migratedLegacyMarker: false);
}
if (File.Exists(_legacyMarkerPath))
{
migratedLegacyMarker = TryMigrateLegacyMarker(context);
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
}
if (_executionSnapshot.IsElevated)
{
return BuildSuppressedDecision(context, "elevated", "oobe_suppressed_elevated");
}
if (string.Equals(context.LaunchSource, "postinstall", StringComparison.OrdinalIgnoreCase))
{
return BuildDecision(context, OobeStateStatus.FirstRun, shouldShowOobe: true);
}
return BuildDecision(context, OobeStateStatus.FirstRun, shouldShowOobe: true);
} }
catch (Exception ex) catch (Exception ex)
{ {
return BuildUnavailableDecision(context, ex.Message); lastException = ex;
Console.Error.WriteLine($"[OobeStateService] TempPath failed: {ex.Message}");
stateDir = null;
} }
} }
private bool TryMigrateLegacyMarker(CommandContext context) // 策略3: 最后的兜底使用当前用户的应用程序数据目录和Launcher同目录
{ if (stateDir == null)
var result = MarkCompleted(context);
return result.Success;
}
private void TryDeleteLegacyMarker()
{ {
try try
{ {
if (File.Exists(_legacyMarkerPath)) var launcherDir = AppContext.BaseDirectory;
{ stateDir = Path.Combine(launcherDir, ".launcher", "state");
File.Delete(_legacyMarkerPath); Directory.CreateDirectory(stateDir);
Console.WriteLine($"[OobeStateService] Using Launcher directory: {stateDir}");
} }
} catch (Exception ex)
catch
{ {
lastException = ex;
Console.Error.WriteLine($"[OobeStateService] All strategies failed! Last error: {ex.Message}");
// 如果所有策略都失败,抛出异常让上层处理
throw new InvalidOperationException("无法创建 OOBE 状态存储目录失败", lastException);
} }
} }
private OobeLaunchDecision BuildDecision( _markerPath = Path.Combine(stateDir, "first_run_completed");
CommandContext context, Console.WriteLine($"[OobeStateService] Initialized successfully, marker path: {_markerPath}");
OobeStateStatus status,
bool shouldShowOobe,
bool usedLegacyMarker = false,
bool migratedLegacyMarker = false)
{
return new OobeLaunchDecision
{
Status = status,
ShouldShowOobe = shouldShowOobe,
StatePath = _statePath,
LaunchSource = context.LaunchSource,
IsElevated = _executionSnapshot.IsElevated,
UserName = _executionSnapshot.UserName,
UserSid = _executionSnapshot.UserSid,
UsedLegacyMarker = usedLegacyMarker,
MigratedLegacyMarker = migratedLegacyMarker,
ResultCode = "ok"
};
} }
private OobeLaunchDecision BuildSuppressedDecision(CommandContext context, string reason, string resultCode) public bool IsFirstRun()
{ {
return new OobeLaunchDecision try
{ {
Status = OobeStateStatus.Suppressed, return !File.Exists(_markerPath);
ShouldShowOobe = false, }
StatePath = _statePath, catch (Exception ex)
LaunchSource = context.LaunchSource, {
IsElevated = _executionSnapshot.IsElevated, Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}");
UserName = _executionSnapshot.UserName, // 如果无法检查默认视为首次运行确保OOBE能显示
UserSid = _executionSnapshot.UserSid, return true;
SuppressionReason = reason, }
ResultCode = resultCode
};
} }
private OobeLaunchDecision BuildUnavailableDecision(CommandContext context, string errorMessage) public void MarkCompleted()
{ {
return new OobeLaunchDecision try
{ {
Status = OobeStateStatus.Unavailable, var dir = Path.GetDirectoryName(_markerPath);
ShouldShowOobe = false, if (!string.IsNullOrWhiteSpace(dir))
StatePath = _statePath, {
LaunchSource = context.LaunchSource, Directory.CreateDirectory(dir);
IsElevated = _executionSnapshot.IsElevated,
UserName = _executionSnapshot.UserName,
UserSid = _executionSnapshot.UserSid,
ResultCode = "oobe_state_unavailable",
ErrorMessage = errorMessage
};
} }
private static string GetDefaultStateRoot() File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
Console.WriteLine("[OobeStateService] Marked first run as completed");
}
catch (Exception ex)
{ {
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}");
if (string.IsNullOrWhiteSpace(appData)) // 如果无法写入也没关系下次启动还会显示OOBE
{ }
throw new InvalidOperationException("LocalApplicationData is unavailable.");
}
return Path.Combine(appData, "LanMountainDesktop");
} }
} }

View File

@@ -30,11 +30,6 @@ 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)
{
return elevationRequiredResult;
}
var manifest = ReadManifestFromPackage(fullSourcePath); var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory); Directory.CreateDirectory(fullPluginsDirectory);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id)); var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
@@ -56,46 +51,6 @@ internal sealed class PluginInstallerService
}; };
} }
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
{
if (!OperatingSystem.IsWindows())
{
return null;
}
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(localAppData))
{
return null;
}
var allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop"));
var normalizedPluginsDirectory = EnsureTrailingSeparator(Path.GetFullPath(pluginsDirectory));
if (normalizedPluginsDirectory.StartsWith(allowedRoot, StringComparison.OrdinalIgnoreCase))
{
return null;
}
Logger.Warn(
$"Plugin installation requires explicit elevation. Reason='plugin_requires_elevation'; " +
$"PluginsDirectory='{pluginsDirectory}'; AllowedRoot='{allowedRoot}'.");
return new LauncherResult
{
Success = false,
Stage = "plugin.install",
Code = "plugin_elevation_required",
Message = "Plugin installation outside the current user's LanMountainDesktop data directory requires explicit elevation.",
ErrorMessage = "Plugin installation target is outside the current user's LanMountainDesktop data directory.",
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["pluginsDirectory"] = pluginsDirectory,
["allowedRoot"] = allowedRoot,
["elevationReason"] = "outside_user_scope"
}
};
}
public PluginManifest ReadManifestFromPackage(string packagePath) public PluginManifest ReadManifestFromPackage(string packagePath)
{ {
using var archive = ZipFile.OpenRead(packagePath); using var archive = ZipFile.OpenRead(packagePath);

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +0,0 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class WelcomeOobeStep : IOobeStep
{
private readonly CommandContext _context;
private readonly OobeStateService _oobeStateService;
public WelcomeOobeStep(OobeStateService oobeStateService, CommandContext context)
{
_oobeStateService = oobeStateService;
_context = context;
}
public async Task RunAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
OobeWindow? window = null;
await Dispatcher.UIThread.InvokeAsync(() =>
{
window = new OobeWindow();
window.Show();
});
if (window is null)
{
return;
}
await window.WaitForEnterAsync().ConfigureAwait(false);
var completion = _oobeStateService.MarkCompleted(_context);
if (!completion.Success)
{
Logger.Warn(
$"OOBE completion state was not persisted. ResultCode='{completion.ResultCode}'; " +
$"Error='{completion.ErrorMessage}'.");
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (window.IsVisible)
{
window.Close();
}
});
}
}

View File

@@ -1,13 +1,12 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d" mc:Ignorable="d"
d:DesignWidth="600" d:DesignWidth="600"
d:DesignHeight="500" d:DesignHeight="500"
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow" x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
Title="LanMountain Desktop - Loading Details" Title="阑山桌面 - 加载详情"
Width="600" Width="600"
Height="500" Height="500"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
@@ -18,17 +17,18 @@
Icon="/Assets/logo.ico"> Icon="/Assets/logo.ico">
<Grid RowDefinitions="Auto,*,Auto,Auto"> <Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- 标题栏 -->
<Border Grid.Row="0" <Border Grid.Row="0"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="20,16"> Padding="20,16">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4"> <StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Starting LanMountain Desktop" <TextBlock Text="正在启动阑山桌面"
FontSize="18" FontSize="18"
FontWeight="SemiBold" FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/> Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<TextBlock x:Name="SubtitleText" <TextBlock x:Name="SubtitleText"
Text="Initializing..." Text="初始化系统组件..."
FontSize="13" FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/> Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
</StackPanel> </StackPanel>
@@ -46,6 +46,7 @@
</Grid> </Grid>
</Border> </Border>
<!-- 主要内容区域 -->
<Grid Grid.Row="1" Margin="16,12"> <Grid Grid.Row="1" Margin="16,12">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
@@ -53,6 +54,7 @@
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- 整体进度条 -->
<ProgressBar x:Name="OverallProgressBar" <ProgressBar x:Name="OverallProgressBar"
Grid.Row="0" Grid.Row="0"
Height="8" Height="8"
@@ -62,12 +64,14 @@
CornerRadius="4" CornerRadius="4"
Margin="0,0,0,16"/> Margin="0,0,0,16"/>
<!-- 当前活动项 -->
<Border Grid.Row="1" <Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}" Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="8" CornerRadius="8"
Padding="16,12" Padding="16,12"
Margin="0,0,0,12"> Margin="0,0,0,12">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*"> <Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
<!-- 图标 -->
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0" <Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
Width="40" Width="40"
Height="40" Height="40"
@@ -84,20 +88,23 @@
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
</Border> </Border>
<!-- 名称 -->
<TextBlock x:Name="CurrentItemName" <TextBlock x:Name="CurrentItemName"
Grid.Row="0" Grid.Column="1" Grid.Row="0" Grid.Column="1"
Text="Initializing..." Text="正在初始化..."
FontSize="15" FontSize="15"
FontWeight="SemiBold" FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/> Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<!-- 描述 -->
<TextBlock x:Name="CurrentItemDescription" <TextBlock x:Name="CurrentItemDescription"
Grid.Row="1" Grid.Column="1" Grid.Row="1" Grid.Column="1"
Text="Preparing components" Text="准备加载系统组件"
FontSize="13" FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,4,0,0"/> Margin="0,4,0,0"/>
<!-- 进度 -->
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0"> <Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
<ProgressBar x:Name="CurrentItemProgress" <ProgressBar x:Name="CurrentItemProgress"
Height="4" Height="4"
@@ -109,13 +116,15 @@
</Grid> </Grid>
</Border> </Border>
<!-- 加载项列表 -->
<Border Grid.Row="2" <Border Grid.Row="2"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8"> CornerRadius="8">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<!-- 列表标题 -->
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto"> <Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" <TextBlock Grid.Column="0"
Text="Loading Items" Text="加载项"
FontSize="12" FontSize="12"
FontWeight="SemiBold" FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/> Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
@@ -126,20 +135,22 @@
Foreground="{DynamicResource TextFillColorSecondaryBrush}" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,0,4,0"/> Margin="0,0,4,0"/>
<TextBlock Grid.Column="2" <TextBlock Grid.Column="2"
Text="Done" Text="已完成"
FontSize="12" FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/> Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
</Grid> </Grid>
<!-- 列表内容 -->
<ScrollViewer Grid.Row="1" <ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
Margin="8,0,8,8"> Margin="8,0,8,8">
<ItemsControl x:Name="LoadingItemsList"> <ItemsControl x:Name="LoadingItemsList">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="views:LoadingItemViewModel"> <DataTemplate>
<Grid ColumnDefinitions="Auto,*,Auto,Auto" <Grid ColumnDefinitions="Auto,*,Auto,Auto"
Margin="4,3" Margin="4,3"
Opacity="{Binding Opacity}"> Opacity="{Binding Opacity}">
<!-- 状态图标 -->
<TextBlock Grid.Column="0" <TextBlock Grid.Column="0"
Text="{Binding StatusIcon}" Text="{Binding StatusIcon}"
FontSize="14" FontSize="14"
@@ -148,6 +159,7 @@
Margin="0,0,8,0" Margin="0,0,8,0"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<!-- 名称 -->
<TextBlock Grid.Column="1" <TextBlock Grid.Column="1"
Text="{Binding Name}" Text="{Binding Name}"
FontSize="13" FontSize="13"
@@ -155,6 +167,7 @@
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<!-- 进度 -->
<TextBlock Grid.Column="2" <TextBlock Grid.Column="2"
Text="{Binding ProgressText}" Text="{Binding ProgressText}"
FontSize="12" FontSize="12"
@@ -162,6 +175,7 @@
Margin="8,0" Margin="8,0"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<!-- 类型标签 -->
<Border Grid.Column="3" <Border Grid.Column="3"
Background="{Binding TypeBackground}" Background="{Binding TypeBackground}"
CornerRadius="4" CornerRadius="4"
@@ -180,6 +194,7 @@
</Border> </Border>
</Grid> </Grid>
<!-- 错误信息区域 -->
<Border x:Name="ErrorPanel" <Border x:Name="ErrorPanel"
Grid.Row="2" Grid.Row="2"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}" Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
@@ -199,13 +214,14 @@
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<TextBlock x:Name="ErrorText" <TextBlock x:Name="ErrorText"
Grid.Column="1" Grid.Column="1"
Text="An error occurred while loading." Text="加载过程中出现错误"
FontSize="13" FontSize="13"
Foreground="{DynamicResource SystemFillColorCriticalBrush}" Foreground="{DynamicResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"/> TextWrapping="Wrap"/>
</Grid> </Grid>
</Border> </Border>
<!-- 底部按钮 -->
<Border Grid.Row="3" <Border Grid.Row="3"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="16,12"> Padding="16,12">
@@ -218,12 +234,12 @@
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button x:Name="DetailsButton" <Button x:Name="DetailsButton"
Content="Details" Content="查看详情"
Width="90" Width="90"
Height="32" Height="32"
FontSize="13"/> FontSize="13"/>
<Button x:Name="CancelButton" <Button x:Name="CancelButton"
Content="Cancel" Content="取消"
Width="90" Width="90"
Height="32" Height="32"
FontSize="13"/> FontSize="13"/>

View File

@@ -23,12 +23,14 @@ public partial class LoadingDetailsWindow : Window
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
// 初始化列表
var itemsList = this.FindControl<ItemsControl>("LoadingItemsList"); var itemsList = this.FindControl<ItemsControl>("LoadingItemsList");
if (itemsList != null) if (itemsList != null)
{ {
itemsList.ItemsSource = _items; itemsList.ItemsSource = _items;
} }
// 创建更新定时器
_updateTimer = new DispatcherTimer _updateTimer = new DispatcherTimer
{ {
Interval = TimeSpan.FromMilliseconds(100) Interval = TimeSpan.FromMilliseconds(100)
@@ -57,7 +59,8 @@ public partial class LoadingDetailsWindow : Window
} }
/// <summary> /// <summary>
/// 鏇存柊鍔犺浇鐘舵€? /// </summary> /// 更新加载状态
/// </summary>
public void UpdateLoadingState(LoadingStateMessage state) public void UpdateLoadingState(LoadingStateMessage state)
{ {
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
@@ -70,6 +73,7 @@ public partial class LoadingDetailsWindow : Window
// 更新整体进度 // 更新整体进度
UpdateOverallProgress(state); UpdateOverallProgress(state);
// 更新当前活动项
UpdateCurrentItem(state); UpdateCurrentItem(state);
// 更新列表 // 更新列表
@@ -120,7 +124,8 @@ public partial class LoadingDetailsWindow : Window
} }
/// <summary> /// <summary>
/// 鏇存柊褰撳墠娲诲姩椤? /// </summary> /// 更新当前活动项
/// </summary>
private void UpdateCurrentItem(LoadingStateMessage state) private void UpdateCurrentItem(LoadingStateMessage state)
{ {
var currentItem = state.ActiveItems.FirstOrDefault(); var currentItem = state.ActiveItems.FirstOrDefault();
@@ -157,6 +162,7 @@ public partial class LoadingDetailsWindow : Window
/// </summary> /// </summary>
private void UpdateItemsList(LoadingStateMessage state) private void UpdateItemsList(LoadingStateMessage state)
{ {
// 同步列表项
foreach (var item in state.ActiveItems) foreach (var item in state.ActiveItems)
{ {
var existing = _items.FirstOrDefault(i => i.Id == item.Id); var existing = _items.FirstOrDefault(i => i.Id == item.Id);
@@ -181,7 +187,7 @@ public partial class LoadingDetailsWindow : Window
} }
} }
// 鎸夌姸鎬佹帓搴忥細杩涜<EFBFBD>涓?-> 绛夊緟涓?-> 宸插畬鎴?-> 澶辫触 // 按状态排序:进行中 -> 等待中 -> 已完成 -> 失败
var sortedItems = _items.OrderBy(i => GetStatePriority(i.State)).ToList(); var sortedItems = _items.OrderBy(i => GetStatePriority(i.State)).ToList();
_items.Clear(); _items.Clear();
foreach (var item in sortedItems) foreach (var item in sortedItems)
@@ -234,20 +240,17 @@ public partial class LoadingDetailsWindow : Window
/// </summary> /// </summary>
private static string GetStageDescription(StartupStage stage) => stage switch private static string GetStageDescription(StartupStage stage) => stage switch
{ {
StartupStage.Initializing => "正在初始化系统...", StartupStage.Initializing => "正在初始化系统...",
StartupStage.LoadingSettings => "正在加载设置...", StartupStage.LoadingSettings => "正在加载设置...",
StartupStage.LoadingPlugins => "正在加载插件...", StartupStage.LoadingPlugins => "正在加载插件...",
StartupStage.InitializingUI => "正在初始化界面...", StartupStage.InitializingUI => "正在初始化界面...",
StartupStage.ShellInitialized => "桌面外壳已初始化", StartupStage.Ready => "加载完成",
StartupStage.DesktopVisible => "桌面已经可见", _ => "正在加载..."
StartupStage.ActivationRedirected => "已激活现有实例",
StartupStage.ActivationFailed => "现有实例激活失败",
StartupStage.Ready => "加载完成",
_ => "正在加载..."
}; };
/// <summary> /// <summary>
/// 鑾峰彇椤规弿杩? /// </summary> /// 获取项描述
/// </summary>
private static string GetItemDescription(LoadingItem item) private static string GetItemDescription(LoadingItem item)
{ {
if (!string.IsNullOrEmpty(item.Description)) if (!string.IsNullOrEmpty(item.Description))
@@ -265,7 +268,8 @@ public partial class LoadingDetailsWindow : Window
} }
/// <summary> /// <summary>
/// 鑾峰彇椤瑰浘鏍? /// </summary> /// 获取项图标
/// </summary>
private static string GetItemIcon(LoadingItemType type) => type switch private static string GetItemIcon(LoadingItemType type) => type switch
{ {
LoadingItemType.Plugin => "\uE768", LoadingItemType.Plugin => "\uE768",
@@ -294,7 +298,8 @@ public partial class LoadingDetailsWindow : Window
} }
/// <summary> /// <summary>
/// 鍔犺浇椤硅<EFBFBD>鍥炬ā鍨?/// </summary> /// 加载项视图模型
/// </summary>
public class LoadingItemViewModel : INotifyPropertyChanged public class LoadingItemViewModel : INotifyPropertyChanged
{ {
public string Id { get; } public string Id { get; }
@@ -389,4 +394,3 @@ public class LoadingItemViewModel : INotifyPropertyChanged
_ => new SolidColorBrush(Color.Parse("#616161")) _ => new SolidColorBrush(Color.Parse("#616161"))
}; };
} }

View File

@@ -1,12 +0,0 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginAppearanceSnapshotRequest(string SessionId);
public sealed record PluginAppearanceSnapshot(
string ThemeVariant,
string? AccentColor = null,
double CornerRadiusScale = 1.0,
IReadOnlyDictionary<string, double>? CornerRadiusTokens = null,
IReadOnlyDictionary<string, string>? ResourceAliases = null);
public sealed record PluginAppearanceChangedNotification(PluginAppearanceSnapshot Snapshot);

View File

@@ -1,45 +0,0 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginHeartbeatPing(
string SessionId,
DateTimeOffset SentAtUtc);
public sealed record PluginHeartbeatPong(
string SessionId,
DateTimeOffset ReceivedAtUtc);
public sealed record PluginLogEntry(
string Level,
string Category,
string Message,
DateTimeOffset TimestampUtc,
string? Exception = null);
public static class PluginLogLevels
{
public const string Trace = "trace";
public const string Debug = "debug";
public const string Information = "information";
public const string Warning = "warning";
public const string Error = "error";
public const string Critical = "critical";
}
public sealed record PluginFaultReport(
string SessionId,
string FaultKind,
bool IsFatal,
string Message,
string? StackTrace = null,
int? WorkerProcessId = null,
int? ExitCode = null,
DateTimeOffset? OccurredAtUtc = null);
public static class PluginFaultKinds
{
public const string ManagedException = "managed-exception";
public const string NativeCrash = "native-crash";
public const string WatchdogTimeout = "watchdog-timeout";
public const string StartupFailure = "startup-failure";
public const string ForcedTermination = "forced-termination";
}

View File

@@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageId>LanMountainDesktop.PluginIsolation.Contracts</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
<Description>Transport-neutral IPC contracts for the LanMountainDesktop plugin isolation architecture.</Description>
<PackageTags>LanMountainDesktop;Plugin;IPC;Isolation;Contracts</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View File

@@ -1,33 +0,0 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginInitializeRequest(
string PluginId,
string SessionId,
string HostPipeName,
string DataDirectory,
IReadOnlyDictionary<string, string>? StartupProperties = null);
public sealed record PluginInitializeResponse(
bool Succeeded,
string? ErrorCode = null,
string? ErrorMessage = null);
public sealed record PluginStopRequest(
string Reason,
bool RestartRequested = false);
public sealed record PluginRestartRequest(string Reason);
public sealed record PluginLifecycleStateChanged(
string State,
string? Detail = null);
public static class PluginLifecycleStates
{
public const string Starting = "starting";
public const string Ready = "ready";
public const string Degraded = "degraded";
public const string Stopping = "stopping";
public const string Stopped = "stopped";
public const string Faulted = "faulted";
}

View File

@@ -1,17 +0,0 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginCapabilityDeclaration(
string Name,
string Version,
string? Description = null);
public static class PluginCapabilityNames
{
public const string Settings = "settings";
public const string Appearance = "appearance";
public const string DesktopComponentUi = "ui.desktop-component";
public const string ComponentEditorUi = "ui.component-editor";
public const string SettingsPageUi = "ui.settings-page";
public const string Logging = "diagnostics.log";
public const string FaultReporting = "diagnostics.fault";
}

View File

@@ -1,15 +0,0 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public static class PluginIpcErrorCodes
{
public const string ProtocolMismatch = "protocol_mismatch";
public const string SessionRejected = "session_rejected";
public const string CapabilityDenied = "capability_denied";
public const string InvalidRequest = "invalid_request";
public const string UnsupportedRoute = "unsupported_route";
public const string SettingsConflict = "settings_conflict";
public const string UiAttachRejected = "ui_attach_rejected";
public const string WorkerFaulted = "worker_faulted";
public const string WorkerExited = "worker_exited";
public const string HeartbeatTimeout = "heartbeat_timeout";
}

View File

@@ -1,56 +0,0 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public static class PluginIpcRoutes
{
public static class Session
{
public const string Handshake = "session/handshake";
public const string Capabilities = "session/capabilities";
public const string Ready = "session/ready";
}
public static class Lifecycle
{
public const string Initialize = "lifecycle/initialize";
public const string Stop = "lifecycle/stop";
public const string RestartRequest = "lifecycle/restart-request";
public const string StateChanged = "lifecycle/state-changed";
}
public static class Settings
{
public const string GetSnapshot = "settings/get-snapshot";
public const string Write = "settings/write";
public const string Changed = "settings/changed";
}
public static class Appearance
{
public const string GetSnapshot = "appearance/get-snapshot";
public const string Changed = "appearance/changed";
}
public static class Ui
{
public const string Attach = "ui/attach";
public const string Detach = "ui/detach";
public const string Command = "ui/command";
public const string StateChanged = "ui/state-changed";
}
public static class Heartbeat
{
public const string Ping = "heartbeat/ping";
public const string Pong = "heartbeat/pong";
}
public static class Log
{
public const string Write = "log/write";
}
public static class Fault
{
public const string Report = "fault/report";
}
}

View File

@@ -1,39 +0,0 @@
using System.Text.Json.Serialization;
namespace LanMountainDesktop.PluginIsolation.Contracts;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(PluginCapabilityDeclaration))]
[JsonSerializable(typeof(List<PluginCapabilityDeclaration>))]
[JsonSerializable(typeof(PluginSessionHandshakeRequest))]
[JsonSerializable(typeof(PluginSessionHandshakeResponse))]
[JsonSerializable(typeof(PluginReadyNotification))]
[JsonSerializable(typeof(PluginInitializeRequest))]
[JsonSerializable(typeof(PluginInitializeResponse))]
[JsonSerializable(typeof(PluginStopRequest))]
[JsonSerializable(typeof(PluginRestartRequest))]
[JsonSerializable(typeof(PluginLifecycleStateChanged))]
[JsonSerializable(typeof(PluginSettingsSnapshotRequest))]
[JsonSerializable(typeof(PluginSettingsSnapshotResponse))]
[JsonSerializable(typeof(PluginSettingsWriteRequest))]
[JsonSerializable(typeof(PluginSettingsWriteResponse))]
[JsonSerializable(typeof(PluginSettingsChangedNotification))]
[JsonSerializable(typeof(PluginAppearanceSnapshotRequest))]
[JsonSerializable(typeof(PluginAppearanceSnapshot))]
[JsonSerializable(typeof(PluginAppearanceChangedNotification))]
[JsonSerializable(typeof(PluginUiSurfaceDescriptor))]
[JsonSerializable(typeof(List<PluginUiSurfaceDescriptor>))]
[JsonSerializable(typeof(PluginUiAttachRequest))]
[JsonSerializable(typeof(PluginUiAttachResponse))]
[JsonSerializable(typeof(PluginUiDetachNotification))]
[JsonSerializable(typeof(PluginUiCommandRequest))]
[JsonSerializable(typeof(PluginUiCommandResponse))]
[JsonSerializable(typeof(PluginUiStateChangedNotification))]
[JsonSerializable(typeof(PluginHeartbeatPing))]
[JsonSerializable(typeof(PluginHeartbeatPong))]
[JsonSerializable(typeof(PluginLogEntry))]
[JsonSerializable(typeof(PluginFaultReport))]
public partial class PluginIsolationJsonContext : JsonSerializerContext;

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public static class PluginIsolationProtocolVersion
{
public const string Current = "1.0";
}

View File

@@ -1,9 +0,0 @@
# LanMountainDesktop.PluginIsolation.Contracts
Transport-neutral DTOs, route constants, protocol versioning, and JSON serialization context for plugin process isolation.
## Includes
- route groups for session, lifecycle, settings, appearance, UI, heartbeat, log, and fault
- explicit DTOs for routed request and notification payloads
- source-generated `System.Text.Json` context for the IPC protocol

View File

@@ -1,21 +0,0 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginSessionHandshakeRequest(
string PluginId,
string SessionId,
string RuntimeMode,
string ProtocolVersion,
IReadOnlyList<PluginCapabilityDeclaration>? RequestedCapabilities = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public sealed record PluginSessionHandshakeResponse(
bool Accepted,
string ProtocolVersion,
IReadOnlyList<PluginCapabilityDeclaration>? GrantedCapabilities = null,
string? ErrorCode = null,
string? ErrorMessage = null);
public sealed record PluginReadyNotification(
string PluginId,
string SessionId,
IReadOnlyList<PluginUiSurfaceDescriptor>? UiSurfaces = null);

View File

@@ -1,33 +0,0 @@
using System.Text.Json;
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginSettingsSnapshotRequest(
string Scope,
string? SectionId = null,
string? ComponentInstanceId = null);
public sealed record PluginSettingsSnapshotResponse(
string Scope,
JsonElement Snapshot,
string? ETag = null);
public sealed record PluginSettingsWriteRequest(
string Scope,
JsonElement Value,
string? SectionId = null,
string? ComponentInstanceId = null,
string? ETag = null);
public sealed record PluginSettingsWriteResponse(
bool Accepted,
string? ETag = null,
string? ErrorCode = null,
string? ErrorMessage = null);
public sealed record PluginSettingsChangedNotification(
string Scope,
JsonElement Value,
string? SectionId = null,
string? ComponentInstanceId = null,
string? ETag = null);

View File

@@ -1,52 +0,0 @@
using System.Text.Json;
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginUiSurfaceDescriptor(
string SurfaceId,
string SurfaceKind,
string Title,
string? ComponentId = null);
public static class PluginUiSurfaceKinds
{
public const string DesktopComponent = "desktop-component";
public const string ComponentEditor = "component-editor";
public const string SettingsPage = "settings-page";
public const string Window = "window";
}
public sealed record PluginUiAttachRequest(
string SurfaceId,
string SurfaceKind,
string? InstanceId = null,
JsonElement? InitialState = null);
public sealed record PluginUiAttachResponse(
bool Accepted,
JsonElement? InitialState = null,
string? ErrorCode = null,
string? ErrorMessage = null);
public sealed record PluginUiDetachNotification(
string SurfaceId,
string SurfaceKind,
string? InstanceId = null);
public sealed record PluginUiCommandRequest(
string SurfaceId,
string CommandName,
string? InstanceId = null,
JsonElement? Payload = null);
public sealed record PluginUiCommandResponse(
bool Accepted,
JsonElement? Payload = null,
string? ErrorCode = null,
string? ErrorMessage = null);
public sealed record PluginUiStateChangedNotification(
string SurfaceId,
string SurfaceKind,
string? InstanceId = null,
JsonElement? State = null);

View File

@@ -1,27 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageId>LanMountainDesktop.PluginIsolation.Ipc</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
<Description>ClassIsland-style IPC facade for LanMountainDesktop plugin process isolation, backed by dotnetCampus.Ipc.</Description>
<PackageTags>LanMountainDesktop;Plugin;IPC;Isolation;dotnetCampus.Ipc</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View File

@@ -1,90 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public sealed class PluginIpcClient
{
public PluginIpcClient(PluginIpcClientOptions options)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
SerializerContext = options.SerializerContext ?? throw new ArgumentNullException(nameof(options.SerializerContext));
SerializerOptions = SerializerContext.Options;
}
public PluginIpcClientOptions Options { get; }
public JsonSerializerContext SerializerContext { get; }
public JsonSerializerOptions SerializerOptions { get; }
public PluginIpcRequestDispatcher? RequestDispatcher { get; set; }
public PluginIpcNotificationDispatcher? NotificationDispatcher { get; set; }
public Task<TResponse?> RequestAsync<TRequest, TResponse>(
string route,
TRequest payload,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
return RequestCoreAsync<TRequest, TResponse>(route, payload, cancellationToken);
}
public Task NotifyAsync<TPayload>(
string route,
TPayload payload,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
return NotifyCoreAsync(route, Serialize(payload), cancellationToken);
}
private async Task<TResponse?> RequestCoreAsync<TRequest, TResponse>(
string route,
TRequest payload,
CancellationToken cancellationToken)
{
if (RequestDispatcher is null)
{
throw new NotSupportedException(
"PluginIpcClient is not yet bound to a dotnetCampus.Ipc transport dispatcher. " +
"Wire RequestDispatcher during host/worker transport integration.");
}
var response = await RequestDispatcher(route, Serialize(payload), cancellationToken).ConfigureAwait(false);
if (response is null)
{
return default;
}
return Deserialize<TResponse>(response);
}
private async Task NotifyCoreAsync(string route, JsonElement? payload, CancellationToken cancellationToken)
{
if (NotificationDispatcher is null)
{
throw new NotSupportedException(
"PluginIpcClient is not yet bound to a dotnetCampus.Ipc transport dispatcher. " +
"Wire NotificationDispatcher during host/worker transport integration.");
}
await NotificationDispatcher(route, payload, cancellationToken).ConfigureAwait(false);
}
private JsonElement Serialize<T>(T payload)
{
return JsonSerializer.SerializeToElement(payload, SerializerOptions);
}
private T? Deserialize<T>(JsonElement? payload)
{
if (payload is null)
{
return default;
}
return payload.Value.Deserialize<T>(SerializerOptions);
}
}

View File

@@ -1,17 +0,0 @@
using System.Text.Json.Serialization;
using LanMountainDesktop.PluginIsolation.Contracts;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public sealed record PluginIpcClientOptions
{
public required string PipeName { get; init; }
public string ProtocolVersion { get; init; } = PluginIsolationProtocolVersion.Current;
public TimeSpan ConnectTimeout { get; init; } = PluginIpcConstants.DefaultConnectTimeout;
public TimeSpan RequestTimeout { get; init; } = PluginIpcConstants.DefaultRequestTimeout;
public JsonSerializerContext SerializerContext { get; init; } = PluginIsolationJsonContext.Default;
}

View File

@@ -1,25 +0,0 @@
using LanMountainDesktop.PluginIsolation.Contracts;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public static class PluginIpcConstants
{
public const string EnvironmentPluginId = "LANMOUNTAIN_PLUGIN_ID";
public const string EnvironmentSessionId = "LANMOUNTAIN_PLUGIN_SESSION_ID";
public const string EnvironmentHostPipeName = "LANMOUNTAIN_PLUGIN_HOST_PIPE";
public const string EnvironmentProtocolVersion = "LANMOUNTAIN_PLUGIN_PROTOCOL_VERSION";
public const string EnvironmentRuntimeMode = "LANMOUNTAIN_PLUGIN_RUNTIME_MODE";
public const string CommandLinePluginId = "--plugin-id";
public const string CommandLineSessionId = "--session-id";
public const string CommandLineHostPipeName = "--host-pipe-name";
public const string CommandLineProtocolVersion = "--protocol-version";
public const string CommandLineRuntimeMode = "--runtime-mode";
public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(10);
public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(30);
public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(5);
public static readonly TimeSpan DefaultHeartbeatTimeout = TimeSpan.FromSeconds(15);
public const string DefaultProtocolVersion = PluginIsolationProtocolVersion.Current;
}

View File

@@ -1,13 +0,0 @@
using System.Text.Json;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public delegate Task<JsonElement?> PluginIpcRequestDispatcher(
string route,
JsonElement? payload,
CancellationToken cancellationToken);
public delegate Task PluginIpcNotificationDispatcher(
string route,
JsonElement? payload,
CancellationToken cancellationToken);

View File

@@ -1,17 +0,0 @@
using LanMountainDesktop.PluginIsolation.Contracts;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public static class PluginIpcRoutedNotifyIds
{
public const string SessionReady = PluginIpcRoutes.Session.Ready;
public const string LifecycleStateChanged = PluginIpcRoutes.Lifecycle.StateChanged;
public const string SettingsChanged = PluginIpcRoutes.Settings.Changed;
public const string AppearanceChanged = PluginIpcRoutes.Appearance.Changed;
public const string UiDetach = PluginIpcRoutes.Ui.Detach;
public const string UiStateChanged = PluginIpcRoutes.Ui.StateChanged;
public const string HeartbeatPing = PluginIpcRoutes.Heartbeat.Ping;
public const string HeartbeatPong = PluginIpcRoutes.Heartbeat.Pong;
public const string LogWrite = PluginIpcRoutes.Log.Write;
public const string FaultReport = PluginIpcRoutes.Fault.Report;
}

View File

@@ -1,113 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public sealed class PluginIpcServer
{
private readonly Dictionary<string, Func<JsonElement?, CancellationToken, Task<JsonElement?>>> _requestHandlers =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Func<JsonElement?, CancellationToken, Task>> _notificationHandlers =
new(StringComparer.OrdinalIgnoreCase);
public PluginIpcServer(PluginIpcServerOptions options)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
SerializerContext = options.SerializerContext ?? throw new ArgumentNullException(nameof(options.SerializerContext));
SerializerOptions = SerializerContext.Options;
}
public PluginIpcServerOptions Options { get; }
public JsonSerializerContext SerializerContext { get; }
public JsonSerializerOptions SerializerOptions { get; }
public void MapRequest<TRequest, TResponse>(
string route,
Func<TRequest, CancellationToken, Task<TResponse>> handler)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
ArgumentNullException.ThrowIfNull(handler);
_requestHandlers[route] = async (payload, cancellationToken) =>
{
var request = Deserialize<TRequest>(payload);
var response = await handler(request, cancellationToken).ConfigureAwait(false);
return Serialize(response);
};
}
public void MapNotification<TPayload>(
string route,
Func<TPayload, CancellationToken, Task> handler)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
ArgumentNullException.ThrowIfNull(handler);
_notificationHandlers[route] = (payload, cancellationToken) =>
{
var notification = Deserialize<TPayload>(payload);
return handler(notification, cancellationToken);
};
}
public async Task<JsonElement?> HandleRequestAsync(
string route,
JsonElement? payload,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
if (!_requestHandlers.TryGetValue(route, out var handler))
{
throw new InvalidOperationException($"No IPC request handler is registered for route '{route}'.");
}
return await handler(payload, cancellationToken).ConfigureAwait(false);
}
public Task HandleNotificationAsync(
string route,
JsonElement? payload,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
if (!_notificationHandlers.TryGetValue(route, out var handler))
{
throw new InvalidOperationException($"No IPC notification handler is registered for route '{route}'.");
}
return handler(payload, cancellationToken);
}
private JsonElement Serialize<T>(T payload)
{
return JsonSerializer.SerializeToElement(payload, SerializerOptions);
}
private T Deserialize<T>(JsonElement? payload)
{
if (payload is null)
{
if (default(T) is null)
{
return default!;
}
throw new InvalidOperationException(
$"IPC payload is required for '{typeof(T).FullName}', but the caller provided no payload.");
}
var value = payload.Value.Deserialize<T>(SerializerOptions);
if (value is null && default(T) is not null)
{
throw new InvalidOperationException(
$"Failed to deserialize IPC payload to '{typeof(T).FullName}'.");
}
return value!;
}
}

View File

@@ -1,17 +0,0 @@
using System.Text.Json.Serialization;
using LanMountainDesktop.PluginIsolation.Contracts;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public sealed record PluginIpcServerOptions
{
public required string PipeName { get; init; }
public string ProtocolVersion { get; init; } = PluginIsolationProtocolVersion.Current;
public TimeSpan HeartbeatInterval { get; init; } = PluginIpcConstants.DefaultHeartbeatInterval;
public TimeSpan HeartbeatTimeout { get; init; } = PluginIpcConstants.DefaultHeartbeatTimeout;
public JsonSerializerContext SerializerContext { get; init; } = PluginIsolationJsonContext.Default;
}

View File

@@ -1,10 +0,0 @@
# LanMountainDesktop.PluginIsolation.Ipc
ClassIsland-inspired IPC facade for LanMountainDesktop plugin isolation.
## Includes
- host and worker startup constants
- centralized routed notification IDs
- transport-neutral routed client and server wrappers
- explicit dependency on `dotnetCampus.Ipc` for the eventual pipe transport binding

View File

@@ -1,15 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginPublicIpcBuilder
{
IPluginPublicIpcBuilder AddService<TContract>(
string? objectId = null,
IEnumerable<string>? notifyIds = null)
where TContract : class;
IPluginPublicIpcBuilder AddService(
Type contractType,
object implementation,
string? objectId = null,
IEnumerable<string>? notifyIds = null);
}

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginPublicIpcContributor
{
void ConfigurePublicIpc(IPluginPublicIpcBuilder builder);
}

View File

@@ -1,12 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.PluginSdk;
public interface IPluginWorker
{
void ConfigureServices(IPluginWorkerContext context, IServiceCollection services);
Task StartAsync(IPluginWorkerContext context, IServiceProvider services, CancellationToken cancellationToken = default);
Task StopAsync(CancellationToken cancellationToken = default);
}

View File

@@ -1,26 +0,0 @@
using LanMountainDesktop.PluginIsolation.Contracts;
namespace LanMountainDesktop.PluginSdk;
public interface IPluginWorkerContext
{
string PluginId { get; }
PluginManifest Manifest { get; }
PluginRuntimeMode RuntimeMode { get; }
string SessionId { get; }
string HostPipeName { get; }
string ProtocolVersion { get; }
string PluginDirectory { get; }
string DataDirectory { get; }
IReadOnlyList<PluginCapabilityDeclaration> GrantedCapabilities { get; }
IReadOnlyDictionary<string, string> StartupProperties { get; }
}

View File

@@ -25,10 +25,7 @@
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" /> <PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" /> <ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -10,8 +10,7 @@ public sealed record PluginManifest(
string? Author = null, string? Author = null,
string? Version = null, string? Version = null,
string? ApiVersion = null, string? ApiVersion = null,
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null, IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
PluginRuntimeConfiguration? Runtime = null)
{ {
private static readonly JsonSerializerOptions SerializerOptions = new() private static readonly JsonSerializerOptions SerializerOptions = new()
{ {
@@ -57,13 +56,9 @@ public sealed record PluginManifest(
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly)); return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
} }
public PluginRuntimeMode RuntimeMode =>
PluginRuntimeModes.TryParse(Runtime?.Mode, out var mode) ? mode : PluginRuntimeMode.InProcess;
private PluginManifest NormalizeAndValidate(string manifestPath) private PluginManifest NormalizeAndValidate(string manifestPath)
{ {
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts); var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
var normalizedRuntime = (Runtime ?? new PluginRuntimeConfiguration()).NormalizeAndValidate(manifestPath);
var normalized = this with var normalized = this with
{ {
Id = RequireValue(Id, nameof(Id), manifestPath), Id = RequireValue(Id, nameof(Id), manifestPath),
@@ -73,8 +68,7 @@ public sealed record PluginManifest(
Author = NormalizeOptionalValue(Author), Author = NormalizeOptionalValue(Author),
Version = NormalizeOptionalValue(Version), Version = NormalizeOptionalValue(Version),
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion, ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
SharedContracts = normalizedSharedContracts, SharedContracts = normalizedSharedContracts
Runtime = normalizedRuntime
}; };
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion)) if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))

View File

@@ -1,7 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginPublicIpcServiceDescriptor(
Type ContractType,
object Implementation,
string? ObjectId,
string[] NotifyIds);

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginPublicIpcServiceRegistration(
Type ContractType,
string? ObjectId,
string[] NotifyIds);

View File

@@ -1,15 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginRuntimeConfiguration(string Mode = PluginRuntimeModes.InProcess)
{
public PluginRuntimeMode RuntimeMode =>
PluginRuntimeModes.TryParse(Mode, out var mode) ? mode : PluginRuntimeMode.InProcess;
internal PluginRuntimeConfiguration NormalizeAndValidate(string manifestPath)
{
return this with
{
Mode = PluginRuntimeModes.NormalizeManifestValue(Mode, manifestPath)
};
}
}

View File

@@ -1,8 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public enum PluginRuntimeMode
{
InProcess = 0,
IsolatedBackground = 1,
IsolatedWindow = 2
}

View File

@@ -1,58 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public static class PluginRuntimeModes
{
public const string InProcess = "in-proc";
public const string IsolatedBackground = "isolated-background";
public const string IsolatedWindow = "isolated-window";
public static bool TryParse(string? value, out PluginRuntimeMode mode)
{
switch (value?.Trim().ToLowerInvariant())
{
case null:
case "":
case InProcess:
mode = PluginRuntimeMode.InProcess;
return true;
case IsolatedBackground:
mode = PluginRuntimeMode.IsolatedBackground;
return true;
case IsolatedWindow:
mode = PluginRuntimeMode.IsolatedWindow;
return true;
default:
mode = default;
return false;
}
}
public static PluginRuntimeMode Parse(string? value, string sourceName, string propertyName = "runtime.mode")
{
if (TryParse(value, out var mode))
{
return mode;
}
var candidate = string.IsNullOrWhiteSpace(value) ? "<empty>" : value.Trim();
throw new InvalidOperationException(
$"Plugin manifest '{sourceName}' declares unsupported runtime mode '{candidate}' in '{propertyName}'. " +
$"Supported values: '{InProcess}', '{IsolatedBackground}', '{IsolatedWindow}'.");
}
public static string NormalizeManifestValue(string? value, string sourceName, string propertyName = "runtime.mode")
{
return ToManifestValue(Parse(value, sourceName, propertyName));
}
public static string ToManifestValue(PluginRuntimeMode mode)
{
return mode switch
{
PluginRuntimeMode.InProcess => InProcess,
PluginRuntimeMode.IsolatedBackground => IsolatedBackground,
PluginRuntimeMode.IsolatedWindow => IsolatedWindow,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported plugin runtime mode.")
};
}
}

View File

@@ -1,5 +1,4 @@
using Avalonia.Controls; using Avalonia.Controls;
using dotnetCampus.Ipc.CompilerServices.Attributes;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.PluginSdk;
@@ -113,55 +112,6 @@ public static class PluginServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddPluginPublicIpc<TContract, TImplementation>(
this IServiceCollection services,
string? objectId = null,
params string[] notifyIds)
where TContract : class
where TImplementation : class, TContract
{
ArgumentNullException.ThrowIfNull(services);
EnsurePublicIpcContract(typeof(TContract));
EnsureSingletonRegistration<TContract, TImplementation>(services);
if (!services.Any(descriptor =>
descriptor.ServiceType == typeof(PluginPublicIpcServiceRegistration) &&
descriptor.ImplementationInstance is PluginPublicIpcServiceRegistration existing &&
existing.ContractType == typeof(TContract) &&
string.Equals(existing.ObjectId, objectId, StringComparison.Ordinal)))
{
services.AddSingleton(new PluginPublicIpcServiceRegistration(
typeof(TContract),
objectId,
notifyIds ?? []));
}
return services;
}
public static IServiceCollection AddPluginPublicIpcContributor<TContributor>(this IServiceCollection services)
where TContributor : class, IPluginPublicIpcContributor
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IPluginPublicIpcContributor, TContributor>();
return services;
}
private static void EnsurePublicIpcContract(Type contractType)
{
if (!contractType.IsInterface)
{
throw new InvalidOperationException(
$"Public IPC contract '{contractType.FullName}' must be an interface.");
}
if (!Attribute.IsDefined(contractType, typeof(IpcPublicAttribute), inherit: false))
{
throw new InvalidOperationException(
$"Public IPC contract '{contractType.FullName}' must be marked with '{nameof(IpcPublicAttribute)}'.");
}
}
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services) private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
where TContract : class where TContract : class
where TImplementation : class, TContract where TImplementation : class, TContract

View File

@@ -1,20 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.PluginSdk;
public abstract class PluginWorkerBase : IPluginWorker
{
public virtual void ConfigureServices(IPluginWorkerContext context, IServiceCollection services)
{
}
public virtual Task StartAsync(IPluginWorkerContext context, IServiceProvider services, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public virtual Task StopAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class PluginWorkerEntranceAttribute : Attribute
{
}

View File

@@ -5,9 +5,7 @@ Official SDK package for LanMountainDesktop plugins.
## Includes ## Includes
- `IPlugin`/`PluginBase` entry abstractions - `IPlugin`/`PluginBase` entry abstractions
- `IPluginWorker`/`PluginWorkerBase` worker-side entry abstractions for isolated background mode
- `PluginManifest` and shared contract declarations - `PluginManifest` and shared contract declarations
- `runtime.mode` manifest support for `in-proc`, `isolated-background`, and `isolated-window`
- desktop component registration extensions - desktop component registration extensions
- plugin runtime context and host service abstractions - plugin runtime context and host service abstractions
- build-transitive packaging targets for `.laapp` output - build-transitive packaging targets for `.laapp` output

View File

@@ -22,4 +22,3 @@ Update `plugin.json` fields as needed before release:
- `description` - `description`
- `author` - `author`
- `version` - `version`
- `runtime.mode` (`in-proc` by default, `isolated-background` for phase-1 worker mode)

View File

@@ -6,8 +6,5 @@
"version": "1.0.0", "version": "1.0.0",
"apiVersion": "4.0.2", "apiVersion": "4.0.2",
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll", "entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
"sharedContracts": [], "sharedContracts": []
"runtime": {
"mode": "in-proc"
}
} }

View File

@@ -1,18 +0,0 @@
namespace LanMountainDesktop.Shared.Contracts.Launcher;
/// <summary>
/// Standardized host process exit codes consumed by the launcher.
/// </summary>
public static class HostExitCodes
{
public const int Success = 0;
// Secondary instance activated the existing primary instance successfully.
public const int SecondaryActivationSucceeded = 12;
// Secondary instance failed to activate the existing primary instance.
public const int SecondaryActivationFailed = 13;
// Restart relaunch couldn't acquire the single-instance lock in time.
public const int RestartLockNotAcquired = 14;
}

View File

@@ -1,38 +1,89 @@
namespace LanMountainDesktop.Shared.Contracts.Launcher; namespace LanMountainDesktop.Shared.Contracts.Launcher;
/// <summary>
/// 启动阶段枚举
/// </summary>
public enum StartupStage public enum StartupStage
{ {
/// <summary>
/// 初始化中
/// </summary>
Initializing, Initializing,
/// <summary>
/// 加载设置中
/// </summary>
LoadingSettings, LoadingSettings,
/// <summary>
/// 加载插件中
/// </summary>
LoadingPlugins, LoadingPlugins,
/// <summary>
/// 初始化界面中
/// </summary>
InitializingUI, InitializingUI,
ShellInitialized,
DesktopVisible, /// <summary>
ActivationRedirected, /// 就绪
ActivationFailed, /// </summary>
Ready Ready
} }
/// <summary>
/// 启动进度消息
/// </summary>
public record StartupProgressMessage public record StartupProgressMessage
{ {
/// <summary>
/// 当前阶段
/// </summary>
public StartupStage Stage { get; init; } public StartupStage Stage { get; init; }
/// <summary>
/// 进度百分比 (0-100)
/// </summary>
public int ProgressPercent { get; init; } public int ProgressPercent { get; init; }
/// <summary>
/// 状态消息
/// </summary>
public string? Message { get; init; } public string? Message { get; init; }
/// <summary>
/// 时间戳
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
} }
/// <summary>
/// Launcher IPC 常量
/// </summary>
public static class LauncherIpcConstants public static class LauncherIpcConstants
{ {
/// <summary>
/// 命名管道名称
/// </summary>
public const string PipeName = "LanMountainDesktop_Launcher"; public const string PipeName = "LanMountainDesktop_Launcher";
/// <summary>
/// Launcher 进程 ID 环境变量
/// </summary>
public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID"; public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID";
/// <summary>
/// 包根目录环境变量
/// </summary>
public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT"; public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT";
/// <summary>
/// 版本环境变量
/// </summary>
public const string VersionEnvVar = "LMD_VERSION"; public const string VersionEnvVar = "LMD_VERSION";
/// <summary>
/// 开发代号环境变量
/// </summary>
public const string CodenameEnvVar = "LMD_CODENAME"; public const string CodenameEnvVar = "LMD_CODENAME";
} }

View File

@@ -1,9 +0,0 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
[IpcPublic(IgnoresIpcException = true)]
public interface IPublicAppInfoService
{
PublicAppInfoSnapshot GetAppInfo();
}

View File

@@ -1,9 +0,0 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
[IpcPublic(IgnoresIpcException = true)]
public interface IPublicPluginCatalogService
{
PublicIpcCatalogSnapshot GetCatalog();
}

View File

@@ -1,15 +0,0 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
[IpcPublic(IgnoresIpcException = true)]
public interface IPublicShellControlService
{
Task<bool> ActivateMainWindowAsync();
Task<bool> OpenSettingsAsync(string? pageTag = null);
Task<bool> RestartAsync();
Task<bool> ExitAsync();
}

View File

@@ -1,8 +0,0 @@
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
public sealed record PublicIpcServiceRegistration(
Type ContractType,
Func<IServiceProvider, object> ImplementationFactory,
string? ObjectId,
string? PluginId,
string[] NotifyIds);

View File

@@ -1,83 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddLanMountainDesktopIpcHost(
this IServiceCollection services,
string? pipeName = null)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton(provider =>
{
var host = new PublicIpcHostService(pipeName ?? IpcConstants.DefaultPipeName);
foreach (var registration in provider.GetServices<PublicIpcServiceRegistration>())
{
var implementation = registration.ImplementationFactory(provider);
host.RegisterPublicService(
registration.ContractType,
implementation,
registration.ObjectId,
registration.PluginId,
registration.NotifyIds);
}
host.Start();
return host;
});
services.AddSingleton<IExternalIpcNotificationPublisher>(provider =>
provider.GetRequiredService<PublicIpcHostService>());
return services;
}
public static IServiceCollection AddPublicIpcService<TContract, TImplementation>(
this IServiceCollection services,
string? objectId = null,
string? pluginId = null,
params string[] notifyIds)
where TContract : class
where TImplementation : class, TContract
{
ArgumentNullException.ThrowIfNull(services);
EnsureSingletonRegistration<TContract, TImplementation>(services);
if (!services.Any(descriptor =>
descriptor.ServiceType == typeof(PublicIpcServiceRegistration) &&
descriptor.ImplementationInstance is PublicIpcServiceRegistration existing &&
existing.ContractType == typeof(TContract) &&
string.Equals(existing.ObjectId, objectId, StringComparison.Ordinal)))
{
services.AddSingleton(new PublicIpcServiceRegistration(
typeof(TContract),
provider => provider.GetRequiredService<TContract>(),
objectId,
pluginId,
notifyIds ?? []));
}
return services;
}
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
where TContract : class
where TImplementation : class, TContract
{
var descriptor = services.LastOrDefault(item => item.ServiceType == typeof(TContract));
if (descriptor is null)
{
services.AddSingleton<TContract, TImplementation>();
return;
}
if (descriptor.Lifetime != ServiceLifetime.Singleton)
{
throw new InvalidOperationException(
$"Public IPC contract '{typeof(TContract).FullName}' must be registered as Singleton.");
}
}
}

View File

@@ -1,7 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public interface IExternalIpcNotificationPublisher
{
Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
where TPayload : class;
}

View File

@@ -1,14 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public static class IpcConstants
{
public const string DefaultPipeName = "LanMountainDesktop.IPC.v1.Server";
public const string ProtocolVersion = "external-ipc-public-api.v1";
public static class Routes
{
public const string SessionGetInfo = "lanmountain.session.get-info";
public const string CatalogGet = "lanmountain.catalog.get";
}
}

View File

@@ -1,8 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public static class IpcRoutedNotifyIds
{
public const string CatalogChanged = "lanmountain.catalog.changed";
public const string LauncherStartupProgress = "lanmountain.launcher.startup-progress";
public const string LauncherLoadingState = "lanmountain.launcher.loading-state";
}

View File

@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageId>LanMountainDesktop.Shared.IPC</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
<Description>Public IPC abstractions and host/client infrastructure for LanMountainDesktop, backed by dotnetCampus.Ipc.</Description>
<PackageTags>LanMountainDesktop;IPC;dotnetCampus.Ipc;Integration</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View File

@@ -1,96 +0,0 @@
using dotnetCampus.Ipc.CompilerServices.GeneratedProxies;
using dotnetCampus.Ipc.IpcRouteds.DirectRouteds;
using dotnetCampus.Ipc.Pipes;
namespace LanMountainDesktop.Shared.IPC;
public sealed class LanMountainDesktopIpcClient : IDisposable
{
private bool _started;
public LanMountainDesktopIpcClient(string? clientPipeName = null)
{
Provider = string.IsNullOrWhiteSpace(clientPipeName)
? new IpcProvider()
: new IpcProvider(clientPipeName);
RoutedProvider = new JsonIpcDirectRoutedProvider(Provider);
}
public IpcProvider Provider { get; }
public JsonIpcDirectRoutedProvider RoutedProvider { get; }
public PeerProxy? Peer { get; private set; }
public bool IsConnected => Peer is not null && Peer.IsConnectedFinished;
public async Task ConnectAsync(string pipeName = IpcConstants.DefaultPipeName)
{
EnsureStarted();
Peer = await Provider.GetAndConnectToPeerAsync(pipeName).ConfigureAwait(false);
}
public void RegisterNotifyHandler<TPayload>(string notifyId, Action<TPayload> handler)
where TPayload : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
ArgumentNullException.ThrowIfNull(handler);
RoutedProvider.AddNotifyHandler(notifyId, handler);
}
public void RegisterNotifyHandler<TPayload>(string notifyId, Func<TPayload, Task> handler)
where TPayload : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
ArgumentNullException.ThrowIfNull(handler);
RoutedProvider.AddNotifyHandler(notifyId, handler);
}
public TContract CreateProxy<TContract>(string? objectId = null)
where TContract : class
{
var peer = Peer ?? throw new InvalidOperationException("IPC client is not connected.");
return Provider.CreateIpcProxy<TContract>(peer, objectId);
}
public async Task<PublicIpcCatalogSnapshot?> GetCatalogAsync()
{
var client = await GetRoutedClientAsync().ConfigureAwait(false);
return await client.GetResponseAsync<PublicIpcCatalogSnapshot>(IpcConstants.Routes.CatalogGet)
.ConfigureAwait(false);
}
public async Task<PublicIpcSessionInfo?> GetSessionInfoAsync()
{
var client = await GetRoutedClientAsync().ConfigureAwait(false);
return await client.GetResponseAsync<PublicIpcSessionInfo>(IpcConstants.Routes.SessionGetInfo)
.ConfigureAwait(false);
}
private async Task<JsonIpcDirectRoutedClientProxy> GetRoutedClientAsync()
{
if (Peer is null)
{
throw new InvalidOperationException("IPC client is not connected.");
}
await Task.CompletedTask;
return new JsonIpcDirectRoutedClientProxy(Peer);
}
private void EnsureStarted()
{
if (_started)
{
return;
}
RoutedProvider.StartServer();
_started = true;
}
public void Dispose()
{
Provider.Dispose();
}
}

View File

@@ -1,9 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicAppInfoSnapshot(
string ApplicationName,
string Version,
string Codename,
string PipeName,
int ProcessId,
DateTimeOffset StartedAt);

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicIpcCatalogSnapshot(
PublicIpcServiceDescriptor[] Services,
PublicPluginDescriptor[] Plugins,
DateTimeOffset Timestamp);

View File

@@ -1,219 +0,0 @@
using System.Reflection;
using System.Collections.Concurrent;
using dotnetCampus.Ipc.Context;
using dotnetCampus.Ipc.CompilerServices.GeneratedProxies;
using dotnetCampus.Ipc.IpcRouteds.DirectRouteds;
using dotnetCampus.Ipc.Pipes;
namespace LanMountainDesktop.Shared.IPC;
public sealed class PublicIpcHostService : IDisposable, IExternalIpcNotificationPublisher
{
private static readonly MethodInfo CreateIpcJointMethod = typeof(GeneratedIpcFactory)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Single(method =>
method.Name == nameof(GeneratedIpcFactory.CreateIpcJoint) &&
method.IsGenericMethodDefinition &&
method.GetParameters().Length == 3);
private readonly Dictionary<(Type ContractType, string ObjectId), PublicServiceEntry> _services = new();
private readonly ConcurrentDictionary<string, PeerProxy> _connectedPeers = new(StringComparer.OrdinalIgnoreCase);
private readonly object _gate = new();
private bool _started;
public PublicIpcHostService(string pipeName = IpcConstants.DefaultPipeName)
{
PipeName = pipeName;
StartedAt = DateTimeOffset.UtcNow;
Provider = new IpcProvider(pipeName);
RoutedProvider = new JsonIpcDirectRoutedProvider(Provider);
}
public string PipeName { get; }
public DateTimeOffset StartedAt { get; }
public IpcProvider Provider { get; }
public JsonIpcDirectRoutedProvider RoutedProvider { get; }
public Func<IReadOnlyList<PublicPluginDescriptor>> PluginDescriptorProvider { get; set; } =
static () => Array.Empty<PublicPluginDescriptor>();
public void Start()
{
if (_started)
{
return;
}
RoutedProvider.AddRequestHandler(IpcConstants.Routes.SessionGetInfo, () => BuildSessionInfo());
RoutedProvider.AddRequestHandler(IpcConstants.Routes.CatalogGet, () => GetCatalogSnapshot());
Provider.PeerConnected += OnPeerConnected;
RoutedProvider.StartServer();
_started = true;
}
public void RegisterPublicService<TContract>(
TContract implementation,
string? objectId = null,
string? pluginId = null,
params string[] notifyIds)
where TContract : class
{
RegisterPublicService(typeof(TContract), implementation, objectId, pluginId, notifyIds);
}
public void RegisterPublicService(
Type contractType,
object implementation,
string? objectId = null,
string? pluginId = null,
IEnumerable<string>? notifyIds = null)
{
ArgumentNullException.ThrowIfNull(contractType);
ArgumentNullException.ThrowIfNull(implementation);
var normalizedObjectId = objectId ?? string.Empty;
var normalizedNotifyIds = notifyIds?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
lock (_gate)
{
if (_services.ContainsKey((contractType, normalizedObjectId)))
{
throw new InvalidOperationException(
$"Public IPC contract '{contractType.FullName}' with object id '{normalizedObjectId}' is already registered.");
}
CreateIpcJointMethod
.MakeGenericMethod(contractType)
.Invoke(null, [Provider, implementation, string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId]);
_services[(contractType, normalizedObjectId)] = new PublicServiceEntry(
contractType,
implementation,
string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId,
pluginId,
normalizedNotifyIds);
}
if (_started)
{
_ = NotifyCatalogChangedAsync();
}
}
public PublicIpcCatalogSnapshot GetCatalogSnapshot()
{
PublicIpcServiceDescriptor[] services;
lock (_gate)
{
services = _services.Values
.Select(entry => new PublicIpcServiceDescriptor(
entry.ContractType.FullName ?? entry.ContractType.Name,
entry.ContractType.Assembly.GetName().Name ?? string.Empty,
entry.ContractType.AssemblyQualifiedName,
entry.ObjectId,
entry.PluginId,
string.IsNullOrWhiteSpace(entry.PluginId),
entry.NotifyIds))
.OrderBy(entry => entry.PluginId ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.ContractTypeName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
var plugins = PluginDescriptorProvider()?.ToArray() ?? Array.Empty<PublicPluginDescriptor>();
return new PublicIpcCatalogSnapshot(services, plugins, DateTimeOffset.UtcNow);
}
public Task PublishStartupProgressAsync(
LanMountainDesktop.Shared.Contracts.Launcher.StartupProgressMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
return NotifyAsync(IpcRoutedNotifyIds.LauncherStartupProgress, message, cancellationToken);
}
public Task PublishLoadingStateAsync(
LanMountainDesktop.Shared.Contracts.Launcher.LoadingStateMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
return NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, message, cancellationToken);
}
public async Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
where TPayload : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
ArgumentNullException.ThrowIfNull(payload);
cancellationToken.ThrowIfCancellationRequested();
foreach (var peer in _connectedPeers.Values)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var client = new JsonIpcDirectRoutedClientProxy(peer);
await client.NotifyAsync(notifyId, payload).ConfigureAwait(false);
}
catch
{
// Keep notification fan-out best-effort. Broken peers are cleaned by dotnetCampus.Ipc.
}
}
}
private Task NotifyCatalogChangedAsync()
{
return NotifyAsync(IpcRoutedNotifyIds.CatalogChanged, GetCatalogSnapshot());
}
private PublicIpcSessionInfo BuildSessionInfo()
{
return new PublicIpcSessionInfo(
PipeName,
IpcConstants.ProtocolVersion,
[
IpcConstants.Routes.SessionGetInfo,
IpcConstants.Routes.CatalogGet,
IpcRoutedNotifyIds.CatalogChanged,
IpcRoutedNotifyIds.LauncherStartupProgress,
IpcRoutedNotifyIds.LauncherLoadingState
],
StartedAt);
}
public void Dispose()
{
Provider.PeerConnected -= OnPeerConnected;
Provider.Dispose();
}
private void OnPeerConnected(object? sender, PeerConnectedArgs e)
{
var peer = e.Peer;
_connectedPeers[peer.PeerName] = peer;
peer.PeerConnectionBroken -= OnPeerConnectionBroken;
peer.PeerConnectionBroken += OnPeerConnectionBroken;
}
private void OnPeerConnectionBroken(object? sender, IPeerConnectionBrokenArgs e)
{
if (sender is PeerProxy peer)
{
_connectedPeers.TryRemove(peer.PeerName, out _);
}
}
private sealed record PublicServiceEntry(
Type ContractType,
object Implementation,
string? ObjectId,
string? PluginId,
string[] NotifyIds);
}

View File

@@ -1,10 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicIpcServiceDescriptor(
string ContractTypeName,
string ContractAssemblyName,
string? ContractAssemblyQualifiedName,
string? ObjectId,
string? PluginId,
bool IsBuiltIn,
string[] NotifyIds);

View File

@@ -1,7 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicIpcSessionInfo(
string PipeName,
string ProtocolVersion,
string[] Capabilities,
DateTimeOffset StartedAt);

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