diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4409015..06c1158 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build +name: Build on: push: @@ -10,6 +10,7 @@ on: env: DOTNET_VERSION: '10.0.x' Solution_Name: LanMountainDesktop.slnx + DOTNET_gcServer: 1 jobs: build-windows: @@ -31,6 +32,7 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: 'preview' - name: Restore run: dotnet restore ${{ env.Solution_Name }} @@ -63,12 +65,22 @@ jobs: sudo apt-get install -y \ libfontconfig1 libfreetype6 \ libx11-6 libxrandr2 libxinerama1 \ - libxi6 libxcursor1 libxext6 + libxi6 libxcursor1 libxext6 \ + libxrender1 libxkbcommon-x11-0 \ + clang zlib1g-dev + + # Ubuntu 24.04+ moved several packages to t64 names. + sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2 + sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2 + + # Prefer modern WebKit package, fallback for older images. + sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: 'preview' - name: Restore run: dotnet restore ${{ env.Solution_Name }} @@ -95,10 +107,14 @@ jobs: fetch-depth: 0 submodules: recursive + - name: Install dependencies + run: brew install portaudio + - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: 'preview' - name: Restore run: dotnet restore ${{ env.Solution_Name }} @@ -129,6 +145,7 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: 'preview' - name: Pack SDK and template packages shell: pwsh diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 905f31d..65a5201 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -1,4 +1,4 @@ -name: Quality Check +name: Quality Check on: pull_request: @@ -9,6 +9,7 @@ on: env: DOTNET_VERSION: '10.0.x' Solution_Name: LanMountainDesktop.slnx + DOTNET_gcServer: 1 jobs: analyze: @@ -24,12 +25,13 @@ jobs: with: fetch-depth: 0 submodules: recursive - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: 'preview' - name: Restore run: dotnet restore ${{ env.Solution_Name }} diff --git a/.github/workflows/ddss-publish.yml b/.github/workflows/ddss-publish.yml new file mode 100644 index 0000000..718407a --- /dev/null +++ b/.github/workflows/ddss-publish.yml @@ -0,0 +1,166 @@ +name: DDSS + +on: + workflow_run: + workflows: + - PLONDS + types: + - completed + workflow_dispatch: + inputs: + tag: + description: 'Release tag' + required: true + type: string + +env: + DOTNET_VERSION: '10.0.x' + +jobs: + publish: + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: write + actions: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Resolve release tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + RAW_TAG="${{ github.event.inputs.tag }}" + if [[ "$RAW_TAG" == v* ]]; then + TAG="$RAW_TAG" + else + TAG="v$RAW_TAG" + fi + else + gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata + TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)" + fi + + echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" + echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$GITHUB_ENV" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: preview + + - name: Prepare signing key + env: + UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }} + PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }} + PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }} + shell: bash + run: | + set -euo pipefail + KEY="${PLONDS_SIGNING_KEY:-}" + if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi + if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi + if [[ -z "$KEY" ]]; then + echo "No signing key is configured." + exit 1 + fi + printf '%s' "$KEY" > update-private-key.pem + echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV" + + - name: Build PLONDS tool + run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release + + - name: Download release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + mkdir -p release-assets + gh release download "$RELEASE_TAG" -D release-assets + find release-assets -maxdepth 1 -type f | sort + + - name: Upload release assets to Rainyun S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + aws --version + for file in release-assets/*; do + [[ -f "$file" ]] || continue + name="$(basename "$file")" + if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then + continue + fi + key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}" + sha256="$(sha256sum "$file" | awk '{print $1}')" + existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)" + if [[ "$existing_sha" == "$sha256" ]]; then + echo "Skip existing asset: $name" + continue + fi + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$key" \ + --body "$file" \ + --metadata "sha256=$sha256" + done + + - name: Build DDSS manifest + shell: bash + run: | + set -euo pipefail + mkdir -p ddss-output + dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \ + build-ddss \ + --release-tag "$RELEASE_TAG" \ + --assets-dir release-assets \ + --output-dir ddss-output \ + --private-key "$UPDATE_PRIVATE_KEY_PATH" \ + --repository "${{ github.repository }}" \ + --s3-base-url "$S3_BASE_URL" + + - name: Upload DDSS manifest to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber + + - name: Upload DDSS manifest to Rainyun S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + for file in ddss-output/ddss.json ddss-output/ddss.json.sig; do + name="$(basename "$file")" + key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}" + sha256="$(sha256sum "$file" | awk '{print $1}')" + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$key" \ + --body "$file" \ + --metadata "sha256=$sha256" + done diff --git a/.github/workflows/plonds-build.yml b/.github/workflows/plonds-build.yml new file mode 100644 index 0000000..3f53ade --- /dev/null +++ b/.github/workflows/plonds-build.yml @@ -0,0 +1,235 @@ +name: PLONDS + +on: + release: + types: + - published + - prereleased + workflow_dispatch: + inputs: + tag: + description: 'Release tag' + required: true + type: string + baseline_tag: + description: 'Optional baseline tag' + required: false + type: string + channel: + description: 'Update channel' + required: false + type: choice + default: stable + options: + - stable + - preview + +env: + DOTNET_VERSION: '10.0.x' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Resolve release context + shell: bash + run: | + if [[ "${{ github.event_name }}" == "release" ]]; then + TAG="${{ github.event.release.tag_name }}" + if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then + CHANNEL="preview" + else + CHANNEL="stable" + fi + BASELINE_TAG="" + else + RAW_TAG="${{ github.event.inputs.tag }}" + if [[ "${RAW_TAG}" == v* ]]; then + TAG="${RAW_TAG}" + else + TAG="v${RAW_TAG}" + fi + CHANNEL="${{ github.event.inputs.channel }}" + BASELINE_TAG="${{ github.event.inputs.baseline_tag }}" + fi + + echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" + echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV" + echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV" + echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: preview + + - name: Prepare signing key + env: + UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }} + PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }} + PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }} + shell: bash + run: | + set -euo pipefail + KEY="${PLONDS_SIGNING_KEY:-}" + if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi + if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi + if [[ -z "$KEY" ]]; then + echo "No signing key is configured." + exit 1 + fi + printf '%s' "$KEY" > update-private-key.pem + echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV" + + - name: Build PLONDS tool + run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release + + - name: Resolve baseline plan + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $repo = '${{ github.repository }}' + $tag = $env:RELEASE_TAG + $baselineInput = $env:BASELINE_TAG_INPUT + $currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json + $allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json + $platforms = @('windows-x64', 'windows-x86', 'linux-x64') + + $entries = foreach ($platform in $platforms) { + $assetName = "files-$platform.zip" + $currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1 + if (-not $currentAsset) { + throw "Current release $tag does not contain required asset $assetName" + } + + $baselineRelease = $null + if (-not [string]::IsNullOrWhiteSpace($baselineInput)) { + $normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" } + $baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1 + if (-not $baselineRelease) { + throw "Specified baseline tag not found: $normalizedBaseline" + } + } + else { + $baselineRelease = $allReleases | + Where-Object { + $_.tag_name -ne $tag -and + -not $_.draft -and + [bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and + ($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0 + } | + Select-Object -First 1 + } + + [pscustomobject]@{ + platform = $platform + assetName = $assetName + baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null } + baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null } + isFullPayload = -not $baselineRelease + } + } + + $plan = [pscustomobject]@{ + tag = $tag + version = $env:RELEASE_VERSION + channel = $env:RELEASE_CHANNEL + platforms = $entries + } + + $plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8 + Get-Content plonds-plan.json + + - name: Download payload zips + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $repo = '${{ github.repository }}' + $plan = Get-Content plonds-plan.json | ConvertFrom-Json + + foreach ($entry in $plan.platforms) { + $currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)" + New-Item -ItemType Directory -Path $currentDir -Force | Out-Null + gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir + + if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) { + $baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)" + New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null + gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir + } + } + + - name: Build delta assets + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $plan = Get-Content plonds-plan.json | ConvertFrom-Json + foreach ($entry in $plan.platforms) { + $currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)" + $args = @( + 'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--', + 'build-delta', + '--platform', $entry.platform, + '--current-version', $plan.version, + '--current-tag', $plan.tag, + '--current-zip', $currentZip, + '--output-dir', 'plonds-output', + '--private-key', $env:UPDATE_PRIVATE_KEY_PATH, + '--channel', $plan.channel + ) + + if ([bool]$entry.isFullPayload) { + $args += @('--is-full-payload', 'true') + } + else { + $baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)" + $args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip) + } + + dotnet @args + } + + dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- ` + build-index ` + --release-tag $plan.tag ` + --version $plan.version ` + --channel $plan.channel ` + --platform-summaries-dir plonds-output/platform-summaries ` + --output-dir plonds-output ` + --private-key $env:UPDATE_PRIVATE_KEY_PATH + + - name: Upload PLONDS assets to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber + + - name: Persist run metadata + shell: bash + run: | + mkdir -p plonds-run-metadata + printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt + + - name: Upload run metadata artifact + uses: actions/upload-artifact@v4 + with: + name: plonds-run-metadata + path: plonds-run-metadata/tag.txt + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc10782..603901e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ on: env: DOTNET_VERSION: '10.0.x' Solution_Name: LanMountainDesktop.slnx + DOTNET_gcServer: 1 jobs: prepare: @@ -29,14 +30,22 @@ jobs: informational_version: ${{ steps.version.outputs.informational_version }} tag: ${{ steps.version.outputs.tag }} checkout_ref: ${{ steps.version.outputs.checkout_ref }} - + is_prerelease: ${{ steps.version.outputs.is_prerelease }} + release_channel: ${{ steps.version.outputs.release_channel }} + steps: + - name: Checkout repository metadata + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Get release info id: version run: | if [[ "${{ github.event_name }}" == "push" ]]; then TAG="${GITHUB_REF#refs/tags/}" CHECKOUT_REF="${GITHUB_REF}" + IS_PRERELEASE="false" else RAW_TAG="${{ github.event.inputs.tag }}" if [[ "${RAW_TAG}" == refs/tags/* ]]; then @@ -46,19 +55,40 @@ jobs: else TAG="v${RAW_TAG}" fi - CHECKOUT_REF="${GITHUB_SHA}" + + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + CHECKOUT_REF="refs/tags/${TAG}" + else + CHECKOUT_REF="${GITHUB_SHA}" + fi + + if [[ "${{ github.event.inputs.is_prerelease }}" == "true" ]]; then + IS_PRERELEASE="true" + else + IS_PRERELEASE="false" + fi fi + VERSION="${TAG#v}" IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}" while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do VERSION_PARTS+=("0") done ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}" - echo "tag=${TAG}" >> $GITHUB_OUTPUT - echo "version=${VERSION}" >> $GITHUB_OUTPUT - echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT - echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT - echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT + + if [[ "${IS_PRERELEASE}" == "true" ]]; then + RELEASE_CHANNEL="preview" + else + RELEASE_CHANNEL="stable" + fi + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "assembly_version=${ASSEMBLY_VERSION}" >> "$GITHUB_OUTPUT" + echo "informational_version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "checkout_ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT" + echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT" + echo "release_channel=${RELEASE_CHANNEL}" >> "$GITHUB_OUTPUT" build-windows: needs: prepare @@ -67,7 +97,6 @@ jobs: fail-fast: false matrix: include: - # 完整版(自包含 .NET 运行时) - arch: x64 self_contained: true suffix: '' @@ -75,7 +104,7 @@ jobs: self_contained: true suffix: '' name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }} - + steps: - name: Checkout uses: actions/checkout@v4 @@ -88,6 +117,7 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: 'preview' - name: Restore run: dotnet restore ${{ env.Solution_Name }} @@ -100,7 +130,35 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - - name: Publish + - name: Publish Launcher (AOT) + run: | + $version = "${{ needs.prepare.outputs.version }}" + $arch = "${{ matrix.arch }}" + $launcherPublishDir = "publish/launcher-win-$arch" + + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj ` + -c Release ` + -o ./$launcherPublishDir ` + --self-contained ` + -r win-$arch ` + -p:PublishAot=true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:EnableCompressionInSingleFile=true ` + -p:DebugType=none ` + -p:DebugSymbols=false ` + -p:Version=${{ needs.prepare.outputs.version }} ` + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} ` + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} ` + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} + + if ($LASTEXITCODE -ne 0) { + Write-Error "Launcher AOT publish failed" + exit 1 + } + shell: pwsh + + - name: Publish Main App run: | $selfContained = "${{ matrix.self_contained }}" -eq "true" $publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" } @@ -135,79 +193,69 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} ` -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} } - - Write-Host "Published to: $publishDir" - Write-Host "Self-contained: $selfContained" shell: pwsh - - name: Install Inno Setup - run: choco install innosetup -y --no-progress + - name: Restructure for Launcher + run: | + $version = "${{ needs.prepare.outputs.version }}" + $arch = "${{ matrix.arch }}" + $selfContained = "${{ matrix.self_contained }}" -eq "true" + $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } + $launcherPublishDir = "publish/launcher-win-$arch" + $appDir = "app-$version" + $newStructure = "publish-launcher/windows-$arch" + + New-Item -ItemType Directory -Path $newStructure -Force | Out-Null + $appPath = Join-Path $newStructure $appDir + Move-Item -Path $publishDir -Destination $appPath -Force + + if (Test-Path $launcherPublishDir) { + Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force + } + + New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null + + Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue + Move-Item -Path $newStructure -Destination $publishDir -Force + shell: pwsh + + - name: Install Inno Setup and 7z + run: | + choco install innosetup -y --no-progress + choco install 7zip -y --no-progress shell: pwsh - name: Build Installer run: | $version = "${{ needs.prepare.outputs.version }}" $arch = "${{ matrix.arch }}" - $selfContained = "${{ matrix.self_contained }}" -eq "true" $suffix = "${{ matrix.suffix }}" - $publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" } - $installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss" + $selfContained = "${{ matrix.self_contained }}" -eq "true" + $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } $outputDir = "build-installer" - - # Verify source directory exists - if (-not (Test-Path -Path $publishDir)) { - Write-Error "Publish directory not found: $publishDir" - Get-ChildItem -Path "publish" -Directory -ErrorAction SilentlyContinue | Select-Object Name - exit 1 - } - - # Create output directory + $installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss" + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - # Verify installer script exists - if (-not (Test-Path -Path $installerScript)) { - Write-Error "Installer script not found: $installerScript" - exit 1 - } - - # Find Inno Setup compiler (choco may install a shim in PATH) - $isccPath = $null - $isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue - if ($isccCommand) { - $isccPath = $isccCommand.Source - } $candidatePaths = @( - "C:\Program Files (x86)\Inno Setup 6\ISCC.exe", - "C:\Program Files\Inno Setup 6\ISCC.exe", - "$env:ChocolateyInstall\bin\ISCC.exe", + (Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue), + "$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe", + "$env:ProgramFiles\Inno Setup 6\ISCC.exe", "$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe" - ) + ) | Where-Object { $_ -and (Test-Path $_) } + $isccPath = $candidatePaths | Select-Object -First 1 if (-not $isccPath) { - foreach ($candidate in $candidatePaths) { - if ($candidate -and (Test-Path -Path $candidate)) { - $isccPath = $candidate - break - } - } - } - - if (-not $isccPath) { - Write-Host "ISCC.exe was not found in PATH or known locations." - Write-Host "Checked locations:" - $candidatePaths | ForEach-Object { Write-Host " - $_" } - Write-Host "Chocolatey bin listing (if exists):" - Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName Write-Error "Inno Setup compiler not found." exit 1 } - - Write-Host "Found Inno Setup at: $isccPath" - - # Build installer with iscc.exe - Write-Host "Building installer for Windows $arch with version $version..." - + + if (-not (Test-Path $installerScript)) { + Write-Error "Installer script not found: $(Join-Path $PWD $installerScript)" + exit 1 + } + $publishDir = (Resolve-Path $publishDir).Path $outputDir = (Resolve-Path $outputDir).Path $installerScript = (Resolve-Path $installerScript).Path @@ -221,32 +269,65 @@ jobs: "/DIsSelfContained=$selfContained", $installerScript ) - - Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')" - - # Execute the compiler + & $isccPath @compileArgs if ($LASTEXITCODE -ne 0) { Write-Error "Inno Setup compiler exited with code $LASTEXITCODE" exit 1 } - - # Check if build was successful + $installerFile = Get-ChildItem -Path $outputDir -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $installerFile) { Write-Error "Failed to create installer" exit 1 } - - Write-Host "✅ Successfully created: $($installerFile.Name)" - Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB" shell: pwsh - - name: Upload Installer + - name: Package Payload Zip + run: | + $version = "${{ needs.prepare.outputs.version }}" + $arch = "${{ matrix.arch }}" + $payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version" + if (-not (Test-Path $payloadRoot)) { + Write-Error "Payload root not found: $payloadRoot" + exit 1 + } + + $stageDir = Join-Path $PWD "payload-stage/windows-$arch" + $releaseDir = Join-Path $PWD "release-assets" + Remove-Item -Path $stageDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $stageDir -Force | Out-Null + New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null + + Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object { + $relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/') + if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) { + return + } + + $destination = Join-Path $stageDir ($relative -replace '/', [System.IO.Path]::DirectorySeparatorChar) + $destinationDir = Split-Path -Path $destination -Parent + if (-not [string]::IsNullOrWhiteSpace($destinationDir)) { + New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null + } + + Copy-Item -Path $_.FullName -Destination $destination -Force + } + + $payloadZip = Join-Path $releaseDir "files-windows-$arch.zip" + if (Test-Path $payloadZip) { + Remove-Item $payloadZip -Force + } + Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force + shell: pwsh + + - name: Upload Release Assets uses: actions/upload-artifact@v4 with: - name: release-windows-${{ matrix.arch }}${{ matrix.suffix }} - path: build-installer/*.exe + name: release-windows-${{ matrix.arch }} + path: | + release-assets/files-windows-${{ matrix.arch }}.zip + build-installer/*.exe if-no-files-found: error retention-days: 30 @@ -254,7 +335,7 @@ jobs: needs: prepare runs-on: ubuntu-latest name: Build_Linux - + steps: - name: Checkout uses: actions/checkout@v4 @@ -270,12 +351,18 @@ jobs: libfontconfig1 libfreetype6 \ libx11-6 libxrandr2 libxinerama1 \ libxi6 libxcursor1 libxext6 \ - libxrender1 libxkbcommon-x11-0 + libxrender1 libxkbcommon-x11-0 \ + clang zlib1g-dev zip rsync + + sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2 + sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2 + sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: 'preview' - name: Restore run: dotnet restore ${{ env.Solution_Name }} @@ -288,11 +375,29 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - - name: Publish + - name: Publish Launcher (AOT) + run: | + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ + -c Release \ + -o ./publish/launcher-linux-x64 \ + --self-contained \ + -r linux-x64 \ + -p:PublishAot=true \ + -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:EnableCompressionInSingleFile=true \ + -p:DebugType=none \ + -p:DebugSymbols=false \ + -p:Version=${{ needs.prepare.outputs.version }} \ + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} + + - name: Publish Main App run: | dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \ -c Release \ - -o ./publish/linux-x64 \ + -o ./publish/linux-x64-app \ --self-contained \ -r linux-x64 \ -p:PublishSingleFile=false \ @@ -306,6 +411,24 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} + - name: Restructure for Launcher + run: | + version="${{ needs.prepare.outputs.version }}" + publishDir="publish/linux-x64" + appDir="app-$version" + launcherDir="publish/launcher-linux-x64" + + mkdir -p "$publishDir" + mv "publish/linux-x64-app" "$publishDir/$appDir" + + if [ -d "$launcherDir" ]; then + cp -r "$launcherDir"/* "$publishDir/" + chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true + fi + + touch "$publishDir/$appDir/.current" + rm -rf "$launcherDir" + - name: Package as DEB run: | version="${{ needs.prepare.outputs.version }}" @@ -315,41 +438,17 @@ jobs: arch="amd64" desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop" icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png" - - # Verify source directory exists - if [ ! -d "$source" ]; then - echo "Error: Source directory not found: $source" - ls -la publish/ || echo "publish directory not found" - exit 1 - fi - - # Create DEB package structure + mkdir -p "build-deb/DEBIAN" mkdir -p "build-deb/usr/local/bin" mkdir -p "build-deb/usr/share/applications" mkdir -p "build-deb/usr/share/pixmaps" mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps" - - # Copy application files - cp -r "$source"/* "build-deb/usr/local/bin/" - - # Verify copy was successful - item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l) - echo "DEB package contains $item_count files" - - if [ "$item_count" -eq 0 ]; then - echo "Error: DEB package is empty after copy" - exit 1 - fi - if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then - echo "Error: Linux desktop resources are missing" - ls -la "LanMountainDesktop/packaging/linux" || true - exit 1 - fi + cp -r "$source"/* "build-deb/usr/local/bin/" sed \ - -e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \ + -e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \ -e "s|@@ICON@@|lanmountaindesktop|g" \ "$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop" @@ -366,49 +465,69 @@ jobs: printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true' printf '%s\n' 'fi' } > "build-deb/DEBIAN/postinst" - - # Create control file (NOTE: No leading spaces in control file) + { printf '%s\n' "Package: $package_name" printf '%s\n' "Version: $package_version" printf '%s\n' "Architecture: $arch" - printf '%s\n' "Maintainer: LanMountain Team " - printf '%s\n' "Description: LanMountain Desktop Application" - printf '%s\n' " A desktop application for LanMountain." + printf '%s\n' 'Maintainer: LanMountain Team ' + printf '%s\n' 'Description: LanMountain Desktop Application' + printf '%s\n' ' A desktop application for LanMountain.' } > "build-deb/DEBIAN/control" - - # Set proper permissions - chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/* + + chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/* chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop" chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png" chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png" chmod 755 "build-deb/DEBIAN/postinst" - - # Create DEB file - if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then - echo "Successfully created: ${package_name}_${package_version}_${arch}.deb" - ls -lh "${package_name}_${package_version}_${arch}.deb" - else - echo "Error: Failed to build DEB package" + + dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb" + + - name: Package Payload Zip + run: | + version="${{ needs.prepare.outputs.version }}" + payload_root="publish/linux-x64/app-$version" + release_dir="$PWD/release-assets" + stage_dir="$PWD/payload-stage/linux-x64" + + if [ ! -d "$payload_root" ]; then + echo "Payload root not found: $payload_root" exit 1 fi - - name: Upload + rm -rf "$stage_dir" + mkdir -p "$stage_dir" "$release_dir" + rsync -a \ + --exclude '.current' \ + --exclude '.partial' \ + --exclude '.destroy' \ + "$payload_root/" "$stage_dir/" + + ( + cd "$stage_dir" + zip -qr "$release_dir/files-linux-x64.zip" . + ) + + - name: Upload Release Assets uses: actions/upload-artifact@v4 with: - name: release-linux - path: "*.deb" + name: release-linux-x64 + path: | + release-assets/files-linux-x64.zip + *.deb if-no-files-found: error retention-days: 30 build-macos: needs: prepare runs-on: macos-latest + continue-on-error: true strategy: + fail-fast: false matrix: arch: [x64, arm64] name: Build_macOS_${{ matrix.arch }} - + steps: - name: Checkout uses: actions/checkout@v4 @@ -417,10 +536,14 @@ jobs: submodules: recursive ref: ${{ needs.prepare.outputs.checkout_ref }} + - name: Install dependencies + run: brew install portaudio + - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: 'preview' - name: Restore run: dotnet restore ${{ env.Solution_Name }} @@ -433,11 +556,29 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - - name: Publish + - name: Publish Launcher (AOT) + run: | + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ + -c Release \ + -o ./publish/launcher-macos-${{ matrix.arch }} \ + --self-contained \ + -r osx-${{ matrix.arch }} \ + -p:PublishAot=true \ + -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:EnableCompressionInSingleFile=true \ + -p:DebugType=none \ + -p:DebugSymbols=false \ + -p:Version=${{ needs.prepare.outputs.version }} \ + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} + + - name: Publish Main App run: | dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \ -c Release \ - -o ./publish/macos-${{ matrix.arch }} \ + -o ./publish/macos-${{ matrix.arch }}-app \ --self-contained \ -r osx-${{ matrix.arch }} \ -p:PublishSingleFile=false \ @@ -451,45 +592,50 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - - name: Package as DMG + - name: Package Payload Zip + run: | + release_dir="$PWD/release-assets" + stage_dir="$PWD/payload-stage/macos-${{ matrix.arch }}" + payload_root="publish/macos-${{ matrix.arch }}-app" + + rm -rf "$stage_dir" + mkdir -p "$stage_dir" "$release_dir" + rsync -a "$payload_root/" "$stage_dir/" + ( + cd "$stage_dir" + zip -qr "$release_dir/files-macos-${{ matrix.arch }}.zip" . + ) + + - name: Restructure and Package as DMG + continue-on-error: true run: | version="${{ needs.prepare.outputs.version }}" arch="${{ matrix.arch }}" - source="publish/macos-$arch" app_name="LanMountainDesktop" package_name="${app_name}-${version}-macos-${arch}" - - # Verify source directory exists - if [ ! -d "$source" ]; then - echo "Error: Source directory not found: $source" - ls -la publish/ || echo "publish directory not found" - exit 1 - fi - - # Create app bundle structure + launcherDir="publish/launcher-macos-$arch" + appSourceDir="publish/macos-$arch-app" + mkdir -p "${app_name}.app/Contents/MacOS" - mkdir -p "${app_name}.app/Contents/Resources" - - # Copy application files - cp -r "$source"/* "${app_name}.app/Contents/MacOS/" - - # Verify copy was successful - item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l) - echo "App bundle contains $item_count files" - - if [ "$item_count" -eq 0 ]; then - echo "Error: App bundle is empty after copy" - exit 1 + appDir="app-$version" + mkdir -p "${app_name}.app/Contents/MacOS/$appDir" + + cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/" + if [ -d "$launcherDir" ]; then + cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/" + chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true fi - # Create Info.plist + touch "${app_name}.app/Contents/MacOS/$appDir/.current" + mkdir -p "${app_name}.app/Contents/Resources" + { printf '%s\n' '' printf '%s\n' '' printf '%s\n' '' printf '%s\n' '' printf '%s\n' ' CFBundleExecutable' - printf '%s\n' ' LanMountainDesktop' + printf '%s\n' ' LanMountainDesktop.Launcher' printf '%s\n' ' CFBundleName' printf '%s\n' ' LanMountain Desktop' printf '%s\n' ' CFBundleVersion' @@ -503,96 +649,76 @@ jobs: printf '%s\n' '' printf '%s\n' '' } > "${app_name}.app/Contents/Info.plist" - - # Create DMG + mkdir -p dmg-temp cp -r "${app_name}.app" dmg-temp/ - - if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then - echo "Successfully created: ${package_name}.dmg" - ls -lh "${package_name}.dmg" - else - echo "Error: Failed to create DMG" - exit 1 - fi - - # Cleanup - rm -rf dmg-temp "${app_name}.app" + hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" - - name: Upload + - name: Upload Release Assets + if: always() uses: actions/upload-artifact@v4 with: name: release-macos-${{ matrix.arch }} - path: "*.dmg" - if-no-files-found: error + path: | + release-assets/files-macos-${{ matrix.arch }}.zip + *.dmg + if-no-files-found: ignore retention-days: 30 github-release: - needs: [ prepare, build-windows, build-linux, build-macos ] + needs: [prepare, build-windows, build-linux] runs-on: ubuntu-latest permissions: contents: write - + steps: - - name: Download artifacts + - name: Download release artifacts uses: actions/download-artifact@v4 with: - path: artifacts + path: release-files pattern: release-* + merge-multiple: true - - name: List artifacts structure + - name: Validate release files run: | - echo "🔍 Artifact directory structure:" - find artifacts -type f -o -type d | sort - echo "" - echo "📊 Files found:" - find artifacts -type f -exec ls -lh {} \; - echo "" - echo "📁 Full tree:" - tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g' + echo "Release files:" + find release-files -maxdepth 1 -type f -exec ls -lh {} \; - - name: Flatten artifacts for release - run: | - echo "📦 Organizing artifacts..." - mkdir -p release-files - find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \; - echo "" - echo "✅ Files ready for release:" - ls -lh release-files/ || echo "⚠️ No files found in release-files" - echo "" - echo "📋 Total files:" - file_count=$(find release-files -type f | wc -l) - echo "$file_count" - if [ "$file_count" -eq 0 ]; then - echo "Error: No installer/package files found for release" + if [ ! -f release-files/files-windows-x64.zip ] || [ ! -f release-files/files-windows-x86.zip ] || [ ! -f release-files/files-linux-x64.zip ]; then + echo "Required payload zips are missing." exit 1 fi - - name: Create Release + file_count=$(find release-files -maxdepth 1 -type f | wc -l) + if [ "$file_count" -eq 0 ]; then + echo "No release files were produced." + exit 1 + fi + + - name: Create or Update Release uses: ncipollo/release-action@v1 with: tag: ${{ needs.prepare.outputs.tag }} name: ${{ needs.prepare.outputs.tag }} - commit: ${{ github.sha }} allowUpdates: true draft: false - prerelease: ${{ github.event.inputs.is_prerelease == 'true' }} - artifacts: "release-files/**" + prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }} + artifacts: 'release-files/**' body: | ## Release ${{ needs.prepare.outputs.version }} - ### Windows - - **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer (包含 .NET 运行时) - - **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer (包含 .NET 运行时) + ### Installers + - `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe` + - `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe` + - `LanMountainDesktop_${{ needs.prepare.outputs.version }}_amd64.deb` - Installation: Double-click the .exe file and follow the wizard. - - ### Linux - - **LanMountainDesktop-{version}-linux-x64.deb** - Debian package (x64) + ### Payload Archives + - `files-windows-x64.zip` + - `files-windows-x86.zip` + - `files-linux-x64.zip` ### macOS - - **LanMountainDesktop-{version}-macos-x64.dmg** - Intel processor - - **LanMountainDesktop-{version}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3) + - macOS assets are best-effort and will not block the release. - See commits for changes. + Release keeps only the stable installer and payload outputs. PLONDS delta assets and external mirror metadata are generated by follow-up workflows. token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index f9aac1e..cb9265e 100644 --- a/.gitignore +++ b/.gitignore @@ -512,3 +512,5 @@ nul /*.deb /*.dmg /*.AppImage +/velopack-output-local-verify +/velopack-output-local diff --git a/.trae/documents/launcher_comprehensive_improvement_plan.md b/.trae/documents/launcher_comprehensive_improvement_plan.md new file mode 100644 index 0000000..6b67c90 --- /dev/null +++ b/.trae/documents/launcher_comprehensive_improvement_plan.md @@ -0,0 +1,805 @@ +# LanMountainDesktop Launcher 全面改进计划 + +## 概述 + +本计划旨在将 LanMountainDesktop 的 Launcher 改进为符合原子化架构的独立启动器,参考 ClassIsland 的极简设计,同时保留阑山桌面的特色功能。 + +## 目标 + +1. **P0 (必须完成)**: 重写 Launcher 为极简模式,移除与主程序的耦合 +2. **P1 (应该完成)**: 将 OOBE、Splash、更新、插件管理迁移到主程序 +3. **P2 (推荐完成)**: 实现 Launcher 自更新机制 +4. **P3 (可选优化)**: 性能优化和代码清理 +5. **P4 (长期规划)**: 增强功能和可扩展性 + +## 当前问题 + +1. Launcher 是 Avalonia 应用,启动慢、内存占用高 +2. Launcher 引用了 PluginSdk,与主程序有耦合 +3. 主程序引用了 Launcher,构建关系复杂 +4. Launcher 职责过多(OOBE + Splash + 更新 + 插件 + 启动) +5. 缺少 Launcher 自更新机制 +6. GitHub Actions 工作流需要适配新的目录结构 + +## 改进后架构 + +``` +安装根目录/ +├── LanMountainDesktop.exe ← 启动器(唯一入口,极简,~100行代码) +├── app-1.0.0/ ← 版本目录 +│ ├── .current ← 当前版本标记 +│ ├── LanMountainDesktop.exe ← 主程序 +│ └── ... (所有依赖) +└── .launcher/ ← 启动器数据(可选) + └── snapshots/ ← 版本快照 +``` + +## 详细实施步骤 + +### P0: 基础架构重构 + +#### 1. 重写 Launcher 为极简模式 + +**文件**: `LanMountainDesktop.Launcher/Program.cs` + +**目标**: +- 代码量控制在 100 行以内 +- 零外部依赖(不使用 Avalonia) +- 只负责:版本选择、启动主程序、清理旧版本 + +**完整实现代码**: + +```csharp +// LanMountainDesktop.Launcher/Program.cs +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace LanMountainDesktop.Launcher; + +internal static class Program +{ + private const string HostExecutableName = "LanMountainDesktop.exe"; + private const string HostExecutableNameLinux = "LanMountainDesktop"; + + [STAThread] + private static int Main(string[] args) + { + var rootDir = GetRootDirectory(); + + // 1. 查找最佳版本 + var installation = FindBestVersion(rootDir); + if (installation == null) + { + ShowError("找不到有效的 LanMountainDesktop 版本,请重新安装。"); + return 1; + } + + // 2. 清理旧版本(异步,不阻塞) + _ = Task.Run(() => CleanupOldVersions(rootDir)); + + // 3. 启动主程序 + return LaunchHost(installation, args); + } + + private static string GetRootDirectory() + { + return Path.GetFullPath( + Path.GetDirectoryName(Environment.ProcessPath) ?? ""); + } + + private static string? FindBestVersion(string rootDir) + { + var exeName = OperatingSystem.IsWindows() + ? HostExecutableName + : HostExecutableNameLinux; + + return Directory.GetDirectories(rootDir) + .Where(x => IsValidVersionDirectory(x, exeName)) + .OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1) + .ThenByDescending(x => ParseVersion(Path.GetFileName(x))) + .FirstOrDefault(); + } + + private static bool IsValidVersionDirectory(string path, string exeName) + { + var dirName = Path.GetFileName(path); + return dirName.StartsWith("app-") && + !File.Exists(Path.Combine(path, ".destroy")) && + !File.Exists(Path.Combine(path, ".partial")) && + File.Exists(Path.Combine(path, exeName)); + } + + private static Version ParseVersion(string dirName) + { + // app-1.0.0 or app-1.0.0-123 + var parts = dirName.Split('-'); + if (parts.Length >= 2 && Version.TryParse(parts[1], out var v)) + return v; + return new Version(0, 0); + } + + private static void CleanupOldVersions(string rootDir) + { + try + { + var oldVersions = Directory.GetDirectories(rootDir) + .Where(x => File.Exists(Path.Combine(x, ".destroy"))); + + foreach (var dir in oldVersions) + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + catch { /* 忽略清理失败 */ } + } + + private static int LaunchHost(string installation, string[] args) + { + var exeName = OperatingSystem.IsWindows() + ? HostExecutableName + : HostExecutableNameLinux; + var exePath = Path.Combine(installation, exeName); + + // Linux/macOS: 确保可执行权限 + if (!OperatingSystem.IsWindows()) + { + EnsureExecutable(exePath); + } + + var startInfo = new ProcessStartInfo + { + FileName = exePath, + WorkingDirectory = Path.GetDirectoryName(installation), + UseShellExecute = true + }; + + foreach (var arg in args) + startInfo.ArgumentList.Add(arg); + + // 传递环境变量 + startInfo.EnvironmentVariables["LMD_PACKAGE_ROOT"] = + Path.GetDirectoryName(installation); + startInfo.EnvironmentVariables["LMD_VERSION"] = + Path.GetFileName(installation).Replace("app-", ""); + + try + { + Process.Start(startInfo); + return 0; + } + catch (Exception ex) + { + ShowError($"启动失败: {ex.Message}"); + return 1; + } + } + + private static void EnsureExecutable(string path) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = "chmod", + Arguments = $"+x \"{path}\"", + CreateNoWindow = true + })?.WaitForExit(); + } + catch { } + } + + private static void ShowError(string message) + { + if (OperatingSystem.IsWindows()) + { + // Win32 MessageBox + try + { + MessageBox(IntPtr.Zero, message, "LanMountainDesktop", 0x10); + } + catch + { + Console.Error.WriteLine(message); + } + } + else + { + Console.Error.WriteLine(message); + } + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type); +} +``` + +#### 2. 修改 Launcher 项目文件 + +**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` + +**完整内容**: + +```xml + + + WinExe + net10.0 + enable + enable + 1.0.0 + Assets\logo_nightly.ico + + + + + + PreserveNewest + + + +``` + +#### 3. 移除主程序对 Launcher 的引用 + +**文件**: `LanMountainDesktop/LanMountainDesktop.csproj` + +**修改**: 删除以下行 +```xml + + +``` + +#### 4. 修改主程序支持新架构 + +**文件**: `LanMountainDesktop/Program.cs` + +**修改**: 添加环境变量读取 + +```csharp +// 在 Program.cs 中添加 +internal static class LaunchContext +{ + public static string? PackageRoot => + Environment.GetEnvironmentVariable("LMD_PACKAGE_ROOT"); + public static string? Version => + Environment.GetEnvironmentVariable("LMD_VERSION"); + public static bool IsLaunchedByLauncher => + !string.IsNullOrEmpty(PackageRoot); +} +``` + +--- + +### P1: 功能迁移 + +#### 5. 将 OOBE 迁移到主程序 + +**新建文件**: `LanMountainDesktop/Services/Oobe/OobeService.cs` + +```csharp +using LanMountainDesktop.Models; +using LanMountainDesktop.Services.Settings; + +namespace LanMountainDesktop.Services.Oobe; + +public class OobeService +{ + private readonly string _oobeStatePath; + + public OobeService() + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + _oobeStatePath = Path.Combine(appData, "LanMountainDesktop", ".oobe_completed"); + } + + public bool IsFirstRun() + { + return !File.Exists(_oobeStatePath); + } + + public void MarkCompleted() + { + var dir = Path.GetDirectoryName(_oobeStatePath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.WriteAllText(_oobeStatePath, DateTime.UtcNow.ToString("O")); + } +} +``` + +**新建文件**: `LanMountainDesktop/Views/Oobe/OobeWindow.axaml` + +```xml + + + + + + + + diff --git a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs new file mode 100644 index 0000000..cb04ee7 --- /dev/null +++ b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs @@ -0,0 +1,197 @@ +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using Avalonia.Styling; + +namespace LanMountainDesktop.Launcher.Views; + +/// +/// OOBE(首次使用体验)窗口 - 欢迎页面 +/// +public partial class OobeWindow : Window +{ + private readonly TaskCompletionSource _completionSource = new(); + private bool _isTransitioning = false; + + public OobeWindow() + { + AvaloniaXamlLoader.Load(this); + + // 延迟到窗口加载完成后再初始化 + this.Loaded += OnWindowLoaded; + this.Opened += OnWindowOpened; + } + + /// + /// 窗口加载完成事件 + /// + private void OnWindowLoaded(object? sender, RoutedEventArgs e) + { + Console.WriteLine("[OobeWindow] Window loaded, initializing components..."); + + var enterButton = this.FindControl + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs new file mode 100644 index 0000000..1d1864e --- /dev/null +++ b/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs @@ -0,0 +1,123 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; + +namespace LanMountainDesktop.Launcher.Views; + +/// +/// 更新进度窗口 - 用于 apply-update 命令模式下显示更新/插件升级进度 +/// +public partial class UpdateWindow : Window +{ + public UpdateWindow() + { + AvaloniaXamlLoader.Load(this); + InitializeEventHandlers(); + } + + /// + /// 初始化事件处理程序 + /// + private void InitializeEventHandlers() + { + var minimizeButton = this.FindControl