Compare commits
6 commits
feature/pl
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f08fd24a14 | ||
|
|
da94ff8438 | ||
|
|
41bcfa908d | ||
|
|
2ca1db535c | ||
|
|
3eae04abdd | ||
|
|
2b10b55b38 |
31 changed files with 962 additions and 139 deletions
|
|
@ -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
|
|||
|
||||
</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
|
||||
|
||||
<ReleaseDownloads release="v2.1.9" />
|
||||
|
||||
<details>
|
||||
<summary>View 2.1.8 downloads</summary>
|
||||
|
||||
<ReleaseDownloads release="v2.1.8" />
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<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
|
||||
|
||||
<ContributorGrid from="v2.0.2" to="v2.1.8" />
|
||||
<ContributorGrid from="v2.0.2" to="v2.1.9" />
|
||||
|
|
|
|||
32
LANCommander.Documentation/Releases/2.1.9.mdx
Normal file
32
LANCommander.Documentation/Releases/2.1.9.mdx
Normal 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" />
|
||||
|
|
@ -35,6 +35,8 @@ namespace LANCommander.Launcher.Services.Extensions
|
|||
services.AddSingleton<KeepAliveService>();
|
||||
#endregion
|
||||
|
||||
services.AddSingleton<ICurrentProcessInfo, CurrentProcessInfo>();
|
||||
services.AddSingleton<IElevatedProcessLauncher, ElevatedProcessLauncher>();
|
||||
services.AddSingleton<IScriptInterceptor, ElevatedScriptInterceptor>();
|
||||
services.AddSingleton<ScriptDebugger>();
|
||||
services.AddSingleton<IScriptDebugger>(sp =>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<bool> 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<SDK.Models.Manifest.Game>("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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
102
LANCommander.SDK.Tests/AppPathsTests.cs
Normal file
102
LANCommander.SDK.Tests/AppPathsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a full path under the application's config directory.
|
||||
/// </summary>
|
||||
|
|
@ -17,9 +21,33 @@ public static class AppPaths
|
|||
public static string GetConfigPath(params string[] 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>
|
||||
/// 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>
|
||||
/// <returns>The resolved config directory path.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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:
|
||||
/// <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>
|
||||
/// <returns>The local application data path for this application.</returns>
|
||||
/// <returns>The application data path for this application.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,15 +115,20 @@ namespace LANCommander.SDK.Helpers
|
|||
// ── Linux helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>xrandr</c> output to find the active resolution and refresh rate.
|
||||
/// Works on X11 and XWayland.
|
||||
/// Parses <c>xrandr</c> 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:
|
||||
/// <code>
|
||||
/// 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*+
|
||||
/// </code>
|
||||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -170,23 +170,63 @@ namespace LANCommander.SDK.PowerShell
|
|||
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();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
if (bypassExecutionPolicy)
|
||||
initialSessionState.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Bypass;
|
||||
|
||||
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();
|
||||
|
||||
|
||||
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");
|
||||
|
||||
if (Directory.Exists(modulesPath))
|
||||
|
|
|
|||
|
|
@ -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<string> 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<string> 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(),
|
||||
|
|
|
|||
|
|
@ -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<StorageLocation> GetDefaultStorageLocationAsync()
|
||||
|
|
|
|||
|
|
@ -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<string> GetThumbnailPathAsync(Guid id)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ namespace LANCommander.Server.Services
|
|||
});
|
||||
}
|
||||
|
||||
storagePath = AppPaths.ResolveStorageLocationPath(storagePath);
|
||||
|
||||
if (!Directory.Exists(storagePath))
|
||||
Directory.CreateDirectory(storagePath);
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,8 @@ namespace LANCommander.Server.Services
|
|||
});
|
||||
}
|
||||
|
||||
storagePath = AppPaths.ResolveStorageLocationPath(storagePath);
|
||||
|
||||
if (!Directory.Exists(storagePath))
|
||||
Directory.CreateDirectory(storagePath);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -42,8 +42,9 @@ namespace LANCommander.Server.Services
|
|||
public IEnumerable<LauncherArtifact> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
public async Task SaveUploadWorksAsync()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ public static class Migrations
|
|||
builder.Services.AddScoped<IMigration, MoveLauncherMigration>();
|
||||
builder.Services.AddScoped<IMigration, MoveLogsMigration>();
|
||||
|
||||
// Align settings-based storage paths with the unified resolver
|
||||
builder.Services.AddScoped<IMigration, AlignSettingsStoragePathsMigration>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue