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

Read, Never Copied

~15 min · single-source-of-truth, catalog, cache, concurrency

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

Fourteen of forty-five

Before Voyager, the family apps reached Dad's browser through a bookmark file somebody kept by hand. It stopped at 14 apps while the family grew to about 45. Nobody decided to stop it. A copy simply has no way to hear that the original moved.

So the architecture's fourth invariant forbids the copy outright: "Family facts are read, never copied." The app list, its URLs and its favourites come from Firelink's launch catalog at run time, and "no list of apps, ports or hosts lives in Voyager's source, settings or documentation." What the catalog lists, and how Firelink composes it from the family's registry, is Firelink Quest's subject. This lesson is the reading side: how a browser holds a fact it does not own without becoming its second owner.

One client, two readers

The catalog answers at GET /api/v1/launcher/catalog?context=…, where the context is loopback, lan or tailnet. Firelink's own Native Launcher read it first. When Voyager became the second Swift reader, the request and its types moved into the kit's CwkKitCore as FamilyCatalogClient, and both apps now consume that one file. Its header draws the line in one sentence: "Registry composition, credentials, favorites/recents writes and cache ownership stay with the apps." Why a second consumer is the moment to lift code into the kit is Sibling Kit Quest's subject.

The client is strict about what it accepts. A 401 or 403 becomes authorizationRequired. A reply counts as a catalog only when its schema_version is 1, its context echoes the one asked for, its revision is not empty and no two targets share an id. Anything else is invalidResponse, and nothing half-parsed reaches the screen.

A dated projection

FamilyCatalogModel, in Voyager's parent, describes itself in one line: "A dated projection of Firelink's registry. It owns no accounts, credentials, health checks or launch policy." The second block below is the projection. For each target it does four things:

  • It drops a target this device cannot open. That is the kit's rule, opens(on:), and lesson 3 explains it.
  • A web target survives only with an http or https scheme, a host, and no user name or password inside the URL. The test suite feeds it a javascript: target, and that one never reaches the grid.
  • A native app survives only with a dotted bundle identifier made of letters, digits, dots and hyphens. The Mac opens it through Launch Services by that identifier, and says the app "is not installed on this Mac" when it is not.
  • Favourites come first, then titles in the Finder's order (localizedStandardCompare).

The projection carries the moment it was read, and the Home prints it as "Updated …" beside the count of destinations. A live read during the Home's verification on 2026-10-03 returned 46. That is a dated observation. The code never assumes a count.

A cache that knows where it came from

The model saves the last good catalog as family-catalog.json in Voyager's folder. (The architecture's list of files still calls it catalog-cache.json; the code wins.) The saved entry carries a key, the hub address plus the context: baseURL.absoluteString + "|" + context. When the address changes, the first block clears the projection and shows the saved catalog only if its key matches the new endpoint. One hub's catalog never appears as another hub's answer.

When a read fails and a saved catalog exists, the Home keeps it on screen with one line under it: "Showing the saved catalog. Its destinations may have changed." A single test pins all three halves of that promise. The saved catalog survives an outage, it is not shown for a different endpoint, and the file on disk is byte-for-byte untouched by the failed read.

Late answers and repeated asks

Two reads can be in flight at once, for example when Dad changes the hub in Settings while the old hub is still answering. A generation counter settles it. Each read takes a ticket, and after every await it checks that its ticket is still the newest. A reply that arrives after a newer read began is dropped. The third block is the test: a gate holds two replies open, releases the current endpoint first and the old one second, and expects the current revision to stand.

Repeated asks collapse instead of multiplying. A read already under way absorbs another request, even a forced one, and a projection younger than 60 seconds is not read again unless forced. The Home's Refresh button forces. A private window never asks at all, and the verification build creates the model disabled, so a smoke run never touches the real Firelink.

Principle: A read-only cache stays honest when it carries three things: whose answer it is, how old it is, and a rule that a newer question beats an older reply. Drop any one and it quietly turns back into a copy.

Three things the Home can say

With a projection, the Home draws the grid. When Firelink refuses because this device has no session with the hub, needsSignIn turns on and the Home offers one action, Sign In to Firelink. The next lesson is about that credential. With neither, the Home says "Family apps are unavailable here right now." and offers to open Firelink. In none of the three does Voyager make up a list of its own.

Code

cwkVoyager, Sources/VoyagerCore/FamilyCatalog.swift: the cache keyed by endpoint, and the generation ticket (verbatim, lines 141-170)·swift
    private func load(baseURL: URL, force: Bool, session: SessionReader?) async {
        guard enabled else { return }
        let context = FirelinkHubSession.context(for: baseURL)
        let key = baseURL.absoluteString + "|" + context
        if endpoint != key {
            generation += 1
            endpoint = key
            projection = nil
            needsSignIn = false
            refusedSession = nil
            if let cached, cached.endpoint == key {
                projection = Self.project(cached.catalog, at: cached.receivedAt, for: device)
                isCached = true
            } else { isCached = false }
            isRefreshing = false
        }
        guard !isRefreshing else { return }
        if !force, !isCached, let projection, Date().timeIntervalSince(projection.refreshedAt) < 60 { return }
        generation += 1
        let ticket = generation
        isRefreshing = true
        defer { if ticket == generation { isRefreshing = false } }
        // Off the service host the hub's own session rides along; on the service host the loopback
        // session answers and nothing is read.
        let cookie = context == "loopback" ? nil : await session?(baseURL)
        guard ticket == generation else { return }
        let client = FamilyCatalogClient(transport: FirelinkHubSession.transport(transport, hub: baseURL, cookie: cookie))
        do {
            let catalog = try await client.fetch(baseURL: baseURL, context: context)
            guard ticket == generation else { return }
cwkVoyager, Sources/VoyagerCore/FamilyCatalog.swift: the projection, what survives and in which order (verbatim, lines 196-215)·swift
    static func project(_ catalog: FamilyLaunchCatalog, at date: Date, for device: KitFamilyMac) -> FamilyCatalogProjection {
        let items = catalog.targets.filter { $0.opens(on: device) }.compactMap { target -> FamilyLaunchItem? in
            let destination: FamilyLaunchItem.Destination
            switch target.kind {
            case .web:
                guard let raw = target.url, let url = URL(string: raw),
                      ["http", "https"].contains(url.scheme?.lowercased() ?? ""), url.host != nil,
                      url.user == nil, url.password == nil else { return nil }
                destination = .web(url)
            case .nativeApp:
                guard let bundle = target.bundleID, !bundle.isEmpty,
                      bundle.split(separator: ".").count > 1,
                      bundle.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "-") }) else { return nil }
                destination = .nativeApp(bundleID: bundle)
            }
            return FamilyLaunchItem(id: target.id, title: target.memberTitle, label: target.label,
                                    destination: destination, favorite: target.favorite)
        }.sorted { a, b in a.favorite == b.favorite ? a.title.localizedStandardCompare(b.title) == .orderedAscending : a.favorite }
        return FamilyCatalogProjection(revision: catalog.revision, refreshedAt: date, items: items)
    }
cwkVoyager, Tests/VoyagerCoreTests/FamilyCatalogModelTests.swift: a late reply for the old endpoint loses (verbatim, lines 101-116)·swift
    @Test func lateEndpointResponsesCannotOverwriteTheCurrentCatalog() async {
        let gate = CatalogGate()
        let model = FamilyCatalogModel(transport: { try await gate.request($0) })
        let old = Task { await model.refresh(baseURL: url("https://old.test")) }
        for _ in 0..<100 where !(await gate.contains("old.test")) { await Task.yield() }
        let current = Task { await model.refresh(baseURL: url("https://current.test")) }
        for _ in 0..<100 where !(await gate.contains("current.test")) { await Task.yield() }
        #expect(await gate.contains("old.test"))
        #expect(await gate.contains("current.test"))
        await gate.finish("current.test", Self.reply("current",context:"tailnet"))
        await current.value
        await gate.finish("old.test", Self.reply("old",context:"tailnet"))
        await old.value
        #expect(model.projection?.revision == "current")
        #expect(!model.isRefreshing)
    }

External links

Exercise

Pick a list your app shows but another service owns (a team directory, a product catalog, a feature-flag set). Write a read-only model for it that keeps one saved copy keyed by endpoint and context, stamps every projection with the time it was read, and drops replies that arrive after a newer request began. Then write the gated test: two requests in flight, release them in the wrong order, and assert the newer answer stands.
Hint
Increment a counter before each read, capture it, and compare after every await, including the one that reads a credential before the request. For the gate, an actor holding a dictionary of CheckedContinuation values keyed by host lets the test resume each reply exactly when it wants.

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.