diff --git a/ArchiSteamFarm/Core/ASF.cs b/ArchiSteamFarm/Core/ASF.cs index 033ec03d2..9d4b3ae56 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 @@ public static class ASF { 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 @@ public static class ASF { // 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 bbd909407..dcca3035a 100644 --- a/ArchiSteamFarm/Core/Utilities.cs +++ b/ArchiSteamFarm/Core/Utilities.cs @@ -50,8 +50,12 @@ using Zxcvbn; 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 @@ public static class Utilities { 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) { + internal static async Task UpdateCleanup(string targetDirectory) { + ArgumentException.ThrowIfNullOrEmpty(targetDirectory); + + bool updateCleanup = false; + + try { + string updateDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryNew); + + if (Directory.Exists(updateDirectory)) { + if (!updateCleanup) { + updateCleanup = true; + + ASF.ArchiLogger.LogGenericInfo(Strings.UpdateCleanup); + } + + Directory.Delete(updateDirectory, true); + } + + string backupDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectoryOld); + + if (Directory.Exists(backupDirectory)) { + if (!updateCleanup) { + updateCleanup = true; + + ASF.ArchiLogger.LogGenericInfo(Strings.UpdateCleanup); + } + + await DeletePotentiallyUsedDirectory(backupDirectory).ConfigureAwait(false); + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return false; + } + + if (updateCleanup) { + ASF.ArchiLogger.LogGenericInfo(Strings.Done); + } + + return true; + } + + internal static async Task UpdateFromArchive(ZipArchive zipArchive, string targetDirectory) { ArgumentNullException.ThrowIfNull(zipArchive); 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); - - return false; - } - - string relativeFilePath = Path.GetRelativePath(targetDirectory, file); - - if (string.IsNullOrEmpty(relativeFilePath)) { - ASF.ArchiLogger.LogNullError(relativeFilePath); - - return false; - } - - string? relativeDirectoryName = Path.GetDirectoryName(relativeFilePath); - - switch (relativeDirectoryName) { - case null: - ASF.ArchiLogger.LogNullError(relativeDirectoryName); - - 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; - } - - 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; - } - - break; - } - - string targetBackupDirectory = relativeDirectoryName.Length > 0 ? Path.Combine(backupDirectory, relativeDirectoryName) : backupDirectory; - Directory.CreateDirectory(targetBackupDirectory); - - string targetBackupFile = Path.Combine(targetBackupDirectory, fileName); - - File.Move(file, targetBackupFile, true); + // Firstly, ensure once again our directories are purged and ready to work with + if (!await UpdateCleanup(targetDirectory).ConfigureAwait(false)) { + return false; } - // We can now get rid of directories that are empty - DeleteEmptyDirectoriesRecursively(targetDirectory); + // 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); - if (!Directory.Exists(targetDirectory)) { - Directory.CreateDirectory(targetDirectory); - } + zipArchive.ExtractToDirectory(updateDirectory, true); - // 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)); + // 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); - if (!file.StartsWith(targetDirectory, StringComparison.Ordinal)) { - throw new InvalidOperationException(nameof(file)); - } + Directory.CreateDirectory(backupDirectory); - 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"; + MoveAllUpdateFiles(targetDirectory, backupDirectory, true); - File.Move(file, targetBackupFile, true); - } + // Finally, we can move the newly extracted files to target directory + MoveAllUpdateFiles(updateDirectory, targetDirectory, false); - // Check if this file requires its own folder - if (zipFile.Name != zipFile.FullName) { - string? directory = Path.GetDirectoryName(file); - - if (string.IsNullOrEmpty(directory)) { - ASF.ArchiLogger.LogNullError(directory); - - return false; - } - - if (!Directory.Exists(directory)) { - Directory.CreateDirectory(directory); - } - - // We're not interested in extracting placeholder files (but we still want directories created for them, done above) - switch (zipFile.Name) { - case ".gitkeep": - continue; - } - } - - 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 @@ public static class Utilities { } } - 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 @@ public static class Utilities { 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 61a884711..0275c62a7 100644 --- a/ArchiSteamFarm/NLog/Logging.cs +++ b/ArchiSteamFarm/NLog/Logging.cs @@ -183,9 +183,7 @@ internal static class Logging { 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 e91b1665b..bf9845368 100644 --- a/ArchiSteamFarm/Plugins/PluginsCore.cs +++ b/ArchiSteamFarm/Plugins/PluginsCore.cs @@ -766,9 +766,31 @@ public static class PluginsCore { 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 @@ public static class PluginsCore { 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 @@ public static class PluginsCore { 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 3891486c3..bc372bf70 100644 --- a/ArchiSteamFarm/Program.cs +++ b/ArchiSteamFarm/Program.cs @@ -415,31 +415,21 @@ internal static class Program { // 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 1bb235540..990ac7587 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 1e45c7894..e8ea8bd81 100644 --- a/ArchiSteamFarm/Steam/Bot.cs +++ b/ArchiSteamFarm/Steam/Bot.cs @@ -354,11 +354,12 @@ public sealed class Bot : IAsyncDisposable, IDisposable { // 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);