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

Offline, and the Doors In

~13 min · swiftui, offline, share-extension, app-intents

Level 0First Word
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

The watcher owns its reads

An early build on the phone opened some moments to a blank page. No error, and no request at all. The cause was SwiftUI's lifetime rules. A .task is cancelled whenever the view that started it is torn down, and on iPhone a split view builds and drops its detail view more than once on the way in. The page's watcher lived in a view's state, so it could belong to a copy that was thrown away, while the copy left on screen never started one.

The fix moved ownership. AppModel keeps one MomentWatcher per open moment, and a screen only says start, refresh and stop. Screens count in and out as viewers, and the watcher stops its work a second after the last one leaves, so a copy dropped on the way in never cancels the read the surviving copy is waiting for. The watcher also knows about the outbox (the second code block). A moment that is still being sent answers 404, so the watcher keeps looking: every 1.5 s for the first 90 s, then every 5 s for as long as the moment is open. A share imported on the next foreground once took 96 s to arrive, and a watcher that gave up in silence would have left the page blank again. The moment's event stream opens only while a turn runs, and if it drops, the screen says to check Tailscale instead of a bare network error.

Completed work stays on the device

Captures waiting to go are one half of offline. The other half is everything already done: translations, explanations, versions, kept expressions, roots, trips, photos and Pippa's recorded voice. OfflineCorpus keeps read copies of all of it on the phone, over the kit's replaceable mirror, in a directory keyed by the engine's address. Lists and open moments draw from these copies before any network read, so a screen opened in airplane mode shows what the engine last said.

Every 60 s while Spark is open, a sync pass drains the outbox and then copies what exists: the full moment archive page by page, every photo, every recorded voice chunk, the roots, the trips and the kept expressions. The copying half never generates: it asks for no translation, voice or picture the engine has not already made. The drain before it is another matter. A capture still waiting in the outbox sends its practical turn there, so a pass can start that capture's first reading, the one it was always going to ask for. Speech that was never synthesized needs an explicit tap while online, and a reading heard once replays from the phone.

Copies have one hard problem: an old answer arriving after a newer one. The first code block is the guard. Each read takes a ticket for its path; any write through the app, or any result recorded from the event stream, bumps an epoch; changing the engine address retires the whole store. A read whose ticket is no longer current is dropped instead of saved. Outbox files retire only after the engine's record and the files have been written to these copies, and a failed disk write keeps the capture in the queue.

Principle: An offline copy is a cache with a fence. Decide which write wins before two of them race, and let the newest writer win over a slow reader.

Doors in

  • The share sheet. SparkShare takes photos, files, PDFs, links and text from any app. It never talks to the engine and never waits on a network. It writes what arrived into the App Group inbox with a moment id and capture truth minted at the share, then closes. The app keeps it as captures on its next foreground, so a menu shared in the evening keeps the evening's time even if the app takes it in the morning. A share's first file takes the id minted at the share, so a re-import after a crash keeps it as the same moment and the same outbox entry. The rest become moments of their own with ids minted at import, so a crash partway through a many-file share can bring those files in twice. A test sends 24 photos through it. A share that cannot be read is reported and kept until Dad discards it.
  • Control Center. One ControlWidget puts a Spark Camera button in Control Center and on the Lock Screen, and the Action Button can run it too. Its intent opens the app on purpose (the third code block): a photo needs the app's camera, Vision and outbox, and what the control saves is unlocking, finding the app and tapping. One comment carries a lesson from a sibling: no device names in intent text, or App Store Connect refuses the binary.
  • Send to Spark. Waystone or Vesta on the same device can open a cwkspark://link address. The next moment Dad keeps arrives linked to that crumb, and only the reference travels. Track 8 covers those links.

Talk with Pippa hands the thread over

Every moment with a thread shows two ways into the Pippa app (a quick recall has its own answer buttons instead of the mics). Continue in Pippa opens the moment's thread in the Pippa app, or in the WebUI if the app is not there. Talk with Pippa is the kit's row of two mics, KO and EN. A mic opens cwkpippa://conversation/<id>?voice=ko (or en), and the Pippa app opens that thread in voice mode, listening in the language Dad chose. The language is his pick, never detected.

Dad chose this shape from three options on the second day: hand off to the Pippa app.

PippaMobile로 넘기기

Hand it to PippaMobile. A voice loop inside Spark would have been a second iPhone copy of PippaMobile's hands-free loop. Its realtime listener would also dial ElevenLabs directly with a single-use token, and Spark's phone holds no provider credentials by invariant. So Spark runs no voice loop at all. Voice Mode Quest teaches the loop Spark hands off to, and it already names Spark among the doors that pass a thread to the phone app.

Code

cwkSpark, clients/apple/SparkMobile/Sources/OfflineCorpus.swift: tickets and epochs, so an older read never overwrites a newer copy (verbatim, lines 33-50)·swift
    func begin(_ key: String) -> Ticket {
        lock.withLock { let id = UUID(); requests[key] = id; return Ticket(epoch: epoch, id: id) }
    }
    func current(_ ticket: Ticket, _ key: String) -> Bool {
        lock.withLock { !retired && ticket.epoch == epoch && requests[key] == ticket.id }
    }
    func changed() { lock.withLock { epoch += 1 } }
    func retire() { lock.withLock { epoch += 1; retired = true } }
    var hasWriteFailure: Bool { lock.withLock { !failures.isEmpty } }
    @discardableResult func write(_ data: Data, key: String, ticket: Ticket? = nil) -> Bool {
        lock.withLock {
            guard !retired else { return false }
            if let ticket, !current(ticket, key) { return false }
            let saved = mirror(key).write(OfflineSnapshot(value: data))
            if saved { failures.remove(key) } else { failures.insert(key) }
            return saved
        }
    }
cwkSpark, clients/apple/SparkMobile/Sources/MomentWatcher.swift: a moment still in the outbox is looked for, quickly and then patiently (verbatim, lines 92-119)·swift
    private func load() async {
        let client = self.client
        fetchMedia()
        let deadline = ContinuousClock.now + Self.arrivalPatience
        while !Task.isCancelled {
            do {
                let moment = try await client.moment(momentId)
                guard client.offline === self.client.offline, !Task.isCancelled else { return }
                state = MomentState(moment: moment, prose: state?.prose ?? [:])
                error = nil
                fetchMedia()
                if state?.hasRunningTurn == true { watch() }
                return
            } catch let failure as EngineClient.Failure where failure.status == 404 {
                // Not on the engine yet: the outbox is still sending it. Keep
                // looking while the moment is open, more slowly after a while:
                // a share imported on the next foreground took 96 s, and a
                // watcher that gave up in silence left the page blank.
                error = nil
                try? await Task.sleep(for: ContinuousClock.now < deadline ? Self.arrivalPoll : Self.slowArrivalPoll)
            } catch {
                if !Self.isCancellation(error) {
                    self.error = [client.offline?.message("/api/moments/\(momentId)"), error.localizedDescription].compactMap { $0 }.joined(separator: " · ")
                }
                return
            }
        }
    }
cwkSpark, clients/apple/SparkMobile/Intents/OpenCameraIntent.swift: the Control Center button opens the app with the camera ready (verbatim, lines 14-28)·swift
struct OpenSparkCameraIntent: AppIntent {
    static var title: LocalizedStringResource { "Open Spark Camera" }
    // No device names in intent text: App Store Connect refuses the binary
    // (ITMS-90626), as cwkWaystone learned twice.
    static var description: IntentDescription {
        IntentDescription("Opens Spark with the camera ready, so Pippa can read what is in front of you.")
    }
    static var openAppWhenRun: Bool { true }

    @MainActor
    func perform() async throws -> some IntentResult {
        CaptureRequests.shared.camera = true
        return .result()
    }
}

External links

Exercise

Add an offline read cache to an app that fetches lists and details from a server. Key the cache by server address, draw screens from it before any network call, and fence its writes: each read takes a ticket, every local mutation bumps an epoch, and a stale ticket's answer is dropped. Write a test where a slow read started before a mutation returns after it, and assert that the cached value is the newer one.
Hint
Two counters are enough: a per-key request id for the newest read of that path, and one global epoch for any change. A write is saved only if both still match. Spark also retires the whole cache when the server address changes, so nothing from the old server leaks into the new one.

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.