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

Denied to Extensions, Opened by Name

~14 min · web-extensions, widgetkit, deep-links, honest-gaps

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

The same list, read the other way

The catalog that grants the microphone also builds a fence. On the Mac, the same code that installs trust (lesson 4) hands the extension host a set of family hosts: the hub's and Pippa's from Settings, and the host of every web target in the catalog. To those the host adds three patterns that are family on every Mac: *://localhost/*, *://127.0.0.1/* and *://*.ts.net/*. Then every installed extension is explicitly denied all of them, whatever it asked for. The first block is the mechanism.

Three details make it work. A denied pattern beats a granted one: measured on 2026-10-04 against a package that asked for <all_urls>, WebKit's hasAccess says no for a denied host, so the extension keeps everything it was granted elsewhere. The host remembers exactly which family patterns it denied each extension, so a change clears those and none of the extension's own. And Settings can let one extension in by its id, after which the family's sites open to it until Dad takes that back. The test installs a package granted every site, checks that it reaches example.com but not loopback, a ts.net host or a catalog host, adds a host the catalog names later, lets the extension in, and takes it out again.

The reason was a measured failure. With 1Password's extension running on cwkPippa, its content script took over navigator.credentials, and a Touch ID passkey could not be saved while its desktop pairing was open (876ea6a, 2026-10-04). Track 8 tells the rest of the 1Password story, and Pippa Embed Quest teaches the Chrome extension model itself, hands-on.

Wide to deny, narrow to grant

Read side by side, the two uses of the family list err in opposite directions.

Microphone and camera grantExtension fence
UnitAn exact origin: scheme, host, portA host pattern: http or https (WebKit's * scheme matches only those two), any port
TailnetOnly origins the catalog or Settings listEvery *.ts.net host
LoopbackAny loopback spellinglocalhost and 127.0.0.1
Cost of being too wideA page nobody vouched for hears DadAn extension misses a family site

The fence can name every *.ts.net host without asking tailscaled, because a name pattern is enough to keep something out. It is not enough to let something in, which is why the grant waits for an exact origin. Each list errs in the direction that is cheap for what it guards.

By name, today

The architecture lists the address field as taking "a URL, a search, a family app by name, or a question for Pippa." Two of those four are built. AddressResolver opens with a comment, "Phase 2 puts a family app's name in front of both (the launch catalog)", and its resolve(_:) knows full schemes, text that reads as a host, and the search template. Nothing in the address field, its suggestions or the toolbar consults the catalog. A bare word is a search, so typing a family app's name there today searches the web for that word. A question for Pippa is not built there either. The plan keeps both open, and Dad ruled that they are taught as not yet built.

Today "by name" means two surfaces:

  • On the Mac, the native Home's "Find a family app" field. It filters the catalog grid as Dad types, matching each target's title and label together, ignoring case.
  • On iPhone and iPad, a Home Screen widget that shows the family apps by name.

A widget that holds no address

The phone app keeps a shelf for the widget: each web target's catalog id, title and favourite flag, plus the catalog revision and when it was read. Never an address. The shelf lives in the app's team-prefixed keychain group, which the widget shares, through the kit's family document store. An App Group is the usual tool for this, but a new App Group cannot be registered by the family's key-based TestFlight pipeline, and a keychain group is already in every profile.

A tile is a link, cwkvoyager://family/<target id> (second block). The widget never dials anything. Its timeline policy is .never, and the app reloads it only when the shelf's apps change. The small, medium and large sizes show 4, 8 and 16 tiles, favourites first.

The small Voyager widget on an iPhone Home Screen: four family app tiles, Almanac, Anvil, Avatars and Bard, each a letter icon above its name.
The small size: four tiles, each a family app by name. The widget dials nothing; a tap hands the app an id to resolve.

When a tile is tapped, the app resolves the id against the catalog it already holds (third block). If the id is not listed, or no catalog has been read yet, it reads the catalog first. Then it opens the address that catalog lists, bringing an existing tab of the same origin to the front instead of opening a second one, or says plainly that Firelink no longer lists that app. So when the family moves an app to another Mac, Firelink's pointer changes and nothing on the Home Screen does. The tap follows the move once the app has read the catalog again, which it does when it comes to the front, at most once a minute. A tap that wins that race, or comes while the hub cannot be reached, still opens the address the old catalog listed: a listed id is never held back to wait for a fresh read.

That scheme is the only one the phone app declares. Voyager is not the default browser on iOS (Dad, 2026-10-26), so it claims no http or https. One testing gotcha: simctl openurl always stops at an "Open in Voyager?" confirmation that a widget tap on a phone never shows, so the link path is held by the app's hosted suite, where seven tests cover the link and the shelf.

Principle: Whatever lives outside your app, a widget, a shortcut, a bookmark, should hold the stable name of a thing and let the app resolve it at the moment of use. An address saved out there is a copy, and lesson 1 already said what copies do.

Code

cwkVoyager, Sources/VoyagerWebKit/WebKitExtensionHost.swift: the family's hosts denied to every extension unless Dad lets one in (verbatim, lines 50-85)·swift
    /// The family's hosts, kept out of every extension's reach unless Dad
    /// let that extension in. Loopback and the tailnet are always family.
    public var familyHosts: Set<String> = [] {
        didSet { if familyHosts != oldValue { applyFamilyAccess() } }
    }
    public var allowedOnFamilySites: Set<String> = [] {
        didSet { if allowedOnFamilySites != oldValue { applyFamilyAccess() } }
    }
    /// The family patterns each context was denied, so a change clears
    /// exactly those and no pattern of the extension's own.
    private var familyDenials: [String: [WKWebExtension.MatchPattern]] = [:]
    /// What is family on every Mac: this Mac by either name, and the tailnet.
    static let alwaysFamilyPatterns = ["*://localhost/*", "*://127.0.0.1/*", "*://*.ts.net/*"]

    /// The match patterns that stand for the family's hosts now.
    func familyPatterns() -> [WKWebExtension.MatchPattern] {
        var texts = Self.alwaysFamilyPatterns
        for host in familyHosts.sorted() where !host.isEmpty && !host.contains("/") && !host.contains("*") {
            texts.append("*://\(host)/*")
        }
        return texts.compactMap { try? WKWebExtension.MatchPattern(string: $0) }
    }

    private func applyFamilyAccess() {
        for (id, context) in contexts { applyFamilyAccess(to: context, id: id) }
    }

    /// A denied pattern wins over a granted one (measured 2026-10-04 against
    /// a package granted `<all_urls>`: `hasAccess` says no for a denied
    /// host), so an extension keeps what it asked for everywhere else.
    private func applyFamilyAccess(to context: WKWebExtensionContext, id: String) {
        for pattern in familyDenials[id] ?? [] { context.setPermissionStatus(.unknown, for: pattern) }
        let denied = allowedOnFamilySites.contains(id) ? [] : familyPatterns()
        for pattern in denied { context.setPermissionStatus(.deniedExplicitly, for: pattern) }
        familyDenials[id] = denied
    }
cwkVoyager, clients/apple/VoyagerMobile/Shared/FamilyShelf.swift: the link a widget tile opens names a catalog target and nothing else (verbatim, lines 52-70)·swift
/// `cwkvoyager://family/<target id>`: what a widget tile opens.
enum FamilyLink {
    static let scheme = "cwkvoyager"

    static func url(forTarget id: String) -> URL? {
        var components = URLComponents()
        components.scheme = scheme
        components.host = "family"
        components.path = "/" + id
        return components.url
    }

    /// The target id a family link names; nil for anything else.
    static func targetID(in url: URL) -> String? {
        guard url.scheme?.lowercased() == scheme, url.host(percentEncoded: false) == "family" else { return nil }
        let id = String(url.path(percentEncoded: false).dropFirst())
        return id.isEmpty || id.contains("/") ? nil : id
    }
}
cwkVoyager, clients/apple/VoyagerMobile/Sources/MobileBrowser.swift: the app resolves the id against the catalog when the tile is tapped (verbatim, lines 190-214)·swift
    // MARK: Addresses from outside

    /// A link the widget opened. A family link names a catalog target, read
    /// against the catalog now; Voyager declares no web scheme on iOS.
    func handle(_ url: URL) {
        if let id = FamilyLink.targetID(in: url) {
            Task { await openFamilyTarget(id) }
        }
    }

    func openFamilyTarget(_ id: String) async {
        if MobileFamily.resolve(id, in: services.familyCatalog.projection) == .notListed || services.familyCatalog.projection == nil {
            await refreshCatalog()
        }
        switch MobileFamily.resolve(id, in: services.familyCatalog.projection) {
        case .open(let url):
            openFamily(url)
        case .notListed:
            showHome()
            post("Firelink's catalog no longer lists that app. The widget shows the current list after Voyager reads it.")
        case .noCatalog:
            showHome()
            if let problem = services.familyCatalog.problem { post(problem) }
        }
    }

External links

Exercise

Build a small Home Screen widget (or a menu bar item, or a shortcut list) that opens items from a list a server owns. Store only each item's stable id and title where the widget can read it, never its address. Give your app a custom URL scheme that carries just the id, and on open resolve the id against a fresh read of the list: open it, re-read once if it is missing, and tell the user plainly if it is gone. Then rename an item's address on the server and confirm the widget still opens it without being touched.
Hint
Validate the incoming link strictly: right scheme, right host, exactly one non-empty path segment, or ignore it. Reload the widget's timeline only when the stored list actually changes, so the widget never needs network access of its own.

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.