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

A Glance Goes Down, Commands Come Up

~15 min · watchos, watchconnectivity, swift, architecture

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The phone navigates; the wrist shows and taps

Dad's ask for the watch was one short line: "도보는 특히 워치도 지원해야 하고", walking especially has to work on the watch too. On a walk the phone lives in a pocket and the wrist is where he looks. What he did not ask for, and what Pathfinder refuses to build, is a second navigator. The phone holds the route, the voice and the link to Pippa. The watch shows what the phone says and sends Dad's taps back. Architecture invariant 17 says it in one line: the phone is the navigator; the watch shows and taps.

That split decides the plumbing, because two different kinds of thing cross the pairing. Going down is state: what the guidance looks like right now. Going up are acts: Dad tapped "end", Dad tapped "walk home". State wants the newest version and nothing else; yesterday's "240 m to the turn" is worthless. An act wants to arrive, once, and soon. One transport cannot be good at both, so Pathfinder uses two.

Which Apple door, and which one it isn't

Everything rides WatchConnectivity's WCSession. Three of its calls matter here:

CallWhat it promisesUsed for
updateApplicationContext(_:)A dictionary that replaces the previous one. Delivered when the system sees an opportunity; readable at any moment from receivedApplicationContext, a cold launch included.The glance, down
transferUserInfo(_:)Queued on the other device, delivered in order, carried on while the app is suspended.A command's durable record, up
sendMessage(_:replyHandler:errorHandler:)Immediate, only while the counterpart is reachable. Sent from the watch, it wakes the iOS app in the background.The ring that wakes the phone, up

Apple's own page for the first call says it is "well suited for updating your app's glance", and that is exactly the name the phone's value got.

Two things are absent from the code, and they are worth naming because the family's word for the wrist lane, "handoff", invites the wrong guess. There is no NSUserActivity anywhere in Pathfinder: Apple's Handoff lets a person pick up an activity on another device, and it is not a feed a phone streams turns into. There is no Live Activity either. The architecture's known-limits table lists one (lock screen, Dynamic Island, the watch's Smart Stack) as the next candidate, not as something built.

Down: one NavigationGlance

NavigationGlance lives in PathfinderKit, so its rules run in the Mac suite. One value carries the whole wrist:

  • Where the trip stands: state (idle, guiding, off_route, rerouting, arrived), mode (walking or driving) and the destination's name.
  • The turn ahead: maneuver with its kind, its words and its distance.
  • How far, how long: remaining distance, remaining time and an ETA.
  • On a drive: the next speed camera's limit and distance, and the speed.
  • The tap: a cue with a serial number (lesson 4).
  • Pippa: the talk's phase and a talk line cut to 140 characters, and whether the guidance voice is muted.
  • Starting points: up to 12 of Dad's saved places, so a walk can start from the wrist.
  • The way: up to 160 route points of what is left of the route, and where the phone puts Dad on it.
  • A sentence for Dad when something needs him, plus the bookkeeping: an id, updatedAt, and whether the phone app is on screen.

The route only rides along when its geometry may leave the provider's own map. Apple's routes may. Kakao's and Google's providers declare routeIsPortable: false, so on those trips the wrist shows the turns alone and the map page never appears.

The wire is property-list values with every absent field omitted. That is not tidiness. An Optional.none bridges to CFNull, which is not a property-list value, and a dictionary holding one cannot be sent: updateApplicationContext throws, and transferUserInfo reports it only later, through its finish callback (the warning below). CwkKitWrist's WristEnvelope.seal checks every key and throws instead, naming the field.

The cadence: at once, or every two seconds

A context per GPS fix is traffic the wrist does not need. isSignificantChange(from:) splits the glance in two. A change of state, mode, destination, the maneuver's kind or words, the cue, the talk phase, the mute, the places, the problem sentence, the phone app reaching or leaving the screen, or the camera limit is significant and goes out at once. Everything else (the distances, the time, the speed, the dot and the line) is a moving number and goes out at most every publishEvery = 2 seconds. It is a gate on calls, not a timer: the phone publishes when something happens, and the gate decides whether this one is worth a context.

Up: WristCommand through the capture lane

Dad's taps become a WristCommand: go(placeID, mode), end, toggleVoice, talk, endTalk and pull. They ride CwkKitWrist's capture lane, the family's shared watch plumbing, which does three things in order. It writes the command to the watch's own outbox file first. It hands the same envelope to transferUserInfo, the durable record. Then, if the phone is reachable, it sends it again with sendMessage, the ring, because a transfer alone launches nothing on the phone. The watch lets go of an entry only when the transfer reports it landed. Durable, and able to wake a phone asleep in a pocket: the right shape for an act. Lesson 2 shows what that shape costs on the receiving end.

Choose the lane by what a duplicate, a delay and a loss each cost. State tolerates loss (the next one carries everything) and hates delay and duplicates. An act tolerates a short delay and hates loss. A transport that is good at one is wrong for the other.

Code

WatchBridge.swift (the iOS app): publish, verbatim·swift
  /// Publishes `glance` when the watch should hear it: a significant change
  /// at once, the moving numbers at most every ``publishEvery`` seconds.
  func publish(_ glance: NavigationGlance, force: Bool = false) {
    if let blocker {
      trouble = blocker
      return
    }
    let session = WCSession.default
    let significant = glance.isSignificantChange(from: lastPublished)
    guard force || significant || Date().timeIntervalSince(lastPublishedAt) >= Self.publishEvery else { return }
    guard let sealed = try? WristEnvelope.seal(glance) else { return }
    do {
      try session.updateApplicationContext(sealed)
      lastPublished = glance
      lastPublishedAt = Date()
      trouble = nil
    } catch {
      // The context is the latest-only lane: the next publish carries all of
      // it. The refusal is kept, for the drive log.
      trouble = "the watch refused the glance: \(error.localizedDescription)"
    }
  }
A minimal sketch: the publishing gate, with time passed in (compiled with swiftc and run)·swift
import Foundation

// A minimal sketch of the phone's publishing gate: a significant change goes
// out at once, the moving numbers at most every two seconds. Time is passed
// in, so the run is deterministic.
struct Glance: Equatable {
  var state: String
  var instruction: String
  var cueSerial: Int?
  var remaining: Double  // metres: moves with every fix, never significant

  func isSignificantChange(from last: Glance?) -> Bool {
    guard let last else { return true }
    return state != last.state || instruction != last.instruction || cueSerial != last.cueSerial
  }
}

struct Publisher {
  static let publishEvery: TimeInterval = 2
  var lastPublished: Glance?
  var lastPublishedAt = -Double.infinity
  var sent: [String] = []

  mutating func publish(_ glance: Glance, at now: TimeInterval) {
    let significant = glance.isSignificantChange(from: lastPublished)
    guard significant || now - lastPublishedAt >= Self.publishEvery else { return }
    lastPublished = glance
    lastPublishedAt = now
    let why = significant ? "significant" : "2 s passed"
    let tap = glance.cueSerial.map { "  tap #\($0)" } ?? ""
    sent.append(String(format: "t=%4.1fs  %.0f m left  ", now, glance.remaining) + why + tap)
  }
}

var phone = Publisher()
var remaining = 260.0
// One fix every half second for six seconds of walking.
for tick in 0...12 {
  let now = Double(tick) * 0.5
  remaining -= 0.7
  var glance = Glance(state: "guiding", instruction: "Turn right", cueSerial: nil, remaining: remaining)
  if tick >= 5 { glance.cueSerial = 1 }  // the turn's tap is due: significant, so it goes at once
  phone.publish(glance, at: now)
}
print("fixes: 13, contexts published: \(phone.sent.count)")
phone.sent.forEach { print($0) }

// Output:
// fixes: 13, contexts published: 4
// t= 0.0s  259 m left  significant
// t= 2.0s  257 m left  2 s passed
// t= 2.5s  256 m left  significant  tap #1
// t= 4.5s  253 m left  2 s passed  tap #1

External links

Exercise

Pick a system of yours where two sides talk: a phone and a watch, an app and its server, a browser tab and a worker. List every kind of message that crosses, in both directions. Mark each one as state (only the newest matters) or act (each one must happen once). Then write down the transport each one uses today, and circle every place where state rides a queue or an act rides a latest-wins channel.
Hint
Ask of each message: if two copies arrive, is that harmless? If it arrives an hour late, has a newer message already said everything it said, so it can simply be dropped? If it never arrives, does the next message repair the damage? Three yeses describe state; a no on any of them usually means an act.

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.