using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using System.Diagnostics; using System.IO; using System.Text; using System.Text.RegularExpressions; using LANCommander.Steam.Abstractions; using LANCommander.Steam.Enums; using LANCommander.Steam.Events; using LANCommander.Steam.Models; using LANCommander.Steam.Options; namespace LANCommander.Steam.Services; /// /// Service for interacting with SteamCMD. /// Can be constructed without dependency injection using optional parameters. /// public class SteamCmdService( SteamCmdOptions? options = null, ISteamCmdProfileStore? profileStore = null, ILogger? logger = null) : ISteamCmdService { private readonly ILogger? _logger = logger; private readonly SteamCmdOptions _options = options ?? new SteamCmdOptions(); private readonly CancellationTokenSource _cancellationTokenSource = new(); private string? _executablePath; /// /// Event fired when an install job status changes /// public event EventHandler? InstallStatusChanged; /// /// Event fired when install progress is updated /// public event EventHandler? InstallProgress; /// /// Get or set the SteamCMD executable path /// public string? ExecutablePath { get => _executablePath ?? _options.ExecutablePath; set => _executablePath = value; } public async Task GetConnectionStatusAsync(string username) { try { if (!IsValidUsername(username)) throw new ArgumentException("Invalid username", nameof(username)); // Auto-populate SteamCMD path if not configured if (string.IsNullOrWhiteSpace(ExecutablePath)) { if (_options.AutoDetectPath) { var detectedPath = await AutoDetectSteamCmdPathAsync(); if (!string.IsNullOrWhiteSpace(detectedPath)) { ExecutablePath = detectedPath; _logger?.LogInformation("Auto-detected SteamCMD at: {Path}", detectedPath); } else { _logger?.LogWarning("SteamCMD path is not configured and could not be auto-detected"); return SteamCmdConnectionStatus.NotInstalled; } } else { _logger?.LogWarning("SteamCMD path is not configured and auto-detection is disabled"); return SteamCmdConnectionStatus.NotInstalled; } } if (!File.Exists(ExecutablePath)) { _logger?.LogWarning("SteamCMD executable not found at configured path: {Path}", ExecutablePath); return SteamCmdConnectionStatus.NotInstalled; } // Try to run steamcmd with +quit to check if it's working var result = await ExecuteSteamCmdCommandAsync("+quit"); if (result.Success) { // Check if we're logged in by trying to get user info var loginResult = await ExecuteSteamCmdCommandAsync($"+login {username} +quit", TimeSpan.FromSeconds(30)); return loginResult.Success ? SteamCmdConnectionStatus.Authenticated : SteamCmdConnectionStatus.Unauthenticated; } return SteamCmdConnectionStatus.NotInstalled; } catch (Exception ex) { _logger?.LogError(ex, "Error checking SteamCMD connection status"); return SteamCmdConnectionStatus.NotInstalled; } } public async Task AutoDetectSteamCmdPathAsync() { var possiblePaths = new List(); // Windows paths if (OperatingSystem.IsWindows()) { // Common Steam installation directories var steamPaths = new[] { Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Steam", "steamcmd.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Steam", "steamcmd.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Steam", "steamcmd.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Steam", "steamcmd.exe"), "C:\\Steam\\steamcmd.exe", "C:\\Program Files\\Steam\\steamcmd.exe", "C:\\Program Files (x86)\\Steam\\steamcmd.exe" }; possiblePaths.AddRange(steamPaths); // Check PATH environment variable var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? []; foreach (var dir in pathDirs) { if (!string.IsNullOrWhiteSpace(dir)) { possiblePaths.Add(Path.Combine(dir, "steamcmd.exe")); } } } // Linux paths else if (OperatingSystem.IsLinux()) { possiblePaths.AddRange(new[] { "/app/Data/Steam/steamcmd.sh", "/usr/local/bin/steamcmd", "/usr/bin/steamcmd", "/home/steam/steamcmd/steamcmd.sh", "/opt/steamcmd/steamcmd.sh", "/var/lib/steam/steamcmd/steamcmd.sh" }); // Check PATH environment variable var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(':') ?? []; foreach (var dir in pathDirs) { if (!string.IsNullOrWhiteSpace(dir)) { possiblePaths.Add(Path.Combine(dir, "steamcmd")); } } } // macOS paths else if (OperatingSystem.IsMacOS()) { possiblePaths.AddRange(new[] { "/usr/local/bin/steamcmd", "/opt/homebrew/bin/steamcmd", "/Applications/Steam.app/Contents/MacOS/steamcmd" }); // Check PATH environment variable var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(':') ?? []; foreach (var dir in pathDirs) { if (!string.IsNullOrWhiteSpace(dir)) { possiblePaths.Add(Path.Combine(dir, "steamcmd")); } } } // Check each possible path foreach (var path in possiblePaths) { if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) { try { // Verify it's actually SteamCMD by checking if it responds to --version or +quit var testResult = await ExecuteSteamCmdCommandAsync("+quit", TimeSpan.FromSeconds(30), path); if (testResult.Success) { _logger?.LogDebug("Found SteamCMD at: {Path}", path); return path; } } catch (Exception ex) { _logger?.LogDebug(ex, "Failed to verify SteamCMD at: {Path}", path); } } } _logger?.LogWarning("SteamCMD not found in common installation locations"); return string.Empty; } public async Task LoginToSteamAsync(string username, string? password = null) { try { if (!IsValidUsername(username)) throw new ArgumentException("Invalid username", nameof(username)); if (string.IsNullOrWhiteSpace(ExecutablePath)) { _logger?.LogError("SteamCMD path is not configured"); return SteamCmdStatus.PathNotConfigured; } if (!File.Exists(ExecutablePath)) { _logger?.LogError("SteamCMD executable not found at configured path: {Path}", ExecutablePath); return SteamCmdStatus.ExecutableNotFound; } var loginCommand = string.IsNullOrWhiteSpace(password) ? $"+login {username}" : $"+login {username} {password}"; var result = await ExecuteSteamCmdCommandAsync($"{loginCommand} +quit", TimeSpan.FromSeconds(120)); if (result.Output.Contains("Invalid Password")) return SteamCmdStatus.InvalidPassword; if (result.Success) { _logger?.LogInformation("Successfully logged into Steam as {Username}", username); return SteamCmdStatus.Success; } _logger?.LogError("Failed to log into Steam: {Error}", result.ErrorOutput); return SteamCmdStatus.Error; } catch (Exception ex) { _logger?.LogError(ex, "Error logging into Steam"); return SteamCmdStatus.UnknownError; } } public async Task LogoutAsync(string username) { try { if (!IsValidUsername(username)) return SteamCmdStatus.InvalidUsername; if (string.IsNullOrWhiteSpace(ExecutablePath)) { _logger?.LogError("SteamCMD path is not configured"); return SteamCmdStatus.PathNotConfigured; } if (!File.Exists(ExecutablePath)) { _logger?.LogError("SteamCMD executable not found at configured path: {Path}", ExecutablePath); return SteamCmdStatus.ExecutableNotFound; } var logoutCommand = $"+logout {username}"; var result = await ExecuteSteamCmdCommandAsync($"{logoutCommand} +quit"); if (result.Success) { _logger?.LogInformation("Successfully logged out of the Steam account {Username}", username); return SteamCmdStatus.Success; } _logger?.LogError("Failed to log out of Steam: {Error}", result.ErrorOutput); return SteamCmdStatus.Error; } catch (Exception ex) { _logger?.LogError(ex, "Error logging out of Steam"); return SteamCmdStatus.UnknownError; } } public async Task InstallContentAsync(uint appId, string installDirectory, string? username = null) { if (!string.IsNullOrWhiteSpace(username) && !IsValidUsername(username)) throw new ArgumentException("Invalid username", nameof(username)); if (string.IsNullOrWhiteSpace(ExecutablePath)) { _logger?.LogError("SteamCMD path is not configured"); throw new InvalidOperationException("SteamCMD path is not configured"); } if (!File.Exists(ExecutablePath)) { _logger?.LogError("SteamCMD executable not found at configured path: {Path}", ExecutablePath); throw new FileNotFoundException("SteamCMD executable not found", ExecutablePath); } Directory.CreateDirectory(installDirectory); var commands = new List(); if (!string.IsNullOrWhiteSpace(username)) commands.Add($"+login {username}"); else commands.Add("+login anonymous"); commands.Add($"+force_install_dir \"{installDirectory}\""); commands.Add($"+app_update {appId} validate"); commands.Add("+quit"); // Create install job var job = new SteamCmdInstallJob { AppId = appId, InstallDirectory = installDirectory, Username = username, Status = SteamCmdInstallStatus.InProgress, StatusMessage = "Starting installation...", CompletionSource = new TaskCompletionSource() }; OnInstallStatusChanged(job, SteamCmdInstallStatus.InProgress); try { var result = await ExecuteSteamCmdCommandWithProgressAsync(string.Join(" ", commands), job, _cancellationTokenSource.Token); if (result.Success) { job.Status = SteamCmdInstallStatus.Completed; job.StatusMessage = "Installation completed sucessfully"; job.Progress = 100; job.CompletedAt = DateTime.UtcNow; job.CompletionSource.TrySetResult(SteamCmdStatus.Success); OnInstallStatusChanged(job, SteamCmdInstallStatus.Completed); OnInstallProgress(job, 100, "Installation completed successfully"); _logger?.LogInformation("Successfully installed Steam app {AppId} to {InstallDirectory}", job.AppId, job.InstallDirectory); } else { job.Status = SteamCmdInstallStatus.Failed; job.StatusMessage = $"Installation failed: {result.ErrorOutput}"; job.ErrorMessage = result.ErrorOutput; job.CompletedAt = DateTime.UtcNow; job.CompletionSource?.TrySetResult(SteamCmdStatus.Error); OnInstallStatusChanged(job, SteamCmdInstallStatus.Failed, result.ErrorOutput); _logger?.LogError("Failed to install Steam app {AppId}: {Error}", job.AppId, result.ErrorOutput); } } catch (Exception ex) { job.Status = SteamCmdInstallStatus.Failed; job.StatusMessage = $"Installation failed: {ex.Message}"; job.ErrorMessage = ex.Message; job.CompletedAt = DateTime.UtcNow; job.CompletionSource?.TrySetException(ex); OnInstallStatusChanged(job, SteamCmdInstallStatus.Failed, ex.Message); _logger?.LogError(ex, "Error installing Steam content for app {AppId}", job.AppId); } } public async Task RemoveContentAsync(string installDirectory) { try { if (string.IsNullOrWhiteSpace(installDirectory)) { _logger?.LogError("Install directory is not specified"); return SteamCmdStatus.InstallDirectoryNotFound; } if (!Directory.Exists(installDirectory)) { _logger?.LogWarning("Install directory does not exist: {InstallDirectory}", installDirectory); return SteamCmdStatus.Success; // Consider it already removed } // Remove the directory and all its contents Directory.Delete(installDirectory, true); _logger?.LogInformation("Successfully removed content from {InstallDirectory}", installDirectory); return SteamCmdStatus.Success; } catch (Exception ex) { _logger?.LogError(ex, "Error removing content from {InstallDirectory}", installDirectory); return SteamCmdStatus.UnknownError; } } public async Task> GetProfilesAsync() { if (profileStore == null) { throw new InvalidOperationException("Profile store is not configured. Provide an ISteamCmdProfileStore implementation."); } return await profileStore.GetAllAsync(); } public async Task GetProfileAsync(string username) { if (profileStore == null) { throw new InvalidOperationException("Profile store is not configured. Provide an ISteamCmdProfileStore implementation."); } return await profileStore.GetByUsernameAsync(username); } public async Task SaveProfileAsync(SteamCmdProfile profile) { if (profileStore == null) { throw new InvalidOperationException("Profile store is not configured. Provide an ISteamCmdProfileStore implementation."); } if (profile == null) { throw new ArgumentNullException(nameof(profile)); } if (string.IsNullOrWhiteSpace(profile.Username)) { throw new ArgumentException("Profile username cannot be empty", nameof(profile)); } await profileStore.SaveAsync(profile); _logger?.LogInformation("Saved profile for username: {Username}", profile.Username); } public async Task DeleteProfileAsync(string username) { if (profileStore == null) { throw new InvalidOperationException("Profile store is not configured. Provide an ISteamCmdProfileStore implementation."); } if (string.IsNullOrWhiteSpace(username)) { throw new ArgumentException("Username cannot be empty", nameof(username)); } await profileStore.DeleteAsync(username); _logger?.LogInformation("Deleted profile for username: {Username}", username); } /// public async Task GetAppInfoAsync(uint appId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(ExecutablePath)) { if (_options.AutoDetectPath) { var detected = await AutoDetectSteamCmdPathAsync(); if (!string.IsNullOrEmpty(detected)) ExecutablePath = detected; } if (string.IsNullOrWhiteSpace(ExecutablePath)) { _logger?.LogWarning("SteamCMD path is not configured; cannot get app changenumber."); return null; } } if (!File.Exists(ExecutablePath)) { _logger?.LogWarning("SteamCMD executable not found at {Path}", ExecutablePath); return null; } // +quit can suppress output on some SteamCMD versions; we parse both stdout and stderr var arguments = $"+login anonymous +app_info_update 1 +app_info_print {appId} +quit"; var result = await ExecuteSteamCmdCommandAsync(arguments, TimeSpan.FromSeconds(60)); var combined = (result.Output + "\n" + result.ErrorOutput); var info = ParseAppInfoPrintOutput(appId, combined); if (info != null) _logger?.LogDebug("SteamCMD app info for {AppId}: changenumber={Changenumber}", appId, info.Changenumber); return info; } private static SteamCmdAppInfo? ParseAppInfoPrintOutput(uint appId, string output) { // VDF-like output: "buildid" "3936003" and "timeupdated" "1560990358" (often under "public" branch) var buildIdMatch = Regex.Match(output, @"""buildid""\s+""([^""]+)""", RegexOptions.IgnoreCase); var timeUpdatedMatch = Regex.Match(output, @"""timeupdated""\s+""([^""]+)""", RegexOptions.IgnoreCase); if (!buildIdMatch.Success) return null; var changenumber = buildIdMatch.Groups[1].Value.Trim(); if (string.IsNullOrEmpty(changenumber)) return null; DateTimeOffset? timeUpdated = null; if (timeUpdatedMatch.Success && long.TryParse(timeUpdatedMatch.Groups[1].Value.Trim(), out var unixSeconds)) timeUpdated = DateTimeOffset.FromUnixTimeSeconds(unixSeconds); return new SteamCmdAppInfo { AppId = appId, Changenumber = changenumber, TimeUpdated = timeUpdated }; } private async Task ExecuteSteamCmdCommandWithProgressAsync( string arguments, SteamCmdInstallJob? job = null, CancellationToken cancellationToken = default) { try { if (string.IsNullOrWhiteSpace(ExecutablePath)) { throw new InvalidOperationException("SteamCMD executable path is not configured"); } var processStartInfo = new ProcessStartInfo { FileName = ExecutablePath, Arguments = arguments, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; using var process = new Process { StartInfo = processStartInfo }; var output = new StringBuilder(); var error = new StringBuilder(); process.Start(); // Read streams with \r and \n as line delimiters so we get progress updates in real time. // SteamCMD uses \r for progress lines and full buffering when piped; BeginOutputReadLine // only fires on \n, so output would otherwise appear only at the end. var readStdOut = ReadStreamByLineAsync( process.StandardOutput.BaseStream, Encoding.UTF8, line => { if (string.IsNullOrEmpty(line)) return; output.AppendLine(line); _logger?.LogDebug("SteamCMD Output: {Output}", line); if (job != null) ParseProgressFromOutput(line, job); }, cancellationToken); var readStdErr = ReadStreamByLineAsync( process.StandardError.BaseStream, Encoding.UTF8, line => { if (string.IsNullOrEmpty(line)) return; error.AppendLine(line); _logger?.LogDebug("SteamCMD Error: {Error}", line); if (job != null) ParseProgressFromOutput(line, job); }, cancellationToken); try { await Task.WhenAll( readStdOut, readStdErr, process.WaitForExitAsync(cancellationToken)); } catch (OperationCanceledException) { _logger?.LogWarning("SteamCMD operation was cancelled"); process.Kill(); throw; } catch (Exception ex) { _logger?.LogError(ex, "Error while executing SteamCMD"); } return new SteamCmdResult { Success = process.ExitCode == 0, ExitCode = process.ExitCode, Output = output.ToString(), ErrorOutput = error.ToString() }; } catch (Exception ex) { _logger?.LogError(ex, "Error executing SteamCMD command: {Arguments}", arguments); return new SteamCmdResult { Success = false, ExitCode = -1, ErrorOutput = ex.Message }; } } /// /// Reads a stream and invokes the callback for each line. Lines are split on \r and \n /// so that progress output (often \r-only) is delivered in real time instead of buffered. /// private static async Task ReadStreamByLineAsync( Stream stream, Encoding encoding, Action onLine, CancellationToken cancellationToken = default) { using var reader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: false, leaveOpen: true); var lineBuilder = new StringBuilder(); var charBuffer = new char[256]; int read; while ((read = await reader.ReadAsync(charBuffer.AsMemory(), cancellationToken).ConfigureAwait(false)) > 0) { for (var i = 0; i < read; i++) { var ch = charBuffer[i]; if (ch == '\r' || ch == '\n') { if (lineBuilder.Length > 0) { onLine(lineBuilder.ToString()); lineBuilder.Clear(); } } else { lineBuilder.Append(ch); } } } if (lineBuilder.Length > 0) onLine(lineBuilder.ToString()); } private void ParseProgressFromOutput(string line, SteamCmdInstallJob job) { // SteamCMD progress patterns: // "Downloading update (X of Y MB)" // "Update state (X% at Y MB/s) : downloading update" // "Success. App 'X' fully installed." // "Installing update..." var progressMatch = Regex.Match(line, @"Update state \(([\d.]+)%", RegexOptions.IgnoreCase); if (progressMatch.Success && double.TryParse(progressMatch.Groups[1].Value, out var progress)) { job.Progress = Math.Min(100, Math.Max(0, progress)); // Try to extract download speed var speedMatch = Regex.Match(line, @"at ([\d.]+) (MB|KB)/s", RegexOptions.IgnoreCase); if (speedMatch.Success && double.TryParse(speedMatch.Groups[1].Value, out var speed)) { var unit = speedMatch.Groups[2].Value.ToUpper(); job.BytesPerSecond = (long)(speed * (unit == "MB" ? 1048576 : 1024)); } OnInstallProgress(job, job.Progress, line, job.BytesDownloaded, job.BytesTotal, job.BytesPerSecond); } // Extract download progress (X of Y MB) var downloadMatch = Regex.Match(line, @"Downloading update \(([\d.]+) of ([\d.]+) (MB|KB)\)", RegexOptions.IgnoreCase); if (downloadMatch.Success) { if (double.TryParse(downloadMatch.Groups[1].Value, out var downloaded) && double.TryParse(downloadMatch.Groups[2].Value, out var total)) { var unit = downloadMatch.Groups[3].Value.ToUpper(); var multiplier = unit == "MB" ? 1048576 : 1024; job.BytesDownloaded = (long)(downloaded * multiplier); job.BytesTotal = (long)(total * multiplier); if (job.BytesTotal > 0) { job.Progress = (job.BytesDownloaded * 100.0) / job.BytesTotal; } OnInstallProgress(job, job.Progress, line, job.BytesDownloaded, job.BytesTotal, job.BytesPerSecond); } } // Update status message if (!string.IsNullOrWhiteSpace(line) && !line.Contains("Update state")) { job.StatusMessage = line.Trim(); } } private void OnInstallStatusChanged(SteamCmdInstallJob job, SteamCmdInstallStatus status, string? errorMessage = null) { try { InstallStatusChanged?.Invoke(this, new SteamCmdInstallStatusEventArgs(job, status, errorMessage)); } catch (Exception ex) { _logger?.LogError(ex, "Error invoking InstallStatusChanged event"); } } private void OnInstallProgress(SteamCmdInstallJob job, double progress, string statusMessage, long bytesDownloaded = 0, long bytesTotal = 0, long bytesPerSecond = 0) { try { InstallProgress?.Invoke(this, new SteamCmdInstallProgressEventArgs(job, progress, statusMessage, bytesDownloaded, bytesTotal, bytesPerSecond)); } catch (Exception ex) { _logger?.LogError(ex, "Error invoking InstallProgress event"); } } private async Task ExecuteSteamCmdCommandAsync(string arguments, TimeSpan? timeout = null, string? steamCmdPath = null) { try { var executablePath = string.IsNullOrWhiteSpace(steamCmdPath) ? ExecutablePath : steamCmdPath; if (string.IsNullOrWhiteSpace(executablePath)) { throw new InvalidOperationException("SteamCMD executable path is not configured"); } var processStartInfo = new ProcessStartInfo { FileName = executablePath, Arguments = arguments, UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; using var process = new Process { StartInfo = processStartInfo }; var output = new StringBuilder(); var error = new StringBuilder(); process.OutputDataReceived += (sender, e) => { if (!string.IsNullOrEmpty(e.Data)) { output.AppendLine(e.Data); _logger?.LogDebug("SteamCMD Output: {Output}", e.Data); } }; process.ErrorDataReceived += (sender, e) => { if (!string.IsNullOrEmpty(e.Data)) { error.AppendLine(e.Data); _logger?.LogDebug("SteamCMD Error: {Error}", e.Data); } }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); try { if (timeout.HasValue) await process.WaitForExitAsync().WaitAsync(timeout.Value); else await process.WaitForExitAsync(); } catch (TimeoutException) { _logger?.LogWarning("SteamCMD timed out"); process.Kill(); } catch (Exception ex) { _logger?.LogError(ex, "Error while executing SteamCMD"); } return new SteamCmdResult { Success = process.ExitCode == 0, ExitCode = process.ExitCode, Output = output.ToString(), ErrorOutput = error.ToString() }; } catch (Exception ex) { _logger?.LogError(ex, "Error executing SteamCMD command: {Arguments}", arguments); return new SteamCmdResult { Success = false, ExitCode = -1, ErrorOutput = ex.Message }; } } private static bool IsValidUsername(string value) { return !string.IsNullOrEmpty(value) && Regex.IsMatch(value, @"^[a-zA-Z0-9_]+$"); } private class SteamCmdResult { public bool Success { get; set; } public int ExitCode { get; set; } public string Output { get; set; } = string.Empty; public string ErrorOutput { get; set; } = string.Empty; } }