From aedede3ba4bf525bfab9c3d5dfaefd634f410bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Domeradzki?= Date: Sat, 16 Mar 2024 23:56:57 +0100 Subject: [PATCH] Implement plugin updates with `IPluginUpdates` interface (#3151) * Initial implementation of plugin updates * Update PluginsCore.cs * Update IPluginUpdates.cs * Update PluginsCore.cs * Make it work * Misc * Revert "Misc" This reverts commit bccd1bb2b887f9fdd4e406b14c7e020295a18818. * Proper fix * Make plugin updates independent of GitHub * Final touches * Misc * Allow plugin creators for more flexibility in picking from GitHub releases * Misc rename * Make changelog internal again This is ASF implementation detail, make body available instead and let people implement changelogs themselves * Misc * Add missing localization * Add a way to disable plugin updates * Update PluginsCore.cs * Update PluginsCore.cs * Misc * Update IGitHubPluginUpdates.cs * Update IGitHubPluginUpdates.cs * Update IGitHubPluginUpdates.cs * Update IGitHubPluginUpdates.cs * Make zip selection ignore case * Update ArchiSteamFarm/Core/Utilities.cs Co-authored-by: Vita Chumakova * Misc error notify * Add commands and finally call it a day * Misc progress percentages text * Misc * Flip DefaultPluginsUpdateMode as per the voting * Misc --------- Co-authored-by: Vita Chumakova --- ArchiSteamFarm/Core/ASF.cs | 533 ++++++++---------- ArchiSteamFarm/Core/Utilities.cs | 195 ++++++- ArchiSteamFarm/IPC/ArchiKestrel.cs | 13 +- .../IPC/Controllers/Api/GitHubController.cs | 24 +- .../IPC/Responses/GitHubReleaseResponse.cs | 14 +- .../Localization/Strings.Designer.cs | 72 +++ ArchiSteamFarm/Localization/Strings.resx | 46 ++ .../Interfaces/IGitHubPluginUpdates.cs | 186 ++++++ .../Plugins/Interfaces/IPluginUpdates.cs | 56 ++ ArchiSteamFarm/Plugins/PluginsCore.cs | 216 ++++++- ArchiSteamFarm/SharedInfo.cs | 11 +- ArchiSteamFarm/Steam/Interaction/Actions.cs | 51 +- ArchiSteamFarm/Steam/Interaction/Commands.cs | 46 +- ArchiSteamFarm/Storage/GlobalConfig.cs | 39 +- .../Web/GitHub/Data/ReleaseAsset.cs | 49 ++ .../Web/GitHub/Data/ReleaseResponse.cs | 152 +++++ .../{GitHub.cs => GitHub/GitHubService.cs} | 189 ++----- ArchiSteamFarm/Web/WebBrowser.cs | 22 +- 18 files changed, 1358 insertions(+), 556 deletions(-) create mode 100644 ArchiSteamFarm/Plugins/Interfaces/IGitHubPluginUpdates.cs create mode 100644 ArchiSteamFarm/Plugins/Interfaces/IPluginUpdates.cs create mode 100644 ArchiSteamFarm/Web/GitHub/Data/ReleaseAsset.cs create mode 100644 ArchiSteamFarm/Web/GitHub/Data/ReleaseResponse.cs rename ArchiSteamFarm/Web/{GitHub.cs => GitHub/GitHubService.cs} (56%) diff --git a/ArchiSteamFarm/Core/ASF.cs b/ArchiSteamFarm/Core/ASF.cs index 2a1d4e1eb07d7..c94226d71c252 100644 --- a/ArchiSteamFarm/Core/ASF.cs +++ b/ArchiSteamFarm/Core/ASF.cs @@ -1,18 +1,20 @@ +// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | +// ---------------------------------------------------------------------------------------------- +// // Copyright 2015-2024 Ł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. @@ -41,6 +43,8 @@ using ArchiSteamFarm.Steam.Integration; using ArchiSteamFarm.Storage; using ArchiSteamFarm.Web; +using ArchiSteamFarm.Web.GitHub; +using ArchiSteamFarm.Web.GitHub.Data; using ArchiSteamFarm.Web.Responses; using JetBrains.Annotations; using SteamKit2; @@ -113,6 +117,10 @@ internal static async Task Init() { WebBrowser = new WebBrowser(ArchiLogger, GlobalConfig.WebProxy, true); + if (!await PluginsCore.InitPlugins().ConfigureAwait(false)) { + return false; + } + await UpdateAndRestart().ConfigureAwait(false); if (!Program.IgnoreUnsupportedEnvironment && !await ProtectAgainstCrashes().ConfigureAwait(false)) { @@ -123,10 +131,6 @@ internal static async Task Init() { Program.AllowCrashFileRemoval = true; - if (!await PluginsCore.InitPlugins().ConfigureAwait(false)) { - return false; - } - await PluginsCore.OnASFInitModules(GlobalConfig.AdditionalProperties).ConfigureAwait(false); await InitRateLimiters().ConfigureAwait(false); @@ -185,212 +189,25 @@ internal static async Task RestartOrExit() { } } - internal static async Task Update(GlobalConfig.EUpdateChannel? channel = null, bool updateOverride = false) { - if (channel.HasValue && !Enum.IsDefined(channel.Value)) { - throw new InvalidEnumArgumentException(nameof(channel), (int) channel, typeof(GlobalConfig.EUpdateChannel)); + internal static async Task<(Version? NewVersion, bool RestartNeeded)> Update(GlobalConfig.EUpdateChannel? updateChannel = null, bool updateOverride = false) { + if (updateChannel.HasValue && !Enum.IsDefined(updateChannel.Value)) { + throw new InvalidEnumArgumentException(nameof(updateChannel), (int) updateChannel, typeof(GlobalConfig.EUpdateChannel)); } if (GlobalConfig == null) { throw new InvalidOperationException(nameof(GlobalConfig)); } - if (WebBrowser == null) { - throw new InvalidOperationException(nameof(WebBrowser)); - } + Version? newVersion = await UpdateASF(updateChannel, updateOverride).ConfigureAwait(false); - channel ??= GlobalConfig.UpdateChannel; + bool restartNeeded = newVersion > SharedInfo.Version; - if (!SharedInfo.BuildInfo.CanUpdate || (channel == GlobalConfig.EUpdateChannel.None)) { - return null; + if (!restartNeeded) { + // ASF wasn't updated as part of the process, update the plugins alone + restartNeeded = await PluginsCore.UpdatePlugins(SharedInfo.Version, updateChannel).ConfigureAwait(false); } - 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 null; - } - - ArchiLogger.LogGenericInfo(Strings.Done); - } - - ArchiLogger.LogGenericInfo(Strings.UpdateCheckingNewVersion); - - GitHub.ReleaseResponse? releaseResponse = await GitHub.GetLatestRelease(channel == GlobalConfig.EUpdateChannel.Stable).ConfigureAwait(false); - - if (releaseResponse == null) { - ArchiLogger.LogGenericWarning(Strings.ErrorUpdateCheckFailed); - - return null; - } - - if (string.IsNullOrEmpty(releaseResponse.Tag)) { - ArchiLogger.LogGenericWarning(Strings.ErrorUpdateCheckFailed); - - return null; - } - - Version newVersion = new(releaseResponse.Tag); - - ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.UpdateVersionInfo, SharedInfo.Version, newVersion)); - - if (SharedInfo.Version >= newVersion) { - return newVersion; - } - - if (!updateOverride && (GlobalConfig.UpdatePeriod == 0)) { - ArchiLogger.LogGenericInfo(Strings.UpdateNewVersionAvailable); - await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); - - return null; - } - - // Auto update logic starts here - if (releaseResponse.Assets.IsEmpty) { - ArchiLogger.LogGenericWarning(Strings.ErrorUpdateNoAssets); - - return null; - } - - string targetFile = $"{SharedInfo.ASF}-{SharedInfo.BuildInfo.Variant}.zip"; - GitHub.ReleaseResponse.Asset? binaryAsset = releaseResponse.Assets.FirstOrDefault(asset => !string.IsNullOrEmpty(asset.Name) && asset.Name.Equals(targetFile, StringComparison.OrdinalIgnoreCase)); - - if (binaryAsset == null) { - ArchiLogger.LogGenericWarning(Strings.ErrorUpdateNoAssetForThisVersion); - - return null; - } - - if (binaryAsset.DownloadURL == null) { - ArchiLogger.LogNullError(binaryAsset.DownloadURL); - - return null; - } - - ArchiLogger.LogGenericInfo(Strings.FetchingChecksumFromRemoteServer); - - string? remoteChecksum = await ArchiNet.FetchBuildChecksum(newVersion, SharedInfo.BuildInfo.Variant).ConfigureAwait(false); - - switch (remoteChecksum) { - case null: - // Timeout or error, refuse to update as a security measure - return null; - case "": - // Unknown checksum, release too new or actual malicious build published, no need to scare the user as it's 99.99% the first - ArchiLogger.LogGenericWarning(Strings.ChecksumMissing); - - return SharedInfo.Version; - } - - if (!string.IsNullOrEmpty(releaseResponse.ChangelogPlainText)) { - ArchiLogger.LogGenericInfo(releaseResponse.ChangelogPlainText); - } - - ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.UpdateDownloadingNewVersion, newVersion, binaryAsset.Size / 1024 / 1024)); - - Progress progressReporter = new(); - - progressReporter.ProgressChanged += OnProgressChanged; - - BinaryResponse? response; - - try { - response = await WebBrowser.UrlGetToBinary(binaryAsset.DownloadURL, progressReporter: progressReporter).ConfigureAwait(false); - } finally { - progressReporter.ProgressChanged -= OnProgressChanged; - } - - if (response?.Content == null) { - return null; - } - - ArchiLogger.LogGenericInfo(Strings.VerifyingChecksumWithRemoteServer); - - byte[] responseBytes = response.Content as byte[] ?? response.Content.ToArray(); - - string checksum = Utilities.GenerateChecksumFor(responseBytes); - - if (!checksum.Equals(remoteChecksum, StringComparison.OrdinalIgnoreCase)) { - ArchiLogger.LogGenericError(Strings.ChecksumWrong); - - return SharedInfo.Version; - } - - await PluginsCore.OnUpdateProceeding(newVersion).ConfigureAwait(false); - - bool kestrelWasRunning = ArchiKestrel.IsRunning; - - if (kestrelWasRunning) { - // We disable ArchiKestrel here as the update process moves the core files and might result in IPC crash - // 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 - try { - await ArchiKestrel.Stop().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - } - } - - ArchiLogger.LogGenericInfo(Strings.PatchingFiles); - - MemoryStream ms = new(responseBytes); - - try { - await using (ms.ConfigureAwait(false)) { - using ZipArchive zipArchive = new(ms); - - if (!UpdateFromArchive(zipArchive, SharedInfo.HomeDirectory)) { - ArchiLogger.LogGenericError(Strings.WarningFailed); - } - } - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - - if (kestrelWasRunning) { - // We've temporarily disabled ArchiKestrel but the update has failed, let's bring it back up - // We can't even be sure if it's possible to bring it back up in this state, but it's worth trying anyway - try { - await ArchiKestrel.Start().ConfigureAwait(false); - } catch (Exception ex) { - ArchiLogger.LogGenericWarningException(ex); - } - } - - return null; - } - - ArchiLogger.LogGenericInfo(Strings.UpdateFinished); - - await PluginsCore.OnUpdateFinished(newVersion).ConfigureAwait(false); - - return newVersion; - } finally { - UpdateSemaphore.Release(); - } + return (newVersion, restartNeeded); } private static async Task CanHandleWriteEvent(string filePath) { @@ -800,16 +617,6 @@ private static async Task OnDeletedJsonConfigFile(string name, string fullPath) } } - private static void OnProgressChanged(object? sender, byte progressPercentage) { - const byte printEveryPercentage = 10; - - if (progressPercentage % printEveryPercentage != 0) { - return; - } - - ArchiLogger.LogGenericDebug($"{progressPercentage}%..."); - } - private static async void OnRenamed(object sender, RenamedEventArgs e) { // This function can be called with a possibility of OldName or (new) Name being null, we have to take it into account ArgumentNullException.ThrowIfNull(sender); @@ -915,7 +722,7 @@ private static async Task UpdateAndRestart() { throw new InvalidOperationException(nameof(GlobalConfig)); } - if (!SharedInfo.BuildInfo.CanUpdate || (GlobalConfig.UpdateChannel == GlobalConfig.EUpdateChannel.None)) { + if (GlobalConfig.UpdateChannel == GlobalConfig.EUpdateChannel.None) { return; } @@ -932,150 +739,260 @@ private static async Task UpdateAndRestart() { ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.AutoUpdateCheckInfo, autoUpdatePeriod.ToHumanReadable())); } - Version? newVersion = await Update().ConfigureAwait(false); + (Version? newVersion, bool restartNeeded) = await Update().ConfigureAwait(false); - if (newVersion == null) { - return; + if (SharedInfo.Version > newVersion) { + // User is running version newer than their channel allows + ArchiLogger.LogGenericWarning(Strings.WarningPreReleaseVersion); + await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); } - if (SharedInfo.Version >= newVersion) { - if (SharedInfo.Version > newVersion) { - ArchiLogger.LogGenericWarning(Strings.WarningPreReleaseVersion); - await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); - } - + if (!restartNeeded) { return; } // Allow crash file recovery, if needed - Program.AllowCrashFileRemoval = true; + if (newVersion > SharedInfo.Version) { + Program.AllowCrashFileRemoval = true; + } await RestartOrExit().ConfigureAwait(false); } - private static bool UpdateFromArchive(ZipArchive archive, string targetDirectory) { - ArgumentNullException.ThrowIfNull(archive); - ArgumentException.ThrowIfNullOrEmpty(targetDirectory); - - if (SharedInfo.HomeDirectory == AppContext.BaseDirectory) { - // We're running a build that includes our dependencies in ASF's home - // 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 - LoadAllAssemblies(); - } else { - // 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... - // 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 - // 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 - LoadAssembliesNeededBeforeUpdate(); + private static async Task UpdateASF(GlobalConfig.EUpdateChannel? channel = null, bool updateOverride = false) { + if (channel.HasValue && !Enum.IsDefined(channel.Value)) { + throw new InvalidEnumArgumentException(nameof(channel), (int) channel, typeof(GlobalConfig.EUpdateChannel)); } - // 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 (GlobalConfig == null) { + throw new InvalidOperationException(nameof(GlobalConfig)); + } - if (string.IsNullOrEmpty(fileName)) { - ArchiLogger.LogNullError(fileName); + if (WebBrowser == null) { + throw new InvalidOperationException(nameof(WebBrowser)); + } - return false; - } + channel ??= GlobalConfig.UpdateChannel; - string relativeFilePath = Path.GetRelativePath(targetDirectory, file); + if (!SharedInfo.BuildInfo.CanUpdate || (channel == GlobalConfig.EUpdateChannel.None)) { + return null; + } - if (string.IsNullOrEmpty(relativeFilePath)) { - ArchiLogger.LogNullError(relativeFilePath); + string targetFile; - return false; - } + await UpdateSemaphore.WaitAsync().ConfigureAwait(false); - string? relativeDirectoryName = Path.GetDirectoryName(relativeFilePath); + 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); - switch (relativeDirectoryName) { - case null: - ArchiLogger.LogNullError(relativeDirectoryName); + if (Directory.Exists(backupDirectory)) { + ArchiLogger.LogGenericInfo(Strings.UpdateCleanup); - 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; + 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); } - 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 (Utilities.RelativeDirectoryStartsWith(relativeDirectoryName, SharedInfo.ArchivalLogsDirectory, SharedInfo.ConfigDirectory, SharedInfo.DebugDirectory, SharedInfo.PluginsDirectory, SharedInfo.UpdateDirectory)) { + try { + Directory.Delete(backupDirectory, true); + } catch (Exception e) { + ArchiLogger.LogGenericDebuggingException(e); + continue; } break; + } + + if (Directory.Exists(backupDirectory)) { + ArchiLogger.LogGenericError(Strings.WarningFailed); + + return null; + } + + ArchiLogger.LogGenericInfo(Strings.Done); } - string targetBackupDirectory = relativeDirectoryName.Length > 0 ? Path.Combine(backupDirectory, relativeDirectoryName) : backupDirectory; - Directory.CreateDirectory(targetBackupDirectory); + ArchiLogger.LogGenericInfo(Strings.UpdateCheckingNewVersion); - string targetBackupFile = Path.Combine(targetBackupDirectory, fileName); + ReleaseResponse? releaseResponse = await GitHubService.GetLatestRelease(SharedInfo.GithubRepo, channel == GlobalConfig.EUpdateChannel.Stable).ConfigureAwait(false); - File.Move(file, targetBackupFile, true); - } + if (releaseResponse == null) { + ArchiLogger.LogGenericWarning(Strings.ErrorUpdateCheckFailed); - // We can now get rid of directories that are empty - Utilities.DeleteEmptyDirectoriesRecursively(targetDirectory); + return null; + } - if (!Directory.Exists(targetDirectory)) { - Directory.CreateDirectory(targetDirectory); - } + if (string.IsNullOrEmpty(releaseResponse.Tag)) { + ArchiLogger.LogGenericWarning(Strings.ErrorUpdateCheckFailed); + + return null; + } + + Version newVersion = new(releaseResponse.Tag); + + ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.UpdateVersionInfo, SharedInfo.Version, newVersion)); + + if (SharedInfo.Version >= newVersion) { + return newVersion; + } + + if (!updateOverride && (GlobalConfig.UpdatePeriod == 0)) { + ArchiLogger.LogGenericInfo(Strings.UpdateNewVersionAvailable); + await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); + + return null; + } + + // Auto update logic starts here + if (releaseResponse.Assets.IsEmpty) { + ArchiLogger.LogGenericWarning(Strings.ErrorUpdateNoAssets); + + return null; + } + + targetFile = $"{SharedInfo.ASF}-{SharedInfo.BuildInfo.Variant}.zip"; + ReleaseAsset? binaryAsset = releaseResponse.Assets.FirstOrDefault(asset => !string.IsNullOrEmpty(asset.Name) && asset.Name.Equals(targetFile, StringComparison.OrdinalIgnoreCase)); + + if (binaryAsset == null) { + ArchiLogger.LogGenericWarning(Strings.ErrorUpdateNoAssetForThisVersion); + + return null; + } - // 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 archive.Entries.Where(static zipFile => !string.IsNullOrEmpty(zipFile.Name))) { - string file = Path.GetFullPath(Path.Combine(targetDirectory, zipFile.FullName)); + ArchiLogger.LogGenericInfo(Strings.FetchingChecksumFromRemoteServer); + + string? remoteChecksum = await ArchiNet.FetchBuildChecksum(newVersion, SharedInfo.BuildInfo.Variant).ConfigureAwait(false); + + switch (remoteChecksum) { + case null: + // Timeout or error, refuse to update as a security measure + return null; + case "": + // Unknown checksum, release too new or actual malicious build published, no need to scare the user as it's 99.99% the first + ArchiLogger.LogGenericWarning(Strings.ChecksumMissing); + + return SharedInfo.Version; + } + + if (!string.IsNullOrEmpty(releaseResponse.ChangelogPlainText)) { + ArchiLogger.LogGenericInfo(releaseResponse.ChangelogPlainText); + } + + ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.UpdateDownloadingNewVersion, newVersion, binaryAsset.Size / 1024 / 1024)); + + Progress progressReporter = new(); + + progressReporter.ProgressChanged += onProgressChanged; + + BinaryResponse? response; - if (!file.StartsWith(targetDirectory, StringComparison.Ordinal)) { - throw new InvalidOperationException(nameof(file)); + try { + response = await WebBrowser.UrlGetToBinary(binaryAsset.DownloadURL, progressReporter: progressReporter).ConfigureAwait(false); + } finally { + progressReporter.ProgressChanged -= onProgressChanged; } - 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"; + if (response?.Content == null) { + return null; + } - File.Move(file, targetBackupFile, true); + ArchiLogger.LogGenericInfo(Strings.VerifyingChecksumWithRemoteServer); + + byte[] responseBytes = response.Content as byte[] ?? response.Content.ToArray(); + + string checksum = Utilities.GenerateChecksumFor(responseBytes); + + if (!checksum.Equals(remoteChecksum, StringComparison.OrdinalIgnoreCase)) { + ArchiLogger.LogGenericError(Strings.ChecksumWrong); + + return SharedInfo.Version; } - // Check if this file requires its own folder - if (zipFile.Name != zipFile.FullName) { - string? directory = Path.GetDirectoryName(file); + await PluginsCore.OnUpdateProceeding(newVersion).ConfigureAwait(false); - if (string.IsNullOrEmpty(directory)) { - ArchiLogger.LogNullError(directory); + bool kestrelWasRunning = ArchiKestrel.IsRunning; - return false; + if (kestrelWasRunning) { + // We disable ArchiKestrel here as the update process moves the core files and might result in IPC crash + // 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 + try { + await ArchiKestrel.Stop().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); } + } + + ArchiLogger.LogGenericInfo(Strings.PatchingFiles); - if (!Directory.Exists(directory)) { - Directory.CreateDirectory(directory); + try { + MemoryStream memoryStream = new(responseBytes); + + await using (memoryStream.ConfigureAwait(false)) { + using ZipArchive zipArchive = new(memoryStream); + + if (!await UpdateFromArchive(newVersion, channel.Value, zipArchive).ConfigureAwait(false)) { + ArchiLogger.LogGenericError(Strings.WarningFailed); + } } + } catch (Exception e) { + ArchiLogger.LogGenericException(e); - // We're not interested in extracting placeholder files (but we still want directories created for them, done above) - switch (zipFile.Name) { - case ".gitkeep": - continue; + if (kestrelWasRunning) { + // We've temporarily disabled ArchiKestrel but the update has failed, let's bring it back up + // We can't even be sure if it's possible to bring it back up in this state, but it's worth trying anyway + try { + await ArchiKestrel.Start().ConfigureAwait(false); + } catch (Exception ex) { + ArchiLogger.LogGenericWarningException(ex); + } } + + return null; } - zipFile.ExtractToFile(file); + ArchiLogger.LogGenericInfo(Strings.UpdateFinished); + + await PluginsCore.OnUpdateFinished(newVersion).ConfigureAwait(false); + + return newVersion; + } finally { + UpdateSemaphore.Release(); } - return true; + void onProgressChanged(object? sender, byte progressPercentage) { + ArgumentOutOfRangeException.ThrowIfGreaterThan(progressPercentage, 100); + + Utilities.OnProgressChanged(targetFile, progressPercentage); + } + } + + private static async Task UpdateFromArchive(Version newVersion, GlobalConfig.EUpdateChannel updateChannel, ZipArchive zipArchive) { + ArgumentNullException.ThrowIfNull(newVersion); + + if (!Enum.IsDefined(updateChannel)) { + throw new InvalidEnumArgumentException(nameof(updateChannel), (int) updateChannel, typeof(GlobalConfig.EUpdateChannel)); + } + + ArgumentNullException.ThrowIfNull(zipArchive); + + if (SharedInfo.HomeDirectory == AppContext.BaseDirectory) { + // We're running a build that includes our dependencies in ASF's home + // 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 + LoadAllAssemblies(); + } else { + // 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... + // 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 + // 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 + LoadAssembliesNeededBeforeUpdate(); + } + + // We're ready to start update process, handle any plugin updates ready for new version + await PluginsCore.UpdatePlugins(newVersion, updateChannel).ConfigureAwait(false); + + return Utilities.UpdateFromArchive(zipArchive, SharedInfo.HomeDirectory); } [PublicAPI] diff --git a/ArchiSteamFarm/Core/Utilities.cs b/ArchiSteamFarm/Core/Utilities.cs index acecc40561d18..1f806827e4cf8 100644 --- a/ArchiSteamFarm/Core/Utilities.cs +++ b/ArchiSteamFarm/Core/Utilities.cs @@ -1,18 +1,20 @@ +// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | +// ---------------------------------------------------------------------------------------------- +// // Copyright 2015-2024 Ł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. @@ -26,6 +28,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; +using System.IO.Compression; using System.Linq; using System.Net; using System.Resources; @@ -35,6 +38,7 @@ using AngleSharp.Dom; using AngleSharp.XPath; using ArchiSteamFarm.Localization; +using ArchiSteamFarm.NLog; using ArchiSteamFarm.Storage; using Humanizer; using Humanizer.Localisation; @@ -263,26 +267,6 @@ public static bool TryReadJsonWebToken(string token, [NotNullWhen(true)] out Jso return true; } - internal static void DeleteEmptyDirectoriesRecursively(string directory) { - ArgumentException.ThrowIfNullOrEmpty(directory); - - if (!Directory.Exists(directory)) { - return; - } - - try { - foreach (string subDirectory in Directory.EnumerateDirectories(directory)) { - DeleteEmptyDirectoriesRecursively(subDirectory); - } - - if (!Directory.EnumerateFileSystemEntries(directory).Any()) { - Directory.Delete(directory); - } - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - internal static ulong MathAdd(ulong first, int second) { if (second >= 0) { return first + (uint) second; @@ -291,16 +275,17 @@ internal static ulong MathAdd(ulong first, int second) { return first - (uint) -second; } - internal static bool RelativeDirectoryStartsWith(string directory, params string[] prefixes) { - ArgumentException.ThrowIfNullOrEmpty(directory); + internal static void OnProgressChanged(string fileName, byte progressPercentage) { + ArgumentException.ThrowIfNullOrEmpty(fileName); + ArgumentOutOfRangeException.ThrowIfGreaterThan(progressPercentage, 100); -#pragma warning disable CA1508 // False positive, params could be null when explicitly set - if ((prefixes == null) || (prefixes.Length == 0)) { -#pragma warning restore CA1508 // False positive, params could be null when explicitly set - throw new ArgumentNullException(nameof(prefixes)); + const byte printEveryPercentage = 10; + + if (progressPercentage % printEveryPercentage != 0) { + return; } - return (from prefix in prefixes where directory.Length > prefix.Length let pathSeparator = directory[prefix.Length] where (pathSeparator == Path.DirectorySeparatorChar) || (pathSeparator == Path.AltDirectorySeparatorChar) select prefix).Any(prefix => directory.StartsWith(prefix, StringComparison.Ordinal)); + ASF.ArchiLogger.LogGenericDebug($"{fileName} {progressPercentage}%..."); } internal static (bool IsWeak, string? Reason) TestPasswordStrength(string password, ISet? additionallyForbiddenPhrases = null) { @@ -337,6 +322,120 @@ 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); + 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); + } + + // We can now get rid of directories that are empty + DeleteEmptyDirectoriesRecursively(targetDirectory); + + if (!Directory.Exists(targetDirectory)) { + Directory.CreateDirectory(targetDirectory); + } + + // 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)); + + if (!file.StartsWith(targetDirectory, StringComparison.Ordinal)) { + throw new InvalidOperationException(nameof(file)); + } + + 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"; + + File.Move(file, targetBackupFile, true); + } + + // 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); + } + + return true; + } + internal static void WarnAboutIncompleteTranslation(ResourceManager resourceManager) { ArgumentNullException.ThrowIfNull(resourceManager); @@ -391,4 +490,38 @@ internal static void WarnAboutIncompleteTranslation(ResourceManager resourceMana ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.TranslationIncomplete, $"{CultureInfo.CurrentUICulture.Name} ({CultureInfo.CurrentUICulture.EnglishName})", translationCompleteness.ToString("P1", CultureInfo.CurrentCulture))); } } + + private static void DeleteEmptyDirectoriesRecursively(string directory) { + ArgumentException.ThrowIfNullOrEmpty(directory); + + if (!Directory.Exists(directory)) { + return; + } + + try { + foreach (string subDirectory in Directory.EnumerateDirectories(directory)) { + DeleteEmptyDirectoriesRecursively(subDirectory); + } + + if (!Directory.EnumerateFileSystemEntries(directory).Any()) { + Directory.Delete(directory); + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + private static bool RelativeDirectoryStartsWith(string directory, params string[] prefixes) { + ArgumentException.ThrowIfNullOrEmpty(directory); + +#pragma warning disable CA1508 // False positive, params could be null when explicitly set + if ((prefixes == null) || (prefixes.Length == 0)) { +#pragma warning restore CA1508 // False positive, params could be null when explicitly set + 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)); + } } diff --git a/ArchiSteamFarm/IPC/ArchiKestrel.cs b/ArchiSteamFarm/IPC/ArchiKestrel.cs index 69893e1289e68..1ee063355ccdc 100644 --- a/ArchiSteamFarm/IPC/ArchiKestrel.cs +++ b/ArchiSteamFarm/IPC/ArchiKestrel.cs @@ -1,18 +1,20 @@ +// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | +// ---------------------------------------------------------------------------------------------- +// // Copyright 2015-2024 Ł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. @@ -172,8 +174,7 @@ private static void ConfigureApp([SuppressMessage("ReSharper", "SuggestBaseTypeF string? assemblyDirectory = Path.GetDirectoryName(plugin.GetType().Assembly.Location); if (string.IsNullOrEmpty(assemblyDirectory)) { - // Invalid path provided - continue; + throw new InvalidOperationException(nameof(assemblyDirectory)); } physicalPath = Path.Combine(assemblyDirectory, plugin.PhysicalPath); diff --git a/ArchiSteamFarm/IPC/Controllers/Api/GitHubController.cs b/ArchiSteamFarm/IPC/Controllers/Api/GitHubController.cs index 85e4b2cd804e3..12d46086effc2 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/GitHubController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/GitHubController.cs @@ -1,18 +1,20 @@ +// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | +// ---------------------------------------------------------------------------------------------- +// // Copyright 2015-2024 Ł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. @@ -29,6 +31,8 @@ using ArchiSteamFarm.IPC.Responses; using ArchiSteamFarm.Localization; using ArchiSteamFarm.Web; +using ArchiSteamFarm.Web.GitHub; +using ArchiSteamFarm.Web.GitHub.Data; using Microsoft.AspNetCore.Mvc; namespace ArchiSteamFarm.IPC.Controllers.Api; @@ -47,7 +51,7 @@ public sealed class GitHubController : ArchiController { public async Task> GitHubReleaseGet() { CancellationToken cancellationToken = HttpContext.RequestAborted; - GitHub.ReleaseResponse? releaseResponse = await GitHub.GetLatestRelease(false, cancellationToken).ConfigureAwait(false); + ReleaseResponse? releaseResponse = await GitHubService.GetLatestRelease(SharedInfo.GithubRepo, false, cancellationToken).ConfigureAwait(false); return releaseResponse != null ? Ok(new GenericResponse(new GitHubReleaseResponse(releaseResponse))) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries))); } @@ -67,11 +71,11 @@ public async Task> GitHubReleaseGet(string version CancellationToken cancellationToken = HttpContext.RequestAborted; - GitHub.ReleaseResponse? releaseResponse; + ReleaseResponse? releaseResponse; switch (version.ToUpperInvariant()) { case "LATEST": - releaseResponse = await GitHub.GetLatestRelease(cancellationToken: cancellationToken).ConfigureAwait(false); + releaseResponse = await GitHubService.GetLatestRelease(SharedInfo.GithubRepo, cancellationToken: cancellationToken).ConfigureAwait(false); break; default: @@ -79,7 +83,7 @@ public async Task> GitHubReleaseGet(string version return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(version)))); } - releaseResponse = await GitHub.GetRelease(parsedVersion.ToString(4), cancellationToken).ConfigureAwait(false); + releaseResponse = await GitHubService.GetRelease(SharedInfo.GithubRepo, parsedVersion.ToString(4), cancellationToken).ConfigureAwait(false); break; } @@ -102,7 +106,7 @@ public async Task> GitHubWikiHistoryGet(string pag CancellationToken cancellationToken = HttpContext.RequestAborted; - Dictionary? revisions = await GitHub.GetWikiHistory(page, cancellationToken).ConfigureAwait(false); + Dictionary? revisions = await GitHubService.GetWikiHistory(page, cancellationToken).ConfigureAwait(false); return revisions != null ? revisions.Count > 0 ? Ok(new GenericResponse>(revisions.ToImmutableDictionary())) : BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(page)))) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries))); } @@ -123,7 +127,7 @@ public async Task> GitHubWikiPageGet(string page, CancellationToken cancellationToken = HttpContext.RequestAborted; - string? html = await GitHub.GetWikiPage(page, revision, cancellationToken).ConfigureAwait(false); + string? html = await GitHubService.GetWikiPage(page, revision, cancellationToken).ConfigureAwait(false); return html switch { null => StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries))), diff --git a/ArchiSteamFarm/IPC/Responses/GitHubReleaseResponse.cs b/ArchiSteamFarm/IPC/Responses/GitHubReleaseResponse.cs index 9c658b623cdbb..cab5bd7ffc57d 100644 --- a/ArchiSteamFarm/IPC/Responses/GitHubReleaseResponse.cs +++ b/ArchiSteamFarm/IPC/Responses/GitHubReleaseResponse.cs @@ -1,18 +1,20 @@ +// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | +// ---------------------------------------------------------------------------------------------- +// // Copyright 2015-2024 Ł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. @@ -22,7 +24,7 @@ using System; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using ArchiSteamFarm.Web; +using ArchiSteamFarm.Web.GitHub.Data; namespace ArchiSteamFarm.IPC.Responses; @@ -59,7 +61,7 @@ public sealed class GitHubReleaseResponse { [Required] public string Version { get; private init; } - internal GitHubReleaseResponse(GitHub.ReleaseResponse releaseResponse) { + internal GitHubReleaseResponse(ReleaseResponse releaseResponse) { ArgumentNullException.ThrowIfNull(releaseResponse); ChangelogHTML = releaseResponse.ChangelogHTML ?? ""; diff --git a/ArchiSteamFarm/Localization/Strings.Designer.cs b/ArchiSteamFarm/Localization/Strings.Designer.cs index 84e20f77aa9f8..81460c56a079d 100644 --- a/ArchiSteamFarm/Localization/Strings.Designer.cs +++ b/ArchiSteamFarm/Localization/Strings.Designer.cs @@ -1238,5 +1238,77 @@ public static string IdlingGameNotPossiblePrivate { return ResourceManager.GetString("IdlingGameNotPossiblePrivate", resourceCulture); } } + + public static string WarningSkipping { + get { + return ResourceManager.GetString("WarningSkipping", resourceCulture); + } + } + + public static string PluginUpdatesChecking { + get { + return ResourceManager.GetString("PluginUpdatesChecking", resourceCulture); + } + } + + public static string PluginUpdateChecking { + get { + return ResourceManager.GetString("PluginUpdateChecking", resourceCulture); + } + } + + public static string PluginUpdateNotFound { + get { + return ResourceManager.GetString("PluginUpdateNotFound", resourceCulture); + } + } + + public static string PluginUpdateFound { + get { + return ResourceManager.GetString("PluginUpdateFound", resourceCulture); + } + } + + public static string PluginUpdateNoAssetFound { + get { + return ResourceManager.GetString("PluginUpdateNoAssetFound", resourceCulture); + } + } + + public static string PluginUpdateConflictingAssetsFound { + get { + return ResourceManager.GetString("PluginUpdateConflictingAssetsFound", resourceCulture); + } + } + + public static string PluginUpdateInProgress { + get { + return ResourceManager.GetString("PluginUpdateInProgress", resourceCulture); + } + } + + public static string PluginUpdateFinished { + get { + return ResourceManager.GetString("PluginUpdateFinished", resourceCulture); + } + } + + public static string PluginUpdateEnabled { + get { + return ResourceManager.GetString("PluginUpdateEnabled", resourceCulture); + } + } + + public static string PluginUpdateDisabled { + get { + return ResourceManager.GetString("PluginUpdateDisabled", resourceCulture); + } + } + + public static string CustomPluginUpdatesEnabled { + get { + return ResourceManager.GetString("CustomPluginUpdatesEnabled", resourceCulture); + } + } } } diff --git a/ArchiSteamFarm/Localization/Strings.resx b/ArchiSteamFarm/Localization/Strings.resx index 990837d3340c7..a65fa193f1f95 100644 --- a/ArchiSteamFarm/Localization/Strings.resx +++ b/ArchiSteamFarm/Localization/Strings.resx @@ -764,4 +764,50 @@ Process uptime: {1} Farming {0} ({1}) is disabled, as that game is currently marked as private. If you intend from ASF to farm that game, then consider changing its privacy settings. {0} will be replaced by game's ID (number), {1} will be replaced by game's name + + Skipping: {0}... + {0} will be replaced by text value (string) of entry being skipped. + + + Checking for plugin updates... + + + Checking update for {0} plugin... + {0} will be replaced by plugin name (string). + + + No update available for {0} plugin: {1} ≥ {2}. + {0} will be replaced by plugin name (string), {1} will be replaced by current plugin's version, {2} will be replaced by remote plugin's version. + + + Found {0} plugin update from version {1} to {2}... + {0} will be replaced by plugin name (string), {1} will be replaced by current plugin's version, {2} will be replaced by remote plugin's version. + + + No asset available for {0} plugin update from version {1} to {2}, this usually means the update will be available at later time. + {0} will be replaced by plugin name (string), {1} will be replaced by current plugin's version, {2} will be replaced by remote plugin's version. + + + No asset could be determined for {0} plugin update from version {1} to {2}. This can happen if the release is not finished yet - if it keeps happening, you should notify the plugin creator about that. + {0} will be replaced by plugin name (string), {1} will be replaced by current plugin's version, {2} will be replaced by remote plugin's version. + + + Updating {0} plugin... + {0} will be replaced by plugin name (string). + + + Updating {0} plugin has succeeded, the changes will be loaded on the next ASF launch. + {0} will be replaced by plugin name (string). + + + {0}/{1} plugin has been registered and enabled for automatic updates. + {0} will be replaced by plugin name (string), {1} will be replaced by plugin assembly name (string). + + + {0}/{1} plugin has been disabled from automatic updates, despite supporting such feature. + {0} will be replaced by plugin name (string), {1} will be replaced by plugin assembly name (string). + + + Custom plugins have been registered for automatic updates. ASF team would like to remind you that, for your own safety, you should enable automatic updates only from trusted parties. If you didn't intend to do this, you can disable plugin updates in ASF global config. + diff --git a/ArchiSteamFarm/Plugins/Interfaces/IGitHubPluginUpdates.cs b/ArchiSteamFarm/Plugins/Interfaces/IGitHubPluginUpdates.cs new file mode 100644 index 0000000000000..7b8a784933a74 --- /dev/null +++ b/ArchiSteamFarm/Plugins/Interfaces/IGitHubPluginUpdates.cs @@ -0,0 +1,186 @@ +// ---------------------------------------------------------------------------------------------- +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// ---------------------------------------------------------------------------------------------- +// +// Copyright 2015-2024 Ł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.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using ArchiSteamFarm.Core; +using ArchiSteamFarm.Localization; +using ArchiSteamFarm.Storage; +using ArchiSteamFarm.Web.GitHub; +using ArchiSteamFarm.Web.GitHub.Data; +using JetBrains.Annotations; + +namespace ArchiSteamFarm.Plugins.Interfaces; + +/// +/// +/// Implementing this interface allows your plugin to update from published releases on GitHub. +/// At the minimum you must provide . +/// If you're not following our ASF-PluginTemplate flow, that is, providing release asset named differently than "{PluginName}.zip" then you may also need to override function in order to select target asset based on custom rules. +/// If you have even more complex needs for updating your plugin, you should probably consider implementing base interface instead, where you can provide your own implementation, with optional help from our . +/// +[PublicAPI] +public interface IGitHubPluginUpdates : IPluginUpdates { + /// + /// Boolean value that determines whether your plugin is able to update at the time of calling. You may provide false if, for example, you're inside a critical section and you don't want to update at this time, despite supporting updates otherwise. + /// This effectively skips unnecessary request to GitHub if you're certain that you're not interested in any updates right now. + /// + bool CanUpdate => true; + + /// + /// ASF will use this property as a target for GitHub updates. GitHub repository specified here must have valid releases that will be used for updates. + /// + /// Repository name in format of {Author}/{Repository}. + /// JustArchiNET/ArchiSteamFarm + string RepositoryName { get; } + + Task IPluginUpdates.GetTargetReleaseURL(Version asfVersion, string asfVariant, GlobalConfig.EUpdateChannel updateChannel) { + ArgumentNullException.ThrowIfNull(asfVersion); + ArgumentException.ThrowIfNullOrEmpty(asfVariant); + + if (!Enum.IsDefined(updateChannel)) { + throw new InvalidEnumArgumentException(nameof(updateChannel), (int) updateChannel, typeof(GlobalConfig.EUpdateChannel)); + } + + return GetTargetReleaseURL(asfVersion, asfVariant, updateChannel == GlobalConfig.EUpdateChannel.Stable); + } + + /// + /// ASF will call this function for determining the target asset name to update to. This asset should be available in specified release. It's permitted to return null if you want to cancel update to given version. Default implementation provides vastly universal generic matching, see remarks for more info. + /// + /// Target ASF version that plugin update should be compatible with. In rare cases, this might not match currently running ASF version, in particular when updating to newer release and checking if any plugins are compatible with it. + /// ASF variant of current instance, which may be useful if you're providing different versions for different ASF variants. + /// The target (new) version of the plugin found available in . + /// Available release assets for auto-update. Those come directly from your release on GitHub. + /// + /// Default implementation will select release asset in following order: + /// - {PluginName}-V{Major}-{Minor}-{Build}-{Revision}.zip + /// - {PluginName}-V{Major}-{Minor}-{Build}.zip + /// - {PluginName}-V{Major}-{Minor}.zip + /// - {PluginName}-V{Major}.zip + /// - {PluginName}.zip + /// - *.zip, if exactly 1 release asset matching in the release + /// Where: + /// - {PluginName} is + /// - {Major} is target major ASF version (A from A.B.C.D) + /// - {Minor} is target minor ASF version (B from A.B.C.D) + /// - {Build} is target build (patch) ASF version (C from A.B.C.D) + /// - {Revision} is target revision ASF version (D from A.B.C.D) + /// - * is a wildcard matching any string value + /// For example, when updating MyAwesomePlugin with ASF version V6.0.1.3, it will select first zip file from available ones in the following order: + /// - MyAwesomePlugin-V6.0.1.3.zip + /// - MyAwesomePlugin-V6.0.1.zip + /// - MyAwesomePlugin-V6.0.zip + /// - MyAwesomePlugin-V6.zip + /// - MyAwesomePlugin.zip + /// - *.zip + /// + /// Target release asset from those provided that should be used for auto-update. You may return null if the update is unavailable, for example, because ASF version/variant is determined unsupported, or due to any other reason. + Task GetTargetReleaseAsset(Version asfVersion, string asfVariant, Version newPluginVersion, IReadOnlyCollection releaseAssets) { + ArgumentNullException.ThrowIfNull(asfVersion); + ArgumentException.ThrowIfNullOrEmpty(asfVariant); + ArgumentNullException.ThrowIfNull(newPluginVersion); + + if ((releaseAssets == null) || (releaseAssets.Count == 0)) { + throw new ArgumentNullException(nameof(releaseAssets)); + } + + Dictionary assetsByName = releaseAssets.ToDictionary(static asset => asset.Name, StringComparer.OrdinalIgnoreCase); + + List matches = [ + $"{Name}-V{asfVersion.Major}-{asfVersion.Minor}-{asfVersion.Build}-{asfVersion.Revision}.zip", + $"{Name}-V{asfVersion.Major}-{asfVersion.Minor}-{asfVersion.Build}.zip", + $"{Name}-V{asfVersion.Major}-{asfVersion.Minor}.zip", + $"{Name}-V{asfVersion.Major}.zip", + $"{Name}.zip" + ]; + + foreach (string match in matches) { + if (assetsByName.TryGetValue(match, out ReleaseAsset? targetAsset)) { + return Task.FromResult(targetAsset); + } + } + + // The very last fallback in case user uses different naming scheme + HashSet zipAssets = releaseAssets.Where(static asset => asset.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)).ToHashSet(); + + if (zipAssets.Count == 1) { + return Task.FromResult(zipAssets.First()); + } + + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.PluginUpdateConflictingAssetsFound, Name, Version, newPluginVersion)); + + return Task.FromResult(null); + } + + protected async Task GetTargetReleaseURL(Version asfVersion, string asfVariant, bool stable) { + ArgumentNullException.ThrowIfNull(asfVersion); + ArgumentException.ThrowIfNullOrEmpty(asfVariant); + + if (!CanUpdate) { + return null; + } + + if (string.IsNullOrEmpty(RepositoryName)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(RepositoryName))); + + return null; + } + + ReleaseResponse? releaseResponse = await GitHubService.GetLatestRelease(RepositoryName, stable).ConfigureAwait(false); + + if (releaseResponse == null) { + return null; + } + + Version newVersion = new(releaseResponse.Tag); + + if (Version >= newVersion) { + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginUpdateNotFound, Name, Version, newVersion)); + + return null; + } + + if (releaseResponse.Assets.Count == 0) { + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.PluginUpdateNoAssetFound, Name, Version, newVersion)); + + return null; + } + + ReleaseAsset? asset = await GetTargetReleaseAsset(asfVersion, asfVariant, newVersion, releaseResponse.Assets).ConfigureAwait(false); + + if ((asset == null) || !releaseResponse.Assets.Contains(asset)) { + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.PluginUpdateNoAssetFound, Name, Version, newVersion)); + + return null; + } + + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginUpdateFound, Name, Version, newVersion)); + + return asset.DownloadURL; + } +} diff --git a/ArchiSteamFarm/Plugins/Interfaces/IPluginUpdates.cs b/ArchiSteamFarm/Plugins/Interfaces/IPluginUpdates.cs new file mode 100644 index 0000000000000..65abbc1650be7 --- /dev/null +++ b/ArchiSteamFarm/Plugins/Interfaces/IPluginUpdates.cs @@ -0,0 +1,56 @@ +// ---------------------------------------------------------------------------------------------- +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// ---------------------------------------------------------------------------------------------- +// +// Copyright 2015-2024 Ł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 ArchiSteamFarm.Storage; +using JetBrains.Annotations; + +namespace ArchiSteamFarm.Plugins.Interfaces; + +/// +/// Implementing this interface allows you to provide custom logic for updating your plugin to newer version. +/// Plugin updates are happening on usual basis per configuration of auto-updates from ASF, as well as other triggers such as update command. +/// If you're using GitHub platform with plugin releases, you might be interested in instead. +/// +[PublicAPI] +public interface IPluginUpdates : IPlugin { + /// + /// ASF will call this function for determining the target release asset URL to update to. + /// + /// Target ASF version that plugin update should be compatible with. In rare cases, this might not match currently running ASF version, in particular when updating to newer release and checking if any plugins are compatible with it. + /// ASF variant of current instance, which may be useful if you're providing different versions for different ASF variants. + /// ASF update channel specified for this request. This might be different from the one specified in , as user might've specified other one for this request. + /// Target release asset URL that should be used for auto-update. It's permitted to return null if you want to skip update, e.g. because no new version is available. + Task GetTargetReleaseURL(Version asfVersion, string asfVariant, GlobalConfig.EUpdateChannel updateChannel); + + /// + /// ASF will call this method after update to the new plugin version has been finished, just before restart of the process. + /// + Task OnPluginUpdateFinished() => Task.CompletedTask; + + /// + /// ASF will call this method before proceeding with an update to the new plugin version. + /// + Task OnPluginUpdateProceeding() => Task.CompletedTask; +} diff --git a/ArchiSteamFarm/Plugins/PluginsCore.cs b/ArchiSteamFarm/Plugins/PluginsCore.cs index dc037bb85aea7..17b0c39b4d702 100644 --- a/ArchiSteamFarm/Plugins/PluginsCore.cs +++ b/ArchiSteamFarm/Plugins/PluginsCore.cs @@ -1,18 +1,20 @@ +// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | +// ---------------------------------------------------------------------------------------------- +// // Copyright 2015-2024 Ł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. @@ -22,6 +24,7 @@ using System; using System.Collections.Frozen; using System.Collections.Generic; +using System.Collections.Immutable; using System.ComponentModel; using System.Composition; using System.Composition.Convention; @@ -29,6 +32,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; +using System.IO.Compression; using System.Linq; using System.Reflection; using System.Security.Cryptography; @@ -43,6 +47,8 @@ using ArchiSteamFarm.Steam.Data; using ArchiSteamFarm.Steam.Exchange; using ArchiSteamFarm.Steam.Integration.Callbacks; +using ArchiSteamFarm.Storage; +using ArchiSteamFarm.Web.Responses; using JetBrains.Annotations; using SteamKit2; @@ -54,6 +60,8 @@ public static class PluginsCore { [ImportMany] internal static FrozenSet ActivePlugins { get; private set; } = FrozenSet.Empty; + private static FrozenSet ActivePluginUpdates = FrozenSet.Empty; + [PublicAPI] public static async Task GetCrossProcessSemaphore(string objectName) { ArgumentException.ThrowIfNullOrEmpty(objectName); @@ -154,6 +162,16 @@ internal static async Task GetChangeNumberToStartFrom() { return results.FirstOrDefault(static result => result != null); } + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] + internal static HashSet GetPluginsForUpdate(IReadOnlyCollection pluginAssemblyNames) { + if ((pluginAssemblyNames == null) || (pluginAssemblyNames.Count == 0)) { + throw new ArgumentNullException(nameof(pluginAssemblyNames)); + } + + // We use ActivePlugins here, since we want to pick up also plugins removed from automatic updates + return ActivePlugins.OfType().Where(plugin => pluginAssemblyNames.Contains(plugin.GetType().Assembly.GetName().Name)).ToHashSet(); + } + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] internal static async Task InitPlugins() { if (ActivePlugins.Count > 0) { @@ -238,10 +256,10 @@ internal static async Task InitPlugins() { await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); activePlugins.ExceptWith(invalidPlugins); + } - if (activePlugins.Count == 0) { - return true; - } + if (activePlugins.Count == 0) { + return true; } ActivePlugins = activePlugins.ToFrozenSet(); @@ -253,6 +271,42 @@ internal static async Task InitPlugins() { Console.Title = SharedInfo.ProgramIdentifier; } + GlobalConfig.EPluginsUpdateMode pluginsUpdateMode = ASF.GlobalConfig?.PluginsUpdateMode ?? GlobalConfig.DefaultPluginsUpdateMode; + ImmutableHashSet pluginsUpdateList = ASF.GlobalConfig?.PluginsUpdateList ?? GlobalConfig.DefaultPluginsUpdateList; + + HashSet activePluginUpdates = []; + + foreach (IPluginUpdates plugin in activePlugins.OfType()) { + string? pluginAssemblyName = plugin.GetType().Assembly.GetName().Name; + + if (string.IsNullOrEmpty(pluginAssemblyName)) { + ASF.ArchiLogger.LogNullError(nameof(pluginAssemblyName)); + + continue; + } + + switch (pluginsUpdateMode) { + case GlobalConfig.EPluginsUpdateMode.Blacklist when !pluginsUpdateList.Contains(pluginAssemblyName): + case GlobalConfig.EPluginsUpdateMode.Whitelist when pluginsUpdateList.Contains(pluginAssemblyName): + activePluginUpdates.Add(plugin); + + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginUpdateEnabled, plugin.Name, pluginAssemblyName)); + + break; + case GlobalConfig.EPluginsUpdateMode.Blacklist when pluginsUpdateList.Contains(pluginAssemblyName): + case GlobalConfig.EPluginsUpdateMode.Whitelist when !pluginsUpdateList.Contains(pluginAssemblyName): + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginUpdateDisabled, plugin.Name, pluginAssemblyName)); + + break; + } + } + + if (activePluginUpdates.Count > 0) { + ASF.ArchiLogger.LogGenericWarning(Strings.CustomPluginUpdatesEnabled); + + ActivePluginUpdates = activePluginUpdates.ToFrozenSet(); + } + return true; } @@ -655,6 +709,48 @@ internal static async Task OnUpdateProceeding(Version newVersion) { } } + internal static async Task UpdatePlugins(Version asfVersion, GlobalConfig.EUpdateChannel? updateChannel = null) { + ArgumentNullException.ThrowIfNull(asfVersion); + + if (updateChannel.HasValue && !Enum.IsDefined(updateChannel.Value)) { + throw new InvalidEnumArgumentException(nameof(updateChannel), (int) updateChannel, typeof(GlobalConfig.EUpdateChannel)); + } + + if (ActivePluginUpdates.Count == 0) { + return false; + } + + return await UpdatePlugins(asfVersion, ActivePluginUpdates, updateChannel).ConfigureAwait(false); + } + + internal static async Task UpdatePlugins(Version asfVersion, IReadOnlyCollection plugins, GlobalConfig.EUpdateChannel? updateChannel = null) { + ArgumentNullException.ThrowIfNull(asfVersion); + + if ((plugins == null) || (plugins.Count == 0)) { + throw new ArgumentNullException(nameof(plugins)); + } + + if (updateChannel.HasValue && !Enum.IsDefined(updateChannel.Value)) { + throw new InvalidEnumArgumentException(nameof(updateChannel), (int) updateChannel, typeof(GlobalConfig.EUpdateChannel)); + } + + if (ASF.WebBrowser == null) { + throw new InvalidOperationException(nameof(ASF.WebBrowser)); + } + + updateChannel ??= ASF.GlobalConfig?.UpdateChannel ?? GlobalConfig.DefaultUpdateChannel; + + if (updateChannel == GlobalConfig.EUpdateChannel.None) { + return false; + } + + ASF.ArchiLogger.LogGenericInfo(Strings.PluginUpdatesChecking); + + IList pluginUpdates = await Utilities.InParallel(plugins.Select(plugin => UpdatePlugin(asfVersion, plugin, updateChannel.Value))).ConfigureAwait(false); + + return pluginUpdates.Any(static restartNeeded => restartNeeded); + } + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] private static HashSet? LoadAssembliesFrom(string path) { ArgumentException.ThrowIfNullOrEmpty(path); @@ -667,6 +763,14 @@ internal static async Task OnUpdateProceeding(Version newVersion) { try { foreach (string assemblyPath in Directory.EnumerateFiles(path, "*.dll", SearchOption.AllDirectories)) { + string? assemblyDirectoryName = Path.GetFileName(Path.GetDirectoryName(assemblyPath)); + + if (assemblyDirectoryName == SharedInfo.UpdateDirectory) { + ASF.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningSkipping, assemblyPath)); + + continue; + } + Assembly assembly; try { @@ -688,4 +792,102 @@ internal static async Task OnUpdateProceeding(Version newVersion) { return assemblies; } + + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3000", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] + private static async Task UpdatePlugin(Version asfVersion, IPluginUpdates plugin, GlobalConfig.EUpdateChannel updateChannel) { + ArgumentNullException.ThrowIfNull(asfVersion); + ArgumentNullException.ThrowIfNull(plugin); + + if (!Enum.IsDefined(updateChannel) || (updateChannel == GlobalConfig.EUpdateChannel.None)) { + throw new InvalidEnumArgumentException(nameof(updateChannel), (int) updateChannel, typeof(GlobalConfig.EUpdateChannel)); + } + + if (ASF.WebBrowser == null) { + throw new InvalidOperationException(nameof(ASF.WebBrowser)); + } + + string pluginName; + + try { + pluginName = plugin.Name; + + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginUpdateChecking, pluginName)); + + string? assemblyDirectory = Path.GetDirectoryName(plugin.GetType().Assembly.Location); + + if (string.IsNullOrEmpty(assemblyDirectory)) { + 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); + } + + Uri? releaseURL = await plugin.GetTargetReleaseURL(asfVersion, SharedInfo.BuildInfo.Variant, updateChannel).ConfigureAwait(false); + + if (releaseURL == null) { + return false; + } + + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginUpdateInProgress, pluginName)); + + Progress progressReporter = new(); + + progressReporter.ProgressChanged += onProgressChanged; + + BinaryResponse? response; + + try { + response = await ASF.WebBrowser.UrlGetToBinary(releaseURL, progressReporter: progressReporter).ConfigureAwait(false); + } finally { + progressReporter.ProgressChanged -= onProgressChanged; + } + + if (response?.Content == null) { + return false; + } + + ASF.ArchiLogger.LogGenericInfo(Strings.PatchingFiles); + + byte[] responseBytes = response.Content as byte[] ?? response.Content.ToArray(); + + MemoryStream memoryStream = new(responseBytes); + + await using (memoryStream.ConfigureAwait(false)) { + using ZipArchive zipArchive = new(memoryStream); + + await plugin.OnPluginUpdateProceeding().ConfigureAwait(false); + + if (!Utilities.UpdateFromArchive(zipArchive, assemblyDirectory)) { + ASF.ArchiLogger.LogGenericError(Strings.WarningFailed); + + return false; + } + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return false; + } + + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginUpdateFinished, pluginName)); + + try { + await plugin.OnPluginUpdateFinished().ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + + return true; + + void onProgressChanged(object? sender, byte progressPercentage) { + ArgumentOutOfRangeException.ThrowIfGreaterThan(progressPercentage, 100); + + Utilities.OnProgressChanged(pluginName, progressPercentage); + } + } } diff --git a/ArchiSteamFarm/SharedInfo.cs b/ArchiSteamFarm/SharedInfo.cs index 6045dc1c977de..bbe5c9eefc45e 100644 --- a/ArchiSteamFarm/SharedInfo.cs +++ b/ArchiSteamFarm/SharedInfo.cs @@ -1,18 +1,20 @@ +// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | +// ---------------------------------------------------------------------------------------------- +// // Copyright 2015-2024 Ł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. @@ -45,7 +47,6 @@ public static class SharedInfo { internal const string EnvironmentVariableCryptKeyFile = $"{EnvironmentVariableCryptKey}_FILE"; internal const string EnvironmentVariableNetworkGroup = $"{ASF}_NETWORK_GROUP"; internal const string EnvironmentVariablePath = $"{ASF}_PATH"; - internal const string GithubReleaseURL = $"https://api.github.com/repos/{GithubRepo}/releases"; internal const string GithubRepo = $"JustArchiNET/{AssemblyName}"; internal const string GlobalConfigFileName = $"{ASF}{JsonConfigExtension}"; internal const string GlobalCrashFileName = $"{ASF}.crash"; diff --git a/ArchiSteamFarm/Steam/Interaction/Actions.cs b/ArchiSteamFarm/Steam/Interaction/Actions.cs index 900fa18eceef1..ee3eb832fef61 100644 --- a/ArchiSteamFarm/Steam/Interaction/Actions.cs +++ b/ArchiSteamFarm/Steam/Interaction/Actions.cs @@ -1,18 +1,20 @@ +// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | +// ---------------------------------------------------------------------------------------------- +// // Copyright 2015-2024 Ł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. @@ -32,6 +34,8 @@ using ArchiSteamFarm.Core; using ArchiSteamFarm.Helpers; using ArchiSteamFarm.Localization; +using ArchiSteamFarm.Plugins; +using ArchiSteamFarm.Plugins.Interfaces; using ArchiSteamFarm.Steam.Data; using ArchiSteamFarm.Steam.Exchange; using ArchiSteamFarm.Steam.Storage; @@ -473,19 +477,46 @@ static async () => { throw new InvalidEnumArgumentException(nameof(channel), (int) channel, typeof(GlobalConfig.EUpdateChannel)); } - Version? version = await ASF.Update(channel, true).ConfigureAwait(false); + (Version? newVersion, bool restartNeeded) = await ASF.Update(channel, true).ConfigureAwait(false); - if (version == null) { + if (restartNeeded) { + Utilities.InBackground(ASF.RestartOrExit); + } + + if (newVersion == null) { return (false, null, null); } - if (SharedInfo.Version >= version) { - return (false, $"V{SharedInfo.Version} ≥ V{version}", version); + return newVersion > SharedInfo.Version ? (true, null, newVersion) : (false, $"V{SharedInfo.Version} ≥ V{newVersion}", newVersion); + } + + [PublicAPI] + public static async Task<(bool Success, string? Message)> UpdatePlugins(IReadOnlyCollection plugins, GlobalConfig.EUpdateChannel? channel = null) { + if ((plugins == null) || (plugins.Count == 0)) { + throw new ArgumentNullException(nameof(plugins)); + } + + if (channel.HasValue && !Enum.IsDefined(channel.Value)) { + throw new InvalidEnumArgumentException(nameof(channel), (int) channel, typeof(GlobalConfig.EUpdateChannel)); + } + + HashSet pluginAssemblyNames = plugins.ToHashSet(StringComparer.OrdinalIgnoreCase); + + HashSet pluginsForUpdate = PluginsCore.GetPluginsForUpdate(pluginAssemblyNames); + + if (pluginsForUpdate.Count == 0) { + return (false, Strings.NothingFound); + } + + bool restartNeeded = await PluginsCore.UpdatePlugins(SharedInfo.Version, pluginsForUpdate, channel).ConfigureAwait(false); + + if (restartNeeded) { + Utilities.InBackground(ASF.RestartOrExit); } - Utilities.InBackground(ASF.RestartOrExit); + string message = restartNeeded ? Strings.UpdateFinished : Strings.NothingFound; - return (true, null, version); + return (true, message); } internal async Task AcceptDigitalGiftCards() { diff --git a/ArchiSteamFarm/Steam/Interaction/Commands.cs b/ArchiSteamFarm/Steam/Interaction/Commands.cs index 63adf803bcb9b..0d9c51bcb761b 100644 --- a/ArchiSteamFarm/Steam/Interaction/Commands.cs +++ b/ArchiSteamFarm/Steam/Interaction/Commands.cs @@ -1,18 +1,20 @@ +// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | +// ---------------------------------------------------------------------------------------------- +// // Copyright 2015-2024 Ł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. @@ -327,6 +329,10 @@ public static EAccess GetProxyAccess(Bot bot, EAccess access, ulong steamID = 0) return await ResponseUnpackBoosters(access, Utilities.GetArgsAsText(args, 1, ","), steamID).ConfigureAwait(false); case "UPDATE": return await ResponseUpdate(access, args[1]).ConfigureAwait(false); + case "UPDATEPLUGINS" when args.Length > 2: + return await ResponseUpdatePlugins(access, Utilities.GetArgsAsText(args, 2, ","), args[1]).ConfigureAwait(false); + case "UPDATEPLUGINS": + return await ResponseUpdatePlugins(access, args[1]).ConfigureAwait(false); default: string? pluginsResponse = await PluginsCore.OnBotCommand(Bot, access, message, args, steamID).ConfigureAwait(false); @@ -3161,6 +3167,38 @@ internal void OnNewLicenseList() { return FormatStaticResponse($"{(success ? Strings.Success : Strings.WarningFailed)}{(!string.IsNullOrEmpty(message) ? $" {message}" : version != null ? $" {version}" : "")}"); } + private static async Task ResponseUpdatePlugins(EAccess access, string pluginsText, string? channelText = null) { + if (!Enum.IsDefined(access)) { + throw new InvalidEnumArgumentException(nameof(access), (int) access, typeof(EAccess)); + } + + ArgumentException.ThrowIfNullOrEmpty(pluginsText); + + if (access < EAccess.Owner) { + return null; + } + + GlobalConfig.EUpdateChannel? channel = null; + + if (!string.IsNullOrEmpty(channelText)) { + if (!Enum.TryParse(channelText, true, out GlobalConfig.EUpdateChannel parsedChannel) || (parsedChannel == GlobalConfig.EUpdateChannel.None)) { + return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(channelText))); + } + + channel = parsedChannel; + } + + string[] plugins = pluginsText.Split(SharedInfo.ListElementSeparators, StringSplitOptions.RemoveEmptyEntries); + + if (plugins.Length == 0) { + return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(plugins))); + } + + (bool success, string? message) = await Actions.UpdatePlugins(plugins, channel).ConfigureAwait(false); + + return FormatStaticResponse($"{(success ? Strings.Success : Strings.WarningFailed)}{(!string.IsNullOrEmpty(message) ? $" {message}" : "")}"); + } + private string? ResponseVersion(EAccess access) { if (!Enum.IsDefined(access)) { throw new InvalidEnumArgumentException(nameof(access), (int) access, typeof(EAccess)); diff --git a/ArchiSteamFarm/Storage/GlobalConfig.cs b/ArchiSteamFarm/Storage/GlobalConfig.cs index e898a8faeb37c..60544a2fd2433 100644 --- a/ArchiSteamFarm/Storage/GlobalConfig.cs +++ b/ArchiSteamFarm/Storage/GlobalConfig.cs @@ -1,18 +1,20 @@ +// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | +// ---------------------------------------------------------------------------------------------- +// // Copyright 2015-2024 Ł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. @@ -107,6 +109,9 @@ public sealed class GlobalConfig { [PublicAPI] public const EOptimizationMode DefaultOptimizationMode = EOptimizationMode.MaxPerformance; + [PublicAPI] + public const EPluginsUpdateMode DefaultPluginsUpdateMode = EPluginsUpdateMode.Whitelist; + [PublicAPI] public const string? DefaultSteamMessagePrefix = "/me "; @@ -140,6 +145,9 @@ public sealed class GlobalConfig { [PublicAPI] public static readonly Guid? DefaultLicenseID; + [PublicAPI] + public static readonly ImmutableHashSet DefaultPluginsUpdateList = ImmutableHashSet.Empty; + private static readonly FrozenSet ForbiddenIPCPasswordPhrases = new HashSet(5, StringComparer.InvariantCultureIgnoreCase) { "ipc", "api", "gui", "asf-ui", "asf-gui" }.ToFrozenSet(StringComparer.InvariantCultureIgnoreCase); [JsonIgnore] @@ -277,6 +285,13 @@ internal set { [JsonInclude] public EOptimizationMode OptimizationMode { get; private init; } = DefaultOptimizationMode; + [JsonDisallowNull] + [JsonInclude] + public ImmutableHashSet PluginsUpdateList { get; private init; } = DefaultPluginsUpdateList; + + [JsonInclude] + public EPluginsUpdateMode PluginsUpdateMode { get; private init; } = DefaultPluginsUpdateMode; + [JsonInclude] [MaxLength(SteamChatMessage.MaxMessagePrefixBytes / SteamChatMessage.ReservedEscapeMessageBytes)] [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "This is optional, supportive attribute, we don't care if it gets trimmed or not")] @@ -417,6 +432,12 @@ internal GlobalConfig() { } [UsedImplicitly] public bool ShouldSerializeOptimizationMode() => !Saving || (OptimizationMode != DefaultOptimizationMode); + [UsedImplicitly] + public bool ShouldSerializePluginsUpdateList() => !Saving || ((PluginsUpdateList != DefaultPluginsUpdateList) && !PluginsUpdateList.SetEquals(DefaultPluginsUpdateList)); + + [UsedImplicitly] + public bool ShouldSerializePluginsUpdateMode() => !Saving || (PluginsUpdateMode != DefaultPluginsUpdateMode); + [UsedImplicitly] public bool ShouldSerializeSSteamOwnerID() => !Saving; @@ -472,6 +493,10 @@ internal GlobalConfig() { } return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(OptimizationMode), OptimizationMode)); } + if (!Enum.IsDefined(PluginsUpdateMode)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(PluginsUpdateMode), PluginsUpdateMode)); + } + if (!string.IsNullOrEmpty(SteamMessagePrefix) && !SteamChatMessage.IsValidPrefix(SteamMessagePrefix)) { return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamMessagePrefix), SteamMessagePrefix)); } @@ -577,6 +602,12 @@ public enum EOptimizationMode : byte { MinMemoryUsage } + [PublicAPI] + public enum EPluginsUpdateMode : byte { + Whitelist, + Blacklist + } + [PublicAPI] public enum EUpdateChannel : byte { None, diff --git a/ArchiSteamFarm/Web/GitHub/Data/ReleaseAsset.cs b/ArchiSteamFarm/Web/GitHub/Data/ReleaseAsset.cs new file mode 100644 index 0000000000000..ff0137093f263 --- /dev/null +++ b/ArchiSteamFarm/Web/GitHub/Data/ReleaseAsset.cs @@ -0,0 +1,49 @@ +// ---------------------------------------------------------------------------------------------- +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// ---------------------------------------------------------------------------------------------- +// +// Copyright 2015-2024 Ł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.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace ArchiSteamFarm.Web.GitHub.Data; + +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class ReleaseAsset { + [JsonInclude] + [JsonPropertyName("browser_download_url")] + [JsonRequired] + public Uri DownloadURL { get; private init; } = null!; + + [JsonInclude] + [JsonPropertyName("name")] + [JsonRequired] + public string Name { get; private init; } = ""; + + [JsonInclude] + [JsonPropertyName("size")] + [JsonRequired] + public uint Size { get; private init; } + + [JsonConstructor] + private ReleaseAsset() { } +} diff --git a/ArchiSteamFarm/Web/GitHub/Data/ReleaseResponse.cs b/ArchiSteamFarm/Web/GitHub/Data/ReleaseResponse.cs new file mode 100644 index 0000000000000..879f808c2b02a --- /dev/null +++ b/ArchiSteamFarm/Web/GitHub/Data/ReleaseResponse.cs @@ -0,0 +1,152 @@ +// ---------------------------------------------------------------------------------------------- +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// ---------------------------------------------------------------------------------------------- +// +// Copyright 2015-2024 Ł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.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.Json.Serialization; +using ArchiSteamFarm.Core; +using Markdig; +using Markdig.Renderers; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace ArchiSteamFarm.Web.GitHub.Data; + +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class ReleaseResponse { + internal string? ChangelogHTML { + get { + if (BackingChangelogHTML != null) { + return BackingChangelogHTML; + } + + if (Changelog == null) { + ASF.ArchiLogger.LogNullError(Changelog); + + return null; + } + + using StringWriter writer = new(); + + HtmlRenderer renderer = new(writer); + + renderer.Render(Changelog); + writer.Flush(); + + return BackingChangelogHTML = writer.ToString(); + } + } + + internal string? ChangelogPlainText { + get { + if (BackingChangelogPlainText != null) { + return BackingChangelogPlainText; + } + + if (Changelog == null) { + ASF.ArchiLogger.LogNullError(Changelog); + + return null; + } + + using StringWriter writer = new(); + + HtmlRenderer renderer = new(writer) { + EnableHtmlForBlock = false, + EnableHtmlForInline = false, + EnableHtmlEscape = false + }; + + renderer.Render(Changelog); + writer.Flush(); + + return BackingChangelogPlainText = writer.ToString(); + } + } + + private MarkdownDocument? Changelog { + get { + if (BackingChangelog != null) { + return BackingChangelog; + } + + if (string.IsNullOrEmpty(MarkdownBody)) { + ASF.ArchiLogger.LogNullError(MarkdownBody); + + return null; + } + + return BackingChangelog = ExtractChangelogFromBody(MarkdownBody); + } + } + + [JsonInclude] + [JsonPropertyName("assets")] + [JsonRequired] + public ImmutableHashSet Assets { get; private init; } = ImmutableHashSet.Empty; + + [JsonInclude] + [JsonPropertyName("prerelease")] + [JsonRequired] + public bool IsPreRelease { get; private init; } + + [JsonInclude] + [JsonPropertyName("body")] + [JsonRequired] + public string MarkdownBody { get; private init; } = ""; + + [JsonInclude] + [JsonPropertyName("published_at")] + [JsonRequired] + public DateTime PublishedAt { get; private init; } + + [JsonInclude] + [JsonPropertyName("tag_name")] + [JsonRequired] + public string Tag { get; private init; } = ""; + + private MarkdownDocument? BackingChangelog; + private string? BackingChangelogHTML; + private string? BackingChangelogPlainText; + + [JsonConstructor] + private ReleaseResponse() { } + + private static MarkdownDocument ExtractChangelogFromBody(string markdownText) { + ArgumentException.ThrowIfNullOrEmpty(markdownText); + + MarkdownDocument markdownDocument = Markdown.Parse(markdownText); + MarkdownDocument result = []; + + foreach (Block block in markdownDocument.SkipWhile(static block => block is not HeadingBlock { Inline.FirstChild: LiteralInline literalInline } || (literalInline.Content.ToString()?.Equals("Changelog", StringComparison.OrdinalIgnoreCase) != true)).Skip(1).TakeWhile(static block => block is not ThematicBreakBlock).ToList()) { + // All blocks that we're interested in must be removed from original markdownDocument firstly + markdownDocument.Remove(block); + result.Add(block); + } + + return result; + } +} diff --git a/ArchiSteamFarm/Web/GitHub.cs b/ArchiSteamFarm/Web/GitHub/GitHubService.cs similarity index 56% rename from ArchiSteamFarm/Web/GitHub.cs rename to ArchiSteamFarm/Web/GitHub/GitHubService.cs index 7225c7b9a93a6..3fab37403bb7b 100644 --- a/ArchiSteamFarm/Web/GitHub.cs +++ b/ArchiSteamFarm/Web/GitHub/GitHubService.cs @@ -1,18 +1,20 @@ +// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| -// | +// ---------------------------------------------------------------------------------------------- +// // Copyright 2015-2024 Ł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. @@ -22,45 +24,58 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.IO; using System.Linq; using System.Net; -using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using AngleSharp.Dom; using ArchiSteamFarm.Core; +using ArchiSteamFarm.Web.GitHub.Data; using ArchiSteamFarm.Web.Responses; -using Markdig; -using Markdig.Renderers; -using Markdig.Syntax; -using Markdig.Syntax.Inlines; +using JetBrains.Annotations; -namespace ArchiSteamFarm.Web; +namespace ArchiSteamFarm.Web.GitHub; -internal static class GitHub { - internal static async Task GetLatestRelease(bool stable = true, CancellationToken cancellationToken = default) { - Uri request = new($"{SharedInfo.GithubReleaseURL}{(stable ? "/latest" : "?per_page=1")}"); +public static class GitHubService { + private static Uri URL => new("https://api.github.com"); + + [PublicAPI] + public static async Task GetLatestRelease(string repoName, bool stable = true, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrEmpty(repoName); if (stable) { + Uri request = new(URL, $"/repos/{repoName}/releases/latest"); + return await GetReleaseFromURL(request, cancellationToken).ConfigureAwait(false); } - ImmutableList? response = await GetReleasesFromURL(request, cancellationToken).ConfigureAwait(false); + ImmutableList? response = await GetReleases(repoName, 1, cancellationToken).ConfigureAwait(false); return response?.FirstOrDefault(); } - internal static async Task GetRelease(string version, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(version); + [PublicAPI] + public static async Task GetRelease(string repoName, string tag, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrEmpty(repoName); + ArgumentException.ThrowIfNullOrEmpty(tag); - Uri request = new($"{SharedInfo.GithubReleaseURL}/tags/{version}"); + Uri request = new(URL, $"/repos/{repoName}/releases/tags/{tag}"); return await GetReleaseFromURL(request, cancellationToken).ConfigureAwait(false); } + [PublicAPI] + public static async Task?> GetReleases(string repoName, byte count = 10, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrEmpty(repoName); + ArgumentOutOfRangeException.ThrowIfZero(count); + ArgumentOutOfRangeException.ThrowIfGreaterThan(count, 100); + + Uri request = new(URL, $"/repos/{repoName}/releases?per_page={count}"); + + return await GetReleasesFromURL(request, cancellationToken).ConfigureAwait(false); + } + internal static async Task?> GetWikiHistory(string page, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(page); @@ -152,21 +167,6 @@ internal static class GitHub { return markdownBodyNode?.InnerHtml.Trim() ?? ""; } - private static MarkdownDocument ExtractChangelogFromBody(string markdownText) { - ArgumentException.ThrowIfNullOrEmpty(markdownText); - - MarkdownDocument markdownDocument = Markdown.Parse(markdownText); - MarkdownDocument result = []; - - foreach (Block block in markdownDocument.SkipWhile(static block => block is not HeadingBlock { Inline.FirstChild: LiteralInline literalInline } || (literalInline.Content.ToString()?.Equals("Changelog", StringComparison.OrdinalIgnoreCase) != true)).Skip(1).TakeWhile(static block => block is not ThematicBreakBlock).ToList()) { - // All blocks that we're interested in must be removed from original markdownDocument firstly - markdownDocument.Remove(block); - result.Add(block); - } - - return result; - } - private static async Task GetReleaseFromURL(Uri request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -190,125 +190,4 @@ private static MarkdownDocument ExtractChangelogFromBody(string markdownText) { return response?.Content; } - - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - internal sealed class ReleaseResponse { - internal string? ChangelogHTML { - get { - if (BackingChangelogHTML != null) { - return BackingChangelogHTML; - } - - if (Changelog == null) { - ASF.ArchiLogger.LogNullError(Changelog); - - return null; - } - - using StringWriter writer = new(); - - HtmlRenderer renderer = new(writer); - - renderer.Render(Changelog); - writer.Flush(); - - return BackingChangelogHTML = writer.ToString(); - } - } - - internal string? ChangelogPlainText { - get { - if (BackingChangelogPlainText != null) { - return BackingChangelogPlainText; - } - - if (Changelog == null) { - ASF.ArchiLogger.LogNullError(Changelog); - - return null; - } - - using StringWriter writer = new(); - - HtmlRenderer renderer = new(writer) { - EnableHtmlForBlock = false, - EnableHtmlForInline = false, - EnableHtmlEscape = false - }; - - renderer.Render(Changelog); - writer.Flush(); - - return BackingChangelogPlainText = writer.ToString(); - } - } - - private MarkdownDocument? Changelog { - get { - if (BackingChangelog != null) { - return BackingChangelog; - } - - if (string.IsNullOrEmpty(MarkdownBody)) { - ASF.ArchiLogger.LogNullError(MarkdownBody); - - return null; - } - - return BackingChangelog = ExtractChangelogFromBody(MarkdownBody); - } - } - - [JsonInclude] - [JsonPropertyName("assets")] - [JsonRequired] - internal ImmutableHashSet Assets { get; private init; } = ImmutableHashSet.Empty; - - [JsonInclude] - [JsonPropertyName("prerelease")] - [JsonRequired] - internal bool IsPreRelease { get; private init; } - - [JsonInclude] - [JsonPropertyName("published_at")] - [JsonRequired] - internal DateTime PublishedAt { get; private init; } - - [JsonInclude] - [JsonPropertyName("tag_name")] - [JsonRequired] - internal string Tag { get; private init; } = ""; - - private MarkdownDocument? BackingChangelog; - private string? BackingChangelogHTML; - private string? BackingChangelogPlainText; - - [JsonInclude] - [JsonPropertyName("body")] - [JsonRequired] - private string? MarkdownBody { get; init; } = ""; - - [JsonConstructor] - private ReleaseResponse() { } - - internal sealed class Asset { - [JsonInclude] - [JsonPropertyName("browser_download_url")] - [JsonRequired] - internal Uri? DownloadURL { get; private init; } - - [JsonInclude] - [JsonPropertyName("name")] - [JsonRequired] - internal string? Name { get; private init; } - - [JsonInclude] - [JsonPropertyName("size")] - [JsonRequired] - internal uint Size { get; private init; } - - [JsonConstructor] - private Asset() { } - } - } } diff --git a/ArchiSteamFarm/Web/WebBrowser.cs b/ArchiSteamFarm/Web/WebBrowser.cs index ab50e12b397d1..477bdac00ebaf 100644 --- a/ArchiSteamFarm/Web/WebBrowser.cs +++ b/ArchiSteamFarm/Web/WebBrowser.cs @@ -177,22 +177,24 @@ public HttpClient GenerateDisposableHttpClient(bool extendedTimeout = false) { while (response.Content.CanRead) { int read = await response.Content.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false); - if (read == 0) { + if (read <= 0) { break; } - await ms.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); - - if ((progressReporter == null) || (batchIncreaseSize == 0) || (batch >= 99)) { - continue; - } + // Report progress in-between downloading only if file is big enough to justify it + // Current logic below will report progress if file is bigger than ~800 KB + if (batchIncreaseSize >= buffer.Length) { + readThisBatch += read; - readThisBatch += read; + for (; (readThisBatch >= batchIncreaseSize) && (batch < 99); readThisBatch -= batchIncreaseSize) { + // We need a copy of variable being passed when in for loops, as loop will proceed before our event is launched + byte progress = ++batch; - while ((readThisBatch >= batchIncreaseSize) && (batch < 99)) { - readThisBatch -= batchIncreaseSize; - progressReporter.Report(++batch); + progressReporter?.Report(progress); + } } + + await ms.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw;