This commit is contained in:
lincube
2026-04-16 14:17:46 +08:00
parent 2f0c178df2
commit 1aaf6cd0e9
21 changed files with 1856 additions and 611 deletions

View File

@@ -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

View File

@@ -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:

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -4,5 +4,6 @@
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
</Project>

View File

@@ -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-----

View File

@@ -19,6 +19,30 @@
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106" PrivateAssets="all" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
</ItemGroup>
<!-- Embed public-key.pem and copy to .launcher/update/ in output directory -->
<ItemGroup>
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
<PropertyGroup>
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublicKeyDestDir)" />
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
</Target>
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
<PropertyGroup>
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublishedKeyDestDir)" />
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -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."),

View File

@@ -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));
}
}
}

View File

@@ -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<IOobeStep> _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;
}
}
}

View File

@@ -110,7 +110,7 @@ internal sealed class UpdateEngineService
};
}
public LauncherResult ApplyPendingUpdate()
public async Task<LauncherResult> 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<SignedFileMap>(fileMapText);
if (fileMap is null || fileMap.Files.Count == 0)
{

View File

@@ -3,15 +3,38 @@
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
Title="阑山桌面"
Width="420"
Height="220"
Height="240"
CanResize="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="None">
<Grid Margin="24">
<Grid Margin="24" RowDefinitions="*,Auto,Auto,Auto">
<TextBlock x:Name="AppNameText"
Text="阑山桌面"
FontSize="34"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
HorizontalAlignment="Center"
Grid.Row="0" />
<ProgressBar x:Name="ProgressIndicator"
Grid.Row="1"
Minimum="0"
Maximum="100"
Value="0"
Height="4"
Margin="0,12,0,0"
IsIndeterminate="True" />
<TextBlock x:Name="StageText"
Grid.Row="2"
FontSize="12"
Foreground="#999999"
HorizontalAlignment="Center"
Margin="0,8,0,0"
Text="" />
<TextBlock x:Name="DetailText"
Grid.Row="3"
FontSize="11"
Foreground="#BBBBBB"
HorizontalAlignment="Center"
Margin="0,2,0,0"
Text="" />
</Grid>
</Window>

View File

@@ -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<TextBlock>("StageText");
var detailText = this.GetControl<TextBlock>("DetailText");
var progressIndicator = this.GetControl<ProgressBar>("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);
}
}

View File

@@ -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 '<IsTestProject>\s*true\s*</IsTestProject>|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

View File

@@ -76,20 +76,37 @@
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.22.0" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
<PackageReference Include="log4net" Version="3.3.0" />
</ItemGroup>
<Target Name="CopyLauncherToOutput" AfterTargets="Build">
<PropertyGroup>
<_LauncherOutputPath>..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\</_LauncherOutputPath>
</PropertyGroup>
<ItemGroup>
<LauncherFiles Include="..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\**\*.*" />
<LauncherFiles Include="$(_LauncherOutputPath)**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(LauncherFiles)" DestinationFiles="@(LauncherFiles->'$(OutDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(LauncherFiles)" DestinationFiles="@(LauncherFiles->'$(OutDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" Condition="Exists('$(_LauncherOutputPath)')" />
</Target>
<Target Name="PublishLauncher" BeforeTargets="CopyLauncherToPublish" Condition="'$(PublishDir)' != '' and '$(RuntimeIdentifier)' != ''">
<PropertyGroup>
<_LauncherPublishPath>..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\</_LauncherPublishPath>
</PropertyGroup>
<MSBuild Projects="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj"
Targets="Publish"
Properties="Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=$(SelfContained);PublishDir=$(_LauncherPublishPath);PublishSingleFile=false;PublishTrimmed=false;PublishReadyToRun=false;DebugType=none;DebugSymbols=false" />
</Target>
<Target Name="CopyLauncherToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
<PropertyGroup>
<_LauncherPublishSource Condition="'$(RuntimeIdentifier)' != '' and Exists('..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\')">..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\</_LauncherPublishSource>
<_LauncherPublishSource Condition="'$(_LauncherPublishSource)' == ''">..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\</_LauncherPublishSource>
</PropertyGroup>
<ItemGroup>
<LauncherPublishFiles Include="..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\**\*.*" />
<LauncherPublishFiles Include="$(_LauncherPublishSource)**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(LauncherPublishFiles)" DestinationFiles="@(LauncherPublishFiles->'$(PublishDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(LauncherPublishFiles)" DestinationFiles="@(LauncherPublishFiles->'$(PublishDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" Condition="Exists('$(_LauncherPublishSource)')" />
</Target>
</Project>

View File

@@ -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<TelemetryEvent> _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<string, object?>
{
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<string, object?>
{
["api_key"] = PostHogApiKey,
["event"] = "app_first_launch",
["distinct_id"] = installId,
["timestamp"] = timestamp.ToString("o"),
["properties"] = new Dictionary<string, object?>
{
["install_id"] = installId,
["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(),
["launch_time_utc"] = timestamp.ToString("o")
}
["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<string, object?>(),
stateBefore,
stateAfter);
var identity = TelemetryIdentityService.Instance;
var distinctId = identity.TelemetryId;
var seq = Interlocked.Increment(ref _sequence);
lock (_queueLock)
var properties = new Dictionary<string, object?>(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<TelemetryEvent> eventsToSend;
lock (_queueLock)
{
if (_eventQueue.Count == 0)
{
return;
}
eventsToSend = new List<TelemetryEvent>();
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<string, object?>
{
["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();
}
}

View File

@@ -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<string, object?> Payload,
IReadOnlyDictionary<string, object?>? StateBefore = null,
IReadOnlyDictionary<string, object?>? StateAfter = null)
{
public Dictionary<string, object?> ToPostHogProperties()
{
var properties = new Dictionary<string, object?>(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<string, object?> Copy(IReadOnlyDictionary<string, object?> source)
{
return source.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -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");
}
/// <summary>
/// Gets the path to the Launcher's incoming update directory where delta packages should be placed.
/// </summary>
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);
}
/// <summary>
/// Checks whether a GitHub Release contains delta update assets (files.json, files.json.sig, update.zip).
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
UpdateCheckResult checkResult,
IProgress<double>? 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<string, GitHubReleaseAsset>(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<double>(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);
}
/// <summary>
/// Checks whether the pending update is a delta update (files.json in incoming dir) vs a full installer.
/// </summary>
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;
}
@@ -271,9 +449,18 @@ public sealed class UpdateWorkflowService
// For "Silent Download" and "Silent Install" modes, automatically download the update
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
{
// 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
}
catch (OperationCanceledException)
@@ -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))
{

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -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-----