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

WatchConnectivity: A Record, a Doorbell, and One Delegate

~18 min · on-the-wrist, watchconnectivity, wcsession, delegates, property-lists

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A wrist message sat 512 seconds until he opened the phone app."

Two Ways to Send, Two Different Promises

WCSession offers several ways to move data between a watch and its phone, and two matter for a capture. transferUserInfo is the record: the system queues it, keeps the order, survives both apps exiting, and reports didFinish to the sender when it has arrived. sendMessage is live and needs the counterpart reachable. The family's watch apps were first built on the belief that a background transfer launches the iPhone app to deliver it. The SDK header says otherwise: a transfer gives the counterpart "a delegate callback on next launch", while sendMessage launches the iOS app if it is not running. The owner measured the difference: a message spoken on the wrist sat for 512 seconds with the phone locked in a pocket, until he opened the phone app himself.

So the watch does both with the same sealed envelope. The transfer stays the record, and the watch lets go of its queue entry only on the transfer's didFinish. The message is the doorbell that wakes the phone app now. The phone takes both through one door that commits and remembers the id, so whichever arrives second is dropped as an echo. The phone answers the doorbell only after that door has stored the capture, and that answer is what lets the wrist say the phone has it while the transfer is still settling.

An Absent Optional Is Not a Property List

The training app's watch captures from a module tap crossed to the phone perfectly; spoken captures never arrived, while the watch showed each one saved, queued and synced. The payload wrote two optional fields as moduleRef as Any, which puts Optional.none into a [String: Any]. transferUserInfo accepts property-list values only; it took the dictionary and the transfer never landed, and every signal on the wrist stayed green. The wire test had built the dictionary and read it straight back, which Swift is happy to do, so it never serialized. The fix omits an absent field, and the shared envelope now refuses a non-property-list value when it is sealed.

One Delegate, Held Strongly, Activated Without a View

A session has exactly one delegate, and the property is weak. Three failures came from those two facts. In native Pippa, a forwarding relay created inside an install function and never stored deallocated immediately, and the session silently had no delegate: no activation, no transfers, no messages, while the watch said the phone was thinking. In the training app, the watch set its own catalog store as the delegate after the kit had installed its proxy, the later assignment won, and the kit lost didFinish, so the watch never let go of anything and re-offered its whole history on every activation. And a phone app activated its session from a root view's .task: the background launch that a doorbell causes has no view, so on the one launch that mattered the session was never activated. Keep one retained delegate, read the application context from receivedApplicationContext instead of taking the delegate to hear it, activate from the app's initializer, and on the phone, activate again in sessionDidDeactivate, because the counterpart may be a different watch.

Code

Seal only property-list values, and refuse the rest at the source·swift
import Foundation

enum SealError: Error, Equatable {
    case notPropertyList(key: String)
}

/// What rides between watch and phone. Plain property-list values only: an absent optional
/// written as `Optional<String>.none as Any` is accepted by transferUserInfo and then never delivered.
enum WristEnvelope {
    static let idKey = "id"

    static func seal(id: String, fields: [String: Any]) throws(SealError) -> [String: Any] {
        var info = fields
        info[idKey] = id
        for (key, value) in info {
            guard PropertyListSerialization.propertyList([key: value], isValidFor: .binary) else {
                throw .notPropertyList(key: key)
            }
        }
        return info
    }

    static func identifier(in info: [String: Any]) -> String? { info[idKey] as? String }
}

// let place: String? = nil
// seal(id: "c1", fields: ["text": "hi", "place": place as Any])  -> throws notPropertyList(key: "place")
// if let place { fields["place"] = place }                        -> sealed, "place" simply absent
One retained delegate: record plus doorbell, let go only on didFinish·swift
import Foundation
import WatchConnectivity

/// The ONE delegate the session has. WCSession.delegate is weak and single: whoever assigns it
/// last owns every callback, and an unretained relay leaves the session with no delegate at all.
final class WristSessionProxy: NSObject, WCSessionDelegate, @unchecked Sendable {
    static let shared = WristSessionProxy()          // retained for the life of the process
    var onFinished: @Sendable (String) -> Void = { _ in }
    var onHandedOver: @Sendable (String) -> Void = { _ in }
    /// Phone side: the one door. True once the capture is stored, now or by an earlier arrival.
    var onReceive: @Sendable ([String: Any]) -> Bool = { _ in false }

    func activate() {
        guard WCSession.isSupported() else { return }
        WCSession.default.delegate = self
        WCSession.default.activate()                  // from App.init: a background launch has no view
    }

    /// The record: queued, ordered, survives both apps exiting, delivered on the phone app's next launch.
    /// The ring: sendMessage launches the iOS app if it is not running. Same envelope, one door.
    func offer(_ info: [String: Any]) {
        let session = WCSession.default
        session.transferUserInfo(info)
        guard session.isReachable, let id = WristEnvelope.identifier(in: info) else { return }
        let handedOver = onHandedOver
        session.sendMessage(info, replyHandler: { reply in
            // "The phone has it" is the phone's own word. The queue entry still waits for didFinish.
            if reply["stored"] as? String == id { handedOver(id) }
        }, errorHandler: nil)
    }

    func session(_ session: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) {}

    // The watch lets go of an entry ONLY here, never at send time and never on the ring's reply.
    func session(_ session: WCSession, didFinish transfer: WCSessionUserInfoTransfer, error: Error?) {
        guard error == nil, let id = WristEnvelope.identifier(in: transfer.userInfo) else { return }
        onFinished(id)
    }

    func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { _ = onReceive(userInfo) }

    // The ring carries a reply handler, so it arrives here. Answer only after the door has stored it.
    func session(_ session: WCSession, didReceiveMessage message: [String: Any],
                 replyHandler: @escaping ([String: Any]) -> Void) {
        if onReceive(message), let id = WristEnvelope.identifier(in: message) {
            replyHandler(["stored": id])
        } else {
            replyHandler([:])
        }
    }

    #if os(iOS)
    func sessionDidBecomeInactive(_ session: WCSession) {}
    func sessionDidDeactivate(_ session: WCSession) { session.activate() }   // it may be a different watch now
    #endif
}

External links

Exercise

Write the watch half of Spark's link: seal a capture with WristEnvelope.seal, offer it with a transfer and a message, and remove it from the watch queue only in didFinish. Add a test that seals a payload with an absent optional written as Optional<String>.none as Any and asserts the refusal names the key. Then add a source-reading test for the phone target that fails if any file other than the proxy conforms to WCSessionDelegate or assigns .delegate = on a session, and prove it fails by adding such a line.
Hint
PropertyListSerialization.propertyList(_:isValidFor: .binary) is the check the transfer effectively performs. For the source test, scan .swift files that import WatchConnectivity, strip // comments first, and name the offending file in the failure message.

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 inPlease sign in to comment.

No comments yet — be the first.