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

The One Child

~15 min · webkit, delegates, objective-c, testing

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

Six files, and where WebKit lives

Sources/VoyagerWebKit/ is six files, and the browser's WebKit lives in them.

FileLinesWhat it is
WebKitEngine.swift262The BrowserEngine: one data store per identity, the configuration every page starts from, the user agent, downloads
WebKitPage.swift526"One WKWebView as an EnginePage"
WebKitPanel.swift314Pippa's panel as a PanelSurface, with the gates on every message it sends up
WebKitExtensionHost.swift705One WKWebExtensionController and a context per installed package (track 8)
PageScripts.swift591The scripts that read a page for Pippa, in a content world of Voyager's own (track 7)
PagePointer.swift171What the pointer is over, the context menu, and the Mac's web view subclass

The engine's platform branches are here too: 20 #if os(macOS) lines across five of the six files, against none in the parent. One honest footnote. The Mac shell has a single WebKit import of its own, in SmokeRun.swift, the verification build's walk through its own screens: it casts a page's surface to WKWebView to run a script in it and to see which view holds the keyboard after a tab switch. Its own comment says it "runs only when the isolation gate permits", so it is an instrument, never part of browsing. The phone app imports WebKit nowhere.

One identity, one store

To the parent, an IdentityID is a UUID and nothing more. What an identity is to WebKit, its cookies and site storage, first appears in the child: store(for:) makes one WKWebsiteDataStore per identity, named by that UUID, and a store that lives only in memory for the private identity. Track 5 is about what that makes possible. Here, look at the boundary in the second function of the first block below. When the parent needs Firelink's hub cookie for one request, the child reads the identity's cookies itself and hands up only the header built from the names the parent asked for. The parent never holds a cookie store.

Translating WebKit's questions

The header of WebKitPage.swift states its whole job: "Everything WebKit asks its host to decide is turned into the contract's question and handed to the parent." Two examples:

  • A new window. WebKit asks its UI delegate for a web view. The child makes a WebKitPage from the configuration WebKit passed in, which carries the opener's data store, so the pop-up is in the opener's identity by construction. Then it asks the parent, with a NewPageRequest, whether to keep it. If the parent says no, the child closes it and hands WebKit nothing.
  • The microphone or the camera. WebKit asks with a security origin, a frame and a capture type. The child builds a PermissionRequest, asks the parent, and maps the answer back, as the third block shows: grant to grant, deny to deny, ask to WebKit's own prompt.

The same file wakes a sleeping tab. Handed a PageState, it puts the bytes into the web view's interactionState only when the state's engine name is its own, which is the child's half of the rule lesson 3 tested on the parent's side.

The test for a silent failure

Most Swift mistakes are loud. This one is not. Under its Swift overlay WebKit is an Objective-C framework, and it decides whether to call a delegate method by asking the delegate whether it responds to that method's Objective-C selector. Write an async Swift method whose argument labels are a little off and it compiles, it is never called, and nothing says so. The page's request simply never arrives.

The trap is easy to fall into because WebKit's async methods have Swift names of their own. In Swift the media-capture decision is webView(_:decideMediaCapturePermissionsFor:initiatedBy:type:) async, while the selector WebKit looks for is webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:. The house sign says to grep the build log for "nearly matches" after adding any delegate method. The test in the second block turns that advice into a rule: fourteen selectors the page must answer, each checked with responds(to:) on a real WebKitPage, plus three more for the engine's download delegate. Trust zones live behind one of the fourteen. A typo there would quietly hand every microphone request back to WebKit's default, and the family's grant would never be consulted.

The real-WebKit suite

VoyagerWebKitTests runs 63 tests in 8 suites against WebKit inside the test process. Every suite is .serialized, with a time limit of one or two minutes. The page tests stay off the network: their pages are data: addresses, or HTML loaded under a made-up secure origin, https://voyager.test/. Their recorder denies every permission, so no system prompt appears and nothing is captured.

One gotcha the house sign records: a WebKit test that awaits something a hidden page never does hangs the whole run, in silence. getUserMedia, for one, is not answered until the page is in a visible window, and swift test prints nothing until it ends. The way out is to run one test at a time under an alarm, so the test that hangs names itself.

Code

cwkVoyager, Sources/VoyagerWebKit/WebKitEngine.swift: one store per identity, and only a header handed up (verbatim, lines 78-104)·swift
    /// One data store per identity, named by the identity's id: its cookies
    /// and site storage are in no other identity's reach.
    func store(for identity: IdentityID) -> WKWebsiteDataStore {
        if let store = stores[identity] { return store }
        // The private identity's store lives in memory for this run only.
        let store = identity == .privateSession ? WKWebsiteDataStore.nonPersistent() : WKWebsiteDataStore(forIdentifier: identity.raw)
        stores[identity] = store
        if identity != .privateSession {
            // The cookie store holds its observers weakly; the engine keeps
            // the watcher, and the watcher keeps no store.
            let watcher = CookieWatcher(identity: identity, engine: self)
            store.httpCookieStore.add(watcher)
            cookieWatchers[identity] = watcher
        }
        return store
    }

    public var cookiesDidChange: (@MainActor (IdentityID) -> Void)?
    private var cookieWatchers: [IdentityID: CookieWatcher] = [:]

    /// Reads the identity's cookies here, in the child, and hands the parent
    /// only the header made of the names it asked for.
    public func cookieHeader(for origin: PageOrigin, path: String, named names: Set<String>, identity: IdentityID) async -> String? {
        guard identity != .privateSession, !names.isEmpty else { return nil }
        let cookies = await store(for: identity).httpCookieStore.allCookies()
        return RequestCookies.header(from: cookies, for: origin, path: path, named: names)
    }
cwkVoyager, Tests/VoyagerWebKitTests/WebKitPageTests.swift: the selector test (verbatim, lines 206-230)·swift
    /// WebKit calls a delegate method only when the class answers its
    /// Objective-C selector, and an async Swift method whose labels are a
    /// little off answers nothing: no error, the page's request just never
    /// arrives (coop learning #733). Trust zones live behind one of these.
    @Test(arguments: [
        "webView:decidePolicyForNavigationAction:decisionHandler:",
        "webView:decidePolicyForNavigationResponse:decisionHandler:",
        "webView:navigationAction:didBecomeDownload:",
        "webView:navigationResponse:didBecomeDownload:",
        "webView:didFailProvisionalNavigation:withError:",
        "webView:didFailNavigation:withError:",
        "webViewWebContentProcessDidTerminate:",
        "webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:",
        "webViewDidClose:",
        "webView:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:",
        "webView:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:",
        "webView:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:",
        "webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:",
        "webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:",
    ])
    func thePageAnswersWhatWebKitAsks(selector: String) {
        let page = makePage()
        defer { page.close() }
        #expect(page.responds(to: NSSelectorFromString(selector)))
    }
cwkVoyager, Sources/VoyagerWebKit/WebKitPage.swift: WebKit's capture question becomes the contract's, and the parent's answer goes back (verbatim, lines 482-514)·swift
    /// Trust zones live here: the request arrives before any prompt, with the
    /// origin WebKit vouches for, and the parent's answer is final.
    func webView(
        _ webView: WKWebView, decideMediaCapturePermissionsFor origin: WKSecurityOrigin,
        initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType
    ) async -> WKPermissionDecision {
        let request = Self.permissionRequest(
            asking: self.origin(of: origin), isMainFrame: frame.isMainFrame, pageURL: webView.url, type: type)
        return Self.answer(delegate?.page(self, decide: request) ?? .ask)
    }

    /// The frame that asks, and the page Dad sees it in. A frame inside
    /// another page is judged with that page.
    static func permissionRequest(
        asking: PageOrigin, isMainFrame: Bool, pageURL: URL?, type: WKMediaCaptureType
    ) -> PermissionRequest {
        let kind: PermissionKind = switch type {
        case .camera: .camera
        case .microphone: .microphone
        case .cameraAndMicrophone: .cameraAndMicrophone
        @unknown default: .cameraAndMicrophone
        }
        let top = isMainFrame ? asking : (pageURL.flatMap(PageOrigin.init(url:)) ?? asking)
        return PermissionRequest(origin: asking, topOrigin: top, kind: kind)
    }

    static func answer(_ decision: PermissionDecision) -> WKPermissionDecision {
        switch decision {
        case .grant: .grant
        case .deny: .deny
        case .ask: .prompt
        }
    }

External links

Exercise

If your app implements delegate methods of an Objective-C framework from Swift (WebKit, MapKit, AVFoundation, UIKit), write a parameterized test that lists the Objective-C selectors you expect the framework to call and checks that your delegate responds to each. Then break one argument label on purpose, and watch the test fail while the build stays green.
Hint
With Swift Testing the whole test is @Test(arguments:) over an array of selector strings and #expect(object.responds(to: NSSelectorFromString(selector))). The delegate class must inherit from NSObject. Take the selector names from the framework's Objective-C header or Apple's documentation, never from your own Swift method, or the test will agree with your typo.

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.