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