From 2b10b55b382ed1eb9ba04a7307a69f23c1daa9fa Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Thu, 23 Jul 2026 21:31:47 -0500 Subject: [PATCH 1/6] Create abstraction around elevated process launching for better testing --- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../PowerShell/CurrentProcessInfo.cs | 26 ++ .../PowerShell/ElevatedProcessLauncher.cs | 22 ++ .../PowerShell/ElevatedScriptInterceptor.cs | 52 ++-- .../PowerShell/ICurrentProcessInfo.cs | 21 ++ .../PowerShell/IElevatedProcessLauncher.cs | 32 +++ .../Tests/ElevatedScriptInterceptorTests.cs | 229 ++++++++++++++++++ 7 files changed, 349 insertions(+), 35 deletions(-) create mode 100644 LANCommander.Launcher.Services/PowerShell/CurrentProcessInfo.cs create mode 100644 LANCommander.Launcher.Services/PowerShell/ElevatedProcessLauncher.cs create mode 100644 LANCommander.Launcher.Services/PowerShell/ICurrentProcessInfo.cs create mode 100644 LANCommander.Launcher.Services/PowerShell/IElevatedProcessLauncher.cs create mode 100644 LANCommander.Launcher.Tests/Tests/ElevatedScriptInterceptorTests.cs diff --git a/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs b/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs index 6e20a892..74382799 100644 --- a/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs +++ b/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs @@ -35,6 +35,8 @@ namespace LANCommander.Launcher.Services.Extensions services.AddSingleton(); #endregion + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => diff --git a/LANCommander.Launcher.Services/PowerShell/CurrentProcessInfo.cs b/LANCommander.Launcher.Services/PowerShell/CurrentProcessInfo.cs new file mode 100644 index 00000000..f805d7ed --- /dev/null +++ b/LANCommander.Launcher.Services/PowerShell/CurrentProcessInfo.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace LANCommander.Launcher.Services; + +public class CurrentProcessInfo : ICurrentProcessInfo +{ + public string ExecutablePath => Process.GetCurrentProcess().MainModule!.FileName; + + public bool IsElevated + { + get + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + + return Environment.UserName == "root"; + } + } +} diff --git a/LANCommander.Launcher.Services/PowerShell/ElevatedProcessLauncher.cs b/LANCommander.Launcher.Services/PowerShell/ElevatedProcessLauncher.cs new file mode 100644 index 00000000..2c9e8136 --- /dev/null +++ b/LANCommander.Launcher.Services/PowerShell/ElevatedProcessLauncher.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; +using System.Threading.Tasks; + +namespace LANCommander.Launcher.Services; + +public class ElevatedProcessLauncher : IElevatedProcessLauncher +{ + public async Task LaunchAndWaitAsync(ElevatedProcessRequest request) + { + using var process = new Process(); + + process.StartInfo.FileName = request.FileName; + process.StartInfo.Verb = "runas"; + process.StartInfo.UseShellExecute = true; + process.StartInfo.WorkingDirectory = request.WorkingDirectory; + process.StartInfo.Arguments = request.Arguments; + + process.Start(); + + await process.WaitForExitAsync(); + } +} diff --git a/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs b/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs index 8f4bfc40..3f65a3b2 100644 --- a/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs +++ b/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs @@ -1,35 +1,19 @@ -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Security.Principal; using CommandLine; using LANCommander.Launcher.Models; -using LANCommander.SDK; using LANCommander.SDK.Enums; using LANCommander.SDK.PowerShell; namespace LANCommander.Launcher.Services; -public class ElevatedScriptInterceptor : IScriptInterceptor +public class ElevatedScriptInterceptor( + ICurrentProcessInfo currentProcessInfo, + IElevatedProcessLauncher processLauncher) : IScriptInterceptor { public async Task ExecuteAsync(PowerShellScript script) { try { - bool isElevated = false; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var identity = WindowsIdentity.GetCurrent(); - var principal = new WindowsPrincipal(identity); - - isElevated = principal.IsInRole(WindowsBuiltInRole.Administrator); - } - else - { - isElevated = Environment.UserName == "root"; - } - - if (script.RunAsAdmin && !isElevated) + if (script.RunAsAdmin && !currentProcessInfo.IsElevated) { var manifest = script.Variables.GetValue("GameManifest"); @@ -50,28 +34,26 @@ public class ElevatedScriptInterceptor : IScriptInterceptor } var arguments = Parser.Default.FormatCommandLine(options); - var path = Process.GetCurrentProcess().MainModule!.FileName; - var process = new Process(); - - process.StartInfo.FileName = path; - process.StartInfo.Verb = "runas"; - process.StartInfo.UseShellExecute = true; - process.StartInfo.WorkingDirectory = script.WorkingDirectory; - process.StartInfo.Arguments = arguments; - - process.Start(); - - await process.WaitForExitAsync(); + // Re-launch this launcher as a minimal, elevated process that runs just this script + // (with all its runtime parameters) and then exits. Wait until it has finished before + // reporting the script as handled so the caller doesn't continue prematurely. + await processLauncher.LaunchAndWaitAsync(new ElevatedProcessRequest + { + FileName = currentProcessInfo.ExecutablePath, + Arguments = arguments, + WorkingDirectory = script.WorkingDirectory, + }); return true; } } - catch (Exception ex) + catch (Exception) { - // Not running as admin + // Unable to determine elevation state or launch the elevated process; fall back to + // running the script in-process. } return false; } -} \ No newline at end of file +} diff --git a/LANCommander.Launcher.Services/PowerShell/ICurrentProcessInfo.cs b/LANCommander.Launcher.Services/PowerShell/ICurrentProcessInfo.cs new file mode 100644 index 00000000..89359668 --- /dev/null +++ b/LANCommander.Launcher.Services/PowerShell/ICurrentProcessInfo.cs @@ -0,0 +1,21 @@ +namespace LANCommander.Launcher.Services; + +/// +/// Exposes information about the currently running launcher process that the +/// needs in order to decide whether a script must be +/// re-launched with elevated privileges. Abstracted so the elevation decision can be tested without +/// depending on the real process token. +/// +public interface ICurrentProcessInfo +{ + /// + /// The full path to the executable backing the current process. This is the "minimal launcher" + /// that gets re-invoked (elevated) to actually run the script. + /// + string ExecutablePath { get; } + + /// + /// True if the current process is already running with administrator/root privileges. + /// + bool IsElevated { get; } +} diff --git a/LANCommander.Launcher.Services/PowerShell/IElevatedProcessLauncher.cs b/LANCommander.Launcher.Services/PowerShell/IElevatedProcessLauncher.cs new file mode 100644 index 00000000..4e91be3a --- /dev/null +++ b/LANCommander.Launcher.Services/PowerShell/IElevatedProcessLauncher.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; + +namespace LANCommander.Launcher.Services; + +/// +/// Describes how to re-launch the launcher as a minimal, elevated process that runs a single script +/// with the supplied runtime parameters and then exits. +/// +public class ElevatedProcessRequest +{ + /// The launcher executable to invoke elevated. + public required string FileName { get; init; } + + /// The formatted command line (RunScript verb + options) passed to the elevated process. + public required string Arguments { get; init; } + + /// The working directory the elevated script should run in. + public string? WorkingDirectory { get; init; } +} + +/// +/// Launches an elevated process and waits for it to finish. Abstracted so the interceptor's +/// wait-for-completion behavior can be tested without spawning a real UAC-elevated process. +/// +public interface IElevatedProcessLauncher +{ + /// + /// Starts the elevated process described by and completes only once + /// that process has exited. + /// + Task LaunchAndWaitAsync(ElevatedProcessRequest request); +} diff --git a/LANCommander.Launcher.Tests/Tests/ElevatedScriptInterceptorTests.cs b/LANCommander.Launcher.Tests/Tests/ElevatedScriptInterceptorTests.cs new file mode 100644 index 00000000..b676d143 --- /dev/null +++ b/LANCommander.Launcher.Tests/Tests/ElevatedScriptInterceptorTests.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using LANCommander.Launcher.Services; +using LANCommander.SDK.Abstractions; +using LANCommander.SDK.Enums; +using LANCommander.SDK.PowerShell; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; +using SdkSettings = LANCommander.SDK.Models.Settings; +using ManifestGame = LANCommander.SDK.Models.Manifest.Game; + +namespace LANCommander.Launcher.Tests.Tests; + +/// +/// Verifies the admin-elevation path for launcher scripts. When a script is flagged +/// #Requires -RunAsAdministrator and the launcher is not already elevated, the interceptor +/// must re-launch the launcher as a minimal elevated process, pass it every runtime parameter the +/// script needs, wait until that process exits, and only then report the script as handled. In every +/// other case (no admin required, already elevated, or a failure) it must fall through so the script +/// runs in-process. +/// +public class ElevatedScriptInterceptorTests +{ + private static PowerShellScript CreateScript(ScriptType type) + { + var services = new ServiceCollection(); + + services.AddLogging(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + + return new PowerShellScript(provider, type, Options.Create(new SdkSettings())); + } + + [Fact] + public async Task NonAdminScript_ReturnsFalse_AndDoesNotLaunchElevatedProcess() + { + var processInfo = new FakeCurrentProcessInfo { IsElevated = false }; + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.Install); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + // Note: not calling AsAdmin() — script does not require elevation. + + var handled = await interceptor.ExecuteAsync(script); + + Assert.False(handled); + Assert.Equal(0, launcher.LaunchCount); + } + + [Fact] + public async Task AdminScript_WhenAlreadyElevated_ReturnsFalse_AndDoesNotLaunchElevatedProcess() + { + var processInfo = new FakeCurrentProcessInfo { IsElevated = true }; + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.Install).AsAdmin(); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.False(handled); + Assert.Equal(0, launcher.LaunchCount); + } + + [Fact] + public async Task AdminScript_WhenNotElevated_LaunchesMinimalLauncherWithRunAsParametersAndWaits() + { + var gameId = Guid.NewGuid(); + var processInfo = new FakeCurrentProcessInfo + { + IsElevated = false, + ExecutablePath = @"C:\LANCommander\LANCommander.Launcher.exe", + }; + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.Install).AsAdmin().UseWorkingDirectory("WorkDir"); + script.AddVariable("GameManifest", new ManifestGame { Id = gameId }); + script.AddVariable("InstallDirectory", "InstallDir"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.True(handled); + Assert.Equal(1, launcher.LaunchCount); + + var request = Assert.Single(launcher.Requests); + + // Re-launches this same launcher executable as the elevated process. + Assert.Equal(processInfo.ExecutablePath, request.FileName); + // Preserves the working directory so the elevated script runs in the right place. + Assert.Equal("WorkDir", request.WorkingDirectory); + + // Passes the RunScript verb plus every parameter the elevated process needs to run the script. + Assert.Contains("RunScript", request.Arguments); + Assert.Contains(gameId.ToString(), request.Arguments); + Assert.Contains("InstallDir", request.Arguments); + Assert.Contains(ScriptType.Install.ToString(), request.Arguments); + + // The interceptor must not report the script handled until the elevated process has exited. + Assert.True(launcher.CompletedBeforeReturn); + } + + [Fact] + public async Task KeyChangeScript_ForwardsAllocatedKeyToElevatedProcess() + { + var processInfo = new FakeCurrentProcessInfo { IsElevated = false }; + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.KeyChange).AsAdmin(); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + script.AddVariable("AllocatedKey", "KEY-12345"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.True(handled); + var request = Assert.Single(launcher.Requests); + Assert.Contains(ScriptType.KeyChange.ToString(), request.Arguments); + Assert.Contains("KEY-12345", request.Arguments); + } + + [Fact] + public async Task NameChangeScript_ForwardsOldAndNewAliasesToElevatedProcess() + { + var processInfo = new FakeCurrentProcessInfo { IsElevated = false }; + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.NameChange).AsAdmin(); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + script.AddVariable("OldPlayerAlias", "OldAlias"); + script.AddVariable("NewPlayerAlias", "NewAlias"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.True(handled); + var request = Assert.Single(launcher.Requests); + Assert.Contains(ScriptType.NameChange.ToString(), request.Arguments); + Assert.Contains("OldAlias", request.Arguments); + Assert.Contains("NewAlias", request.Arguments); + } + + [Fact] + public async Task WhenElevationCheckThrows_ReturnsFalse_SoScriptRunsInProcess() + { + var processInfo = new ThrowingCurrentProcessInfo(); + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.Install).AsAdmin(); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.False(handled); + Assert.Equal(0, launcher.LaunchCount); + } + + [Fact] + public async Task WhenElevatedLaunchFails_ReturnsFalse_SoScriptRunsInProcess() + { + var processInfo = new FakeCurrentProcessInfo { IsElevated = false }; + var launcher = new RecordingElevatedProcessLauncher { ThrowOnLaunch = true }; + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.Install).AsAdmin(); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.False(handled); + } + + private sealed class FakeCurrentProcessInfo : ICurrentProcessInfo + { + public string ExecutablePath { get; init; } = @"C:\LANCommander\LANCommander.Launcher.exe"; + public bool IsElevated { get; init; } + } + + private sealed class ThrowingCurrentProcessInfo : ICurrentProcessInfo + { + public string ExecutablePath => throw new InvalidOperationException("path unavailable"); + public bool IsElevated => throw new InvalidOperationException("cannot determine elevation"); + } + + private sealed class RecordingElevatedProcessLauncher : IElevatedProcessLauncher + { + public List Requests { get; } = new(); + public int LaunchCount => Requests.Count; + public bool ThrowOnLaunch { get; init; } + + /// Set once the (awaited) launch has fully completed. Proves the caller waited. + public bool CompletedBeforeReturn { get; private set; } + + public async Task LaunchAndWaitAsync(ElevatedProcessRequest request) + { + Requests.Add(request); + + if (ThrowOnLaunch) + throw new InvalidOperationException("elevated launch failed"); + + // Simulate the elevated process running for a moment; if the interceptor did not await + // this, CompletedBeforeReturn would still be false when ExecuteAsync returns. + await Task.Delay(20); + + CompletedBeforeReturn = true; + } + } + + private sealed class FakeSettingsProvider : ISettingsProvider + { + public SdkSettings CurrentValue { get; } = new(); + + public void Update(Action patch) => patch(CurrentValue); + } +} From 3eae04abdd7c2bdff3a08c20455b82c737813736 Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Fri, 24 Jul 2026 00:57:29 -0500 Subject: [PATCH 2/6] Better handle bypass execution policy for scripts --- .../PowerShellScriptExecutionTests.cs | 89 +++++++++++++++++++ .../PowerShell/PowerShellScript.cs | 54 +++++++++-- 2 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 LANCommander.SDK.Tests/PowerShell/PowerShellScriptExecutionTests.cs diff --git a/LANCommander.SDK.Tests/PowerShell/PowerShellScriptExecutionTests.cs b/LANCommander.SDK.Tests/PowerShell/PowerShellScriptExecutionTests.cs new file mode 100644 index 00000000..0d7de7f7 --- /dev/null +++ b/LANCommander.SDK.Tests/PowerShell/PowerShellScriptExecutionTests.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using LANCommander.SDK.Abstractions; +using LANCommander.SDK.Enums; +using LANCommander.SDK.PowerShell; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using SdkSettings = LANCommander.SDK.Models.Settings; + +namespace LANCommander.SDK.Tests.PowerShell; + +public class PowerShellScriptExecutionTests : IDisposable +{ + private readonly string _workingDirectory; + + public PowerShellScriptExecutionTests() + { + _workingDirectory = Path.Combine(Path.GetTempPath(), $"lc-ps-exec-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_workingDirectory); + } + + public void Dispose() + { + if (Directory.Exists(_workingDirectory)) + Directory.Delete(_workingDirectory, true); + } + + private static PowerShellScript CreateScript(ScriptType type = ScriptType.Install) + { + var services = new ServiceCollection(); + + services.AddLogging(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + + return new PowerShellScript(provider, type, Options.Create(new SdkSettings())); + } + + [Fact] + public async Task ExecuteAsync_RunsUnsignedInlineScript_AndReturnsValue() + { + var script = CreateScript() + .UseWorkingDirectory(_workingDirectory) + .UseInline("$Return = 42"); + + var result = await script.ExecuteAsync(); + + // Reaching a real returned value proves the runspace opened (ExecutionPolicy.Bypass applied on + // Windows) and the script executed rather than being silently skipped. + Assert.Equal(42, result); + } + + [Fact] + public async Task ExecuteAsync_ExecutesScriptSideEffects_InWorkingDirectory() + { + var markerPath = Path.Combine(_workingDirectory, "marker.txt"); + + var script = CreateScript() + .UseWorkingDirectory(_workingDirectory) + .UseInline("Set-Content -Path (Join-Path $WorkingDirectory 'marker.txt') -Value 'ran'"); + + await script.ExecuteAsync(); + + Assert.True(File.Exists(markerPath), "The script's side effect did not run — the script was skipped."); + Assert.Equal("ran", (await File.ReadAllTextAsync(markerPath)).Trim()); + } + + [Fact] + public async Task ExecuteAsync_PassesVariablesIntoScript() + { + var script = CreateScript() + .UseWorkingDirectory(_workingDirectory) + .AddVariable("Multiplier", 7) + .UseInline("$Return = $Multiplier * 6"); + + var result = await script.ExecuteAsync(); + + Assert.Equal(42, result); + } + + private sealed class FakeSettingsProvider : ISettingsProvider + { + public SdkSettings CurrentValue { get; } = new(); + + public void Update(Action patch) => patch(CurrentValue); + } +} diff --git a/LANCommander.SDK/PowerShell/PowerShellScript.cs b/LANCommander.SDK/PowerShell/PowerShellScript.cs index 8c08db4f..f0b5363b 100644 --- a/LANCommander.SDK/PowerShell/PowerShellScript.cs +++ b/LANCommander.SDK/PowerShell/PowerShellScript.cs @@ -170,23 +170,63 @@ namespace LANCommander.SDK.PowerShell return Regex.IsMatch(Contents, pattern); } - public async Task ExecuteAsync() + /// + /// Builds the runspace configuration. When is set we + /// prefer an execution policy of so + /// unsigned game scripts run without prompting. + /// + private InitialSessionState CreateSessionState(bool bypassExecutionPolicy) { - T result = default; - var initialSessionState = InitialSessionState.CreateDefault(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (bypassExecutionPolicy) initialSessionState.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Bypass; initialSessionState.AddCustomCmdlets(); - DisableWow64Redirection(); + return initialSessionState; + } - using (Runspace runspace = RunspaceFactory.CreateRunspace(initialSessionState)) + /// + /// Opens a PowerShell runspace, preferring an execution policy of Bypass on Windows. Applying a + /// process-scope Bypass during can throw on machines where the + /// execution policy is locked down by Group Policy; in that case we fall back to opening the + /// runspace with the system default policy so script execution is never silently skipped. + /// + private Runspace OpenRunspace() + { + var bypassExecutionPolicy = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + var runspace = RunspaceFactory.CreateRunspace(CreateSessionState(bypassExecutionPolicy)); + + try { runspace.Open(); - + + return runspace; + } + catch (Exception ex) when (bypassExecutionPolicy) + { + Logger?.LogWarning(ex, "Failed to open PowerShell runspace with ExecutionPolicy.Bypass; retrying with the system default execution policy"); + + runspace.Dispose(); + + var fallback = RunspaceFactory.CreateRunspace(CreateSessionState(false)); + + fallback.Open(); + + return fallback; + } + } + + public async Task ExecuteAsync() + { + T result = default; + + DisableWow64Redirection(); + + using (Runspace runspace = OpenRunspace()) + { var modulesPath = AppPaths.GetConfigPath("Modules"); if (Directory.Exists(modulesPath)) From 2ca1db535c020af2b1e52ded9b464be37a64977f Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Fri, 24 Jul 2026 17:35:58 -0500 Subject: [PATCH 3/6] Fix app path resolution for server, add better tests --- LANCommander.SDK.Tests/AppPathsTests.cs | 102 ++++++++++++++++++ LANCommander.SDK/AppPaths.cs | 83 +++++++++++--- .../ArchiveService.cs | 14 +-- .../GameSaveService.cs | 4 +- LANCommander.Server.Services/MediaService.cs | 4 +- LANCommander.Server.Services/UpdateService.cs | 4 +- .../Services/SaveClientTests.cs | 60 +++++++++++ .../Endpoints/DownloadEndpoints.cs | 1 + .../Endpoints/SaveEndpoints.cs | 12 +-- .../Extensions/GameSaveExtensions.cs | 13 --- 10 files changed, 243 insertions(+), 54 deletions(-) create mode 100644 LANCommander.SDK.Tests/AppPathsTests.cs delete mode 100644 LANCommander.Server/Extensions/GameSaveExtensions.cs diff --git a/LANCommander.SDK.Tests/AppPathsTests.cs b/LANCommander.SDK.Tests/AppPathsTests.cs new file mode 100644 index 00000000..e7671c54 --- /dev/null +++ b/LANCommander.SDK.Tests/AppPathsTests.cs @@ -0,0 +1,102 @@ +using LANCommander.SDK.Helpers; + +namespace LANCommander.SDK.Tests; + +public class AppPathsTests +{ + // ── ResolveStorageLocationPath: rooted paths ───────────────────────────── + + [Fact] + public void ResolveStorageLocationPath_RootedPath_ReturnedAsIs() + { + var rooted = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + + var resolved = AppPaths.ResolveStorageLocationPath(rooted); + + Assert.Equal(rooted, resolved); + } + + [Fact] + public void ResolveStorageLocationPath_RootedPathWithSegments_CombinesUnderRoot() + { + var rooted = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + + var resolved = AppPaths.ResolveStorageLocationPath(rooted, "user", "game", "save"); + + Assert.Equal(Path.Combine(rooted, "user", "game", "save"), resolved); + } + + // ── ResolveStorageLocationPath: relative paths anchor to the config dir ─── + + [Fact] + public void ResolveStorageLocationPath_RelativePath_AnchoredToConfigDirectory() + { + var resolved = AppPaths.ResolveStorageLocationPath("Saves"); + + Assert.Equal(Path.Combine(AppPaths.GetConfigDirectory(), "Saves"), resolved); + } + + [Fact] + public void ResolveStorageLocationPath_RelativePathWithSegments_AnchoredToConfigDirectory() + { + var resolved = AppPaths.ResolveStorageLocationPath("Saves", "user", "game", "save"); + + Assert.Equal( + Path.Combine(AppPaths.GetConfigDirectory(), "Saves", "user", "game", "save"), + resolved); + } + + /// + /// Regression guard for the reported bug: writes and reads of the same save both went through two + /// different resolvers that disagreed for relative storage paths (one anchored to the working + /// directory, the other to the config directory). Every consumer must now resolve identically. + /// + [Fact] + public void ResolveStorageLocationPath_SameRelativeInput_IsDeterministicAcrossCallers() + { + var writer = AppPaths.ResolveStorageLocationPath("Saves", "user", "game", "save"); + var reader = AppPaths.ResolveStorageLocationPath("Saves", "user", "game", "save"); + + Assert.Equal(writer, reader); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveStorageLocationPath_NullOrWhitespacePath_Throws(string? path) + { + Assert.Throws(() => AppPaths.ResolveStorageLocationPath(path!)); + } + + // ── GetConfigDirectory ─────────────────────────────────────────────────── + + [Fact] + public void GetConfigDirectory_ReturnsAbsoluteExistingDirectory() + { + var configDir = AppPaths.GetConfigDirectory(); + + Assert.True(Path.IsPathRooted(configDir)); + Assert.True(Directory.Exists(configDir)); + } + + /// + /// With no override, the data root is a "Data" folder under the current working directory when writable. + /// + [Fact] + public void GetConfigDirectory_AnchoredToWorkingDirectory() + { + // Skip when an operator override or a read-only working directory changes the anchor. + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(AppPaths.DataDirectoryEnvironmentVariable))) + return; + + var workingDir = Directory.GetCurrentDirectory(); + + if (!DirectoryHelper.IsDirectoryWritable(workingDir)) + return; + + var configDir = Path.GetFullPath(AppPaths.GetConfigDirectory()); + + Assert.Equal(Path.GetFullPath(Path.Combine(workingDir, "Data")), configDir); + } +} diff --git a/LANCommander.SDK/AppPaths.cs b/LANCommander.SDK/AppPaths.cs index 9989440d..381901c3 100644 --- a/LANCommander.SDK/AppPaths.cs +++ b/LANCommander.SDK/AppPaths.cs @@ -1,6 +1,8 @@ using System; using System.IO; +using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; using LANCommander.SDK.Helpers; namespace LANCommander.SDK; @@ -9,6 +11,8 @@ public static class AppPaths { private static string _configDirectory = String.Empty; + public const string DataDirectoryEnvironmentVariable = "LANCOMMANDER_DATA_DIR"; + /// /// Builds a full path under the application's config directory. /// @@ -17,9 +21,33 @@ public static class AppPaths public static string GetConfigPath(params string[] paths) => Path.Combine(GetConfigDirectory(), Path.Combine(paths)); + /// + /// Resolves a storage location path to an absolute path using a single, consistent rule so that + /// every consumer (saves, media, archives, ...) resolves the same way: rooted paths are used as-is, + /// while relative paths are resolved beneath the config directory (i.e. next to the server binary). + /// + /// The configured storage location path (absolute or relative). + /// Additional path segments appended to the resolved storage location. + /// The absolute path to the storage location (plus any appended segments). + public static string ResolveStorageLocationPath(string storageLocationPath, params string[] segments) + { + if (String.IsNullOrWhiteSpace(storageLocationPath)) + throw new ArgumentException("A storage location path must be provided.", nameof(storageLocationPath)); + + var root = Path.IsPathRooted(storageLocationPath) + ? storageLocationPath + : Path.Combine(GetConfigDirectory(), storageLocationPath); + + return segments is { Length: > 0 } + ? Path.Combine(new[] { root }.Concat(segments).ToArray()) + : root; + } + /// /// Locates (and creates if necessary) the directory in which application data will be stored. - /// Prefers the current working directory when writable; otherwise falls back to the user's local application data. + /// Resolution order: the override if set; otherwise a + /// "Data" folder under the current working directory when writable; otherwise a "Data" folder under + /// the current user's platform-native application data directory. /// /// The resolved config directory path. public static string GetConfigDirectory() @@ -27,36 +55,57 @@ public static class AppPaths if (!String.IsNullOrWhiteSpace(_configDirectory)) return _configDirectory; - var baseDirectory = Directory.GetCurrentDirectory(); + var overrideDirectory = Environment.GetEnvironmentVariable(DataDirectoryEnvironmentVariable); - if (DirectoryHelper.IsDirectoryWritable(baseDirectory)) - _configDirectory = baseDirectory; + if (!String.IsNullOrWhiteSpace(overrideDirectory)) + { + // Operator-specified data root is used verbatim (no implicit "Data" subfolder). + _configDirectory = Path.GetFullPath(overrideDirectory); + } else - _configDirectory = GetAppDataPath(); - - _configDirectory = Path.Combine(_configDirectory, "Data"); - + { + var baseDirectory = Directory.GetCurrentDirectory(); + + _configDirectory = DirectoryHelper.IsDirectoryWritable(baseDirectory) + ? Path.Combine(baseDirectory, "Data") + : Path.Combine(GetAppDataPath(), "Data"); + } + if (!Directory.Exists(_configDirectory)) Directory.CreateDirectory(_configDirectory); - + return _configDirectory; } /// - /// Gets (and creates if necessary) the base local application data directory for the current user, - /// scoped by the entry assembly's company and product metadata. + /// Gets (and creates if necessary) the base per-user application data directory for the current user, + /// scoped by the entry assembly's company and product metadata. Uses the platform-native convention: + /// %LOCALAPPDATA% on Windows, ~/Library/Application Support on macOS, and + /// $XDG_DATA_HOME (~/.local/share) on Linux. /// - /// The local application data path for this application. + /// The application data path for this application. public static string GetAppDataPath() { var (company, product) = GetCompanyAndProduct(); - var userRoot = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - - var appDataPath = Path.Combine(userRoot, company, product); - + + string userRoot; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + // .NET maps LocalApplicationData to ~/.local/share on macOS; use the native location instead. + userRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", "Application Support"); + else + // Windows: %LOCALAPPDATA%. Linux: $XDG_DATA_HOME or ~/.local/share. + userRoot = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + var appDataPath = Path.Combine(new[] { userRoot, company, product } + .Where(segment => !String.IsNullOrWhiteSpace(segment)) + .ToArray()!); + if (!Directory.Exists(appDataPath)) Directory.CreateDirectory(appDataPath); - + return appDataPath; } diff --git a/LANCommander.Server.Services/ArchiveService.cs b/LANCommander.Server.Services/ArchiveService.cs index 55cd352c..19dc0b24 100644 --- a/LANCommander.Server.Services/ArchiveService.cs +++ b/LANCommander.Server.Services/ArchiveService.cs @@ -36,27 +36,23 @@ namespace LANCommander.Server.Services public string GetArchiveFileLocation(Archive archive, StorageLocation storageLocation) { - return Path.IsPathRooted(storageLocation.Path) - ? Path.Combine(storageLocation.Path, archive.ObjectKey) - : AppPaths.GetConfigPath(storageLocation.Path, archive.ObjectKey); + return AppPaths.ResolveStorageLocationPath(storageLocation.Path, archive.ObjectKey); } public async Task GetArchiveFileLocationAsync(Archive archive) { string storageLocationPath; - + if (archive.StorageLocation != null) storageLocationPath = archive.StorageLocation.Path; else { var storageLocation = await storageLocationService.GetAsync(archive.StorageLocationId); - + storageLocationPath = storageLocation.Path; } - - return Path.IsPathRooted(storageLocationPath) ? - Path.Combine(storageLocationPath, archive.ObjectKey) : - AppPaths.GetConfigPath(storageLocationPath, archive.ObjectKey); + + return AppPaths.ResolveStorageLocationPath(storageLocationPath, archive.ObjectKey); } public async Task GetArchiveFileLocationAsync(string objectKey) diff --git a/LANCommander.Server.Services/GameSaveService.cs b/LANCommander.Server.Services/GameSaveService.cs index d558f550..1d5b35e9 100644 --- a/LANCommander.Server.Services/GameSaveService.cs +++ b/LANCommander.Server.Services/GameSaveService.cs @@ -71,9 +71,7 @@ namespace LANCommander.Server.Services public string GetSavePath(GameSave save) { var gameId = save.GameId ?? throw new InvalidOperationException($"No game ID is available for save {save.Id}"); - return Path.IsPathRooted(save.StorageLocation.Path) ? - Path.Combine(save.StorageLocation.Path, save.UserId.ToString(), gameId.ToString(), $"{save.Id}") : - Path.Combine(AppPaths.GetConfigDirectory(), save.StorageLocation.Path, save.UserId.ToString(), gameId.ToString(), $"{save.Id}"); + return AppPaths.ResolveStorageLocationPath(save.StorageLocation.Path, save.UserId.ToString(), gameId.ToString(), save.Id.ToString()); } public async Task GetDefaultStorageLocationAsync() diff --git a/LANCommander.Server.Services/MediaService.cs b/LANCommander.Server.Services/MediaService.cs index 239b06b2..7ea548cc 100644 --- a/LANCommander.Server.Services/MediaService.cs +++ b/LANCommander.Server.Services/MediaService.cs @@ -97,9 +97,7 @@ namespace LANCommander.Server.Services GetMediaPath(entity.FileId, entity.StorageLocation); public static string GetMediaPath(Guid id, StorageLocation storageLocation) => - Path.IsPathRooted(storageLocation.Path) - ? Path.Combine(storageLocation.Path, id.ToString()) - : Path.Combine(AppPaths.GetConfigDirectory(), storageLocation.Path, id.ToString()); + AppPaths.ResolveStorageLocationPath(storageLocation.Path, id.ToString()); public async Task GetThumbnailPathAsync(Guid id) { diff --git a/LANCommander.Server.Services/UpdateService.cs b/LANCommander.Server.Services/UpdateService.cs index f4cdc7ec..d756e8c9 100644 --- a/LANCommander.Server.Services/UpdateService.cs +++ b/LANCommander.Server.Services/UpdateService.cs @@ -256,9 +256,7 @@ namespace LANCommander.Server.Services GetLauncherFileLocation(artifact.Name); public string GetLauncherFileLocation(string objectKey) => - Path.IsPathRooted(_settingsProvider.CurrentValue.Server.Launcher.StoragePath) ? - Path.Combine(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, objectKey) : - Path.Combine(AppPaths.GetConfigDirectory(), _settingsProvider.CurrentValue.Server.Launcher.StoragePath, objectKey); + AppPaths.ResolveStorageLocationPath(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, objectKey); public LauncherArtifact GetLauncherArtifact(string objectKey) { diff --git a/LANCommander.Server.Tests/Services/SaveClientTests.cs b/LANCommander.Server.Tests/Services/SaveClientTests.cs index 1b1a9760..cd79ff96 100644 --- a/LANCommander.Server.Tests/Services/SaveClientTests.cs +++ b/LANCommander.Server.Tests/Services/SaveClientTests.cs @@ -96,6 +96,66 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture) } } + /// + /// Regression for the reported bug: saves were written to a working-directory-relative location while + /// downloads looked under the config directory. Upload and download now share , + /// which must anchor a relative storage location beneath the config directory. + /// + [Fact] + public void GetSavePath_RelativeStorageLocation_ResolvesUnderConfigDirectory() + { + var saveService = GetService(); + + var save = new GameSave + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + GameId = Guid.NewGuid(), + StorageLocation = new StorageLocation + { + Path = "Saves", + Type = StorageLocationType.Save, + }, + }; + + var path = saveService.GetSavePath(save); + + path.ShouldBe(Path.Combine( + AppPaths.GetConfigDirectory(), + "Saves", + save.UserId.ToString(), + save.GameId.ToString(), + save.Id.ToString())); + } + + [Fact] + public void GetSavePath_RootedStorageLocation_UsedVerbatim() + { + var saveService = GetService(); + + var rooted = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + + var save = new GameSave + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + GameId = Guid.NewGuid(), + StorageLocation = new StorageLocation + { + Path = rooted, + Type = StorageLocationType.Save, + }, + }; + + var path = saveService.GetSavePath(save); + + path.ShouldBe(Path.Combine( + rooted, + save.UserId.ToString(), + save.GameId.ToString(), + save.Id.ToString())); + } + [Fact] public async Task SaveUploadWorksAsync() { diff --git a/LANCommander.Server/Endpoints/DownloadEndpoints.cs b/LANCommander.Server/Endpoints/DownloadEndpoints.cs index aadb440e..9ae8199d 100644 --- a/LANCommander.Server/Endpoints/DownloadEndpoints.cs +++ b/LANCommander.Server/Endpoints/DownloadEndpoints.cs @@ -80,6 +80,7 @@ public static class DownloadEndpoints var save = await gameSaveService .Include(s => s.Game!) .Include(s => s.User!) + .Include(s => s.StorageLocation!) .GetAsync(id); if (user == null || user.Identity?.Name != save.User?.UserName && !user.IsInRole(RoleService.AdministratorRoleName)) diff --git a/LANCommander.Server/Endpoints/SaveEndpoints.cs b/LANCommander.Server/Endpoints/SaveEndpoints.cs index f5976060..56f960dd 100644 --- a/LANCommander.Server/Endpoints/SaveEndpoints.cs +++ b/LANCommander.Server/Endpoints/SaveEndpoints.cs @@ -170,8 +170,8 @@ public static class SaveEndpoints if (latestSave == null) return TypedResults.NotFound(); - var fileName = latestSave.GetUploadPath(); - + var fileName = saveService.GetSavePath(latestSave); + if (!File.Exists(fileName)) return TypedResults.NotFound(); @@ -201,11 +201,11 @@ public static class SaveEndpoints .Include(s => s.StorageLocation) .FirstOrDefaultAsync(s => s.Id == id && s.UserId == user.Id); - var fileName = save.GetUploadPath(); - + var fileName = saveService.GetSavePath(save); + if (!File.Exists(fileName)) return TypedResults.NotFound(); - + var downloadName = $"{save.Game.Title} - {user.UserName} - {save.CreatedOn}".SanitizeFilename(); return TypedResults.File(new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read), "application/octet-stream", $"{downloadName}.lcs"); @@ -297,7 +297,7 @@ public static class SaveEndpoints try { - var saveUploadFile = save.GetUploadPath(); + var saveUploadFile = saveService.GetSavePath(save); var saveUploadPath = Path.GetDirectoryName(saveUploadFile); if (!Directory.Exists(saveUploadPath)) diff --git a/LANCommander.Server/Extensions/GameSaveExtensions.cs b/LANCommander.Server/Extensions/GameSaveExtensions.cs deleted file mode 100644 index 16079209..00000000 --- a/LANCommander.Server/Extensions/GameSaveExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using LANCommander.Server.Services; -using Steamworks.Data; - -namespace LANCommander.Server.Extensions -{ - public static class GameSaveExtensions - { - public static string GetUploadPath(this Data.Models.GameSave gameSave) - { - return Path.Combine(gameSave.StorageLocation.Path, gameSave.UserId.ToString(), gameSave.GameId.ToString(), gameSave.Id.ToString()); - } - } -} From 41bcfa908d33d611b8cf8d04d52e2e2fd68262e5 Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Fri, 24 Jul 2026 18:00:32 -0500 Subject: [PATCH 4/6] Unify path resolution server-wide --- .../ArchiveService.cs | 3 - LANCommander.Server.Services/ModuleService.cs | 2 + LANCommander.Server.Services/ScriptService.cs | 2 + LANCommander.Server.Services/SetupService.cs | 7 +- LANCommander.Server.Services/UpdateService.cs | 15 ++-- .../Endpoints/LauncherEndpoints.cs | 3 +- .../Endpoints/UploadEndpoints.cs | 3 - .../Background/DownloadLauncherArtifacts.cs | 5 +- .../AlignSettingsStoragePathsMigration.cs | 89 +++++++++++++++++++ LANCommander.Server/Startup/Filesystem.cs | 11 ++- LANCommander.Server/Startup/Migrations.cs | 3 + 11 files changed, 122 insertions(+), 21 deletions(-) create mode 100644 LANCommander.Server/Migrations/AlignSettingsStoragePathsMigration.cs diff --git a/LANCommander.Server.Services/ArchiveService.cs b/LANCommander.Server.Services/ArchiveService.cs index 19dc0b24..d35fd0ef 100644 --- a/LANCommander.Server.Services/ArchiveService.cs +++ b/LANCommander.Server.Services/ArchiveService.cs @@ -189,9 +189,6 @@ namespace LANCommander.Server.Services { var storageLocation = await storageLocationService.GetAsync(storageLocationId); - if (!Directory.Exists(storageLocation.Path)) - Directory.CreateDirectory(storageLocation.Path); - var archive = new Archive { ObjectKey = Guid.NewGuid().ToString(), diff --git a/LANCommander.Server.Services/ModuleService.cs b/LANCommander.Server.Services/ModuleService.cs index c796332b..40d54273 100644 --- a/LANCommander.Server.Services/ModuleService.cs +++ b/LANCommander.Server.Services/ModuleService.cs @@ -67,6 +67,8 @@ namespace LANCommander.Server.Services }); } + storagePath = AppPaths.ResolveStorageLocationPath(storagePath); + if (!Directory.Exists(storagePath)) Directory.CreateDirectory(storagePath); diff --git a/LANCommander.Server.Services/ScriptService.cs b/LANCommander.Server.Services/ScriptService.cs index 57630138..80eb40f3 100644 --- a/LANCommander.Server.Services/ScriptService.cs +++ b/LANCommander.Server.Services/ScriptService.cs @@ -136,6 +136,8 @@ namespace LANCommander.Server.Services }); } + storagePath = AppPaths.ResolveStorageLocationPath(storagePath); + if (!Directory.Exists(storagePath)) Directory.CreateDirectory(storagePath); diff --git a/LANCommander.Server.Services/SetupService.cs b/LANCommander.Server.Services/SetupService.cs index e63b77e2..56cb8377 100644 --- a/LANCommander.Server.Services/SetupService.cs +++ b/LANCommander.Server.Services/SetupService.cs @@ -140,8 +140,11 @@ namespace LANCommander.Server.Services foreach (var storageLocation in storageLocations) { - if (!Directory.Exists(storageLocation.Path)) - Directory.CreateDirectory(storageLocation.Path); + // Store the configured (possibly relative) path, but create the resolved physical directory. + var resolvedPath = AppPaths.ResolveStorageLocationPath(storageLocation.Path); + + if (!Directory.Exists(resolvedPath)) + Directory.CreateDirectory(resolvedPath); try { diff --git a/LANCommander.Server.Services/UpdateService.cs b/LANCommander.Server.Services/UpdateService.cs index d756e8c9..c354f065 100644 --- a/LANCommander.Server.Services/UpdateService.cs +++ b/LANCommander.Server.Services/UpdateService.cs @@ -42,8 +42,9 @@ namespace LANCommander.Server.Services public IEnumerable GetLauncherArtifactsFromLocalFiles() { var currentVersion = versionProvider.GetCurrentVersion(); - var downloadedLaunchers = Directory.GetFiles(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, $"LANCommander.Launcher*v{currentVersion.WithoutMetadata()}.*"); - var downloadedInstallers = Directory.GetFiles(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, $"LANCommander.Launcher-{currentVersion.WithoutMetadata()}*Setup*.*"); + var launcherStoragePath = AppPaths.ResolveStorageLocationPath(_settingsProvider.CurrentValue.Server.Launcher.StoragePath); + var downloadedLaunchers = Directory.GetFiles(launcherStoragePath, $"LANCommander.Launcher*v{currentVersion.WithoutMetadata()}.*"); + var downloadedInstallers = Directory.GetFiles(launcherStoragePath, $"LANCommander.Launcher-{currentVersion.WithoutMetadata()}*Setup*.*"); var downloads = downloadedLaunchers.Concat(downloadedInstallers).Distinct(); @@ -149,7 +150,7 @@ namespace LANCommander.Server.Services client.DownloadFileCompleted += ReleaseDownloaded; client.QueryString.Add("Version", release.TagName); - await client.DownloadFileTaskAsync(new Uri(releaseFile), Path.Combine(_settingsProvider.CurrentValue.Server.Update.StoragePath, $"{release.TagName}.zip")); + await client.DownloadFileTaskAsync(new Uri(releaseFile), AppPaths.ResolveStorageLocationPath(_settingsProvider.CurrentValue.Server.Update.StoragePath, $"{release.TagName}.zip")); } } @@ -181,7 +182,7 @@ namespace LANCommander.Server.Services var uri = new Uri(releaseFile); client.QueryString.Add("Version", release.TagName); - await client.DownloadFileTaskAsync(uri, Path.Combine(_settingsProvider.CurrentValue.Server.Update.StoragePath, $"{release.TagName}.zip")); + await client.DownloadFileTaskAsync(uri, AppPaths.ResolveStorageLocationPath(_settingsProvider.CurrentValue.Server.Update.StoragePath, $"{release.TagName}.zip")); } } } @@ -216,7 +217,7 @@ namespace LANCommander.Server.Services private void ReleaseDownloaded(object? sender, System.ComponentModel.AsyncCompletedEventArgs e) { string version = ((WebClient)sender).QueryString["Version"]; - string path = Path.Combine(_settingsProvider.CurrentValue.Server.Update.StoragePath, $"{version}.zip"); + string path = AppPaths.ResolveStorageLocationPath(_settingsProvider.CurrentValue.Server.Update.StoragePath, $"{version}.zip"); _logger?.LogInformation("Update version {Version} has been downloaded", version); @@ -242,7 +243,7 @@ namespace LANCommander.Server.Services var process = new ProcessStartInfo(); process.FileName = processExecutable; - process.Arguments = $"-Version {version} -Path \"{_settingsProvider.CurrentValue.Server.Update.StoragePath}\" -Executable {Process.GetCurrentProcess().MainModule.FileName}"; + process.Arguments = $"-Version {version} -Path \"{AppPaths.ResolveStorageLocationPath(_settingsProvider.CurrentValue.Server.Update.StoragePath)}\" -Executable {Process.GetCurrentProcess().MainModule.FileName}"; process.UseShellExecute = true; Process.Start(process); @@ -260,7 +261,7 @@ namespace LANCommander.Server.Services public LauncherArtifact GetLauncherArtifact(string objectKey) { - string name = Path.Combine(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, objectKey); + string name = AppPaths.ResolveStorageLocationPath(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, objectKey); return GetArtifactFromName(name); } } diff --git a/LANCommander.Server/Endpoints/LauncherEndpoints.cs b/LANCommander.Server/Endpoints/LauncherEndpoints.cs index ec0c797c..bc531750 100644 --- a/LANCommander.Server/Endpoints/LauncherEndpoints.cs +++ b/LANCommander.Server/Endpoints/LauncherEndpoints.cs @@ -1,3 +1,4 @@ +using LANCommander.SDK; using LANCommander.SDK.Models; using LANCommander.Server.Services; using LANCommander.Server.Services.Abstractions; @@ -23,7 +24,7 @@ public static class LauncherEndpoints { var version = versionProvider.GetCurrentVersion(); var fileName = $"LANCommander.Launcher-Windows-x64-v{version.WithoutMetadata()}.zip"; - var path = Path.Combine(settingsProvider.CurrentValue.Server.Launcher.StoragePath, fileName); + var path = AppPaths.ResolveStorageLocationPath(settingsProvider.CurrentValue.Server.Launcher.StoragePath, fileName); if (!File.Exists(path) || !settingsProvider.CurrentValue.Server.Launcher.HostUpdates) { diff --git a/LANCommander.Server/Endpoints/UploadEndpoints.cs b/LANCommander.Server/Endpoints/UploadEndpoints.cs index 5b6ba13c..bf72979a 100644 --- a/LANCommander.Server/Endpoints/UploadEndpoints.cs +++ b/LANCommander.Server/Endpoints/UploadEndpoints.cs @@ -28,9 +28,6 @@ public static class UploadEndpoints storageLocationId == null || storageLocationId == Guid.Empty ? null : storageLocationId, SDK.Enums.StorageLocationType.Archive); - if (!Directory.Exists(storageLocation.Path)) - Directory.CreateDirectory(storageLocation.Path); - var archive = new Archive { ObjectKey = Guid.NewGuid().ToString(), diff --git a/LANCommander.Server/Jobs/Background/DownloadLauncherArtifacts.cs b/LANCommander.Server/Jobs/Background/DownloadLauncherArtifacts.cs index a4088856..be709d09 100644 --- a/LANCommander.Server/Jobs/Background/DownloadLauncherArtifacts.cs +++ b/LANCommander.Server/Jobs/Background/DownloadLauncherArtifacts.cs @@ -1,4 +1,5 @@ -using LANCommander.SDK.Extensions; +using LANCommander.SDK; +using LANCommander.SDK.Extensions; using LANCommander.Server.Services; using LANCommander.Server.Services.Models; using Microsoft.Extensions.Options; @@ -28,7 +29,7 @@ namespace LANCommander.Server.Jobs.Background if (!localArtifacts.Any(a => a.Name.EndsWith(artifact.Name))) { using (var downloadStream = await httpClient.GetStreamAsync(artifact.Url)) - using (var fs = new FileStream(Path.Combine(settings.Value.Server.Launcher.StoragePath, artifact.Name), FileMode.Create)) + using (var fs = new FileStream(AppPaths.ResolveStorageLocationPath(settings.Value.Server.Launcher.StoragePath, artifact.Name), FileMode.Create)) { await downloadStream.CopyToAsync(fs); op.Complete(); diff --git a/LANCommander.Server/Migrations/AlignSettingsStoragePathsMigration.cs b/LANCommander.Server/Migrations/AlignSettingsStoragePathsMigration.cs new file mode 100644 index 00000000..799d5a27 --- /dev/null +++ b/LANCommander.Server/Migrations/AlignSettingsStoragePathsMigration.cs @@ -0,0 +1,89 @@ +using LANCommander.SDK; +using LANCommander.SDK.Helpers; +using LANCommander.SDK.Migrations; +using Semver; + +namespace LANCommander.Server.Migrations; + +/// +/// Moves the settings-based storage directories (Update, Launcher, Backups, Snippets, Modules) from +/// their previously-written raw locations (resolved verbatim relative to the working directory) to the +/// unified location produced by . +/// This aligns runtime writes with the same resolution rule used everywhere else so relative paths land +/// under the config directory instead of next to the binary. +/// +public class AlignSettingsStoragePathsMigration( + SettingsProvider settingsProvider, + ILogger logger) : FileSystemMigration(logger) +{ + public override SemVersion Version => new(2, 1, 0); + + private IEnumerable GetConfiguredPaths() + { + var settings = settingsProvider.CurrentValue; + + yield return settings.Server.Update.StoragePath; + yield return settings.Server.Launcher.StoragePath; + yield return settings.Server.Backups.StoragePath; + yield return settings.Server.Scripts.Snippets.StoragePath; + yield return settings.Server.Scripts.Modules.StoragePath; + } + + private static (string Source, string Destination)? GetMove(string configuredPath) + { + if (string.IsNullOrWhiteSpace(configuredPath)) + return null; + + // Rooted paths already resolve verbatim, so there is nothing to move. + if (Path.IsPathRooted(configuredPath)) + return null; + + var source = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), configuredPath)); + var destination = AppPaths.ResolveStorageLocationPath(configuredPath); + + if (string.Equals(source, destination, StringComparison.Ordinal)) + return null; + + return (source, destination); + } + + public override async Task ExecuteAsync() + { + foreach (var configuredPath in GetConfiguredPaths()) + { + var move = GetMove(configuredPath); + + if (move == null) + continue; + + var (source, destination) = move.Value; + + try + { + if (!Directory.Exists(source)) + continue; + + Logger.LogInformation("Moving storage directory from \"{Source}\" to \"{Destination}\"", source, destination); + + DirectoryHelper.MoveContents(source, destination); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error while moving storage directory from \"{Source}\" to \"{Destination}\"", source, destination); + } + } + } + + public override Task ShouldExecuteAsync() + { + foreach (var configuredPath in GetConfiguredPaths()) + { + var move = GetMove(configuredPath); + + if (move != null && Directory.Exists(move.Value.Source)) + return Task.FromResult(true); + } + + return Task.FromResult(false); + } +} diff --git a/LANCommander.Server/Startup/Filesystem.cs b/LANCommander.Server/Startup/Filesystem.cs index 7ac0d11b..fb09dc0c 100644 --- a/LANCommander.Server/Startup/Filesystem.cs +++ b/LANCommander.Server/Startup/Filesystem.cs @@ -23,9 +23,14 @@ public static class Filesystem foreach (var directory in directories) { - logger.LogDebug("Ensuring directory {Directory} exists", directory); - if (!Directory.Exists(directory)) - Directory.CreateDirectory(directory); + if (string.IsNullOrWhiteSpace(directory)) + continue; + + var resolved = AppPaths.ResolveStorageLocationPath(directory); + + logger.LogDebug("Ensuring directory {Directory} exists", resolved); + if (!Directory.Exists(resolved)) + Directory.CreateDirectory(resolved); } return app; diff --git a/LANCommander.Server/Startup/Migrations.cs b/LANCommander.Server/Startup/Migrations.cs index ee12e58a..16aa8906 100644 --- a/LANCommander.Server/Startup/Migrations.cs +++ b/LANCommander.Server/Startup/Migrations.cs @@ -29,6 +29,9 @@ public static class Migrations builder.Services.AddScoped(); builder.Services.AddScoped(); + // Align settings-based storage paths with the unified resolver + builder.Services.AddScoped(); + return builder; } From da94ff84387654fe4ecbaad067c47f29bde37982 Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Mon, 27 Jul 2026 00:26:12 -0500 Subject: [PATCH 5/6] Fix detection of primary display's resolution on some Linux multi-display configurations --- LANCommander.SDK/Helpers/DisplayHelper.cs | 71 ++++++++++++++++------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/LANCommander.SDK/Helpers/DisplayHelper.cs b/LANCommander.SDK/Helpers/DisplayHelper.cs index 277d50f8..6fa404aa 100644 --- a/LANCommander.SDK/Helpers/DisplayHelper.cs +++ b/LANCommander.SDK/Helpers/DisplayHelper.cs @@ -115,15 +115,20 @@ namespace LANCommander.SDK.Helpers // ── Linux helpers ───────────────────────────────────────────────────────── /// - /// Parses xrandr output to find the active resolution and refresh rate. - /// Works on X11 and XWayland. + /// Parses xrandr output to find the primary display's active resolution + /// and refresh rate. Works on X11 and XWayland. + /// + /// We deliberately parse the primary output's connected line rather than the + /// "Screen 0: ... current W x H" summary, because that summary reports the + /// combined bounding box of all displays in a multi-monitor setup. /// /// Example xrandr output: /// - /// Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 16384 x 16384 - /// DP-1 connected primary 1920x1080+0+0 ... - /// 1920x1080 60.00*+ 50.00 59.94 - /// 1280x720 60.00 59.94 + /// Screen 0: minimum 16 x 16, current 4480 x 1440, maximum 32767 x 32767 + /// DP-1 connected 1920x1200+2560+0 ... + /// 1920x1200 59.88*+ + /// DP-3 connected primary 2560x1440+0+0 ... + /// 2560x1440 164.85*+ /// /// private static bool TryGetScreenFromXrandr(out Bounds bounds, out int refreshRate, out int bitsPerPixel) @@ -138,24 +143,50 @@ namespace LANCommander.SDK.Helpers if (string.IsNullOrWhiteSpace(output)) return false; - // "Screen 0: ... current 1920 x 1080 ..." - var screenMatch = Regex.Match(output, @"current\s+(\d+)\s*x\s*(\d+)"); - if (!screenMatch.Success) + var lines = output.Split('\n'); + + var connectedLine = @"^\S+\s+connected(\s+primary)?\s+(\d+)x(\d+)\+\d+\+\d+"; + + var primaryIndex = -1; + var fallbackIndex = -1; + + for (var i = 0; i < lines.Length; i++) + { + var match = Regex.Match(lines[i], connectedLine); + if (!match.Success) + continue; + + if (match.Groups[1].Success && primaryIndex == -1) + primaryIndex = i; + + if (fallbackIndex == -1) + fallbackIndex = i; + } + + var displayIndex = primaryIndex != -1 ? primaryIndex : fallbackIndex; + if (displayIndex == -1) return false; - bounds.Width = int.Parse(screenMatch.Groups[1].Value); - bounds.Height = int.Parse(screenMatch.Groups[2].Value); + var displayMatch = Regex.Match(lines[displayIndex], connectedLine); + bounds.Width = int.Parse(displayMatch.Groups[2].Value); + bounds.Height = int.Parse(displayMatch.Groups[3].Value); - // A mode line looks like: " 1920x1080 60.00*+ 50.00 59.94" - // The active refresh rate is the one immediately followed by '*'. - var refreshMatch = Regex.Match(output, @"(\d+\.\d+)\*"); - if (refreshMatch.Success && - float.TryParse(refreshMatch.Groups[1].Value, - System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, - out var rate)) + for (var i = displayIndex + 1; i < lines.Length; i++) { - refreshRate = (int)Math.Round(rate); + // A non-indented, non-empty line starts the next output's block. + if (lines[i].Length > 0 && !char.IsWhiteSpace(lines[i][0])) + break; + + var refreshMatch = Regex.Match(lines[i], @"(\d+\.\d+)\*"); + if (refreshMatch.Success && + float.TryParse(refreshMatch.Groups[1].Value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var rate)) + { + refreshRate = (int)Math.Round(rate); + break; + } } return bounds.Width > 0 && bounds.Height > 0; From f08fd24a147f2f108230545941b22e1c160ad406 Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Mon, 27 Jul 2026 20:09:00 -0500 Subject: [PATCH 6/6] Release notes for 2.1.9 --- LANCommander.Documentation/Releases/2.1.0.mdx | 31 ++++++++++++++++-- LANCommander.Documentation/Releases/2.1.9.mdx | 32 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 LANCommander.Documentation/Releases/2.1.9.mdx diff --git a/LANCommander.Documentation/Releases/2.1.0.mdx b/LANCommander.Documentation/Releases/2.1.0.mdx index 653ae378..2e80e1e7 100644 --- a/LANCommander.Documentation/Releases/2.1.0.mdx +++ b/LANCommander.Documentation/Releases/2.1.0.mdx @@ -9,7 +9,7 @@ import ContributorGrid from '@site/src/components/ContributorGrid'; # LANCommander 2.1.0 Release Notes :::tip Latest Version -This page covers the full LANCommander 2.1 series. The latest patch is **2.1.8** — see [Patch Updates](#patch-updates) below for what's changed since the initial release. +This page covers the full LANCommander 2.1 series. The latest patch is **2.1.9** — see [Patch Updates](#patch-updates) below for what's changed since the initial release. ::: LANCommander 2.1.0 is a landmark release that touches virtually every part of the platform. A brand new launcher built on Avalonia, a standalone packager application, a C++ SDK powering a legacy Win32 launcher, major server improvements, and the launch of LANCommander HQ all come together in what has been the most ambitious update cycle to date. @@ -586,10 +586,37 @@ Actions, scripts, and save paths can now be scoped to a specific runtime platfor +### 2.1.9 +
+View 2.1.9 patch notes + +#### Improvements +- Path resolution has been unified across the server so that storage paths are resolved consistently everywhere. A migration aligns existing settings storage paths automatically on upgrade. +- Depot queries have been optimized for better performance. +- Server notifications now use a shorter timeout so a slow or unreachable server no longer holds up the launcher. +- Updated SharpCompress to the latest version. This should resolve most extraction issues for games with large archives. + +#### Bug Fixes +- Fixed detection of the primary display's resolution on some Linux multi-display configurations. +- Fixed application path resolution on the server, correcting how saves, media, archives, and updates are located. +- Improved handling of the bypass execution policy for scripts. +- Fixed installation of wine32 and winetricks. + + + +
+ ## Downloads + + +
+View 2.1.8 downloads + +
+
View 2.1.7 downloads @@ -648,4 +675,4 @@ Actions, scripts, and save paths can now be scoped to a specific runtime platfor ## Contributors - + diff --git a/LANCommander.Documentation/Releases/2.1.9.mdx b/LANCommander.Documentation/Releases/2.1.9.mdx new file mode 100644 index 00000000..cf305a44 --- /dev/null +++ b/LANCommander.Documentation/Releases/2.1.9.mdx @@ -0,0 +1,32 @@ +--- +title: 2.1.9 +--- + +import ReleaseDownloads from '@site/src/components/ReleaseDownloads'; +import ContributorGrid from '@site/src/components/ContributorGrid'; + +# LANCommander 2.1.9 Release Notes + +:::info Full Release Notes +This is a patch release for the **2.1 series**. For the complete feature overview and all patch notes, visit the [LANCommander 2.1.0 release notes](./2.1.0). +::: + +## Improvements +- Path resolution has been unified across the server so that storage paths are resolved consistently everywhere. A migration aligns existing settings storage paths automatically on upgrade. +- Depot queries have been optimized for better performance. +- Server notifications now use a shorter timeout so a slow or unreachable server no longer holds up the launcher. +- Updated SharpCompress to the latest version. This should resolve most extraction issues for games with large archives. + +## Bug Fixes +- Fixed detection of the primary display's resolution on some Linux multi-display configurations. +- Fixed application path resolution on the server, correcting how saves, media, archives, and updates are located. +- Improved handling of the bypass execution policy for scripts. +- Fixed installation of wine32 and winetricks. + +## Downloads + + + +## Contributors + +