diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 00000000..d9e2ae9c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,53 @@ +name: Bug Report +description: File a bug report +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: what-should-have-happened + attributes: + label: What did you expect to happen? + placeholder: I expected that... + validations: + required: true + - type: textarea + id: what-actually-happened + attributes: + label: Instead of that, what actually happened? + placeholder: ... but instead, what happened was... + validations: + required: true + - type: dropdown + id: operating-system + attributes: + label: Which operating system are you running on? + options: + - Linux + - macOS + - Windows + - Other + validations: + required: true + - type: input + id: dotnet-version + attributes: + label: Version + description: What version of DepotDownloader are you running on? + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: textarea + id: additional-info + attributes: + label: Additional Information + description: Is there anything else that you think we should know? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..47e6660c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Discussions + url: https://github.com/SteamRE/DepotDownloader/discussions/new + about: Please ask and answer questions here. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 00000000..72cbe5db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,36 @@ +name: Feature Request +description: Suggest an idea for this project +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Thanks, we appreciate good ideas! + - type: textarea + id: problem-area + attributes: + label: What problem is this feature trying to solve? + placeholder: I'm really frustrated when... + validations: + required: true + - type: textarea + id: proposed-solution + attributes: + label: How would you like it to be solved? + placeholder: I think that it could be solved by... + validations: + required: true + - type: textarea + id: alternative-solutions + attributes: + label: Have you considered any alternative solutions + placeholder: I did think that that it also could be solved by ..., but... + validations: + required: true + - type: textarea + id: additional-info + attributes: + label: Additional Information + description: Is there anything else that you think we should know? + validations: + required: false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5dbc0a5d..265a1562 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,28 +15,114 @@ on: jobs: build: - name: .NET on ${{ matrix.os }} (${{ matrix.configuration }}) - runs-on: ${{ matrix.os }} + name: .NET on ${{ matrix.runs-on }} (${{ matrix.configuration }}) + runs-on: ${{ matrix.runs-on }} strategy: fail-fast: false matrix: - os: [macos-latest, ubuntu-latest, windows-latest] + runs-on: [macos-latest, ubuntu-latest, windows-latest] configuration: [Release, Debug] + env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup .NET Core - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v2 with: - dotnet-version: '6.0.x' + dotnet-version: 6.0.x - name: Build run: dotnet publish -c ${{ matrix.configuration }} -o artifacts - name: Upload artifact - uses: actions/upload-artifact@v2 - if: matrix.configuration == 'Release' + uses: actions/upload-artifact@v3 + if: matrix.configuration == 'Release' && matrix.runs-on == 'windows-latest' with: - name: DepotDownloader-${{ runner.os }} + name: DepotDownloader-framework path: artifacts if-no-files-found: error + + - name: Publish Windows-x64 + if: matrix.configuration == 'Release' && matrix.runs-on == 'windows-latest' + run: dotnet publish --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime win-x64 --output selfcontained-win-x64 + + - name: Publish Windows-arm64 + if: matrix.configuration == 'Release' && matrix.runs-on == 'windows-latest' + run: dotnet publish --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime win-arm64 --output selfcontained-win-arm64 + + - name: Publish Linux-x64 + if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' + run: dotnet publish --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime linux-x64 --output selfcontained-linux-x64 + + - name: Publish Linux-arm + if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' + run: dotnet publish --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime linux-arm --output selfcontained-linux-arm + + - name: Publish Linux-arm64 + if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' + run: dotnet publish --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime linux-arm64 --output selfcontained-linux-arm64 + + - name: Publish macOS-x64 + if: matrix.configuration == 'Release' && matrix.runs-on == 'macos-latest' + run: dotnet publish --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime osx-x64 --output selfcontained-osx-x64 + + - name: Publish macOS-arm64 + if: matrix.configuration == 'Release' && matrix.runs-on == 'macos-latest' + run: dotnet publish --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime osx-arm64 --output selfcontained-osx-arm64 + + - name: Upload Windows-x64 + uses: actions/upload-artifact@v3 + if: matrix.configuration == 'Release' && matrix.runs-on == 'windows-latest' + with: + name: DepotDownloader-windows-x64 + path: selfcontained-win-x64 + if-no-files-found: error + + - name: Upload Windows-arm64 + uses: actions/upload-artifact@v3 + if: matrix.configuration == 'Release' && matrix.runs-on == 'windows-latest' + with: + name: DepotDownloader-windows-arm64 + path: selfcontained-win-arm64 + if-no-files-found: error + + - name: Upload Linux-x64 + uses: actions/upload-artifact@v3 + if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' + with: + name: DepotDownloader-linux-x64 + path: selfcontained-linux-x64 + if-no-files-found: error + + - name: Upload Linux-arm + uses: actions/upload-artifact@v3 + if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' + with: + name: DepotDownloader-linux-arm + path: selfcontained-linux-arm + if-no-files-found: error + + - name: Upload Linux-arm64 + uses: actions/upload-artifact@v3 + if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' + with: + name: DepotDownloader-linux-arm64 + path: selfcontained-linux-arm64 + if-no-files-found: error + + - name: Upload macOS-x64 + uses: actions/upload-artifact@v3 + if: matrix.configuration == 'Release' && matrix.runs-on == 'macos-latest' + with: + name: DepotDownloader-macos-x64 + path: selfcontained-osx-x64 + if-no-files-found: error + + - name: Upload macOS-arm64 + uses: actions/upload-artifact@v3 + if: matrix.configuration == 'Release' && matrix.runs-on == 'macos-latest' + with: + name: DepotDownloader-macos-arm64 + path: selfcontained-osx-arm64 + if-no-files-found: error diff --git a/.github/workflows/sk2-ci.yml b/.github/workflows/sk2-ci.yml new file mode 100644 index 00000000..185be88c --- /dev/null +++ b/.github/workflows/sk2-ci.yml @@ -0,0 +1,41 @@ +name: SteamKit2 Continuous Integration + +on: + schedule: + - cron: '0 1 * * SUN' + workflow_dispatch: + +jobs: + build: + name: .NET on ${{ matrix.runs-on }} (${{ matrix.configuration }}) + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + runs-on: [ macos-latest, ubuntu-latest, windows-latest ] + configuration: [ Release, Debug ] + env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + steps: + - uses: actions/checkout@v3 + + - name: Setup .NET Core + uses: actions/setup-dotnet@v2 + with: + dotnet-version: 6.0.x + + - name: Configure NuGet + run: | + dotnet nuget add source --username USERNAME --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/SteamRE/index.json" + dotnet add DepotDownloader/DepotDownloader.csproj package SteamKit2 --prerelease + + - name: Build + run: dotnet publish -c ${{ matrix.configuration }} -o artifacts + + - name: Upload artifact + uses: actions/upload-artifact@v3 + if: matrix.configuration == 'Release' + with: + name: DepotDownloader-${{ runner.os }} + path: artifacts + if-no-files-found: error diff --git a/DepotDownloader/AccountSettingsStore.cs b/DepotDownloader/AccountSettingsStore.cs index c6c9a251..8bed9622 100644 --- a/DepotDownloader/AccountSettingsStore.cs +++ b/DepotDownloader/AccountSettingsStore.cs @@ -17,8 +17,10 @@ namespace DepotDownloader [ProtoMember(2, IsRequired = false)] public ConcurrentDictionary ContentServerPenalty { get; private set; } - [ProtoMember(3, IsRequired = false)] - public Dictionary LoginKeys { get; private set; } + // Member 3 was a Dictionary for LoginKeys. + + [ProtoMember(4, IsRequired = false)] + public Dictionary LoginTokens { get; private set; } string FileName; @@ -26,7 +28,7 @@ namespace DepotDownloader { SentryData = new Dictionary(); ContentServerPenalty = new ConcurrentDictionary(); - LoginKeys = new Dictionary(); + LoginTokens = new Dictionary(); } static bool Loaded diff --git a/DepotDownloader/ContentDownloader.cs b/DepotDownloader/ContentDownloader.cs index a44cf14a..1f8538eb 100644 --- a/DepotDownloader/ContentDownloader.cs +++ b/DepotDownloader/ContentDownloader.cs @@ -21,7 +21,7 @@ namespace DepotDownloader public const uint INVALID_APP_ID = uint.MaxValue; public const uint INVALID_DEPOT_ID = uint.MaxValue; public const ulong INVALID_MANIFEST_ID = ulong.MaxValue; - public const string DEFAULT_BRANCH = "Public"; + public const string DEFAULT_BRANCH = "public"; public static DownloadConfig Config = new DownloadConfig(); @@ -41,21 +41,18 @@ namespace DepotDownloader public string branch { get; private set; } public string installDir { get; private set; } - public string contentName { get; private set; } public byte[] depotKey { get; private set; } public DepotDownloadInfo( uint depotid, uint appId, ulong manifestId, string branch, - string installDir, string contentName, - byte[] depotKey) + string installDir, byte[] depotKey) { this.id = depotid; this.appId = appId; this.manifestId = manifestId; this.branch = branch; this.installDir = installDir; - this.contentName = contentName; this.depotKey = depotKey; } } @@ -244,9 +241,9 @@ namespace DepotDownloader if (manifests.Children.Count == 0 && manifests_encrypted.Children.Count == 0) return INVALID_MANIFEST_ID; - var node = manifests[branch]; + var node = manifests[branch]["gid"]; - if (branch != "Public" && node == KeyValue.Invalid) + if (node == KeyValue.Invalid && !string.Equals(branch, DEFAULT_BRANCH, StringComparison.OrdinalIgnoreCase)) { var node_encrypted = manifests_encrypted[branch]; if (node_encrypted != KeyValue.Invalid) @@ -258,24 +255,9 @@ namespace DepotDownloader Config.BetaPassword = password = Console.ReadLine(); } - var encrypted_v1 = node_encrypted["encrypted_gid"]; - var encrypted_v2 = node_encrypted["encrypted_gid_2"]; + var encrypted_gid = node_encrypted["gid"]; - if (encrypted_v1 != KeyValue.Invalid) - { - var input = Util.DecodeHexString(encrypted_v1.Value); - var manifest_bytes = CryptoHelper.VerifyAndDecryptPassword(input, password); - - if (manifest_bytes == null) - { - Console.WriteLine("Password was invalid for branch {0}", branch); - return INVALID_MANIFEST_ID; - } - - return BitConverter.ToUInt64(manifest_bytes, 0); - } - - if (encrypted_v2 != KeyValue.Invalid) + if (encrypted_gid != KeyValue.Invalid) { // Submit the password to Steam now to get encryption keys steam3.CheckAppBetaPassword(appId, Config.BetaPassword); @@ -286,7 +268,7 @@ namespace DepotDownloader return INVALID_MANIFEST_ID; } - var input = Util.DecodeHexString(encrypted_v2.Value); + var input = Util.DecodeHexString(encrypted_gid.Value); byte[] manifest_bytes; try { @@ -314,50 +296,31 @@ namespace DepotDownloader return UInt64.Parse(node.Value); } - static string GetAppOrDepotName(uint depotId, uint appId) + static string GetAppName(uint appId) { - if (depotId == INVALID_DEPOT_ID) - { - var info = GetSteam3AppSection(appId, EAppInfoSection.Common); - - if (info == null) - return String.Empty; - - return info["name"].AsString(); - } - - var depots = GetSteam3AppSection(appId, EAppInfoSection.Depots); - - if (depots == null) + var info = GetSteam3AppSection(appId, EAppInfoSection.Common); + if (info == null) return String.Empty; - var depotChild = depots[depotId.ToString()]; - - if (depotChild == null) - return String.Empty; - - return depotChild["name"].AsString(); + return info["name"].AsString(); } public static bool InitializeSteam3(string username, string password) { - // capture the supplied password in case we need to re-use it after checking the login key - Config.SuppliedPassword = password; - - string loginKey = null; + string loginToken = null; if (username != null && Config.RememberPassword) { - _ = AccountSettingsStore.Instance.LoginKeys.TryGetValue(username, out loginKey); + _ = AccountSettingsStore.Instance.LoginTokens.TryGetValue(username.ToLowerInvariant(), out loginToken); } steam3 = new Steam3Session( new SteamUser.LogOnDetails { Username = username, - Password = loginKey == null ? password : null, + Password = loginToken == null ? password : null, ShouldRememberPassword = Config.RememberPassword, - LoginKey = loginKey, + AccessToken = loginToken, LoginID = Config.LoginID ?? 0x534B32, // "SK2" } ); @@ -384,7 +347,6 @@ namespace DepotDownloader if (steam3 == null) return; - steam3.TryWaitForLoginKey(); steam3.Disconnect(); } @@ -492,7 +454,7 @@ namespace DepotDownloader } else { - var contentName = GetAppOrDepotName(INVALID_DEPOT_ID, appId); + var contentName = GetAppName(appId); throw new ContentDownloaderException(String.Format("App {0} ({1}) is not available from this account.", appId, contentName)); } } @@ -615,11 +577,9 @@ namespace DepotDownloader if (steam3 != null && appId != INVALID_APP_ID) steam3.RequestAppInfo(appId); - var contentName = GetAppOrDepotName(depotId, appId); - if (!AccountHasAccess(depotId)) { - Console.WriteLine("Depot {0} ({1}) is not available from this account.", depotId, contentName); + Console.WriteLine("Depot {0}) is not available from this account.", depotId); return null; } @@ -627,16 +587,16 @@ namespace DepotDownloader if (manifestId == INVALID_MANIFEST_ID) { manifestId = GetSteam3DepotManifest(depotId, appId, branch); - if (manifestId == INVALID_MANIFEST_ID && branch != "public") + if (manifestId == INVALID_MANIFEST_ID && !string.Equals(branch, DEFAULT_BRANCH, StringComparison.OrdinalIgnoreCase)) { - Console.WriteLine("Warning: Depot {0} does not have branch named \"{1}\". Trying public branch.", depotId, branch); - branch = "public"; + Console.WriteLine("Warning: Depot {0} does not have branch named \"{1}\". Trying {2} branch.", depotId, branch, DEFAULT_BRANCH); + branch = DEFAULT_BRANCH; manifestId = GetSteam3DepotManifest(depotId, appId, branch); } if (manifestId == INVALID_MANIFEST_ID) { - Console.WriteLine("Depot {0} ({1}) missing public subsection or manifest section.", depotId, contentName); + Console.WriteLine("Depot {0} missing public subsection or manifest section.", depotId); return null; } } @@ -659,7 +619,7 @@ namespace DepotDownloader var depotKey = steam3.DepotKeys[depotId]; - return new DepotDownloadInfo(depotId, appId, manifestId, branch, installDir, contentName, depotKey); + return new DepotDownloadInfo(depotId, appId, manifestId, branch, installDir, depotKey); } private class ChunkMatch @@ -758,7 +718,7 @@ namespace DepotDownloader { var depotCounter = new DepotDownloadCounter(); - Console.WriteLine("Processing depot {0} - {1}", depot.id, depot.contentName); + Console.WriteLine("Processing depot {0}", depot.id); ProtoManifest oldProtoManifest = null; ProtoManifest newProtoManifest = null; @@ -997,7 +957,7 @@ namespace DepotDownloader var depot = depotFilesData.depotDownloadInfo; var depotCounter = depotFilesData.depotCounter; - Console.WriteLine("Downloading depot {0} - {1}", depot.id, depot.contentName); + Console.WriteLine("Downloading depot {0}", depot.id); var files = depotFilesData.filteredFiles.Where(f => !f.Flags.HasFlag(EDepotFileFlag.Directory)).ToArray(); var networkChunkQueue = new ConcurrentQueue<(FileStreamData fileStreamData, ProtoManifest.FileData fileData, ProtoManifest.ChunkData chunk)>(); diff --git a/DepotDownloader/DepotDownloader.csproj b/DepotDownloader/DepotDownloader.csproj index 896b7a96..8bbc1735 100644 --- a/DepotDownloader/DepotDownloader.csproj +++ b/DepotDownloader/DepotDownloader.csproj @@ -4,16 +4,18 @@ net6.0 true LatestMajor - 2.4.7 + 2.5.0 Steam Downloading Utility SteamRE Team Copyright © SteamRE Team 2021 + ..\Icon\DepotDownloader.ico - - - - + + + + + diff --git a/DepotDownloader/DownloadConfig.cs b/DepotDownloader/DownloadConfig.cs index 448e589f..1022aac4 100644 --- a/DepotDownloader/DownloadConfig.cs +++ b/DepotDownloader/DownloadConfig.cs @@ -22,10 +22,11 @@ namespace DepotDownloader public int MaxServers { get; set; } public int MaxDownloads { get; set; } - public string SuppliedPassword { get; set; } public bool RememberPassword { get; set; } // A Steam LoginID to allow multiple concurrent connections public uint? LoginID { get; set; } + + public bool UseQrCode { get; set; } } } diff --git a/DepotDownloader/PlatformUtilities.cs b/DepotDownloader/PlatformUtilities.cs index 9e3d2b6c..5ecdc8d3 100644 --- a/DepotDownloader/PlatformUtilities.cs +++ b/DepotDownloader/PlatformUtilities.cs @@ -11,11 +11,23 @@ namespace DepotDownloader private const int ModeExecute = ModeExecuteOwner | ModeExecuteGroup | ModeExecuteOther; [StructLayout(LayoutKind.Explicit, Size = 144)] - private readonly struct StatLinux + private readonly struct StatLinuxX64 { [FieldOffset(24)] public readonly uint st_mode; } + [StructLayout(LayoutKind.Explicit, Size = 104)] + private readonly struct StatLinuxArm32 + { + [FieldOffset(16)] public readonly uint st_mode; + } + + [StructLayout(LayoutKind.Explicit, Size = 128)] + private readonly struct StatLinuxArm64 + { + [FieldOffset(16)] public readonly uint st_mode; + } + [StructLayout(LayoutKind.Explicit, Size = 144)] private readonly struct StatOSX { @@ -23,7 +35,13 @@ namespace DepotDownloader } [DllImport("libc", EntryPoint = "__xstat", SetLastError = true)] - private static extern int statLinux(int version, string path, out StatLinux statLinux); + private static extern int statLinuxX64(int version, string path, out StatLinuxX64 statLinux); + + [DllImport("libc", EntryPoint = "__xstat", SetLastError = true)] + private static extern int statLinuxArm32(int version, string path, out StatLinuxArm32 statLinux); + + [DllImport("libc", EntryPoint = "__xstat", SetLastError = true)] + private static extern int statLinuxArm64(int version, string path, out StatLinuxArm64 statLinux); [DllImport("libc", EntryPoint = "stat", SetLastError = true)] private static extern int statOSX(string path, out StatOSX stat); @@ -34,9 +52,6 @@ namespace DepotDownloader [DllImport("libc", SetLastError = true)] private static extern int chmod(string path, uint mode); - [DllImport("libc", SetLastError = true)] - private static extern int chmod(string path, ushort mode); - [DllImport("libc", CallingConvention = CallingConvention.Cdecl, SetLastError = true)] private static extern IntPtr strerror(int errno); @@ -49,36 +64,63 @@ namespace DepotDownloader } } - public static void SetExecutable(string path, bool value) + private static uint GetFileMode(string path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - ThrowIf(statLinux(1, path, out var stat)); - - var hasExecuteMask = (stat.st_mode & ModeExecute) == ModeExecute; - if (hasExecuteMask != value) + switch (RuntimeInformation.ProcessArchitecture) { - ThrowIf(chmod(path, (uint)(value - ? stat.st_mode | ModeExecute - : stat.st_mode & ~ModeExecute))); + case Architecture.X64: + { + ThrowIf(statLinuxX64(1, path, out var stat)); + return stat.st_mode; + } + case Architecture.Arm: + { + ThrowIf(statLinuxArm32(3, path, out var stat)); + return stat.st_mode; + } + case Architecture.Arm64: + { + ThrowIf(statLinuxArm64(0, path, out var stat)); + return stat.st_mode; + } } } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - StatOSX stat; - - ThrowIf(RuntimeInformation.ProcessArchitecture == Architecture.Arm64 - ? statOSX(path, out stat) - : statOSXCompat(path, out stat)); - - var hasExecuteMask = (stat.st_mode & ModeExecute) == ModeExecute; - if (hasExecuteMask != value) + switch (RuntimeInformation.ProcessArchitecture) { - ThrowIf(chmod(path, (ushort)(value - ? stat.st_mode | ModeExecute - : stat.st_mode & ~ModeExecute))); + case Architecture.X64: + { + ThrowIf(statOSXCompat(path, out var stat)); + return stat.st_mode; + } + case Architecture.Arm64: + { + ThrowIf(statOSX(path, out var stat)); + return stat.st_mode; + } } } + throw new PlatformNotSupportedException(); + } + + public static void SetExecutable(string path, bool value) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + var mode = GetFileMode(path); + var hasExecuteMask = (mode & ModeExecute) == ModeExecute; + if (hasExecuteMask != value) + { + ThrowIf(chmod(path, (uint)(value + ? mode | ModeExecute + : mode & ~ModeExecute))); + } } } } diff --git a/DepotDownloader/Program.cs b/DepotDownloader/Program.cs index 13c9f6e6..c70b4ce8 100644 --- a/DepotDownloader/Program.cs +++ b/DepotDownloader/Program.cs @@ -43,6 +43,7 @@ namespace DepotDownloader new Option("--username", "The username of the account to login to for restricted content"), new Option("--password", "The password of the account to login to for restricted content"), new Option("--remember-password", "If set, remember the password for subsequent logins of this user"), + new Option("--qr", "If set, allows logging in with a QR code generate by the Steam Mobile App"), new Option(new[] { "--directory", "--dir" }, "The directory in which to place downloaded files"), new Option("--filelist", "A list of files to download (from the manifest). Prefix file path with 'regex:' if you want to match with regex").ExistingOnly(), @@ -113,6 +114,7 @@ namespace DepotDownloader string? Username, string? Password, bool RememberPassword, + bool Qr, DirectoryInfo? Directory, FileInfo? FileList, bool Validate, @@ -144,6 +146,7 @@ namespace DepotDownloader } ContentDownloader.Config.RememberPassword = input.RememberPassword; + ContentDownloader.Config.UseQrCode = input.Qr; ContentDownloader.Config.DownloadManifestOnly = input.ManifestOnly; ContentDownloader.Config.CellID = input.CellId ?? 0; @@ -257,21 +260,24 @@ namespace DepotDownloader private static bool InitializeSteam(string? username, string? password) { - if (username != null && password == null && (!ContentDownloader.Config.RememberPassword || !AccountSettingsStore.Instance.LoginKeys.ContainsKey(username))) + if (!ContentDownloader.Config.UseQrCode) { - do + if (username != null && password == null && (!ContentDownloader.Config.RememberPassword || !AccountSettingsStore.Instance.LoginTokens.ContainsKey(username.ToLowerInvariant()))) { - Console.Write("Enter account password for \"{0}\": ", username); - password = Console.IsInputRedirected - ? Console.ReadLine() - : Util.ReadPassword(); + do + { + Console.Write("Enter account password for \"{0}\": ", username); + password = Console.IsInputRedirected + ? Console.ReadLine() + : Util.ReadPassword(); - Console.WriteLine(); - } while (string.Empty == password); - } - else if (username == null) - { - Console.WriteLine("No username given. Using anonymous account with dedicated server subscription."); + Console.WriteLine(); + } while (string.Empty == password); + } + else if (username == null) + { + Console.WriteLine("No username given. Using anonymous account with dedicated server subscription."); + } } return ContentDownloader.InitializeSteam3(username, password); diff --git a/DepotDownloader/Steam3Session.cs b/DepotDownloader/Steam3Session.cs index 79a949b7..15eabe6a 100644 --- a/DepotDownloader/Steam3Session.cs +++ b/DepotDownloader/Steam3Session.cs @@ -6,7 +6,9 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using QRCoder; using SteamKit2; +using SteamKit2.Authentication; using SteamKit2.Internal; namespace DepotDownloader @@ -53,11 +55,11 @@ namespace DepotDownloader bool bAborted; bool bExpectingDisconnectRemote; bool bDidDisconnect; - bool bDidReceiveLoginKey; bool bIsConnectionRecovery; int connectionBackoff; int seq; // more hack fixes DateTime connectTime; + AuthSession authSession; // input readonly SteamUser.LogOnDetails logonDetails; @@ -72,14 +74,13 @@ namespace DepotDownloader { this.logonDetails = details; - this.authenticatedUser = details.Username != null; + this.authenticatedUser = details.Username != null || ContentDownloader.Config.UseQrCode; this.credentials = new Credentials(); this.bConnected = false; this.bConnecting = false; this.bAborted = false; this.bExpectingDisconnectRemote = false; this.bDidDisconnect = false; - this.bDidReceiveLoginKey = false; this.seq = 0; this.AppTokens = new Dictionary(); @@ -112,11 +113,10 @@ namespace DepotDownloader this.callbacks.Subscribe(SessionTokenCallback); this.callbacks.Subscribe(LicenseListCallback); this.callbacks.Subscribe(UpdateMachineAuthCallback); - this.callbacks.Subscribe(LoginKeyCallback); Console.Write("Connecting to Steam3..."); - if (authenticatedUser) + if (details.Username != null) { var fi = new FileInfo(String.Format("{0}.sentryFile", logonDetails.Username)); if (AccountSettingsStore.Instance.SentryData != null && AccountSettingsStore.Instance.SentryData.ContainsKey(logonDetails.Username)) @@ -419,7 +419,6 @@ namespace DepotDownloader bExpectingDisconnectRemote = false; bDidDisconnect = false; bIsConnectionRecovery = false; - bDidReceiveLoginKey = false; } void Connect() @@ -428,6 +427,7 @@ namespace DepotDownloader bConnected = false; bConnecting = true; connectionBackoff = 0; + authSession = null; ResetConnectionFlags(); @@ -466,23 +466,6 @@ namespace DepotDownloader steamClient.Disconnect(); } - public void TryWaitForLoginKey() - { - if (logonDetails.Username == null || !credentials.LoggedOn || !ContentDownloader.Config.RememberPassword) return; - - var totalWaitPeriod = DateTime.Now.AddSeconds(3); - - while (true) - { - var now = DateTime.Now; - if (now >= totalWaitPeriod) break; - - if (bDidReceiveLoginKey) break; - - callbacks.RunWaitAllCallbacks(TimeSpan.FromMilliseconds(100)); - } - } - private void WaitForCallbacks() { callbacks.RunWaitCallbacks(TimeSpan.FromSeconds(1)); @@ -496,11 +479,17 @@ namespace DepotDownloader } } - private void ConnectedCallback(SteamClient.ConnectedCallback connected) + private async void ConnectedCallback(SteamClient.ConnectedCallback connected) { Console.WriteLine(" Done!"); bConnecting = false; bConnected = true; + + // Update our tracking so that we don't time out, even if we need to reconnect multiple times, + // e.g. if the authentication phase takes a while and therefore multiple connections. + connectTime = DateTime.Now; + connectionBackoff = 0; + if (!authenticatedUser) { Console.Write("Logging anonymously into Steam3..."); @@ -508,7 +497,102 @@ namespace DepotDownloader } else { - Console.Write("Logging '{0}' into Steam3...", logonDetails.Username); + if (logonDetails.Username != null) + { + Console.WriteLine("Logging '{0}' into Steam3...", logonDetails.Username); + } + + if (authSession is null) + { + if (logonDetails.Username != null && logonDetails.Password != null && logonDetails.AccessToken is null) + { + try + { + authSession = await steamClient.Authentication.BeginAuthSessionViaCredentialsAsync(new SteamKit2.Authentication.AuthSessionDetails + { + Username = logonDetails.Username, + Password = logonDetails.Password, + IsPersistentSession = ContentDownloader.Config.RememberPassword, + Authenticator = new UserConsoleAuthenticator(), + }); + } + catch (TaskCanceledException) + { + return; + } + catch (Exception ex) + { + Console.Error.WriteLine("Failed to authenticate with Steam: " + ex.Message); + Abort(false); + return; + } + } + else if (logonDetails.AccessToken is null && ContentDownloader.Config.UseQrCode) + { + Console.WriteLine("Logging in with QR code..."); + + try + { + var session = await steamClient.Authentication.BeginAuthSessionViaQRAsync(new AuthSessionDetails + { + IsPersistentSession = ContentDownloader.Config.RememberPassword, + Authenticator = new UserConsoleAuthenticator(), + }); + + authSession = session; + + // Steam will periodically refresh the challenge url, so we need a new QR code. + session.ChallengeURLChanged = () => + { + Console.WriteLine(); + Console.WriteLine("The QR code has changed:"); + + DisplayQrCode(session.ChallengeURL); + }; + + // Draw initial QR code immediately + DisplayQrCode(session.ChallengeURL); + } + catch (TaskCanceledException) + { + return; + } + catch (Exception ex) + { + Console.Error.WriteLine("Failed to authenticate with Steam: " + ex.Message); + Abort(false); + return; + } + } + } + + if (authSession != null) + { + try + { + var result = await authSession.PollingWaitForResultAsync(); + + logonDetails.Username = result.AccountName; + logonDetails.Password = null; + logonDetails.AccessToken = result.RefreshToken; + + AccountSettingsStore.Instance.LoginTokens[result.AccountName] = result.RefreshToken; + AccountSettingsStore.Save(); + } + catch (TaskCanceledException) + { + return; + } + catch (Exception ex) + { + Console.Error.WriteLine("Failed to authenticate with Steam: " + ex.Message); + Abort(false); + return; + } + + authSession = null; + } + steamUser.LogOn(logonDetails); } } @@ -517,6 +601,8 @@ namespace DepotDownloader { bDidDisconnect = true; + DebugLog.WriteLine(nameof(Steam3Session), $"Disconnected: bIsConnectionRecovery = {bIsConnectionRecovery}, UserInitiated = {disconnected.UserInitiated}, bExpectingDisconnectRemote = {bExpectingDisconnectRemote}"); + // When recovering the connection, we want to reconnect even if the remote disconnects us if (!bIsConnectionRecovery && (disconnected.UserInitiated || bExpectingDisconnectRemote)) { @@ -553,14 +639,14 @@ namespace DepotDownloader { var isSteamGuard = loggedOn.Result == EResult.AccountLogonDenied; var is2FA = loggedOn.Result == EResult.AccountLoginDeniedNeedTwoFactor; - var isLoginKey = ContentDownloader.Config.RememberPassword && logonDetails.LoginKey != null && loggedOn.Result == EResult.InvalidPassword; + var isAccessToken = ContentDownloader.Config.RememberPassword && logonDetails.AccessToken != null && loggedOn.Result == EResult.InvalidPassword; // TODO: Get EResult for bad access token - if (isSteamGuard || is2FA || isLoginKey) + if (isSteamGuard || is2FA || isAccessToken) { bExpectingDisconnectRemote = true; Abort(false); - if (!isLoginKey) + if (!isAccessToken) { Console.WriteLine("This account is protected by Steam Guard."); } @@ -573,23 +659,15 @@ namespace DepotDownloader logonDetails.TwoFactorCode = Console.ReadLine(); } while (String.Empty == logonDetails.TwoFactorCode); } - else if (isLoginKey) + else if (isAccessToken) { - AccountSettingsStore.Instance.LoginKeys.Remove(logonDetails.Username); + AccountSettingsStore.Instance.LoginTokens.Remove(logonDetails.Username); AccountSettingsStore.Save(); - logonDetails.LoginKey = null; - - if (ContentDownloader.Config.SuppliedPassword != null) - { - Console.WriteLine("Login key was expired. Connecting with supplied password."); - logonDetails.Password = ContentDownloader.Config.SuppliedPassword; - } - else - { - Console.Write("Login key was expired. Please enter your password: "); - logonDetails.Password = Util.ReadPassword(); - } + // TODO: Handle gracefully by falling back to password prompt? + Console.WriteLine("Access token was rejected."); + Abort(false); + return; } else { @@ -674,7 +752,7 @@ namespace DepotDownloader private void UpdateMachineAuthCallback(SteamUser.UpdateMachineAuthCallback machineAuth) { var hash = Util.SHAHash(machineAuth.Data); - Console.WriteLine("Got Machine Auth: {0} {1} {2} {3}", machineAuth.FileName, machineAuth.Offset, machineAuth.BytesToWrite, machineAuth.Data.Length, hash); + Console.WriteLine("Got Machine Auth: {0} {1} {2} {3}", machineAuth.FileName, machineAuth.Offset, machineAuth.BytesToWrite, machineAuth.Data.Length); AccountSettingsStore.Instance.SentryData[logonDetails.Username] = machineAuth.Data; AccountSettingsStore.Save(); @@ -700,16 +778,16 @@ namespace DepotDownloader steamUser.SendMachineAuthResponse(authResponse); } - private void LoginKeyCallback(SteamUser.LoginKeyCallback loginKey) + private static void DisplayQrCode(string challengeUrl) { - Console.WriteLine("Accepted new login key for account {0}", logonDetails.Username); - - AccountSettingsStore.Instance.LoginKeys[logonDetails.Username] = loginKey.LoginKey; - AccountSettingsStore.Save(); - - steamUser.AcceptNewLoginKey(loginKey); - - bDidReceiveLoginKey = true; + // Encode the link as a QR code + using var qrGenerator = new QRCodeGenerator(); + var qrCodeData = qrGenerator.CreateQrCode(challengeUrl, QRCodeGenerator.ECCLevel.L); + using var qrCode = new AsciiQRCode(qrCodeData); + var qrCodeAsAsciiArt = qrCode.GetGraphic(1, drawQuietZones: false); + + Console.WriteLine("Use the Steam Mobile App to sign in with this QR code:"); + Console.WriteLine(qrCodeAsAsciiArt); } } } diff --git a/Icon/DepotDownloader.ico b/Icon/DepotDownloader.ico new file mode 100644 index 00000000..6b0c58ce Binary files /dev/null and b/Icon/DepotDownloader.ico differ diff --git a/Icon/DepotDownloader.png b/Icon/DepotDownloader.png new file mode 100644 index 00000000..dac5e74c Binary files /dev/null and b/Icon/DepotDownloader.png differ diff --git a/Icon/DepotDownloader.svg b/Icon/DepotDownloader.svg new file mode 100644 index 00000000..ab1d371a --- /dev/null +++ b/Icon/DepotDownloader.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/global.json b/global.json new file mode 100644 index 00000000..1b8195c4 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "6.0.100", + "rollForward": "latestMinor" + } +}