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

A Gate Before the First Brick, and Public API Only

~14 min · engine gate, public API, user agent, testing

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

A ruling still has to be checked

Dad's "웹키트로 가자" settled the engine. It did not settle whether the browser Dad needed could actually be built on it. So before any shell code, the plan set Phase 0: a throwaway spike that runs a gate of eleven cases on the Mac. Each case has three columns: what it is, when it passes, and what a failure would mean. The third column is the one most plans leave out, and it is what turns a test list into a gate. You decide in advance which failures change the plan.

It needed no download. The engine was already on the Mac, which is itself a small argument for WebKit.

The eleven cases and what they found

CaseResult on 2026-10-03
W1 1PasswordIts Chrome package loads with no manifest error. As installed, its background stops at the first Chrome call WebKit leaves undefined; with stand-ins for the missing names it starts and asks for its desktop app. 1Password's desktop app refused every earlier signature and listed Voyager as a connected browser only once the installed bundle was signed with the paid team's Apple Development identity and the hardened runtime. Pairing, the pop-up and filling stayed open; the extensions track tells that story
W2 other extensionsThe three other extensions Dad uses start, one of them only after two members WebKit defines were hidden. An assistant extension that drives pages through debugger and tabGroups fails, as expected
W3 Google sign-in, W4 bot checksPages load under a Safari user agent with no block or challenge; the sign-in itself was done by a person in the installed app, never simulated, and passed
W5 mediaH.264/AAC, HEVC, VP9, AV1 and Opus playable; YouTube played. FairPlay available to a page, Widevine not
W6 trustA microphone request reaches the host with its origin before any prompt, and a refusal is final. An HTTPS family name and plain-http loopback are secure contexts; a plain-http tailnet name is not and cannot ask at all
W7 identitiesCookies and stored values isolated per identity; a pop-up shares its opener's store
W8 inputKorean input right in pages and the address field (Dad typed it)
W9 sleepinteractionState restored address, scroll and back list, even from 624 bytes written to a file
W10 background memoryThe 567-second result from the memory lesson
W11 Web InspectorNot run: it attaches to an inspectable page, which the app sets, and Dad had not opened it yet

Two habits in that table are worth copying. The results say what was exercised and what was not: the extension probe hosted no tab, pop-up or native messaging, so the evaluation says plainly that "starts" is not "works". And the cases that needed a person (a real sign-in, typing Korean, the microphone grant) are marked as needing a person, never simulated.

The exit line of Phase 0 reads: "every case a machine can judge has run, and none failed in a way that reopens the engine choice." The cases that needed a person moved into the first brick, where a real window served them better than a spike. The spike was thrown away, and nothing from it was promoted without being rewritten against the architecture.

Principle: A gate is a list of cases where each failure already has a consequence written beside it. Without that column you have tests; with it you have a decision you can let a machine make.

Public API only

Architecture invariant 3 is short: the engine is the system's WebKit, through its public API. Voyager ships no engine and patches none. A private WebKit interface enters only by Dad's ruling, named in the architecture with what breaks when Apple changes it. None is used today.

The temptations are real, and the repo lists them by name: an inline inspector, text extraction, a raised memory limit. Every one would make a feature easier. Every one can break without warning on an OS update, in a browser Dad depends on daily. That is why the gaps in the previous lesson are answered in Voyager's own code (a find count from the page's visible text, a mute from its own content world) instead of reached through a private call. The parity contract's Never table holds the same line: no bundled or patched engine, and no private WebKit interface without Dad's ruling, because it breaks when Apple changes it, silently.

Presenting as Safari, coherently

Public API still leaves one decision that affects every site: what the browser says it is. WebKit's own user agent names no browser, and sites read that as an embedded web view; Google refuses sign-in to those, the same policy that counted against CEF. Voyager sets applicationNameForUserAgent, a public configuration property for the app name inside WebKit's user agent string, to the token Safari presents on the same system: Version/ plus Safari's version, then Safari/605.1.15. On the Mac it reads the version from Safari's own bundle. On iPhone and iPad, Safari's version is the system's, so it uses that. (If Safari's bundle could not be read on a Mac, the same fallback would apply there, where the system version and Safari's are not the same number.)

The Never table explains why it is all of Safari's token and not a tweak here and there: "Partial spoofing is what sites detect; one coherent identity or none." The pages, and Pippa's own panel, get the same token. A test in the real-WebKit suite, itPresentsSafarisToken, loads a page and checks what navigator.userAgent actually says.

The last line of public API in this story is one property: every page sets isInspectable = true, so Safari's Web Inspector can attach. That is Voyager's answer to Chrome DevTools: no private inline inspector, and the system's own tool through a door Apple documents.

Code

cwkVoyager, Sources/VoyagerWebKit/WebKitEngine.swift: Safari's token, read from the system (verbatim, lines 164-179)·swift
    /// WebKit's own user agent names no browser, and sites take that for an
    /// embedded web view (Google refuses sign-in to those). Voyager presents
    /// the token Safari presents on this system.
    static let userAgentApplication = "Version/\(safariVersion) Safari/605.1.15"

    private static var safariVersion: String {
        #if os(macOS)
        if let version = Bundle(path: "/Applications/Safari.app")?
            .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
            return version
        }
        #endif
        // On iPhone and iPad, Safari's version is the system's.
        let system = ProcessInfo.processInfo.operatingSystemVersion
        return "\(system.majorVersion).\(system.minorVersion)"
    }
cwkVoyager, Sources/VoyagerWebKit/WebKitEngine.swift: every page's configuration carries the identity's store and the token (verbatim, lines 106-110)·swift
    func configuration(for identity: IdentityID) -> WKWebViewConfiguration {
        let configuration = WKWebViewConfiguration()
        configuration.websiteDataStore = store(for: identity)
        configuration.applicationNameForUserAgent = Self.userAgentApplication
        configuration.mediaTypesRequiringUserActionForPlayback = []
cwkVoyager, Tests/VoyagerWebKitTests/WebKitPageTests.swift: the page itself reports the token (verbatim, lines 137-144)·swift
    @Test func itPresentsSafarisToken() async {
        let page = makePage()
        defer { page.close() }
        await secure(page, "<p>ua</p>")
        let agent = await js(page, "return navigator.userAgent")
        #expect(agent.contains("Version/"))
        #expect(agent.hasSuffix("Safari/605.1.15"))
    }

External links

Exercise

Before your next significant build, write a gate of five to eleven cases as a table with four columns: the case, the exact condition under which it passes, what a failure would mean for the plan, and who runs it (a machine or a named person). Mark at least one case whose failure would reverse the main technical decision. Run every case a machine can judge in a throwaway spike, write the result beside each case including what the run did not exercise, and then throw the spike away.
Hint
If you cannot fill the "a failure means" column for a case, it is not a gate case yet; it is a curiosity. Keep cases that need a person's own accounts or hands as that person's, and say so in the result instead of faking them.

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.