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
39 changes: 30 additions & 9 deletions src/MultiSeat.Service/Sessions/ProcessInjector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ public async Task<int> 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})",
Expand Down Expand Up @@ -131,7 +132,7 @@ public async Task<int> LaunchInSessionAsync(
cb = Marshal.SizeOf<Kernel32.StartupInfo>(),
lpDesktop = @"WinSta0\Default",
dwFlags = Kernel32.STARTF_USESHOWWINDOW,
wShowWindow = Kernel32.SW_SHOW // games should be visible
wShowWindow = ShowWindowFor(minimized)
};

var creationFlags = CreationFlagsFor(launchPlan.Supervised);
Expand Down Expand Up @@ -209,6 +210,15 @@ internal static uint CreationFlagsFor(bool supervised) =>
(supervised ? Kernel32.CREATE_NO_WINDOW : Kernel32.CREATE_NEW_CONSOLE) |
Kernel32.NORMAL_PRIORITY_CLASS;

/// <summary>
/// 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.
/// </summary>
internal static short ShowWindowFor(bool minimized) =>
minimized ? Kernel32.SW_SHOWMINNOACTIVE : Kernel32.SW_SHOW;

/// <summary>
/// Launch Apollo streaming server inside a target session.
/// Special handling: Apollo needs specific env vars and the config path.
Expand All @@ -220,18 +230,29 @@ public Task<int> 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);
}

/// <summary>What a seat Apollo launch asks LaunchInSessionAsync for.</summary>
internal readonly record struct ApolloLaunchRequest(
string ExePath, string Arguments, string? WorkingDir, bool Minimized);

/// <summary>
/// Builds the seat Apollo launch request. The one place that decides Apollo starts minimized
/// (issue #120).
/// </summary>
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);

/// <summary>
/// Refuse to launch a seat's process into the console session.
Expand Down
45 changes: 45 additions & 0 deletions src/MultiSeat.Tests/Sessions/SteamIsolationOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading