diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index 2ec42d8c..00000000
--- a/.gitmodules
+++ /dev/null
@@ -1,3 +0,0 @@
-[submodule "BytexDigital.Steam"]
- path = BytexDigital.Steam
- url = https://github.com/liamcannon/BytexDigital.Steam.git
diff --git a/BytexDigital.Steam b/BytexDigital.Steam
deleted file mode 160000
index f7189ce7..00000000
--- a/BytexDigital.Steam
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit f7189ce7c2dc4dff0b7c0dd6181f9057c43c859f
diff --git a/FASTER.sln b/FASTER.sln
index c858ee9b..dd2c023d 100644
--- a/FASTER.sln
+++ b/FASTER.sln
@@ -23,8 +23,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FASTERTests", "FASTERTests\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FASTER Maintenance", "FASTER Maintenance\FASTER Maintenance.csproj", "{465FB100-A08C-4E4F-A321-EC85C5C177B3}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BytexDigital.Steam", "BytexDigital.Steam\BytexDigital.Steam\BytexDigital.Steam.csproj", "{22F5B346-B3B2-302D-9D68-35A2DFCE2081}"
-EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -41,10 +39,6 @@ Global
{65FDF864-BF9B-414A-A6E6-3473BCFB62BE}.Release|Any CPU.Build.0 = Release|Any CPU
{465FB100-A08C-4E4F-A321-EC85C5C177B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{465FB100-A08C-4E4F-A321-EC85C5C177B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {22F5B346-B3B2-302D-9D68-35A2DFCE2081}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {22F5B346-B3B2-302D-9D68-35A2DFCE2081}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {22F5B346-B3B2-302D-9D68-35A2DFCE2081}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {22F5B346-B3B2-302D-9D68-35A2DFCE2081}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/FASTER/App.config b/FASTER/App.config
index 2a7ac144..fdaf16e3 100644
--- a/FASTER/App.config
+++ b/FASTER/App.config
@@ -38,7 +38,7 @@
True
- Stable
+ public
False
@@ -55,9 +55,6 @@
Segoe UI
-
- 10
-
False
diff --git a/FASTER/FASTER - Backup.csproj b/FASTER/FASTER - Backup.csproj
index d82765ad..45742f16 100644
--- a/FASTER/FASTER - Backup.csproj
+++ b/FASTER/FASTER - Backup.csproj
@@ -50,7 +50,6 @@
-
@@ -83,4 +82,4 @@
Settings.Designer.cs
-
\ No newline at end of file
+
diff --git a/FASTER/FASTER.csproj b/FASTER/FASTER.csproj
index 8f1e5136..c1d23d4e 100644
--- a/FASTER/FASTER.csproj
+++ b/FASTER/FASTER.csproj
@@ -72,10 +72,8 @@
+
-
-
-
diff --git a/FASTER/MainWindow.xaml.cs b/FASTER/MainWindow.xaml.cs
index 0b24bde7..d2f44529 100644
--- a/FASTER/MainWindow.xaml.cs
+++ b/FASTER/MainWindow.xaml.cs
@@ -30,6 +30,7 @@ namespace FASTER
public partial class MainWindow
{
internal bool ConvertMods { get; set; }
+ internal string LegacySteamCmdPathForConversion { get; set; } = string.Empty;
internal string Version;
internal bool NavEnabled = true;
@@ -160,8 +161,7 @@ private async void MetroWindow_Loaded(object sender, RoutedEventArgs e)
private void MetroWindow_Closing(object sender, CancelEventArgs e)
{
Properties.Settings.Default.Save();
- SteamUpdaterViewModel.Instance.SteamClient?.Shutdown();
- SteamUpdaterViewModel.Instance.SteamClient?.Dispose();
+ SteamUpdaterViewModel.Instance.Dispose();
Application.Current.Shutdown();
}
@@ -451,13 +451,16 @@ private async Task ModConversion()
{
var properties = Properties.Settings.Default;
var modStagingDir = properties.modStagingDirectory;
+ string legacySteamCmdRoot = string.IsNullOrWhiteSpace(LegacySteamCmdPathForConversion)
+ ? properties.steamCMDPath
+ : LegacySteamCmdPathForConversion;
var controller = await this.ShowProgressAsync("Please wait...", "Checking Drive Space...");
controller.Maximum = properties.steamMods.SteamMods.Count;
var progress = 0;
long fullzize = 0;
- foreach (var mod in properties.steamMods.SteamMods.Select(m => Path.Combine(Properties.Settings.Default.steamCMDPath, "steamapps", "workshop", "content", "107410", m.WorkshopId.ToString())).Concat(properties.localMods.Select(m => m.Path)))
+ foreach (var mod in properties.steamMods.SteamMods.Select(m => Path.Combine(legacySteamCmdRoot, "steamapps", "workshop", "content", "107410", m.WorkshopId.ToString())).Concat(properties.localMods.Select(m => m.Path)))
{
if(!Directory.Exists(mod))
continue;
@@ -494,7 +497,7 @@ private async Task ModConversion()
foreach (var steamMod in properties.steamMods.SteamMods)
{
var newPath = Path.Combine(modStagingDir, steamMod.WorkshopId.ToString());
- var oldPath = Path.Combine(Properties.Settings.Default.steamCMDPath, "steamapps", "workshop", "content", "107410", steamMod.WorkshopId.ToString());
+ var oldPath = Path.Combine(legacySteamCmdRoot, "steamapps", "workshop", "content", "107410", steamMod.WorkshopId.ToString());
if (!Directory.Exists(newPath))
Directory.CreateDirectory(newPath);
diff --git a/FASTER/Models/Functions.cs b/FASTER/Models/Functions.cs
index b2b51613..87a685c6 100644
--- a/FASTER/Models/Functions.cs
+++ b/FASTER/Models/Functions.cs
@@ -15,8 +15,17 @@ public static void CheckSettings()
if (!Directory.Exists(Properties.Settings.Default.serverPath))
Properties.Settings.Default.serverPath = string.Empty;
- if (!Directory.Exists(Properties.Settings.Default.steamCMDPath))
- Properties.Settings.Default.steamCMDPath = string.Empty;
+ if (!string.IsNullOrWhiteSpace(Properties.Settings.Default.steamCMDPath))
+ {
+ try
+ {
+ Properties.Settings.Default.steamCMDPath = Path.GetFullPath(Properties.Settings.Default.steamCMDPath);
+ }
+ catch (Exception)
+ {
+ Properties.Settings.Default.steamCMDPath = string.Empty;
+ }
+ }
}
public static string ParseFileSize(long size)
diff --git a/FASTER/Models/SteamUpdaterModel.cs b/FASTER/Models/SteamUpdaterModel.cs
index 0077dd3c..0aabde16 100644
--- a/FASTER/Models/SteamUpdaterModel.cs
+++ b/FASTER/Models/SteamUpdaterModel.cs
@@ -1,5 +1,7 @@
using FASTER.Properties;
+using System;
using System.ComponentModel;
+using System.IO;
namespace FASTER.Models
{
@@ -32,17 +34,36 @@ public string Username
}
}
- public string Password
+ public string SteamCmdDirectory
{
- get => Settings.Default.steamPassword;
+ get
+ {
+ if (string.IsNullOrWhiteSpace(Settings.Default.steamCMDPath))
+ {
+ Settings.Default.steamCMDPath = GetDefaultSteamCmdDirectory();
+ Settings.Default.Save();
+ }
+
+ return Settings.Default.steamCMDPath;
+ }
set
{
- Settings.Default.steamPassword = value;
+ Settings.Default.steamCMDPath = string.IsNullOrWhiteSpace(value)
+ ? GetDefaultSteamCmdDirectory()
+ : value.Trim();
Settings.Default.Save();
- RaisePropertyChanged(nameof(Password));
+ RaisePropertyChanged(nameof(SteamCmdDirectory));
}
}
+ private static string GetDefaultSteamCmdDirectory()
+ {
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "FASTER",
+ "SteamCMD");
+ }
+
public string ModStagingDirectory
{
get => Settings.Default.modStagingDirectory;
@@ -84,102 +105,40 @@ public double Progress
}
}
- public bool UsingPerfBinaries
- {
- get => Settings.Default.usingPerfBinaries;
- set
- {
- Settings.Default.usingPerfBinaries = value;
- Settings.Default.Save();
- RaisePropertyChanged(nameof(UsingPerfBinaries));
- }
- }
-
- public bool UsingContactDlc
- {
- get => Settings.Default.usingContactDlc;
- set
- {
- Settings.Default.usingContactDlc = value;
- Settings.Default.Save();
- RaisePropertyChanged(nameof(UsingContactDlc));
- }
- }
-
- public bool UsingGMDlc
+ public string ServerBranch
{
- get => Settings.Default.usingGMDlc;
- set
+ get
{
- Settings.Default.usingGMDlc = value;
- Settings.Default.Save();
- RaisePropertyChanged(nameof(UsingGMDlc));
- }
- }
+ string? branch = Settings.Default.serverBranch?.Trim().ToLowerInvariant();
+ if (branch is not ("public" or "contact" or "creatordlc" or "profiling"))
+ {
+ // Migrate the old independent depot toggles to SteamCMD's
+ // mutually-exclusive whole-app branches. Profiling takes
+ // precedence, followed by Contact and Creator DLC.
+ branch = Settings.Default.usingPerfBinaries
+ ? "profiling"
+ : Settings.Default.usingContactDlc
+ ? "contact"
+ : Settings.Default.usingGMDlc || Settings.Default.usingPFDlc ||
+ Settings.Default.usingCSLADlc || Settings.Default.usingWSDlc ||
+ Settings.Default.usingSPEDlc || Settings.Default.usingRFDlc ||
+ Settings.Default.usingEFDlc
+ ? "creatordlc"
+ : "public";
+ Settings.Default.serverBranch = branch;
+ Settings.Default.Save();
+ }
- public bool UsingPFDlc
- {
- get => Settings.Default.usingPFDlc;
- set
- {
- Settings.Default.usingPFDlc = value;
- Settings.Default.Save();
- RaisePropertyChanged(nameof(UsingPFDlc));
+ return branch;
}
- }
-
- public bool UsingCSLADlc
- {
- get => Settings.Default.usingCSLADlc;
- set
- {
- Settings.Default.usingCSLADlc = value;
- Settings.Default.Save();
- RaisePropertyChanged(nameof(UsingCSLADlc));
- }
- }
-
- public bool UsingWSDlc
- {
- get => Settings.Default.usingWSDlc;
- set
- {
- Settings.Default.usingWSDlc = value;
- Settings.Default.Save();
- RaisePropertyChanged(nameof(UsingWSDlc));
- }
- }
-
- public bool UsingSPEDlc
- {
- get => Settings.Default.usingSPEDlc;
- set
- {
- Settings.Default.usingSPEDlc = value;
- Settings.Default.Save();
- RaisePropertyChanged(nameof(UsingSPEDlc));
- }
- }
-
- public bool UsingRFDlc
- {
- get => Settings.Default.usingRFDlc;
- set
- {
- Settings.Default.usingRFDlc = value;
- Settings.Default.Save();
- RaisePropertyChanged(nameof(UsingRFDlc));
- }
- }
-
- public bool UsingEFDlc
- {
- get => Settings.Default.usingEFDlc;
set
{
- Settings.Default.usingEFDlc = value;
+ string? branch = value?.Trim().ToLowerInvariant();
+ Settings.Default.serverBranch = branch is "public" or "contact" or "creatordlc" or "profiling"
+ ? branch
+ : "public";
Settings.Default.Save();
- RaisePropertyChanged(nameof(UsingEFDlc));
+ RaisePropertyChanged(nameof(ServerBranch));
}
}
diff --git a/FASTER/Models/SteamWebApi.cs b/FASTER/Models/SteamWebApi.cs
index 66b823fd..e59921a1 100644
--- a/FASTER/Models/SteamWebApi.cs
+++ b/FASTER/Models/SteamWebApi.cs
@@ -1,16 +1,9 @@
-using BytexDigital.Steam.Core;
-
-using MahApps.Metro.Controls.Dialogs;
-
using Newtonsoft.Json.Linq;
using System;
using System.Diagnostics;
-using System.IO;
using System.Net;
using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
using System.Windows;
namespace FASTER.Models
@@ -99,118 +92,6 @@ private static string GetApiKey()
}
}
- internal class AuthCodeProvider : SteamAuthenticator
- {
-
- private readonly string _persistenceDirectory;
- private readonly string _uniqueStorageName;
- public string AccessToken { get; protected set; }
- public string GuardData { get; protected set; }
-
-
- public AuthCodeProvider(string uniqueStorageName, string persistenceDirectory)
- {
- _uniqueStorageName = uniqueStorageName;
- _persistenceDirectory = persistenceDirectory;
- }
-
- public override async Task GetEmailAuthenticationCodeAsync(string accountEmail, bool previousCodeWasIncorrect, CancellationToken cancellationToken = default)
- {
- if (previousCodeWasIncorrect)
- MainWindow.Instance.SteamUpdaterViewModel.Parameters.Output += "\nPreviously entered email code was incorrect!";
-
-
- MainWindow.Instance.SteamUpdaterViewModel.Parameters.Output += "\nPlease enter your 2FA code: ";
-
- var input = await MainWindow.Instance.SteamUpdaterViewModel.SteamGuardInput();
-
- MainWindow.Instance.SteamUpdaterViewModel.Parameters.Output += "\nRetrying... ";
-
- return input;
- }
-
- public override async Task GetTwoFactorAuthenticationCodeAsync(bool previousCodeWasIncorrect, CancellationToken cancellationToken = default)
- {
- if (previousCodeWasIncorrect)
- MainWindow.Instance.SteamUpdaterViewModel.Parameters.Output += "\nPreviously entered 2FA code was incorrect!";
-
-
- MainWindow.Instance.SteamUpdaterViewModel.Parameters.Output += "\nPlease enter your 2FA code: ";
-
- var input = await MainWindow.Instance.SteamUpdaterViewModel.SteamGuardInput();
-
- MainWindow.Instance.SteamUpdaterViewModel.Parameters.Output += "\nRetrying... ";
-
- return input;
- }
-
- public override async Task NotifyMobileNotificationAsync(CancellationToken cancellationToken = default)
- {
- MainWindow.Instance.SteamUpdaterViewModel.Parameters.Output += "\nMobile notification sent. Answer \"OK\" once you've authorized this login. If no notification was received or you'd like to enter a traditional 2FA code, press \"Cancel\": ";
-
- MessageDialogResult response;
-
- do
- {
- response = await MainWindow.Instance.SteamUpdaterViewModel.SteamGuardInputPhone();
- } while (response != MessageDialogResult.Affirmative && response != MessageDialogResult.Negative);
-
- MainWindow.Instance.SteamUpdaterViewModel.Parameters.Output += "\n\tAuth : Authorizing...";
-
- return response == MessageDialogResult.Affirmative;
- }
-
- public override Task PersistAccessTokenAsync(string token, CancellationToken cancellationToken = default)
- {
- AccessToken = token;
-
- if (string.IsNullOrEmpty(_persistenceDirectory)) return Task.CompletedTask;
-
- Directory.CreateDirectory(_persistenceDirectory);
- File.WriteAllText(Path.Combine(_persistenceDirectory, $"{_uniqueStorageName}_accesstoken"), AccessToken);
-
- return Task.CompletedTask;
- }
-
-
- public override Task GetAccessTokenAsync(CancellationToken cancellationToken = default)
- {
- if (string.IsNullOrEmpty(_persistenceDirectory))
- {
- return Task.FromResult(AccessToken);
- }
-
- var path = Path.Combine(_persistenceDirectory, $"{_uniqueStorageName}_accesstoken");
-
- return Task.FromResult(File.Exists(path) ? File.ReadAllText(path) : AccessToken);
- }
-
- public override Task PersistGuardDataAsync(string data, CancellationToken cancellationToken = default)
- {
- GuardData = data;
-
- if (string.IsNullOrEmpty(_persistenceDirectory)) return Task.CompletedTask;
-
- Directory.CreateDirectory(_persistenceDirectory);
- File.WriteAllText(Path.Combine(_persistenceDirectory, $"{_uniqueStorageName}_guarddata"), GuardData);
-
- return Task.CompletedTask;
- }
-
- public override Task GetGuardDataAsync(CancellationToken cancellationToken = default)
- {
- if (string.IsNullOrEmpty(_persistenceDirectory))
- {
- return Task.FromResult(GuardData);
- }
-
- var path = Path.Combine(_persistenceDirectory, $"{_uniqueStorageName}_guarddata");
-
- return Task.FromResult(File.Exists(path) ? File.ReadAllText(path) : GuardData);
- }
- }
-
-
internal class SteamApiFileDetails
{
public uint result { get; set; }
diff --git a/FASTER/Properties/Settings.Designer.cs b/FASTER/Properties/Settings.Designer.cs
index 2d01c64c..737deb4d 100644
--- a/FASTER/Properties/Settings.Designer.cs
+++ b/FASTER/Properties/Settings.Designer.cs
@@ -133,7 +133,7 @@ public bool setupRun {
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
- [global::System.Configuration.DefaultSettingValueAttribute("Stable")]
+ [global::System.Configuration.DefaultSettingValueAttribute("public")]
public string serverBranch {
get {
return ((string)(this["serverBranch"]));
@@ -269,18 +269,6 @@ public string font {
}
}
- [global::System.Configuration.UserScopedSettingAttribute()]
- [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
- [global::System.Configuration.DefaultSettingValueAttribute("10")]
- public ushort CliWorkers {
- get {
- return ((ushort)(this["CliWorkers"]));
- }
- set {
- this["CliWorkers"] = value;
- }
- }
-
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("False")]
diff --git a/FASTER/Properties/Settings.settings b/FASTER/Properties/Settings.settings
index cf927128..f0334235 100644
--- a/FASTER/Properties/Settings.settings
+++ b/FASTER/Properties/Settings.settings
@@ -30,7 +30,7 @@
True
- Stable
+ public
@@ -65,9 +65,6 @@
-
- 10
-
False
diff --git a/FASTER/Services/SteamCmd/SteamCmdClient.cs b/FASTER/Services/SteamCmd/SteamCmdClient.cs
new file mode 100644
index 00000000..cbc652c4
--- /dev/null
+++ b/FASTER/Services/SteamCmd/SteamCmdClient.cs
@@ -0,0 +1,857 @@
+using System.IO;
+
+namespace FASTER.Services.SteamCmd;
+
+public sealed class SteamCmdClient : IDisposable, IAsyncDisposable
+{
+ private const int MaximumWorkshopDownloadAttempts = 3;
+ private static readonly TimeSpan PromptTimeout = TimeSpan.FromMinutes(5);
+ private static readonly TimeSpan DownloadTimeout = TimeSpan.FromMinutes(45);
+
+ private readonly SteamCmdInstaller _installer;
+ private readonly SemaphoreSlim _operationGate = new(1, 1);
+ private readonly object _stateGate = new();
+
+ private SteamCmdSession? _activeSession;
+ private CancellationTokenSource? _activeCancellation;
+ private bool _operationActive;
+ private bool _disposed;
+
+ public SteamCmdClient(string rootDirectory)
+ {
+ RootDirectory = SteamCmdCommandBuilder.ValidateAndNormalizePath(
+ rootDirectory,
+ nameof(rootDirectory));
+ _installer = new SteamCmdInstaller(RootDirectory);
+ }
+
+ public string RootDirectory { get; }
+
+ public bool IsInstalled => _installer.IsInstalled;
+
+ public bool IsRunning
+ {
+ get
+ {
+ lock (_stateGate)
+ return _operationActive || _activeSession is { HasExited: false };
+ }
+ }
+
+ public async Task EnsureInstalledAsync(
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ using CancellationTokenSource operationCancellation =
+ CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ SetActiveCancellation(operationCancellation);
+ SetOperationActive(true);
+ try
+ {
+ await _installer.EnsureInstalledAsync(progress, operationCancellation.Token).ConfigureAwait(false);
+ }
+ finally
+ {
+ ClearActiveState(null, operationCancellation);
+ SetOperationActive(false);
+ _operationGate.Release();
+ }
+ }
+
+ public async Task DownloadWorkshopItemsAsync(
+ string username,
+ string password,
+ IEnumerable workshopIds,
+ Func>? guardCodeProvider = null,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ throw new SteamCmdAuthenticationException(
+ "Workshop downloads require a Steam account that owns Arma 3; an API key is not a Steam login.");
+ }
+
+ ArgumentNullException.ThrowIfNull(workshopIds);
+ List ids = NormalizeWorkshopIds(workshopIds);
+ if (ids.Count == 0)
+ throw new ArgumentException("At least one Workshop ID is required.", nameof(workshopIds));
+
+ await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ using CancellationTokenSource operationCancellation =
+ CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ SetActiveCancellation(operationCancellation);
+ SetOperationActive(true);
+
+ List results = new(ids.Count);
+ SteamCmdSession? session = null;
+ int? exitCode = null;
+ try
+ {
+ await _installer.EnsureInstalledAsync(progress, operationCancellation.Token).ConfigureAwait(false);
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.Starting,
+ "Starting SteamCMD..."));
+
+ session = StartSession(password, progress);
+ SetActiveSession(session);
+ await AuthenticateAsync(
+ session,
+ username,
+ password,
+ guardCodeProvider,
+ progress,
+ operationCancellation.Token).ConfigureAwait(false);
+
+ for (int index = 0; index < ids.Count; index++)
+ {
+ operationCancellation.Token.ThrowIfCancellationRequested();
+ ulong workshopId = ids[index];
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.DownloadingWorkshopItem,
+ $"Downloading Workshop item {workshopId} ({index + 1}/{ids.Count})...",
+ 0,
+ workshopId));
+
+ SteamCmdWorkshopItemResult itemResult = await DownloadWorkshopItemWithRetriesAsync(
+ session,
+ workshopId,
+ progress,
+ operationCancellation.Token).ConfigureAwait(false);
+ results.Add(itemResult);
+ }
+
+ exitCode = await session.QuitAndWaitAsync(operationCancellation.Token).ConfigureAwait(false);
+ string? exitError = exitCode is not 0
+ ? $"SteamCMD exited with code {exitCode}."
+ : null;
+
+ progress?.Report(new SteamCmdProgress(
+ results.All(item => item.Success) && exitError == null
+ ? SteamCmdProgressKind.Completed
+ : SteamCmdProgressKind.Warning,
+ results.All(item => item.Success) && exitError == null
+ ? "SteamCMD completed all Workshop downloads."
+ : "SteamCMD completed with one or more Workshop failures.",
+ 100));
+
+ return new SteamCmdWorkshopBatchResult(results, false, exitCode, exitError);
+ }
+ catch (OperationCanceledException)
+ {
+ await StopSessionSafelyAsync(session).ConfigureAwait(false);
+ AddUnfinishedResults(ids, results, "Cancelled.");
+ return new SteamCmdWorkshopBatchResult(results, true, session?.ExitCode, "Cancelled.");
+ }
+ catch (SteamCmdAuthenticationException)
+ {
+ await StopSessionSafelyAsync(session).ConfigureAwait(false);
+ throw;
+ }
+ catch (Exception exception)
+ {
+ await StopSessionSafelyAsync(session).ConfigureAwait(false);
+ string error = SanitizeExceptionMessage(exception.Message, password);
+ AddUnfinishedResults(ids, results, error);
+ return new SteamCmdWorkshopBatchResult(results, false, session?.ExitCode, error);
+ }
+ finally
+ {
+ await DisposeSessionSafelyAsync(session).ConfigureAwait(false);
+ ClearActiveState(session, operationCancellation);
+ SetOperationActive(false);
+ _operationGate.Release();
+ }
+ }
+
+ public async Task UpdateServerAsync(
+ string username,
+ string password,
+ string installDirectory,
+ SteamCmdServerBranch branch,
+ Func>? guardCodeProvider = null,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ string forceInstallCommand = SteamCmdCommandBuilder.BuildForceInstallDirectoryCommand(installDirectory);
+ string normalizedInstallDirectory = SteamCmdCommandBuilder.ValidateAndNormalizePath(
+ installDirectory,
+ nameof(installDirectory));
+ string updateCommand = SteamCmdCommandBuilder.BuildServerUpdateCommand(branch);
+ Directory.CreateDirectory(normalizedInstallDirectory);
+
+ await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ using CancellationTokenSource operationCancellation =
+ CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ SetActiveCancellation(operationCancellation);
+ SetOperationActive(true);
+
+ SteamCmdSession? session = null;
+ try
+ {
+ await _installer.EnsureInstalledAsync(progress, operationCancellation.Token).ConfigureAwait(false);
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.Starting,
+ "Starting SteamCMD..."));
+
+ session = StartSession(password, progress);
+ SetActiveSession(session);
+
+ // SteamCMD requires force_install_dir before login for reliable app
+ // placement. Sending it afterward is ignored by some app-update flows.
+ await WaitForPromptAsync(session, operationCancellation.Token).ConfigureAwait(false);
+ await session.SendCommandAsync(forceInstallCommand, operationCancellation.Token).ConfigureAwait(false);
+ await WaitForPromptAfterCommandAsync(session, operationCancellation.Token).ConfigureAwait(false);
+
+ await AuthenticateAsync(
+ session,
+ username,
+ password,
+ guardCodeProvider,
+ progress,
+ operationCancellation.Token,
+ waitForInitialPrompt: false).ConfigureAwait(false);
+
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.UpdatingServer,
+ $"Updating Arma 3 Dedicated Server ({branch})...",
+ 0));
+ await session.SendCommandAsync(updateCommand, operationCancellation.Token).ConfigureAwait(false);
+ string? commandError = await WaitForServerUpdateAsync(
+ session,
+ progress,
+ operationCancellation.Token).ConfigureAwait(false);
+
+ int? exitCode = await session.QuitAndWaitAsync(operationCancellation.Token).ConfigureAwait(false);
+ string? error = commandError;
+ if (error == null && exitCode is not 0)
+ error = $"SteamCMD exited with code {exitCode}.";
+
+ bool success = error == null;
+ if (success && !ServerInstallHasContent(normalizedInstallDirectory))
+ {
+ success = false;
+ error = "SteamCMD reported success, but the Arma 3 server executable is missing.";
+ }
+
+ progress?.Report(new SteamCmdProgress(
+ success ? SteamCmdProgressKind.Completed : SteamCmdProgressKind.Error,
+ success ? "Arma 3 Dedicated Server update completed." : error!,
+ success ? 100 : null));
+ return new SteamCmdServerUpdateResult(success, false, exitCode, error);
+ }
+ catch (OperationCanceledException)
+ {
+ await StopSessionSafelyAsync(session).ConfigureAwait(false);
+ return new SteamCmdServerUpdateResult(false, true, session?.ExitCode, "Cancelled.");
+ }
+ catch (SteamCmdAuthenticationException)
+ {
+ await StopSessionSafelyAsync(session).ConfigureAwait(false);
+ throw;
+ }
+ catch (Exception exception)
+ {
+ await StopSessionSafelyAsync(session).ConfigureAwait(false);
+ return new SteamCmdServerUpdateResult(
+ false,
+ false,
+ session?.ExitCode,
+ SanitizeExceptionMessage(exception.Message, password));
+ }
+ finally
+ {
+ await DisposeSessionSafelyAsync(session).ConfigureAwait(false);
+ ClearActiveState(session, operationCancellation);
+ SetOperationActive(false);
+ _operationGate.Release();
+ }
+ }
+
+ public async Task CancelAsync(CancellationToken cancellationToken = default)
+ {
+ CancellationTokenSource? operationCancellation;
+ SteamCmdSession? session;
+ lock (_stateGate)
+ {
+ operationCancellation = _activeCancellation;
+ session = _activeSession;
+ }
+
+ operationCancellation?.Cancel();
+ if (session != null)
+ await session.StopAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ public void Reset()
+ {
+ ThrowIfDisposed();
+ lock (_stateGate)
+ {
+ if (_operationActive || _activeSession is { HasExited: false })
+ throw new InvalidOperationException("SteamCMD cannot be reset while an operation is running.");
+
+ _activeSession = null;
+ _activeCancellation = null;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ CancelAsync(CancellationToken.None).GetAwaiter().GetResult();
+ _disposed = true;
+ GC.SuppressFinalize(this);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ return;
+
+ await CancelAsync(CancellationToken.None).ConfigureAwait(false);
+ _disposed = true;
+ GC.SuppressFinalize(this);
+ }
+
+ private SteamCmdSession StartSession(string password, IProgress? progress)
+ {
+ IEnumerable secrets = string.IsNullOrEmpty(password) ? [] : [password];
+ return SteamCmdSession.Start(
+ _installer.ExecutablePath,
+ RootDirectory,
+ secrets,
+ progress);
+ }
+
+ private static async Task AuthenticateAsync(
+ SteamCmdSession session,
+ string username,
+ string password,
+ Func>? guardCodeProvider,
+ IProgress? progress,
+ CancellationToken cancellationToken,
+ bool waitForInitialPrompt = true)
+ {
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.Authenticating,
+ string.IsNullOrWhiteSpace(username)
+ ? "Logging in to Steam anonymously..."
+ : "Logging in to Steam..."));
+
+ if (waitForInitialPrompt)
+ await WaitForPromptAsync(session, cancellationToken).ConfigureAwait(false);
+ await session.SendCommandAsync(
+ SteamCmdCommandBuilder.BuildLoginCommand(username),
+ cancellationToken).ConfigureAwait(false);
+
+ bool loggedIn = false;
+ while (true)
+ {
+ SteamCmdOutputEvent outputEvent = await session.NextEventAsync(
+ PromptTimeout,
+ cancellationToken).ConfigureAwait(false);
+ switch (outputEvent.Kind)
+ {
+ case SteamCmdOutputEventKind.PasswordPrompt:
+ if (string.IsNullOrEmpty(password))
+ {
+ throw new SteamCmdAuthenticationException(
+ "SteamCMD requested a password. Enter the account password and try again, or complete a cached SteamCMD login first.");
+ }
+ await session.SendSecretAsync(password, cancellationToken).ConfigureAwait(false);
+ break;
+
+ case SteamCmdOutputEventKind.SteamGuardPrompt:
+ await AnswerGuardChallengeAsync(
+ session,
+ CreateGuardChallenge(outputEvent.Text),
+ guardCodeProvider,
+ progress,
+ cancellationToken).ConfigureAwait(false);
+ break;
+
+ case SteamCmdOutputEventKind.MobileConfirmationPrompt:
+ await AnswerGuardChallengeAsync(
+ session,
+ new SteamCmdGuardChallenge(
+ SteamCmdGuardChallengeKind.MobileConfirmation,
+ outputEvent.Text),
+ guardCodeProvider,
+ progress,
+ cancellationToken).ConfigureAwait(false);
+ break;
+
+ case SteamCmdOutputEventKind.LoggedIn:
+ loggedIn = true;
+ break;
+
+ case SteamCmdOutputEventKind.LoginFailed:
+ throw new SteamCmdAuthenticationException(
+ string.IsNullOrWhiteSpace(outputEvent.Text)
+ ? "Steam rejected the login."
+ : outputEvent.Text);
+
+ // "Logged in OK" is followed by a user-info/licence refresh.
+ // A timeout or error in that phase is still an authentication
+ // failure even though the first acknowledgement was received.
+ case SteamCmdOutputEventKind.Timeout:
+ case SteamCmdOutputEventKind.Error:
+ throw new SteamCmdAuthenticationException(outputEvent.Text);
+
+ case SteamCmdOutputEventKind.Prompt:
+ if (loggedIn)
+ return;
+ throw new SteamCmdAuthenticationException(
+ "SteamCMD returned to its prompt without confirming login.");
+ }
+ }
+ }
+
+ private static async Task AnswerGuardChallengeAsync(
+ SteamCmdSession session,
+ SteamCmdGuardChallenge challenge,
+ Func>? guardCodeProvider,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ if (guardCodeProvider == null)
+ throw new SteamCmdAuthenticationException("Steam Guard approval is required to log in.");
+
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.WaitingForGuard,
+ challenge.Prompt));
+ // A UI callback may itself expose no cancellation API. WaitAsync keeps
+ // the SteamCMD operation cancellable even if that callback is still
+ // waiting for a dialog response.
+ string? response = await guardCodeProvider(challenge, cancellationToken)
+ .WaitAsync(cancellationToken)
+ .ConfigureAwait(false);
+ response ??= string.Empty;
+ if (challenge.Kind != SteamCmdGuardChallengeKind.MobileConfirmation && string.IsNullOrWhiteSpace(response))
+ throw new SteamCmdAuthenticationException("A Steam Guard code was not provided.");
+
+ // Mobile confirmation is completed out-of-band in Steam's app. There
+ // is no code to write; continue consuming output until login completes.
+ if (challenge.Kind == SteamCmdGuardChallengeKind.MobileConfirmation && response.Length == 0)
+ return;
+
+ await session.SendSecretAsync(response, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static SteamCmdGuardChallenge CreateGuardChallenge(string prompt)
+ {
+ SteamCmdGuardChallengeKind kind;
+ if (prompt.Contains("email", StringComparison.OrdinalIgnoreCase))
+ kind = SteamCmdGuardChallengeKind.EmailCode;
+ else if (prompt.Contains("two-factor", StringComparison.OrdinalIgnoreCase) ||
+ prompt.Contains("authenticator", StringComparison.OrdinalIgnoreCase))
+ kind = SteamCmdGuardChallengeKind.TwoFactorCode;
+ else
+ kind = SteamCmdGuardChallengeKind.Unknown;
+
+ return new SteamCmdGuardChallenge(kind, prompt);
+ }
+
+ private async Task DownloadWorkshopItemAsync(
+ SteamCmdSession session,
+ ulong workshopId,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ await session.SendCommandAsync(
+ SteamCmdCommandBuilder.BuildWorkshopDownloadCommand(workshopId),
+ cancellationToken).ConfigureAwait(false);
+
+ bool successSignal = false;
+ string? failure = null;
+ while (true)
+ {
+ SteamCmdOutputEvent outputEvent = await session.NextEventAsync(
+ DownloadTimeout,
+ cancellationToken).ConfigureAwait(false);
+ switch (outputEvent.Kind)
+ {
+ case SteamCmdOutputEventKind.WorkshopDownloadSucceeded
+ when outputEvent.WorkshopId == workshopId:
+ successSignal = true;
+ break;
+
+ case SteamCmdOutputEventKind.WorkshopDownloadFailed
+ when outputEvent.WorkshopId == workshopId:
+ failure = outputEvent.Text;
+ break;
+
+ case SteamCmdOutputEventKind.Progress when outputEvent.ProgressPercent is double percentage:
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.DownloadingWorkshopItem,
+ $"Downloading Workshop item {workshopId}: {percentage:0.00}%",
+ Math.Clamp(percentage, 0, 100),
+ workshopId));
+ break;
+
+ case SteamCmdOutputEventKind.Timeout
+ when outputEvent.WorkshopId is null || outputEvent.WorkshopId == workshopId:
+ case SteamCmdOutputEventKind.Error
+ when outputEvent.WorkshopId is null || outputEvent.WorkshopId == workshopId:
+ failure ??= outputEvent.Text;
+ break;
+
+ case SteamCmdOutputEventKind.LoginFailed:
+ if (IsLikelyTransientWorkshopFailure(outputEvent.Text))
+ {
+ failure ??= outputEvent.Text;
+ break;
+ }
+ throw new SteamCmdAuthenticationException(outputEvent.Text);
+
+ case SteamCmdOutputEventKind.Prompt:
+ string sourcePath = GetWorkshopContentPath(workshopId);
+ bool hasContent = successSignal && DirectoryHasContent(sourcePath);
+ if (!hasContent && failure == null)
+ {
+ failure = successSignal
+ ? "SteamCMD reported success, but the Workshop content directory is missing or empty."
+ : "SteamCMD returned no success result for this Workshop item.";
+ }
+
+ bool success = successSignal && hasContent && failure == null;
+ return new SteamCmdWorkshopItemResult(workshopId, success, sourcePath, failure);
+ }
+ }
+ }
+
+ private async Task DownloadWorkshopItemWithRetriesAsync(
+ SteamCmdSession session,
+ ulong workshopId,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ SteamCmdWorkshopItemResult? result = null;
+ for (int attempt = 1; attempt <= MaximumWorkshopDownloadAttempts; attempt++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ result = await DownloadWorkshopItemAsync(
+ session,
+ workshopId,
+ progress,
+ cancellationToken).ConfigureAwait(false);
+
+ if (result.Success)
+ {
+ ReportWorkshopItemResult(result, progress);
+ return result;
+ }
+
+ if (attempt == MaximumWorkshopDownloadAttempts ||
+ !IsLikelyTransientWorkshopFailure(result.Error))
+ {
+ ReportWorkshopItemResult(result, progress);
+ return result;
+ }
+
+ TimeSpan delay = GetWorkshopRetryDelay(attempt);
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.Warning,
+ $"Workshop item {workshopId} failed transiently. Retrying in {delay.TotalSeconds:0} second(s) " +
+ $"(attempt {attempt + 1}/{MaximumWorkshopDownloadAttempts})...",
+ WorkshopId: workshopId));
+ await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+ }
+
+ // The bounded loop always returns, but retaining a defensive result keeps
+ // future changes from turning an exhausted retry into a false success.
+ result ??= new SteamCmdWorkshopItemResult(
+ workshopId,
+ false,
+ GetWorkshopContentPath(workshopId),
+ "SteamCMD did not complete the Workshop download.");
+ ReportWorkshopItemResult(result, progress);
+ return result;
+ }
+
+ ///
+ /// Returns whether a completed Workshop command failed for a reason that is
+ /// safe to retry in the same authenticated SteamCMD session.
+ ///
+ public static bool IsLikelyTransientWorkshopFailure(string? error)
+ {
+ if (string.IsNullOrWhiteSpace(error))
+ return false;
+
+ string message = error.Trim();
+ string[] permanentMarkers =
+ [
+ "access denied",
+ "account logon denied",
+ "authentication",
+ "configuration",
+ "decryption key",
+ "disk",
+ "does not own",
+ "file system",
+ "disk full",
+ "disk write",
+ "file permission",
+ "i/o operation",
+ "invalid parameter",
+ "invalid password",
+ "license",
+ "login failed",
+ "logon failed",
+ "missing file privileges",
+ "missing subscription",
+ "no match",
+ "no subscription",
+ "not owned",
+ "not authorized",
+ "ownership",
+ "password",
+ "permission",
+ "read-only",
+ "steam guard",
+ "subscription",
+ "two-factor",
+ "unauthorized",
+ "write failure"
+ ];
+ if (permanentMarkers.Any(marker => message.Contains(marker, StringComparison.OrdinalIgnoreCase)))
+ return false;
+
+ string[] transientMarkers =
+ [
+ "failure",
+ "busy",
+ "connection",
+ "content server",
+ "network",
+ "rate limit",
+ "service unavailable",
+ "temporar",
+ "timed out",
+ "timeout",
+ "try again"
+ ];
+ return transientMarkers.Any(marker => message.Contains(marker, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static TimeSpan GetWorkshopRetryDelay(int completedAttempts) =>
+ TimeSpan.FromSeconds(Math.Min(4, 1 << Math.Clamp(completedAttempts - 1, 0, 2)));
+
+ private static void ReportWorkshopItemResult(
+ SteamCmdWorkshopItemResult result,
+ IProgress? progress)
+ {
+ progress?.Report(new SteamCmdProgress(
+ result.Success ? SteamCmdProgressKind.Completed : SteamCmdProgressKind.Error,
+ result.Success
+ ? $"Workshop item {result.WorkshopId} downloaded successfully."
+ : $"Workshop item {result.WorkshopId} failed: {result.Error}",
+ result.Success ? 100 : null,
+ result.WorkshopId));
+ }
+
+ private static async Task WaitForPromptAsync(
+ SteamCmdSession session,
+ CancellationToken cancellationToken)
+ {
+ while (true)
+ {
+ SteamCmdOutputEvent outputEvent = await session.NextEventAsync(
+ PromptTimeout,
+ cancellationToken).ConfigureAwait(false);
+ if (outputEvent.Kind == SteamCmdOutputEventKind.Prompt)
+ return;
+ if (outputEvent.Kind == SteamCmdOutputEventKind.Error)
+ throw new SteamCmdException(outputEvent.Text);
+ }
+ }
+
+ private static async Task WaitForPromptAfterCommandAsync(
+ SteamCmdSession session,
+ CancellationToken cancellationToken)
+ {
+ string? error = null;
+ while (true)
+ {
+ SteamCmdOutputEvent outputEvent = await session.NextEventAsync(
+ PromptTimeout,
+ cancellationToken).ConfigureAwait(false);
+ switch (outputEvent.Kind)
+ {
+ case SteamCmdOutputEventKind.Error:
+ case SteamCmdOutputEventKind.Timeout:
+ error ??= outputEvent.Text;
+ break;
+ case SteamCmdOutputEventKind.Prompt:
+ if (error != null)
+ throw new SteamCmdException(error);
+ return;
+ }
+ }
+ }
+
+ private static async Task WaitForServerUpdateAsync(
+ SteamCmdSession session,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ bool successSignal = false;
+ string? error = null;
+ while (true)
+ {
+ SteamCmdOutputEvent outputEvent = await session.NextEventAsync(
+ DownloadTimeout,
+ cancellationToken).ConfigureAwait(false);
+ switch (outputEvent.Kind)
+ {
+ case SteamCmdOutputEventKind.AppUpdateSucceeded
+ when outputEvent.AppId == SteamCmdCommandBuilder.ServerAppId:
+ successSignal = true;
+ break;
+ case SteamCmdOutputEventKind.Progress when outputEvent.ProgressPercent is double percentage:
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.UpdatingServer,
+ $"Updating Arma 3 Dedicated Server: {percentage:0.00}%",
+ Math.Clamp(percentage, 0, 100)));
+ break;
+ case SteamCmdOutputEventKind.Error:
+ case SteamCmdOutputEventKind.Timeout:
+ error ??= outputEvent.Text;
+ break;
+ case SteamCmdOutputEventKind.LoginFailed:
+ throw new SteamCmdAuthenticationException(outputEvent.Text);
+ case SteamCmdOutputEventKind.Prompt:
+ return error ?? (successSignal
+ ? null
+ : "SteamCMD returned no success result for app 233780.");
+ }
+ }
+ }
+
+ private string GetWorkshopContentPath(ulong workshopId) => Path.Combine(
+ RootDirectory,
+ "steamapps",
+ "workshop",
+ "content",
+ SteamCmdCommandBuilder.WorkshopAppId.ToString(),
+ workshopId.ToString());
+
+ private static bool DirectoryHasContent(string path)
+ {
+ try
+ {
+ return Directory.Exists(path) && Directory.EnumerateFileSystemEntries(path).Any();
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
+ private static bool ServerInstallHasContent(string installDirectory) =>
+ File.Exists(Path.Combine(installDirectory, "arma3server_x64.exe")) ||
+ File.Exists(Path.Combine(installDirectory, "arma3server.exe"));
+
+ private static List NormalizeWorkshopIds(IEnumerable workshopIds)
+ {
+ HashSet seen = new();
+ List result = new();
+ foreach (ulong workshopId in workshopIds)
+ {
+ if (workshopId == 0)
+ throw new ArgumentOutOfRangeException(nameof(workshopIds), "Workshop IDs must be greater than zero.");
+ if (seen.Add(workshopId))
+ result.Add(workshopId);
+ }
+ return result;
+ }
+
+ private void AddUnfinishedResults(
+ IEnumerable requestedIds,
+ ICollection results,
+ string error)
+ {
+ HashSet completedIds = results.Select(result => result.WorkshopId).ToHashSet();
+ foreach (ulong workshopId in requestedIds.Where(id => !completedIds.Contains(id)))
+ {
+ results.Add(new SteamCmdWorkshopItemResult(
+ workshopId,
+ false,
+ GetWorkshopContentPath(workshopId),
+ error));
+ }
+ }
+
+ private static string SanitizeExceptionMessage(string message, string secret)
+ {
+ if (string.IsNullOrEmpty(secret))
+ return message;
+ return message.Replace(secret, "[REDACTED]", StringComparison.Ordinal);
+ }
+
+ private static async Task StopSessionSafelyAsync(SteamCmdSession? session)
+ {
+ if (session == null)
+ return;
+ try
+ {
+ await session.StopAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is SteamCmdException or IOException or InvalidOperationException or ObjectDisposedException)
+ {
+ }
+ }
+
+ private static async Task DisposeSessionSafelyAsync(SteamCmdSession? session)
+ {
+ if (session == null)
+ return;
+ try
+ {
+ await session.DisposeAsync().ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is SteamCmdException or IOException or InvalidOperationException or ObjectDisposedException)
+ {
+ }
+ }
+
+ private void SetActiveSession(SteamCmdSession session)
+ {
+ lock (_stateGate)
+ _activeSession = session;
+ }
+
+ private void SetActiveCancellation(CancellationTokenSource cancellation)
+ {
+ lock (_stateGate)
+ _activeCancellation = cancellation;
+ }
+
+ private void SetOperationActive(bool active)
+ {
+ lock (_stateGate)
+ _operationActive = active;
+ }
+
+ private void ClearActiveState(
+ SteamCmdSession? session,
+ CancellationTokenSource operationCancellation)
+ {
+ lock (_stateGate)
+ {
+ if (ReferenceEquals(_activeSession, session))
+ _activeSession = null;
+ if (ReferenceEquals(_activeCancellation, operationCancellation))
+ _activeCancellation = null;
+ }
+ }
+
+ private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
+}
diff --git a/FASTER/Services/SteamCmd/SteamCmdCommandBuilder.cs b/FASTER/Services/SteamCmd/SteamCmdCommandBuilder.cs
new file mode 100644
index 00000000..87d4484b
--- /dev/null
+++ b/FASTER/Services/SteamCmd/SteamCmdCommandBuilder.cs
@@ -0,0 +1,83 @@
+using System.Globalization;
+using System.IO;
+using System.Text.RegularExpressions;
+
+namespace FASTER.Services.SteamCmd;
+
+public static partial class SteamCmdCommandBuilder
+{
+ public const uint WorkshopAppId = 107410;
+ public const uint ServerAppId = 233780;
+
+ public static string BuildLoginCommand(string? username)
+ {
+ if (string.IsNullOrWhiteSpace(username))
+ return "login anonymous";
+
+ string accountName = username.Trim();
+ if (!AccountNamePattern().IsMatch(accountName))
+ {
+ throw new ArgumentException(
+ "The Steam account name may contain only letters, numbers, underscores, and hyphens.",
+ nameof(username));
+ }
+
+ return $"login {accountName}";
+ }
+
+ public static string BuildWorkshopDownloadCommand(ulong workshopId)
+ {
+ if (workshopId == 0)
+ throw new ArgumentOutOfRangeException(nameof(workshopId), "A Workshop ID must be greater than zero.");
+
+ return string.Create(
+ CultureInfo.InvariantCulture,
+ $"workshop_download_item {WorkshopAppId} {workshopId} validate");
+ }
+
+ public static string BuildForceInstallDirectoryCommand(string installDirectory)
+ {
+ string normalizedPath = ValidateAndNormalizePath(installDirectory, nameof(installDirectory));
+ return $"force_install_dir \"{normalizedPath}\"";
+ }
+
+ public static string BuildServerUpdateCommand(SteamCmdServerBranch branch)
+ {
+ string branchName = branch switch
+ {
+ SteamCmdServerBranch.Public => "public",
+ SteamCmdServerBranch.Contact => "contact",
+ SteamCmdServerBranch.CreatorDlc => "creatordlc",
+ SteamCmdServerBranch.Profiling => "profiling",
+ _ => throw new ArgumentOutOfRangeException(nameof(branch), branch, "Unsupported Steam server branch.")
+ };
+
+ return $"app_update {ServerAppId} -beta {branchName} validate";
+ }
+
+ public static string BuildQuitCommand() => "quit";
+
+ public static string ValidateAndNormalizePath(string path, string? parameterName = null)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ throw new ArgumentException("A directory path is required.", parameterName ?? nameof(path));
+
+ if (path.IndexOfAny(['\0', '\r', '\n', '\"']) >= 0)
+ throw new ArgumentException("The directory path contains characters SteamCMD cannot safely accept.", parameterName ?? nameof(path));
+
+ string fullPath;
+ try
+ {
+ fullPath = Path.GetFullPath(path.Trim());
+ }
+ catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
+ {
+ throw new ArgumentException("The directory path is invalid.", parameterName ?? nameof(path), exception);
+ }
+
+ return Path.TrimEndingDirectorySeparator(fullPath);
+ }
+
+ [GeneratedRegex("^[A-Za-z0-9_-]+$", RegexOptions.CultureInvariant)]
+ private static partial Regex AccountNamePattern();
+}
diff --git a/FASTER/Services/SteamCmd/SteamCmdInstaller.cs b/FASTER/Services/SteamCmd/SteamCmdInstaller.cs
new file mode 100644
index 00000000..f1e4ada0
--- /dev/null
+++ b/FASTER/Services/SteamCmd/SteamCmdInstaller.cs
@@ -0,0 +1,282 @@
+using System.IO;
+using System.IO.Compression;
+using System.Net.Http;
+
+namespace FASTER.Services.SteamCmd;
+
+internal sealed class SteamCmdInstaller
+{
+ private const long MaximumArchiveBytes = 64L * 1024 * 1024;
+ private const long MaximumExpandedBytes = 512L * 1024 * 1024;
+ private static readonly Uri DownloadUri = new("https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip");
+ private static readonly HttpClient HttpClient = new();
+
+ private readonly string _rootDirectory;
+
+ public SteamCmdInstaller(string rootDirectory)
+ {
+ _rootDirectory = rootDirectory;
+ }
+
+ public string ExecutablePath => Path.Combine(_rootDirectory, "steamcmd.exe");
+
+ private string ReadinessMarkerPath => Path.Combine(_rootDirectory, ".faster-steamcmd-ready");
+
+ public bool IsInstalled => File.Exists(ExecutablePath) && File.Exists(ReadinessMarkerPath);
+
+ public async Task EnsureInstalledAsync(
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ if (IsInstalled)
+ return;
+
+ // Accept an existing user-supplied or interrupted install, but do not
+ // call it ready until SteamCMD has successfully completed self-update.
+ if (File.Exists(ExecutablePath))
+ {
+ await BootstrapAsync(progress, cancellationToken).ConfigureAwait(false);
+ await WriteReadinessMarkerAsync(cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ string temporaryDirectory = Path.Combine(
+ Path.GetTempPath(),
+ $"faster-steamcmd-{Guid.NewGuid():N}");
+ string archivePath = Path.Combine(temporaryDirectory, "steamcmd.zip");
+ string extractionPath = Path.Combine(temporaryDirectory, "extracted");
+
+ Directory.CreateDirectory(temporaryDirectory);
+ try
+ {
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.Installing,
+ "Downloading SteamCMD from Valve...",
+ 0));
+
+ await DownloadArchiveAsync(archivePath, progress, cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.Installing,
+ "Validating and extracting SteamCMD..."));
+
+ ExtractArchiveSafely(archivePath, extractionPath, cancellationToken);
+ string extractedExecutable = Path.Combine(extractionPath, "steamcmd.exe");
+ if (!File.Exists(extractedExecutable))
+ throw new SteamCmdException("Valve's SteamCMD archive did not contain steamcmd.exe.");
+
+ Directory.CreateDirectory(_rootDirectory);
+ CopyDirectory(extractionPath, _rootDirectory, cancellationToken);
+
+ if (!File.Exists(ExecutablePath))
+ throw new SteamCmdException("SteamCMD could not be installed in the selected directory.");
+
+ await BootstrapAsync(progress, cancellationToken).ConfigureAwait(false);
+ await WriteReadinessMarkerAsync(cancellationToken).ConfigureAwait(false);
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.Completed,
+ "SteamCMD is installed and up to date.",
+ 100));
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (SteamCmdException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ throw new SteamCmdException("SteamCMD installation failed.", exception);
+ }
+ finally
+ {
+ DeleteTemporaryDirectory(temporaryDirectory);
+ }
+ }
+
+ private async Task WriteReadinessMarkerAsync(CancellationToken cancellationToken)
+ {
+ string temporaryMarker = ReadinessMarkerPath + $".{Guid.NewGuid():N}.tmp";
+ try
+ {
+ await File.WriteAllTextAsync(
+ temporaryMarker,
+ "SteamCMD completed bootstrap successfully.",
+ cancellationToken).ConfigureAwait(false);
+ File.Move(temporaryMarker, ReadinessMarkerPath, true);
+ }
+ finally
+ {
+ try
+ {
+ if (File.Exists(temporaryMarker))
+ File.Delete(temporaryMarker);
+ }
+ catch (IOException)
+ {
+ }
+ }
+ }
+
+ private static async Task DownloadArchiveAsync(
+ string destinationPath,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ using HttpResponseMessage response = await HttpClient.GetAsync(
+ DownloadUri,
+ HttpCompletionOption.ResponseHeadersRead,
+ cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ long? contentLength = response.Content.Headers.ContentLength;
+ if (contentLength > MaximumArchiveBytes)
+ throw new SteamCmdException("The SteamCMD archive is unexpectedly large.");
+
+ await using Stream source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using FileStream destination = new(
+ destinationPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ 81920,
+ FileOptions.Asynchronous | FileOptions.SequentialScan);
+
+ byte[] buffer = new byte[81920];
+ long downloaded = 0;
+ while (true)
+ {
+ int read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
+ if (read == 0)
+ break;
+
+ downloaded += read;
+ if (downloaded > MaximumArchiveBytes)
+ throw new SteamCmdException("The SteamCMD archive exceeded the safe download limit.");
+
+ await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
+ double? percentage = contentLength is > 0
+ ? Math.Min(100, downloaded * 100d / contentLength.Value)
+ : null;
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.Installing,
+ "Downloading SteamCMD from Valve...",
+ percentage));
+ }
+ }
+
+ private static void ExtractArchiveSafely(
+ string archivePath,
+ string destinationDirectory,
+ CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(destinationDirectory);
+ string destinationRoot = Path.GetFullPath(destinationDirectory) + Path.DirectorySeparatorChar;
+ long expandedBytes = 0;
+
+ using ZipArchive archive = ZipFile.OpenRead(archivePath);
+ foreach (ZipArchiveEntry entry in archive.Entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ expandedBytes = checked(expandedBytes + entry.Length);
+ if (expandedBytes > MaximumExpandedBytes)
+ throw new SteamCmdException("The SteamCMD archive exceeded the safe extraction limit.");
+
+ string targetPath = Path.GetFullPath(Path.Combine(destinationDirectory, entry.FullName));
+ if (!targetPath.StartsWith(destinationRoot, StringComparison.OrdinalIgnoreCase))
+ throw new SteamCmdException("The SteamCMD archive contains an unsafe path.");
+
+ if (string.IsNullOrEmpty(entry.Name))
+ {
+ Directory.CreateDirectory(targetPath);
+ continue;
+ }
+
+ string? targetParent = Path.GetDirectoryName(targetPath);
+ if (targetParent != null)
+ Directory.CreateDirectory(targetParent);
+
+ using Stream source = entry.Open();
+ using FileStream destination = new(targetPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
+ source.CopyTo(destination);
+ }
+ }
+
+ private static void CopyDirectory(
+ string sourceDirectory,
+ string destinationDirectory,
+ CancellationToken cancellationToken)
+ {
+ foreach (string directory in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string relativePath = Path.GetRelativePath(sourceDirectory, directory);
+ Directory.CreateDirectory(Path.Combine(destinationDirectory, relativePath));
+ }
+
+ foreach (string file in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string relativePath = Path.GetRelativePath(sourceDirectory, file);
+ string destination = Path.Combine(destinationDirectory, relativePath);
+ string? parent = Path.GetDirectoryName(destination);
+ if (parent != null)
+ Directory.CreateDirectory(parent);
+ File.Copy(file, destination, true);
+ }
+ }
+
+ private async Task BootstrapAsync(
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ progress?.Report(new SteamCmdProgress(
+ SteamCmdProgressKind.Installing,
+ "SteamCMD is applying its self-update..."));
+
+ // SteamCMD initializes as an interactive console application even when
+ // it only needs to self-update. Ordinary redirected pipes can hang (or
+ // crash recent builds), so bootstrap it through the same ConPTY-backed
+ // session used for downloads.
+ await using SteamCmdSession session = SteamCmdSession.Start(
+ ExecutablePath,
+ _rootDirectory,
+ [],
+ progress);
+
+ while (true)
+ {
+ SteamCmdOutputEvent outputEvent = await session.NextEventAsync(
+ TimeSpan.FromMinutes(5),
+ cancellationToken).ConfigureAwait(false);
+ if (outputEvent.Kind == SteamCmdOutputEventKind.Prompt)
+ break;
+ if (outputEvent.Kind is SteamCmdOutputEventKind.Error or SteamCmdOutputEventKind.Timeout)
+ throw new SteamCmdException(outputEvent.Text);
+ }
+
+ int? exitCode = await session.QuitAndWaitAsync(cancellationToken).ConfigureAwait(false);
+ if (exitCode is not 0)
+ throw new SteamCmdException($"SteamCMD self-update exited with code {exitCode}.");
+ }
+
+ private static void DeleteTemporaryDirectory(string temporaryDirectory)
+ {
+ try
+ {
+ string temporaryRoot = Path.GetFullPath(Path.GetTempPath());
+ string target = Path.GetFullPath(temporaryDirectory);
+ if (target.StartsWith(temporaryRoot, StringComparison.OrdinalIgnoreCase) && Directory.Exists(target))
+ Directory.Delete(target, true);
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+}
diff --git a/FASTER/Services/SteamCmd/SteamCmdModels.cs b/FASTER/Services/SteamCmd/SteamCmdModels.cs
new file mode 100644
index 00000000..e033f729
--- /dev/null
+++ b/FASTER/Services/SteamCmd/SteamCmdModels.cs
@@ -0,0 +1,102 @@
+using System.Collections.ObjectModel;
+
+namespace FASTER.Services.SteamCmd;
+
+public enum SteamCmdServerBranch
+{
+ Public,
+ Contact,
+ CreatorDlc,
+ Profiling
+}
+
+public enum SteamCmdGuardChallengeKind
+{
+ EmailCode,
+ TwoFactorCode,
+ MobileConfirmation,
+ Unknown
+}
+
+public sealed record SteamCmdGuardChallenge(
+ SteamCmdGuardChallengeKind Kind,
+ string Prompt);
+
+public enum SteamCmdProgressKind
+{
+ Installing,
+ Starting,
+ Authenticating,
+ WaitingForGuard,
+ DownloadingWorkshopItem,
+ UpdatingServer,
+ Completed,
+ Warning,
+ Error,
+ Output
+}
+
+public sealed record SteamCmdProgress(
+ SteamCmdProgressKind Kind,
+ string Message,
+ double? Percentage = null,
+ ulong? WorkshopId = null);
+
+public sealed record SteamCmdWorkshopItemResult(
+ ulong WorkshopId,
+ bool Success,
+ string SourcePath,
+ string? Error = null);
+
+public sealed class SteamCmdWorkshopBatchResult
+{
+ public SteamCmdWorkshopBatchResult(
+ IEnumerable items,
+ bool cancelled,
+ int? exitCode,
+ string? error = null)
+ {
+ ArgumentNullException.ThrowIfNull(items);
+ Items = new ReadOnlyCollection(items.ToList());
+ Cancelled = cancelled;
+ ExitCode = exitCode;
+ Error = error;
+ }
+
+ public IReadOnlyList Items { get; }
+
+ public bool Cancelled { get; }
+
+ public int? ExitCode { get; }
+
+ public string? Error { get; }
+
+ public bool Success => !Cancelled && Error == null && Items.Count > 0 && Items.All(item => item.Success);
+}
+
+public sealed record SteamCmdServerUpdateResult(
+ bool Success,
+ bool Cancelled,
+ int? ExitCode,
+ string? Error = null);
+
+public class SteamCmdException : Exception
+{
+ public SteamCmdException(string message)
+ : base(message)
+ {
+ }
+
+ public SteamCmdException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
+
+public sealed class SteamCmdAuthenticationException : SteamCmdException
+{
+ public SteamCmdAuthenticationException(string message)
+ : base(message)
+ {
+ }
+}
diff --git a/FASTER/Services/SteamCmd/SteamCmdOutputParser.cs b/FASTER/Services/SteamCmd/SteamCmdOutputParser.cs
new file mode 100644
index 00000000..a06142ed
--- /dev/null
+++ b/FASTER/Services/SteamCmd/SteamCmdOutputParser.cs
@@ -0,0 +1,674 @@
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace FASTER.Services.SteamCmd;
+
+///
+/// Identifies a logical event found in SteamCMD console output.
+///
+public enum SteamCmdOutputEventKind
+{
+ Output,
+ Prompt,
+ PasswordPrompt,
+ SteamGuardPrompt,
+ MobileConfirmationPrompt,
+ LoggedIn,
+ LoginFailed,
+ WorkshopDownloadRequested,
+ WorkshopDownloadSucceeded,
+ WorkshopDownloadFailed,
+ Progress,
+ AppUpdateSucceeded,
+ Error,
+ Timeout
+}
+
+///
+/// Describes the authentication action requested by a Steam Guard prompt.
+///
+public enum SteamCmdGuardPromptKind
+{
+ Unknown,
+ EmailCode,
+ AuthenticatorCode,
+ MobileApproval
+}
+
+///
+/// A sanitized event produced from SteamCMD's console stream.
+///
+/// The kind of output that was recognized.
+/// Sanitized console text or a canonical prompt description.
+/// The workshop item associated with the event, when known.
+/// The reported percentage, without rounding, when present.
+/// The Steam application associated with the event, when present.
+/// The kind of Steam Guard interaction, when present.
+public sealed record SteamCmdOutputEvent(
+ SteamCmdOutputEventKind Kind,
+ string Text,
+ ulong? WorkshopId = null,
+ double? ProgressPercent = null,
+ uint? AppId = null,
+ SteamCmdGuardPromptKind? GuardPromptKind = null);
+
+///
+/// Incrementally converts the character stream written by SteamCMD into logical events.
+///
+///
+/// The parser accepts arbitrary chunk boundaries. Console records are delimited by CR, LF,
+/// or CRLF; interactive prompts are recognized before a delimiter is received. Supplied
+/// secrets are replaced in every event's text and are never returned as raw output.
+///
+public sealed class SteamCmdOutputParser
+{
+ private const string RedactionMarker = "[REDACTED]";
+
+ private static readonly Regex SteamPromptRegex = new(
+ @"Steam>\s*$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
+
+ private static readonly Regex PasswordPromptRegex = new(
+ @"(?:(?:^|\s)(?:password|passphrase)\s*:|please\s+enter(?:\s+your)?\s+password\s*:?)\s*$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
+
+ private static readonly Regex WorkshopSuccessRegex = new(
+ @"\bSuccess[.!]?\s+Downloaded\s+item\s+(?\d+)\b",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
+
+ private static readonly Regex WorkshopFailureRegex = new(
+ @"\bDownload(?:ing|ed)?\s+item\s+(?\d+)\s+failed\b(?:\s*\((?[^)]*)\))?",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
+
+ private static readonly Regex WorkshopTimeoutRegex = new(
+ @"\b(?:timeout|timed\s+out)\b.*?\bdownload(?:ing)?\s+(?:workshop\s+)?item\s+(?\d+)\b",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
+
+ private static readonly Regex WorkshopDownloadingRegex = new(
+ @"\bDownloading\s+(?:workshop\s+)?item\s+(?\d+)\b",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
+
+ private static readonly Regex WorkshopCommandRegex = new(
+ @"\bworkshop_download_item\s+(?\d+)\s+(?\d+)\b",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
+
+ private static readonly Regex ProgressRegex = new(
+ @"\bprogress\s*:\s*(?