diff --git a/src/MultiSeat.Service/Sessions/ProcessInjector.cs b/src/MultiSeat.Service/Sessions/ProcessInjector.cs index 80c8b92..f6db956 100644 --- a/src/MultiSeat.Service/Sessions/ProcessInjector.cs +++ b/src/MultiSeat.Service/Sessions/ProcessInjector.cs @@ -49,7 +49,8 @@ public async Task LaunchInSessionAsync( string? arguments = null, string? workingDir = null, CancellationToken ct = default, - bool allowConsoleSession = false) + bool allowConsoleSession = false, + bool minimized = false) { _logger.LogInformation( "Launching '{Exe}' in session {Sid} (account: {Account})", @@ -131,7 +132,7 @@ public async Task LaunchInSessionAsync( cb = Marshal.SizeOf(), lpDesktop = @"WinSta0\Default", dwFlags = Kernel32.STARTF_USESHOWWINDOW, - wShowWindow = Kernel32.SW_SHOW // games should be visible + wShowWindow = ShowWindowFor(minimized) }; var creationFlags = CreationFlagsFor(launchPlan.Supervised); @@ -209,6 +210,15 @@ internal static uint CreationFlagsFor(bool supervised) => (supervised ? Kernel32.CREATE_NO_WINDOW : Kernel32.CREATE_NEW_CONSOLE) | Kernel32.NORMAL_PRIORITY_CLASS; + /// + /// Initial window state for a launch in a seat. Games are shown, as they always were. Seat + /// Apollo is started minimized without taking focus, so its console does not sit in the seat's + /// view. It keeps its console (CREATE_NEW_CONSOLE, not CREATE_NO_WINDOW) so GetConsoleWindow + /// stays non-NULL for Apollo's restart and quit logic; see issue #120. + /// + internal static short ShowWindowFor(bool minimized) => + minimized ? Kernel32.SW_SHOWMINNOACTIVE : Kernel32.SW_SHOW; + /// /// Launch Apollo streaming server inside a target session. /// Special handling: Apollo needs specific env vars and the config path. @@ -220,18 +230,29 @@ public Task LaunchApolloInSessionAsync( string configPath, CancellationToken ct) { - var arguments = $"\"{configPath}\""; + var request = ApolloLaunchRequestFor(apolloExePath, configPath); + return LaunchInSessionAsync( + sessionId, accountName, + request.ExePath, request.Arguments, + request.WorkingDir, + ct, + minimized: request.Minimized); + } + /// What a seat Apollo launch asks LaunchInSessionAsync for. + internal readonly record struct ApolloLaunchRequest( + string ExePath, string Arguments, string? WorkingDir, bool Minimized); + + /// + /// Builds the seat Apollo launch request. The one place that decides Apollo starts minimized + /// (issue #120). + /// + internal static ApolloLaunchRequest ApolloLaunchRequestFor(string apolloExePath, string configPath) => // Working dir = per-seat config dir (not the Apollo install dir). // Apollo resolves sunshine_state.json from {workingDir}/config/sunshine_state.json. // Each seat must have its own working dir so they get distinct UUIDs and // Moonlight can list them as separate servers. - return LaunchInSessionAsync( - sessionId, accountName, - apolloExePath, arguments, - Path.GetDirectoryName(configPath), - ct); - } + new(apolloExePath, $"\"{configPath}\"", Path.GetDirectoryName(configPath), Minimized: true); /// /// Refuse to launch a seat's process into the console session. diff --git a/src/MultiSeat.Tests/Sessions/SteamIsolationOptionsTests.cs b/src/MultiSeat.Tests/Sessions/SteamIsolationOptionsTests.cs index 64230f6..d9e9fe9 100644 --- a/src/MultiSeat.Tests/Sessions/SteamIsolationOptionsTests.cs +++ b/src/MultiSeat.Tests/Sessions/SteamIsolationOptionsTests.cs @@ -69,4 +69,49 @@ public void OrdinaryLaunchKeepsItsNewConsole() Kernel32.CREATE_UNICODE_ENVIRONMENT | Kernel32.CREATE_NEW_CONSOLE | Kernel32.NORMAL_PRIORITY_CLASS, flags); } + + // Seat Apollo starts minimized without stealing focus (issue #120); everything else is shown. + // Apollo keeps its console, so GetConsoleWindow stays non-NULL. + [Fact] + public void ApolloLaunchIsMinimizedWithoutActivation() + { + Assert.Equal(Kernel32.SW_SHOWMINNOACTIVE, ProcessInjector.ShowWindowFor(minimized: true)); + } + + [Fact] + public void OrdinaryLaunchStaysVisible() + { + Assert.Equal(Kernel32.SW_SHOW, ProcessInjector.ShowWindowFor(minimized: false)); + } + + [Fact] + public void ApolloLaunchKeepsItsConsoleAndNeverUsesNoWindow() + { + // Apollo is never supervised, so it takes the ordinary flags. + var flags = ProcessInjector.CreationFlagsFor(supervised: false); + Assert.True((flags & Kernel32.CREATE_NEW_CONSOLE) != 0); + Assert.True((flags & Kernel32.CREATE_NO_WINDOW) == 0); + } + + [Fact] + public void SupervisedLaunchWindowStateIsUnchanged() + { + // The supervised Steam launch is not minimized: only the Apollo launch asks for that. + Assert.Equal(Kernel32.SW_SHOW, ProcessInjector.ShowWindowFor(minimized: false)); + Assert.True((ProcessInjector.CreationFlagsFor(supervised: true) & Kernel32.CREATE_NO_WINDOW) != 0); + } + + // The wiring: the Apollo launch request is minimized, quotes the config path, and runs from + // the config file's directory. + [Fact] + public void ApolloLaunchRequestIsMinimizedWithSeatConfigAndWorkingDir() + { + var request = ProcessInjector.ApolloLaunchRequestFor( + @"C:\Program Files\ApolloVibe\sunshine.exe", + @"C:\Seats\seat1\config\sunshine.conf"); + Assert.True(request.Minimized); + Assert.Equal(@"C:\Program Files\ApolloVibe\sunshine.exe", request.ExePath); + Assert.Equal("\"C:\\Seats\\seat1\\config\\sunshine.conf\"", request.Arguments); + Assert.Equal(@"C:\Seats\seat1\config", request.WorkingDir); + } }