Compare commits

..

2 Commits

24 changed files with 526 additions and 150 deletions

View File

@@ -1,12 +1,12 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:theme="using:Avalonia.Themes.Fluent" xmlns:theme="using:Avalonia.Themes.Fluent"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanDesktopPLONDS.Installer.App" x:Class="LanDesktopPLONDS.Installer.App"
RequestedThemeVariant="Default"> RequestedThemeVariant="Default">
<Application.Resources> <Application.Resources>
<ResourceDictionary> <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="DesignCornerRadiusMicro">2</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius> <CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusSm">4</CornerRadius> <CornerRadius x:Key="DesignCornerRadiusSm">4</CornerRadius>
@@ -76,10 +76,12 @@
<Style Selector="UserControl"> <Style Selector="UserControl">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" /> <Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style> </Style>
<Style Selector="fi|FluentIcon"> <Style Selector="TextBlock.installer-icon">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource InstallerIconFontFamily}" />
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" /> <Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="TextAlignment" Value="Center" />
</Style> </Style>
<Style Selector="TextBlock"> <Style Selector="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
@@ -142,13 +144,13 @@
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" /> <Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style> </Style>
<Style Selector="Button.primary-command fi|FluentIcon"> <Style Selector="Button.primary-command TextBlock.installer-icon">
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
</Style> </Style>
<Style Selector="Button.primary-command TextBlock"> <Style Selector="Button.primary-command TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
</Style> </Style>
<Style Selector="Button.primary-command:disabled fi|FluentIcon"> <Style Selector="Button.primary-command:disabled TextBlock.installer-icon">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style> </Style>
<Style Selector="Button.primary-command:disabled TextBlock"> <Style Selector="Button.primary-command:disabled TextBlock">
@@ -174,13 +176,13 @@
<Style Selector="Button.secondary-command TextBlock"> <Style Selector="Button.secondary-command TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style> </Style>
<Style Selector="Button.secondary-command fi|FluentIcon"> <Style Selector="Button.secondary-command TextBlock.installer-icon">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style> </Style>
<Style Selector="Button.secondary-command:disabled TextBlock"> <Style Selector="Button.secondary-command:disabled TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style> </Style>
<Style Selector="Button.secondary-command:disabled fi|FluentIcon"> <Style Selector="Button.secondary-command:disabled TextBlock.installer-icon">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style> </Style>
<Style Selector="TextBox"> <Style Selector="TextBox">

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

View File

@@ -8,7 +8,14 @@
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile> <EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<OptimizationPreference>Size</OptimizationPreference> <OptimizationPreference>Size</OptimizationPreference>
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
<PublishReadyToRun>false</PublishReadyToRun> <PublishReadyToRun>false</PublishReadyToRun>
<DebuggerSupport>false</DebuggerSupport>
<EventSourceSupport>false</EventSourceSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<InvariantGlobalization>true</InvariantGlobalization>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
@@ -16,24 +23,11 @@
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </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 <Target
Name="PrepareInstallerEmbeddedNativeLibraries" Name="PrepareInstallerEmbeddedNativeLibraries"
BeforeTargets="AssignTargetPaths" BeforeTargets="AssignTargetPaths"
Condition="'$(PublishAot)' == 'true' and '$(RuntimeIdentifier)' == 'win-x64'"> Condition="'$(PublishAot)' == 'true' and '$(RuntimeIdentifier)' == 'win-x64'">
<ItemGroup> <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 <InstallerNativeLibrary
Include="$(PkgHarfBuzzSharp_NativeAssets_Win32)\runtimes\win-x64\native\libHarfBuzzSharp.dll" Include="$(PkgHarfBuzzSharp_NativeAssets_Win32)\runtimes\win-x64\native\libHarfBuzzSharp.dll"
CompressedName="libHarfBuzzSharp.dll.gz" CompressedName="libHarfBuzzSharp.dll.gz"
@@ -53,9 +47,6 @@
Command="powershell -NoProfile -ExecutionPolicy Bypass -File &quot;$(MSBuildThisFileDirectory)Compress-NativeLibrary.ps1&quot; -SourcePath &quot;%(InstallerNativeLibrary.FullPath)&quot; -DestinationPath &quot;$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\%(InstallerNativeLibrary.CompressedName)&quot;" /> Command="powershell -NoProfile -ExecutionPolicy Bypass -File &quot;$(MSBuildThisFileDirectory)Compress-NativeLibrary.ps1&quot; -SourcePath &quot;%(InstallerNativeLibrary.FullPath)&quot; -DestinationPath &quot;$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\%(InstallerNativeLibrary.CompressedName)&quot;" />
<ItemGroup> <ItemGroup>
<EmbeddedResource
Include="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\av_libglesv2.dll.gz"
LogicalName="LanDesktopPLONDS.Installer.NativeLibraries.av_libglesv2.dll.gz" />
<EmbeddedResource <EmbeddedResource
Include="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\libHarfBuzzSharp.dll.gz" Include="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\libHarfBuzzSharp.dll.gz"
LogicalName="LanDesktopPLONDS.Installer.NativeLibraries.libHarfBuzzSharp.dll.gz" /> LogicalName="LanDesktopPLONDS.Installer.NativeLibraries.libHarfBuzzSharp.dll.gz" />
@@ -68,7 +59,7 @@
<PropertyGroup Condition="'$(PublishAot)' == 'true'"> <PropertyGroup Condition="'$(PublishAot)' == 'true'">
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings> <SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<TrimmerSingleWarn>false</TrimmerSingleWarn> <TrimmerSingleWarn>false</TrimmerSingleWarn>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<IsAotCompatible>true</IsAotCompatible> <IsAotCompatible>true</IsAotCompatible>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -20,11 +20,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" /> <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.Desktop" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Themes.Fluent" /> <PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="FluentIcons.Avalonia" />
<PackageReference Include="HarfBuzzSharp.NativeAssets.Win32" GeneratePathProperty="true" PrivateAssets="all" /> <PackageReference Include="HarfBuzzSharp.NativeAssets.Win32" GeneratePathProperty="true" PrivateAssets="all" />
<PackageReference Include="SkiaSharp.NativeAssets.Win32" GeneratePathProperty="true" PrivateAssets="all" /> <PackageReference Include="SkiaSharp.NativeAssets.Win32" GeneratePathProperty="true" PrivateAssets="all" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />

View File

@@ -13,7 +13,6 @@ internal static class NativeDependencyBootstrapper
private static readonly string[] NativeLibraryNames = private static readonly string[] NativeLibraryNames =
[ [
"av_libglesv2.dll",
"libHarfBuzzSharp.dll", "libHarfBuzzSharp.dll",
"libSkiaSharp.dll" "libSkiaSharp.dll"
]; ];
@@ -47,7 +46,7 @@ internal static class NativeDependencyBootstrapper
} }
catch (Exception ex) catch (Exception ex)
{ {
System.Diagnostics.Debug.WriteLine($"[NativeDependencyBootstrapper] Failed to prepare native dependencies: {ex}"); InstallerStartupDiagnostics.Log($"Native dependency preparation failed: {ex}");
return false; return false;
} }
} }

View File

@@ -1,4 +1,5 @@
using Avalonia; using Avalonia;
using Avalonia.Win32;
namespace LanDesktopPLONDS.Installer; namespace LanDesktopPLONDS.Installer;
@@ -7,17 +8,21 @@ public static class Program
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
InstallerStartupDiagnostics.Initialize();
try try
{ {
InstallerStartupDiagnostics.Log("Preparing native dependencies.");
if (!NativeDependencyBootstrapper.TryPrepare()) 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); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
} }
catch (Exception ex) 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>() return AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()
.WithInterFont() .With(new Win32PlatformOptions
.LogToTrace(); {
RenderingMode = [Win32RenderingMode.Software],
CompositionMode = [Win32CompositionMode.RedirectionSurface]
});
} }
} }

View File

@@ -28,6 +28,7 @@ internal sealed class FilesPackageInstaller
var sourceAppDirectory = ResolveFullPackageAppDirectory(package.ExtractDirectory, package.Version); var sourceAppDirectory = ResolveFullPackageAppDirectory(package.ExtractDirectory, package.Version);
var targetDeployment = BuildDeploymentDirectory(launcherRoot, package.Version); var targetDeployment = BuildDeploymentDirectory(launcherRoot, package.Version);
InstallerElevation.EnsureCanInstall(launcherRoot);
InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, EstimateRequiredBytes(sourceAppDirectory)); InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, EstimateRequiredBytes(sourceAppDirectory));
Directory.CreateDirectory(launcherRoot); Directory.CreateDirectory(launcherRoot);
await CopyLauncherRootPayloadAsync(package.ExtractDirectory, sourceAppDirectory, launcherRoot, package.Version, progress, cancellationToken) await CopyLauncherRootPayloadAsync(package.ExtractDirectory, sourceAppDirectory, launcherRoot, package.Version, progress, cancellationToken)
@@ -299,7 +300,9 @@ internal sealed class FilesPackageInstaller
return; 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)) if (string.IsNullOrWhiteSpace(startMenu))
{ {
startMenu = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu); startMenu = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);

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

View File

@@ -6,15 +6,13 @@ public static class InstallerPathGuard
public static string GetDefaultInstallPath() public static string GetDefaultInstallPath()
{ {
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(programFiles)) if (string.IsNullOrWhiteSpace(localAppData))
{ {
programFiles = Path.Combine( localAppData = AppContext.BaseDirectory;
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Programs");
} }
return Path.Combine(programFiles, ApplicationDirectoryName); return Path.Combine(localAppData, "Programs", ApplicationDirectoryName);
} }
public static string GetInstallPathForSelectedFolder(string selectedFolder) public static string GetInstallPathForSelectedFolder(string selectedFolder)

View File

@@ -1,5 +1,4 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using FluentIcons.Common;
using LanDesktopPLONDS.Installer.Models; using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.ViewModels; namespace LanDesktopPLONDS.Installer.ViewModels;
@@ -7,7 +6,7 @@ namespace LanDesktopPLONDS.Installer.ViewModels;
public sealed partial class InstallerStepViewModel( public sealed partial class InstallerStepViewModel(
InstallerStepId stepId, InstallerStepId stepId,
string title, string title,
Icon icon) : ObservableObject string iconGlyph) : ObservableObject
{ {
[ObservableProperty] [ObservableProperty]
private bool _isUnlocked; private bool _isUnlocked;
@@ -19,5 +18,5 @@ public sealed partial class InstallerStepViewModel(
public string Title { get; } = title; public string Title { get; } = title;
public Icon Icon { get; } = icon; public string IconGlyph { get; } = iconGlyph;
} }

View File

@@ -2,7 +2,6 @@ using System.Collections.ObjectModel;
using System.Diagnostics; using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using FluentIcons.Common;
using LanDesktopPLONDS.Installer.Models; using LanDesktopPLONDS.Installer.Models;
using LanDesktopPLONDS.Installer.Services; using LanDesktopPLONDS.Installer.Services;
using LanMountainDesktop.Shared.Contracts.Privacy; using LanMountainDesktop.Shared.Contracts.Privacy;
@@ -81,11 +80,11 @@ public sealed partial class MainWindowViewModel : ObservableObject
_privacyConsentStore = privacyConsentStore ?? new InstallerPrivacyConsentStore(); _privacyConsentStore = privacyConsentStore ?? new InstallerPrivacyConsentStore();
Steps = Steps =
[ [
new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", Icon.Play), new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", "\uE768"),
new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", Icon.Folder), new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", "\uE838"),
new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", Icon.Info), new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", "\uE946"),
new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", Icon.ArrowDownload), new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", "\uE896"),
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", Icon.Circle) new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", "\uE73E")
]; ];
SyncSteps(); SyncSteps();
DeviceIdPreview = _privacyIdentity.GetOrCreateDeviceId(); DeviceIdPreview = _privacyIdentity.GetOrCreateDeviceId();

View File

@@ -1,6 +1,5 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:vm="using:LanDesktopPLONDS.Installer.ViewModels" xmlns:vm="using:LanDesktopPLONDS.Installer.ViewModels"
x:Class="LanDesktopPLONDS.Installer.Views.MainWindow" x:Class="LanDesktopPLONDS.Installer.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
@@ -62,7 +61,7 @@
<Style Selector="Button.step-nav-item:disabled TextBlock.step-title"> <Style Selector="Button.step-nav-item:disabled TextBlock.step-title">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style> </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}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style> </Style>
<Style Selector="Border.step-nav-selected-fill"> <Style Selector="Border.step-nav-selected-fill">
@@ -129,8 +128,8 @@
Height="28" Height="28"
Background="{DynamicResource InstallerAccentBrush}" Background="{DynamicResource InstallerAccentBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"> CornerRadius="{DynamicResource DesignCornerRadiusSm}">
<fi:FluentIcon Icon="ArrowDownload" <TextBlock Classes="installer-icon"
IconVariant="Regular" Text="&#xE896;"
Foreground="{DynamicResource InstallerOnAccentBrush}" Foreground="{DynamicResource InstallerOnAccentBrush}"
FontSize="16" /> FontSize="16" />
</Border> </Border>
@@ -148,15 +147,15 @@
<Button Classes="titlebar-icon-button" <Button Classes="titlebar-icon-button"
ToolTip.Tip="最小化" ToolTip.Tip="最小化"
Click="OnMinimizeClick"> Click="OnMinimizeClick">
<fi:FluentIcon Icon="Subtract" <TextBlock Classes="installer-icon"
IconVariant="Regular" Text="&#xE921;"
FontSize="14" /> FontSize="14" />
</Button> </Button>
<Button Classes="titlebar-icon-button" <Button Classes="titlebar-icon-button"
ToolTip.Tip="关闭" ToolTip.Tip="关闭"
Click="OnCloseClick"> Click="OnCloseClick">
<fi:FluentIcon Icon="Dismiss" <TextBlock Classes="installer-icon"
IconVariant="Regular" Text="&#xE711;"
FontSize="14" /> FontSize="14" />
</Button> </Button>
</StackPanel> </StackPanel>
@@ -196,13 +195,13 @@
Margin="10,0"> Margin="10,0">
<Grid Width="18" <Grid Width="18"
VerticalAlignment="Center"> VerticalAlignment="Center">
<fi:FluentIcon Icon="{Binding Icon}" <TextBlock Classes="installer-icon"
IconVariant="Regular" Text="{Binding IconGlyph}"
Foreground="{DynamicResource InstallerTextSecondaryBrush}" Foreground="{DynamicResource InstallerTextSecondaryBrush}"
FontSize="17" FontSize="17"
IsVisible="{Binding !IsSelected}" /> IsVisible="{Binding !IsSelected}" />
<fi:FluentIcon Icon="{Binding Icon}" <TextBlock Classes="installer-icon"
IconVariant="Filled" Text="{Binding IconGlyph}"
Foreground="{DynamicResource InstallerTextPrimaryBrush}" Foreground="{DynamicResource InstallerTextPrimaryBrush}"
FontSize="17" FontSize="17"
IsVisible="{Binding IsSelected}" /> IsVisible="{Binding IsSelected}" />
@@ -257,8 +256,8 @@
Height="40" Height="40"
Background="{DynamicResource InstallerSubtleFillBrush}" Background="{DynamicResource InstallerSubtleFillBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"> CornerRadius="{DynamicResource DesignCornerRadiusMd}">
<fi:FluentIcon Icon="CloudArrowDown" <TextBlock Classes="installer-icon"
IconVariant="Regular" Text="&#xE896;"
FontSize="20" /> FontSize="20" />
</Border> </Border>
<StackPanel Grid.Column="1" <StackPanel Grid.Column="1"
@@ -302,8 +301,8 @@
Command="{Binding BrowseCommand}"> Command="{Binding BrowseCommand}">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
<fi:FluentIcon Icon="FolderOpen" <TextBlock Classes="installer-icon"
IconVariant="Regular" /> Text="&#xE838;" />
<TextBlock Text="浏览" /> <TextBlock Text="浏览" />
</StackPanel> </StackPanel>
</Button> </Button>
@@ -341,8 +340,8 @@
<Border Classes="info-panel"> <Border Classes="info-panel">
<Grid ColumnDefinitions="Auto,*" <Grid ColumnDefinitions="Auto,*"
ColumnSpacing="10"> ColumnSpacing="10">
<fi:FluentIcon Icon="Shield" <TextBlock Classes="installer-icon"
IconVariant="Regular" Text="&#xEA18;"
Foreground="{DynamicResource InstallerAccentBrush}" Foreground="{DynamicResource InstallerAccentBrush}"
FontSize="18" /> FontSize="18" />
<TextBlock Grid.Column="1" <TextBlock Grid.Column="1"
@@ -416,8 +415,8 @@
Command="{Binding StartInstallCommand}"> Command="{Binding StartInstallCommand}">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
<fi:FluentIcon Icon="ArrowDownload" <TextBlock Classes="installer-icon"
IconVariant="Regular" /> Text="&#xE896;" />
<TextBlock Text="开始安装" /> <TextBlock Text="开始安装" />
</StackPanel> </StackPanel>
</Button> </Button>
@@ -426,8 +425,8 @@
IsEnabled="{Binding IsInstalling}"> IsEnabled="{Binding IsInstalling}">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
<fi:FluentIcon Icon="Dismiss" <TextBlock Classes="installer-icon"
IconVariant="Regular" /> Text="&#xE711;" />
<TextBlock Text="取消" /> <TextBlock Text="取消" />
</StackPanel> </StackPanel>
</Button> </Button>
@@ -454,8 +453,8 @@
Height="40" Height="40"
Background="{DynamicResource InstallerSubtleFillBrush}" Background="{DynamicResource InstallerSubtleFillBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"> CornerRadius="{DynamicResource DesignCornerRadiusMd}">
<fi:FluentIcon Icon="CheckmarkCircle" <TextBlock Classes="installer-icon"
IconVariant="Regular" Text="&#xE73E;"
Foreground="{DynamicResource InstallerSuccessBrush}" Foreground="{DynamicResource InstallerSuccessBrush}"
FontSize="22" /> FontSize="22" />
</Border> </Border>
@@ -473,8 +472,8 @@
Command="{Binding LaunchCommand}"> Command="{Binding LaunchCommand}">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
<fi:FluentIcon Icon="Play" <TextBlock Classes="installer-icon"
IconVariant="Regular" /> Text="&#xE768;" />
<TextBlock Text="打开阑山桌面" /> <TextBlock Text="打开阑山桌面" />
</StackPanel> </StackPanel>
</Button> </Button>
@@ -495,8 +494,8 @@
IsVisible="{Binding HasError}"> IsVisible="{Binding HasError}">
<Grid ColumnDefinitions="Auto,*" <Grid ColumnDefinitions="Auto,*"
ColumnSpacing="10"> ColumnSpacing="10">
<fi:FluentIcon Icon="ErrorCircle" <TextBlock Classes="installer-icon"
IconVariant="Regular" Text="&#xE783;"
Foreground="{DynamicResource InstallerErrorBrush}" Foreground="{DynamicResource InstallerErrorBrush}"
FontSize="18" /> FontSize="18" />
<TextBlock Grid.Column="1" <TextBlock Grid.Column="1"
@@ -511,8 +510,8 @@
Command="{Binding BackCommand}"> Command="{Binding BackCommand}">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
<fi:FluentIcon Icon="ArrowLeft" <TextBlock Classes="installer-icon"
IconVariant="Regular" /> Text="&#xE72B;" />
<TextBlock Text="上一步" /> <TextBlock Text="上一步" />
</StackPanel> </StackPanel>
</Button> </Button>
@@ -522,8 +521,8 @@
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
<TextBlock Text="下一步" /> <TextBlock Text="下一步" />
<fi:FluentIcon Icon="ArrowRight" <TextBlock Classes="installer-icon"
IconVariant="Regular" /> Text="&#xE72A;" />
</StackPanel> </StackPanel>
</Button> </Button>
</Grid> </Grid>

View File

@@ -5,7 +5,7 @@
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2"> <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security> <security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3"> <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" /> <requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges> </requestedPrivileges>
</security> </security>
</trustInfo> </trustInfo>

View File

@@ -87,31 +87,68 @@ internal sealed class DeploymentLocator
var explicitAppRoot = context.ExplicitAppRoot; var explicitAppRoot = context.ExplicitAppRoot;
var devModeConfigIgnored = !context.IsDebugMode && Views.ErrorWindow.CheckDevModeEnabled(); 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? resolvedPath;
string? source; string? source;
if (!string.IsNullOrWhiteSpace(explicitAppRoot)) if (!string.IsNullOrWhiteSpace(explicitAppRoot))
{ {
Logger.Info($"Trying explicit app root: {explicitAppRoot}");
var explicitRoot = Path.GetFullPath(explicitAppRoot); var explicitRoot = Path.GetFullPath(explicitAppRoot);
resolvedPath = TryResolveExplicitAppRoot(explicitRoot, executable, searchedPaths, out source); resolvedPath = TryResolveExplicitAppRoot(explicitRoot, executable, searchedPaths, out source);
} }
else else
{ {
Logger.Info("Trying published or portable host...");
resolvedPath = TryResolvePublishedOrPortableHost(executable, searchedPaths, out source); resolvedPath = TryResolvePublishedOrPortableHost(executable, searchedPaths, out source);
} }
if (resolvedPath is null && context.IsDebugMode) if (resolvedPath is null && context.IsDebugMode)
{ {
Logger.Info("Debug mode: trying debug host paths...");
resolvedPath = TryResolveDebugHost(executable, searchedPaths, out source); resolvedPath = TryResolveDebugHost(executable, searchedPaths, out source);
} }
if (resolvedPath is null) if (resolvedPath is null)
{ {
Logger.Warn("Standard resolution failed, trying legacy fallback...");
resolvedPath = ResolveHostExecutablePathLegacy(); resolvedPath = ResolveHostExecutablePathLegacy();
if (!string.IsNullOrWhiteSpace(resolvedPath)) if (!string.IsNullOrWhiteSpace(resolvedPath))
{ {
searchedPaths.Add(Path.GetFullPath(resolvedPath)); searchedPaths.Add(Path.GetFullPath(resolvedPath));
source = "legacy_fallback"; 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}");
} }
} }

View File

@@ -19,12 +19,15 @@ internal sealed class AirAppRuntimeBridge
public async Task EnsureStartedAsync() public async Task EnsureStartedAsync()
{ {
Logger.Info($"AIRAPP: Checking if AirApp Runtime is available. AppRoot='{_appRoot}'");
if (await TryGetStatusAsync().ConfigureAwait(false) is not null) if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
{ {
Logger.Info("AirApp Runtime is already available."); Logger.Info("AIRAPP: AirApp Runtime is already available.");
return; return;
} }
Logger.Info("AIRAPP: Starting AirApp Runtime...");
Process? process; Process? process;
try try
{ {
@@ -36,24 +39,28 @@ internal sealed class AirAppRuntimeBridge
} }
catch (Exception ex) 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; 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++) for (var attempt = 1; attempt <= ConnectAttempts; attempt++)
{ {
Logger.Info($"AIRAPP: Attempt {attempt}/{ConnectAttempts} - Checking IPC connection...");
if (await TryGetStatusAsync().ConfigureAwait(false) is not null) if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
{ {
Logger.Info("AirApp Runtime IPC is ready."); Logger.Info("AIRAPP: AirApp Runtime IPC is ready.");
return; 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) public async Task AttachHostAsync(int hostProcessId)
@@ -65,10 +72,15 @@ internal sealed class AirAppRuntimeBridge
try try
{ {
using var cts = new CancellationTokenSource();
using var client = new LanMountainDesktopIpcClient(); 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 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}."); Logger.Info($"AirApp Runtime host attach completed. Accepted={result.Accepted}; Code='{result.Code}'; HostPid={hostProcessId}.");
} }
catch (Exception ex) catch (Exception ex)
@@ -81,13 +93,29 @@ internal sealed class AirAppRuntimeBridge
{ {
try try
{ {
using var cts = new CancellationTokenSource();
using var client = new LanMountainDesktopIpcClient(); 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>(); 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; return null;
} }
} }

View File

@@ -144,8 +144,18 @@ internal sealed class LauncherOrchestrator
return; 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(); _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; splashWindow.Closed += splashClosedHandler;

View File

@@ -208,13 +208,15 @@ internal sealed class HostLaunchService
private static async Task EnsureAirAppRuntimeStartedAsync(string appRoot, string? dataRoot) private static async Task EnsureAirAppRuntimeStartedAsync(string appRoot, string? dataRoot)
{ {
Logger.Info("HOST LAUNCH: Attempting to pre-start AirApp Runtime...");
try try
{ {
await new AirAppRuntimeBridge(appRoot, dataRoot).EnsureStartedAsync().ConfigureAwait(false); await new AirAppRuntimeBridge(appRoot, dataRoot).EnsureStartedAsync().ConfigureAwait(false);
Logger.Info("HOST LAUNCH: AirApp Runtime pre-start completed.");
} }
catch (Exception ex) 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 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); var process = Process.Start(startInfo);
Logger.Info( Logger.Info(
$"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? "<none>"}'; Path='{plan.HostPath}'; " + $"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? "<none>"}'; Path='{plan.HostPath}'; " +
@@ -257,15 +264,30 @@ internal sealed class HostLaunchService
if (process is null) 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); 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); return HostStartAttempt.Started(startMode, process, plan);
} }
catch (Exception ex) 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); return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan);
} }
} }

View File

@@ -86,7 +86,7 @@ internal sealed class HostStartupMonitor
]).ConfigureAwait(false); ]).ConfigureAwait(false);
if (!connected) 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 else
{ {
@@ -106,6 +106,8 @@ internal sealed class HostStartupMonitor
var nextShellStatusPollAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.ShellStatusPollInterval; var nextShellStatusPollAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.ShellStatusPollInterval;
var ipcReconnectAttemptIndex = 0; var ipcReconnectAttemptIndex = 0;
var activationRetryAttempted = false; var activationRetryAttempted = false;
var lastIpcConnectionFailureReported = DateTimeOffset.MinValue;
var ipcConnectionFailureCount = 0;
while (true) while (true)
{ {
@@ -224,6 +226,7 @@ internal sealed class HostStartupMonitor
if (connected) if (connected)
{ {
ipcConnected = true; 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.") var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.")
.ConfigureAwait(false); .ConfigureAwait(false);
if (shellSuccess is not null) if (shellSuccess is not null)
@@ -232,6 +235,18 @@ internal sealed class HostStartupMonitor
continue; 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; nextReconnectAttemptAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.IpcReconnectInterval;
} }
@@ -263,6 +278,16 @@ internal sealed class HostStartupMonitor
nextCheckpointAt = softTimeoutAt; nextCheckpointAt = softTimeoutAt;
} }
if (!ipcConnected && nextReconnectAttemptAt < nextCheckpointAt)
{
nextCheckpointAt = nextReconnectAttemptAt;
}
if (ipcConnected && nextShellStatusPollAt < nextCheckpointAt)
{
nextCheckpointAt = nextShellStatusPollAt;
}
var delay = nextCheckpointAt - now; var delay = nextCheckpointAt - now;
if (delay > TimeSpan.FromSeconds(1)) if (delay > TimeSpan.FromSeconds(1))
{ {
@@ -351,11 +376,11 @@ internal sealed class HostStartupMonitor
if (!connected && !request.HostProcess.HasExited) if (!connected && !request.HostProcess.HasExited)
{ {
request.AttemptRegistry.MarkOwnedWaitingForShell("Host process is still running, but public IPC is not ready yet."); 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( return new Outcome(
true, false,
"startup_pending", "ipc_connection_failed",
"Host process is still running; Launcher will not start another process while public IPC finishes startup.", $"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, recoveryActivationAttempted,
request.ComposeLaunchDetails(true, recoveryActivationAttempted)); request.ComposeLaunchDetails(true, recoveryActivationAttempted));
} }

View File

@@ -89,6 +89,14 @@ internal sealed class StartupAttemptRegistry
ExecuteWithLock(() => ExecuteWithLock(() =>
{ {
var existing = LoadUnsafe(); 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)) if (existing is not null && IsCoordinatorLive(existing))
{ {
active = Clone(existing); active = Clone(existing);
@@ -145,6 +153,34 @@ internal sealed class StartupAttemptRegistry
return reserved is not null; 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() public StartupAttemptRecord? GetOwnedAttempt()
{ {
StartupAttemptRecord? result = null; StartupAttemptRecord? result = null;

View File

@@ -2,22 +2,26 @@ namespace LanMountainDesktop.Launcher.Startup;
internal static class StartupTimeoutPolicy internal static class StartupTimeoutPolicy
{ {
public static readonly TimeSpan SoftTimeout = TimeSpan.FromSeconds(30); public static readonly TimeSpan SoftTimeout = TimeSpan.FromSeconds(45);
public static readonly TimeSpan HardTimeout = TimeSpan.FromSeconds(120); public static readonly TimeSpan HardTimeout = TimeSpan.FromSeconds(180);
/// <summary>Initial Public IPC connect attempt (AOT cold start may be slower).</summary> /// <summary>Initial Public IPC connect attempt (AOT cold start is significantly slower).</summary>
public static readonly TimeSpan InitialIpcConnectTimeout = TimeSpan.FromMilliseconds(1200); public static readonly TimeSpan InitialIpcConnectTimeout = TimeSpan.FromMilliseconds(3000);
/// <summary>Subsequent reconnect attempts use increasing per-try timeouts.</summary> /// <summary>Subsequent reconnect attempts use increasing per-try timeouts.</summary>
public static readonly TimeSpan[] IpcReconnectAttemptTimeouts = public static readonly TimeSpan[] IpcReconnectAttemptTimeouts =
[ [
TimeSpan.FromMilliseconds(800),
TimeSpan.FromMilliseconds(1500), TimeSpan.FromMilliseconds(1500),
TimeSpan.FromMilliseconds(3000), 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 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);
} }

View File

@@ -212,6 +212,33 @@ public sealed class OnlineInstallerCoreTests : IDisposable
Assert.ThrowsAny<Exception>(() => InstallerPathGuard.NormalizeInstallPath(path)); 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] [Fact]
public async Task FilesPackageInstaller_DeploysFullPackageWithCurrentMarker() public async Task FilesPackageInstaller_DeploysFullPackageWithCurrentMarker()
{ {

View File

@@ -246,6 +246,9 @@ public partial class App : Application
ReportStartupProgress(StartupStage.Initializing, 10, "Initializing application..."); ReportStartupProgress(StartupStage.Initializing, 10, "Initializing application...");
ReportStartupProgress(StartupStage.LoadingSettings, 20, "Loading settings..."); ReportStartupProgress(StartupStage.LoadingSettings, 20, "Loading settings...");
} }
// 启动心跳线程,确保启动器能检测到主应用的活跃状态
_ = StartLauncherHeartbeatAsync();
} }
catch (Exception ex) 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) private void ReportStartupProgress(StartupStage stage, int percent, string message)
{ {
QueueOrSendLauncherProgress(new StartupProgressMessage QueueOrSendLauncherProgress(new StartupProgressMessage
@@ -1824,11 +1860,22 @@ public partial class App : Application
_publicIpcHostService.Start(); _publicIpcHostService.Start();
AppLogger.Info( AppLogger.Info(
"PublicIpc", "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) 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
{
// 忽略控制台写入失败
}
} }
} }

View File

@@ -116,8 +116,8 @@ public sealed class ComponentRegistry
"Class Schedule", "Class Schedule",
"CalendarDate", "CalendarDate",
"Date", "Date",
MinWidthCells: 2, MinWidthCells: 4,
MinHeightCells: 4, MinHeightCells: 3,
AllowStatusBarPlacement: false, AllowStatusBarPlacement: false,
AllowDesktopPlacement: true, AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free), ResizeMode: DesktopComponentResizeMode.Free),

View File

@@ -683,18 +683,18 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
var scale = ResolveScale(); var scale = ResolveScale();
var cardRadius = ComponentChromeCornerRadiusHelper.Small(); var cardRadius = ComponentChromeCornerRadiusHelper.Small();
var timeFontSize = Math.Clamp(11 * scale, 8, 14); var timeFontSize = Math.Clamp(16 * scale, 12, 22);
var courseNameFontSize = Math.Clamp(14 * scale, 10, 18); var courseNameFontSize = Math.Clamp(20 * scale, 16, 28);
var detailFontSize = Math.Clamp(11 * scale, 8, 14); var detailFontSize = Math.Clamp(15 * scale, 12, 20);
var progressFontSize = Math.Clamp(10 * scale, 7, 12); var progressFontSize = Math.Clamp(13 * scale, 10, 16);
var cardPadding = new Thickness( var cardPadding = new Thickness(
Math.Clamp(10 * scale, 6, 14), Math.Clamp(14 * scale, 10, 20),
Math.Clamp(8 * scale, 5, 12), Math.Clamp(12 * scale, 8, 18),
Math.Clamp(10 * scale, 6, 14), Math.Clamp(14 * scale, 10, 20),
Math.Clamp(8 * scale, 5, 12)); Math.Clamp(12 * scale, 8, 18));
var timeColumnWidth = Math.Clamp(44 * scale, 30, 56); var timeColumnWidth = Math.Clamp(60 * scale, 45, 80);
var accentBarWidth = Math.Clamp(3 * scale, 2, 4); var accentBarWidth = Math.Clamp(4 * scale, 3, 6);
var progressBarHeight = Math.Clamp(3 * scale, 2, 4); var progressBarHeight = Math.Clamp(4 * scale, 3, 6);
for (var i = 0; i < _courseItems.Count; i++) for (var i = 0; i < _courseItems.Count; i++)
{ {
@@ -894,10 +894,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
var itemBorder = new Border var itemBorder = new Border
{ {
Padding = new Thickness( Padding = new Thickness(
Math.Clamp(10 * scale, 6, 14), Math.Clamp(14 * scale, 10, 20),
Math.Clamp(2 * scale, 1, 4), Math.Clamp(4 * scale, 2, 6),
Math.Clamp(10 * scale, 6, 14), Math.Clamp(14 * scale, 10, 20),
Math.Clamp(2 * scale, 1, 4)), Math.Clamp(4 * scale, 2, 6)),
Background = Brushes.Transparent, Background = Brushes.Transparent,
Child = itemGrid Child = itemGrid
}; };
@@ -1022,11 +1022,11 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
HeaderGrid.ColumnSpacing = Math.Clamp(8 * scale, 4, 14); HeaderGrid.ColumnSpacing = Math.Clamp(8 * scale, 4, 14);
DateGroup.Spacing = Math.Clamp(1.5 * scale, 0.5, 3); 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 dateFontByScale = Math.Clamp(36 * scale, 20, 48);
var weekdayFontByScale = Math.Clamp(14 * scale, 10, 18); var weekdayFontByScale = Math.Clamp(18 * scale, 14, 24);
var classCountFontByScale = Math.Clamp(12 * scale, 9, 15); var classCountFontByScale = Math.Clamp(15 * scale, 12, 20);
var availableWidth = Math.Max(1, Bounds.Width - headerPadding.Left - headerPadding.Right); var availableWidth = Math.Max(1, Bounds.Width - headerPadding.Left - headerPadding.Right);
var dateGroupEstimatedWidth = dateFontByScale * 0.6 * 3 + DateGroup.Spacing * 2; var dateGroupEstimatedWidth = dateFontByScale * 0.6 * 3 + DateGroup.Spacing * 2;