diff --git a/Directory.Packages.props b/Directory.Packages.props
index 323d0b30..c06a0b92 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -116,6 +116,7 @@
+
diff --git a/LANCommander.Documentation/Scripting/Cmdlets.md b/LANCommander.Documentation/Scripting/Cmdlets.md
index d79a0edd..5d63d84c 100644
--- a/LANCommander.Documentation/Scripting/Cmdlets.md
+++ b/LANCommander.Documentation/Scripting/Cmdlets.md
@@ -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
-```
-
-### 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
-```
-
-### 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
- -OutputPath (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
-```
-
-### 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
-```
-
-### 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
- -WebAssetType
+Get-SteamAppInfo
+ -AppId
```
### 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)"
}
```
\ No newline at end of file
diff --git a/LANCommander.SDK/PowerShell/AsyncCmdlet.cs b/LANCommander.SDK/PowerShell/AsyncCmdlet.cs
index e8011be6..9c949f44 100644
--- a/LANCommander.SDK/PowerShell/AsyncCmdlet.cs
+++ b/LANCommander.SDK/PowerShell/AsyncCmdlet.cs
@@ -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;
///
/// Base class for PowerShell cmdlets that need to execute async code.
-/// Provides a ProcessRecordAsync method that can be overridden for async operations.
+/// Based on PowerShell-OpenAuthenticode AsyncPSCmdlet.
+/// Override BeginProcessingAsync, ProcessRecordAsync, and/or EndProcessingAsync; WriteObject, WriteError, etc.
+/// can be called from async code and are marshalled to the pipeline thread.
///
-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? _currentReplyPipe;
///
- /// Gets the cancellation token for the current operation.
+ /// Gets the cancellation token for the current operation. Canceled when the cmdlet is stopped.
///
- protected CancellationToken CancellationToken => _cancellationTokenSource?.Token ?? CancellationToken.None;
+ protected CancellationToken CancellationToken => _cancelSource.Token;
///
- /// Override this method to implement async record processing.
+ /// Override to perform async startup. Default implementation returns a completed task.
+ ///
+ protected override void BeginProcessing()
+ {
+ SessionState.PSVariable.Set("LANCommander.SDK.PSHostUI", Host.UI);
+ RunBlockInAsync(BeginProcessingAsync);
+ }
+
+ ///
+ /// Override to perform async startup.
+ ///
+ protected virtual Task BeginProcessingAsync() => Task.CompletedTask;
+
+ ///
+ /// Processes a single record by running ProcessRecordAsync and consuming pipeline output on the pipeline thread.
+ ///
+ protected override void ProcessRecord() => RunBlockInAsync(() => ProcessRecordAsync(CancellationToken));
+
+ ///
+ /// Override to implement async record processing.
///
protected abstract Task ProcessRecordAsync(CancellationToken cancellationToken);
///
- /// Processes a single record synchronously by calling the async ProcessRecordAsync method.
+ /// Override to perform async cleanup. Default implementation returns a completed task.
///
- protected override void ProcessRecord()
+ protected override void EndProcessing() => RunBlockInAsync(EndProcessingAsync);
+
+ ///
+ /// Override to perform async cleanup.
+ ///
+ protected virtual Task EndProcessingAsync() => Task.CompletedTask;
+
+ ///
+ /// Called when the cmdlet is stopping. Cancels the cancellation token.
+ ///
+ protected override void StopProcessing()
{
+ _cancelSource.Cancel();
+ base.StopProcessing();
+ }
+
+ private void RunBlockInAsync(Func task)
+ {
+ using var outPipe = new BlockingCollection<(object?, PipelineType)>();
+ using var replyPipe = new BlockingCollection();
+ 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)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));
}
}
///
- /// Called when the cmdlet is stopping.
+ /// Writes an object to the pipeline. Safe to call from async code; marshalled to the pipeline thread.
///
- protected override void StopProcessing()
+ public new void WriteObject(object? sendToPipeline) => WriteObject(sendToPipeline, false);
+
+ ///
+ /// Writes an object to the pipeline. Safe to call from async code; marshalled to the pipeline thread.
+ ///
+ public new void WriteObject(object? sendToPipeline, bool enumerateCollection)
{
- _cancellationTokenSource?.Cancel();
- base.StopProcessing();
+ ThrowIfStopped();
+ _currentOutPipe?.Add((sendToPipeline, enumerateCollection ? PipelineType.OutputEnumerate : PipelineType.Output));
+ }
+
+ ///
+ /// Writes an error record. Safe to call from async code; marshalled to the pipeline thread.
+ ///
+ public new void WriteError(ErrorRecord errorRecord)
+ {
+ ThrowIfStopped();
+ _currentOutPipe?.Add((errorRecord, PipelineType.Error));
+ }
+
+ ///
+ /// Writes a warning. Safe to call from async code; marshalled to the pipeline thread.
+ ///
+ public new void WriteWarning(string message)
+ {
+ ThrowIfStopped();
+ _currentOutPipe?.Add((message, PipelineType.Warning));
+ }
+
+ ///
+ /// Writes verbose output. Safe to call from async code; marshalled to the pipeline thread.
+ ///
+ public new void WriteVerbose(string message)
+ {
+ ThrowIfStopped();
+ _currentOutPipe?.Add((message, PipelineType.Verbose));
+ }
+
+ ///
+ /// Writes debug output. Safe to call from async code; marshalled to the pipeline thread.
+ ///
+ public new void WriteDebug(string message)
+ {
+ ThrowIfStopped();
+ _currentOutPipe?.Add((message, PipelineType.Debug));
+ }
+
+ ///
+ /// Writes an information record. Safe to call from async code; marshalled to the pipeline thread.
+ ///
+ public new void WriteInformation(InformationRecord informationRecord)
+ {
+ ThrowIfStopped();
+ _currentOutPipe?.Add((informationRecord, PipelineType.Information));
+ }
+
+ ///
+ /// Writes a progress record. Safe to call from async code; marshalled to the pipeline thread.
+ ///
+ public new void WriteProgress(ProgressRecord progressRecord)
+ {
+ ThrowIfStopped();
+ _currentOutPipe?.Add((progressRecord, PipelineType.Progress));
+ }
+
+ ///
+ /// Confirms an operation with the user. Safe to call from async code; blocks until the pipeline thread returns the result.
+ ///
+ 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();
+ }
+
+ ///
+ /// Disposes the cancellation source.
+ ///
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ _cancelSource.Dispose();
+ }
+
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
}
}
diff --git a/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamAppInfo.cs b/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamAppInfo.cs
new file mode 100644
index 00000000..1a44c953
--- /dev/null
+++ b/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamAppInfo.cs
@@ -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));
+ }
+ }
+}
diff --git a/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamManual.cs b/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamManual.cs
deleted file mode 100644
index 641f86e8..00000000
--- a/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamManual.cs
+++ /dev/null
@@ -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));
- }
- }
-}
diff --git a/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamManualUri.cs b/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamManualUri.cs
deleted file mode 100644
index 105c1afe..00000000
--- a/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamManualUri.cs
+++ /dev/null
@@ -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));
- }
- }
-}
diff --git a/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamWebAssetUri.cs b/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamWebAssetUri.cs
index 115e33e9..e0def476 100644
--- a/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamWebAssetUri.cs
+++ b/LANCommander.SDK/PowerShell/Cmdlets/Get-SteamWebAssetUri.cs
@@ -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)
diff --git a/LANCommander.SDK/PowerShell/Cmdlets/Search-SteamGames.cs b/LANCommander.SDK/PowerShell/Cmdlets/Search-SteamGames.cs
index 726d7d92..98c98759 100644
--- a/LANCommander.SDK/PowerShell/Cmdlets/Search-SteamGames.cs
+++ b/LANCommander.SDK/PowerShell/Cmdlets/Search-SteamGames.cs
@@ -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
{
diff --git a/LANCommander.SDK/PowerShell/Cmdlets/Test-SteamManual.cs b/LANCommander.SDK/PowerShell/Cmdlets/Test-SteamManual.cs
deleted file mode 100644
index d944d5ab..00000000
--- a/LANCommander.SDK/PowerShell/Cmdlets/Test-SteamManual.cs
+++ /dev/null
@@ -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));
- }
- }
-}
diff --git a/LANCommander.SDK/PowerShell/Cmdlets/Test-SteamWebAsset.cs b/LANCommander.SDK/PowerShell/Cmdlets/Test-SteamWebAsset.cs
deleted file mode 100644
index bfacfb15..00000000
--- a/LANCommander.SDK/PowerShell/Cmdlets/Test-SteamWebAsset.cs
+++ /dev/null
@@ -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));
- }
- }
-}
diff --git a/LANCommander.SDK/PowerShell/Extensions/InitialSessionStateExtensions.cs b/LANCommander.SDK/PowerShell/Extensions/InitialSessionStateExtensions.cs
index c17744be..0a52616b 100644
--- a/LANCommander.SDK/PowerShell/Extensions/InitialSessionStateExtensions.cs
+++ b/LANCommander.SDK/PowerShell/Extensions/InitialSessionStateExtensions.cs
@@ -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));
}
}
\ No newline at end of file
diff --git a/LANCommander.SDK/PowerShell/SteamServicesProvider.cs b/LANCommander.SDK/PowerShell/SteamServicesProvider.cs
index a0f79fed..f1cf3589 100644
--- a/LANCommander.SDK/PowerShell/SteamServicesProvider.cs
+++ b/LANCommander.SDK/PowerShell/SteamServicesProvider.cs
@@ -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
///
/// Gets or creates the Steam Store service for the current session.
///
- 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;
}
+
+
}
diff --git a/LANCommander.Steam/Abstractions/ISteamCmdService.cs b/LANCommander.Steam/Abstractions/ISteamCmdService.cs
index 8d61f18a..4f83d954 100644
--- a/LANCommander.Steam/Abstractions/ISteamCmdService.cs
+++ b/LANCommander.Steam/Abstractions/ISteamCmdService.cs
@@ -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
///
string? ExecutablePath { get; set; }
+ ///
+ /// 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.
+ ///
+ Task GetAppInfoAsync(uint appId, System.Threading.CancellationToken cancellationToken = default);
+
///
/// Event fired when an install job status changes (started, completed, failed)
///
diff --git a/LANCommander.Steam/Abstractions/ISteamWebApiService.cs b/LANCommander.Steam/Abstractions/ISteamWebApiService.cs
new file mode 100644
index 00000000..6ecccda0
--- /dev/null
+++ b/LANCommander.Steam/Abstractions/ISteamWebApiService.cs
@@ -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 GetAppInfo(uint appId);
+ public Task> SearchGamesAsync(string keyword);
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Extensions/ServiceCollectionExtensions.cs b/LANCommander.Steam/Extensions/ServiceCollectionExtensions.cs
index 75ec3ca5..4fb01467 100644
--- a/LANCommander.Steam/Extensions/ServiceCollectionExtensions.cs
+++ b/LANCommander.Steam/Extensions/ServiceCollectionExtensions.cs
@@ -34,6 +34,7 @@ public static class ServiceCollectionExtensions
sp.GetService>()?.Value,
sp.GetService(),
sp.GetService>()));
+ services.AddSingleton();
return services;
}
@@ -60,6 +61,7 @@ public static class ServiceCollectionExtensions
sp.GetService>()?.Value,
sp.GetService(),
sp.GetService>()));
+ services.AddSingleton();
return services;
}
@@ -86,6 +88,7 @@ public static class ServiceCollectionExtensions
sp.GetService>()?.Value,
sp.GetService(),
sp.GetService>()));
+ services.AddSingleton();
return services;
}
diff --git a/LANCommander.Steam/GameSearchResult.cs b/LANCommander.Steam/GameSearchResult.cs
index 189844e3..1ddae00c 100644
--- a/LANCommander.Steam/GameSearchResult.cs
+++ b/LANCommander.Steam/GameSearchResult.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/LANCommander.Steam.csproj b/LANCommander.Steam/LANCommander.Steam.csproj
index 83631051..c5f0c4a4 100644
--- a/LANCommander.Steam/LANCommander.Steam.csproj
+++ b/LANCommander.Steam/LANCommander.Steam.csproj
@@ -1,4 +1,4 @@
-
+
net9.0
diff --git a/LANCommander.Steam/Models/SteamCmdAppInfo.cs b/LANCommander.Steam/Models/SteamCmdAppInfo.cs
new file mode 100644
index 00000000..2ebbb3ed
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdAppInfo.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace LANCommander.Steam.Models;
+
+///
+/// App info from SteamCMD (app_info_print): changenumber (buildid) and last updated time for the public branch.
+///
+public class SteamCmdAppInfo
+{
+ ///
+ /// Steam application ID.
+ ///
+ public uint AppId { get; set; }
+
+ ///
+ /// Build ID / changenumber for the public branch.
+ ///
+ public string? Changenumber { get; set; }
+
+ ///
+ /// When the public branch was last updated (Unix timestamp from SteamCMD).
+ ///
+ public DateTimeOffset? TimeUpdated { get; set; }
+}
diff --git a/LANCommander.Steam/Models/SteamCmdNet/AppAssociation.cs b/LANCommander.Steam/Models/SteamCmdNet/AppAssociation.cs
new file mode 100644
index 00000000..cde10b15
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/AppAssociation.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/AppCommon.cs b/LANCommander.Steam/Models/SteamCmdNet/AppCommon.cs
new file mode 100644
index 00000000..392415c6
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/AppCommon.cs
@@ -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? Associations { get; set; }
+
+ [JsonPropertyName("category")]
+ public Dictionary? 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? ContentDescriptors { get; set; }
+
+ [JsonPropertyName("content_descriptors_including_dlc")]
+ public Dictionary? 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? Genres { get; set; }
+
+ [JsonPropertyName("header_image")]
+ public LocalizedImageMap? HeaderImageMap { get; set; }
+
+ [JsonPropertyName("icon")]
+ public string? Icon { get; set; }
+
+ [JsonPropertyName("languages")]
+ public Dictionary? 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? 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? StoreTags { get; set; }
+
+ [JsonPropertyName("supported_languages")]
+ public Dictionary? 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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/AppConfig.cs b/LANCommander.Steam/Models/SteamCmdNet/AppConfig.cs
new file mode 100644
index 00000000..d175b0f9
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/AppConfig.cs
@@ -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? 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? 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>? SignaturesCheckedOnLaunch { get; set; }
+
+ [JsonPropertyName("signedfiles")]
+ public Dictionary? SignedFiles { get; set; }
+
+ [JsonPropertyName("steam_china_only")]
+ public Dictionary? 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? Depots { get; set; }
+
+ [JsonPropertyName("appmanagesdlc")]
+ public string? AppManagesDlc { get; set; }
+
+ [JsonPropertyName("baselanguages")]
+ public string? BaseLanguages { get; set; }
+
+ [JsonPropertyName("branches")]
+ public Dictionary? 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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/AppExtended.cs b/LANCommander.Steam/Models/SteamCmdNet/AppExtended.cs
new file mode 100644
index 00000000..b41a5606
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/AppExtended.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/AppInfo.cs b/LANCommander.Steam/Models/SteamCmdNet/AppInfo.cs
new file mode 100644
index 00000000..157f63d8
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/AppInfo.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/AppInfoResponse.cs b/LANCommander.Steam/Models/SteamCmdNet/AppInfoResponse.cs
new file mode 100644
index 00000000..1ee5bc3d
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/AppInfoResponse.cs
@@ -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? Data { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/AppInstall.cs b/LANCommander.Steam/Models/SteamCmdNet/AppInstall.cs
new file mode 100644
index 00000000..7ac982e4
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/AppInstall.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/AppMapping.cs b/LANCommander.Steam/Models/SteamCmdNet/AppMapping.cs
new file mode 100644
index 00000000..65c63791
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/AppMapping.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/AppUfs.cs b/LANCommander.Steam/Models/SteamCmdNet/AppUfs.cs
new file mode 100644
index 00000000..f11d8008
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/AppUfs.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/Branch.cs b/LANCommander.Steam/Models/SteamCmdNet/Branch.cs
new file mode 100644
index 00000000..92841649
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/Branch.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/DeckTestResult.cs b/LANCommander.Steam/Models/SteamCmdNet/DeckTestResult.cs
new file mode 100644
index 00000000..b671fa47
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/DeckTestResult.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/Depot.cs b/LANCommander.Steam/Models/SteamCmdNet/Depot.cs
new file mode 100644
index 00000000..dd01de8a
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/Depot.cs
@@ -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? Manifests { get; set; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/DepotConfig.cs b/LANCommander.Steam/Models/SteamCmdNet/DepotConfig.cs
new file mode 100644
index 00000000..fd331eda
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/DepotConfig.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/ExternalArguments.cs b/LANCommander.Steam/Models/SteamCmdNet/ExternalArguments.cs
new file mode 100644
index 00000000..fe7f5d38
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/ExternalArguments.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/LaunchConfig.cs b/LANCommander.Steam/Models/SteamCmdNet/LaunchConfig.cs
new file mode 100644
index 00000000..52ea5441
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/LaunchConfig.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/LaunchEntry.cs b/LANCommander.Steam/Models/SteamCmdNet/LaunchEntry.cs
new file mode 100644
index 00000000..82cb0f18
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/LaunchEntry.cs
@@ -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? DescriptionLoc { get; set; }
+
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/LibraryAssetVariant.cs b/LANCommander.Steam/Models/SteamCmdNet/LibraryAssetVariant.cs
new file mode 100644
index 00000000..39e8cf10
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/LibraryAssetVariant.cs
@@ -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? Image { get; set; }
+
+ [JsonPropertyName("image2x")]
+ public Dictionary? ImageLarge { get; set; }
+
+ [JsonPropertyName("logo_position")]
+ public LogoPosition? Position { get; set; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/LibraryAssets.cs b/LANCommander.Steam/Models/SteamCmdNet/LibraryAssets.cs
new file mode 100644
index 00000000..5ddef837
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/LibraryAssets.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/LibraryAssetsFull.cs b/LANCommander.Steam/Models/SteamCmdNet/LibraryAssetsFull.cs
new file mode 100644
index 00000000..d9b1009a
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/LibraryAssetsFull.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/LocalizedImageMap.cs b/LANCommander.Steam/Models/SteamCmdNet/LocalizedImageMap.cs
new file mode 100644
index 00000000..51665aed
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/LocalizedImageMap.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace LANCommander.Steam.Models.SteamCmdNet;
+
+public class LocalizedImageMap
+{
+ [JsonExtensionData]
+ public Dictionary? Values { get; set; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/LogoPosition.cs b/LANCommander.Steam/Models/SteamCmdNet/LogoPosition.cs
new file mode 100644
index 00000000..0e3419dc
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/LogoPosition.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/Manifest.cs b/LANCommander.Steam/Models/SteamCmdNet/Manifest.cs
new file mode 100644
index 00000000..d4ae6864
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/Manifest.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/RegistryRoot.cs b/LANCommander.Steam/Models/SteamCmdNet/RegistryRoot.cs
new file mode 100644
index 00000000..efdcb698
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/RegistryRoot.cs
@@ -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? Keys { get; set; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/SteamDeckCompatibility.cs b/LANCommander.Steam/Models/SteamCmdNet/SteamDeckCompatibility.cs
new file mode 100644
index 00000000..47a2d269
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/SteamDeckCompatibility.cs
@@ -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? Configuration { get; set; }
+
+ [JsonPropertyName("steamos_compatibility")]
+ public string? SteamOsCompatibility { get; set; }
+
+ [JsonPropertyName("steamos_tests")]
+ public Dictionary? SteamOsTests { get; set; }
+
+ [JsonPropertyName("test_timestamp")]
+ public string? TestTimestamp { get; set; }
+
+ [JsonPropertyName("tested_build_id")]
+ public string? TestedBuildId { get; set; }
+
+ [JsonPropertyName("tests")]
+ public Dictionary? Tests { get; set; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Models/SteamCmdNet/SupportedLanguage.cs b/LANCommander.Steam/Models/SteamCmdNet/SupportedLanguage.cs
new file mode 100644
index 00000000..67e55a65
--- /dev/null
+++ b/LANCommander.Steam/Models/SteamCmdNet/SupportedLanguage.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/LANCommander.Steam/Services/SteamCmdService.cs b/LANCommander.Steam/Services/SteamCmdService.cs
index 6f791e80..e14ca99d 100644
--- a/LANCommander.Steam/Services/SteamCmdService.cs
+++ b/LANCommander.Steam/Services/SteamCmdService.cs
@@ -473,7 +473,66 @@ public class SteamCmdService(
await profileStore.DeleteAsync(username);
_logger?.LogInformation("Deleted profile for username: {Username}", username);
}
-
+
+ ///
+ public async Task 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 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(
}
}
+ ///
+ /// 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.
+ ///
+ private static async Task ReadStreamByLineAsync(
+ Stream stream,
+ Encoding encoding,
+ Action 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:
diff --git a/LANCommander.Steam/Services/SteamStoreService.cs b/LANCommander.Steam/Services/SteamWebApiService.cs
similarity index 51%
rename from LANCommander.Steam/Services/SteamStoreService.cs
rename to LANCommander.Steam/Services/SteamWebApiService.cs
index 5d9e0118..32822e1b 100644
--- a/LANCommander.Steam/Services/SteamStoreService.cs
+++ b/LANCommander.Steam/Services/SteamWebApiService.cs
@@ -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 GetAppInfo(uint appId)
{
- HttpClient = new HttpClient();
- HttpClient.BaseAddress = new Uri("https://store.steampowered.com");
+ var response = await httpClient.GetFromJsonAsync($"https://api.steamcmd.net/v1/info/{appId}");
+
+ if (response.Data?.ContainsKey(appId) ?? false)
+ return response.Data[appId];
+
+ return null;
}
public async Task> 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();
+ List 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 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 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 webAssetTypeMap = new Dictionary()
diff --git a/LANCommander.Steam/SteamClient.cs b/LANCommander.Steam/SteamClient.cs
index 9a85a046..64b27d55 100644
--- a/LANCommander.Steam/SteamClient.cs
+++ b/LANCommander.Steam/SteamClient.cs
@@ -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> 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();
+ var matches = dom.DocumentNode.SelectNodes("//a[@data-ds-appid]");
+
+ if (matches == null || matches.Count == 0)
+ return Enumerable.Empty();
+
+ foreach (var match in matches)
{
- HttpClient = new HttpClient();
- HttpClient.BaseAddress = new Uri("https://store.steampowered.com");
- }
-
- public async Task> 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();
- var matches = dom.DocumentNode.SelectNodes("//a[@data-ds-appid]");
-
- if (matches == null || matches.Count == 0)
- return Enumerable.Empty();
-
- 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 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 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 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 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 webAssetTypeMap = new Dictionary()
- {
- { 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 webAssetTypeMap = new Dictionary()
+ {
+ { 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]}");
+ }
}
diff --git a/LANCommander.Steam/WebAssetType.cs b/LANCommander.Steam/WebAssetType.cs
index 8fdf7839..f8d70b53 100644
--- a/LANCommander.Steam/WebAssetType.cs
+++ b/LANCommander.Steam/WebAssetType.cs
@@ -1,8 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-
-namespace LANCommander.Steam
+namespace LANCommander.Steam
{
public enum WebAssetType
{