Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 100 additions & 74 deletions App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,50 +63,22 @@ public App()
protected override void OnStartup(StartupEventArgs e)
{
// Parse command line arguments early so special startup modes can short-circuit normal flow.
bool startMinimized = false;
bool isAutostart = false;
bool isSmokeTest = false;
bool registerLaunchTask = false;
bool launchedViaTask = false;
#if DEBUG
bool isTestMode = false;
#endif
var startupMode = StartupMode.Parse(e.Args);
bool effectiveStartMinimized = false;
ApplicationSettingsModel? loadedSettings = null;

foreach (var arg in e.Args)
effectiveStartMinimized = startupMode.StartMinimized;

if (startupMode.IsSmokeTest)
{
switch (arg.ToLowerInvariant())
{
#if DEBUG
case "--test":
isTestMode = true;
break;
#endif
case "--smoke-test":
isSmokeTest = true;
break;
case "--start-minimized":
startMinimized = true;
break;
case "--autostart":
isAutostart = true;
break;
case "--startup": // Alternative startup argument
isAutostart = true;
startMinimized = true;
break;
case RegisterLaunchTaskArgument:
registerLaunchTask = true;
break;
case LaunchedViaTaskArgument:
launchedViaTask = true;
break;
}
var smokeLogger = this.ServiceProvider.GetRequiredService<ILogger<App>>();
var smokeTestResult = this.RunSmokeTestWithTimeout(smokeLogger, TimeSpan.FromSeconds(10));
Environment.ExitCode = smokeTestResult;
this.Shutdown(smokeTestResult);
Environment.Exit(smokeTestResult);
return;
}

effectiveStartMinimized = startMinimized;

// Set up global exception handlers first
AppDomain.CurrentDomain.UnhandledException += this.OnUnhandledException;
this.DispatcherUnhandledException += this.OnDispatcherUnhandledException;
Expand All @@ -130,14 +102,14 @@ protected override void OnStartup(StartupEventArgs e)
}
else
{
if (launchedViaTask)
if (startupMode.LaunchedViaTask)
{
logger.LogError("Application was launched via managed task marker but is still not elevated.");
}
#if DEBUG
else if (!isSmokeTest && !isTestMode)
else if (!startupMode.IsTestMode)
#else
else if (!isSmokeTest)
else
#endif
{
var launchedElevatedInstance = Task.Run(async () => await elevatedTaskService.TryRunLaunchTaskAsync()).GetAwaiter().GetResult();
Expand All @@ -148,7 +120,7 @@ protected override void OnStartup(StartupEventArgs e)
return;
}

if (!registerLaunchTask)
if (!startupMode.RegisterLaunchTask)
{
logger.LogInformation("Managed elevated launch task is unavailable. Requesting one-time elevation to bootstrap persistent launch.");
var restartInitiated = Task.Run(async () => await elevationService.RestartWithElevation(new[] { RegisterLaunchTaskArgument })).GetAwaiter().GetResult();
Expand All @@ -160,43 +132,38 @@ protected override void OnStartup(StartupEventArgs e)
}

#if DEBUG
if (!isSmokeTest && !isTestMode)
if (!startupMode.IsTestMode)
#else
if (!isSmokeTest)
if (true)
#endif
{
logger.LogError("ThreadPilot requires administrator privileges and cannot continue without elevation.");
this.ShowElevationRequiredMessage();
this.Shutdown(1);
return;
}

logger.LogWarning("Application is running without administrator privileges in smoke test mode.");
}

// Enforce single-instance after elevation bootstrap logic to avoid mutex races during handoff.
if (!isSmokeTest)
bool createdNew;
this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew);
if (!createdNew)
{
bool createdNew;
this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew);
if (!createdNew)
{
System.Windows.MessageBox.Show(
"ThreadPilot is already running.",
"Instance already open",
MessageBoxButton.OK,
MessageBoxImage.Information);
System.Windows.MessageBox.Show(
"ThreadPilot is already running.",
"Instance already open",
MessageBoxButton.OK,
MessageBoxImage.Information);

this.Shutdown();
return;
}
this.Shutdown();
return;
}

base.OnStartup(e);

// Check for test mode
#if DEBUG
if (isTestMode)
if (startupMode.IsTestMode)
{
// Run in console test mode
AllocConsole();
Expand All @@ -209,13 +176,6 @@ protected override void OnStartup(StartupEventArgs e)
}
#endif

if (isSmokeTest)
{
var smokeTestResult = Task.Run(async () => await this.RunSmokeTestAsync(logger)).GetAwaiter().GetResult();
this.Shutdown(smokeTestResult);
return;
}

try
{
var settingsService = this.ServiceProvider.GetRequiredService<IApplicationSettingsService>();
Expand All @@ -226,7 +186,7 @@ protected override void OnStartup(StartupEventArgs e)
var settings = settingsService.Settings;
loadedSettings = settings;
localizationService.ApplyLanguage(settings.Language);
effectiveStartMinimized = startMinimized || settings.StartMinimized;
effectiveStartMinimized = startupMode.StartMinimized || settings.StartMinimized;
var useDarkTheme = settings.HasUserThemePreference
? settings.UseDarkTheme
: themeService.GetSystemUsesDarkTheme();
Expand Down Expand Up @@ -258,7 +218,7 @@ protected override void OnStartup(StartupEventArgs e)
throw new InvalidOperationException("MainWindow could not be created");
}

var startupWindowBehavior = StartupWindowBehavior.Resolve(isAutostart, effectiveStartMinimized);
var startupWindowBehavior = StartupWindowBehavior.Resolve(startupMode.IsAutostart, effectiveStartMinimized);
var showStartupSuggestion = loadedSettings != null
&& StartupMinimizedSuggestionPolicy.ShouldShow(loadedSettings, startupWindowBehavior);
mainWindow.ConfigureStartupMode(
Expand Down Expand Up @@ -301,16 +261,33 @@ protected override void OnStartup(StartupEventArgs e)
}
}

private async Task<int> RunSmokeTestAsync(ILogger logger)
private int RunSmokeTestWithTimeout(ILogger logger, TimeSpan timeout)
{
var smokeTestTask = Task.Run(() => this.RunSmokeTest(logger));
if (smokeTestTask.Wait(timeout))
{
return smokeTestTask.GetAwaiter().GetResult();
}

logger.LogError("ThreadPilot smoke test timed out after {TimeoutSeconds} seconds", timeout.TotalSeconds);
return 2;
}

private int RunSmokeTest(ILogger logger)
{
try
{
logger.LogInformation("Starting ThreadPilot smoke test");

var settingsService = this.ServiceProvider.GetRequiredService<IApplicationSettingsService>();
await settingsService.LoadSettingsAsync().ConfigureAwait(false);
_ = this.ServiceProvider.GetRequiredService<ProcessViewModel>();
_ = this.ServiceProvider.GetRequiredService<PowerPlanViewModel>();
_ = this.ServiceProvider.GetRequiredService<ILoggerFactory>();
_ = this.ServiceProvider.GetRequiredService<IApplicationSettingsService>();
_ = this.ServiceProvider.GetRequiredService<IThemeService>();
_ = this.ServiceProvider.GetRequiredService<ILocalizationService>();

if (!System.IO.Directory.Exists(AppContext.BaseDirectory))
{
throw new InvalidOperationException("Application base directory was not found.");
}

logger.LogInformation("ThreadPilot smoke test completed successfully");
return 0;
Expand All @@ -322,6 +299,55 @@ private async Task<int> RunSmokeTestAsync(ILogger logger)
}
}

private readonly struct StartupMode
{
public bool StartMinimized { get; init; }

public bool IsAutostart { get; init; }

public bool IsSmokeTest { get; init; }

public bool RegisterLaunchTask { get; init; }

public bool LaunchedViaTask { get; init; }

public bool IsTestMode { get; init; }

public static StartupMode Parse(IEnumerable<string> args)
{
var mode = default(StartupMode);
foreach (var arg in args)
{
switch (arg.ToLowerInvariant())
{
case "--test":
mode = mode with { IsTestMode = true };
break;
case "--smoke-test":
mode = mode with { IsSmokeTest = true };
break;
case "--start-minimized":
mode = mode with { StartMinimized = true };
break;
case "--autostart":
mode = mode with { IsAutostart = true };
break;
case "--startup":
mode = mode with { IsAutostart = true, StartMinimized = true };
break;
case RegisterLaunchTaskArgument:
mode = mode with { RegisterLaunchTask = true };
break;
case LaunchedViaTaskArgument:
mode = mode with { LaunchedViaTask = true };
break;
}
}

return mode;
}
}

protected override void OnExit(ExitEventArgs e)
{
AppDomain.CurrentDomain.UnhandledException -= this.OnUnhandledException;
Expand Down
78 changes: 78 additions & 0 deletions Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
namespace ThreadPilot.Core.Tests
{
public sealed class AppSmokeTestStartupTests
{
[Fact]
public void OnStartup_HandlesSmokeTestBeforeElevationSingleInstanceAndWindowStartup()
{
var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs"));

var smokeTestBranchIndex = source.IndexOf("if (startupMode.IsSmokeTest)", StringComparison.Ordinal);
var elevationIndex = source.IndexOf("GetRequiredService<IElevationService>", StringComparison.Ordinal);
var mutexIndex = source.IndexOf("Global\\\\ThreadPilot_SingleInstance", StringComparison.Ordinal);
var baseStartupIndex = source.IndexOf("base.OnStartup(e);", StringComparison.Ordinal);
var mainWindowIndex = source.IndexOf("GetRequiredService<MainWindow>", StringComparison.Ordinal);

Assert.NotEqual(-1, smokeTestBranchIndex);
Assert.True(smokeTestBranchIndex < elevationIndex);
Assert.True(smokeTestBranchIndex < mutexIndex);
Assert.True(smokeTestBranchIndex < baseStartupIndex);
Assert.True(smokeTestBranchIndex < mainWindowIndex);
}

[Fact]
public void SmokeTestMode_ExitsTheProcessAfterShutdownToAvoidDispatcherOrTimerHangs()
{
var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs"));

var smokeTestBranch = ExtractSection(
source,
"if (startupMode.IsSmokeTest)",
" // Set up global exception handlers first");

Assert.Contains("this.Shutdown(smokeTestResult);", smokeTestBranch, StringComparison.Ordinal);
Assert.Contains("Environment.Exit(smokeTestResult);", smokeTestBranch, StringComparison.Ordinal);
}

[Fact]
public void RunSmokeTest_DoesNotResolveUiViewModelsOrMainWindow()
{
var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs"));
var smokeTestMethod = ExtractSection(
source,
"private int RunSmokeTest",
"protected override void OnExit");

Assert.DoesNotContain("ProcessViewModel", smokeTestMethod, StringComparison.Ordinal);
Assert.DoesNotContain("PowerPlanViewModel", smokeTestMethod, StringComparison.Ordinal);
Assert.DoesNotContain("MainWindow", smokeTestMethod, StringComparison.Ordinal);
}

private static string ExtractSection(string source, string startMarker, string endMarker)
{
var startIndex = source.IndexOf(startMarker, StringComparison.Ordinal);
Assert.NotEqual(-1, startIndex);

var endIndex = source.IndexOf(endMarker, startIndex, StringComparison.Ordinal);
Assert.NotEqual(-1, endIndex);

return source[startIndex..endIndex];
}

private static string GetRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj")))
{
directory = directory.Parent;
}

if (directory == null)
{
throw new InvalidOperationException("Repository root was not found.");
}

return directory.FullName;
}
}
}
Loading