mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-08-21 06:41:20 +08:00
Compare commits
6 Commits
02547eeea6
...
v0.8.5.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7c118d13 | ||
|
|
f51ec309a6 | ||
|
|
9224c9a33a | ||
|
|
703ed7b48a | ||
|
|
5af7ac8b56 | ||
|
|
4cb52e56c7 |
166
.github/workflows/ddss-publish.yml
vendored
Normal file
166
.github/workflows/ddss-publish.yml
vendored
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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
|
||||||
235
.github/workflows/plonds-build.yml
vendored
Normal file
235
.github/workflows/plonds-build.yml
vendored
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
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
|
||||||
546
.github/workflows/release.yml
vendored
546
.github/workflows/release.yml
vendored
@@ -20,7 +20,6 @@ env:
|
|||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
Solution_Name: LanMountainDesktop.slnx
|
Solution_Name: LanMountainDesktop.slnx
|
||||||
DOTNET_gcServer: 1
|
DOTNET_gcServer: 1
|
||||||
ENABLE_LEGACY_DELTA_FALLBACK: 'false'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
prepare:
|
||||||
@@ -31,14 +30,22 @@ 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
|
||||||
@@ -48,19 +55,40 @@ jobs:
|
|||||||
else
|
else
|
||||||
TAG="v${RAW_TAG}"
|
TAG="v${RAW_TAG}"
|
||||||
fi
|
fi
|
||||||
CHECKOUT_REF="${GITHUB_SHA}"
|
|
||||||
|
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||||
|
CHECKOUT_REF="refs/tags/${TAG}"
|
||||||
|
else
|
||||||
|
CHECKOUT_REF="${GITHUB_SHA}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${{ github.event.inputs.is_prerelease }}" == "true" ]]; then
|
||||||
|
IS_PRERELEASE="true"
|
||||||
|
else
|
||||||
|
IS_PRERELEASE="false"
|
||||||
|
fi
|
||||||
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
|
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
if [[ "${IS_PRERELEASE}" == "true" ]]; then
|
||||||
echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT
|
RELEASE_CHANNEL="preview"
|
||||||
echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT
|
else
|
||||||
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
|
RELEASE_CHANNEL="stable"
|
||||||
|
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
|
||||||
@@ -108,9 +136,6 @@ 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 publish
|
|
||||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||||
-c Release `
|
-c Release `
|
||||||
-o ./$launcherPublishDir `
|
-o ./$launcherPublishDir `
|
||||||
@@ -121,27 +146,16 @@ 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)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Warn if unexpected extra files are produced
|
|
||||||
$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
|
||||||
@@ -179,9 +193,6 @@ 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
|
||||||
@@ -192,30 +203,18 @@ 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"
|
||||||
|
|
||||||
Write-Host "Restructuring for Launcher mode..."
|
|
||||||
Write-Host "Version: $version"
|
|
||||||
Write-Host "Publish dir: $publishDir"
|
|
||||||
|
|
||||||
$newStructure = "publish-launcher/windows-$arch"
|
$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
|
||||||
|
|
||||||
$launcherSource = $launcherPublishDir
|
if (Test-Path $launcherPublishDir) {
|
||||||
if (Test-Path $launcherSource) {
|
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||||
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
|
||||||
@@ -231,60 +230,31 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
|
||||||
$suffix = "${{ matrix.suffix }}"
|
$suffix = "${{ matrix.suffix }}"
|
||||||
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" }
|
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||||
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
|
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||||
$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
|
||||||
|
|
||||||
if (-not (Test-Path -Path $installerScript)) {
|
|
||||||
Write-Error "Installer script not found: $installerScript"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$isccPath = $null
|
|
||||||
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
|
|
||||||
if ($isccCommand) {
|
|
||||||
$isccPath = $isccCommand.Source
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidatePaths = @(
|
$candidatePaths = @(
|
||||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
(Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
|
||||||
"C:\Program Files\Inno Setup 6\ISCC.exe",
|
"$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe",
|
||||||
"$env:ChocolateyInstall\bin\ISCC.exe",
|
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||||
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
|
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
|
||||||
)
|
) | Where-Object { $_ -and (Test-Path $_) }
|
||||||
|
|
||||||
|
$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
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Found Inno Setup at: $isccPath"
|
if (-not (Test-Path $installerScript)) {
|
||||||
|
Write-Error "Installer script not found: $(Join-Path $PWD $installerScript)"
|
||||||
Write-Host "Building installer for Windows $arch with version $version..."
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
$publishDir = (Resolve-Path $publishDir).Path
|
$publishDir = (Resolve-Path $publishDir).Path
|
||||||
$outputDir = (Resolve-Path $outputDir).Path
|
$outputDir = (Resolve-Path $outputDir).Path
|
||||||
@@ -300,8 +270,6 @@ 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"
|
||||||
@@ -313,123 +281,53 @@ 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: Install vpk
|
- name: Package Payload Zip
|
||||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = "Stop"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
dotnet tool uninstall --global vpk | Out-Null
|
$arch = "${{ matrix.arch }}"
|
||||||
if ($LASTEXITCODE -ne 0) {
|
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
||||||
Write-Host "vpk is not preinstalled, proceeding with fresh install."
|
if (-not (Test-Path $payloadRoot)) {
|
||||||
|
Write-Error "Payload root not found: $payloadRoot"
|
||||||
|
exit 1
|
||||||
}
|
}
|
||||||
dotnet tool install --global vpk --allow-roll-forward
|
|
||||||
"$env:USERPROFILE\\.dotnet\\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
|
||||||
$env:PATH = "$env:USERPROFILE\\.dotnet\\tools;$env:PATH"
|
|
||||||
vpk -h
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Prepare Previous Velopack Full Package
|
$stageDir = Join-Path $PWD "payload-stage/windows-$arch"
|
||||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
$releaseDir = Join-Path $PWD "release-assets"
|
||||||
run: |
|
Remove-Item -Path $stageDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
$outputDir = "velopack-output"
|
New-Item -ItemType Directory -Path $stageDir -Force | Out-Null
|
||||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null
|
||||||
|
|
||||||
try {
|
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
||||||
$headers = @{ "User-Agent" = "LanMountainDesktop-CI"; "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" }
|
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
||||||
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" -Headers $headers
|
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||||
$previousRelease = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1
|
return
|
||||||
if ($previousRelease) {
|
|
||||||
$previousFull = $previousRelease.assets |
|
|
||||||
Where-Object { $_.name -like "*-full.nupkg" } |
|
|
||||||
Select-Object -First 1
|
|
||||||
if ($previousFull) {
|
|
||||||
$dest = Join-Path $outputDir $previousFull.name
|
|
||||||
Invoke-WebRequest -Uri $previousFull.browser_download_url -OutFile $dest -Headers $headers
|
|
||||||
Write-Host "Downloaded previous package for Velopack delta generation."
|
|
||||||
} else {
|
|
||||||
Write-Host "No previous full package found. Velopack will generate full package only."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
Write-Host "Could not fetch previous release package: $_"
|
|
||||||
}
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Build Velopack Packages
|
$destination = Join-Path $stageDir ($relative -replace '/', [System.IO.Path]::DirectorySeparatorChar)
|
||||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
$destinationDir = Split-Path -Path $destination -Parent
|
||||||
run: |
|
if (-not [string]::IsNullOrWhiteSpace($destinationDir)) {
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
|
||||||
$arch = "${{ matrix.arch }}"
|
}
|
||||||
$publishDir = "publish/windows-$arch"
|
|
||||||
$appDir = "app-$version"
|
|
||||||
$currentAppPath = Join-Path $publishDir $appDir
|
|
||||||
$outputDir = "velopack-output"
|
|
||||||
|
|
||||||
if (-not (Test-Path $currentAppPath)) {
|
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||||||
Write-Error "Expected app directory not found: $currentAppPath"
|
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
$payloadZip = Join-Path $releaseDir "files-windows-$arch.zip"
|
||||||
vpk pack `
|
if (Test-Path $payloadZip) {
|
||||||
--packId LanMountainDesktop `
|
Remove-Item $payloadZip -Force
|
||||||
--packVersion $version `
|
|
||||||
--packDir $currentAppPath `
|
|
||||||
--mainExe LanMountainDesktop.exe `
|
|
||||||
--outputDir $outputDir `
|
|
||||||
--channel win `
|
|
||||||
--noPortable `
|
|
||||||
--skipVeloAppCheck
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "Velopack packaging failed."
|
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force
|
||||||
Get-ChildItem -Path $outputDir -File | Select-Object Name,Length
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Legacy Delta Fallback (disabled by default)
|
- name: Upload Release Assets
|
||||||
if: matrix.self_contained == true && matrix.arch == 'x64' && env.ENABLE_LEGACY_DELTA_FALLBACK == 'true'
|
|
||||||
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
|
|
||||||
& $scriptPath `
|
|
||||||
-PreviousVersion "0.0.0" `
|
|
||||||
-CurrentVersion $version `
|
|
||||||
-PreviousDir $currentAppPath `
|
|
||||||
-CurrentDir $currentAppPath `
|
|
||||||
-OutputDir $outputDir
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Upload Velopack Package
|
|
||||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-velopack-windows-x64
|
name: release-windows-${{ matrix.arch }}
|
||||||
path: |
|
path: |
|
||||||
velopack-output/*.nupkg
|
release-assets/files-windows-${{ matrix.arch }}.zip
|
||||||
velopack-output/releases.win.json
|
build-installer/*.exe
|
||||||
velopack-output/assets.win.json
|
|
||||||
velopack-output/RELEASES
|
|
||||||
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
|
||||||
|
|
||||||
@@ -454,13 +352,10 @@ 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 zip rsync
|
||||||
|
|
||||||
# Ubuntu 24.04+ moved several packages to t64 names.
|
|
||||||
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
|
||||||
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
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
|
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
@@ -482,8 +377,6 @@ 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 \
|
||||||
@@ -494,15 +387,11 @@ 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 }} \
|
||||||
if [ $? -ne 0 ]; then
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
echo "Launcher AOT publish failed"
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
exit 1
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
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: |
|
||||||
@@ -529,25 +418,15 @@ 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
|
||||||
@@ -560,12 +439,6 @@ 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"
|
||||||
@@ -574,20 +447,6 @@ 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" \
|
||||||
@@ -611,9 +470,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"/*
|
||||||
@@ -622,26 +481,49 @@ 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"
|
||||||
|
|
||||||
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
|
dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"
|
||||||
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
|
|
||||||
ls -lh "${package_name}_${package_version}_${arch}.deb"
|
- name: Package Payload Zip
|
||||||
else
|
run: |
|
||||||
echo "Error: Failed to build DEB package"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
|
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
|
||||||
|
|
||||||
- name: Upload
|
rm -rf "$stage_dir"
|
||||||
|
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
|
name: release-linux-x64
|
||||||
path: "*.deb"
|
path: |
|
||||||
|
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 }}
|
||||||
@@ -676,8 +558,6 @@ 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 }} \
|
||||||
@@ -688,15 +568,11 @@ 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 }} \
|
||||||
if [ $? -ne 0 ]; then
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
echo "Launcher AOT publish failed"
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
exit 1
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
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: |
|
||||||
@@ -716,7 +592,22 @@ 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 }}"
|
||||||
@@ -725,41 +616,19 @@ 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">'
|
||||||
@@ -783,103 +652,96 @@ 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"
|
||||||
|
|
||||||
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
|
- name: Upload Release Assets
|
||||||
echo "Successfully created: ${package_name}.dmg"
|
if: always()
|
||||||
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: "*.dmg"
|
path: |
|
||||||
if-no-files-found: error
|
release-assets/files-macos-${{ matrix.arch }}.zip
|
||||||
|
*.dmg
|
||||||
|
if-no-files-found: ignore
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
github-release:
|
github-release:
|
||||||
needs: [ prepare, build-windows, build-linux, build-macos ]
|
needs: [prepare, build-windows, build-linux]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: release-files
|
||||||
pattern: release-*
|
pattern: release-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: List artifacts structure
|
- name: Normalize release files
|
||||||
run: |
|
run: |
|
||||||
echo "Artifact directory structure:"
|
mkdir -p release-bundle
|
||||||
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'
|
|
||||||
|
|
||||||
- name: Flatten artifacts for release
|
mapfile -t downloaded_files < <(find release-files -type f)
|
||||||
run: |
|
if [ "${#downloaded_files[@]}" -eq 0 ]; then
|
||||||
echo "Organizing artifacts..."
|
echo "No downloaded release artifacts were found."
|
||||||
mkdir -p release-files
|
|
||||||
# Copy installers and packages
|
|
||||||
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
|
||||||
# Copy Velopack release feed and update packages
|
|
||||||
find artifacts -type f \( -name "releases.win.json" -o -name "assets.win.json" -o -name "RELEASES" -o -name "*.nupkg" \) -exec cp -v {} release-files/ \;
|
|
||||||
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
|
|
||||||
echo "Error: No release files found"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create Release
|
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: |
|
||||||
|
echo "Release files:"
|
||||||
|
find release-bundle -maxdepth 1 -type f -exec ls -lh {} \;
|
||||||
|
|
||||||
|
if [ ! -f release-bundle/files-windows-x64.zip ] || [ ! -f release-bundle/files-windows-x86.zip ] || [ ! -f release-bundle/files-linux-x64.zip ]; then
|
||||||
|
echo "Required payload zips are missing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_count=$(find release-bundle -maxdepth 1 -type f | wc -l)
|
||||||
|
if [ "$file_count" -eq 0 ]; then
|
||||||
|
echo "No release files were produced."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create or Update 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: ${{ github.event.inputs.is_prerelease == 'true' }}
|
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||||
artifacts: "release-files/**"
|
artifacts: 'release-bundle/*'
|
||||||
body: |
|
body: |
|
||||||
## Release ${{ needs.prepare.outputs.version }}
|
## Release ${{ needs.prepare.outputs.version }}
|
||||||
|
|
||||||
### Windows
|
### Installers
|
||||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime)
|
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe`
|
||||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime)
|
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe`
|
||||||
|
- `LanMountainDesktop_${{ needs.prepare.outputs.version }}_amd64.deb`
|
||||||
|
|
||||||
**Note:** The Launcher is now built with AOT (Ahead-of-Time) compilation as a single executable file for faster startup and smaller footprint.
|
### Payload Archives
|
||||||
|
- `files-windows-x64.zip`
|
||||||
Installation: Double-click the .exe file and follow the wizard.
|
- `files-windows-x86.zip`
|
||||||
|
- `files-linux-x64.zip`
|
||||||
### Incremental Update (Windows x64)
|
|
||||||
- **releases.win.json** - Velopack release feed consumed by the launcher update flow
|
|
||||||
- **LanMountainDesktop-<version>-full.nupkg** - full package
|
|
||||||
- **LanMountainDesktop-<version>-delta.nupkg** - delta package (when available)
|
|
||||||
|
|
||||||
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
|
||||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-x64.dmg** - Intel processor
|
- macOS assets are best-effort and will not block the release.
|
||||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
|
|
||||||
|
|
||||||
See commits for changes.
|
Release keeps only the stable installer and payload outputs. PLONDS delta assets and external mirror metadata are generated by follow-up workflows.
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
11
.trae/specs/external-ipc-public-api/checklist.md
Normal file
11
.trae/specs/external-ipc-public-api/checklist.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 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.
|
||||||
24
.trae/specs/external-ipc-public-api/spec.md
Normal file
24
.trae/specs/external-ipc-public-api/spec.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 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
|
||||||
12
.trae/specs/external-ipc-public-api/tasks.md
Normal file
12
.trae/specs/external-ipc-public-api/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 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.
|
||||||
43
.trae/specs/launcher-oobe-elevation-hardening/spec.md
Normal file
43
.trae/specs/launcher-oobe-elevation-hardening/spec.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# 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.
|
||||||
9
.trae/specs/launcher-oobe-elevation-hardening/tasks.md
Normal file
9
.trae/specs/launcher-oobe-elevation-hardening/tasks.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 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.
|
||||||
@@ -6,3 +6,6 @@
|
|||||||
- [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`.
|
||||||
|
|||||||
@@ -52,3 +52,9 @@ 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.
|
||||||
|
|||||||
13
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
13
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 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.
|
||||||
44
.trae/specs/pdc-incremental-migration/spec.md
Normal file
44
.trae/specs/pdc-incremental-migration/spec.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 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`
|
||||||
15
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
15
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 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.
|
||||||
12
.trae/specs/plugin-process-isolation/checklist.md
Normal file
12
.trae/specs/plugin-process-isolation/checklist.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 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 回路
|
||||||
41
.trae/specs/plugin-process-isolation/spec.md
Normal file
41
.trae/specs/plugin-process-isolation/spec.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 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 的借鉴点和不照搬的部分写清楚,避免后续实现阶段误把插件协议做成远程对象模型。
|
||||||
12
.trae/specs/plugin-process-isolation/tasks.md
Normal file
12
.trae/specs/plugin-process-isolation/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 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 壳层适配器
|
||||||
|
- [ ] 为故障、心跳、降级与恢复补齐端到端测试
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
# Checklist
|
# Checklist (Deprecated)
|
||||||
|
|
||||||
- [x] `releases.win.json` recognized by host update download flow.
|
- [x] Spec marked as deprecated.
|
||||||
- [x] Launcher pending update check supports VeloPack payload.
|
- [x] Active implementation ownership moved to `pdc-incremental-migration`.
|
||||||
- [x] Launcher apply uses deployment markers (`.current/.partial/.destroy`) unchanged.
|
- [x] No release workflow dependency remains on VeloPack.
|
||||||
- [x] Legacy script path retained as emergency fallback.
|
|
||||||
- [ ] Staging verification report attached.
|
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
# VeloPack Update Integration
|
# VeloPack Update Integration (Deprecated)
|
||||||
|
|
||||||
## Goal
|
## Status
|
||||||
Switch incremental package generation and release assets to VeloPack native outputs while keeping Launcher as the update installer and rollback authority.
|
|
||||||
|
|
||||||
## Requirements
|
This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration/`.
|
||||||
- CI/release pipeline produces `releases.win.json` and `*.nupkg` assets for Windows x64.
|
|
||||||
- Launcher can detect pending VeloPack payload in `.launcher/update/incoming`.
|
|
||||||
- Launcher applies update into new `app-*` deployment and preserves rollback snapshot behavior.
|
|
||||||
- Existing launcher responsibilities (OOBE/startup/plugin upgrade) remain unchanged.
|
|
||||||
|
|
||||||
## Acceptance
|
## Deprecation Reason
|
||||||
- Build and quality workflows pass after migration changes.
|
|
||||||
- Release workflow publishes VeloPack assets.
|
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||||
- Launcher `update apply` succeeds with VeloPack full package payload.
|
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||||
- Manual rollback still works after a VeloPack-based update.
|
- 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.
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
# Tasks
|
# Tasks (Deprecated)
|
||||||
|
|
||||||
- [x] Fix Launcher `LoadingDetailsWindow.axaml` compile regression.
|
- [x] Mark VeloPack integration spec as deprecated.
|
||||||
- [x] Add VeloPack feed/package model support in Launcher update engine.
|
- [x] Remove VeloPack runtime branches from launcher/host update path.
|
||||||
- [x] Keep legacy delta flow behind disabled fallback switch.
|
- [x] Remove VeloPack release workflow packaging steps.
|
||||||
- [x] Migrate release workflow packaging assets to VeloPack outputs.
|
- [ ] Keep archive for historical context only (no new implementation tasks here).
|
||||||
- [x] Update host-side update workflow to download VeloPack payload files.
|
|
||||||
- [ ] Run full release workflow dry-run on GitHub and validate artifacts.
|
|
||||||
- [ ] Validate end-to-end update + rollback on a staging machine.
|
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
// 初始化日志记录器
|
|
||||||
Logger.Initialize();
|
Logger.Initialize();
|
||||||
Logger.Info("Launcher starting...");
|
var context = LauncherRuntimeContext.Current;
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -24,41 +29,31 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,135 +61,108 @@ 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)
|
||||||
{
|
{
|
||||||
var command = context.Command.ToLowerInvariant();
|
switch (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("[预览模式] 这是一个错误页面预览。\n\n用于查看错误页面的样式和布局。");
|
errorWindow.SetErrorMessage("[Preview] This is the launcher error window preview.");
|
||||||
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[] { "初始化...", "检查更新...", "检查插件...", "正在启动...", "就绪" };
|
var messages = new[] { "Initializing...", "Checking updates...", "Checking plugins...", "Launching host...", "Ready" };
|
||||||
var reporter = (ISplashStageReporter)window;
|
var reporter = (ISplashStageReporter)window;
|
||||||
|
|
||||||
for (int i = 0; i < stages.Length; i++)
|
for (var i = 0; i < stages.Length; i++)
|
||||||
{
|
{
|
||||||
reporter.Report(stages[i], messages[i]);
|
reporter.Report(stages[i], messages[i]);
|
||||||
await Task.Delay(800);
|
await Task.Delay(800).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待5秒后自动关闭
|
await Task.Delay(5000).ConfigureAwait(false);
|
||||||
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 (int i = 0; i < stages.Length; i++)
|
for (var i = 0; i < stages.Length; i++)
|
||||||
{
|
{
|
||||||
window.Report(stages[i], $"正在{GetStageName(stages[i])}...", (i + 1) * 20);
|
window.Report(stages[i], $"Processing {stages[i]}...", (i + 1) * 20);
|
||||||
await Task.Delay(600);
|
await Task.Delay(600).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
await window.WaitForEnterAsync();
|
Logger.Info("OOBE preview completed by user.");
|
||||||
Console.WriteLine("[Launcher] OOBE preview completed by user");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[Launcher] OOBE preview error: {ex.Message}");
|
Logger.Error("OOBE preview failed.", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户点击后关闭应用程序
|
|
||||||
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();
|
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
window.Closed += (s, e) => tcs.TrySetResult();
|
window.Closed += (_, _) => tcs.TrySetResult();
|
||||||
await tcs.Task;
|
await tcs.Task.ConfigureAwait(false);
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,112 +172,106 @@ 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 = $"启动器发生错误: {ex.Message}",
|
Message = $"Launcher failed: {ex.Message}",
|
||||||
ErrorMessage = ex.ToString()
|
ErrorMessage = ex.ToString()
|
||||||
};
|
};
|
||||||
|
|
||||||
Console.Error.WriteLine($"[Launcher] Exception caught: {ex}");
|
|
||||||
|
|
||||||
// 在 UI 线程显示错误窗口 - 使用更健壮的方式
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 安全关闭 Splash 窗口
|
|
||||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
|
||||||
{
|
|
||||||
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 != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 等待用户选择或窗口关闭
|
|
||||||
var errorResult = await errorWindow.WaitForChoiceAsync();
|
|
||||||
Console.WriteLine($"[Launcher] ErrorWindow result: {errorResult}");
|
|
||||||
}
|
|
||||||
catch (Exception waitEx)
|
|
||||||
{
|
|
||||||
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);
|
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
||||||
|
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;
|
Environment.ExitCode = result.Success ? 0 : 1;
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
|
||||||
/// apply-update 模式:执行增量更新和插件升级,完成后自动退出
|
{
|
||||||
/// </summary>
|
var resultPath = context.GetOption("result");
|
||||||
|
if (string.IsNullOrWhiteSpace(resultPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
errorWindow = new ErrorWindow();
|
||||||
|
errorWindow.SetErrorMessage(
|
||||||
|
$"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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errorWindow is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("Failure window closed unexpectedly.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task RunApplyUpdateWithWindowAsync(
|
private static async Task RunApplyUpdateWithWindowAsync(
|
||||||
IClassicDesktopStyleApplicationLifetime desktop,
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
CommandContext context,
|
CommandContext context,
|
||||||
@@ -326,8 +288,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. 应用增量更新
|
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "Verifying update...", 10));
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -335,24 +296,20 @@ public partial class App : Application
|
|||||||
errorMessage = updateResult.Message;
|
errorMessage = updateResult.Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 应用待处理的插件升级
|
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "正在升级插件...", 60));
|
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "Applying plugin upgrades...", 60));
|
||||||
var pluginsDir = context.GetOption("plugins-dir")
|
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
|
||||||
?? 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", "正在清理...", 90));
|
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "Cleaning up old deployments...", 90));
|
||||||
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,33 +317,26 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,17 @@ using LanMountainDesktop.Shared.Contracts.Launcher;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Launcher;
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
[JsonSourceGenerationOptions(
|
||||||
|
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))]
|
||||||
@@ -17,10 +25,8 @@ 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>))]
|
||||||
[JsonSerializable(typeof(VelopackReleaseFeed))]
|
|
||||||
[JsonSerializable(typeof(VelopackReleaseAsset))]
|
|
||||||
[JsonSerializable(typeof(List<VelopackReleaseAsset>))]
|
|
||||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
-----BEGIN RSA PUBLIC KEY-----
|
-----BEGIN RSA PUBLIC KEY-----
|
||||||
MIIBCgKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGxbjZT
|
MIIBigKCAYEAt3yev3f0D1AZthEmr7ZGeDTcjIOGwQgPGRK/qV1XMlYS96AYiqlQ
|
||||||
B+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS17YI
|
ToZyA+WrDAXOUHcpaIzei+GdieTs+IE0q64dvBY5+wJShKhGMdcJ+nibt6qfsgvX
|
||||||
90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uzay3go
|
M2jSuR5ubHP9HGqBQNgLYdGFyD/IA7cDG5AsrGTXtVIldbkSzHPJiAp69G3fu9Hi
|
||||||
msbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNizr0l
|
J7o7jE3pzTTPoArpjcCheoK/+9vjZOmEmkw71uWvmtld8KgOYz5Wk+GbQ2mJk6NJ
|
||||||
YcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM5iUa
|
5TNqvlnzbYl946f78XNvHnnguLEU7q4SK0vgE7F92G10xB1A6DCTZQINjz/RrO5s
|
||||||
20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQAB
|
M/r29/jRSZbdrqbDIufxzxSeU80ADd7THSAGTVltynO0prAKW4be7ZtKbZVXgMUO
|
||||||
|
NMyCZUPCvSZP21Z7FSVyzf3wWYbyn/iBYCogticl5GBlr6ChQ/kfOQCGysCuDRK0
|
||||||
|
/RJ+ukWQCpl41Sh33B3HltOoKNuVuOkhwiDvJ4ckDoupf+4hzTzqWCuZf3NLAsYf
|
||||||
|
FQiGowgqx0l5AgMBAAE=
|
||||||
-----END RSA PUBLIC KEY-----
|
-----END RSA PUBLIC KEY-----
|
||||||
@@ -4,6 +4,19 @@ 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; }
|
||||||
@@ -20,6 +33,8 @@ 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 参数或调试器附加时才启用
|
||||||
@@ -28,6 +43,20 @@ 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;
|
||||||
@@ -62,6 +91,44 @@ 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);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||||
<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>
|
||||||
|
|||||||
63
LanMountainDesktop.Launcher/Models/OobeStateModels.cs
Normal file
63
LanMountainDesktop.Launcher/Models/OobeStateModels.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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);
|
||||||
@@ -11,8 +11,6 @@ public sealed class ReleaseInfo
|
|||||||
public required DateTime PublishedAt { get; init; }
|
public required DateTime PublishedAt { get; init; }
|
||||||
public required List<ReleaseAsset> Assets { get; init; }
|
public required List<ReleaseAsset> Assets { get; init; }
|
||||||
public string? Body { get; init; }
|
public string? Body { get; init; }
|
||||||
public string? VelopackFeedUrl { get; init; }
|
|
||||||
public string? VelopackLegacyReleasesUrl { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -53,3 +53,92 @@ 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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Models;
|
|
||||||
|
|
||||||
internal sealed class VelopackReleaseFeed
|
|
||||||
{
|
|
||||||
public List<VelopackReleaseAsset> Assets { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class VelopackReleaseAsset
|
|
||||||
{
|
|
||||||
public string PackageId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string Version { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string Type { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string FileName { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? SHA1 { get; set; }
|
|
||||||
|
|
||||||
public string? SHA256 { get; set; }
|
|
||||||
|
|
||||||
public long Size { get; set; }
|
|
||||||
}
|
|
||||||
@@ -10,32 +10,60 @@ 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)
|
|
||||||
{
|
{
|
||||||
var installer = new PluginInstallerService();
|
if (commandContext.IsLegacyPluginInstall)
|
||||||
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
|
{
|
||||||
}
|
var installer = new PluginInstallerService();
|
||||||
|
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!commandContext.IsGuiCommand)
|
||||||
|
{
|
||||||
|
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
// apply-update 命令:启动 Avalonia GUI 显示更新进度窗口
|
|
||||||
if (string.Equals(commandContext.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
LauncherRuntimeContext.Current = commandContext;
|
LauncherRuntimeContext.Current = commandContext;
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
return Environment.ExitCode;
|
return Environment.ExitCode;
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
// 处理其他 CLI 命令 (update, plugin, rollback 等)
|
|
||||||
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
|
Logger.Error("Launcher failed before GUI flow completed.", ex);
|
||||||
}
|
|
||||||
|
|
||||||
// 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序
|
var result = new LauncherResult
|
||||||
LauncherRuntimeContext.Current = commandContext;
|
{
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
Success = false,
|
||||||
return Environment.ExitCode;
|
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);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AppBuilder BuildAvaloniaApp()
|
private static AppBuilder BuildAvaloniaApp()
|
||||||
|
|||||||
3
LanMountainDesktop.Launcher/Properties/AssemblyInfo.cs
Normal file
3
LanMountainDesktop.Launcher/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||||
@@ -104,26 +104,6 @@ internal static class Commands
|
|||||||
|
|
||||||
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
|
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
|
||||||
{
|
{
|
||||||
var releasesUrl = context.GetOption("releases-url");
|
|
||||||
if (!string.IsNullOrWhiteSpace(releasesUrl))
|
|
||||||
{
|
|
||||||
var packageUrls = new List<string>();
|
|
||||||
var packageUrl = context.GetOption("package-url");
|
|
||||||
if (!string.IsNullOrWhiteSpace(packageUrl))
|
|
||||||
{
|
|
||||||
packageUrls.Add(packageUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
var packageUrlsCsv = context.GetOption("package-urls");
|
|
||||||
if (!string.IsNullOrWhiteSpace(packageUrlsCsv))
|
|
||||||
{
|
|
||||||
packageUrls.AddRange(packageUrlsCsv
|
|
||||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await updateEngine.DownloadVelopackAsync(releasesUrl, packageUrls, CancellationToken.None).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await updateEngine.DownloadAsync(
|
return await updateEngine.DownloadAsync(
|
||||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||||
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ 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 =>
|
||||||
{
|
{
|
||||||
@@ -79,38 +78,199 @@ internal sealed class DeploymentLocator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? ResolveHostExecutablePath()
|
public HostResolutionResult ResolveHostExecutable(CommandContext context)
|
||||||
{
|
{
|
||||||
// 使用新的灵活定位器
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
var options = new HostDiscoveryOptions
|
|
||||||
{
|
|
||||||
ExecutableName = "LanMountainDesktop",
|
|
||||||
PreferDevModeConfig = true,
|
|
||||||
RecursiveSearch = false, // 默认不启用递归搜索以提高性能
|
|
||||||
AdditionalSearchPaths = new List<string>
|
|
||||||
{
|
|
||||||
// 可以通过配置文件或环境变量添加更多路径
|
|
||||||
"${AppRoot}",
|
|
||||||
"${AppRoot}/..",
|
|
||||||
"${BaseDirectory}/../..",
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var locator = new FlexibleHostLocator(_appRoot, options);
|
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||||
var result = locator.ResolveHostExecutablePath();
|
var searchedPaths = new List<string>();
|
||||||
|
var explicitAppRoot = context.ExplicitAppRoot;
|
||||||
|
var devModeConfigIgnored = !context.IsDebugMode && Views.ErrorWindow.CheckDevModeEnabled();
|
||||||
|
|
||||||
if (result != null)
|
string? resolvedPath;
|
||||||
|
string? source;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(explicitAppRoot))
|
||||||
{
|
{
|
||||||
return result;
|
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()
|
||||||
|
{
|
||||||
return ResolveHostExecutablePathLegacy();
|
return ResolveHostExecutablePathLegacy();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private string? TryResolveExplicitAppRoot(
|
||||||
/// 传统的主程序路径解析(作为备选)
|
string explicitRoot,
|
||||||
/// </summary>
|
string executable,
|
||||||
|
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";
|
||||||
@@ -126,14 +286,12 @@ 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))
|
||||||
@@ -144,14 +302,12 @@ internal sealed class DeploymentLocator
|
|||||||
// 4. å¼€å<E282AC>‘模å¼<C3A5>:如果å<C593>¯ç”¨äº†å¼€å<E282AC>‘模å¼<C3A5>,优先使用ä¿<C3A4>å˜çš„自定义路径
|
// 4. å¼€å<E282AC>‘模å¼<C3A5>:如果å<C593>¯ç”¨äº†å¼€å<E282AC>‘模å¼<C3A5>,优先使用ä¿<C3A4>å˜çš„自定义路径
|
||||||
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))
|
||||||
{
|
{
|
||||||
@@ -179,7 +335,7 @@ internal sealed class DeploymentLocator
|
|||||||
{
|
{
|
||||||
var possiblePaths = new[]
|
var possiblePaths = new[]
|
||||||
{
|
{
|
||||||
// 从 Launcher 项目运行
|
// ä»?Launcher 项目è¿<EFBFBD>行
|
||||||
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),
|
||||||
|
|
||||||
@@ -203,17 +359,14 @@ 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 项目运行:..\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
// ä»?Launcher 项目è¿<EFBFBD>行ï¼?.\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),
|
||||||
|
|
||||||
@@ -221,7 +374,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 目录运行
|
// ä»?dev-test 目录è¿<EFBFBD>行
|
||||||
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,9 +409,8 @@ internal sealed class DeploymentLocator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 清理旧版本部署,保留最近的N个版本
|
/// 清ç<EFBFBD>†æ—§ç‰ˆæœ¬éƒ¨ç½²ï¼Œä¿<EFBFBD>留最近的N个版æœ? /// </summary>
|
||||||
/// </summary>
|
/// <param name="minVersionsToKeep">最少ä¿<C3A4>留版本数,默è®?ä¸?/param>
|
||||||
/// <param name="minVersionsToKeep">最少保留版本数,默认3个</param>
|
|
||||||
public void CleanupOldDeployments(int minVersionsToKeep = 3)
|
public void CleanupOldDeployments(int minVersionsToKeep = 3)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -272,7 +424,6 @@ 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
|
||||||
@@ -349,7 +500,6 @@ internal sealed class DeploymentLocator
|
|||||||
{
|
{
|
||||||
if (versionsToKeep.Contains(deployment.Path))
|
if (versionsToKeep.Contains(deployment.Path))
|
||||||
{
|
{
|
||||||
// 保留此版本,如果之前标记了destroy则取消标记
|
|
||||||
if (deployment.IsDestroyed)
|
if (deployment.IsDestroyed)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -365,7 +515,6 @@ internal sealed class DeploymentLocator
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果还没标记destroy的,先标记
|
|
||||||
if (!deployment.IsDestroyed)
|
if (!deployment.IsDestroyed)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -387,7 +536,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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,7 +549,7 @@ internal sealed class DeploymentLocator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 仅清理已标记为.destroy的部署(兼容旧方法)
|
/// 仅清ç<EFBFBD>†å·²æ ‡è®°ä¸?destroy的部署(兼容旧方法)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Obsolete("Use CleanupOldDeployments instead")]
|
[Obsolete("Use CleanupOldDeployments instead")]
|
||||||
public void CleanupDestroyedDeployments()
|
public void CleanupDestroyedDeployments()
|
||||||
@@ -432,8 +581,7 @@ internal sealed class DeploymentLocator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 从部署目录读取版本信息
|
/// 从部署目录读å<EFBFBD>–版本信æ<EFBFBD>? /// </summary>
|
||||||
/// </summary>
|
|
||||||
public AppVersionInfo GetVersionInfo()
|
public AppVersionInfo GetVersionInfo()
|
||||||
{
|
{
|
||||||
var deploymentDir = FindCurrentDeploymentDirectory();
|
var deploymentDir = FindCurrentDeploymentDirectory();
|
||||||
@@ -453,16 +601,16 @@ internal sealed class DeploymentLocator
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// 忽略读取失败,回退到默认值
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回退:从目录名解析版本,使用默认开发代号
|
|
||||||
return new AppVersionInfo
|
return new AppVersionInfo
|
||||||
{
|
{
|
||||||
Version = GetCurrentVersion(),
|
Version = GetCurrentVersion(),
|
||||||
Codename = "Administrate" // 默认开发代号
|
Codename = "Administrate"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
18
LanMountainDesktop.Launcher/Services/HostResolutionResult.cs
Normal file
18
LanMountainDesktop.Launcher/Services/HostResolutionResult.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -262,6 +262,9 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,104 +1,221 @@
|
|||||||
|
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 readonly string _markerPath;
|
private const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
public OobeStateService(string appRoot)
|
private readonly string _stateDirectory;
|
||||||
|
private readonly string _statePath;
|
||||||
|
private readonly string _legacyMarkerPath;
|
||||||
|
private readonly LauncherExecutionSnapshot _executionSnapshot;
|
||||||
|
|
||||||
|
public OobeStateService(
|
||||||
|
string appRoot,
|
||||||
|
string? stateRootOverride = null,
|
||||||
|
LauncherExecutionSnapshot? executionSnapshot = null)
|
||||||
{
|
{
|
||||||
// 优先使用 LocalApplicationData(用户目录,普通用户一定有权限)
|
_ = Path.GetFullPath(appRoot);
|
||||||
string? stateDir = null;
|
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
|
||||||
Exception? lastException = null;
|
|
||||||
|
|
||||||
// 策略1: LocalApplicationData(首选,用户目录,普通用户一定有写权限)
|
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||||
try
|
? GetDefaultStateRoot()
|
||||||
{
|
: Path.GetFullPath(stateRootOverride);
|
||||||
var appDataDir = Path.Combine(
|
_stateDirectory = Path.Combine(stateRoot, ".launcher", "state");
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
|
||||||
"LanMountainDesktop");
|
_legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
|
||||||
stateDir = Path.Combine(appDataDir, ".launcher", "state");
|
|
||||||
Directory.CreateDirectory(stateDir);
|
|
||||||
Console.WriteLine($"[OobeStateService] Using LocalApplicationData: {stateDir}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
lastException = ex;
|
|
||||||
Console.Error.WriteLine($"[OobeStateService] LocalApplicationData failed: {ex.Message}");
|
|
||||||
stateDir = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 策略2: 如果LocalApplicationData不行,使用用户的临时目录
|
|
||||||
if (stateDir == null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var tempDir = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", ".launcher", "state");
|
|
||||||
Directory.CreateDirectory(tempDir);
|
|
||||||
stateDir = tempDir;
|
|
||||||
Console.WriteLine($"[OobeStateService] Using TempPath: {stateDir}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
lastException = ex;
|
|
||||||
Console.Error.WriteLine($"[OobeStateService] TempPath failed: {ex.Message}");
|
|
||||||
stateDir = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 策略3: 最后的兜底:使用当前用户的应用程序数据目录(和Launcher同目录
|
|
||||||
if (stateDir == null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var launcherDir = AppContext.BaseDirectory;
|
|
||||||
stateDir = Path.Combine(launcherDir, ".launcher", "state");
|
|
||||||
Directory.CreateDirectory(stateDir);
|
|
||||||
Console.WriteLine($"[OobeStateService] Using Launcher directory: {stateDir}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
lastException = ex;
|
|
||||||
Console.Error.WriteLine($"[OobeStateService] All strategies failed! Last error: {ex.Message}");
|
|
||||||
// 如果所有策略都失败,抛出异常让上层处理
|
|
||||||
throw new InvalidOperationException("无法创建 OOBE 状态存储目录失败", lastException);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_markerPath = Path.Combine(stateDir, "first_run_completed");
|
|
||||||
Console.WriteLine($"[OobeStateService] Initialized successfully, marker path: {_markerPath}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsFirstRun()
|
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
|
||||||
{
|
{
|
||||||
return !File.Exists(_markerPath);
|
Directory.CreateDirectory(_stateDirectory);
|
||||||
|
var payload = new OobeStateFile
|
||||||
|
{
|
||||||
|
SchemaVersion = CurrentSchemaVersion,
|
||||||
|
CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||||
|
UserName = _executionSnapshot.UserName,
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[OobeStateService] Failed to check first run: {ex.Message}");
|
Logger.Warn(
|
||||||
// 如果无法检查,默认视为首次运行,确保OOBE能显示
|
$"Failed to persist OOBE state. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
|
||||||
return true;
|
$"Error='{ex.Message}'.");
|
||||||
|
return new OobeCompletionResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
ResultCode = "oobe_state_unavailable",
|
||||||
|
ErrorMessage = ex.Message
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MarkCompleted()
|
private OobeLaunchDecision EvaluateCore(CommandContext context)
|
||||||
{
|
{
|
||||||
|
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 dir = Path.GetDirectoryName(_markerPath);
|
var migratedLegacyMarker = false;
|
||||||
if (!string.IsNullOrWhiteSpace(dir))
|
if (File.Exists(_statePath))
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(dir);
|
using var stream = File.OpenRead(_statePath);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
|
if (File.Exists(_legacyMarkerPath))
|
||||||
Console.WriteLine("[OobeStateService] Marked first run as completed");
|
{
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[OobeStateService] Failed to mark completed: {ex.Message}");
|
return BuildUnavailableDecision(context, ex.Message);
|
||||||
// 如果无法写入也没关系,下次启动还会显示OOBE
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool TryMigrateLegacyMarker(CommandContext context)
|
||||||
|
{
|
||||||
|
var result = MarkCompleted(context);
|
||||||
|
return result.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryDeleteLegacyMarker()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_legacyMarkerPath))
|
||||||
|
{
|
||||||
|
File.Delete(_legacyMarkerPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OobeLaunchDecision BuildDecision(
|
||||||
|
CommandContext context,
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return new OobeLaunchDecision
|
||||||
|
{
|
||||||
|
Status = OobeStateStatus.Suppressed,
|
||||||
|
ShouldShowOobe = false,
|
||||||
|
StatePath = _statePath,
|
||||||
|
LaunchSource = context.LaunchSource,
|
||||||
|
IsElevated = _executionSnapshot.IsElevated,
|
||||||
|
UserName = _executionSnapshot.UserName,
|
||||||
|
UserSid = _executionSnapshot.UserSid,
|
||||||
|
SuppressionReason = reason,
|
||||||
|
ResultCode = resultCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private OobeLaunchDecision BuildUnavailableDecision(CommandContext context, string errorMessage)
|
||||||
|
{
|
||||||
|
return new OobeLaunchDecision
|
||||||
|
{
|
||||||
|
Status = OobeStateStatus.Unavailable,
|
||||||
|
ShouldShowOobe = false,
|
||||||
|
StatePath = _statePath,
|
||||||
|
LaunchSource = context.LaunchSource,
|
||||||
|
IsElevated = _executionSnapshot.IsElevated,
|
||||||
|
UserName = _executionSnapshot.UserName,
|
||||||
|
UserSid = _executionSnapshot.UserSid,
|
||||||
|
ResultCode = "oobe_state_unavailable",
|
||||||
|
ErrorMessage = errorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDefaultStateRoot()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (string.IsNullOrWhiteSpace(appData))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("LocalApplicationData is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(appData, "LanMountainDesktop");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ 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));
|
||||||
@@ -51,6 +56,46 @@ 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);
|
||||||
|
|||||||
@@ -104,11 +104,7 @@ internal sealed class UpdateCheckService
|
|||||||
Name = a.Name ?? "",
|
Name = a.Name ?? "",
|
||||||
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
|
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
|
||||||
Size = a.Size
|
Size = a.Size
|
||||||
}).ToList() ?? [],
|
}).ToList() ?? []
|
||||||
VelopackFeedUrl = r.Assets?.FirstOrDefault(a =>
|
|
||||||
string.Equals(a.Name, "releases.win.json", StringComparison.OrdinalIgnoreCase))?.BrowserDownloadUrl,
|
|
||||||
VelopackLegacyReleasesUrl = r.Assets?.FirstOrDefault(a =>
|
|
||||||
string.Equals(a.Name, "RELEASES", StringComparison.OrdinalIgnoreCase))?.BrowserDownloadUrl
|
|
||||||
}).ToList() ?? [];
|
}).ToList() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
50
LanMountainDesktop.Launcher/Services/WelcomeOobeStep.cs
Normal file
50
LanMountainDesktop.Launcher/Services/WelcomeOobeStep.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,14 +23,12 @@ 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)
|
||||||
@@ -59,8 +57,7 @@ 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(() =>
|
||||||
@@ -73,7 +70,6 @@ public partial class LoadingDetailsWindow : Window
|
|||||||
// 鏇存柊鏁翠綋杩涘害
|
// 鏇存柊鏁翠綋杩涘害
|
||||||
UpdateOverallProgress(state);
|
UpdateOverallProgress(state);
|
||||||
|
|
||||||
// 更新当前活动项
|
|
||||||
UpdateCurrentItem(state);
|
UpdateCurrentItem(state);
|
||||||
|
|
||||||
// 鏇存柊鍒楄〃
|
// 鏇存柊鍒楄〃
|
||||||
@@ -124,8 +120,7 @@ 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();
|
||||||
@@ -162,7 +157,6 @@ 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);
|
||||||
@@ -187,7 +181,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)
|
||||||
@@ -240,17 +234,20 @@ 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.Ready => "加载完成",
|
StartupStage.ShellInitialized => "桌面外壳已初始化",
|
||||||
_ => "正在加载..."
|
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))
|
||||||
@@ -268,8 +265,7 @@ 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",
|
||||||
@@ -298,8 +294,7 @@ 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; }
|
||||||
@@ -394,3 +389,4 @@ public class LoadingItemViewModel : INotifyPropertyChanged
|
|||||||
_ => new SolidColorBrush(Color.Parse("#616161"))
|
_ => new SolidColorBrush(Color.Parse("#616161"))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||||
|
|
||||||
|
public static class PluginIsolationProtocolVersion
|
||||||
|
{
|
||||||
|
public const string Current = "1.0";
|
||||||
|
}
|
||||||
9
LanMountainDesktop.PluginIsolation.Contracts/README.md
Normal file
9
LanMountainDesktop.PluginIsolation.Contracts/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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);
|
||||||
52
LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs
Normal file
52
LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<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>
|
||||||
90
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs
Normal file
90
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
25
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcConstants.cs
Normal file
25
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcConstants.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
13
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcDelegates.cs
Normal file
13
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcDelegates.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
113
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServer.cs
Normal file
113
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServer.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
10
LanMountainDesktop.PluginIsolation.Ipc/README.md
Normal file
10
LanMountainDesktop.PluginIsolation.Ipc/README.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 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
|
||||||
15
LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs
Normal file
15
LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface IPluginPublicIpcContributor
|
||||||
|
{
|
||||||
|
void ConfigurePublicIpc(IPluginPublicIpcBuilder builder);
|
||||||
|
}
|
||||||
12
LanMountainDesktop.PluginSdk/IPluginWorker.cs
Normal file
12
LanMountainDesktop.PluginSdk/IPluginWorker.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
26
LanMountainDesktop.PluginSdk/IPluginWorkerContext.cs
Normal file
26
LanMountainDesktop.PluginSdk/IPluginWorkerContext.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -25,7 +25,10 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ 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()
|
||||||
{
|
{
|
||||||
@@ -56,9 +57,13 @@ 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),
|
||||||
@@ -68,7 +73,8 @@ 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))
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record PluginPublicIpcServiceDescriptor(
|
||||||
|
Type ContractType,
|
||||||
|
object Implementation,
|
||||||
|
string? ObjectId,
|
||||||
|
string[] NotifyIds);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record PluginPublicIpcServiceRegistration(
|
||||||
|
Type ContractType,
|
||||||
|
string? ObjectId,
|
||||||
|
string[] NotifyIds);
|
||||||
15
LanMountainDesktop.PluginSdk/PluginRuntimeConfiguration.cs
Normal file
15
LanMountainDesktop.PluginSdk/PluginRuntimeConfiguration.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
8
LanMountainDesktop.PluginSdk/PluginRuntimeMode.cs
Normal file
8
LanMountainDesktop.PluginSdk/PluginRuntimeMode.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public enum PluginRuntimeMode
|
||||||
|
{
|
||||||
|
InProcess = 0,
|
||||||
|
IsolatedBackground = 1,
|
||||||
|
IsolatedWindow = 2
|
||||||
|
}
|
||||||
58
LanMountainDesktop.PluginSdk/PluginRuntimeModes.cs
Normal file
58
LanMountainDesktop.PluginSdk/PluginRuntimeModes.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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;
|
||||||
@@ -112,6 +113,55 @@ 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
|
||||||
|
|||||||
20
LanMountainDesktop.PluginSdk/PluginWorkerBase.cs
Normal file
20
LanMountainDesktop.PluginSdk/PluginWorkerBase.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||||
|
public sealed class PluginWorkerEntranceAttribute : Attribute
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ 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
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ 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)
|
||||||
|
|||||||
@@ -6,5 +6,8 @@
|
|||||||
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,38 @@
|
|||||||
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,
|
||||||
/// <summary>
|
DesktopVisible,
|
||||||
/// 就绪
|
ActivationRedirected,
|
||||||
/// </summary>
|
ActivationFailed,
|
||||||
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
[IpcPublic(IgnoresIpcException = true)]
|
||||||
|
public interface IPublicAppInfoService
|
||||||
|
{
|
||||||
|
PublicAppInfoSnapshot GetAppInfo();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
[IpcPublic(IgnoresIpcException = true)]
|
||||||
|
public interface IPublicPluginCatalogService
|
||||||
|
{
|
||||||
|
PublicIpcCatalogSnapshot GetCatalog();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
|
||||||
|
|
||||||
|
public sealed record PublicIpcServiceRegistration(
|
||||||
|
Type ContractType,
|
||||||
|
Func<IServiceProvider, object> ImplementationFactory,
|
||||||
|
string? ObjectId,
|
||||||
|
string? PluginId,
|
||||||
|
string[] NotifyIds);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public interface IExternalIpcNotificationPublisher
|
||||||
|
{
|
||||||
|
Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
|
||||||
|
where TPayload : class;
|
||||||
|
}
|
||||||
14
LanMountainDesktop.Shared.IPC/IpcConstants.cs
Normal file
14
LanMountainDesktop.Shared.IPC/IpcConstants.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
8
LanMountainDesktop.Shared.IPC/IpcRoutedNotifyIds.cs
Normal file
8
LanMountainDesktop.Shared.IPC/IpcRoutedNotifyIds.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<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>
|
||||||
96
LanMountainDesktop.Shared.IPC/LanMountainDesktopIpcClient.cs
Normal file
96
LanMountainDesktop.Shared.IPC/LanMountainDesktopIpcClient.cs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
LanMountainDesktop.Shared.IPC/PublicAppInfoSnapshot.cs
Normal file
9
LanMountainDesktop.Shared.IPC/PublicAppInfoSnapshot.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed record PublicAppInfoSnapshot(
|
||||||
|
string ApplicationName,
|
||||||
|
string Version,
|
||||||
|
string Codename,
|
||||||
|
string PipeName,
|
||||||
|
int ProcessId,
|
||||||
|
DateTimeOffset StartedAt);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed record PublicIpcCatalogSnapshot(
|
||||||
|
PublicIpcServiceDescriptor[] Services,
|
||||||
|
PublicPluginDescriptor[] Plugins,
|
||||||
|
DateTimeOffset Timestamp);
|
||||||
219
LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs
Normal file
219
LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
10
LanMountainDesktop.Shared.IPC/PublicIpcServiceDescriptor.cs
Normal file
10
LanMountainDesktop.Shared.IPC/PublicIpcServiceDescriptor.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed record PublicIpcServiceDescriptor(
|
||||||
|
string ContractTypeName,
|
||||||
|
string ContractAssemblyName,
|
||||||
|
string? ContractAssemblyQualifiedName,
|
||||||
|
string? ObjectId,
|
||||||
|
string? PluginId,
|
||||||
|
bool IsBuiltIn,
|
||||||
|
string[] NotifyIds);
|
||||||
7
LanMountainDesktop.Shared.IPC/PublicIpcSessionInfo.cs
Normal file
7
LanMountainDesktop.Shared.IPC/PublicIpcSessionInfo.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed record PublicIpcSessionInfo(
|
||||||
|
string PipeName,
|
||||||
|
string ProtocolVersion,
|
||||||
|
string[] Capabilities,
|
||||||
|
DateTimeOffset StartedAt);
|
||||||
8
LanMountainDesktop.Shared.IPC/PublicPluginDescriptor.cs
Normal file
8
LanMountainDesktop.Shared.IPC/PublicPluginDescriptor.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed record PublicPluginDescriptor(
|
||||||
|
string PluginId,
|
||||||
|
string DisplayName,
|
||||||
|
string? Version,
|
||||||
|
bool IsLoaded,
|
||||||
|
bool IsEnabled);
|
||||||
3
LanMountainDesktop.Shared.IPC/README.md
Normal file
3
LanMountainDesktop.Shared.IPC/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# LanMountainDesktop.Shared.IPC
|
||||||
|
|
||||||
|
Public IPC abstractions and host/client helpers for LanMountainDesktop.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user