diff --git a/DepotDownloader/Ansi.cs b/DepotDownloader/Ansi.cs new file mode 100644 index 00000000..7041debc --- /dev/null +++ b/DepotDownloader/Ansi.cs @@ -0,0 +1,51 @@ +using System; +using Spectre.Console; + +namespace DepotDownloader; + +static class Ansi +{ + // https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC + // https://learn.microsoft.com/en-us/windows/terminal/tutorials/progress-bar-sequences + public enum ProgressState + { + Hidden = 0, + Default = 1, + Error = 2, + Indeterminate = 3, + Warning = 4, + } + + const char ESC = (char)0x1B; + const char BEL = (char)0x07; + + private static bool useProgress; + + public static void Init() + { + if (Console.IsInputRedirected || Console.IsOutputRedirected) + { + return; + } + + var (supportsAnsi, legacyConsole) = AnsiDetector.Detect(stdError: false, upgrade: true); + + useProgress = supportsAnsi && !legacyConsole; + } + + public static void Progress(ulong downloaded, ulong total) + { + var progress = (byte)MathF.Round(downloaded / (float)total * 100.0f); + Progress(ProgressState.Default, progress); + } + + public static void Progress(ProgressState state, byte progress = 0) + { + if (!useProgress) + { + return; + } + + Console.Write($"{ESC}]9;4;{(byte)state};{progress}{BEL}"); + } +} diff --git a/DepotDownloader/AnsiDetector.cs b/DepotDownloader/AnsiDetector.cs new file mode 100644 index 00000000..2110d9cc --- /dev/null +++ b/DepotDownloader/AnsiDetector.cs @@ -0,0 +1,134 @@ +// Copied from https://github.com/spectreconsole/spectre.console/blob/d79e6adc5f8e637fb35c88f987023ffda6707243/src/Spectre.Console/Internal/Backends/Ansi/AnsiDetector.cs +// MIT License - Copyright(c) 2020 Patrik Svensson, Phil Scott, Nils Andresen +// which is partially based on https://github.com/keqingrong/supports-ansi/blob/master/index.js +// + +using System; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +namespace Spectre.Console; + +internal static class AnsiDetector +{ + private static readonly Regex[] _regexes = + [ + new("^xterm"), // xterm, PuTTY, Mintty + new("^rxvt"), // RXVT + new("^eterm"), // Eterm + new("^screen"), // GNU screen, tmux + new("tmux"), // tmux + new("^vt100"), // DEC VT series + new("^vt102"), // DEC VT series + new("^vt220"), // DEC VT series + new("^vt320"), // DEC VT series + new("ansi"), // ANSI + new("scoansi"), // SCO ANSI + new("cygwin"), // Cygwin, MinGW + new("linux"), // Linux console + new("konsole"), // Konsole + new("bvterm"), // Bitvise SSH Client + new("^st-256color"), // Suckless Simple Terminal, st + new("alacritty"), // Alacritty + ]; + + public static (bool SupportsAnsi, bool LegacyConsole) Detect(bool stdError, bool upgrade) + { + // Running on Windows? + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Running under ConEmu? + var conEmu = Environment.GetEnvironmentVariable("ConEmuANSI"); + if (!string.IsNullOrEmpty(conEmu) && conEmu.Equals("On", StringComparison.OrdinalIgnoreCase)) + { + return (true, false); + } + + var supportsAnsi = Windows.SupportsAnsi(upgrade, stdError, out var legacyConsole); + return (supportsAnsi, legacyConsole); + } + + return DetectFromTerm(); + } + + private static (bool SupportsAnsi, bool LegacyConsole) DetectFromTerm() + { + // Check if the terminal is of type ANSI/VT100/xterm compatible. + var term = Environment.GetEnvironmentVariable("TERM"); + if (!string.IsNullOrWhiteSpace(term)) + { + if (_regexes.Any(regex => regex.IsMatch(term))) + { + return (true, false); + } + } + + return (false, true); + } + + private static class Windows + { + private const int STD_OUTPUT_HANDLE = -11; + private const int STD_ERROR_HANDLE = -12; + private const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004; + private const uint DISABLE_NEWLINE_AUTO_RETURN = 0x0008; + + [DllImport("kernel32.dll")] + private static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); + + [DllImport("kernel32.dll")] + private static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int nStdHandle); + + [DllImport("kernel32.dll")] + public static extern uint GetLastError(); + + public static bool SupportsAnsi(bool upgrade, bool stdError, out bool isLegacy) + { + isLegacy = false; + + try + { + var @out = GetStdHandle(stdError ? STD_ERROR_HANDLE : STD_OUTPUT_HANDLE); + if (!GetConsoleMode(@out, out var mode)) + { + // Could not get console mode, try TERM (set in cygwin, WSL-Shell). + var (ansiFromTerm, legacyFromTerm) = DetectFromTerm(); + + isLegacy = ansiFromTerm ? legacyFromTerm : isLegacy; + return ansiFromTerm; + } + + if ((mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0) + { + isLegacy = true; + + if (!upgrade) + { + return false; + } + + // Try enable ANSI support. + mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING | DISABLE_NEWLINE_AUTO_RETURN; + if (!SetConsoleMode(@out, mode)) + { + // Enabling failed. + return false; + } + + isLegacy = false; + } + + return true; + } + catch + { + // All we know here is that we don't support ANSI. + return false; + } + } + } +} diff --git a/DepotDownloader/ContentDownloader.cs b/DepotDownloader/ContentDownloader.cs index e68a3a02..6e528b91 100644 --- a/DepotDownloader/ContentDownloader.cs +++ b/DepotDownloader/ContentDownloader.cs @@ -610,6 +610,7 @@ namespace DepotDownloader private class GlobalDownloadCounter { + public ulong completeDownloadSize; public ulong totalBytesCompressed; public ulong totalBytesUncompressed; } @@ -624,6 +625,8 @@ namespace DepotDownloader private static async Task DownloadSteam3Async(List depots) { + Ansi.Progress(Ansi.ProgressState.Indeterminate); + var cts = new CancellationTokenSource(); cdnPool.ExhaustedToken = cts; @@ -634,7 +637,7 @@ namespace DepotDownloader // First, fetch all the manifests for each depot (including previous manifests) and perform the initial setup foreach (var depot in depots) { - var depotFileData = await ProcessDepotManifestAndFiles(cts, depot); + var depotFileData = await ProcessDepotManifestAndFiles(cts, depot, downloadCounter); if (depotFileData != null) { @@ -665,11 +668,13 @@ namespace DepotDownloader await DownloadSteam3AsyncDepotFiles(cts, downloadCounter, depotFileData, allFileNamesAllDepots); } + Ansi.Progress(Ansi.ProgressState.Hidden); + Console.WriteLine("Total downloaded: {0} bytes ({1} bytes uncompressed) from {2} depots", downloadCounter.totalBytesCompressed, downloadCounter.totalBytesUncompressed, depots.Count); } - private static async Task ProcessDepotManifestAndFiles(CancellationTokenSource cts, DepotDownloadInfo depot) + private static async Task ProcessDepotManifestAndFiles(CancellationTokenSource cts, DepotDownloadInfo depot, GlobalDownloadCounter downloadCounter) { var depotCounter = new DepotDownloadCounter(); @@ -751,7 +756,7 @@ namespace DepotDownloader } else { - Console.Write("Downloading depot manifest..."); + Console.Write("Downloading depot manifest... "); DepotManifest depotManifest = null; ulong manifestRequestCode = 0; @@ -814,7 +819,7 @@ namespace DepotDownloader if (e.StatusCode == HttpStatusCode.Unauthorized || e.StatusCode == HttpStatusCode.Forbidden) { - Console.WriteLine("Encountered 401 for depot manifest {0} {1}. Aborting.", depot.DepotId, depot.ManifestId); + Console.WriteLine("Encountered {2} for depot manifest {0} {1}. Aborting.", depot.DepotId, depot.ManifestId, (int)e.StatusCode); break; } @@ -889,6 +894,7 @@ namespace DepotDownloader Directory.CreateDirectory(Path.GetDirectoryName(fileFinalPath)); Directory.CreateDirectory(Path.GetDirectoryName(fileStagingPath)); + downloadCounter.completeDownloadSize += file.TotalSize; depotCounter.completeDownloadSize += file.TotalSize; } }); @@ -918,7 +924,7 @@ namespace DepotDownloader await Util.InvokeAsync( files.Select(file => new Func(async () => - await Task.Run(() => DownloadSteam3AsyncDepotFile(cts, depotFilesData, file, networkChunkQueue)))), + await Task.Run(() => DownloadSteam3AsyncDepotFile(cts, downloadCounter, depotFilesData, file, networkChunkQueue)))), maxDegreeOfParallelism: Config.MaxDownloads ); @@ -966,6 +972,7 @@ namespace DepotDownloader private static void DownloadSteam3AsyncDepotFile( CancellationTokenSource cts, + GlobalDownloadCounter downloadCounter, DepotFilesData depotFilesData, ProtoManifest.FileData file, ConcurrentQueue<(FileStreamData, ProtoManifest.FileData, ProtoManifest.ChunkData)> networkChunkQueue) @@ -1128,6 +1135,11 @@ namespace DepotDownloader Console.WriteLine("{0,6:#00.00}% {1}", (depotDownloadCounter.sizeDownloaded / (float)depotDownloadCounter.completeDownloadSize) * 100.0f, fileFinalPath); } + lock (downloadCounter) + { + downloadCounter.completeDownloadSize -= file.TotalSize; + } + return; } @@ -1136,6 +1148,11 @@ namespace DepotDownloader { depotDownloadCounter.sizeDownloaded += sizeOnDisk; } + + lock (downloadCounter) + { + downloadCounter.completeDownloadSize -= sizeOnDisk; + } } var fileIsExecutable = file.Flags.HasFlag(EDepotFileFlag.Executable); @@ -1217,7 +1234,7 @@ namespace DepotDownloader if (e.StatusCode == HttpStatusCode.Unauthorized || e.StatusCode == HttpStatusCode.Forbidden) { - Console.WriteLine("Encountered 401 for chunk {0}. Aborting.", chunkID); + Console.WriteLine("Encountered {1} for chunk {0}. Aborting.", chunkID, (int)e.StatusCode); break; } @@ -1281,6 +1298,8 @@ namespace DepotDownloader { downloadCounter.totalBytesCompressed += chunk.CompressedLength; downloadCounter.totalBytesUncompressed += chunk.UncompressedLength; + + Ansi.Progress(downloadCounter.totalBytesUncompressed, downloadCounter.completeDownloadSize); } if (remainingChunks == 0) diff --git a/DepotDownloader/Program.cs b/DepotDownloader/Program.cs index 14f7f88d..ae44b765 100644 --- a/DepotDownloader/Program.cs +++ b/DepotDownloader/Program.cs @@ -26,6 +26,8 @@ namespace DepotDownloader return 1; } + Ansi.Init(); + DebugLog.Enabled = false; AccountSettingsStore.LoadFromFile("account.config");