Skip to content

ios: prepare for iPhone Duo and add a laptop mode - #7911

Draft
osy wants to merge 10 commits into
mainfrom
feature/duo
Draft

osy wants to merge 10 commits into
mainfrom
feature/duo

Conversation

@osy

@osy osy commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Prepares the iOS app for iPhone Duo (iOS 27.1) and adds a laptop mode for it. Duo is a folding iPhone: closed, the outer display reserves a vertical strip for the status bar and camera; open, the inner display is iPad-sized; folded partway on a table, the fold splits the screen into a top and a bottom half. Building with the iOS 27.1 SDK turns on edge-to-edge layout and vertical bars there, so the app needs these changes before it ships a 27.1 build.

Layout on Duo

  • The VM toolbar becomes a column inside the strip, below the camera, on the closed outer display (Apple's layout region for a vertical bar). Elsewhere it keeps the draggable corner toolbar. The collapsed state now lives in the window state so it survives folding and unfolding.
  • The home list's Settings button becomes a gear symbol so it joins the other symbols in the vertical bar, and the details bar gains visibility priorities on iOS 27 so Run/Stop is the last to overflow.
  • The Start/Resume button and the headless notice stay out of the fold.
  • The terminal uses the corner-adapted safe area (iOS 26) instead of hand-made iPad-only padding.

Laptop mode

  • With the device folded like a laptop, the guest display takes the top half and the bottom half becomes an input deck: the on-screen keyboard with UTM's key row, or a new touchpad (relative pointer, tap and two-finger tap to click, two-finger scroll, press-and-hold to drag, haptic clicks). A key on each swaps to the other, and the toolbar's keyboard button steps between them. The trigger is the fold's reserved region, not the hinge angle.

Correctness

  • "New Window…" uses SwiftUI's openWindow and hides itself when the system won't allow another window, since new scenes are only allowed on Duo's inner display. The display picker only offers devices no other window already shows.
  • Pixel conversion no longer falls back to the deprecated UIScreen.main, and the ProMotion default reads the idiom from the trait collection.
  • The settings sheet stays single-column in regular-width sheets, and unused accessory-height externs are removed.

Code that needs the iOS 27.1 SDK is compiled only when the SwiftUI module is at least that version, so the project still builds with Xcode 27.0.

Testing: Not yet tested by a human. This draft must not be marked ready for review until a human has tested it; the testing statement will be added then.

Automated testing so far, on simulators with Xcode 27.1 beta:

  • iPhone Duo, iOS 27.1, closed and in the Open, Laptop and Book poses driven through Device Hub: the toolbar and bars on both panels, the deck with the keyboard and the touchpad and the swap between them in both directions, the terminal window folded, the guest display staying clear of the fold and key row, the toolbar above the keyboard and above the deck, and repeated fold cycles with the app staying alive.
  • iPhone 15 Pro, iPad Pro 11-inch (4th generation) and iPhone SE (3rd generation) on iOS 17.0; iPhone 18 Pro and iPad Pro 13-inch (M5) on iOS 27.0; Apple Vision Pro on visionOS 27.0.
  • Builds for iOS and visionOS with Xcode 27.1 and 27.0, and for macOS.

@osy
osy force-pushed the feature/duo branch 2 times, most recently from e9ab106 to d7cc1a2 Compare September 24, 2026 13:29
osy added 10 commits September 24, 2026 20:52
UIScreen.mainScreen is deprecated and ambiguous on a device with two
displays, and Apple's resizability audit flags it. Use the scene's
screen when the view is in a window, since its native scale still
differs from the trait scale on downsampled displays, and the view's own
display scale trait otherwise.

Assisted-by: Claude:claude-fable-5-1
UIDevice.currentDevice.userInterfaceIdiom is what Apple's resizability
audit asks to replace with the trait collection. The branches and the
result stay the same: only iPad defaults to the display's maximum frame
rate, iPhone keeps 60 Hz unless the FPS limit setting says otherwise.

Assisted-by: Claude:claude-fable-5-1
UTMSettingsView was the only sheet whose NavigationView had no stack
style, so in a regular-width sheet such as on iPad or the open iPhone
Duo it could render as a two-column split. The other sheets already use
the stack style.

Assisted-by: Claude:claude-fable-5-1
The constants were declared for the XIB accessory view and never defined
or used since it was replaced.

Assisted-by: Claude:claude-fable-5-1
The terminal padded its top by hand to keep the first line out of the
rounded corners: only on iPad, from the first connected scene, which is
the wrong one when there are two, and with the bottom inset reused as a
top inset. iPhone Duo is the phone idiom and its inner display has
iPad-like corners, so the check did not fire there at all.

iOS 26 has a layout region that adapts the safe area to the corners, so
constrain the terminal to it instead, adapted along the vertical axis:
on the closed iPhone Duo the horizontal adaptation would inset the sides
for the whole height while the vertical one only pushes the first line
down by 17 pt. The constraints are now set up once in viewDidLoad rather
than on every appearance.

Assisted-by: Claude:claude-fable-5-1
On the closed iPhone Duo, symbol toolbar items move into the vertical
strip next to the camera while text items stay in a horizontal bar. The
Settings button was the only text item next to the system Edit button,
so the home list ended up with two bars for four controls; a gear symbol
puts it with the other items and it keeps its title in the overflow
menu.

The details toolbar is one item group, which cannot express priorities.
On iOS 27 it becomes separate items with Run/Stop marked high priority
and Clone low, because a vertical bar overflows from the bottom, where
Run/Stop sits, and the outer landscape panel is only 466 pt tall.
Older systems and the other platforms keep the grouped layout, now built
from the same button definitions.

Assisted-by: Claude:claude-fable-5-1
"New Window…" called requestSceneSessionActivation with no error
handler whenever UIApplication reports multiple scenes. On iPhone Duo
that stays true although new scenes are only allowed on the inner
display, so the request could fail silently on the outer one. Give the
window group an identifier and use openWindow from iOS 16, gated on the
supportsMultipleWindows environment value which follows the system's
current answer; the iOS 15 path keeps the UIKit call but now reports a
refusal in an alert.

Two windows of one session could also pick the same display, and both
would then request its resolution with the last one winning. The window
and external monitor pickers only offer devices that no other window
already shows.

Assisted-by: Claude:claude-fable-5-1
The closed iPhone Duo reserves a vertical strip on one side of the
outer display for the status bar and camera, and its safe area pushes
the floating toolbar out of it. Nine buttons then need 352 of the 382 pt
that remain, and the strip can also be on the leading side, which the
corner setting cannot express. Apple's rule for that display is
controls on the side, in a column, and the system puts its own bars
inside the strip below the camera.

When the window is compact and its safe area is inset on one side only,
the toolbar becomes a column with the show/hide button first, placed in
the iOS 27.1 layout region for a bar on that edge: on the closed Duo
that is a 44 pt column centred in the strip starting under the camera.
It can still be collapsed and expanded but not dragged, and its tip
arrow follows the edge. Other phones inset both sides equally in
landscape and neither in portrait, so they keep the draggable corner
toolbar, and without the region the column sits next to the strip.

The collapsed state and the idle timers move from view state into
VMWindowState so that switching between the two layouts, or a scene
moving between the two panels, keeps the toolbar collapsed or expanded
as it was. A toolbar that appears again still resets its idle timers,
so it never comes back invisible. The collapsed choice, including a
collapse from dragging, still persists in the defaults for the next
launch and for the visionOS ornament.

Assisted-by: Claude:claude-fable-5-1
A partially folded iPhone Duo reports the fold as a reserved division
region. Alerts, menus and sheets already move out of it, but a control
centred in the VM window lands right in the curve: the Start/Resume
button shown while the VM is stopped or paused, and the notice shown
when no device is selected. Place them in the largest part of the
window that no active division runs through; without a fold that is the
whole window, so nothing changes elsewhere.

The query only exists in the iOS 27.1 SDK, so the code is compiled in
only when the SwiftUI module is at least that version and the project
still builds with Xcode 27.0.

Assisted-by: Claude:claude-fable-5-1
… laptop

With the device partially folded on a table, the guest display takes the
half above the fold and the half below becomes an input deck holding one
of two things the user can swap: the on-screen keyboard with UTM's
accessory row, or a new touchpad drawn like the keyboard. The trigger is
the fold itself, reported by the iOS 27.1 SDK as an active horizontal
division region; Apple keeps the hinge angle for effects and the
reserved regions for layout. The deck is remembered per window so
folding again brings the same one back. Only the guest display gets the
touchpad; a terminal window keeps the keyboard in the deck.

The display controller now constrains the Metal view above the deck,
the fold and the accessory row, and requests the guest resolution for
that area instead of the whole view, which is also what the dynamic
resolution needs when the device opens or closes. Setting the deck from
a SwiftUI update defers the resize to the next turn of the run loop, and
a resize before the guest display exists no longer divides by its zero
size. The hosted display takes whatever size SwiftUI proposes: SwiftUI
measures the window against the part above the keyboard and centres a
window that reports more, so a display sized from its own constraints
lifted the whole window off the top of the screen once the reserved
space below the guest display exceeded the keyboard's height. The
toolbar keeps above the deck with a padding inside its own reader for
the same reason, and leaves the keyboard to the system as before.

The touchpad feeds the same relative pointer path as a connected mouse:
one finger moves the pointer with the drag speed setting applied, a tap
clicks, a two finger tap right-clicks, two fingers scroll with inertia
and a finger held down drags. Clicks come with a rigid haptic tap and a
lighter one on release of a drag, so a press feels like a physical
trackpad; nothing for motion or scrolling. A key on the accessory row
swaps to the touchpad, a key on the touchpad swaps back and the
toolbar's keyboard button steps between the two while folded.

The layout is done by hand from the division's frame rather than with
ArrangementView so the hosted display keeps its identity across the
fold and is not torn down and recreated. A fold becoming active does
not change the window's size, so the region is read again whenever the
hinge moves, and the deck passes the count of those readings down to
the other readers in the window. Without the 27.1 SDK the deck code is
compiled out and Xcode 27.0 still builds the project.

While the deck is active the accessory row leaves the keyboard and sits
between the guest display and the fold, so that the guest display and
the toolbar can lay out around it. The swap dismisses and re-shows the
keyboard, since reloading the input views while the keyboard moves
between the panels crashes inside UIKit. The keyboard itself is asked
for once the pose has settled, with one retry. A keyboard the system
places off the screen while it moves between the panels is not counted
as shown, since reacting to it asks for the keyboard again and the two
keep bouncing. The display controller learns whether the keyboard is
shown from the window instead of watching the notifications itself.

Touches on the deck reach the display controller through the responder
chain, so its touch handling only takes touches on the guest display.
Folding locks the zoom to fit and shows the keyboard, and restores the
window's own choices when the device opens again; neither forced value
is persisted, and a window restored while folded keeps them forced.

Assisted-by: Claude:claude-fable-5-1
Assisted-by: Claude:claude-opus-5-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant