Skip to content
C.W.K.
Lesson 05 of 05 · published

One Parent, Two Shells

~14 min · ios, macos, size-classes, architecture

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

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

DifferenceMaciPhone and iPadLives in
Default page zoom and interface size150% and 125%, for Dad's eyes1 and 1; text size comes from iOSthe shell
Windowsmany, one controller eachone BrowserWindowModelthe shell
Chrome extensionsan extension host, made before any windowno extension hostthe shell
Departure log and network watchbothneitherthe shell
A page's dialogan NSAlert, once its tab is in fronta SwiftUI alert, answered through a continuationthe shell
File chooser and context menuVoyager'sWebKit on iOS draws its own; the shell answers nil and an empty listthe 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) branchthe other branchthe 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.

Principle: A second platform is the real test of a core. Count what it costs the core, not the app: here, two lines and no branch.

Code

cwkVoyager, clients/apple/VoyagerMobile/Sources/MobileBrowser.swift: the phone wires the same parent and the same child (verbatim, lines 72-102)·swift
        // A phone page is laid out for the phone: the Mac's 150% page zoom
        // and interface size would push it off the screen. Dad's text size
        // comes from iOS here.
        let defaults = VoyagerSettings(pageZoom: 1, interfaceScale: 1)
        let settings = SettingsModel(store: (try? paths.settingsFile()).map { SettingsStore(url: $0, defaults: defaults) }, defaults: defaults)
        if let problem = settings.problem {
            notices.append("The saved settings could not be read (\(problem)). This run uses the defaults and leaves the file alone.")
        }
        let history = HistoryModel(store: (try? paths.historyFile()).map(HistoryStore.init(url:)))
        let bookmarks = BookmarksModel(store: (try? paths.bookmarksFile()).map(BookmarkStore.init(url:)))
        let passwords = PasswordsModel(store: (try? paths.loginsFile()).map(LoginStore.init(url:)), vault: KeychainPasswordVault())
        let workspace = WorkspaceModel(store: (try? paths.workspaceFile()).map(WorkspaceStore.init(url:)))
        let downloads = DownloadsModel(folder: (try? paths.downloadsDirectory()) ?? FileManager.default.temporaryDirectory)
        let services = BrowserServices(
            engine: WebKitEngine(), identities: identities, downloads: downloads, settings: settings,
            history: history, bookmarks: bookmarks, passwords: passwords, workspace: workspace)
        // A phone holds no family role, so a target bound to one (the site's
        // dev server, `@workbench`) never shows here: the kit's one rule.
        services.familyCatalog = FamilyCatalogModel(cacheURL: try? paths.familyCatalogFile(), device: .current)
        services.identityStore = identityStore
        self.services = services

        let window = BrowserWindowModel(services: services)
        window.visits = history
        self.window = window
        pippa = PippaPanelHost(window: window, location: nil)
        pippaSurface = PippaPanelHost(window: window, location: nil, kind: .workspace)
        connector = PippaConnector()
        self.notices = notices
        services.identityInUse = { [weak window] id in window?.tabs.contains { $0.identity == id } ?? false }
        window.shell = self
cwkVoyager, clients/apple/VoyagerMobile/Sources/RootView.swift: the layout follows the size class (verbatim, lines 1-31)·swift
// The phone's one window: the address field on top, the tab in front, and
// the bar at the bottom (back, forward, Home, Pippa, the tabs). On an iPad in
// a regular width the tabs also run along the top. Size classes decide,
// never the device (IOS-NATIVE-DELIVERY §4.1).

import SwiftUI
import UIKit
import VoyagerCore

struct RootView: View {
    let browser: MobileBrowser
    @Environment(\.horizontalSizeClass) private var width

    private var window: BrowserWindowModel { browser.window }

    var body: some View {
        @Bindable var browser = browser
        VStack(spacing: 0) {
            if width == .regular { TabStrip(browser: browser) }
            AddressBar(browser: browser)
            if let tab = window.selectedTab, tab.status.isLoading {
                ProgressView(value: max(tab.status.progress, 0.05)).progressViewStyle(.linear).frame(height: 2)
            } else {
                Divider()
            }
            content
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                // A notice never moves the page: it floats over it.
                .overlay(alignment: .bottom) { NoticeStack(browser: browser) }
            BottomBar(browser: browser)
        }

External links

Exercise

Take an app whose rules already live in a core library (or the one you split in lesson 1's exercise) and give it a second shell on a platform it does not target yet. A command-line tool counts. Make the second shell answer the same shell protocol the first one does, assemble the core in the same order, and keep a tally of every line you had to change inside the core to make it build.
Hint
Each line you change in the core is a finding: it names something platform-shaped that had slipped below the line (a home-folder lookup, a colour type, a file picker). Move it into the shells or behind the contract, then build both shells again. The goal is a tally of zero on the next platform.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.