fix: suppress last-active persistence during panic wipe (#1064)

panicClearAllData() cleared conversation.lastActive, but the trailing
selectedPrivateChatPeer=nil / activeChannel=.mesh resets route through the
change-gated setters and re-persist a .mesh record via persistLastActive()
(the sole write site), so the removal was a no-op and the pointer survived.
Wrap the wipe in a beginPanicWipe()/finishPanicWipe() suppression window so
the resets can't re-write the pointer; remove the key once at the end. The
pointer now ends absent -> next launch hits the first-launch/list fallback.
Adds test_panicWipeOrderLeavesNoRestoreRecord replaying the full panic order.
This commit is contained in:
ecgang 2026-07-06 11:16:06 -07:00
parent 8428dfed66
commit 325a65ce6f
3 changed files with 73 additions and 3 deletions

View File

@ -351,6 +351,11 @@ final class ConversationStore: ObservableObject {
private let storage: UserDefaults
private let lastActiveKey = "conversation.lastActive"
/// True for the duration of a panic wipe. Suppresses `persistLastActive()`
/// so the selection/channel resets the wipe performs cannot re-write the
/// pointer we are about to remove.
private var isPanicWiping = false
/// Snapshot of the persisted value read once at init, before any launch
/// writer (e.g. `GeoChannelCoordinator` re-applying the location channel)
/// can overwrite the key. Restore decisions read this, never the disk.
@ -404,6 +409,9 @@ final class ConversationStore: ObservableObject {
/// single-writer selection paths on every switch; an open DM wins over
/// the active channel.
private func persistLastActive() {
// Panic wipe suppresses last-active persistence so the selection/channel
// resets that follow it can't re-write the pointer being cleared.
guard !isPanicWiping else { return }
let record: LastActiveRecord
if let peerID = selectedPrivatePeerID {
record = LastActiveRecord(kind: .direct, peerID: peerID.id)
@ -425,6 +433,17 @@ final class ConversationStore: ObservableObject {
storage.removeObject(forKey: lastActiveKey)
}
/// Begins a panic wipe: suppress last-active persistence so the selection/channel
/// resets that follow cannot re-write the pointer we're about to remove.
func beginPanicWipe() { isPanicWiping = true }
/// Finishes a panic wipe: remove the persisted pointer and re-enable persistence.
/// MUST be called AFTER all selection/channel state has been reset.
func finishPanicWipe() {
clearPersistedLastActive()
isPanicWiping = false
}
/// Decides what to present at launch from the value persisted last
/// session. Pure aside from reading the init snapshot: performs no
/// selection mutation and never writes `activeChannel`, so it cannot race

View File

@ -1177,9 +1177,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// single-writer ConversationStore; the derived `messages` view and
// the legacy mirror empty with it)
conversations.clearAll()
// Also erase the persisted last-active pointer (#1064) so a wiped
// DM/channel cannot be restored on the next launch.
conversations.clearPersistedLastActive()
// Begin suppressing last-active persistence (#1064). The selection and
// channel resets below route through the store's setters, which would
// otherwise re-persist a `.mesh` pointer; the wipe is finished (and the
// pointer removed once) at the very end of this method.
conversations.beginPanicWipe()
pendingGeohashSystemMessages.removeAll()
// Delete all keychain data (including Noise and Nostr keys)
@ -1307,6 +1309,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
#endif
}
// Finish the panic wipe (#1064): must run AFTER `selectedPrivateChatPeer
// = nil` / `activeChannel = .mesh` above so no setter re-persists the
// pointer. Removes the last-active key once and re-enables persistence,
// leaving the key ABSENT so next launch hits the conversation-list
// first-launch fallback.
conversations.finishPanicWipe()
// Force immediate UI update for panic mode
// UI updates immediately - no flushing needed

View File

@ -112,6 +112,48 @@ final class ConversationStoreLastActiveTests: XCTestCase {
)
}
func test_panicWipeOrderLeavesNoRestoreRecord() {
// #1064 panic wipe, FULL ORDER: `clearPersistedLastActive()` alone is
// insufficient because `panicClearAllData()` also runs
// `selectedPrivateChatPeer = nil` and `activeChannel = .mesh` AFTER it
// both route through the store's setters, whose guarded `persistLastActive()`
// re-writes a `.mesh` record on a real state change, resurrecting the
// pointer. The begin/finish suppression window fixes that. This test
// mirrors panicClearAllData's exact order against a single store:
// beginPanicWipe setSelectedPrivatePeer(nil) setActiveChannel(.mesh) finishPanicWipe
// Without the fix (i.e. without begin/finish), those selection/channel
// resets re-persist a `.mesh` record here `setSelectedPrivatePeer(nil)`
// is the real state change that writes it (the store's default channel
// is already `.mesh`, so `setActiveChannel(.mesh)` is the second trigger
// whenever panic runs from a non-mesh channel). Either way the pointer
// survives as `.mesh` and the next launch would `.deferToChannelRestore`
// instead of `.conversationList`.
let storage = makeStorage()
// The user is in a DM: a `.direct` record is persisted and would restore.
let session = ConversationStore(storage: storage)
session.setSelectedPrivatePeer(peerID)
XCTAssertEqual(
ConversationStore(storage: storage)
.restoreLastActiveConversation(isPeerResolvable: { _ in true }),
.restoredDirectChat(peerID)
)
// Reproduce panicClearAllData's order on the SAME store.
session.beginPanicWipe()
session.setSelectedPrivatePeer(nil) // re-persist trigger #1 (suppressed)
session.setActiveChannel(.mesh) // re-persist trigger #2 (suppressed)
session.finishPanicWipe() // removes the pointer once
// A fresh store on the SAME storage finds NO record and falls back to
// the conversation list the pointer is truly absent, not `.mesh`.
XCTAssertEqual(
ConversationStore(storage: storage)
.restoreLastActiveConversation(isPeerResolvable: { _ in true }),
.conversationList
)
}
func test_firstLaunchPresentsConversationList() {
let storage = makeStorage()