feat.在线安装器,更好的Issue与pull request模板。

This commit is contained in:
lincube
2026-06-03 00:50:52 +08:00
parent 29bd47986c
commit 28b06031f7
38 changed files with 2976 additions and 123 deletions

View File

@@ -0,0 +1,59 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanDesktopPLONDS.Installer.App"
RequestedThemeVariant="Default">
<Application.Resources>
<FontFamily x:Key="AppFontFamily">Inter, Segoe UI, Microsoft YaHei UI</FontFamily>
<CornerRadius x:Key="DesignCornerRadiusMicro">2</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusSm">6</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusMd">8</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusLg">10</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXl">12</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusComponent">12</CornerRadius>
<SolidColorBrush x:Key="InstallerWindowBackgroundBrush" Color="#F7F9FC" />
<SolidColorBrush x:Key="InstallerTintBrush" Color="#DDF8FAFF" />
<SolidColorBrush x:Key="InstallerSurfaceBrush" Color="#F9FFFFFF" />
<SolidColorBrush x:Key="InstallerBorderBrush" Color="#22000000" />
<SolidColorBrush x:Key="InstallerSecondaryTextBrush" Color="#A0000000" />
</Application.Resources>
<Application.Styles>
<sty:FluentAvaloniaTheme />
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="UserControl">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="fi|FluentIcon">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="Button.titlebar-icon-button">
<Setter Property="Width" Value="40" />
<Setter Property="Height" Value="40" />
<Setter Property="MinWidth" Value="40" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
</Style>
<Style Selector="StackPanel.installer-page-container">
<Setter Property="Spacing" Value="18" />
<Setter Property="Margin" Value="0,20,0,24" />
<Setter Property="MaxWidth" Value="860" />
</Style>
<Style Selector="TextBlock.page-title-text">
<Setter Property="FontSize" Value="28" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="TextBlock.page-description-text">
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="{DynamicResource InstallerSecondaryTextBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
</Application.Styles>
</Application>

View File

@@ -0,0 +1,33 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using LanDesktopPLONDS.Installer.Services;
using LanDesktopPLONDS.Installer.ViewModels;
using LanDesktopPLONDS.Installer.Views;
using LanMountainDesktop.Shared.Contracts.Privacy;
namespace LanDesktopPLONDS.Installer;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var privacyIdentity = new PrivacyDeviceIdentityProvider();
var installService = OnlineInstallService.CreateDefault(privacyIdentity);
var consentStore = new InstallerPrivacyConsentStore();
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(installService, privacyIdentity, consentStore)
};
}
base.OnFrameworkInitializationCompleted();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,34 @@
<Project>
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<OptimizationPreference>Size</OptimizationPreference>
<PublishReadyToRun>false</PublishReadyToRun>
</PropertyGroup>
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup Condition="'$(PublishAot)' == 'true'">
<TrimmerRootAssembly Include="Avalonia" />
<TrimmerRootAssembly Include="Avalonia.Desktop" />
<TrimmerRootAssembly Include="FluentAvalonia" />
<TrimmerRootAssembly Include="FluentIcons.Avalonia" />
<TrimmerRootAssembly Include="LanDesktopPLONDS.installer" />
<TrimmerRootAssembly Include="System.Text.Json" />
</ItemGroup>
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<Import Project="LanDesktopPLONDS.installer.AOT.props" Condition="Exists('LanDesktopPLONDS.installer.AOT.props')" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.0.0-dev</Version>
<PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>Assets\logo.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="FluentAvaloniaUI" />
<PackageReference Include="FluentIcons.Avalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\logo.ico" />
<AvaloniaResource Include="..\LanMountainDesktop\Assets\logo_nightly.png" Link="Assets\logo_nightly.png" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace LanDesktopPLONDS.Installer.Models;
public sealed record InstallerDeployProgress(
string Stage,
string? TargetVersion,
double DownloadProgress,
double InstallProgress,
string? CurrentFile,
long BytesDownloaded,
long? TotalBytes);

View File

@@ -0,0 +1,10 @@
namespace LanDesktopPLONDS.Installer.Models;
public enum InstallerStepId
{
Welcome = 0,
InstallLocation = 1,
PrivacyConfirm = 2,
Deploy = 3,
Complete = 4
}

View File

@@ -0,0 +1,9 @@
namespace LanDesktopPLONDS.Installer.Models;
public sealed record InstallerWorkflowState(
InstallerStepId CurrentStep,
InstallerStepId MaxUnlockedStep,
string InstallPath,
bool PrivacyConfirmed,
string? TargetVersion,
string? ErrorMessage);

View File

@@ -0,0 +1,20 @@
using Avalonia;
namespace LanDesktopPLONDS.Installer;
public static class Program
{
[STAThread]
public static void Main(string[] args)
{
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]

View File

@@ -0,0 +1,344 @@
using System.Diagnostics;
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.Services;
internal sealed class FilesPackageInstaller
{
public async Task InstallAsync(
PreparedFilesPackage package,
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
await InstallAsync(package, installPath, OnlineInstallOptions.Default, progress, cancellationToken)
.ConfigureAwait(false);
}
public async Task InstallAsync(
PreparedFilesPackage package,
string installPath,
OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(package);
var launcherRoot = InstallerPathGuard.NormalizeInstallPath(installPath);
var sourceAppDirectory = ResolveFullPackageAppDirectory(package.ExtractDirectory, package.Version);
var targetDeployment = BuildDeploymentDirectory(launcherRoot, package.Version);
InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, EstimateRequiredBytes(sourceAppDirectory));
Directory.CreateDirectory(launcherRoot);
await CopyLauncherRootPayloadAsync(package.ExtractDirectory, sourceAppDirectory, launcherRoot, package.Version, progress, cancellationToken)
.ConfigureAwait(false);
progress?.Report(new InstallerDeployProgress(
"Creating deployment",
package.Version,
1,
0.15,
null,
0,
null));
PrepareTargetDirectory(targetDeployment);
await CopyDirectoryAsync(sourceAppDirectory, targetDeployment, package.Version, progress, cancellationToken)
.ConfigureAwait(false);
progress?.Report(new InstallerDeployProgress(
"Activating deployment",
package.Version,
1,
0.92,
null,
0,
null));
ActivateInitialDeployment(launcherRoot, targetDeployment);
CreateWindowsShortcutsIfAvailable(launcherRoot, options.CreateDesktopShortcut);
progress?.Report(new InstallerDeployProgress(
"Completed",
package.Version,
1,
1,
null,
0,
null));
}
public static string BuildDeploymentDirectory(string launcherRoot, string version)
{
var sanitized = string.IsNullOrWhiteSpace(version) ? "0.0.0" : version.Trim();
var index = 0;
while (true)
{
var candidate = Path.Combine(launcherRoot, $"app-{sanitized}-{index}");
if (!Directory.Exists(candidate))
{
return candidate;
}
index++;
}
}
public static string ResolveFullPackageAppDirectory(string filesDirectory, string version)
{
var root = Path.GetFullPath(filesDirectory);
if (!Directory.Exists(root))
{
throw new DirectoryNotFoundException($"PLONDS Files package directory is missing: {root}");
}
var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var directExecutable = Path.Combine(root, executableName);
if (File.Exists(directExecutable))
{
return root;
}
var versionDirectory = Directory
.EnumerateDirectories(root, $"app-{version}*", SearchOption.TopDirectoryOnly)
.FirstOrDefault(path => File.Exists(Path.Combine(path, executableName)));
if (!string.IsNullOrWhiteSpace(versionDirectory))
{
return versionDirectory;
}
var nested = Directory
.EnumerateDirectories(root, "*", SearchOption.AllDirectories)
.FirstOrDefault(path => File.Exists(Path.Combine(path, executableName)));
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
throw new FileNotFoundException($"PLONDS Files package does not contain {executableName}.");
}
private static void PrepareTargetDirectory(string targetDeployment)
{
if (Directory.Exists(targetDeployment))
{
Directory.Delete(targetDeployment, recursive: true);
}
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
}
private static async Task CopyDirectoryAsync(
string sourceDirectory,
string targetDirectory,
string version,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
var sourceFiles = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories).ToArray();
var total = Math.Max(1, sourceFiles.Length);
for (var index = 0; index < sourceFiles.Length; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var sourcePath = sourceFiles[index];
var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(sourceDirectory, sourcePath));
if (IsDeploymentMarker(relativePath))
{
continue;
}
var targetPath = Path.GetFullPath(Path.Combine(targetDirectory, relativePath));
InstallerPathGuard.EnsureChildPath(targetDirectory, targetPath);
var targetParent = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetParent))
{
Directory.CreateDirectory(targetParent);
}
await using (var source = File.OpenRead(sourcePath))
await using (var target = File.Create(targetPath))
{
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
}
progress?.Report(new InstallerDeployProgress(
"Copying files",
version,
1,
0.18 + ((index + 1) * 0.70 / total),
relativePath,
index + 1,
total));
}
}
private static async Task CopyLauncherRootPayloadAsync(
string packageRoot,
string sourceAppDirectory,
string launcherRoot,
string version,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
var resolvedPackageRoot = Path.GetFullPath(packageRoot);
var resolvedAppDirectory = Path.GetFullPath(sourceAppDirectory);
if (string.Equals(
resolvedPackageRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
resolvedAppDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase))
{
return;
}
var files = Directory
.EnumerateFiles(resolvedPackageRoot, "*", SearchOption.AllDirectories)
.Where(path => !InstallerPathGuard.IsSameOrChildPath(resolvedAppDirectory, path))
.Where(path =>
{
var relative = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, path));
return !relative.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
})
.ToArray();
var total = Math.Max(1, files.Length);
for (var index = 0; index < files.Length; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var sourcePath = files[index];
var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, sourcePath));
if (IsDeploymentMarker(relativePath))
{
continue;
}
var targetPath = Path.GetFullPath(Path.Combine(launcherRoot, relativePath));
InstallerPathGuard.EnsureChildPath(launcherRoot, targetPath);
var targetParent = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetParent))
{
Directory.CreateDirectory(targetParent);
}
await using (var source = File.OpenRead(sourcePath))
await using (var target = File.Create(targetPath))
{
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
}
progress?.Report(new InstallerDeployProgress(
"Copying launcher files",
version,
1,
0.10 + ((index + 1) * 0.05 / total),
relativePath,
index + 1,
total));
}
}
private static void ActivateInitialDeployment(string launcherRoot, string targetDeployment)
{
foreach (var existingCurrent in Directory.EnumerateFiles(launcherRoot, ".current", SearchOption.AllDirectories))
{
try
{
File.Delete(existingCurrent);
}
catch
{
}
}
var partialMarker = Path.Combine(targetDeployment, ".partial");
if (File.Exists(partialMarker))
{
File.Delete(partialMarker);
}
File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty);
Directory.CreateDirectory(Path.Combine(launcherRoot, ".Launcher"));
}
private static long EstimateRequiredBytes(string sourceDirectory)
{
return Directory
.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)
.Sum(path => new FileInfo(path).Length);
}
private static bool IsDeploymentMarker(string relativePath)
{
var name = Path.GetFileName(relativePath);
return name is ".current" or ".partial" or ".destroy";
}
private static void CreateWindowsShortcutsIfAvailable(string launcherRoot, bool createDesktopShortcut)
{
try
{
if (!OperatingSystem.IsWindows())
{
return;
}
var launcherPath = Path.Combine(launcherRoot, "LanMountainDesktop.Launcher.exe");
if (!File.Exists(launcherPath))
{
var deployedLauncher = Directory
.EnumerateFiles(launcherRoot, "LanMountainDesktop.Launcher.exe", SearchOption.AllDirectories)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(deployedLauncher))
{
File.Copy(deployedLauncher, launcherPath, overwrite: true);
}
}
if (!File.Exists(launcherPath))
{
return;
}
var startMenu = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu);
if (string.IsNullOrWhiteSpace(startMenu))
{
startMenu = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
}
if (string.IsNullOrWhiteSpace(startMenu))
{
return;
}
var programs = Path.Combine(startMenu, "Programs");
Directory.CreateDirectory(programs);
var shortcutPath = Path.Combine(programs, "LanMountainDesktop.url");
WriteUrlShortcut(shortcutPath, launcherPath);
if (!createDesktopShortcut)
{
return;
}
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
if (string.IsNullOrWhiteSpace(desktop))
{
return;
}
Directory.CreateDirectory(desktop);
WriteUrlShortcut(Path.Combine(desktop, "LanMountainDesktop.url"), launcherPath);
}
catch
{
// Shortcut creation is best-effort; deployment itself must remain usable without shell integration.
}
}
private static void WriteUrlShortcut(string shortcutPath, string targetPath)
{
File.WriteAllText(
shortcutPath,
$"[InternetShortcut]{Environment.NewLine}URL=file:///{targetPath.Replace('\\', '/')}{Environment.NewLine}");
}
}

View File

@@ -0,0 +1,29 @@
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.Services;
public interface IOnlineInstallService
{
Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken);
Task InstallFreshAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
Task InstallFreshAsync(
string installPath,
OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
Task RepairAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
Task UpdateIncrementalAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,6 @@
using System.Text.Json.Serialization;
namespace LanDesktopPLONDS.Installer.Services;
[JsonSerializable(typeof(InstallerPlondsManifest))]
internal sealed partial class InstallerJsonContext : JsonSerializerContext;

View File

@@ -0,0 +1,129 @@
namespace LanDesktopPLONDS.Installer.Services;
public static class InstallerPathGuard
{
public static string GetDefaultInstallPath()
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (string.IsNullOrWhiteSpace(programFiles))
{
programFiles = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Programs");
}
return Path.Combine(programFiles, "LanMountainDesktop");
}
public static string NormalizeInstallPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Installation path is required.", nameof(path));
}
var fullPath = Path.GetFullPath(path.Trim());
ValidateInstallPath(fullPath);
return fullPath;
}
public static void ValidateInstallPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException("Installation path is required.");
}
var fullPath = Path.GetFullPath(path);
var root = Path.GetPathRoot(fullPath);
if (string.Equals(
fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
root?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Choose a folder instead of a drive root.");
}
var blockedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Windows",
"System32",
"SysWOW64",
"Program Files",
"Program Files (x86)",
"Users"
};
var name = Path.GetFileName(fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (blockedNames.Contains(name))
{
throw new InvalidOperationException("Choose a dedicated application folder.");
}
}
public static void EnsureUsableInstallPath(string path, long requiredBytes)
{
var fullPath = NormalizeInstallPath(path);
var directory = Directory.Exists(fullPath)
? new DirectoryInfo(fullPath)
: Directory.CreateDirectory(fullPath);
var testPath = Path.Combine(directory.FullName, $".write-test-{Guid.NewGuid():N}.tmp");
try
{
File.WriteAllText(testPath, string.Empty);
}
finally
{
if (File.Exists(testPath))
{
File.Delete(testPath);
}
}
var drive = new DriveInfo(directory.Root.FullName);
if (drive.AvailableFreeSpace > 0 && drive.AvailableFreeSpace < requiredBytes)
{
throw new InvalidOperationException("The selected drive does not have enough free space.");
}
}
public static void EnsureChildPath(string parent, string child)
{
if (!IsSameOrChildPath(parent, child))
{
throw new InvalidDataException($"Path escapes the expected root: {child}");
}
}
public static bool IsSameOrChildPath(string parent, string child)
{
var resolvedParent = Path.GetFullPath(parent)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var resolvedChild = Path.GetFullPath(child);
return string.Equals(
resolvedParent,
resolvedChild.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase)
|| resolvedChild.StartsWith(resolvedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| resolvedChild.StartsWith(resolvedParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}
public static string NormalizeRelativePath(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new InvalidDataException("Package entry path is empty.");
}
var normalized = relativePath
.Replace('\\', Path.DirectorySeparatorChar)
.Replace('/', Path.DirectorySeparatorChar)
.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (Path.IsPathRooted(normalized) || normalized.Split(Path.DirectorySeparatorChar).Contains(".."))
{
throw new InvalidDataException($"Package entry path is invalid: {relativePath}");
}
return normalized;
}
}

View File

@@ -0,0 +1,357 @@
using System.Globalization;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.Services;
internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagingRoot)
{
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL";
private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/plonds/PLONDS.json";
private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static IReadOnlyList<InstallerPlondsSource> CreateBuiltInSources()
{
return
[
new("s3", "s3", ResolveManifestUrl(S3ManifestUrlEnvironmentVariable, DefaultS3ManifestUrl), 100),
new("github", "github", ResolveManifestUrl(GitHubManifestUrlEnvironmentVariable, DefaultGitHubManifestUrl), 50)
];
}
public async Task<InstallerPlondsCandidate> FindLatestAsync(CancellationToken cancellationToken)
{
var sources = CreateBuiltInSources().ToList();
var candidates = new List<InstallerPlondsCandidate>();
for (var index = 0; index < sources.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var source = sources[index];
InstallerPlondsManifest? manifest;
try
{
manifest = await GetManifestAsync(source, cancellationToken).ConfigureAwait(false);
}
catch
{
continue;
}
if (manifest is null)
{
continue;
}
AddManifestSources(sources, manifest.Sources);
var filesUrl = InstallerPlondsUrlResolver.ResolveFilesZipUrls(manifest, source).FirstOrDefault();
if (filesUrl is null)
{
continue;
}
candidates.Add(new InstallerPlondsCandidate(source, manifest, filesUrl));
}
return candidates
.Where(candidate => TryParseVersion(candidate.Manifest.CurrentVersion, out _))
.OrderByDescending(candidate => ParseVersion(candidate.Manifest.CurrentVersion))
.ThenByDescending(candidate => candidate.Source.Priority)
.FirstOrDefault()
?? throw new InvalidOperationException("No usable PLONDS full package source was found.");
}
public async Task<PreparedFilesPackage> DownloadAndPrepareFullPackageAsync(
InstallerPlondsCandidate candidate,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
var version = ParseVersion(candidate.Manifest.CurrentVersion).ToString();
var packageRoot = Path.Combine(stagingRoot, SanitizePathSegment(version), SanitizePathSegment(candidate.Source.Id), "full");
if (Directory.Exists(packageRoot))
{
Directory.Delete(packageRoot, recursive: true);
}
Directory.CreateDirectory(packageRoot);
var zipPath = Path.Combine(packageRoot, "Files.zip");
var extractDirectory = Path.Combine(packageRoot, "Files");
Directory.CreateDirectory(extractDirectory);
await DownloadToFileAsync(candidate, zipPath, progress, cancellationToken).ConfigureAwait(false);
await VerifyPackageAsync(zipPath, candidate.Manifest, candidate.FilesZipUrl, cancellationToken).ConfigureAwait(false);
ExtractZip(zipPath, extractDirectory);
progress?.Report(new InstallerDeployProgress(
"Files package prepared",
version,
1,
0.10,
"Files.zip",
new FileInfo(zipPath).Length,
new FileInfo(zipPath).Length));
return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest);
}
public static long EstimateInstallBytes(InstallerPlondsManifest manifest)
{
var filesBytes = manifest.FilesMap?.Values.Sum(file => Math.Max(0, file.Size)) ?? 0;
var packageBytes = FindChecksumSizeHint(manifest.Checksums);
return Math.Max(filesBytes, packageBytes);
}
private async Task<InstallerPlondsManifest?> GetManifestAsync(
InstallerPlondsSource source,
CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync(source.ManifestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync(stream, InstallerJsonContext.Default.InstallerPlondsManifest, cancellationToken)
.ConfigureAwait(false);
}
private async Task DownloadToFileAsync(
InstallerPlondsCandidate candidate,
string destinationPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync(candidate.FilesZipUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength;
var partialPath = $"{destinationPath}.partial";
long downloaded = 0;
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
await using (var target = File.Create(partialPath))
{
var buffer = new byte[128 * 1024];
while (true)
{
var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
{
break;
}
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
downloaded += read;
var fraction = totalBytes is > 0 ? Math.Clamp((double)downloaded / totalBytes.Value, 0, 1) : 0;
progress?.Report(new InstallerDeployProgress(
"Downloading Files.zip",
candidate.Manifest.CurrentVersion,
fraction,
0,
"Files.zip",
downloaded,
totalBytes));
}
}
File.Move(partialPath, destinationPath, overwrite: true);
}
private static async Task VerifyPackageAsync(
string zipPath,
InstallerPlondsManifest manifest,
Uri filesZipUrl,
CancellationToken cancellationToken)
{
var checksum = FindChecksum(manifest.Checksums, GetChecksumKeys(filesZipUrl));
if (checksum is null)
{
throw new InvalidDataException("PLONDS manifest does not declare a checksum for Files.zip.");
}
var (algorithm, expectedHash) = ParseChecksum(checksum);
var actualHash = await ComputeHashAsync(zipPath, algorithm, cancellationToken).ConfigureAwait(false);
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"PLONDS Files.zip checksum mismatch. Expected {algorithm}:{expectedHash}, actual {algorithm}:{actualHash}.");
}
}
private static void ExtractZip(string zipPath, string destinationDirectory)
{
if (Directory.Exists(destinationDirectory))
{
Directory.Delete(destinationDirectory, recursive: true);
}
Directory.CreateDirectory(destinationDirectory);
using var archive = ZipFile.OpenRead(zipPath);
foreach (var entry in archive.Entries)
{
var normalizedName = InstallerPathGuard.NormalizeRelativePath(entry.FullName);
var destinationPath = Path.GetFullPath(Path.Combine(destinationDirectory, normalizedName));
InstallerPathGuard.EnsureChildPath(destinationDirectory, destinationPath);
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
var parent = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(parent))
{
Directory.CreateDirectory(parent);
}
entry.ExtractToFile(destinationPath, overwrite: true);
}
}
private static void AddManifestSources(List<InstallerPlondsSource> sources, IEnumerable<InstallerPlondsSource>? manifestSources)
{
if (manifestSources is null)
{
return;
}
foreach (var source in manifestSources)
{
if (string.IsNullOrWhiteSpace(source.Id) || string.IsNullOrWhiteSpace(source.ManifestUrl))
{
continue;
}
if (sources.Any(existing => string.Equals(existing.Id, source.Id, StringComparison.OrdinalIgnoreCase) ||
string.Equals(existing.ManifestUrl, source.ManifestUrl, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
sources.Add(source with
{
Id = source.Id.Trim(),
Kind = string.IsNullOrWhiteSpace(source.Kind) ? "http" : source.Kind.Trim(),
ManifestUrl = source.ManifestUrl.Trim()
});
}
}
private static IReadOnlyList<string> GetChecksumKeys(Uri url)
{
var urlFileName = Path.GetFileName(url.LocalPath);
return new[] { "Files.zip", "files.zip", "files-windows-x64.zip", urlFileName }
.Where(key => !string.IsNullOrWhiteSpace(key))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string? FindChecksum(IReadOnlyDictionary<string, string>? checksums, IEnumerable<string> keys)
{
if (checksums is null || checksums.Count == 0)
{
return null;
}
foreach (var key in keys)
{
if (checksums.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
var match = checksums.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match.Value))
{
return match.Value;
}
}
return null;
}
private static (string Algorithm, string Hash) ParseChecksum(string checksum)
{
var normalized = checksum.Trim();
var separatorIndex = normalized.IndexOf(':', StringComparison.Ordinal);
if (separatorIndex > 0)
{
var algorithm = normalized[..separatorIndex].Trim().ToLowerInvariant();
var hash = NormalizeHash(normalized[(separatorIndex + 1)..]);
if (algorithm is "md5" or "sha256" && hash.Length > 0)
{
return (algorithm, hash);
}
}
var inferred = NormalizeHash(normalized);
return inferred.Length switch
{
32 => ("md5", inferred),
64 => ("sha256", inferred),
_ => throw new InvalidDataException($"Unsupported PLONDS checksum format: {checksum}")
};
}
private static async Task<string> ComputeHashAsync(string filePath, string algorithm, CancellationToken cancellationToken)
{
using HashAlgorithm hasher = algorithm switch
{
"md5" => MD5.Create(),
"sha256" => SHA256.Create(),
_ => throw new InvalidDataException($"Unsupported PLONDS checksum algorithm: {algorithm}")
};
await using var stream = File.OpenRead(filePath);
var hash = await hasher.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static long FindChecksumSizeHint(IReadOnlyDictionary<string, string>? checksums)
{
_ = checksums;
return 0;
}
private static Version ParseVersion(string version)
{
var normalized = version.Trim().TrimStart('v', 'V');
return Version.Parse(normalized);
}
private static bool TryParseVersion(string version, out Version parsed)
{
return Version.TryParse(version.Trim().TrimStart('v', 'V'), out parsed!);
}
private static string NormalizeHash(string value)
{
return value.Trim().Replace(" ", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
}
private static string ResolveManifestUrl(string environmentVariable, string fallback)
{
var value = Environment.GetEnvironmentVariable(environmentVariable);
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
private static string SanitizePathSegment(string value)
{
var invalid = Path.GetInvalidFileNameChars();
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
var sanitized = new string(chars).Trim();
return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized;
}
}

View File

@@ -0,0 +1,51 @@
namespace LanDesktopPLONDS.Installer.Services;
internal static class InstallerPlondsUrlResolver
{
public static IReadOnlyList<Uri> ResolveFilesZipUrls(
InstallerPlondsManifest manifest,
InstallerPlondsSource source)
{
var urls = new List<string?>();
var sourceKind = source.Kind.Trim().ToLowerInvariant();
if (sourceKind is "s3")
{
urls.Add(manifest.Downloads?.S3?.FilesZipUrl);
}
else if (sourceKind is "github")
{
urls.Add(manifest.Downloads?.GitHub?.FilesZipUrl);
}
urls.Add(DerivePackageUrl(source.ManifestUrl));
urls.Add(manifest.Downloads?.S3?.FilesZipUrl);
urls.Add(manifest.Downloads?.GitHub?.FilesZipUrl);
return urls
.Where(url => !string.IsNullOrWhiteSpace(url))
.Select(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null)
.OfType<Uri>()
.Where(uri => uri.Scheme is "http" or "https")
.DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string? DerivePackageUrl(string manifestUrl)
{
if (!Uri.TryCreate(manifestUrl, UriKind.Absolute, out var uri) ||
uri.Scheme is not ("http" or "https"))
{
return null;
}
var builder = new UriBuilder(uri);
var lastSlash = builder.Path.LastIndexOf('/');
builder.Path = lastSlash >= 0
? $"{builder.Path[..(lastSlash + 1)]}Files.zip"
: "Files.zip";
builder.Query = string.Empty;
builder.Fragment = string.Empty;
return builder.Uri.AbsoluteUri;
}
}

View File

@@ -0,0 +1,118 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LanDesktopPLONDS.Installer.Services;
public sealed partial class InstallerPrivacyConsentStore
{
private const string ConsentFileName = "privacy-consent.json";
private readonly string _consentPath;
private readonly object _gate = new();
public InstallerPrivacyConsentStore(string? consentPath = null)
{
_consentPath = string.IsNullOrWhiteSpace(consentPath)
? GetDefaultConsentPath()
: Path.GetFullPath(consentPath);
}
public bool HasConfirmed(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
return false;
}
lock (_gate)
{
var document = TryLoad();
return document is not null
&& string.Equals(document.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)
&& document.ConfirmedAtUtc <= DateTimeOffset.UtcNow;
}
}
public void SaveConfirmed(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
throw new ArgumentException("Device ID is required.", nameof(deviceId));
}
lock (_gate)
{
Save(new InstallerPrivacyConsentDocument(
SchemaVersion: 1,
DeviceId: deviceId,
ConfirmedAtUtc: DateTimeOffset.UtcNow,
Categories:
[
"anonymousDeviceId",
"systemAndArchitecture",
"targetVersion",
"serverReceivedIpAddress"
]));
}
}
public static string GetDefaultConsentPath()
{
var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(root))
{
root = AppContext.BaseDirectory;
}
return Path.Combine(root, "LanMountainDesktop", "Installer", ConsentFileName);
}
private InstallerPrivacyConsentDocument? TryLoad()
{
try
{
if (!File.Exists(_consentPath))
{
return null;
}
var json = File.ReadAllText(_consentPath);
return JsonSerializer.Deserialize(
json,
InstallerPrivacyConsentJsonContext.Default.InstallerPrivacyConsentDocument);
}
catch
{
return null;
}
}
private void Save(InstallerPrivacyConsentDocument document)
{
var directory = Path.GetDirectoryName(_consentPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var tempPath = $"{_consentPath}.{Guid.NewGuid():N}.tmp";
var json = JsonSerializer.Serialize(
document,
InstallerPrivacyConsentJsonContext.Default.InstallerPrivacyConsentDocument);
File.WriteAllText(tempPath, json);
File.Move(tempPath, _consentPath, overwrite: true);
}
private sealed record InstallerPrivacyConsentDocument(
int SchemaVersion,
string DeviceId,
DateTimeOffset ConfirmedAtUtc,
IReadOnlyList<string> Categories);
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(InstallerPrivacyConsentDocument))]
private sealed partial class InstallerPrivacyConsentJsonContext : JsonSerializerContext;
}

View File

@@ -0,0 +1,83 @@
using LanDesktopPLONDS.Installer.Models;
using LanMountainDesktop.Shared.Contracts.Privacy;
namespace LanDesktopPLONDS.Installer.Services;
internal sealed class OnlineInstallService(
InstallerPlondsClient plondsClient,
FilesPackageInstaller packageInstaller,
IPrivacyDeviceIdentityProvider privacyIdentity) : IOnlineInstallService
{
private InstallerPlondsCandidate? _latestCandidate;
public static OnlineInstallService CreateDefault(IPrivacyDeviceIdentityProvider privacyIdentity)
{
var httpClient = new HttpClient
{
Timeout = TimeSpan.FromMinutes(20)
};
var stagingRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Installer",
"PLONDS");
return new OnlineInstallService(
new InstallerPlondsClient(httpClient, stagingRoot),
new FilesPackageInstaller(),
privacyIdentity);
}
public async Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken)
{
var candidate = await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false);
_latestCandidate = candidate;
return new OnlineInstallPackageInfo(
candidate.Manifest.CurrentVersion,
candidate.Source.Id,
candidate.FilesZipUrl,
InstallerPlondsClient.EstimateInstallBytes(candidate.Manifest));
}
public async Task InstallFreshAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
await InstallFreshAsync(installPath, OnlineInstallOptions.Default, progress, cancellationToken)
.ConfigureAwait(false);
}
public async Task InstallFreshAsync(
string installPath,
OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = privacyIdentity.GetOrCreateDeviceId();
var candidate = _latestCandidate ?? await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false);
var package = await plondsClient.DownloadAndPrepareFullPackageAsync(candidate, progress, cancellationToken).ConfigureAwait(false);
await packageInstaller.InstallAsync(package, installPath, options, progress, cancellationToken).ConfigureAwait(false);
}
public Task RepairAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = installPath;
_ = progress;
_ = cancellationToken;
throw new NotSupportedException("Repair is reserved for a later installer version.");
}
public Task UpdateIncrementalAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = installPath;
_ = progress;
_ = cancellationToken;
throw new NotSupportedException("Incremental update is reserved for a later installer version.");
}
}

View File

@@ -0,0 +1,81 @@
namespace LanDesktopPLONDS.Installer.Services;
internal sealed record InstallerPlondsSource(
string Id,
string Kind,
string ManifestUrl,
int Priority = 0);
internal sealed record InstallerPlondsManifest(
string FormatVersion,
string CurrentVersion,
string PreviousVersion,
bool IsFullUpdate,
bool RequiresCleanInstall,
string Channel,
string Platform,
DateTimeOffset UpdatedAt,
IReadOnlyDictionary<string, InstallerPlondsFileEntry> FilesMap,
IReadOnlyDictionary<string, InstallerPlondsChangedFileEntry> ChangedFilesMap,
IReadOnlyDictionary<string, string> Checksums,
InstallerPlondsDownloads? Downloads,
IReadOnlyList<InstallerPlondsSource>? Sources);
internal sealed record InstallerPlondsFileEntry(
string Action,
string Hash,
long Size,
string HashAlgorithm = "sha256");
internal sealed record InstallerPlondsChangedFileEntry(
string ArchivePath,
string Hash,
long Size,
string HashAlgorithm = "sha256");
internal sealed record InstallerPlondsDownloads(
InstallerPlondsGitHubDownloads? GitHub,
InstallerPlondsS3Downloads? S3);
internal sealed record InstallerPlondsGitHubDownloads(
string? ReleaseUrl,
string? ManifestUrl,
string? ChangedZipUrl,
string? FilesZipUrl);
internal sealed record InstallerPlondsS3Downloads(
string? Bucket,
string? Prefix,
string? ManifestKey,
string? ManifestUrl,
string? ChangedZipKey,
string? ChangedZipUrl,
string? ChangedFolderKey,
string? ChangedFolderUrl,
string? FilesZipKey,
string? FilesZipUrl,
string? FilesFolderKey,
string? FilesFolderUrl);
public sealed record OnlineInstallPackageInfo(
string Version,
string SourceId,
Uri FilesZipUrl,
long EstimatedBytes);
public sealed record OnlineInstallOptions(bool CreateDesktopShortcut)
{
public static OnlineInstallOptions Default { get; } = new(CreateDesktopShortcut: false);
}
internal sealed record InstallerPlondsCandidate(
InstallerPlondsSource Source,
InstallerPlondsManifest Manifest,
Uri FilesZipUrl);
internal sealed record PreparedFilesPackage(
string Version,
string SourceId,
string ZipPath,
string ExtractDirectory,
InstallerPlondsManifest Manifest);

View File

@@ -0,0 +1,22 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.ViewModels;
public sealed partial class InstallerStepViewModel(
InstallerStepId stepId,
string title,
string iconKey) : ObservableObject
{
[ObservableProperty]
private bool _isUnlocked;
[ObservableProperty]
private bool _isSelected;
public InstallerStepId StepId { get; } = stepId;
public string Title { get; } = title;
public string IconKey { get; } = iconKey;
}

View File

@@ -0,0 +1,359 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LanDesktopPLONDS.Installer.Models;
using LanDesktopPLONDS.Installer.Services;
using LanMountainDesktop.Shared.Contracts.Privacy;
namespace LanDesktopPLONDS.Installer.ViewModels;
public sealed partial class MainWindowViewModel : ObservableObject
{
private readonly IOnlineInstallService _installService;
private readonly IPrivacyDeviceIdentityProvider _privacyIdentity;
private readonly InstallerPrivacyConsentStore _privacyConsentStore;
private CancellationTokenSource? _installCts;
private bool _isNavigatingInternally;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(BackCommand))]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
private InstallerStepId _currentStep = InstallerStepId.Welcome;
[ObservableProperty]
private InstallerStepId _maxUnlockedStep = InstallerStepId.Welcome;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
private string _installPath = InstallerPathGuard.GetDefaultInstallPath();
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
private bool _privacyConfirmed;
[ObservableProperty]
private string? _targetVersion;
[ObservableProperty]
private string? _sourceId;
[ObservableProperty]
private string? _errorMessage;
[ObservableProperty]
private string _statusText = "准备开始安装";
[ObservableProperty]
private double _downloadProgress;
[ObservableProperty]
private double _installProgress;
[ObservableProperty]
private string? _currentFile;
[ObservableProperty]
private string _downloadBytesText = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
private bool _isInstalling;
[ObservableProperty]
private bool _createDesktopShortcut;
[ObservableProperty]
private InstallerStepViewModel? _selectedStep;
public MainWindowViewModel(
IOnlineInstallService installService,
IPrivacyDeviceIdentityProvider privacyIdentity,
InstallerPrivacyConsentStore? privacyConsentStore = null)
{
_installService = installService;
_privacyIdentity = privacyIdentity;
_privacyConsentStore = privacyConsentStore ?? new InstallerPrivacyConsentStore();
Steps =
[
new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", "Play"),
new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", "Folder"),
new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", "Info"),
new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", "Apps"),
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", "Circle")
];
SyncSteps();
SelectedStep = Steps[0];
DeviceIdPreview = _privacyIdentity.GetOrCreateDeviceId();
PrivacyConfirmed = _privacyConsentStore.HasConfirmed(DeviceIdPreview);
}
public ObservableCollection<InstallerStepViewModel> Steps { get; }
public Func<string, Task<string?>>? BrowseRequested { get; set; }
public string WindowTitle => "LanDesktopPLONDS Installer";
public string DeviceIdPreview { get; }
public bool IsWelcomeStep => CurrentStep == InstallerStepId.Welcome;
public bool IsLocationStep => CurrentStep == InstallerStepId.InstallLocation;
public bool IsPrivacyStep => CurrentStep == InstallerStepId.PrivacyConfirm;
public bool IsDeployStep => CurrentStep == InstallerStepId.Deploy;
public bool IsCompleteStep => CurrentStep == InstallerStepId.Complete;
public bool CanGoBack => CurrentStep > InstallerStepId.Welcome && !IsInstalling;
public bool CanGoNext => CurrentStep switch
{
InstallerStepId.Welcome => !IsInstalling,
InstallerStepId.InstallLocation => !string.IsNullOrWhiteSpace(InstallPath) && !IsInstalling,
InstallerStepId.PrivacyConfirm => PrivacyConfirmed && !IsInstalling,
_ => false
};
public bool CanStartInstall => CurrentStep == InstallerStepId.Deploy &&
PrivacyConfirmed &&
!string.IsNullOrWhiteSpace(InstallPath) &&
!IsInstalling;
public InstallerWorkflowState Snapshot => new(
CurrentStep,
MaxUnlockedStep,
InstallPath,
PrivacyConfirmed,
TargetVersion,
ErrorMessage);
partial void OnCurrentStepChanged(InstallerStepId value)
{
OnPropertyChanged(nameof(IsWelcomeStep));
OnPropertyChanged(nameof(IsLocationStep));
OnPropertyChanged(nameof(IsPrivacyStep));
OnPropertyChanged(nameof(IsDeployStep));
OnPropertyChanged(nameof(IsCompleteStep));
OnPropertyChanged(nameof(CanGoBack));
OnPropertyChanged(nameof(CanGoNext));
OnPropertyChanged(nameof(CanStartInstall));
SyncSteps();
}
partial void OnMaxUnlockedStepChanged(InstallerStepId value)
{
_ = value;
SyncSteps();
}
partial void OnSelectedStepChanged(InstallerStepViewModel? value)
{
if (_isNavigatingInternally || value is null)
{
return;
}
if (value.StepId <= MaxUnlockedStep)
{
CurrentStep = value.StepId;
return;
}
SyncSteps();
}
[RelayCommand(CanExecute = nameof(CanGoNext))]
private async Task NextAsync()
{
ErrorMessage = null;
if (CurrentStep == InstallerStepId.InstallLocation)
{
try
{
InstallerPathGuard.ValidateInstallPath(InstallPath);
var info = await _installService.CheckLatestAsync(CancellationToken.None);
TargetVersion = info.Version;
SourceId = info.SourceId;
StatusText = $"准备安装 {info.Version}";
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
return;
}
}
else if (CurrentStep == InstallerStepId.PrivacyConfirm)
{
_privacyConsentStore.SaveConfirmed(DeviceIdPreview);
}
UnlockAndNavigate(CurrentStep + 1);
}
[RelayCommand(CanExecute = nameof(CanGoBack))]
private void Back()
{
if (CurrentStep > InstallerStepId.Welcome)
{
CurrentStep -= 1;
}
}
[RelayCommand]
private async Task BrowseAsync()
{
if (BrowseRequested is null)
{
return;
}
var selected = await BrowseRequested(InstallPath);
if (!string.IsNullOrWhiteSpace(selected))
{
InstallPath = selected;
}
}
[RelayCommand(CanExecute = nameof(CanStartInstall))]
private async Task StartInstallAsync()
{
ErrorMessage = null;
IsInstalling = true;
StartInstallCommand.NotifyCanExecuteChanged();
_installCts?.Dispose();
_installCts = new CancellationTokenSource();
try
{
var progress = new Progress<InstallerDeployProgress>(ApplyProgress);
var options = new OnlineInstallOptions(CreateDesktopShortcut);
await _installService.InstallFreshAsync(InstallPath, options, progress, _installCts.Token);
UnlockAndNavigate(InstallerStepId.Complete);
StatusText = "安装完成";
}
catch (OperationCanceledException)
{
ErrorMessage = "安装已取消。";
StatusText = "安装已取消";
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
StatusText = "安装失败";
}
finally
{
IsInstalling = false;
StartInstallCommand.NotifyCanExecuteChanged();
}
}
[RelayCommand]
private void CancelInstall()
{
_installCts?.Cancel();
}
[RelayCommand]
private void Launch()
{
LaunchCore();
}
private void LaunchCore()
{
var launcher = Path.Combine(InstallPath, OperatingSystem.IsWindows()
? "LanMountainDesktop.Launcher.exe"
: "LanMountainDesktop.Launcher");
if (!File.Exists(launcher))
{
ErrorMessage = "未找到 LanMountainDesktop.Launcher。";
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = launcher,
Arguments = "--launch-source postinstall",
WorkingDirectory = InstallPath,
UseShellExecute = true
});
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
}
}
private void UnlockAndNavigate(InstallerStepId step)
{
if (step > MaxUnlockedStep)
{
MaxUnlockedStep = step;
}
CurrentStep = step;
}
private void ApplyProgress(InstallerDeployProgress progress)
{
StatusText = progress.Stage;
TargetVersion = progress.TargetVersion ?? TargetVersion;
DownloadProgress = progress.DownloadProgress;
InstallProgress = progress.InstallProgress;
CurrentFile = progress.CurrentFile;
DownloadBytesText = FormatBytes(progress.BytesDownloaded, progress.TotalBytes);
}
private void SyncSteps()
{
_isNavigatingInternally = true;
try
{
foreach (var step in Steps)
{
step.IsUnlocked = step.StepId <= MaxUnlockedStep;
step.IsSelected = step.StepId == CurrentStep;
if (step.StepId == CurrentStep && !ReferenceEquals(SelectedStep, step))
{
SelectedStep = step;
}
}
}
finally
{
_isNavigatingInternally = false;
}
}
private static string FormatBytes(long downloaded, long? total)
{
if (downloaded <= 0 && total is not > 0)
{
return string.Empty;
}
var downloadedText = ToSize(downloaded);
return total is > 0 ? $"{downloadedText} / {ToSize(total.Value)}" : downloadedText;
}
private static string ToSize(long value)
{
string[] suffixes = ["B", "KB", "MB", "GB"];
var size = (double)value;
var suffix = 0;
while (size >= 1024 && suffix < suffixes.Length - 1)
{
size /= 1024;
suffix++;
}
return $"{size:0.##} {suffixes[suffix]}";
}
}

View File

@@ -0,0 +1,321 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:vm="using:LanDesktopPLONDS.Installer.ViewModels"
x:Class="LanDesktopPLONDS.Installer.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="1080"
Height="720"
MinWidth="860"
MinHeight="620"
CanResize="True"
Title="{Binding WindowTitle}"
Background="Transparent"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="48"
WindowDecorations="None">
<Window.Styles>
<Style Selector="Grid.step-page">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="Grid.step-page.visible">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="{DynamicResource InstallerSecondaryTextBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="Border.inline-panel">
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Padding" Value="18" />
</Style>
</Window.Styles>
<Grid x:Name="RootGrid"
Background="{DynamicResource InstallerWindowBackgroundBrush}"
RowDefinitions="48,*">
<Border Grid.RowSpan="2"
Background="{DynamicResource InstallerTintBrush}"
IsHitTestVisible="False" />
<Border Grid.Row="0"
Background="Transparent"
PointerPressed="OnTitleBarPointerPressed">
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Orientation="Horizontal"
Margin="12,0,0,0"
Spacing="8"
VerticalAlignment="Center">
<fi:FluentIcon Icon="ArrowDownload"
IconVariant="Regular"
FontSize="18" />
<TextBlock Text="{Binding WindowTitle}"
FontSize="12"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="4"
Margin="0,0,8,0"
VerticalAlignment="Center">
<Button Classes="titlebar-icon-button"
ToolTip.Tip="最小化"
Click="OnMinimizeClick">
<fi:FluentIcon Icon="Subtract"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button Classes="titlebar-icon-button"
ToolTip.Tip="关闭"
Click="OnCloseClick">
<fi:FluentIcon Icon="Dismiss"
IconVariant="Regular"
FontSize="14" />
</Button>
</StackPanel>
</Grid>
</Border>
<ui:FANavigationView x:Name="StepNavigation"
Grid.Row="1"
PaneDisplayMode="Left"
OpenPaneLength="272"
IsPaneOpen="True"
IsSettingsVisible="False"
IsBackButtonVisible="False"
IsPaneToggleButtonVisible="False"
IsPaneVisible="True"
MenuItemsSource="{Binding Steps}"
SelectedItem="{Binding SelectedStep, Mode=TwoWay}"
Background="Transparent"
Margin="0,0,0,0">
<ui:FANavigationView.Resources>
<SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" />
<SolidColorBrush x:Key="NavigationViewDefaultPaneBackground" Color="Transparent" />
<SolidColorBrush x:Key="NavigationViewExpandedPaneBackground" Color="Transparent" />
<SolidColorBrush x:Key="NavigationViewPaneBackground" Color="Transparent" />
</ui:FANavigationView.Resources>
<ui:FANavigationView.MenuItemTemplate>
<DataTemplate x:DataType="vm:InstallerStepViewModel">
<ui:FANavigationViewItem Content="{Binding Title}"
Tag="{Binding StepId}"
IsEnabled="{Binding IsUnlocked}">
<ui:FANavigationViewItem.IconSource>
<ui:FAFontIconSource Glyph="&#xE10F;" />
</ui:FANavigationViewItem.IconSource>
</ui:FANavigationViewItem>
</DataTemplate>
</ui:FANavigationView.MenuItemTemplate>
<Grid Margin="28,4,36,28"
RowDefinitions="*,Auto">
<Grid Grid.Row="0">
<Grid Classes="step-page"
IsVisible="{Binding IsWelcomeStep}">
<StackPanel Classes="installer-page-container">
<TextBlock Classes="page-title-text"
Text="安装阑山桌面" />
<TextBlock Classes="page-description-text"
Text="在线安装程序会从 PLONDS 获取最新完整包,并部署到本机的版本目录结构中。" />
<ui:FASettingsExpander Header="准备开始"
Description="安装器将检查最新版本、下载 Files 完整包、校验并部署。">
<StackPanel Spacing="8">
<TextBlock Text="首版支持 Windows 首次安装。修复和增量更新入口将在后续版本开放。"
Classes="muted" />
<TextBlock Text="安装完成后将使用 LanMountainDesktop.Launcher 作为统一入口。"
Classes="muted" />
</StackPanel>
</ui:FASettingsExpander>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsLocationStep}">
<StackPanel Classes="installer-page-container">
<TextBlock Classes="page-title-text"
Text="选择安装位置" />
<TextBlock Classes="page-description-text"
Text="请选择一个专用文件夹。默认位置需要管理员权限,和现有安装器保持一致。" />
<ui:FASettingsExpander Header="安装目录"
Description="安装根目录下会创建 .Launcher 和 app-{version}-0。">
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBox Text="{Binding InstallPath, Mode=TwoWay}"
PlaceholderText="安装路径" />
<Button Grid.Column="1"
Command="{Binding BrowseCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="FolderOpen"
IconVariant="Regular" />
<TextBlock Text="浏览" />
</StackPanel>
</Button>
</Grid>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="安装后选项"
Description="开始菜单快捷方式会自动创建,桌面快捷方式可选。">
<StackPanel Spacing="10">
<CheckBox IsChecked="{Binding CreateDesktopShortcut}"
Content="创建桌面快捷方式" />
</StackPanel>
</ui:FASettingsExpander>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsPrivacyStep}">
<StackPanel Classes="installer-page-container">
<TextBlock Classes="page-title-text"
Text="确认上传数据" />
<TextBlock Classes="page-description-text"
Text="请确认安装阶段需要使用的匿名数据类别。" />
<ui:FASettingsExpander Header="匿名设备码"
Description="与后续隐私计算使用同一设备码口径。">
<TextBlock Text="{Binding DeviceIdPreview}"
TextWrapping="Wrap"
FontFamily="Consolas" />
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="网络与统计"
Description="服务端会接收 IP 地址,用于防 DDoS 与统计用户量。">
<StackPanel Spacing="8">
<TextBlock Classes="muted"
Text="安装器会发送匿名设备码、系统与架构信息、目标版本和请求 IP。不会上传用户名、机器名或安装目录。" />
<CheckBox IsChecked="{Binding PrivacyConfirmed}"
Content="我确认上述匿名数据可用于安装、风控和用户量统计。" />
</StackPanel>
</ui:FASettingsExpander>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsDeployStep}">
<StackPanel Classes="installer-page-container">
<TextBlock Classes="page-title-text"
Text="开始部署" />
<TextBlock Classes="page-description-text"
Text="安装时会下载 Files 完整包并写入当前版本目录。" />
<Border Classes="inline-panel">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto,Auto"
ColumnSpacing="12"
RowSpacing="8">
<TextBlock Text="版本" />
<TextBlock Grid.Column="1"
Text="{Binding TargetVersion}" />
<TextBlock Grid.Row="1"
Text="来源" />
<TextBlock Grid.Row="1"
Grid.Column="1"
Text="{Binding SourceId}" />
<TextBlock Grid.Row="2"
Text="状态" />
<TextBlock Grid.Row="2"
Grid.Column="1"
Text="{Binding StatusText}" />
</Grid>
<StackPanel Spacing="6">
<TextBlock Text="下载进度" />
<ProgressBar Minimum="0"
Maximum="1"
Value="{Binding DownloadProgress}" />
<TextBlock Classes="muted"
Text="{Binding DownloadBytesText}" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="安装进度" />
<ProgressBar Minimum="0"
Maximum="1"
Value="{Binding InstallProgress}" />
<TextBlock Classes="muted"
Text="{Binding CurrentFile}" />
</StackPanel>
<StackPanel Orientation="Horizontal"
Spacing="8">
<Button Command="{Binding StartInstallCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="ArrowDownload"
IconVariant="Regular" />
<TextBlock Text="开始安装" />
</StackPanel>
</Button>
<Button Command="{Binding CancelInstallCommand}"
IsEnabled="{Binding IsInstalling}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="Dismiss"
IconVariant="Regular" />
<TextBlock Text="取消" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsCompleteStep}">
<StackPanel Classes="installer-page-container">
<TextBlock Classes="page-title-text"
Text="完成安装" />
<TextBlock Classes="page-description-text"
Text="阑山桌面已经部署完成。" />
<ui:FASettingsExpander Header="启动应用"
Description="使用 Launcher 进入首次启动流程。">
<StackPanel Spacing="12">
<TextBlock Text="如果需要,可以从这里重新启动 LanMountainDesktop.Launcher。"
Classes="muted" />
<Button Command="{Binding LaunchCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="Play"
IconVariant="Regular" />
<TextBlock Text="启动阑山桌面" />
</StackPanel>
</Button>
</StackPanel>
</ui:FASettingsExpander>
</StackPanel>
</Grid>
</Grid>
<Grid Grid.Row="1"
ColumnDefinitions="*,Auto,Auto"
ColumnSpacing="8"
Margin="0,16,0,0">
<TextBlock Text="{Binding ErrorMessage}"
Foreground="#C42B1C"
TextWrapping="Wrap"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Command="{Binding BackCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="ArrowLeft"
IconVariant="Regular" />
<TextBlock Text="上一步" />
</StackPanel>
</Button>
<Button Grid.Column="2"
Command="{Binding NextCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<TextBlock Text="下一步" />
<fi:FluentIcon Icon="ArrowRight"
IconVariant="Regular" />
</StackPanel>
</Button>
</Grid>
</Grid>
</ui:FANavigationView>
</Grid>
</Window>

View File

@@ -0,0 +1,62 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LanDesktopPLONDS.Installer.ViewModels;
namespace LanDesktopPLONDS.Installer.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is MainWindowViewModel viewModel)
{
viewModel.BrowseRequested = BrowseForFolderAsync;
}
}
private async Task<string?> BrowseForFolderAsync(string currentPath)
{
var startFolder = Directory.Exists(currentPath)
? await StorageProvider.TryGetFolderFromPathAsync(currentPath)
: null;
var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "选择安装位置",
AllowMultiple = false,
SuggestedStartLocation = startFolder
});
return result.Count == 0 ? null : result[0].Path.LocalPath;
}
private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
BeginMoveDrag(e);
}
}
private void OnMinimizeClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
WindowState = WindowState.Minimized;
}
private void OnCloseClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
Close();
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.0.0.0" name="LanDesktopPLONDS.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>