Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
Pat Hartl
f08fd24a14 Release notes for 2.1.9
All checks were successful
Publish Documentation Updates / dispatch (push) Successful in 9s
2026-07-27 20:09:00 -05:00
Pat Hartl
da94ff8438 Fix detection of primary display's resolution on some Linux multi-display configurations
Some checks failed
LANCommander Release / prep (push) Failing after 49s
LANCommander Release / build_server_linux_arm64 (push) Has been skipped
LANCommander Release / build_server_linux_x64 (push) Has been skipped
LANCommander Release / build_server_osx_arm64 (push) Has been skipped
LANCommander Release / build_server_osx_x64 (push) Has been skipped
LANCommander Release / build_server_win_arm64 (push) Has been skipped
LANCommander Release / build_server_win_x64 (push) Has been skipped
LANCommander Release / build_launcher_linux_arm64 (push) Has been skipped
LANCommander Release / build_launcher_linux_x64 (push) Has been skipped
LANCommander Release / build_launcher_osx_arm64 (push) Has been skipped
LANCommander Release / build_launcher_osx_x64 (push) Has been skipped
LANCommander Release / build_launcher_win_arm64 (push) Has been skipped
LANCommander Release / build_launcher_win_x64 (push) Has been skipped
LANCommander Release / build_packager (push) Has been skipped
LANCommander Release / build_release (push) Has been skipped
2026-07-27 00:26:12 -05:00
Pat Hartl
41bcfa908d Unify path resolution server-wide 2026-07-24 18:00:32 -05:00
Pat Hartl
2ca1db535c Fix app path resolution for server, add better tests 2026-07-24 17:35:58 -05:00
Pat Hartl
3eae04abdd Better handle bypass execution policy for scripts 2026-07-24 00:57:29 -05:00
Pat Hartl
2b10b55b38 Create abstraction around elevated process launching for better testing 2026-07-23 21:31:47 -05:00
31 changed files with 962 additions and 139 deletions

View file

@ -9,7 +9,7 @@ import ContributorGrid from '@site/src/components/ContributorGrid';
# LANCommander 2.1.0 Release Notes # LANCommander 2.1.0 Release Notes
:::tip Latest Version :::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. 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
</details> </details>
### 2.1.9
<details>
<summary>View 2.1.9 patch notes</summary>
#### 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.
<ReleaseDownloads release="v2.1.9" />
</details>
## Downloads ## Downloads
<ReleaseDownloads release="v2.1.9" />
<details>
<summary>View 2.1.8 downloads</summary>
<ReleaseDownloads release="v2.1.8" /> <ReleaseDownloads release="v2.1.8" />
</details>
<details> <details>
<summary>View 2.1.7 downloads</summary> <summary>View 2.1.7 downloads</summary>
@ -648,4 +675,4 @@ Actions, scripts, and save paths can now be scoped to a specific runtime platfor
## Contributors ## Contributors
<ContributorGrid from="v2.0.2" to="v2.1.8" /> <ContributorGrid from="v2.0.2" to="v2.1.9" />

View file

@ -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
<ReleaseDownloads release="v2.1.9" />
## Contributors
<ContributorGrid from="v2.1.8" to="v2.1.9" />

View file

@ -35,6 +35,8 @@ namespace LANCommander.Launcher.Services.Extensions
services.AddSingleton<KeepAliveService>(); services.AddSingleton<KeepAliveService>();
#endregion #endregion
services.AddSingleton<ICurrentProcessInfo, CurrentProcessInfo>();
services.AddSingleton<IElevatedProcessLauncher, ElevatedProcessLauncher>();
services.AddSingleton<IScriptInterceptor, ElevatedScriptInterceptor>(); services.AddSingleton<IScriptInterceptor, ElevatedScriptInterceptor>();
services.AddSingleton<ScriptDebugger>(); services.AddSingleton<ScriptDebugger>();
services.AddSingleton<IScriptDebugger>(sp => services.AddSingleton<IScriptDebugger>(sp =>

View file

@ -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";
}
}
}

View file

@ -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();
}
}

View file

@ -1,35 +1,19 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Principal;
using CommandLine; using CommandLine;
using LANCommander.Launcher.Models; using LANCommander.Launcher.Models;
using LANCommander.SDK;
using LANCommander.SDK.Enums; using LANCommander.SDK.Enums;
using LANCommander.SDK.PowerShell; using LANCommander.SDK.PowerShell;
namespace LANCommander.Launcher.Services; namespace LANCommander.Launcher.Services;
public class ElevatedScriptInterceptor : IScriptInterceptor public class ElevatedScriptInterceptor(
ICurrentProcessInfo currentProcessInfo,
IElevatedProcessLauncher processLauncher) : IScriptInterceptor
{ {
public async Task<bool> ExecuteAsync(PowerShellScript script) public async Task<bool> ExecuteAsync(PowerShellScript script)
{ {
try try
{ {
bool isElevated = false; if (script.RunAsAdmin && !currentProcessInfo.IsElevated)
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)
{ {
var manifest = script.Variables.GetValue<SDK.Models.Manifest.Game>("GameManifest"); var manifest = script.Variables.GetValue<SDK.Models.Manifest.Game>("GameManifest");
@ -50,28 +34,26 @@ public class ElevatedScriptInterceptor : IScriptInterceptor
} }
var arguments = Parser.Default.FormatCommandLine(options); var arguments = Parser.Default.FormatCommandLine(options);
var path = Process.GetCurrentProcess().MainModule!.FileName;
var process = new Process(); // 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
process.StartInfo.FileName = path; // reporting the script as handled so the caller doesn't continue prematurely.
process.StartInfo.Verb = "runas"; await processLauncher.LaunchAndWaitAsync(new ElevatedProcessRequest
process.StartInfo.UseShellExecute = true; {
process.StartInfo.WorkingDirectory = script.WorkingDirectory; FileName = currentProcessInfo.ExecutablePath,
process.StartInfo.Arguments = arguments; Arguments = arguments,
WorkingDirectory = script.WorkingDirectory,
process.Start(); });
await process.WaitForExitAsync();
return true; 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; return false;
} }
} }

View file

@ -0,0 +1,21 @@
namespace LANCommander.Launcher.Services;
/// <summary>
/// Exposes information about the currently running launcher process that the
/// <see cref="ElevatedScriptInterceptor"/> 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.
/// </summary>
public interface ICurrentProcessInfo
{
/// <summary>
/// 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.
/// </summary>
string ExecutablePath { get; }
/// <summary>
/// True if the current process is already running with administrator/root privileges.
/// </summary>
bool IsElevated { get; }
}

View file

@ -0,0 +1,32 @@
using System.Threading.Tasks;
namespace LANCommander.Launcher.Services;
/// <summary>
/// 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.
/// </summary>
public class ElevatedProcessRequest
{
/// <summary>The launcher executable to invoke elevated.</summary>
public required string FileName { get; init; }
/// <summary>The formatted command line (RunScript verb + options) passed to the elevated process.</summary>
public required string Arguments { get; init; }
/// <summary>The working directory the elevated script should run in.</summary>
public string? WorkingDirectory { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public interface IElevatedProcessLauncher
{
/// <summary>
/// Starts the elevated process described by <paramref name="request"/> and completes only once
/// that process has exited.
/// </summary>
Task LaunchAndWaitAsync(ElevatedProcessRequest request);
}

View file

@ -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;
/// <summary>
/// Verifies the admin-elevation path for launcher scripts. When a script is flagged
/// <c>#Requires -RunAsAdministrator</c> 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.
/// </summary>
public class ElevatedScriptInterceptorTests
{
private static PowerShellScript CreateScript(ScriptType type)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ISettingsProvider, FakeSettingsProvider>();
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<ElevatedProcessRequest> Requests { get; } = new();
public int LaunchCount => Requests.Count;
public bool ThrowOnLaunch { get; init; }
/// <summary>Set once the (awaited) launch has fully completed. Proves the caller waited.</summary>
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<SdkSettings> patch) => patch(CurrentValue);
}
}

View file

@ -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);
}
/// <summary>
/// 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.
/// </summary>
[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<ArgumentException>(() => AppPaths.ResolveStorageLocationPath(path!));
}
// ── GetConfigDirectory ───────────────────────────────────────────────────
[Fact]
public void GetConfigDirectory_ReturnsAbsoluteExistingDirectory()
{
var configDir = AppPaths.GetConfigDirectory();
Assert.True(Path.IsPathRooted(configDir));
Assert.True(Directory.Exists(configDir));
}
/// <summary>
/// With no override, the data root is a "Data" folder under the current working directory when writable.
/// </summary>
[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);
}
}

View file

@ -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<ISettingsProvider, FakeSettingsProvider>();
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<int>();
// 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<int>();
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<int>();
Assert.Equal(42, result);
}
private sealed class FakeSettingsProvider : ISettingsProvider
{
public SdkSettings CurrentValue { get; } = new();
public void Update(Action<SdkSettings> patch) => patch(CurrentValue);
}
}

View file

@ -1,6 +1,8 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices;
using LANCommander.SDK.Helpers; using LANCommander.SDK.Helpers;
namespace LANCommander.SDK; namespace LANCommander.SDK;
@ -9,6 +11,8 @@ public static class AppPaths
{ {
private static string _configDirectory = String.Empty; private static string _configDirectory = String.Empty;
public const string DataDirectoryEnvironmentVariable = "LANCOMMANDER_DATA_DIR";
/// <summary> /// <summary>
/// Builds a full path under the application's config directory. /// Builds a full path under the application's config directory.
/// </summary> /// </summary>
@ -17,9 +21,33 @@ public static class AppPaths
public static string GetConfigPath(params string[] paths) public static string GetConfigPath(params string[] paths)
=> Path.Combine(GetConfigDirectory(), Path.Combine(paths)); => Path.Combine(GetConfigDirectory(), Path.Combine(paths));
/// <summary>
/// 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).
/// </summary>
/// <param name="storageLocationPath">The configured storage location path (absolute or relative).</param>
/// <param name="segments">Additional path segments appended to the resolved storage location.</param>
/// <returns>The absolute path to the storage location (plus any appended segments).</returns>
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;
}
/// <summary> /// <summary>
/// Locates (and creates if necessary) the directory in which application data will be stored. /// 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 <see cref="DataDirectoryEnvironmentVariable"/> 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.
/// </summary> /// </summary>
/// <returns>The resolved config directory path.</returns> /// <returns>The resolved config directory path.</returns>
public static string GetConfigDirectory() public static string GetConfigDirectory()
@ -27,36 +55,57 @@ public static class AppPaths
if (!String.IsNullOrWhiteSpace(_configDirectory)) if (!String.IsNullOrWhiteSpace(_configDirectory))
return _configDirectory; return _configDirectory;
var baseDirectory = Directory.GetCurrentDirectory(); var overrideDirectory = Environment.GetEnvironmentVariable(DataDirectoryEnvironmentVariable);
if (DirectoryHelper.IsDirectoryWritable(baseDirectory)) if (!String.IsNullOrWhiteSpace(overrideDirectory))
_configDirectory = baseDirectory; {
// Operator-specified data root is used verbatim (no implicit "Data" subfolder).
_configDirectory = Path.GetFullPath(overrideDirectory);
}
else else
_configDirectory = GetAppDataPath(); {
var baseDirectory = Directory.GetCurrentDirectory();
_configDirectory = Path.Combine(_configDirectory, "Data");
_configDirectory = DirectoryHelper.IsDirectoryWritable(baseDirectory)
? Path.Combine(baseDirectory, "Data")
: Path.Combine(GetAppDataPath(), "Data");
}
if (!Directory.Exists(_configDirectory)) if (!Directory.Exists(_configDirectory))
Directory.CreateDirectory(_configDirectory); Directory.CreateDirectory(_configDirectory);
return _configDirectory; return _configDirectory;
} }
/// <summary> /// <summary>
/// Gets (and creates if necessary) the base local application data directory for the current user, /// 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. /// scoped by the entry assembly's company and product metadata. Uses the platform-native convention:
/// <c>%LOCALAPPDATA%</c> on Windows, <c>~/Library/Application Support</c> on macOS, and
/// <c>$XDG_DATA_HOME</c> (<c>~/.local/share</c>) on Linux.
/// </summary> /// </summary>
/// <returns>The local application data path for this application.</returns> /// <returns>The application data path for this application.</returns>
public static string GetAppDataPath() public static string GetAppDataPath()
{ {
var (company, product) = GetCompanyAndProduct(); var (company, product) = GetCompanyAndProduct();
var userRoot = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string userRoot;
var appDataPath = Path.Combine(userRoot, company, product);
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)) if (!Directory.Exists(appDataPath))
Directory.CreateDirectory(appDataPath); Directory.CreateDirectory(appDataPath);
return appDataPath; return appDataPath;
} }

View file

@ -115,15 +115,20 @@ namespace LANCommander.SDK.Helpers
// ── Linux helpers ───────────────────────────────────────────────────────── // ── Linux helpers ─────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Parses <c>xrandr</c> output to find the active resolution and refresh rate. /// Parses <c>xrandr</c> output to find the primary display's active resolution
/// Works on X11 and XWayland. /// 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: /// Example xrandr output:
/// <code> /// <code>
/// Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 16384 x 16384 /// Screen 0: minimum 16 x 16, current 4480 x 1440, maximum 32767 x 32767
/// DP-1 connected primary 1920x1080+0+0 ... /// DP-1 connected 1920x1200+2560+0 ...
/// 1920x1080 60.00*+ 50.00 59.94 /// 1920x1200 59.88*+
/// 1280x720 60.00 59.94 /// DP-3 connected primary 2560x1440+0+0 ...
/// 2560x1440 164.85*+
/// </code> /// </code>
/// </summary> /// </summary>
private static bool TryGetScreenFromXrandr(out Bounds bounds, out int refreshRate, out int bitsPerPixel) 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)) if (string.IsNullOrWhiteSpace(output))
return false; return false;
// "Screen 0: ... current 1920 x 1080 ..." var lines = output.Split('\n');
var screenMatch = Regex.Match(output, @"current\s+(\d+)\s*x\s*(\d+)");
if (!screenMatch.Success) 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; return false;
bounds.Width = int.Parse(screenMatch.Groups[1].Value); var displayMatch = Regex.Match(lines[displayIndex], connectedLine);
bounds.Height = int.Parse(screenMatch.Groups[2].Value); 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" for (var i = displayIndex + 1; i < lines.Length; i++)
// 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))
{ {
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; return bounds.Width > 0 && bounds.Height > 0;

View file

@ -170,23 +170,63 @@ namespace LANCommander.SDK.PowerShell
return Regex.IsMatch(Contents, pattern); return Regex.IsMatch(Contents, pattern);
} }
public async Task<T> ExecuteAsync<T>() /// <summary>
/// Builds the runspace configuration. When <paramref name="bypassExecutionPolicy"/> is set we
/// prefer an execution policy of <see cref="Microsoft.PowerShell.ExecutionPolicy.Bypass"/> so
/// unsigned game scripts run without prompting.
/// </summary>
private InitialSessionState CreateSessionState(bool bypassExecutionPolicy)
{ {
T result = default;
var initialSessionState = InitialSessionState.CreateDefault(); var initialSessionState = InitialSessionState.CreateDefault();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (bypassExecutionPolicy)
initialSessionState.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Bypass; initialSessionState.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Bypass;
initialSessionState.AddCustomCmdlets(); initialSessionState.AddCustomCmdlets();
DisableWow64Redirection(); return initialSessionState;
}
using (Runspace runspace = RunspaceFactory.CreateRunspace(initialSessionState)) /// <summary>
/// Opens a PowerShell runspace, preferring an execution policy of Bypass on Windows. Applying a
/// process-scope Bypass during <see cref="Runspace.Open"/> 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.
/// </summary>
private Runspace OpenRunspace()
{
var bypassExecutionPolicy = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var runspace = RunspaceFactory.CreateRunspace(CreateSessionState(bypassExecutionPolicy));
try
{ {
runspace.Open(); 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<T> ExecuteAsync<T>()
{
T result = default;
DisableWow64Redirection();
using (Runspace runspace = OpenRunspace())
{
var modulesPath = AppPaths.GetConfigPath("Modules"); var modulesPath = AppPaths.GetConfigPath("Modules");
if (Directory.Exists(modulesPath)) if (Directory.Exists(modulesPath))

View file

@ -36,27 +36,23 @@ namespace LANCommander.Server.Services
public string GetArchiveFileLocation(Archive archive, StorageLocation storageLocation) public string GetArchiveFileLocation(Archive archive, StorageLocation storageLocation)
{ {
return Path.IsPathRooted(storageLocation.Path) return AppPaths.ResolveStorageLocationPath(storageLocation.Path, archive.ObjectKey);
? Path.Combine(storageLocation.Path, archive.ObjectKey)
: AppPaths.GetConfigPath(storageLocation.Path, archive.ObjectKey);
} }
public async Task<string> GetArchiveFileLocationAsync(Archive archive) public async Task<string> GetArchiveFileLocationAsync(Archive archive)
{ {
string storageLocationPath; string storageLocationPath;
if (archive.StorageLocation != null) if (archive.StorageLocation != null)
storageLocationPath = archive.StorageLocation.Path; storageLocationPath = archive.StorageLocation.Path;
else else
{ {
var storageLocation = await storageLocationService.GetAsync(archive.StorageLocationId); var storageLocation = await storageLocationService.GetAsync(archive.StorageLocationId);
storageLocationPath = storageLocation.Path; storageLocationPath = storageLocation.Path;
} }
return Path.IsPathRooted(storageLocationPath) ? return AppPaths.ResolveStorageLocationPath(storageLocationPath, archive.ObjectKey);
Path.Combine(storageLocationPath, archive.ObjectKey) :
AppPaths.GetConfigPath(storageLocationPath, archive.ObjectKey);
} }
public async Task<string> GetArchiveFileLocationAsync(string objectKey) public async Task<string> GetArchiveFileLocationAsync(string objectKey)
@ -193,9 +189,6 @@ namespace LANCommander.Server.Services
{ {
var storageLocation = await storageLocationService.GetAsync(storageLocationId); var storageLocation = await storageLocationService.GetAsync(storageLocationId);
if (!Directory.Exists(storageLocation.Path))
Directory.CreateDirectory(storageLocation.Path);
var archive = new Archive var archive = new Archive
{ {
ObjectKey = Guid.NewGuid().ToString(), ObjectKey = Guid.NewGuid().ToString(),

View file

@ -71,9 +71,7 @@ namespace LANCommander.Server.Services
public string GetSavePath(GameSave save) public string GetSavePath(GameSave save)
{ {
var gameId = save.GameId ?? throw new InvalidOperationException($"No game ID is available for save {save.Id}"); var gameId = save.GameId ?? throw new InvalidOperationException($"No game ID is available for save {save.Id}");
return Path.IsPathRooted(save.StorageLocation.Path) ? return AppPaths.ResolveStorageLocationPath(save.StorageLocation.Path, save.UserId.ToString(), gameId.ToString(), save.Id.ToString());
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}");
} }
public async Task<StorageLocation> GetDefaultStorageLocationAsync() public async Task<StorageLocation> GetDefaultStorageLocationAsync()

View file

@ -97,9 +97,7 @@ namespace LANCommander.Server.Services
GetMediaPath(entity.FileId, entity.StorageLocation); GetMediaPath(entity.FileId, entity.StorageLocation);
public static string GetMediaPath(Guid id, StorageLocation storageLocation) => public static string GetMediaPath(Guid id, StorageLocation storageLocation) =>
Path.IsPathRooted(storageLocation.Path) AppPaths.ResolveStorageLocationPath(storageLocation.Path, id.ToString());
? Path.Combine(storageLocation.Path, id.ToString())
: Path.Combine(AppPaths.GetConfigDirectory(), storageLocation.Path, id.ToString());
public async Task<string> GetThumbnailPathAsync(Guid id) public async Task<string> GetThumbnailPathAsync(Guid id)
{ {

View file

@ -67,6 +67,8 @@ namespace LANCommander.Server.Services
}); });
} }
storagePath = AppPaths.ResolveStorageLocationPath(storagePath);
if (!Directory.Exists(storagePath)) if (!Directory.Exists(storagePath))
Directory.CreateDirectory(storagePath); Directory.CreateDirectory(storagePath);

View file

@ -136,6 +136,8 @@ namespace LANCommander.Server.Services
}); });
} }
storagePath = AppPaths.ResolveStorageLocationPath(storagePath);
if (!Directory.Exists(storagePath)) if (!Directory.Exists(storagePath))
Directory.CreateDirectory(storagePath); Directory.CreateDirectory(storagePath);

View file

@ -140,8 +140,11 @@ namespace LANCommander.Server.Services
foreach (var storageLocation in storageLocations) foreach (var storageLocation in storageLocations)
{ {
if (!Directory.Exists(storageLocation.Path)) // Store the configured (possibly relative) path, but create the resolved physical directory.
Directory.CreateDirectory(storageLocation.Path); var resolvedPath = AppPaths.ResolveStorageLocationPath(storageLocation.Path);
if (!Directory.Exists(resolvedPath))
Directory.CreateDirectory(resolvedPath);
try try
{ {

View file

@ -42,8 +42,9 @@ namespace LANCommander.Server.Services
public IEnumerable<LauncherArtifact> GetLauncherArtifactsFromLocalFiles() public IEnumerable<LauncherArtifact> GetLauncherArtifactsFromLocalFiles()
{ {
var currentVersion = versionProvider.GetCurrentVersion(); var currentVersion = versionProvider.GetCurrentVersion();
var downloadedLaunchers = Directory.GetFiles(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, $"LANCommander.Launcher*v{currentVersion.WithoutMetadata()}.*"); var launcherStoragePath = AppPaths.ResolveStorageLocationPath(_settingsProvider.CurrentValue.Server.Launcher.StoragePath);
var downloadedInstallers = Directory.GetFiles(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, $"LANCommander.Launcher-{currentVersion.WithoutMetadata()}*Setup*.*"); 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(); var downloads = downloadedLaunchers.Concat(downloadedInstallers).Distinct();
@ -149,7 +150,7 @@ namespace LANCommander.Server.Services
client.DownloadFileCompleted += ReleaseDownloaded; client.DownloadFileCompleted += ReleaseDownloaded;
client.QueryString.Add("Version", release.TagName); 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); var uri = new Uri(releaseFile);
client.QueryString.Add("Version", release.TagName); 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) private void ReleaseDownloaded(object? sender, System.ComponentModel.AsyncCompletedEventArgs e)
{ {
string version = ((WebClient)sender).QueryString["Version"]; 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); _logger?.LogInformation("Update version {Version} has been downloaded", version);
@ -242,7 +243,7 @@ namespace LANCommander.Server.Services
var process = new ProcessStartInfo(); var process = new ProcessStartInfo();
process.FileName = processExecutable; 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.UseShellExecute = true;
Process.Start(process); Process.Start(process);
@ -256,13 +257,11 @@ namespace LANCommander.Server.Services
GetLauncherFileLocation(artifact.Name); GetLauncherFileLocation(artifact.Name);
public string GetLauncherFileLocation(string objectKey) => public string GetLauncherFileLocation(string objectKey) =>
Path.IsPathRooted(_settingsProvider.CurrentValue.Server.Launcher.StoragePath) ? AppPaths.ResolveStorageLocationPath(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, objectKey);
Path.Combine(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, objectKey) :
Path.Combine(AppPaths.GetConfigDirectory(), _settingsProvider.CurrentValue.Server.Launcher.StoragePath, objectKey);
public LauncherArtifact GetLauncherArtifact(string 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); return GetArtifactFromName(name);
} }
} }

View file

@ -96,6 +96,66 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture)
} }
} }
/// <summary>
/// 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 <see cref="GameSaveService.GetSavePath"/>,
/// which must anchor a relative storage location beneath the config directory.
/// </summary>
[Fact]
public void GetSavePath_RelativeStorageLocation_ResolvesUnderConfigDirectory()
{
var saveService = GetService<GameSaveService>();
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<GameSaveService>();
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] [Fact]
public async Task SaveUploadWorksAsync() public async Task SaveUploadWorksAsync()
{ {

View file

@ -80,6 +80,7 @@ public static class DownloadEndpoints
var save = await gameSaveService var save = await gameSaveService
.Include(s => s.Game!) .Include(s => s.Game!)
.Include(s => s.User!) .Include(s => s.User!)
.Include(s => s.StorageLocation!)
.GetAsync(id); .GetAsync(id);
if (user == null || user.Identity?.Name != save.User?.UserName && !user.IsInRole(RoleService.AdministratorRoleName)) if (user == null || user.Identity?.Name != save.User?.UserName && !user.IsInRole(RoleService.AdministratorRoleName))

View file

@ -1,3 +1,4 @@
using LANCommander.SDK;
using LANCommander.SDK.Models; using LANCommander.SDK.Models;
using LANCommander.Server.Services; using LANCommander.Server.Services;
using LANCommander.Server.Services.Abstractions; using LANCommander.Server.Services.Abstractions;
@ -23,7 +24,7 @@ public static class LauncherEndpoints
{ {
var version = versionProvider.GetCurrentVersion(); var version = versionProvider.GetCurrentVersion();
var fileName = $"LANCommander.Launcher-Windows-x64-v{version.WithoutMetadata()}.zip"; 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) if (!File.Exists(path) || !settingsProvider.CurrentValue.Server.Launcher.HostUpdates)
{ {

View file

@ -170,8 +170,8 @@ public static class SaveEndpoints
if (latestSave == null) if (latestSave == null)
return TypedResults.NotFound(); return TypedResults.NotFound();
var fileName = latestSave.GetUploadPath(); var fileName = saveService.GetSavePath(latestSave);
if (!File.Exists(fileName)) if (!File.Exists(fileName))
return TypedResults.NotFound(); return TypedResults.NotFound();
@ -201,11 +201,11 @@ public static class SaveEndpoints
.Include(s => s.StorageLocation) .Include(s => s.StorageLocation)
.FirstOrDefaultAsync(s => s.Id == id && s.UserId == user.Id); .FirstOrDefaultAsync(s => s.Id == id && s.UserId == user.Id);
var fileName = save.GetUploadPath(); var fileName = saveService.GetSavePath(save);
if (!File.Exists(fileName)) if (!File.Exists(fileName))
return TypedResults.NotFound(); return TypedResults.NotFound();
var downloadName = $"{save.Game.Title} - {user.UserName} - {save.CreatedOn}".SanitizeFilename(); 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"); 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 try
{ {
var saveUploadFile = save.GetUploadPath(); var saveUploadFile = saveService.GetSavePath(save);
var saveUploadPath = Path.GetDirectoryName(saveUploadFile); var saveUploadPath = Path.GetDirectoryName(saveUploadFile);
if (!Directory.Exists(saveUploadPath)) if (!Directory.Exists(saveUploadPath))

View file

@ -28,9 +28,6 @@ public static class UploadEndpoints
storageLocationId == null || storageLocationId == Guid.Empty ? null : storageLocationId, storageLocationId == null || storageLocationId == Guid.Empty ? null : storageLocationId,
SDK.Enums.StorageLocationType.Archive); SDK.Enums.StorageLocationType.Archive);
if (!Directory.Exists(storageLocation.Path))
Directory.CreateDirectory(storageLocation.Path);
var archive = new Archive var archive = new Archive
{ {
ObjectKey = Guid.NewGuid().ToString(), ObjectKey = Guid.NewGuid().ToString(),

View file

@ -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());
}
}
}

View file

@ -1,4 +1,5 @@
using LANCommander.SDK.Extensions; using LANCommander.SDK;
using LANCommander.SDK.Extensions;
using LANCommander.Server.Services; using LANCommander.Server.Services;
using LANCommander.Server.Services.Models; using LANCommander.Server.Services.Models;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -28,7 +29,7 @@ namespace LANCommander.Server.Jobs.Background
if (!localArtifacts.Any(a => a.Name.EndsWith(artifact.Name))) if (!localArtifacts.Any(a => a.Name.EndsWith(artifact.Name)))
{ {
using (var downloadStream = await httpClient.GetStreamAsync(artifact.Url)) 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); await downloadStream.CopyToAsync(fs);
op.Complete(); op.Complete();

View file

@ -0,0 +1,89 @@
using LANCommander.SDK;
using LANCommander.SDK.Helpers;
using LANCommander.SDK.Migrations;
using Semver;
namespace LANCommander.Server.Migrations;
/// <summary>
/// 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 <see cref="AppPaths.ResolveStorageLocationPath(string, string[])"/>.
/// 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.
/// </summary>
public class AlignSettingsStoragePathsMigration(
SettingsProvider<Settings.Settings> settingsProvider,
ILogger<AlignSettingsStoragePathsMigration> logger) : FileSystemMigration(logger)
{
public override SemVersion Version => new(2, 1, 0);
private IEnumerable<string> 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<bool> 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);
}
}

View file

@ -23,9 +23,14 @@ public static class Filesystem
foreach (var directory in directories) foreach (var directory in directories)
{ {
logger.LogDebug("Ensuring directory {Directory} exists", directory); if (string.IsNullOrWhiteSpace(directory))
if (!Directory.Exists(directory)) continue;
Directory.CreateDirectory(directory);
var resolved = AppPaths.ResolveStorageLocationPath(directory);
logger.LogDebug("Ensuring directory {Directory} exists", resolved);
if (!Directory.Exists(resolved))
Directory.CreateDirectory(resolved);
} }
return app; return app;

View file

@ -29,6 +29,9 @@ public static class Migrations
builder.Services.AddScoped<IMigration, MoveLauncherMigration>(); builder.Services.AddScoped<IMigration, MoveLauncherMigration>();
builder.Services.AddScoped<IMigration, MoveLogsMigration>(); builder.Services.AddScoped<IMigration, MoveLogsMigration>();
// Align settings-based storage paths with the unified resolver
builder.Services.AddScoped<IMigration, AlignSettingsStoragePathsMigration>();
return builder; return builder;
} }