From 2a43a87e08df63e94b2148f7f3c11b901ed080d1 Mon Sep 17 00:00:00 2001 From: JustArchi Date: Sat, 13 Jun 2020 12:08:21 +0200 Subject: [PATCH] Initial SteamTokenDumper upload --- ...rm.OfficialPlugins.SteamTokenDumper.csproj | 15 + .../GlobalCache.cs | 294 ++++++++++++ .../RequestData.cs | 81 ++++ .../ResponseData.cs | 61 +++ .../SharedInfo.cs | 34 ++ .../StaticHelpers.cs | 40 ++ .../SteamTokenDumperPlugin.cs | 430 ++++++++++++++++++ ArchiSteamFarm.sln | 10 +- ArchiSteamFarm/Plugins/ISteamPICSChanges.cs | 1 + ArchiSteamFarm/SharedInfo.cs | 6 +- appveyor.yml | 2 + 11 files changed, 970 insertions(+), 4 deletions(-) create mode 100644 ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper.csproj create mode 100644 ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalCache.cs create mode 100644 ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/RequestData.cs create mode 100644 ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ResponseData.cs create mode 100644 ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs create mode 100644 ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/StaticHelpers.cs create mode 100644 ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper.csproj b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper.csproj new file mode 100644 index 000000000..775e64a53 --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper.csproj @@ -0,0 +1,15 @@ + + + Library + + + + + all + + + + + + + diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalCache.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalCache.cs new file mode 100644 index 000000000..ccf5c0ee1 --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalCache.cs @@ -0,0 +1,294 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2020 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ArchiSteamFarm.Collections; +using ArchiSteamFarm.Helpers; +using JetBrains.Annotations; +using Newtonsoft.Json; +using SteamKit2; + +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { + internal sealed class GlobalCache : SerializableFile { + [NotNull] + private static string SharedFilePath => Path.Combine(ArchiSteamFarm.SharedInfo.ConfigDirectory, nameof(SteamTokenDumper) + ".cache"); + + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary AppChangeNumbers = new ConcurrentDictionary(); + + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary AppTokens = new ConcurrentDictionary(); + + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary DepotKeys = new ConcurrentDictionary(); + + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary PackageTokens = new ConcurrentDictionary(); + + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentHashSet SubmittedAppIDs = new ConcurrentHashSet(); + + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentHashSet SubmittedDepotIDs = new ConcurrentHashSet(); + + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentHashSet SubmittedPackageIDs = new ConcurrentHashSet(); + + [JsonProperty(Required = Required.DisallowNull)] + internal uint LastChangeNumber { get; private set; } + + internal GlobalCache() => FilePath = SharedFilePath; + + internal ulong GetAppToken(uint appID) => AppTokens[appID]; + + [NotNull] + internal Dictionary GetAppTokensForSubmission() => AppTokens.Where(appToken => !SubmittedAppIDs.Contains(appToken.Key)).ToDictionary(appToken => appToken.Key, appToken => appToken.Value); + + [NotNull] + internal Dictionary GetDepotKeysForSubmission() => DepotKeys.Where(depotKey => !SubmittedDepotIDs.Contains(depotKey.Key)).ToDictionary(depotKey => depotKey.Key, depotKey => depotKey.Value); + + [NotNull] + internal Dictionary GetPackageTokensForSubmission() => PackageTokens.Where(packageToken => !SubmittedPackageIDs.Contains(packageToken.Key)).ToDictionary(packageToken => packageToken.Key, packageToken => packageToken.Value); + + [ItemNotNull] + internal static async Task Load() { + if (!File.Exists(SharedFilePath)) { + return new GlobalCache(); + } + + GlobalCache globalCache = null; + + try { + string json = await RuntimeCompatibility.File.ReadAllTextAsync(SharedFilePath).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(json)) { + globalCache = JsonConvert.DeserializeObject(json); + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + + if (globalCache == null) { + ASF.ArchiLogger.LogGenericError($"{nameof(GlobalCache)} could not be loaded, a fresh instance will be initialized."); + + globalCache = new GlobalCache(); + } + + return globalCache; + } + + internal async Task OnPICSChanges(uint currentChangeNumber, [NotNull] IReadOnlyCollection> appChanges) { + if ((currentChangeNumber == 0) || (appChanges == null)) { + throw new ArgumentNullException(nameof(appChanges)); + } + + if (currentChangeNumber <= LastChangeNumber) { + return; + } + + ASF.ArchiLogger.LogGenericTrace($"{LastChangeNumber} => {currentChangeNumber}"); + + LastChangeNumber = currentChangeNumber; + + foreach ((uint appID, SteamApps.PICSChangesCallback.PICSChangeData appData) in appChanges) { + if (!AppChangeNumbers.TryGetValue(appID, out uint previousChangeNumber) || (appData.ChangeNumber <= previousChangeNumber)) { + continue; + } + + AppChangeNumbers.TryRemove(appID, out _); + ASF.ArchiLogger.LogGenericTrace($"App needs refresh: {appID}"); + } + + await Save().ConfigureAwait(false); + } + + internal async Task OnPICSChangesRestart(uint currentChangeNumber) { + if (currentChangeNumber == 0) { + throw new ArgumentNullException(); + } + + if (currentChangeNumber <= LastChangeNumber) { + return; + } + + ASF.ArchiLogger.LogGenericDebug($"RESET {LastChangeNumber} => {currentChangeNumber}"); + + LastChangeNumber = currentChangeNumber; + + AppChangeNumbers.Clear(); + + await Save().ConfigureAwait(false); + } + + internal bool ShouldRefreshAppInfo(uint appID) => !AppChangeNumbers.ContainsKey(appID); + internal bool ShouldRefreshDepotKey(uint depotID) => !DepotKeys.ContainsKey(depotID); + + internal async Task UpdateAppChangeNumbers([NotNull] IReadOnlyCollection> appChangeNumbers) { + if (appChangeNumbers == null) { + throw new ArgumentNullException(nameof(appChangeNumbers)); + } + + bool save = false; + + foreach ((uint appID, uint changeNumber) in appChangeNumbers) { + if (AppChangeNumbers.TryGetValue(appID, out uint previousChangeNumber) && (previousChangeNumber == changeNumber)) { + continue; + } + + AppChangeNumbers[appID] = changeNumber; + save = true; + } + + if (save) { + await Save().ConfigureAwait(false); + } + } + + internal async Task UpdateAppTokens([NotNull] IReadOnlyCollection> appTokens, [NotNull] IReadOnlyCollection publicAppIDs) { + if ((appTokens == null) || (publicAppIDs == null)) { + throw new ArgumentNullException(nameof(appTokens) + " || " + nameof(publicAppIDs)); + } + + bool save = false; + + foreach ((uint appID, ulong appToken) in appTokens) { + if (AppTokens.TryGetValue(appID, out ulong previousAppToken) && (previousAppToken == appToken)) { + continue; + } + + AppTokens[appID] = appToken; + + if (appToken == 0) { + // Backend is not interested in zero access tokens + SubmittedAppIDs.Add(appID); + } + + save = true; + } + + foreach (uint appID in publicAppIDs) { + if (AppTokens.TryGetValue(appID, out ulong previousAppToken) && (previousAppToken == 0)) { + continue; + } + + AppTokens[appID] = 0; + + // Backend is not interested in zero access tokens + SubmittedAppIDs.Add(appID); + + save = true; + } + + if (save) { + await Save().ConfigureAwait(false); + } + } + + internal async Task UpdateDepotKeys([NotNull] IReadOnlyCollection depotKeyResults) { + if (depotKeyResults == null) { + throw new ArgumentNullException(nameof(depotKeyResults)); + } + + bool save = false; + + foreach (SteamApps.DepotKeyCallback depotKeyResult in depotKeyResults) { + if ((depotKeyResult == null) || (depotKeyResult.Result != EResult.OK)) { + continue; + } + + string depotKey = BitConverter.ToString(depotKeyResult.DepotKey).Replace("-", ""); + + if (DepotKeys.TryGetValue(depotKeyResult.DepotID, out string previousDepotKey) && (previousDepotKey == depotKey)) { + continue; + } + + DepotKeys[depotKeyResult.DepotID] = depotKey; + + if (string.IsNullOrEmpty(depotKey)) { + // Backend is not interested in zero depot keys + SubmittedDepotIDs.Add(depotKeyResult.DepotID); + } + + save = true; + } + + if (save) { + await Save().ConfigureAwait(false); + } + } + + internal async Task UpdatePackageTokens([NotNull] IReadOnlyCollection> packageTokens) { + if (packageTokens == null) { + throw new ArgumentNullException(nameof(packageTokens)); + } + + bool save = false; + + foreach ((uint packageID, ulong packageToken) in packageTokens) { + if (PackageTokens.TryGetValue(packageID, out ulong previousPackageToken) && (previousPackageToken == packageToken)) { + continue; + } + + PackageTokens[packageID] = packageToken; + + if (packageToken == 0) { + // Backend is not interested in zero access tokens + SubmittedPackageIDs.Add(packageID); + } + + save = true; + } + + if (save) { + await Save().ConfigureAwait(false); + } + } + + internal async Task UpdateSubmittedData([NotNull] IReadOnlyCollection appIDs, [NotNull] IReadOnlyCollection packageIDs, [NotNull] IReadOnlyCollection depotIDs) { + if ((appIDs == null) || (packageIDs == null) || (depotIDs == null)) { + throw new ArgumentNullException(nameof(appIDs) + " || " + nameof(packageIDs) + " || " + nameof(depotIDs)); + } + + bool save = false; + + foreach (uint _ in appIDs.Where(appID => SubmittedAppIDs.Add(appID))) { + save = true; + } + + foreach (uint _ in packageIDs.Where(packageID => SubmittedPackageIDs.Add(packageID))) { + save = true; + } + + foreach (uint _ in depotIDs.Where(depotID => SubmittedDepotIDs.Add(depotID))) { + save = true; + } + + if (save) { + await Save().ConfigureAwait(false); + } + } + } +} diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/RequestData.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/RequestData.cs new file mode 100644 index 000000000..a005aa7c5 --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/RequestData.cs @@ -0,0 +1,81 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2020 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using JetBrains.Annotations; +using Newtonsoft.Json; +using SteamKit2; + +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { + internal sealed class RequestData { +#pragma warning disable IDE0052 + [JsonProperty(PropertyName = "apps", Required = Required.Always)] + private readonly ImmutableDictionary Apps; +#pragma warning restore IDE0052 + +#pragma warning disable IDE0052 + [JsonProperty(PropertyName = "depots", Required = Required.Always)] + private readonly ImmutableDictionary Depots; +#pragma warning restore IDE0052 + +#pragma warning disable IDE0052 + [JsonProperty(PropertyName = "guid", Required = Required.Always)] + private readonly string Guid = ASF.GlobalDatabase.Guid.ToString("N"); +#pragma warning restore IDE0052 + + private readonly ulong SteamID; + +#pragma warning disable IDE0052 + [JsonProperty(PropertyName = "subs", Required = Required.Always)] + private readonly ImmutableDictionary Subs; +#pragma warning restore IDE0052 + +#pragma warning disable IDE0051, 414 + [JsonProperty(PropertyName = "token", Required = Required.Always)] + private readonly string Token = SharedInfo.Token; +#pragma warning restore IDE0051, 414 + +#pragma warning disable IDE0051, 414 + [JsonProperty(PropertyName = "v", Required = Required.Always)] + private readonly byte Version = SharedInfo.ApiVersion; +#pragma warning restore IDE0051, 414 + +#pragma warning disable IDE0051 + [JsonProperty(PropertyName = "steamid", Required = Required.Always)] + [NotNull] + private string SteamIDText => new SteamID(SteamID).Render(); +#pragma warning restore IDE0051 + + internal RequestData(ulong steamID, [NotNull] IEnumerable> apps, [NotNull] IEnumerable> accessTokens, [NotNull] IEnumerable> depots) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount || (apps == null) || (accessTokens == null) || (depots == null)) { + throw new ArgumentNullException(nameof(steamID) + " || " + nameof(apps) + " || " + nameof(accessTokens) + " || " + nameof(depots)); + } + + SteamID = steamID; + + Apps = apps.ToImmutableDictionary(app => app.Key.ToString(), app => app.Value.ToString()); + Subs = accessTokens.ToImmutableDictionary(package => package.Key.ToString(), package => package.Value.ToString()); + Depots = depots.ToImmutableDictionary(depot => depot.Key.ToString(), depot => depot.Value); + } + } +} diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ResponseData.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ResponseData.cs new file mode 100644 index 000000000..fc642d51c --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ResponseData.cs @@ -0,0 +1,61 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2020 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; + +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { + [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] + internal sealed class ResponseData { +#pragma warning disable 649 + [JsonProperty(PropertyName = "data", Required = Required.Always)] + internal readonly InternalData Data; +#pragma warning restore 649 + +#pragma warning disable 649 + [JsonProperty(PropertyName = "success", Required = Required.Always)] + internal readonly bool Success; +#pragma warning restore 649 + + [JsonConstructor] + private ResponseData() { } + + internal sealed class InternalData { +#pragma warning disable 649 + [JsonProperty(PropertyName = "new_apps", Required = Required.Always)] + internal readonly uint NewAppsCount; +#pragma warning restore 649 + +#pragma warning disable 649 + [JsonProperty(PropertyName = "new_depots", Required = Required.Always)] + internal readonly uint NewDepotsCount; +#pragma warning restore 649 + +#pragma warning disable 649 + [JsonProperty(PropertyName = "new_subs", Required = Required.Always)] + internal readonly uint NewSubsCount; +#pragma warning restore 649 + + [JsonConstructor] + private InternalData() { } + } + } +} diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs new file mode 100644 index 000000000..6f1b09008 --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs @@ -0,0 +1,34 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2020 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { + internal static class SharedInfo { + internal const byte ApiVersion = 1; + internal const ushort ItemsPerSingleRequest = 2048; // Should be synchronized with TimeoutForLongRunningTasksInSeconds + internal const byte MaximumHoursBetweenRefresh = 8; // Per single bot account, makes sense to be 2 or 3 times less than MinimumHoursBetweenUploads + internal const byte MaximumMinutesBeforeFirstUpload = 60; // Must be greater or equal to MinimumMinutesBeforeFirstUpload + internal const byte MinimumHoursBetweenUploads = 24; + internal const byte MinimumMinutesBeforeFirstUpload = 10; // Must be less or equal to MaximumMinutesBeforeFirstUpload + internal const string ServerURL = "https://asf-token-dumper.xpaw.me"; + internal const byte TimeoutForLongRunningTasksInSeconds = 60; // Should be synchronized with ItemsPerSingleRequest + internal const string Token = "STEAM_TOKEN_DUMPER_TOKEN"; + } +} diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/StaticHelpers.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/StaticHelpers.cs new file mode 100644 index 000000000..1cbb51276 --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/StaticHelpers.cs @@ -0,0 +1,40 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2020 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading.Tasks; +using JetBrains.Annotations; +using SteamKit2; + +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { + internal static class StaticHelpers { + [NotNull] + internal static Task ToLongRunningTask([NotNull] this AsyncJob job) where T : CallbackMsg { + if (job == null) { + throw new ArgumentNullException(nameof(job)); + } + + job.Timeout = TimeSpan.FromSeconds(SharedInfo.TimeoutForLongRunningTasksInSeconds); + + return job.ToTask(); + } + } +} diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs new file mode 100644 index 000000000..c42da35e1 --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs @@ -0,0 +1,430 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2020 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Composition; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using ArchiSteamFarm.Localization; +using ArchiSteamFarm.Plugins; +using JetBrains.Annotations; +using Newtonsoft.Json.Linq; +using SteamKit2; + +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { + [Export(typeof(IPlugin))] + [UsedImplicitly] + internal sealed class SteamTokenDumperPlugin : IASF, IBot, IBotSteamClient, ISteamPICSChanges { + private static readonly ConcurrentDictionary BotSubscriptions = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary BotSynchronizations = new ConcurrentDictionary(); + private static readonly SemaphoreSlim SubmissionSemaphore = new SemaphoreSlim(1, 1); + private static readonly Timer SubmissionTimer = new Timer(async e => await SubmitData().ConfigureAwait(false)); + + private static GlobalCache GlobalCache; + private static bool IsEnabled; + + public string Name => nameof(SteamTokenDumperPlugin); + + public Version Version => typeof(SteamTokenDumperPlugin).Assembly.GetName().Version ?? throw new ArgumentNullException(nameof(Version)); + + public Task GetPreferredChangeNumberToStartFrom() => Task.FromResult(IsEnabled ? GlobalCache?.LastChangeNumber ?? 0 : 0); + + public void OnASFInit(IReadOnlyDictionary additionalConfigProperties = null) { + const string enabledProperty = nameof(SteamTokenDumperPlugin) + "Enabled"; + + bool enabled = false; + + if (additionalConfigProperties != null) { + foreach ((string configProperty, JToken configValue) in additionalConfigProperties) { + try { + if (configProperty == enabledProperty) { + enabled = configValue.Value(); + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + break; + } + } + } + + IsEnabled = enabled; + + if (!enabled) { + ASF.ArchiLogger.LogGenericInfo($"{Name} is currently disabled. If you'd like to help SteamDB in data submission, check out our wiki."); + + return; + } + + GlobalCache ??= GlobalCache.Load().Result; + + TimeSpan startIn = TimeSpan.FromMinutes(Utilities.RandomNext(SharedInfo.MinimumMinutesBeforeFirstUpload, SharedInfo.MaximumMinutesBeforeFirstUpload)); + + lock (SubmissionTimer) { + SubmissionTimer.Change(startIn, TimeSpan.FromHours(SharedInfo.MinimumHoursBetweenUploads)); + } + + ASF.ArchiLogger.LogGenericInfo($"{Name} has been initialized successfully, thank you for your help. The first submission will happen in approximately {startIn.ToHumanReadable()} from now."); + } + + public async void OnBotDestroy(Bot bot) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if (BotSubscriptions.TryRemove(bot, out IDisposable subscription)) { + subscription.Dispose(); + } + + if (BotSynchronizations.TryRemove(bot, out (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer) synchronization)) { + synchronization.RefreshSemaphore.Dispose(); + + await synchronization.RefreshTimer.DisposeAsync().ConfigureAwait(false); + } + } + + public async void OnBotInit(Bot bot) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if (!IsEnabled) { + return; + } + + SemaphoreSlim refreshSemaphore = new SemaphoreSlim(1, 1); + Timer refreshTimer = new Timer(async e => await Refresh(bot).ConfigureAwait(false)); + + if (!BotSynchronizations.TryAdd(bot, (refreshSemaphore, refreshTimer))) { + refreshSemaphore.Dispose(); + + await refreshTimer.DisposeAsync().ConfigureAwait(false); + } + } + + public void OnBotSteamCallbacksInit(Bot bot, CallbackManager callbackManager) { + if ((bot == null) || (callbackManager == null)) { + throw new ArgumentNullException(nameof(bot) + " || " + nameof(callbackManager)); + } + + if (BotSubscriptions.TryRemove(bot, out IDisposable subscription)) { + subscription.Dispose(); + } + + if (!IsEnabled) { + return; + } + + subscription = callbackManager.Subscribe(callback => OnLicenseList(bot, callback)); + + if (!BotSubscriptions.TryAdd(bot, subscription)) { + subscription.Dispose(); + } + } + + public IReadOnlyCollection OnBotSteamHandlersInit(Bot bot) => null; + + public void OnLoaded() { } + + public async void OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary appChanges, IReadOnlyDictionary packageChanges) { + if ((currentChangeNumber == 0) || (appChanges == null) || (packageChanges == null)) { + throw new ArgumentNullException(nameof(currentChangeNumber) + " || " + nameof(appChanges) + " || " + nameof(packageChanges)); + } + + if (!IsEnabled) { + return; + } + + if (GlobalCache == null) { + throw new ArgumentNullException(nameof(GlobalCache)); + } + + await GlobalCache.OnPICSChanges(currentChangeNumber, appChanges).ConfigureAwait(false); + } + + public async void OnPICSChangesRestart(uint currentChangeNumber) { + if (currentChangeNumber == 0) { + throw new ArgumentNullException(nameof(currentChangeNumber)); + } + + if (!IsEnabled) { + return; + } + + if (GlobalCache == null) { + throw new ArgumentNullException(nameof(GlobalCache)); + } + + await GlobalCache.OnPICSChangesRestart(currentChangeNumber).ConfigureAwait(false); + } + + private static async void OnLicenseList([NotNull] Bot bot, [NotNull] SteamApps.LicenseListCallback callback) { + if ((bot == null) || (callback == null)) { + throw new ArgumentNullException(nameof(callback)); + } + + if (!IsEnabled) { + return; + } + + if (GlobalCache == null) { + throw new ArgumentNullException(nameof(GlobalCache)); + } + + Dictionary packageTokens = callback.LicenseList.ToDictionary(license => license.PackageID, license => license.AccessToken); + + await GlobalCache.UpdatePackageTokens(packageTokens).ConfigureAwait(false); + await Refresh(bot, packageTokens.Keys).ConfigureAwait(false); + } + + private static async Task Refresh([NotNull] Bot bot, IReadOnlyCollection packageIDs = null) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if (!IsEnabled) { + return; + } + + if (GlobalCache == null) { + throw new ArgumentNullException(nameof(GlobalCache)); + } + + if (!BotSynchronizations.TryGetValue(bot, out (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer) synchronization)) { + throw new ArgumentNullException(nameof(synchronization)); + } + + if (!await synchronization.RefreshSemaphore.WaitAsync(0).ConfigureAwait(false)) { + return; + } + + try { + if (!bot.IsConnectedAndLoggedOn) { + return; + } + + packageIDs ??= bot.OwnedPackageIDsReadOnly; + + HashSet appIDsToRefresh = new HashSet(); + + foreach (uint packageID in packageIDs) { + if (!ASF.GlobalDatabase.PackagesDataReadOnly.TryGetValue(packageID, out (uint ChangeNumber, HashSet AppIDs) packageData)) { + // ASF might not have the package info for us at the moment, we'll retry later + continue; + } + + appIDsToRefresh.UnionWith(packageData.AppIDs.Where(appID => GlobalCache.ShouldRefreshAppInfo(appID))); + } + + if (appIDsToRefresh.Count == 0) { + bot.ArchiLogger.LogGenericDebug($"There are no apps to refresh for {bot.BotName}."); + + return; + } + + bot.ArchiLogger.LogGenericInfo($"Retrieving a total of {appIDsToRefresh.Count} app access tokens..."); + + HashSet appIDsThisRound = new HashSet(Math.Min(appIDsToRefresh.Count, SharedInfo.ItemsPerSingleRequest)); + + using (HashSet.Enumerator enumerator = appIDsToRefresh.GetEnumerator()) { + while (true) { + while ((appIDsThisRound.Count < SharedInfo.ItemsPerSingleRequest) && enumerator.MoveNext()) { + appIDsThisRound.Add(enumerator.Current); + } + + if (appIDsThisRound.Count == 0) { + break; + } + + bot.ArchiLogger.LogGenericInfo($"Retrieving {appIDsThisRound.Count} app access tokens..."); + + SteamApps.PICSTokensCallback response; + + try { + response = await bot.SteamApps.PICSGetAccessTokens(appIDsThisRound, Enumerable.Empty()); + } catch (Exception e) { + bot.ArchiLogger.LogGenericWarningException(e); + + return; + } + + bot.ArchiLogger.LogGenericInfo($"Finished retrieving {appIDsThisRound.Count} app access tokens."); + + appIDsThisRound.Clear(); + + await GlobalCache.UpdateAppTokens(response.AppTokens, response.AppTokensDenied).ConfigureAwait(false); + } + } + + bot.ArchiLogger.LogGenericInfo($"Finished retrieving a total of {appIDsToRefresh.Count} app access tokens."); + bot.ArchiLogger.LogGenericInfo($"Retrieving all depots for a total of {appIDsToRefresh.Count} apps..."); + + appIDsThisRound.Clear(); + + using (HashSet.Enumerator enumerator = appIDsToRefresh.GetEnumerator()) { + while (true) { + while ((appIDsThisRound.Count < SharedInfo.ItemsPerSingleRequest) && enumerator.MoveNext()) { + appIDsThisRound.Add(enumerator.Current); + } + + if (appIDsThisRound.Count == 0) { + break; + } + + bot.ArchiLogger.LogGenericInfo($"Retrieving {appIDsThisRound.Count} app infos..."); + + AsyncJobMultiple.ResultSet response; + + try { + response = await bot.SteamApps.PICSGetProductInfo(appIDsThisRound.Select(appID => new SteamApps.PICSRequest { ID = appID, AccessToken = GlobalCache.GetAppToken(appID), Public = false }), Enumerable.Empty()); + } catch (Exception e) { + bot.ArchiLogger.LogGenericWarningException(e); + + return; + } + + if (response.Results == null) { + bot.ArchiLogger.LogGenericWarning(string.Format(Strings.WarningFailedWithError, nameof(response.Results))); + + return; + } + + bot.ArchiLogger.LogGenericInfo($"Finished retrieving {appIDsThisRound.Count} app infos."); + + appIDsThisRound.Clear(); + + Dictionary appChangeNumbers = new Dictionary(); + HashSet> depotTasks = new HashSet>(); + + foreach (SteamApps.PICSProductInfoCallback.PICSProductInfo app in response.Results.SelectMany(result => result.Apps.Values)) { + appChangeNumbers[app.ID] = app.ChangeNumber; + + if (GlobalCache.ShouldRefreshDepotKey(app.ID)) { + depotTasks.Add(bot.SteamApps.GetDepotDecryptionKey(app.ID, app.ID).ToLongRunningTask()); + } + + foreach (KeyValue depot in app.KeyValues["depots"].Children) { + if (uint.TryParse(depot.Name, out uint depotID) && GlobalCache.ShouldRefreshDepotKey(depotID)) { + depotTasks.Add(bot.SteamApps.GetDepotDecryptionKey(depotID, app.ID).ToLongRunningTask()); + } + } + } + + await GlobalCache.UpdateAppChangeNumbers(appChangeNumbers).ConfigureAwait(false); + + if (depotTasks.Count > 0) { + bot.ArchiLogger.LogGenericInfo($"Retrieving {depotTasks.Count} depot keys..."); + + SteamApps.DepotKeyCallback[] results = await Task.WhenAll(depotTasks).ConfigureAwait(false); + + bot.ArchiLogger.LogGenericInfo($"Finished retrieving {depotTasks.Count} depot keys."); + + await GlobalCache.UpdateDepotKeys(results).ConfigureAwait(false); + } + } + } + + bot.ArchiLogger.LogGenericInfo($"Finished retrieving all depot keys for a total of {appIDsToRefresh.Count} apps."); + } finally { + TimeSpan timeSpan = TimeSpan.FromHours(SharedInfo.MaximumHoursBetweenRefresh); + + synchronization.RefreshTimer.Change(timeSpan, timeSpan); + synchronization.RefreshSemaphore.Release(); + + Utilities.InBackground(SubmitData); + } + } + + private static async Task SubmitData() { + const string request = SharedInfo.ServerURL + "/submit"; + + if (!IsEnabled) { + return; + } + + if (GlobalCache == null) { + throw new ArgumentNullException(nameof(GlobalCache)); + } + + if (!await SubmissionSemaphore.WaitAsync(0).ConfigureAwait(false)) { + ASF.ArchiLogger.LogGenericDebug($"Skipped {nameof(SubmitData)} trigger because there is already one in progress."); + + return; + } + + try { + Dictionary appTokens = GlobalCache.GetAppTokensForSubmission(); + Dictionary packageTokens = GlobalCache.GetPackageTokensForSubmission(); + Dictionary depotKeys = GlobalCache.GetDepotKeysForSubmission(); + + if ((appTokens.Count == 0) && (packageTokens.Count == 0) && (depotKeys.Count == 0)) { + ASF.ArchiLogger.LogGenericInfo("There is no new data to submit, everything up-to-date."); + + return; + } + + ulong contributorSteamID = (ASF.GlobalConfig.SteamOwnerID > 0) && new SteamID(ASF.GlobalConfig.SteamOwnerID).IsIndividualAccount ? ASF.GlobalConfig.SteamOwnerID : Bot.BotsReadOnly.Values.Where(bot => bot.SteamID > 0).OrderByDescending(bot => bot.OwnedPackageIDsReadOnly.Count).FirstOrDefault()?.SteamID ?? 0; + + if (contributorSteamID == 0) { + ASF.ArchiLogger.LogGenericError($"Skipped {nameof(SubmitData)} trigger because there is no valid steamID we could classify as a contributor. Consider setting up {nameof(ASF.GlobalConfig.SteamOwnerID)} property."); + + return; + } + + RequestData requestData = new RequestData(contributorSteamID, appTokens, packageTokens, depotKeys); + + ASF.ArchiLogger.LogGenericInfo($"Submitting registered apps/subs/depots: {appTokens.Count}/{packageTokens.Count}/{depotKeys.Count}..."); + + WebBrowser.ObjectResponse response = await ASF.WebBrowser.UrlPostToJsonObject(request, requestData, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors).ConfigureAwait(false); + + if ((response?.Content == null) || response.StatusCode.IsClientErrorCode()) { + ASF.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + +#if NETFRAMEWORK + if (response?.StatusCode == (HttpStatusCode) 429) { +#else + if (response?.StatusCode == HttpStatusCode.TooManyRequests) { +#endif + TimeSpan startIn = TimeSpan.FromMinutes(Utilities.RandomNext(SharedInfo.MinimumMinutesBeforeFirstUpload, SharedInfo.MaximumMinutesBeforeFirstUpload)); + + lock (SubmissionTimer) { + SubmissionTimer.Change(startIn, TimeSpan.FromHours(SharedInfo.MinimumHoursBetweenUploads)); + } + + ASF.ArchiLogger.LogGenericInfo($"The submission will happen in approximately {startIn.ToHumanReadable()} from now."); + } + + return; + } + + ASF.ArchiLogger.LogGenericInfo($"Data successfully submitted. Newly registered apps/subs/depots: {response.Content.Data.NewAppsCount}/{response.Content.Data.NewSubsCount}/{response.Content.Data.NewDepotsCount}."); + + await GlobalCache.UpdateSubmittedData(appTokens.Keys, packageTokens.Keys, depotKeys.Keys).ConfigureAwait(false); + } finally { + SubmissionSemaphore.Release(); + } + } + } +} diff --git a/ArchiSteamFarm.sln b/ArchiSteamFarm.sln index cffca1eeb..8458239e6 100644 --- a/ArchiSteamFarm.sln +++ b/ArchiSteamFarm.sln @@ -9,7 +9,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArchiSteamFarm.Tests", "Arc EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArchiSteamFarm.CustomPlugins.ExamplePlugin", "ArchiSteamFarm.CustomPlugins.ExamplePlugin\ArchiSteamFarm.CustomPlugins.ExamplePlugin.csproj", "{2E2C26B6-7C1D-4BAF-BCF9-79286DA08F82}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArchiSteamFarm.CustomPlugins.PeriodicGC", "ArchiSteamFarm.CustomPlugins.PeriodicGC\ArchiSteamFarm.CustomPlugins.PeriodicGC.csproj", "{2C935C25-1B03-4C55-81C9-DF1D472D72F4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArchiSteamFarm.CustomPlugins.PeriodicGC", "ArchiSteamFarm.CustomPlugins.PeriodicGC\ArchiSteamFarm.CustomPlugins.PeriodicGC.csproj", "{2C935C25-1B03-4C55-81C9-DF1D472D72F4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArchiSteamFarm.OfficialPlugins.SteamTokenDumper", "ArchiSteamFarm.OfficialPlugins.SteamTokenDumper\ArchiSteamFarm.OfficialPlugins.SteamTokenDumper.csproj", "{A9299EE5-AF67-4FCB-8D03-263B41819504}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -33,12 +35,16 @@ Global {2C935C25-1B03-4C55-81C9-DF1D472D72F4}.Debug|Any CPU.Build.0 = Debug|Any CPU {2C935C25-1B03-4C55-81C9-DF1D472D72F4}.Release|Any CPU.ActiveCfg = Release|Any CPU {2C935C25-1B03-4C55-81C9-DF1D472D72F4}.Release|Any CPU.Build.0 = Release|Any CPU + {A9299EE5-AF67-4FCB-8D03-263B41819504}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A9299EE5-AF67-4FCB-8D03-263B41819504}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A9299EE5-AF67-4FCB-8D03-263B41819504}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A9299EE5-AF67-4FCB-8D03-263B41819504}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {D7D54143-C857-4B76-A219-0E98C5BC4895} RESX_ShowErrorsInErrorList = False + SolutionGuid = {D7D54143-C857-4B76-A219-0E98C5BC4895} EndGlobalSection EndGlobal diff --git a/ArchiSteamFarm/Plugins/ISteamPICSChanges.cs b/ArchiSteamFarm/Plugins/ISteamPICSChanges.cs index 28adc3f3f..b51557cd0 100644 --- a/ArchiSteamFarm/Plugins/ISteamPICSChanges.cs +++ b/ArchiSteamFarm/Plugins/ISteamPICSChanges.cs @@ -31,6 +31,7 @@ namespace ArchiSteamFarm.Plugins { /// ASF uses this method for determining the point in time from which it should keep history going upon a restart. The actual point in time that will be used is calculated as the lowest change number from all loaded plugins, to guarantee that no plugin will miss any changes, while allowing possible duplicates for those plugins that were already synchronized with newer changes. If you don't care about persistent state and just want to receive the ongoing history, you should return 0 (which is equal to "I'm fine with any"). If there won't be any plugin asking for a specific point in time, ASF will start returning entries since the start of the program. /// /// The most recent change number from which you're fine to receive + [NotNull] Task GetPreferredChangeNumberToStartFrom(); /// diff --git a/ArchiSteamFarm/SharedInfo.cs b/ArchiSteamFarm/SharedInfo.cs index 269eab6ef..e3fdd175e 100644 --- a/ArchiSteamFarm/SharedInfo.cs +++ b/ArchiSteamFarm/SharedInfo.cs @@ -26,7 +26,10 @@ using ArchiSteamFarm.Plugins; using JetBrains.Annotations; namespace ArchiSteamFarm { - internal static class SharedInfo { + public static class SharedInfo { + [PublicAPI] + public const string ConfigDirectory = "config"; + internal const ulong ArchiSteamID = 76561198006963719; internal const string ArchivalLogFile = "log.{#}.txt"; internal const string ArchivalLogsDirectory = "logs"; @@ -34,7 +37,6 @@ namespace ArchiSteamFarm { internal const ulong ASFGroupSteamID = 103582791440160998; internal const string AssemblyDocumentation = AssemblyName + ".xml"; internal const string AssemblyName = nameof(ArchiSteamFarm); - internal const string ConfigDirectory = "config"; internal const string DatabaseExtension = ".db"; internal const string DebugDirectory = "debug"; internal const string EnvironmentVariableCryptKey = ASF + "_CRYPTKEY"; diff --git a/appveyor.yml b/appveyor.yml index 7aad5388f..c1fd280d1 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -16,6 +16,8 @@ environment: NET_CORE_VERSION: netcoreapp3.1 NET_FRAMEWORK_VERSION: net48 NODE_JS_VERSION: lts + STEAM_TOKEN_DUMPER_TOKEN: + secure: uttQUE9ZK7BIa9SIbDkpUTMx7Slnl3zAPkRNzE465YgwxLdLEwv6yYR5QXCSZolb5Qq23Z/LmZNGd3M6B0+hbx3waWOeW2AiWvfCcnUmuT+3wfLJsgLbf1g4agFS7zsDgeRPfnNMzOxD8etelnA5YOOUMNB3RLw3fIdznNd+Fs6R0Ou3/1UavDuHKkbh1+A5 VARIANTS: generic generic-netf linux-arm linux-arm64 linux-x64 osx-x64 win-x64 # NOTE: When modifying variants, don't forget to update ASF_VARIANT definitions in SharedInfo.cs! matrix: allow_failures: