Move Steam integrations to separate assembly, add cmdlets for various Steam actions

This commit is contained in:
Pat Hartl 2026-01-24 16:37:07 -06:00
parent 036ef8a8c4
commit c5506b440f
67 changed files with 2512 additions and 517 deletions

View file

@ -5,6 +5,9 @@ using LANCommander.SDK.PowerShell;
using LANCommander.SDK.Providers;
using LANCommander.SDK.Rpc.Client;
using LANCommander.SDK.Services;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Extensions;
using LANCommander.Steam.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using RpcSubscriber = LANCommander.SDK.Rpc.Clients.RpcSubscriber;

View file

@ -32,6 +32,7 @@
<PackageReference Include="PeanutButter.INI" />
<PackageReference Include="Semver" />
<PackageReference Include="SharpCompress" />
<PackageReference Include="Svrooij.PowerShell.DI" />
<PackageReference Include="System.Management.Automation" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
@ -50,5 +51,9 @@
<PackagePath>\</PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LANCommander.Steam\LANCommander.Steam.csproj" />
</ItemGroup>
</Project>

View file

@ -15,6 +15,7 @@ public class Settings
public DebugSettings Debug { get; set; } = new();
public UpdateSettings Updates { get; set; } = new();
public IPXRelaySettings IPXRelay { get; set; } = new();
public SteamSettings Steam { get; set; } = new();
public string Culture { get; set; } = "en-US";
}

View file

@ -0,0 +1,11 @@
using System.Collections.Generic;
using LANCommander.Steam.Models;
namespace LANCommander.SDK.Models;
public class SteamSettings
{
public string Path { get; set; } = string.Empty;
public string InstallDirectory { get; set; } = "";
public ICollection<SteamCmdProfile> Profiles { get; set; } = [];
}

View file

@ -0,0 +1,63 @@
using System;
using System.Management.Automation;
using System.Security;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Enums;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommunications.Connect, "SteamCmd")]
[OutputType(typeof(SteamCmdStatus))]
[GenerateBindings]
public partial class ConnectSteamCmdCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public string Username { get; set; } = string.Empty;
[Parameter(Mandatory = false)]
public SecureString? Password { get; set; }
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken token)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
string? password = null;
if (Password != null)
{
var ptr = System.Runtime.InteropServices.Marshal.SecureStringToBSTR(Password);
try
{
password = System.Runtime.InteropServices.Marshal.PtrToStringBSTR(ptr);
}
finally
{
System.Runtime.InteropServices.Marshal.ZeroFreeBSTR(ptr);
}
}
var status = await _steamCmdService.LoginToSteamAsync(Username, password);
WriteObject(status);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "LoginError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -11,7 +11,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
[Cmdlet(VerbsData.Convert, "AspectRatio")]
[OutputType(typeof(string))]
public class ConvertAspectRatioCmdlet : BaseCmdlet
public class ConvertAspectRatioCmdlet : Cmdlet
{
[Parameter(Mandatory = true, Position = 0)]
public int Width { get; set; }

View file

@ -11,7 +11,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsData.ConvertFrom, "SerializedBase64")]
[OutputType(typeof(object))]
public class ConvertFromSerializedBase64Cmdlet : BaseCmdlet
public class ConvertFromSerializedBase64Cmdlet : Cmdlet
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
public string Input { get; set; }

View file

@ -12,7 +12,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsData.ConvertTo, "SerializedBase64")]
[OutputType(typeof(object))]
public class ConvertToSerializedBase64Cmdlet : BaseCmdlet
public class ConvertToSerializedBase64Cmdlet : Cmdlet
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
public object Input { get; set; }

View file

@ -6,7 +6,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsData.ConvertTo, "StringBytes")]
[OutputType(typeof(byte[]))]
public class ConvertToStringBytesCmdlet : BaseCmdlet
public class ConvertToStringBytesCmdlet : Cmdlet
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
public string Input { get; set; }

View file

@ -0,0 +1,45 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Enums;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommunications.Disconnect, "SteamCmd")]
[OutputType(typeof(SteamCmdStatus))]
[GenerateBindings]
public partial class DisconnectSteamCmdCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public string Username { get; set; } = string.Empty;
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var status = await _steamCmdService.LogoutAsync(Username);
WriteObject(status);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "LogoutError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -5,7 +5,7 @@ using System.Management.Automation;
namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsData.Edit, "PatchBinary")]
public class EditPatchBinaryCmdlet : BaseCmdlet
public class EditPatchBinaryCmdlet : Cmdlet
{
[Parameter(Mandatory = true, Position = 0)]
public long Offset { get; set; }

View file

@ -12,7 +12,7 @@ using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsData.Edit, "PatchGameSpy")]
public class EditPatchGameSpy : BaseCmdlet
public class EditPatchGameSpy : Cmdlet
{
private const string GAMESPY_HOSTNAME = "gamespy.com";
private const string GAMESPY_PUBLICKEY = "BF05D63E93751AD4A59A4A7389CF0BE8A22CCDEEA1E7F12C062D6E194472EFDA5184CCECEB4FBADF5EB1D7ABFE91181453972AA971F624AF9BA8F0F82E2869FB7D44BDE8D56EE50977898F3FEE75869622C4981F07506248BD3D092E8EA05C12B2FA37881176084C8F8B8756C4722CDC57D2AD28ACD3AD85934FB48D6B2D2027";

View file

@ -7,7 +7,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsCommon.Get, "GameManifest")]
[OutputType(typeof(SDK.Models.Manifest.Game))]
public class GetGameManifestCmdlet : BaseCmdlet
public class GetGameManifestCmdlet : Cmdlet
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
public string Path { get; set; }

View file

@ -9,7 +9,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsCommon.Get, "VerticalFov")]
[OutputType(typeof(string))]
public class GetHorizontalFovCmdlet : BaseCmdlet
public class GetHorizontalFovCmdlet : Cmdlet
{
[Parameter] public int Width { get; set; } = 0;
[Parameter] public int Height { get; set; } = 0;

View file

@ -8,7 +8,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsCommon.Get, "PrimaryDisplay")]
[OutputType(typeof(string))]
public class GetPrimaryDisplayCmdlet : BaseCmdlet
public class GetPrimaryDisplayCmdlet : Cmdlet
{
protected override void ProcessRecord()
{

View file

@ -0,0 +1,44 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Enums;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamCmdConnectionStatus")]
[OutputType(typeof(SteamCmdConnectionStatus))]
[GenerateBindings]
public partial class GetSteamCmdConnectionStatusCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public string Username { get; set; } = string.Empty;
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var status = await _steamCmdService.GetConnectionStatusAsync(Username);
WriteObject(status);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "GetConnectionStatusError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,43 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamCmdPath")]
[OutputType(typeof(string))]
[GenerateBindings]
public partial class GetSteamCmdPathCmdlet : DependencyCmdlet<PowerShellStartup>
{
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var path = await _steamCmdService.AutoDetectSteamCmdPathAsync();
if (!string.IsNullOrEmpty(path))
{
WriteObject(path);
}
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "AutoDetectPathError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,47 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Models;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamCmdProfile")]
[OutputType(typeof(SteamCmdProfile))]
[GenerateBindings]
public partial class GetSteamCmdProfileCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public string Username { get; set; } = string.Empty;
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var profile = await _steamCmdService.GetProfileAsync(Username);
if (profile != null)
{
WriteObject(profile);
}
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "GetProfileError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,44 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Models;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamCmdProfiles")]
[OutputType(typeof(SteamCmdProfile))]
[GenerateBindings]
public partial class GetSteamCmdProfilesCmdlet : DependencyCmdlet<PowerShellStartup>
{
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var profiles = await _steamCmdService.GetProfilesAsync();
foreach (var profile in profiles)
{
WriteObject(profile);
}
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "GetProfilesError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,47 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Models;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamInstallJob")]
[OutputType(typeof(SteamCmdInstallJob))]
[GenerateBindings]
public partial class GetSteamInstallJobCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public Guid JobId { get; set; }
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var job = _steamCmdService.GetInstallJob(JobId);
if (job != null)
{
WriteObject(job);
}
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "GetInstallJobError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,44 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Models;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamInstallJobs")]
[OutputType(typeof(SteamCmdInstallJob))]
[GenerateBindings]
public partial class GetSteamInstallJobsCmdlet : DependencyCmdlet<PowerShellStartup>
{
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var jobs = _steamCmdService.GetInstallJobs();
foreach (var job in jobs)
{
WriteObject(job);
}
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "GetInstallJobsError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,62 @@
using System;
using System.IO;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Services;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamManual")]
[OutputType(typeof(byte[]))]
[GenerateBindings]
public partial class GetSteamManualCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public int AppId { get; set; }
[Parameter(Mandatory = false)]
public string? OutputPath { get; set; }
[ServiceDependency]
private SteamStoreService _steamStoreService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamStoreService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamStoreService is not available in the PowerShell session"),
"SteamStoreServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var manualData = await _steamStoreService.DownloadManualAsync(AppId);
if (manualData == null || manualData.Length == 0)
{
WriteWarning($"No manual found for App ID {AppId}");
return;
}
if (!string.IsNullOrEmpty(OutputPath))
{
await File.WriteAllBytesAsync(OutputPath, manualData, cancellationToken);
WriteObject(OutputPath);
}
else
{
WriteObject(manualData);
}
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "DownloadManualError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,30 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Services;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamManualUri")]
[OutputType(typeof(Uri))]
[GenerateBindings]
public partial class GetSteamManualUriCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public int AppId { get; set; }
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
try
{
var uri = SteamStoreService.GetManualUri(AppId);
WriteObject(uri);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "GetManualUriError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,34 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam;
using LANCommander.Steam.Services;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamWebAssetUri")]
[OutputType(typeof(Uri))]
[GenerateBindings]
public partial class GetSteamWebAssetUriCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public int AppId { get; set; }
[Parameter(Mandatory = true, Position = 1)]
public WebAssetType WebAssetType { get; set; }
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
try
{
var uri = SteamStoreService.GetWebAssetUri(AppId, WebAssetType);
WriteObject(uri);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "GetWebAssetUriError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -8,7 +8,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsCommon.Get, "UserCustomField")]
[OutputType(typeof(string))]
public class GetUserCustomFieldCmdlet(ProfileClient profileClient) : BaseCmdlet
public class GetUserCustomFieldCmdlet(ProfileClient profileClient) : Cmdlet
{
[Parameter(Mandatory = true, Position = 0)]
public string Name { get; set; }

View file

@ -9,7 +9,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsCommon.Get, "VerticalFov")]
[OutputType(typeof(string))]
public class GetVerticalFovCmdlet : BaseCmdlet
public class GetVerticalFovCmdlet : Cmdlet
{
[Parameter] public int Width { get; set; } = 0;
[Parameter] public int Height { get; set; } = 0;

View file

@ -0,0 +1,50 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Models;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsLifecycle.Install, "SteamContent")]
[OutputType(typeof(SteamCmdInstallJob))]
[GenerateBindings]
public partial class InstallSteamContentCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public uint AppId { get; set; }
[Parameter(Mandatory = true, Position = 1)]
public string InstallDirectory { get; set; } = string.Empty;
[Parameter(Mandatory = false)]
public string? Username { get; set; }
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var job = await _steamCmdService.InstallContentAsync(AppId, InstallDirectory, Username);
WriteObject(job);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "InstallContentError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -5,7 +5,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsData.Out, "PlayerAvatar")]
[OutputType(typeof(string))]
public class OutPlayerAvatarCmdlet(ProfileClient profileClient) : BaseCmdlet
public class OutPlayerAvatarCmdlet(ProfileClient profileClient) : Cmdlet
{
protected override void ProcessRecord()
{

View file

@ -0,0 +1,41 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Remove, "SteamCmdProfile")]
[GenerateBindings]
public partial class RemoveSteamCmdProfileCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public string Username { get; set; } = string.Empty;
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
await _steamCmdService.DeleteProfileAsync(Username);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "DeleteProfileError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,44 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Enums;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Remove, "SteamContent")]
[OutputType(typeof(SteamCmdStatus))]
[GenerateBindings]
public partial class RemoveSteamContentCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public string InstallDirectory { get; set; } = string.Empty;
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var status = await _steamCmdService.RemoveContentAsync(InstallDirectory);
WriteObject(status);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "RemoveContentError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,47 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam;
using LANCommander.Steam.Services;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Search, "SteamGames")]
[OutputType(typeof(GameSearchResult))]
[GenerateBindings]
public partial class SearchSteamGamesCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public string Keyword { get; set; } = string.Empty;
[ServiceDependency]
private SteamStoreService _steamStoreService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamStoreService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamStoreService is not available in the PowerShell session"),
"SteamStoreServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var results = await _steamStoreService.SearchGamesAsync(Keyword);
foreach (var result in results)
{
WriteObject(result);
}
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "SearchGamesError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,51 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Models;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Set, "SteamCmdProfile")]
[GenerateBindings]
public partial class SetSteamCmdProfileCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public string Username { get; set; } = string.Empty;
[Parameter(Mandatory = true, Position = 1)]
public string InstallDirectory { get; set; } = string.Empty;
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var profile = new SteamCmdProfile
{
Username = Username,
InstallDirectory = InstallDirectory
};
await _steamCmdService.SaveProfileAsync(profile);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "SaveProfileError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,43 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsLifecycle.Stop, "SteamInstallJob")]
[OutputType(typeof(bool))]
[GenerateBindings]
public partial class StopSteamInstallJobCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public Guid JobId { get; set; }
[ServiceDependency]
private ISteamCmdService _steamCmdService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamCmdService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamCmdService is not available in the PowerShell session"),
"SteamCmdServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var cancelled = await _steamCmdService.CancelInstallJobAsync(JobId);
WriteObject(cancelled);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "CancelInstallJobError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,43 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Services;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsDiagnostic.Test, "SteamManual")]
[OutputType(typeof(bool))]
[GenerateBindings]
public partial class TestSteamManualCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public int AppId { get; set; }
[ServiceDependency]
private SteamStoreService _steamStoreService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamStoreService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamStoreService is not available in the PowerShell session"),
"SteamStoreServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var exists = await _steamStoreService.HasManualAsync(AppId);
WriteObject(exists);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "HasManualError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -0,0 +1,47 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam;
using LANCommander.Steam.Services;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsDiagnostic.Test, "SteamWebAsset")]
[OutputType(typeof(bool))]
[GenerateBindings]
public partial class TestSteamWebAssetCmdlet : DependencyCmdlet<PowerShellStartup>
{
[Parameter(Mandatory = true, Position = 0)]
public int AppId { get; set; }
[Parameter(Mandatory = true, Position = 1)]
public WebAssetType WebAssetType { get; set; }
[ServiceDependency]
private SteamStoreService _steamStoreService;
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
if (_steamStoreService == null)
{
WriteError(new ErrorRecord(
new InvalidOperationException("SteamStoreService is not available in the PowerShell session"),
"SteamStoreServiceNotAvailable",
ErrorCategory.InvalidOperation,
null));
return;
}
try
{
var (exists, _) = await _steamStoreService.HasWebAssetAsync(AppId, WebAssetType);
WriteObject(exists);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "HasWebAssetError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -13,7 +13,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
/// </summary>
[Cmdlet(VerbsData.Update, "IniValue")]
[OutputType(typeof(string))]
public class UpdateIniValueCmdlet : BaseCmdlet
public class UpdateIniValueCmdlet : Cmdlet
{
/// <summary>
/// Gets or sets the section in the INI file that contains the key to be updated.

View file

@ -5,7 +5,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsData.Update, "UserCustomField")]
[OutputType(typeof(string))]
public class UpdateUserCustomFieldCmdlet(ProfileClient profileClient) : BaseCmdlet
public class UpdateUserCustomFieldCmdlet(ProfileClient profileClient) : Cmdlet
{
[Parameter(Mandatory = true, Position = 0)]
public string Name { get; set; }

View file

@ -6,7 +6,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
{
[Cmdlet(VerbsCommunications.Write, "GameManifest")]
[OutputType(typeof(string))]
public class WriteGameManifestCmdlet : BaseCmdlet
public class WriteGameManifestCmdlet : Cmdlet
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
public string Path { get; set; }

View file

@ -6,7 +6,7 @@ namespace LANCommander.SDK.PowerShell.Cmdlets
[Cmdlet(VerbsCommunications.Write, "ReplaceContentInFile")]
[OutputType(typeof(string))]
public class ReplaceContentInFileCmdlet : BaseCmdlet
public class ReplaceContentInFileCmdlet : Cmdlet
{
[Parameter(Mandatory = true, Position = 0)]
public string Pattern { get; set; }

View file

@ -1,8 +0,0 @@
using System.Management.Automation;
namespace LANCommander.SDK.PowerShell.Cmdlets
{
public abstract class BaseCmdlet : Cmdlet
{
}
}

View file

@ -27,5 +27,28 @@ public static class InitialSessionStateExtensions
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Update-UserCustomField", typeof(UpdateUserCustomFieldCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Write-GameManifest", typeof(WriteGameManifestCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Write-ReplaceContentInFile", typeof(ReplaceContentInFileCmdlet), null));
// SteamCMD cmdlets
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Connect-SteamCmd", typeof(ConnectSteamCmdCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Disconnect-SteamCmd", typeof(DisconnectSteamCmdCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-SteamCmdConnectionStatus", typeof(GetSteamCmdConnectionStatusCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-SteamCmdPath", typeof(GetSteamCmdPathCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-SteamCmdProfile", typeof(GetSteamCmdProfileCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-SteamCmdProfiles", typeof(GetSteamCmdProfilesCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-SteamInstallJob", typeof(GetSteamInstallJobCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-SteamInstallJobs", typeof(GetSteamInstallJobsCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Install-SteamContent", typeof(InstallSteamContentCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Remove-SteamContent", typeof(RemoveSteamContentCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Remove-SteamCmdProfile", typeof(RemoveSteamCmdProfileCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Set-SteamCmdProfile", typeof(SetSteamCmdProfileCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Stop-SteamInstallJob", typeof(StopSteamInstallJobCmdlet), null));
// Steam Store cmdlets
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-SteamManual", typeof(GetSteamManualCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-SteamManualUri", typeof(GetSteamManualUriCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-SteamWebAssetUri", typeof(GetSteamWebAssetUriCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Search-SteamGames", typeof(SearchSteamGamesCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Test-SteamManual", typeof(TestSteamManualCmdlet), null));
initialSessionState.Commands.Add(new SessionStateCmdletEntry("Test-SteamWebAsset", typeof(TestSteamWebAssetCmdlet), null));
}
}

View file

@ -1,4 +1,4 @@
using LANCommander.SDK.Enums;
using LANCommander.SDK.Enums;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;

View file

@ -0,0 +1,18 @@
using LANCommander.SDK.Extensions;
using LANCommander.SDK.Models;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Services;
using Microsoft.Extensions.DependencyInjection;
using Svrooij.PowerShell.DI;
namespace LANCommander.SDK.PowerShell;
public class PowerShellStartup : PsStartup
{
public override void ConfigureServices(IServiceCollection services)
{
services.AddLANCommanderClient<Settings>();
services.AddScoped<ISteamCmdService, SteamCmdService>();
services.AddScoped<SteamStoreService>();
}
}

View file

@ -0,0 +1,49 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LANCommander.SDK.Abstractions;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Models;
namespace LANCommander.SDK.Providers;
public class SteamCmdProfileStore(ISettingsProvider settingsProvider) : ISteamCmdProfileStore
{
public async Task<IEnumerable<SteamCmdProfile>> GetAllAsync()
=> settingsProvider.CurrentValue.Steam.Profiles;
public async Task<SteamCmdProfile?> GetByUsernameAsync(string username)
=> settingsProvider.CurrentValue.Steam.Profiles.FirstOrDefault(p => p.Username == username);
public async Task SaveAsync(SteamCmdProfile profile)
{
settingsProvider.Update(s =>
{
var existing = s.Steam.Profiles.FirstOrDefault(p => p.Username == profile.Username);
if (existing != null)
{
// Update existing profile
existing.InstallDirectory = profile.InstallDirectory;
}
else
{
// Add new profile
s.Steam.Profiles.Add(profile);
}
});
}
public async Task DeleteAsync(string username)
{
settingsProvider.Update(s =>
{
var existing = s.Steam.Profiles.FirstOrDefault(p => p.Username == username);
if (existing != null)
{
s.Steam.Profiles.Remove(existing);
}
});
}
}

View file

@ -55,7 +55,6 @@ public static class IServiceCollectionExtensions
services.AddScoped<RoleService>();
services.AddScoped<UserCustomFieldService>();
services.AddScoped<GameCustomFieldService>();
services.AddScoped<SteamCMDService>();
services.AddScoped<ChatService>();
services.AddScoped<ChatMessageService>();
services.AddScoped<ChatThreadService>();

View file

@ -1,441 +0,0 @@
using LANCommander.Server.Services.Enums;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
namespace LANCommander.Server.Services;
public class SteamCMDService(
ILogger<SteamCMDService> logger,
SettingsProvider<Settings.Settings> settingsProvider) : BaseService(logger, settingsProvider)
{
public async Task<SteamCmdConnectionStatus> GetConnectionStatusAsync(string username)
{
try
{
if (!IsValidUsername(username))
throw new ArgumentException("Invalid username", nameof(username));
// Auto-populate SteamCMD path if not configured
if (string.IsNullOrWhiteSpace(_settingsProvider.CurrentValue.Server.SteamCMD.Path))
{
var detectedPath = await AutoDetectSteamCmdPathAsync();
if (!string.IsNullOrWhiteSpace(detectedPath))
{
_settingsProvider.Update(s =>
{
s.Server.SteamCMD.Path = detectedPath;
});
_logger.LogInformation("Auto-detected SteamCMD at: {Path}", detectedPath);
}
else
{
_logger.LogWarning("SteamCMD path is not configured and could not be auto-detected");
return SteamCmdConnectionStatus.NotInstalled;
}
}
if (!File.Exists(_settingsProvider.CurrentValue.Server.SteamCMD.Path))
{
_logger.LogWarning("SteamCMD executable not found at configured path: {Path}", _settingsProvider.CurrentValue.Server.SteamCMD.Path);
return SteamCmdConnectionStatus.NotInstalled;
}
// Try to run steamcmd with +quit to check if it's working
var result = await ExecuteSteamCmdCommandAsync("+quit");
if (result.Success)
{
// Check if we're logged in by trying to get user info
var loginResult = await ExecuteSteamCmdCommandAsync($"+login {username} +quit", TimeSpan.FromSeconds(30));
return loginResult.Success ? SteamCmdConnectionStatus.Authenticated : SteamCmdConnectionStatus.Unauthenticated;
}
return SteamCmdConnectionStatus.NotInstalled;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking SteamCMD connection status");
return SteamCmdConnectionStatus.NotInstalled;
}
}
public async Task<string> AutoDetectSteamCmdPathAsync()
{
var possiblePaths = new List<string>();
// Windows paths
if (OperatingSystem.IsWindows())
{
// Common Steam installation directories
var steamPaths = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Steam", "steamcmd.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Steam", "steamcmd.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Steam", "steamcmd.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Steam", "steamcmd.exe"),
"C:\\Steam\\steamcmd.exe",
"C:\\Program Files\\Steam\\steamcmd.exe",
"C:\\Program Files (x86)\\Steam\\steamcmd.exe"
};
possiblePaths.AddRange(steamPaths);
// Check PATH environment variable
var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? [];
foreach (var dir in pathDirs)
{
if (!string.IsNullOrWhiteSpace(dir))
{
possiblePaths.Add(Path.Combine(dir, "steamcmd.exe"));
}
}
}
// Linux paths
else if (OperatingSystem.IsLinux())
{
possiblePaths.AddRange(new[]
{
"/app/Data/Steam/steamcmd.sh",
"/usr/local/bin/steamcmd",
"/usr/bin/steamcmd",
"/home/steam/steamcmd/steamcmd.sh",
"/opt/steamcmd/steamcmd.sh",
"/var/lib/steam/steamcmd/steamcmd.sh"
});
// Check PATH environment variable
var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(':') ?? [];
foreach (var dir in pathDirs)
{
if (!string.IsNullOrWhiteSpace(dir))
{
possiblePaths.Add(Path.Combine(dir, "steamcmd"));
}
}
}
// macOS paths
else if (OperatingSystem.IsMacOS())
{
possiblePaths.AddRange(new[]
{
"/usr/local/bin/steamcmd",
"/opt/homebrew/bin/steamcmd",
"/Applications/Steam.app/Contents/MacOS/steamcmd"
});
// Check PATH environment variable
var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(':') ?? [];
foreach (var dir in pathDirs)
{
if (!string.IsNullOrWhiteSpace(dir))
{
possiblePaths.Add(Path.Combine(dir, "steamcmd"));
}
}
}
// Check each possible path
foreach (var path in possiblePaths)
{
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
{
try
{
// Verify it's actually SteamCMD by checking if it responds to --version or +quit
var testResult = await ExecuteSteamCmdCommandAsync("+quit", TimeSpan.FromSeconds(30), path);
if (testResult.Success)
{
_logger.LogDebug("Found SteamCMD at: {Path}", path);
return path;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to verify SteamCMD at: {Path}", path);
}
}
}
_logger.LogWarning("SteamCMD not found in common installation locations");
return String.Empty;
}
public async Task<SteamCmdStatus> LoginToSteamAsync(string username, string? password = null)
{
try
{
if (!IsValidUsername(username))
throw new ArgumentException("Invalid username", nameof(username));
if (string.IsNullOrWhiteSpace(_settingsProvider.CurrentValue.Server.SteamCMD.Path))
{
_logger.LogError("SteamCMD path is not configured");
return SteamCmdStatus.PathNotConfigured;
}
if (!File.Exists(_settingsProvider.CurrentValue.Server.SteamCMD.Path))
{
_logger.LogError("SteamCMD executable not found at configured path: {Path}", _settingsProvider.CurrentValue.Server.SteamCMD.Path);
return SteamCmdStatus.ExecutableNotFound;
}
var loginCommand = string.IsNullOrWhiteSpace(password)
? $"+login {username}"
: $"+login {username} {password}";
var result = await ExecuteSteamCmdCommandAsync($"{loginCommand} +quit", TimeSpan.FromSeconds(30));
if (result.Output.Contains("Invalid Password"))
return SteamCmdStatus.InvalidPassword;
if (result.Success)
{
_logger.LogInformation("Successfully logged into Steam as {Username}", username);
return SteamCmdStatus.Success;
}
_logger.LogError("Failed to log into Steam: {Error}", result.ErrorOutput);
return SteamCmdStatus.Error;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error logging into Steam");
return SteamCmdStatus.UnknownError;
}
}
public async Task<SteamCmdStatus> LogoutAsync(string username)
{
try
{
if (!IsValidUsername(username))
return SteamCmdStatus.InvalidUsername;
if (string.IsNullOrWhiteSpace(_settingsProvider.CurrentValue.Server.SteamCMD.Path))
{
_logger.LogError("SteamCMD path is not configured");
return SteamCmdStatus.PathNotConfigured;
}
if (!File.Exists(_settingsProvider.CurrentValue.Server.SteamCMD.Path))
{
_logger.LogError("SteamCMD executable not found at configured path: {Path}", _settingsProvider.CurrentValue.Server.SteamCMD.Path);
return SteamCmdStatus.ExecutableNotFound;
}
var logoutCommand = $"+logout {username}";
var result = await ExecuteSteamCmdCommandAsync($"{logoutCommand} +quit");
if (result.Success)
{
_logger.LogInformation("Successfully logged out of the Steam account {Username}", username);
return SteamCmdStatus.Success;
}
_logger.LogError("Failed to log out of Steam: {Error}", result.ErrorOutput);
return SteamCmdStatus.Error;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error logging out of Steam");
return SteamCmdStatus.UnknownError;
}
}
public async Task<SteamCmdStatus> InstallContentAsync(uint appId, string installDirectory, string username)
{
try
{
if (!IsValidUsername(username))
throw new ArgumentException("Invalid username", nameof(username));
if (string.IsNullOrWhiteSpace(_settingsProvider.CurrentValue.Server.SteamCMD.Path))
{
_logger.LogError("SteamCMD path is not configured");
return SteamCmdStatus.PathNotConfigured;
}
if (!File.Exists(_settingsProvider.CurrentValue.Server.SteamCMD.Path))
{
_logger.LogError("SteamCMD executable not found at configured path: {Path}", _settingsProvider.CurrentValue.Server.SteamCMD.Path);
return SteamCmdStatus.ExecutableNotFound;
}
// Ensure install directory exists
Directory.CreateDirectory(installDirectory);
var commands = new List<string>();
// Add login command if credentials provided
if (!string.IsNullOrWhiteSpace(username))
{
var loginCommand = $"+login {username}";
commands.Add(loginCommand);
}
else
{
commands.Add("+login anonymous");
}
// Add install commands
commands.Add($"+force_install_dir \"{installDirectory}\"");
commands.Add($"+app_update {appId} validate");
commands.Add("+quit");
var commandString = string.Join(" ", commands);
var result = await ExecuteSteamCmdCommandAsync(commandString);
if (result.Success)
{
_logger.LogInformation("Successfully installed Steam app {AppId} to {InstallDirectory}", appId, installDirectory);
return SteamCmdStatus.Success;
}
_logger.LogError("Failed to install Steam app {AppId}: {Error}", appId, result.ErrorOutput);
return SteamCmdStatus.Error;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error installing Steam content for app {AppId}", appId);
return SteamCmdStatus.UnknownError;
}
}
public async Task<SteamCmdStatus> RemoveContentAsync(string installDirectory)
{
try
{
if (string.IsNullOrWhiteSpace(installDirectory))
{
_logger.LogError("Install directory is not specified");
return SteamCmdStatus.InstallDirectoryNotFound;
}
if (!Directory.Exists(installDirectory))
{
_logger.LogWarning("Install directory does not exist: {InstallDirectory}", installDirectory);
return SteamCmdStatus.Success; // Consider it already removed
}
// Remove the directory and all its contents
Directory.Delete(installDirectory, true);
_logger.LogInformation("Successfully removed content from {InstallDirectory}", installDirectory);
return SteamCmdStatus.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing content from {InstallDirectory}", installDirectory);
return SteamCmdStatus.UnknownError;
}
}
private async Task<SteamCmdResult> ExecuteSteamCmdCommandAsync(string arguments, TimeSpan? timeout = null, string steamCmdPath = "")
{
try
{
var executablePath = String.IsNullOrWhiteSpace(steamCmdPath) ? _settingsProvider.CurrentValue.Server.SteamCMD.Path : steamCmdPath;
var processStartInfo = new ProcessStartInfo
{
FileName = executablePath,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = new Process { StartInfo = processStartInfo };
var output = new StringBuilder();
var error = new StringBuilder();
process.OutputDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
output.AppendLine(e.Data);
_logger.LogDebug("SteamCMD Output: {Output}", e.Data);
}
};
process.ErrorDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
error.AppendLine(e.Data);
_logger.LogDebug("SteamCMD Error: {Error}", e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
try
{
if (timeout.HasValue)
await process.WaitForExitAsync().WaitAsync(timeout.Value);
else
await process.WaitForExitAsync();
}
catch (TimeoutException)
{
_logger.LogWarning("SteamCMD timed out");
process.Kill();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while executing SteamCMD");
}
return new SteamCmdResult
{
Success = process.ExitCode == 0,
ExitCode = process.ExitCode,
Output = output.ToString(),
ErrorOutput = error.ToString()
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing SteamCMD command: {Arguments}", arguments);
return new SteamCmdResult
{
Success = false,
ExitCode = -1,
ErrorOutput = ex.Message
};
}
}
private static bool IsValidUsername(string value)
{
return !string.IsNullOrEmpty(value) &&
Regex.IsMatch(value, @"^[a-zA-Z0-9_]+$");
}
private class SteamCmdResult
{
public bool Success { get; set; }
public int ExitCode { get; set; }
public string Output { get; set; } = string.Empty;
public string ErrorOutput { get; set; } = string.Empty;
}
}

View file

@ -17,7 +17,6 @@ public class ServerSettings
public RoleSettings Roles { get; set; } = new();
public ScriptSettings Scripts { get; set; } = new();
public GameServerSettings GameServers { get; set; } = new();
public SteamCmdSettings SteamCMD { get; set; } = new();
public UpdateSettings Update { get; set; } = new();
public UserSaveSettings UserSaves { get; set; } = new();
}

View file

@ -1,7 +0,0 @@
namespace LANCommander.Server.Settings.Models;
public class SteamCmdProfile
{
public string Username { get; set; } = String.Empty;
public string InstallDirectory { get; set; } = String.Empty;
}

View file

@ -1,8 +0,0 @@
namespace LANCommander.Server.Settings.Models;
public class SteamCmdSettings
{
public string Path { get; set; } = String.Empty;
public string InstallDirectory { get; set; } = "";
public ICollection<SteamCmdProfile> Profiles { get; set; } = [];
}

View file

@ -23,6 +23,7 @@ builder.AddServerProcessStatusMonitor();
builder.AddLANCommanderServices();
builder.AddMigrations();
builder.AddDatabase(args);
builder.UseSteam();
builder.Services.AddHealthChecks();

View file

@ -0,0 +1,18 @@
using LANCommander.Steam.Extensions;
namespace LANCommander.Server.Startup;
public static class Steam
{
public static WebApplicationBuilder UseSteam(this WebApplicationBuilder builder)
{
var settings = builder.Configuration.Get<Settings.Settings>();
builder.Services.AddSteamCmd(o =>
{
o.ExecutablePath = settings?.Steam.Path;
});
return builder;
}
}

View file

@ -1,10 +1,12 @@
@page "/Settings/Integrations/Steam"
@using LANCommander.Server.Services.Enums
@using LANCommander.Server.Settings
@using LANCommander.Server.Settings.Models
@using LANCommander.Server.UI.Pages.Settings.Components
@using LANCommander.Steam.Abstractions
@using LANCommander.Steam.Enums
@using LANCommander.Steam.Models
@using Microsoft.Extensions.Options
@inject SteamCMDService SteamCmdService
@inject ISteamCmdService SteamCmdService
@inject ISteamCmdProfileStore SteamCmdProfileStore
@inject IOptions<Settings> Settings
@inject SettingsProvider<Settings> SettingsProvider
@inject IMessageService MessageService
@ -20,7 +22,7 @@
<PageContent>
<Form Model="Settings.Value" Layout="@FormLayout.Vertical">
<FormItem Label="SteamCMD Path">
<FilePicker @bind-Value="context.Server.SteamCMD.Path" Title="Choose SteamCMD Executable Path" Root="@_rootPath" />
<FilePicker @bind-Value="SteamCmdService.ExecutablePath" Title="Choose SteamCMD Executable Path" Root="@_rootPath" />
</FormItem>
</Form>
@ -28,7 +30,7 @@
<Flex Direction="FlexDirection.Vertical" Gap="FlexGap.Large">
<Collapse>
@foreach (var profile in Settings.Value.Server.SteamCMD.Profiles)
@foreach (var profile in _profiles)
{
<Panel Header="@(String.IsNullOrWhiteSpace(profile.Username) ? "New Profile" : profile.Username)">
<ExtraTemplate>
@ -57,6 +59,8 @@
SteamCmdConnectionStatus _connectionStatus;
List<SteamCmdProfile> _profiles = [];
string _username;
string _password;
@ -64,35 +68,20 @@
protected override async Task OnInitializedAsync()
{
if (!Settings.Value.Server.SteamCMD.Profiles.Any())
_profiles = (await SteamCmdService.GetProfilesAsync()).ToList();
if (!_profiles.Any())
AddProfile();
if (string.IsNullOrWhiteSpace(Settings.Value.Server.SteamCMD.Path))
{
var detectedPath = await SteamCmdService.AutoDetectSteamCmdPathAsync();
if (!string.IsNullOrWhiteSpace(detectedPath))
{
Settings.Value.Server.SteamCMD.Path = detectedPath;
SettingsProvider.Update(s =>
{
s.Server.SteamCMD.Path = detectedPath;
});
}
}
}
void AddProfile()
{
Settings.Value.Server.SteamCMD.Profiles.Add(new SteamCmdProfile());
_profiles.Add(new SteamCmdProfile());
}
async Task RemoveProfile(SteamCmdProfile profile)
{
Settings.Value.Server.SteamCMD.Profiles.Remove(profile);
if (!String.IsNullOrWhiteSpace(profile.Username))
if (!string.IsNullOrWhiteSpace(profile.Username))
{
var logoutMessage = new MessageConfig
{
@ -104,14 +93,17 @@
MessageService.Loading(logoutMessage);
await SteamCmdService.LogoutAsync(profile.Username);
await SteamCmdService.DeleteProfileAsync(profile.Username);
logoutMessage.Content = "Successfully logged out from Steam!";
logoutMessage.Duration = 3;
_profiles = (await SteamCmdService.GetProfilesAsync()).ToList();
MessageService.Success(logoutMessage);
}
if (!Settings.Value.Server.SteamCMD.Profiles.Any())
if (!_profiles.Any())
AddProfile();
}
@ -119,12 +111,10 @@
{
try
{
Settings.Value.Server.SteamCMD.Profiles = Settings.Value.Server.SteamCMD.Profiles.Where(p => !String.IsNullOrWhiteSpace(p.Username)).ToList();
SettingsProvider.Update(s =>
foreach (var profile in _profiles)
{
s.Server.SteamCMD = Settings.Value.Server.SteamCMD;
});
await SteamCmdService.SaveProfileAsync(profile);
}
MessageService.Success("Settings saved!");
}

View file

@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using LANCommander.Steam.Models;
namespace LANCommander.Steam.Abstractions;
/// <summary>
/// Interface for storing and retrieving SteamCMD profiles
/// Allows consumers to implement their own storage mechanism
/// </summary>
public interface ISteamCmdProfileStore
{
/// <summary>
/// Get all profiles
/// </summary>
Task<IEnumerable<SteamCmdProfile>> GetAllAsync();
/// <summary>
/// Get a profile by username
/// </summary>
Task<SteamCmdProfile?> GetByUsernameAsync(string username);
/// <summary>
/// Save or update a profile
/// </summary>
Task SaveAsync(SteamCmdProfile profile);
/// <summary>
/// Delete a profile by username
/// </summary>
Task DeleteAsync(string username);
}

View file

@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using LANCommander.Steam.Enums;
using LANCommander.Steam.Events;
using LANCommander.Steam.Models;
namespace LANCommander.Steam.Abstractions;
/// <summary>
/// Interface for SteamCMD service operations
/// </summary>
public interface ISteamCmdService
{
/// <summary>
/// Get or set the SteamCMD executable path
/// </summary>
string? ExecutablePath { get; set; }
/// <summary>
/// Event fired when an install job status changes (started, completed, failed)
/// </summary>
event EventHandler<SteamCmdInstallStatusEventArgs>? InstallStatusChanged;
/// <summary>
/// Event fired when install progress is updated
/// </summary>
event EventHandler<SteamCmdInstallProgressEventArgs>? InstallProgress;
/// <summary>
/// Check the connection status for a username
/// </summary>
Task<SteamCmdConnectionStatus> GetConnectionStatusAsync(string username);
/// <summary>
/// Auto-detect the SteamCMD executable path
/// </summary>
Task<string> AutoDetectSteamCmdPathAsync();
/// <summary>
/// Login to Steam with username and optional password
/// </summary>
Task<SteamCmdStatus> LoginToSteamAsync(string username, string? password = null);
/// <summary>
/// Logout from Steam
/// </summary>
Task<SteamCmdStatus> LogoutAsync(string username);
/// <summary>
/// Queue an installation job for Steam content
/// Returns a job ID that can be used to track progress
/// </summary>
Task<SteamCmdInstallJob> InstallContentAsync(uint appId, string installDirectory, string? username = null);
/// <summary>
/// Get an install job by ID
/// </summary>
SteamCmdInstallJob? GetInstallJob(Guid jobId);
/// <summary>
/// Get all install jobs
/// </summary>
IEnumerable<SteamCmdInstallJob> GetInstallJobs();
/// <summary>
/// Cancel an install job
/// </summary>
Task<bool> CancelInstallJobAsync(Guid jobId);
/// <summary>
/// Remove installed content from a directory
/// </summary>
Task<SteamCmdStatus> RemoveContentAsync(string installDirectory);
/// <summary>
/// Get all profiles (requires profile store to be configured)
/// </summary>
Task<IEnumerable<SteamCmdProfile>> GetProfilesAsync();
/// <summary>
/// Get a profile by username (requires profile store to be configured)
/// </summary>
Task<SteamCmdProfile?> GetProfileAsync(string username);
/// <summary>
/// Save a profile (requires profile store to be configured)
/// </summary>
Task SaveProfileAsync(SteamCmdProfile profile);
/// <summary>
/// Delete a profile by username (requires profile store to be configured)
/// </summary>
Task DeleteProfileAsync(string username);
}

View file

@ -1,4 +1,4 @@
namespace LANCommander.Server.Services.Enums;
namespace LANCommander.Steam.Enums;
public enum SteamCmdConnectionStatus
{

View file

@ -0,0 +1,32 @@
namespace LANCommander.Steam.Enums;
/// <summary>
/// Status of an installation job
/// </summary>
public enum SteamCmdInstallStatus
{
/// <summary>
/// Job is queued and waiting to start
/// </summary>
Queued,
/// <summary>
/// Job is currently being processed
/// </summary>
InProgress,
/// <summary>
/// Job completed successfully
/// </summary>
Completed,
/// <summary>
/// Job failed
/// </summary>
Failed,
/// <summary>
/// Job was cancelled
/// </summary>
Cancelled
}

View file

@ -1,4 +1,4 @@
namespace LANCommander.Server.Services.Enums;
namespace LANCommander.Steam.Enums;
public enum SteamCmdStatus
{

View file

@ -0,0 +1,56 @@
using System;
using LANCommander.Steam.Models;
namespace LANCommander.Steam.Events;
/// <summary>
/// Event arguments for install progress updates
/// </summary>
public class SteamCmdInstallProgressEventArgs : EventArgs
{
/// <summary>
/// The install job this progress update is for
/// </summary>
public SteamCmdInstallJob Job { get; }
/// <summary>
/// Progress percentage (0-100)
/// </summary>
public double Progress { get; }
/// <summary>
/// Status message
/// </summary>
public string StatusMessage { get; }
/// <summary>
/// Bytes downloaded
/// </summary>
public long BytesDownloaded { get; }
/// <summary>
/// Total bytes to download
/// </summary>
public long BytesTotal { get; }
/// <summary>
/// Download speed in bytes per second
/// </summary>
public long BytesPerSecond { get; }
public SteamCmdInstallProgressEventArgs(
SteamCmdInstallJob job,
double progress,
string statusMessage,
long bytesDownloaded = 0,
long bytesTotal = 0,
long bytesPerSecond = 0)
{
Job = job;
Progress = progress;
StatusMessage = statusMessage;
BytesDownloaded = bytesDownloaded;
BytesTotal = bytesTotal;
BytesPerSecond = bytesPerSecond;
}
}

View file

@ -0,0 +1,33 @@
using System;
using LANCommander.Steam.Enums;
using LANCommander.Steam.Models;
namespace LANCommander.Steam.Events;
/// <summary>
/// Event arguments for install status changes (started, completed, failed)
/// </summary>
public class SteamCmdInstallStatusEventArgs : EventArgs
{
/// <summary>
/// The install job this status change is for
/// </summary>
public SteamCmdInstallJob Job { get; }
/// <summary>
/// The new status
/// </summary>
public SteamCmdInstallStatus Status { get; }
/// <summary>
/// Error message if the status is Failed
/// </summary>
public string? ErrorMessage { get; }
public SteamCmdInstallStatusEventArgs(SteamCmdInstallJob job, SteamCmdInstallStatus status, string? errorMessage = null)
{
Job = job;
Status = status;
ErrorMessage = errorMessage;
}
}

View file

@ -0,0 +1,81 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Implementations;
using LANCommander.Steam.Options;
using LANCommander.Steam.Services;
namespace LANCommander.Steam.Extensions;
/// <summary>
/// Extension methods for registering SteamCMD services
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Add SteamCMD service with default in-memory profile store
/// </summary>
public static IServiceCollection AddSteamCmd(this IServiceCollection services, Action<SteamCmdOptions>? configure = null)
{
if (configure != null)
{
services.Configure(configure);
}
else
{
services.Configure<SteamCmdOptions>(_ => { });
}
services.AddSingleton<ISteamCmdProfileStore, InMemorySteamCmdProfileStore>();
services.AddScoped<ISteamCmdService, SteamCmdService>();
return services;
}
/// <summary>
/// Add SteamCMD service with custom profile store implementation
/// </summary>
public static IServiceCollection AddSteamCmd<TProfileStore>(
this IServiceCollection services,
Action<SteamCmdOptions>? configure = null)
where TProfileStore : class, ISteamCmdProfileStore
{
if (configure != null)
{
services.Configure(configure);
}
else
{
services.Configure<SteamCmdOptions>(_ => { });
}
services.AddSingleton<ISteamCmdProfileStore, TProfileStore>();
services.AddScoped<ISteamCmdService, SteamCmdService>();
return services;
}
/// <summary>
/// Add SteamCMD service with existing profile store instance
/// </summary>
public static IServiceCollection AddSteamCmd(
this IServiceCollection services,
ISteamCmdProfileStore profileStore,
Action<SteamCmdOptions>? configure = null)
{
if (configure != null)
{
services.Configure(configure);
}
else
{
services.Configure<SteamCmdOptions>(_ => { });
}
services.AddSingleton(profileStore);
services.AddScoped<ISteamCmdService, SteamCmdService>();
return services;
}
}

View file

@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Models;
namespace LANCommander.Steam.Implementations;
/// <summary>
/// In-memory implementation of ISteamCmdProfileStore
/// Useful for testing or simple scenarios
/// </summary>
public class InMemorySteamCmdProfileStore : ISteamCmdProfileStore
{
private readonly Dictionary<string, SteamCmdProfile> _profiles = new();
public Task<IEnumerable<SteamCmdProfile>> GetAllAsync()
{
return Task.FromResult<IEnumerable<SteamCmdProfile>>(_profiles.Values.ToList());
}
public Task<SteamCmdProfile?> GetByUsernameAsync(string username)
{
_profiles.TryGetValue(username, out var profile);
return Task.FromResult(profile);
}
public Task SaveAsync(SteamCmdProfile profile)
{
_profiles[profile.Username] = profile;
return Task.CompletedTask;
}
public Task DeleteAsync(string username)
{
_profiles.Remove(username);
return Task.CompletedTask;
}
}

View file

@ -1,13 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="SteamWebAPI2" />
<PackageReference Include="System.Text.Json" />
</ItemGroup>
<ItemGroup>
<Folder Include="Extensions\" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,86 @@
using System;
using System.Threading.Tasks;
using LANCommander.Steam.Enums;
namespace LANCommander.Steam.Models;
/// <summary>
/// Represents an installation job in the queue
/// </summary>
public class SteamCmdInstallJob
{
/// <summary>
/// Unique identifier for this install job
/// </summary>
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Steam App ID to install
/// </summary>
public uint AppId { get; set; }
/// <summary>
/// Installation directory
/// </summary>
public string InstallDirectory { get; set; } = string.Empty;
/// <summary>
/// Username for Steam login (null for anonymous)
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Current status of the installation
/// </summary>
public SteamCmdInstallStatus Status { get; set; } = SteamCmdInstallStatus.Queued;
/// <summary>
/// Progress percentage (0-100)
/// </summary>
public double Progress { get; set; }
/// <summary>
/// Current status message
/// </summary>
public string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Bytes downloaded
/// </summary>
public long BytesDownloaded { get; set; }
/// <summary>
/// Total bytes to download
/// </summary>
public long BytesTotal { get; set; }
/// <summary>
/// Download speed in bytes per second
/// </summary>
public long BytesPerSecond { get; set; }
/// <summary>
/// When the job was created
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// When the job started processing
/// </summary>
public DateTime? StartedAt { get; set; }
/// <summary>
/// When the job completed
/// </summary>
public DateTime? CompletedAt { get; set; }
/// <summary>
/// Error message if the job failed
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Task completion source for awaiting the job
/// </summary>
internal TaskCompletionSource<SteamCmdStatus>? CompletionSource { get; set; }
}

View file

@ -0,0 +1,17 @@
namespace LANCommander.Steam.Models;
/// <summary>
/// Represents a SteamCMD profile with username and install directory
/// </summary>
public class SteamCmdProfile
{
/// <summary>
/// Steam username for this profile
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// Default install directory for this profile
/// </summary>
public string InstallDirectory { get; set; } = string.Empty;
}

View file

@ -0,0 +1,22 @@
namespace LANCommander.Steam.Options;
/// <summary>
/// Configuration options for SteamCMD service
/// </summary>
public class SteamCmdOptions
{
/// <summary>
/// Path to the SteamCMD executable
/// </summary>
public string? ExecutablePath { get; set; }
/// <summary>
/// Default install directory for Steam content
/// </summary>
public string? DefaultInstallDirectory { get; set; }
/// <summary>
/// Whether to auto-detect the SteamCMD path if not configured
/// </summary>
public bool AutoDetectPath { get; set; } = true;
}

View file

@ -0,0 +1,860 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Enums;
using LANCommander.Steam.Events;
using LANCommander.Steam.Models;
using LANCommander.Steam.Options;
namespace LANCommander.Steam.Services;
/// <summary>
/// Service for interacting with SteamCMD
/// </summary>
public class SteamCmdService : ISteamCmdService
{
private readonly ILogger<SteamCmdService> _logger;
private readonly ISteamCmdProfileStore? _profileStore;
private readonly SteamCmdOptions _options;
private readonly ConcurrentDictionary<Guid, SteamCmdInstallJob> _installJobs = new();
private readonly SemaphoreSlim _queueSemaphore = new(1, 1);
private readonly CancellationTokenSource _cancellationTokenSource = new();
private Task? _queueProcessorTask;
private string? _executablePath;
/// <summary>
/// Event fired when an install job status changes
/// </summary>
public event EventHandler<SteamCmdInstallStatusEventArgs>? InstallStatusChanged;
/// <summary>
/// Event fired when install progress is updated
/// </summary>
public event EventHandler<SteamCmdInstallProgressEventArgs>? InstallProgress;
/// <summary>
/// Get or set the SteamCMD executable path
/// </summary>
public string? ExecutablePath
{
get => _executablePath ?? _options.ExecutablePath;
set => _executablePath = value;
}
public SteamCmdService(
ILogger<SteamCmdService> logger,
IOptions<SteamCmdOptions>? options = null,
ISteamCmdProfileStore? profileStore = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new SteamCmdOptions();
_profileStore = profileStore;
// Start the queue processor
_queueProcessorTask = Task.Run(ProcessInstallQueueAsync, _cancellationTokenSource.Token);
}
public async Task<SteamCmdConnectionStatus> GetConnectionStatusAsync(string username)
{
try
{
if (!IsValidUsername(username))
throw new ArgumentException("Invalid username", nameof(username));
// Auto-populate SteamCMD path if not configured
if (string.IsNullOrWhiteSpace(ExecutablePath))
{
if (_options.AutoDetectPath)
{
var detectedPath = await AutoDetectSteamCmdPathAsync();
if (!string.IsNullOrWhiteSpace(detectedPath))
{
ExecutablePath = detectedPath;
_logger.LogInformation("Auto-detected SteamCMD at: {Path}", detectedPath);
}
else
{
_logger.LogWarning("SteamCMD path is not configured and could not be auto-detected");
return SteamCmdConnectionStatus.NotInstalled;
}
}
else
{
_logger.LogWarning("SteamCMD path is not configured and auto-detection is disabled");
return SteamCmdConnectionStatus.NotInstalled;
}
}
if (!File.Exists(ExecutablePath))
{
_logger.LogWarning("SteamCMD executable not found at configured path: {Path}", ExecutablePath);
return SteamCmdConnectionStatus.NotInstalled;
}
// Try to run steamcmd with +quit to check if it's working
var result = await ExecuteSteamCmdCommandAsync("+quit");
if (result.Success)
{
// Check if we're logged in by trying to get user info
var loginResult = await ExecuteSteamCmdCommandAsync($"+login {username} +quit", TimeSpan.FromSeconds(30));
return loginResult.Success ? SteamCmdConnectionStatus.Authenticated : SteamCmdConnectionStatus.Unauthenticated;
}
return SteamCmdConnectionStatus.NotInstalled;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking SteamCMD connection status");
return SteamCmdConnectionStatus.NotInstalled;
}
}
public async Task<string> AutoDetectSteamCmdPathAsync()
{
var possiblePaths = new List<string>();
// Windows paths
if (OperatingSystem.IsWindows())
{
// Common Steam installation directories
var steamPaths = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Steam", "steamcmd.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Steam", "steamcmd.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Steam", "steamcmd.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Steam", "steamcmd.exe"),
"C:\\Steam\\steamcmd.exe",
"C:\\Program Files\\Steam\\steamcmd.exe",
"C:\\Program Files (x86)\\Steam\\steamcmd.exe"
};
possiblePaths.AddRange(steamPaths);
// Check PATH environment variable
var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? [];
foreach (var dir in pathDirs)
{
if (!string.IsNullOrWhiteSpace(dir))
{
possiblePaths.Add(Path.Combine(dir, "steamcmd.exe"));
}
}
}
// Linux paths
else if (OperatingSystem.IsLinux())
{
possiblePaths.AddRange(new[]
{
"/app/Data/Steam/steamcmd.sh",
"/usr/local/bin/steamcmd",
"/usr/bin/steamcmd",
"/home/steam/steamcmd/steamcmd.sh",
"/opt/steamcmd/steamcmd.sh",
"/var/lib/steam/steamcmd/steamcmd.sh"
});
// Check PATH environment variable
var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(':') ?? [];
foreach (var dir in pathDirs)
{
if (!string.IsNullOrWhiteSpace(dir))
{
possiblePaths.Add(Path.Combine(dir, "steamcmd"));
}
}
}
// macOS paths
else if (OperatingSystem.IsMacOS())
{
possiblePaths.AddRange(new[]
{
"/usr/local/bin/steamcmd",
"/opt/homebrew/bin/steamcmd",
"/Applications/Steam.app/Contents/MacOS/steamcmd"
});
// Check PATH environment variable
var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(':') ?? [];
foreach (var dir in pathDirs)
{
if (!string.IsNullOrWhiteSpace(dir))
{
possiblePaths.Add(Path.Combine(dir, "steamcmd"));
}
}
}
// Check each possible path
foreach (var path in possiblePaths)
{
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
{
try
{
// Verify it's actually SteamCMD by checking if it responds to --version or +quit
var testResult = await ExecuteSteamCmdCommandAsync("+quit", TimeSpan.FromSeconds(30), path);
if (testResult.Success)
{
_logger.LogDebug("Found SteamCMD at: {Path}", path);
return path;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to verify SteamCMD at: {Path}", path);
}
}
}
_logger.LogWarning("SteamCMD not found in common installation locations");
return string.Empty;
}
public async Task<SteamCmdStatus> LoginToSteamAsync(string username, string? password = null)
{
try
{
if (!IsValidUsername(username))
throw new ArgumentException("Invalid username", nameof(username));
if (string.IsNullOrWhiteSpace(ExecutablePath))
{
_logger.LogError("SteamCMD path is not configured");
return SteamCmdStatus.PathNotConfigured;
}
if (!File.Exists(ExecutablePath))
{
_logger.LogError("SteamCMD executable not found at configured path: {Path}", ExecutablePath);
return SteamCmdStatus.ExecutableNotFound;
}
var loginCommand = string.IsNullOrWhiteSpace(password)
? $"+login {username}"
: $"+login {username} {password}";
var result = await ExecuteSteamCmdCommandAsync($"{loginCommand} +quit", TimeSpan.FromSeconds(30));
if (result.Output.Contains("Invalid Password"))
return SteamCmdStatus.InvalidPassword;
if (result.Success)
{
_logger.LogInformation("Successfully logged into Steam as {Username}", username);
return SteamCmdStatus.Success;
}
_logger.LogError("Failed to log into Steam: {Error}", result.ErrorOutput);
return SteamCmdStatus.Error;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error logging into Steam");
return SteamCmdStatus.UnknownError;
}
}
public async Task<SteamCmdStatus> LogoutAsync(string username)
{
try
{
if (!IsValidUsername(username))
return SteamCmdStatus.InvalidUsername;
if (string.IsNullOrWhiteSpace(ExecutablePath))
{
_logger.LogError("SteamCMD path is not configured");
return SteamCmdStatus.PathNotConfigured;
}
if (!File.Exists(ExecutablePath))
{
_logger.LogError("SteamCMD executable not found at configured path: {Path}", ExecutablePath);
return SteamCmdStatus.ExecutableNotFound;
}
var logoutCommand = $"+logout {username}";
var result = await ExecuteSteamCmdCommandAsync($"{logoutCommand} +quit");
if (result.Success)
{
_logger.LogInformation("Successfully logged out of the Steam account {Username}", username);
return SteamCmdStatus.Success;
}
_logger.LogError("Failed to log out of Steam: {Error}", result.ErrorOutput);
return SteamCmdStatus.Error;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error logging out of Steam");
return SteamCmdStatus.UnknownError;
}
}
public async Task<SteamCmdInstallJob> InstallContentAsync(uint appId, string installDirectory, string? username = null)
{
if (!string.IsNullOrWhiteSpace(username) && !IsValidUsername(username))
throw new ArgumentException("Invalid username", nameof(username));
if (string.IsNullOrWhiteSpace(ExecutablePath))
{
_logger.LogError("SteamCMD path is not configured");
throw new InvalidOperationException("SteamCMD path is not configured");
}
if (!File.Exists(ExecutablePath))
{
_logger.LogError("SteamCMD executable not found at configured path: {Path}", ExecutablePath);
throw new FileNotFoundException("SteamCMD executable not found", ExecutablePath);
}
// Create install job
var job = new SteamCmdInstallJob
{
AppId = appId,
InstallDirectory = installDirectory,
Username = username,
Status = SteamCmdInstallStatus.Queued,
StatusMessage = "Queued for installation",
CompletionSource = new TaskCompletionSource<SteamCmdStatus>()
};
_installJobs[job.Id] = job;
_logger.LogInformation("Queued installation job {JobId} for app {AppId} to {InstallDirectory}", job.Id, appId, installDirectory);
// Queue processor will pick it up automatically
return job;
}
public SteamCmdInstallJob? GetInstallJob(Guid jobId)
{
_installJobs.TryGetValue(jobId, out var job);
return job;
}
public IEnumerable<SteamCmdInstallJob> GetInstallJobs()
{
return _installJobs.Values.ToList();
}
public async Task<bool> CancelInstallJobAsync(Guid jobId)
{
if (!_installJobs.TryGetValue(jobId, out var job))
return false;
if (job.Status == SteamCmdInstallStatus.Completed || job.Status == SteamCmdInstallStatus.Failed)
return false;
job.Status = SteamCmdInstallStatus.Cancelled;
job.StatusMessage = "Cancelled by user";
job.CompletedAt = DateTime.UtcNow;
job.CompletionSource?.TrySetCanceled();
OnInstallStatusChanged(job, SteamCmdInstallStatus.Cancelled);
_logger.LogInformation("Cancelled install job {JobId}", jobId);
return true;
}
public async Task<SteamCmdStatus> RemoveContentAsync(string installDirectory)
{
try
{
if (string.IsNullOrWhiteSpace(installDirectory))
{
_logger.LogError("Install directory is not specified");
return SteamCmdStatus.InstallDirectoryNotFound;
}
if (!Directory.Exists(installDirectory))
{
_logger.LogWarning("Install directory does not exist: {InstallDirectory}", installDirectory);
return SteamCmdStatus.Success; // Consider it already removed
}
// Remove the directory and all its contents
Directory.Delete(installDirectory, true);
_logger.LogInformation("Successfully removed content from {InstallDirectory}", installDirectory);
return SteamCmdStatus.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing content from {InstallDirectory}", installDirectory);
return SteamCmdStatus.UnknownError;
}
}
public async Task<IEnumerable<SteamCmdProfile>> GetProfilesAsync()
{
if (_profileStore == null)
{
throw new InvalidOperationException("Profile store is not configured. Provide an ISteamCmdProfileStore implementation.");
}
return await _profileStore.GetAllAsync();
}
public async Task<SteamCmdProfile?> GetProfileAsync(string username)
{
if (_profileStore == null)
{
throw new InvalidOperationException("Profile store is not configured. Provide an ISteamCmdProfileStore implementation.");
}
return await _profileStore.GetByUsernameAsync(username);
}
public async Task SaveProfileAsync(SteamCmdProfile profile)
{
if (_profileStore == null)
{
throw new InvalidOperationException("Profile store is not configured. Provide an ISteamCmdProfileStore implementation.");
}
if (profile == null)
{
throw new ArgumentNullException(nameof(profile));
}
if (string.IsNullOrWhiteSpace(profile.Username))
{
throw new ArgumentException("Profile username cannot be empty", nameof(profile));
}
await _profileStore.SaveAsync(profile);
_logger.LogInformation("Saved profile for username: {Username}", profile.Username);
}
public async Task DeleteProfileAsync(string username)
{
if (_profileStore == null)
{
throw new InvalidOperationException("Profile store is not configured. Provide an ISteamCmdProfileStore implementation.");
}
if (string.IsNullOrWhiteSpace(username))
{
throw new ArgumentException("Username cannot be empty", nameof(username));
}
await _profileStore.DeleteAsync(username);
_logger.LogInformation("Deleted profile for username: {Username}", username);
}
private async Task ProcessInstallQueueAsync()
{
while (!_cancellationTokenSource.Token.IsCancellationRequested)
{
try
{
var queuedJob = _installJobs.Values
.FirstOrDefault(j => j.Status == SteamCmdInstallStatus.Queued);
if (queuedJob == null)
{
await Task.Delay(1000, _cancellationTokenSource.Token);
continue;
}
// Process the job (semaphore is acquired inside ProcessInstallJobAsync)
await ProcessInstallJobAsync(queuedJob);
}
catch (OperationCanceledException)
{
// Service is being disposed
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing install queue");
await Task.Delay(5000, _cancellationTokenSource.Token);
}
}
}
private async Task ProcessInstallJobAsync(SteamCmdInstallJob job)
{
await _queueSemaphore.WaitAsync(_cancellationTokenSource.Token);
try
{
job.Status = SteamCmdInstallStatus.InProgress;
job.StartedAt = DateTime.UtcNow;
job.StatusMessage = "Starting installation...";
OnInstallStatusChanged(job, SteamCmdInstallStatus.InProgress);
// Ensure install directory exists
Directory.CreateDirectory(job.InstallDirectory);
var commands = new List<string>();
// Add login command if credentials provided
if (!string.IsNullOrWhiteSpace(job.Username))
{
commands.Add($"+login {job.Username}");
}
else
{
commands.Add("+login anonymous");
}
// Add install commands
commands.Add($"+force_install_dir \"{job.InstallDirectory}\"");
commands.Add($"+app_update {job.AppId} validate");
commands.Add("+quit");
var commandString = string.Join(" ", commands);
var result = await ExecuteSteamCmdCommandWithProgressAsync(
commandString,
job,
_cancellationTokenSource.Token);
if (result.Success)
{
job.Status = SteamCmdInstallStatus.Completed;
job.StatusMessage = "Installation completed successfully";
job.Progress = 100;
job.CompletedAt = DateTime.UtcNow;
job.CompletionSource?.TrySetResult(SteamCmdStatus.Success);
OnInstallStatusChanged(job, SteamCmdInstallStatus.Completed);
OnInstallProgress(job, 100, "Installation completed successfully");
_logger.LogInformation("Successfully installed Steam app {AppId} to {InstallDirectory}", job.AppId, job.InstallDirectory);
}
else
{
job.Status = SteamCmdInstallStatus.Failed;
job.StatusMessage = $"Installation failed: {result.ErrorOutput}";
job.ErrorMessage = result.ErrorOutput;
job.CompletedAt = DateTime.UtcNow;
job.CompletionSource?.TrySetResult(SteamCmdStatus.Error);
OnInstallStatusChanged(job, SteamCmdInstallStatus.Failed, result.ErrorOutput);
_logger.LogError("Failed to install Steam app {AppId}: {Error}", job.AppId, result.ErrorOutput);
}
}
catch (OperationCanceledException)
{
job.Status = SteamCmdInstallStatus.Cancelled;
job.StatusMessage = "Installation cancelled";
job.CompletedAt = DateTime.UtcNow;
job.CompletionSource?.TrySetCanceled();
OnInstallStatusChanged(job, SteamCmdInstallStatus.Cancelled);
}
catch (Exception ex)
{
job.Status = SteamCmdInstallStatus.Failed;
job.StatusMessage = $"Installation error: {ex.Message}";
job.ErrorMessage = ex.Message;
job.CompletedAt = DateTime.UtcNow;
job.CompletionSource?.TrySetException(ex);
OnInstallStatusChanged(job, SteamCmdInstallStatus.Failed, ex.Message);
_logger.LogError(ex, "Error installing Steam content for app {AppId}", job.AppId);
}
finally
{
_queueSemaphore.Release();
}
}
private async Task<SteamCmdResult> ExecuteSteamCmdCommandWithProgressAsync(
string arguments,
SteamCmdInstallJob? job = null,
CancellationToken cancellationToken = default)
{
try
{
if (string.IsNullOrWhiteSpace(ExecutablePath))
{
throw new InvalidOperationException("SteamCMD executable path is not configured");
}
var processStartInfo = new ProcessStartInfo
{
FileName = ExecutablePath,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = new Process { StartInfo = processStartInfo };
var output = new StringBuilder();
var error = new StringBuilder();
process.OutputDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
output.AppendLine(e.Data);
_logger.LogDebug("SteamCMD Output: {Output}", e.Data);
// Parse progress if job is provided
if (job != null)
{
ParseProgressFromOutput(e.Data, job);
}
}
};
process.ErrorDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
error.AppendLine(e.Data);
_logger.LogDebug("SteamCMD Error: {Error}", e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
_logger.LogWarning("SteamCMD operation was cancelled");
process.Kill();
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while executing SteamCMD");
}
return new SteamCmdResult
{
Success = process.ExitCode == 0,
ExitCode = process.ExitCode,
Output = output.ToString(),
ErrorOutput = error.ToString()
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing SteamCMD command: {Arguments}", arguments);
return new SteamCmdResult
{
Success = false,
ExitCode = -1,
ErrorOutput = ex.Message
};
}
}
private void ParseProgressFromOutput(string line, SteamCmdInstallJob job)
{
// SteamCMD progress patterns:
// "Downloading update (X of Y MB)"
// "Update state (X% at Y MB/s) : downloading update"
// "Success. App 'X' fully installed."
// "Installing update..."
var progressMatch = Regex.Match(line, @"Update state \(([\d.]+)%", RegexOptions.IgnoreCase);
if (progressMatch.Success && double.TryParse(progressMatch.Groups[1].Value, out var progress))
{
job.Progress = Math.Min(100, Math.Max(0, progress));
// Try to extract download speed
var speedMatch = Regex.Match(line, @"at ([\d.]+) (MB|KB)/s", RegexOptions.IgnoreCase);
if (speedMatch.Success && double.TryParse(speedMatch.Groups[1].Value, out var speed))
{
var unit = speedMatch.Groups[2].Value.ToUpper();
job.BytesPerSecond = (long)(speed * (unit == "MB" ? 1048576 : 1024));
}
OnInstallProgress(job, job.Progress, line, job.BytesDownloaded, job.BytesTotal, job.BytesPerSecond);
}
// Extract download progress (X of Y MB)
var downloadMatch = Regex.Match(line, @"Downloading update \(([\d.]+) of ([\d.]+) (MB|KB)\)", RegexOptions.IgnoreCase);
if (downloadMatch.Success)
{
if (double.TryParse(downloadMatch.Groups[1].Value, out var downloaded) &&
double.TryParse(downloadMatch.Groups[2].Value, out var total))
{
var unit = downloadMatch.Groups[3].Value.ToUpper();
var multiplier = unit == "MB" ? 1048576 : 1024;
job.BytesDownloaded = (long)(downloaded * multiplier);
job.BytesTotal = (long)(total * multiplier);
if (job.BytesTotal > 0)
{
job.Progress = (job.BytesDownloaded * 100.0) / job.BytesTotal;
}
OnInstallProgress(job, job.Progress, line, job.BytesDownloaded, job.BytesTotal, job.BytesPerSecond);
}
}
// Update status message
if (!string.IsNullOrWhiteSpace(line) && !line.Contains("Update state"))
{
job.StatusMessage = line.Trim();
}
}
private void OnInstallStatusChanged(SteamCmdInstallJob job, SteamCmdInstallStatus status, string? errorMessage = null)
{
try
{
InstallStatusChanged?.Invoke(this, new SteamCmdInstallStatusEventArgs(job, status, errorMessage));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking InstallStatusChanged event");
}
}
private void OnInstallProgress(SteamCmdInstallJob job, double progress, string statusMessage, long bytesDownloaded = 0, long bytesTotal = 0, long bytesPerSecond = 0)
{
try
{
InstallProgress?.Invoke(this, new SteamCmdInstallProgressEventArgs(job, progress, statusMessage, bytesDownloaded, bytesTotal, bytesPerSecond));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking InstallProgress event");
}
}
private async Task<SteamCmdResult> ExecuteSteamCmdCommandAsync(string arguments, TimeSpan? timeout = null, string? steamCmdPath = null)
{
try
{
var executablePath = string.IsNullOrWhiteSpace(steamCmdPath) ? ExecutablePath : steamCmdPath;
if (string.IsNullOrWhiteSpace(executablePath))
{
throw new InvalidOperationException("SteamCMD executable path is not configured");
}
var processStartInfo = new ProcessStartInfo
{
FileName = executablePath,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = new Process { StartInfo = processStartInfo };
var output = new StringBuilder();
var error = new StringBuilder();
process.OutputDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
output.AppendLine(e.Data);
_logger.LogDebug("SteamCMD Output: {Output}", e.Data);
}
};
process.ErrorDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
error.AppendLine(e.Data);
_logger.LogDebug("SteamCMD Error: {Error}", e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
try
{
if (timeout.HasValue)
await process.WaitForExitAsync().WaitAsync(timeout.Value);
else
await process.WaitForExitAsync();
}
catch (TimeoutException)
{
_logger.LogWarning("SteamCMD timed out");
process.Kill();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while executing SteamCMD");
}
return new SteamCmdResult
{
Success = process.ExitCode == 0,
ExitCode = process.ExitCode,
Output = output.ToString(),
ErrorOutput = error.ToString()
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing SteamCMD command: {Arguments}", arguments);
return new SteamCmdResult
{
Success = false,
ExitCode = -1,
ErrorOutput = ex.Message
};
}
}
private static bool IsValidUsername(string value)
{
return !string.IsNullOrEmpty(value) &&
Regex.IsMatch(value, @"^[a-zA-Z0-9_]+$");
}
private class SteamCmdResult
{
public bool Success { get; set; }
public int ExitCode { get; set; }
public string Output { get; set; } = string.Empty;
public string ErrorOutput { get; set; } = string.Empty;
}
}

View file

@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Mime;
using System.Threading.Tasks;
using HtmlAgilityPack;
namespace LANCommander.Steam.Services;
public class SteamStoreService
{
private readonly HttpClient HttpClient;
public SteamStoreService()
{
HttpClient = new HttpClient();
HttpClient.BaseAddress = new Uri("https://store.steampowered.com");
}
public async Task<IEnumerable<GameSearchResult>> SearchGamesAsync(string keyword)
{
HtmlWeb web = new HtmlWeb();
HtmlDocument dom = await web.LoadFromWebAsync($"https://store.steampowered.com/search/suggest?term={keyword}&f=games&cc=US");
var results = new List<GameSearchResult>();
var matches = dom.DocumentNode.SelectNodes("//a[@data-ds-appid]");
if (matches == null || matches.Count == 0)
return [];
foreach (var match in matches)
{
try
{
var appId = match.Attributes["data-ds-appid"].Value;
var matchNameElement = match.SelectSingleNode(".//div[@class = 'match_name']");
appId = appId.Split(',').First();
if (matchNameElement != null)
{
results.Add(new GameSearchResult
{
Name = matchNameElement.InnerText,
AppId = Convert.ToInt32(appId)
});
}
}
catch (Exception ex) { }
}
return results;
}
public async Task<(bool Exists, string MimeType)> HasWebAssetAsync(int appId, WebAssetType webAssetType)
{
var webAssetUri = GetWebAssetUri(appId, webAssetType);
var response = await HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, webAssetUri));
var exists = response.Content.Headers.ContentType.MediaType == MediaTypeNames.Image.Jpeg || response.Content.Headers.ContentType.MediaType == "image/png";
return (exists, response.Content.Headers.ContentType.MediaType);
}
public async Task<bool> HasManualAsync(int appId)
{
var manualUri = GetManualUri(appId);
var response = await HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, manualUri));
return response.Content.Headers.ContentType.MediaType == MediaTypeNames.Application.Pdf;
}
public async Task<byte[]> DownloadManualAsync(int appId)
{
var manualUri = GetManualUri(appId);
var response = await HttpClient.GetAsync(manualUri);
if (!response.IsSuccessStatusCode)
return null;
using (var ms = new MemoryStream())
{
await response.Content.CopyToAsync(ms);
return ms.ToArray();
}
}
public static Uri GetManualUri(int appId)
{
return new Uri($"https://store.steampowered.com/manual/{appId}");
}
public static Uri GetWebAssetUri(int appId, WebAssetType type)
{
Dictionary<WebAssetType, string> webAssetTypeMap = new Dictionary<WebAssetType, string>()
{
{ WebAssetType.Capsule, "capsule_231x87.jpg" },
{ WebAssetType.CapsuleLarge, "capsule_616x353.jpg" },
{ WebAssetType.Header, "header.jpg" },
{ WebAssetType.HeroCapsule, "hero_capsule.jpg" },
{ WebAssetType.LibraryCover, "library_600x900.jpg" },
{ WebAssetType.LibraryHeader, "library_header.jpg" },
{ WebAssetType.LibraryHero, "library_hero.jpg" },
{ WebAssetType.Logo, "logo.png" }
};
return new Uri($"https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/{appId}/{webAssetTypeMap[type]}");
}
}