diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 5b94876bc..57496d110 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,5 +1,10 @@ 更新履歴 +==== Ver 3.11.0(2024/01/07) + * NEW: Cookie使用時の関連発言表示に対応 + * FIX: APIリクエストのタイムアウト時に接続が切断されない場合がある不具合を修正 + * FIX: 存在しないユーザーのプロフィールを取得しようとした場合のエラーが適切に処理されない不具合を修正 + ==== Ver 3.10.1(2023/12/23) * FIX: OAuth 1.0a によるAPIアクセスに失敗する不具合を修正 diff --git a/OpenTween.Tests/Api/GraphQL/TimelineResponseTest.cs b/OpenTween.Tests/Api/GraphQL/TimelineResponseTest.cs new file mode 100644 index 000000000..4a793df84 --- /dev/null +++ b/OpenTween.Tests/Api/GraphQL/TimelineResponseTest.cs @@ -0,0 +1,41 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2024 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +using System.Threading.Tasks; +using Xunit; + +namespace OpenTween.Api.GraphQL +{ + public class TimelineResponseTest + { + [Fact] + public async Task ToTwitterStatuses_Test() + { + using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/SearchTimeline_SimpleTweet.json"); + var tweets = TimelineTweet.ExtractTimelineTweets(await apiResponse.ReadAsJsonXml()); + var timelineResponse = new TimelineResponse(tweets, "", ""); + + var statuses = timelineResponse.ToTwitterStatuses(); + Assert.Single(statuses); + Assert.Equal("1619433164757413894", statuses[0].IdStr); + } + } +} diff --git a/OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs b/OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs index 9c4e287d9..292fbadad 100644 --- a/OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs +++ b/OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs @@ -81,5 +81,30 @@ public async Task Send_UserUnavailableTest() mock.VerifyAll(); } + + [Fact] + public async Task Send_EmptyTest() + { + // ユーザーが存在しない場合にエラー情報を含まない空のオブジェクトが返されることがある + using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/UserByScreenName_Empty.json"); + + var mock = new Mock(); + mock.Setup(x => + x.SendAsync(It.IsAny()) + ) + .ReturnsAsync(apiResponse); + + var request = new UserByScreenNameRequest + { + ScreenName = "==INVALID==", + }; + + var ex = await Assert.ThrowsAsync( + () => request.Send(mock.Object) + ); + Assert.Equal("User is not available.", ex.Message); + + mock.VerifyAll(); + } } } diff --git a/OpenTween.Tests/AsyncExceptionBoundaryTest.cs b/OpenTween.Tests/AsyncExceptionBoundaryTest.cs new file mode 100644 index 000000000..f17e34b4d --- /dev/null +++ b/OpenTween.Tests/AsyncExceptionBoundaryTest.cs @@ -0,0 +1,128 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2024 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +using System; +using System.IO; +using System.Threading.Tasks; +using Xunit; + +namespace OpenTween +{ + public class AsyncExceptionBoundaryTest + { + [Fact] + public async Task Wrap_SynchronousTest() + { + static Task AsyncFunc() + => throw new OperationCanceledException(); + + // 例外を無視して終了する + await AsyncExceptionBoundary.Wrap(AsyncFunc); + } + + [Fact] + public async Task Wrap_AsynchronousTest() + { + static async Task AsyncFunc() + { + await Task.Yield(); + throw new OperationCanceledException(); + } + + // 例外を無視して終了する + await AsyncExceptionBoundary.Wrap(AsyncFunc); + } + + [Fact] + public async Task Wrap_IgnoredTest() + { + static Task AsyncFunc() + => throw new OperationCanceledException(); + + static bool IgnoreException(Exception ex) + => ex is OperationCanceledException; + + // 例外を無視して終了する + await AsyncExceptionBoundary.Wrap(AsyncFunc, IgnoreException); + } + + [Fact] + public async Task Wrap_NotIgnoredTest() + { + static Task AsyncFunc() + => throw new IOException(); + + static bool IgnoreException(Exception ex) + => ex is OperationCanceledException; + + // 例外を返して終了する + await Assert.ThrowsAsync( + () => AsyncExceptionBoundary.Wrap(AsyncFunc, IgnoreException) + ); + } + + [Fact] + public async Task IgnoreException_Test() + { + var task = Task.FromException(new OperationCanceledException()); + + // 例外を無視して終了する + await AsyncExceptionBoundary.IgnoreException(task); + } + + [Fact] + public async Task IgnoreExceptionAndDispose_Test() + { + var task = Task.FromException(new OperationCanceledException()); + + // 例外を無視して終了する + await AsyncExceptionBoundary.IgnoreExceptionAndDispose(task); + } + + [Fact] + public async Task IgnoreExceptionAndDispose_DisposeTest() + { + using var image = TestUtils.CreateDummyImage(); + var task = Task.FromResult(image); // IDisposable であることを静的に判定できない場合も想定 + + // 正常終了したとき Result が IDisposable だった場合は破棄する + await AsyncExceptionBoundary.IgnoreExceptionAndDispose(task); + Assert.True(image.IsDisposed); + } + + [Fact] + public async Task IgnoreExceptionAndDispose_IEnumerableTest() + { + using var image1 = TestUtils.CreateDummyImage(); + using var image2 = TestUtils.CreateDummyImage(); + var tasks = new[] + { + Task.FromResult(image1), // IDisposable であることを静的に判定できない場合も想定 + Task.FromResult(image2), + }; + + // 正常終了したとき Result が IDisposable だった場合は破棄する + await AsyncExceptionBoundary.IgnoreExceptionAndDispose(tasks); + Assert.True(image1.IsDisposed); + Assert.True(image2.IsDisposed); + } + } +} diff --git a/OpenTween.Tests/Resources/Responses/UserByScreenName_Empty.json b/OpenTween.Tests/Resources/Responses/UserByScreenName_Empty.json new file mode 100644 index 000000000..73cd5c32b --- /dev/null +++ b/OpenTween.Tests/Resources/Responses/UserByScreenName_Empty.json @@ -0,0 +1,3 @@ +{ + "data": {} +} diff --git a/OpenTween/Api/GraphQL/TimelineResponse.cs b/OpenTween/Api/GraphQL/TimelineResponse.cs index d92430a07..052ea6108 100644 --- a/OpenTween/Api/GraphQL/TimelineResponse.cs +++ b/OpenTween/Api/GraphQL/TimelineResponse.cs @@ -21,11 +21,8 @@ #nullable enable -using System; -using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; +using OpenTween.Api.DataModel; namespace OpenTween.Api.GraphQL { @@ -33,5 +30,12 @@ public record TimelineResponse( TimelineTweet[] Tweets, string? CursorTop, string? CursorBottom - ); + ) + { + public TwitterStatus[] ToTwitterStatuses() + => this.Tweets + .Where(x => !x.IsTombstone) + .Select(x => x.ToTwitterStatus()) + .ToArray(); + } } diff --git a/OpenTween/Api/GraphQL/UserByScreenNameRequest.cs b/OpenTween/Api/GraphQL/UserByScreenNameRequest.cs index 4b3c8ccad..f3d627dff 100644 --- a/OpenTween/Api/GraphQL/UserByScreenNameRequest.cs +++ b/OpenTween/Api/GraphQL/UserByScreenNameRequest.cs @@ -71,20 +71,33 @@ public async Task Send(IApiConnection apiConnection) ErrorResponse.ThrowIfError(rootElm); - var userElm = rootElm.XPathSelectElement("/data/user/result"); - this.ThrowIfUserUnavailable(userElm); + try + { + var userElm = rootElm.XPathSelectElement("/data/user/result"); + this.ThrowIfUserUnavailable(userElm); - return new(userElm); + return new(userElm); + } + catch (WebApiException ex) + { + ex.ResponseText = JsonUtils.JsonXmlToString(rootElm); + throw; + } } - private void ThrowIfUserUnavailable(XElement userElm) + private void ThrowIfUserUnavailable(XElement? userElm) { + if (userElm == null) + { + var errorText = "User is not available."; + throw new WebApiException(errorText); + } + var typeName = userElm.Element("__typename")?.Value; if (typeName == "UserUnavailable") { var errorText = userElm.Element("message")?.Value ?? "User is not available."; - var json = JsonUtils.JsonXmlToString(userElm); - throw new WebApiException(errorText, json); + throw new WebApiException(errorText); } } } diff --git a/OpenTween/AsyncExceptionBoundary.cs b/OpenTween/AsyncExceptionBoundary.cs new file mode 100644 index 000000000..61d7b6da2 --- /dev/null +++ b/OpenTween/AsyncExceptionBoundary.cs @@ -0,0 +1,73 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2024 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace OpenTween +{ + public static class AsyncExceptionBoundary + { + public static Task Wrap(Func func) + => Wrap(func, _ => true); + + public static async Task Wrap(Func func, Func ignoreException) + { + try + { + await func(); + } + catch (Exception ex) when (ignoreException(ex)) + { + } + } + + public static async Task IgnoreException(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + } + } + + public static async Task IgnoreExceptionAndDispose(Task task) + { + try + { + var ret = await task.ConfigureAwait(false); + (ret as IDisposable)?.Dispose(); + } + catch + { + } + } + + public static Task IgnoreExceptionAndDispose(IEnumerable> tasks) + => Task.WhenAll(tasks.Select(x => IgnoreExceptionAndDispose(x))); + } +} diff --git a/OpenTween/Connection/TwitterApiConnection.cs b/OpenTween/Connection/TwitterApiConnection.cs index 410359737..64833db4f 100644 --- a/OpenTween/Connection/TwitterApiConnection.cs +++ b/OpenTween/Connection/TwitterApiConnection.cs @@ -156,17 +156,7 @@ public static async Task HandleTimeout(Func> fu // タイムアウト // キャンセル後のタスクで発生した例外は無視する - static async Task IgnoreExceptions(Task task) - { - try - { - await task.ConfigureAwait(false); - } - catch - { - } - } - _ = IgnoreExceptions(task); + _ = AsyncExceptionBoundary.IgnoreExceptionAndDispose(task); cts.Cancel(); throw new OperationCanceledException("Timeout", cancellactionToken); diff --git a/OpenTween/ImageCache.cs b/OpenTween/ImageCache.cs index f16725384..19173df5a 100644 --- a/OpenTween/ImageCache.cs +++ b/OpenTween/ImageCache.cs @@ -56,25 +56,11 @@ public ImageCache() this.InnerDictionary = new LRUCacheDictionary>(trimLimit: 300, autoTrimCount: 100); this.InnerDictionary.CacheRemoved += (s, e) => { - // まだ参照されている場合もあるのでDisposeはファイナライザ任せ this.CacheRemoveCount++; + // まだ参照されている場合もあるのでDisposeはファイナライザ任せ var task = e.Item.Value; - if (task.Status != TaskStatus.RanToCompletion || task.IsFaulted) - { - // Task の例外がハンドルされないまま破棄されると AggregateException が発生するため try-catch で処理する Task を挟む - static async Task HandleException(Task t) - { - try - { - _ = await t.ConfigureAwait(false); - } - catch - { - } - } - _ = HandleException(task); - } + _ = AsyncExceptionBoundary.IgnoreException(task); }; this.cancelTokenSource = new CancellationTokenSource(); diff --git a/OpenTween/Properties/AssemblyInfo.cs b/OpenTween/Properties/AssemblyInfo.cs index 1b6461bdb..30a1975b7 100644 --- a/OpenTween/Properties/AssemblyInfo.cs +++ b/OpenTween/Properties/AssemblyInfo.cs @@ -22,7 +22,7 @@ // 次の GUID は、このプロジェクトが COM に公開される場合の、typelib の ID です [assembly: Guid("2d0ae0ba-adac-49a2-9b10-26fd69e695bf")] -[assembly: AssemblyVersion("3.10.1.0")] +[assembly: AssemblyVersion("3.11.0.0")] [assembly: InternalsVisibleTo("OpenTween.Tests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // for Moq diff --git a/OpenTween/Properties/Resources.Designer.cs b/OpenTween/Properties/Resources.Designer.cs index 72428ee55..7a466ce67 100644 --- a/OpenTween/Properties/Resources.Designer.cs +++ b/OpenTween/Properties/Resources.Designer.cs @@ -580,6 +580,11 @@ internal static string ChangeIconToolStripMenuItem_Confirm { /// /// 更新履歴 /// + ///==== Ver 3.11.0(2024/01/07) + /// * NEW: Cookie使用時の関連発言表示に対応 + /// * FIX: APIリクエストのタイムアウト時に接続が切断されない場合がある不具合を修正 + /// * FIX: 存在しないユーザーのプロフィールを取得しようとした場合のエラーが適切に処理されない不具合を修正 + /// ///==== Ver 3.10.1(2023/12/23) /// * FIX: OAuth 1.0a によるAPIアクセスに失敗する不具合を修正 /// @@ -590,12 +595,7 @@ internal static string ChangeIconToolStripMenuItem_Confirm { ///==== Ver 3.9.0(2023/12/03) /// * NEW: graphqlエンドポイントに対するレートリミットの表示に対応 /// * CHG: タイムライン更新時に全件ではなく新着投稿のみ差分を取得する動作に変更 - /// * FIX: 設定したタイムアウト時間を超えてAPI接続が持続する場合がある不具合を修正 - /// * FIX: プロフィール情報のURL欄のパースに失敗する場合がある不具合を修正 - /// - この問題が起きるユーザーのツイートが含まれているとタイムラインの読み込みに失敗する問題も改善されます - /// - ///==== Ver 3.8.0(2023/11/29) - /// * NEW: graphqlエンドポイントを使用した [残りの文字列は切り詰められました]"; に類似しているローカライズされた文字列を検索します。 + /// * FIX: 設定したタイムアウト時間を超えてAPI接続が持続する場合がある不 [残りの文字列は切り詰められました]"; に類似しているローカライズされた文字列を検索します。 /// internal static string ChangeLog { get { diff --git a/OpenTween/TaskCollection.cs b/OpenTween/TaskCollection.cs index 9569860a5..e70ad91c3 100644 --- a/OpenTween/TaskCollection.cs +++ b/OpenTween/TaskCollection.cs @@ -59,21 +59,13 @@ public Task RunAll(bool runOnThreadPool) private Task WrapAsyncFunc(Func func, bool runOnThreadPool) { - async Task TaskExceptionBoundary(Func func) - { - try - { - await func(); - } - catch (Exception ex) when (this.ignoreExceptionFunc(ex)) - { - } - } + Task WrappedFunc() + => AsyncExceptionBoundary.Wrap(func, this.ignoreExceptionFunc); if (runOnThreadPool) - return Task.Run(() => TaskExceptionBoundary(func)); + return Task.Run(WrappedFunc); else - return TaskExceptionBoundary(func); + return WrappedFunc(); } } } diff --git a/OpenTween/TweetThumbnail.cs b/OpenTween/TweetThumbnail.cs index 042a8d135..90c26219a 100644 --- a/OpenTween/TweetThumbnail.cs +++ b/OpenTween/TweetThumbnail.cs @@ -156,27 +156,7 @@ private async Task> GetThumbailInfoAsync(PostClass po private void DisposeImages() { var oldImageTasks = this.loadImageTasks.OfType>().ToArray(); - - static async Task DisposeTaskResults(Task[] tasks) - { - try - { - await Task.WhenAll(tasks).ConfigureAwait(false); - } - catch - { - } - - foreach (var task in tasks) - { - if (task.IsFaulted || task.IsCanceled) - continue; - - task.Result.Dispose(); - } - } - - _ = DisposeTaskResults(oldImageTasks); + _ = AsyncExceptionBoundary.IgnoreExceptionAndDispose(oldImageTasks); } } } diff --git a/OpenTween/Twitter.cs b/OpenTween/Twitter.cs index 341b69631..63ba0066c 100644 --- a/OpenTween/Twitter.cs +++ b/OpenTween/Twitter.cs @@ -677,9 +677,7 @@ public async Task GetUserTimelineApi(bool read, UserTimelineTabModel tab, bool m var response = await request.Send(this.Api.Connection) .ConfigureAwait(false); - statuses = response.Tweets - .Where(x => !x.IsTombstone) - .Select(x => x.ToTwitterStatus()) + statuses = response.ToTwitterStatuses() .Where(x => x.User.IdStr == userId) // リプライツリーに含まれる他ユーザーのツイートを除外 .ToArray(); @@ -884,12 +882,10 @@ public async Task GetListStatus(bool read, ListTimelineTabModel tab, bool more, var response = await request.Send(this.Api.Connection) .ConfigureAwait(false); - var convertedStatuses = response.Tweets - .Where(x => !x.IsTombstone) - .Select(x => x.ToTwitterStatus()); + var convertedStatuses = response.ToTwitterStatuses(); if (!SettingManager.Instance.Common.IsListsIncludeRts) - convertedStatuses = convertedStatuses.Where(x => x.RetweetedStatus == null); + convertedStatuses = convertedStatuses.Where(x => x.RetweetedStatus == null).ToArray(); statuses = convertedStatuses.ToArray(); tab.CursorBottom = response.CursorBottom; @@ -1073,10 +1069,24 @@ private async Task GetConversationPosts(PostClass firstPost, PostCl else query += $" from:{targetPost.ScreenName} to:{targetPost.ScreenName}"; - var statuses = await this.Api.SearchTweets(query, count: 100) - .ConfigureAwait(false); + TwitterStatus[] statuses; + if (this.Api.AuthType == APIAuthType.TwitterComCookie) + { + var request = new SearchTimelineRequest(query); + var response = await request.Send(this.Api.Connection) + .ConfigureAwait(false); + + statuses = response.ToTwitterStatuses(); + } + else + { + var response = await this.Api.SearchTweets(query, count: 100) + .ConfigureAwait(false); - return statuses.Statuses.Select(x => this.CreatePostsFromStatusData(x)).ToArray(); + statuses = response.Statuses; + } + + return statuses.Select(x => this.CreatePostsFromStatusData(x)).ToArray(); } public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more) @@ -1094,10 +1104,7 @@ public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more) var response = await request.Send(this.Api.Connection) .ConfigureAwait(false); - statuses = response.Tweets - .Where(x => !x.IsTombstone) - .Select(x => x.ToTwitterStatus()) - .ToArray(); + statuses = response.ToTwitterStatuses(); tab.CursorBottom = response.CursorBottom; diff --git a/appveyor.yml b/appveyor.yml index fb643672c..b60cfa1cd 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,4 @@ -version: 3.10.0.{build} +version: 3.10.1.{build} os: Visual Studio 2022