Automatic translations update
[ArchiSteamFarm.git] / ArchiSteamFarm / Core / ASF.cs
blob9d4b3ae56e5fe031334095a0adb5d6269d999306
1 // ----------------------------------------------------------------------------------------------
2 // _ _ _ ____ _ _____
3 // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___
4 // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \
5 // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | |
6 // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_|
7 // ----------------------------------------------------------------------------------------------
8 // |
9 // Copyright 2015-2024 Ɓukasz "JustArchi" Domeradzki
10 // Contact: JustArchi@JustArchi.net
11 // |
12 // Licensed under the Apache License, Version 2.0 (the "License");
13 // you may not use this file except in compliance with the License.
14 // You may obtain a copy of the License at
15 // |
16 // http://www.apache.org/licenses/LICENSE-2.0
17 // |
18 // Unless required by applicable law or agreed to in writing, software
19 // distributed under the License is distributed on an "AS IS" BASIS,
20 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21 // See the License for the specific language governing permissions and
22 // limitations under the License.
24 using System;
25 using System.Collections.Concurrent;
26 using System.Collections.Frozen;
27 using System.Collections.Generic;
28 using System.ComponentModel;
29 using System.Diagnostics.CodeAnalysis;
30 using System.Globalization;
31 using System.IO;
32 using System.IO.Compression;
33 using System.Linq;
34 using System.Reflection;
35 using System.Threading;
36 using System.Threading.Tasks;
37 using ArchiSteamFarm.Helpers;
38 using ArchiSteamFarm.IPC;
39 using ArchiSteamFarm.Localization;
40 using ArchiSteamFarm.NLog;
41 using ArchiSteamFarm.Plugins;
42 using ArchiSteamFarm.Steam;
43 using ArchiSteamFarm.Steam.Integration;
44 using ArchiSteamFarm.Storage;
45 using ArchiSteamFarm.Web;
46 using ArchiSteamFarm.Web.GitHub;
47 using ArchiSteamFarm.Web.GitHub.Data;
48 using ArchiSteamFarm.Web.Responses;
49 using JetBrains.Annotations;
50 using SteamKit2;
51 using SteamKit2.Discovery;
53 namespace ArchiSteamFarm.Core;
55 public static class ASF {
56 // This is based on internal Valve guidelines, we're not using it as a hard limit
57 private const byte MaximumRecommendedBotsCount = 10;
59 [PublicAPI]
60 public static readonly ArchiLogger ArchiLogger = new(SharedInfo.ASF);
62 [PublicAPI]
63 public static byte LoadBalancingDelay => Math.Max(GlobalConfig?.LoginLimiterDelay ?? 0, GlobalConfig.DefaultLoginLimiterDelay);
65 [PublicAPI]
66 public static GlobalConfig? GlobalConfig { get; internal set; }
68 [PublicAPI]
69 public static GlobalDatabase? GlobalDatabase { get; internal set; }
71 [PublicAPI]
72 public static WebBrowser? WebBrowser { get; private set; }
74 internal static readonly SemaphoreSlim OpenConnectionsSemaphore = new(WebBrowser.MaxConnections, WebBrowser.MaxConnections);
76 internal static string DebugDirectory => Path.Combine(SharedInfo.DebugDirectory, OS.ProcessStartTime.ToString("yyyy-MM-dd-THH-mm-ss", CultureInfo.InvariantCulture));
78 internal static ICrossProcessSemaphore? ConfirmationsSemaphore { get; private set; }
79 internal static ICrossProcessSemaphore? GiftsSemaphore { get; private set; }
80 internal static ICrossProcessSemaphore? InventorySemaphore { get; private set; }
81 internal static ICrossProcessSemaphore? LoginRateLimitingSemaphore { get; private set; }
82 internal static ICrossProcessSemaphore? LoginSemaphore { get; private set; }
83 internal static ICrossProcessSemaphore? RateLimitingSemaphore { get; private set; }
84 internal static FrozenDictionary<Uri, (ICrossProcessSemaphore RateLimitingSemaphore, SemaphoreSlim OpenConnectionsSemaphore)>? WebLimitingSemaphores { get; private set; }
86 private static readonly FrozenSet<string> AssembliesNeededBeforeUpdate = new HashSet<string>(1, StringComparer.Ordinal) { "System.IO.Pipes" }.ToFrozenSet(StringComparer.Ordinal);
87 private static readonly SemaphoreSlim UpdateSemaphore = new(1, 1);
89 private static Timer? AutoUpdatesTimer;
90 private static FileSystemWatcher? FileSystemWatcher;
91 private static ConcurrentDictionary<string, object>? LastWriteEvents;
93 [PublicAPI]
94 public static bool IsOwner(ulong steamID) {
95 if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) {
96 throw new ArgumentOutOfRangeException(nameof(steamID));
99 return steamID == GlobalConfig?.SteamOwnerID;
102 internal static string GetFilePath(EFileType fileType) {
103 if (!Enum.IsDefined(fileType)) {
104 throw new InvalidEnumArgumentException(nameof(fileType), (int) fileType, typeof(EFileType));
107 return fileType switch {
108 EFileType.Config => Path.Combine(SharedInfo.ConfigDirectory, SharedInfo.GlobalConfigFileName),
109 EFileType.Database => Path.Combine(SharedInfo.ConfigDirectory, SharedInfo.GlobalDatabaseFileName),
110 EFileType.Crash => Path.Combine(SharedInfo.ConfigDirectory, SharedInfo.GlobalCrashFileName),
111 _ => throw new InvalidOperationException(nameof(fileType))
115 internal static async Task<bool> Init() {
116 if (GlobalConfig == null) {
117 throw new InvalidOperationException(nameof(GlobalConfig));
120 WebBrowser = new WebBrowser(ArchiLogger, GlobalConfig.WebProxy, true);
122 if (!await PluginsCore.InitPlugins().ConfigureAwait(false)) {
123 return false;
126 await UpdateAndRestart().ConfigureAwait(false);
128 if (!Program.IgnoreUnsupportedEnvironment && !await ProtectAgainstCrashes().ConfigureAwait(false)) {
129 ArchiLogger.LogFatalError(Strings.ErrorTooManyCrashes);
131 return true;
134 Program.AllowCrashFileRemoval = true;
136 await PluginsCore.OnASFInitModules(GlobalConfig.AdditionalProperties).ConfigureAwait(false);
137 await InitRateLimiters().ConfigureAwait(false);
139 StringComparer botsComparer = await PluginsCore.GetBotsComparer().ConfigureAwait(false);
141 Bot.Init(botsComparer);
143 if (!Program.Service && !GlobalConfig.Headless && !Console.IsInputRedirected) {
144 Logging.StartInteractiveConsole();
147 if (GlobalConfig.IPC) {
148 await ArchiKestrel.Start().ConfigureAwait(false);
151 uint changeNumberToStartFrom = await PluginsCore.GetChangeNumberToStartFrom().ConfigureAwait(false);
153 SteamPICSChanges.Init(changeNumberToStartFrom);
155 await RegisterBots().ConfigureAwait(false);
157 if (Program.ConfigWatch) {
158 InitConfigWatchEvents();
161 return true;
164 internal static bool IsValidBotName(string botName) {
165 ArgumentException.ThrowIfNullOrEmpty(botName);
167 if (botName[0] == '.') {
168 return false;
171 if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) {
172 return false;
175 return Path.GetRelativePath(".", botName) == botName;
178 internal static async Task RestartOrExit() {
179 if (GlobalConfig == null) {
180 throw new InvalidOperationException(nameof(GlobalConfig));
183 if (Program.RestartAllowed && GlobalConfig.AutoRestart) {
184 ArchiLogger.LogGenericInfo(Strings.Restarting);
185 await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false);
186 await Program.Restart().ConfigureAwait(false);
187 } else {
188 ArchiLogger.LogGenericInfo(Strings.Exiting);
189 await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false);
190 await Program.Exit().ConfigureAwait(false);
194 internal static async Task<(bool Updated, Version? NewVersion)> Update(GlobalConfig.EUpdateChannel? updateChannel = null, bool updateOverride = false, bool forced = false) {
195 if (updateChannel.HasValue && !Enum.IsDefined(updateChannel.Value)) {
196 throw new InvalidEnumArgumentException(nameof(updateChannel), (int) updateChannel, typeof(GlobalConfig.EUpdateChannel));
199 if (GlobalConfig == null) {
200 throw new InvalidOperationException(nameof(GlobalConfig));
203 (bool updated, Version? newVersion) = await UpdateASF(updateChannel, updateOverride, forced).ConfigureAwait(false);
205 if (!updated) {
206 // ASF wasn't updated as part of the process, update the plugins alone
207 updated = await PluginsCore.UpdatePlugins(SharedInfo.Version, false, updateChannel, updateOverride, forced).ConfigureAwait(false);
210 return (updated, newVersion);
213 private static async Task<bool> CanHandleWriteEvent(string filePath) {
214 ArgumentException.ThrowIfNullOrEmpty(filePath);
216 if (LastWriteEvents == null) {
217 throw new InvalidOperationException(nameof(LastWriteEvents));
220 // Save our event in dictionary
221 object currentWriteEvent = new();
222 LastWriteEvents[filePath] = currentWriteEvent;
224 // Wait a second for eventual other events to arrive
225 await Task.Delay(1000).ConfigureAwait(false);
227 // We're allowed to handle this event if the one that is saved after full second is our event and we succeed in clearing it (we don't care what we're clearing anymore, it doesn't have to be atomic operation)
228 return LastWriteEvents.TryGetValue(filePath, out object? savedWriteEvent) && (currentWriteEvent == savedWriteEvent) && LastWriteEvents.TryRemove(filePath, out _);
231 private static HashSet<string> GetLoadedAssembliesNames() {
232 Assembly[] loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
234 return loadedAssemblies.Select(static loadedAssembly => loadedAssembly.FullName).Where(static name => !string.IsNullOrEmpty(name)).ToHashSet(StringComparer.Ordinal)!;
237 private static void InitConfigWatchEvents() {
238 if ((FileSystemWatcher != null) || (LastWriteEvents != null)) {
239 return;
242 if (Bot.BotsComparer == null) {
243 throw new InvalidOperationException(nameof(Bot.BotsComparer));
246 FileSystemWatcher = new FileSystemWatcher(SharedInfo.ConfigDirectory) { NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite };
248 FileSystemWatcher.Changed += OnChanged;
249 FileSystemWatcher.Created += OnCreated;
250 FileSystemWatcher.Deleted += OnDeleted;
251 FileSystemWatcher.Renamed += OnRenamed;
253 LastWriteEvents = new ConcurrentDictionary<string, object>(Bot.BotsComparer);
255 FileSystemWatcher.EnableRaisingEvents = true;
258 private static async Task InitRateLimiters() {
259 ConfirmationsSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(ConfirmationsSemaphore)).ConfigureAwait(false);
260 GiftsSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(GiftsSemaphore)).ConfigureAwait(false);
261 InventorySemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(InventorySemaphore)).ConfigureAwait(false);
262 LoginRateLimitingSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(LoginRateLimitingSemaphore)).ConfigureAwait(false);
263 LoginSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(LoginSemaphore)).ConfigureAwait(false);
264 RateLimitingSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(RateLimitingSemaphore)).ConfigureAwait(false);
266 WebLimitingSemaphores ??= new Dictionary<Uri, (ICrossProcessSemaphore RateLimitingSemaphore, SemaphoreSlim OpenConnectionsSemaphore)>(5) {
267 { ArchiWebHandler.SteamCheckoutURL, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}-{nameof(ArchiWebHandler.SteamCheckoutURL)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) },
268 { ArchiWebHandler.SteamCommunityURL, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}-{nameof(ArchiWebHandler.SteamCommunityURL)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) },
269 { ArchiWebHandler.SteamHelpURL, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}-{nameof(ArchiWebHandler.SteamHelpURL)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) },
270 { ArchiWebHandler.SteamStoreURL, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}-{nameof(ArchiWebHandler.SteamStoreURL)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) },
271 { WebAPI.DefaultBaseAddress, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}-{nameof(WebAPI)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) }
272 }.ToFrozenDictionary();
275 private static void LoadAllAssemblies() {
276 HashSet<string> loadedAssembliesNames = GetLoadedAssembliesNames();
278 LoadAssembliesRecursively(Assembly.GetExecutingAssembly(), loadedAssembliesNames);
281 private static void LoadAssembliesNeededBeforeUpdate() {
282 HashSet<string> loadedAssembliesNames = GetLoadedAssembliesNames();
284 foreach (string assemblyName in AssembliesNeededBeforeUpdate.Where(loadedAssembliesNames.Add)) {
285 Assembly assembly;
287 try {
288 assembly = Assembly.Load(assemblyName);
289 } catch (Exception e) {
290 ArchiLogger.LogGenericDebuggingException(e);
292 continue;
295 LoadAssembliesRecursively(assembly, loadedAssembliesNames);
299 [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
300 private static void LoadAssembliesRecursively(Assembly assembly, ISet<string> loadedAssembliesNames) {
301 ArgumentNullException.ThrowIfNull(assembly);
303 if ((loadedAssembliesNames == null) || (loadedAssembliesNames.Count == 0)) {
304 throw new ArgumentNullException(nameof(loadedAssembliesNames));
307 foreach (AssemblyName assemblyName in assembly.GetReferencedAssemblies().Where(assemblyName => loadedAssembliesNames.Add(assemblyName.FullName))) {
308 Assembly loadedAssembly;
310 try {
311 loadedAssembly = Assembly.Load(assemblyName);
312 } catch (Exception e) {
313 ArchiLogger.LogGenericDebuggingException(e);
315 continue;
318 LoadAssembliesRecursively(loadedAssembly, loadedAssembliesNames);
322 private static async void OnAutoUpdatesTimer(object? state = null) => await UpdateAndRestart().ConfigureAwait(false);
324 private static async void OnChanged(object sender, FileSystemEventArgs e) {
325 ArgumentNullException.ThrowIfNull(sender);
326 ArgumentNullException.ThrowIfNull(e);
328 if (string.IsNullOrEmpty(e.Name)) {
329 throw new InvalidOperationException(nameof(e.Name));
332 if (string.IsNullOrEmpty(e.FullPath)) {
333 throw new InvalidOperationException(nameof(e.FullPath));
336 await OnChangedFile(e.Name, e.FullPath).ConfigureAwait(false);
339 private static async Task OnChangedConfigFile(string name, string fullPath) {
340 ArgumentException.ThrowIfNullOrEmpty(name);
341 ArgumentException.ThrowIfNullOrEmpty(fullPath);
343 await OnCreatedConfigFile(name, fullPath).ConfigureAwait(false);
346 private static async Task OnChangedConfigFile(string name) {
347 ArgumentException.ThrowIfNullOrEmpty(name);
349 if (!name.Equals(SharedInfo.IPCConfigFile, StringComparison.OrdinalIgnoreCase) || (GlobalConfig?.IPC != true)) {
350 return;
353 if (!await CanHandleWriteEvent(name).ConfigureAwait(false)) {
354 return;
357 ArchiLogger.LogGenericInfo(Strings.IPCConfigChanged);
358 await ArchiKestrel.Stop().ConfigureAwait(false);
359 await ArchiKestrel.Start().ConfigureAwait(false);
362 private static async Task OnChangedFile(string name, string fullPath) {
363 ArgumentException.ThrowIfNullOrEmpty(name);
364 ArgumentException.ThrowIfNullOrEmpty(fullPath);
366 string extension = Path.GetExtension(name);
368 switch (extension) {
369 case SharedInfo.JsonConfigExtension:
370 case SharedInfo.IPCConfigExtension:
371 await OnChangedConfigFile(name, fullPath).ConfigureAwait(false);
373 break;
374 case SharedInfo.KeysExtension:
375 await OnChangedKeysFile(name, fullPath).ConfigureAwait(false);
377 break;
381 private static async Task OnChangedKeysFile(string name, string fullPath) {
382 ArgumentException.ThrowIfNullOrEmpty(name);
383 ArgumentException.ThrowIfNullOrEmpty(fullPath);
385 await OnCreatedKeysFile(name, fullPath).ConfigureAwait(false);
388 private static async Task OnConfigChanged() {
389 string globalConfigFile = GetFilePath(EFileType.Config);
391 if (string.IsNullOrEmpty(globalConfigFile)) {
392 throw new InvalidOperationException(nameof(globalConfigFile));
395 (GlobalConfig? globalConfig, _) = await GlobalConfig.Load(globalConfigFile).ConfigureAwait(false);
397 if (globalConfig == null) {
398 // Invalid config file, we allow user to fix it without destroying the ASF instance right away
399 return;
402 if (globalConfig == GlobalConfig) {
403 return;
406 ArchiLogger.LogGenericInfo(Strings.GlobalConfigChanged);
407 await RestartOrExit().ConfigureAwait(false);
410 private static async void OnCreated(object sender, FileSystemEventArgs e) {
411 ArgumentNullException.ThrowIfNull(sender);
412 ArgumentNullException.ThrowIfNull(e);
414 if (string.IsNullOrEmpty(e.Name)) {
415 throw new InvalidOperationException(nameof(e.Name));
418 if (string.IsNullOrEmpty(e.FullPath)) {
419 throw new InvalidOperationException(nameof(e.FullPath));
422 await OnCreatedFile(e.Name, e.FullPath).ConfigureAwait(false);
425 private static async Task OnCreatedConfigFile(string name, string fullPath) {
426 ArgumentException.ThrowIfNullOrEmpty(name);
427 ArgumentException.ThrowIfNullOrEmpty(fullPath);
429 string extension = Path.GetExtension(name);
431 switch (extension) {
432 case SharedInfo.IPCConfigExtension:
433 await OnChangedConfigFile(name).ConfigureAwait(false);
435 break;
436 case SharedInfo.JsonConfigExtension:
437 await OnCreatedJsonFile(name, fullPath).ConfigureAwait(false);
439 break;
443 private static async Task OnCreatedFile(string name, string fullPath) {
444 ArgumentException.ThrowIfNullOrEmpty(name);
445 ArgumentException.ThrowIfNullOrEmpty(fullPath);
447 string extension = Path.GetExtension(name);
449 switch (extension) {
450 case SharedInfo.JsonConfigExtension:
451 await OnCreatedConfigFile(name, fullPath).ConfigureAwait(false);
453 break;
455 case SharedInfo.KeysExtension:
456 await OnCreatedKeysFile(name, fullPath).ConfigureAwait(false);
458 break;
462 private static async Task OnCreatedJsonFile(string name, string fullPath) {
463 ArgumentException.ThrowIfNullOrEmpty(name);
464 ArgumentException.ThrowIfNullOrEmpty(fullPath);
466 if (Bot.Bots == null) {
467 throw new InvalidOperationException(nameof(Bot.Bots));
470 string botName = Path.GetFileNameWithoutExtension(name);
472 if (string.IsNullOrEmpty(botName) || (botName[0] == '.')) {
473 return;
476 if (!await CanHandleWriteEvent(fullPath).ConfigureAwait(false)) {
477 return;
480 if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) {
481 await OnConfigChanged().ConfigureAwait(false);
483 return;
486 if (!IsValidBotName(botName)) {
487 return;
490 if (Bot.Bots.TryGetValue(botName, out Bot? bot)) {
491 await bot.OnConfigChanged(false).ConfigureAwait(false);
492 } else {
493 await Bot.RegisterBot(botName).ConfigureAwait(false);
495 if (Bot.Bots.Count > MaximumRecommendedBotsCount) {
496 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningExcessiveBotsCount, MaximumRecommendedBotsCount));
501 private static async Task OnCreatedKeysFile(string name, string fullPath) {
502 ArgumentException.ThrowIfNullOrEmpty(name);
503 ArgumentException.ThrowIfNullOrEmpty(fullPath);
505 if (Bot.Bots == null) {
506 throw new InvalidOperationException(nameof(Bot.Bots));
509 string botName = Path.GetFileNameWithoutExtension(name);
511 if (string.IsNullOrEmpty(botName) || (botName[0] == '.')) {
512 return;
515 if (!await CanHandleWriteEvent(fullPath).ConfigureAwait(false)) {
516 return;
519 if (!Bot.Bots.TryGetValue(botName, out Bot? bot)) {
520 return;
523 await bot.ImportKeysToRedeem(fullPath).ConfigureAwait(false);
526 private static async void OnDeleted(object sender, FileSystemEventArgs e) {
527 ArgumentNullException.ThrowIfNull(sender);
528 ArgumentNullException.ThrowIfNull(e);
530 if (string.IsNullOrEmpty(e.Name)) {
531 throw new InvalidOperationException(nameof(e.Name));
534 if (string.IsNullOrEmpty(e.FullPath)) {
535 throw new InvalidOperationException(nameof(e.FullPath));
538 await OnDeletedFile(e.Name, e.FullPath).ConfigureAwait(false);
541 private static async Task OnDeletedConfigFile(string name, string fullPath) {
542 ArgumentException.ThrowIfNullOrEmpty(name);
543 ArgumentException.ThrowIfNullOrEmpty(fullPath);
545 string extension = Path.GetExtension(name);
547 switch (extension) {
548 case SharedInfo.IPCConfigExtension:
549 await OnChangedConfigFile(name).ConfigureAwait(false);
551 break;
552 case SharedInfo.JsonConfigExtension:
553 await OnDeletedJsonConfigFile(name, fullPath).ConfigureAwait(false);
555 break;
559 private static async Task OnDeletedFile(string name, string fullPath) {
560 ArgumentException.ThrowIfNullOrEmpty(name);
561 ArgumentException.ThrowIfNullOrEmpty(fullPath);
563 string extension = Path.GetExtension(name);
565 switch (extension) {
566 case SharedInfo.JsonConfigExtension:
567 case SharedInfo.IPCConfigExtension:
568 await OnDeletedConfigFile(name, fullPath).ConfigureAwait(false);
570 break;
574 private static async Task OnDeletedJsonConfigFile(string name, string fullPath) {
575 ArgumentException.ThrowIfNullOrEmpty(name);
576 ArgumentException.ThrowIfNullOrEmpty(fullPath);
578 if (Bot.Bots == null) {
579 throw new InvalidOperationException(nameof(Bot.Bots));
582 string botName = Path.GetFileNameWithoutExtension(name);
584 if (string.IsNullOrEmpty(botName)) {
585 return;
588 if (!await CanHandleWriteEvent(fullPath).ConfigureAwait(false)) {
589 return;
592 if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) {
593 if (File.Exists(fullPath)) {
594 return;
597 // Some editors might decide to delete file and re-create it in order to modify it
598 // If that's the case, we wait for maximum of 5 seconds before shutting down
599 await Task.Delay(5000).ConfigureAwait(false);
601 if (File.Exists(fullPath)) {
602 return;
605 ArchiLogger.LogGenericError(Strings.ErrorGlobalConfigRemoved);
606 await Program.Exit(1).ConfigureAwait(false);
608 return;
611 if (!IsValidBotName(botName)) {
612 return;
615 if (Bot.Bots.TryGetValue(botName, out Bot? bot)) {
616 await bot.OnConfigChanged(true).ConfigureAwait(false);
620 private static async void OnRenamed(object sender, RenamedEventArgs e) {
621 // This function can be called with a possibility of OldName or (new) Name being null, we have to take it into account
622 ArgumentNullException.ThrowIfNull(sender);
623 ArgumentNullException.ThrowIfNull(e);
625 if (!string.IsNullOrEmpty(e.OldName) && !string.IsNullOrEmpty(e.OldFullPath)) {
626 await OnDeletedFile(e.OldName, e.OldFullPath).ConfigureAwait(false);
629 if (!string.IsNullOrEmpty(e.Name) && !string.IsNullOrEmpty(e.FullPath)) {
630 await OnCreatedFile(e.Name, e.FullPath).ConfigureAwait(false);
634 private static async Task<bool> ProtectAgainstCrashes() {
635 if (Debugging.IsDebugBuild) {
636 // Allow debug builds to run unconditionally, we expect to crash a lot in those
637 return true;
640 string crashFilePath = GetFilePath(EFileType.Crash);
642 CrashFile crashFile = await CrashFile.CreateOrLoad(crashFilePath).ConfigureAwait(false);
644 if (crashFile.StartupCount >= WebBrowser.MaxTries) {
645 // We've reached maximum allowed count of recent crashes, return failure
646 return false;
649 DateTime now = DateTime.UtcNow;
651 if (now - crashFile.LastStartup > TimeSpan.FromMinutes(5)) {
652 // Last crash was long ago, restart counter
653 crashFile.StartupCount = 1;
654 } else if (++crashFile.StartupCount >= WebBrowser.MaxTries) {
655 // We've reached maximum allowed count of recent crashes, return failure
656 return false;
659 crashFile.LastStartup = now;
661 // We're allowing this run to proceed
662 return true;
665 private static async Task RegisterBots() {
666 if (GlobalConfig == null) {
667 throw new InvalidOperationException(nameof(GlobalConfig));
670 if (GlobalDatabase == null) {
671 throw new InvalidOperationException(nameof(GlobalDatabase));
674 if (WebBrowser == null) {
675 throw new InvalidOperationException(nameof(WebBrowser));
678 // Ensure that we ask for a list of servers if we don't have any saved servers available
679 IEnumerable<ServerRecord> servers = await GlobalDatabase.ServerListProvider.FetchServerListAsync().ConfigureAwait(false);
681 if (!servers.Any()) {
682 ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.Initializing, nameof(SteamDirectory)));
684 SteamConfiguration steamConfiguration = SteamConfiguration.Create(static builder => builder.WithProtocolTypes(GlobalConfig.SteamProtocols).WithCellID(GlobalDatabase.CellID).WithServerListProvider(GlobalDatabase.ServerListProvider).WithHttpClientFactory(static () => WebBrowser.GenerateDisposableHttpClient()));
686 try {
687 await SteamDirectory.LoadAsync(steamConfiguration).ConfigureAwait(false);
688 ArchiLogger.LogGenericInfo(Strings.Success);
689 } catch (Exception e) {
690 ArchiLogger.LogGenericWarningException(e);
691 ArchiLogger.LogGenericWarning(Strings.BotSteamDirectoryInitializationFailed);
695 HashSet<string> botNames;
697 try {
698 botNames = Directory.EnumerateFiles(SharedInfo.ConfigDirectory, $"*{SharedInfo.JsonConfigExtension}").Select(Path.GetFileNameWithoutExtension).Where(static botName => !string.IsNullOrEmpty(botName) && IsValidBotName(botName)).ToHashSet(Bot.BotsComparer)!;
699 } catch (Exception e) {
700 ArchiLogger.LogGenericException(e);
702 return;
705 switch (botNames.Count) {
706 case 0:
707 ArchiLogger.LogGenericWarning(Strings.ErrorNoBotsDefined);
709 return;
710 case > MaximumRecommendedBotsCount:
711 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningExcessiveBotsCount, MaximumRecommendedBotsCount));
712 await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false);
714 break;
717 await Utilities.InParallel(botNames.OrderBy(static botName => botName, Bot.BotsComparer).Select(Bot.RegisterBot)).ConfigureAwait(false);
720 private static async Task UpdateAndRestart() {
721 if (GlobalConfig == null) {
722 throw new InvalidOperationException(nameof(GlobalConfig));
725 if (GlobalConfig.UpdateChannel == GlobalConfig.EUpdateChannel.None) {
726 return;
729 if ((AutoUpdatesTimer == null) && (GlobalConfig.UpdatePeriod > 0)) {
730 TimeSpan autoUpdatePeriod = TimeSpan.FromHours(GlobalConfig.UpdatePeriod);
732 AutoUpdatesTimer = new Timer(
733 OnAutoUpdatesTimer,
734 null,
735 autoUpdatePeriod, // Delay
736 autoUpdatePeriod // Period
739 ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.AutoUpdateCheckInfo, autoUpdatePeriod.ToHumanReadable()));
742 (bool updated, Version? newVersion) = await Update().ConfigureAwait(false);
744 if (!updated) {
745 if ((newVersion != null) && (SharedInfo.Version > newVersion)) {
746 // User is running version newer than their channel allows
747 ArchiLogger.LogGenericWarning(Strings.WarningPreReleaseVersion);
748 await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false);
751 return;
754 // Allow crash file recovery, if needed
755 Program.AllowCrashFileRemoval = true;
757 await RestartOrExit().ConfigureAwait(false);
760 private static async Task<(bool Updated, Version? NewVersion)> UpdateASF(GlobalConfig.EUpdateChannel? channel = null, bool updateOverride = false, bool forced = false) {
761 if (channel.HasValue && !Enum.IsDefined(channel.Value)) {
762 throw new InvalidEnumArgumentException(nameof(channel), (int) channel, typeof(GlobalConfig.EUpdateChannel));
765 if (GlobalConfig == null) {
766 throw new InvalidOperationException(nameof(GlobalConfig));
769 if (WebBrowser == null) {
770 throw new InvalidOperationException(nameof(WebBrowser));
773 channel ??= GlobalConfig.UpdateChannel;
775 if (!SharedInfo.BuildInfo.CanUpdate || (channel == GlobalConfig.EUpdateChannel.None)) {
776 return (false, null);
779 string targetFile;
781 await UpdateSemaphore.WaitAsync().ConfigureAwait(false);
783 try {
784 // If directories from previous update exist, it's a good idea to purge them now
785 if (!await Utilities.UpdateCleanup(SharedInfo.HomeDirectory).ConfigureAwait(false)) {
786 return (false, null);
789 ArchiLogger.LogGenericInfo(Strings.UpdateCheckingNewVersion);
791 ReleaseResponse? releaseResponse = await GitHubService.GetLatestRelease(SharedInfo.GithubRepo, channel == GlobalConfig.EUpdateChannel.Stable).ConfigureAwait(false);
793 if (releaseResponse == null) {
794 ArchiLogger.LogGenericWarning(Strings.ErrorUpdateCheckFailed);
796 return (false, null);
799 if (string.IsNullOrEmpty(releaseResponse.Tag)) {
800 ArchiLogger.LogGenericWarning(Strings.ErrorUpdateCheckFailed);
802 return (false, null);
805 Version newVersion = new(releaseResponse.Tag);
807 ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.UpdateVersionInfo, SharedInfo.Version, newVersion));
809 if (!forced && (SharedInfo.Version >= newVersion)) {
810 return (false, newVersion);
813 if (!updateOverride && (GlobalConfig.UpdatePeriod == 0)) {
814 ArchiLogger.LogGenericInfo(Strings.UpdateNewVersionAvailable);
815 await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false);
817 return (false, newVersion);
820 // Auto update logic starts here
821 if (releaseResponse.Assets.IsEmpty) {
822 ArchiLogger.LogGenericWarning(Strings.ErrorUpdateNoAssets);
824 return (false, newVersion);
827 targetFile = $"{SharedInfo.ASF}-{SharedInfo.BuildInfo.Variant}.zip";
828 ReleaseAsset? binaryAsset = releaseResponse.Assets.FirstOrDefault(asset => !string.IsNullOrEmpty(asset.Name) && asset.Name.Equals(targetFile, StringComparison.OrdinalIgnoreCase));
830 if (binaryAsset == null) {
831 ArchiLogger.LogGenericWarning(Strings.ErrorUpdateNoAssetForThisVersion);
833 return (false, newVersion);
836 ArchiLogger.LogGenericInfo(Strings.FetchingChecksumFromRemoteServer);
838 // Keep short timeout allowed for this call, as we don't want to hold the flow for too long
839 using CancellationTokenSource archiNetCancellation = new(TimeSpan.FromSeconds(15));
841 string? remoteChecksum = await ArchiNet.FetchBuildChecksum(newVersion, SharedInfo.BuildInfo.Variant, archiNetCancellation.Token).ConfigureAwait(false);
843 switch (remoteChecksum) {
844 case null:
845 // Timeout or error, refuse to update as a security measure
846 ArchiLogger.LogGenericWarning(Strings.ChecksumTimeout);
848 return (false, newVersion);
849 case "":
850 // Unknown checksum, release too new or actual malicious build published, no need to scare the user as it's 99.99% the first
851 ArchiLogger.LogGenericWarning(Strings.ChecksumMissing);
853 return (false, newVersion);
856 if (!string.IsNullOrEmpty(releaseResponse.ChangelogPlainText)) {
857 ArchiLogger.LogGenericInfo(releaseResponse.ChangelogPlainText);
860 ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.UpdateDownloadingNewVersion, newVersion, binaryAsset.Size / 1024 / 1024));
862 Progress<byte> progressReporter = new();
864 progressReporter.ProgressChanged += onProgressChanged;
866 BinaryResponse? response;
868 try {
869 // ReSharper disable once MethodSupportsCancellation - the token initialized above is not meant to be passed here
870 response = await WebBrowser.UrlGetToBinary(binaryAsset.DownloadURL, progressReporter: progressReporter).ConfigureAwait(false);
871 } finally {
872 progressReporter.ProgressChanged -= onProgressChanged;
875 if (response?.Content == null) {
876 return (false, newVersion);
879 ArchiLogger.LogGenericInfo(Strings.VerifyingChecksumWithRemoteServer);
881 byte[] responseBytes = response.Content as byte[] ?? response.Content.ToArray();
883 string checksum = Utilities.GenerateChecksumFor(responseBytes);
885 if (!checksum.Equals(remoteChecksum, StringComparison.OrdinalIgnoreCase)) {
886 ArchiLogger.LogGenericError(Strings.ChecksumWrong);
888 return (false, newVersion);
891 await PluginsCore.OnUpdateProceeding(newVersion).ConfigureAwait(false);
893 bool kestrelWasRunning = ArchiKestrel.IsRunning;
895 if (kestrelWasRunning) {
896 // We disable ArchiKestrel here as the update process moves the core files and might result in IPC crash
897 // TODO: It might fail if the update was triggered from the API, this should be something to improve in the future, by changing the structure into request -> return response -> finish update
898 try {
899 await ArchiKestrel.Stop().ConfigureAwait(false);
900 } catch (Exception e) {
901 ArchiLogger.LogGenericWarningException(e);
905 ArchiLogger.LogGenericInfo(Strings.PatchingFiles);
907 try {
908 MemoryStream memoryStream = new(responseBytes);
910 await using (memoryStream.ConfigureAwait(false)) {
911 using ZipArchive zipArchive = new(memoryStream);
913 if (!await UpdateFromArchive(newVersion, channel.Value, updateOverride, forced, zipArchive).ConfigureAwait(false)) {
914 ArchiLogger.LogGenericError(Strings.WarningFailed);
917 } catch (Exception e) {
918 ArchiLogger.LogGenericException(e);
920 if (kestrelWasRunning) {
921 // We've temporarily disabled ArchiKestrel but the update has failed, let's bring it back up
922 // We can't even be sure if it's possible to bring it back up in this state, but it's worth trying anyway
923 try {
924 await ArchiKestrel.Start().ConfigureAwait(false);
925 } catch (Exception ex) {
926 ArchiLogger.LogGenericWarningException(ex);
930 return (false, newVersion);
933 ArchiLogger.LogGenericInfo(Strings.UpdateFinished);
935 await PluginsCore.OnUpdateFinished(newVersion).ConfigureAwait(false);
937 return (true, newVersion);
938 } finally {
939 UpdateSemaphore.Release();
942 void onProgressChanged(object? sender, byte progressPercentage) {
943 ArgumentOutOfRangeException.ThrowIfGreaterThan(progressPercentage, 100);
945 Utilities.OnProgressChanged(targetFile, progressPercentage);
949 private static async Task<bool> UpdateFromArchive(Version newVersion, GlobalConfig.EUpdateChannel updateChannel, bool updateOverride, bool forced, ZipArchive zipArchive) {
950 ArgumentNullException.ThrowIfNull(newVersion);
952 if (!Enum.IsDefined(updateChannel)) {
953 throw new InvalidEnumArgumentException(nameof(updateChannel), (int) updateChannel, typeof(GlobalConfig.EUpdateChannel));
956 ArgumentNullException.ThrowIfNull(zipArchive);
958 if (SharedInfo.HomeDirectory == AppContext.BaseDirectory) {
959 // We're running a build that includes our dependencies in ASF's home
960 // Before actually moving files in update procedure, let's minimize the risk of some assembly not being loaded that we may need in the process
961 LoadAllAssemblies();
962 } else {
963 // This is a tricky one, for some reason we might need to preload some selected assemblies even in OS-specific builds that normally should be self-contained...
964 // It's as if the executable file was directly mapped to memory and moving it out of the original path caused the whole thing to crash
965 // TODO: This is a total hack, I wish we could get to the bottom of this hole and find out what is really going on there in regards to the above
966 LoadAssembliesNeededBeforeUpdate();
969 // We're ready to start update process, handle any plugin updates ready for new version
970 await PluginsCore.UpdatePlugins(newVersion, true, updateChannel, updateOverride, forced).ConfigureAwait(false);
972 return await Utilities.UpdateFromArchive(zipArchive, SharedInfo.HomeDirectory).ConfigureAwait(false);
975 [PublicAPI]
976 public enum EUserInputType : byte {
977 None,
978 Login,
979 Password,
980 SteamGuard,
981 SteamParentalCode,
982 TwoFactorAuthentication,
983 Cryptkey,
984 DeviceConfirmation
987 internal enum EFileType : byte {
988 Config,
989 Database,
990 Crash