From ae9dfca3b3b96fb8e2f72258be540a73e417cfc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Domeradzki?= Date: Thu, 4 Apr 2024 21:21:58 +0200 Subject: [PATCH] Closes #3156 (#3182) * Closes #3156 * Misc * Misc * Rewrite update mechanism ONCE AGAIN, this time to eradicate FSW * Make creating debug directory non-fatal again, like it used to be * Deduplicate code * Remove dead code * Print update cleanup just once * Address remaining feedback, go back to _old and _new * One more nice improvement --- ArchiSteamFarm/Core/ASF.cs | 37 +--- ArchiSteamFarm/Core/Utilities.cs | 234 +++++++++++++++----------- ArchiSteamFarm/NLog/Logging.cs | 4 +- ArchiSteamFarm/Plugins/PluginsCore.cs | 37 +++- ArchiSteamFarm/Program.cs | 24 +-- ArchiSteamFarm/SharedInfo.cs | 3 +- ArchiSteamFarm/Steam/Bot.cs | 5 +- 7 files changed, 185 insertions(+), 159 deletions(-) diff --git a/ArchiSteamFarm/Core/ASF.cs b/ArchiSteamFarm/Core/ASF.cs index 033ec03d278e2..9d4b3ae56e5fe 100644 --- a/ArchiSteamFarm/Core/ASF.cs +++ b/ArchiSteamFarm/Core/ASF.cs @@ -73,6 +73,8 @@ public static class ASF { internal static readonly SemaphoreSlim OpenConnectionsSemaphore = new(WebBrowser.MaxConnections, WebBrowser.MaxConnections); + internal static string DebugDirectory => Path.Combine(SharedInfo.DebugDirectory, OS.ProcessStartTime.ToString("yyyy-MM-dd-THH-mm-ss", CultureInfo.InvariantCulture)); + internal static ICrossProcessSemaphore? ConfirmationsSemaphore { get; private set; } internal static ICrossProcessSemaphore? GiftsSemaphore { get; private set; } internal static ICrossProcessSemaphore? InventorySemaphore { get; private set; } @@ -779,36 +781,9 @@ private static async Task UpdateAndRestart() { await UpdateSemaphore.WaitAsync().ConfigureAwait(false); try { - // If backup directory from previous update exists, it's a good idea to purge it now - string backupDirectory = Path.Combine(SharedInfo.HomeDirectory, SharedInfo.UpdateDirectory); - - if (Directory.Exists(backupDirectory)) { - ArchiLogger.LogGenericInfo(Strings.UpdateCleanup); - - for (byte i = 0; (i < WebBrowser.MaxTries) && Directory.Exists(backupDirectory); i++) { - if (i > 0) { - // It's entirely possible that old process is still running, wait a short moment for eventual cleanup - await Task.Delay(5000).ConfigureAwait(false); - } - - try { - Directory.Delete(backupDirectory, true); - } catch (Exception e) { - ArchiLogger.LogGenericDebuggingException(e); - - continue; - } - - break; - } - - if (Directory.Exists(backupDirectory)) { - ArchiLogger.LogGenericError(Strings.WarningFailed); - - return (false, null); - } - - ArchiLogger.LogGenericInfo(Strings.Done); + // If directories from previous update exist, it's a good idea to purge them now + if (!await Utilities.UpdateCleanup(SharedInfo.HomeDirectory).ConfigureAwait(false)) { + return (false, null); } ArchiLogger.LogGenericInfo(Strings.UpdateCheckingNewVersion); @@ -994,7 +969,7 @@ private static async Task UpdateFromArchive(Version newVersion, GlobalConf // We're ready to start update process, handle any plugin updates ready for new version await PluginsCore.UpdatePlugins(newVersion, true, updateChannel, updateOverride, forced).ConfigureAwait(false); - return Utilities.UpdateFromArchive(zipArchive, SharedInfo.HomeDirectory); + return await Utilities.UpdateFromArchive(zipArchive, SharedInfo.HomeDirectory).ConfigureAwait(false); } [PublicAPI] diff --git a/ArchiSteamFarm/Core/Utilities.cs b/ArchiSteamFarm/Core/Utilities.cs index bbd9094073557..dcca3035a552e 100644 --- a/ArchiSteamFarm/Core/Utilities.cs +++ b/ArchiSteamFarm/Core/Utilities.cs @@ -50,8 +50,12 @@ namespace ArchiSteamFarm.Core; public static class Utilities { + private const byte MaxSharingViolationTries = 15; + private const uint SharingViolationHResult = 0x80070020; private const byte TimeoutForLongRunningTasksInSeconds = 60; + private static readonly FrozenSet DirectorySeparators = new HashSet(2) { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.ToFrozenSet(); + // normally we'd just use words like "steam" and "farm", but the library we're currently using is a bit iffy about banned words, so we need to also add combinations such as "steamfarm" private static readonly FrozenSet ForbiddenPasswordPhrases = new HashSet(10, StringComparer.InvariantCultureIgnoreCase) { "archisteamfarm", "archi", "steam", "farm", "archisteam", "archifarm", "steamfarm", "asf", "asffarm", "password" }.ToFrozenSet(StringComparer.InvariantCultureIgnoreCase); @@ -322,117 +326,76 @@ internal static (bool IsWeak, string? Reason) TestPasswordStrength(string passwo return (result.Score < 4, suggestions is { Count: > 0 } ? string.Join(' ', suggestions.Where(static suggestion => suggestion.Length > 0)) : null); } - internal static bool UpdateFromArchive(ZipArchive zipArchive, string targetDirectory) { - ArgumentNullException.ThrowIfNull(zipArchive); + internal static async Task UpdateCleanup(string targetDirectory) { ArgumentException.ThrowIfNullOrEmpty(targetDirectory); - // Firstly we'll move all our existing files to a backup directory - string backupDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectory); - - foreach (string file in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.AllDirectories)) { - string fileName = Path.GetFileName(file); - - if (string.IsNullOrEmpty(fileName)) { - ASF.ArchiLogger.LogNullError(fileName); + bool updateCleanup = false; - return false; - } + try { + string updateDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryNew); - string relativeFilePath = Path.GetRelativePath(targetDirectory, file); + if (Directory.Exists(updateDirectory)) { + if (!updateCleanup) { + updateCleanup = true; - if (string.IsNullOrEmpty(relativeFilePath)) { - ASF.ArchiLogger.LogNullError(relativeFilePath); + ASF.ArchiLogger.LogGenericInfo(Strings.UpdateCleanup); + } - return false; + Directory.Delete(updateDirectory, true); } - string? relativeDirectoryName = Path.GetDirectoryName(relativeFilePath); - - switch (relativeDirectoryName) { - case null: - ASF.ArchiLogger.LogNullError(relativeDirectoryName); + string backupDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryOld); - return false; - case "": - // No directory, root folder - switch (fileName) { - case Logging.NLogConfigurationFile: - case SharedInfo.LogFile: - // Files with those names in root directory we want to keep - continue; - } + if (Directory.Exists(backupDirectory)) { + if (!updateCleanup) { + updateCleanup = true; - break; - case SharedInfo.ArchivalLogsDirectory: - case SharedInfo.ConfigDirectory: - case SharedInfo.DebugDirectory: - case SharedInfo.PluginsDirectory: - case SharedInfo.UpdateDirectory: - // Files in those directories we want to keep in their current place - continue; - default: - // Files in subdirectories of those directories we want to keep as well - if (RelativeDirectoryStartsWith(relativeDirectoryName, SharedInfo.ArchivalLogsDirectory, SharedInfo.ConfigDirectory, SharedInfo.DebugDirectory, SharedInfo.PluginsDirectory, SharedInfo.UpdateDirectory)) { - continue; - } + ASF.ArchiLogger.LogGenericInfo(Strings.UpdateCleanup); + } - break; + await DeletePotentiallyUsedDirectory(backupDirectory).ConfigureAwait(false); } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); - string targetBackupDirectory = relativeDirectoryName.Length > 0 ? Path.Combine(backupDirectory, relativeDirectoryName) : backupDirectory; - Directory.CreateDirectory(targetBackupDirectory); - - string targetBackupFile = Path.Combine(targetBackupDirectory, fileName); - - File.Move(file, targetBackupFile, true); + return false; } - // We can now get rid of directories that are empty - DeleteEmptyDirectoriesRecursively(targetDirectory); - - if (!Directory.Exists(targetDirectory)) { - Directory.CreateDirectory(targetDirectory); + if (updateCleanup) { + ASF.ArchiLogger.LogGenericInfo(Strings.Done); } - // Now enumerate over files in the zip archive, skip directory entries that we're not interested in (we can create them ourselves if needed) - foreach (ZipArchiveEntry zipFile in zipArchive.Entries.Where(static zipFile => !string.IsNullOrEmpty(zipFile.Name))) { - string file = Path.GetFullPath(Path.Combine(targetDirectory, zipFile.FullName)); + return true; + } - if (!file.StartsWith(targetDirectory, StringComparison.Ordinal)) { - throw new InvalidOperationException(nameof(file)); - } + internal static async Task UpdateFromArchive(ZipArchive zipArchive, string targetDirectory) { + ArgumentNullException.ThrowIfNull(zipArchive); + ArgumentException.ThrowIfNullOrEmpty(targetDirectory); - if (File.Exists(file)) { - // This is possible only with files that we decided to leave in place during our backup function - string targetBackupFile = $"{file}.bak"; + // Firstly, ensure once again our directories are purged and ready to work with + if (!await UpdateCleanup(targetDirectory).ConfigureAwait(false)) { + return false; + } - File.Move(file, targetBackupFile, true); - } + // Now extract the zip file to entirely new location, this decreases chance of corruptions if user kills the process during this stage + string updateDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryNew); - // Check if this file requires its own folder - if (zipFile.Name != zipFile.FullName) { - string? directory = Path.GetDirectoryName(file); + zipArchive.ExtractToDirectory(updateDirectory, true); - if (string.IsNullOrEmpty(directory)) { - ASF.ArchiLogger.LogNullError(directory); + // Now, critical section begins, we're going to move all files from target directory to a backup directory + string backupDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryOld); - return false; - } + Directory.CreateDirectory(backupDirectory); - if (!Directory.Exists(directory)) { - Directory.CreateDirectory(directory); - } + MoveAllUpdateFiles(targetDirectory, backupDirectory, true); - // We're not interested in extracting placeholder files (but we still want directories created for them, done above) - switch (zipFile.Name) { - case ".gitkeep": - continue; - } - } + // Finally, we can move the newly extracted files to target directory + MoveAllUpdateFiles(updateDirectory, targetDirectory, false); - zipFile.ExtractToFile(file); - } + // Critical section has finished, we can now cleanup the update directory, backup directory must wait for the process restart + Directory.Delete(updateDirectory, true); + // The update process is done return true; } @@ -491,23 +454,104 @@ internal static void WarnAboutIncompleteTranslation(ResourceManager resourceMana } } - private static void DeleteEmptyDirectoriesRecursively(string directory) { + private static async Task DeletePotentiallyUsedDirectory(string directory) { ArgumentException.ThrowIfNullOrEmpty(directory); - if (!Directory.Exists(directory)) { + for (byte i = 1; (i <= MaxSharingViolationTries) && Directory.Exists(directory); i++) { + if (i > 1) { + await Task.Delay(1000).ConfigureAwait(false); + } + + try { + Directory.Delete(directory, true); + } catch (IOException e) when ((i < MaxSharingViolationTries) && ((uint) e.HResult == SharingViolationHResult)) { + // It's entirely possible that old process is still running, we allow this to happen and add additional delay + ASF.ArchiLogger.LogGenericDebuggingException(e); + + continue; + } + return; } + } - try { - foreach (string subDirectory in Directory.EnumerateDirectories(directory)) { - DeleteEmptyDirectoriesRecursively(subDirectory); + private static void MoveAllUpdateFiles(string sourceDirectory, string targetDirectory, bool keepUserFiles) { + ArgumentException.ThrowIfNullOrEmpty(sourceDirectory); + ArgumentException.ThrowIfNullOrEmpty(sourceDirectory); + + // Determine if targetDirectory is within sourceDirectory, if yes we need to skip it from enumeration further below + string targetRelativeDirectoryPath = Path.GetRelativePath(sourceDirectory, targetDirectory); + + foreach (string file in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) { + string fileName = Path.GetFileName(file); + + if (string.IsNullOrEmpty(fileName)) { + throw new InvalidOperationException(nameof(fileName)); } - if (!Directory.EnumerateFileSystemEntries(directory).Any()) { - Directory.Delete(directory); + string relativeFilePath = Path.GetRelativePath(sourceDirectory, file); + + if (string.IsNullOrEmpty(relativeFilePath)) { + throw new InvalidOperationException(nameof(relativeFilePath)); } - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); + + string? relativeDirectoryName = Path.GetDirectoryName(relativeFilePath); + + switch (relativeDirectoryName) { + case null: + throw new InvalidOperationException(nameof(keepUserFiles)); + case "": + // No directory, root folder + switch (fileName) { + case Logging.NLogConfigurationFile when keepUserFiles: + case SharedInfo.LogFile when keepUserFiles: + // Files with those names in root directory we want to keep + continue; + } + + break; + case SharedInfo.ArchivalLogsDirectory when keepUserFiles: + case SharedInfo.ConfigDirectory when keepUserFiles: + case SharedInfo.DebugDirectory when keepUserFiles: + case SharedInfo.PluginsDirectory when keepUserFiles: + case SharedInfo.UpdateDirectoryNew: + case SharedInfo.UpdateDirectoryOld: + // Files in those constant directories we want to keep in their current place + continue; + default: + // If we're moving files deeper into source location, we need to skip the newly created location from it + if (!string.IsNullOrEmpty(targetRelativeDirectoryPath) && ((relativeDirectoryName == targetRelativeDirectoryPath) || RelativeDirectoryStartsWith(relativeDirectoryName, targetRelativeDirectoryPath))) { + continue; + } + + // Below code block should match the case above, it handles subdirectories + if (RelativeDirectoryStartsWith(relativeDirectoryName, SharedInfo.UpdateDirectoryNew, SharedInfo.UpdateDirectoryOld)) { + continue; + } + + if (keepUserFiles && RelativeDirectoryStartsWith(relativeDirectoryName, SharedInfo.ArchivalLogsDirectory, SharedInfo.ConfigDirectory, SharedInfo.DebugDirectory, SharedInfo.PluginsDirectory)) { + continue; + } + + break; + } + + // We're going to move this file out of the current place, overwriting existing one if needed + string targetUpdateDirectory; + + if (relativeDirectoryName.Length > 0) { + // File inside a subdirectory + targetUpdateDirectory = Path.Combine(targetDirectory, relativeDirectoryName); + + Directory.CreateDirectory(targetUpdateDirectory); + } else { + // File in root directory + targetUpdateDirectory = targetDirectory; + } + + string targetUpdateFile = Path.Combine(targetUpdateDirectory, fileName); + + File.Move(file, targetUpdateFile, true); } } @@ -518,8 +562,6 @@ private static bool RelativeDirectoryStartsWith(string directory, params string[ throw new ArgumentNullException(nameof(prefixes)); } - HashSet separators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; - - return prefixes.Where(prefix => (directory.Length > prefix.Length) && separators.Contains(directory[prefix.Length])).Any(prefix => directory.StartsWith(prefix, StringComparison.Ordinal)); + return prefixes.Any(prefix => !string.IsNullOrEmpty(prefix) && (directory.Length > prefix.Length) && DirectorySeparators.Contains(directory[prefix.Length]) && directory.StartsWith(prefix, StringComparison.Ordinal)); } } diff --git a/ArchiSteamFarm/NLog/Logging.cs b/ArchiSteamFarm/NLog/Logging.cs index 61a884711f98f..0275c62a76d31 100644 --- a/ArchiSteamFarm/NLog/Logging.cs +++ b/ArchiSteamFarm/NLog/Logging.cs @@ -183,9 +183,7 @@ internal static void InitCoreLoggers(bool uniqueInstance) { if (uniqueInstance) { try { - if (!Directory.Exists(SharedInfo.ArchivalLogsDirectory)) { - Directory.CreateDirectory(SharedInfo.ArchivalLogsDirectory); - } + Directory.CreateDirectory(SharedInfo.ArchivalLogsDirectory); } catch (Exception e) { ASF.ArchiLogger.LogGenericException(e); } diff --git a/ArchiSteamFarm/Plugins/PluginsCore.cs b/ArchiSteamFarm/Plugins/PluginsCore.cs index e91b1665b5ee0..bf9845368a614 100644 --- a/ArchiSteamFarm/Plugins/PluginsCore.cs +++ b/ArchiSteamFarm/Plugins/PluginsCore.cs @@ -766,9 +766,31 @@ internal static async Task UpdatePlugins(Version asfVersion, bool asfUpdat try { foreach (string assemblyPath in Directory.EnumerateFiles(path, "*.dll", SearchOption.AllDirectories)) { - string? assemblyDirectoryName = Path.GetFileName(Path.GetDirectoryName(assemblyPath)); + string? assemblyDirectory = Path.GetDirectoryName(assemblyPath); - if (assemblyDirectoryName == SharedInfo.UpdateDirectory) { + if (string.IsNullOrEmpty(assemblyDirectory)) { + throw new InvalidOperationException(nameof(assemblyDirectory)); + } + + // Skip from loading those files that come from update directories + // We determine that by checking if any directory name along the path to the assembly matches + bool skip = false; + + string? relativeAssemblyDirectory = Path.GetRelativePath(path, assemblyDirectory); + string? relativeAssemblyDirectoryName = Path.GetFileName(relativeAssemblyDirectory); + + while (!string.IsNullOrEmpty(relativeAssemblyDirectoryName)) { + if (relativeAssemblyDirectoryName is SharedInfo.UpdateDirectoryOld or SharedInfo.UpdateDirectoryNew) { + skip = true; + + break; + } + + relativeAssemblyDirectory = Path.GetDirectoryName(relativeAssemblyDirectory); + relativeAssemblyDirectoryName = Path.GetFileName(relativeAssemblyDirectory); + } + + if (skip) { ASF.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningSkipping, assemblyPath)); continue; @@ -822,12 +844,9 @@ private static async Task UpdatePlugin(Version asfVersion, bool asfUpdate, throw new InvalidOperationException(nameof(assemblyDirectory)); } - string backupDirectory = Path.Combine(assemblyDirectory, SharedInfo.UpdateDirectory); - - if (Directory.Exists(backupDirectory)) { - ASF.ArchiLogger.LogGenericInfo(Strings.UpdateCleanup); - - Directory.Delete(backupDirectory, true); + // If directories from previous update exist, it's a good idea to purge them now + if (!await Utilities.UpdateCleanup(assemblyDirectory).ConfigureAwait(false)) { + return false; } Uri? releaseURL = await plugin.GetTargetReleaseURL(asfVersion, SharedInfo.BuildInfo.Variant, asfUpdate, updateChannel, forced).ConfigureAwait(false); @@ -871,7 +890,7 @@ private static async Task UpdatePlugin(Version asfVersion, bool asfUpdate, await plugin.OnPluginUpdateProceeding().ConfigureAwait(false); - if (!Utilities.UpdateFromArchive(zipArchive, assemblyDirectory)) { + if (!await Utilities.UpdateFromArchive(zipArchive, assemblyDirectory).ConfigureAwait(false)) { ASF.ArchiLogger.LogGenericError(Strings.WarningFailed); return false; diff --git a/ArchiSteamFarm/Program.cs b/ArchiSteamFarm/Program.cs index 3891486c3f32c..bc372bf70b79f 100644 --- a/ArchiSteamFarm/Program.cs +++ b/ArchiSteamFarm/Program.cs @@ -415,31 +415,21 @@ private static async Task InitGlobalDatabaseAndServices() { // If debugging is on, we prepare debug directory prior to running if (Debugging.IsUserDebugging) { - if (Debugging.IsDebugConfigured) { - ASF.ArchiLogger.LogGenericDebug($"{globalDatabaseFile}: {globalDatabase.ToJsonText(true)}"); - } - Logging.EnableTraceLogging(); if (Debugging.IsDebugConfigured) { + ASF.ArchiLogger.LogGenericDebug($"{globalDatabaseFile}: {globalDatabase.ToJsonText(true)}"); + DebugLog.AddListener(new Debugging.DebugListener()); + DebugLog.Enabled = true; - if (Directory.Exists(SharedInfo.DebugDirectory)) { - try { - Directory.Delete(SharedInfo.DebugDirectory, true); - await Task.Delay(1000).ConfigureAwait(false); // Dirty workaround giving Windows some time to sync - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } + try { + Directory.CreateDirectory(ASF.DebugDirectory); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); } } - - try { - Directory.CreateDirectory(SharedInfo.DebugDirectory); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } } WebBrowser.Init(); diff --git a/ArchiSteamFarm/SharedInfo.cs b/ArchiSteamFarm/SharedInfo.cs index 1bb235540ae01..990ac7587e2ef 100644 --- a/ArchiSteamFarm/SharedInfo.cs +++ b/ArchiSteamFarm/SharedInfo.cs @@ -68,7 +68,8 @@ public static class SharedInfo { internal const string ProjectURL = $"https://github.com/{GithubRepo}"; internal const ushort ShortInformationDelay = InformationDelay / 2; internal const string UlongCompatibilityStringPrefix = "s_"; - internal const string UpdateDirectory = "_old"; + internal const string UpdateDirectoryNew = "_new"; + internal const string UpdateDirectoryOld = "_old"; internal const string WebsiteDirectory = "www"; [PublicAPI] diff --git a/ArchiSteamFarm/Steam/Bot.cs b/ArchiSteamFarm/Steam/Bot.cs index 1e45c7894e73c..e8ea8bd810b71 100644 --- a/ArchiSteamFarm/Steam/Bot.cs +++ b/ArchiSteamFarm/Steam/Bot.cs @@ -354,11 +354,12 @@ private Bot(string botName, BotConfig botConfig, BotDatabase botDatabase) { // Initialize SteamClient = new SteamClient(SteamConfiguration, botName); - if (Debugging.IsDebugConfigured && Directory.Exists(SharedInfo.DebugDirectory)) { - string debugListenerPath = Path.Combine(SharedInfo.DebugDirectory, botName); + if (Debugging.IsDebugConfigured && Directory.Exists(ASF.DebugDirectory)) { + string debugListenerPath = Path.Combine(ASF.DebugDirectory, botName); try { Directory.CreateDirectory(debugListenerPath); + SteamClient.DebugNetworkListener = new NetHookNetworkListener(debugListenerPath, SteamClient); } catch (Exception e) { ArchiLogger.LogGenericException(e);