What a shell is
The engine sits below the parent. The shell sits on the other side: it draws, and it answers whatever only a screen can answer. The parent names that second boundary too, as BrowserShell, "the part of a page's requests that only a screen can answer": show a page's dialog, choose files, open an address in another app, ask Dad for a server's name and password, and offer commands at the top of a context menu. On the Mac, BrowserWindowController conforms to it. On iPhone and iPad, MobileBrowser does. The parent in the middle knows both its neighbours only as protocols.
Drawing goes through the contract as well. A page hands the shell an EngineSurface whose view is typed AnyObject. The Mac shell casts it to NSView, the phone's PageView casts it to UIView and hosts it in SwiftUI, and the parent never learns which.
Assembled the same way
The phone's file says what it is in its first lines: VoyagerCore's services and one window, "put together the way the Mac's AppDelegate does", and "Everything about tabs, trust, the catalog and Pippa is the parent's; this file only wires it to the phone." Read AppDelegate.applicationDidFinishLaunching beside MobileBrowser.init and the same steps appear, nearly in the same order: identities from their store, settings, history, bookmarks, the password store, account shortcuts, downloads, then BrowserServices given a WebKitEngine(), then the family catalog, the stored session, and a sleep timer that runs once a minute. An unreadable identities or settings file gets the same notice on both, word for word, and is left alone on both. The first block below is the phone's half.
What differs, and where it lives
| Difference | Mac | iPhone and iPad | Lives in |
|---|---|---|---|
| Default page zoom and interface size | 150% and 125%, for Dad's eyes | 1 and 1; text size comes from iOS | the shell |
| Windows | many, one controller each | one BrowserWindowModel | the shell |
| Chrome extensions | an extension host, made before any window | no extension host | the shell |
| Departure log and network watch | both | neither | the shell |
| A page's dialog | an NSAlert, once its tab is in front | a SwiftUI alert, answered through a continuation | the shell |
| File chooser and context menu | Voyager's | WebKit on iOS draws its own; the shell answers nil and an empty list | the shell |
| Web view subclass, pinch zoom, Safari's version in the user agent, the Dock's download bounce, an extension's pop-up | #if os(macOS) branch | the other branch | the child |
Read the last column. Every difference sits in a shell or in the child. Not one of them sits in the parent.
Size classes, never devices
The phone app is one universal target for iPhone and iPad together (TARGETED_DEVICE_FAMILY: "1,2"). That setting only says the one build runs on both. The app could still ask UIKit whether it is on a phone or a pad, and the family's iOS rule says not to branch on the answer, so RootView, in the second block, reads the horizontal size class from the environment. In a regular width the tabs also run along the top; in a compact one they live behind a button in the bottom bar. An iPad in a narrow Split View is compact and gets the phone's layout, which is exactly right, because what matters is the room on screen, not the hardware around it. Swift Quest's track on the universal iPhone and iPad app covers the mechanics.
What the iPhone cost the parent
Here is the payoff, measured. The commit that first built the parent and the child for iOS (22dc017, 2026-10-26) changed two lines in the parent. Both replaced FileManager.default.homeDirectoryForCurrentUser, which exists only on the Mac, with NSHomeDirectory(), which iOS also has. The parent gained no branch. The child gained one: an extension action's pop-up is a popover on the Mac and a view controller on iOS. Everything else the phone needed was a new shell.
Two more details show the same care. Under the hosted test suite the phone app builds no browser at all and shows "Voyager is under test.", so a test run dials nothing and writes nothing. And the phone's parity is narrower by design, with each gap written down in the parity contract instead of left to be discovered: no extension host, no list of downloads, no network-path watch, so an unreachable Pippa is retried on the panel's timer rather than the moment the network returns.