From f2b6eb3e2feba6bc614c259c48e4f2974089b279 Mon Sep 17 00:00:00 2001 From: JantsoP Date: Wed, 12 Aug 2026 13:32:36 +0300 Subject: [PATCH 1/2] Remove SteamKit2 in favor of steamcmd This will completely remove SteamKit2 hacks in favor of official steamcmd with way faster downloads and able to download multiple mods at the same time. --- .gitmodules | 3 - BytexDigital.Steam | 1 - FASTER.sln | 6 - FASTER/App.config | 5 +- FASTER/FASTER.csproj | 3 - FASTER/MainWindow.xaml.cs | 11 +- FASTER/Models/Functions.cs | 13 +- FASTER/Models/SteamUpdaterModel.cs | 145 +- FASTER/Models/SteamWebApi.cs | 125 -- FASTER/Properties/Settings.Designer.cs | 14 +- FASTER/Properties/Settings.settings | 5 +- FASTER/Services/SteamCmd/SteamCmdClient.cs | 857 +++++++++++ .../SteamCmd/SteamCmdCommandBuilder.cs | 83 ++ FASTER/Services/SteamCmd/SteamCmdInstaller.cs | 282 ++++ FASTER/Services/SteamCmd/SteamCmdModels.cs | 102 ++ .../Services/SteamCmd/SteamCmdOutputParser.cs | 674 +++++++++ .../SteamCmd/SteamCmdPseudoConsole.cs | 348 +++++ FASTER/Services/SteamCmd/SteamCmdSession.cs | 288 ++++ .../SteamCmd/WorkshopContentMirror.cs | 254 ++++ FASTER/ViewModel/SteamUpdaterViewModel.cs | 1304 +++++++++-------- FASTER/Views/Settings.xaml | 14 +- FASTER/Views/Settings.xaml.cs | 17 - FASTER/Views/Setup.xaml | 9 +- FASTER/Views/Setup.xaml.cs | 32 +- FASTER/Views/Updater.xaml | 67 +- FASTER/Views/Updater.xaml.cs | 12 +- .../Services/SteamCmd/SteamCmdClientTests.cs | 41 + .../SteamCmd/SteamCmdCommandBuilderTests.cs | 111 ++ .../SteamCmd/SteamCmdLiveSmokeTests.cs | 76 + .../SteamCmd/SteamCmdOutputParserTests.cs | 287 ++++ FASTERTests/WorkshopContentMirrorTests.cs | 127 ++ README.md | 19 +- global.json | 2 +- 33 files changed, 4352 insertions(+), 985 deletions(-) delete mode 100644 .gitmodules delete mode 160000 BytexDigital.Steam create mode 100644 FASTER/Services/SteamCmd/SteamCmdClient.cs create mode 100644 FASTER/Services/SteamCmd/SteamCmdCommandBuilder.cs create mode 100644 FASTER/Services/SteamCmd/SteamCmdInstaller.cs create mode 100644 FASTER/Services/SteamCmd/SteamCmdModels.cs create mode 100644 FASTER/Services/SteamCmd/SteamCmdOutputParser.cs create mode 100644 FASTER/Services/SteamCmd/SteamCmdPseudoConsole.cs create mode 100644 FASTER/Services/SteamCmd/SteamCmdSession.cs create mode 100644 FASTER/Services/SteamCmd/WorkshopContentMirror.cs create mode 100644 FASTERTests/Services/SteamCmd/SteamCmdClientTests.cs create mode 100644 FASTERTests/Services/SteamCmd/SteamCmdCommandBuilderTests.cs create mode 100644 FASTERTests/Services/SteamCmd/SteamCmdLiveSmokeTests.cs create mode 100644 FASTERTests/Services/SteamCmd/SteamCmdOutputParserTests.cs create mode 100644 FASTERTests/WorkshopContentMirrorTests.cs 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 aa44bded..00000000 --- a/BytexDigital.Steam +++ /dev/null @@ -1 +0,0 @@ -Subproject commit aa44bdedc3d146ba42c584c9963e775458696776 diff --git a/FASTER.sln b/FASTER.sln index cfb10947..15ef967c 100644 --- a/FASTER.sln +++ b/FASTER.sln @@ -19,8 +19,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FASTERTests", "FASTERTests\FASTERTests.csproj", "{65FDF864-BF9B-414A-A6E6-3473BCFB62BE}" 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 @@ -35,10 +33,6 @@ Global {65FDF864-BF9B-414A-A6E6-3473BCFB62BE}.Debug|Any CPU.Build.0 = Debug|Any CPU {65FDF864-BF9B-414A-A6E6-3473BCFB62BE}.Release|Any CPU.ActiveCfg = Release|Any CPU {65FDF864-BF9B-414A-A6E6-3473BCFB62BE}.Release|Any CPU.Build.0 = 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 d6474083..71248a82 100644 --- a/FASTER/App.config +++ b/FASTER/App.config @@ -35,7 +35,7 @@ True - Stable + public False @@ -52,9 +52,6 @@ Segoe UI - - 10 - False diff --git a/FASTER/FASTER.csproj b/FASTER/FASTER.csproj index f58e0b53..f6f5580c 100644 --- a/FASTER/FASTER.csproj +++ b/FASTER/FASTER.csproj @@ -75,7 +75,4 @@ - - - diff --git a/FASTER/MainWindow.xaml.cs b/FASTER/MainWindow.xaml.cs index 3c6b9ffc..8c3fa6e5 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(); } @@ -549,13 +549,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; @@ -595,7 +598,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 0884ef53..d99edba4 100644 --- a/FASTER/Models/Functions.cs +++ b/FASTER/Models/Functions.cs @@ -14,8 +14,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 cc3a23bf..8b4453dc 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; @@ -87,102 +108,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 61f89c1e..a89007ea 100644 --- a/FASTER/Models/SteamWebApi.cs +++ b/FASTER/Models/SteamWebApi.cs @@ -1,12 +1,7 @@ -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.Net.Sockets; @@ -102,126 +97,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; - } - - // Reads a token file. New files are DPAPI-protected; old plain-text files are still accepted - // and get rewritten in the new format the next time Steam persists them. - private static string ReadProtected(string path) - { - var raw = File.ReadAllText(path); - return Encryption.Instance.IsCurrentFormat(raw) ? Encryption.Instance.DecryptData(raw) : raw; - } - - 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"), Encryption.Instance.EncryptData(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) ? ReadProtected(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"), Encryption.Instance.EncryptData(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) ? ReadProtected(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 bdf6f6f2..c75be0d4 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"])); @@ -281,18 +281,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 c041c409..35dd6208 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 748934f2..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 IDialogCoordinator DialogCoordinator { get; set; } - private CancellationTokenSource tokenSource = new(); + public SteamUpdaterModel Parameters { get; } - public bool IsDownloading => DownloadTasks.Count > 0 || IsLoggingIn || IsDlOverride; + public IDialogCoordinator DialogCoordinator { get; set; } = null!; - 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,814 +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() { - if (IsDownloading) + if (_isBusy) { - Parameters.Output += "\nAn update is already running."; + AppendOutput("SteamCMD's directory cannot be changed while an update is running."); return; } - Analytics.TrackEvent("Updater - Clicked Update", new Dictionary - { - {"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"}, - }; - - 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)); - + 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; } - - var result = await RunServerUpdater(Parameters.InstallDirectory, appId, depotsDownload); - Parameters.IsUpdating = false; - - Parameters.Output += result switch - { - UpdateState.Success => "\n\nAll Done ! ", - UpdateState.LoginFailed => "\n\nSteam login failed. Nothing was updated.", - UpdateState.Cancelled => "\n\nUpdate was cancelled.", - _ => "\n\nUpdate did not complete. Check the output above for errors." - }; - } - - 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)) { - if (tokenSource.IsCancellationRequested) - return UpdateState.Cancelled; - - 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) - { - if(ex.Message.Contains("'tasks'")) - Parameters.Output += "\nSkipped..."; - else - { - throw; - } - } - catch (OperationCanceledException) + 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) { + 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 UpdateState.Success; + 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}... "; - - var downloadHandler = await SteamContentClient.GetPublishedFileDataAsync(modId, manifestId, tokenSource.Token); + string stagingRoot = GetStagingRoot(path, modId); + await _contentMirror.MirrorAsync(item.SourcePath, stagingRoot, modId, _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) + { + AppendOutput($"Steam login failed: {exception.Message}"); + return UpdateState.LoginFailed; + } + catch (Exception exception) { - sw.Stop(); - Parameters.Output += $"\nError: {ex.Message}{(ex.InnerException != null ? $" Inner Exception: {ex.InnerException.Message}" : "")}"; - SteamClient.Shutdown(); - SteamClient.Dispose(); - SteamClient = null; + 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) { - Logger.Log($"RunModsUpdater: starting, {mods.Count} mods total"); - - tokenSource = new CancellationTokenSource(); + ArgumentNullException.ThrowIfNull(mods); - if (!await TryLoginAsync()) + List candidates = mods.Where(mod => !mod.IsLocal).ToList(); + if (candidates.Count == 0) { - IsLoggingIn = false; - return UpdateState.LoginFailed; + Parameters.Progress = 0; + AppendOutput("No downloadable Workshop mods were selected."); + return UpdateState.Cancelled; } - Parameters.Output += "\nAdding mods to download list..."; + List pending = new(); - SemaphoreSlim maxThread = new(1); - var ml = mods.Where(m => !m.IsLocal).ToList(); - uint finished = 0; - IsDlOverride = true; - Logger.Log($"RunModsUpdater: {ml.Count} non-local mods to update"); - - foreach (ArmaMod mod in ml) + foreach (ArmaMod mod in candidates) { - Logger.Log($"RunModsUpdater: waiting semaphore for mod {mod.WorkshopId} ({mod.Name})"); - await maxThread.WaitAsync(); + 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); + } + } - _ = Task.Factory.StartNew(() => DownloadModAsync(mod), TaskCreationOptions.LongRunning) - .Unwrap() - .ContinueWith((t) => - { - if (t.IsFaulted) - Logger.Log($" ContinueWith: task for {mod.WorkshopId} faulted: {t.Exception}"); - finished += 1; - Parameters.Output += $"\n Thread {mod.WorkshopId} complete ({finished} / {ml.Count})"; - Parameters.Progress = finished * 100.0 / ml.Count; - Logger.Log($" ContinueWith: mod {mod.WorkshopId} done ({finished}/{ml.Count}), releasing semaphore."); - maxThread.Release(); - }); - } - - Logger.Log("RunModsUpdater: all tasks queued, waiting for last semaphore..."); - Parameters.Output += "\nAlmost there..."; - try + if (pending.Count == 0) { - await maxThread.WaitAsync(); + Parameters.Progress = 100; + AppendOutput("All selected Workshop mods are already up to date."); + return UpdateState.Success; } - finally + + if (string.IsNullOrWhiteSpace(Parameters.Username)) { - IsDlOverride = false; - } + foreach (ArmaMod mod in pending) + mod.Status = ArmaModStatus.NotComplete; - Logger.Log("RunModsUpdater: all done."); - Parameters.Output += "\nMods updated !"; - return UpdateState.Success; - } + AppendOutput("A Steam account that owns Arma 3 is required for Workshop downloads. An API key is not a download login."); + return UpdateState.LoginFailed; + } - private async Task TryLoginAsync() - { - Logger.Log("RunModsUpdater: calling SteamLogin..."); - bool loginOk = false; - try { loginOk = await SteamLogin(); } - catch (Exception ex) { Logger.Log($"RunModsUpdater: SteamLogin threw exception: {ex}"); } + 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; - Logger.Log(loginOk - ? "RunModsUpdater: SteamLogin OK" - : "RunModsUpdater: SteamLogin failed, aborting."); + AppendOutput($"The mod staging directory is unavailable: {exception.Message}"); + return UpdateState.Error; + } - return loginOk; - } + if (!await TryBeginOperationAsync()) + return UpdateState.Cancelled; - private async Task DownloadModAsync(ArmaMod mod) - { - Logger.Log($" Task started: {mod.WorkshopId} ({mod.Name}), path={mod.Path}"); try { - if (!Directory.Exists(mod.Path)) - { - Logger.Log($" Creating dir: {mod.Path}"); - Directory.CreateDirectory(mod.Path); - } + 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); + + AppendOutput($"Downloading {workshopIds.Count} Workshop item(s) in one SteamCMD session..."); - if (tokenSource.Token.IsCancellationRequested) + IProgress progress = new Progress(update => { - Logger.Log($" Cancellation requested before {mod.WorkshopId}, skipping."); - return; + 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); + } + }); + + 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; + 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; + } + + try + { + // 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; + 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 (Exception exception) + { + mod.Status = ArmaModStatus.NotComplete; + failed++; + Parameters.Progress = (completed + failed) / (double)pending.Count * 100d; + AppendOutput($"Workshop item {mod.WorkshopId} downloaded, but staging failed: {exception.Message}"); + } } - Parameters.Output += $"\n Starting {mod.WorkshopId}"; - Stopwatch sw = Stopwatch.StartNew(); - try + if (batch.Cancelled || _tokenSource.IsCancellationRequested) { - await DownloadModContentAsync(mod); + AppendOutput( + $"Mod update cancelled. {completed} completed item(s) were staged; " + + $"{failed} item(s) were unfinished or failed."); + return UpdateState.Cancelled; } - catch (TaskCanceledException) + + if (failed > 0 || !batch.Success) { - Logger.Log($" {mod.WorkshopId} task cancelled."); - sw.Stop(); - mod.Status = ArmaModStatus.NotComplete; + 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; } - catch (Exception ex) - { - Logger.Log($" ERROR downloading {mod.WorkshopId}: {ex.GetType().Name}: {ex.Message}{(ex.InnerException != null ? $" | Inner: {ex.InnerException.Message}" : "")}\n StackTrace: {ex.StackTrace}"); - sw.Stop(); + + 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; + + AppendOutput("Mod update cancelled before the batch completed."); + return UpdateState.Cancelled; + } + catch (SteamCmdAuthenticationException exception) + { + foreach (ArmaMod mod in pending) 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"; + AppendOutput($"Steam login failed: {exception.Message}"); + return UpdateState.LoginFailed; } - catch (Exception ex) + catch (Exception exception) { - Logger.Log($" UNHANDLED ERROR in task for {mod.WorkshopId}: {ex.GetType().Name}: {ex.Message}\n StackTrace: {ex.StackTrace}"); + 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(); } } - private async Task DownloadModContentAsync(ArmaMod mod) + public async Task SteamGuardInput() { - ManifestId manifestId = default; + EnsureDialogCoordinator(); + return await DialogCoordinator.ShowInputAsync(this, "Steam Guard", "Enter the Steam Guard code") ?? string.Empty; + } - if (mod.LocalLastUpdated > mod.SteamLastUpdated && mod.Size > 0) - { - mod.Status = ArmaModStatus.UpToDate; - Parameters.Output += $"\n Mod{mod.WorkshopId} already up to date. Ignoring..."; - Logger.Log($" {mod.WorkshopId} already up to date, skipping."); - return; - } + public async Task SteamGuardInputPhone() + { + 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); + } - if (!SteamClient.Credentials.IsAnonymous) - { - Logger.Log($" Getting manifest for {mod.WorkshopId}"); - Parameters.Output += $"\n Getting manifest for {mod.WorkshopId}"; - manifestId = (await SteamContentClient.GetPublishedFileDetailsAsync(mod.WorkshopId)).hcontent_file; - Manifest manifest = await SteamContentClient.GetManifestAsync(107410, 107410, manifestId); - Parameters.Output += $"\n Manifest retrieved {mod.WorkshopId}"; - Logger.Log($" Manifest retrieved for {mod.WorkshopId}, syncing deleted files..."); - SyncDeleteRemovedFiles(mod.Path, manifest); - } + public event PropertyChangedEventHandler? PropertyChanged; - Logger.Log($" Requesting download handler for {mod.WorkshopId}"); - Parameters.Output += $"\n Attempting to start download of item {mod.WorkshopId}... "; + public void Dispose() + { + if (_disposed) + return; - var downloadHandler = await SteamContentClient.GetPublishedFileDataAsync(mod.WorkshopId, manifestId, tokenSource.Token); - Logger.Log($" Download handler obtained for {mod.WorkshopId}, starting download..."); - await DownloadForMultiple(downloadHandler, mod.Path); - Logger.Log($" Download complete for {mod.WorkshopId}"); + _disposed = true; + _statusTimer.Stop(); + _statusTimer.Tick -= Timer_Tick; + _tokenSource.Cancel(); + DisposeSteamCmdClient(); + _tokenSource.Dispose(); - mod.Status = ArmaModStatus.UpToDate; - var nx = DateTime.UnixEpoch; - var ts = DateTime.UtcNow - nx; - mod.LocalLastUpdated = (ulong)ts.TotalSeconds; + lock (_logGate) + { + _logWriter?.Dispose(); + _logWriter = null; + } } - internal async Task SteamLogin() + private void MigrateLegacyPasswordToSession() { - Logger.Log("SteamLogin: start"); - if (tokenSource.IsCancellationRequested) - tokenSource = new CancellationTokenSource(); - IsLoggingIn = true; - var path = Path.Combine(Path.GetDirectoryName(ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.PerUserRoamingAndLocal).FilePath) ?? string.Empty, "sentries"); + string encryptedPassword = Properties.Settings.Default.steamPassword; + if (string.IsNullOrWhiteSpace(encryptedPassword)) + return; - SteamCredentials _steamCredentials = new(Parameters.Username, Encryption.Instance.DecryptData(Parameters.Password)); + _sessionPassword = Encryption.Instance.DecryptData(encryptedPassword) ?? string.Empty; + Properties.Settings.Default.steamPassword = string.Empty; + Properties.Settings.Default.Save(); + } - 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"; - } + private async Task TryBeginOperationAsync() + { + if (_disposed) + throw new ObjectDisposedException(nameof(SteamUpdaterViewModel)); - if (!SteamClient.IsConnected || SteamClient.IsFaulted) + if (!await _operationGate.WaitAsync(0)) { - Parameters.Output += $"\nConnecting to Steam as {(_steamCredentials.IsAnonymous ? "anonymous" : _steamCredentials.Username)}"; - SteamClient.MaximumLogonAttempts = 5; - try - { await SteamClient.ConnectAsync(tokenSource.Token); } - catch (SteamClientAlreadyRunningException) - { - Logger.Log("SteamLogin: SteamClientAlreadyRunningException - client already running"); - Parameters.Output += $"\nClient already logged in."; - IsLoggingIn = false; - return false; - } - catch (Exception ex) - { - Logger.Log($"SteamLogin: ConnectAsync failed: {ex.GetType().Name}: {ex.Message}\nStackTrace: {ex.StackTrace}"); - Parameters.Output += $"\nFailed! Error: {ex.Message}"; - var savedUsername = SteamClient.Credentials.Username; - SteamClient.Shutdown(); - SteamClient.Dispose(); - SteamClient = null; - - 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 \"{savedUsername}\" in the sentries directory." - + $"{path}"; - } - IsLoggingIn = false; - return false; - } + AppendOutput("Another SteamCMD operation is already running."); + return false; } - Logger.Log($"SteamLogin: creating SteamContentClient with {Properties.Settings.Default.CliWorkers} workers"); - SteamContentClient = new SteamContentClient(SteamClient, Properties.Settings.Default.CliWorkers); - Parameters.Output += "\nConnected !"; - Logger.Log("SteamLogin: connected OK"); - IsLoggingIn = false; - return SteamClient.IsConnected; + _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; } - internal bool SteamReset() + private void EndOperation() { - Parameters.Output += "\nDisconnecting..."; - SteamClient.Shutdown(); - SteamClient.Dispose(); - SteamClient = null; - Parameters.Output += "\nDisconnected."; - return SteamClient == null; + Parameters.IsUpdating = false; + _isBusy = false; + RaisePropertyChanged(nameof(IsDownloading)); + RaisePropertyChanged(nameof(CanConfigureUpdater)); + RefreshSteamCmdStatus(); + _operationGate.Release(); } - private void SyncDeleteRemovedFiles(string targetDir, Manifest manifest) + private async Task EnsureSteamCmdInstalledAsync( + IProgress progress, + CancellationToken cancellationToken) { - Console.WriteLine("Checking for unnecessary files in target directory..."); + 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; + } - foreach (var localFilePath in Directory.GetFiles(targetDir, "*", SearchOption.AllDirectories)) - { - var relativeLocalPath = Path.GetRelativePath(targetDir, localFilePath); + private SteamCmdClient GetOrCreateSteamCmdClient() + { + string root = SteamCmdCommandBuilder.ValidateAndNormalizePath( + Parameters.SteamCmdDirectory, + nameof(Parameters.SteamCmdDirectory)); - if (manifest.Files.Any(x => string.Equals(x.FileName, relativeLocalPath, StringComparison.InvariantCultureIgnoreCase))) - continue; + if (_steamCmdClient != null && + !string.Equals(_steamCmdClient.RootDirectory, root, StringComparison.OrdinalIgnoreCase)) + { + if (_steamCmdClient.IsRunning) + throw new InvalidOperationException("SteamCMD's directory cannot be changed during an active operation."); - Console.WriteLine($"Deleting local file {relativeLocalPath}"); - File.Delete(localFilePath); + DisposeSteamCmdClient(); } + + return _steamCmdClient ??= new SteamCmdClient(root); } + private IProgress CreateProgressReporter() => + new Progress(ReportSteamCmdProgress); - private async Task Download(IDownloadHandler downloadHandler, string targetDir) + private void ReportSteamCmdProgress(SteamCmdProgress progress) { - SteamDiagLogger.Attach(downloadHandler); + // Every event is written here first, unfiltered, so the raw SteamCMD + // chatter suppressed from the UI below is still available for troubleshooting. + LogSteamCmdProgress(progress); - 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"; + if (progress.Percentage is double percentage) + Parameters.Progress = Math.Clamp(percentage, 0, 100); - if (tokenSource.IsCancellationRequested) - tokenSource = new CancellationTokenSource(); + // 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)) + { + return; + } - Task downloadTask = Task.Run(async () => + if (!string.IsNullOrWhiteSpace(progress.Message) && progress.Message != _lastProgressMessage) { - await downloadHandler.SetupAsync(targetDir, file => true, tokenSource.Token); - await downloadHandler.VerifyAsync(tokenSource.Token); - await downloadHandler.DownloadAsync(tokenSource.Token); - }); + _lastProgressMessage = progress.Message; + AppendOutput(progress.Message); + } + } - Parameters.Output += "\nOK."; + private async Task RequestGuardResponseAsync( + SteamCmdGuardChallenge challenge, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Dispatcher? dispatcher = Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) + { + return await dispatcher + .InvokeAsync(() => ShowGuardPromptAsync(challenge, cancellationToken)) + .Task + .Unwrap(); + } - DownloadTasks.Add(downloadTask); + return await ShowGuardPromptAsync(challenge, cancellationToken); + } - Parameters.Progress = 0; - Parameters.Output += $"\nDownloading {downloadHandler.TotalFileCount} files with total size of {Functions.ParseFileSize(downloadHandler.TotalFileSize)}..."; - Parameters.Output += $"\nVerifying Install..."; + private async Task ShowGuardPromptAsync( + SteamCmdGuardChallenge challenge, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + EnsureDialogCoordinator(); - while (!downloadTask.IsCompleted && !downloadTask.IsCanceled && !tokenSource.Token.IsCancellationRequested && !skipDownload) + if (challenge.Kind == SteamCmdGuardChallengeKind.MobileConfirmation) { + MessageDialogResult response = await SteamGuardInputPhone(); + if (response == MessageDialogResult.Affirmative) + return string.Empty; + } - var delayTask = Task.Delay(500, tokenSource.Token); - await Task.WhenAny(delayTask, downloadTask); + return await DialogCoordinator.ShowInputAsync( + this, + "Steam Guard", + string.IsNullOrWhiteSpace(challenge.Prompt) ? "Enter the Steam Guard code" : challenge.Prompt) + ?? string.Empty; + } - if (tokenSource.Token.IsCancellationRequested) - Parameters.Output += "\nTask cancellation requested"; - Parameters.Output += $"\nProgress {downloadHandler.TotalProgress * 100:00.00}%"; - Parameters.Progress = downloadHandler.TotalProgress * 100; + private void EnsureDialogCoordinator() + { + if (DialogCoordinator == null) + throw new SteamCmdAuthenticationException("Steam Guard input is required, but the updater dialog is not available."); + } + + 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; + } } - if (skipDownload) - { - Parameters.Output += "\nSkipping Download..."; - Parameters.Progress = 0; - await downloadHandler.DisposeAsync(); - DownloadTasks.Remove(downloadTask); - return; + return Parameters.ModStagingDirectory; + } + + 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; } + } + 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); + } - if (downloadTask.IsCanceled) - { + 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}"; + } - Parameters.Output += "\nTask Cancelled"; - Parameters.Progress = 0; - await downloadHandler.DisposeAsync(); - DownloadTasks.Remove(downloadTask); - return; - } + private static string FormatElapsed(TimeSpan elapsed) => + $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"; + private void Timer_Tick(object? sender, EventArgs e) + { + if (!_disposed) + RefreshSteamCmdStatus(); + } + + private void RefreshSteamCmdStatus() + { try - { await downloadTask; } - catch (TaskCanceledException) { - Parameters.Output += "\nTask Cancelled"; - Parameters.Progress = 0; - 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; } - catch (ArgumentException) + catch { - Parameters.Output += $"\nSkipping download : No tasks "; - Parameters.Progress = 0; - } - finally - { - DownloadTasks.Remove(downloadTask); - await downloadHandler.DisposeAsync(); - downloadTask.Dispose(); + UpdaterOnline = false; } } - private async Task DownloadForMultiple(IDownloadHandler downloadHandler, string targetDir) + private void DisposeSteamCmdClient() { - SteamDiagLogger.Attach(downloadHandler); - - Logger.Log($"DownloadForMultiple: targetDir={targetDir}"); - if (targetDir == null) - { - Logger.Log("DownloadForMultiple: targetDir is null, aborting."); - return; - } + _steamCmdClient?.Dispose(); + _steamCmdClient = null; + } - if (!Directory.Exists(targetDir)) - { - Logger.Log($"DownloadForMultiple: creating dir {targetDir}"); - Directory.CreateDirectory(targetDir); - } + 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}"; - tokenSource.Token.ThrowIfCancellationRequested(); - ulong downloadedSize = 0; - downloadHandler.FileVerified += (_, args) => + lock (_logGate) { - if (args.RequiresDownload) + try { - Logger.Log($" File verified (needs download): {args.ManifestFile.FileName} ({Functions.ParseFileSize(args.ManifestFile.TotalSize)})"); - Parameters.Output += $"\n File verified : {args.ManifestFile.FileName} ({Functions.ParseFileSize(args.ManifestFile.TotalSize)})"; + bool firstWrite = _logWriter == null; + _logWriter ??= CreateLogWriter(); + if (firstWrite) + AppendOutput($"Detailed SteamCMD logging is being written to {LogFilePath}"); + _logWriter.WriteLine(line); } - }; - downloadHandler.VerificationCompleted += (_, args) => - { - Logger.Log($" Verification completed: {args.QueuedFiles.Count} files queued ({args.QueuedFiles.Sum(f => (double)f.TotalSize)} bytes)"); - 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; - Logger.Log($" File downloaded: progress {downloadHandler.TotalProgress * 100:00.00}% ({Functions.ParseFileSize(downloadedSize)}/{Functions.ParseFileSize(downloadHandler.TotalFileSize)})"); - Parameters.Output += $"\n Progress {downloadHandler.TotalProgress * 100:00.00}% ({Functions.ParseFileSize(downloadedSize)} / {Functions.ParseFileSize(downloadHandler.TotalFileSize)})"; - }; - downloadHandler.DownloadComplete += (_, _) => - { - Logger.Log(" DownloadComplete event fired."); - Parameters.Output += "\n Download completed"; - }; - - Logger.Log($"DownloadForMultiple: starting Task.Run (Setup/Verify/Download), totalFiles={downloadHandler.TotalFileCount}, totalSize={Functions.ParseFileSize(downloadHandler.TotalFileSize)}"); - Task downloadTask = Task.Run(async () => - { - try + catch (IOException) { - Logger.Log(" SetupAsync starting..."); - await downloadHandler.SetupAsync(targetDir, file => true, tokenSource.Token); - Logger.Log(" SetupAsync done. VerifyAsync starting..."); - await downloadHandler.VerifyAsync(tokenSource.Token); - Logger.Log(" VerifyAsync done. DownloadAsync starting..."); - await downloadHandler.DownloadAsync(tokenSource.Token); - Logger.Log(" DownloadAsync done."); } - catch (Exception ex) + catch (UnauthorizedAccessException) { - Logger.Log($" ERROR inside download Task.Run: {ex.GetType().Name}: {ex.Message}\n StackTrace: {ex.StackTrace}"); - throw; } - }); - - Parameters.Output += "\n OK."; - - DownloadTasks.Add(downloadTask); + } + } - 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) - { - var delayTask = Task.Delay(500, tokenSource.Token); - await Task.WhenAny(delayTask, downloadTask); + private static StreamWriter CreateLogWriter() + { + Directory.CreateDirectory(Path.GetDirectoryName(LogFilePath)!); + return new StreamWriter(LogFilePath, append: true) { AutoFlush = true }; + } - if (tokenSource.IsCancellationRequested) - Parameters.Output += "\n Task cancellation requested"; - } + private void AppendOutput(string message) + { + if (string.IsNullOrWhiteSpace(message)) + return; - if (downloadTask.IsCanceled) + Dispatcher? dispatcher = Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) { - Logger.Log("DownloadForMultiple: task was cancelled."); - Parameters.Output += "\n Task Cancelled"; - DownloadTasks.Remove(downloadTask); - await downloadHandler.DisposeAsync(); + _ = dispatcher.BeginInvoke(() => AppendOutput(message)); return; } - try - { await downloadTask; } - catch (TaskCanceledException) + 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) { - Logger.Log("DownloadForMultiple: TaskCanceledException caught on await."); - Parameters.Output += "\n Task Cancelled"; - throw; + int firstLineBreak = output.IndexOf( + '\n', + output.Length - MaximumConsoleCharacters); + output = "[Earlier SteamCMD output was trimmed]\n" + + output[(firstLineBreak >= 0 ? firstLineBreak + 1 : output.Length - MaximumConsoleCharacters)..]; } - catch (Exception ex) - { - Logger.Log($"DownloadForMultiple: exception on await downloadTask: {ex.GetType().Name}: {ex.Message}\n StackTrace: {ex.StackTrace}"); - throw; - } - finally - { - Logger.Log("DownloadForMultiple: finalizing, disposing handler."); - DownloadTasks.Remove(downloadTask); - await downloadHandler.DisposeAsync(); - downloadTask.Dispose(); - } - } - public async Task SteamGuardInput() - { return await DialogCoordinator.ShowInputAsync(this, "Steam Guard", "Please enter your 2FA code"); } - - 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); } - - public event PropertyChangedEventHandler? PropertyChanged; - - private void RaisePropertyChanged(string property) - { - if (PropertyChanged == null) return; - PropertyChanged(this, new PropertyChangedEventArgs(property)); + Parameters.Output = output; } - public void Dispose() - { - tokenSource.Dispose(); - } + 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 4e79e4bc..94140e8c 100644 --- a/FASTER/Views/Settings.xaml +++ b/FASTER/Views/Settings.xaml @@ -39,7 +39,6 @@ - @@ -61,16 +60,7 @@