Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ extension WorkspaceDetailView {
// avoidance; otherwise the view ALSO shrinks for the keyboard
// and the reservation double-counts (extra gap when open).
.ignoresSafeArea(.keyboard, edges: .bottom)
// The surface's frame must be KEYBOARD-INVARIANT: with only the keyboard
// inset ignored, the bottom edge respects the home indicator while the
// keyboard is down but extends to the window bottom while it is up
// (the keyboard region subsumes the indicator inset), so the frame
// breathed by the indicator height on every transition and every
// frame-relative keyboard conversion inside the surface was that much
// off mid-animation (the bars visibly popped by it after each rise —
// device forensics 2026-08-06). Extending under the indicator in ALL
// states pins the frame; the dock's required safe-area cap keeps the
// bars clear of the indicator while the keyboard is down.
.ignoresSafeArea(.container, edges: .bottom)
// Keep the grid clear of the Dynamic Island and nav bar.
.padding(.top, terminalTopPadding)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,41 +1,73 @@
import CmuxMobileShellModel
import Testing
@testable import CmuxMobileShellUI

/// Startup admission contract for an explicitly injected attach URL.
///
/// The original version of this suite was written against a
/// `connectInjectedAttach(_:attachURL:_:)` coordinator API that was reworked
/// before it merged, so the file has not compiled since it landed — and a
/// compile-broken test target blocks every `cmux.xctestplan` run (the plan
/// builds all of its targets even under `-only-testing`). These tests assert
/// the same admission contract against the coordinator API that shipped; the
/// URL-connect side effects live in `CMUXMobileRootView`, which claims and
/// finishes attempts through exactly these entry points.
@Suite
struct MobileInjectedAttachStartupTests {
@Test
@MainActor
func beginsRouteAdmissionWithoutAnExternalTransportReadinessBarrier() async throws {
func connectedInjectedAttachConsumesStartupWithoutFallback() throws {
let coordinator = MobileStartupConnectionCoordinator()
let attempt = try #require(coordinator.claimInjectedAttach())
let recorder = MobileInjectedAttachURLRecorder()
let attachURL = "cmux-ios://attach?v=2&payload=iroh-route"

let completion = await coordinator.connectInjectedAttach(
attempt,
attachURL: attachURL
) { rawURL in
await recorder.record(rawURL)
return MobilePairingURLConnectionResult.connected
}

let completedAttempt = try #require(completion)
#expect(await recorder.values() == [attachURL])
#expect(completedAttempt.result == .connected)
#expect(!completedAttempt.shouldReconnectStoredMac)

let shouldFallBack = coordinator.finishInjectedAttach(attempt, outcome: .connected)

#expect(!shouldFallBack)
#expect(!coordinator.shouldFallBackFromInjectedAttach)
// A consumed explicit route keeps owning startup: the saved-Mac
// reconnect must not also dial.
#expect(coordinator.claimStoredReconnect() == nil)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private actor MobileInjectedAttachURLRecorder {
private var urls: [String] = []
@Test
@MainActor
func approvalGatedInjectedAttachConsumesStartupWithoutFallback() throws {
let coordinator = MobileStartupConnectionCoordinator()
let attempt = try #require(coordinator.claimInjectedAttach())

let shouldFallBack = coordinator.finishInjectedAttach(
attempt, outcome: .awaitingUserApproval
)

#expect(!shouldFallBack)
#expect(!coordinator.shouldFallBackFromInjectedAttach)
// An attach parked on the Mac-side approval prompt still owns startup:
// the saved-Mac reconnect dialing underneath it would race the very
// connection the user is approving.
#expect(coordinator.claimStoredReconnect() == nil)
}

@Test
@MainActor
func failedInjectedAttachReleasesStartupToStoredReconnect() throws {
let coordinator = MobileStartupConnectionCoordinator()
let attempt = try #require(coordinator.claimInjectedAttach())

let shouldFallBack = coordinator.finishInjectedAttach(attempt, outcome: .failed)

func record(_ url: String) {
urls.append(url)
#expect(shouldFallBack)
#expect(coordinator.shouldFallBackFromInjectedAttach)
// A failed explicit route releases startup so the authenticated shell
// is not stranded disconnected.
#expect(coordinator.claimStoredReconnect() != nil)
}

func values() -> [String] {
urls
@Test
@MainActor
func onlyOneStartupSourceCanClaimAdmission() throws {
let coordinator = MobileStartupConnectionCoordinator()

#expect(coordinator.claimInjectedAttach() != nil)
#expect(coordinator.claimInjectedAttach() == nil)
#expect(coordinator.claimStoredReconnect() == nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#if canImport(UIKit)
public import UIKit

/// Process-wide record of the software keyboard's most recent frame transition.
///
/// `UIView.keyboardLayoutGuide` only reflects keyboard changes UIKit routed to
/// that view's window while the view was installed; a view (re)attached around
/// a workspace switch can miss the transition entirely and stay seated on the
/// guide's bottom-safe-area fallback while the keyboard is up. Keyboard
/// notifications, by contrast, are posted process-wide regardless of any
/// view's attachment, so this tracker is always able to answer "where is the
/// keyboard now?" for late-attaching views. It is a read-only catch-up source:
/// guide-constrained chrome keeps following the guide, and consumers use the
/// tracker only to bound how far below the keyboard that chrome may sit.
@MainActor
public final class MobileKeyboardFrameTracker {
/// The single process-wide tracker. Created on first access; access it
/// before the first keyboard presentation so no transition is missed.
/// The keyboard is process-global UIKit state, and a late-created view
/// must read transitions observed BEFORE it existed — a per-instance
/// observer cannot provide that by construction. Consumers hold an
/// injectable reference (`GhosttySurfaceView.keyboardFrameTracker`), so
/// tests isolate with a private-center instance and never touch this one.
// lint:allow singleton — process-global keyboard state, injectable at use sites.
public static let shared = MobileKeyboardFrameTracker()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// The most recent keyboard transition, or `nil` while the keyboard state
/// is unknown (nothing observed yet, keyboard fully hidden, a transition
/// posted without a readable end frame, or state discarded on
/// backgrounding because iOS can tear the keyboard down without a paired
/// notification).
public private(set) var latestTransition: MobileKeyboardTransition?

private nonisolated(unsafe) var tokens: [NSObjectProtocol] = []
private nonisolated let notificationCenter: NotificationCenter

/// Creates a tracker subscribed to the keyboard frame notifications.
///
/// - Parameter notificationCenter: The center to observe; tests inject a
/// private center so posted fixtures cannot leak into other suites.
public init(notificationCenter: NotificationCenter = .default) {
self.notificationCenter = notificationCenter
tokens = [
notificationCenter.addObserver(
forName: UIResponder.keyboardWillChangeFrameNotification,
object: nil,
queue: .main
) { [weak self] notification in
let transition = MobileKeyboardTransition(notification: notification)
MainActor.assumeIsolated {
// A transition without a readable end frame leaves the
// keyboard state unknown; fail closed (clear) so a stale
// floor can never keep the dock and viewport raised after
// the keyboard actually changed.
self?.latestTransition = transition
}
},
// The settled twin of willChangeFrame: consumers converting the
// end frame through a view whose own frame moved during the
// transition re-derive against final geometry from this one.
notificationCenter.addObserver(
forName: UIResponder.keyboardDidChangeFrameNotification,
object: nil,
queue: .main
) { [weak self] notification in
let transition = MobileKeyboardTransition(notification: notification)
MainActor.assumeIsolated {
self?.latestTransition = transition
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
notificationCenter.addObserver(
forName: UIResponder.keyboardDidHideNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.latestTransition = nil }
},
notificationCenter.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.latestTransition = nil }
},
]
}

deinit {
for token in tokens {
notificationCenter.removeObserver(token)
}
}

/// Returns how much of `view` the tracked keyboard covers from its bottom
/// edge, or zero while the keyboard state is unknown, the view is detached,
/// or the keyboard does not reach the view's bottom (floating/split iPad
/// keyboards, or an end frame parked below the screen after a dismissal).
public func overlap(in view: UIView) -> CGFloat {
latestTransition?.overlap(in: view) ?? 0
}

/// Returns how much of `view`'s window the tracked keyboard covers, in
/// window points — stable across a transition even when the view's own
/// frame animates with it. Zero while unknown or detached.
public func overlapInWindow(of view: UIView) -> CGFloat {
latestTransition?.overlapInWindow(of: view) ?? 0
}

/// Returns whether the tracked keyboard is visible to `view` (including
/// floating/split iPad keyboards that reserve no bottom space), or false
/// while the keyboard state is unknown or the view is detached.
public func isVisible(in view: UIView) -> Bool {
latestTransition?.isVisible(in: view) ?? false
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ public struct MobileKeyboardTransition: Sendable {
).height
}

/// Returns how much of `view`'s WINDOW is covered by the keyboard's final
/// frame, in window points.
///
/// Window-space overlap is stable across a keyboard transition even when
/// the view's own frame animates with it (safe-area propagation can resize
/// hosted views mid-transition), so constraints anchored to the window can
/// be seeded from the first notification without a late correction.
///
/// - Parameter view: The view whose window should be measured.
/// - Returns: The bottom overlap in window coordinates, or zero when the
/// view is detached or the keyboard does not reach the window's bottom.
@MainActor public func overlapInWindow(of view: UIView) -> CGFloat {
guard let window = view.window else { return 0 }
let keyboardFrameInWindow = window.convert(endFrame, from: nil)
return MobileKeyboardReservation(
keyboardFrameInWindow: keyboardFrameInWindow,
viewFrameInWindow: window.bounds
).height
}

/// Returns whether the keyboard is visible to `view`, including floating or
/// split iPad keyboards that do not reserve bottom layout space.
@MainActor public func isVisible(in view: UIView) -> Bool {
Expand Down
Loading