diff --git a/.editorconfig b/.editorconfig index 7fc0707..fb9b357 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,6 +10,9 @@ indent_style = tab insert_final_newline = true trim_trailing_whitespace = true +[*.{cs,vb}] +dotnet_diagnostic.CA1859.severity = none + ############################### # C# Coding Conventions # ############################### diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3278f7d..e070d13 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,11 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, ubuntu-latest, windows-latest] + os: [ + macos-latest, + ubuntu-latest, + #windows-latest + ] runs-on: ${{ matrix.os }} @@ -190,13 +194,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4.2.2 - # TODO: It'd be perfect if we could match final artifacts to the platform they target, so e.g. linux build comes from the linux machine - # However, that is currently impossible due to https://github.com/dotnet/msbuild/issues/3897 - # Therefore, we'll (sadly) pull artifacts from Windows machine only for now - - name: Download generic artifact from windows-latest + - name: Download generic artifact from ubuntu-latest uses: actions/download-artifact@v4.1.8 with: - name: windows-latest_${{ env.PLUGIN_NAME }}-generic + name: ubuntu-latest_${{ env.PLUGIN_NAME }}-generic path: out - name: Unzip and copy generic artifact diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index 1d7647e..0515d56 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -40,7 +40,7 @@ internal static PluginContext Context { } // ReSharper disable once InconsistentNaming - private static readonly AsyncLocal _context = new(); + private static readonly Utils.Workarounds.AsyncLocal _context = new(); private static CancellationToken CancellationToken => Context.CancellationToken; public string Name => StaticName; @@ -141,7 +141,9 @@ public async void CollectGamesOnClock(object? source) { if (!cts.IsCancellationRequested) { string cmd = $"FREEGAMES {FreeGamesCommand.CollectInternalCommandString} " + string.Join(' ', reorderedBots.Select(static bot => bot.BotName)); +#pragma warning disable CS1998 await OnBotCommand(null!, EAccess.None, cmd, cmd.Split()).ConfigureAwait(false); +#pragma warning restore CS1998 } } } @@ -212,12 +214,15 @@ private async Task RemoveBot(Bot bot) { private void StartTimerIfNeeded() => CollectIntervalManager.StartTimerIfNeeded(); ~ASFFreeGamesPlugin() => CollectIntervalManager.Dispose(); - public readonly GithubPluginUpdater Updater = new(new Lazy(GetVersion)); + + #region IGitHubPluginUpdates implementation + private readonly GithubPluginUpdater Updater = new(new Lazy(GetVersion)); string IGitHubPluginUpdates.RepositoryName => GithubPluginUpdater.RepositoryName; bool IGitHubPluginUpdates.CanUpdate => Updater.CanUpdate; Task IGitHubPluginUpdates.GetTargetReleaseURL(Version asfVersion, string asfVariant, bool asfUpdate, bool stable, bool forced) => Updater.GetTargetReleaseURL(asfVersion, asfVariant, asfUpdate, stable, forced); + #endregion } #pragma warning restore CA1812 // ASF uses this class during runtime diff --git a/ASFFreeGames/Commands/FreeGamesCommand.cs b/ASFFreeGames/Commands/FreeGamesCommand.cs index 0396f60..8805a15 100644 --- a/ASFFreeGames/Commands/FreeGamesCommand.cs +++ b/ASFFreeGames/Commands/FreeGamesCommand.cs @@ -156,9 +156,18 @@ public void Dispose() { private async ValueTask HandleInternalCollectCommand(Bot? bot, string[] args, CancellationToken cancellationToken) { Dictionary botMap = Context.Bots.ToDictionary(static b => b.BotName.Trim(), static b => b, StringComparer.InvariantCultureIgnoreCase); - Bot[] bots = args.Skip(2).Select(botName => botMap.GetValueOrDefault(botName.Trim())).Where(static b => b is not null).ToArray()!; + List bots = []; - if (bots.Length == 0) { + for (int i = 2; i < args.Length; i++) { + string botName = args[i].Trim(); + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (botMap.TryGetValue(botName, out Bot? savedBot) && savedBot is not null) { + bots.Add(savedBot); + } + } + + if (bots.Count == 0) { if (bot is null) { return null; } @@ -168,7 +177,7 @@ public void Dispose() { int collected = await CollectGames(bots, ECollectGameRequestSource.Scheduled, cancellationToken).ConfigureAwait(false); - return FormatBotResponse(bot, $"Collected a total of {collected} free game(s)" + (bots.Length > 1 ? $" on {bots.Length} bots" : $" on {bots.FirstOrDefault()?.BotName}")); + return FormatBotResponse(bot, $"Collected a total of {collected} free game(s)" + (bots.Count > 1 ? $" on {bots.Count} bots" : $" on {bots.FirstOrDefault()?.BotName}")); } private async Task SaveOptions(CancellationToken cancellationToken) { diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs index 93e8835..805bebc 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs @@ -59,13 +59,12 @@ public static async Task Save(ASFFreeGamesOptions options, CancellationToken can try { #pragma warning disable CAC001 #pragma warning disable CA2007 - - // Use FileOptions.Asynchronous when creating a file stream for async operations - await using FileStream fs = new(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite, 4096, FileOptions.Asynchronous); + await using FileStream fs = new(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); #pragma warning restore CA2007 #pragma warning restore CAC001 - using IMemoryOwner buffer = MemoryPool.Shared.Rent(checked(fs.Length > 0 ? (int) fs.Length + 1 : 1 << 15)); - int read = await fs.ReadAsync(buffer.Memory, cancellationToken).ConfigureAwait(false); + byte[] buffer = new byte[fs.Length > 0 ? (int) fs.Length + 1 : 1 << 15]; + + int read = await fs.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); try { fs.Position = 0; @@ -76,7 +75,8 @@ public static async Task Save(ASFFreeGamesOptions options, CancellationToken can catch (Exception) { fs.Position = 0; - await fs.WriteAsync(buffer.Memory[..read], cancellationToken).ConfigureAwait(false); + + await fs.WriteAsync(((ReadOnlyMemory) buffer)[..read], cancellationToken).ConfigureAwait(false); fs.SetLength(read); throw; diff --git a/ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs b/ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs index a43df1b..9538c5c 100644 --- a/ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs +++ b/ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs @@ -155,7 +155,14 @@ private async Task> DoDownloadUsingInstance long dateMillis = date.ToUnixTimeMilliseconds(); - return entries.Select(entry => entry.ToRedditGameEntry(dateMillis)).ToArray(); + List redditGameEntries = []; + + // ReSharper disable once LoopCanBeConvertedToQuery + foreach (RedlibGameEntry entry in entries) { + redditGameEntries.Add(entry.ToRedditGameEntry(dateMillis)); + } + + return redditGameEntries; } private async Task> DownloadUsingInstance(SimpleHttpClient client, Uri uri, uint retry, CancellationToken cancellationToken) { diff --git a/ASFFreeGames/Github/GithubPluginUpdater.cs b/ASFFreeGames/Github/GithubPluginUpdater.cs index c343d1b..6697919 100644 --- a/ASFFreeGames/Github/GithubPluginUpdater.cs +++ b/ASFFreeGames/Github/GithubPluginUpdater.cs @@ -14,16 +14,34 @@ public class GithubPluginUpdater(Lazy version) { private Version CurrentVersion => version.Value; + private static void LogGenericError(string message) { + if (string.IsNullOrEmpty(message)) { + return; + } + + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"{nameof(GithubPluginUpdater)}: {message}"); + } + + private static void LogGenericDebug(string message) { + if (string.IsNullOrEmpty(message)) { + return; + } + + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericDebug($"{nameof(GithubPluginUpdater)}: {message}"); + } + public async Task GetTargetReleaseURL(Version asfVersion, string asfVariant, bool asfUpdate, bool stable, bool forced) { ArgumentNullException.ThrowIfNull(asfVersion); ArgumentException.ThrowIfNullOrEmpty(asfVariant); if (!CanUpdate) { + LogGenericDebug("CanUpdate is false"); + return null; } if (string.IsNullOrEmpty(RepositoryName)) { - //ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError(Strings.FormatWarningFailedWithError(nameof(RepositoryName))); + LogGenericError("RepositoryName is null or empty"); return null; } @@ -31,15 +49,20 @@ public class GithubPluginUpdater(Lazy version) { ReleaseResponse? releaseResponse = await GitHubService.GetLatestRelease(RepositoryName).ConfigureAwait(false); if (releaseResponse == null) { + LogGenericError("GetLatestRelease returned null"); + return null; } if (releaseResponse.IsPreRelease) { + LogGenericError("GetLatestRelease returned pre-release"); + return null; } - if (stable && !((releaseResponse.PublishedAt - DateTime.UtcNow).Duration() > TimeSpan.FromHours(3))) { - // Skip updates that are too recent + if (stable && ((releaseResponse.PublishedAt - DateTime.UtcNow).Duration() < TimeSpan.FromHours(3))) { + LogGenericDebug("GetLatestRelease returned too recent"); + return null; } @@ -48,14 +71,12 @@ public class GithubPluginUpdater(Lazy version) { if (!forced && (CurrentVersion >= newVersion)) { // Allow same version to be re-updated when we're updating ASF release and more than one asset is found - potential compatibility difference if ((CurrentVersion > newVersion) || !asfUpdate || (releaseResponse.Assets.Count(static asset => asset.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) < 2)) { - //ASF.ArchiLogger.LogGenericInfo(Strings.FormatPluginUpdateNotFound(Name, Version, newVersion)); - return null; } } if (releaseResponse.Assets.Count == 0) { - //ASF.ArchiLogger.LogGenericWarning(Strings.FormatPluginUpdateNoAssetFound(Name, Version, newVersion)); + LogGenericError($"GetLatestRelease for version {newVersion} returned no assets"); return null; } @@ -63,12 +84,12 @@ public class GithubPluginUpdater(Lazy version) { ReleaseAsset? asset = releaseResponse.Assets.FirstOrDefault(static asset => asset.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) && (asset.Size > (1 << 18))); if ((asset == null) || !releaseResponse.Assets.Contains(asset)) { - //ASF.ArchiLogger.LogGenericWarning(Strings.FormatPluginUpdateNoAssetFound(Name, Version, newVersion)); + LogGenericError($"GetLatestRelease for version {newVersion} returned no valid assets"); return null; } - //.ArchiLogger.LogGenericInfo(Strings.FormatPluginUpdateFound(Name, Version, newVersion)); + LogGenericDebug($"GetLatestRelease for version {newVersion} returned asset {asset.Name} with url {asset.DownloadURL}"); return asset.DownloadURL; } diff --git a/ASFFreeGames/Reddit/RedditHelper.cs b/ASFFreeGames/Reddit/RedditHelper.cs index c5f2128..97597da 100644 --- a/ASFFreeGames/Reddit/RedditHelper.cs +++ b/ASFFreeGames/Reddit/RedditHelper.cs @@ -43,7 +43,7 @@ IReadOnlyCollection returnValue() { } // ReSharper disable once LoopCanBePartlyConvertedToQuery - foreach (JsonNode? comment in children.AsArray()) { + foreach (JsonNode? comment in (JsonArray) children) { JsonNode? commentData = comment?["data"]; if (commentData is null) { diff --git a/ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs b/ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs index fe89a4e..7ec6264 100644 --- a/ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs +++ b/ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs @@ -89,10 +89,10 @@ internal static List ParseUrls(JsonNode json) { return []; } - List uris = new(instances.AsArray().Count); + List uris = new(((JsonArray) instances).Count); // ReSharper disable once LoopCanBePartlyConvertedToQuery - foreach (JsonNode? instance in instances.AsArray()) { + foreach (JsonNode? instance in (JsonArray) instances) { JsonNode? url = instance?["url"]; if (Uri.TryCreate(url?.GetValue() ?? "", UriKind.Absolute, out Uri? instanceUri) && instanceUri.Scheme is "http" or "https") { diff --git a/ASFFreeGames/Utils/Workarounds/AsyncLocal.cs b/ASFFreeGames/Utils/Workarounds/AsyncLocal.cs new file mode 100644 index 0000000..737df59 --- /dev/null +++ b/ASFFreeGames/Utils/Workarounds/AsyncLocal.cs @@ -0,0 +1,84 @@ +using System; +using System.Reflection; + +namespace Maxisoft.ASF.Utils.Workarounds; + +public sealed class AsyncLocal { + // ReSharper disable once StaticMemberInGenericType + private static readonly Type? AsyncLocalType; + +#pragma warning disable CA1810 + static AsyncLocal() { +#pragma warning restore CA1810 + try { + AsyncLocalType = Type.GetType("System.Threading.AsyncLocal`1") + ?.MakeGenericType(typeof(T)); + } + catch (InvalidOperationException) { + // ignore + } + + try { + AsyncLocalType ??= Type.GetType("System.Threading.AsyncLocal") + ?.MakeGenericType(typeof(T)); + } + + catch (InvalidOperationException) { + // ignore + } + } + + private readonly object? Delegate; + private T? NonSafeValue; + + /// Instantiates an instance that does not receive change notifications. + public AsyncLocal() { + if (AsyncLocalType is not null) { + try { + Delegate = Activator.CreateInstance(AsyncLocalType)!; + } + catch (Exception) { + // ignored + } + } + } + + /// Gets or sets the value of the ambient data. + /// The value of the ambient data. If no value has been set, the returned value is default(T). + public T? Value { + get { + if (Delegate is not null) { + try { + PropertyInfo? property = Delegate.GetType().GetProperty("Value"); + + if (property is not null) { + return (T) property.GetValue(Delegate)!; + } + } + catch (Exception) { + // ignored + } + } + + return (T) NonSafeValue!; + } + set { + if (Delegate is not null) { + try { + PropertyInfo? property = Delegate.GetType().GetProperty("Value"); + + if (property is not null) { + property.SetValue(Delegate, value); + + return; + } + } + catch (Exception) { + // ignored + } + } + + NonSafeValue = value; + } + } +} diff --git a/Directory.Build.props b/Directory.Build.props index 8ee0ef4..e8ce76b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ ASFFreeGames - 1.8.0.0 + 1.8.1.0 net8.0