From 1aaf6cd0e97ecfeb47640df74a5e673c31a5ff52 Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 16 Apr 2026 14:17:46 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=95=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 6 +- .github/workflows/code-quality.yml | 3 +- .github/workflows/release.yml | 1433 +++++++++++++++-- CHANGELOG.md | 25 + Directory.Build.props | 1 + .../Assets/public-key.pem | 8 + .../LanMountainDesktop.Launcher.csproj | 24 + .../Services/Commands.cs | 2 +- .../Services/DeferredSplashStageReporter.cs | 32 + .../Services/LauncherFlowCoordinator.cs | 22 +- .../Services/UpdateEngineService.cs | 4 +- .../Views/SplashWindow.axaml | 29 +- .../Views/SplashWindow.axaml.cs | 38 +- .../.github/workflows/windows-ci.yml | 217 --- LanMountainDesktop/LanMountainDesktop.csproj | 27 +- .../Services/PostHogUsageTelemetryService.cs | 279 +--- LanMountainDesktop/Services/TelemetryEvent.cs | 55 - .../Services/UpdateWorkflowService.cs | 200 ++- scripts/update-private-key.pem | 27 + .../Assets/public-key.pem | 8 + tools/scripts/update-private-key.pem | 27 + 21 files changed, 1856 insertions(+), 611 deletions(-) create mode 100644 LanMountainDesktop.Launcher/Assets/public-key.pem create mode 100644 LanMountainDesktop.Launcher/Services/DeferredSplashStageReporter.cs delete mode 100644 LanMountainDesktop/.github/workflows/windows-ci.yml delete mode 100644 LanMountainDesktop/Services/TelemetryEvent.cs create mode 100644 scripts/update-private-key.pem create mode 100644 tools/LanMountainDesktop.Launcher/Assets/public-key.pem create mode 100644 tools/scripts/update-private-key.pem diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4409015..4017cba 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: @@ -63,7 +64,8 @@ jobs: sudo apt-get install -y \ libfontconfig1 libfreetype6 \ libx11-6 libxrandr2 libxinerama1 \ - libxi6 libxcursor1 libxext6 + libxi6 libxcursor1 libxext6 \ + libxrender1 libxkbcommon-x11-0 - name: Setup .NET uses: actions/setup-dotnet@v4 diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 905f31d..744395a 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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7831b9..a554855 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: @@ -67,7 +68,6 @@ jobs: fail-fast: false matrix: include: - # 完整版(自包含 .NET 运行时) - arch: x64 self_contained: true suffix: '' @@ -100,7 +100,40 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - - name: Publish + - name: Publish Launcher + run: | + $version = "${{ needs.prepare.outputs.version }}" + $arch = "${{ matrix.arch }}" + $selfContained = "${{ matrix.self_contained }}" -eq "true" + $launcherPublishDir = "publish/launcher-win-$arch" + + if ($selfContained) { + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj ` + -c Release ` + -o ./$launcherPublishDir ` + --self-contained ` + -r win-$arch ` + -p:PublishSingleFile=false ` + -p:PublishTrimmed=false ` + -p:PublishReadyToRun=false ` + -p:DebugType=none ` + -p:DebugSymbols=false + } else { + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj ` + -c Release ` + -o ./$launcherPublishDir ` + --self-contained:false ` + -p:PublishSingleFile=false ` + -p:PublishTrimmed=false ` + -p:PublishReadyToRun=false ` + -p:DebugType=none ` + -p:DebugSymbols=false + } + + Write-Host "Launcher published to: $launcherPublishDir" + 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" } @@ -146,39 +179,34 @@ jobs: $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" - Write-Host "重组目录结构为 Launcher 模式..." - Write-Host "版本: $version" - Write-Host "发布目录: $publishDir" + Write-Host "Restructuring for Launcher mode..." + Write-Host "Version: $version" + Write-Host "Publish dir: $publishDir" - # 创建新的目录结构 $newStructure = "publish-launcher/windows-$arch" New-Item -ItemType Directory -Path $newStructure -Force | Out-Null - # 移动主程序到 app-{version} 子目录 $appPath = Join-Path $newStructure $appDir Move-Item -Path $publishDir -Destination $appPath -Force - # Launcher 应该在根目录 - # 注意: Launcher 已经通过 CopyLauncherToPublish 目标复制到了 Launcher/ 子目录 - $launcherSource = Join-Path $appPath "Launcher" + $launcherSource = $launcherPublishDir if (Test-Path $launcherSource) { - Write-Host "移动 Launcher 到根目录..." - Get-ChildItem -Path $launcherSource | Move-Item -Destination $newStructure -Force - Remove-Item -Path $launcherSource -Recurse -Force + Write-Host "Copying Launcher to root..." + Copy-Item -Path "$launcherSource\*" -Destination $newStructure -Recurse -Force } else { - Write-Warning "Launcher 目录不存在: $launcherSource" + Write-Warning "Launcher publish dir not found: $launcherSource" } - # 创建 .current 标记 New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null - Write-Host "新目录结构:" + Write-Host "New directory structure:" Get-ChildItem -Path $newStructure -Recurse -Depth 2 | Select-Object FullName - # 替换原发布目录 Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue Move-Item -Path $newStructure -Destination $publishDir -Force shell: pwsh @@ -196,23 +224,19 @@ jobs: $installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss" $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 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) { @@ -247,7 +271,6 @@ jobs: Write-Host "Found Inno Setup at: $isccPath" - # Build installer with iscc.exe Write-Host "Building installer for Windows $arch with version $version..." $publishDir = (Resolve-Path $publishDir).Path @@ -266,21 +289,19 @@ jobs: 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 "Successfully created: $($installerFile.Name)" Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB" shell: pwsh @@ -291,67 +312,118 @@ jobs: $publishDir = "publish/windows-${{ matrix.arch }}" $appDir = "app-$version" $currentAppPath = Join-Path $publishDir $appDir - - Write-Host "生成增量更新包..." - Write-Host "当前版本: $version" - - # TODO: 从上一个 Release 下载并解压以生成增量包 - # 这里先生成完整的 files.json - $outputDir = "delta-output" + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - # 生成 files.json (完整文件清单) - $files = Get-ChildItem -Path $currentAppPath -Recurse -File - $fileEntries = @() - - foreach ($file in $files) { - $relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/') - $relativePath = $relativePath.Replace('\', '/') - - # 跳过标记文件 - if ($relativePath -match '^\.(current|partial|destroy)$') { - continue - } - - $hash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash.ToLower() - - $fileEntries += @{ - Path = $relativePath - Action = "add" - Sha256 = $hash - Size = $file.Length - ArchivePath = $relativePath + # --- Determine previous version and download its update.zip for diff --- + $previousVersion = $null + $previousAppPath = $null + try { + $headers = @{ "User-Agent" = "LanMountainDesktop-CI"; "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" } + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" -Headers $headers + $previousRelease = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1 + if ($previousRelease) { + $previousVersion = $previousRelease.tag_name.TrimStart('v','V') + Write-Host "Previous release version: $previousVersion" + + # Try to download update.zip from previous release for diff + $prevUpdateZip = $previousRelease.assets | Where-Object { $_.name -eq "update.zip" } | Select-Object -First 1 + if ($prevUpdateZip) { + Write-Host "Found update.zip in previous release - extracting for diff..." + $prevZipDest = Join-Path $outputDir "prev-update.zip" + Invoke-WebRequest -Uri $prevUpdateZip.browser_download_url -OutFile $prevZipDest -Headers $headers + + $previousAppPath = Join-Path $outputDir "prev-app" + New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null + Expand-Archive -Path $prevZipDest -DestinationPath $previousAppPath -Force + Remove-Item -Path $prevZipDest -Force + + $prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count + Write-Host "Extracted $prevFileCount files from previous version for diff" + } else { + Write-Host "No update.zip found in previous release - will generate full package" + } } + } catch { + Write-Host "Could not fetch previous release: $_" } - $filesJson = @{ - FromVersion = $null - ToVersion = $version - GeneratedAt = (Get-Date).ToUniversalTime().ToString("o") - Files = $fileEntries - } | ConvertTo-Json -Depth 10 + # --- Generate file manifest with diff against previous version --- + Write-Host "Generating update package for version $version..." + $files = Get-ChildItem -Path $currentAppPath -Recurse -File + $fileEntries = @() + $changedFiles = @() + $reusedCount = 0 + $addedCount = 0 + $replacedCount = 0 + $deletedCount = 0 - $filesJsonPath = Join-Path $outputDir "files-$version.json" - $filesJson | Set-Content -Path $filesJsonPath -Encoding UTF8 + # Build hash map of previous version files for quick lookup + $prevHashMap = @{} + if ($previousAppPath -and (Test-Path $previousAppPath)) { + $prevFiles = Get-ChildItem -Path $previousAppPath -Recurse -File + foreach ($pf in $prevFiles) { + $relPath = $pf.FullName.Substring($previousAppPath.Length).TrimStart('\', '/').Replace('\', '/') + if ($relPath -match '^\.(current|partial|destroy) + + - name: Sign File Map + if: matrix.self_contained == true && matrix.arch == 'x64' + run: | + $outputDir = "delta-output" + $filesJsonPath = Join-Path $outputDir "files.json" + $signaturePath = Join-Path $outputDir "files.json.sig" - Write-Host "生成文件清单: $filesJsonPath" - Write-Host "文件数量: $($fileEntries.Count)" + if (-not (Test-Path $filesJsonPath)) { + Write-Error "files.json not found at $filesJsonPath" + exit 1 + } - # 创建完整应用包 (app-{version}.zip) - $appZipPath = Join-Path $outputDir "app-$version.zip" - Compress-Archive -Path "$currentAppPath\*" -DestinationPath $appZipPath -CompressionLevel Optimal + # Sign using the private key from repository secrets + $privateKeyPem = "${{ secrets.UPDATE_PRIVATE_KEY_PEM }}" + if ([string]::IsNullOrWhiteSpace($privateKeyPem)) { + Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder" + Write-Warning "Incremental updates will fail signature verification without a valid signature" + Set-Content -Path $signaturePath -Value "" -Encoding ASCII + exit 0 + } - Write-Host "创建应用包: $appZipPath" - Write-Host "包大小: $([Math]::Round((Get-Item $appZipPath).Length / 1MB, 2)) MB" + $privateKeyPath = Join-Path $env:RUNNER_TEMP "signing-key.pem" + Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII + + # Use .NET RSA for signing (inline C# to avoid PowerShell RSA API limitations) + Add-Type -ReferencedAssemblies @("System.Security.Cryptography", "System.IO") -TypeDefinition @" + using System; + using System.IO; + using System.Security.Cryptography; + public class RsaSigner { + public static void Sign(string jsonPath, string keyPath, string sigPath) { + var jsonBytes = File.ReadAllBytes(jsonPath); + var rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(keyPath)); + var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + File.WriteAllText(sigPath, Convert.ToBase64String(sig)); + } + } + "@ + + [RsaSigner]::Sign($filesJsonPath, $privateKeyPath, $signaturePath) + Remove-Item -Path $privateKeyPath -Force + + Write-Host "Signed files.json -> files.json.sig" shell: pwsh - name: Upload Delta Package if: matrix.self_contained == true && matrix.arch == 'x64' uses: actions/upload-artifact@v4 with: - name: delta-package-windows-${{ matrix.arch }} - path: delta-output/* + name: release-delta-windows-x64 + path: | + delta-output/files.json + delta-output/files.json.sig + delta-output/update.zip + if-no-files-found: error + retention-days: 90 - name: Upload Installer uses: actions/upload-artifact@v4 @@ -399,11 +471,24 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - - name: Publish + - name: Publish Launcher + run: | + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ + -c Release \ + -o ./publish/launcher-linux-x64 \ + --self-contained \ + -r linux-x64 \ + -p:PublishSingleFile=false \ + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false \ + -p:DebugType=none \ + -p:DebugSymbols=false + + - 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 \ @@ -417,6 +502,34 @@ 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" + + echo "Restructuring for Launcher mode..." + echo "Version: $version" + + mkdir -p "$publishDir" + mv "publish/linux-x64-app" "$publishDir/$appDir" + + if [ -d "$launcherDir" ]; then + echo "Copying Launcher to root..." + cp -r "$launcherDir"/* "$publishDir/" + chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true + else + echo "Warning: Launcher publish dir not found: $launcherDir" + fi + + touch "$publishDir/$appDir/.current" + + echo "New directory structure:" + find "$publishDir" -maxdepth 2 | head -50 + + rm -rf "$launcherDir" + - name: Package as DEB run: | version="${{ needs.prepare.outputs.version }}" @@ -427,24 +540,20 @@ jobs: 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" @@ -460,7 +569,7 @@ jobs: fi 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" @@ -478,7 +587,6 @@ jobs: 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" @@ -488,14 +596,12 @@ jobs: 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" @@ -544,11 +650,24 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - - name: Publish + - name: Publish Launcher + run: | + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ + -c Release \ + -o ./publish/launcher-macos-${{ matrix.arch }} \ + --self-contained \ + -r osx-${{ matrix.arch }} \ + -p:PublishSingleFile=false \ + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false \ + -p:DebugType=none \ + -p:DebugSymbols=false + + - 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 \ @@ -562,29 +681,42 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - - name: Package as DMG + - name: Restructure and Package as DMG 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" + launcherDir="publish/launcher-macos-$arch" + appSourceDir="publish/macos-$arch-app" + + echo "Restructuring for Launcher mode..." + echo "Version: $version" + + mkdir -p "${app_name}.app/Contents/MacOS" + + appDir="app-$version" + mkdir -p "${app_name}.app/Contents/MacOS/$appDir" + + if [ -d "$appSourceDir" ]; then + cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/" + else + echo "Error: Main app source directory not found: $appSourceDir" exit 1 fi - - # Create app bundle structure - mkdir -p "${app_name}.app/Contents/MacOS" + + if [ -d "$launcherDir" ]; then + echo "Copying Launcher to root..." + cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/" + chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true + else + echo "Warning: Launcher publish dir not found: $launcherDir" + fi + + touch "${app_name}.app/Contents/MacOS/$appDir/.current" + 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" @@ -593,14 +725,13 @@ jobs: exit 1 fi - # Create Info.plist { 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' @@ -615,7 +746,6 @@ jobs: printf '%s\n' '' } > "${app_name}.app/Contents/Info.plist" - # Create DMG mkdir -p dmg-temp cp -r "${app_name}.app" dmg-temp/ @@ -627,7 +757,6 @@ jobs: exit 1 fi - # Cleanup rm -rf dmg-temp "${app_name}.app" - name: Upload @@ -653,29 +782,32 @@ jobs: - name: List artifacts structure run: | - echo "🔍 Artifact directory structure:" + echo "Artifact directory structure:" find artifacts -type f -o -type d | sort echo "" - echo "📊 Files found:" + echo "Files found:" find artifacts -type f -exec ls -lh {} \; echo "" - echo "📁 Full tree:" + echo "Full tree:" tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g' - name: Flatten artifacts for release run: | - echo "📦 Organizing artifacts..." + echo "Organizing artifacts..." mkdir -p release-files + # Copy installers and packages find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \; + # Copy delta update files (files.json, files.json.sig, update.zip) + find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \; echo "" - echo "✅ Files ready for release:" - ls -lh release-files/ || echo "⚠️ No files found in release-files" + echo "Files ready for release:" + ls -lh release-files/ || echo "No files found in release-files" echo "" - echo "📋 Total files:" + 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" + echo "Error: No release files found" exit 1 fi @@ -693,11 +825,1090 @@ jobs: ## Release ${{ needs.prepare.outputs.version }} ### Windows - - **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer (包含 .NET 运行时) - - **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer (包含 .NET 运行时) + - **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer (includes .NET runtime) + - **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer (includes .NET runtime) Installation: Double-click the .exe file and follow the wizard. + ### Incremental Update (Windows x64) + - **files.json** - Update manifest listing changed files + - **files.json.sig** - RSA signature of the manifest + - **update.zip** - Archive containing changed files + + Existing users: The app will automatically detect and apply the incremental update on next launch. + + ### Linux + - **LanMountainDesktop-{version}-linux-x64.deb** - Debian package (x64) + + ### macOS + - **LanMountainDesktop-{version}-macos-x64.dmg** - Intel processor + - **LanMountainDesktop-{version}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3) + + See commits for changes. + token: ${{ secrets.GITHUB_TOKEN }} +) { continue } + $prevHashMap[$relPath] = (Get-FileHash -Path $pf.FullName -Algorithm SHA256).Hash.ToLower() + } + Write-Host "Previous version has $($prevHashMap.Count) files for comparison" + } + + foreach ($file in $files) { + $relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/') + $relativePath = $relativePath.Replace('\', '/') + + # Skip deployment marker files + if ($relativePath -match '^\.(current|partial|destroy) + + - name: Sign File Map + if: matrix.self_contained == true && matrix.arch == 'x64' + run: | + $outputDir = "delta-output" + $filesJsonPath = Join-Path $outputDir "files.json" + $signaturePath = Join-Path $outputDir "files.json.sig" + + if (-not (Test-Path $filesJsonPath)) { + Write-Error "files.json not found at $filesJsonPath" + exit 1 + } + + # Sign using the private key from repository secrets + $privateKeyPem = "${{ secrets.UPDATE_PRIVATE_KEY_PEM }}" + if ([string]::IsNullOrWhiteSpace($privateKeyPem)) { + Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder" + Write-Warning "Incremental updates will fail signature verification without a valid signature" + Set-Content -Path $signaturePath -Value "" -Encoding ASCII + exit 0 + } + + $privateKeyPath = Join-Path $env:RUNNER_TEMP "signing-key.pem" + Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII + + # Use .NET RSA for signing (inline C# to avoid PowerShell RSA API limitations) + Add-Type -ReferencedAssemblies @("System.Security.Cryptography", "System.IO") -TypeDefinition @" + using System; + using System.IO; + using System.Security.Cryptography; + public class RsaSigner { + public static void Sign(string jsonPath, string keyPath, string sigPath) { + var jsonBytes = File.ReadAllBytes(jsonPath); + var rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(keyPath)); + var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + File.WriteAllText(sigPath, Convert.ToBase64String(sig)); + } + } + "@ + + [RsaSigner]::Sign($filesJsonPath, $privateKeyPath, $signaturePath) + Remove-Item -Path $privateKeyPath -Force + + Write-Host "Signed files.json -> files.json.sig" + shell: pwsh + + - name: Upload Delta Package + if: matrix.self_contained == true && matrix.arch == 'x64' + uses: actions/upload-artifact@v4 + with: + name: release-delta-windows-x64 + path: | + delta-output/files.json + delta-output/files.json.sig + delta-output/update.zip + if-no-files-found: error + retention-days: 90 + + - name: Upload Installer + uses: actions/upload-artifact@v4 + with: + name: release-windows-${{ matrix.arch }}${{ matrix.suffix }} + path: build-installer/*.exe + if-no-files-found: error + retention-days: 30 + + build-linux: + needs: prepare + runs-on: ubuntu-latest + name: Build_Linux + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libfontconfig1 libfreetype6 \ + libx11-6 libxrandr2 libxinerama1 \ + libxi6 libxcursor1 libxext6 \ + libxrender1 libxkbcommon-x11-0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore + run: dotnet restore ${{ env.Solution_Name }} + + - name: Build + run: > + dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal + -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 Launcher + run: | + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ + -c Release \ + -o ./publish/launcher-linux-x64 \ + --self-contained \ + -r linux-x64 \ + -p:PublishSingleFile=false \ + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false \ + -p:DebugType=none \ + -p:DebugSymbols=false + + - name: Publish Main App + run: | + dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \ + -c Release \ + -o ./publish/linux-x64-app \ + --self-contained \ + -r linux-x64 \ + -p:PublishSingleFile=false \ + -p:SelfContained=true \ + -p:DebugType=none \ + -p:DebugSymbols=false \ + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false \ + -p:Version=${{ needs.prepare.outputs.version }} \ + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} + + - name: Restructure for Launcher + run: | + version="${{ needs.prepare.outputs.version }}" + publishDir="publish/linux-x64" + appDir="app-$version" + launcherDir="publish/launcher-linux-x64" + + echo "Restructuring for Launcher mode..." + echo "Version: $version" + + mkdir -p "$publishDir" + mv "publish/linux-x64-app" "$publishDir/$appDir" + + if [ -d "$launcherDir" ]; then + echo "Copying Launcher to root..." + cp -r "$launcherDir"/* "$publishDir/" + chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true + else + echo "Warning: Launcher publish dir not found: $launcherDir" + fi + + touch "$publishDir/$appDir/.current" + + echo "New directory structure:" + find "$publishDir" -maxdepth 2 | head -50 + + rm -rf "$launcherDir" + + - name: Package as DEB + run: | + version="${{ needs.prepare.outputs.version }}" + source="publish/linux-x64" + package_name="LanMountainDesktop" + package_version="${version}" + arch="amd64" + desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop" + icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png" + + if [ ! -d "$source" ]; then + echo "Error: Source directory not found: $source" + ls -la publish/ || echo "publish directory not found" + exit 1 + fi + + mkdir -p "build-deb/DEBIAN" + mkdir -p "build-deb/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" + + cp -r "$source"/* "build-deb/usr/local/bin/" + + item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l) + echo "DEB package contains $item_count files" + + if [ "$item_count" -eq 0 ]; then + echo "Error: DEB package is empty after copy" + exit 1 + fi + + if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then + echo "Error: Linux desktop resources are missing" + ls -la "LanMountainDesktop/packaging/linux" || true + exit 1 + fi + + sed \ + -e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \ + -e "s|@@ICON@@|lanmountaindesktop|g" \ + "$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop" + + cp "$icon_source" "build-deb/usr/share/pixmaps/lanmountaindesktop.png" + cp "$icon_source" "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png" + + { + printf '%s\n' '#!/bin/sh' + printf '%s\n' 'set -e' + printf '%s\n' 'if command -v update-desktop-database >/dev/null 2>&1; then' + printf '%s\n' ' update-desktop-database /usr/share/applications >/dev/null 2>&1 || true' + printf '%s\n' 'fi' + printf '%s\n' 'if command -v gtk-update-icon-cache >/dev/null 2>&1; then' + printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true' + printf '%s\n' 'fi' + } > "build-deb/DEBIAN/postinst" + + { + 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." + } > "build-deb/DEBIAN/control" + + 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" + + 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" + exit 1 + fi + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: release-linux + path: "*.deb" + if-no-files-found: error + retention-days: 30 + + build-macos: + needs: prepare + runs-on: macos-latest + strategy: + matrix: + arch: [x64, arm64] + name: Build_macOS_${{ matrix.arch }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore + run: dotnet restore ${{ env.Solution_Name }} + + - name: Build + run: > + dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal + -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 Launcher + run: | + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ + -c Release \ + -o ./publish/launcher-macos-${{ matrix.arch }} \ + --self-contained \ + -r osx-${{ matrix.arch }} \ + -p:PublishSingleFile=false \ + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false \ + -p:DebugType=none \ + -p:DebugSymbols=false + + - name: Publish Main App + run: | + dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \ + -c Release \ + -o ./publish/macos-${{ matrix.arch }}-app \ + --self-contained \ + -r osx-${{ matrix.arch }} \ + -p:PublishSingleFile=false \ + -p:SelfContained=true \ + -p:DebugType=none \ + -p:DebugSymbols=false \ + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false \ + -p:Version=${{ needs.prepare.outputs.version }} \ + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} + + - name: Restructure and Package as DMG + run: | + version="${{ needs.prepare.outputs.version }}" + arch="${{ matrix.arch }}" + app_name="LanMountainDesktop" + package_name="${app_name}-${version}-macos-${arch}" + launcherDir="publish/launcher-macos-$arch" + appSourceDir="publish/macos-$arch-app" + + echo "Restructuring for Launcher mode..." + echo "Version: $version" + + mkdir -p "${app_name}.app/Contents/MacOS" + + appDir="app-$version" + mkdir -p "${app_name}.app/Contents/MacOS/$appDir" + + if [ -d "$appSourceDir" ]; then + cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/" + else + echo "Error: Main app source directory not found: $appSourceDir" + exit 1 + fi + + if [ -d "$launcherDir" ]; then + echo "Copying Launcher to root..." + cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/" + chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true + else + echo "Warning: Launcher publish dir not found: $launcherDir" + fi + + touch "${app_name}.app/Contents/MacOS/$appDir/.current" + + mkdir -p "${app_name}.app/Contents/Resources" + + item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l) + echo "App bundle contains $item_count files" + + if [ "$item_count" -eq 0 ]; then + echo "Error: App bundle is empty after copy" + exit 1 + fi + + { + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' ' CFBundleExecutable' + printf '%s\n' ' LanMountainDesktop.Launcher' + printf '%s\n' ' CFBundleName' + printf '%s\n' ' LanMountain Desktop' + printf '%s\n' ' CFBundleVersion' + printf '%s\n' " $version" + printf '%s\n' ' CFBundleShortVersionString' + printf '%s\n' " $version" + printf '%s\n' ' CFBundleIdentifier' + printf '%s\n' ' com.lanmountain.desktop' + printf '%s\n' ' CFBundlePackageType' + printf '%s\n' ' APPL' + printf '%s\n' '' + printf '%s\n' '' + } > "${app_name}.app/Contents/Info.plist" + + 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 + + rm -rf dmg-temp "${app_name}.app" + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: release-macos-${{ matrix.arch }} + path: "*.dmg" + if-no-files-found: error + retention-days: 30 + + github-release: + needs: [ prepare, build-windows, build-linux, build-macos ] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: release-* + + - name: List artifacts structure + 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' + + - name: Flatten artifacts for release + run: | + echo "Organizing artifacts..." + mkdir -p release-files + # Copy installers and packages + find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \; + # Copy delta update files (files.json, files.json.sig, update.zip) + find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \; + echo "" + echo "Files ready for release:" + ls -lh release-files/ || echo "No files found in release-files" + echo "" + echo "Total files:" + file_count=$(find release-files -type f | wc -l) + echo "$file_count" + if [ "$file_count" -eq 0 ]; then + echo "Error: No release files found" + exit 1 + fi + + - name: Create 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/**" + body: | + ## Release ${{ needs.prepare.outputs.version }} + + ### Windows + - **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer (includes .NET runtime) + - **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer (includes .NET runtime) + + Installation: Double-click the .exe file and follow the wizard. + + ### Incremental Update (Windows x64) + - **files.json** - Update manifest listing changed files + - **files.json.sig** - RSA signature of the manifest + - **update.zip** - Archive containing changed files + + Existing users: The app will automatically detect and apply the incremental update on next launch. + + ### Linux + - **LanMountainDesktop-{version}-linux-x64.deb** - Debian package (x64) + + ### macOS + - **LanMountainDesktop-{version}-macos-x64.dmg** - Intel processor + - **LanMountainDesktop-{version}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3) + + See commits for changes. + token: ${{ secrets.GITHUB_TOKEN }} +) { + continue + } + + $hash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash.ToLower() + + if ($prevHashMap.ContainsKey($relativePath)) { + $prevHash = $prevHashMap[$relativePath] + if ($hash -eq $prevHash) { + # File unchanged - reuse from current deployment + $fileEntries += @{ + Path = $relativePath + Action = "reuse" + Sha256 = $hash + } + $reusedCount++ + } else { + # File changed - replace with new version + $fileEntries += @{ + Path = $relativePath + Action = "replace" + Sha256 = $hash + ArchivePath = $relativePath + } + $changedFiles += $file + $replacedCount++ + } + # Remove from map so we can detect deleted files later + $prevHashMap.Remove($relativePath) + } else { + # New file not in previous version + $fileEntries += @{ + Path = $relativePath + Action = "add" + Sha256 = $hash + ArchivePath = $relativePath + } + $changedFiles += $file + $addedCount++ + } + } + + # Files in previous version but not in current = deleted + foreach ($deletedPath in $prevHashMap.Keys) { + $fileEntries += @{ + Path = $deletedPath + Action = "delete" + } + $deletedCount++ + } + + Write-Host "Delta summary: $reusedCount reused, $replacedCount replaced, $addedCount added, $deletedCount deleted" + Write-Host "Changed files to include in update.zip: $($changedFiles.Count)" + + $filesJson = @{ + FromVersion = $previousVersion + ToVersion = $version + Platform = "windows" + Arch = "x64" + Files = $fileEntries + } | ConvertTo-Json -Depth 10 + + # Write files.json (Launcher expects this exact name) + $filesJsonPath = Join-Path $outputDir "files.json" + $filesJson | Set-Content -Path $filesJsonPath -Encoding UTF8 + + Write-Host "Generated files.json with $($fileEntries.Count) entries" + + # Create update.zip with only changed files (Launcher expects this exact name) + $tempDir = Join-Path $outputDir "temp_staging" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + foreach ($file in $changedFiles) { + $relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/') + $destPath = Join-Path $tempDir $relativePath + $destDir = Split-Path -Parent $destPath + if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } + Copy-Item -Path $file.FullName -Destination $destPath -Force + } + + $updateZipPath = Join-Path $outputDir "update.zip" + if ($changedFiles.Count -gt 0) { + Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal + } else { + # No changed files - create empty zip + $emptyDir = Join-Path $outputDir "empty" + New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null + $placeholder = Join-Item $emptyDir ".empty" + Set-Content -Path (Join-Path $emptyDir ".empty") -Value "" + Compress-Archive -Path "$emptyDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal + Remove-Item -Path $emptyDir -Recurse -Force + } + Remove-Item -Path $tempDir -Recurse -Force + + Write-Host "Created update.zip: $([Math]::Round((Get-Item $updateZipPath).Length / 1MB, 2)) MB" + + # Clean up previous version extraction + if ($previousAppPath -and (Test-Path $previousAppPath)) { + Remove-Item -Path $previousAppPath -Recurse -Force -ErrorAction SilentlyContinue + } + shell: pwsh + + - name: Sign File Map + if: matrix.self_contained == true && matrix.arch == 'x64' + run: | + $outputDir = "delta-output" + $filesJsonPath = Join-Path $outputDir "files.json" + $signaturePath = Join-Path $outputDir "files.json.sig" + + if (-not (Test-Path $filesJsonPath)) { + Write-Error "files.json not found at $filesJsonPath" + exit 1 + } + + # Sign using the private key from repository secrets + $privateKeyPem = "${{ secrets.UPDATE_PRIVATE_KEY_PEM }}" + if ([string]::IsNullOrWhiteSpace($privateKeyPem)) { + Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder" + Write-Warning "Incremental updates will fail signature verification without a valid signature" + Set-Content -Path $signaturePath -Value "" -Encoding ASCII + exit 0 + } + + $privateKeyPath = Join-Path $env:RUNNER_TEMP "signing-key.pem" + Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII + + # Use .NET RSA for signing (inline C# to avoid PowerShell RSA API limitations) + Add-Type -ReferencedAssemblies @("System.Security.Cryptography", "System.IO") -TypeDefinition @" + using System; + using System.IO; + using System.Security.Cryptography; + public class RsaSigner { + public static void Sign(string jsonPath, string keyPath, string sigPath) { + var jsonBytes = File.ReadAllBytes(jsonPath); + var rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(keyPath)); + var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + File.WriteAllText(sigPath, Convert.ToBase64String(sig)); + } + } + "@ + + [RsaSigner]::Sign($filesJsonPath, $privateKeyPath, $signaturePath) + Remove-Item -Path $privateKeyPath -Force + + Write-Host "Signed files.json -> files.json.sig" + shell: pwsh + + - name: Upload Delta Package + if: matrix.self_contained == true && matrix.arch == 'x64' + uses: actions/upload-artifact@v4 + with: + name: release-delta-windows-x64 + path: | + delta-output/files.json + delta-output/files.json.sig + delta-output/update.zip + if-no-files-found: error + retention-days: 90 + + - name: Upload Installer + uses: actions/upload-artifact@v4 + with: + name: release-windows-${{ matrix.arch }}${{ matrix.suffix }} + path: build-installer/*.exe + if-no-files-found: error + retention-days: 30 + + build-linux: + needs: prepare + runs-on: ubuntu-latest + name: Build_Linux + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libfontconfig1 libfreetype6 \ + libx11-6 libxrandr2 libxinerama1 \ + libxi6 libxcursor1 libxext6 \ + libxrender1 libxkbcommon-x11-0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore + run: dotnet restore ${{ env.Solution_Name }} + + - name: Build + run: > + dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal + -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 Launcher + run: | + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ + -c Release \ + -o ./publish/launcher-linux-x64 \ + --self-contained \ + -r linux-x64 \ + -p:PublishSingleFile=false \ + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false \ + -p:DebugType=none \ + -p:DebugSymbols=false + + - name: Publish Main App + run: | + dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \ + -c Release \ + -o ./publish/linux-x64-app \ + --self-contained \ + -r linux-x64 \ + -p:PublishSingleFile=false \ + -p:SelfContained=true \ + -p:DebugType=none \ + -p:DebugSymbols=false \ + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false \ + -p:Version=${{ needs.prepare.outputs.version }} \ + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} + + - name: Restructure for Launcher + run: | + version="${{ needs.prepare.outputs.version }}" + publishDir="publish/linux-x64" + appDir="app-$version" + launcherDir="publish/launcher-linux-x64" + + echo "Restructuring for Launcher mode..." + echo "Version: $version" + + mkdir -p "$publishDir" + mv "publish/linux-x64-app" "$publishDir/$appDir" + + if [ -d "$launcherDir" ]; then + echo "Copying Launcher to root..." + cp -r "$launcherDir"/* "$publishDir/" + chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true + else + echo "Warning: Launcher publish dir not found: $launcherDir" + fi + + touch "$publishDir/$appDir/.current" + + echo "New directory structure:" + find "$publishDir" -maxdepth 2 | head -50 + + rm -rf "$launcherDir" + + - name: Package as DEB + run: | + version="${{ needs.prepare.outputs.version }}" + source="publish/linux-x64" + package_name="LanMountainDesktop" + package_version="${version}" + arch="amd64" + desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop" + icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png" + + if [ ! -d "$source" ]; then + echo "Error: Source directory not found: $source" + ls -la publish/ || echo "publish directory not found" + exit 1 + fi + + mkdir -p "build-deb/DEBIAN" + mkdir -p "build-deb/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" + + cp -r "$source"/* "build-deb/usr/local/bin/" + + item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l) + echo "DEB package contains $item_count files" + + if [ "$item_count" -eq 0 ]; then + echo "Error: DEB package is empty after copy" + exit 1 + fi + + if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then + echo "Error: Linux desktop resources are missing" + ls -la "LanMountainDesktop/packaging/linux" || true + exit 1 + fi + + sed \ + -e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \ + -e "s|@@ICON@@|lanmountaindesktop|g" \ + "$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop" + + cp "$icon_source" "build-deb/usr/share/pixmaps/lanmountaindesktop.png" + cp "$icon_source" "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png" + + { + printf '%s\n' '#!/bin/sh' + printf '%s\n' 'set -e' + printf '%s\n' 'if command -v update-desktop-database >/dev/null 2>&1; then' + printf '%s\n' ' update-desktop-database /usr/share/applications >/dev/null 2>&1 || true' + printf '%s\n' 'fi' + printf '%s\n' 'if command -v gtk-update-icon-cache >/dev/null 2>&1; then' + printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true' + printf '%s\n' 'fi' + } > "build-deb/DEBIAN/postinst" + + { + 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." + } > "build-deb/DEBIAN/control" + + 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" + + 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" + exit 1 + fi + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: release-linux + path: "*.deb" + if-no-files-found: error + retention-days: 30 + + build-macos: + needs: prepare + runs-on: macos-latest + strategy: + matrix: + arch: [x64, arm64] + name: Build_macOS_${{ matrix.arch }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore + run: dotnet restore ${{ env.Solution_Name }} + + - name: Build + run: > + dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal + -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 Launcher + run: | + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ + -c Release \ + -o ./publish/launcher-macos-${{ matrix.arch }} \ + --self-contained \ + -r osx-${{ matrix.arch }} \ + -p:PublishSingleFile=false \ + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false \ + -p:DebugType=none \ + -p:DebugSymbols=false + + - name: Publish Main App + run: | + dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \ + -c Release \ + -o ./publish/macos-${{ matrix.arch }}-app \ + --self-contained \ + -r osx-${{ matrix.arch }} \ + -p:PublishSingleFile=false \ + -p:SelfContained=true \ + -p:DebugType=none \ + -p:DebugSymbols=false \ + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false \ + -p:Version=${{ needs.prepare.outputs.version }} \ + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} + + - name: Restructure and Package as DMG + run: | + version="${{ needs.prepare.outputs.version }}" + arch="${{ matrix.arch }}" + app_name="LanMountainDesktop" + package_name="${app_name}-${version}-macos-${arch}" + launcherDir="publish/launcher-macos-$arch" + appSourceDir="publish/macos-$arch-app" + + echo "Restructuring for Launcher mode..." + echo "Version: $version" + + mkdir -p "${app_name}.app/Contents/MacOS" + + appDir="app-$version" + mkdir -p "${app_name}.app/Contents/MacOS/$appDir" + + if [ -d "$appSourceDir" ]; then + cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/" + else + echo "Error: Main app source directory not found: $appSourceDir" + exit 1 + fi + + if [ -d "$launcherDir" ]; then + echo "Copying Launcher to root..." + cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/" + chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true + else + echo "Warning: Launcher publish dir not found: $launcherDir" + fi + + touch "${app_name}.app/Contents/MacOS/$appDir/.current" + + mkdir -p "${app_name}.app/Contents/Resources" + + item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l) + echo "App bundle contains $item_count files" + + if [ "$item_count" -eq 0 ]; then + echo "Error: App bundle is empty after copy" + exit 1 + fi + + { + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' ' CFBundleExecutable' + printf '%s\n' ' LanMountainDesktop.Launcher' + printf '%s\n' ' CFBundleName' + printf '%s\n' ' LanMountain Desktop' + printf '%s\n' ' CFBundleVersion' + printf '%s\n' " $version" + printf '%s\n' ' CFBundleShortVersionString' + printf '%s\n' " $version" + printf '%s\n' ' CFBundleIdentifier' + printf '%s\n' ' com.lanmountain.desktop' + printf '%s\n' ' CFBundlePackageType' + printf '%s\n' ' APPL' + printf '%s\n' '' + printf '%s\n' '' + } > "${app_name}.app/Contents/Info.plist" + + 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 + + rm -rf dmg-temp "${app_name}.app" + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: release-macos-${{ matrix.arch }} + path: "*.dmg" + if-no-files-found: error + retention-days: 30 + + github-release: + needs: [ prepare, build-windows, build-linux, build-macos ] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: release-* + + - name: List artifacts structure + 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' + + - name: Flatten artifacts for release + run: | + echo "Organizing artifacts..." + mkdir -p release-files + # Copy installers and packages + find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \; + # Copy delta update files (files.json, files.json.sig, update.zip) + find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \; + echo "" + echo "Files ready for release:" + ls -lh release-files/ || echo "No files found in release-files" + echo "" + echo "Total files:" + file_count=$(find release-files -type f | wc -l) + echo "$file_count" + if [ "$file_count" -eq 0 ]; then + echo "Error: No release files found" + exit 1 + fi + + - name: Create 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/**" + body: | + ## Release ${{ needs.prepare.outputs.version }} + + ### Windows + - **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer (includes .NET runtime) + - **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer (includes .NET runtime) + + Installation: Double-click the .exe file and follow the wizard. + + ### Incremental Update (Windows x64) + - **files.json** - Update manifest listing changed files + - **files.json.sig** - RSA signature of the manifest + - **update.zip** - Archive containing changed files + + Existing users: The app will automatically detect and apply the incremental update on next launch. + ### Linux - **LanMountainDesktop-{version}-linux-x64.deb** - Debian package (x64) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb0f98..0c59579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # 更新日志 / Changelog +## [0.8.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.4) - 2026-04-12 + +### 新增 (Added) + +- ✨ **全新淡入淡出动画系统**: 引入了一套全新的淡入淡出动画效果 + - 提升界面切换和元素显示的视觉流畅度 + - 为用户带来更加自然优雅的交互体验 + +### 变更 (Changed) + +- ♻️ **SDK 更新**: 更新插件 SDK,优化插件开发接口和兼容性 +- 🎨 **网速显示组件优化**: 优化了网速显示组件的显示效果 + - 改进数据展示方式,提升可读性 + - 优化视觉样式,与整体设计语言更加协调 + +### 修复 (Fixed) + +- 无 + +### 移除 (Removed) + +- 无 + +*** + ## [0.8.3.5](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.5) - 2026-04-12 ### 新增 (Added) diff --git a/Directory.Build.props b/Directory.Build.props index 327b321..58cb0ec 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,5 +4,6 @@ net10.0 enable enable + true diff --git a/LanMountainDesktop.Launcher/Assets/public-key.pem b/LanMountainDesktop.Launcher/Assets/public-key.pem new file mode 100644 index 0000000..4d6a77a --- /dev/null +++ b/LanMountainDesktop.Launcher/Assets/public-key.pem @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGxbjZT +B+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS17YI +90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uzay3go +msbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNizr0l +YcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM5iUa +20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQAB +-----END RSA PUBLIC KEY----- \ No newline at end of file diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj index 71c7f18..32742ce 100644 --- a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj +++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj @@ -19,6 +19,30 @@ + + + + + + + + + $(MSBuildProjectDirectory)\Assets\public-key.pem + $(OutDir).launcher\update + + + + + + + + $(MSBuildProjectDirectory)\Assets\public-key.pem + $(PublishDir).launcher\update + + + + + diff --git a/LanMountainDesktop.Launcher/Services/Commands.cs b/LanMountainDesktop.Launcher/Services/Commands.cs index e94ad82..ac8f8bc 100644 --- a/LanMountainDesktop.Launcher/Services/Commands.cs +++ b/LanMountainDesktop.Launcher/Services/Commands.cs @@ -89,7 +89,7 @@ internal static class Commands return context.SubCommand.ToLowerInvariant() switch { "check" => updateEngine.CheckPendingUpdate(), - "apply" => updateEngine.ApplyPendingUpdate(), + "apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false), "rollback" => updateEngine.RollbackLatest(), "download" => await updateEngine.DownloadAsync( context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."), diff --git a/LanMountainDesktop.Launcher/Services/DeferredSplashStageReporter.cs b/LanMountainDesktop.Launcher/Services/DeferredSplashStageReporter.cs new file mode 100644 index 0000000..c64a390 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/DeferredSplashStageReporter.cs @@ -0,0 +1,32 @@ +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Views; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class DeferredSplashStageReporter : ISplashStageReporter +{ + private ISplashStageReporter? _inner; + private readonly List<(string Stage, string Message)> _pending = []; + + public void SetInner(ISplashStageReporter inner) + { + _inner = inner; + foreach (var (stage, message) in _pending) + { + _inner.Report(stage, message); + } + _pending.Clear(); + } + + public void Report(string stage, string message) + { + if (_inner is not null) + { + _inner.Report(stage, message); + } + else + { + _pending.Add((stage, message)); + } + } +} diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index ef1075f..bf6a059 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -13,7 +13,6 @@ internal sealed class LauncherFlowCoordinator private readonly UpdateEngineService _updateEngine; private readonly UpdateCheckService _updateCheckService; private readonly PluginInstallerService _pluginInstallerService; - private readonly ISplashStageReporter _splashStageReporter; private readonly IReadOnlyList _oobeSteps; public LauncherFlowCoordinator( @@ -30,7 +29,6 @@ internal sealed class LauncherFlowCoordinator _updateEngine = updateEngine; _updateCheckService = updateCheckService; _pluginInstallerService = pluginInstallerService; - _splashStageReporter = new NullSplashStageReporter(); _oobeSteps = [new WelcomeOobeStep(_oobeStateService)]; } @@ -41,7 +39,6 @@ internal sealed class LauncherFlowCoordinator // 清理待删除的旧版本 _deploymentLocator.CleanupDestroyedDeployments(); - _splashStageReporter.Report("bootstrap", "bootstrap"); if (_oobeStateService.IsFirstRun()) { foreach (var step in _oobeSteps) @@ -57,16 +54,18 @@ internal sealed class LauncherFlowCoordinator return window; }); + var reporter = (ISplashStageReporter)splashWindow; + try { - _splashStageReporter.Report("silentUpdate", "update"); - var updateResult = _updateEngine.ApplyPendingUpdate(); + reporter.Report("silentUpdate", "update"); + var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); if (!updateResult.Success) { return updateResult; } - _splashStageReporter.Report("pluginTasks", "plugins"); + reporter.Report("pluginTasks", "plugins"); var pluginsDir = _context.GetOption("plugins-dir") ?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins"); var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir); @@ -75,7 +74,7 @@ internal sealed class LauncherFlowCoordinator return queueResult; } - _splashStageReporter.Report("launchHost", "launch"); + reporter.Report("launchHost", "launch"); var hostResult = LaunchHost(); if (!hostResult.Success) { @@ -192,13 +191,4 @@ internal sealed class LauncherFlowCoordinator } } } - - private sealed class NullSplashStageReporter : ISplashStageReporter - { - public void Report(string stage, string message) - { - _ = stage; - _ = message; - } - } } diff --git a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs index 4995ddd..802927b 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs @@ -110,7 +110,7 @@ internal sealed class UpdateEngineService }; } - public LauncherResult ApplyPendingUpdate() + public async Task ApplyPendingUpdateAsync() { Directory.CreateDirectory(_incomingRoot); Directory.CreateDirectory(_snapshotsRoot); @@ -136,7 +136,7 @@ internal sealed class UpdateEngineService return Failed("update.apply", "signature_failed", verifyResult.Message); } - var fileMapText = File.ReadAllText(fileMapPath); + var fileMapText = await File.ReadAllTextAsync(fileMapPath); var fileMap = JsonSerializer.Deserialize(fileMapText); if (fileMap is null || fileMap.Files.Count == 0) { diff --git a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml index d75b78f..8093baa 100644 --- a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml +++ b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml @@ -3,15 +3,38 @@ x:Class="LanMountainDesktop.Launcher.Views.SplashWindow" Title="阑山桌面" Width="420" - Height="220" + Height="240" CanResize="False" WindowStartupLocation="CenterScreen" SystemDecorations="None"> - + + HorizontalAlignment="Center" + Grid.Row="0" /> + + + diff --git a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs index 30286dd..d244b5e 100644 --- a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs @@ -1,12 +1,48 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; +using LanMountainDesktop.Launcher.Services; namespace LanMountainDesktop.Launcher.Views; -internal partial class SplashWindow : Window +internal partial class SplashWindow : Window, ISplashStageReporter { + private static readonly (string Stage, string Label, double Progress)[] StageMap = + [ + ("bootstrap", "正在初始化...", 10), + ("silentUpdate", "正在应用更新...", 35), + ("pluginTasks", "正在处理插件...", 65), + ("launchHost", "正在启动...", 90), + ]; + public SplashWindow() { AvaloniaXamlLoader.Load(this); } + + public void Report(string stage, string message) + { + var (label, progress) = ResolveStageInfo(stage); + + var stageText = this.GetControl("StageText"); + var detailText = this.GetControl("DetailText"); + var progressIndicator = this.GetControl("ProgressIndicator"); + + stageText.Text = label; + detailText.Text = message; + progressIndicator.IsIndeterminate = false; + progressIndicator.Value = progress; + } + + private static (string Label, double Progress) ResolveStageInfo(string stage) + { + foreach (var (s, label, progress) in StageMap) + { + if (string.Equals(s, stage, StringComparison.OrdinalIgnoreCase)) + { + return (label, progress); + } + } + + return (stage, 0); + } } diff --git a/LanMountainDesktop/.github/workflows/windows-ci.yml b/LanMountainDesktop/.github/workflows/windows-ci.yml deleted file mode 100644 index 886147c..0000000 --- a/LanMountainDesktop/.github/workflows/windows-ci.yml +++ /dev/null @@ -1,217 +0,0 @@ -name: Desktop CI - -on: - push: - branches: - - "**" - tags: - - "v*" - pull_request: - workflow_dispatch: - inputs: - version: - description: "Package version override (for example: 1.2.3)" - required: false - type: string - -concurrency: - group: desktop-ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }} - -env: - DOTNET_VERSION: "10.0.x" - PROJECT_PATH: "LanMountainDesktop.csproj" - -jobs: - validate: - name: Validate Build (Windows) - runs-on: windows-latest - timeout-minutes: 20 - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - cache: true - cache-dependency-path: | - **/*.csproj - - - name: Restore - run: dotnet restore .\${{ env.PROJECT_PATH }} - - - name: Build - run: dotnet build .\${{ env.PROJECT_PATH }} -c Release --no-restore - - - name: Test (if test projects exist) - shell: pwsh - run: | - $testProjects = @(Get-ChildItem -Path . -Recurse -Filter *.csproj | Where-Object { - Select-String -Path $_.FullName -Pattern '\s*true\s*|Microsoft.NET.Test.Sdk' -Quiet - }) - - if ($testProjects.Count -eq 0) { - Write-Host "No test projects found. Skipping dotnet test." - exit 0 - } - - foreach ($project in $testProjects) { - Write-Host "Running tests in $($project.FullName)" - dotnet test $project.FullName -c Release --verbosity normal - } - - resolve_version: - name: Resolve Package Version - runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') - outputs: - value: ${{ steps.version.outputs.value }} - permissions: - contents: read - steps: - - name: Resolve version - id: version - shell: pwsh - run: | - $manualVersion = '${{ github.event.inputs.version }}' - if ($manualVersion) { - $version = $manualVersion.Trim() - } elseif ($env:GITHUB_REF -like "refs/tags/v*") { - $version = $env:GITHUB_REF_NAME.Substring(1) - } elseif ($env:GITHUB_REF -like "refs/tags/*") { - $version = $env:GITHUB_REF_NAME - } else { - $version = "0.0.$env:GITHUB_RUN_NUMBER" - } - - if (-not $version) { - throw "Failed to resolve package version." - } - - if ($version -notmatch '^\d+\.\d+\.\d+([\-+][0-9A-Za-z\.-]+)?$') { - throw "Invalid version format: $version" - } - - "value=$version" >> $env:GITHUB_OUTPUT - Write-Host "Using package version: $version" - - package: - name: Package (${{ matrix.name }}) - needs: - - validate - - resolve_version - if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - - name: Windows - runner: windows-latest - rid: win-x64 - artifact_name: LanMountainDesktop-Setup - artifact_path: artifacts/installer/*.exe - - name: Linux - runner: ubuntu-latest - rid: linux-x64 - artifact_name: LanMountainDesktop-linux-x64 - artifact_path: artifacts/packages/*linux-x64*.zip - - name: macOS - runner: macos-latest - rid: osx-x64 - artifact_name: LanMountainDesktop-osx-x64 - artifact_path: artifacts/packages/*osx-x64*.zip - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - cache: true - cache-dependency-path: | - **/*.csproj - - - name: Install Inno Setup - if: matrix.rid == 'win-x64' - shell: pwsh - run: | - if (Get-Command iscc.exe -ErrorAction SilentlyContinue) { - Write-Host "Inno Setup is already installed." - exit 0 - } - - if (Get-Command choco -ErrorAction SilentlyContinue) { - choco install innosetup --yes --no-progress - } elseif (Get-Command winget -ErrorAction SilentlyContinue) { - winget install --id JRSoftware.InnoSetup -e --source winget --accept-package-agreements --accept-source-agreements - } else { - throw "Neither choco nor winget is available to install Inno Setup." - } - - - name: Build Package - shell: pwsh - run: | - ./scripts/package.ps1 ` - -Configuration Release ` - -RuntimeIdentifier ${{ matrix.rid }} ` - -Version "${{ needs.resolve_version.outputs.value }}" - - - name: Upload Package Artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact_name }}-${{ needs.resolve_version.outputs.value }} - path: ${{ matrix.artifact_path }} - if-no-files-found: error - - - name: Upload Windows Publish Artifact - if: matrix.rid == 'win-x64' - uses: actions/upload-artifact@v4 - with: - name: LanMountainDesktop-Publish-win-x64-${{ needs.resolve_version.outputs.value }} - path: artifacts/publish/win-x64/** - if-no-files-found: error - - publish_release_assets: - name: Attach Artifacts to GitHub Release - runs-on: ubuntu-latest - needs: - - package - - resolve_version - if: startsWith(github.ref, 'refs/tags/v') - permissions: - contents: write - steps: - - name: Download Windows Installer Artifact - uses: actions/download-artifact@v4 - with: - name: LanMountainDesktop-Setup-${{ needs.resolve_version.outputs.value }} - path: release-assets/windows - - - name: Download Linux Package Artifact - uses: actions/download-artifact@v4 - with: - name: LanMountainDesktop-linux-x64-${{ needs.resolve_version.outputs.value }} - path: release-assets/linux - - - name: Download macOS Package Artifact - uses: actions/download-artifact@v4 - with: - name: LanMountainDesktop-osx-x64-${{ needs.resolve_version.outputs.value }} - path: release-assets/macos - - - name: Attach Artifacts - uses: softprops/action-gh-release@v2 - with: - files: | - release-assets/windows/*.exe - release-assets/linux/*.zip - release-assets/macos/*.zip diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 9053f23..c309d56 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -76,20 +76,37 @@ - + + + + <_LauncherOutputPath>..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\ + - + - + + + + + + <_LauncherPublishPath>..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\ + + + + <_LauncherPublishSource Condition="'$(RuntimeIdentifier)' != '' and Exists('..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\')">..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\ + <_LauncherPublishSource Condition="'$(_LauncherPublishSource)' == ''">..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\ + - + - + diff --git a/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs b/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs index 186e1c9..2d13678 100644 --- a/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs +++ b/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs @@ -1,29 +1,23 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; -using System.Text; -using System.Text.Json; using System.Threading; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services.Settings; +using PostHog; namespace LanMountainDesktop.Services; public sealed class PostHogUsageTelemetryService : IDisposable { private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9"; - private const string PostHogHost = "https://us.i.posthog.com/capture/"; + private const string PostHogHostUrl = "https://us.i.posthog.com"; private readonly ISettingsFacadeService _settingsFacade; private readonly ISettingsService _settingsService; - private readonly HttpClient _httpClient = new() - { - Timeout = TimeSpan.FromSeconds(10) - }; - private readonly Queue _eventQueue = new(); - private readonly object _queueLock = new(); + private readonly PostHogClient _client; + private readonly CancellationTokenSource _cts = new(); private Timer? _flushTimer; private bool _isInitialized; @@ -39,6 +33,14 @@ public sealed class PostHogUsageTelemetryService : IDisposable _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); _settingsService = settingsFacade.Settings; _settingsService.Changed += OnSettingsChanged; + + _client = new PostHogClient(new PostHogOptions + { + ProjectApiKey = PostHogApiKey, + HostUrl = new Uri(PostHogHostUrl), + FlushAt = 20, + FlushInterval = TimeSpan.FromSeconds(30) + }); } public bool IsUsageEnabled => _isUsageEnabled; @@ -56,7 +58,7 @@ public sealed class PostHogUsageTelemetryService : IDisposable RefreshEnabledState(forceSessionStart: true); _flushTimer = new Timer( - _ => FlushEvents(), + _ => _ = _client.FlushAsync(), null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)); @@ -88,14 +90,12 @@ public sealed class PostHogUsageTelemetryService : IDisposable return; } - ClearQueuedEvents(); StopSessionWithoutSending(); } catch (Exception ex) { AppLogger.Warn("PostHogUsage", "Failed to refresh usage analytics enabled state.", ex); _isUsageEnabled = false; - ClearQueuedEvents(); StopSessionWithoutSending(); } } @@ -278,7 +278,7 @@ public sealed class PostHogUsageTelemetryService : IDisposable EndSession(source, isRestart); } - FlushEvents(); + _ = _client.FlushAsync(); AppLogger.Info( "PostHogUsage", $"Usage telemetry shutdown complete. Source='{source}'; Restart='{isRestart}'; Enabled={_isUsageEnabled}."); @@ -291,16 +291,13 @@ public sealed class PostHogUsageTelemetryService : IDisposable _flushTimer?.Dispose(); _settingsService.Changed -= OnSettingsChanged; Shutdown(isRestart: false, source: "Dispose"); - FlushEvents(); + _cts.Cancel(); + _client.Dispose(); } catch (Exception ex) { AppLogger.Warn("PostHogUsage", "Error disposing usage telemetry service.", ex); } - finally - { - _httpClient.Dispose(); - } } private void EnsureBaselineEventSent() @@ -313,66 +310,35 @@ public sealed class PostHogUsageTelemetryService : IDisposable return; } - var now = DateTimeOffset.UtcNow; - if (SendBaselineEventToPostHog(identity.InstallId, now)) + var distinctId = identity.InstallId; + var personProps = new Dictionary { - identity.MarkBaselineReported(); - } - } - catch (Exception ex) - { - AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex); - } - } - - private bool SendBaselineEventToPostHog(string installId, DateTimeOffset timestamp) - { - try - { - var requestBody = new Dictionary - { - ["api_key"] = PostHogApiKey, - ["event"] = "app_first_launch", - ["distinct_id"] = installId, - ["timestamp"] = timestamp.ToString("o"), - ["properties"] = new Dictionary - { - ["install_id"] = installId, - ["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(), - ["os_name"] = TelemetryEnvironmentInfo.GetOsName(), - ["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(), - ["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(), - ["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(), - ["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(), - ["language"] = TelemetryEnvironmentInfo.GetSystemLanguage(), - ["launch_time_utc"] = timestamp.ToString("o") - } + ["install_id"] = identity.InstallId, + ["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(), + ["os_name"] = TelemetryEnvironmentInfo.GetOsName(), + ["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(), + ["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(), + ["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(), + ["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(), + ["language"] = TelemetryEnvironmentInfo.GetSystemLanguage() }; - var json = JsonSerializer.Serialize(requestBody); - var bytes = Encoding.UTF8.GetBytes(json); + _ = _client.IdentifyAsync(distinctId, personProps, null, _cts.Token); - using var content = new ByteArrayContent(bytes); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + _client.Capture( + distinctId, + "app_first_launch", + personProps, + groups: null, + sendFeatureFlags: false); - var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult(); - var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - - if (!response.IsSuccessStatusCode) - { - AppLogger.Warn( - "PostHogUsage", - $"PostHog baseline event failed: {response.StatusCode} - {responseBody}"); - return false; - } - - AppLogger.Info("PostHogUsage", "Sent first-launch baseline event."); - return true; + _ = _client.FlushAsync(); + identity.MarkBaselineReported(); + AppLogger.Info("PostHogUsage", "Sent first-launch baseline event via SDK."); } catch (Exception ex) { AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex); - return false; } } @@ -479,137 +445,60 @@ public sealed class PostHogUsageTelemetryService : IDisposable return; } - var eventData = new TelemetryEvent( - eventName, - TelemetryIdentityService.Instance.TelemetryId, - TelemetryIdentityService.Instance.InstallId, - TelemetryIdentityService.Instance.TelemetryId, - _sessionId, - Interlocked.Increment(ref _sequence), - DateTimeOffset.UtcNow, - payload ?? new Dictionary(), - stateBefore, - stateAfter); + var identity = TelemetryIdentityService.Instance; + var distinctId = identity.TelemetryId; + var seq = Interlocked.Increment(ref _sequence); - lock (_queueLock) + var properties = new Dictionary(StringComparer.OrdinalIgnoreCase) { - _eventQueue.Enqueue(eventData); + ["install_id"] = identity.InstallId, + ["telemetry_id"] = identity.TelemetryId, + ["session_id"] = _sessionId, + ["sequence"] = seq, + ["timestamp_utc"] = DateTimeOffset.UtcNow.ToString("o"), + ["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(), + ["os_name"] = TelemetryEnvironmentInfo.GetOsName(), + ["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(), + ["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(), + ["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(), + ["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(), + ["language"] = TelemetryEnvironmentInfo.GetSystemLanguage() + }; + + if (payload is not null) + { + foreach (var kvp in payload) + { + properties[$"payload_{kvp.Key}"] = kvp.Value; + } } + if (stateBefore is not null && stateBefore.Count > 0) + { + foreach (var kvp in stateBefore) + { + properties[$"state_before_{kvp.Key}"] = kvp.Value; + } + } + + if (stateAfter is not null && stateAfter.Count > 0) + { + foreach (var kvp in stateAfter) + { + properties[$"state_after_{kvp.Key}"] = kvp.Value; + } + } + + _client.Capture( + distinctId, + eventName, + properties, + groups: null, + sendFeatureFlags: false); + if (forceFlush) { - FlushEvents(); - return; - } - - var shouldFlush = false; - lock (_queueLock) - { - shouldFlush = _eventQueue.Count >= 20; - } - - if (shouldFlush) - { - FlushEvents(); - } - } - - private void FlushEvents() - { - List eventsToSend; - - lock (_queueLock) - { - if (_eventQueue.Count == 0) - { - return; - } - - eventsToSend = new List(); - while (_eventQueue.Count > 0 && eventsToSend.Count < 20) - { - eventsToSend.Add(_eventQueue.Dequeue()); - } - } - - try - { - foreach (var telemetryEvent in eventsToSend) - { - if (!SendEventToPostHog(telemetryEvent, flushImmediately: false)) - { - throw new InvalidOperationException($"Failed to send PostHog event '{telemetryEvent.EventName}'."); - } - } - } - catch (Exception ex) - { - AppLogger.Warn("PostHogUsage", "Failed to send queued events to PostHog.", ex); - - lock (_queueLock) - { - foreach (var evt in eventsToSend) - { - if (_eventQueue.Count >= 100) - { - break; - } - - _eventQueue.Enqueue(evt); - } - } - } - } - - private bool SendEventToPostHog(TelemetryEvent telemetryEvent, bool flushImmediately) - { - try - { - var requestBody = new Dictionary - { - ["api_key"] = PostHogApiKey, - ["event"] = telemetryEvent.EventName, - ["distinct_id"] = telemetryEvent.DistinctId, - ["timestamp"] = telemetryEvent.Timestamp.ToString("o"), - ["properties"] = telemetryEvent.ToPostHogProperties() - }; - - var json = JsonSerializer.Serialize(requestBody); - var bytes = Encoding.UTF8.GetBytes(json); - - using var content = new ByteArrayContent(bytes); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - - var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult(); - var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - - if (!response.IsSuccessStatusCode) - { - AppLogger.Warn( - "PostHogUsage", - $"PostHog event '{telemetryEvent.EventName}' failed: {response.StatusCode} - {responseBody}"); - return false; - } - - if (flushImmediately) - { - AppLogger.Info("PostHogUsage", $"Sent event '{telemetryEvent.EventName}' immediately."); - } - - return true; - } - catch (Exception ex) - { - AppLogger.Warn("PostHogUsage", $"Failed to send PostHog event '{telemetryEvent.EventName}'.", ex); - return false; - } - } - - private void ClearQueuedEvents() - { - lock (_queueLock) - { - _eventQueue.Clear(); + _ = _client.FlushAsync(); } } diff --git a/LanMountainDesktop/Services/TelemetryEvent.cs b/LanMountainDesktop/Services/TelemetryEvent.cs deleted file mode 100644 index cad7fa5..0000000 --- a/LanMountainDesktop/Services/TelemetryEvent.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace LanMountainDesktop.Services; - -internal sealed record TelemetryEvent( - string EventName, - string DistinctId, - string InstallId, - string TelemetryId, - string SessionId, - long Sequence, - DateTimeOffset Timestamp, - IReadOnlyDictionary Payload, - IReadOnlyDictionary? StateBefore = null, - IReadOnlyDictionary? StateAfter = null) -{ - public Dictionary ToPostHogProperties() - { - var properties = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["install_id"] = InstallId, - ["telemetry_id"] = TelemetryId, - ["session_id"] = SessionId, - ["sequence"] = Sequence, - ["timestamp_utc"] = Timestamp.ToString("o"), - ["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(), - ["os_name"] = TelemetryEnvironmentInfo.GetOsName(), - ["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(), - ["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(), - ["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(), - ["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(), - ["language"] = TelemetryEnvironmentInfo.GetSystemLanguage(), - ["payload"] = Copy(Payload) - }; - - if (StateBefore is not null && StateBefore.Count > 0) - { - properties["state_before"] = Copy(StateBefore); - } - - if (StateAfter is not null && StateAfter.Count > 0) - { - properties["state_after"] = Copy(StateAfter); - } - - return properties; - } - - private static Dictionary Copy(IReadOnlyDictionary source) - { - return source.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase); - } -} diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs index c1498e5..e29a05f 100644 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using LanMountainDesktop.PluginSdk; @@ -47,6 +49,13 @@ public sealed class UpdateWorkflowService private readonly ISettingsFacadeService _settingsFacade; private readonly string _updatesDirectory; + private const string LauncherDirectoryName = ".launcher"; + private const string UpdateDirectoryName = "update"; + private const string IncomingDirectoryName = "incoming"; + private const string DeltaManifestFileName = "files.json"; + private const string DeltaSignatureFileName = "files.json.sig"; + private const string DeltaArchiveFileName = "update.zip"; + public UpdateWorkflowService(ISettingsFacadeService settingsFacade) { _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); @@ -56,6 +65,175 @@ public sealed class UpdateWorkflowService "Updates"); } + /// + /// Gets the path to the Launcher's incoming update directory where delta packages should be placed. + /// + public static string GetLauncherIncomingDirectory() + { + // The app runs from app-{version}/ subdirectory; Launcher root is one level up. + var appBaseDir = AppContext.BaseDirectory; + var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (string.IsNullOrWhiteSpace(launcherRoot)) + { + launcherRoot = appBaseDir; + } + return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName); + } + + /// + /// Checks whether a GitHub Release contains delta update assets (files.json, files.json.sig, update.zip). + /// + public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release) + { + if (release is null || release.Assets is null || release.Assets.Count == 0) + { + return false; + } + + var assetNames = release.Assets.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + return assetNames.Contains(DeltaManifestFileName) + && assetNames.Contains(DeltaSignatureFileName) + && assetNames.Contains(DeltaArchiveFileName); + } + + /// + /// Downloads the delta update package (files.json, files.json.sig, update.zip) from a GitHub Release + /// and places them in the Launcher's incoming directory for the Launcher to apply on next startup. + /// + public async Task DownloadDeltaUpdateAsync( + UpdateCheckResult checkResult, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(checkResult); + + if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null) + { + return new UpdateDownloadResult(false, null, "No update available for delta download."); + } + + if (!IsDeltaUpdateAvailable(checkResult.Release)) + { + return new UpdateDownloadResult(false, null, "Release does not contain delta update assets."); + } + + var incomingDir = GetLauncherIncomingDirectory(); + + try + { + Directory.CreateDirectory(incomingDir); + } + catch (Exception ex) + { + return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}"); + } + + var state = _settingsFacade.Update.Get(); + var downloadSource = state.UpdateDownloadSource; + var downloadThreads = state.UpdateDownloadThreads; + + var requiredAssets = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [DeltaManifestFileName] = null!, + [DeltaSignatureFileName] = null!, + [DeltaArchiveFileName] = null! + }; + + foreach (var asset in checkResult.Release.Assets) + { + if (requiredAssets.ContainsKey(asset.Name)) + { + requiredAssets[asset.Name] = asset; + } + } + + if (requiredAssets.Any(kvp => kvp.Value is null)) + { + return new UpdateDownloadResult(false, null, "One or more delta assets not found in release."); + } + + var totalAssets = requiredAssets.Count; + var completedAssets = 0; + + foreach (var (name, asset) in requiredAssets) + { + var destinationPath = Path.Combine(incomingDir, name); + + // Skip if already downloaded and file exists + if (File.Exists(destinationPath)) + { + var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken); + if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase)) + { + AppLogger.Info("UpdateWorkflow", $"Delta asset {name} already downloaded with matching hash, skipping."); + completedAssets++; + progress?.Report((double)completedAssets / totalAssets); + continue; + } + } + + var assetProgress = progress is null ? null : new Progress(p => + { + var overallProgress = ((double)completedAssets + p) / totalAssets; + progress.Report(overallProgress); + }); + + var result = await _settingsFacade.Update.DownloadAssetAsync( + asset, + destinationPath, + downloadSource, + downloadThreads, + assetProgress, + cancellationToken); + + if (!result.Success) + { + // Clean up partially downloaded files + foreach (var file in requiredAssets.Keys) + { + try { File.Delete(Path.Combine(incomingDir, file)); } catch { } + } + return new UpdateDownloadResult(false, null, $"Failed to download delta asset {name}: {result.ErrorMessage}"); + } + + completedAssets++; + progress?.Report((double)completedAssets / totalAssets); + } + + // Save state indicating a delta update is pending + SaveState(state with + { + PendingUpdateInstallerPath = Path.Combine(incomingDir, DeltaManifestFileName), + PendingUpdateVersion = checkResult.LatestVersionText, + PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue + ? null + : checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(), + LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + PendingUpdateSha256 = null + }); + + AppLogger.Info("UpdateWorkflow", $"Delta update package downloaded to {incomingDir}. Will be applied by Launcher on next startup."); + + return new UpdateDownloadResult(true, Path.Combine(incomingDir, DeltaManifestFileName), null); + } + + /// + /// Checks whether the pending update is a delta update (files.json in incoming dir) vs a full installer. + /// + public bool IsPendingDeltaUpdate() + { + var state = _settingsFacade.Update.Get(); + var pendingPath = state.PendingUpdateInstallerPath?.Trim(); + if (string.IsNullOrWhiteSpace(pendingPath)) + { + return false; + } + + // Delta updates are identified by the manifest file path + return pendingPath.EndsWith(DeltaManifestFileName, StringComparison.OrdinalIgnoreCase) + || pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase); + } + public UpdatePendingInfo? GetPendingUpdate() { var state = _settingsFacade.Update.Get(); @@ -261,7 +439,7 @@ public sealed class UpdateWorkflowService { // Always check for updates on startup (removed AutoCheckUpdates check) var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken); - if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null) + if (!result.Success || !result.IsUpdateAvailable || result.Release is null) { return; } @@ -272,7 +450,16 @@ public sealed class UpdateWorkflowService if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) || string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase)) { - await DownloadReleaseAsync(result, cancellationToken: cancellationToken); + // Prefer delta update if available (smaller download, faster) + if (IsDeltaUpdateAvailable(result.Release)) + { + AppLogger.Info("UpdateWorkflow", "Delta update available, downloading incremental package."); + await DownloadDeltaUpdateAsync(result, cancellationToken: cancellationToken); + } + else if (result.PreferredAsset is not null) + { + await DownloadReleaseAsync(result, cancellationToken: cancellationToken); + } } // For "Manual" mode, just check but don't download } @@ -302,6 +489,15 @@ public sealed class UpdateWorkflowService return false; } + // For delta updates, the files are already in .launcher/update/incoming/. + // Just exit the app - the Launcher will detect and apply the update on next startup. + if (IsPendingDeltaUpdate()) + { + AppLogger.Info("UpdateWorkflow", "Delta update pending in incoming directory. Exiting to let Launcher apply on next startup."); + ClearPendingUpdate(); + return true; + } + var result = LaunchPendingInstaller(silent: true, exitApplicationAfterLaunch: false); if (!result.Success && !string.IsNullOrWhiteSpace(result.ErrorMessage)) { diff --git a/scripts/update-private-key.pem b/scripts/update-private-key.pem new file mode 100644 index 0000000..5d6287a --- /dev/null +++ b/scripts/update-private-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGx +bjZTB+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS +17YI90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uza +y3gomsbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNi +zr0lYcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM +5iUa20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQABAoIBAE9CETlTJz7S+txc +u4GB9y5dGKlBUijgE1RpFeNV8zOK5pW//ka8cRhju5VoMfn+cnMto/PSbqnOjUyG +mM4ig9msvVVyyns1djJbdIw5VbIBhfTdHwfQ5TasM/nSrTtlGX+ya1Pr9ZOGVCtD +rdDG10vH8PhMo6l2VbpRjPTc7qi6qv22UBnmhfTxlqusuunIAmDPwimj2+J5+NX5 +yH9xJamHNglPnNPujNh1IcPovSnm9MJ+JtSIztPSmdQ43SI+NOa2dNN4iQFHULO9 +LtZvbGJxmexkbjo0SWkvQ2Iut4gRaBpH19a9HnhG3CExji/XjLEqVcQZ0uzoHSQn +3fStFjkCgYEA3oQCdgnzTFimDT8GTsxqBEDVQiLHBjcOPplmghBJyULb/XHIOvcp ++fSmxeT4mQE0N/AsTnlBnYhIx5ZVh8/wljmXllHt0uVRWF4BLoSGnA2wzhk0Jrgo +a2N9PzR8bjMA31zRKy2+TwSOSKnR+Yn5zhpa3qwQ7RN70j/GeMmndo8CgYEA4p7d +uVlxch2/LhyzyV2HMAY1rJWOJ37B9Ut2oGK0LWfgr88J2O3yJ3rhcQBx41aI5OIC +sq8mLyG0GuQpGe0s5xgUnZSpvoPjKElwHQM4sLPLs8isQdrv97XfeswhPOKHHVRz +bfiU4MtfwXnGfi5CT7muJcELXDrDhEX2UcPnkgMCgYBFOQQa/JV31swxqr2nnegN +Uq4FWRRZVp9T0h0VsUODHQ2bFt6XmXSxke6f+c9sqfc4v7rI3ugOvesGTDpnecT6 +twf1d59o0HYx62yqsAfAXHH4a9bRhNDuN5ErLITZM3y9//4CVMSziFNLP6lW3Bme +iIxkYVsSpdELY1O3F+TE+QKBgB+spMDrR3fzwGzphhd3AxYrSAU/QgczKFjom0P/ +h79w7W6lOXMgjuAFxMzOixyDU87p6AahhGzCATJhAX2mMMh8DSWZScBfHrjaytjD +QoEwICCYw7rQpwmwWfQH4/1mjAwFabzNKcHhqxiXtK6eOJZ8FWMhgDz72af7P1pe +T1eRAoGBANJpd6mSlq5cXgyWUqdFQ/0Zf/Y2Yh0fNzm+pNi6F4LVW20mp9Zqh46P ++BN5UvdNgZ4DbNQVjTLWVQU24/wyOkLLKaR7E/Ozd/L7zQmm+28bGQO/x3s+EvZD ++BIighRvesjIbXff9rjWKUsRzeCTS2x1tqQP6J7IKrlgKMV2zEYM +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/tools/LanMountainDesktop.Launcher/Assets/public-key.pem b/tools/LanMountainDesktop.Launcher/Assets/public-key.pem new file mode 100644 index 0000000..1b6db09 --- /dev/null +++ b/tools/LanMountainDesktop.Launcher/Assets/public-key.pem @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAuUj9uZoZqp0sMFx0fmymnRxpSXn9OLAmgfcORrUpqFDHlVsA3+9Z +SyIxss8SAdEpiZ+Qjs5VWCVxUxEkzzYy6mZdWXdQ7cwJrPcO8HRrr0vuCoinoMyB +12f0LUToYZp0VJ51pp+/9R+zfG7pfyn0224SvKH9KYI1G38yE4qxZF218sFeqlgM +hJtk2Y+pMO4dogq7oZXo/DNHqSbTlVoLhHWbP84qhJgtasJINPEPMEmsWd7FiD9H +b81nzibFprbzLxEjP0iktJtl6rJHqBBhS4WSO+uS+G2lNmzGxAzddFBpXcEoKKls +cK9PGU3Kop0yHCWaNU1bcPuAgmqMDUz3/QIDAQAB +-----END RSA PUBLIC KEY----- \ No newline at end of file diff --git a/tools/scripts/update-private-key.pem b/tools/scripts/update-private-key.pem new file mode 100644 index 0000000..f92b1fc --- /dev/null +++ b/tools/scripts/update-private-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAuUj9uZoZqp0sMFx0fmymnRxpSXn9OLAmgfcORrUpqFDHlVsA +3+9ZSyIxss8SAdEpiZ+Qjs5VWCVxUxEkzzYy6mZdWXdQ7cwJrPcO8HRrr0vuCoin +oMyB12f0LUToYZp0VJ51pp+/9R+zfG7pfyn0224SvKH9KYI1G38yE4qxZF218sFe +qlgMhJtk2Y+pMO4dogq7oZXo/DNHqSbTlVoLhHWbP84qhJgtasJINPEPMEmsWd7F +iD9Hb81nzibFprbzLxEjP0iktJtl6rJHqBBhS4WSO+uS+G2lNmzGxAzddFBpXcEo +KKlscK9PGU3Kop0yHCWaNU1bcPuAgmqMDUz3/QIDAQABAoIBAQCytvmsRTwOee1+ +dB8VNl1620WezqB1RkrOPusxPlqA8/GeWRm95ZJ+Suwe6WYYBJSJHzSC2fgtvmfR +VI7powB3YOcXfWO9CnomsGJjghfADH/8/xSYn8l5aNZ3t6hhRGaCnBkk759qovov +wpdLxb9cy44dDi4vFF1/OS+m87bo82eRHsJepdr+HXMXc9/iEn2q0JHH/eGfw7Is +vVS27w/Z94bTfR78/QcpGqRwyXXcjqQU1wJ/7DHefim16+6wtdLXTOx70RW984Vt +QdY0g6lTU1NpB8K4R9/Reaz/5fIUx6C7bFMEljBcBxr9OEDruTDtphIeh4YU+0o8 +o5v2FtGNAoGBAMGFBcY2Afn2zTyf+ZwiiIV3bNmM0GFKfVhE7OOEnGq6su1/ZamQ +vsNILL2jhaQ0MrKizvd551TnmbNDU9ipOOAw4VWnjMWXEyGSlYbwViqjSdAD0CDL +NUJrULS4Hj4VCqzauSm9Bs7WcuRW9Px8Jpm0DTmoc9WjD+Rf5Y8uYE6vAoGBAPUb +XNL9FLKw9K2QpAmjb6KJadpaErmPkMY/calB82TjLfVw60DtxkZOVLSxPDgKzwlI +3L1WtvbylP0cPMwUNKpQLQabWiKtkxMI45mjxkpoeILC6+gKtqugrIEy+wLO2YDe +x3qOJpxpNQ3usPPg1rHHfgg4vJ7ubCt/f9zC2y8TAoGBAKxTwKiZP3lQhcMO0kBv +oBL6Hjw8YPPCWYxZFHomhQOl7eAAKo+tDbLoeq8FBuUKdnsM8DEApTe+Zeh0dB3j +03oRDRgxc/IgbjDfT7gyHQkrD3flbVlGm87hsaS8sHGoWzFCNNEuOvnFjdo4dUDB +bb5Bz+UgVMZRxr0fiFTQf4KRAoGARasVY1NUQsZRhdQLDEJMROLSF6JqmBvahr8Z +y4ZXbGG2eoEyHS54oRs6sHGAMF3CI112gMrZDrA88QTJsyg7H/3SDoKxyBGWMF7i +cpU+k3/GYUSOUVJaQcZVwhN/jXjGEf9Aq/EjwGmXDvK9kVRjMf0GMcgOtQ4H6QVA +jrtEGckCgYBB03bnBQsaAxcARMJQCQBlRHFoO3FMs/zKfQCJoAD/nA6JS396TfkI +G9FnXAHA6kDfMPWRGn2tNjsHLbJQSd93Wl7VgH+KRcsgC/vWf+sgNid7ig+Knvx9 +NTfdOD68+NcbvFWEgpDq7PWwyVX9Qc8lsmZhpv9urXvgo3Ucu3KizQ== +-----END RSA PRIVATE KEY----- \ No newline at end of file