From 0a9a0692e69fd08a10a65a13505b4a4c80df4e64 Mon Sep 17 00:00:00 2001 From: "K.I.R.O" <236710061+vinney491-dotcom@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:59:08 -0500 Subject: [PATCH 1/2] fix(core): harden session, settings, and realtime lifecycle races Stop Follow Character fighting itself, clear DM/key state across account changes, marshal confirms and incoming calls onto Framework, and fix realtime Stop/Start plus settings privacy retry storms. Co-authored-by: Cursor --- .../Apps/Aethergram/AethergramApp.Settings.cs | 4 +- src/Aetherphone/Apps/Clock/ClockApp.Alarms.cs | 2 +- .../Apps/Collections/CollectionsApp.Browse.cs | 3 + .../Apps/Collections/CollectionsApp.cs | 3 - .../Apps/Settings/Pages/AccountPage.cs | 4 +- .../Apps/Settings/Pages/AppearancePage.cs | 10 +-- .../Apps/Settings/Pages/NamePage.cs | 16 ++-- .../Apps/Settings/Pages/PrivacyPage.cs | 5 +- .../Apps/Settings/Pages/ProfilePage.cs | 6 +- .../Apps/Settings/Pages/RootSettingsPage.cs | 3 +- .../Apps/Settings/Pages/TagsMentionsPage.cs | 4 +- src/Aetherphone/Core/Aethernet/SignInFlow.cs | 4 +- src/Aetherphone/Core/Aethernet/StoreWork.cs | 1 - .../Core/Confirm/ConfirmService.cs | 27 ++++-- src/Aetherphone/Core/Crypto/KeyVault.cs | 6 +- .../Core/Message/ChatThreadStoreBase.cs | 2 +- .../Core/Telephony/CallAudioController.cs | 10 ++- src/Aetherphone/Core/Telephony/CallHub.cs | 20 ++++- .../Core/Telephony/RealtimeConnection.cs | 86 ++++++++++++++++--- src/Aetherphone/Plugin.cs | 27 +++++- .../Windows/Components/SocialProfilePages.cs | 14 +-- 21 files changed, 196 insertions(+), 61 deletions(-) diff --git a/src/Aetherphone/Apps/Aethergram/AethergramApp.Settings.cs b/src/Aetherphone/Apps/Aethergram/AethergramApp.Settings.cs index 8e79079b..1bc540bd 100644 --- a/src/Aetherphone/Apps/Aethergram/AethergramApp.Settings.cs +++ b/src/Aetherphone/Apps/Aethergram/AethergramApp.Settings.cs @@ -138,12 +138,14 @@ private void EnsureMessagePolicyLoaded() { messagePolicy = me.MessagePolicy; privateAccount = me.IsPrivate; - messagePolicyLoaded = true; } + + messagePolicyLoaded = true; } catch (Exception exception) { AepLog.Warning($"Aethergram message privacy load failed: {exception.Message}"); + messagePolicyLoaded = true; } finally { diff --git a/src/Aetherphone/Apps/Clock/ClockApp.Alarms.cs b/src/Aetherphone/Apps/Clock/ClockApp.Alarms.cs index dc7adb53..e53946b3 100644 --- a/src/Aetherphone/Apps/Clock/ClockApp.Alarms.cs +++ b/src/Aetherphone/Apps/Clock/ClockApp.Alarms.cs @@ -54,7 +54,7 @@ private void DrawAlarmRow(Rect row, AlarmEntry alarm) { var scale = UiScale.Current; var timeInk = alarm.Enabled ? ui.TitleInk : ui.MutedInk; - var time = $"{alarm.Hour:D2}:{alarm.Minute:D2}"; + var time = TimeText.Clock(new DateTime(1, 1, 1, alarm.Hour, alarm.Minute, 0)); var timeSize = Typography.Measure(time, TextStyles.Title1); Typography.Draw(new Vector2(row.Min.X, row.Center.Y - timeSize.Y * 0.5f), time, timeInk, TextStyles.Title1); diff --git a/src/Aetherphone/Apps/Collections/CollectionsApp.Browse.cs b/src/Aetherphone/Apps/Collections/CollectionsApp.Browse.cs index abcfd033..3602ffd8 100644 --- a/src/Aetherphone/Apps/Collections/CollectionsApp.Browse.cs +++ b/src/Aetherphone/Apps/Collections/CollectionsApp.Browse.cs @@ -539,6 +539,9 @@ private bool DrawPagerButton(Vector2 center, bool left, bool enabled, float scal private void DrawOwnershipSegments(Rect bar) { + ownershipLabels[0] = Loc.T(L.Collections.FilterAll); + ownershipLabels[1] = Loc.T(L.Collections.FilterOwned); + ownershipLabels[2] = Loc.T(L.Collections.FilterMissing); var selected = SegmentStrip.Draw("collections.ownership", bar, ownershipLabels, (int)ownership, ui.Palette); if (selected != (int)ownership) { diff --git a/src/Aetherphone/Apps/Collections/CollectionsApp.cs b/src/Aetherphone/Apps/Collections/CollectionsApp.cs index ba2b73a6..abd7dc03 100644 --- a/src/Aetherphone/Apps/Collections/CollectionsApp.cs +++ b/src/Aetherphone/Apps/Collections/CollectionsApp.cs @@ -89,9 +89,6 @@ public void OnOpened() { router.Reset(); ResetFilters(); - ownershipLabels[0] = Loc.T(L.Collections.FilterAll); - ownershipLabels[1] = Loc.T(L.Collections.FilterOwned); - ownershipLabels[2] = Loc.T(L.Collections.FilterMissing); lodestoneId = ResolveLocalId(); catalog.ResetOwned(); catalog.ResetSummaries(); diff --git a/src/Aetherphone/Apps/Settings/Pages/AccountPage.cs b/src/Aetherphone/Apps/Settings/Pages/AccountPage.cs index ecd85dcb..cfd9972b 100644 --- a/src/Aetherphone/Apps/Settings/Pages/AccountPage.cs +++ b/src/Aetherphone/Apps/Settings/Pages/AccountPage.cs @@ -544,7 +544,9 @@ private void DrawAccountsSection(PhoneTheme theme, float scale, bool signedIn) AddAccount(); } - if (SettingsRow.Bool(actions.NextRow(), Loc.T(L.Account.FollowCharacter), session.FollowsCharacter, theme)) + var followCharacter = SettingsRow.Bool(actions.NextRow(), Loc.T(L.Account.FollowCharacter), + session.FollowsCharacter, theme); + if (followCharacter != session.FollowsCharacter) { ToggleFollowCharacter(); } diff --git a/src/Aetherphone/Apps/Settings/Pages/AppearancePage.cs b/src/Aetherphone/Apps/Settings/Pages/AppearancePage.cs index 3a6ef0ab..968f3af9 100644 --- a/src/Aetherphone/Apps/Settings/Pages/AppearancePage.cs +++ b/src/Aetherphone/Apps/Settings/Pages/AppearancePage.cs @@ -49,7 +49,7 @@ public void Draw(in PhoneContext context, Rect body) { SettingsSection.Header(Loc.T(L.Settings.Theme), theme); var accentLabel = Loc.T(L.Settings.Accent); - var cardWidth = ImGui.GetContentRegionAvail().X - 2f * Metrics.Space.Lg * UiScale.Current; + var cardWidth = ScrollLayout.StableContentWidth() - 2f * Metrics.Space.Lg * UiScale.Current; var accentStacked = SwatchStrip.NeedsTwoRows(accentLabel, ThemeCatalog.Accents.Count + 1, cardWidth); var card = GroupCard.Begin(theme, accentStacked ? 5 : 4); var modeIndex = SegmentStrip.Draw("settings.themeMode", card.NextRow(), ModeLabels(), CurrentModeIndex(), @@ -179,12 +179,12 @@ private void DrawHomeSection(PhoneTheme theme) { SettingsSection.Header(Loc.T(L.Home.HomeScreen), theme); var card = GroupCard.Begin(theme, 3); + var previousDensityIndex = DensityIndex(configuration.HomeGridRows); var densityIndex = SegmentStrip.Draw("settings.homeGrid", card.NextRow(), DensityLabels(), - DensityIndex(configuration.HomeGridRows), theme); - var rows = GridRowOptions[densityIndex]; - if (rows != configuration.HomeGridRows) + previousDensityIndex, theme); + if (densityIndex != previousDensityIndex) { - configuration.HomeGridRows = rows; + configuration.HomeGridRows = GridRowOptions[densityIndex]; configuration.Save(); } diff --git a/src/Aetherphone/Apps/Settings/Pages/NamePage.cs b/src/Aetherphone/Apps/Settings/Pages/NamePage.cs index b7f1f35a..11f8dc04 100644 --- a/src/Aetherphone/Apps/Settings/Pages/NamePage.cs +++ b/src/Aetherphone/Apps/Settings/Pages/NamePage.cs @@ -27,7 +27,7 @@ internal sealed class NamePage : ISettingsPage, IDisposable private readonly CancellationTokenSource cancellation = new(); private string editDisplay = string.Empty; private string editHandle = string.Empty; - private string editStatus = string.Empty; + private LocString? editStatusKey; private string? loadedFor; private volatile bool busy; private volatile int outcome; @@ -69,13 +69,13 @@ public void Draw(in PhoneContext context, Rect body) if (outcome == 2) { outcome = 0; - editStatus = Loc.T(L.Account.HandleTaken); + editStatusKey = L.Account.HandleTaken; } if (outcome == 3) { outcome = 0; - editStatus = Loc.T(L.Account.CannotReach); + editStatusKey = L.Account.CannotReach; } if (loadedFor != user.Id) @@ -83,7 +83,7 @@ public void Draw(in PhoneContext context, Rect body) loadedFor = user.Id; editDisplay = user.DisplayName; editHandle = user.Handle; - editStatus = string.Empty; + editStatusKey = null; } using (AppSurface.Begin(body)) @@ -101,12 +101,12 @@ public void Draw(in PhoneContext context, Rect body) Save(); } - if (editStatus.Length > 0) + if (editStatusKey is { } statusKey) { ImGui.Dummy(new Vector2(0f, 10f * scale)); using (ImRaii.PushColor(ImGuiCol.Text, theme.Danger)) { - Typography.Wrapped(editStatus); + Typography.Wrapped(Loc.T(statusKey)); } } @@ -188,12 +188,12 @@ private void Save() if (editDisplay.Trim().Length == 0 || !SocialProfilePages.IsHandleValid(editHandle)) { - editStatus = Loc.T(L.Account.HandleRules); + editStatusKey = L.Account.HandleRules; return; } busy = true; - editStatus = string.Empty; + editStatusKey = null; var request = new UpdateProfileRequest(editDisplay.Trim(), editHandle.Trim(), null); var token = cancellation.Token; _ = Task.Run(async () => diff --git a/src/Aetherphone/Apps/Settings/Pages/PrivacyPage.cs b/src/Aetherphone/Apps/Settings/Pages/PrivacyPage.cs index c81fd399..054c1d62 100644 --- a/src/Aetherphone/Apps/Settings/Pages/PrivacyPage.cs +++ b/src/Aetherphone/Apps/Settings/Pages/PrivacyPage.cs @@ -226,12 +226,15 @@ private void EnsureLoaded() { shareReadReceipts = me.ShareReadReceipts; sharePresence = me.SharePresence; - chatPrivacyLoaded = true; } + + // Latch loaded even on null/failure so a dead endpoint cannot refetch every frame. + chatPrivacyLoaded = true; } catch (Exception exception) { AepLog.Warning($"Chat privacy load failed: {exception.Message}"); + chatPrivacyLoaded = true; } finally { diff --git a/src/Aetherphone/Apps/Settings/Pages/ProfilePage.cs b/src/Aetherphone/Apps/Settings/Pages/ProfilePage.cs index e0ddec26..51cf7cf3 100644 --- a/src/Aetherphone/Apps/Settings/Pages/ProfilePage.cs +++ b/src/Aetherphone/Apps/Settings/Pages/ProfilePage.cs @@ -40,7 +40,11 @@ public void Draw(in PhoneContext context, Rect body) var theme = context.Theme; using (AppSurface.Begin(body)) { - if (session.IsSignedIn && session.CurrentUser is not null && !initialSynced) + if (!session.IsSignedIn) + { + initialSynced = false; + } + else if (session.CurrentUser is not null && !initialSynced) { initialSynced = true; PushTimeZone(null); diff --git a/src/Aetherphone/Apps/Settings/Pages/RootSettingsPage.cs b/src/Aetherphone/Apps/Settings/Pages/RootSettingsPage.cs index 138cb125..7c4b29db 100644 --- a/src/Aetherphone/Apps/Settings/Pages/RootSettingsPage.cs +++ b/src/Aetherphone/Apps/Settings/Pages/RootSettingsPage.cs @@ -116,7 +116,8 @@ private static void DrawVersion(PhoneTheme theme) var size = Typography.Measure(label, 0.78f); var origin = ImGui.GetCursorScreenPos(); var avail = ImGui.GetContentRegionAvail().X; - Typography.Draw(new Vector2(origin.X + (avail - size.X) * 0.5f, origin.Y), label, theme.TextMuted, 0.78f); + Typography.Draw(ImGui.GetWindowDrawList(), new Vector2(origin.X + (avail - size.X) * 0.5f, origin.Y), label, + theme.TextMuted, 0.78f); ImGui.Dummy(new Vector2(avail, size.Y)); } } diff --git a/src/Aetherphone/Apps/Settings/Pages/TagsMentionsPage.cs b/src/Aetherphone/Apps/Settings/Pages/TagsMentionsPage.cs index 193c10cf..e7c43db2 100644 --- a/src/Aetherphone/Apps/Settings/Pages/TagsMentionsPage.cs +++ b/src/Aetherphone/Apps/Settings/Pages/TagsMentionsPage.cs @@ -131,12 +131,14 @@ private void EnsureLoaded() mentionPolicy = me.MentionPolicy; tagPolicy = me.TagPolicy; requireTagApproval = me.RequireTagApproval; - loaded = true; } + + loaded = true; } catch (Exception exception) { AepLog.Warning($"Tag privacy load failed: {exception.Message}"); + loaded = true; } finally { diff --git a/src/Aetherphone/Core/Aethernet/SignInFlow.cs b/src/Aetherphone/Core/Aethernet/SignInFlow.cs index 321db841..523c6662 100644 --- a/src/Aetherphone/Core/Aethernet/SignInFlow.cs +++ b/src/Aetherphone/Core/Aethernet/SignInFlow.cs @@ -101,7 +101,7 @@ public void VerifyLodestone() if (result.Auth is { } auth) { session.SignIn(auth.Token, auth.User); - signedIn?.Invoke(); + _ = Plugin.Framework.RunOnFrameworkThread(() => signedIn?.Invoke()); Reset(); return; } @@ -188,7 +188,7 @@ private async Task PollXivLoopAsync(string flowId, int intervalSeconds, int expi if (result.Auth is { } auth) { session.SignIn(auth.Token, auth.User); - signedIn?.Invoke(); + _ = Plugin.Framework.RunOnFrameworkThread(() => signedIn?.Invoke()); Reset(); return; } diff --git a/src/Aetherphone/Core/Aethernet/StoreWork.cs b/src/Aetherphone/Core/Aethernet/StoreWork.cs index c6c427d4..155bb626 100644 --- a/src/Aetherphone/Core/Aethernet/StoreWork.cs +++ b/src/Aetherphone/Core/Aethernet/StoreWork.cs @@ -67,6 +67,5 @@ public void Run( public void Dispose() { cancellation.Cancel(); - cancellation.Dispose(); } } diff --git a/src/Aetherphone/Core/Confirm/ConfirmService.cs b/src/Aetherphone/Core/Confirm/ConfirmService.cs index 99898592..6d6d8668 100644 --- a/src/Aetherphone/Core/Confirm/ConfirmService.cs +++ b/src/Aetherphone/Core/Confirm/ConfirmService.cs @@ -25,6 +25,12 @@ internal sealed class ConfirmService public void Ask(ConfirmRequest request) { + if (!Plugin.Framework.IsInFrameworkUpdateThread) + { + _ = Plugin.Framework.RunOnFrameworkThread(() => Ask(request)); + return; + } + if (Active is not null) { queued.Enqueue(request); @@ -64,15 +70,26 @@ public void Proceed() Status = null; handler(ok => { - Busy = false; - if (ok) + void Finish() { - Advance(); + Busy = false; + if (ok) + { + Advance(); + } + else + { + Status = request.FailedMessage; + } } - else + + if (!Plugin.Framework.IsInFrameworkUpdateThread) { - Status = request.FailedMessage; + _ = Plugin.Framework.RunOnFrameworkThread(Finish); + return; } + + Finish(); }); return; } diff --git a/src/Aetherphone/Core/Crypto/KeyVault.cs b/src/Aetherphone/Core/Crypto/KeyVault.cs index a6c10c50..6ca7425e 100644 --- a/src/Aetherphone/Core/Crypto/KeyVault.cs +++ b/src/Aetherphone/Core/Crypto/KeyVault.cs @@ -24,22 +24,26 @@ internal sealed class KeyVault : IDisposable private EcPrivateKey? privateKey; private MyKeysDto? serverBundle; private volatile bool refreshing; + private string? lastUserId; public KeyVault(Configuration configuration, AethernetSession session, KeysClient client) { this.configuration = configuration; this.session = session; this.client = client; + lastUserId = session.CurrentUser?.Id; session.Changed += OnSessionChanged; } private void OnSessionChanged() { - if (session.IsSignedIn) + var userId = session.CurrentUser?.Id; + if (string.Equals(userId, lastUserId, StringComparison.Ordinal)) { return; } + lastUserId = userId; _ = RefreshAsync(CancellationToken.None); } diff --git a/src/Aetherphone/Core/Message/ChatThreadStoreBase.cs b/src/Aetherphone/Core/Message/ChatThreadStoreBase.cs index 6420b1e6..af12f4d5 100644 --- a/src/Aetherphone/Core/Message/ChatThreadStoreBase.cs +++ b/src/Aetherphone/Core/Message/ChatThreadStoreBase.cs @@ -104,7 +104,7 @@ protected ChatThreadStoreBase(string logTag, AethernetSession session, SafetyCli private void OnSessionAccountChanged() { var accountId = session.CurrentUser?.Id; - if (accountId is null || string.Equals(accountId, lastAccountId, StringComparison.Ordinal)) + if (string.Equals(accountId, lastAccountId, StringComparison.Ordinal)) { return; } diff --git a/src/Aetherphone/Core/Telephony/CallAudioController.cs b/src/Aetherphone/Core/Telephony/CallAudioController.cs index 4d3be86a..f77e5956 100644 --- a/src/Aetherphone/Core/Telephony/CallAudioController.cs +++ b/src/Aetherphone/Core/Telephony/CallAudioController.cs @@ -44,13 +44,15 @@ public bool EnsureStartedLocked(Guid callId, int localSlot) return false; } + if (localSlot < 0) + { + return false; + } + var input = AudioDevices.ResolveInput(configuration.CallInputDevice); var output = AudioDevices.ResolveOutput(configuration.CallOutputDevice); var created = new CallSession(callId, connection, input, output, volume) { Muted = muted, }; - if (localSlot >= 0) - { - created.SetLocalSlot(localSlot); - } + created.SetLocalSlot(localSlot); session = created; remoteSlots.Clear(); diff --git a/src/Aetherphone/Core/Telephony/CallHub.cs b/src/Aetherphone/Core/Telephony/CallHub.cs index ab933b5f..8805f643 100644 --- a/src/Aetherphone/Core/Telephony/CallHub.cs +++ b/src/Aetherphone/Core/Telephony/CallHub.cs @@ -468,7 +468,25 @@ private void HandleDeclined(Guid id, CallControl message) return; } - var pending = 0; + var fromId = message.From?.UserId; + if (fromId is not null) + { + if (dialingTo?.UserId == fromId) + { + dialingTo = null; + } + + for (var index = 0; index < roster.Length; index++) + { + if (roster[index].UserId == fromId) + { + var participant = roster[index]; + roster[index] = participant with { State = ParticipantState.Left }; + } + } + } + + var pending = dialingTo is not null ? 1 : 0; for (var index = 0; index < roster.Length; index++) { var participant = roster[index]; diff --git a/src/Aetherphone/Core/Telephony/RealtimeConnection.cs b/src/Aetherphone/Core/Telephony/RealtimeConnection.cs index 96b67c82..5e4851cc 100644 --- a/src/Aetherphone/Core/Telephony/RealtimeConnection.cs +++ b/src/Aetherphone/Core/Telephony/RealtimeConnection.cs @@ -11,12 +11,16 @@ internal sealed class RealtimeConnection : IDisposable private const int MaxMessageBytes = 1024 * 1024; private static readonly TimeSpan HealthyConnectionThreshold = TimeSpan.FromSeconds(60); private static readonly TimeSpan SendTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan StopJoinTimeout = TimeSpan.FromSeconds(2); private readonly AethernetSession session; private readonly object gate = new(); private readonly SemaphoreSlim sendLock = new(1, 1); private CancellationTokenSource? lifetime; private ClientWebSocket? socket; + private Task? runTask; + private int generation; private volatile bool connected; + private volatile bool disposed; public RealtimeConnection(AethernetSession session) { @@ -32,14 +36,15 @@ public void Start() { lock (gate) { - if (lifetime is not null) + if (disposed || lifetime is not null) { return; } lifetime = new CancellationTokenSource(); var token = lifetime.Token; - _ = Task.Run(() => RunAsync(token)); + var runGeneration = ++generation; + runTask = Task.Run(() => RunAsync(token, runGeneration)); } } @@ -47,12 +52,14 @@ public void Stop() { CancellationTokenSource? toCancel; ClientWebSocket? toAbort; + Task? toWait; lock (gate) { toCancel = lifetime; lifetime = null; toAbort = socket; - socket = null; + toWait = runTask; + runTask = null; } toCancel?.Cancel(); @@ -65,20 +72,33 @@ public void Stop() AepLog.Warning($"Realtime abort failed: {exception.Message}"); } - toAbort?.Dispose(); + // The receive loop owns the socket via `using` - only Abort here so its Dispose runs once. + if (toWait is not null) + { + try + { + toWait.Wait(StopJoinTimeout); + } + catch (Exception exception) + { + AepLog.Warning($"Realtime stop join failed: {exception.Message}"); + } + } + toCancel?.Dispose(); SetConnected(false); } - private async Task RunAsync(CancellationToken token) + private async Task RunAsync(CancellationToken token, int runGeneration) { var attempt = 0; while (!token.IsCancellationRequested) { var connectedAtUtc = DateTime.MinValue; + ClientWebSocket? ws = null; try { - using var ws = new ClientWebSocket(); + ws = new ClientWebSocket(); ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(15); ws.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); var bearer = session.Token; @@ -90,6 +110,11 @@ private async Task RunAsync(CancellationToken token) await ws.ConnectAsync(BuildUri(session.BaseUrl), token).ConfigureAwait(false); lock (gate) { + if (runGeneration != generation) + { + return; + } + socket = ws; } @@ -109,13 +134,21 @@ private async Task RunAsync(CancellationToken token) { lock (gate) { - socket = null; + if (ReferenceEquals(socket, ws)) + { + socket = null; + } } - SetConnected(false); + if (runGeneration == generation) + { + SetConnected(false); + } + + ws?.Dispose(); } - if (token.IsCancellationRequested) + if (token.IsCancellationRequested || runGeneration != generation) { break; } @@ -202,6 +235,11 @@ public Task SendMediaAsync(byte[] frame) private async Task SendAsync(byte[] payload, WebSocketMessageType type) { + if (disposed) + { + return; + } + ClientWebSocket? ws; lock (gate) { @@ -218,7 +256,15 @@ private async Task SendAsync(byte[] payload, WebSocketMessageType type) return; } - await sendLock.WaitAsync().ConfigureAwait(false); + try + { + await sendLock.WaitAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + try { using var timeout = new CancellationTokenSource(SendTimeout); @@ -227,7 +273,16 @@ private async Task SendAsync(byte[] payload, WebSocketMessageType type) catch (OperationCanceledException) { AepLog.Warning("Realtime send timed out; dropping the connection."); - ws.Abort(); + try + { + ws.Abort(); + } + catch (Exception) + { + } + } + catch (ObjectDisposedException) + { } catch (Exception exception) { @@ -235,7 +290,13 @@ private async Task SendAsync(byte[] payload, WebSocketMessageType type) } finally { - sendLock.Release(); + try + { + sendLock.Release(); + } + catch (ObjectDisposedException) + { + } } } @@ -267,6 +328,7 @@ private static Uri BuildUri(string baseUrl) public void Dispose() { + disposed = true; Stop(); sendLock.Dispose(); } diff --git a/src/Aetherphone/Plugin.cs b/src/Aetherphone/Plugin.cs index aa0c236e..71ee41a3 100644 --- a/src/Aetherphone/Plugin.cs +++ b/src/Aetherphone/Plugin.cs @@ -169,8 +169,24 @@ private void TearDownPartialConstruction() ClientState.Login -= OnLogin; Framework.Update -= OnAutoOpenTick; ContextMenu.OnMenuOpened -= OnMenuOpened; - CommandManager.RemoveHandler(AepConstants.PrimaryCommand); - CommandManager.RemoveHandler(AepConstants.AliasCommand); + try + { + CommandManager.RemoveHandler(AepConstants.PrimaryCommand); + } + catch (Exception exception) + { + AepLog.Warning($"Primary command remove failed during partial teardown: {exception.Message}"); + } + + try + { + CommandManager.RemoveHandler(AepConstants.AliasCommand); + } + catch (Exception exception) + { + AepLog.Warning($"Alias command remove failed during partial teardown: {exception.Message}"); + } + if (services is not null) { services.Notifications.Changed -= UpdateDtrBadge; @@ -384,8 +400,11 @@ private void RunShortcut(string name) private void OnIncomingCall() { - phoneWindow.Maximize(); - phoneWindow.IsOpen = true; + _ = Framework.RunOnFrameworkThread(() => + { + phoneWindow.Maximize(); + phoneWindow.IsOpen = true; + }); } private void OnMenuOpened(IMenuOpenedArgs args) diff --git a/src/Aetherphone/Windows/Components/SocialProfilePages.cs b/src/Aetherphone/Windows/Components/SocialProfilePages.cs index 7f305142..dc1bafe3 100644 --- a/src/Aetherphone/Windows/Components/SocialProfilePages.cs +++ b/src/Aetherphone/Windows/Components/SocialProfilePages.cs @@ -81,7 +81,7 @@ internal sealed class SocialProfilePages private string editDisplay = string.Empty; private string editHandle = string.Empty; private string editBio = string.Empty; - private string editStatus = string.Empty; + private LocString? editStatusKey; private string? editLoadedFor; private volatile bool editBusy; private volatile int editOutcome; @@ -466,7 +466,7 @@ public void DrawEditProfile(Rect area, PhoneTheme theme, INavigator navigation) if (editOutcome == 2) { editOutcome = 0; - editStatus = Loc.T(style.HandleTaken); + editStatusKey = style.HandleTaken; } if (editLoadedFor != me.Id) @@ -475,7 +475,7 @@ public void DrawEditProfile(Rect area, PhoneTheme theme, INavigator navigation) editDisplay = me.DisplayName; editHandle = me.Handle; editBio = me.Bio; - editStatus = string.Empty; + editStatusKey = null; } var handleValid = IsHandleValid(editHandle); @@ -509,12 +509,12 @@ public void DrawEditProfile(Rect area, PhoneTheme theme, INavigator navigation) DrawHandleField(theme); ImGui.Dummy(new Vector2(0f, 10f * scale)); ui.Field(Loc.T(style.BioLabel), "##editBio", ref editBio, BioMax, true); - if (editStatus.Length > 0) + if (editStatusKey is { } statusKey) { ImGui.Dummy(new Vector2(0f, 10f * scale)); using (ImRaii.PushColor(ImGuiCol.Text, theme.Danger)) { - Typography.Wrapped(editStatus); + Typography.Wrapped(Loc.T(statusKey)); } } } @@ -566,12 +566,12 @@ private void SaveProfile() if (!IsHandleValid(editHandle) || editDisplay.Trim().Length == 0) { - editStatus = Loc.T(style.HandleRules); + editStatusKey = style.HandleRules; return; } editBusy = true; - editStatus = string.Empty; + editStatusKey = null; store.UpdateProfile(editDisplay.Trim(), editHandle.Trim(), editBio.Trim(), (ok, _) => { editBusy = false; From ab98f8f5332dac15ebfbd9f0495c0a3c9ce44120 Mon Sep 17 00:00:00 2001 From: "K.I.R.O" <236710061+vinney491-dotcom@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:30:18 -0500 Subject: [PATCH 2/2] fix(confirm): tolerate missing Framework in unit tests Ask/Proceed were null-refing Plugin.Framework outside the game, which broke ModerationNoticeTests on CI. Co-authored-by: Cursor --- src/Aetherphone/Core/Confirm/ConfirmService.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Aetherphone/Core/Confirm/ConfirmService.cs b/src/Aetherphone/Core/Confirm/ConfirmService.cs index 6d6d8668..7ec3134e 100644 --- a/src/Aetherphone/Core/Confirm/ConfirmService.cs +++ b/src/Aetherphone/Core/Confirm/ConfirmService.cs @@ -25,9 +25,11 @@ internal sealed class ConfirmService public void Ask(ConfirmRequest request) { - if (!Plugin.Framework.IsInFrameworkUpdateThread) + // Plugin.Framework is null in unit tests - run inline there. In-game, hop onto the + // Framework thread when Ask arrives from a websocket / worker callback. + if (Plugin.Framework is { } framework && !framework.IsInFrameworkUpdateThread) { - _ = Plugin.Framework.RunOnFrameworkThread(() => Ask(request)); + _ = framework.RunOnFrameworkThread(() => Ask(request)); return; } @@ -83,9 +85,9 @@ void Finish() } } - if (!Plugin.Framework.IsInFrameworkUpdateThread) + if (Plugin.Framework is { } framework && !framework.IsInFrameworkUpdateThread) { - _ = Plugin.Framework.RunOnFrameworkThread(Finish); + _ = framework.RunOnFrameworkThread(Finish); return; }