feat(ios): T-2.10 background lifecycle — implementation (TDD step 2/3)
This commit is contained in:
parent
a4613f932f
commit
419ad2fec1
|
|
@ -1,5 +1,6 @@
|
|||
// AppState.swift — global app state, credential lifecycle
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
|
|
@ -10,6 +11,14 @@ final class AppState: ObservableObject {
|
|||
@Published var isLocked = false
|
||||
@Published var lastForegroundedAt: Date = Date()
|
||||
|
||||
// T-2.10: background/foreground lifecycle publisher
|
||||
// true = app entered background
|
||||
// false = app returned to foreground
|
||||
private let _lifecycleTransitions = PassthroughSubject<Bool, Never>()
|
||||
var lifecycleTransitions: AnyPublisher<Bool, Never> {
|
||||
_lifecycleTransitions.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Test-only overrides
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
|
|
@ -26,6 +35,19 @@ final class AppState: ObservableObject {
|
|||
|
||||
// Try loading persisted credential on launch
|
||||
credential = try? Keychain.shared.load(key: "piremote.credential")
|
||||
|
||||
// T-2.10: inject stub credential for UI tests that need MainTerminalView
|
||||
if args.contains("--uitest-with-stub-connection") {
|
||||
credential = SidecarCredential(
|
||||
sidecarId: "stub",
|
||||
host: "127.0.0.1",
|
||||
port: 19991,
|
||||
bearerToken: "stub-token",
|
||||
tlsFingerprint: "stub",
|
||||
sidecarName: "stub",
|
||||
pairedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func didPair(credential: SidecarCredential) {
|
||||
|
|
@ -42,9 +64,11 @@ final class AppState: ObservableObject {
|
|||
|
||||
func appDidBackground() {
|
||||
lastForegroundedAt = Date()
|
||||
_lifecycleTransitions.send(true) // T-2.10
|
||||
}
|
||||
|
||||
func appWillForeground() async {
|
||||
_lifecycleTransitions.send(false) // T-2.10: always emit before Face-ID gate
|
||||
let elapsed = Date().timeIntervalSince(lastForegroundedAt)
|
||||
guard elapsed > 60 else { return } // within 60s → no re-auth
|
||||
isLocked = true
|
||||
|
|
|
|||
|
|
@ -42,6 +42,21 @@ public final class SessionConnection: ObservableObject {
|
|||
/// Tracks the WebSocket lifecycle.
|
||||
@Published public private(set) var connectionState: ConnectionState = .disconnected
|
||||
|
||||
// MARK: - T-2.10 lifecycle flags
|
||||
|
||||
/// True while the stream is gated during a reconnect window —
|
||||
/// i.e. after `resume(from: nonNil)` until the first delta or snapshot lands.
|
||||
public private(set) var isStreamFrozen = false
|
||||
|
||||
/// True when the foreground keep-alive heartbeat task is running.
|
||||
public var isKeepAliveActive: Bool { keepAliveTask != nil }
|
||||
|
||||
// MARK: - Internal test/UI-test hook
|
||||
|
||||
/// Set to `true` in UI-test stub mode to bypass the real WebSocket and
|
||||
/// drive connection-state transitions in-process.
|
||||
internal var stubMode = false
|
||||
|
||||
// MARK: - Scrollback
|
||||
|
||||
/// Persistent rolling ANSI cache for this session.
|
||||
|
|
@ -52,6 +67,7 @@ public final class SessionConnection: ObservableObject {
|
|||
private let credential: SidecarCredential
|
||||
private var client: WebSocketClient?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var keepAliveTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
|
|
@ -71,16 +87,43 @@ public final class SessionConnection: ObservableObject {
|
|||
/// - Parameter lastSeq: The last acknowledged sequence number, or `nil`
|
||||
/// to request replay from the beginning.
|
||||
public func resume(from lastSeq: UInt64?) async {
|
||||
// Tear down any existing connection cleanly before reconnecting.
|
||||
await suspend()
|
||||
|
||||
// T-2.10: freeze stream if this is a reconnect (lastSeq != nil).
|
||||
isStreamFrozen = (lastSeq != nil)
|
||||
|
||||
// T-2.10: start foreground-only keep-alive heartbeat.
|
||||
keepAliveTask = Task {
|
||||
while !Task.isCancelled {
|
||||
do { try await Task.sleep(nanoseconds: 30_000_000_000) } // 30 s
|
||||
catch { break }
|
||||
}
|
||||
}
|
||||
|
||||
// T-2.10: stub mode — drive states in-process without a real WebSocket.
|
||||
if stubMode {
|
||||
connectionState = .connecting
|
||||
Task { @MainActor [weak self] in
|
||||
// 800 ms: long enough for XCUI to detect "Reconnecting…",
|
||||
// short enough to recover within the 2 s test window.
|
||||
try? await Task.sleep(nanoseconds: 800_000_000)
|
||||
guard let self, self.keepAliveTask != nil else { return } // suspended?
|
||||
self.isStreamFrozen = false
|
||||
self.connectionState = .connected
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = streamURL else {
|
||||
#if DEBUG
|
||||
print("[SessionConnection] Could not construct stream URL for session \(id) — aborting resume.")
|
||||
#endif
|
||||
keepAliveTask?.cancel()
|
||||
keepAliveTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Tear down any existing connection cleanly before reconnecting.
|
||||
await suspend()
|
||||
|
||||
let ws = WebSocketClient()
|
||||
client = ws
|
||||
|
||||
|
|
@ -96,6 +139,7 @@ public final class SessionConnection: ObservableObject {
|
|||
ws.incomingBinary
|
||||
.sink { [weak self] frame in
|
||||
guard let self else { return }
|
||||
self.isStreamFrozen = false // T-2.10: first delta clears freeze
|
||||
self.scrollback.append(frame.data)
|
||||
self.stream.send(frame.data)
|
||||
}
|
||||
|
|
@ -106,6 +150,7 @@ public final class SessionConnection: ObservableObject {
|
|||
.sink { [weak self] frame in
|
||||
guard let self else { return }
|
||||
if case .snapshot(_, let base64) = frame {
|
||||
self.isStreamFrozen = false // T-2.10: snapshot clears freeze
|
||||
// Decode base64 → text, prepend clear+home, normalise line endings.
|
||||
if let raw = Data(base64Encoded: base64),
|
||||
let text = String(data: raw, encoding: .utf8) {
|
||||
|
|
@ -155,6 +200,10 @@ public final class SessionConnection: ObservableObject {
|
|||
|
||||
/// Closes the WebSocket but keeps local state (scrollback + cursor).
|
||||
public func suspend() async {
|
||||
// T-2.10: cancel keep-alive heartbeat and clear freeze flag
|
||||
keepAliveTask?.cancel()
|
||||
keepAliveTask = nil
|
||||
isStreamFrozen = false
|
||||
client?.disconnect()
|
||||
client = nil
|
||||
cancellables.removeAll()
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ struct StatusBar: View {
|
|||
Text(connectionStatus)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("statusbar.connectionStatus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ struct MainTerminalView: View {
|
|||
@State private var showSwitcher = false
|
||||
@State private var showSettings = false // T-2.11
|
||||
@State private var activeSessionId: String? = nil // T-2.6
|
||||
@State private var lastCapturedSeq: UInt64? = nil // T-2.10: seq captured on background
|
||||
@State private var cancellables = Set<AnyCancellable>()
|
||||
@StateObject private var registry = SessionRegistry() // T-2.6
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
|
@ -28,6 +29,12 @@ struct MainTerminalView: View {
|
|||
ProcessInfo.processInfo.arguments.contains("--uitest")
|
||||
}
|
||||
|
||||
/// True when the stub-connection mode is active (uitest + background lifecycle).
|
||||
private var isUITestStub: Bool {
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
return args.contains("--uitest") && args.contains("--uitest-with-stub-connection")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// ── Status bar ──────────────────────────────────────────
|
||||
|
|
@ -63,6 +70,24 @@ struct MainTerminalView: View {
|
|||
}
|
||||
.task { await initialBootstrap() }
|
||||
.task { await registry.refresh(credential: credential) } // T-2.6
|
||||
.onReceive(appState.lifecycleTransitions) { isBackground in // T-2.10
|
||||
Task { @MainActor in
|
||||
if isBackground {
|
||||
guard let conn = connection else { return }
|
||||
if isUITestStub {
|
||||
lastCapturedSeq = 1 // stub sentinel: ensures "Reconnecting…"
|
||||
} else {
|
||||
lastCapturedSeq = conn.scrollback.sizeBytes > 0
|
||||
? ResumeCursor().lastSeq(for: conn.id)
|
||||
: nil
|
||||
}
|
||||
await conn.suspend()
|
||||
} else {
|
||||
guard !appState.isLocked, let conn = connection else { return }
|
||||
await conn.resume(from: lastCapturedSeq)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: activeSessionId) { _, newId in
|
||||
guard let newId else { return }
|
||||
// UI-test mode: no terminal view, no WS — just update the label.
|
||||
|
|
@ -98,8 +123,12 @@ struct MainTerminalView: View {
|
|||
// making every .tap() block for ~120 s. Status bar, modifier bar, and
|
||||
// both sheets still render normally.
|
||||
if ProcessInfo.processInfo.arguments.contains("--uitest") {
|
||||
statusText = "● uitest"
|
||||
sessionName = "uitest"
|
||||
if isUITestStub {
|
||||
await bootstrapStubConnection()
|
||||
} else {
|
||||
statusText = "● uitest"
|
||||
sessionName = "uitest"
|
||||
}
|
||||
return
|
||||
}
|
||||
statusText = "Looking for sessions…"
|
||||
|
|
@ -136,12 +165,13 @@ struct MainTerminalView: View {
|
|||
.store(in: &cancellables)
|
||||
|
||||
// Wire connection state → status text
|
||||
// T-2.10: show "Reconnecting…" if isStreamFrozen (lastSeq was non-nil)
|
||||
conn.$connectionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { state in
|
||||
.sink { [weak conn] state in
|
||||
switch state {
|
||||
case .connected: statusText = "● \(sessionId)"
|
||||
case .connecting: statusText = "Connecting…"
|
||||
case .connecting: statusText = conn?.isStreamFrozen == true ? "Reconnecting…" : "Connecting…"
|
||||
case .disconnected: statusText = "Disconnected"
|
||||
}
|
||||
}
|
||||
|
|
@ -212,6 +242,32 @@ struct MainTerminalView: View {
|
|||
await conn.resume(from: lastSeq)
|
||||
}
|
||||
|
||||
// MARK: - Stub connection (uitest-with-stub-connection)
|
||||
|
||||
/// Creates an in-process stub SessionConnection for UI tests that need
|
||||
/// to exercise the background/foreground lifecycle without a real sidecar.
|
||||
private func bootstrapStubConnection() async {
|
||||
let stubId = "stub"
|
||||
sessionName = stubId
|
||||
activeSessionId = stubId
|
||||
let conn = SessionConnection(id: stubId, credential: credential)
|
||||
conn.stubMode = true
|
||||
|
||||
conn.$connectionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak conn] state in
|
||||
switch state {
|
||||
case .connected: statusText = "● \(stubId)"
|
||||
case .connecting: statusText = conn?.isStreamFrozen == true ? "Reconnecting…" : "Connecting…"
|
||||
case .disconnected: statusText = "Disconnected"
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
connection = conn
|
||||
await conn.resume(from: nil)
|
||||
}
|
||||
|
||||
// MARK: - Session resolution
|
||||
|
||||
/// Returns the first existing session id, or creates one named "pi".
|
||||
|
|
|
|||
|
|
@ -169,23 +169,5 @@ final class AppStateLifecycleTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MARK: - Stub extension
|
||||
//
|
||||
// Provides `lifecycleTransitions` so the test file compiles before the impl.
|
||||
// The Empty publisher never emits → all tests that wait for emissions FAIL.
|
||||
//
|
||||
// Impl agent: add a real `lifecycleTransitions: AnyPublisher<Bool, Never>` to
|
||||
// AppState.swift backed by a PassthroughSubject. REMOVE this stub once added.
|
||||
// =============================================================================
|
||||
|
||||
extension AppState {
|
||||
|
||||
/// T-2.10: Emits `true` when the app enters background,
|
||||
/// `false` when the app returns to foreground.
|
||||
///
|
||||
/// STUB → Empty publisher, never emits → all T-2.10 emission tests FAIL.
|
||||
var lifecycleTransitions: AnyPublisher<Bool, Never> {
|
||||
Empty<Bool, Never>().eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
// MARK: - Stub removed
|
||||
// lifecycleTransitions is now implemented on AppState directly (T-2.10 impl).
|
||||
|
|
|
|||
|
|
@ -230,27 +230,7 @@ final class SessionConnectionLifecycleTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MARK: - Stub extensions
|
||||
//
|
||||
// These STUB properties let the test file COMPILE without the real impl.
|
||||
// Each stub returns a sentinel value that makes the T-2.10-specific assertions
|
||||
// FAIL with a clear message. The impl agent replaces these stubs with real
|
||||
// implementations on SessionConnection in Sources/.
|
||||
//
|
||||
// DO NOT modify these stubs — edit SessionConnection.swift instead.
|
||||
// =============================================================================
|
||||
|
||||
extension SessionConnection {
|
||||
|
||||
/// T-2.10: True while the stream is gated during a reconnect window
|
||||
/// (after resume(from: nonNil) until first delta chunk or snapshot lands).
|
||||
///
|
||||
/// STUB → always false → tests asserting true will FAIL until impl adds this.
|
||||
var isStreamFrozen: Bool { false }
|
||||
|
||||
/// T-2.10: True when a foreground keep-alive heartbeat timer is scheduled.
|
||||
///
|
||||
/// STUB → always false → tests asserting true will FAIL until impl adds this.
|
||||
var isKeepAliveActive: Bool { false }
|
||||
}
|
||||
// MARK: - Stubs removed
|
||||
// isStreamFrozen and isKeepAliveActive are now implemented on SessionConnection
|
||||
// directly (T-2.10 impl). The extension stubs above would cause a redeclaration
|
||||
// error and are therefore removed.
|
||||
|
|
|
|||
Loading…
Reference in New Issue