Add iOS lifecycle hardening hooks

This commit is contained in:
Mikhail Kilin
2026-05-20 15:51:15 +03:00
parent 8bea159569
commit 59050d0b5f
3 changed files with 106 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
import Foundation
public enum AppLifecycleState: Equatable, Sendable {
case foreground
case background
}
public struct ScopedSessionEvent: Equatable, Sendable {
public var accountId: String
public var generation: Int
public var event: SessionEvent
public init(accountId: String, generation: Int, event: SessionEvent) {
self.accountId = accountId
self.generation = generation
self.event = event
}
}
@MainActor
public final class SessionLifecycleCoordinator: ObservableObject {
@Published public private(set) var lifecycleState: AppLifecycleState = .foreground
@Published public private(set) var activeAccountId: String
@Published public private(set) var generation = 0
public init(activeAccountId: String) {
self.activeAccountId = activeAccountId
}
public var shouldPollEvents: Bool {
lifecycleState == .foreground
}
public func enterBackground() {
lifecycleState = .background
}
public func enterForeground() {
lifecycleState = .foreground
}
public func switchAccount(to accountId: String) {
guard accountId != activeAccountId else {
return
}
activeAccountId = accountId
generation += 1
}
public func accepts(_ event: ScopedSessionEvent) -> Bool {
event.accountId == activeAccountId && event.generation == generation
}
}

View File

@@ -9,6 +9,7 @@ struct TeleTuiIOSSmokeTests {
try await chatDetailLoadsAndSendsMessage()
try await messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy()
try await platformServicesCoverNotificationsMediaVoiceClipboardAndAccounts()
lifecycleCoordinatorDropsStaleAccountEvents()
try await profileLoadsFromSelectedChat()
appStorageUsesApplicationSupportStyleAccountPaths()
print("TeleTuiIOS smoke tests passed")
@@ -141,6 +142,35 @@ struct TeleTuiIOSSmokeTests {
precondition(switcher.activeAccount.databasePath.path.hasSuffix("/Accounts/work/tdlib"))
}
@MainActor
private static func lifecycleCoordinatorDropsStaleAccountEvents() {
let coordinator = SessionLifecycleCoordinator(activeAccountId: "personal")
precondition(coordinator.shouldPollEvents)
coordinator.enterBackground()
precondition(!coordinator.shouldPollEvents)
coordinator.enterForeground()
precondition(coordinator.shouldPollEvents)
let oldGenerationEvent = ScopedSessionEvent(
accountId: "personal",
generation: coordinator.generation,
event: .authChanged(.ready)
)
precondition(coordinator.accepts(oldGenerationEvent))
coordinator.switchAccount(to: "work")
precondition(!coordinator.accepts(oldGenerationEvent))
let newGenerationEvent = ScopedSessionEvent(
accountId: "work",
generation: coordinator.generation,
event: .authChanged(.ready)
)
precondition(coordinator.accepts(newGenerationEvent))
}
@MainActor
private static func profileLoadsFromSelectedChat() async throws {
let bridge = FakeSessionBridge(auth: .ready)

23
docs/ios/hardening.md Normal file
View File

@@ -0,0 +1,23 @@
# iOS Hardening Notes
Phase 6 real-device validation is blocked on this machine until full Xcode is selected.
Current local blocker:
```text
xcode-select: error: tool 'xcodebuild' requires Xcode, but active developer directory '/Library/Developer/CommandLineTools' is a command line tools instance
```
Implemented hardening hooks:
- `SessionLifecycleCoordinator` suspends polling while the app is backgrounded.
- Account switches increment a session generation counter.
- Events are accepted only when both account id and generation match the active session, preventing stale events from a previous account from reaching view models.
Manual smoke still required after Xcode/TDLib device setup:
1. Auth with real Telegram credentials.
2. Load chats, open multiple chats, send/edit/delete/reply/forward/react.
3. Background and foreground during history load and media download.
4. Switch accounts rapidly while receiving updates.
5. Confirm no stale events from the previous account mutate the active account.