From 80dfe8ada8f886a9747f8302d6edc941a8df305d Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Tue, 20 Oct 2026 19:22:56 -0500 Subject: [PATCH 1/2] fix(service): start seat Apollo minimized so it opens no visible console window (#120) Seat Apollo went through the generic launch path, which shows the new console (SW_SHOW). Pass SW_SHOWMINNOACTIVE for the Apollo launch only, keeping CREATE_NEW_CONSOLE so GetConsoleWindow stays non-NULL. Games and the supervised Steam launch keep SW_SHOW. Co-Authored-By: Claude Sonnet 5.5 --- .../Sessions/ProcessInjector.cs | 17 ++++++++-- .../Sessions/SteamIsolationOptionsTests.cs | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/MultiSeat.Service/Sessions/ProcessInjector.cs b/src/MultiSeat.Service/Sessions/ProcessInjector.cs index 80c8b92..0c8e42c 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. @@ -230,7 +240,8 @@ public Task LaunchApolloInSessionAsync( sessionId, accountName, apolloExePath, arguments, Path.GetDirectoryName(configPath), - ct); + ct, + minimized: true); } /// diff --git a/src/MultiSeat.Tests/Sessions/SteamIsolationOptionsTests.cs b/src/MultiSeat.Tests/Sessions/SteamIsolationOptionsTests.cs index 64230f6..f5f3699 100644 --- a/src/MultiSeat.Tests/Sessions/SteamIsolationOptionsTests.cs +++ b/src/MultiSeat.Tests/Sessions/SteamIsolationOptionsTests.cs @@ -69,4 +69,35 @@ 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); + } } From bfc80fea90b66833602bd5c46013113324a74cdd Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Tue, 20 Oct 2026 19:40:31 -0500 Subject: [PATCH 2/2] test(service): pin the seat Apollo launch request so it stays minimized (#120) Move the Apollo launch arguments, working dir and the minimized decision into a pure helper, so a test fails if the Apollo launch stops asking for a minimized window. Co-Authored-By: Claude Sonnet 5.5 --- .../Sessions/ProcessInjector.cs | 26 +++++++++++++------ .../Sessions/SteamIsolationOptionsTests.cs | 14 ++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/MultiSeat.Service/Sessions/ProcessInjector.cs b/src/MultiSeat.Service/Sessions/ProcessInjector.cs index 0c8e42c..f6db956 100644 --- a/src/MultiSeat.Service/Sessions/ProcessInjector.cs +++ b/src/MultiSeat.Service/Sessions/ProcessInjector.cs @@ -230,19 +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, - minimized: true); - } + 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 f5f3699..d9e9fe9 100644 --- a/src/MultiSeat.Tests/Sessions/SteamIsolationOptionsTests.cs +++ b/src/MultiSeat.Tests/Sessions/SteamIsolationOptionsTests.cs @@ -100,4 +100,18 @@ public void SupervisedLaunchWindowStateIsUnchanged() 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); + } }