From 59050d0b5f9024fc1d7f7b48da9ee23c85d3ee02 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Wed, 20 May 2026 15:51:15 +0300 Subject: [PATCH] Add iOS lifecycle hardening hooks --- .../Sources/TeleTuiIOSCore/Lifecycle.swift | 53 +++++++++++++++++++ .../Sources/TeleTuiIOSSmokeTests/main.swift | 30 +++++++++++ docs/ios/hardening.md | 23 ++++++++ 3 files changed, 106 insertions(+) create mode 100644 apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Lifecycle.swift create mode 100644 docs/ios/hardening.md diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Lifecycle.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Lifecycle.swift new file mode 100644 index 0000000..7750afc --- /dev/null +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Lifecycle.swift @@ -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 + } +} diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift index f8781aa..32ff291 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift @@ -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) diff --git a/docs/ios/hardening.md b/docs/ios/hardening.md new file mode 100644 index 0000000..130d64a --- /dev/null +++ b/docs/ios/hardening.md @@ -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.