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*(?\d+(?:[.,]\d+)?)\s*%?", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private static readonly Regex AppSuccessRegex = new( + @"\bSuccess!\s+App\s+['""]?(?\d+)['""]?\s+fully\s+installed\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private static readonly Regex LoggedInRegex = new( + @"\b(?:Logged\s+in\s+OK|Waiting\s+for\s+user\s+info\.{0,3}\s*OK)\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private static readonly Regex LoginFailedRegex = new( + @"(?:\b(?:login|logon|logging\s+in)\b.*\bfailed\b|\bwaiting\s+for\s+user\s+info\b.*\bfailed\b|\bFAILED\s*\((?:Invalid\s+Password|Account\s+Logon\s+Denied|Invalid\s+Login\s+Auth\s+Code|Two[- ]Factor\s+Code\s+Mismatch|Rate\s+Limit\s+Exceeded)\)|\binvalid\s+(?:password|steam\s+guard\s+code|authentication\s+code)\b|\bunable\s+to\s+log\s+(?:in|on)\b)", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private static readonly Regex ErrorRegex = new( + @"(?:^|\s)(?:ERROR!|ERROR\s*:|\[ERROR\])", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private static readonly Regex TimeoutRegex = new( + @"\b(?:timeout|timed\s+out)\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private readonly object syncRoot = new(); + private readonly StringBuilder recordBuffer = new(); + private readonly List secrets = []; + + private AnsiState ansiState; + private bool skipLineFeed; + private bool completed; + private bool steamPromptEmitted; + private bool passwordPromptEmitted; + private bool guardPromptEmitted; + private bool steamPromptAwaitingActivity; + private bool passwordPromptAwaitingActivity; + private string? guardPromptAwaitingActivityText; + private ulong? currentWorkshopId; + + public SteamCmdOutputParser(IEnumerable? secretsToRedact = null) + { + if (secretsToRedact is null) + { + return; + } + + foreach (string secret in secretsToRedact) + { + AddSecret(secret); + } + } + + /// + /// Adds a value that must be removed from all subsequently produced event text. + /// + public void AddSecret(string secret) + { + if (string.IsNullOrEmpty(secret)) + { + return; + } + + lock (syncRoot) + { + if (secrets.Contains(secret, StringComparer.Ordinal)) + { + return; + } + + secrets.Add(secret); + secrets.Sort(static (left, right) => right.Length.CompareTo(left.Length)); + } + } + + /// + /// Parses the next arbitrary chunk of SteamCMD console characters. + /// + public IReadOnlyList Feed(string chunk) + { + ArgumentNullException.ThrowIfNull(chunk); + + lock (syncRoot) + { + ObjectDisposedException.ThrowIf(completed, this); + + if (chunk.Length == 0) + { + return Array.Empty(); + } + + List events = []; + foreach (char character in chunk) + { + ProcessCharacter(character, events); + } + + return events; + } + } + + /// + /// Flushes the final unterminated console record. This method is idempotent. + /// + public IReadOnlyList Complete() + { + lock (syncRoot) + { + if (completed) + { + return Array.Empty(); + } + + completed = true; + List events = []; + CompleteRecord(events); + return events; + } + } + + private void ProcessCharacter(char character, List events) + { + switch (ansiState) + { + case AnsiState.None: + if (character == '\u001b') + { + ansiState = AnsiState.Escape; + } + else if (character == '\u009b') + { + ansiState = AnsiState.ControlSequence; + } + else if (character == '\u009d') + { + ansiState = AnsiState.OperatingSystemCommand; + } + else + { + ProcessVisibleCharacter(character, events); + } + + break; + + case AnsiState.Escape: + if (character == '[') + { + ansiState = AnsiState.ControlSequence; + } + else if (character is ']' or 'P' or 'X' or '^' or '_') + { + ansiState = AnsiState.OperatingSystemCommand; + } + else if (character is >= '\u0020' and <= '\u002f') + { + ansiState = AnsiState.EscapeIntermediate; + } + else if (character == '\u001b') + { + ansiState = AnsiState.Escape; + } + else + { + ansiState = AnsiState.None; + } + + break; + + case AnsiState.EscapeIntermediate: + if (character is >= '\u0030' and <= '\u007e') + { + ansiState = AnsiState.None; + } + else if (character == '\u001b') + { + ansiState = AnsiState.Escape; + } + + break; + + case AnsiState.ControlSequence: + if (character is >= '\u0040' and <= '\u007e') + { + ansiState = AnsiState.None; + } + else if (character == '\u001b') + { + ansiState = AnsiState.Escape; + } + + break; + + case AnsiState.OperatingSystemCommand: + if (character == '\u0007') + { + ansiState = AnsiState.None; + } + else if (character == '\u001b') + { + ansiState = AnsiState.OperatingSystemCommandEscape; + } + + break; + + case AnsiState.OperatingSystemCommandEscape: + if (character == '\\') + { + ansiState = AnsiState.None; + } + else if (character != '\u001b') + { + ansiState = AnsiState.OperatingSystemCommand; + } + + break; + } + } + + private void ProcessVisibleCharacter(char character, List events) + { + if (character == '\r') + { + CompleteRecord(events); + skipLineFeed = true; + return; + } + + if (character == '\n') + { + if (skipLineFeed) + { + skipLineFeed = false; + } + else + { + CompleteRecord(events); + } + + return; + } + + skipLineFeed = false; + + if (character == '\0') + { + return; + } + + if (character == '\b') + { + if (recordBuffer.Length > 0) + { + recordBuffer.Length--; + } + + return; + } + + recordBuffer.Append(character); + DetectInteractivePrompt(events); + } + + private void DetectInteractivePrompt(List events) + { + string currentRecord = recordBuffer.ToString(); + Match steamPromptMatch = SteamPromptRegex.Match(currentRecord); + + if (!steamPromptEmitted && !steamPromptAwaitingActivity && steamPromptMatch.Success) + { + if (steamPromptMatch.Index > 0) + { + // ConPTY frequently paints the next prompt directly after the + // command result without CR/LF (for example + // "Waiting for user info...OKSteam>"). Publish that result + // first so command completion cannot overtake its success or + // failure event in the channel. + string precedingOutput = currentRecord[..steamPromptMatch.Index]; + if (!string.IsNullOrWhiteSpace(precedingOutput)) + { + ProcessRecord(precedingOutput, events); + passwordPromptAwaitingActivity = false; + guardPromptAwaitingActivityText = null; + } + + recordBuffer.Clear(); + recordBuffer.Append("Steam>"); + currentRecord = "Steam>"; + } + + steamPromptEmitted = true; + steamPromptAwaitingActivity = true; + events.Add(CreateEvent(SteamCmdOutputEventKind.Prompt, "Steam>")); + } + + if (!passwordPromptEmitted && !passwordPromptAwaitingActivity && PasswordPromptRegex.IsMatch(currentRecord)) + { + passwordPromptEmitted = true; + passwordPromptAwaitingActivity = true; + events.Add(CreateEvent(SteamCmdOutputEventKind.PasswordPrompt, "Password prompt")); + } + + if (guardPromptEmitted || + !TryGetGuardPromptKind(currentRecord, out SteamCmdGuardPromptKind guardKind) || + string.Equals( + guardPromptAwaitingActivityText, + currentRecord.Trim(), + StringComparison.OrdinalIgnoreCase)) + { + return; + } + + guardPromptEmitted = true; + guardPromptAwaitingActivityText = currentRecord.Trim(); + SteamCmdOutputEventKind eventKind = guardKind == SteamCmdGuardPromptKind.MobileApproval + ? SteamCmdOutputEventKind.MobileConfirmationPrompt + : SteamCmdOutputEventKind.SteamGuardPrompt; + events.Add(CreateEvent(eventKind, currentRecord, guardPromptKind: guardKind)); + } + + private void CompleteRecord(List events) + { + if (recordBuffer.Length > 0) + { + string record = recordBuffer.ToString(); + bool recordIsPrompt = SteamPromptRegex.IsMatch(record) || + PasswordPromptRegex.IsMatch(record) || + TryGetGuardPromptKind(record, out _); + ProcessRecord(record, events); + if (!recordIsPrompt && !string.IsNullOrWhiteSpace(record)) + { + // ConPTY may repaint the same prompt in a later terminal frame. + // Do not emit it again until command/authentication activity has + // appeared between the two frames. + steamPromptAwaitingActivity = false; + passwordPromptAwaitingActivity = false; + guardPromptAwaitingActivityText = null; + } + recordBuffer.Clear(); + } + + steamPromptEmitted = false; + passwordPromptEmitted = false; + guardPromptEmitted = false; + } + + private void ProcessRecord(string rawRecord, List events) + { + string record = rawRecord.Trim(); + if (record.Length == 0) + { + return; + } + + // Sanitize before trimming so a secret that intentionally begins or ends with + // whitespace cannot be partially exposed by normalization. + string eventText = Sanitize(rawRecord).Trim(); + events.Add(CreateEvent(SteamCmdOutputEventKind.Output, eventText)); + + if (LoggedInRegex.IsMatch(record)) + { + events.Add(CreateEvent(SteamCmdOutputEventKind.LoggedIn, eventText)); + } + + bool loginFailed = LoginFailedRegex.IsMatch(record); + if (loginFailed) + { + events.Add(CreateEvent(SteamCmdOutputEventKind.LoginFailed, eventText)); + } + + bool workshopFailure = TryParseWorkshopFailure(record, out ulong failedWorkshopId); + if (workshopFailure) + { + events.Add(CreateEvent( + SteamCmdOutputEventKind.WorkshopDownloadFailed, + eventText, + workshopId: failedWorkshopId)); + currentWorkshopId = null; + } + else if (TryParseWorkshopSuccess(record, out ulong successfulWorkshopId)) + { + events.Add(CreateEvent( + SteamCmdOutputEventKind.WorkshopDownloadSucceeded, + eventText, + workshopId: successfulWorkshopId)); + currentWorkshopId = null; + } + else if (TryParseWorkshopRequest(record, out ulong requestedWorkshopId)) + { + if (currentWorkshopId != requestedWorkshopId) + { + events.Add(CreateEvent( + SteamCmdOutputEventKind.WorkshopDownloadRequested, + eventText, + workshopId: requestedWorkshopId)); + } + + currentWorkshopId = requestedWorkshopId; + } + + Match progressMatch = ProgressRegex.Match(record); + if (progressMatch.Success && TryParseProgress(progressMatch.Groups["progress"].Value, out double progress)) + { + events.Add(CreateEvent( + SteamCmdOutputEventKind.Progress, + eventText, + workshopId: currentWorkshopId, + progressPercent: progress)); + } + + Match appSuccessMatch = AppSuccessRegex.Match(record); + if (appSuccessMatch.Success + && uint.TryParse( + appSuccessMatch.Groups["appId"].Value, + NumberStyles.None, + CultureInfo.InvariantCulture, + out uint appId)) + { + events.Add(CreateEvent( + SteamCmdOutputEventKind.AppUpdateSucceeded, + eventText, + appId: appId)); + } + + bool timeout = TimeoutRegex.IsMatch(record); + if (timeout) + { + events.Add(CreateEvent( + SteamCmdOutputEventKind.Timeout, + eventText, + workshopId: failedWorkshopId == 0 ? currentWorkshopId : failedWorkshopId)); + } + + if (ErrorRegex.IsMatch(record) && !loginFailed) + { + events.Add(CreateEvent( + SteamCmdOutputEventKind.Error, + eventText, + workshopId: failedWorkshopId == 0 ? currentWorkshopId : failedWorkshopId)); + } + } + + private bool TryParseWorkshopFailure(string record, out ulong workshopId) + { + Match failureMatch = WorkshopFailureRegex.Match(record); + if (TryParseId(failureMatch, out workshopId)) + { + return true; + } + + Match timeoutMatch = WorkshopTimeoutRegex.Match(record); + return TryParseId(timeoutMatch, out workshopId); + } + + private static bool TryParseWorkshopSuccess(string record, out ulong workshopId) => + TryParseId(WorkshopSuccessRegex.Match(record), out workshopId); + + private static bool TryParseWorkshopRequest(string record, out ulong workshopId) + { + Match downloadingMatch = WorkshopDownloadingRegex.Match(record); + if (TryParseId(downloadingMatch, out workshopId)) + { + return true; + } + + return TryParseId(WorkshopCommandRegex.Match(record), out workshopId); + } + + private static bool TryParseId(Match match, out ulong workshopId) + { + workshopId = 0; + return match.Success + && ulong.TryParse( + match.Groups["id"].Value, + NumberStyles.None, + CultureInfo.InvariantCulture, + out workshopId); + } + + private static bool TryParseProgress(string value, out double progress) => + double.TryParse( + value.Replace(',', '.'), + NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, + out progress); + + private static bool TryGetGuardPromptKind(string value, out SteamCmdGuardPromptKind kind) + { + string prompt = value.Trim(); + if (prompt.Length == 0) + { + kind = default; + return false; + } + + bool mentionsCode = prompt.Contains("code", StringComparison.OrdinalIgnoreCase); + // SteamCMD can print an explanatory sentence followed by the actual + // input prompt on the next line (for example, a Mobile Authenticator + // explanation followed by "Two-factor code:"). Only the terminal + // prompt should cause FASTER to send a secret. + bool asksForInput = prompt.EndsWith(':') || prompt.EndsWith('?'); + + bool mentionsMobileApp = prompt.Contains("mobile app", StringComparison.OrdinalIgnoreCase) + || prompt.Contains("Steam app", StringComparison.OrdinalIgnoreCase); + bool requestsApproval = prompt.Contains("approve", StringComparison.OrdinalIgnoreCase) + || prompt.Contains("confirm", StringComparison.OrdinalIgnoreCase) + || prompt.Contains("sign in request", StringComparison.OrdinalIgnoreCase); + if (mentionsMobileApp && requestsApproval) + { + kind = SteamCmdGuardPromptKind.MobileApproval; + return true; + } + + if (mentionsCode + && asksForInput + && (prompt.Contains("email", StringComparison.OrdinalIgnoreCase) + || prompt.Contains("e-mail", StringComparison.OrdinalIgnoreCase))) + { + kind = SteamCmdGuardPromptKind.EmailCode; + return true; + } + + if (mentionsCode + && asksForInput + && (prompt.Contains("authenticator", StringComparison.OrdinalIgnoreCase) + || prompt.Contains("two-factor", StringComparison.OrdinalIgnoreCase) + || prompt.Contains("two factor", StringComparison.OrdinalIgnoreCase) + || prompt.Contains("2fa", StringComparison.OrdinalIgnoreCase))) + { + kind = SteamCmdGuardPromptKind.AuthenticatorCode; + return true; + } + + if (mentionsCode + && (prompt.EndsWith(':') || prompt.EndsWith('?')) + && prompt.Contains("Steam Guard", StringComparison.OrdinalIgnoreCase)) + { + kind = SteamCmdGuardPromptKind.Unknown; + return true; + } + + kind = default; + return false; + } + + private SteamCmdOutputEvent CreateEvent( + SteamCmdOutputEventKind kind, + string text, + ulong? workshopId = null, + double? progressPercent = null, + uint? appId = null, + SteamCmdGuardPromptKind? guardPromptKind = null) => + new( + kind, + Sanitize(text).Trim(), + workshopId, + progressPercent, + appId, + guardPromptKind); + + private string Sanitize(string value) + { + string sanitized = value; + foreach (string secret in secrets) + { + sanitized = sanitized.Replace(secret, RedactionMarker, StringComparison.Ordinal); + } + + return sanitized; + } + + private enum AnsiState + { + None, + Escape, + EscapeIntermediate, + ControlSequence, + OperatingSystemCommand, + OperatingSystemCommandEscape + } +} diff --git a/FASTER/Services/SteamCmd/SteamCmdPseudoConsole.cs b/FASTER/Services/SteamCmd/SteamCmdPseudoConsole.cs new file mode 100644 index 00000000..7675566a --- /dev/null +++ b/FASTER/Services/SteamCmd/SteamCmdPseudoConsole.cs @@ -0,0 +1,348 @@ +using Microsoft.Win32.SafeHandles; + +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace FASTER.Services.SteamCmd; + +/// +/// Hosts SteamCMD in the native Windows pseudoconsole (ConPTY). SteamCMD does +/// not flush its interactive prompts or consume redirected stdin reliably when +/// it is launched with ordinary anonymous pipes. +/// +internal sealed class SteamCmdPseudoConsole : IDisposable +{ + private const uint ExtendedStartupInfoPresent = 0x00080000; + private const int StartfUseStdHandles = 0x00000100; + private static readonly IntPtr PseudoConsoleAttribute = (IntPtr)0x00020016; + + private readonly FileStream _input; + private readonly FileStream _output; + private IntPtr _pseudoConsole; + private int _closed; + + private SteamCmdPseudoConsole( + Process process, + FileStream input, + FileStream output, + IntPtr pseudoConsole) + { + Process = process; + _input = input; + _output = output; + _pseudoConsole = pseudoConsole; + } + + public Process Process { get; } + + public Stream Input => _input; + + public Stream Output => _output; + + public static SteamCmdPseudoConsole Start(string executablePath, string workingDirectory) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) + { + throw new PlatformNotSupportedException( + "SteamCMD interactive sessions require Windows 10 version 1809, Windows Server 2019, or newer."); + } + + SafeFileHandle? pseudoConsoleInput = null; + SafeFileHandle? hostInput = null; + SafeFileHandle? hostOutput = null; + SafeFileHandle? pseudoConsoleOutput = null; + FileStream? inputStream = null; + FileStream? outputStream = null; + Process? process = null; + IntPtr pseudoConsole = IntPtr.Zero; + IntPtr attributeList = IntPtr.Zero; + ProcessInformation processInformation = default; + + try + { + CreatePipeOrThrow(out pseudoConsoleInput, out hostInput); + CreatePipeOrThrow(out hostOutput, out pseudoConsoleOutput); + + Coord consoleSize = new(160, 40); + int createResult = CreatePseudoConsole( + consoleSize, + pseudoConsoleInput.DangerousGetHandle(), + pseudoConsoleOutput.DangerousGetHandle(), + 0, + out pseudoConsole); + ThrowForHResult(createResult, "Windows could not create a pseudoconsole for SteamCMD."); + + StartupInfoEx startupInfo = new(); + startupInfo.StartupInfo.cb = Marshal.SizeOf(); + // Explicitly clear inherited standard handles. Without this flag a + // child launched by a console-hosted parent (including tests and + // some shells) can keep writing to the parent's console instead of + // the attached ConPTY. + startupInfo.StartupInfo.Flags = StartfUseStdHandles; + + IntPtr attributeListSize = IntPtr.Zero; + _ = InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attributeListSize); + if (attributeListSize == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows could not size the pseudoconsole startup attributes."); + + attributeList = Marshal.AllocHGlobal(attributeListSize); + if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows could not initialize the pseudoconsole startup attributes."); + + startupInfo.AttributeList = attributeList; + if (!UpdateProcThreadAttribute( + attributeList, + 0, + PseudoConsoleAttribute, + pseudoConsole, + (IntPtr)IntPtr.Size, + IntPtr.Zero, + IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows could not attach SteamCMD to its pseudoconsole."); + } + + StringBuilder commandLine = new($"\"{executablePath}\""); + if (!CreateProcessW( + executablePath, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + false, + ExtendedStartupInfoPresent, + IntPtr.Zero, + workingDirectory, + ref startupInfo, + out processInformation)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "SteamCMD could not be started in the pseudoconsole."); + } + + process = Process.GetProcessById(checked((int)processInformation.ProcessId)); + + // ConPTY requires synchronous pipe handles. FileStream still exposes + // task-based reads/writes; its synchronous handles are serviced by + // the session's independent input and output tasks. + inputStream = new FileStream(hostInput, FileAccess.Write, 4096, false); + hostInput = null; + outputStream = new FileStream(hostOutput, FileAccess.Read, 4096, false); + hostOutput = null; + + // Once CreateProcess succeeds, our copies of the ConPTY-facing ends + // must close so broken-pipe/EOF detection works when the session ends. + pseudoConsoleInput.Dispose(); + pseudoConsoleInput = null; + pseudoConsoleOutput.Dispose(); + pseudoConsoleOutput = null; + + SteamCmdPseudoConsole result = new(process, inputStream, outputStream, pseudoConsole); + process = null; + inputStream = null; + outputStream = null; + pseudoConsole = IntPtr.Zero; + return result; + } + catch (EntryPointNotFoundException exception) + { + throw new PlatformNotSupportedException( + "This Windows installation does not provide the ConPTY API required by SteamCMD.", + exception); + } + catch (SteamCmdException) + { + throw; + } + catch (Exception exception) when (exception is Win32Exception or IOException or ArgumentException) + { + throw new SteamCmdException("SteamCMD's interactive console could not be created.", exception); + } + finally + { + if (attributeList != IntPtr.Zero) + { + DeleteProcThreadAttributeList(attributeList); + Marshal.FreeHGlobal(attributeList); + } + + CloseNativeHandle(processInformation.ThreadHandle); + CloseNativeHandle(processInformation.ProcessHandle); + + if (process != null) + { + try + { + if (!process.HasExited) + process.Kill(true); + } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception) + { + } + process.Dispose(); + } + + inputStream?.Dispose(); + outputStream?.Dispose(); + pseudoConsoleInput?.Dispose(); + hostInput?.Dispose(); + hostOutput?.Dispose(); + pseudoConsoleOutput?.Dispose(); + + if (pseudoConsole != IntPtr.Zero) + ClosePseudoConsole(pseudoConsole); + } + } + + /// + /// Ends the console session. Closing the output channel first follows the + /// documented deadlock-safe teardown path on Windows versions before 11 24H2. + /// + public void Close() + { + if (Interlocked.Exchange(ref _closed, 1) != 0) + return; + + _input.Dispose(); + _output.Dispose(); + IntPtr pseudoConsole = Interlocked.Exchange(ref _pseudoConsole, IntPtr.Zero); + if (pseudoConsole != IntPtr.Zero) + ClosePseudoConsole(pseudoConsole); + } + + public void Dispose() + { + Close(); + Process.Dispose(); + } + + private static void CreatePipeOrThrow(out SafeFileHandle readPipe, out SafeFileHandle writePipe) + { + if (!CreatePipe(out readPipe, out writePipe, IntPtr.Zero, 0)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows could not create a SteamCMD console pipe."); + } + + private static void ThrowForHResult(int result, string message) + { + if (result < 0) + throw new SteamCmdException(message, Marshal.GetExceptionForHR(result) ?? new InvalidOperationException(message)); + } + + private static void CloseNativeHandle(IntPtr handle) + { + if (handle != IntPtr.Zero && handle != new IntPtr(-1)) + _ = CloseHandle(handle); + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct Coord + { + public Coord(short x, short y) + { + X = x; + Y = y; + } + + public readonly short X; + public readonly short Y; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + public int cb; + public string? Reserved; + public string? Desktop; + public string? Title; + public int X; + public int Y; + public int XSize; + public int YSize; + public int XCountChars; + public int YCountChars; + public int FillAttribute; + public int Flags; + public short ShowWindow; + public short Reserved2Count; + public IntPtr Reserved2; + public IntPtr StandardInput; + public IntPtr StandardOutput; + public IntPtr StandardError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfoEx + { + public StartupInfo StartupInfo; + public IntPtr AttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + public IntPtr ProcessHandle; + public IntPtr ThreadHandle; + public uint ProcessId; + public uint ThreadId; + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreatePipe( + out SafeFileHandle readPipe, + out SafeFileHandle writePipe, + IntPtr pipeAttributes, + uint size); + + [DllImport("kernel32.dll")] + private static extern int CreatePseudoConsole( + Coord size, + IntPtr input, + IntPtr output, + uint flags, + out IntPtr pseudoConsole); + + [DllImport("kernel32.dll")] + private static extern void ClosePseudoConsole(IntPtr pseudoConsole); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool InitializeProcThreadAttributeList( + IntPtr attributeList, + int attributeCount, + int flags, + ref IntPtr size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UpdateProcThreadAttribute( + IntPtr attributeList, + uint flags, + IntPtr attribute, + IntPtr value, + IntPtr size, + IntPtr previousValue, + IntPtr returnSize); + + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(IntPtr attributeList); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW( + string? applicationName, + StringBuilder commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, + IntPtr environment, + string currentDirectory, + ref StartupInfoEx startupInfo, + out ProcessInformation processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/FASTER/Services/SteamCmd/SteamCmdSession.cs b/FASTER/Services/SteamCmd/SteamCmdSession.cs new file mode 100644 index 00000000..cb1d6eb9 --- /dev/null +++ b/FASTER/Services/SteamCmd/SteamCmdSession.cs @@ -0,0 +1,288 @@ +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading.Channels; + +namespace FASTER.Services.SteamCmd; + +internal sealed class SteamCmdSession : IAsyncDisposable +{ + private static readonly TimeSpan ShutdownGracePeriod = TimeSpan.FromSeconds(7); + + private readonly SteamCmdPseudoConsole _console; + private readonly Process _process; + private readonly StreamWriter _input; + private readonly StreamReader _output; + private readonly SteamCmdOutputParser _outputParser; + private readonly Channel _events; + private readonly SemaphoreSlim _inputGate = new(1, 1); + private readonly IProgress? _progress; + private readonly Task _outputPump; + private int _stopping; + private bool _disposed; + + private SteamCmdSession( + SteamCmdPseudoConsole console, + IEnumerable secrets, + IProgress? progress) + { + _console = console; + _process = console.Process; + _input = new StreamWriter(console.Input, new UTF8Encoding(false), 4096, leaveOpen: true) + { + NewLine = "\r\n" + }; + _input.AutoFlush = true; + _output = new StreamReader(console.Output, new UTF8Encoding(false), true, 4096, leaveOpen: true); + _progress = progress; + _outputParser = new SteamCmdOutputParser(secrets); + _events = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + + _outputPump = Task.Factory.StartNew( + () => PumpOutput(_output, _outputParser), + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + public bool HasExited + { + get + { + try + { + return _process.HasExited; + } + catch (InvalidOperationException) + { + return true; + } + } + } + + public int? ExitCode => HasExited ? _process.ExitCode : null; + + public static SteamCmdSession Start( + string executablePath, + string workingDirectory, + IEnumerable secrets, + IProgress? progress) + { + SteamCmdPseudoConsole? console = null; + try + { + console = SteamCmdPseudoConsole.Start(executablePath, workingDirectory); + SteamCmdSession session = new(console, secrets, progress); + console = null; + return session; + } + catch + { + console?.Dispose(); + throw; + } + } + + public async Task SendCommandAsync(string command, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (string.IsNullOrWhiteSpace(command) || command.IndexOfAny(['\r', '\n', '\0']) >= 0) + throw new ArgumentException("A SteamCMD command must be one non-empty line.", nameof(command)); + + await WriteLineAsync(command, cancellationToken).ConfigureAwait(false); + } + + public async Task SendSecretAsync(string secret, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (secret.IndexOfAny(['\r', '\n', '\0']) >= 0) + throw new ArgumentException("A Steam credential response must be one line.", nameof(secret)); + + _outputParser.AddSecret(secret); + await WriteLineAsync(secret, cancellationToken).ConfigureAwait(false); + } + + public async Task NextEventAsync( + TimeSpan timeout, + CancellationToken cancellationToken) + { + using CancellationTokenSource timeoutSource = new(timeout); + using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutSource.Token); + try + { + return await _events.Reader.ReadAsync(linkedSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && timeoutSource.IsCancellationRequested) + { + throw new SteamCmdException("SteamCMD did not respond before the operation timed out."); + } + catch (ChannelClosedException exception) + { + string exitDescription = ExitCode is int exitCode ? $" (exit code {exitCode})" : string.Empty; + throw new SteamCmdException($"SteamCMD exited before completing the command{exitDescription}.", exception); + } + } + + public async Task QuitAndWaitAsync(CancellationToken cancellationToken) + { + if (!HasExited) + { + try + { + await SendCommandAsync(SteamCmdCommandBuilder.BuildQuitCommand(), cancellationToken).ConfigureAwait(false); + } + catch (SteamCmdException) + { + // The process may exit between HasExited and writing the quit command. + } + catch (IOException) + { + } + catch (InvalidOperationException) + { + } + } + + await WaitForExitOrKillAsync(cancellationToken).ConfigureAwait(false); + return ExitCode; + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _stopping, 1) != 0) + { + if (!HasExited) + await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + return; + } + + if (!HasExited) + { + try + { + await SendCommandAsync(SteamCmdCommandBuilder.BuildQuitCommand(), CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) when (exception is SteamCmdException or IOException or InvalidOperationException or ObjectDisposedException) + { + // The process may exit between HasExited and writing the quit command. + } + } + + await WaitForExitOrKillAsync(cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + await StopAsync(CancellationToken.None).ConfigureAwait(false); + _disposed = true; + _input.Dispose(); + _console.Close(); + await ObservePumpAsync(_outputPump).ConfigureAwait(false); + _output.Dispose(); + _inputGate.Dispose(); + _console.Dispose(); + } + + private async Task WriteLineAsync(string value, CancellationToken cancellationToken) + { + await _inputGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (HasExited) + throw new SteamCmdException("SteamCMD exited before input could be sent."); + await _input.WriteLineAsync(value.AsMemory(), cancellationToken).ConfigureAwait(false); + await _input.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _inputGate.Release(); + } + } + + private void PumpOutput(StreamReader reader, SteamCmdOutputParser parser) + { + char[] buffer = new char[1024]; + try + { + while (true) + { + int read = reader.Read(buffer, 0, buffer.Length); + if (read == 0) + break; + Publish(parser.Feed(new string(buffer, 0, read))); + } + + Publish(parser.Complete()); + } + catch (Exception exception) when (exception is IOException or ObjectDisposedException) + { + } + finally + { + _events.Writer.TryComplete(); + } + } + + private void Publish(IReadOnlyList parsedEvents) + { + foreach (SteamCmdOutputEvent parsedEvent in parsedEvents) + { + _events.Writer.TryWrite(parsedEvent); + if (parsedEvent.Kind == SteamCmdOutputEventKind.Output && !string.IsNullOrWhiteSpace(parsedEvent.Text)) + { + _progress?.Report(new SteamCmdProgress( + SteamCmdProgressKind.Output, + parsedEvent.Text)); + } + } + } + + private async Task WaitForExitOrKillAsync(CancellationToken cancellationToken) + { + if (!HasExited) + { + Task exitTask = _process.WaitForExitAsync(CancellationToken.None); + Task graceTask = Task.Delay(ShutdownGracePeriod, CancellationToken.None); + if (await Task.WhenAny(exitTask, graceTask).ConfigureAwait(false) != exitTask) + TryKillProcessTree(); + } + + if (!HasExited) + await _process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + } + + private void TryKillProcessTree() + { + try + { + if (!HasExited) + _process.Kill(true); + } + catch (InvalidOperationException) + { + } + } + + private static async Task ObservePumpAsync(Task pump) + { + try + { + await pump.ConfigureAwait(false); + } + catch (Exception exception) when (exception is IOException or ObjectDisposedException) + { + } + } +} diff --git a/FASTER/Services/SteamCmd/WorkshopContentMirror.cs b/FASTER/Services/SteamCmd/WorkshopContentMirror.cs new file mode 100644 index 00000000..7ea1d3f6 --- /dev/null +++ b/FASTER/Services/SteamCmd/WorkshopContentMirror.cs @@ -0,0 +1,254 @@ +using System.Globalization; +using System.IO; + +namespace FASTER.Services.SteamCmd; + +/// +/// Promotes a SteamCMD Workshop download into FASTER's managed mod staging directory. +/// The existing staged mod is left untouched until the replacement has been copied in full. +/// +public sealed class WorkshopContentMirror +{ + private const int CopyBufferSize = 128 * 1024; + + public async Task MirrorAsync( + string sourceDirectory, + string stagingDirectory, + ulong workshopId, + CancellationToken cancellationToken = default) + { + if (workshopId == 0) + { + throw new ArgumentOutOfRangeException(nameof(workshopId), "A Workshop id must be greater than zero."); + } + + string sourcePath = NormalizeDirectoryPath(sourceDirectory, nameof(sourceDirectory)); + string stagingPath = NormalizeDirectoryPath(stagingDirectory, nameof(stagingDirectory)); + + if (!Directory.Exists(sourcePath)) + { + throw new DirectoryNotFoundException($"The SteamCMD Workshop directory does not exist: {sourcePath}"); + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (!Directory.EnumerateFileSystemEntries(sourcePath).Any()) + { + throw new InvalidDataException($"The SteamCMD Workshop directory is empty: {sourcePath}"); + } + + // An incoming directory is created below staging. If staging were inside the + // source, recursively copying the source could discover its own output. + if (IsSamePath(sourcePath, stagingPath) || IsStrictChildPath(stagingPath, sourcePath)) + { + throw new InvalidOperationException("The staging directory cannot be the source directory or a child of it."); + } + + string workshopDirectoryName = workshopId.ToString(CultureInfo.InvariantCulture); + string targetPath = GetDirectChildPath(stagingPath, workshopDirectoryName); + string operationId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + string incomingPath = GetDirectChildPath(stagingPath, $"{workshopDirectoryName}.incoming-{operationId}"); + string backupPath = GetDirectChildPath(stagingPath, $"{workshopDirectoryName}.backup-{operationId}"); + + Directory.CreateDirectory(stagingPath); + + if (File.Exists(targetPath)) + { + throw new IOException($"The managed mod target is a file, not a directory: {targetPath}"); + } + + Directory.CreateDirectory(incomingPath); + + try + { + await CopyDirectoryAsync(sourcePath, incomingPath, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + bool oldTargetMoved = false; + try + { + if (Directory.Exists(targetPath)) + { + Directory.Move(targetPath, backupPath); + oldTargetMoved = true; + } + + // Incoming and target are siblings, so this promotion is a same-volume + // directory rename rather than another partial copy. + Directory.Move(incomingPath, targetPath); + } + catch (Exception swapException) + { + if (oldTargetMoved && Directory.Exists(backupPath) && + !Directory.Exists(targetPath) && !File.Exists(targetPath)) + { + try + { + Directory.Move(backupPath, targetPath); + } + catch (Exception rollbackException) + { + throw new IOException( + $"Could not promote Workshop item {workshopId}, and restoring the previous staged copy also failed. " + + $"The previous copy remains at '{backupPath}'.", + new AggregateException(swapException, rollbackException)); + } + } + + throw new IOException($"Could not promote Workshop item {workshopId} into the staging directory.", swapException); + } + + // Promotion succeeded. A failed cleanup is harmless and the uniquely named + // backup can be removed on a later maintenance pass. + DeleteDirectChildBestEffort(stagingPath, backupPath); + return targetPath; + } + catch + { + DeleteDirectChildBestEffort(stagingPath, incomingPath); + throw; + } + } + + private static async Task CopyDirectoryAsync( + string sourcePath, + string destinationPath, + CancellationToken cancellationToken) + { + foreach (string sourceEntry in Directory.EnumerateFileSystemEntries(sourcePath)) + { + cancellationToken.ThrowIfCancellationRequested(); + + FileAttributes attributes = File.GetAttributes(sourceEntry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException($"Workshop content contains an unsupported reparse point: {sourceEntry}"); + } + + string destinationEntry = Path.Combine(destinationPath, Path.GetFileName(sourceEntry)); + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.CreateDirectory(destinationEntry); + await CopyDirectoryAsync(sourceEntry, destinationEntry, cancellationToken).ConfigureAwait(false); + Directory.SetLastWriteTimeUtc(destinationEntry, Directory.GetLastWriteTimeUtc(sourceEntry)); + continue; + } + + await using FileStream sourceStream = new( + sourceEntry, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + CopyBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using FileStream destinationStream = new( + destinationEntry, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + CopyBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + await sourceStream.CopyToAsync(destinationStream, CopyBufferSize, cancellationToken).ConfigureAwait(false); + await destinationStream.FlushAsync(cancellationToken).ConfigureAwait(false); + File.SetLastWriteTimeUtc(destinationEntry, File.GetLastWriteTimeUtc(sourceEntry)); + } + } + + private static string NormalizeDirectoryPath(string path, string parameterName) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("A directory path is required.", parameterName); + } + + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + } + + private static string GetDirectChildPath(string stagingPath, string childName) + { + if (string.IsNullOrWhiteSpace(childName) || + !string.Equals(childName, Path.GetFileName(childName), StringComparison.Ordinal) || + childName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + throw new InvalidOperationException("A managed staging entry must have a single valid directory name."); + } + + string childPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.Combine(stagingPath, childName))); + string? parentPath = Path.GetDirectoryName(childPath); + if (parentPath is null || !IsSamePath(parentPath, stagingPath) || + !string.Equals(Path.GetFileName(childPath), childName, PathComparison)) + { + throw new InvalidOperationException("A managed mod target must be a direct child of the staging directory."); + } + + return childPath; + } + + private static bool IsStrictChildPath(string candidatePath, string parentPath) + { + string relativePath = Path.GetRelativePath(parentPath, candidatePath); + return !Path.IsPathRooted(relativePath) && + !string.Equals(relativePath, ".", StringComparison.Ordinal) && + !string.Equals(relativePath, "..", StringComparison.Ordinal) && + !relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) && + !relativePath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal); + } + + private static bool IsSamePath(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + PathComparison); + + private static void DeleteDirectChildBestEffort(string stagingPath, string candidatePath) + { + try + { + string childPath = GetDirectChildPath(stagingPath, Path.GetFileName(candidatePath)); + if (!IsSamePath(childPath, candidatePath) || !Directory.Exists(childPath)) + { + return; + } + + DeleteDirectoryTreeWithoutFollowingReparsePoints(childPath); + } + catch + { + // Cleanup must never hide the result of the copy, promotion, or rollback. + } + } + + private static void DeleteDirectoryTreeWithoutFollowingReparsePoints(string directoryPath) + { + DirectoryInfo directory = new(directoryPath); + if ((directory.Attributes & FileAttributes.ReparsePoint) != 0) + { + directory.Delete(); + return; + } + + foreach (FileSystemInfo entry in directory.EnumerateFileSystemInfos()) + { + if ((entry.Attributes & FileAttributes.ReparsePoint) != 0) + { + entry.Delete(); + } + else if (entry is DirectoryInfo childDirectory) + { + DeleteDirectoryTreeWithoutFollowingReparsePoints(childDirectory.FullName); + } + else + { + entry.Attributes = FileAttributes.Normal; + entry.Delete(); + } + } + + directory.Attributes = FileAttributes.Directory; + directory.Delete(); + } + + private static StringComparison PathComparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; +} diff --git a/FASTER/ViewModel/SteamUpdaterViewModel.cs b/FASTER/ViewModel/SteamUpdaterViewModel.cs index ee740d8b..8fc66d45 100644 --- a/FASTER/ViewModel/SteamUpdaterViewModel.cs +++ b/FASTER/ViewModel/SteamUpdaterViewModel.cs @@ -1,12 +1,5 @@ -using BytexDigital.Steam.ContentDelivery; -using BytexDigital.Steam.ContentDelivery.Exceptions; -using BytexDigital.Steam.ContentDelivery.Models; -using BytexDigital.Steam.ContentDelivery.Models.Downloading; -using BytexDigital.Steam.Core; -using BytexDigital.Steam.Core.Exceptions; -using BytexDigital.Steam.Core.Structs; - using FASTER.Models; +using FASTER.Services.SteamCmd; using MahApps.Metro.Controls.Dialogs; @@ -14,59 +7,74 @@ using System.Collections.ObjectModel; using System.ComponentModel; -using System.Configuration; using System.Diagnostics; using System.IO; +using System.Windows; using System.Windows.Threading; namespace FASTER.ViewModel { public sealed class SteamUpdaterViewModel : INotifyPropertyChanged, IDisposable { + private const int MaximumConsoleCharacters = 200_000; + private static readonly Lazy Lazy = + new(() => new SteamUpdaterViewModel(new SteamUpdaterModel())); + + private static readonly string LogFilePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "FASTER", "Logs", "SteamCmd.log"); + + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly WorkshopContentMirror _contentMirror = new(); + private readonly DispatcherTimer _statusTimer; + private readonly object _logGate = new(); + + private CancellationTokenSource _tokenSource = new(); + private SteamCmdClient? _steamCmdClient; + private StreamWriter? _logWriter; + private string _sessionPassword = string.Empty; + private string? _lastProgressMessage; + private bool _isBusy; + private bool _updaterOnline; + private bool _updaterFaulted; + private bool _disposed; + public SteamUpdaterViewModel() + : this(new SteamUpdaterModel()) { - Parameters = new SteamUpdaterModel(); } - private static readonly Lazy - lazy = - new(() => new SteamUpdaterViewModel(new SteamUpdaterModel())); - - public static SteamUpdaterViewModel Instance => lazy.Value; - private SteamUpdaterViewModel(SteamUpdaterModel model) { - Parameters = model; - DownloadTasks.ListChanged += (_, _) => RaisePropertyChanged(nameof(IsDownloading)); - var timer = new DispatcherTimer + Parameters = model; + MigrateLegacyPasswordToSession(); + + _statusTimer = new DispatcherTimer { - Interval = TimeSpan.FromSeconds(1) + Interval = TimeSpan.FromSeconds(1), + IsEnabled = true }; - timer.Tick += Timer_Tick; - timer.IsEnabled = true; + _statusTimer.Tick += Timer_Tick; } - private bool _isLoggingIn; - private bool _isDlOverride; - private bool _updaterOnline; - private bool _updaterFaulted; - - public SteamUpdaterModel Parameters { get; set; } + public static SteamUpdaterViewModel Instance => Lazy.Value; + public SteamUpdaterModel Parameters { get; } - public IDialogCoordinator DialogCoordinator { get; set; } - private CancellationTokenSource tokenSource = new(); + public IDialogCoordinator DialogCoordinator { get; set; } = null!; - public bool IsDownloading => DownloadTasks.Count > 0 || IsLoggingIn || IsDlOverride; - - private BindingList DownloadTasks { get; } = new BindingList(); + public bool IsDownloading => _isBusy; + public bool CanConfigureUpdater => !_isBusy; public bool UpdaterOnline { get => _updaterOnline; - set + private set { + if (_updaterOnline == value) + return; + _updaterOnline = value; RaisePropertyChanged(nameof(UpdaterOnline)); } @@ -75,704 +83,832 @@ public bool UpdaterOnline public bool UpdaterFaulted { get => _updaterFaulted; - set + private set { + if (_updaterFaulted == value) + return; + _updaterFaulted = value; RaisePropertyChanged(nameof(UpdaterFaulted)); } } - public bool IsLoggingIn + public void PasswordChanged(string password) { - get => _isLoggingIn; - set - { - _isLoggingIn = value; - RaisePropertyChanged(nameof(IsLoggingIn)); - RaisePropertyChanged(nameof(IsDownloading)); - } + _sessionPassword = password ?? string.Empty; } - public bool IsDlOverride + internal string GetPw() => _sessionPassword; + + public async Task UpdateClick() { - get => _isDlOverride; - set + Analytics.TrackEvent("Updater - Clicked Update", new Dictionary { - _isDlOverride = value; - RaisePropertyChanged(nameof(IsDlOverride)); - RaisePropertyChanged(nameof(IsDownloading)); - } - } + {"Name", Parameters.Username ?? string.Empty}, + {"Branch", Parameters.ServerBranch} + }); - internal SteamClient SteamClient; - internal SteamContentClient SteamContentClient; + Parameters.Output = "Starting the Arma 3 server update through SteamCMD..."; + int result = await RunServerUpdater(Parameters.InstallDirectory, Parameters.ServerBranch); - public void PasswordChanged(string password) - { - Parameters.Password = Encryption.Instance.EncryptData(password); + if (result == UpdateState.Success) + AppendOutput("Server update completed."); } - private void Timer_Tick(object sender, EventArgs e) + public void UpdateCancelClick() { - if (SteamClient == null) - { - UpdaterFaulted = false; - UpdaterOnline = false; + if (!_isBusy) return; + + AppendOutput("Cancellation requested. SteamCMD will exit after a short grace period."); + _tokenSource.Cancel(); + + SteamCmdClient? client = _steamCmdClient; + if (client != null) + { + _ = Task.Run(async () => + { + try + { + await client.CancelAsync(); + } + catch (Exception exception) + { + AppendOutput($"SteamCMD cancellation warning: {exception.Message}"); + } + }); } + } - UpdaterFaulted = SteamClient.IsFaulted; - UpdaterOnline = SteamClient.IsConnected; + public void ModStagingDirClick() + { + string path = MainWindow.Instance.SelectFolder(Parameters.ModStagingDirectory); + if (!string.IsNullOrWhiteSpace(path)) + Parameters.ModStagingDirectory = path; } - internal string GetPw() - { return Encryption.Instance.DecryptData(Parameters.Password); } + public void ServerDirClick() + { + string path = MainWindow.Instance.SelectFolder(Parameters.InstallDirectory); + if (!string.IsNullOrWhiteSpace(path)) + Parameters.InstallDirectory = path; + } - public async Task UpdateClick() + public void SteamCmdDirClick() { - Analytics.TrackEvent("Updater - Clicked Update", new Dictionary + if (_isBusy) { - {"Name", Properties.Settings.Default.steamUserName}, - {"DLCs", $"{(Parameters.UsingGMDlc ? "GM " : "")}{(Parameters.UsingCSLADlc? "CSLA " : "")}{(Parameters.UsingPFDlc ? "SOG " : "")}{(Parameters.UsingWSDlc ? "WS " : "")}{(Parameters.UsingSPEDlc ? "SPE " : "")}{(Parameters.UsingRFDlc ? "RF " : "")}{(Parameters.UsingEFDlc ? "EF " : "")}"}, - {"Branch", $"{(Parameters.UsingPerfBinaries? "Profiling" : "Public")}"} - }); - - Parameters.IsUpdating = true; - Parameters.Output = "Starting Update..."; - Parameters.Output += "\nPlease don't quit this page or cancel the download\nThis might take a while..."; - - uint appId = 233780; - Dictionary depotsIDs = new() - //Find a way to update automatically with depot changes - { - {233781, "Arma 3 Alpha Dedicated Server Content (internal)"}, - {233782, "Arma 3 Alpha Dedicated Server binary Windows (internal)"}, - {233783, "Arma 3 Alpha Dedicated Server binary Linux (internal)"}, - {233784, "Arma 3 Server Profiling - WINDOWS Depot"}, - {233785, "Arma 3 Server - Profiler - LINUX Depot"}, - {233792, "Arma 3 Server Creator DLC - GM"}, - {233788, "Arma 3 Server Creator DLC - SPE"}, - {233793, "Arma 3 Server Creator DLC - CSLA"}, - {233794, "Arma 3 Server Creator DLC - SOGPF"}, - {233795, "Arma 3 Server Creator DLC - WS"}, - {233799, "Arma 3 Server Creator DLC - RF"}, - {233798, "Arma 3 Server Creator DLC - EF"}, - }; - - //IReadOnlyList depotsList; - - //try - //{ depotsList = await GetAppDepots(appId); } - //catch - //{ - // Parameters.Output += "\n\n /!\\ Something went wrong while getting the depots list. Check login/password and your internet connexion.\nAlternatively, clear the sentry folder and try again."; - // return; - //} - - - //if(depotsList == null || depotsList.Count == 0) - //{ - // Parameters.Output += "\n\n /!\\ Could not retrieve depots list. PLease retry later or check your internet connection\nAlternatively, clear the sentry folder and try again."; - // return; - //} - - List<(uint id, string branch, string pass)> depotsDownload = new(); - - Parameters.Output += "\nChecking Shared Content..."; - //Downloading Depot 233781 from either branch contact or public - depotsDownload.Add(( - depotsIDs.FirstOrDefault(d => d.Value == "Arma 3 Alpha Dedicated Server Content (internal)").Key, - Parameters.UsingContactDlc ? "contact" : "public", - null)); - - Parameters.Output += "\nChecking Executables..."; - - - // Downloading depot 233782 fow Windows from branch public - depotsDownload.Add(( - depotsIDs.FirstOrDefault(d => d.Value == "Arma 3 Alpha Dedicated Server binary Windows (internal)").Key, - "public", - null)); + AppendOutput("SteamCMD's directory cannot be changed while an update is running."); + return; + } + string path = MainWindow.Instance.SelectFolder(Parameters.SteamCmdDirectory); + if (string.IsNullOrWhiteSpace(path)) + return; - //Download depot 233784 for windows in branch profiling - if (Parameters.UsingPerfBinaries) - depotsDownload.Add(( - depotsIDs.FirstOrDefault(d => d.Value == "Arma 3 Server Profiling - WINDOWS Depot").Key, - "profiling", - "CautionSpecialProfilingAndTestingBranchArma3")); + Parameters.SteamCmdDirectory = path; + DisposeSteamCmdClient(); + RefreshSteamCmdStatus(); + } + public async Task PrepareSteamCmdAsync() + { + if (!await TryBeginOperationAsync()) + return false; - //Downloading mods - if (Parameters.UsingGMDlc) + try { - Parameters.Output += "\nChecking Arma 3 Server Creator DLC - GM..."; - depotsDownload.Add(( - depotsIDs.FirstOrDefault(d => d.Value == "Arma 3 Server Creator DLC - GM").Key, - "creatordlc", - null)); + await EnsureSteamCmdInstalledAsync(CreateProgressReporter(), _tokenSource.Token); + AppendOutput("SteamCMD is ready."); + return true; } - - if (Parameters.UsingCSLADlc) + catch (OperationCanceledException) { - Parameters.Output += "\nChecking Arma 3 Server Creator DLC - CSLA..."; - depotsDownload.Add(( - depotsIDs.FirstOrDefault(d => d.Value == "Arma 3 Server Creator DLC - CSLA").Key, - "creatordlc", - null)); + AppendOutput("SteamCMD preparation was cancelled."); + return false; } - - if (Parameters.UsingPFDlc) + catch (Exception exception) { - Parameters.Output += "\nChecking Arma 3 Server Creator DLC - SOGPF..."; - depotsDownload.Add(( - depotsIDs.FirstOrDefault(d => d.Value == "Arma 3 Server Creator DLC - SOGPF").Key, - "creatordlc", - null)); + UpdaterFaulted = true; + AppendOutput($"SteamCMD preparation failed: {exception.Message}"); + return false; } - - if (Parameters.UsingWSDlc) + finally { - Parameters.Output += "\nChecking Arma 3 Server Creator DLC - Western Sahara..."; - depotsDownload.Add(( - depotsIDs.FirstOrDefault(d => d.Value == "Arma 3 Server Creator DLC - WS").Key, - "creatordlc", - null)); + EndOperation(); } + } - if (Parameters.UsingSPEDlc) + public bool ResetSteamCmd() + { + if (_isBusy || _steamCmdClient?.IsRunning == true) { - Parameters.Output += "\nChecking Arma 3 Server Creator DLC - SPE..."; - depotsDownload.Add(( - depotsIDs.FirstOrDefault(d => d.Value == "Arma 3 Server Creator DLC - SPE").Key, - "creatordlc", - null)); + AppendOutput("Cancel the active update before resetting SteamCMD."); + return false; } - if (Parameters.UsingRFDlc) + try { - Parameters.Output += "\nChecking Arma 3 Server Creator DLC - RF..."; - depotsDownload.Add(( - depotsIDs.FirstOrDefault(d => d.Value == "Arma 3 Server Creator DLC - RF").Key, - "creatordlc", - null)); + _steamCmdClient?.Reset(); + DisposeSteamCmdClient(); + UpdaterFaulted = false; + RefreshSteamCmdStatus(); + AppendOutput("SteamCMD process state was reset. Cached login and downloaded content were kept."); + return true; } - - if (Parameters.UsingEFDlc) + catch (Exception exception) { - Parameters.Output += "\nChecking Arma 3 Server Creator DLC - EF..."; - depotsDownload.Add(( - depotsIDs.FirstOrDefault(d => d.Value == "Arma 3 Server Creator DLC - EF").Key, - "creatordlc", - null)); + UpdaterFaulted = true; + AppendOutput($"SteamCMD reset failed: {exception.Message}"); + return false; } - - await RunServerUpdater(Parameters.InstallDirectory, appId, depotsDownload); - - Parameters.Output += "\n\nAll Done ! "; - } - - public void UpdateCancelClick() - { - Parameters.Output += "\nUpdate Cancelled."; - Parameters.IsUpdating = false; - - tokenSource.Cancel(); - } - - public void ModStagingDirClick() - { - string path = MainWindow.Instance.SelectFolder(Parameters.ModStagingDirectory); - - if (path == null) - return; - - Parameters.ModStagingDirectory = path; - } - - public void ServerDirClick() - { - string path = MainWindow.Instance.SelectFolder(Parameters.InstallDirectory); - - if (path == null) - return; - - Parameters.InstallDirectory = path; } - internal async Task RunServerUpdater(string path, uint appId, List<(uint id, string branch, string pass)> depots) + internal async Task RunServerUpdater(string path, string branchName) { if (string.IsNullOrWhiteSpace(path)) + { + AppendOutput("Select a server install directory before updating."); return UpdateState.Cancelled; + } - if (!Directory.Exists(path)) - Directory.CreateDirectory(path); - tokenSource = new CancellationTokenSource(); - - if (!await SteamLogin()) - return UpdateState.LoginFailed; - - Stopwatch sw = Stopwatch.StartNew(); - - foreach (var depot in depots) + if (!TryParseServerBranch(branchName, out SteamCmdServerBranch branch)) { - try - { - ManifestId manifestId; - manifestId = await SteamContentClient.GetDepotManifestIdAsync(appId, depot.id, depot.branch, depot.pass); + AppendOutput($"Unsupported SteamCMD server branch: {branchName}"); + return UpdateState.Error; + } - Parameters.Output += $"\n\nFetching informations of app {appId}, depot {depot.id} from Steam ({depots.IndexOf(depot)+1}/{depots.Count})... "; - var downloadHandler = await SteamContentClient.GetAppDataAsync(appId, depot.id, manifestId, tokenSource.Token); + if (!await TryBeginOperationAsync()) + return UpdateState.Cancelled; - await Download(downloadHandler, path); - } - catch (ArgumentException ex) + Stopwatch stopwatch = Stopwatch.StartNew(); + try + { + SteamCmdClient client = await EnsureSteamCmdInstalledAsync(CreateProgressReporter(), _tokenSource.Token); + AppendOutput($"Updating Arma 3 Dedicated Server ({branchName})..."); + + string username = Parameters.Username?.Trim() ?? string.Empty; + string password = username.Length == 0 ? string.Empty : _sessionPassword; + + // App 233780 can be updated anonymously, but prefer the configured + // account whenever one is supplied. Besides honoring the updater UI, + // this keeps the authenticated SteamCMD session ready for Workshop + // content that requires the account's Arma 3 entitlement. A blank + // username deliberately retains SteamCMD's anonymous fallback. + SteamCmdServerUpdateResult result = await client.UpdateServerAsync( + username, + password, + path, + branch, + RequestGuardResponseAsync, + CreateProgressReporter(), + _tokenSource.Token); + + if (result.Cancelled) { - if(ex.Message.Contains("'tasks'")) - Parameters.Output += "\nSkipped..."; - else - { - throw; - } + AppendOutput("Server update cancelled."); + return UpdateState.Cancelled; } - catch (Exception ex) + + if (!result.Success) { - Parameters.Output += $"\nError: {ex.Message}{(ex.InnerException != null ? $" Inner Exception: {ex.InnerException.Message}" : "")}"; + AppendOutput(FormatSteamCmdFailure("Server update failed", result.Error, result.ExitCode)); return UpdateState.Error; } - } - sw.Stop(); - Parameters.Output += $"\nDone in {sw.Elapsed.Hours}h {sw.Elapsed.Minutes}m {sw.Elapsed.Seconds}s {sw.Elapsed.Milliseconds}ms"; - return 0; + Parameters.Progress = 100; + AppendOutput($"Server files verified in {FormatElapsed(stopwatch.Elapsed)}."); + return UpdateState.Success; + } + catch (OperationCanceledException) + { + AppendOutput("Server update cancelled."); + return UpdateState.Cancelled; + } + catch (SteamCmdAuthenticationException exception) + { + AppendOutput($"Steam login failed: {exception.Message}"); + return UpdateState.LoginFailed; + } + catch (Exception exception) + { + UpdaterFaulted = true; + AppendOutput($"Server update failed: {exception.Message}"); + return UpdateState.Error; + } + finally + { + stopwatch.Stop(); + EndOperation(); + } } public async Task RunModUpdater(ulong modId, string path) { - tokenSource = new CancellationTokenSource(); + if (modId == 0) + return UpdateState.Error; - try - { - //if(SteamClient is {IsConnected: true}) - //{ - // SteamClient?.Shutdown(); - // SteamClient?.Dispose(); - // SteamClient = null; - //} - if (!await SteamLogin()) - return UpdateState.LoginFailed; - } - catch(Exception) + if (string.IsNullOrWhiteSpace(Parameters.Username)) { + AppendOutput("A Steam account that owns Arma 3 is required for Workshop downloads. An API key is not a download login."); return UpdateState.LoginFailed; } - Stopwatch sw = Stopwatch.StartNew(); + if (!await TryBeginOperationAsync()) + return UpdateState.Cancelled; + Stopwatch stopwatch = Stopwatch.StartNew(); try { - ManifestId manifestId = default; + SteamCmdClient client = await EnsureSteamCmdInstalledAsync(CreateProgressReporter(), _tokenSource.Token); + AppendOutput($"Downloading Workshop item {modId} through SteamCMD..."); - Parameters.Output += $"\nFetching mod {modId} infos... "; + SteamCmdWorkshopBatchResult batch = await client.DownloadWorkshopItemsAsync( + Parameters.Username, + _sessionPassword, + new[] {modId}, + RequestGuardResponseAsync, + CreateProgressReporter(), + _tokenSource.Token); - if (!SteamClient.Credentials.IsAnonymous) //IS SYNC ENABLED - { - manifestId = (await SteamContentClient.GetPublishedFileDetailsAsync(modId)).hcontent_file; - Manifest manifest = await SteamContentClient.GetManifestAsync(107410, 107410, manifestId); + if (batch.Cancelled) + return UpdateState.Cancelled; - SyncDeleteRemovedFiles(path, manifest); + SteamCmdWorkshopItemResult? item = batch.Items.FirstOrDefault(result => result.WorkshopId == modId); + if (item == null || !item.Success) + { + string? error = item?.Error ?? batch.Error; + AppendOutput(FormatSteamCmdFailure($"Workshop item {modId} failed", error, batch.ExitCode)); + return IsLoginFailure(error) ? UpdateState.LoginFailed : UpdateState.Error; } - Parameters.Output += $"\nAttempting to start download of item {modId}... "; + string stagingRoot = GetStagingRoot(path, modId); + await _contentMirror.MirrorAsync(item.SourcePath, stagingRoot, modId, _tokenSource.Token); - var downloadHandler = await SteamContentClient.GetPublishedFileDataAsync(modId, manifestId, tokenSource.Token); - - await Download(downloadHandler, path); + Parameters.Progress = 100; + AppendOutput($"Workshop item {modId} was staged in {FormatElapsed(stopwatch.Elapsed)}."); + return UpdateState.Success; } - catch (TaskCanceledException) + catch (OperationCanceledException) { - sw.Stop(); - SteamClient.Shutdown(); - SteamClient.Dispose(); - SteamClient = null; + AppendOutput($"Workshop item {modId} was cancelled."); return UpdateState.Cancelled; } - catch (Exception ex) + catch (SteamCmdAuthenticationException exception) { - sw.Stop(); - Parameters.Output += $"\nError: {ex.Message}{(ex.InnerException != null ? $" Inner Exception: {ex.InnerException.Message}" : "")}"; - SteamClient.Shutdown(); - SteamClient.Dispose(); - SteamClient = null; + AppendOutput($"Steam login failed: {exception.Message}"); + return UpdateState.LoginFailed; + } + catch (Exception exception) + { + AppendOutput($"Workshop item {modId} failed: {exception.Message}"); return UpdateState.Error; } - - sw.Stop(); - Parameters.Output += $"\nDownload completed, it took {sw.Elapsed.Minutes + sw.Elapsed.Hours * 60}m {sw.Elapsed.Seconds}s {sw.Elapsed.Milliseconds}ms"; - return UpdateState.Success; + finally + { + stopwatch.Stop(); + EndOperation(); + } } - public async Task RunModsUpdater(ObservableCollection mods) { + ArgumentNullException.ThrowIfNull(mods); - tokenSource = new CancellationTokenSource(); - if(!await SteamLogin()) - { - IsLoggingIn = false; + List candidates = mods.Where(mod => !mod.IsLocal).ToList(); + if (candidates.Count == 0) + { + Parameters.Progress = 0; + AppendOutput("No downloadable Workshop mods were selected."); + return UpdateState.Cancelled; + } + + List pending = new(); + + foreach (ArmaMod mod in candidates) + { + if (mod.LocalLastUpdated > mod.SteamLastUpdated && mod.Size > 0) + { + mod.Status = ArmaModStatus.UpToDate; + AppendOutput($"Workshop item {mod.WorkshopId} is already up to date."); + } + else + { + pending.Add(mod); + } + } + + if (pending.Count == 0) + { + Parameters.Progress = 100; + AppendOutput("All selected Workshop mods are already up to date."); + return UpdateState.Success; + } + + if (string.IsNullOrWhiteSpace(Parameters.Username)) + { + foreach (ArmaMod mod in pending) + mod.Status = ArmaModStatus.NotComplete; + + AppendOutput("A Steam account that owns Arma 3 is required for Workshop downloads. An API key is not a download login."); return UpdateState.LoginFailed; } - Parameters.Output += "\nAdding mods to download list..."; + string stagingRoot; + try + { + stagingRoot = SteamCmdCommandBuilder.ValidateAndNormalizePath( + Parameters.ModStagingDirectory, + nameof(Parameters.ModStagingDirectory)); + Directory.CreateDirectory(stagingRoot); + } + catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException) + { + foreach (ArmaMod mod in pending) + mod.Status = ArmaModStatus.NotComplete; + + AppendOutput($"The mod staging directory is unavailable: {exception.Message}"); + return UpdateState.Error; + } - SemaphoreSlim maxThread = new(1); - var ml = mods.Where(m => !m.IsLocal).ToList(); - uint finished = 0; - IsDlOverride = true; + if (!await TryBeginOperationAsync()) + return UpdateState.Cancelled; - foreach (ArmaMod mod in ml) + try { - await maxThread.WaitAsync(); + SteamCmdClient client = await EnsureSteamCmdInstalledAsync(CreateProgressReporter(), _tokenSource.Token); + List workshopIds = pending.Select(mod => (ulong)mod.WorkshopId).Distinct().ToList(); + Dictionary positions = workshopIds + .Select((id, index) => (id, index)) + .ToDictionary(entry => entry.id, entry => entry.index); - _ = Task.Factory.StartNew(() => + AppendOutput($"Downloading {workshopIds.Count} Workshop item(s) in one SteamCMD session..."); + + IProgress progress = new Progress(update => { - if (!Directory.Exists(mod.Path)) - Directory.CreateDirectory(mod.Path); + ReportSteamCmdProgress(update); + if (update.WorkshopId is ulong id && update.Percentage is double itemPercentage && + positions.TryGetValue(id, out int index)) + { + Parameters.Progress = Math.Clamp( + (index + Math.Clamp(itemPercentage, 0, 100) / 100d) / workshopIds.Count * 100d, + 0, + 100); + } + }); - if (tokenSource.Token.IsCancellationRequested) + SteamCmdWorkshopBatchResult batch = await client.DownloadWorkshopItemsAsync( + Parameters.Username, + _sessionPassword, + workshopIds, + RequestGuardResponseAsync, + progress, + _tokenSource.Token); + + Dictionary results = batch.Items + .GroupBy(item => item.WorkshopId) + .ToDictionary(group => group.Key, group => group.Last()); + + int completed = 0; + int failed = 0; + foreach (ArmaMod mod in pending) + { + if (!results.TryGetValue(mod.WorkshopId, out SteamCmdWorkshopItemResult? item) || !item.Success) { mod.Status = ArmaModStatus.NotComplete; - return; + failed++; + string? error = item?.Error ?? batch.Error ?? "SteamCMD returned no result for this item."; + if (!batch.Cancelled || !string.Equals(error, "Cancelled.", StringComparison.OrdinalIgnoreCase)) + AppendOutput($"Workshop item {mod.WorkshopId} failed: {error}"); + Parameters.Progress = (completed + failed) / (double)pending.Count * 100d; + continue; } - Parameters.Output += $"\n Starting {mod.WorkshopId}"; - Stopwatch sw = Stopwatch.StartNew(); try { - ManifestId manifestId = default; - - if(mod.LocalLastUpdated > mod.SteamLastUpdated && mod.Size > 0) - { - mod.Status = ArmaModStatus.UpToDate; - Parameters.Output += $"\n Mod{mod.WorkshopId} already up to date. Ignoring..."; - return; - } - - if (!SteamClient.Credentials.IsAnonymous) //IS SYNC NEABLED - { - Parameters.Output += $"\n Getting manifest for {mod.WorkshopId}"; - manifestId = SteamContentClient.GetPublishedFileDetailsAsync(mod.WorkshopId).Result.hcontent_file; - Manifest manifest = SteamContentClient.GetManifestAsync(107410, 107410, manifestId).Result; - Parameters.Output += $"\n Manifest retrieved {mod.WorkshopId}"; - SyncDeleteRemovedFiles(mod.Path, manifest); - } - - Parameters.Output += $"\n Attempting to start download of item {mod.WorkshopId}... "; - - var downloadHandler = SteamContentClient.GetPublishedFileDataAsync(mod.WorkshopId, manifestId, tokenSource.Token); - DownloadForMultiple(downloadHandler.Result, mod.Path).Wait(); - + // Once SteamCMD has reported a complete item, promote it even if + // cancellation stopped the rest of the batch. The mirror swaps the + // old target only after the new copy is complete, so this preserves + // every successful download without exposing partial staged content. + string target = await _contentMirror.MirrorAsync( + item.SourcePath, + stagingRoot, + mod.WorkshopId, + CancellationToken.None); + + mod.Path = target; mod.Status = ArmaModStatus.UpToDate; - var nx = DateTime.UnixEpoch; - var ts = DateTime.UtcNow - nx; - mod.LocalLastUpdated = (ulong)ts.TotalSeconds; + mod.LocalLastUpdated = (ulong)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds; + await Task.Run(mod.CheckModSize, CancellationToken.None); + completed++; + Parameters.Progress = (completed + failed) / (double)pending.Count * 100d; + AppendOutput($"Workshop item {mod.WorkshopId} downloaded and staged ({completed + failed}/{pending.Count})."); } - catch (TaskCanceledException) + catch (Exception exception) { - sw.Stop(); mod.Status = ArmaModStatus.NotComplete; + failed++; + Parameters.Progress = (completed + failed) / (double)pending.Count * 100d; + AppendOutput($"Workshop item {mod.WorkshopId} downloaded, but staging failed: {exception.Message}"); } - catch (Exception ex) - { - sw.Stop(); - mod.Status = ArmaModStatus.NotComplete; - Parameters.Output += $"\nError: {ex.Message}{(ex.InnerException != null ? $" Inner Exception: {ex.InnerException.Message}" : "")}"; - } - sw.Stop(); - - mod.CheckModSize(); + } - Parameters.Output += $"\n Download {mod.WorkshopId} completed, it took {sw.Elapsed.Minutes + sw.Elapsed.Hours*60}m {sw.Elapsed.Seconds}s {sw.Elapsed.Milliseconds}ms"; + if (batch.Cancelled || _tokenSource.IsCancellationRequested) + { + AppendOutput( + $"Mod update cancelled. {completed} completed item(s) were staged; " + + $"{failed} item(s) were unfinished or failed."); + return UpdateState.Cancelled; + } - }, TaskCreationOptions.LongRunning).ContinueWith((_) => + if (failed > 0 || !batch.Success) { - finished += 1; - Parameters.Output += $"\n Thread {mod.WorkshopId} complete ({finished} / {ml.Count})"; - Parameters.Progress = finished * ml.Count / 100.00; - maxThread.Release(); - }); + string? error = batch.Error ?? results.Values.FirstOrDefault(item => !item.Success)?.Error; + AppendOutput($"Mod update finished with {failed} failure(s). Successful items were kept."); + return IsLoginFailure(error) && completed == 0 ? UpdateState.LoginFailed : UpdateState.Error; + } + + Parameters.Progress = 100; + AppendOutput($"All {completed} mod(s) were updated successfully."); + return UpdateState.Success; } + catch (OperationCanceledException) + { + foreach (ArmaMod mod in pending.Where(mod => mod.Status != ArmaModStatus.UpToDate)) + mod.Status = ArmaModStatus.NotComplete; - Parameters.Output += "\nAlmost there..."; - await maxThread.WaitAsync(); + AppendOutput("Mod update cancelled before the batch completed."); + return UpdateState.Cancelled; + } + catch (SteamCmdAuthenticationException exception) + { + foreach (ArmaMod mod in pending) + mod.Status = ArmaModStatus.NotComplete; + + AppendOutput($"Steam login failed: {exception.Message}"); + return UpdateState.LoginFailed; + } + catch (Exception exception) + { + foreach (ArmaMod mod in pending.Where(mod => mod.Status != ArmaModStatus.UpToDate)) + mod.Status = ArmaModStatus.NotComplete; + AppendOutput($"Mod update failed: {exception.Message}"); + return UpdateState.Error; + } + finally + { + EndOperation(); + } + } - Parameters.Output += "\nMods updated !"; - IsDlOverride = false; - return UpdateState.Success; + public async Task SteamGuardInput() + { + EnsureDialogCoordinator(); + return await DialogCoordinator.ShowInputAsync(this, "Steam Guard", "Enter the Steam Guard code") ?? string.Empty; } - internal async Task SteamLogin() + public async Task SteamGuardInputPhone() { - if (tokenSource.IsCancellationRequested) - tokenSource = new CancellationTokenSource(); - IsLoggingIn = true; - var path = Path.Combine(Path.GetDirectoryName(ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.PerUserRoamingAndLocal).FilePath) ?? string.Empty, "sentries"); + EnsureDialogCoordinator(); + return await DialogCoordinator.ShowMessageAsync( + this, + "Steam Guard", + "Approve the login in the Steam mobile app, then press OK. Press Cancel to enter a code instead.", + MessageDialogStyle.AffirmativeAndNegative); + } - SteamCredentials _steamCredentials = new(Parameters.Username, Encryption.Instance.DecryptData(Parameters.Password)); + public event PropertyChangedEventHandler? PropertyChanged; - if (SteamClient == null || SteamClient.Credentials.Username != _steamCredentials.Username || SteamClient.Credentials.Password != _steamCredentials.Password) - { - SteamClient = new SteamClient(_steamCredentials, new AuthCodeProvider(_steamCredentials.Username, path)); - SteamClient.InternalClientAttemptingConnect += () => Parameters.Output += "\n\tClient : Attempting connect.."; - SteamClient.InternalClientConnected += () => Parameters.Output += "\n\tClient : Connected"; - SteamClient.InternalClientDisconnected += () => Parameters.Output += "\n\tClient : Disconnected"; - SteamClient.InternalClientLoggedOn += () => Parameters.Output += "\n\tClient : Logged on"; - SteamClient.InternalClientLoggedOff += () => Parameters.Output += "\n\tClient : Logged off"; - } + public void Dispose() + { + if (_disposed) + return; - if (!SteamClient.IsConnected || SteamClient.IsFaulted) - { - Parameters.Output += $"\nConnecting to Steam as {(_steamCredentials.IsAnonymous ? "anonymous" : _steamCredentials.Username)}"; - SteamClient.MaximumLogonAttempts = 5; - try - { await SteamClient.ConnectAsync(tokenSource.Token); } - catch (SteamClientAlreadyRunningException) - { - Parameters.Output += $"\nClient already logged in."; - IsLoggingIn = false; - return false; - } - catch (Exception ex) - { - Parameters.Output += $"\nFailed! Error: {ex.Message}"; - SteamClient.Shutdown(); - SteamClient.Dispose(); - SteamClient = null; + _disposed = true; + _statusTimer.Stop(); + _statusTimer.Tick -= Timer_Tick; + _tokenSource.Cancel(); + DisposeSteamCmdClient(); + _tokenSource.Dispose(); - if (ex.GetBaseException() is SteamAuthenticationException) - { - Parameters.Output += "\nWarning: The logon may have failed due to expired sentry-data." - + $"\nIf you are sure that the provided username and password are correct, consider deleting the token file for the user \"{SteamClient?.Credentials.Username}\" in the sentries directory." - + $"{path}"; - } - IsLoggingIn = false; - return false; - } + lock (_logGate) + { + _logWriter?.Dispose(); + _logWriter = null; } - - SteamContentClient = new SteamContentClient(SteamClient, Properties.Settings.Default.CliWorkers); - Parameters.Output += "\nConnected !"; - IsLoggingIn = false; - return SteamClient.IsConnected; } - internal bool SteamReset() + private void MigrateLegacyPasswordToSession() { - Parameters.Output += "\nDisconnecting..."; - SteamClient.Shutdown(); - SteamClient.Dispose(); - SteamClient = null; - Parameters.Output += "\nDisconnected."; - return SteamClient == null; + string encryptedPassword = Properties.Settings.Default.steamPassword; + if (string.IsNullOrWhiteSpace(encryptedPassword)) + return; + + _sessionPassword = Encryption.Instance.DecryptData(encryptedPassword) ?? string.Empty; + Properties.Settings.Default.steamPassword = string.Empty; + Properties.Settings.Default.Save(); } - private void SyncDeleteRemovedFiles(string targetDir, Manifest manifest) + private async Task TryBeginOperationAsync() { - Console.WriteLine("Checking for unnecessary files in target directory..."); + if (_disposed) + throw new ObjectDisposedException(nameof(SteamUpdaterViewModel)); - foreach (var localFilePath in Directory.GetFiles(targetDir, "*", SearchOption.AllDirectories)) + if (!await _operationGate.WaitAsync(0)) { - var relativeLocalPath = Path.GetRelativePath(targetDir, localFilePath); - - if (manifest.Files.Any(x => string.Equals(x.FileName, relativeLocalPath, StringComparison.InvariantCultureIgnoreCase))) - continue; - - Console.WriteLine($"Deleting local file {relativeLocalPath}"); - File.Delete(localFilePath); + AppendOutput("Another SteamCMD operation is already running."); + return false; } + + _tokenSource.Dispose(); + _tokenSource = new CancellationTokenSource(); + _lastProgressMessage = null; + _isBusy = true; + Parameters.IsUpdating = true; + Parameters.Progress = 0; + UpdaterFaulted = false; + RaisePropertyChanged(nameof(IsDownloading)); + RaisePropertyChanged(nameof(CanConfigureUpdater)); + return true; } + private void EndOperation() + { + Parameters.IsUpdating = false; + _isBusy = false; + RaisePropertyChanged(nameof(IsDownloading)); + RaisePropertyChanged(nameof(CanConfigureUpdater)); + RefreshSteamCmdStatus(); + _operationGate.Release(); + } - private async Task Download(IDownloadHandler downloadHandler, string targetDir) + private async Task EnsureSteamCmdInstalledAsync( + IProgress progress, + CancellationToken cancellationToken) { - ulong downloadedSize = 0; - bool skipDownload = false; - downloadHandler.FileVerified += (_, args) => Parameters.Output += $"{(args.RequiresDownload ? $"\nFile verified : {args.ManifestFile.FileName} ({Functions.ParseFileSize(args.ManifestFile.TotalSize)})" : "")}"; - downloadHandler.VerificationCompleted += (_, args) => { - Parameters.Output += $"\nVerification completed, {args.QueuedFiles.Count} files queued for download. ({args.QueuedFiles.Sum(f => (double)f.TotalSize)} bytes)"; - if (args.QueuedFiles.Count == 0) - {skipDownload = true; } - }; - downloadHandler.FileDownloaded += (_, args) => - { - downloadedSize += args.TotalSize; - Parameters.Output += $"\nProgress {downloadHandler.TotalProgress * 100:00.00}% ({Functions.ParseFileSize(downloadedSize)} / {Functions.ParseFileSize(downloadHandler.TotalFileSize)})"; - Parameters.Progress = downloadHandler.TotalProgress * 100; - }; - downloadHandler.DownloadComplete += (_, _) => Parameters.Output += "\nDownload completed"; + SteamCmdClient client = GetOrCreateSteamCmdClient(); + if (!client.IsInstalled) + AppendOutput($"Installing Valve SteamCMD in {client.RootDirectory}..."); + + await client.EnsureInstalledAsync(progress, cancellationToken); + UpdaterOnline = client.IsInstalled; + UpdaterFaulted = false; + return client; + } - if (tokenSource.IsCancellationRequested) - tokenSource = new CancellationTokenSource(); + private SteamCmdClient GetOrCreateSteamCmdClient() + { + string root = SteamCmdCommandBuilder.ValidateAndNormalizePath( + Parameters.SteamCmdDirectory, + nameof(Parameters.SteamCmdDirectory)); - Task downloadTask = Task.Run(async () => + if (_steamCmdClient != null && + !string.Equals(_steamCmdClient.RootDirectory, root, StringComparison.OrdinalIgnoreCase)) { - await downloadHandler.SetupAsync(targetDir, file => true, tokenSource.Token); - await downloadHandler.VerifyAsync(tokenSource.Token); - await downloadHandler.DownloadAsync(tokenSource.Token); - }); + if (_steamCmdClient.IsRunning) + throw new InvalidOperationException("SteamCMD's directory cannot be changed during an active operation."); - Parameters.Output += "\nOK."; - - DownloadTasks.Add(downloadTask); + DisposeSteamCmdClient(); + } - Parameters.Progress = 0; - Parameters.Output += $"\nDownloading {downloadHandler.TotalFileCount} files with total size of {Functions.ParseFileSize(downloadHandler.TotalFileSize)}..."; - Parameters.Output += $"\nVerifying Install..."; + return _steamCmdClient ??= new SteamCmdClient(root); + } - while (!downloadTask.IsCompleted && !downloadTask.IsCanceled && !tokenSource.Token.IsCancellationRequested && !skipDownload) - { + private IProgress CreateProgressReporter() => + new Progress(ReportSteamCmdProgress); - var delayTask = Task.Delay(500, tokenSource.Token); - await Task.WhenAny(delayTask, downloadTask); + private void ReportSteamCmdProgress(SteamCmdProgress progress) + { + // Every event is written here first, unfiltered, so the raw SteamCMD + // chatter suppressed from the UI below is still available for troubleshooting. + LogSteamCmdProgress(progress); - if (tokenSource.Token.IsCancellationRequested) - Parameters.Output += "\nTask cancellation requested"; - Parameters.Output += $"\nProgress {downloadHandler.TotalProgress * 100:00.00}%"; - Parameters.Progress = downloadHandler.TotalProgress * 100; - } + if (progress.Percentage is double percentage) + Parameters.Progress = Math.Clamp(percentage, 0, 100); - if (skipDownload) + // Raw SteamCMD output and per-chunk percentage lines can generate + // thousands of UI updates during a large Workshop collection. The + // structured status/error messages retain the useful information; + // percentages are represented by the progress bar. + if (progress.Kind == SteamCmdProgressKind.Output || + (progress.Percentage is > 0 && + progress.Kind is SteamCmdProgressKind.DownloadingWorkshopItem or SteamCmdProgressKind.UpdatingServer)) { - Parameters.Output += "\nSkipping Download..."; - Parameters.Progress = 0; - await downloadHandler.DisposeAsync(); - DownloadTasks.Remove(downloadTask); return; } - - if (downloadTask.IsCanceled) + if (!string.IsNullOrWhiteSpace(progress.Message) && progress.Message != _lastProgressMessage) { - - Parameters.Output += "\nTask Cancelled"; - Parameters.Progress = 0; - await downloadHandler.DisposeAsync(); - DownloadTasks.Remove(downloadTask); - return; + _lastProgressMessage = progress.Message; + AppendOutput(progress.Message); } + } - try - { await downloadTask; } - catch (TaskCanceledException) - { - Parameters.Output += "\nTask Cancelled"; - Parameters.Progress = 0; - throw; - } - catch (ArgumentException) + private async Task RequestGuardResponseAsync( + SteamCmdGuardChallenge challenge, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Dispatcher? dispatcher = Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) { - Parameters.Output += $"\nSkipping download : No tasks "; - Parameters.Progress = 0; + return await dispatcher + .InvokeAsync(() => ShowGuardPromptAsync(challenge, cancellationToken)) + .Task + .Unwrap(); } - finally + + return await ShowGuardPromptAsync(challenge, cancellationToken); + } + + private async Task ShowGuardPromptAsync( + SteamCmdGuardChallenge challenge, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + EnsureDialogCoordinator(); + + if (challenge.Kind == SteamCmdGuardChallengeKind.MobileConfirmation) { - DownloadTasks.Remove(downloadTask); - await downloadHandler.DisposeAsync(); - downloadTask.Dispose(); + MessageDialogResult response = await SteamGuardInputPhone(); + if (response == MessageDialogResult.Affirmative) + return string.Empty; } + + return await DialogCoordinator.ShowInputAsync( + this, + "Steam Guard", + string.IsNullOrWhiteSpace(challenge.Prompt) ? "Enter the Steam Guard code" : challenge.Prompt) + ?? string.Empty; } - private async Task DownloadForMultiple(IDownloadHandler downloadHandler, string targetDir) + private void EnsureDialogCoordinator() { - if (targetDir == null) - return; + if (DialogCoordinator == null) + throw new SteamCmdAuthenticationException("Steam Guard input is required, but the updater dialog is not available."); + } - if (!Directory.Exists(targetDir)) - Directory.CreateDirectory(targetDir); - - tokenSource.Token.ThrowIfCancellationRequested(); - ulong downloadedSize = 0; - downloadHandler.FileVerified += (_, args) => Parameters.Output += $"{(args.RequiresDownload ? $"\n File verified : {args.ManifestFile.FileName} ({Functions.ParseFileSize(args.ManifestFile.TotalSize)})" : "")}"; - downloadHandler.VerificationCompleted += (_, args) => Parameters.Output += $"\n Verification completed, {args.QueuedFiles.Count} files queued for download. ({args.QueuedFiles.Sum(f => (double)f.TotalSize)} bytes)"; - downloadHandler.FileDownloaded += (_, args) => - { - downloadedSize += args.TotalSize; - Parameters.Output += $"\n Progress {downloadHandler.TotalProgress * 100:00.00}% ({Functions.ParseFileSize(downloadedSize)} / {Functions.ParseFileSize(downloadHandler.TotalFileSize)})"; - }; - downloadHandler.DownloadComplete += (_, _) => Parameters.Output += "\n Download completed"; - - Task downloadTask = Task.Run(async () => - { - await downloadHandler.SetupAsync(targetDir, file => true, tokenSource.Token); - await downloadHandler.VerifyAsync(tokenSource.Token); - await downloadHandler.DownloadAsync(tokenSource.Token); - }); + private string GetStagingRoot(string requestedTarget, ulong workshopId) + { + if (!string.IsNullOrWhiteSpace(requestedTarget)) + { + string fullTarget = Path.TrimEndingDirectorySeparator(Path.GetFullPath(requestedTarget)); + if (string.Equals( + Path.GetFileName(fullTarget), + workshopId.ToString(), + StringComparison.OrdinalIgnoreCase) && + Path.GetDirectoryName(fullTarget) is string parent) + { + return parent; + } + } - Parameters.Output += "\n OK."; + return Parameters.ModStagingDirectory; + } - DownloadTasks.Add(downloadTask); + private static bool TryParseServerBranch(string? branchName, out SteamCmdServerBranch branch) + { + switch (branchName?.Trim().ToLowerInvariant()) + { + case "public": + case "stable": + branch = SteamCmdServerBranch.Public; + return true; + case "contact": + branch = SteamCmdServerBranch.Contact; + return true; + case "creatordlc": + branch = SteamCmdServerBranch.CreatorDlc; + return true; + case "profiling": + branch = SteamCmdServerBranch.Profiling; + return true; + default: + branch = default; + return false; + } + } - Parameters.Output += $"\n Downloading {downloadHandler.TotalFileCount} files with total size of {Functions.ParseFileSize(downloadHandler.TotalFileSize)}..."; - Parameters.Progress = 0; - while (!downloadTask.IsCompleted && !downloadTask.IsCanceled && !tokenSource.IsCancellationRequested) - { + private static bool IsLoginFailure(string? error) + { + if (string.IsNullOrWhiteSpace(error)) + return false; + + return error.Contains("login", StringComparison.OrdinalIgnoreCase) || + error.Contains("password", StringComparison.OrdinalIgnoreCase) || + error.Contains("Steam Guard", StringComparison.OrdinalIgnoreCase) || + error.Contains("authentication", StringComparison.OrdinalIgnoreCase) || + error.Contains("account", StringComparison.OrdinalIgnoreCase); + } - var delayTask = Task.Delay(500, tokenSource.Token); - await Task.WhenAny(delayTask, downloadTask); + private static string FormatSteamCmdFailure(string prefix, string? error, int? exitCode) + { + string detail = string.IsNullOrWhiteSpace(error) ? "SteamCMD did not report success." : error; + return exitCode.HasValue + ? $"{prefix}: {detail} (exit code {exitCode.Value})" + : $"{prefix}: {detail}"; + } - if (tokenSource.IsCancellationRequested) - Parameters.Output += "\n Task cancellation requested"; - } + private static string FormatElapsed(TimeSpan elapsed) => + $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"; - if (downloadTask.IsCanceled) - { - Parameters.Output += "\n Task Cancelled"; - DownloadTasks.Remove(downloadTask); - await downloadHandler.DisposeAsync(); - return; - } + private void Timer_Tick(object? sender, EventArgs e) + { + if (!_disposed) + RefreshSteamCmdStatus(); + } + private void RefreshSteamCmdStatus() + { try - { await downloadTask; } - catch (TaskCanceledException) { - Parameters.Output += "\n Task Cancelled"; - throw; + // IsInstalled also checks the readiness marker written after + // SteamCMD has completed its first self-update. An executable + // left behind by an interrupted bootstrap is not ready yet. + UpdaterOnline = GetOrCreateSteamCmdClient().IsInstalled; } - finally + catch { - DownloadTasks.Remove(downloadTask); - await downloadHandler.DisposeAsync(); - downloadTask.Dispose(); + UpdaterOnline = false; } } - public async Task SteamGuardInput() - { return await DialogCoordinator.ShowInputAsync(this, "Steam Guard", "Please enter your 2FA code"); } + private void DisposeSteamCmdClient() + { + _steamCmdClient?.Dispose(); + _steamCmdClient = null; + } - public async Task SteamGuardInputPhone() - { return await DialogCoordinator.ShowMessageAsync(this, "Steam Guard", "Press OK after accepting authentification on mobile\nOr press Cancel to enter a 2FA Code", MessageDialogStyle.AffirmativeAndNegative); } + private void LogSteamCmdProgress(SteamCmdProgress progress) + { + string percentageSuffix = progress.Percentage is double percentage + ? $" ({percentage:0.##}%)" + : string.Empty; + string line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{progress.Kind}] {progress.Message}{percentageSuffix}"; - public event PropertyChangedEventHandler PropertyChanged; + lock (_logGate) + { + try + { + bool firstWrite = _logWriter == null; + _logWriter ??= CreateLogWriter(); + if (firstWrite) + AppendOutput($"Detailed SteamCMD logging is being written to {LogFilePath}"); + _logWriter.WriteLine(line); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } - private void RaisePropertyChanged(string property) + private static StreamWriter CreateLogWriter() { - if (PropertyChanged == null) return; - PropertyChanged(this, new PropertyChangedEventArgs(property)); + Directory.CreateDirectory(Path.GetDirectoryName(LogFilePath)!); + return new StreamWriter(LogFilePath, append: true) { AutoFlush = true }; } - public void Dispose() + private void AppendOutput(string message) { - tokenSource.Dispose(); + if (string.IsNullOrWhiteSpace(message)) + return; + + Dispatcher? dispatcher = Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) + { + _ = dispatcher.BeginInvoke(() => AppendOutput(message)); + return; + } + + string cleanMessage = message.Replace("\r\n", "\n").Replace('\r', '\n').Trim('\n'); + string output = string.IsNullOrEmpty(Parameters.Output) + ? cleanMessage + : $"{Parameters.Output}\n{cleanMessage}"; + if (output.Length > MaximumConsoleCharacters) + { + int firstLineBreak = output.IndexOf( + '\n', + output.Length - MaximumConsoleCharacters); + output = "[Earlier SteamCMD output was trimmed]\n" + + output[(firstLineBreak >= 0 ? firstLineBreak + 1 : output.Length - MaximumConsoleCharacters)..]; + } + + Parameters.Output = output; } + + private void RaisePropertyChanged(string property) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property)); } public static class UpdateState { - public const int Success = 0; - public const int Error = 1; + public const int Success = 0; + public const int Error = 1; public const int LoginFailed = 2; - public const int Cancelled = 3; + public const int Cancelled = 3; } } diff --git a/FASTER/Views/Settings.xaml b/FASTER/Views/Settings.xaml index 0b4fdd32..e553b1af 100644 --- a/FASTER/Views/Settings.xaml +++ b/FASTER/Views/Settings.xaml @@ -38,7 +38,6 @@ - @@ -50,16 +49,7 @@ - - - - - - - - - - + @@ -68,7 +58,7 @@