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
+
+
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);
+ }
+}
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.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/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.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;
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))
diff --git a/LANCommander.Server.Services/ArchiveService.cs b/LANCommander.Server.Services/ArchiveService.cs
index 55cd352c..d35fd0ef 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)
@@ -193,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/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/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 f4cdc7ec..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);
@@ -256,13 +257,11 @@ 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)
{
- 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.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/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/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/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/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());
- }
- }
-}
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;
}