Add iOS lifecycle hardening hooks
This commit is contained in:
53
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Lifecycle.swift
Normal file
53
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Lifecycle.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ struct TeleTuiIOSSmokeTests {
|
|||||||
try await chatDetailLoadsAndSendsMessage()
|
try await chatDetailLoadsAndSendsMessage()
|
||||||
try await messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy()
|
try await messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy()
|
||||||
try await platformServicesCoverNotificationsMediaVoiceClipboardAndAccounts()
|
try await platformServicesCoverNotificationsMediaVoiceClipboardAndAccounts()
|
||||||
|
lifecycleCoordinatorDropsStaleAccountEvents()
|
||||||
try await profileLoadsFromSelectedChat()
|
try await profileLoadsFromSelectedChat()
|
||||||
appStorageUsesApplicationSupportStyleAccountPaths()
|
appStorageUsesApplicationSupportStyleAccountPaths()
|
||||||
print("TeleTuiIOS smoke tests passed")
|
print("TeleTuiIOS smoke tests passed")
|
||||||
@@ -141,6 +142,35 @@ struct TeleTuiIOSSmokeTests {
|
|||||||
precondition(switcher.activeAccount.databasePath.path.hasSuffix("/Accounts/work/tdlib"))
|
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
|
@MainActor
|
||||||
private static func profileLoadsFromSelectedChat() async throws {
|
private static func profileLoadsFromSelectedChat() async throws {
|
||||||
let bridge = FakeSessionBridge(auth: .ready)
|
let bridge = FakeSessionBridge(auth: .ready)
|
||||||
|
|||||||
23
docs/ios/hardening.md
Normal file
23
docs/ios/hardening.md
Normal 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.
|
||||||
Reference in New Issue
Block a user