mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 17:24:27 +08:00
Compare commits
2 Commits
2768b76e1e
...
v0.8.8.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2793be68d4 | ||
|
|
13895e0f43 |
@@ -1,12 +1,12 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:theme="using:Avalonia.Themes.Fluent"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanDesktopPLONDS.Installer.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<FontFamily x:Key="AppFontFamily">Inter, Segoe UI, Microsoft YaHei UI</FontFamily>
|
||||
<FontFamily x:Key="AppFontFamily">Segoe UI, Microsoft YaHei UI</FontFamily>
|
||||
<FontFamily x:Key="InstallerIconFontFamily">Segoe MDL2 Assets</FontFamily>
|
||||
<CornerRadius x:Key="DesignCornerRadiusMicro">2</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusSm">4</CornerRadius>
|
||||
@@ -76,10 +76,12 @@
|
||||
<Style Selector="UserControl">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||
</Style>
|
||||
<Style Selector="fi|FluentIcon">
|
||||
<Style Selector="TextBlock.installer-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource InstallerIconFontFamily}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
|
||||
@@ -142,13 +144,13 @@
|
||||
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary-command fi|FluentIcon">
|
||||
<Style Selector="Button.primary-command TextBlock.installer-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary-command TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary-command:disabled fi|FluentIcon">
|
||||
<Style Selector="Button.primary-command:disabled TextBlock.installer-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary-command:disabled TextBlock">
|
||||
@@ -174,13 +176,13 @@
|
||||
<Style Selector="Button.secondary-command TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary-command fi|FluentIcon">
|
||||
<Style Selector="Button.secondary-command TextBlock.installer-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary-command:disabled TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary-command:disabled fi|FluentIcon">
|
||||
<Style Selector="Button.secondary-command:disabled TextBlock.installer-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="TextBox">
|
||||
|
||||
92
LanDesktopPLONDS.installer/InstallerStartupDiagnostics.cs
Normal file
92
LanDesktopPLONDS.installer/InstallerStartupDiagnostics.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer;
|
||||
|
||||
internal static class InstallerStartupDiagnostics
|
||||
{
|
||||
private const uint MessageBoxIconError = 0x00000010;
|
||||
private const uint MessageBoxOk = 0x00000000;
|
||||
|
||||
private static int _initialized;
|
||||
private static int _fatalMessageShown;
|
||||
|
||||
public static string LogPath => Path.Combine(GetLogDirectory(), "startup.log");
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _initialized, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||
{
|
||||
var exception = args.ExceptionObject as Exception;
|
||||
ReportFatal("The installer encountered an unhandled startup error.", exception);
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, args) =>
|
||||
{
|
||||
ReportFatal("The installer encountered an unobserved background error.", args.Exception);
|
||||
args.SetObserved();
|
||||
};
|
||||
|
||||
Log("Startup diagnostics initialized.");
|
||||
}
|
||||
|
||||
public static void Log(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(GetLogDirectory());
|
||||
File.AppendAllText(
|
||||
LogPath,
|
||||
$"[{DateTimeOffset.Now:O}] {message}{Environment.NewLine}",
|
||||
Encoding.UTF8);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Diagnostics must never become the reason the installer cannot start.
|
||||
}
|
||||
}
|
||||
|
||||
public static void ReportFatal(string message, Exception? exception)
|
||||
{
|
||||
Log(exception is null ? message : $"{message}{Environment.NewLine}{exception}");
|
||||
|
||||
if (!OperatingSystem.IsWindows() || Interlocked.Exchange(ref _fatalMessageShown, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var details = exception is null
|
||||
? message
|
||||
: $"{message}{Environment.NewLine}{Environment.NewLine}{exception.GetType().Name}: {exception.Message}";
|
||||
_ = MessageBox(
|
||||
IntPtr.Zero,
|
||||
$"{details}{Environment.NewLine}{Environment.NewLine}Log: {LogPath}",
|
||||
"LanDesktopPLONDS Installer",
|
||||
MessageBoxOk | MessageBoxIconError);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetLogDirectory()
|
||||
{
|
||||
var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
root = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.Combine(root, "LanMountainDesktop", "Installer", "logs");
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "MessageBoxW", CharSet = CharSet.Unicode)]
|
||||
private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
|
||||
}
|
||||
@@ -8,7 +8,14 @@
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<OptimizationPreference>Size</OptimizationPreference>
|
||||
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
|
||||
<PublishReadyToRun>false</PublishReadyToRun>
|
||||
<DebuggerSupport>false</DebuggerSupport>
|
||||
<EventSourceSupport>false</EventSourceSupport>
|
||||
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
|
||||
<UseSystemResourceKeys>true</UseSystemResourceKeys>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -16,24 +23,11 @@
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(PublishAot)' == 'true'">
|
||||
<TrimmerRootAssembly Include="Avalonia" />
|
||||
<TrimmerRootAssembly Include="Avalonia.Desktop" />
|
||||
<TrimmerRootAssembly Include="Avalonia.Themes.Fluent" />
|
||||
<TrimmerRootAssembly Include="FluentIcons.Avalonia" />
|
||||
<TrimmerRootAssembly Include="LanDesktopPLONDS.installer" />
|
||||
<TrimmerRootAssembly Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target
|
||||
Name="PrepareInstallerEmbeddedNativeLibraries"
|
||||
BeforeTargets="AssignTargetPaths"
|
||||
Condition="'$(PublishAot)' == 'true' and '$(RuntimeIdentifier)' == 'win-x64'">
|
||||
<ItemGroup>
|
||||
<InstallerNativeLibrary
|
||||
Include="$(PkgAvalonia_Angle_Windows_Natives)\runtimes\win-x64\native\av_libglesv2.dll"
|
||||
CompressedName="av_libglesv2.dll.gz"
|
||||
Condition="Exists('$(PkgAvalonia_Angle_Windows_Natives)\runtimes\win-x64\native\av_libglesv2.dll')" />
|
||||
<InstallerNativeLibrary
|
||||
Include="$(PkgHarfBuzzSharp_NativeAssets_Win32)\runtimes\win-x64\native\libHarfBuzzSharp.dll"
|
||||
CompressedName="libHarfBuzzSharp.dll.gz"
|
||||
@@ -53,9 +47,6 @@
|
||||
Command="powershell -NoProfile -ExecutionPolicy Bypass -File "$(MSBuildThisFileDirectory)Compress-NativeLibrary.ps1" -SourcePath "%(InstallerNativeLibrary.FullPath)" -DestinationPath "$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\%(InstallerNativeLibrary.CompressedName)"" />
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource
|
||||
Include="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\av_libglesv2.dll.gz"
|
||||
LogicalName="LanDesktopPLONDS.Installer.NativeLibraries.av_libglesv2.dll.gz" />
|
||||
<EmbeddedResource
|
||||
Include="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\libHarfBuzzSharp.dll.gz"
|
||||
LogicalName="LanDesktopPLONDS.Installer.NativeLibraries.libHarfBuzzSharp.dll.gz" />
|
||||
@@ -68,7 +59,7 @@
|
||||
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -20,11 +20,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Angle.Windows.Natives" GeneratePathProperty="true" PrivateAssets="all" />
|
||||
<PackageReference Include="Avalonia.Angle.Windows.Natives" ExcludeAssets="all" PrivateAssets="all" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" />
|
||||
<PackageReference Include="HarfBuzzSharp.NativeAssets.Win32" GeneratePathProperty="true" PrivateAssets="all" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Win32" GeneratePathProperty="true" PrivateAssets="all" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
|
||||
@@ -13,7 +13,6 @@ internal static class NativeDependencyBootstrapper
|
||||
|
||||
private static readonly string[] NativeLibraryNames =
|
||||
[
|
||||
"av_libglesv2.dll",
|
||||
"libHarfBuzzSharp.dll",
|
||||
"libSkiaSharp.dll"
|
||||
];
|
||||
@@ -47,7 +46,7 @@ internal static class NativeDependencyBootstrapper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[NativeDependencyBootstrapper] Failed to prepare native dependencies: {ex}");
|
||||
InstallerStartupDiagnostics.Log($"Native dependency preparation failed: {ex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Win32;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer;
|
||||
|
||||
@@ -7,17 +8,21 @@ public static class Program
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
InstallerStartupDiagnostics.Initialize();
|
||||
try
|
||||
{
|
||||
InstallerStartupDiagnostics.Log("Preparing native dependencies.");
|
||||
if (!NativeDependencyBootstrapper.TryPrepare())
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine("[Program] Failed to prepare native dependencies, but continuing...");
|
||||
throw new InvalidOperationException("Failed to prepare native dependencies.");
|
||||
}
|
||||
|
||||
InstallerStartupDiagnostics.Log("Starting Avalonia desktop lifetime.");
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[Program] Unhandled exception: {ex}");
|
||||
InstallerStartupDiagnostics.ReportFatal("The installer failed to start.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +30,10 @@ public static class Program
|
||||
{
|
||||
return AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
.With(new Win32PlatformOptions
|
||||
{
|
||||
RenderingMode = [Win32RenderingMode.Software],
|
||||
CompositionMode = [Win32CompositionMode.RedirectionSurface]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ internal sealed class FilesPackageInstaller
|
||||
var sourceAppDirectory = ResolveFullPackageAppDirectory(package.ExtractDirectory, package.Version);
|
||||
var targetDeployment = BuildDeploymentDirectory(launcherRoot, package.Version);
|
||||
|
||||
InstallerElevation.EnsureCanInstall(launcherRoot);
|
||||
InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, EstimateRequiredBytes(sourceAppDirectory));
|
||||
Directory.CreateDirectory(launcherRoot);
|
||||
await CopyLauncherRootPayloadAsync(package.ExtractDirectory, sourceAppDirectory, launcherRoot, package.Version, progress, cancellationToken)
|
||||
@@ -299,7 +300,9 @@ internal sealed class FilesPackageInstaller
|
||||
return;
|
||||
}
|
||||
|
||||
var startMenu = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu);
|
||||
var startMenu = InstallerElevation.IsRunningElevated()
|
||||
? Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu)
|
||||
: Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
|
||||
if (string.IsNullOrWhiteSpace(startMenu))
|
||||
{
|
||||
startMenu = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
|
||||
|
||||
52
LanDesktopPLONDS.installer/Services/InstallerElevation.cs
Normal file
52
LanDesktopPLONDS.installer/Services/InstallerElevation.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
internal static class InstallerElevation
|
||||
{
|
||||
public static bool IsRunningElevated()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var principal = new WindowsPrincipal(identity);
|
||||
return principal.IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
|
||||
public static bool RequiresElevation(string installPath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(installPath);
|
||||
return IsUnderSpecialFolder(fullPath, Environment.SpecialFolder.ProgramFiles)
|
||||
|| IsUnderSpecialFolder(fullPath, Environment.SpecialFolder.ProgramFilesX86)
|
||||
|| IsUnderWindowsDirectory(fullPath);
|
||||
}
|
||||
|
||||
public static void EnsureCanInstall(string installPath)
|
||||
{
|
||||
if (RequiresElevation(installPath) && !IsRunningElevated())
|
||||
{
|
||||
throw new UnauthorizedAccessException(
|
||||
"The selected installation path requires administrator permission. Restart the installer as administrator or choose a user-writable folder.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsUnderSpecialFolder(string fullPath, Environment.SpecialFolder folder)
|
||||
{
|
||||
var root = Environment.GetFolderPath(folder);
|
||||
return !string.IsNullOrWhiteSpace(root) && InstallerPathGuard.IsSameOrChildPath(root, fullPath);
|
||||
}
|
||||
|
||||
private static bool IsUnderWindowsDirectory(string fullPath)
|
||||
{
|
||||
var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
|
||||
return !string.IsNullOrWhiteSpace(windows) && InstallerPathGuard.IsSameOrChildPath(windows, fullPath);
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,13 @@ public static class InstallerPathGuard
|
||||
|
||||
public static string GetDefaultInstallPath()
|
||||
{
|
||||
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
if (string.IsNullOrWhiteSpace(programFiles))
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
programFiles = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Programs");
|
||||
localAppData = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.Combine(programFiles, ApplicationDirectoryName);
|
||||
return Path.Combine(localAppData, "Programs", ApplicationDirectoryName);
|
||||
}
|
||||
|
||||
public static string GetInstallPathForSelectedFolder(string selectedFolder)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using FluentIcons.Common;
|
||||
using LanDesktopPLONDS.Installer.Models;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer.ViewModels;
|
||||
@@ -7,7 +6,7 @@ namespace LanDesktopPLONDS.Installer.ViewModels;
|
||||
public sealed partial class InstallerStepViewModel(
|
||||
InstallerStepId stepId,
|
||||
string title,
|
||||
Icon icon) : ObservableObject
|
||||
string iconGlyph) : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private bool _isUnlocked;
|
||||
@@ -19,5 +18,5 @@ public sealed partial class InstallerStepViewModel(
|
||||
|
||||
public string Title { get; } = title;
|
||||
|
||||
public Icon Icon { get; } = icon;
|
||||
public string IconGlyph { get; } = iconGlyph;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using FluentIcons.Common;
|
||||
using LanDesktopPLONDS.Installer.Models;
|
||||
using LanDesktopPLONDS.Installer.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Privacy;
|
||||
@@ -81,11 +80,11 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
_privacyConsentStore = privacyConsentStore ?? new InstallerPrivacyConsentStore();
|
||||
Steps =
|
||||
[
|
||||
new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", Icon.Play),
|
||||
new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", Icon.Folder),
|
||||
new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", Icon.Info),
|
||||
new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", Icon.ArrowDownload),
|
||||
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", Icon.Circle)
|
||||
new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", "\uE768"),
|
||||
new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", "\uE838"),
|
||||
new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", "\uE946"),
|
||||
new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", "\uE896"),
|
||||
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", "\uE73E")
|
||||
];
|
||||
SyncSteps();
|
||||
DeviceIdPreview = _privacyIdentity.GetOrCreateDeviceId();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:vm="using:LanDesktopPLONDS.Installer.ViewModels"
|
||||
x:Class="LanDesktopPLONDS.Installer.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
@@ -62,7 +61,7 @@
|
||||
<Style Selector="Button.step-nav-item:disabled TextBlock.step-title">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.step-nav-item:disabled fi|FluentIcon">
|
||||
<Style Selector="Button.step-nav-item:disabled TextBlock.installer-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.step-nav-selected-fill">
|
||||
@@ -129,8 +128,8 @@
|
||||
Height="28"
|
||||
Background="{DynamicResource InstallerAccentBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}">
|
||||
<fi:FluentIcon Icon="ArrowDownload"
|
||||
IconVariant="Regular"
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
Foreground="{DynamicResource InstallerOnAccentBrush}"
|
||||
FontSize="16" />
|
||||
</Border>
|
||||
@@ -148,15 +147,15 @@
|
||||
<Button Classes="titlebar-icon-button"
|
||||
ToolTip.Tip="最小化"
|
||||
Click="OnMinimizeClick">
|
||||
<fi:FluentIcon Icon="Subtract"
|
||||
IconVariant="Regular"
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
<Button Classes="titlebar-icon-button"
|
||||
ToolTip.Tip="关闭"
|
||||
Click="OnCloseClick">
|
||||
<fi:FluentIcon Icon="Dismiss"
|
||||
IconVariant="Regular"
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
@@ -196,13 +195,13 @@
|
||||
Margin="10,0">
|
||||
<Grid Width="18"
|
||||
VerticalAlignment="Center">
|
||||
<fi:FluentIcon Icon="{Binding Icon}"
|
||||
IconVariant="Regular"
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="{Binding IconGlyph}"
|
||||
Foreground="{DynamicResource InstallerTextSecondaryBrush}"
|
||||
FontSize="17"
|
||||
IsVisible="{Binding !IsSelected}" />
|
||||
<fi:FluentIcon Icon="{Binding Icon}"
|
||||
IconVariant="Filled"
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="{Binding IconGlyph}"
|
||||
Foreground="{DynamicResource InstallerTextPrimaryBrush}"
|
||||
FontSize="17"
|
||||
IsVisible="{Binding IsSelected}" />
|
||||
@@ -257,8 +256,8 @@
|
||||
Height="40"
|
||||
Background="{DynamicResource InstallerSubtleFillBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}">
|
||||
<fi:FluentIcon Icon="CloudArrowDown"
|
||||
IconVariant="Regular"
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
FontSize="20" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1"
|
||||
@@ -302,8 +301,8 @@
|
||||
Command="{Binding BrowseCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="FolderOpen"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
<TextBlock Text="浏览" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -341,8 +340,8 @@
|
||||
<Border Classes="info-panel">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="10">
|
||||
<fi:FluentIcon Icon="Shield"
|
||||
IconVariant="Regular"
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
Foreground="{DynamicResource InstallerAccentBrush}"
|
||||
FontSize="18" />
|
||||
<TextBlock Grid.Column="1"
|
||||
@@ -416,8 +415,8 @@
|
||||
Command="{Binding StartInstallCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="ArrowDownload"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
<TextBlock Text="开始安装" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -426,8 +425,8 @@
|
||||
IsEnabled="{Binding IsInstalling}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="Dismiss"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
<TextBlock Text="取消" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -454,8 +453,8 @@
|
||||
Height="40"
|
||||
Background="{DynamicResource InstallerSubtleFillBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}">
|
||||
<fi:FluentIcon Icon="CheckmarkCircle"
|
||||
IconVariant="Regular"
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
Foreground="{DynamicResource InstallerSuccessBrush}"
|
||||
FontSize="22" />
|
||||
</Border>
|
||||
@@ -473,8 +472,8 @@
|
||||
Command="{Binding LaunchCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="Play"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
<TextBlock Text="打开阑山桌面" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -495,8 +494,8 @@
|
||||
IsVisible="{Binding HasError}">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="10">
|
||||
<fi:FluentIcon Icon="ErrorCircle"
|
||||
IconVariant="Regular"
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
Foreground="{DynamicResource InstallerErrorBrush}"
|
||||
FontSize="18" />
|
||||
<TextBlock Grid.Column="1"
|
||||
@@ -511,8 +510,8 @@
|
||||
Command="{Binding BackCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="ArrowLeft"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
<TextBlock Text="上一步" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -522,8 +521,8 @@
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<TextBlock Text="下一步" />
|
||||
<fi:FluentIcon Icon="ArrowRight"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
@@ -87,31 +87,68 @@ internal sealed class DeploymentLocator
|
||||
var explicitAppRoot = context.ExplicitAppRoot;
|
||||
var devModeConfigIgnored = !context.IsDebugMode && Views.ErrorWindow.CheckDevModeEnabled();
|
||||
|
||||
Logger.Info($"=== HOST RESOLUTION START ===");
|
||||
Logger.Info($" AppRoot: {_appRoot}");
|
||||
Logger.Info($" Executable: {executable}");
|
||||
Logger.Info($" IsDebugMode: {context.IsDebugMode}");
|
||||
Logger.Info($" ExplicitAppRoot: {explicitAppRoot ?? "<none>"}");
|
||||
Logger.Info($" LauncherBaseDirectory: {AppContext.BaseDirectory}");
|
||||
|
||||
string? resolvedPath;
|
||||
string? source;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(explicitAppRoot))
|
||||
{
|
||||
Logger.Info($"Trying explicit app root: {explicitAppRoot}");
|
||||
var explicitRoot = Path.GetFullPath(explicitAppRoot);
|
||||
resolvedPath = TryResolveExplicitAppRoot(explicitRoot, executable, searchedPaths, out source);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Info("Trying published or portable host...");
|
||||
resolvedPath = TryResolvePublishedOrPortableHost(executable, searchedPaths, out source);
|
||||
}
|
||||
|
||||
if (resolvedPath is null && context.IsDebugMode)
|
||||
{
|
||||
Logger.Info("Debug mode: trying debug host paths...");
|
||||
resolvedPath = TryResolveDebugHost(executable, searchedPaths, out source);
|
||||
}
|
||||
|
||||
if (resolvedPath is null)
|
||||
{
|
||||
Logger.Warn("Standard resolution failed, trying legacy fallback...");
|
||||
resolvedPath = ResolveHostExecutablePathLegacy();
|
||||
if (!string.IsNullOrWhiteSpace(resolvedPath))
|
||||
{
|
||||
searchedPaths.Add(Path.GetFullPath(resolvedPath));
|
||||
source = "legacy_fallback";
|
||||
Logger.Info($"Legacy fallback found: {resolvedPath}");
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Info($"=== HOST RESOLUTION RESULT ===");
|
||||
Logger.Info($" Success: {!string.IsNullOrWhiteSpace(resolvedPath)}");
|
||||
Logger.Info($" ResolvedPath: {resolvedPath ?? "<NOT FOUND>"}");
|
||||
Logger.Info($" Source: {source ?? "<none>"}");
|
||||
Logger.Info($" SearchedPaths ({searchedPaths.Count}):");
|
||||
foreach (var path in searchedPaths.Take(10))
|
||||
{
|
||||
Logger.Info($" - {path}");
|
||||
}
|
||||
if (searchedPaths.Count > 10)
|
||||
{
|
||||
Logger.Info($" ... and {searchedPaths.Count - 10} more");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolvedPath))
|
||||
{
|
||||
Logger.Error("CRITICAL: Could not resolve host executable path!");
|
||||
Console.Error.WriteLine("[CRITICAL] Could not find main application executable!");
|
||||
Console.Error.WriteLine($"[CRITICAL] Searched {searchedPaths.Count} locations:");
|
||||
foreach (var path in searchedPaths.Take(5))
|
||||
{
|
||||
Console.Error.WriteLine($"[CRITICAL] - {path}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,15 @@ internal sealed class AirAppRuntimeBridge
|
||||
|
||||
public async Task EnsureStartedAsync()
|
||||
{
|
||||
Logger.Info($"AIRAPP: Checking if AirApp Runtime is available. AppRoot='{_appRoot}'");
|
||||
|
||||
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||
{
|
||||
Logger.Info("AirApp Runtime is already available.");
|
||||
Logger.Info("AIRAPP: AirApp Runtime is already available.");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.Info("AIRAPP: Starting AirApp Runtime...");
|
||||
Process? process;
|
||||
try
|
||||
{
|
||||
@@ -36,24 +39,28 @@ internal sealed class AirAppRuntimeBridge
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"AirApp Runtime start request failed. AppRoot='{_appRoot}'; Error='{ex.Message}'.");
|
||||
Logger.Warn($"AIRAPP: AirApp Runtime start request failed. AppRoot='{_appRoot}'; Error='{ex.Message}'");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.Info($"AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'.");
|
||||
Logger.Info($"AIRAPP: AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'.");
|
||||
|
||||
for (var attempt = 1; attempt <= ConnectAttempts; attempt++)
|
||||
{
|
||||
Logger.Info($"AIRAPP: Attempt {attempt}/{ConnectAttempts} - Checking IPC connection...");
|
||||
|
||||
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||
{
|
||||
Logger.Info("AirApp Runtime IPC is ready.");
|
||||
Logger.Info("AIRAPP: AirApp Runtime IPC is ready.");
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt)).ConfigureAwait(false);
|
||||
var delayMs = 250 * attempt;
|
||||
Logger.Info($"AIRAPP: IPC not ready, waiting {delayMs}ms before retry...");
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(delayMs)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Warn("AirApp Runtime did not become ready after pre-start; Host fallback remains available.");
|
||||
Logger.Warn("AIRAPP: AirApp Runtime did not become ready after pre-start; Host fallback remains available.");
|
||||
}
|
||||
|
||||
public async Task AttachHostAsync(int hostProcessId)
|
||||
@@ -65,10 +72,15 @@ internal sealed class AirAppRuntimeBridge
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||
|
||||
var connectTask = client.ConnectAsync(IpcConstants.AirAppRuntimePipeName);
|
||||
await connectTask.WaitAsync(TimeSpan.FromSeconds(3), cts.Token).ConfigureAwait(false);
|
||||
|
||||
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||
var result = await proxy.AttachHostAsync(hostProcessId).ConfigureAwait(false);
|
||||
var attachTask = proxy.AttachHostAsync(hostProcessId);
|
||||
var result = await attachTask.WaitAsync(TimeSpan.FromSeconds(3), cts.Token).ConfigureAwait(false);
|
||||
Logger.Info($"AirApp Runtime host attach completed. Accepted={result.Accepted}; Code='{result.Code}'; HostPid={hostProcessId}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -81,13 +93,29 @@ internal sealed class AirAppRuntimeBridge
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||
|
||||
var connectTask = client.ConnectAsync(IpcConstants.AirAppRuntimePipeName);
|
||||
await connectTask.WaitAsync(TimeSpan.FromSeconds(2), cts.Token).ConfigureAwait(false);
|
||||
|
||||
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||
return await proxy.GetStatusAsync().ConfigureAwait(false);
|
||||
var statusTask = proxy.GetStatusAsync();
|
||||
return await statusTask.WaitAsync(TimeSpan.FromSeconds(2), cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Logger.Info("AIRAPP: TryGetStatusAsync timed out (2s).");
|
||||
return null;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.Info("AIRAPP: TryGetStatusAsync cancelled.");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Info($"AIRAPP: TryGetStatusAsync failed: {ex.GetType().Name} - {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +144,18 @@ internal sealed class LauncherOrchestrator
|
||||
return;
|
||||
}
|
||||
|
||||
if (!softTimeoutShown)
|
||||
{
|
||||
// 用户在软超时前关闭窗口,提示确认
|
||||
Logger.Info("Splash window was closed manually before soft timeout. Cancelling startup attempt.");
|
||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, "User cancelled startup before soft timeout.");
|
||||
// 取消后续监控
|
||||
successTcs.TrySetCanceled();
|
||||
return;
|
||||
}
|
||||
|
||||
_startupAttemptRegistry.MarkOwnedDetachedWaiting();
|
||||
Logger.Warn("Splash window was closed manually. Launcher will continue monitoring the current startup attempt.");
|
||||
Logger.Warn("Splash window was closed manually after soft timeout. Launcher will continue monitoring the current startup attempt in detached mode.");
|
||||
};
|
||||
splashWindow.Closed += splashClosedHandler;
|
||||
|
||||
|
||||
@@ -208,13 +208,15 @@ internal sealed class HostLaunchService
|
||||
|
||||
private static async Task EnsureAirAppRuntimeStartedAsync(string appRoot, string? dataRoot)
|
||||
{
|
||||
Logger.Info("HOST LAUNCH: Attempting to pre-start AirApp Runtime...");
|
||||
try
|
||||
{
|
||||
await new AirAppRuntimeBridge(appRoot, dataRoot).EnsureStartedAsync().ConfigureAwait(false);
|
||||
Logger.Info("HOST LAUNCH: AirApp Runtime pre-start completed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"AirApp Runtime pre-start failed; Host fallback remains available. Error='{ex.Message}'.");
|
||||
Logger.Warn($"HOST LAUNCH: AirApp Runtime pre-start failed; Host fallback remains available. Error='{ex.Message}'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +251,11 @@ internal sealed class HostLaunchService
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Info($"ATTEMPTING HOST START: Path='{plan.HostPath}'; WorkingDir='{plan.WorkingDirectory}'; Mode='{startMode}'");
|
||||
Logger.Info($" Arguments: {HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)}");
|
||||
Logger.Info($" File exists: {File.Exists(plan.HostPath)}");
|
||||
Logger.Info($" Working dir exists: {Directory.Exists(plan.WorkingDirectory)}");
|
||||
|
||||
var process = Process.Start(startInfo);
|
||||
Logger.Info(
|
||||
$"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? "<none>"}'; Path='{plan.HostPath}'; " +
|
||||
@@ -257,15 +264,30 @@ internal sealed class HostLaunchService
|
||||
|
||||
if (process is null)
|
||||
{
|
||||
Logger.Error($"CRITICAL: Process.Start returned null! Path='{plan.HostPath}'; Mode='{startMode}'");
|
||||
Console.Error.WriteLine($"[CRITICAL] Process.Start returned null for path: {plan.HostPath}");
|
||||
return HostStartAttempt.StartFailed(startMode, "process_start_returned_null", plan);
|
||||
}
|
||||
|
||||
await Task.Yield();
|
||||
// 等待一小段时间,检查进程是否立即退出
|
||||
await Task.Delay(500).ConfigureAwait(false);
|
||||
|
||||
if (process.HasExited)
|
||||
{
|
||||
Logger.Error($"CRITICAL: Host process exited immediately! ExitCode={process.ExitCode}; Path='{plan.HostPath}'");
|
||||
Console.Error.WriteLine($"[CRITICAL] Host process exited immediately with code {process.ExitCode}");
|
||||
return HostStartAttempt.StartFailed(startMode, $"process_exited_immediately_code_{process.ExitCode}", plan);
|
||||
}
|
||||
|
||||
Logger.Info($"Host process started successfully and is running. PID={process.Id}");
|
||||
return HostStartAttempt.Started(startMode, process, plan);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Host start failed. Mode='{startMode}'.", ex);
|
||||
Logger.Error($"CRITICAL: Host start exception! Path='{plan.HostPath}'; Mode='{startMode}'; Exception={ex.GetType().Name}; Message='{ex.Message}'", ex);
|
||||
Console.Error.WriteLine($"[CRITICAL] Host start failed: {ex.Message}");
|
||||
Console.Error.WriteLine($"[CRITICAL] Path: {plan.HostPath}");
|
||||
Console.Error.WriteLine($"[CRITICAL] Exception: {ex}");
|
||||
return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ internal sealed class HostStartupMonitor
|
||||
]).ConfigureAwait(false);
|
||||
if (!connected)
|
||||
{
|
||||
Logger.Info("Host public IPC is not ready yet. Launcher will keep monitoring the host process and retry.");
|
||||
Logger.Info("Host public IPC is not ready yet after initial connection attempts. Launcher will keep monitoring the host process and retry periodically.");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -106,6 +106,8 @@ internal sealed class HostStartupMonitor
|
||||
var nextShellStatusPollAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.ShellStatusPollInterval;
|
||||
var ipcReconnectAttemptIndex = 0;
|
||||
var activationRetryAttempted = false;
|
||||
var lastIpcConnectionFailureReported = DateTimeOffset.MinValue;
|
||||
var ipcConnectionFailureCount = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
@@ -224,6 +226,7 @@ internal sealed class HostStartupMonitor
|
||||
if (connected)
|
||||
{
|
||||
ipcConnected = true;
|
||||
Logger.Info($"Host public IPC reconnected successfully after {ipcConnectionFailureCount} failed attempts.");
|
||||
var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.")
|
||||
.ConfigureAwait(false);
|
||||
if (shellSuccess is not null)
|
||||
@@ -232,6 +235,18 @@ internal sealed class HostStartupMonitor
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ipcConnectionFailureCount++;
|
||||
// 每 30 秒报告一次 IPC 连接失败
|
||||
if ((now - lastIpcConnectionFailureReported).TotalSeconds >= 30)
|
||||
{
|
||||
lastIpcConnectionFailureReported = now;
|
||||
var elapsed = (now - startedAt).TotalSeconds;
|
||||
Logger.Warn($"Host public IPC connection still unavailable after {elapsed:0}s and {ipcConnectionFailureCount} reconnect attempts. Host process is alive (PID={request.HostProcess.Id}).");
|
||||
request.Reporter.Report("diagnostic", $"正在等待主应用响应... (已尝试 {ipcConnectionFailureCount} 次)");
|
||||
}
|
||||
}
|
||||
|
||||
nextReconnectAttemptAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.IpcReconnectInterval;
|
||||
}
|
||||
@@ -263,6 +278,16 @@ internal sealed class HostStartupMonitor
|
||||
nextCheckpointAt = softTimeoutAt;
|
||||
}
|
||||
|
||||
if (!ipcConnected && nextReconnectAttemptAt < nextCheckpointAt)
|
||||
{
|
||||
nextCheckpointAt = nextReconnectAttemptAt;
|
||||
}
|
||||
|
||||
if (ipcConnected && nextShellStatusPollAt < nextCheckpointAt)
|
||||
{
|
||||
nextCheckpointAt = nextShellStatusPollAt;
|
||||
}
|
||||
|
||||
var delay = nextCheckpointAt - now;
|
||||
if (delay > TimeSpan.FromSeconds(1))
|
||||
{
|
||||
@@ -351,11 +376,11 @@ internal sealed class HostStartupMonitor
|
||||
if (!connected && !request.HostProcess.HasExited)
|
||||
{
|
||||
request.AttemptRegistry.MarkOwnedWaitingForShell("Host process is still running, but public IPC is not ready yet.");
|
||||
request.PublishCoordinatorStatus(true, false, true);
|
||||
request.PublishCoordinatorStatus(true, true, false);
|
||||
return new Outcome(
|
||||
true,
|
||||
"startup_pending",
|
||||
"Host process is still running; Launcher will not start another process while public IPC finishes startup.",
|
||||
false,
|
||||
"ipc_connection_failed",
|
||||
$"Host process is still running after {StartupTimeoutPolicy.HardTimeout.TotalSeconds:0} seconds, but public IPC connection could not be established. This may indicate the host is stuck during initialization.",
|
||||
recoveryActivationAttempted,
|
||||
request.ComposeLaunchDetails(true, recoveryActivationAttempted));
|
||||
}
|
||||
|
||||
@@ -89,6 +89,14 @@ internal sealed class StartupAttemptRegistry
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var existing = LoadUnsafe();
|
||||
|
||||
// 清理过期的记录
|
||||
if (existing is not null && IsStaleAttempt(existing))
|
||||
{
|
||||
Logger.Info($"Cleaning up stale startup attempt record. AttemptId='{existing.AttemptId}'; State='{existing.State}'; Age={(DateTimeOffset.UtcNow - existing.UpdatedAtUtc).TotalMinutes:0.1}min.");
|
||||
existing = null;
|
||||
}
|
||||
|
||||
if (existing is not null && IsCoordinatorLive(existing))
|
||||
{
|
||||
active = Clone(existing);
|
||||
@@ -145,6 +153,34 @@ internal sealed class StartupAttemptRegistry
|
||||
return reserved is not null;
|
||||
}
|
||||
|
||||
private static bool IsStaleAttempt(StartupAttemptRecord record)
|
||||
{
|
||||
// 记录超过 10 分钟且状态为终结或非活跃状态
|
||||
if (DateTimeOffset.UtcNow - record.UpdatedAtUtc > TimeSpan.FromMinutes(10))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 进程已死且协调器心跳超时
|
||||
if (record.CoordinatorPid > 0 &&
|
||||
!TryGetLiveProcess(record.CoordinatorPid, out _) &&
|
||||
DateTimeOffset.UtcNow - record.HeartbeatAtUtc > TimeSpan.FromMinutes(2))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 主进程已死且协调器已死
|
||||
if (record.HostPid > 0 &&
|
||||
!TryGetLiveProcess(record.HostPid, out _) &&
|
||||
record.CoordinatorPid > 0 &&
|
||||
!TryGetLiveProcess(record.CoordinatorPid, out _))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? GetOwnedAttempt()
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
|
||||
@@ -2,22 +2,26 @@ namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal static class StartupTimeoutPolicy
|
||||
{
|
||||
public static readonly TimeSpan SoftTimeout = TimeSpan.FromSeconds(30);
|
||||
public static readonly TimeSpan HardTimeout = TimeSpan.FromSeconds(120);
|
||||
public static readonly TimeSpan SoftTimeout = TimeSpan.FromSeconds(45);
|
||||
public static readonly TimeSpan HardTimeout = TimeSpan.FromSeconds(180);
|
||||
|
||||
/// <summary>Initial Public IPC connect attempt (AOT cold start may be slower).</summary>
|
||||
public static readonly TimeSpan InitialIpcConnectTimeout = TimeSpan.FromMilliseconds(1200);
|
||||
/// <summary>Initial Public IPC connect attempt (AOT cold start is significantly slower).</summary>
|
||||
public static readonly TimeSpan InitialIpcConnectTimeout = TimeSpan.FromMilliseconds(3000);
|
||||
|
||||
/// <summary>Subsequent reconnect attempts use increasing per-try timeouts.</summary>
|
||||
public static readonly TimeSpan[] IpcReconnectAttemptTimeouts =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(800),
|
||||
TimeSpan.FromMilliseconds(1500),
|
||||
TimeSpan.FromMilliseconds(3000),
|
||||
TimeSpan.FromMilliseconds(5000)
|
||||
TimeSpan.FromMilliseconds(5000),
|
||||
TimeSpan.FromMilliseconds(8000),
|
||||
TimeSpan.FromMilliseconds(10000)
|
||||
];
|
||||
|
||||
public static readonly TimeSpan ExistingHostProbeTimeout = TimeSpan.FromMilliseconds(900);
|
||||
public static readonly TimeSpan ExistingHostProbeTimeout = TimeSpan.FromMilliseconds(1500);
|
||||
public static readonly TimeSpan ShellStatusPollInterval = TimeSpan.FromSeconds(1);
|
||||
public static readonly TimeSpan IpcReconnectInterval = TimeSpan.FromSeconds(2);
|
||||
public static readonly TimeSpan IpcReconnectInterval = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>Maximum time to wait for host process exit after it starts (for early-exit detection).</summary>
|
||||
public static readonly TimeSpan HostEarlyExitWindow = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
@@ -212,6 +212,33 @@ public sealed class OnlineInstallerCoreTests : IDisposable
|
||||
Assert.ThrowsAny<Exception>(() => InstallerPathGuard.NormalizeInstallPath(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallerPathGuard_DefaultsToUserWritableProgramsFolder()
|
||||
{
|
||||
var path = InstallerPathGuard.GetDefaultInstallPath();
|
||||
|
||||
Assert.EndsWith(Path.Combine("Programs", InstallerPathGuard.ApplicationDirectoryName), path);
|
||||
Assert.DoesNotContain("Program Files", path, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallerElevation_DetectsProtectedProgramFilesPath()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
if (string.IsNullOrWhiteSpace(programFiles))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.True(InstallerElevation.RequiresElevation(Path.Combine(programFiles, InstallerPathGuard.ApplicationDirectoryName)));
|
||||
Assert.False(InstallerElevation.RequiresElevation(Path.Combine(_tempRoot, InstallerPathGuard.ApplicationDirectoryName)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilesPackageInstaller_DeploysFullPackageWithCurrentMarker()
|
||||
{
|
||||
|
||||
@@ -246,6 +246,9 @@ public partial class App : Application
|
||||
ReportStartupProgress(StartupStage.Initializing, 10, "Initializing application...");
|
||||
ReportStartupProgress(StartupStage.LoadingSettings, 20, "Loading settings...");
|
||||
}
|
||||
|
||||
// 启动心跳线程,确保启动器能检测到主应用的活跃状态
|
||||
_ = StartLauncherHeartbeatAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -253,6 +256,39 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartLauncherHeartbeatAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 每 5 秒发送一次心跳,防止启动器认为主应用已卡死
|
||||
while (!IsShutdownInProgress && _publicIpcHostService is not null)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
|
||||
// 如果还未报告 Ready,发送心跳进度
|
||||
if (!_mainWindowOpened && !IsShutdownInProgress)
|
||||
{
|
||||
// 静默心跳,不记录日志
|
||||
QueueOrSendLauncherProgress(new StartupProgressMessage
|
||||
{
|
||||
Stage = StartupStage.Initializing,
|
||||
ProgressPercent = 15,
|
||||
Message = "Application is initializing...",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}, logSuccess: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
break; // 主窗口已打开,停止心跳
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Heartbeat thread failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportStartupProgress(StartupStage stage, int percent, string message)
|
||||
{
|
||||
QueueOrSendLauncherProgress(new StartupProgressMessage
|
||||
@@ -1824,11 +1860,22 @@ public partial class App : Application
|
||||
_publicIpcHostService.Start();
|
||||
AppLogger.Info(
|
||||
"PublicIpc",
|
||||
$"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'; Version='{versionInfo.Version}'; Codename='{versionInfo.Codename}'.");
|
||||
$"Public IPC host started successfully. PipeName='{IpcConstants.DefaultPipeName}'; Version='{versionInfo.Version}'; Codename='{versionInfo.Codename}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PublicIpc", "Failed to initialize public IPC host.", ex);
|
||||
AppLogger.Error("PublicIpc", "CRITICAL: Failed to initialize public IPC host. Launcher will not be able to connect to this process.", ex);
|
||||
|
||||
// 尝试通过标准错误输出告知启动器
|
||||
try
|
||||
{
|
||||
Console.Error.WriteLine($"[CRITICAL] Public IPC host initialization failed: {ex.Message}");
|
||||
Console.Error.WriteLine("[CRITICAL] The launcher will not be able to connect to this process.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略控制台写入失败
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,8 +116,8 @@ public sealed class ComponentRegistry
|
||||
"Class Schedule",
|
||||
"CalendarDate",
|
||||
"Date",
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 4,
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 3,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
|
||||
@@ -683,18 +683,18 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
var scale = ResolveScale();
|
||||
var cardRadius = ComponentChromeCornerRadiusHelper.Small();
|
||||
var timeFontSize = Math.Clamp(11 * scale, 8, 14);
|
||||
var courseNameFontSize = Math.Clamp(14 * scale, 10, 18);
|
||||
var detailFontSize = Math.Clamp(11 * scale, 8, 14);
|
||||
var progressFontSize = Math.Clamp(10 * scale, 7, 12);
|
||||
var timeFontSize = Math.Clamp(16 * scale, 12, 22);
|
||||
var courseNameFontSize = Math.Clamp(20 * scale, 16, 28);
|
||||
var detailFontSize = Math.Clamp(15 * scale, 12, 20);
|
||||
var progressFontSize = Math.Clamp(13 * scale, 10, 16);
|
||||
var cardPadding = new Thickness(
|
||||
Math.Clamp(10 * scale, 6, 14),
|
||||
Math.Clamp(8 * scale, 5, 12),
|
||||
Math.Clamp(10 * scale, 6, 14),
|
||||
Math.Clamp(8 * scale, 5, 12));
|
||||
var timeColumnWidth = Math.Clamp(44 * scale, 30, 56);
|
||||
var accentBarWidth = Math.Clamp(3 * scale, 2, 4);
|
||||
var progressBarHeight = Math.Clamp(3 * scale, 2, 4);
|
||||
Math.Clamp(14 * scale, 10, 20),
|
||||
Math.Clamp(12 * scale, 8, 18),
|
||||
Math.Clamp(14 * scale, 10, 20),
|
||||
Math.Clamp(12 * scale, 8, 18));
|
||||
var timeColumnWidth = Math.Clamp(60 * scale, 45, 80);
|
||||
var accentBarWidth = Math.Clamp(4 * scale, 3, 6);
|
||||
var progressBarHeight = Math.Clamp(4 * scale, 3, 6);
|
||||
|
||||
for (var i = 0; i < _courseItems.Count; i++)
|
||||
{
|
||||
@@ -894,10 +894,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
var itemBorder = new Border
|
||||
{
|
||||
Padding = new Thickness(
|
||||
Math.Clamp(10 * scale, 6, 14),
|
||||
Math.Clamp(2 * scale, 1, 4),
|
||||
Math.Clamp(10 * scale, 6, 14),
|
||||
Math.Clamp(2 * scale, 1, 4)),
|
||||
Math.Clamp(14 * scale, 10, 20),
|
||||
Math.Clamp(4 * scale, 2, 6),
|
||||
Math.Clamp(14 * scale, 10, 20),
|
||||
Math.Clamp(4 * scale, 2, 6)),
|
||||
Background = Brushes.Transparent,
|
||||
Child = itemGrid
|
||||
};
|
||||
@@ -1022,11 +1022,11 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
HeaderGrid.ColumnSpacing = Math.Clamp(8 * scale, 4, 14);
|
||||
DateGroup.Spacing = Math.Clamp(1.5 * scale, 0.5, 3);
|
||||
CourseListPanel.Spacing = Math.Clamp(2 * scale, 0, 6);
|
||||
CourseListPanel.Spacing = Math.Clamp(4 * scale, 2, 8);
|
||||
|
||||
var dateFontByScale = Math.Clamp(28 * scale, 14, 36);
|
||||
var weekdayFontByScale = Math.Clamp(14 * scale, 10, 18);
|
||||
var classCountFontByScale = Math.Clamp(12 * scale, 9, 15);
|
||||
var dateFontByScale = Math.Clamp(36 * scale, 20, 48);
|
||||
var weekdayFontByScale = Math.Clamp(18 * scale, 14, 24);
|
||||
var classCountFontByScale = Math.Clamp(15 * scale, 12, 20);
|
||||
|
||||
var availableWidth = Math.Max(1, Bounds.Width - headerPadding.Left - headerPadding.Right);
|
||||
var dateGroupEstimatedWidth = dateFontByScale * 0.6 * 3 + DateGroup.Spacing * 2;
|
||||
|
||||
Reference in New Issue
Block a user