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
|
// AppState.swift — global app state, credential lifecycle
|
||||||
|
|
||||||
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
@ -10,6 +11,14 @@ final class AppState: ObservableObject {
|
||||||
@Published var isLocked = false
|
@Published var isLocked = false
|
||||||
@Published var lastForegroundedAt: Date = Date()
|
@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() {
|
private init() {
|
||||||
// Test-only overrides
|
// Test-only overrides
|
||||||
let args = ProcessInfo.processInfo.arguments
|
let args = ProcessInfo.processInfo.arguments
|
||||||
|
|
@ -26,6 +35,19 @@ final class AppState: ObservableObject {
|
||||||
|
|
||||||
// Try loading persisted credential on launch
|
// Try loading persisted credential on launch
|
||||||
credential = try? Keychain.shared.load(key: "piremote.credential")
|
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) {
|
func didPair(credential: SidecarCredential) {
|
||||||
|
|
@ -42,9 +64,11 @@ final class AppState: ObservableObject {
|
||||||
|
|
||||||
func appDidBackground() {
|
func appDidBackground() {
|
||||||
lastForegroundedAt = Date()
|
lastForegroundedAt = Date()
|
||||||
|
_lifecycleTransitions.send(true) // T-2.10
|
||||||
}
|
}
|
||||||
|
|
||||||
func appWillForeground() async {
|
func appWillForeground() async {
|
||||||
|
_lifecycleTransitions.send(false) // T-2.10: always emit before Face-ID gate
|
||||||
let elapsed = Date().timeIntervalSince(lastForegroundedAt)
|
let elapsed = Date().timeIntervalSince(lastForegroundedAt)
|
||||||
guard elapsed > 60 else { return } // within 60s → no re-auth
|
guard elapsed > 60 else { return } // within 60s → no re-auth
|
||||||
isLocked = true
|
isLocked = true
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,21 @@ public final class SessionConnection: ObservableObject {
|
||||||
/// Tracks the WebSocket lifecycle.
|
/// Tracks the WebSocket lifecycle.
|
||||||
@Published public private(set) var connectionState: ConnectionState = .disconnected
|
@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
|
// MARK: - Scrollback
|
||||||
|
|
||||||
/// Persistent rolling ANSI cache for this session.
|
/// Persistent rolling ANSI cache for this session.
|
||||||
|
|
@ -52,6 +67,7 @@ public final class SessionConnection: ObservableObject {
|
||||||
private let credential: SidecarCredential
|
private let credential: SidecarCredential
|
||||||
private var client: WebSocketClient?
|
private var client: WebSocketClient?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var keepAliveTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
|
|
@ -71,16 +87,43 @@ public final class SessionConnection: ObservableObject {
|
||||||
/// - Parameter lastSeq: The last acknowledged sequence number, or `nil`
|
/// - Parameter lastSeq: The last acknowledged sequence number, or `nil`
|
||||||
/// to request replay from the beginning.
|
/// to request replay from the beginning.
|
||||||
public func resume(from lastSeq: UInt64?) async {
|
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 {
|
guard let url = streamURL else {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[SessionConnection] Could not construct stream URL for session \(id) — aborting resume.")
|
print("[SessionConnection] Could not construct stream URL for session \(id) — aborting resume.")
|
||||||
#endif
|
#endif
|
||||||
|
keepAliveTask?.cancel()
|
||||||
|
keepAliveTask = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tear down any existing connection cleanly before reconnecting.
|
|
||||||
await suspend()
|
|
||||||
|
|
||||||
let ws = WebSocketClient()
|
let ws = WebSocketClient()
|
||||||
client = ws
|
client = ws
|
||||||
|
|
||||||
|
|
@ -96,6 +139,7 @@ public final class SessionConnection: ObservableObject {
|
||||||
ws.incomingBinary
|
ws.incomingBinary
|
||||||
.sink { [weak self] frame in
|
.sink { [weak self] frame in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
self.isStreamFrozen = false // T-2.10: first delta clears freeze
|
||||||
self.scrollback.append(frame.data)
|
self.scrollback.append(frame.data)
|
||||||
self.stream.send(frame.data)
|
self.stream.send(frame.data)
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +150,7 @@ public final class SessionConnection: ObservableObject {
|
||||||
.sink { [weak self] frame in
|
.sink { [weak self] frame in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if case .snapshot(_, let base64) = frame {
|
if case .snapshot(_, let base64) = frame {
|
||||||
|
self.isStreamFrozen = false // T-2.10: snapshot clears freeze
|
||||||
// Decode base64 → text, prepend clear+home, normalise line endings.
|
// Decode base64 → text, prepend clear+home, normalise line endings.
|
||||||
if let raw = Data(base64Encoded: base64),
|
if let raw = Data(base64Encoded: base64),
|
||||||
let text = String(data: raw, encoding: .utf8) {
|
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).
|
/// Closes the WebSocket but keeps local state (scrollback + cursor).
|
||||||
public func suspend() async {
|
public func suspend() async {
|
||||||
|
// T-2.10: cancel keep-alive heartbeat and clear freeze flag
|
||||||
|
keepAliveTask?.cancel()
|
||||||
|
keepAliveTask = nil
|
||||||
|
isStreamFrozen = false
|
||||||
client?.disconnect()
|
client?.disconnect()
|
||||||
client = nil
|
client = nil
|
||||||
cancellables.removeAll()
|
cancellables.removeAll()
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ struct StatusBar: View {
|
||||||
Text(connectionStatus)
|
Text(connectionStatus)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("statusbar.connectionStatus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ struct MainTerminalView: View {
|
||||||
@State private var showSwitcher = false
|
@State private var showSwitcher = false
|
||||||
@State private var showSettings = false // T-2.11
|
@State private var showSettings = false // T-2.11
|
||||||
@State private var activeSessionId: String? = nil // T-2.6
|
@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>()
|
@State private var cancellables = Set<AnyCancellable>()
|
||||||
@StateObject private var registry = SessionRegistry() // T-2.6
|
@StateObject private var registry = SessionRegistry() // T-2.6
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
|
@ -28,6 +29,12 @@ struct MainTerminalView: View {
|
||||||
ProcessInfo.processInfo.arguments.contains("--uitest")
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// ── Status bar ──────────────────────────────────────────
|
// ── Status bar ──────────────────────────────────────────
|
||||||
|
|
@ -63,6 +70,24 @@ struct MainTerminalView: View {
|
||||||
}
|
}
|
||||||
.task { await initialBootstrap() }
|
.task { await initialBootstrap() }
|
||||||
.task { await registry.refresh(credential: credential) } // T-2.6
|
.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
|
.onChange(of: activeSessionId) { _, newId in
|
||||||
guard let newId else { return }
|
guard let newId else { return }
|
||||||
// UI-test mode: no terminal view, no WS — just update the label.
|
// 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
|
// making every .tap() block for ~120 s. Status bar, modifier bar, and
|
||||||
// both sheets still render normally.
|
// both sheets still render normally.
|
||||||
if ProcessInfo.processInfo.arguments.contains("--uitest") {
|
if ProcessInfo.processInfo.arguments.contains("--uitest") {
|
||||||
|
if isUITestStub {
|
||||||
|
await bootstrapStubConnection()
|
||||||
|
} else {
|
||||||
statusText = "● uitest"
|
statusText = "● uitest"
|
||||||
sessionName = "uitest"
|
sessionName = "uitest"
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
statusText = "Looking for sessions…"
|
statusText = "Looking for sessions…"
|
||||||
|
|
@ -136,12 +165,13 @@ struct MainTerminalView: View {
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Wire connection state → status text
|
// Wire connection state → status text
|
||||||
|
// T-2.10: show "Reconnecting…" if isStreamFrozen (lastSeq was non-nil)
|
||||||
conn.$connectionState
|
conn.$connectionState
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { state in
|
.sink { [weak conn] state in
|
||||||
switch state {
|
switch state {
|
||||||
case .connected: statusText = "● \(sessionId)"
|
case .connected: statusText = "● \(sessionId)"
|
||||||
case .connecting: statusText = "Connecting…"
|
case .connecting: statusText = conn?.isStreamFrozen == true ? "Reconnecting…" : "Connecting…"
|
||||||
case .disconnected: statusText = "Disconnected"
|
case .disconnected: statusText = "Disconnected"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +242,32 @@ struct MainTerminalView: View {
|
||||||
await conn.resume(from: lastSeq)
|
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
|
// MARK: - Session resolution
|
||||||
|
|
||||||
/// Returns the first existing session id, or creates one named "pi".
|
/// Returns the first existing session id, or creates one named "pi".
|
||||||
|
|
|
||||||
|
|
@ -169,23 +169,5 @@ final class AppStateLifecycleTests: XCTestCase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// MARK: - Stub removed
|
||||||
// MARK: - Stub extension
|
// lifecycleTransitions is now implemented on AppState directly (T-2.10 impl).
|
||||||
//
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -230,27 +230,7 @@ final class SessionConnectionLifecycleTests: XCTestCase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// MARK: - Stubs removed
|
||||||
// MARK: - Stub extensions
|
// isStreamFrozen and isKeepAliveActive are now implemented on SessionConnection
|
||||||
//
|
// directly (T-2.10 impl). The extension stubs above would cause a redeclaration
|
||||||
// These STUB properties let the test file COMPILE without the real impl.
|
// error and are therefore removed.
|
||||||
// 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 }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue