mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
feat..去除了冗余的字体文件,又修改了PLONDS系统
This commit is contained in:
295
.github/workflows/plonds-comparator.yml
vendored
295
.github/workflows/plonds-comparator.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: PLONDS Comparator
|
name: PLONDS Comparator
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
|
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
|
||||||
@@ -9,7 +9,6 @@ on:
|
|||||||
types:
|
types:
|
||||||
- published
|
- published
|
||||||
- prereleased
|
- prereleased
|
||||||
- edited
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
@@ -17,7 +16,7 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
baseline_tag:
|
baseline_tag:
|
||||||
description: 'Optional baseline tag'
|
description: 'Optional baseline tag (auto-detected if omitted)'
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
channel:
|
channel:
|
||||||
@@ -28,12 +27,28 @@ on:
|
|||||||
options:
|
options:
|
||||||
- stable
|
- stable
|
||||||
- preview
|
- preview
|
||||||
|
compare_method:
|
||||||
|
description: '比较方法'
|
||||||
|
required: false
|
||||||
|
type: choice
|
||||||
|
default: file-compare
|
||||||
|
options:
|
||||||
|
- file-compare
|
||||||
|
- commit-analyze
|
||||||
|
hash_algorithm:
|
||||||
|
description: '哈希算法(仅 file-compare 模式)'
|
||||||
|
required: false
|
||||||
|
type: choice
|
||||||
|
default: sha256
|
||||||
|
options:
|
||||||
|
- sha256
|
||||||
|
- md5
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
compare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -48,6 +63,7 @@ jobs:
|
|||||||
- name: Resolve release context
|
- name: Resolve release context
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
if [[ "${{ github.event_name }}" == "release" ]]; then
|
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||||
TAG="${{ github.event.release.tag_name }}"
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
|
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
|
||||||
@@ -55,7 +71,9 @@ jobs:
|
|||||||
else
|
else
|
||||||
CHANNEL="stable"
|
CHANNEL="stable"
|
||||||
fi
|
fi
|
||||||
BASELINE_TAG=""
|
BASELINE_TAG_INPUT=""
|
||||||
|
COMPARE_METHOD="file-compare"
|
||||||
|
HASH_ALGORITHM="sha256"
|
||||||
else
|
else
|
||||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||||
if [[ "${RAW_TAG}" == v* ]]; then
|
if [[ "${RAW_TAG}" == v* ]]; then
|
||||||
@@ -64,18 +82,17 @@ jobs:
|
|||||||
TAG="v${RAW_TAG}"
|
TAG="v${RAW_TAG}"
|
||||||
fi
|
fi
|
||||||
CHANNEL="${{ github.event.inputs.channel }}"
|
CHANNEL="${{ github.event.inputs.channel }}"
|
||||||
BASELINE_TAG="${{ github.event.inputs.baseline_tag }}"
|
BASELINE_TAG_INPUT="${{ github.event.inputs.baseline_tag }}"
|
||||||
|
COMPARE_METHOD="${{ github.event.inputs.compare_method }}"
|
||||||
|
HASH_ALGORITHM="${{ github.event.inputs.hash_algorithm }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||||
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
|
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
|
||||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||||
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
|
echo "BASELINE_TAG_INPUT=${BASELINE_TAG_INPUT}" >> "$GITHUB_ENV"
|
||||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
echo "COMPARE_METHOD=${COMPARE_METHOD}" >> "$GITHUB_ENV"
|
||||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
echo "HASH_ALGORITHM=${HASH_ALGORITHM}" >> "$GITHUB_ENV"
|
||||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
|
||||||
fi
|
|
||||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE%/}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
@@ -83,181 +100,153 @@ jobs:
|
|||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
dotnet-quality: preview
|
dotnet-quality: preview
|
||||||
|
|
||||||
- name: Prepare signing key
|
|
||||||
env:
|
|
||||||
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
|
||||||
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
KEY="${PLONDS_SIGNING_KEY:-}"
|
|
||||||
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
|
|
||||||
if [[ -z "$KEY" ]]; then
|
|
||||||
echo "No signing key is configured."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$KEY" > update-private-key.pem
|
|
||||||
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build PLONDS tool
|
- name: Build PLONDS tool
|
||||||
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||||
|
|
||||||
- name: Resolve baseline plan
|
- name: Resolve baseline
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: pwsh
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
set -euo pipefail
|
||||||
$repo = '${{ github.repository }}'
|
BASELINE_TAG=""
|
||||||
$tag = $env:RELEASE_TAG
|
BASELINE_VERSION=""
|
||||||
$baselineInput = $env:BASELINE_TAG_INPUT
|
|
||||||
$currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json
|
|
||||||
$allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json
|
|
||||||
$platforms = @('windows-x64', 'windows-x86', 'linux-x64')
|
|
||||||
|
|
||||||
$entries = foreach ($platform in $platforms) {
|
if [[ -n "$BASELINE_TAG_INPUT" ]]; then
|
||||||
$assetName = "files-$platform.zip"
|
NORMALIZED="$BASELINE_TAG_INPUT"
|
||||||
$currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
|
if [[ "$NORMALIZED" != v* ]]; then NORMALIZED="v$NORMALIZED"; fi
|
||||||
if (-not $currentAsset) {
|
if gh release view "$NORMALIZED" --repo "${{ github.repository }}" --json tagName >/dev/null 2>&1; then
|
||||||
throw "Current release $tag does not contain required asset $assetName"
|
BASELINE_TAG="$NORMALIZED"
|
||||||
}
|
BASELINE_VERSION="${NORMALIZED#v}"
|
||||||
|
else
|
||||||
|
echo "Specified baseline tag not found: $NORMALIZED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
IS_PRERELEASE="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
|
||||||
|
CANDIDATES="$(gh api "repos/${{ github.repository }}/releases?per_page=50" \
|
||||||
|
--jq ".[] | select(.draft == false and .prerelease == ${IS_PRERELEASE} and .tag_name != \"${RELEASE_TAG}\") | .tag_name")"
|
||||||
|
|
||||||
$baselineRelease = $null
|
for CANDIDATE in $CANDIDATES; do
|
||||||
if (-not [string]::IsNullOrWhiteSpace($baselineInput)) {
|
if gh release download "$CANDIDATE" -p "files-windows-x64.zip" -D /tmp/baseline-check --clobber 2>/dev/null; then
|
||||||
$normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" }
|
BASELINE_TAG="$CANDIDATE"
|
||||||
$baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1
|
BASELINE_VERSION="${CANDIDATE#v}"
|
||||||
if (-not $baselineRelease) {
|
rm -rf /tmp/baseline-check
|
||||||
throw "Specified baseline tag not found: $normalizedBaseline"
|
break
|
||||||
}
|
fi
|
||||||
}
|
done
|
||||||
else {
|
fi
|
||||||
$baselineRelease = $allReleases |
|
|
||||||
Where-Object {
|
|
||||||
$_.tag_name -ne $tag -and
|
|
||||||
-not $_.draft -and
|
|
||||||
[bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and
|
|
||||||
($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0
|
|
||||||
} |
|
|
||||||
Select-Object -First 1
|
|
||||||
}
|
|
||||||
|
|
||||||
[pscustomobject]@{
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
platform = $platform
|
echo "BASELINE_TAG=${BASELINE_TAG}" >> "$GITHUB_ENV"
|
||||||
assetName = $assetName
|
echo "BASELINE_VERSION=${BASELINE_VERSION}" >> "$GITHUB_ENV"
|
||||||
baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null }
|
echo "Resolved baseline: ${BASELINE_TAG}"
|
||||||
baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null }
|
else
|
||||||
isFullPayload = -not $baselineRelease
|
echo "No baseline found. This will be a full update."
|
||||||
}
|
fi
|
||||||
}
|
|
||||||
|
|
||||||
$plan = [pscustomobject]@{
|
|
||||||
tag = $tag
|
|
||||||
version = $env:RELEASE_VERSION
|
|
||||||
channel = $env:RELEASE_CHANNEL
|
|
||||||
platforms = $entries
|
|
||||||
}
|
|
||||||
|
|
||||||
$plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8
|
|
||||||
Get-Content plonds-plan.json
|
|
||||||
|
|
||||||
- name: Download payload zips
|
- name: Download payload zips
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: pwsh
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
set -euo pipefail
|
||||||
$repo = '${{ github.repository }}'
|
mkdir -p plonds-input
|
||||||
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
|
|
||||||
|
|
||||||
foreach ($entry in $plan.platforms) {
|
gh release download "$RELEASE_TAG" -p "files-windows-x64.zip" -D plonds-input
|
||||||
$currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)"
|
mv plonds-input/files-windows-x64.zip plonds-input/current-files-windows-x64.zip
|
||||||
New-Item -ItemType Directory -Path $currentDir -Force | Out-Null
|
|
||||||
gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir
|
|
||||||
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) {
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
$baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)"
|
gh release download "$BASELINE_TAG" -p "files-windows-x64.zip" -D /tmp/baseline-dl
|
||||||
New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null
|
mv /tmp/baseline-dl/files-windows-x64.zip plonds-input/baseline-files-windows-x64.zip
|
||||||
gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir
|
fi
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Build delta assets
|
- name: Run build-delta (file-compare)
|
||||||
shell: pwsh
|
if: env.COMPARE_METHOD == 'file-compare'
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
set -euo pipefail
|
||||||
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
|
mkdir -p plonds-output
|
||||||
foreach ($entry in $plan.platforms) {
|
|
||||||
$currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)"
|
ARGS=(
|
||||||
$args = @(
|
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
|
||||||
'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--',
|
'--configuration' 'Release' '--'
|
||||||
'build-delta',
|
'build-delta'
|
||||||
'--platform', $entry.platform,
|
'--platform' 'windows-x64'
|
||||||
'--current-version', $plan.version,
|
'--current-version' "$RELEASE_VERSION"
|
||||||
'--current-tag', $plan.tag,
|
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
|
||||||
'--current-zip', $currentZip,
|
'--output-dir' "$PWD/plonds-output"
|
||||||
'--output-dir', 'plonds-output',
|
'--channel' "$RELEASE_CHANNEL"
|
||||||
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
|
'--hash-algorithm' "$HASH_ALGORITHM"
|
||||||
'--channel', $plan.channel,
|
)
|
||||||
'--static-output-dir', 'plonds-output/static',
|
|
||||||
'--update-base-url', $env:S3_PUBLIC_BASE_URL
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
|
ARGS+=(
|
||||||
|
'--baseline-version' "$BASELINE_VERSION"
|
||||||
|
'--baseline-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
|
||||||
)
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
if ([bool]$entry.isFullPayload) {
|
dotnet "${ARGS[@]}"
|
||||||
$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
|
- name: Run build-delta-from-commits (commit-analyze)
|
||||||
}
|
if: env.COMPARE_METHOD == 'commit-analyze'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p plonds-output
|
||||||
|
|
||||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- `
|
ARGS=(
|
||||||
build-index `
|
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
|
||||||
--release-tag $plan.tag `
|
'--configuration' 'Release' '--'
|
||||||
--version $plan.version `
|
'build-delta-from-commits'
|
||||||
--channel $plan.channel `
|
'--platform' 'windows-x64'
|
||||||
--platform-summaries-dir plonds-output/platform-summaries `
|
'--current-version' "$RELEASE_VERSION"
|
||||||
--output-dir plonds-output `
|
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
|
||||||
--private-key $env:UPDATE_PRIVATE_KEY_PATH
|
'--output-dir' "$PWD/plonds-output"
|
||||||
|
'--channel' "$RELEASE_CHANNEL"
|
||||||
|
'--baseline-tag' "${BASELINE_TAG:-v0.0.0}"
|
||||||
|
'--current-tag' "$RELEASE_TAG"
|
||||||
|
'--hash-algorithm' "$HASH_ALGORITHM"
|
||||||
|
)
|
||||||
|
|
||||||
foreach ($entry in $plan.platforms) {
|
if [[ -n "$BASELINE_TAG" ]]; then
|
||||||
$summary = Get-Content "plonds-output/platform-summaries/platform-summary-$($entry.platform).json" | ConvertFrom-Json
|
ARGS+=(
|
||||||
$required = @(
|
'--baseline-version' "$BASELINE_VERSION"
|
||||||
"plonds-output/static/meta/channels/$($plan.channel)/$($entry.platform)/latest.json",
|
'--fallback-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
|
||||||
"plonds-output/static/meta/distributions/$($summary.distributionId).json",
|
|
||||||
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json",
|
|
||||||
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json.sig"
|
|
||||||
)
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
foreach ($path in $required) {
|
dotnet "${ARGS[@]}"
|
||||||
if (-not (Test-Path $path)) {
|
|
||||||
throw "Missing PLONDS static output: $path"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$objects = Get-ChildItem -Path "plonds-output/static/repo/sha256" -File -Recurse -ErrorAction SilentlyContinue
|
- name: Validate output
|
||||||
if (-not $objects -or $objects.Count -eq 0) {
|
shell: bash
|
||||||
throw "PLONDS static object repository is empty."
|
run: |
|
||||||
}
|
set -euo pipefail
|
||||||
|
if [[ ! -f plonds-output/changed.zip ]]; then
|
||||||
|
echo "Missing output: changed.zip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f plonds-output/PLONDS.json ]]; then
|
||||||
|
echo "Missing output: PLONDS.json"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
jq -e . plonds-output/PLONDS.json >/dev/null
|
||||||
|
|
||||||
Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force
|
- name: Upload to GitHub Release
|
||||||
|
|
||||||
- name: Upload PLONDS assets to release
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber
|
gh release upload "$RELEASE_TAG" plonds-output/changed.zip plonds-output/PLONDS.json --clobber
|
||||||
|
|
||||||
- name: Persist run metadata
|
- name: Persist run metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mkdir -p plonds-run-metadata
|
mkdir -p plonds-run-metadata
|
||||||
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
|
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
|
||||||
|
printf '%s' "$COMPARE_METHOD" >> plonds-run-metadata/tag.txt
|
||||||
|
|
||||||
- name: Upload run metadata artifact
|
- name: Upload run metadata artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -266,11 +255,3 @@ jobs:
|
|||||||
path: plonds-run-metadata/tag.txt
|
path: plonds-run-metadata/tag.txt
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload PLONDS static artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: plonds-static
|
|
||||||
path: plonds-output/static/**
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 7
|
|
||||||
|
|||||||
512
.trae/specs/plonds-comparator-redesign/spec.md
Normal file
512
.trae/specs/plonds-comparator-redesign/spec.md
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
# PLONDS Comparator 改造设计
|
||||||
|
|
||||||
|
> 日期:2026-05-30
|
||||||
|
> 状态:待审批
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
PLONDS(Penguin Logistics Online Network Distribution System)是 LanMountainDesktop 的文件驱动式分布式更新系统。当前 Comparator 工作流存在以下问题:
|
||||||
|
|
||||||
|
1. **产出物过于复杂**:生成 `update-{platform}.zip`、`plonds-filemap-{platform}.json`、`plonds-filemap-{platform}.json.sig`、`platform-summary-{platform}.json`、`plonds-static.zip` 等多个文件,客户端消费困难
|
||||||
|
2. **模型定义重复**:`Plonds.Shared`、`Plonds.Core`、宿主侧、Launcher 侧各自定义独立的 DTO,字段名不一致
|
||||||
|
3. **签名机制过重**:RSA 签名增加了 CI 复杂度(需要管理密钥),且对文件驱动式更新系统而言 SHA256 哈希校验已足够
|
||||||
|
4. **平台覆盖不当**:Linux 平台不需要 PLONDS 支持,macOS 尚未接入,但代码中硬编码了三个平台
|
||||||
|
5. **工作流间 artifact 传递脆弱**:Comparator → Publisher 通过 artifact 传递 `plonds-static.zip`,容易断裂
|
||||||
|
|
||||||
|
## 2. 设计目标
|
||||||
|
|
||||||
|
- 产出物精简为两个文件:`changed.zip` + `PLONDS.json`
|
||||||
|
- 去掉 RSA 签名,只用 SHA256/MD5 校验
|
||||||
|
- 只关注 Windows 平台
|
||||||
|
- 统一模型定义,消除 DTO 重复
|
||||||
|
- 保持 Comparator 和 Publisher 两个工作流的职责分离
|
||||||
|
|
||||||
|
## 3. 新产出物定义
|
||||||
|
|
||||||
|
### 3.1 changed.zip
|
||||||
|
|
||||||
|
只包含与上一版本有差异的文件(action 为 `add` 或 `replace` 的文件),目录结构与部署目录一致。
|
||||||
|
|
||||||
|
### 3.2 PLONDS.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"formatVersion": "2.0",
|
||||||
|
"currentVersion": "1.2.0",
|
||||||
|
"previousVersion": "1.1.0",
|
||||||
|
"isFullUpdate": false,
|
||||||
|
"requiresCleanInstall": false,
|
||||||
|
"channel": "stable",
|
||||||
|
"platform": "windows-x64",
|
||||||
|
"updatedAt": "2026-05-30T12:00:00Z",
|
||||||
|
|
||||||
|
"filesMap": {
|
||||||
|
"LanMountainDesktop.exe": {
|
||||||
|
"action": "replace",
|
||||||
|
"sha256": "abc123...",
|
||||||
|
"size": 1024000
|
||||||
|
},
|
||||||
|
"LanMountainDesktop.dll": {
|
||||||
|
"action": "reuse",
|
||||||
|
"sha256": "def456...",
|
||||||
|
"size": 512000
|
||||||
|
},
|
||||||
|
"OldModule.dll": {
|
||||||
|
"action": "delete",
|
||||||
|
"sha256": "",
|
||||||
|
"size": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"changedFilesMap": {
|
||||||
|
"LanMountainDesktop.exe": {
|
||||||
|
"archivePath": "LanMountainDesktop.exe",
|
||||||
|
"sha256": "abc123...",
|
||||||
|
"size": 1024000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"checksums": {
|
||||||
|
"changed.zip": "md5:9a8b7c6d..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 字段语义
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `formatVersion` | string | 协议版本,固定 `"2.0"` |
|
||||||
|
| `currentVersion` | string | 当前发布版本 |
|
||||||
|
| `previousVersion` | string | 基线版本(全量更新时为 `"0.0.0"`) |
|
||||||
|
| `isFullUpdate` | bool | 是否为全量更新(找不到基线版本时为 true) |
|
||||||
|
| `requiresCleanInstall` | bool | 启动器是否也更新了——如果是,客户端不走增量流程,让用户重新运行安装器 |
|
||||||
|
| `channel` | string | 更新通道:`stable` 或 `preview` |
|
||||||
|
| `platform` | string | 平台标识:`windows-x64` |
|
||||||
|
| `updatedAt` | string | ISO 8601 时间戳 |
|
||||||
|
| `filesMap` | object | 全量文件图:每个文件的 action + sha256 + size |
|
||||||
|
| `changedFilesMap` | object | 变更文件图:只包含需要从 changed.zip 解压的文件 |
|
||||||
|
| `checksums` | object | 产出物的 MD5 值 |
|
||||||
|
|
||||||
|
### 3.4 filesMap 中 action 的值
|
||||||
|
|
||||||
|
| Action | 含义 | changed.zip 中是否包含 |
|
||||||
|
|--------|------|----------------------|
|
||||||
|
| `add` | 新增文件 | ✅ |
|
||||||
|
| `replace` | 替换文件 | ✅ |
|
||||||
|
| `reuse` | 复用上一版本文件 | ❌ |
|
||||||
|
| `delete` | 删除文件 | ❌ |
|
||||||
|
|
||||||
|
### 3.5 requiresCleanInstall 判断逻辑
|
||||||
|
|
||||||
|
比较 `LanMountainDesktop.Launcher.exe` 在当前版本和基线版本中的 SHA256:
|
||||||
|
- 如果 SHA256 不同 → `requiresCleanInstall = true`
|
||||||
|
- 如果 SHA256 相同或没有基线版本 → `requiresCleanInstall = false`
|
||||||
|
|
||||||
|
## 4. Plonds.Tool build-delta 命令改造
|
||||||
|
|
||||||
|
### 4.1 新命令签名
|
||||||
|
|
||||||
|
```
|
||||||
|
build-delta --platform <platform>
|
||||||
|
--current-version <version>
|
||||||
|
--current-zip <file>
|
||||||
|
--output-dir <dir>
|
||||||
|
--channel <channel>
|
||||||
|
[--baseline-version <version>]
|
||||||
|
[--baseline-zip <file>]
|
||||||
|
[--launcher-path <relative-path>]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 参数说明
|
||||||
|
|
||||||
|
| 参数 | 必需 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--platform` | 是 | 平台标识,如 `windows-x64` |
|
||||||
|
| `--current-version` | 是 | 当前发布版本号 |
|
||||||
|
| `--current-zip` | 是 | 当前版本的 payload zip 路径 |
|
||||||
|
| `--output-dir` | 是 | 输出目录 |
|
||||||
|
| `--channel` | 是 | 更新通道 |
|
||||||
|
| `--baseline-version` | 否 | 基线版本号(省略则视为全量更新) |
|
||||||
|
| `--baseline-zip` | 否 | 基线版本的 payload zip 路径(省略则视为全量更新) |
|
||||||
|
| `--launcher-path` | 否 | Launcher 可执行文件的相对路径,默认 `LanMountainDesktop.Launcher.exe` |
|
||||||
|
|
||||||
|
### 4.3 删除的参数
|
||||||
|
|
||||||
|
| 参数 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| `--current-tag` | 不再需要,version 就够了 |
|
||||||
|
| `--private-key` | 去掉签名 |
|
||||||
|
| `--is-full-payload` | 自动判断:没有 baseline-zip 就是全量 |
|
||||||
|
| `--static-output-dir` | 不再生成 S3 静态布局 |
|
||||||
|
| `--update-base-url` | 不再生成 S3 URL |
|
||||||
|
| `--baseline-tag` | 不再需要 |
|
||||||
|
|
||||||
|
### 4.4 内部逻辑
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 解压 current-zip → currentDir
|
||||||
|
2. 如果有 baseline-zip → 解压 → baselineDir
|
||||||
|
否则 → baselineDir = 空(全量更新)
|
||||||
|
|
||||||
|
3. 扫描 currentDir → 计算 SHA256
|
||||||
|
4. 扫描 baselineDir → 计算 SHA256(如果有)
|
||||||
|
|
||||||
|
5. 对比生成 filesMap:
|
||||||
|
- 两个版本都有且 SHA256 相同 → reuse
|
||||||
|
- 两个版本都有但 SHA256 不同 → replace
|
||||||
|
- 只在新版本中存在 → add
|
||||||
|
- 只在旧版本中存在 → delete
|
||||||
|
|
||||||
|
6. 从 filesMap 提取 changedFilesMap:
|
||||||
|
- 只包含 action=add/replace 的条目
|
||||||
|
- 添加 archivePath(在 changed.zip 中的路径)
|
||||||
|
|
||||||
|
7. 打包 changed.zip:
|
||||||
|
- 只包含 add/replace 的文件
|
||||||
|
- 保持原始目录结构
|
||||||
|
|
||||||
|
8. 判断 requiresCleanInstall:
|
||||||
|
- 比较 Launcher 可执行文件在两个版本中的 SHA256
|
||||||
|
- 如果不同 → requiresCleanInstall=true
|
||||||
|
|
||||||
|
9. 计算 changed.zip 的 MD5
|
||||||
|
|
||||||
|
10. 生成 PLONDS.json
|
||||||
|
|
||||||
|
11. 输出到 output-dir:
|
||||||
|
- changed.zip
|
||||||
|
- PLONDS.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 不再生成的产物
|
||||||
|
|
||||||
|
| 旧产物 | 处置 |
|
||||||
|
|--------|------|
|
||||||
|
| `update-{platform}.zip` | 被 `changed.zip` 替代 |
|
||||||
|
| `plonds-filemap-{platform}.json` | 被 `PLONDS.json` 替代 |
|
||||||
|
| `plonds-filemap-{platform}.json.sig` | 去掉签名 |
|
||||||
|
| `platform-summary-{platform}.json` | 不再需要 |
|
||||||
|
| `plonds-static.zip` | 不再生成 S3 静态布局 |
|
||||||
|
| `meta/channels/...` | 不再由 Tool 生成,由 Publisher 负责 |
|
||||||
|
|
||||||
|
## 5. Plonds.Shared 模型改造
|
||||||
|
|
||||||
|
### 5.1 删除的模型
|
||||||
|
|
||||||
|
| 模型 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| `PlondsFileMap` | 被新的 `PlondsManifest` 替代 |
|
||||||
|
| `PlondsFileEntry` | 被新的 `PlondsFileEntry` 替代 |
|
||||||
|
| `PlondsComponent` | 不再有组件概念 |
|
||||||
|
| `PlondsDistributionInfo` | 不再生成分发文档 |
|
||||||
|
| `PlondsChannelPointer` | 由 Publisher 用脚本生成 |
|
||||||
|
| `PlondsReleaseManifest` | 不再需要 |
|
||||||
|
| `PlondsReleasePlatformEntry` | 不再需要 |
|
||||||
|
| `PlondsSignatureDescriptor` | 去掉签名 |
|
||||||
|
| `PlondsMirrorAsset` | 由 Publisher 处理 |
|
||||||
|
| `PlondsMirrorEntry` | 由 Publisher 处理 |
|
||||||
|
| `PlondsMetadataCatalog` | 不再需要 |
|
||||||
|
| `PlondsAssetEntry` | 不再需要 |
|
||||||
|
|
||||||
|
### 5.2 新模型定义
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// PlondsManifest — 对应 PLONDS.json
|
||||||
|
public sealed record PlondsManifest(
|
||||||
|
string FormatVersion,
|
||||||
|
string CurrentVersion,
|
||||||
|
string PreviousVersion,
|
||||||
|
bool IsFullUpdate,
|
||||||
|
bool RequiresCleanInstall,
|
||||||
|
string Channel,
|
||||||
|
string Platform,
|
||||||
|
DateTimeOffset UpdatedAt,
|
||||||
|
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
|
||||||
|
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
|
||||||
|
IReadOnlyDictionary<string, string> Checksums);
|
||||||
|
|
||||||
|
// PlondsFileEntry — filesMap 中的条目
|
||||||
|
public sealed record PlondsFileEntry(
|
||||||
|
string Action, // add | replace | reuse | delete
|
||||||
|
string Sha256,
|
||||||
|
long Size);
|
||||||
|
|
||||||
|
// PlondsChangedFileEntry — changedFilesMap 中的条目
|
||||||
|
public sealed record PlondsChangedFileEntry(
|
||||||
|
string ArchivePath, // 在 changed.zip 中的路径
|
||||||
|
string Sha256,
|
||||||
|
long Size);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 设计决策
|
||||||
|
|
||||||
|
- `FilesMap` 和 `ChangedFilesMap` 用 `IReadOnlyDictionary<string, T>` 而非 `IReadOnlyList<T>`,key 就是文件相对路径,查找 O(1)
|
||||||
|
- 去掉 `Component` 概念——当前只有一个 `app` 组件,分层没有实际意义
|
||||||
|
- `FormatVersion` 固定为 `"2.0"`,与旧格式区分
|
||||||
|
|
||||||
|
## 6. Comparator 工作流改造
|
||||||
|
|
||||||
|
### 6.1 保留两个工作流
|
||||||
|
|
||||||
|
- **Comparator**(`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json`
|
||||||
|
- **Publisher**(`plonds-publisher.yml`,原 `plonds-uploader.yml`):发布器,负责上传到 S3 和生成 channel pointer
|
||||||
|
|
||||||
|
### 6.2 Comparator 改造后步骤
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# plonds-comparator.yml
|
||||||
|
触发: release.published / release.prereleased / workflow_dispatch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
compare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- Checkout
|
||||||
|
|
||||||
|
- 解析发布上下文
|
||||||
|
→ RELEASE_TAG, RELEASE_VERSION, RELEASE_CHANNEL
|
||||||
|
|
||||||
|
- Setup .NET
|
||||||
|
|
||||||
|
- 构建 PLONDS Tool
|
||||||
|
|
||||||
|
- 解析基线版本
|
||||||
|
→ 查找上一个同频道 Release
|
||||||
|
→ 如果有 → 记录 baseline_tag, baseline_version
|
||||||
|
→ 如果没有 → is_full_update=true
|
||||||
|
|
||||||
|
- 下载 payload zips
|
||||||
|
→ 下载当前版本 files-windows-x64.zip
|
||||||
|
→ 下载基线版本 files-windows-x64.zip (如果有)
|
||||||
|
|
||||||
|
- 运行 build-delta
|
||||||
|
→ dotnet run Plonds.Tool -- build-delta \
|
||||||
|
--platform windows-x64 \
|
||||||
|
--current-version $VERSION \
|
||||||
|
--current-zip files-windows-x64.zip \
|
||||||
|
--output-dir plonds-output \
|
||||||
|
--channel $CHANNEL \
|
||||||
|
[--baseline-version $BASELINE_VERSION] \
|
||||||
|
[--baseline-zip baseline-files-windows-x64.zip]
|
||||||
|
|
||||||
|
- 上传到 GitHub Release
|
||||||
|
→ gh release upload changed.zip PLONDS.json
|
||||||
|
|
||||||
|
- 传递元数据给 Publisher
|
||||||
|
→ 上传 artifact: plonds-run-metadata (tag.txt)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 与当前步骤的差异
|
||||||
|
|
||||||
|
| 当前步骤 | 改造后 |
|
||||||
|
|---------|--------|
|
||||||
|
| 准备签名密钥 | ❌ 删除 |
|
||||||
|
| 解析基线计划 (pwsh,三平台) | ✅ 简化:只找 Windows,逻辑简化 |
|
||||||
|
| 下载 payload zips (pwsh,三平台) | ✅ 简化:只下载 Windows |
|
||||||
|
| 构建增量资产 (pwsh,含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
|
||||||
|
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
|
||||||
|
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
|
||||||
|
|
||||||
|
## 7. 双模式差分生成
|
||||||
|
|
||||||
|
### 7.1 概述
|
||||||
|
|
||||||
|
Comparator 支持两种差分生成方法,通过 `workflow_dispatch` 的 `compare_method` 输入项选择:
|
||||||
|
|
||||||
|
| 方法 | 标识 | 核心思路 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 方法一 | `file-compare` | 下载两个版本的 files zip,全量文件哈希对比 |
|
||||||
|
| 方法二 | `commit-analyze` | 分析两个版本之间的 git commit,映射源码变更到产物文件 |
|
||||||
|
|
||||||
|
### 7.2 GitHub Actions 触发器新增输入项
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag: ...
|
||||||
|
baseline_tag: ...
|
||||||
|
channel: ...
|
||||||
|
compare_method: # 新增
|
||||||
|
description: '比较方法'
|
||||||
|
type: choice
|
||||||
|
default: file-compare
|
||||||
|
options:
|
||||||
|
- file-compare
|
||||||
|
- commit-analyze
|
||||||
|
hash_algorithm: # 新增(仅方法一)
|
||||||
|
description: '哈希算法'
|
||||||
|
type: choice
|
||||||
|
default: sha256
|
||||||
|
options:
|
||||||
|
- sha256
|
||||||
|
- md5
|
||||||
|
```
|
||||||
|
|
||||||
|
当由 `release` 事件触发时,默认使用 `file-compare` + `sha256`。
|
||||||
|
|
||||||
|
### 7.3 方法一:文件对比模式(file-compare)
|
||||||
|
|
||||||
|
**流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 下载当前版本 files-windows-x64.zip
|
||||||
|
2. 下载基线版本 files-windows-x64.zip(如果有)
|
||||||
|
3. 解压两个 zip 到临时目录
|
||||||
|
4. 用指定哈希算法(sha256/md5)扫描两个目录的所有文件
|
||||||
|
5. 对比哈希值,生成 filesMap(add/replace/reuse/delete)
|
||||||
|
6. 从当前版本目录中提取 add/replace 的文件 → changed.zip
|
||||||
|
7. 生成 PLONDS.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**PlondsDeltaBuildOptions 新增参数:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
string HashAlgorithm = "sha256" // "sha256" | "md5"
|
||||||
|
```
|
||||||
|
|
||||||
|
**哈希算法对 PLONDS.json 的影响:**
|
||||||
|
|
||||||
|
- `sha256`:`filesMap` 和 `changedFilesMap` 中使用 `sha256` 字段
|
||||||
|
- `md5`:`filesMap` 和 `changedFilesMap` 中使用 `md5` 字段
|
||||||
|
|
||||||
|
### 7.4 方法二:Commit 分析模式(commit-analyze)
|
||||||
|
|
||||||
|
**流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 下载当前版本 files-windows-x64.zip
|
||||||
|
2. 解压到临时目录
|
||||||
|
3. git log --name-only baseline_tag..current_tag
|
||||||
|
→ 得到两个版本之间的 commit 列表和涉及的源码文件
|
||||||
|
4. 过滤:只保留源码目录下的文件
|
||||||
|
5. 用简单规则映射源码文件到产物文件
|
||||||
|
6. 从当前版本的解压目录中提取映射到的产物文件 → changed.zip
|
||||||
|
7. 生成 PLONDS.json
|
||||||
|
8. 如果没有源码变更 → 自动回退到方法一
|
||||||
|
```
|
||||||
|
|
||||||
|
**源码目录过滤规则:**
|
||||||
|
|
||||||
|
只分析以下目录下的文件变更:
|
||||||
|
|
||||||
|
| 目录 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `LanMountainDesktop/` | 主宿主应用 |
|
||||||
|
| `LanMountainDesktop.Launcher/` | 启动器 |
|
||||||
|
| `LanMountainDesktop.Shared.Contracts/` | 共享契约 |
|
||||||
|
| `LanMountainDesktop.PluginSdk/` | 插件 SDK |
|
||||||
|
| `LanMountainDesktop.Appearance/` | 外观系统 |
|
||||||
|
| `LanMountainDesktop.Settings.Core/` | 设置核心 |
|
||||||
|
| `LanMountainDesktop.ComponentSystem/` | 组件系统 |
|
||||||
|
|
||||||
|
忽略的目录:`docs/`、`scripts/`、`.github/`、`.trae/`、`PenguinLogisticsOnlineNetworkDistributionSystem/`
|
||||||
|
|
||||||
|
**源码到产物的映射规则:**
|
||||||
|
|
||||||
|
| 源码路径模式 | 映射到产物文件 |
|
||||||
|
|-------------|--------------|
|
||||||
|
| `LanMountainDesktop/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.dll`, `LanMountainDesktop.exe` |
|
||||||
|
| `LanMountainDesktop.Launcher/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.Launcher.exe` |
|
||||||
|
| `LanMountainDesktop.Shared.Contracts/**/*.cs` | `LanMountainDesktop.Shared.Contracts.dll` |
|
||||||
|
| `LanMountainDesktop.PluginSdk/**/*.cs` | `LanMountainDesktop.PluginSdk.dll` |
|
||||||
|
| `LanMountainDesktop.Appearance/**/*.cs` | `LanMountainDesktop.Appearance.dll` |
|
||||||
|
| `LanMountainDesktop.Settings.Core/**/*.cs` | `LanMountainDesktop.Settings.Core.dll` |
|
||||||
|
| `LanMountainDesktop.ComponentSystem/**/*.cs` | `LanMountainDesktop.ComponentSystem.dll` |
|
||||||
|
| `**/*.json`(配置文件) | 同路径的 .json |
|
||||||
|
| 其他无法映射的变更 | 保守标记 → 所有核心 .dll/.exe |
|
||||||
|
|
||||||
|
**方法二在 Plonds.Tool 中的新命令:**
|
||||||
|
|
||||||
|
```
|
||||||
|
build-delta-from-commits --platform <platform>
|
||||||
|
--current-version <version>
|
||||||
|
--current-zip <file>
|
||||||
|
--output-dir <dir>
|
||||||
|
--channel <channel>
|
||||||
|
--baseline-tag <tag>
|
||||||
|
--current-tag <tag>
|
||||||
|
[--source-dirs <dir1,dir2,...>]
|
||||||
|
[--fallback-zip <file>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必需 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--platform` | 是 | 平台标识 |
|
||||||
|
| `--current-version` | 是 | 当前发布版本号 |
|
||||||
|
| `--current-zip` | 是 | 当前版本的 payload zip |
|
||||||
|
| `--output-dir` | 是 | 输出目录 |
|
||||||
|
| `--channel` | 是 | 更新通道 |
|
||||||
|
| `--baseline-tag` | 是 | 基线版本的 git tag |
|
||||||
|
| `--current-tag` | 是 | 当前版本的 git tag |
|
||||||
|
| `--source-dirs` | 否 | 要分析的源码目录列表(逗号分隔) |
|
||||||
|
| `--fallback-zip` | 否 | 回退到方法一时使用的基线 zip |
|
||||||
|
|
||||||
|
**回退逻辑:**
|
||||||
|
|
||||||
|
如果 `git log` 分析后发现没有源码目录下的文件变更(比如只有 docs/ 变更),则自动回退到方法一:
|
||||||
|
1. 如果提供了 `--fallback-zip` → 用方法一对比两个 zip
|
||||||
|
2. 如果没有提供 → 生成全量更新(`isFullUpdate=true`)
|
||||||
|
|
||||||
|
### 7.5 方法二的 PLONDS.json 特殊处理
|
||||||
|
|
||||||
|
方法二无法像方法一那样生成完整的 `filesMap`(因为不知道哪些文件是 reuse 的),因此:
|
||||||
|
|
||||||
|
- `filesMap` 只包含映射到的变更文件(标记为 `add` 或 `replace`)
|
||||||
|
- 不包含 `reuse` 和 `delete` 条目
|
||||||
|
- `isFullUpdate` 始终为 `false`(除非回退到方法一且无基线)
|
||||||
|
- `requiresCleanInstall` 根据 Launcher.exe 是否在映射到的变更文件列表中判断
|
||||||
|
|
||||||
|
### 7.6 工作流中的条件分支
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Run build-delta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "$COMPARE_METHOD" == "commit-analyze" ]]; then
|
||||||
|
# 方法二
|
||||||
|
dotnet run --project ... -- build-delta-from-commits \
|
||||||
|
--platform windows-x64 \
|
||||||
|
--current-version $RELEASE_VERSION \
|
||||||
|
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
|
||||||
|
--output-dir $PWD/plonds-output \
|
||||||
|
--channel $RELEASE_CHANNEL \
|
||||||
|
--baseline-tag $BASELINE_TAG \
|
||||||
|
--current-tag $RELEASE_TAG \
|
||||||
|
--fallback-zip $PWD/plonds-input/baseline-files-windows-x64.zip
|
||||||
|
else
|
||||||
|
# 方法一
|
||||||
|
dotnet run --project ... -- build-delta \
|
||||||
|
--platform windows-x64 \
|
||||||
|
--current-version $RELEASE_VERSION \
|
||||||
|
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
|
||||||
|
--output-dir $PWD/plonds-output \
|
||||||
|
--channel $RELEASE_CHANNEL \
|
||||||
|
--hash-algorithm $HASH_ALGORITHM \
|
||||||
|
--baseline-version $BASELINE_VERSION \
|
||||||
|
--baseline-zip $PWD/plonds-input/baseline-files-windows-x64.zip
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
方法二时,基线 zip 仍然需要下载(用于回退),但不需要解压(除非回退)。
|
||||||
|
|
||||||
|
### 7.7 两种方法的步骤差异
|
||||||
|
|
||||||
|
| 步骤 | 方法一 (file-compare) | 方法二 (commit-analyze) |
|
||||||
|
|------|----------------------|------------------------|
|
||||||
|
| 下载基线 zip | ✅ 需要 | ✅ 需要(用于回退) |
|
||||||
|
| 下载当前 zip | ✅ | ✅ |
|
||||||
|
| 解压两个 zip | ✅ | ✅ 只解压当前(回退时解压基线) |
|
||||||
|
| git diff/log | ❌ | ✅ 需要 fetch-depth:0 |
|
||||||
|
| 哈希对比 | ✅ 两个目录全量扫描 | ❌ 不做(除非回退) |
|
||||||
|
| 源码→产物映射 | ❌ | ✅ |
|
||||||
|
| 回退逻辑 | ❌ | ✅ 无源码变更时回退方法一 |
|
||||||
|
|
||||||
|
## 8. 不在本次改造范围内的事项
|
||||||
|
|
||||||
|
- Publisher 工作流改造(后续单独设计)
|
||||||
|
- Rollback 工作流改造(后续单独设计)
|
||||||
|
- 宿主侧客户端代码改造(PlondsUpdateApplier 等,后续单独设计)
|
||||||
|
- Launcher 侧客户端代码改造(后续单独设计)
|
||||||
|
- Plonds.Api 项目处置(后续决定是否保留)
|
||||||
|
- `build-index`、`build-plonds`、`generate`、`publish`、`sign`、`pack-payload` 等 Tool 命令的清理(后续处理)
|
||||||
14
CheckIpcAot/CheckIpcAot.csproj
Normal file
14
CheckIpcAot/CheckIpcAot.csproj
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="dotnetCampus.Ipc" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
10
CheckIpcAot/Program.cs
Normal file
10
CheckIpcAot/Program.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
[IpcPublic]
|
||||||
|
public interface IMyService {
|
||||||
|
Task<MyResult> DoWork(MyRequest req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MyResult { public string Msg {get;set;} }
|
||||||
|
public class MyRequest { public string Data {get;set;} }
|
||||||
@@ -39,4 +39,9 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSerializable(typeof(PrivacyAgreementState))]
|
[JsonSerializable(typeof(PrivacyAgreementState))]
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
||||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
||||||
|
[JsonSerializable(typeof(AirAppOpenRequest))]
|
||||||
|
[JsonSerializable(typeof(AirAppRegistrationRequest))]
|
||||||
|
[JsonSerializable(typeof(AirAppInstanceInfo))]
|
||||||
|
[JsonSerializable(typeof(AirAppOperationResult))]
|
||||||
|
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
|
||||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
|
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||||
<!-- 忽略某些警告 -->
|
<!-- 忽略某些警告 -->
|
||||||
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||||
@@ -60,7 +61,8 @@
|
|||||||
|
|
||||||
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
|
||||||
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
|
||||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
<!-- [Fix]: 必须设置为 true 以支持 dotnetCampus.Ipc 内部的反射序列化。相关类型的剪裁保护通过 AppJsonContext 保证 -->
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
|
||||||
<!-- 启用 ISerializable 支持(部分库需要) -->
|
<!-- 启用 ISerializable 支持(部分库需要) -->
|
||||||
<IsAotCompatible>true</IsAotCompatible>
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
|
|||||||
@@ -146,15 +146,10 @@ public sealed class WindowLayerIsolationTests
|
|||||||
public void FusedDesktopWindows_KeepDesktopBottomMostBoundary()
|
public void FusedDesktopWindows_KeepDesktopBottomMostBoundary()
|
||||||
{
|
{
|
||||||
var desktopWidgetWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "DesktopWidgetWindow.axaml.cs");
|
var desktopWidgetWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "DesktopWidgetWindow.axaml.cs");
|
||||||
var transparentOverlayWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "TransparentOverlayWindow.axaml.cs");
|
|
||||||
|
|
||||||
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", desktopWidgetWindow);
|
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", desktopWidgetWindow);
|
||||||
Assert.Contains("RefreshDesktopLayer", desktopWidgetWindow);
|
Assert.Contains("RefreshDesktopLayer", desktopWidgetWindow);
|
||||||
Assert.Contains("SendToBottom", desktopWidgetWindow);
|
Assert.Contains("SendToBottom", desktopWidgetWindow);
|
||||||
|
|
||||||
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", transparentOverlayWindow);
|
|
||||||
Assert.Contains("RefreshDesktopLayer", transparentOverlayWindow);
|
|
||||||
Assert.Contains("SendToBottom", transparentOverlayWindow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -75,9 +75,7 @@ public partial class App : Application
|
|||||||
private DispatcherTimer? _shellRecoveryTimer;
|
private DispatcherTimer? _shellRecoveryTimer;
|
||||||
private PluginRuntimeService? _pluginRuntimeService;
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
private MainWindow? _mainWindow;
|
private MainWindow? _mainWindow;
|
||||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
|
||||||
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
|
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
|
||||||
private bool _isExitingFusedDesktopEditMode;
|
|
||||||
private bool _mainWindowClosed;
|
private bool _mainWindowClosed;
|
||||||
private DesktopShellHost? _desktopShellHost;
|
private DesktopShellHost? _desktopShellHost;
|
||||||
private PublicIpcHostService? _publicIpcHostService;
|
private PublicIpcHostService? _publicIpcHostService;
|
||||||
@@ -454,22 +452,10 @@ public partial class App : Application
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate();
|
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
|
||||||
fusedDesktopManager.EnterEditMode();
|
|
||||||
|
|
||||||
EnsureTransparentOverlayWindow();
|
|
||||||
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow.Show();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_fusedComponentLibraryWindow is { } existingWindow)
|
if (_fusedComponentLibraryWindow is { } existingWindow)
|
||||||
{
|
{
|
||||||
if (_transparentOverlayWindow is not null)
|
|
||||||
{
|
|
||||||
existingWindow.SetOverlayWindow(_transparentOverlayWindow);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existingWindow.IsVisible)
|
if (!existingWindow.IsVisible)
|
||||||
{
|
{
|
||||||
existingWindow.Show();
|
existingWindow.Show();
|
||||||
@@ -477,7 +463,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
if (centerInWorkArea)
|
if (centerInWorkArea)
|
||||||
{
|
{
|
||||||
existingWindow.CenterInWorkArea(_transparentOverlayWindow);
|
existingWindow.CenterInWorkArea();
|
||||||
}
|
}
|
||||||
|
|
||||||
existingWindow.Activate();
|
existingWindow.Activate();
|
||||||
@@ -486,16 +472,12 @@ public partial class App : Application
|
|||||||
|
|
||||||
var window = new FusedDesktopComponentLibraryWindow();
|
var window = new FusedDesktopComponentLibraryWindow();
|
||||||
_fusedComponentLibraryWindow = window;
|
_fusedComponentLibraryWindow = window;
|
||||||
if (_transparentOverlayWindow is not null)
|
|
||||||
{
|
|
||||||
window.SetOverlayWindow(_transparentOverlayWindow);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Closed += OnFusedComponentLibraryWindowClosed;
|
window.Closed += OnFusedComponentLibraryWindowClosed;
|
||||||
window.Show();
|
window.Show();
|
||||||
if (centerInWorkArea)
|
if (centerInWorkArea)
|
||||||
{
|
{
|
||||||
window.CenterInWorkArea(_transparentOverlayWindow);
|
window.CenterInWorkArea();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Activate();
|
window.Activate();
|
||||||
@@ -503,7 +485,13 @@ public partial class App : Application
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
|
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||||
|
if (_fusedComponentLibraryWindow is { } libWindow)
|
||||||
|
{
|
||||||
|
_fusedComponentLibraryWindow = null;
|
||||||
|
libWindow.Closed -= OnFusedComponentLibraryWindowClosed;
|
||||||
|
libWindow.Close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,50 +508,13 @@ public partial class App : Application
|
|||||||
_fusedComponentLibraryWindow = null;
|
_fusedComponentLibraryWindow = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.PreserveEditModeOnClose && !_isExitingFusedDesktopEditMode)
|
|
||||||
{
|
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ExitFusedDesktopEditModeFromUi(bool closeLibrary)
|
|
||||||
{
|
|
||||||
if (_isExitingFusedDesktopEditMode)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isExitingFusedDesktopEditMode = true;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (closeLibrary && _fusedComponentLibraryWindow is { } libraryWindow)
|
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||||
{
|
|
||||||
_fusedComponentLibraryWindow = null;
|
|
||||||
libraryWindow.Closed -= OnFusedComponentLibraryWindowClosed;
|
|
||||||
libraryWindow.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow?.SaveLayoutAndHide();
|
|
||||||
}
|
|
||||||
catch (Exception overlayEx)
|
|
||||||
{
|
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay.", overlayEx);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
|
||||||
}
|
|
||||||
catch (Exception exitEx)
|
|
||||||
{
|
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode.", exitEx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_isExitingFusedDesktopEditMode = false;
|
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode after library closed.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,11 +841,6 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
|
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
|
||||||
|
|
||||||
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow.Hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||||
mainWindow.PrepareEnterAnimation();
|
mainWindow.PrepareEnterAnimation();
|
||||||
|
|
||||||
@@ -938,26 +884,6 @@ public partial class App : Application
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureTransparentOverlayWindow()
|
|
||||||
{
|
|
||||||
if (_transparentOverlayWindow is null)
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow = new TransparentOverlayWindow();
|
|
||||||
_transparentOverlayWindow.RestoreMainWindowRequested += (s, e) =>
|
|
||||||
{
|
|
||||||
RestoreOrCreateMainWindow("TransparentOverlay");
|
|
||||||
};
|
|
||||||
_transparentOverlayWindow.ExitEditRequested += (s, e) =>
|
|
||||||
{
|
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
|
||||||
};
|
|
||||||
_transparentOverlayWindow.RestoreComponentLibraryRequested += (s, e) =>
|
|
||||||
{
|
|
||||||
OpenFusedDesktopComponentLibraryFromUi(centerInWorkArea: true);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
|
internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
|
||||||
{
|
{
|
||||||
@@ -1263,31 +1189,16 @@ public partial class App : Application
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_fusedComponentLibraryWindow = null;
|
_fusedComponentLibraryWindow = null;
|
||||||
try
|
|
||||||
{
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode during shutdown.", ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_transparentOverlayWindow is not null)
|
try
|
||||||
{
|
{
|
||||||
try
|
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
||||||
{
|
}
|
||||||
_transparentOverlayWindow.Close();
|
catch (Exception ex)
|
||||||
}
|
{
|
||||||
catch (Exception ex)
|
AppLogger.Warn("FusedDesktop", "Failed to shut down fused desktop manager during exit cleanup.", ex);
|
||||||
{
|
|
||||||
AppLogger.Warn("DesktopShell", "Failed to close transparent overlay during exit cleanup.", ex);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_transparentOverlayWindow = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioRecorderServiceFactory.DisposeSharedServices();
|
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||||
@@ -1572,13 +1483,6 @@ public partial class App : Application
|
|||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"DesktopShell",
|
"DesktopShell",
|
||||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||||
|
|
||||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
|
||||||
if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop)
|
|
||||||
{
|
|
||||||
EnsureTransparentOverlayWindow();
|
|
||||||
_transparentOverlayWindow?.Show();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1668,7 +1572,6 @@ public partial class App : Application
|
|||||||
|
|
||||||
if (IsMainWindowDesktopLayerEnabled())
|
if (IsMainWindowDesktopLayerEnabled())
|
||||||
{
|
{
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
||||||
_mainWindow.ShowInTaskbar = false;
|
_mainWindow.ShowInTaskbar = false;
|
||||||
_mainWindowDesktopLayerService.EnableOrRefresh(_mainWindow);
|
_mainWindowDesktopLayerService.EnableOrRefresh(_mainWindow);
|
||||||
@@ -1697,7 +1600,6 @@ public partial class App : Application
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ExitFusedDesktopEditModeFromUi(closeLibrary: true);
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,35 +0,0 @@
|
|||||||
# MiSans 字体说明
|
|
||||||
|
|
||||||
## 中文
|
|
||||||
|
|
||||||
本项目内置 MiSans 字体,用于在不同设备上保持相对一致的文字渲染效果。
|
|
||||||
|
|
||||||
### 包含文件
|
|
||||||
|
|
||||||
- `MiSans-Regular.ttf`
|
|
||||||
- `MiSans-Semibold.ttf`
|
|
||||||
- `MiSans-Bold.ttf`
|
|
||||||
|
|
||||||
### 来源
|
|
||||||
|
|
||||||
- 上游仓库:https://github.com/dsrkafuu/misans
|
|
||||||
- 上游所引用的小米字体页面:https://hyperos.mi.com/font/zh/
|
|
||||||
|
|
||||||
### 许可与使用说明
|
|
||||||
|
|
||||||
- 上游脚本或打包仓库使用 Apache-2.0 许可。
|
|
||||||
- MiSans 字体本身的版权和补充使用条款以小米官方说明为准:
|
|
||||||
- https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
|
||||||
|
|
||||||
在重新分发本项目时,请自行确认并遵守 MiSans 字体的相关条款。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
This project bundles MiSans fonts for more consistent cross-device rendering.
|
|
||||||
|
|
||||||
### Sources
|
|
||||||
|
|
||||||
- Upstream package repository: https://github.com/dsrkafuu/misans
|
|
||||||
- Xiaomi font source page: https://hyperos.mi.com/font/zh/
|
|
||||||
|
|
||||||
Please review and comply with the MiSans font terms before redistributing this application.
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
@@ -11,46 +12,46 @@ using LanMountainDesktop.Views.Components;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 融合桌面中央管理器服务接口
|
|
||||||
/// </summary>
|
|
||||||
public interface IFusedDesktopManagerService
|
public interface IFusedDesktopManagerService
|
||||||
{
|
{
|
||||||
void Initialize();
|
void Initialize();
|
||||||
void EnterEditMode();
|
|
||||||
void ExitEditMode();
|
|
||||||
void ReloadWidgets();
|
void ReloadWidgets();
|
||||||
void Shutdown();
|
void Shutdown();
|
||||||
|
void AddComponent(string componentId);
|
||||||
|
void RemoveComponent(string placementId);
|
||||||
|
void EnterEditMode();
|
||||||
|
void ExitEditMode();
|
||||||
|
bool IsEditMode { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 融合桌面中央管理器服务实现。用于管理常态下的各个小窗口实体。
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||||
{
|
{
|
||||||
private readonly IFusedDesktopLayoutService _layoutService;
|
private readonly IFusedDesktopLayoutService _layoutService;
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
private readonly Dictionary<string, DesktopWidgetWindow> _widgetWindows = [];
|
private readonly Dictionary<string, DesktopWidgetWindow> _widgetWindows = [];
|
||||||
|
|
||||||
// 基础服务依赖
|
|
||||||
private readonly IWeatherInfoService _weatherDataService;
|
private readonly IWeatherInfoService _weatherDataService;
|
||||||
private readonly TimeZoneService _timeZoneService;
|
private readonly TimeZoneService _timeZoneService;
|
||||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||||
|
|
||||||
private ComponentRegistry? _componentRegistry;
|
private ComponentRegistry? _componentRegistry;
|
||||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||||
private bool _isEditMode;
|
private bool _isEditMode;
|
||||||
|
|
||||||
private const double DefaultCellSize = 100;
|
private const double DefaultCellSize = 100;
|
||||||
|
private const double DefaultComponentWidth = 200;
|
||||||
|
private const double DefaultComponentHeight = 200;
|
||||||
|
|
||||||
|
public bool IsEditMode => _isEditMode;
|
||||||
|
|
||||||
public FusedDesktopManagerService(
|
public FusedDesktopManagerService(
|
||||||
IFusedDesktopLayoutService layoutService,
|
IFusedDesktopLayoutService layoutService,
|
||||||
ISettingsFacadeService settingsFacade)
|
ISettingsFacadeService settingsFacade)
|
||||||
{
|
{
|
||||||
_layoutService = layoutService;
|
_layoutService = layoutService;
|
||||||
_settingsFacade = settingsFacade;
|
_settingsFacade = settingsFacade;
|
||||||
|
|
||||||
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
|
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
|
||||||
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
|
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
|
||||||
}
|
}
|
||||||
@@ -58,15 +59,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
public void Initialize()
|
public void Initialize()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows()) return;
|
if (!OperatingSystem.IsWindows()) return;
|
||||||
|
|
||||||
// 检查融合桌面功能是否启用
|
|
||||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
if (!appSnapshot.EnableFusedDesktop)
|
if (!appSnapshot.EnableFusedDesktop)
|
||||||
{
|
{
|
||||||
AppLogger.Info("FusedDesktop", "Fused desktop is disabled. Skipping initialization.");
|
AppLogger.Info("FusedDesktop", "Fused desktop is disabled. Skipping initialization.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
EnsureRegistries();
|
EnsureRegistries();
|
||||||
ReloadWidgets();
|
ReloadWidgets();
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
private void EnsureRegistries()
|
private void EnsureRegistries()
|
||||||
{
|
{
|
||||||
if (_componentRuntimeRegistry is not null) return;
|
if (_componentRuntimeRegistry is not null) return;
|
||||||
|
|
||||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||||
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||||
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||||
@@ -88,12 +88,12 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
if (_isEditMode) return;
|
if (_isEditMode) return;
|
||||||
_isEditMode = true;
|
_isEditMode = true;
|
||||||
|
|
||||||
// 【修复问题3】不再隐藏窗口,而是将窗口内容转移到编辑模式覆盖层
|
|
||||||
// 这样可以保持组件的运行状态(动画、输入等)
|
|
||||||
foreach (var window in _widgetWindows.Values)
|
foreach (var window in _widgetWindows.Values)
|
||||||
{
|
{
|
||||||
window.Hide();
|
window.SetEditMode(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("FusedDesktop", "Entered edit mode.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ExitEditMode()
|
public void ExitEditMode()
|
||||||
@@ -101,25 +101,91 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
if (!_isEditMode) return;
|
if (!_isEditMode) return;
|
||||||
_isEditMode = false;
|
_isEditMode = false;
|
||||||
|
|
||||||
// 编辑完成,重新加载布局(可能已发生更改)并显示
|
foreach (var window in _widgetWindows.Values)
|
||||||
ReloadWidgets();
|
{
|
||||||
|
window.SetEditMode(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("FusedDesktop", "Exited edit mode.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddComponent(string componentId)
|
||||||
|
{
|
||||||
|
EnsureRegistries();
|
||||||
|
if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor))
|
||||||
|
{
|
||||||
|
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {componentId}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var placement = new FusedDesktopComponentPlacementSnapshot
|
||||||
|
{
|
||||||
|
PlacementId = Guid.NewGuid().ToString("N"),
|
||||||
|
ComponentId = componentId,
|
||||||
|
Width = DefaultComponentWidth,
|
||||||
|
Height = DefaultComponentHeight
|
||||||
|
};
|
||||||
|
|
||||||
|
var screen = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)
|
||||||
|
?.MainWindow?.Screens.Primary;
|
||||||
|
if (screen is not null)
|
||||||
|
{
|
||||||
|
var scaling = screen.Scaling;
|
||||||
|
var workArea = screen.WorkingArea;
|
||||||
|
placement.X = (workArea.Width / scaling - placement.Width) / 2;
|
||||||
|
placement.Y = (workArea.Height / scaling - placement.Height) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
_layoutService.AddComponentPlacement(placement);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var window = CreateWidgetWindow(placement);
|
||||||
|
if (window != null)
|
||||||
|
{
|
||||||
|
_widgetWindows[placement.PlacementId] = window;
|
||||||
|
if (_isEditMode)
|
||||||
|
{
|
||||||
|
window.SetEditMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Show();
|
||||||
|
window.Position = new PixelPoint((int)placement.X, (int)placement.Y);
|
||||||
|
window.RefreshDesktopLayer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("FusedDesktopMgr", $"Failed to create widget window for {componentId}", ex);
|
||||||
|
_layoutService.RemoveComponentPlacement(placement.PlacementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("FusedDesktopMgr", $"Added component '{componentId}' with placement '{placement.PlacementId}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveComponent(string placementId)
|
||||||
|
{
|
||||||
|
if (_widgetWindows.Remove(placementId, out var windowToRemove))
|
||||||
|
{
|
||||||
|
windowToRemove.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
_layoutService.RemoveComponentPlacement(placementId);
|
||||||
|
AppLogger.Info("FusedDesktopMgr", $"Removed component placement '{placementId}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ReloadWidgets()
|
public void ReloadWidgets()
|
||||||
{
|
{
|
||||||
if (_isEditMode) return; // 编辑模式下不渲染小窗口
|
|
||||||
|
|
||||||
var layout = _layoutService.Load();
|
var layout = _layoutService.Load();
|
||||||
var existingIds = new HashSet<string>(_widgetWindows.Keys);
|
var existingIds = new HashSet<string>(_widgetWindows.Keys);
|
||||||
|
|
||||||
foreach (var placement in layout.ComponentPlacements)
|
foreach (var placement in layout.ComponentPlacements)
|
||||||
{
|
{
|
||||||
existingIds.Remove(placement.PlacementId);
|
existingIds.Remove(placement.PlacementId);
|
||||||
|
|
||||||
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
||||||
{
|
{
|
||||||
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
|
existingWindow.Position = new PixelPoint((int)placement.X, (int)placement.Y);
|
||||||
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
|
||||||
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
|
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
|
||||||
if (existingWindow.IsVisible == false)
|
if (existingWindow.IsVisible == false)
|
||||||
{
|
{
|
||||||
@@ -130,15 +196,19 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 新组件,生成窗口
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var window = CreateWidgetWindow(placement);
|
var window = CreateWidgetWindow(placement);
|
||||||
if (window != null)
|
if (window != null)
|
||||||
{
|
{
|
||||||
_widgetWindows[placement.PlacementId] = window;
|
_widgetWindows[placement.PlacementId] = window;
|
||||||
|
if (_isEditMode)
|
||||||
|
{
|
||||||
|
window.SetEditMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
window.Show();
|
window.Show();
|
||||||
window.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
window.Position = new PixelPoint((int)placement.X, (int)placement.Y);
|
||||||
window.RefreshDesktopLayer();
|
window.RefreshDesktopLayer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,8 +218,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除被删除的组件
|
|
||||||
foreach (var id in existingIds)
|
foreach (var id in existingIds)
|
||||||
{
|
{
|
||||||
if (_widgetWindows.Remove(id, out var windowToRemove))
|
if (_widgetWindows.Remove(id, out var windowToRemove))
|
||||||
@@ -179,7 +248,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {placement.ComponentId}");
|
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {placement.ComponentId}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var control = descriptor.CreateControl(
|
var control = descriptor.CreateControl(
|
||||||
DefaultCellSize,
|
DefaultCellSize,
|
||||||
_timeZoneService,
|
_timeZoneService,
|
||||||
@@ -188,28 +257,24 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
_calculatorDataService,
|
_calculatorDataService,
|
||||||
_settingsFacade,
|
_settingsFacade,
|
||||||
placement.PlacementId);
|
placement.PlacementId);
|
||||||
|
|
||||||
// 将组件包装到一个具有准确宽高的容器内(如果组件自身没有设置宽度)
|
|
||||||
control.Width = placement.Width;
|
control.Width = placement.Width;
|
||||||
control.Height = placement.Height;
|
control.Height = placement.Height;
|
||||||
|
|
||||||
var window = new DesktopWidgetWindow(control);
|
var window = new DesktopWidgetWindow(control, placement.PlacementId);
|
||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 工厂
|
|
||||||
/// </summary>
|
|
||||||
public static class FusedDesktopManagerServiceFactory
|
public static class FusedDesktopManagerServiceFactory
|
||||||
{
|
{
|
||||||
private static IFusedDesktopManagerService? _instance;
|
private static IFusedDesktopManagerService? _instance;
|
||||||
private static readonly object _lock = new();
|
private static readonly object _lock = new();
|
||||||
|
|
||||||
public static IFusedDesktopManagerService GetOrCreate()
|
public static IFusedDesktopManagerService GetOrCreate()
|
||||||
{
|
{
|
||||||
if (_instance is not null) return _instance;
|
if (_instance is not null) return _instance;
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
@@ -12,6 +13,13 @@ public partial class DesktopWidgetWindow : Window
|
|||||||
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
||||||
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
|
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
|
||||||
|
|
||||||
|
private bool _isEditMode;
|
||||||
|
private bool _isDragging;
|
||||||
|
private PixelPoint _dragStartWindowPosition;
|
||||||
|
private Point _dragStartPointerPosition;
|
||||||
|
|
||||||
|
public string? PlacementId { get; }
|
||||||
|
|
||||||
public DesktopWidgetWindow()
|
public DesktopWidgetWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -23,11 +31,34 @@ public partial class DesktopWidgetWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public DesktopWidgetWindow(Control componentContent) : this()
|
public DesktopWidgetWindow(Control componentContent, string? placementId = null) : this()
|
||||||
{
|
{
|
||||||
|
PlacementId = placementId;
|
||||||
ComponentContainer.Child = componentContent;
|
ComponentContainer.Child = componentContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetEditMode(bool editMode)
|
||||||
|
{
|
||||||
|
if (_isEditMode == editMode) return;
|
||||||
|
_isEditMode = editMode;
|
||||||
|
|
||||||
|
if (ComponentContainer.Child is Control child)
|
||||||
|
{
|
||||||
|
child.IsHitTestVisible = !editMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editMode)
|
||||||
|
{
|
||||||
|
Cursor = new Cursor(StandardCursorType.SizeAll);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Cursor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("DesktopWidgetWindow", $"Edit mode set to {editMode}. PlacementId='{PlacementId}'.");
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateComponentLayout(double width, double height)
|
public void UpdateComponentLayout(double width, double height)
|
||||||
{
|
{
|
||||||
ComponentContainer.Width = width;
|
ComponentContainer.Width = width;
|
||||||
@@ -74,6 +105,109 @@ public partial class DesktopWidgetWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isEditMode && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
{
|
||||||
|
BeginDrag(e);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isEditMode && e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||||
|
{
|
||||||
|
ShowContextMenu(e);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnPointerPressed(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerMoved(PointerEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isDragging)
|
||||||
|
{
|
||||||
|
var currentPointer = e.GetPosition(this);
|
||||||
|
var delta = currentPointer - _dragStartPointerPosition;
|
||||||
|
|
||||||
|
Position = new PixelPoint(
|
||||||
|
_dragStartWindowPosition.X + (int)delta.X,
|
||||||
|
_dragStartWindowPosition.Y + (int)delta.Y);
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnPointerMoved(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isDragging)
|
||||||
|
{
|
||||||
|
EndDrag();
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnPointerReleased(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeginDrag(PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
_isDragging = true;
|
||||||
|
_dragStartWindowPosition = Position;
|
||||||
|
_dragStartPointerPosition = e.GetPosition(this);
|
||||||
|
e.Pointer.Capture(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EndDrag()
|
||||||
|
{
|
||||||
|
_isDragging = false;
|
||||||
|
|
||||||
|
if (PlacementId is not null)
|
||||||
|
{
|
||||||
|
var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||||
|
var layout = layoutService.Load();
|
||||||
|
var placement = layout.ComponentPlacements.Find(
|
||||||
|
p => string.Equals(p.PlacementId, PlacementId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (placement is not null)
|
||||||
|
{
|
||||||
|
placement.X = Position.X;
|
||||||
|
placement.Y = Position.Y;
|
||||||
|
layoutService.Save(layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshDesktopLayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowContextMenu(PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
var removeItem = new MenuItem
|
||||||
|
{
|
||||||
|
Header = "移除组件"
|
||||||
|
};
|
||||||
|
removeItem.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
if (PlacementId is not null)
|
||||||
|
{
|
||||||
|
FusedDesktopManagerServiceFactory.GetOrCreate().RemoveComponent(PlacementId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var menu = new ContextMenu
|
||||||
|
{
|
||||||
|
Items = { removeItem }
|
||||||
|
};
|
||||||
|
menu.Open(this);
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateInteractiveRegion()
|
private void UpdateInteractiveRegion()
|
||||||
{
|
{
|
||||||
_regionPassthroughService.SetInteractiveRegions(this, new List<Rect>
|
_regionPassthroughService.SetInteractiveRegions(this, new List<Rect>
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ namespace LanMountainDesktop.Views;
|
|||||||
|
|
||||||
public partial class FusedDesktopComponentLibraryWindow : Window
|
public partial class FusedDesktopComponentLibraryWindow : Window
|
||||||
{
|
{
|
||||||
private TransparentOverlayWindow? _overlayWindow;
|
|
||||||
|
|
||||||
public FusedDesktopComponentLibraryWindow()
|
public FusedDesktopComponentLibraryWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -45,13 +43,6 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
|||||||
RootGrid.Resources["DesignCornerRadiusComponent"] = tokens.Component;
|
RootGrid.Resources["DesignCornerRadiusComponent"] = tokens.Component;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool PreserveEditModeOnClose { get; private set; }
|
|
||||||
|
|
||||||
public void SetOverlayWindow(TransparentOverlayWindow overlayWindow)
|
|
||||||
{
|
|
||||||
_overlayWindow = overlayWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void CenterInWorkArea(Window? referenceWindow = null)
|
public void CenterInWorkArea(Window? referenceWindow = null)
|
||||||
{
|
{
|
||||||
var screen = referenceWindow is not null
|
var screen = referenceWindow is not null
|
||||||
@@ -74,22 +65,13 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
|||||||
|
|
||||||
private void OnAddComponentRequested(object? sender, string componentId)
|
private void OnAddComponentRequested(object? sender, string componentId)
|
||||||
{
|
{
|
||||||
if (_overlayWindow is null)
|
FusedDesktopManagerServiceFactory.GetOrCreate().AddComponent(componentId);
|
||||||
{
|
AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' directly to fused desktop.");
|
||||||
AppLogger.Warn("FusedDesktopLibrary", "Overlay window is not set.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_overlayWindow.AddComponentToCenter(componentId);
|
|
||||||
AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' at fused desktop grid center.");
|
|
||||||
|
|
||||||
PreserveEditModeOnClose = true;
|
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
PreserveEditModeOnClose = false;
|
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +87,6 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
|||||||
{
|
{
|
||||||
if (e.Key == Key.Escape)
|
if (e.Key == Key.Escape)
|
||||||
{
|
{
|
||||||
PreserveEditModeOnClose = false;
|
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:fi="using:FluentIcons.Avalonia"
|
|
||||||
x:Class="LanMountainDesktop.Views.TransparentOverlayWindow"
|
|
||||||
WindowDecorations="None"
|
|
||||||
CanResize="False"
|
|
||||||
ShowInTaskbar="False"
|
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
|
||||||
TransparencyLevelHint="Transparent"
|
|
||||||
Background="{x:Null}"
|
|
||||||
Title="LanMountainDesktop Fused Desktop">
|
|
||||||
<Window.Styles>
|
|
||||||
<Style Selector="Border.fused-desktop-component-host">
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="BorderBrush" Value="Transparent" />
|
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
|
||||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusComponent}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.fused-desktop-component-host.selected">
|
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}" />
|
|
||||||
<Setter Property="BorderThickness" Value="2" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.fused-desktop-resize-handle">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
|
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.fused-desktop-edit-toolbar">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
|
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusIsland}" />
|
|
||||||
<Setter Property="BoxShadow" Value="0 8 32 #33000000" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.edit-toolbar-button">
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="BorderBrush" Value="Transparent" />
|
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
|
||||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
|
||||||
<Setter Property="Padding" Value="14,8" />
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
|
||||||
<Setter Property="Transitions">
|
|
||||||
<Transitions>
|
|
||||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
|
||||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
|
||||||
</Transitions>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.edit-toolbar-button:pointerover">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonHoverBackgroundBrush}" />
|
|
||||||
<Setter Property="RenderTransform" Value="scale(1.02)" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.edit-toolbar-button:pressed">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonPressedBackgroundBrush}" />
|
|
||||||
<Setter Property="RenderTransform" Value="scale(0.98)" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.edit-toolbar-button fi|FluentIcon">
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
|
||||||
<Setter Property="FontSize" Value="16" />
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.edit-toolbar-separator">
|
|
||||||
<Setter Property="Width" Value="1" />
|
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
|
||||||
<Setter Property="Margin" Value="4,8" />
|
|
||||||
<Setter Property="Opacity" Value="0.5" />
|
|
||||||
</Style>
|
|
||||||
</Window.Styles>
|
|
||||||
|
|
||||||
<Grid x:Name="OverlayRoot"
|
|
||||||
Background="{x:Null}">
|
|
||||||
<Canvas x:Name="ComponentCanvas"
|
|
||||||
PointerPressed="OnCanvasPointerPressed" />
|
|
||||||
|
|
||||||
<Border x:Name="EditToolbar"
|
|
||||||
Classes="fused-desktop-edit-toolbar"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Bottom"
|
|
||||||
Margin="0,0,0,20"
|
|
||||||
Padding="6"
|
|
||||||
IsHitTestVisible="True">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="2">
|
|
||||||
<Button Classes="edit-toolbar-button"
|
|
||||||
Click="OnRestoreComponentLibraryClick">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<fi:FluentIcon Icon="Apps" IconVariant="Regular" />
|
|
||||||
<TextBlock Text="找回组件库" VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Border Classes="edit-toolbar-separator" />
|
|
||||||
<Button Classes="edit-toolbar-button"
|
|
||||||
Click="OnExitEditClick">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<fi:FluentIcon Icon="Dismiss" IconVariant="Regular" />
|
|
||||||
<TextBlock Text="退出编辑" VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</Window>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
|||||||
namespace Plonds.Core.Publishing;
|
|
||||||
|
|
||||||
public sealed record PlatformPublishResult(
|
|
||||||
string Platform,
|
|
||||||
string DistributionId,
|
|
||||||
string CurrentAppDirectory,
|
|
||||||
string? PreviousDirectory,
|
|
||||||
string PreviousVersion,
|
|
||||||
string FileMapPath,
|
|
||||||
string SignaturePath,
|
|
||||||
string DistributionPath,
|
|
||||||
string LatestPath,
|
|
||||||
IReadOnlyList<string> InstallerFiles);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Plonds.Core.Publishing;
|
|
||||||
|
|
||||||
public sealed record PlondsBuildOptions(
|
|
||||||
string ReleaseTag,
|
|
||||||
string AssetsDirectory,
|
|
||||||
string OutputRoot,
|
|
||||||
string PrivateKeyPath,
|
|
||||||
string Repository,
|
|
||||||
string? S3BaseUrl = null);
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
|
public static class PlondsCommitAnalyzer
|
||||||
|
{
|
||||||
|
private static readonly string[] SourceDirectories =
|
||||||
|
[
|
||||||
|
"LanMountainDesktop/",
|
||||||
|
"LanMountainDesktop.Launcher/",
|
||||||
|
"LanMountainDesktop.Shared.Contracts/",
|
||||||
|
"LanMountainDesktop.PluginSdk/",
|
||||||
|
"LanMountainDesktop.Appearance/",
|
||||||
|
"LanMountainDesktop.Settings.Core/",
|
||||||
|
"LanMountainDesktop.ComponentSystem/"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly (string Prefix, string[] Artifacts)[] SourceToArtifactMappings =
|
||||||
|
[
|
||||||
|
("LanMountainDesktop/", ["LanMountainDesktop.dll", "LanMountainDesktop.exe"]),
|
||||||
|
("LanMountainDesktop.Launcher/", ["LanMountainDesktop.Launcher.exe", "LanMountainDesktop.Launcher.dll"]),
|
||||||
|
("LanMountainDesktop.Shared.Contracts/", ["LanMountainDesktop.Shared.Contracts.dll"]),
|
||||||
|
("LanMountainDesktop.PluginSdk/", ["LanMountainDesktop.PluginSdk.dll"]),
|
||||||
|
("LanMountainDesktop.Appearance/", ["LanMountainDesktop.Appearance.dll"]),
|
||||||
|
("LanMountainDesktop.Settings.Core/", ["LanMountainDesktop.Settings.Core.dll"]),
|
||||||
|
("LanMountainDesktop.ComponentSystem/", ["LanMountainDesktop.ComponentSystem.dll"])
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] SourceCodeExtensions =
|
||||||
|
[
|
||||||
|
".cs", ".axaml", ".xaml", ".csproj"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static HashSet<string> GetChangedSourceFiles(string baselineTag, string currentTag)
|
||||||
|
{
|
||||||
|
var changedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var start = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "git",
|
||||||
|
Arguments = $"log --name-only --pretty=format: {baselineTag}..{currentTag}",
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (start is null)
|
||||||
|
{
|
||||||
|
return changedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
var output = start.StandardOutput.ReadToEnd();
|
||||||
|
start.WaitForExit();
|
||||||
|
|
||||||
|
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim().Replace('\\', '/');
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsSourceDirectoryFile(trimmed))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
changedFiles.Add(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HashSet<string> MapSourceFilesToArtifacts(IReadOnlySet<string> sourceFiles)
|
||||||
|
{
|
||||||
|
var artifacts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var hasUnmappedChanges = false;
|
||||||
|
|
||||||
|
foreach (var sourceFile in sourceFiles)
|
||||||
|
{
|
||||||
|
var normalized = sourceFile.Replace('\\', '/');
|
||||||
|
var mapped = false;
|
||||||
|
|
||||||
|
foreach (var (prefix, artifactList) in SourceToArtifactMappings)
|
||||||
|
{
|
||||||
|
if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsSourceCodeFile(normalized) && !IsConfigFile(normalized))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var artifact in artifactList)
|
||||||
|
{
|
||||||
|
artifacts.Add(artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapped && IsConfigFile(normalized))
|
||||||
|
{
|
||||||
|
var artifactPath = MapConfigToArtifact(normalized);
|
||||||
|
if (artifactPath is not null)
|
||||||
|
{
|
||||||
|
artifacts.Add(artifactPath);
|
||||||
|
mapped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapped)
|
||||||
|
{
|
||||||
|
hasUnmappedChanges = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnmappedChanges)
|
||||||
|
{
|
||||||
|
foreach (var (_, artifactList) in SourceToArtifactMappings)
|
||||||
|
{
|
||||||
|
foreach (var artifact in artifactList)
|
||||||
|
{
|
||||||
|
artifacts.Add(artifact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsSourceDirectoryFile(string path)
|
||||||
|
{
|
||||||
|
var normalized = path.Replace('\\', '/');
|
||||||
|
return SourceDirectories.Any(d => normalized.StartsWith(d, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSourceCodeFile(string path)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(path);
|
||||||
|
return SourceCodeExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsConfigFile(string path)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(path);
|
||||||
|
return string.Equals(ext, ".json", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(ext, ".xml", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? MapConfigToArtifact(string sourcePath)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(sourcePath);
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
|
public sealed record PlondsCommitDeltaBuildOptions(
|
||||||
|
string Platform,
|
||||||
|
string CurrentVersion,
|
||||||
|
string CurrentPayloadZip,
|
||||||
|
string OutputRoot,
|
||||||
|
string Channel,
|
||||||
|
string BaselineTag,
|
||||||
|
string CurrentTag,
|
||||||
|
string? FallbackBaselineZip = null,
|
||||||
|
string? BaselineVersion = null,
|
||||||
|
string LauncherRelativePath = "LanMountainDesktop.Launcher.exe",
|
||||||
|
string HashAlgorithm = "sha256");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
|
public sealed record PlondsCommitDeltaBuildResult(
|
||||||
|
string Platform,
|
||||||
|
string ChangedZipPath,
|
||||||
|
string ManifestPath,
|
||||||
|
bool IsFullUpdate,
|
||||||
|
bool RequiresCleanInstall,
|
||||||
|
bool FellBackToFileCompare,
|
||||||
|
string CurrentVersion,
|
||||||
|
string? BaselineVersion,
|
||||||
|
IReadOnlyList<string> ChangedSourceFiles,
|
||||||
|
IReadOnlyList<string> MappedArtifactFiles);
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Plonds.Shared;
|
||||||
|
using Plonds.Shared.Models;
|
||||||
|
|
||||||
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
|
public sealed class PlondsCommitDeltaBuilder
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public PlondsCommitDeltaBuildResult Build(PlondsCommitDeltaBuildOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
var hashAlgorithm = PlondsDeltaBuilder.ValidateHashAlgorithmInternal(options.HashAlgorithm);
|
||||||
|
|
||||||
|
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
|
||||||
|
if (!File.Exists(currentPayloadZip))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("Current payload zip not found.", currentPayloadZip);
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
||||||
|
var workRoot = Path.Combine(outputRoot, "work", options.Platform);
|
||||||
|
var currentExtractRoot = Path.Combine(workRoot, "current");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(outputRoot);
|
||||||
|
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
|
||||||
|
|
||||||
|
var changedSourceFiles = PlondsCommitAnalyzer.GetChangedSourceFiles(options.BaselineTag, options.CurrentTag);
|
||||||
|
|
||||||
|
if (changedSourceFiles.Count == 0)
|
||||||
|
{
|
||||||
|
return FallbackToFileCompare(options, currentPayloadZip, outputRoot, workRoot, hashAlgorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mappedArtifacts = PlondsCommitAnalyzer.MapSourceFilesToArtifacts(changedSourceFiles);
|
||||||
|
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
||||||
|
|
||||||
|
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var artifact in mappedArtifacts.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (!currentManifest.TryGetValue(artifact, out var fingerprint))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = GetHash(fingerprint, hashAlgorithm);
|
||||||
|
var action = PlondsConstants.ActionReplace;
|
||||||
|
|
||||||
|
filesMap[artifact] = new PlondsFileEntry(action, hash, fingerprint.Size, hashAlgorithm);
|
||||||
|
changedFilesMap[artifact] = new PlondsChangedFileEntry(artifact, hash, fingerprint.Size, hashAlgorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
var changedZipPath = CreateChangedZipFromArtifacts(currentExtractRoot, mappedArtifacts, outputRoot, options.Platform);
|
||||||
|
|
||||||
|
var requiresCleanInstall = mappedArtifacts.Contains(options.LauncherRelativePath, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
|
||||||
|
|
||||||
|
var manifest = new PlondsManifest(
|
||||||
|
FormatVersion: PlondsConstants.FormatVersion,
|
||||||
|
CurrentVersion: options.CurrentVersion,
|
||||||
|
PreviousVersion: options.BaselineVersion ?? options.BaselineTag.TrimStart('v'),
|
||||||
|
IsFullUpdate: false,
|
||||||
|
RequiresCleanInstall: requiresCleanInstall,
|
||||||
|
Channel: options.Channel,
|
||||||
|
Platform: options.Platform,
|
||||||
|
UpdatedAt: DateTimeOffset.UtcNow,
|
||||||
|
CompareMethod: PlondsConstants.CompareMethodCommitAnalyze,
|
||||||
|
HashAlgorithm: hashAlgorithm,
|
||||||
|
FilesMap: filesMap,
|
||||||
|
ChangedFilesMap: changedFilesMap,
|
||||||
|
Checksums: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["changed.zip"] = $"md5:{changedZipMd5}"
|
||||||
|
});
|
||||||
|
|
||||||
|
var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
|
||||||
|
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||||
|
File.WriteAllText(manifestPath, manifestJson);
|
||||||
|
|
||||||
|
return new PlondsCommitDeltaBuildResult(
|
||||||
|
Platform: options.Platform,
|
||||||
|
ChangedZipPath: changedZipPath,
|
||||||
|
ManifestPath: manifestPath,
|
||||||
|
IsFullUpdate: false,
|
||||||
|
RequiresCleanInstall: requiresCleanInstall,
|
||||||
|
FellBackToFileCompare: false,
|
||||||
|
CurrentVersion: options.CurrentVersion,
|
||||||
|
BaselineVersion: options.BaselineVersion,
|
||||||
|
ChangedSourceFiles: changedSourceFiles.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||||
|
MappedArtifactFiles: mappedArtifacts.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlondsCommitDeltaBuildResult FallbackToFileCompare(
|
||||||
|
PlondsCommitDeltaBuildOptions options,
|
||||||
|
string currentPayloadZip,
|
||||||
|
string outputRoot,
|
||||||
|
string workRoot,
|
||||||
|
string hashAlgorithm)
|
||||||
|
{
|
||||||
|
var fallbackZip = string.IsNullOrWhiteSpace(options.FallbackBaselineZip)
|
||||||
|
? null
|
||||||
|
: Path.GetFullPath(options.FallbackBaselineZip);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(fallbackZip) || !File.Exists(fallbackZip))
|
||||||
|
{
|
||||||
|
var currentExtractRoot = Path.Combine(workRoot, "current");
|
||||||
|
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
|
||||||
|
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
||||||
|
|
||||||
|
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var fp = currentManifest[path];
|
||||||
|
var hash = GetHash(fp, hashAlgorithm);
|
||||||
|
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionAdd, hash, fp.Size, hashAlgorithm);
|
||||||
|
changedFilesMap[path] = new PlondsChangedFileEntry(path, hash, fp.Size, hashAlgorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
var changedZipPath = CreateChangedZipFromArtifacts(currentExtractRoot, filesMap.Keys.ToHashSet(), outputRoot, options.Platform);
|
||||||
|
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
|
||||||
|
|
||||||
|
var manifest = new PlondsManifest(
|
||||||
|
FormatVersion: PlondsConstants.FormatVersion,
|
||||||
|
CurrentVersion: options.CurrentVersion,
|
||||||
|
PreviousVersion: "0.0.0",
|
||||||
|
IsFullUpdate: true,
|
||||||
|
RequiresCleanInstall: false,
|
||||||
|
Channel: options.Channel,
|
||||||
|
Platform: options.Platform,
|
||||||
|
UpdatedAt: DateTimeOffset.UtcNow,
|
||||||
|
CompareMethod: PlondsConstants.CompareMethodCommitAnalyze,
|
||||||
|
HashAlgorithm: hashAlgorithm,
|
||||||
|
FilesMap: filesMap,
|
||||||
|
ChangedFilesMap: changedFilesMap,
|
||||||
|
Checksums: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["changed.zip"] = $"md5:{changedZipMd5}"
|
||||||
|
});
|
||||||
|
|
||||||
|
var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
|
||||||
|
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||||
|
File.WriteAllText(manifestPath, manifestJson);
|
||||||
|
|
||||||
|
return new PlondsCommitDeltaBuildResult(
|
||||||
|
Platform: options.Platform,
|
||||||
|
ChangedZipPath: changedZipPath,
|
||||||
|
ManifestPath: manifestPath,
|
||||||
|
IsFullUpdate: true,
|
||||||
|
RequiresCleanInstall: false,
|
||||||
|
FellBackToFileCompare: true,
|
||||||
|
CurrentVersion: options.CurrentVersion,
|
||||||
|
BaselineVersion: options.BaselineVersion,
|
||||||
|
ChangedSourceFiles: [],
|
||||||
|
MappedArtifactFiles: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
var deltaBuilder = new PlondsDeltaBuilder();
|
||||||
|
var deltaResult = deltaBuilder.Build(new PlondsDeltaBuildOptions(
|
||||||
|
Platform: options.Platform,
|
||||||
|
CurrentVersion: options.CurrentVersion,
|
||||||
|
CurrentPayloadZip: currentPayloadZip,
|
||||||
|
OutputRoot: outputRoot,
|
||||||
|
Channel: options.Channel,
|
||||||
|
BaselineVersion: options.BaselineVersion,
|
||||||
|
BaselinePayloadZip: fallbackZip,
|
||||||
|
LauncherRelativePath: options.LauncherRelativePath,
|
||||||
|
HashAlgorithm: hashAlgorithm));
|
||||||
|
|
||||||
|
return new PlondsCommitDeltaBuildResult(
|
||||||
|
Platform: deltaResult.Platform,
|
||||||
|
ChangedZipPath: deltaResult.ChangedZipPath,
|
||||||
|
ManifestPath: deltaResult.ManifestPath,
|
||||||
|
IsFullUpdate: deltaResult.IsFullUpdate,
|
||||||
|
RequiresCleanInstall: deltaResult.RequiresCleanInstall,
|
||||||
|
FellBackToFileCompare: true,
|
||||||
|
CurrentVersion: deltaResult.CurrentVersion,
|
||||||
|
BaselineVersion: deltaResult.BaselineVersion,
|
||||||
|
ChangedSourceFiles: [],
|
||||||
|
MappedArtifactFiles: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetHash(PayloadUtilities.FileFingerprint fingerprint, string hashAlgorithm)
|
||||||
|
{
|
||||||
|
if (hashAlgorithm == PlondsConstants.HashAlgorithmMd5)
|
||||||
|
{
|
||||||
|
return ComputeMd5Hex(fingerprint.FullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fingerprint.Sha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateChangedZipFromArtifacts(
|
||||||
|
string currentExtractRoot,
|
||||||
|
IReadOnlySet<string> artifacts,
|
||||||
|
string outputRoot,
|
||||||
|
string platform)
|
||||||
|
{
|
||||||
|
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
|
||||||
|
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
|
||||||
|
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
|
||||||
|
|
||||||
|
foreach (var artifact in artifacts)
|
||||||
|
{
|
||||||
|
var sourcePath = Path.Combine(currentExtractRoot, artifact);
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var destPath = Path.Combine(stagingRoot, artifact);
|
||||||
|
var destDir = Path.GetDirectoryName(destPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(destDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(destDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Copy(sourcePath, destPath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
PayloadUtilities.CreatePayloadZip(stagingRoot, changedZipPath);
|
||||||
|
return changedZipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeMd5Hex(string filePath)
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(filePath);
|
||||||
|
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
namespace Plonds.Core.Publishing;
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
public sealed record PlondsDeltaBuildOptions(
|
public sealed record PlondsDeltaBuildOptions(
|
||||||
string Platform,
|
string Platform,
|
||||||
string CurrentVersion,
|
string CurrentVersion,
|
||||||
string CurrentTag,
|
|
||||||
string CurrentPayloadZip,
|
string CurrentPayloadZip,
|
||||||
string OutputRoot,
|
string OutputRoot,
|
||||||
string PrivateKeyPath,
|
|
||||||
string Channel = "stable",
|
string Channel = "stable",
|
||||||
string? BaselineVersion = null,
|
string? BaselineVersion = null,
|
||||||
string? BaselineTag = null,
|
|
||||||
string? BaselinePayloadZip = null,
|
string? BaselinePayloadZip = null,
|
||||||
bool IsFullPayload = false,
|
string LauncherRelativePath = "LanMountainDesktop.Launcher.exe",
|
||||||
string? StaticOutputRoot = null,
|
string HashAlgorithm = "sha256");
|
||||||
string? UpdateBaseUrl = null);
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
namespace Plonds.Core.Publishing;
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
public sealed record PlondsDeltaBuildResult(
|
public sealed record PlondsDeltaBuildResult(
|
||||||
string Platform,
|
string Platform,
|
||||||
string DistributionId,
|
string ChangedZipPath,
|
||||||
string UpdateArchivePath,
|
string ManifestPath,
|
||||||
string FileMapPath,
|
bool IsFullUpdate,
|
||||||
string FileMapSignaturePath,
|
bool RequiresCleanInstall,
|
||||||
string SummaryPath,
|
string CurrentVersion,
|
||||||
bool IsFullPayload,
|
string? BaselineVersion);
|
||||||
string? BaselineTag,
|
|
||||||
string? BaselineVersion,
|
|
||||||
string TargetVersion);
|
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
using Plonds.Core.Security;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Plonds.Shared;
|
||||||
using Plonds.Shared.Models;
|
using Plonds.Shared.Models;
|
||||||
|
|
||||||
namespace Plonds.Core.Publishing;
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
public sealed class PlondsDeltaBuilder
|
public sealed class PlondsDeltaBuilder
|
||||||
{
|
{
|
||||||
private readonly RsaFileSigner _signer = new();
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
public PlondsDeltaBuildResult Build(PlondsDeltaBuildOptions options)
|
public PlondsDeltaBuildResult Build(PlondsDeltaBuildOptions options)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
var hashAlgorithm = ValidateHashAlgorithmInternal(options.HashAlgorithm);
|
||||||
|
|
||||||
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
|
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
|
||||||
if (!File.Exists(currentPayloadZip))
|
if (!File.Exists(currentPayloadZip))
|
||||||
{
|
{
|
||||||
@@ -29,332 +37,200 @@ public sealed class PlondsDeltaBuilder
|
|||||||
var workRoot = Path.Combine(outputRoot, "work", options.Platform);
|
var workRoot = Path.Combine(outputRoot, "work", options.Platform);
|
||||||
var currentExtractRoot = Path.Combine(workRoot, "current");
|
var currentExtractRoot = Path.Combine(workRoot, "current");
|
||||||
var baselineExtractRoot = Path.Combine(workRoot, "baseline");
|
var baselineExtractRoot = Path.Combine(workRoot, "baseline");
|
||||||
var objectsRoot = Path.Combine(workRoot, "objects");
|
|
||||||
var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets");
|
|
||||||
var summaryRoot = Path.Combine(outputRoot, "platform-summaries");
|
|
||||||
|
|
||||||
Directory.CreateDirectory(releaseAssetsRoot);
|
Directory.CreateDirectory(outputRoot);
|
||||||
Directory.CreateDirectory(summaryRoot);
|
|
||||||
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
|
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
|
||||||
|
|
||||||
var useFullPayload = options.IsFullPayload || string.IsNullOrWhiteSpace(baselinePayloadZip);
|
var isFullUpdate = string.IsNullOrWhiteSpace(baselinePayloadZip);
|
||||||
if (useFullPayload)
|
if (!isFullUpdate)
|
||||||
{
|
|
||||||
PayloadUtilities.EnsureCleanDirectory(baselineExtractRoot);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot);
|
PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
PayloadUtilities.EnsureCleanDirectory(objectsRoot);
|
var previousManifest = isFullUpdate
|
||||||
|
|
||||||
var previousManifest = useFullPayload
|
|
||||||
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
|
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
|
||||||
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
|
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
|
||||||
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
||||||
var updateBaseUrl = string.IsNullOrWhiteSpace(options.UpdateBaseUrl)
|
|
||||||
? null
|
|
||||||
: options.UpdateBaseUrl.TrimEnd('/');
|
|
||||||
var repoBaseUrl = string.IsNullOrWhiteSpace(updateBaseUrl)
|
|
||||||
? null
|
|
||||||
: $"{updateBaseUrl}/repo/sha256";
|
|
||||||
var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot, repoBaseUrl);
|
|
||||||
|
|
||||||
var updateAssetName = $"update-{options.Platform}.zip";
|
var filesMap = BuildFilesMap(previousManifest, currentManifest, hashAlgorithm);
|
||||||
var fileMapAssetName = $"plonds-filemap-{options.Platform}.json";
|
var changedFilesMap = BuildChangedFilesMap(filesMap, hashAlgorithm);
|
||||||
var fileMapSignatureAssetName = fileMapAssetName + ".sig";
|
|
||||||
var distributionId = $"plonds-{options.CurrentVersion}-{options.Platform}";
|
|
||||||
var updateArchivePath = Path.Combine(releaseAssetsRoot, updateAssetName);
|
|
||||||
var fileMapPath = Path.Combine(releaseAssetsRoot, fileMapAssetName);
|
|
||||||
var fileMapSignaturePath = Path.Combine(releaseAssetsRoot, fileMapSignatureAssetName);
|
|
||||||
|
|
||||||
PayloadUtilities.CreatePayloadZip(objectsRoot, updateArchivePath);
|
var changedZipPath = CreateChangedZip(currentExtractRoot, filesMap, outputRoot, options.Platform);
|
||||||
|
|
||||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
var launcherChanged = DetectLauncherChange(previousManifest, currentManifest, options.LauncherRelativePath);
|
||||||
{
|
var requiresCleanInstall = launcherChanged && !isFullUpdate;
|
||||||
["protocol"] = "PLONDS",
|
|
||||||
["channel"] = options.Channel,
|
|
||||||
["releaseTag"] = options.CurrentTag,
|
|
||||||
["baselineTag"] = options.BaselineTag ?? string.Empty,
|
|
||||||
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
|
|
||||||
["targetVersion"] = options.CurrentVersion,
|
|
||||||
["isFullPayload"] = useFullPayload ? "true" : "false"
|
|
||||||
};
|
|
||||||
|
|
||||||
var generatedAt = DateTimeOffset.UtcNow;
|
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
|
||||||
var component = new ComponentDocument(
|
|
||||||
Name: "app",
|
|
||||||
Version: options.CurrentVersion,
|
|
||||||
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["component"] = "app",
|
|
||||||
["mode"] = "file-object"
|
|
||||||
},
|
|
||||||
Files: fileEntries);
|
|
||||||
|
|
||||||
var fileMap = new FileMapDocument(
|
var manifest = new PlondsManifest(
|
||||||
FormatVersion: "1.0",
|
FormatVersion: PlondsConstants.FormatVersion,
|
||||||
DistributionId: distributionId,
|
CurrentVersion: options.CurrentVersion,
|
||||||
FromVersion: options.BaselineVersion ?? "0.0.0",
|
PreviousVersion: options.BaselineVersion ?? "0.0.0",
|
||||||
ToVersion: options.CurrentVersion,
|
IsFullUpdate: isFullUpdate,
|
||||||
Version: options.CurrentVersion,
|
RequiresCleanInstall: requiresCleanInstall,
|
||||||
Platform: options.Platform,
|
|
||||||
Arch: PayloadUtilities.ResolveArch(options.Platform),
|
|
||||||
Channel: options.Channel,
|
|
||||||
GeneratedAt: generatedAt,
|
|
||||||
Metadata: metadata,
|
|
||||||
Components: [component],
|
|
||||||
Files: fileEntries);
|
|
||||||
|
|
||||||
PayloadUtilities.WriteJson(fileMapPath, fileMap);
|
|
||||||
_signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.StaticOutputRoot) && !string.IsNullOrWhiteSpace(updateBaseUrl))
|
|
||||||
{
|
|
||||||
WriteStaticLayout(
|
|
||||||
options,
|
|
||||||
component,
|
|
||||||
objectsRoot,
|
|
||||||
distributionId,
|
|
||||||
fileMapPath,
|
|
||||||
fileMapSignaturePath,
|
|
||||||
Path.GetFullPath(options.StaticOutputRoot),
|
|
||||||
updateBaseUrl,
|
|
||||||
generatedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
var summary = new PlondsReleasePlatformEntry(
|
|
||||||
Platform: options.Platform,
|
|
||||||
DistributionId: distributionId,
|
|
||||||
BaselineTag: options.BaselineTag,
|
|
||||||
BaselineVersion: options.BaselineVersion ?? "0.0.0",
|
|
||||||
TargetVersion: options.CurrentVersion,
|
|
||||||
IsFullPayload: useFullPayload,
|
|
||||||
FilesZipAsset: $"files-{options.Platform}.zip",
|
|
||||||
UpdateZipAsset: updateAssetName,
|
|
||||||
FileMapAsset: fileMapAssetName,
|
|
||||||
FileMapSignatureAsset: fileMapSignatureAssetName,
|
|
||||||
Sha256: PayloadUtilities.ComputeSha256(updateArchivePath));
|
|
||||||
|
|
||||||
var summaryPath = Path.Combine(summaryRoot, $"platform-summary-{options.Platform}.json");
|
|
||||||
PayloadUtilities.WriteJson(summaryPath, summary);
|
|
||||||
|
|
||||||
return new PlondsDeltaBuildResult(
|
|
||||||
options.Platform,
|
|
||||||
distributionId,
|
|
||||||
updateArchivePath,
|
|
||||||
fileMapPath,
|
|
||||||
fileMapSignaturePath,
|
|
||||||
summaryPath,
|
|
||||||
useFullPayload,
|
|
||||||
options.BaselineTag,
|
|
||||||
options.BaselineVersion,
|
|
||||||
options.CurrentVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<FileEntryDocument> BuildFileEntries(
|
|
||||||
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
|
|
||||||
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
|
|
||||||
string objectsRoot,
|
|
||||||
string? repoBaseUrl)
|
|
||||||
{
|
|
||||||
var result = new List<FileEntryDocument>();
|
|
||||||
|
|
||||||
foreach (var path in currentManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var current = currentManifest[path];
|
|
||||||
if (previousManifest.TryGetValue(path, out var previous) &&
|
|
||||||
string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
result.Add(new FileEntryDocument(
|
|
||||||
Path: path,
|
|
||||||
Action: "reuse",
|
|
||||||
Sha256: current.Sha256,
|
|
||||||
Size: current.Size,
|
|
||||||
ObjectPath: null,
|
|
||||||
ObjectKey: null,
|
|
||||||
ObjectUrl: null,
|
|
||||||
Metadata: null));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
|
|
||||||
var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256);
|
|
||||||
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
|
|
||||||
? null
|
|
||||||
: $"{repoBaseUrl.TrimEnd('/')}/{objectPath}";
|
|
||||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["mode"] = "file-object"
|
|
||||||
};
|
|
||||||
if (!string.IsNullOrWhiteSpace(current.UnixFileMode))
|
|
||||||
{
|
|
||||||
metadata["unixFileMode"] = current.UnixFileMode!;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Add(new FileEntryDocument(
|
|
||||||
Path: path,
|
|
||||||
Action: action,
|
|
||||||
Sha256: current.Sha256,
|
|
||||||
Size: current.Size,
|
|
||||||
ObjectPath: objectPath,
|
|
||||||
ObjectKey: objectPath,
|
|
||||||
ObjectUrl: objectUrl,
|
|
||||||
Metadata: metadata));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var path in previousManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
if (currentManifest.ContainsKey(path))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Add(new FileEntryDocument(
|
|
||||||
Path: path,
|
|
||||||
Action: "delete",
|
|
||||||
Sha256: string.Empty,
|
|
||||||
Size: 0,
|
|
||||||
ObjectPath: null,
|
|
||||||
ObjectKey: null,
|
|
||||||
ObjectUrl: null,
|
|
||||||
Metadata: null));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteStaticLayout(
|
|
||||||
PlondsDeltaBuildOptions options,
|
|
||||||
ComponentDocument component,
|
|
||||||
string objectsRoot,
|
|
||||||
string distributionId,
|
|
||||||
string fileMapPath,
|
|
||||||
string fileMapSignaturePath,
|
|
||||||
string staticOutputRoot,
|
|
||||||
string updateBaseUrl,
|
|
||||||
DateTimeOffset generatedAt)
|
|
||||||
{
|
|
||||||
var repoRoot = Path.Combine(staticOutputRoot, "repo", "sha256");
|
|
||||||
var manifestRoot = Path.Combine(staticOutputRoot, "manifests", distributionId);
|
|
||||||
var distributionRoot = Path.Combine(staticOutputRoot, "meta", "distributions");
|
|
||||||
var channelRoot = Path.Combine(staticOutputRoot, "meta", "channels", options.Channel, options.Platform);
|
|
||||||
|
|
||||||
CopyDirectory(objectsRoot, repoRoot);
|
|
||||||
Directory.CreateDirectory(manifestRoot);
|
|
||||||
File.Copy(fileMapPath, Path.Combine(manifestRoot, "plonds-filemap.json"), overwrite: true);
|
|
||||||
File.Copy(fileMapSignaturePath, Path.Combine(manifestRoot, "plonds-filemap.json.sig"), overwrite: true);
|
|
||||||
|
|
||||||
var fileMapUrl = $"{updateBaseUrl}/manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json";
|
|
||||||
var distribution = new DistributionDocument(
|
|
||||||
DistributionId: distributionId,
|
|
||||||
Version: options.CurrentVersion,
|
|
||||||
SourceVersion: options.BaselineVersion ?? "0.0.0",
|
|
||||||
Channel: options.Channel,
|
Channel: options.Channel,
|
||||||
Platform: options.Platform,
|
Platform: options.Platform,
|
||||||
Arch: PayloadUtilities.ResolveArch(options.Platform),
|
UpdatedAt: DateTimeOffset.UtcNow,
|
||||||
PublishedAt: generatedAt,
|
CompareMethod: PlondsConstants.CompareMethodFileCompare,
|
||||||
FileMapUrl: fileMapUrl,
|
HashAlgorithm: hashAlgorithm,
|
||||||
FileMapSignatureUrl: fileMapUrl + ".sig",
|
FilesMap: filesMap,
|
||||||
Components: [component],
|
ChangedFilesMap: changedFilesMap,
|
||||||
InstallerMirrors: [],
|
Checksums: new Dictionary<string, string>
|
||||||
Capabilities: ["file-object"],
|
|
||||||
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
{
|
||||||
["protocol"] = "PLONDS",
|
["changed.zip"] = $"md5:{changedZipMd5}"
|
||||||
["releaseTag"] = options.CurrentTag,
|
|
||||||
["baselineTag"] = options.BaselineTag ?? string.Empty,
|
|
||||||
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
|
|
||||||
["targetVersion"] = options.CurrentVersion,
|
|
||||||
["isFullPayload"] = options.IsFullPayload ? "true" : "false"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var latest = new LatestPointerDocument(
|
var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
|
||||||
DistributionId: distributionId,
|
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||||
Version: options.CurrentVersion,
|
File.WriteAllText(manifestPath, manifestJson);
|
||||||
Channel: options.Channel,
|
|
||||||
|
return new PlondsDeltaBuildResult(
|
||||||
Platform: options.Platform,
|
Platform: options.Platform,
|
||||||
PublishedAt: generatedAt);
|
ChangedZipPath: changedZipPath,
|
||||||
|
ManifestPath: manifestPath,
|
||||||
PayloadUtilities.WriteJson(Path.Combine(distributionRoot, distributionId + ".json"), distribution);
|
IsFullUpdate: isFullUpdate,
|
||||||
PayloadUtilities.WriteJson(Path.Combine(channelRoot, "latest.json"), latest);
|
RequiresCleanInstall: requiresCleanInstall,
|
||||||
|
CurrentVersion: options.CurrentVersion,
|
||||||
|
BaselineVersion: options.BaselineVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CopyDirectory(string sourceDir, string destinationDir)
|
internal static string ValidateHashAlgorithmInternal(string algorithm)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(destinationDir);
|
var normalized = algorithm.Trim().ToLowerInvariant();
|
||||||
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
|
if (normalized is not (PlondsConstants.HashAlgorithmSha256 or PlondsConstants.HashAlgorithmMd5))
|
||||||
{
|
{
|
||||||
var relativePath = Path.GetRelativePath(sourceDir, directory);
|
throw new ArgumentException($"Unsupported hash algorithm: {algorithm}. Supported: sha256, md5");
|
||||||
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
|
return normalized;
|
||||||
{
|
|
||||||
var relativePath = Path.GetRelativePath(sourceDir, file);
|
|
||||||
var destinationPath = Path.Combine(destinationDir, relativePath);
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
|
||||||
File.Copy(file, destinationPath, overwrite: true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record FileMapDocument(
|
private static Dictionary<string, PlondsFileEntry> BuildFilesMap(
|
||||||
string FormatVersion,
|
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
|
||||||
string DistributionId,
|
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
|
||||||
string FromVersion,
|
string hashAlgorithm)
|
||||||
string ToVersion,
|
{
|
||||||
string Version,
|
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||||
string Platform,
|
|
||||||
string Arch,
|
|
||||||
string Channel,
|
|
||||||
DateTimeOffset GeneratedAt,
|
|
||||||
IReadOnlyDictionary<string, string> Metadata,
|
|
||||||
IReadOnlyList<ComponentDocument> Components,
|
|
||||||
IReadOnlyList<FileEntryDocument> Files);
|
|
||||||
|
|
||||||
private sealed record ComponentDocument(
|
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
string Name,
|
{
|
||||||
string Version,
|
var current = currentManifest[path];
|
||||||
IReadOnlyDictionary<string, string>? Metadata,
|
var currentHash = GetHash(current, hashAlgorithm);
|
||||||
IReadOnlyList<FileEntryDocument> Files);
|
|
||||||
|
|
||||||
private sealed record FileEntryDocument(
|
if (previousManifest.TryGetValue(path, out var previous))
|
||||||
string Path,
|
{
|
||||||
string Action,
|
var previousHash = GetHash(previous, hashAlgorithm);
|
||||||
string Sha256,
|
if (string.Equals(currentHash, previousHash, StringComparison.OrdinalIgnoreCase))
|
||||||
long Size,
|
{
|
||||||
string? ObjectPath,
|
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionReuse, currentHash, current.Size, hashAlgorithm);
|
||||||
string? ObjectKey,
|
continue;
|
||||||
string? ObjectUrl,
|
}
|
||||||
IReadOnlyDictionary<string, string>? Metadata);
|
}
|
||||||
|
|
||||||
private sealed record DistributionDocument(
|
var action = previousManifest.ContainsKey(path)
|
||||||
string DistributionId,
|
? PlondsConstants.ActionReplace
|
||||||
string Version,
|
: PlondsConstants.ActionAdd;
|
||||||
string SourceVersion,
|
filesMap[path] = new PlondsFileEntry(action, currentHash, current.Size, hashAlgorithm);
|
||||||
string Channel,
|
}
|
||||||
string Platform,
|
|
||||||
string Arch,
|
|
||||||
DateTimeOffset PublishedAt,
|
|
||||||
string FileMapUrl,
|
|
||||||
string FileMapSignatureUrl,
|
|
||||||
IReadOnlyList<ComponentDocument> Components,
|
|
||||||
IReadOnlyList<InstallerMirrorDocument> InstallerMirrors,
|
|
||||||
IReadOnlyList<string> Capabilities,
|
|
||||||
IReadOnlyDictionary<string, string>? Metadata);
|
|
||||||
|
|
||||||
private sealed record LatestPointerDocument(
|
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
string DistributionId,
|
{
|
||||||
string Version,
|
if (!currentManifest.ContainsKey(path))
|
||||||
string Channel,
|
{
|
||||||
string Platform,
|
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionDelete, string.Empty, 0, hashAlgorithm);
|
||||||
DateTimeOffset PublishedAt);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record InstallerMirrorDocument(
|
return filesMap;
|
||||||
string Platform,
|
}
|
||||||
string? Url,
|
|
||||||
string? FileName,
|
private static string GetHash(PayloadUtilities.FileFingerprint fingerprint, string hashAlgorithm)
|
||||||
string? Sha256,
|
{
|
||||||
long Size);
|
if (hashAlgorithm == PlondsConstants.HashAlgorithmMd5)
|
||||||
|
{
|
||||||
|
return ComputeMd5Hex(fingerprint.FullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fingerprint.Sha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, PlondsChangedFileEntry> BuildChangedFilesMap(
|
||||||
|
IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
|
||||||
|
string hashAlgorithm)
|
||||||
|
{
|
||||||
|
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var (path, entry) in filesMap)
|
||||||
|
{
|
||||||
|
if (entry.Action is PlondsConstants.ActionAdd or PlondsConstants.ActionReplace)
|
||||||
|
{
|
||||||
|
changedFilesMap[path] = new PlondsChangedFileEntry(path, entry.Hash, entry.Size, hashAlgorithm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changedFilesMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateChangedZip(
|
||||||
|
string currentExtractRoot,
|
||||||
|
IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
|
||||||
|
string outputRoot,
|
||||||
|
string platform)
|
||||||
|
{
|
||||||
|
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
|
||||||
|
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
|
||||||
|
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
|
||||||
|
|
||||||
|
foreach (var (path, entry) in filesMap)
|
||||||
|
{
|
||||||
|
if (entry.Action is not (PlondsConstants.ActionAdd or PlondsConstants.ActionReplace))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourcePath = Path.Combine(currentExtractRoot, path);
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var destPath = Path.Combine(stagingRoot, path);
|
||||||
|
var destDir = Path.GetDirectoryName(destPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(destDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(destDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Copy(sourcePath, destPath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
PayloadUtilities.CreatePayloadZip(stagingRoot, changedZipPath);
|
||||||
|
return changedZipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool DetectLauncherChange(
|
||||||
|
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
|
||||||
|
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
|
||||||
|
string launcherRelativePath)
|
||||||
|
{
|
||||||
|
var normalizedPath = launcherRelativePath.Replace('\\', '/');
|
||||||
|
|
||||||
|
if (!currentManifest.TryGetValue(normalizedPath, out var current))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previousManifest.TryGetValue(normalizedPath, out var previous))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeMd5Hex(string filePath)
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(filePath);
|
||||||
|
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace Plonds.Core.Publishing;
|
|
||||||
|
|
||||||
public sealed record PlondsGenerateOptions(
|
|
||||||
string CurrentVersion,
|
|
||||||
string CurrentDirectory,
|
|
||||||
string Platform,
|
|
||||||
string OutputRoot,
|
|
||||||
string PreviousVersion = "0.0.0",
|
|
||||||
string? PreviousDirectory = null,
|
|
||||||
string Channel = "stable",
|
|
||||||
string? DistributionId = null,
|
|
||||||
string? RepoBaseUrl = null,
|
|
||||||
string? FileMapUrl = null,
|
|
||||||
string? FileMapSignatureUrl = null,
|
|
||||||
string? InstallerDirectory = null,
|
|
||||||
string? InstallerBaseUrl = null,
|
|
||||||
string IncrementalStrategy = "release-payload",
|
|
||||||
string? BaselineVersion = null,
|
|
||||||
string? BaselineRef = null,
|
|
||||||
string? SourceCommit = null,
|
|
||||||
bool IsFullPayloadRelease = false,
|
|
||||||
string? CommitRangeStart = null,
|
|
||||||
string? CommitRangeEnd = null);
|
|
||||||
@@ -1,451 +0,0 @@
|
|||||||
using System.IO.Compression;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Plonds.Core.Publishing;
|
|
||||||
|
|
||||||
public sealed class PlondsGenerator
|
|
||||||
{
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
WriteIndented = true
|
|
||||||
};
|
|
||||||
|
|
||||||
public PlatformPublishResult Generate(PlondsGenerateOptions options)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
|
||||||
|
|
||||||
var currentDirectory = Path.GetFullPath(options.CurrentDirectory);
|
|
||||||
if (!Directory.Exists(currentDirectory))
|
|
||||||
{
|
|
||||||
throw new DirectoryNotFoundException($"Current directory not found: {currentDirectory}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var previousDirectory = string.IsNullOrWhiteSpace(options.PreviousDirectory)
|
|
||||||
? null
|
|
||||||
: Path.GetFullPath(options.PreviousDirectory);
|
|
||||||
|
|
||||||
var distributionId = string.IsNullOrWhiteSpace(options.DistributionId)
|
|
||||||
? $"plonds-{options.CurrentVersion}-{options.Platform}"
|
|
||||||
: options.DistributionId.Trim();
|
|
||||||
|
|
||||||
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
|
||||||
var repoRoot = Path.Combine(outputRoot, "repo", "sha256");
|
|
||||||
var manifestsRoot = Path.Combine(outputRoot, "manifests", distributionId);
|
|
||||||
var metaDistributionRoot = Path.Combine(outputRoot, "meta", "distributions");
|
|
||||||
var metaChannelRoot = Path.Combine(outputRoot, "meta", "channels", options.Channel, options.Platform);
|
|
||||||
var installerMirrorRoot = Path.Combine(outputRoot, "installers", options.Platform, options.CurrentVersion);
|
|
||||||
|
|
||||||
Directory.CreateDirectory(repoRoot);
|
|
||||||
Directory.CreateDirectory(manifestsRoot);
|
|
||||||
Directory.CreateDirectory(metaDistributionRoot);
|
|
||||||
Directory.CreateDirectory(metaChannelRoot);
|
|
||||||
|
|
||||||
var previousManifest = options.IsFullPayloadRelease
|
|
||||||
? new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
: ScanDirectory(previousDirectory);
|
|
||||||
var currentManifest = ScanDirectory(currentDirectory);
|
|
||||||
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
|
|
||||||
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
|
|
||||||
var publishedAt = DateTimeOffset.UtcNow;
|
|
||||||
var generatedAt = DateTimeOffset.UtcNow;
|
|
||||||
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
|
|
||||||
? options.PreviousVersion
|
|
||||||
: options.BaselineVersion;
|
|
||||||
var arch = ResolveArch(options.Platform);
|
|
||||||
|
|
||||||
var fileMap = new FileMapDocument(
|
|
||||||
FormatVersion: "2.0",
|
|
||||||
DistributionId: distributionId,
|
|
||||||
FromVersion: options.PreviousVersion,
|
|
||||||
ToVersion: options.CurrentVersion,
|
|
||||||
Version: options.CurrentVersion,
|
|
||||||
Platform: options.Platform,
|
|
||||||
Arch: arch,
|
|
||||||
Channel: options.Channel,
|
|
||||||
PublishedAt: publishedAt,
|
|
||||||
GeneratedAt: generatedAt,
|
|
||||||
BaselineVersion: baselineVersion,
|
|
||||||
Capabilities: ["file-object", "compressed-object"],
|
|
||||||
Components:
|
|
||||||
[
|
|
||||||
new ComponentDocument(
|
|
||||||
Id: "app",
|
|
||||||
Root: "/",
|
|
||||||
Mode: "file-object",
|
|
||||||
Files: fileEntries,
|
|
||||||
Metadata: new Dictionary<string, string> { ["component"] = "app" })
|
|
||||||
],
|
|
||||||
Metadata: new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["protocol"] = "PLONDS",
|
|
||||||
["mode"] = "file-object",
|
|
||||||
["baselineVersion"] = baselineVersion,
|
|
||||||
["incrementalStrategy"] = options.IncrementalStrategy,
|
|
||||||
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
|
|
||||||
["sourceCommit"] = options.SourceCommit ?? string.Empty,
|
|
||||||
["baselineRef"] = options.BaselineRef ?? string.Empty,
|
|
||||||
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
|
|
||||||
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
|
|
||||||
});
|
|
||||||
|
|
||||||
var distribution = new DistributionDocument(
|
|
||||||
DistributionId: distributionId,
|
|
||||||
Version: options.CurrentVersion,
|
|
||||||
Channel: options.Channel,
|
|
||||||
Platform: options.Platform,
|
|
||||||
Arch: arch,
|
|
||||||
PublishedAt: publishedAt,
|
|
||||||
FileMapUrl: options.FileMapUrl,
|
|
||||||
FileMapSignatureUrl: options.FileMapSignatureUrl,
|
|
||||||
Components: fileMap.Components,
|
|
||||||
InstallerMirrors: installerMirrors,
|
|
||||||
Capabilities: ["file-object", "compressed-object"],
|
|
||||||
Metadata: new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["protocol"] = "PLONDS",
|
|
||||||
["baselineVersion"] = baselineVersion,
|
|
||||||
["incrementalStrategy"] = options.IncrementalStrategy,
|
|
||||||
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
|
|
||||||
["sourceCommit"] = options.SourceCommit ?? string.Empty,
|
|
||||||
["baselineRef"] = options.BaselineRef ?? string.Empty,
|
|
||||||
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
|
|
||||||
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
|
|
||||||
});
|
|
||||||
|
|
||||||
var latest = new LatestPointerDocument(
|
|
||||||
DistributionId: distributionId,
|
|
||||||
Version: options.CurrentVersion,
|
|
||||||
Channel: options.Channel,
|
|
||||||
Platform: options.Platform,
|
|
||||||
PublishedAt: publishedAt);
|
|
||||||
|
|
||||||
var fileMapPath = Path.Combine(manifestsRoot, "plonds-filemap.json");
|
|
||||||
var distributionPath = Path.Combine(metaDistributionRoot, distributionId + ".json");
|
|
||||||
var latestPath = Path.Combine(metaChannelRoot, "latest.json");
|
|
||||||
|
|
||||||
WriteJson(fileMapPath, fileMap);
|
|
||||||
WriteJson(distributionPath, distribution);
|
|
||||||
WriteJson(latestPath, latest);
|
|
||||||
|
|
||||||
return new PlatformPublishResult(
|
|
||||||
options.Platform,
|
|
||||||
distributionId,
|
|
||||||
currentDirectory,
|
|
||||||
previousDirectory,
|
|
||||||
options.PreviousVersion,
|
|
||||||
fileMapPath,
|
|
||||||
fileMapPath + ".sig",
|
|
||||||
distributionPath,
|
|
||||||
latestPath,
|
|
||||||
installerMirrors.Select(x => x.FileName ?? string.Empty).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void WriteBundle(string fileMapPath, string signatureBase64)
|
|
||||||
{
|
|
||||||
var fileMapJson = File.ReadAllText(fileMapPath);
|
|
||||||
WriteBundle(fileMapPath, fileMapJson, signatureBase64);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, FileFingerprint> ScanDirectory(string? root)
|
|
||||||
{
|
|
||||||
var manifest = new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
|
|
||||||
{
|
|
||||||
return manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
var resolvedRoot = Path.GetFullPath(root);
|
|
||||||
foreach (var filePath in Directory.EnumerateFiles(resolvedRoot, "*", SearchOption.AllDirectories))
|
|
||||||
{
|
|
||||||
var relativePath = Path.GetRelativePath(resolvedRoot, filePath).Replace('\\', '/');
|
|
||||||
if (ShouldIgnore(relativePath))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var fileInfo = new FileInfo(filePath);
|
|
||||||
manifest[relativePath] = new FileFingerprint(relativePath, filePath, ComputeSha256(filePath), fileInfo.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
return manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<FileEntryDocument> BuildFileEntries(
|
|
||||||
Dictionary<string, FileFingerprint> previousManifest,
|
|
||||||
Dictionary<string, FileFingerprint> currentManifest,
|
|
||||||
string repoRoot,
|
|
||||||
string? repoBaseUrl)
|
|
||||||
{
|
|
||||||
var entries = new List<FileEntryDocument>();
|
|
||||||
|
|
||||||
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var current = currentManifest[path];
|
|
||||||
if (previousManifest.TryGetValue(path, out var previous) &&
|
|
||||||
string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
entries.Add(new FileEntryDocument(
|
|
||||||
Path: path,
|
|
||||||
Action: "reuse",
|
|
||||||
Sha256: current.Sha256,
|
|
||||||
Size: current.Size,
|
|
||||||
Mode: "file-object",
|
|
||||||
ObjectKey: null,
|
|
||||||
ObjectUrl: null,
|
|
||||||
ArchiveSha256: null,
|
|
||||||
Metadata: new Dictionary<string, string> { ["reuseVerified"] = "true" }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
|
|
||||||
var (objectKey, archiveSha256, mode) = CopyContentObjectWithCompression(
|
|
||||||
current.FullPath, repoRoot, current.Sha256, current.Size);
|
|
||||||
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
|
|
||||||
? null
|
|
||||||
: $"{repoBaseUrl.TrimEnd('/')}/{objectKey}";
|
|
||||||
|
|
||||||
entries.Add(new FileEntryDocument(
|
|
||||||
Path: path,
|
|
||||||
Action: action,
|
|
||||||
Sha256: current.Sha256,
|
|
||||||
Size: current.Size,
|
|
||||||
Mode: mode,
|
|
||||||
ObjectKey: objectKey,
|
|
||||||
ObjectUrl: objectUrl,
|
|
||||||
ArchiveSha256: string.IsNullOrEmpty(archiveSha256) ? null : archiveSha256,
|
|
||||||
Metadata: new Dictionary<string, string> { ["mode"] = mode }));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
if (!currentManifest.ContainsKey(path))
|
|
||||||
{
|
|
||||||
entries.Add(new FileEntryDocument(
|
|
||||||
Path: path,
|
|
||||||
Action: "delete",
|
|
||||||
Sha256: string.Empty,
|
|
||||||
Size: 0,
|
|
||||||
Mode: "file-object",
|
|
||||||
ObjectKey: null,
|
|
||||||
ObjectUrl: null,
|
|
||||||
ArchiveSha256: null,
|
|
||||||
Metadata: null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<InstallerMirrorDocument> BuildInstallerMirrors(
|
|
||||||
string platform,
|
|
||||||
string installerMirrorRoot,
|
|
||||||
string? installerSourceDirectory,
|
|
||||||
string? installerBaseUrl)
|
|
||||||
{
|
|
||||||
var result = new List<InstallerMirrorDocument>();
|
|
||||||
if (string.IsNullOrWhiteSpace(installerSourceDirectory) || !Directory.Exists(installerSourceDirectory))
|
|
||||||
{
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
Directory.CreateDirectory(installerMirrorRoot);
|
|
||||||
foreach (var sourceFile in Directory.EnumerateFiles(installerSourceDirectory))
|
|
||||||
{
|
|
||||||
var fileName = Path.GetFileName(sourceFile);
|
|
||||||
var destinationPath = Path.Combine(installerMirrorRoot, fileName);
|
|
||||||
File.Copy(sourceFile, destinationPath, overwrite: true);
|
|
||||||
|
|
||||||
var url = string.IsNullOrWhiteSpace(installerBaseUrl)
|
|
||||||
? null
|
|
||||||
: $"{installerBaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}";
|
|
||||||
result.Add(new InstallerMirrorDocument(
|
|
||||||
Platform: platform,
|
|
||||||
Arch: ResolveArch(platform),
|
|
||||||
Url: url,
|
|
||||||
Name: fileName,
|
|
||||||
FileName: fileName,
|
|
||||||
Sha256: ComputeSha256(destinationPath),
|
|
||||||
Size: new FileInfo(destinationPath).Length));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResolveArch(string platform)
|
|
||||||
{
|
|
||||||
if (platform.EndsWith("-x86", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return "x86";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform.EndsWith("-arm64", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return "arm64";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "x64";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ShouldIgnore(string relativePath)
|
|
||||||
{
|
|
||||||
var normalized = relativePath.Trim().Replace('\\', '/');
|
|
||||||
if (string.IsNullOrWhiteSpace(normalized))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized.Equals(".current", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
normalized.Equals(".partial", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
normalized.Equals(".destroy", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
normalized.StartsWith(".current/", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
normalized.StartsWith(".partial/", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
normalized.StartsWith(".destroy/", StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string CopyContentObject(string sourcePath, string repoRoot, string sha256)
|
|
||||||
{
|
|
||||||
var prefix = sha256[..Math.Min(2, sha256.Length)];
|
|
||||||
var relativeKey = $"{prefix}/{sha256}";
|
|
||||||
var destinationPath = Path.Combine(repoRoot, prefix, sha256);
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
|
||||||
if (!File.Exists(destinationPath))
|
|
||||||
{
|
|
||||||
File.Copy(sourcePath, destinationPath, overwrite: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return relativeKey.Replace('\\', '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (string ObjectKey, string ArchiveSha256, string Mode) CopyContentObjectWithCompression(
|
|
||||||
string sourcePath, string repoRoot, string sha256, long fileSize)
|
|
||||||
{
|
|
||||||
if (fileSize > 65536)
|
|
||||||
{
|
|
||||||
var compressedBytes = CompressGzip(sourcePath);
|
|
||||||
var archiveSha256 = ComputeSha256FromBytes(compressedBytes);
|
|
||||||
var archiveKey = CopyBytesToObjectStore(compressedBytes, repoRoot, archiveSha256);
|
|
||||||
return (archiveKey, archiveSha256, "compressed-object");
|
|
||||||
}
|
|
||||||
|
|
||||||
var key = CopyContentObject(sourcePath, repoRoot, sha256);
|
|
||||||
return (key, string.Empty, "file-object");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] CompressGzip(string filePath)
|
|
||||||
{
|
|
||||||
using var input = File.OpenRead(filePath);
|
|
||||||
using var output = new MemoryStream();
|
|
||||||
using (var gzip = new GZipStream(output, CompressionMode.Compress, leaveOpen: true))
|
|
||||||
{
|
|
||||||
input.CopyTo(gzip);
|
|
||||||
}
|
|
||||||
return output.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ComputeSha256FromBytes(byte[] data)
|
|
||||||
{
|
|
||||||
return Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string CopyBytesToObjectStore(byte[] data, string repoRoot, string sha256)
|
|
||||||
{
|
|
||||||
var prefix = sha256[..Math.Min(2, sha256.Length)];
|
|
||||||
var relativeKey = $"{prefix}/{sha256}";
|
|
||||||
var destinationPath = Path.Combine(repoRoot, prefix, sha256);
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
|
||||||
if (!File.Exists(destinationPath))
|
|
||||||
{
|
|
||||||
File.WriteAllBytes(destinationPath, data);
|
|
||||||
}
|
|
||||||
return relativeKey.Replace('\\', '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteBundle(string fileMapPath, string fileMapJson, string signatureBase64)
|
|
||||||
{
|
|
||||||
var bundle = new BundleDocument(fileMapJson, signatureBase64);
|
|
||||||
WriteJson(fileMapPath + ".bundle.json", bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ComputeSha256(string filePath)
|
|
||||||
{
|
|
||||||
using var stream = File.OpenRead(filePath);
|
|
||||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteJson<T>(string path, T value)
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(value, JsonOptions);
|
|
||||||
File.WriteAllText(path, json, new UTF8Encoding(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record FileFingerprint(string RelativePath, string FullPath, string Sha256, long Size);
|
|
||||||
|
|
||||||
private sealed record FileMapDocument(
|
|
||||||
string FormatVersion,
|
|
||||||
string DistributionId,
|
|
||||||
string FromVersion,
|
|
||||||
string ToVersion,
|
|
||||||
string Version,
|
|
||||||
string Platform,
|
|
||||||
string Arch,
|
|
||||||
string Channel,
|
|
||||||
DateTimeOffset PublishedAt,
|
|
||||||
DateTimeOffset GeneratedAt,
|
|
||||||
string? BaselineVersion,
|
|
||||||
IReadOnlyList<string> Capabilities,
|
|
||||||
IReadOnlyList<ComponentDocument> Components,
|
|
||||||
IReadOnlyDictionary<string, string>? Metadata);
|
|
||||||
|
|
||||||
private sealed record DistributionDocument(
|
|
||||||
string DistributionId,
|
|
||||||
string Version,
|
|
||||||
string Channel,
|
|
||||||
string Platform,
|
|
||||||
string Arch,
|
|
||||||
DateTimeOffset PublishedAt,
|
|
||||||
string? FileMapUrl,
|
|
||||||
string? FileMapSignatureUrl,
|
|
||||||
IReadOnlyList<ComponentDocument> Components,
|
|
||||||
IReadOnlyList<InstallerMirrorDocument> InstallerMirrors,
|
|
||||||
IReadOnlyList<string> Capabilities,
|
|
||||||
IReadOnlyDictionary<string, string>? Metadata);
|
|
||||||
|
|
||||||
private sealed record LatestPointerDocument(
|
|
||||||
string DistributionId,
|
|
||||||
string Version,
|
|
||||||
string Channel,
|
|
||||||
string Platform,
|
|
||||||
DateTimeOffset PublishedAt);
|
|
||||||
|
|
||||||
private sealed record ComponentDocument(
|
|
||||||
string Id,
|
|
||||||
string Root,
|
|
||||||
string Mode,
|
|
||||||
IReadOnlyList<FileEntryDocument> Files,
|
|
||||||
IReadOnlyDictionary<string, string>? Metadata);
|
|
||||||
|
|
||||||
private sealed record FileEntryDocument(
|
|
||||||
string Path,
|
|
||||||
string Action,
|
|
||||||
string Sha256,
|
|
||||||
long Size,
|
|
||||||
string Mode,
|
|
||||||
string? ObjectKey,
|
|
||||||
string? ObjectUrl,
|
|
||||||
string? ArchiveSha256,
|
|
||||||
IReadOnlyDictionary<string, string>? Metadata);
|
|
||||||
|
|
||||||
private sealed record InstallerMirrorDocument(
|
|
||||||
string Platform,
|
|
||||||
string Arch,
|
|
||||||
string? Url,
|
|
||||||
string? Name,
|
|
||||||
string? FileName,
|
|
||||||
string? Sha256,
|
|
||||||
long Size);
|
|
||||||
|
|
||||||
private sealed record BundleDocument(string Manifest, string Signature);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
using Plonds.Core.Security;
|
|
||||||
using Plonds.Shared.Models;
|
|
||||||
|
|
||||||
namespace Plonds.Core.Publishing;
|
|
||||||
|
|
||||||
public sealed class PlondsManifestBuilder
|
|
||||||
{
|
|
||||||
private readonly RsaFileSigner _signer = new();
|
|
||||||
|
|
||||||
public string Build(PlondsBuildOptions options)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
|
||||||
|
|
||||||
var assetsDirectory = Path.GetFullPath(options.AssetsDirectory);
|
|
||||||
if (!Directory.Exists(assetsDirectory))
|
|
||||||
{
|
|
||||||
throw new DirectoryNotFoundException($"PLONDS assets directory not found: {assetsDirectory}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var assetEntries = Directory
|
|
||||||
.EnumerateFiles(assetsDirectory, "*", SearchOption.TopDirectoryOnly)
|
|
||||||
.Where(static path =>
|
|
||||||
{
|
|
||||||
var name = Path.GetFileName(path);
|
|
||||||
return !name.Equals("plonds.json", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& !name.Equals("plonds.json.sig", StringComparison.OrdinalIgnoreCase);
|
|
||||||
})
|
|
||||||
.OrderBy(static path => Path.GetFileName(path), StringComparer.OrdinalIgnoreCase)
|
|
||||||
.Select(path => BuildAssetEntry(path, options.Repository, options.ReleaseTag, options.S3BaseUrl))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var manifest = new PlondsManifest(
|
|
||||||
FormatVersion: "1.0",
|
|
||||||
ReleaseTag: options.ReleaseTag,
|
|
||||||
GeneratedAt: DateTimeOffset.UtcNow,
|
|
||||||
Assets: assetEntries);
|
|
||||||
|
|
||||||
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
|
||||||
Directory.CreateDirectory(outputRoot);
|
|
||||||
var manifestPath = Path.Combine(outputRoot, "plonds.json");
|
|
||||||
PayloadUtilities.WriteJson(manifestPath, manifest);
|
|
||||||
_signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig");
|
|
||||||
return manifestPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PlondsAssetEntry BuildAssetEntry(string assetPath, string repository, string releaseTag, string? s3BaseUrl)
|
|
||||||
{
|
|
||||||
var fileName = Path.GetFileName(assetPath);
|
|
||||||
var mirrors = new List<PlondsMirrorEntry>
|
|
||||||
{
|
|
||||||
new("github", $"https://github.com/{repository}/releases/download/{releaseTag}/{Uri.EscapeDataString(fileName)}")
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(s3BaseUrl))
|
|
||||||
{
|
|
||||||
mirrors.Add(new PlondsMirrorEntry(
|
|
||||||
"s3",
|
|
||||||
$"{s3BaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PlondsAssetEntry(
|
|
||||||
AssetId: fileName,
|
|
||||||
FileName: fileName,
|
|
||||||
Sha256: PayloadUtilities.ComputeSha256(assetPath),
|
|
||||||
Size: new FileInfo(assetPath).Length,
|
|
||||||
Mirrors: mirrors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
namespace Plonds.Core.Publishing;
|
|
||||||
|
|
||||||
public sealed record PlondsPublishOptions(
|
|
||||||
string Version,
|
|
||||||
string AppArtifactsRoot,
|
|
||||||
string InstallerArtifactsRoot,
|
|
||||||
string OutputRoot,
|
|
||||||
string PrivateKeyPath,
|
|
||||||
string Channel = "stable",
|
|
||||||
string? BaselineRoot = null,
|
|
||||||
string? RepoBaseUrl = null,
|
|
||||||
string? InstallerBaseUrl = null,
|
|
||||||
string IncrementalStrategy = "release-payload",
|
|
||||||
string? BaselineVersion = null,
|
|
||||||
string? BaselineRef = null,
|
|
||||||
string? SourceCommit = null,
|
|
||||||
bool IsFullPayloadRelease = false,
|
|
||||||
string? CommitRangeStart = null,
|
|
||||||
string? CommitRangeEnd = null);
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Plonds.Core.Security;
|
|
||||||
using Plonds.Shared;
|
|
||||||
using Plonds.Shared.Models;
|
|
||||||
|
|
||||||
namespace Plonds.Core.Publishing;
|
|
||||||
|
|
||||||
public sealed class PlondsPublisher
|
|
||||||
{
|
|
||||||
private static readonly PlatformConfig[] SupportedPlatforms =
|
|
||||||
[
|
|
||||||
new("windows-x64", "app-payload-windows-x64", [".exe"], ["x64"]),
|
|
||||||
new("windows-x86", "app-payload-windows-x86", [".exe"], ["x86"]),
|
|
||||||
new("linux-x64", "app-payload-linux-x64", [".deb"], ["linux", "x64"])
|
|
||||||
];
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
WriteIndented = true
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly PlondsGenerator _generator = new();
|
|
||||||
private readonly RsaFileSigner _signer = new();
|
|
||||||
|
|
||||||
public IReadOnlyList<PlatformPublishResult> Publish(PlondsPublishOptions options)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
|
||||||
|
|
||||||
var results = new List<PlatformPublishResult>();
|
|
||||||
var releaseAssetsRoot = Path.Combine(Path.GetFullPath(options.OutputRoot), "release-assets");
|
|
||||||
Directory.CreateDirectory(releaseAssetsRoot);
|
|
||||||
|
|
||||||
foreach (var config in SupportedPlatforms)
|
|
||||||
{
|
|
||||||
var artifactRoot = Path.Combine(Path.GetFullPath(options.AppArtifactsRoot), config.ArtifactName);
|
|
||||||
if (!Directory.Exists(artifactRoot))
|
|
||||||
{
|
|
||||||
throw new DirectoryNotFoundException($"App payload artifact root not found for {config.Platform}: {artifactRoot}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentAppDirectory = FindCurrentAppDirectory(artifactRoot, options.Version);
|
|
||||||
if (currentAppDirectory is null)
|
|
||||||
{
|
|
||||||
throw new DirectoryNotFoundException($"Unable to locate app payload directory for {config.Platform} under {artifactRoot}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var baselineRoot = string.IsNullOrWhiteSpace(options.BaselineRoot)
|
|
||||||
? Path.Combine(Path.GetFullPath(options.OutputRoot), "_baselines")
|
|
||||||
: Path.GetFullPath(options.BaselineRoot);
|
|
||||||
var platformBaselineRoot = Path.Combine(baselineRoot, config.Platform);
|
|
||||||
var previousDirectory = Path.Combine(platformBaselineRoot, "current");
|
|
||||||
var previousVersionPath = Path.Combine(platformBaselineRoot, "version.txt");
|
|
||||||
Directory.CreateDirectory(platformBaselineRoot);
|
|
||||||
if (!Directory.Exists(previousDirectory))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(previousDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
var previousVersion = File.Exists(previousVersionPath)
|
|
||||||
? File.ReadAllText(previousVersionPath).Trim()
|
|
||||||
: "0.0.0";
|
|
||||||
|
|
||||||
var installerSourceDirectory = PrepareInstallerMirrorInput(
|
|
||||||
config,
|
|
||||||
options.InstallerArtifactsRoot,
|
|
||||||
Path.Combine(platformBaselineRoot, "installers"));
|
|
||||||
|
|
||||||
var distributionId = $"plonds-{options.Version}-{config.Platform}";
|
|
||||||
var repoBaseUrl = options.RepoBaseUrl;
|
|
||||||
var fileMapUrl = repoBaseUrl is null
|
|
||||||
? null
|
|
||||||
: $"{repoBaseUrl.TrimEnd('/').Replace("/repo/sha256", "/manifests")}/{distributionId}/plonds-filemap.json";
|
|
||||||
var fileMapSignatureUrl = fileMapUrl is null ? null : fileMapUrl + ".sig";
|
|
||||||
var installerBaseUrl = string.IsNullOrWhiteSpace(options.InstallerBaseUrl)
|
|
||||||
? null
|
|
||||||
: $"{options.InstallerBaseUrl.TrimEnd('/')}/{config.Platform}/{options.Version}";
|
|
||||||
|
|
||||||
var result = _generator.Generate(new PlondsGenerateOptions(
|
|
||||||
CurrentVersion: options.Version,
|
|
||||||
CurrentDirectory: currentAppDirectory,
|
|
||||||
Platform: config.Platform,
|
|
||||||
OutputRoot: options.OutputRoot,
|
|
||||||
PreviousVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
|
|
||||||
PreviousDirectory: previousDirectory,
|
|
||||||
Channel: options.Channel,
|
|
||||||
DistributionId: distributionId,
|
|
||||||
RepoBaseUrl: repoBaseUrl,
|
|
||||||
FileMapUrl: fileMapUrl,
|
|
||||||
FileMapSignatureUrl: fileMapSignatureUrl,
|
|
||||||
InstallerDirectory: installerSourceDirectory,
|
|
||||||
InstallerBaseUrl: installerBaseUrl,
|
|
||||||
IncrementalStrategy: options.IncrementalStrategy,
|
|
||||||
BaselineVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
|
|
||||||
BaselineRef: options.BaselineRef,
|
|
||||||
SourceCommit: options.SourceCommit,
|
|
||||||
IsFullPayloadRelease: options.IsFullPayloadRelease,
|
|
||||||
CommitRangeStart: options.CommitRangeStart,
|
|
||||||
CommitRangeEnd: options.CommitRangeEnd));
|
|
||||||
|
|
||||||
_signer.SignFile(result.FileMapPath, options.PrivateKeyPath, result.SignaturePath);
|
|
||||||
|
|
||||||
CopyReleaseAsset(result.FileMapPath, Path.Combine(releaseAssetsRoot, $"plonds-filemap-{config.Platform}.json"));
|
|
||||||
CopyReleaseAsset(result.SignaturePath, Path.Combine(releaseAssetsRoot, $"plonds-filemap-{config.Platform}.json.sig"));
|
|
||||||
CopyReleaseAsset(result.DistributionPath, Path.Combine(releaseAssetsRoot, $"plonds-distribution-{config.Platform}.json"));
|
|
||||||
CopyReleaseAsset(result.LatestPath, Path.Combine(releaseAssetsRoot, $"plonds-latest-{config.Platform}.json"));
|
|
||||||
|
|
||||||
MirrorBaseline(currentAppDirectory, previousDirectory, previousVersionPath, options.Version);
|
|
||||||
results.Add(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
WriteMetadataCatalog(options, results);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteMetadataCatalog(PlondsPublishOptions options, IReadOnlyList<PlatformPublishResult> results)
|
|
||||||
{
|
|
||||||
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
|
||||||
var metadataRoot = Path.Combine(outputRoot, "meta");
|
|
||||||
Directory.CreateDirectory(metadataRoot);
|
|
||||||
|
|
||||||
var generatedAt = DateTimeOffset.UtcNow;
|
|
||||||
var latestPointers = results
|
|
||||||
.Select(result => new PlondsChannelPointer(
|
|
||||||
Channel: options.Channel,
|
|
||||||
Platform: result.Platform,
|
|
||||||
DistributionId: result.DistributionId,
|
|
||||||
Version: options.Version,
|
|
||||||
PublishedAt: generatedAt,
|
|
||||||
DistributionPath: $"distributions/{result.DistributionId}.json",
|
|
||||||
FileMapPath: $"../manifests/{result.DistributionId}/plonds-filemap.json"))
|
|
||||||
.OrderBy(pointer => pointer.Channel, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ThenBy(pointer => pointer.Platform, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var catalog = new PlondsMetadataCatalog(
|
|
||||||
ProtocolName: PlondsConstants.ProtocolName,
|
|
||||||
ProtocolVersion: PlondsConstants.ProtocolVersion,
|
|
||||||
StorageRoot: outputRoot,
|
|
||||||
MetaRoot: metadataRoot,
|
|
||||||
Latest: latestPointers,
|
|
||||||
Metadata: new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["generatedBy"] = "Plonds.Tool",
|
|
||||||
["channel"] = options.Channel,
|
|
||||||
["generatedAt"] = generatedAt.ToString("O")
|
|
||||||
});
|
|
||||||
|
|
||||||
var metadataPath = Path.Combine(metadataRoot, "metadata.json");
|
|
||||||
File.WriteAllText(metadataPath, JsonSerializer.Serialize(catalog, JsonOptions), new UTF8Encoding(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void MirrorBaseline(string currentAppDirectory, string previousDirectory, string previousVersionPath, string version)
|
|
||||||
{
|
|
||||||
if (Directory.Exists(previousDirectory))
|
|
||||||
{
|
|
||||||
Directory.Delete(previousDirectory, recursive: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyDirectory(currentAppDirectory, previousDirectory);
|
|
||||||
File.WriteAllText(previousVersionPath, version);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? FindCurrentAppDirectory(string artifactRoot, string version)
|
|
||||||
{
|
|
||||||
var preferred = Directory.EnumerateDirectories(artifactRoot, $"app-{version}", SearchOption.AllDirectories).FirstOrDefault();
|
|
||||||
if (preferred is not null)
|
|
||||||
{
|
|
||||||
return preferred;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Directory.EnumerateDirectories(artifactRoot, "app-*", SearchOption.AllDirectories)
|
|
||||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.FirstOrDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string PrepareInstallerMirrorInput(PlatformConfig config, string installerArtifactsRoot, string destinationRoot)
|
|
||||||
{
|
|
||||||
var installerFiles = FindInstallerFiles(config, installerArtifactsRoot);
|
|
||||||
if (Directory.Exists(destinationRoot))
|
|
||||||
{
|
|
||||||
Directory.Delete(destinationRoot, recursive: true);
|
|
||||||
}
|
|
||||||
Directory.CreateDirectory(destinationRoot);
|
|
||||||
|
|
||||||
foreach (var file in installerFiles)
|
|
||||||
{
|
|
||||||
File.Copy(file, Path.Combine(destinationRoot, Path.GetFileName(file)), overwrite: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return destinationRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<string> FindInstallerFiles(PlatformConfig config, string installerArtifactsRoot)
|
|
||||||
{
|
|
||||||
var files = Directory.EnumerateFiles(Path.GetFullPath(installerArtifactsRoot), "*", SearchOption.AllDirectories);
|
|
||||||
return files
|
|
||||||
.Where(file => config.InstallerExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase))
|
|
||||||
.Where(file =>
|
|
||||||
{
|
|
||||||
var fileName = Path.GetFileName(file);
|
|
||||||
return config.FileNameTokens.All(token => fileName.Contains(token, StringComparison.OrdinalIgnoreCase));
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CopyReleaseAsset(string sourcePath, string destinationPath)
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
|
||||||
File.Copy(sourcePath, destinationPath, overwrite: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CopyDirectory(string sourceDir, string destinationDir)
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(destinationDir);
|
|
||||||
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
|
|
||||||
{
|
|
||||||
var relativePath = Path.GetRelativePath(sourceDir, directory);
|
|
||||||
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
|
|
||||||
{
|
|
||||||
var relativePath = Path.GetRelativePath(sourceDir, file);
|
|
||||||
var destinationPath = Path.Combine(destinationDir, relativePath);
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
|
||||||
File.Copy(file, destinationPath, overwrite: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record PlatformConfig(
|
|
||||||
string Platform,
|
|
||||||
string ArtifactName,
|
|
||||||
IReadOnlyList<string> InstallerExtensions,
|
|
||||||
IReadOnlyList<string> FileNameTokens);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Plonds.Core.Security;
|
|
||||||
using Plonds.Shared.Models;
|
|
||||||
|
|
||||||
namespace Plonds.Core.Publishing;
|
|
||||||
|
|
||||||
public sealed class PlondsReleaseIndexBuilder
|
|
||||||
{
|
|
||||||
private readonly RsaFileSigner _signer = new();
|
|
||||||
|
|
||||||
public string Build(PlondsReleaseIndexOptions options)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
|
||||||
|
|
||||||
var summariesDirectory = Path.GetFullPath(options.PlatformSummariesDirectory);
|
|
||||||
if (!Directory.Exists(summariesDirectory))
|
|
||||||
{
|
|
||||||
throw new DirectoryNotFoundException($"Platform summary directory not found: {summariesDirectory}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var summaries = Directory
|
|
||||||
.EnumerateFiles(summariesDirectory, "platform-summary-*.json", SearchOption.TopDirectoryOnly)
|
|
||||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.Select(ReadSummary)
|
|
||||||
.OrderBy(static entry => entry.Platform, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var manifest = new PlondsReleaseManifest(
|
|
||||||
FormatVersion: "1.0",
|
|
||||||
ReleaseTag: options.ReleaseTag,
|
|
||||||
Version: options.Version,
|
|
||||||
Channel: options.Channel,
|
|
||||||
GeneratedAt: DateTimeOffset.UtcNow,
|
|
||||||
Platforms: summaries);
|
|
||||||
|
|
||||||
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
|
||||||
var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets");
|
|
||||||
Directory.CreateDirectory(releaseAssetsRoot);
|
|
||||||
|
|
||||||
var manifestPath = Path.Combine(releaseAssetsRoot, "plonds.json");
|
|
||||||
PayloadUtilities.WriteJson(manifestPath, manifest);
|
|
||||||
_signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig");
|
|
||||||
return manifestPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PlondsReleasePlatformEntry ReadSummary(string path)
|
|
||||||
{
|
|
||||||
var json = File.ReadAllText(path);
|
|
||||||
var summary = JsonSerializer.Deserialize<PlondsReleasePlatformEntry>(json, PayloadUtilities.JsonOptions);
|
|
||||||
if (summary is null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Unable to deserialize PLONDS platform summary: {path}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Plonds.Core.Publishing;
|
|
||||||
|
|
||||||
public sealed record PlondsReleaseIndexOptions(
|
|
||||||
string ReleaseTag,
|
|
||||||
string Version,
|
|
||||||
string Channel,
|
|
||||||
string PlatformSummariesDirectory,
|
|
||||||
string OutputRoot,
|
|
||||||
string PrivateKeyPath);
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Plonds.Core.Security;
|
|
||||||
|
|
||||||
public sealed class RsaFileSigner
|
|
||||||
{
|
|
||||||
public string SignFile(string filePath, string privateKeyPath, string? outputPath = null)
|
|
||||||
{
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPath);
|
|
||||||
|
|
||||||
if (!File.Exists(filePath))
|
|
||||||
{
|
|
||||||
throw new FileNotFoundException("Manifest file not found.", filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!File.Exists(privateKeyPath))
|
|
||||||
{
|
|
||||||
throw new FileNotFoundException("Private key PEM file not found.", privateKeyPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
outputPath ??= filePath + ".sig";
|
|
||||||
|
|
||||||
var payload = File.ReadAllBytes(filePath);
|
|
||||||
var privateKeyPem = File.ReadAllText(privateKeyPath, Encoding.ASCII);
|
|
||||||
if (string.IsNullOrWhiteSpace(privateKeyPem))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Private key PEM is empty.");
|
|
||||||
}
|
|
||||||
|
|
||||||
using var rsa = RSA.Create();
|
|
||||||
rsa.ImportFromPem(privateKeyPem);
|
|
||||||
var signature = rsa.SignData(payload, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
||||||
File.WriteAllText(outputPath, Convert.ToBase64String(signature), Encoding.ASCII);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace Plonds.Shared.Models;
|
|
||||||
|
|
||||||
public sealed record PlondsAssetEntry(
|
|
||||||
string AssetId,
|
|
||||||
string FileName,
|
|
||||||
string Sha256,
|
|
||||||
long Size,
|
|
||||||
IReadOnlyList<PlondsMirrorEntry> Mirrors);
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record PlondsChangedFileEntry(
|
||||||
|
string ArchivePath,
|
||||||
|
string Hash,
|
||||||
|
long Size,
|
||||||
|
string HashAlgorithm = "sha256");
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace Plonds.Shared.Models;
|
|
||||||
|
|
||||||
public sealed record PlondsChannelPointer(
|
|
||||||
string Channel,
|
|
||||||
string Platform,
|
|
||||||
string DistributionId,
|
|
||||||
string Version,
|
|
||||||
DateTimeOffset PublishedAt,
|
|
||||||
string? DistributionPath = null,
|
|
||||||
string? FileMapPath = null);
|
|
||||||
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Plonds.Shared.Models;
|
|
||||||
|
|
||||||
public sealed record PlondsComponent(
|
|
||||||
string Id,
|
|
||||||
string Root,
|
|
||||||
string Mode,
|
|
||||||
IReadOnlyList<PlondsFileEntry> Files,
|
|
||||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace Plonds.Shared.Models;
|
|
||||||
|
|
||||||
public sealed record PlondsDistributionInfo(
|
|
||||||
string DistributionId,
|
|
||||||
string Version,
|
|
||||||
string Channel,
|
|
||||||
string Platform,
|
|
||||||
DateTimeOffset PublishedAt,
|
|
||||||
IReadOnlyList<PlondsComponent> Components,
|
|
||||||
IReadOnlyList<PlondsMirrorAsset> InstallerMirrors,
|
|
||||||
IReadOnlyList<string> Capabilities,
|
|
||||||
IReadOnlyList<PlondsSignatureDescriptor> Signatures,
|
|
||||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
|
||||||
|
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
namespace Plonds.Shared.Models;
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
public sealed record PlondsFileEntry(
|
public sealed record PlondsFileEntry(
|
||||||
string Path,
|
string Action,
|
||||||
string Op,
|
string Hash,
|
||||||
string ContentHash,
|
|
||||||
long Size,
|
long Size,
|
||||||
string Mode,
|
string HashAlgorithm = "sha256");
|
||||||
string? ObjectKey = null,
|
|
||||||
string? Compression = null,
|
|
||||||
string? PatchBaseHash = null,
|
|
||||||
string? PatchObjectKey = null);
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace Plonds.Shared.Models;
|
|
||||||
|
|
||||||
public sealed record PlondsFileMap(
|
|
||||||
string FormatVersion,
|
|
||||||
string DistributionId,
|
|
||||||
string SourceVersion,
|
|
||||||
string TargetVersion,
|
|
||||||
string Platform,
|
|
||||||
IReadOnlyList<PlondsComponent> Components,
|
|
||||||
IReadOnlyList<string> Capabilities,
|
|
||||||
IReadOnlyList<PlondsSignatureDescriptor> Signatures,
|
|
||||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
|
||||||
|
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
namespace Plonds.Shared.Models;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
public sealed record PlondsManifest(
|
public sealed record PlondsManifest(
|
||||||
string FormatVersion,
|
string FormatVersion,
|
||||||
string ReleaseTag,
|
string CurrentVersion,
|
||||||
DateTimeOffset GeneratedAt,
|
string PreviousVersion,
|
||||||
IReadOnlyList<PlondsAssetEntry> Assets);
|
bool IsFullUpdate,
|
||||||
|
bool RequiresCleanInstall,
|
||||||
|
string Channel,
|
||||||
|
string Platform,
|
||||||
|
DateTimeOffset UpdatedAt,
|
||||||
|
string CompareMethod,
|
||||||
|
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
string? HashAlgorithm,
|
||||||
|
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
|
||||||
|
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
|
||||||
|
IReadOnlyDictionary<string, string> Checksums);
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Plonds.Shared.Models;
|
|
||||||
|
|
||||||
public sealed record PlondsMetadataCatalog(
|
|
||||||
string ProtocolName,
|
|
||||||
string ProtocolVersion,
|
|
||||||
string StorageRoot,
|
|
||||||
string MetaRoot,
|
|
||||||
IReadOnlyList<PlondsChannelPointer> Latest,
|
|
||||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
|
||||||
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Plonds.Shared.Models;
|
|
||||||
|
|
||||||
public sealed record PlondsMirrorAsset(
|
|
||||||
string Platform,
|
|
||||||
string Arch,
|
|
||||||
string Url,
|
|
||||||
string? FileName = null,
|
|
||||||
string? Sha256 = null,
|
|
||||||
long Size = 0);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
namespace Plonds.Shared.Models;
|
|
||||||
|
|
||||||
public sealed record PlondsMirrorEntry(
|
|
||||||
string Type,
|
|
||||||
string Url);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Plonds.Shared.Models;
|
|
||||||
|
|
||||||
public sealed record PlondsReleaseManifest(
|
|
||||||
string FormatVersion,
|
|
||||||
string ReleaseTag,
|
|
||||||
string Version,
|
|
||||||
string Channel,
|
|
||||||
DateTimeOffset GeneratedAt,
|
|
||||||
IReadOnlyList<PlondsReleasePlatformEntry> Platforms);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace Plonds.Shared.Models;
|
|
||||||
|
|
||||||
public sealed record PlondsReleasePlatformEntry(
|
|
||||||
string Platform,
|
|
||||||
string DistributionId,
|
|
||||||
string? BaselineTag,
|
|
||||||
string? BaselineVersion,
|
|
||||||
string TargetVersion,
|
|
||||||
bool IsFullPayload,
|
|
||||||
string FilesZipAsset,
|
|
||||||
string UpdateZipAsset,
|
|
||||||
string FileMapAsset,
|
|
||||||
string FileMapSignatureAsset,
|
|
||||||
string Sha256);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Plonds.Shared.Models;
|
|
||||||
|
|
||||||
public sealed record PlondsSignatureDescriptor(
|
|
||||||
string Algorithm,
|
|
||||||
string KeyId,
|
|
||||||
string Signature);
|
|
||||||
|
|
||||||
@@ -3,23 +3,39 @@ namespace Plonds.Shared;
|
|||||||
public static class PlondsConstants
|
public static class PlondsConstants
|
||||||
{
|
{
|
||||||
public const string ProtocolName = "PLONDS";
|
public const string ProtocolName = "PLONDS";
|
||||||
public const string ProtocolVersion = "1.0";
|
public const string ProtocolVersion = "2.0";
|
||||||
|
public const string FormatVersion = "2.0";
|
||||||
|
|
||||||
public const string DefaultApiBasePath = "/api/plonds/v1";
|
public const string ActionAdd = "add";
|
||||||
public const string DefaultStorageRoot = "sample-data";
|
public const string ActionReplace = "replace";
|
||||||
public const string DefaultMetaRoot = "meta";
|
public const string ActionReuse = "reuse";
|
||||||
public const string DefaultRepoRoot = "repo";
|
public const string ActionDelete = "delete";
|
||||||
public const string DefaultInstallersRoot = "installers";
|
|
||||||
|
|
||||||
public const string FileObjectMode = "file-object";
|
public const string CompareMethodFileCompare = "file-compare";
|
||||||
public const string CompressedObjectMode = "compressed-object";
|
public const string CompareMethodCommitAnalyze = "commit-analyze";
|
||||||
public const string BinaryPatchMode = "binary-patch";
|
|
||||||
|
|
||||||
public static readonly string[] SupportedFileModes =
|
public const string HashAlgorithmSha256 = "sha256";
|
||||||
|
public const string HashAlgorithmMd5 = "md5";
|
||||||
|
|
||||||
|
public const string DefaultLauncherRelativePath = "LanMountainDesktop.Launcher.exe";
|
||||||
|
|
||||||
|
public static readonly string[] SupportedActions =
|
||||||
[
|
[
|
||||||
FileObjectMode,
|
ActionAdd,
|
||||||
CompressedObjectMode,
|
ActionReplace,
|
||||||
BinaryPatchMode
|
ActionReuse,
|
||||||
|
ActionDelete
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly string[] SupportedHashAlgorithms =
|
||||||
|
[
|
||||||
|
HashAlgorithmSha256,
|
||||||
|
HashAlgorithmMd5
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly string[] SupportedCompareMethods =
|
||||||
|
[
|
||||||
|
CompareMethodFileCompare,
|
||||||
|
CompareMethodCommitAnalyze
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Plonds.Shared;
|
||||||
|
|
||||||
|
public enum PlondsFileAction
|
||||||
|
{
|
||||||
|
Add,
|
||||||
|
Replace,
|
||||||
|
Reuse,
|
||||||
|
Delete
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using Plonds.Core.Publishing;
|
using Plonds.Core.Publishing;
|
||||||
using Plonds.Core.Security;
|
|
||||||
|
|
||||||
return await PlondsCli.RunAsync(args);
|
return await PlondsCli.RunAsync(args);
|
||||||
|
|
||||||
@@ -20,26 +19,14 @@ internal static class PlondsCli
|
|||||||
{
|
{
|
||||||
switch (command)
|
switch (command)
|
||||||
{
|
{
|
||||||
case "generate":
|
|
||||||
RunGenerate(options);
|
|
||||||
return Task.FromResult(0);
|
|
||||||
case "sign":
|
|
||||||
RunSign(options);
|
|
||||||
return Task.FromResult(0);
|
|
||||||
case "publish":
|
|
||||||
RunPublish(options);
|
|
||||||
return Task.FromResult(0);
|
|
||||||
case "pack-payload":
|
|
||||||
RunPackPayload(options);
|
|
||||||
return Task.FromResult(0);
|
|
||||||
case "build-delta":
|
case "build-delta":
|
||||||
RunBuildDelta(options);
|
RunBuildDelta(options);
|
||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
case "build-index":
|
case "build-delta-from-commits":
|
||||||
RunBuildIndex(options);
|
RunBuildDeltaFromCommits(options);
|
||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
case "build-plonds":
|
case "pack-payload":
|
||||||
RunBuildPlonds(options);
|
RunPackPayload(options);
|
||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
default:
|
default:
|
||||||
Console.Error.WriteLine($"Unknown command: {command}");
|
Console.Error.WriteLine($"Unknown command: {command}");
|
||||||
@@ -54,63 +41,51 @@ internal static class PlondsCli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RunGenerate(Dictionary<string, string> options)
|
private static void RunBuildDelta(Dictionary<string, string> options)
|
||||||
{
|
{
|
||||||
var generator = new PlondsGenerator();
|
var builder = new PlondsDeltaBuilder();
|
||||||
var result = generator.Generate(new PlondsGenerateOptions(
|
var result = builder.Build(new PlondsDeltaBuildOptions(
|
||||||
CurrentVersion: Require(options, "current-version"),
|
|
||||||
CurrentDirectory: Require(options, "current-dir"),
|
|
||||||
Platform: Require(options, "platform"),
|
Platform: Require(options, "platform"),
|
||||||
|
CurrentVersion: Require(options, "current-version"),
|
||||||
|
CurrentPayloadZip: Require(options, "current-zip"),
|
||||||
OutputRoot: Require(options, "output-dir"),
|
OutputRoot: Require(options, "output-dir"),
|
||||||
PreviousVersion: Get(options, "previous-version", "0.0.0") ?? "0.0.0",
|
|
||||||
PreviousDirectory: Get(options, "previous-dir"),
|
|
||||||
Channel: Get(options, "channel", "stable") ?? "stable",
|
Channel: Get(options, "channel", "stable") ?? "stable",
|
||||||
DistributionId: Get(options, "distribution-id"),
|
|
||||||
RepoBaseUrl: Get(options, "repo-base-url"),
|
|
||||||
FileMapUrl: Get(options, "file-map-url"),
|
|
||||||
FileMapSignatureUrl: Get(options, "file-map-signature-url"),
|
|
||||||
InstallerDirectory: Get(options, "installer-directory"),
|
|
||||||
InstallerBaseUrl: Get(options, "installer-base-url")));
|
|
||||||
|
|
||||||
Console.WriteLine($"Generated PLONDS artifacts for {result.Platform}: {result.DistributionId}");
|
|
||||||
Console.WriteLine(result.FileMapPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RunSign(Dictionary<string, string> options)
|
|
||||||
{
|
|
||||||
var signer = new RsaFileSigner();
|
|
||||||
var signaturePath = signer.SignFile(
|
|
||||||
Require(options, "manifest"),
|
|
||||||
Require(options, "private-key"),
|
|
||||||
Get(options, "output"));
|
|
||||||
Console.WriteLine(signaturePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RunPublish(Dictionary<string, string> options)
|
|
||||||
{
|
|
||||||
var publisher = new PlondsPublisher();
|
|
||||||
var results = publisher.Publish(new PlondsPublishOptions(
|
|
||||||
Version: Require(options, "version"),
|
|
||||||
AppArtifactsRoot: Require(options, "app-artifacts-root"),
|
|
||||||
InstallerArtifactsRoot: Require(options, "installer-artifacts-root"),
|
|
||||||
OutputRoot: Require(options, "output-dir"),
|
|
||||||
PrivateKeyPath: Require(options, "private-key"),
|
|
||||||
Channel: Get(options, "channel", "stable") ?? "stable",
|
|
||||||
BaselineRoot: Get(options, "baseline-root"),
|
|
||||||
RepoBaseUrl: Get(options, "repo-base-url"),
|
|
||||||
InstallerBaseUrl: Get(options, "installer-base-url"),
|
|
||||||
IncrementalStrategy: Get(options, "incremental-strategy", "release-payload") ?? "release-payload",
|
|
||||||
BaselineVersion: Get(options, "baseline-version"),
|
BaselineVersion: Get(options, "baseline-version"),
|
||||||
BaselineRef: Get(options, "baseline-ref"),
|
BaselinePayloadZip: Get(options, "baseline-zip"),
|
||||||
SourceCommit: Get(options, "source-commit"),
|
LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe",
|
||||||
IsFullPayloadRelease: bool.TryParse(Get(options, "is-full-payload-release", "false"), out var isFullPayloadRelease) && isFullPayloadRelease,
|
HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256"));
|
||||||
CommitRangeStart: Get(options, "commit-range-start"),
|
|
||||||
CommitRangeEnd: Get(options, "commit-range-end")));
|
|
||||||
|
|
||||||
foreach (var result in results)
|
Console.WriteLine($"Built PLONDS delta for {result.Platform}:");
|
||||||
{
|
Console.WriteLine($" IsFullUpdate: {result.IsFullUpdate}");
|
||||||
Console.WriteLine($"{result.Platform}: {result.DistributionId}");
|
Console.WriteLine($" RequiresCleanInstall: {result.RequiresCleanInstall}");
|
||||||
}
|
Console.WriteLine($" ChangedZip: {result.ChangedZipPath}");
|
||||||
|
Console.WriteLine($" Manifest: {result.ManifestPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunBuildDeltaFromCommits(Dictionary<string, string> options)
|
||||||
|
{
|
||||||
|
var builder = new PlondsCommitDeltaBuilder();
|
||||||
|
var result = builder.Build(new PlondsCommitDeltaBuildOptions(
|
||||||
|
Platform: Require(options, "platform"),
|
||||||
|
CurrentVersion: Require(options, "current-version"),
|
||||||
|
CurrentPayloadZip: Require(options, "current-zip"),
|
||||||
|
OutputRoot: Require(options, "output-dir"),
|
||||||
|
Channel: Require(options, "channel"),
|
||||||
|
BaselineTag: Require(options, "baseline-tag"),
|
||||||
|
CurrentTag: Require(options, "current-tag"),
|
||||||
|
FallbackBaselineZip: Get(options, "fallback-zip"),
|
||||||
|
BaselineVersion: Get(options, "baseline-version"),
|
||||||
|
LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe",
|
||||||
|
HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256"));
|
||||||
|
|
||||||
|
Console.WriteLine($"Built PLONDS commit-delta for {result.Platform}:");
|
||||||
|
Console.WriteLine($" IsFullUpdate: {result.IsFullUpdate}");
|
||||||
|
Console.WriteLine($" RequiresCleanInstall: {result.RequiresCleanInstall}");
|
||||||
|
Console.WriteLine($" FellBackToFileCompare: {result.FellBackToFileCompare}");
|
||||||
|
Console.WriteLine($" ChangedSourceFiles: {result.ChangedSourceFiles.Count}");
|
||||||
|
Console.WriteLine($" MappedArtifactFiles: {result.MappedArtifactFiles.Count}");
|
||||||
|
Console.WriteLine($" ChangedZip: {result.ChangedZipPath}");
|
||||||
|
Console.WriteLine($" Manifest: {result.ManifestPath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RunPackPayload(Dictionary<string, string> options)
|
private static void RunPackPayload(Dictionary<string, string> options)
|
||||||
@@ -121,56 +96,6 @@ internal static class PlondsCli
|
|||||||
Console.WriteLine(outputZip);
|
Console.WriteLine(outputZip);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RunBuildDelta(Dictionary<string, string> options)
|
|
||||||
{
|
|
||||||
var builder = new PlondsDeltaBuilder();
|
|
||||||
var result = builder.Build(new PlondsDeltaBuildOptions(
|
|
||||||
Platform: Require(options, "platform"),
|
|
||||||
CurrentVersion: Require(options, "current-version"),
|
|
||||||
CurrentTag: Require(options, "current-tag"),
|
|
||||||
CurrentPayloadZip: Require(options, "current-zip"),
|
|
||||||
OutputRoot: Require(options, "output-dir"),
|
|
||||||
PrivateKeyPath: Require(options, "private-key"),
|
|
||||||
Channel: Get(options, "channel", "stable") ?? "stable",
|
|
||||||
BaselineVersion: Get(options, "baseline-version"),
|
|
||||||
BaselineTag: Get(options, "baseline-tag"),
|
|
||||||
BaselinePayloadZip: Get(options, "baseline-zip"),
|
|
||||||
IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload,
|
|
||||||
StaticOutputRoot: Get(options, "static-output-dir"),
|
|
||||||
UpdateBaseUrl: Get(options, "update-base-url")));
|
|
||||||
|
|
||||||
Console.WriteLine($"Built PLONDS delta for {result.Platform}: {result.UpdateArchivePath}");
|
|
||||||
Console.WriteLine(result.FileMapPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RunBuildIndex(Dictionary<string, string> options)
|
|
||||||
{
|
|
||||||
var builder = new PlondsReleaseIndexBuilder();
|
|
||||||
var manifestPath = builder.Build(new PlondsReleaseIndexOptions(
|
|
||||||
ReleaseTag: Require(options, "release-tag"),
|
|
||||||
Version: Require(options, "version"),
|
|
||||||
Channel: Get(options, "channel", "stable") ?? "stable",
|
|
||||||
PlatformSummariesDirectory: Require(options, "platform-summaries-dir"),
|
|
||||||
OutputRoot: Require(options, "output-dir"),
|
|
||||||
PrivateKeyPath: Require(options, "private-key")));
|
|
||||||
|
|
||||||
Console.WriteLine(manifestPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RunBuildPlonds(Dictionary<string, string> options)
|
|
||||||
{
|
|
||||||
var builder = new PlondsManifestBuilder();
|
|
||||||
var manifestPath = builder.Build(new PlondsBuildOptions(
|
|
||||||
ReleaseTag: Require(options, "release-tag"),
|
|
||||||
AssetsDirectory: Require(options, "assets-dir"),
|
|
||||||
OutputRoot: Require(options, "output-dir"),
|
|
||||||
PrivateKeyPath: Require(options, "private-key"),
|
|
||||||
Repository: Require(options, "repository"),
|
|
||||||
S3BaseUrl: Get(options, "s3-base-url")));
|
|
||||||
|
|
||||||
Console.WriteLine(manifestPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, string> ParseOptions(string[] args)
|
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||||
{
|
{
|
||||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -212,12 +137,8 @@ internal static class PlondsCli
|
|||||||
private static void PrintUsage()
|
private static void PrintUsage()
|
||||||
{
|
{
|
||||||
Console.WriteLine("PLONDS Tool");
|
Console.WriteLine("PLONDS Tool");
|
||||||
|
Console.WriteLine(" build-delta --platform <p> --current-version <v> --current-zip <file> --output-dir <dir> [--channel <ch>] [--baseline-version <v>] [--baseline-zip <file>] [--launcher-path <path>] [--hash-algorithm sha256|md5]");
|
||||||
|
Console.WriteLine(" build-delta-from-commits --platform <p> --current-version <v> --current-zip <file> --output-dir <dir> --channel <ch> --baseline-tag <tag> --current-tag <tag> [--fallback-zip <file>] [--baseline-version <v>] [--launcher-path <path>] [--hash-algorithm sha256|md5]");
|
||||||
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>");
|
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>");
|
||||||
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload] [--static-output-dir <dir>] [--update-base-url <url>]");
|
|
||||||
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
|
|
||||||
Console.WriteLine(" build-plonds --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
|
|
||||||
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");
|
|
||||||
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
|
|
||||||
Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
check_ipc.cs
Normal file
31
check_ipc.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
static void Main()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
var asm = Assembly.LoadFrom(@"C:\Users\USER154971\.nuget\packages\dotnetcampus.ipc\2.0.0-alpha436\lib\net6.0\dotnetCampus.Ipc.dll");
|
||||||
|
var type = asm.GetType("dotnetCampus.Ipc.IpcRouteds.DirectRouteds.JsonIpcDirectRoutedProvider");
|
||||||
|
if (type == null) {
|
||||||
|
Console.WriteLine("Type not found. Trying to find it...");
|
||||||
|
foreach (var t in asm.GetTypes().Where(t => t.Name.Contains("JsonIpc"))) {
|
||||||
|
Console.WriteLine("Found: " + t.FullName);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Console.WriteLine("Type: " + type.FullName);
|
||||||
|
foreach (var prop in type.GetProperties()) {
|
||||||
|
Console.WriteLine("Prop: " + prop.Name + " Type: " + prop.PropertyType.Name);
|
||||||
|
}
|
||||||
|
} catch (ReflectionTypeLoadException ex) {
|
||||||
|
foreach (var e in ex.LoaderExceptions) {
|
||||||
|
Console.WriteLine("LoaderEx: " + e.Message);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Console.WriteLine("Ex: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
size_analysis.ps1
Normal file
71
size_analysis.ps1
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
|
||||||
|
Write-Output "=== FONT FILES ==="
|
||||||
|
Get-ChildItem 'd:\github\LanMountainDesktop\LanMountainDesktop\Assets\Fonts' -File | Sort-Object Length -Descending | ForEach-Object {
|
||||||
|
$sizeKB = [math]::Round($_.Length/1KB, 1)
|
||||||
|
Write-Output "$sizeKB KB $($_.Name)"
|
||||||
|
}
|
||||||
|
$totalFontMB = [math]::Round((Get-ChildItem 'd:\github\LanMountainDesktop\LanMountainDesktop\Assets\Fonts' -File | Measure-Object -Property Length -Sum).Sum/1MB, 2)
|
||||||
|
Write-Output "TOTAL FONTS: $totalFontMB MB"
|
||||||
|
|
||||||
|
Write-Output ""
|
||||||
|
Write-Output "=== ROOT ASSET FILES ==="
|
||||||
|
Get-ChildItem 'd:\github\LanMountainDesktop\LanMountainDesktop\Assets' -File | Sort-Object Length -Descending | ForEach-Object {
|
||||||
|
$sizeKB = [math]::Round($_.Length/1KB, 1)
|
||||||
|
Write-Output "$sizeKB KB $($_.Name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output ""
|
||||||
|
Write-Output "=== MATERIAL WEATHER ICONS ==="
|
||||||
|
$weatherStats = Get-ChildItem 'd:\github\LanMountainDesktop\LanMountainDesktop\Assets\MaterialWeatherIcons' -Recurse -File | Measure-Object -Property Length -Sum
|
||||||
|
$weatherMB = [math]::Round($weatherStats.Sum/1MB, 2)
|
||||||
|
Write-Output "$weatherMB MB total, $($weatherStats.Count) files"
|
||||||
|
|
||||||
|
Write-Output ""
|
||||||
|
Write-Output "=== BUILD OUTPUT (Debug) ==="
|
||||||
|
$debugPath = 'd:\github\LanMountainDesktop\LanMountainDesktop\bin\Debug'
|
||||||
|
if (Test-Path $debugPath) {
|
||||||
|
$debugStats = Get-ChildItem $debugPath -Recurse -File | Measure-Object -Property Length -Sum
|
||||||
|
$debugMB = [math]::Round($debugStats.Sum/1MB, 2)
|
||||||
|
Write-Output "$debugMB MB total, $($debugStats.Count) files"
|
||||||
|
|
||||||
|
$tfmPath = Get-ChildItem $debugPath -Directory | Select-Object -First 1
|
||||||
|
if ($tfmPath) {
|
||||||
|
Write-Output ""
|
||||||
|
Write-Output "=== TOP 50 LARGEST FILES IN BUILD OUTPUT ==="
|
||||||
|
Get-ChildItem $debugPath -Recurse -File | Sort-Object Length -Descending | Select-Object -First 50 | ForEach-Object {
|
||||||
|
$sizeKB = [math]::Round($_.Length/1KB, 1)
|
||||||
|
$rel = $_.FullName.Substring($debugPath.Length+1)
|
||||||
|
Write-Output "$sizeKB KB $rel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Output "Debug output not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output ""
|
||||||
|
Write-Output "=== BUILD OUTPUT (Release) ==="
|
||||||
|
$releasePath = 'd:\github\LanMountainDesktop\LanMountainDesktop\bin\Release'
|
||||||
|
if (Test-Path $releasePath) {
|
||||||
|
$releaseStats = Get-ChildItem $releasePath -Recurse -File | Measure-Object -Property Length -Sum
|
||||||
|
$releaseMB = [math]::Round($releaseStats.Sum/1MB, 2)
|
||||||
|
Write-Output "$releaseMB MB total, $($releaseStats.Count) files"
|
||||||
|
} else {
|
||||||
|
Write-Output "Release output not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output ""
|
||||||
|
Write-Output "=== NUGET CACHE - LARGEST PACKAGES ==="
|
||||||
|
$nugetPath = 'd:\github\LanMountainDesktop\LanMountainDesktop\obj\project.assets.json'
|
||||||
|
if (Test-Path $nugetPath) {
|
||||||
|
$assets = Get-Content $nugetPath -Raw | ConvertFrom-Json
|
||||||
|
$libs = $assets.Libraries
|
||||||
|
$libSizes = @()
|
||||||
|
foreach ($prop in $libs.PSObject.Properties) {
|
||||||
|
$libSizes += [PSCustomObject]@{
|
||||||
|
Name = $prop.Name
|
||||||
|
Type = $prop.Value.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$libSizes | Where-Object { $_.Type -eq 'package' } | Select-Object -First 30 Name | ForEach-Object { Write-Output $_.Name }
|
||||||
|
}
|
||||||
85
size_analysis2.ps1
Normal file
85
size_analysis2.ps1
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
$base = 'd:\github\LanMountainDesktop\LanMountainDesktop\bin\Debug\net10.0'
|
||||||
|
|
||||||
|
Write-Output "=== CATEGORY BREAKDOWN ==="
|
||||||
|
|
||||||
|
$categories = @(
|
||||||
|
@{ Name = "SkiaSharp native (all platforms)"; Pattern = "runtimes\*\native\libSkiaSharp.*" },
|
||||||
|
@{ Name = "SkiaSharp PDB (all platforms)"; Pattern = "runtimes\*\native\libSkiaSharp.pdb" },
|
||||||
|
@{ Name = "HarfBuzzSharp native (all platforms)"; Pattern = "runtimes\*\native\libHarfBuzzSharp.*" },
|
||||||
|
@{ Name = "HarfBuzzSharp PDB (all platforms)"; Pattern = "runtimes\*\native\libHarfBuzzSharp.pdb" },
|
||||||
|
@{ Name = "SQLite native (all platforms)"; Pattern = "runtimes\*\native\*sqlite3*" },
|
||||||
|
@{ Name = "WebView2 native"; Pattern = "runtimes\*\native\*WebView2*" },
|
||||||
|
@{ Name = "Avalonia DLLs"; Pattern = "Avalonia*.dll" },
|
||||||
|
@{ Name = "FluentAvalonia DLLs"; Pattern = "Fluent*.dll" },
|
||||||
|
@{ Name = "Material DLLs"; Pattern = "Material*.dll" },
|
||||||
|
@{ Name = "Sentry DLLs"; Pattern = "Sentry*.dll" },
|
||||||
|
@{ Name = "PostHog DLLs"; Pattern = "PostHog*.dll" },
|
||||||
|
@{ Name = "Microsoft.Extensions DLLs"; Pattern = "Microsoft.Extensions*.dll" },
|
||||||
|
@{ Name = "Microsoft.Data.Sqlite DLLs"; Pattern = "Microsoft.Data*.dll" },
|
||||||
|
@{ Name = "MudTools DLLs"; Pattern = "MudTools*.dll" },
|
||||||
|
@{ Name = "PortAudioSharp DLLs"; Pattern = "PortAudio*.dll" },
|
||||||
|
@{ Name = "Harmony DLLs"; Pattern = "*Harmony*.dll" },
|
||||||
|
@{ Name = "InkCanvas DLLs"; Pattern = "*InkCanvas*.dll" },
|
||||||
|
@{ Name = "InkCore DLLs"; Pattern = "*InkCore*.dll" },
|
||||||
|
@{ Name = "dotnetCampus DLLs"; Pattern = "dotnetCampus*.dll" },
|
||||||
|
@{ Name = "ClassIsland DLLs"; Pattern = "ClassIsland*.dll" },
|
||||||
|
@{ Name = "App DLLs (LanMountainDesktop)"; Pattern = "LanMountainDesktop*.dll" }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($cat in $categories) {
|
||||||
|
$files = Get-ChildItem $base -Recurse -File | Where-Object { $_.Name -like $cat.Pattern -or $_.FullName -like "*\$($cat.Pattern)" }
|
||||||
|
if (-not $files) {
|
||||||
|
$files = Get-ChildItem $base -Recurse -File | Where-Object { $_.FullName -like "*$($cat.Pattern)*" }
|
||||||
|
}
|
||||||
|
if ($files) {
|
||||||
|
$totalMB = [math]::Round(($files | Measure-Object -Property Length -Sum).Sum/1MB, 2)
|
||||||
|
Write-Output "$($cat.Name): $totalMB MB ($($files.Count) files)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output ""
|
||||||
|
Write-Output "=== RUNTIME RID SUBFOLDERS ==="
|
||||||
|
Get-ChildItem "$base\runtimes" -Directory | ForEach-Object {
|
||||||
|
$sizeMB = [math]::Round((Get-ChildItem $_.FullName -Recurse -File | Measure-Object -Property Length -Sum).Sum/1MB, 2)
|
||||||
|
Write-Output "$sizeMB MB $($_.Name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output ""
|
||||||
|
Write-Output "=== AIRAPPHOST RUNTIME RID SUBFOLDERS ==="
|
||||||
|
$airBase = "$base\AirAppHost\runtimes"
|
||||||
|
if (Test-Path $airBase) {
|
||||||
|
Get-ChildItem $airBase -Directory | ForEach-Object {
|
||||||
|
$sizeMB = [math]::Round((Get-ChildItem $_.FullName -Recurse -File | Measure-Object -Property Length -Sum).Sum/1MB, 2)
|
||||||
|
Write-Output "$sizeMB MB $($_.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output ""
|
||||||
|
Write-Output "=== TOP-LEVEL DLLs (not in runtimes/) ==="
|
||||||
|
Get-ChildItem $base -File -Filter "*.dll" | Sort-Object Length -Descending | Select-Object -First 30 | ForEach-Object {
|
||||||
|
$sizeKB = [math]::Round($_.Length/1KB, 1)
|
||||||
|
Write-Output "$sizeKB KB $($_.Name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output ""
|
||||||
|
Write-Output "=== TOTAL SIZE BY EXTENSION ==="
|
||||||
|
Get-ChildItem $base -Recurse -File | Group-Object Extension | Sort-Object Count -Descending | ForEach-Object {
|
||||||
|
$totalMB = [math]::Round(($_.Group | Measure-Object -Property Length -Sum).Sum/1MB, 2)
|
||||||
|
Write-Output "$totalMB MB $($_.Count) files $($_.Name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output ""
|
||||||
|
Write-Output "=== AirAppHost duplicate check ==="
|
||||||
|
$airHostPath = "$base\AirAppHost"
|
||||||
|
if (Test-Path $airHostPath) {
|
||||||
|
$airHostMB = [math]::Round((Get-ChildItem $airHostPath -Recurse -File | Measure-Object -Property Length -Sum).Sum/1MB, 2)
|
||||||
|
Write-Output "AirAppHost folder total: $airHostMB MB"
|
||||||
|
|
||||||
|
$duplicateFiles = Get-ChildItem $airHostPath -Recurse -File | Where-Object {
|
||||||
|
$originalPath = Join-Path $base $_.FullName.Substring($airHostPath.Length+1)
|
||||||
|
(Test-Path $originalPath) -and ((Get-Item $originalPath).Length -eq $_.Length)
|
||||||
|
}
|
||||||
|
$dupMB = [math]::Round(($duplicateFiles | Measure-Object -Property Length -Sum).Sum/1MB, 2)
|
||||||
|
Write-Output "Duplicate files (same name+size as main output): $dupMB MB ($($duplicateFiles.Count) files)"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user