Use steamcmd.net for app info, remove extra Steam cmdlets

This commit is contained in:
Pat Hartl 2026-02-07 18:58:03 -06:00
parent 303f8720f4
commit 322f05cb26
47 changed files with 1333 additions and 511 deletions

View file

@ -116,6 +116,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0" />
<PackageVersion Include="Microsoft.Extensions.Localization" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />

View file

@ -1,4 +1,4 @@
---
.---
title: Cmdlets
---
@ -222,9 +222,9 @@ Update-UserCustomField -Name "SteamId" -Value "34950494"
The following cmdlets provide functionality for interacting with SteamCMD and the Steam Store API. These cmdlets enable you to install Steam games, manage SteamCMD profiles, search for games, and retrieve Steam assets.
## Connection Management
# Connection Management
### `Connect-SteamCmd`
## `Connect-SteamCmd`
Connects to SteamCMD with the specified username and optional password.
### Syntax
@ -243,7 +243,7 @@ $securePassword = ConvertTo-SecureString "mypassword" -AsPlainText -Force
Connect-SteamCmd -Username "myusername" -Password $securePassword
```
### `Disconnect-SteamCmd`
## `Disconnect-SteamCmd`
Disconnects from SteamCMD for the specified username.
### Syntax
@ -260,7 +260,7 @@ The `Disconnect-SteamCmd` cmdlet logs out the specified username from SteamCMD.
Disconnect-SteamCmd -Username "myusername"
```
### `Get-SteamCmdConnectionStatus`
## `Get-SteamCmdConnectionStatus`
Gets the connection status for a SteamCMD username.
### Syntax
@ -278,9 +278,9 @@ $status = Get-SteamCmdConnectionStatus -Username "myusername"
Write-Host "Connected: $($status.IsConnected)"
```
## SteamCMD Configuration
# SteamCMD Configuration
### `Get-SteamCmdPath`
## `Get-SteamCmdPath`
Gets the path to the SteamCMD executable.
### Syntax
@ -299,7 +299,7 @@ if ($steamCmdPath) {
}
```
### `Get-SteamCmdProfile`
## `Get-SteamCmdProfile`
Gets a SteamCMD profile for the specified username.
### Syntax
@ -319,7 +319,7 @@ if ($profile) {
}
```
### `Get-SteamCmdProfiles`
## `Get-SteamCmdProfiles`
Gets all SteamCMD profiles.
### Syntax
@ -338,7 +338,7 @@ foreach ($profile in $profiles) {
}
```
### `Set-SteamCmdProfile`
## `Set-SteamCmdProfile`
Creates or updates a SteamCMD profile.
### Syntax
@ -356,7 +356,7 @@ The `Set-SteamCmdProfile` cmdlet creates or updates a SteamCMD profile with the
Set-SteamCmdProfile -Username "myusername" -InstallDirectory "C:\Steam\Content"
```
### `Remove-SteamCmdProfile`
## `Remove-SteamCmdProfile`
Removes a SteamCMD profile.
### Syntax
@ -373,9 +373,9 @@ The `Remove-SteamCmdProfile` cmdlet deletes the SteamCMD profile for the specifi
Remove-SteamCmdProfile -Username "myusername"
```
## Content Installation
# Steam Content Installation
### `Install-SteamContent`
## `Install-SteamContent`
Installs Steam content (game, DLC, etc.) using SteamCMD.
### Syntax
@ -395,7 +395,7 @@ $job = Install-SteamContent -AppId 730 -InstallDirectory "C:\Games\Counter-Strik
Write-Host "Installation job started: $($job.Id)"
```
### `Remove-SteamContent`
## `Remove-SteamContent`
Removes Steam content from the specified install directory.
### Syntax
@ -412,66 +412,9 @@ The `Remove-SteamContent` cmdlet removes Steam content from the specified instal
Remove-SteamContent -InstallDirectory "C:\Games\Counter-Strike 2"
```
### `Get-SteamInstallJob`
Gets a Steam installation job by its ID.
# Steam Store
### Syntax
```powershell
Get-SteamInstallJob
-JobId <Guid>
```
### Description
The `Get-SteamInstallJob` cmdlet retrieves information about a specific Steam installation job. Returns a `SteamCmdInstallJob` object containing status, progress, and other details about the installation.
### Example
```powershell
$job = Get-SteamInstallJob -JobId "12345678-1234-1234-1234-123456789012"
Write-Host "Status: $($job.Status), Progress: $($job.Progress)%"
```
### `Get-SteamInstallJobs`
Gets all active Steam installation jobs.
### Syntax
```powershell
Get-SteamInstallJobs
```
### Description
The `Get-SteamInstallJobs` cmdlet retrieves all active Steam installation jobs. Returns a collection of `SteamCmdInstallJob` objects.
### Example
```powershell
$jobs = Get-SteamInstallJobs
foreach ($job in $jobs) {
Write-Host "$($job.AppId): $($job.Status) - $($job.Progress)%"
}
```
### `Stop-SteamInstallJob`
Stops a Steam installation job.
### Syntax
```powershell
Stop-SteamInstallJob
-JobId <Guid>
```
### Description
The `Stop-SteamInstallJob` cmdlet cancels a running Steam installation job. Returns a boolean indicating whether the job was successfully cancelled.
### Example
```powershell
$cancelled = Stop-SteamInstallJob -JobId "12345678-1234-1234-1234-123456789012"
if ($cancelled) {
Write-Host "Installation job cancelled"
}
```
## Steam Store
### `Search-SteamGames`
## `Search-SteamGames`
Searches for games on the Steam Store.
### Syntax
@ -491,67 +434,7 @@ foreach ($result in $results) {
}
```
### `Get-SteamManual`
Downloads a game manual from the Steam Store.
### Syntax
```powershell
Get-SteamManual
-AppId <int>
-OutputPath <string> (optional)
```
### Description
The `Get-SteamManual` cmdlet downloads the PDF manual for the specified Steam App ID. If `OutputPath` is provided, the manual is saved to that location and the path is returned. Otherwise, the manual data is returned as a byte array.
### Example
```powershell
# Save manual to file
Get-SteamManual -AppId 730 -OutputPath "C:\Games\CS2\manual.pdf"
# Get manual as byte array
$manualData = Get-SteamManual -AppId 730
```
### `Get-SteamManualUri`
Gets the URI for a game's manual on the Steam Store.
### Syntax
```powershell
Get-SteamManualUri
-AppId <int>
```
### Description
The `Get-SteamManualUri` cmdlet returns the URI where the manual for the specified Steam App ID can be accessed. Returns a `Uri` object.
### Example
```powershell
$uri = Get-SteamManualUri -AppId 730
Write-Host "Manual URL: $uri"
```
### `Test-SteamManual`
Tests whether a game has a manual available on the Steam Store.
### Syntax
```powershell
Test-SteamManual
-AppId <int>
```
### Description
The `Test-SteamManual` cmdlet checks if a manual exists for the specified Steam App ID. Returns a boolean indicating whether a manual is available.
### Example
```powershell
$hasManual = Test-SteamManual -AppId 730
if ($hasManual) {
Write-Host "Manual available"
}
```
### `Get-SteamWebAssetUri`
## `Get-SteamWebAssetUri`
Gets the URI for a Steam web asset (logo, header, etc.).
### Syntax
@ -580,23 +463,25 @@ $logoUri = Get-SteamWebAssetUri -AppId 730 -WebAssetType Logo
Write-Host "Logo URL: $logoUri"
```
### `Test-SteamWebAsset`
Tests whether a Steam web asset exists for a game.
## `Get-SteamAppInfo`
Gets app details from the Steam Store (no API key required) and the **changenumber** (build ID) and last updated time from Steam via SteamKit2 (PICS).
### Syntax
```powershell
Test-SteamWebAsset
-AppId <int>
-WebAssetType <WebAssetType>
Get-SteamAppInfo
-AppId <uint>
```
### Description
The `Test-SteamWebAsset` cmdlet checks if a specific web asset type exists for the specified Steam App ID. Returns a boolean indicating whether the asset is available. The `WebAssetType` parameter accepts the same values as `Get-SteamWebAssetUri`.
The `Get-SteamAppInfo` cmdlet returns a `SteamAppInfo` object with name, short description, release date, developers, publishers, and store URL from the public Steam Store `appdetails` endpoint. It also connects to Steam via SteamKit2 (PICS) to fill **LastChangenumber** and **LastUpdated** for the public branch—no Web API key required. If SteamKit2 cannot connect or the app cannot be queried, those two properties are null.
### Example
```powershell
$hasLogo = Test-SteamWebAsset -AppId 730 -WebAssetType Logo
if ($hasLogo) {
Write-Host "Logo available"
$info = Get-SteamAppInfo -AppId 413150
if ($info) {
Write-Host "Name: $($info.Name)"
Write-Host "Description: $($info.Description)"
Write-Host "Release date: $($info.ReleaseDate)"
Write-Host "Developer: $($info.Developer)"
}
```

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
@ -7,51 +8,248 @@ namespace LANCommander.SDK.PowerShell;
/// <summary>
/// Base class for PowerShell cmdlets that need to execute async code.
/// Provides a ProcessRecordAsync method that can be overridden for async operations.
/// Based on <see href="https://github.com/jborean93/PowerShell-OpenAuthenticode">PowerShell-OpenAuthenticode</see> AsyncPSCmdlet.
/// Override BeginProcessingAsync, ProcessRecordAsync, and/or EndProcessingAsync; WriteObject, WriteError, etc.
/// can be called from async code and are marshalled to the pipeline thread.
/// </summary>
public abstract class AsyncCmdlet : PSCmdlet
public abstract class AsyncCmdlet : PSCmdlet, IDisposable
{
private CancellationTokenSource? _cancellationTokenSource;
private enum PipelineType
{
Output,
OutputEnumerate,
Error,
Warning,
Verbose,
Debug,
Information,
Progress,
ShouldProcess,
}
private readonly CancellationTokenSource _cancelSource = new();
private BlockingCollection<(object?, PipelineType)>? _currentOutPipe;
private BlockingCollection<object?>? _currentReplyPipe;
/// <summary>
/// Gets the cancellation token for the current operation.
/// Gets the cancellation token for the current operation. Canceled when the cmdlet is stopped.
/// </summary>
protected CancellationToken CancellationToken => _cancellationTokenSource?.Token ?? CancellationToken.None;
protected CancellationToken CancellationToken => _cancelSource.Token;
/// <summary>
/// Override this method to implement async record processing.
/// Override to perform async startup. Default implementation returns a completed task.
/// </summary>
protected override void BeginProcessing()
{
SessionState.PSVariable.Set("LANCommander.SDK.PSHostUI", Host.UI);
RunBlockInAsync(BeginProcessingAsync);
}
/// <summary>
/// Override to perform async startup.
/// </summary>
protected virtual Task BeginProcessingAsync() => Task.CompletedTask;
/// <summary>
/// Processes a single record by running ProcessRecordAsync and consuming pipeline output on the pipeline thread.
/// </summary>
protected override void ProcessRecord() => RunBlockInAsync(() => ProcessRecordAsync(CancellationToken));
/// <summary>
/// Override to implement async record processing.
/// </summary>
protected abstract Task ProcessRecordAsync(CancellationToken cancellationToken);
/// <summary>
/// Processes a single record synchronously by calling the async ProcessRecordAsync method.
/// Override to perform async cleanup. Default implementation returns a completed task.
/// </summary>
protected override void ProcessRecord()
protected override void EndProcessing() => RunBlockInAsync(EndProcessingAsync);
/// <summary>
/// Override to perform async cleanup.
/// </summary>
protected virtual Task EndProcessingAsync() => Task.CompletedTask;
/// <summary>
/// Called when the cmdlet is stopping. Cancels the cancellation token.
/// </summary>
protected override void StopProcessing()
{
_cancelSource.Cancel();
base.StopProcessing();
}
private void RunBlockInAsync(Func<Task> task)
{
using var outPipe = new BlockingCollection<(object?, PipelineType)>();
using var replyPipe = new BlockingCollection<object?>();
var blockTask = Task.Run(async () =>
{
try
{
_currentOutPipe = outPipe;
_currentReplyPipe = replyPipe;
await task();
}
finally
{
_currentOutPipe = null;
_currentReplyPipe = null;
outPipe.CompleteAdding();
replyPipe.CompleteAdding();
}
});
try
{
// Expose host UI in session state so application loggers can write to the PowerShell runtime
SessionState.PSVariable.Set("LANCommander.SDK.PSHostUI", Host.UI);
_cancellationTokenSource = new CancellationTokenSource();
ProcessRecordAsync(_cancellationTokenSource.Token).GetAwaiter().GetResult();
foreach (var (data, pipelineType) in outPipe.GetConsumingEnumerable(_cancelSource.Token))
{
switch (pipelineType)
{
case PipelineType.Output:
base.WriteObject(data);
break;
case PipelineType.OutputEnumerate:
base.WriteObject(data, true);
break;
case PipelineType.Error:
base.WriteError((ErrorRecord)data!);
break;
case PipelineType.Warning:
base.WriteWarning((string)data!);
break;
case PipelineType.Verbose:
base.WriteVerbose((string)data!);
break;
case PipelineType.Debug:
base.WriteDebug((string)data!);
break;
case PipelineType.Information:
base.WriteInformation((InformationRecord)data!);
break;
case PipelineType.Progress:
base.WriteProgress((ProgressRecord)data!);
break;
case PipelineType.ShouldProcess:
var (target, action) = (ValueTuple<string, string>)data!;
var res = base.ShouldProcess(target, action);
replyPipe.Add(res, _cancelSource.Token);
break;
}
}
}
catch (OperationCanceledException)
{
// Expected when StopProcessing cancels
}
try
{
blockTask.GetAwaiter().GetResult();
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "ProcessRecordError", ErrorCategory.NotSpecified, null));
}
finally
{
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
base.WriteError(new ErrorRecord(ex, "ProcessRecordError", ErrorCategory.NotSpecified, null));
}
}
/// <summary>
/// Called when the cmdlet is stopping.
/// Writes an object to the pipeline. Safe to call from async code; marshalled to the pipeline thread.
/// </summary>
protected override void StopProcessing()
public new void WriteObject(object? sendToPipeline) => WriteObject(sendToPipeline, false);
/// <summary>
/// Writes an object to the pipeline. Safe to call from async code; marshalled to the pipeline thread.
/// </summary>
public new void WriteObject(object? sendToPipeline, bool enumerateCollection)
{
_cancellationTokenSource?.Cancel();
base.StopProcessing();
ThrowIfStopped();
_currentOutPipe?.Add((sendToPipeline, enumerateCollection ? PipelineType.OutputEnumerate : PipelineType.Output));
}
/// <summary>
/// Writes an error record. Safe to call from async code; marshalled to the pipeline thread.
/// </summary>
public new void WriteError(ErrorRecord errorRecord)
{
ThrowIfStopped();
_currentOutPipe?.Add((errorRecord, PipelineType.Error));
}
/// <summary>
/// Writes a warning. Safe to call from async code; marshalled to the pipeline thread.
/// </summary>
public new void WriteWarning(string message)
{
ThrowIfStopped();
_currentOutPipe?.Add((message, PipelineType.Warning));
}
/// <summary>
/// Writes verbose output. Safe to call from async code; marshalled to the pipeline thread.
/// </summary>
public new void WriteVerbose(string message)
{
ThrowIfStopped();
_currentOutPipe?.Add((message, PipelineType.Verbose));
}
/// <summary>
/// Writes debug output. Safe to call from async code; marshalled to the pipeline thread.
/// </summary>
public new void WriteDebug(string message)
{
ThrowIfStopped();
_currentOutPipe?.Add((message, PipelineType.Debug));
}
/// <summary>
/// Writes an information record. Safe to call from async code; marshalled to the pipeline thread.
/// </summary>
public new void WriteInformation(InformationRecord informationRecord)
{
ThrowIfStopped();
_currentOutPipe?.Add((informationRecord, PipelineType.Information));
}
/// <summary>
/// Writes a progress record. Safe to call from async code; marshalled to the pipeline thread.
/// </summary>
public new void WriteProgress(ProgressRecord progressRecord)
{
ThrowIfStopped();
_currentOutPipe?.Add((progressRecord, PipelineType.Progress));
}
/// <summary>
/// Confirms an operation with the user. Safe to call from async code; blocks until the pipeline thread returns the result.
/// </summary>
public new bool ShouldProcess(string target, string action)
{
ThrowIfStopped();
_currentOutPipe?.Add(((target, action), PipelineType.ShouldProcess));
return (bool)_currentReplyPipe?.Take(CancellationToken)!;
}
private void ThrowIfStopped()
{
if (_cancelSource.IsCancellationRequested)
throw new PipelineStoppedException();
}
/// <summary>
/// Disposes the cancellation source.
/// </summary>
protected virtual void Dispose(bool disposing)
{
if (disposing)
_cancelSource.Dispose();
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}

View file

@ -0,0 +1,35 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Models.SteamCmdNet;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamAppInfo")]
[OutputType(typeof(AppInfo))]
public class GetSteamAppInfoCmdlet : AsyncCmdlet
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
[ValidateRange(1u, uint.MaxValue)]
public uint AppId { get; set; }
protected override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
var steamStoreService = SteamServicesProvider.GetSteamWebApiService(SessionState);
try
{
var info = await steamStoreService.GetAppInfo(AppId);
if (info == null)
return;
WriteObject(info);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "SteamAppInfoError", ErrorCategory.OperationStopped, AppId));
}
}
}

View file

@ -1,49 +0,0 @@
using System;
using System.IO;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Services;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamManual")]
[OutputType(typeof(byte[]))]
public class GetSteamManualCmdlet : AsyncCmdlet
{
[Parameter(Mandatory = true, Position = 0)]
public int AppId { get; set; }
[Parameter(Mandatory = false)]
public string? OutputPath { get; set; }
protected override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
var steamStoreService = SteamServicesProvider.GetSteamStoreService(SessionState);
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

@ -1,28 +0,0 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Services;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "SteamManualUri")]
[OutputType(typeof(Uri))]
public class GetSteamManualUriCmdlet : AsyncCmdlet
{
[Parameter(Mandatory = true, Position = 0)]
public int AppId { get; set; }
protected 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

@ -21,7 +21,7 @@ public class GetSteamWebAssetUriCmdlet : AsyncCmdlet
{
try
{
var uri = SteamStoreService.GetWebAssetUri(AppId, WebAssetType);
var uri = SteamWebApiService.GetWebAssetUri(AppId, WebAssetType);
WriteObject(uri);
}
catch (Exception ex)

View file

@ -16,7 +16,7 @@ public class SearchSteamGamesCmdlet : AsyncCmdlet
protected override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
var steamStoreService = SteamServicesProvider.GetSteamStoreService(SessionState);
var steamStoreService = SteamServicesProvider.GetSteamWebApiService(SessionState);
try
{

View file

@ -1,30 +0,0 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Services;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsDiagnostic.Test, "SteamManual")]
[OutputType(typeof(bool))]
public class TestSteamManualCmdlet : AsyncCmdlet
{
[Parameter(Mandatory = true, Position = 0)]
public int AppId { get; set; }
protected override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
var steamStoreService = SteamServicesProvider.GetSteamStoreService(SessionState);
try
{
var exists = await steamStoreService.HasManualAsync(AppId);
WriteObject(exists);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "HasManualError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -1,34 +0,0 @@
using System;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam;
using LANCommander.Steam.Services;
namespace LANCommander.SDK.PowerShell.Cmdlets;
[Cmdlet(VerbsDiagnostic.Test, "SteamWebAsset")]
[OutputType(typeof(bool))]
public class TestSteamWebAssetCmdlet : AsyncCmdlet
{
[Parameter(Mandatory = true, Position = 0)]
public int AppId { get; set; }
[Parameter(Mandatory = true, Position = 1)]
public WebAssetType WebAssetType { get; set; }
protected override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
var steamStoreService = SteamServicesProvider.GetSteamStoreService(SessionState);
try
{
var (exists, _) = await steamStoreService.HasWebAssetAsync(AppId, WebAssetType);
WriteObject(exists);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "HasWebAssetError", ErrorCategory.OperationStopped, null));
}
}
}

View file

@ -1,8 +1,4 @@
using System;
using System.Linq;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Reflection;
using LANCommander.SDK.PowerShell.Cmdlets;
namespace LANCommander.SDK.PowerShell.Extensions;
@ -28,7 +24,6 @@ public static class InitialSessionStateExtensions
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));
@ -39,13 +34,8 @@ public static class InitialSessionStateExtensions
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));
// 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-SteamAppInfo", typeof(GetSteamAppInfoCmdlet), 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,5 +1,6 @@
using System;
using System.Management.Automation;
using System.Net.Http;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Options;
using LANCommander.Steam.Services;
@ -14,7 +15,7 @@ namespace LANCommander.SDK.PowerShell;
public static class SteamServicesProvider
{
private const string SteamCmdServiceKey = "LANCommander.Steam.SteamCmdService";
private const string SteamStoreServiceKey = "LANCommander.Steam.SteamStoreService";
private const string SteamWebApiServiceKey = "LANCommander.Steam.SteamStoreService";
private const string SettingsProviderKey = "LANCommander.SDK.ISettingsProvider";
private const string PSHostUIKey = "LANCommander.SDK.PSHostUI";
@ -59,17 +60,19 @@ public static class SteamServicesProvider
/// <summary>
/// Gets or creates the Steam Store service for the current session.
/// </summary>
public static SteamStoreService GetSteamStoreService(SessionState sessionState)
public static ISteamWebApiService GetSteamWebApiService(SessionState sessionState)
{
var existing = sessionState.PSVariable.GetValue(SteamStoreServiceKey) as SteamStoreService;
var existing = sessionState.PSVariable.GetValue(SteamWebApiServiceKey) as SteamWebApiService;
if (existing != null)
return existing;
var service = new SteamStoreService();
var service = new SteamWebApiService(new HttpClient());
sessionState.PSVariable.Set(SteamStoreServiceKey, service);
sessionState.PSVariable.Set(SteamWebApiServiceKey, service);
return service;
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.Steam.Enums;
using LANCommander.Steam.Events;
@ -17,6 +18,12 @@ public interface ISteamCmdService
/// </summary>
string? ExecutablePath { get; set; }
/// <summary>
/// Get app info (changenumber / buildid and time updated) for the public branch via app_info_print.
/// Requires SteamCMD to be installed and configured. Returns null if SteamCMD is unavailable or the app cannot be queried.
/// </summary>
Task<SteamCmdAppInfo?> GetAppInfoAsync(uint appId, System.Threading.CancellationToken cancellationToken = default);
/// <summary>
/// Event fired when an install job status changes (started, completed, failed)
/// </summary>

View file

@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using LANCommander.Steam.Models.SteamCmdNet;
namespace LANCommander.Steam.Abstractions;
public interface ISteamWebApiService
{
public Task<AppInfo> GetAppInfo(uint appId);
public Task<IEnumerable<GameSearchResult>> SearchGamesAsync(string keyword);
}

View file

@ -34,6 +34,7 @@ public static class ServiceCollectionExtensions
sp.GetService<IOptions<SteamCmdOptions>>()?.Value,
sp.GetService<ISteamCmdProfileStore>(),
sp.GetService<ILogger<SteamCmdService>>()));
services.AddSingleton<ISteamWebApiService, SteamWebApiService>();
return services;
}
@ -60,6 +61,7 @@ public static class ServiceCollectionExtensions
sp.GetService<IOptions<SteamCmdOptions>>()?.Value,
sp.GetService<ISteamCmdProfileStore>(),
sp.GetService<ILogger<SteamCmdService>>()));
services.AddSingleton<ISteamWebApiService, SteamWebApiService>();
return services;
}
@ -86,6 +88,7 @@ public static class ServiceCollectionExtensions
sp.GetService<IOptions<SteamCmdOptions>>()?.Value,
sp.GetService<ISteamCmdProfileStore>(),
sp.GetService<ILogger<SteamCmdService>>()));
services.AddSingleton<ISteamWebApiService, SteamWebApiService>();
return services;
}

View file

@ -1,12 +1,7 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace LANCommander.Steam;
namespace LANCommander.Steam
public class GameSearchResult
{
public class GameSearchResult
{
public string Name { get; set; }
public int AppId { get; set; }
}
}
public string Name { get; set; }
public int AppId { get; set; }
}

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>

View file

@ -0,0 +1,24 @@
using System;
namespace LANCommander.Steam.Models;
/// <summary>
/// App info from SteamCMD (app_info_print): changenumber (buildid) and last updated time for the public branch.
/// </summary>
public class SteamCmdAppInfo
{
/// <summary>
/// Steam application ID.
/// </summary>
public uint AppId { get; set; }
/// <summary>
/// Build ID / changenumber for the public branch.
/// </summary>
public string? Changenumber { get; set; }
/// <summary>
/// When the public branch was last updated (Unix timestamp from SteamCMD).
/// </summary>
public DateTimeOffset? TimeUpdated { get; set; }
}

View file

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class AppAssociation
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("type")]
public string? Type { get; set; }
}

View file

@ -0,0 +1,148 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public class AppCommon
{
[JsonPropertyName("associations")]
public Dictionary<string, AppAssociation>? Associations { get; set; }
[JsonPropertyName("category")]
public Dictionary<string, string>? Category { get; set; }
[JsonPropertyName("clienticns")]
public string? ClientIcons { get; set; }
[JsonPropertyName("clienticon")]
public string? ClientIcon { get; set; }
[JsonPropertyName("clienttga")]
public string? ClientTGA { get; set; }
[JsonPropertyName("community_hub_visible")]
public string? CommunityHubVisible { get; set; }
[JsonPropertyName("community_visible_stats")]
public string? CommunityVisibleStats { get; set; }
[JsonPropertyName("content_descriptors")]
public Dictionary<string, string>? ContentDescriptors { get; set; }
[JsonPropertyName("content_descriptors_including_dlc")]
public Dictionary<string, string>? ContentDescriptorsIncludingDLC { get; set; }
[JsonPropertyName("controllertagwizard")]
public string? ControllerTagWizard { get; set; }
[JsonPropertyName("exfgls")]
public string? Exfgls { get; set; }
[JsonPropertyName("gameid")]
public string? GameId { get; set; }
[JsonPropertyName("genres")]
public Dictionary<string, string>? Genres { get; set; }
[JsonPropertyName("header_image")]
public LocalizedImageMap? HeaderImageMap { get; set; }
[JsonPropertyName("icon")]
public string? Icon { get; set; }
[JsonPropertyName("languages")]
public Dictionary<string, string>? Languages { get; set; }
[JsonPropertyName("library_assets")]
public LibraryAssets? LibraryAssets { get; set; }
[JsonPropertyName("library_assets_null")]
public LibraryAssetsFull? LibraryAssetsFull { get; set; }
[JsonPropertyName("linuxclienticon")]
public string? LinuxClientIcon { get; set; }
[JsonPropertyName("logo")]
public string? Logo { get; set; }
[JsonPropertyName("logo_small")]
public string? LogoSmall { get; set; }
[JsonPropertyName("market_presence")]
public string? MarketPresence { get; set; }
[JsonPropertyName("metacritic_name")]
public string? MetacriticName { get; set; }
[JsonPropertyName("metacritic_score")]
public int? MetacriticScore { get; set; }
[JsonPropertyName("metacritic_url")]
public string? MetacriticUrl { get; set; }
[JsonPropertyName("metacritic_fullurl")]
public string? MetacriticFullUrl { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("name_localized")]
public Dictionary<string, string>? NameLocalized { get; set; }
[JsonPropertyName("osarch")]
public string? OperatingSystemArchitecture { get; set; }
[JsonPropertyName("osextended")]
public string? OperatingSystemExtended { get; set; }
[JsonPropertyName("oslist")]
public string? OperatingSystemList { get; set; }
[JsonPropertyName("original_release_date")]
public long? OriginalReleaseDate { get; set; }
[JsonPropertyName("primary_genre")]
public int? PrimaryGenre { get; set; }
[JsonPropertyName("releasestatesteamchina")]
public string? ReleaseStateSteamChina { get; set; }
[JsonPropertyName("review_percentage")]
public int? ReviewPercentage { get; set; }
[JsonPropertyName("review_score")]
public int? ReviewScore { get; set; }
[JsonPropertyName("small_capsule")]
public LocalizedImageMap? SmallCapsule { get; set; }
[JsonPropertyName("steam_deck_compatibility")]
public string? SteamDeckCompatibility { get; set; }
[JsonPropertyName("steam_release_date")]
public string? SteamReleaseDate { get; set; }
[JsonPropertyName("steamchinaapproved")]
public string? SteamChinaApproved { get; set; }
[JsonPropertyName("store_asset_mtime")]
public string? StoreAssetMTime { get; set; }
[JsonPropertyName("store_tags")]
public Dictionary<string, string>? StoreTags { get; set; }
[JsonPropertyName("supported_languages")]
public Dictionary<string, string>? SupportedLanguages { get; set; }
[JsonPropertyName("timeline_marker_svg")]
public string? TimelineMarkerSvg { get; set; }
[JsonPropertyName("timeline_marker_updated")]
public string? TimelineMarkerUpdated { get; set; }
[JsonPropertyName("type")]
public string? Type { get; set; }
[JsonPropertyName("workshop_visible")]
public string? WorkshopVisible { get; set; }
}

View file

@ -0,0 +1,133 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class AppConfig
{
[JsonPropertyName("app_mappings")]
public Dictionary<string, AppMapping>? AppMappings { get; set; }
[JsonPropertyName("cegpublickey")]
public string? CegPublicKey { get; set; }
[JsonPropertyName("checkforupdatesbeforelaunch")]
public string? CheckForUpdatesBeforeLaunch { get; set; }
[JsonPropertyName("checkguid")]
public string? CheckGuid { get; set; }
[JsonPropertyName("contenttype")]
public string? ContentType { get; set; }
[JsonPropertyName("duration_control_show_interstitial")]
public string? DurationControlShowInterstitial { get; set; }
[JsonPropertyName("enable_duration_control")]
public string? EnableDurationControl { get; set; }
[JsonPropertyName("enabletextfiltering")]
public string? EnableTextFiltering { get; set; }
[JsonPropertyName("externalarguments")]
public ExternalArguments? ExternalArguments { get; set; }
[JsonPropertyName("gameoverlay_testmode")]
public string? GameOverlayTestMode { get; set; }
[JsonPropertyName("installdir")]
public string? InstallDirectory { get; set; }
[JsonPropertyName("installscriptoverride")]
public string? InstallScriptOverride { get; set; }
[JsonPropertyName("installscriptsignature")]
public string? InstallScriptSignature { get; set; }
[JsonPropertyName("launch")]
public Dictionary<string, LaunchEntry>? Launch { get; set; }
[JsonPropertyName("launchwithoutworkshopupdates")]
public string? LaunchWithoutWorkshopUpdates { get; set; }
[JsonPropertyName("matchmaking_mms_appidinvitenf")]
public string? MatchmakingMmsAppIdInviteNf { get; set; }
[JsonPropertyName("matchmaking_rate_limit")]
public string? MatchmakingRateLimit { get; set; }
[JsonPropertyName("matchmaking_uptodate")]
public string? MatchmakingUpToDate { get; set; }
[JsonPropertyName("sdr-groups")]
public string? SdrGroups { get; set; }
[JsonPropertyName("sdr-groups-global")]
public string? SdrGroupsGlobal { get; set; }
[JsonPropertyName("signaturescheckedonlaunch")]
public Dictionary<string, Dictionary<string, string>>? SignaturesCheckedOnLaunch { get; set; }
[JsonPropertyName("signedfiles")]
public Dictionary<string, string>? SignedFiles { get; set; }
[JsonPropertyName("steam_china_only")]
public Dictionary<string, string>? SteamChinaOnly { get; set; }
[JsonPropertyName("steamconfigurator3rdpartynative")]
public string? SteamConfigurator3rdPartyNative { get; set; }
[JsonPropertyName("steamcontrollertemplateindex")]
public string? SteamControllerTemplateIndex { get; set; }
[JsonPropertyName("steamdecktouchscreen")]
public string? SteamDeckTouchscreen { get; set; }
[JsonPropertyName("systemprofile")]
public string? SystemProfile { get; set; }
[JsonPropertyName("uselaunchcommandline")]
public string? UseLaunchCommandLine { get; set; }
[JsonPropertyName("usemms")]
public string? UseMms { get; set; }
[JsonPropertyName("usesfrenemies")]
public string? UseSfrenemies { get; set; }
[JsonPropertyName("vacmodulefilename")]
public string? VacModuleFileName { get; set; }
[JsonPropertyName("vacmodulefilename_macos")]
public string? VacModuleFileNameMacos { get; set; }
[JsonPropertyName("verifyupdates")]
public string? VerifyUpdates { get; set; }
[JsonPropertyName("depots")]
public Dictionary<string, Depot>? Depots { get; set; }
[JsonPropertyName("appmanagesdlc")]
public string? AppManagesDlc { get; set; }
[JsonPropertyName("baselanguages")]
public string? BaseLanguages { get; set; }
[JsonPropertyName("branches")]
public Dictionary<string, Branch>? Branches { get; set; }
[JsonPropertyName("depotdeltapatches")]
public string? DepotDeltaPatches { get; set; }
[JsonPropertyName("hasdepotsindlc")]
public string? HasDepotsInDlc { get; set; }
[JsonPropertyName("overridescddb")]
public string? OverridesCddb { get; set; }
[JsonPropertyName("privatebranches")]
public string? PrivateBranches { get; set; }
[JsonPropertyName("workshopdepot")]
public string? WorkshopDepot { get; set; }
}

View file

@ -0,0 +1,105 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class AppExtended
{
[JsonPropertyName("aliases")]
public string? Aliases { get; set; }
[JsonPropertyName("developer")]
public string? Developer { get; set; }
[JsonPropertyName("developer_url")]
public string? DeveloperUrl { get; set; }
[JsonPropertyName("dlcavailableonstore")]
public string? DLCAvailableOnStore { get; set; }
[JsonPropertyName("gamedir")]
public string? GameDirectory { get; set; }
[JsonPropertyName("gamemanualurl")]
public string? GameManualUrl { get; set; }
[JsonPropertyName("homepage")]
public string? HomePage { get; set; }
[JsonPropertyName("icon")]
public string? Icon { get; set; }
[JsonPropertyName("installscript")]
public string? InstallScript { get; set; }
[JsonPropertyName("installscript_macos")]
public string? InstallScriptMacOS { get; set; }
[JsonPropertyName("installscript_osx")]
public string? InstallScriptOSX { get; set; }
[JsonPropertyName("isfreeapp")]
public string? IsFreeApp { get; set; }
[JsonPropertyName("languages_macos")]
public string? LanguagesMacOS { get; set; }
[JsonPropertyName("launcheula")]
public string? LaunchEula { get; set; }
[JsonPropertyName("launchulamask")]
public string? LaunchEulaMask { get; set; }
[JsonPropertyName("legacykeyregistrationmethod")]
public string? LegacyKeyRegistrationMethod { get; set; }
[JsonPropertyName("legacykeyregistrylocation")]
public string? LegacyKeyRegistryLocation { get; set; }
[JsonPropertyName("listofdlc")]
public string? ListOfDLC { get; set; }
[JsonPropertyName("loadallbeforelaunch")]
public string? LoadAllBeforeLaunch { get; set; }
[JsonPropertyName("minclientversion")]
public string? MinimumClientVersion { get; set; }
[JsonPropertyName("minclientversion_pw_csgo")]
public string? MinimumClientVersionPwCsgo { get; set; }
[JsonPropertyName("noservers")]
public string? NoServers { get; set; }
[JsonPropertyName("order")]
public int? Order { get; set; }
[JsonPropertyName("primarycache")]
public long? PrimaryCache { get; set; }
[JsonPropertyName("primarycache_mac")]
public long? PrimaryCacheMac { get; set; }
[JsonPropertyName("primarycache_macos")]
public long? PrimaryCacheMacOS { get; set; }
[JsonPropertyName("publisher")]
public string? Publisher { get; set; }
[JsonPropertyName("serverbrowsername")]
public string? ServerBrowserName { get; set; }
[JsonPropertyName("state")]
public string? State { get; set; }
[JsonPropertyName("vacmacmodulecache")]
public string? VacMacModuleCache { get; set; }
[JsonPropertyName("vacmodulecache")]
public string? VacModuleCache { get; set; }
[JsonPropertyName("vacmodulefilename")]
public string? VacModuleFileName { get; set; }
[JsonPropertyName("validoslist")]
public string? ValidOperatingSystemList { get; set; }
}

View file

@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class AppInfo
{
[JsonPropertyName("_change_number")]
public long? ChangeNumber { get; set; }
[JsonPropertyName("_missing_token")]
public bool? MissingToken { get; set; }
[JsonPropertyName("_sha")]
public string? SHA { get; set; }
[JsonPropertyName("_size")]
public long? Size { get; set; }
[JsonPropertyName("appid")]
public long? AppId { get; set; }
[JsonPropertyName("common")]
public AppCommon? Common { get; set; }
[JsonPropertyName("config")]
public AppConfig? Config { get; set; }
[JsonPropertyName("extended")]
public AppExtended? Extended { get; set; }
[JsonPropertyName("install")]
public AppInstall? Install { get; set; }
[JsonPropertyName("ufs")]
public AppUfs? UFS { get; set; }
}

View file

@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class AppInfoResponse
{
[JsonPropertyName("data")]
public Dictionary<uint, AppInfo>? Data { get; set; }
[JsonPropertyName("status")]
public string? Status { get; set; }
}

View file

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class AppInstall
{
[JsonPropertyName("registry")]
public RegistryRoot? Registry { get; set; }
[JsonPropertyName("utf8_registry_strings")]
public string? UTF8RegistryStrings { get; set; }
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class AppMapping
{
[JsonPropertyName("comment")]
public string? Comment { get; set; }
[JsonPropertyName("platform")]
public string? Platform { get; set; }
[JsonPropertyName("tool")]
public string? Tool { get; set; }
}

View file

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class AppUfs
{
[JsonPropertyName("maxnumfiles")]
public string? MaxNumFiles { get; set; }
[JsonPropertyName("quota")]
public string? Quota { get; set; }
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class Branch
{
[JsonPropertyName("buildid")]
public string? BuildId { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("timeupdated")]
public string? TimeUpdated { get; set; }
}

View file

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class DeckTestResult
{
[JsonPropertyName("display")]
public string? Display { get; set; }
[JsonPropertyName("token")]
public string? Token { get; set; }
}

View file

@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class Depot
{
[JsonPropertyName("config")]
public DepotConfig? Config { get; set; }
[JsonPropertyName("depotfromapp")]
public string? DepotFromApp { get; set; }
[JsonPropertyName("sharedinstall")]
public string? SharedInstall { get; set; }
[JsonPropertyName("dlcappid")]
public string? DlcAppId { get; set; }
[JsonPropertyName("systemdefined")]
public string? SystemDefined { get; set; }
[JsonPropertyName("manifests")]
public Dictionary<string, Manifest>? Manifests { get; set; }
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class DepotConfig
{
[JsonPropertyName("oslist")]
public string? OperatingSystemList { get; set; }
[JsonPropertyName("osarch")]
public string? OperatingSystemArchitecture { get; set; }
[JsonPropertyName("optionaldlc")]
public string? OptionalDlc { get; set; }
}

View file

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class ExternalArguments
{
[JsonPropertyName("allowunknown")]
public string? AllowUnknown { get; set; }
}

View file

@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class LaunchConfig
{
[JsonPropertyName("osarch")]
public string? OperatingSystemArchitecture { get; set; }
[JsonPropertyName("oslist")]
public string? OperatingSystemList { get; set; }
[JsonPropertyName("realm")]
public string? Realm { get; set; }
[JsonPropertyName("betakey")]
public string? BetaKey { get; set; }
[JsonPropertyName("ownsdlc")]
public string? OwnsDlc { get; set; }
}

View file

@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class LaunchEntry
{
[JsonPropertyName("arguments")]
public string? Arguments { get; set; }
[JsonPropertyName("config")]
public LaunchConfig? Config { get; set; }
[JsonPropertyName("executable")]
public string? Executable { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("description_loc")]
public Dictionary<string, string>? DescriptionLoc { get; set; }
[JsonPropertyName("type")]
public string? Type { get; set; }
}

View file

@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class LibraryAssetVariant
{
[JsonPropertyName("image")]
public Dictionary<string, string>? Image { get; set; }
[JsonPropertyName("image2x")]
public Dictionary<string, string>? ImageLarge { get; set; }
[JsonPropertyName("logo_position")]
public LogoPosition? Position { get; set; }
}

View file

@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class LibraryAssets
{
[JsonPropertyName("library_capsule")]
public string? Capsule { get; set; }
[JsonPropertyName("library_hero")]
public string? Hero { get; set; }
[JsonPropertyName("library_logo")]
public string? Logo { get; set; }
[JsonPropertyName("logo_position")]
public LogoPosition? LogoPosition { get; set; }
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class LibraryAssetsFull
{
[JsonPropertyName("library_capsule")]
public LibraryAssetVariant? Capsule { get; set; }
[JsonPropertyName("library_hero")]
public LibraryAssetVariant? Hero { get; set; }
[JsonPropertyName("library_logo")]
public LibraryAssetVariant? Logo { get; set; }
}

View file

@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public class LocalizedImageMap
{
[JsonExtensionData]
public Dictionary<string, object>? Values { get; set; }
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class LogoPosition
{
[JsonPropertyName("height_pct")]
public string? HeightPercentage { get; set; }
[JsonPropertyName("width_pct")]
public string? WidthPercentage { get; set; }
[JsonPropertyName("pinned_position")]
public string? PinnedPosition { get; set; }
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class Manifest
{
[JsonPropertyName("download")]
public string? Download { get; set; }
[JsonPropertyName("gid")]
public string? GID { get; set; }
[JsonPropertyName("size")]
public string? Size { get; set; }
}

View file

@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class RegistryRoot
{
// Keys look like: "hkey_local_machine\\software\\valve\\cs2"
[JsonExtensionData]
public Dictionary<string, object>? Keys { get; set; }
}

View file

@ -0,0 +1,28 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class SteamDeckCompatibility
{
[JsonPropertyName("category")]
public string? Category { get; set; }
[JsonPropertyName("configuration")]
public Dictionary<string, string>? Configuration { get; set; }
[JsonPropertyName("steamos_compatibility")]
public string? SteamOsCompatibility { get; set; }
[JsonPropertyName("steamos_tests")]
public Dictionary<string, DeckTestResult>? SteamOsTests { get; set; }
[JsonPropertyName("test_timestamp")]
public string? TestTimestamp { get; set; }
[JsonPropertyName("tested_build_id")]
public string? TestedBuildId { get; set; }
[JsonPropertyName("tests")]
public Dictionary<string, DeckTestResult>? Tests { get; set; }
}

View file

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace LANCommander.Steam.Models.SteamCmdNet;
public sealed class SupportedLanguage
{
[JsonPropertyName("supported")]
public string? Supported { get; set; }
[JsonPropertyName("full_audio")]
public string? FullAudio { get; set; }
}

View file

@ -473,7 +473,66 @@ public class SteamCmdService(
await profileStore.DeleteAsync(username);
_logger?.LogInformation("Deleted profile for username: {Username}", username);
}
/// <inheritdoc />
public async Task<SteamCmdAppInfo?> GetAppInfoAsync(uint appId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(ExecutablePath))
{
if (_options.AutoDetectPath)
{
var detected = await AutoDetectSteamCmdPathAsync();
if (!string.IsNullOrEmpty(detected))
ExecutablePath = detected;
}
if (string.IsNullOrWhiteSpace(ExecutablePath))
{
_logger?.LogWarning("SteamCMD path is not configured; cannot get app changenumber.");
return null;
}
}
if (!File.Exists(ExecutablePath))
{
_logger?.LogWarning("SteamCMD executable not found at {Path}", ExecutablePath);
return null;
}
// +quit can suppress output on some SteamCMD versions; we parse both stdout and stderr
var arguments = $"+login anonymous +app_info_update 1 +app_info_print {appId} +quit";
var result = await ExecuteSteamCmdCommandAsync(arguments, TimeSpan.FromSeconds(60));
var combined = (result.Output + "\n" + result.ErrorOutput);
var info = ParseAppInfoPrintOutput(appId, combined);
if (info != null)
_logger?.LogDebug("SteamCMD app info for {AppId}: changenumber={Changenumber}", appId, info.Changenumber);
return info;
}
private static SteamCmdAppInfo? ParseAppInfoPrintOutput(uint appId, string output)
{
// VDF-like output: "buildid" "3936003" and "timeupdated" "1560990358" (often under "public" branch)
var buildIdMatch = Regex.Match(output, @"""buildid""\s+""([^""]+)""", RegexOptions.IgnoreCase);
var timeUpdatedMatch = Regex.Match(output, @"""timeupdated""\s+""([^""]+)""", RegexOptions.IgnoreCase);
if (!buildIdMatch.Success)
return null;
var changenumber = buildIdMatch.Groups[1].Value.Trim();
if (string.IsNullOrEmpty(changenumber))
return null;
DateTimeOffset? timeUpdated = null;
if (timeUpdatedMatch.Success && long.TryParse(timeUpdatedMatch.Groups[1].Value.Trim(), out var unixSeconds))
timeUpdated = DateTimeOffset.FromUnixTimeSeconds(unixSeconds);
return new SteamCmdAppInfo
{
AppId = appId,
Changenumber = changenumber,
TimeUpdated = timeUpdated
};
}
private async Task<SteamCmdResult> ExecuteSteamCmdCommandWithProgressAsync(
string arguments,
SteamCmdInstallJob? job = null,
@ -502,38 +561,45 @@ public class SteamCmdService(
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();
// Read streams with \r and \n as line delimiters so we get progress updates in real time.
// SteamCMD uses \r for progress lines and full buffering when piped; BeginOutputReadLine
// only fires on \n, so output would otherwise appear only at the end.
var readStdOut = ReadStreamByLineAsync(
process.StandardOutput.BaseStream,
Encoding.UTF8,
line =>
{
if (string.IsNullOrEmpty(line))
return;
output.AppendLine(line);
_logger?.LogDebug("SteamCMD Output: {Output}", line);
if (job != null)
ParseProgressFromOutput(line, job);
},
cancellationToken);
var readStdErr = ReadStreamByLineAsync(
process.StandardError.BaseStream,
Encoding.UTF8,
line =>
{
if (string.IsNullOrEmpty(line))
return;
error.AppendLine(line);
_logger?.LogDebug("SteamCMD Error: {Error}", line);
if (job != null)
ParseProgressFromOutput(line, job);
},
cancellationToken);
try
{
await process.WaitForExitAsync(cancellationToken);
await Task.WhenAll(
readStdOut,
readStdErr,
process.WaitForExitAsync(cancellationToken));
}
catch (OperationCanceledException)
{
@ -567,6 +633,43 @@ public class SteamCmdService(
}
}
/// <summary>
/// Reads a stream and invokes the callback for each line. Lines are split on \r and \n
/// so that progress output (often \r-only) is delivered in real time instead of buffered.
/// </summary>
private static async Task ReadStreamByLineAsync(
Stream stream,
Encoding encoding,
Action<string> onLine,
CancellationToken cancellationToken = default)
{
using var reader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
var lineBuilder = new StringBuilder();
var charBuffer = new char[256];
int read;
while ((read = await reader.ReadAsync(charBuffer.AsMemory(), cancellationToken).ConfigureAwait(false)) > 0)
{
for (var i = 0; i < read; i++)
{
var ch = charBuffer[i];
if (ch == '\r' || ch == '\n')
{
if (lineBuilder.Length > 0)
{
onLine(lineBuilder.ToString());
lineBuilder.Clear();
}
}
else
{
lineBuilder.Append(ch);
}
}
}
if (lineBuilder.Length > 0)
onLine(lineBuilder.ToString());
}
private void ParseProgressFromOutput(string line, SteamCmdInstallJob job)
{
// SteamCMD progress patterns:

View file

@ -1,30 +1,34 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Mime;
using System.Net.Http.Json;
using System.Threading.Tasks;
using HtmlAgilityPack;
using LANCommander.Steam.Abstractions;
using LANCommander.Steam.Models.SteamCmdNet;
namespace LANCommander.Steam.Services;
public class SteamStoreService
public class SteamWebApiService(HttpClient httpClient) : ISteamWebApiService
{
private readonly HttpClient HttpClient;
public SteamStoreService()
public async Task<AppInfo> GetAppInfo(uint appId)
{
HttpClient = new HttpClient();
HttpClient.BaseAddress = new Uri("https://store.steampowered.com");
var response = await httpClient.GetFromJsonAsync<AppInfoResponse>($"https://api.steamcmd.net/v1/info/{appId}");
if (response.Data?.ContainsKey(appId) ?? false)
return response.Data[appId];
return null;
}
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");
HtmlDocument dom =
await web.LoadFromWebAsync($"https://store.steampowered.com/search/suggest?term={keyword}&f=games&cc=US");
var results = new List<GameSearchResult>();
List<GameSearchResult> results = [];
var matches = dom.DocumentNode.SelectNodes("//a[@data-ds-appid]");
if (matches == null || matches.Count == 0)
@ -48,51 +52,15 @@ public class SteamStoreService
});
}
}
catch (Exception ex) { }
catch
{
// Ignore
}
}
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>()

View file

@ -1,116 +1,112 @@
using HtmlAgilityPack;
using SteamWebAPI2.Utilities;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Mime;
using System.Text.Json;
using System.Threading.Tasks;
namespace LANCommander.Steam
namespace LANCommander.Steam;
public class SteamClient
{
public class SteamClient
private readonly HttpClient HttpClient;
public SteamClient()
{
private readonly HttpClient HttpClient;
HttpClient = new HttpClient();
HttpClient.BaseAddress = new Uri("https://store.steampowered.com");
}
public SteamClient()
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 Enumerable.Empty<GameSearchResult>();
foreach (var match in matches)
{
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 Enumerable.Empty<GameSearchResult>();
foreach (var match in matches)
try
{
try
var appId = match.Attributes["data-ds-appid"].Value;
var matchNameElement = match.SelectSingleNode(".//div[@class = 'match_name']");
appId = appId.Split(',').First();
if (matchNameElement != null)
{
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
{
results.Add(new GameSearchResult
{
Name = matchNameElement.InnerText,
AppId = Convert.ToInt32(appId)
});
}
Name = matchNameElement.InnerText,
AppId = Convert.ToInt32(appId)
});
}
catch (Exception ex) { }
}
return results;
catch (Exception ex) { }
}
public async Task<(bool Exists, string MimeType)> HasWebAssetAsync(int appId, WebAssetType webAssetType)
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())
{
var webAssetUri = GetWebAssetUri(appId, webAssetType);
var response = await HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, webAssetUri));
await response.Content.CopyToAsync(ms);
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]}");
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]}");
}
}

View file

@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace LANCommander.Steam
namespace LANCommander.Steam
{
public enum WebAssetType
{