Skip to content
C.W.K.
Stream
Lesson 10 of 10 · published

From the Wrist

~14 min · watchos, watchconnectivity, capture, exactly-once, daily-note, v4

Level 0Cold Draft
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"If it only syncs while the phone app is open, the watch app is practically useless." — Dad, 2026-09-11

One Screen, Three Ways In

Lesson 4 gave the watch one sentence: dictate a thought on your wrist, and the phone files it as a timestamped line on today's daily note. That sentence holds on an ordinary day, and it hides a relay of four hands: the watch, the phone, the daily note and office. This lesson follows one memo through all of them, as the code does it today.

Rekindle on the watch (shipped 2026-09-14) is one screen with one button, Dictate a memo. A wrist is a five-second surface, and every control that isn't the memo stands between you and the thought. There are three ways to reach that microphone. The app's own button. A watch-face complication that is a button and nothing else, with no count and no state, because a complication refreshes on watchOS's budget and a stale number on the face is worse than none; tapping it opens rekindle://memo, and the watch app answers that URL with dictation. And the Action Button: only workout and dive apps can be chosen for it directly, so an app like Rekindle reaches it through a shortcut it publishes (DictateMemoIntent, "Dictate a memo in Rekindle"). The intent only arms a one-shot launch flag and opens the app; the first view to appear reads the flag once and goes straight to the microphone, because WatchKit's dictation screen needs a visible interface controller and a cold launch has none yet.

Dictation itself goes through WatchKit, not SwiftUI. SwiftUI on watchOS has no way to ask for dictation, and a plain TextField gets whatever input screen the system prefers, which on Dad's watch was the keyboard. The family's wrist kit calls presentTextInputController(withSuggestions: nil, allowedInputMode: .plain); nil, not an empty array, is what opens the microphone. Korean and English switch inside that system screen with a swipe up. There is no confirm step: a wrong word is an edit on the phone, never a lost thought. If dictation is unavailable, the screen says so and offers a text field instead of quietly turning a speech button into a keyboard.

The Watch Writes It Down First

The words become a WristMemo on the wrist: trimmed text, an id minted there (rk-memo-…), the moment spoken as an ISO 8601 UTC stamp, and the time-zone identifier the watch is in. The app re-reads the system zone just before stamping, because a watch app can sleep through a flight. Then the memo goes into the watch's own queue, a JSON file under the watch app's Application Support, and only then does anything leave the wrist. "Captured" appears on screen only after that write has returned. A write the watch refuses keeps the words on screen under "Not saved" with the reason, because a memo the watch couldn't write down is gone unless you see it.

That queue is the only copy until the phone confirms delivery, so it is guarded like one. An entry leaves it only when transferUserInfo reports didFinish without an error, never at send time. A queue file that won't read is moved aside with its bytes intact and named on screen, never overwritten with an empty list. And WristMemo decodes by hand with a default for every field after the id, so adding a field in a later version cannot make an update eat what was spoken before it. Underneath the button, the screen shows the honest count: "2 waiting for the phone" while entries remain, and "On the phone; it files as a note." once the queue has emptied.

A Transfer Is a Record, Not a Doorbell

transferUserInfo is the durable lane: the system queues the payload, delivers in order, and keeps it across the phone being off, asleep or out of range. What it does not do is launch the phone app. Apple's wording is that the counterpart receives a delegate callback on its next launch. Dad measured what that means on 2026-09-11, in the shared wrist kit three days before Rekindle's watch existed: a message spoken to a watch, with the iPhone locked in a pocket, sat for 512 seconds until he opened the phone app himself.

So the kit rings. After every transfer, when the phone is reachable (nearby, and unlocked at least once since it booted; the app itself need not be running), the watch sends the same envelope again with sendMessage. A message is what launches the iOS app in the background (sendMessage and its twin for raw bytes, sendMessageData, both do); a transfer is not. The transfer stays the record, and the watch still lets go only on the transfer's didFinish. A ring that fails costs nothing: the transfer delivers at the phone's next launch as before. And every re-offer rings again, whether it comes from the session activating, the phone becoming reachable, or the app opening with something still waiting. So one memo can reach the phone two, three or more times. That is by design, and the next hand has to absorb it.

One Door on the Phone

The phone's receiver is created and activated in the app's init, not in a view, because a ring launches the app in the background with no screen at all. The message and the transfer arrive through the same door. The door opens the envelope as a memo, then asks a seen ledger keyed memo:<id> (512 entries, persisted beside the capture ledger): an id it remembers is an echo and is dropped before Rekindle's code runs. A new id is committed, and only after a successful commit is it remembered.

The commit deliberately does very little. It does not open the daily note. It writes one capture into the App Group drop box that the phone's other capture surfaces share (the share sheet, Siri and the Action Button on the phone, and in-app dictation whenever the note can't take it right away), under the memo's own id, with source: "watch", the spoken moment as its creation time, and the spoken zone. Then it posts a notification so an open library files it at once. The reason for the restraint is the kit's sharpest rule: the watch lets go on the transfer's didFinish whatever the phone did with it, so a commit that throws is a memo held nowhere. It has to succeed with the phone locked, in the background, on WatchConnectivity's own queue. A small directory in a shared container can; an edit to a note that may be open in the editor cannot promise that.

Filed at the Minute It Was Spoken

The drop box is read by the same ingest that files every waiting capture: when the library first appears, on that notification, when the phone unlocks, whenever the app becomes active, when a sync round closes, and when Dad stops typing in the editor. It waits rather than writes in three cases: the phone's files can't be read yet (they are sealed from a restart until the first unlock; after that they stay readable even with the screen locked), a round is open, or Dad is typing in the very note the line is headed for. Otherwise it appends one line to the daily note, notes/YYYY-MM-DD.md, in the form - HH:mm — text, with the day and the minute taken from the spoken moment in the spoken zone. A memo said at 23:50 in Seville and filed when the phone opens the next morning lands on the previous day's note at 23:50, where the thought belongs.

Exactly once is held in layers, and none of them is trusted alone. The drop box is one directory per id, so a second commit of the same memo rewrites the same capture. The door's ledger drops the honest echoes it remembers, but it forgets after 512. Under both sits the store's own ledger: every capture already filed, by id, and, written before the append, the exact line about to be appended and how many times that line already stood in the note. If the app is killed between the append and the record, the next ingest counts the line in the note and records the capture as filed instead of appending it twice.

Then Office

A filed memo marks the library as changed. With the app in front, a round goes to office's sync engine once writing has rested for ten seconds; bringing the app forward also tries a round at once (at most once a minute), and leaving the foreground asks iOS for background time to send whatever is still unsent. What does not happen is a round started from the background: a memo filed while the app sits there, woken by a ring, has no time granted to finish one, and a half-done round would leave an interrupted sync. That memo waits on the phone's disk and travels when the app next comes forward. From office the line reaches every other replica on its next round, and the Mac's library re-reads its note list when a replica's sync lands. Wrist to queue, queue to phone, phone to note, note to office: the watch never reaches office directly. It is not on the tailnet, so the phone is its engine.

Code

One memo's relay, hand to hand·text
WATCH   button | complication rekindle://memo | Action Button -> shortcut
        WristDictation (WatchKit, suggestions: nil) -> words
        WristMemo  id rk-memo-..., spoken_at (UTC), timezone
        outbox.enqueue  -> written to the watch's own queue    ("Captured")
        transferUserInfo  the record: queued, ordered, kept
        sendMessage       the ring: a message launches the phone app
        drop from queue   ONLY on the transfer's didFinish

PHONE   WristReceiver, activated in App.init (no view needed)
        door: seen ledger "memo:<id>"  -> echo? drop
        commit: CaptureDropBox (App Group), id = memo id,
                source "watch", createdAt = spoken moment, zone = spoken zone
        ingest: wait if files sealed / round open / typing in that note
                append "- HH:mm — text" to notes/<spoken day>.md
                capture ledger: filed + appending (exactly once)

OFFICE  next round carries the line: 10 s after writing rests, on coming
        forward, or in the grant on leaving; never started from the background
        other replicas and the Mac pick it up on their own rounds
The phone's door, from the app's init (trimmed)·swift
let receiver = environment.captureContainer.map { container in
  let seen = environment.captureLedger.deletingLastPathComponent()
    .appendingPathComponent("rekindle-wrist-seen.json")
  return WristReceiver<WristMemo>(commit: { memo in
    // Not the note: one capture in the shared box, keyed by the memo's own id.
    try CaptureDropBox(container: container).write(.init(id: memo.id, text: memo.text,
      source: "watch", createdAt: memo.spokenDate, timeZone: memo.timezone))
    NotificationCenter.default.post(name: .rekindleCaptureDropped, object: nil)
  }, ledger: WristSeenLedger(url: seen))
}
receiver?.activate()   // launch path, not onAppear: a ring may launch with no view
_wristReceiver = State(initialValue: receiver)   // kept: the session holds its delegate weakly

External links

Exercise

Trace one memo. At 23:52 in Madrid you dictate a memo; your iPhone is locked in a bag nearby. The ring reaches the phone, but the screen stays locked all night. At 08:05 the next morning you open Rekindle on the phone, still in Madrid. Write down (1) when the watch's "waiting" count drops to zero and why, (2) how many times the memo can reach the phone's door and what happens to each arrival, (3) when, and onto which daily note at which minute, the line is appended, and (4) when the line reaches office.
Hint
The count drops when the transfer's didFinish arrives, not when the ring is answered. The ring and the transfer both reach the door; the second is an echo the seen ledger drops (and the drop-box directory per id would absorb it even if the ledger had forgotten). The phone was unlocked earlier that day, so its files stay readable under the locked screen: the woken app can file the capture straight away if its library ingests, and at the latest it files when you open the app at 08:05. Either way it lands on the previous day's note, as '- 23:52 — …'. No round starts from the background, so the line reaches office when you bring the app forward at 08:05 (coming forward tries a round at once; the ten-second settle round follows only if something is still unsent).

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.