Repository navigation
iOS: keep the bottom bars docked to the keyboard when the layout guide misses a transition #9663
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
9edcf20
iOS: regression tests for bars staying behind a risen keyboard
azooz2003-bit b0e2919
iOS: floor the bottom dock at the notification-tracked keyboard frame
azooz2003-bit b74ab85
iOS: unbreak MobileInjectedAttachStartupTests so the xctestplan compiles
azooz2003-bit eaf3cce
iOS: make the dock floor's keyboard tracker an init-injected dependency
azooz2003-bit b0626fa
iOS: cover the approval-gated injected attach admission path
azooz2003-bit 5ecfb17
iOS: regression tests for the stale keyboard-toggle glyph after re-entry
azooz2003-bit 984c585
iOS: reconcile keyboard visibility (and the toggle glyph) on remount
azooz2003-bit db98aa6
iOS: track the keyboard directly so the dock rides its animation
azooz2003-bit aa7749d
iOS: pin the terminal surface's frame across keyboard transitions
azooz2003-bit 44c76ba
iOS: drain the iPhone install queue without foreground fights
azooz2003-bit 1af287d
iOS: regression test for the dock popping after the surface frame set…
azooz2003-bit fe83b25
iOS: anchor the keyboard floor to the window, not the surface
azooz2003-bit 670b0dd
iOS: the bottom dock becomes a system keyboard accessory
azooz2003-bit 3fab1b1
iOS: keyboard toggle decides from responder truth, not the tracked bit
azooz2003-bit File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 56 additions & 24 deletions
80
...iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| } | ||
| } | ||
116 changes: 116 additions & 0 deletions
116
Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
|
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 | ||
| } | ||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.