Skip to content

Commit 1b16633

Browse files
committed
feat(connectivity): queued WatchConnectivity transports (#187)
2 parents 1055226 + 2e30346 commit 1b16633

14 files changed

Lines changed: 403 additions & 3 deletions

.github/workflows/SundialKit.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ on:
33
push:
44
branches:
55
- main
6-
- atleast-beta.6
7-
tags:
8-
- 'v[0-9]*.[0-9]*.[0-9]*'
96
paths-ignore:
107
- '**.md'
118
- 'Docs/**'

Sources/SundialKitConnectivity/ConnectivityDelegateHandling.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@
8686
_ messageData: Data,
8787
replyHandler: @escaping @Sendable (Data) -> Void
8888
)
89+
90+
/// Handles a received queued dictionary (`transferUserInfo` partner).
91+
///
92+
/// - Parameter userInfo: The received dictionary.
93+
func handleUserInfoReceived(_ userInfo: ConnectivityMessage)
94+
95+
/// Handles a received queued file's contents (`transferFile` partner).
96+
///
97+
/// - Parameters:
98+
/// - fileData: The contents of the received file.
99+
/// - metadata: Optional dictionary that accompanied the file.
100+
func handleFileReceived(_ fileData: Data, metadata: ConnectivityMessage?)
89101
}
90102

91103
// MARK: - ConnectivitySessionDelegate Bridge
@@ -147,5 +159,22 @@
147159
) {
148160
handleBinaryMessageReceived(messageData, replyHandler: replyHandler)
149161
}
162+
163+
/// Called when a queued dictionary is received.
164+
public func session(
165+
_: any ConnectivitySession,
166+
didReceiveUserInfo userInfo: ConnectivityMessage
167+
) {
168+
handleUserInfoReceived(userInfo)
169+
}
170+
171+
/// Called when a queued file's contents are received.
172+
public func session(
173+
_: any ConnectivitySession,
174+
didReceiveFile fileData: Data,
175+
metadata: ConnectivityMessage?
176+
) {
177+
handleFileReceived(fileData, metadata: metadata)
178+
}
150179
}
151180
#endif

Sources/SundialKitConnectivity/ConnectivityManager+DelegateHandling.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,5 +192,20 @@
192192
// Binary messages are not currently forwarded to observers
193193
// Future enhancement: Could add binary message observer protocol
194194
}
195+
196+
/// Handles a received queued dictionary (`transferUserInfo` partner).
197+
nonisolated public func handleUserInfoReceived(_: ConnectivityMessage) {
198+
// Queued user-info transfers are not currently forwarded to observers
199+
// Future enhancement: routed in the Phase 2 stream-routing work
200+
}
201+
202+
/// Handles a received queued file's contents (`transferFile` partner).
203+
nonisolated public func handleFileReceived(
204+
_: Data,
205+
metadata _: ConnectivityMessage?
206+
) {
207+
// Queued file transfers are not currently forwarded to observers
208+
// Future enhancement: routed in the Phase 2 stream-routing work
209+
}
195210
}
196211
#endif

Sources/SundialKitConnectivity/ConnectivitySession.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ public protocol ConnectivitySession: AnyObject, Sendable {
5050
/// Returns `nil` if no context has been received or if the context dictionary is empty.
5151
var receivedApplicationContext: ConnectivityMessage? { get }
5252

53+
/// The number of queued `transferUserInfo(_:)` transfers not yet delivered.
54+
var outstandingUserInfoTransferCount: Int { get }
55+
56+
/// The number of queued `transferFile(_:metadata:)` transfers not yet delivered.
57+
var outstandingFileTransferCount: Int { get }
58+
5359
func activate() throws
5460
func updateApplicationContext(_ context: ConnectivityMessage) throws
5561
func sendMessage(
@@ -70,4 +76,29 @@ public protocol ConnectivitySession: AnyObject, Sendable {
7076
_ data: Data,
7177
_ completion: @escaping (Result<Data, any Error>) -> Void
7278
)
79+
80+
/// Queues a dictionary for background delivery to the counterpart device.
81+
///
82+
/// This is the queued **dictionary** transport, backed by WatchConnectivity's
83+
/// `transferUserInfo(_:)`. Unlike `sendMessage(_:_:)`, delivery does not require
84+
/// the counterpart to be reachable; transfers are queued by the system and
85+
/// delivered opportunistically in FIFO order, surviving app relaunches.
86+
///
87+
/// - Parameter userInfo: The dictionary to queue for delivery
88+
func transferUserInfo(_ userInfo: ConnectivityMessage)
89+
90+
/// Queues binary data for background delivery to the counterpart device.
91+
///
92+
/// This is the queued **binary** transport, backed by WatchConnectivity's
93+
/// `transferFile(_:metadata:)`. It is the partner to `transferUserInfo(_:)`
94+
/// for `BinaryMessagable` payloads. The caller passes the encoded `Data`; the
95+
/// implementation writes it to a uniquely named temporary file and transfers
96+
/// that — so the file URL is generated automatically and the caller never
97+
/// manages a file. Delivery does not require the counterpart to be reachable.
98+
///
99+
/// - Parameters:
100+
/// - fileData: The encoded binary data (type footer included) to transfer
101+
/// - metadata: Optional dictionary describing the payload. Not required for
102+
/// type resolution since the binary type footer is embedded in the data.
103+
func transferFile(_ fileData: Data, metadata: ConnectivityMessage?)
73104
}

Sources/SundialKitConnectivity/ConnectivitySessionDelegate.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,39 @@ public protocol ConnectivitySessionDelegate: AnyObject {
7979
didReceiveMessageData messageData: Data,
8080
replyHandler: @escaping @Sendable (Data) -> Void
8181
)
82+
83+
/// Called when a queued dictionary is received from the counterpart device.
84+
///
85+
/// This method is invoked when the session receives a dictionary via
86+
/// WatchConnectivity's `session(_:didReceiveUserInfo:)`, the receive partner
87+
/// of `transferUserInfo(_:)`. Queued transfers have no reply handler.
88+
///
89+
/// - Parameters:
90+
/// - session: The connectivity session
91+
/// - userInfo: The received dictionary
92+
func session(
93+
_ session: any ConnectivitySession,
94+
didReceiveUserInfo userInfo: ConnectivityMessage
95+
)
96+
97+
/// Called when a queued file's contents are received from the counterpart device.
98+
///
99+
/// This method is invoked when the session receives a file via
100+
/// WatchConnectivity's `session(_:didReceive:)`, the receive partner of
101+
/// `transferFile(_:metadata:)`. The file is read into `Data` synchronously
102+
/// before this method is called, because WatchConnectivity deletes the
103+
/// underlying file as soon as the delegate callback returns. Carrying `Data`
104+
/// (rather than a `URL`) mirrors `didReceiveMessageData:` and sidesteps the
105+
/// file-lifetime concern.
106+
///
107+
/// - Parameters:
108+
/// - session: The connectivity session
109+
/// - fileData: The contents of the received file
110+
/// - metadata: Optional dictionary that accompanied the file. Not required
111+
/// for type resolution since the binary type footer is embedded in the data.
112+
func session(
113+
_ session: any ConnectivitySession,
114+
didReceiveFile fileData: Data,
115+
metadata: ConnectivityMessage?
116+
)
82117
}

Sources/SundialKitConnectivity/NeverConnectivitySession.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ public final class NeverConnectivitySession: NSObject, ConnectivitySession, Send
7272
nil
7373
}
7474

75+
/// The number of queued user-info transfers (always 0).
76+
public var outstandingUserInfoTransferCount: Int {
77+
0
78+
}
79+
80+
/// The number of queued file transfers (always 0).
81+
public var outstandingFileTransferCount: Int {
82+
0
83+
}
84+
7585
/// Attempts to activate the session (always throws).
7686
///
7787
/// - Throws: `SundialError.sessionNotSupported`
@@ -101,4 +111,10 @@ public final class NeverConnectivitySession: NSObject, ConnectivitySession, Send
101111
) {
102112
completion(.failure(SundialError.sessionNotSupported))
103113
}
114+
115+
/// Attempts to queue a dictionary for delivery (no-op).
116+
public func transferUserInfo(_: ConnectivityMessage) {}
117+
118+
/// Attempts to queue binary data for delivery (no-op).
119+
public func transferFile(_: Data, metadata _: ConnectivityMessage?) {}
104120
}

Sources/SundialKitConnectivity/SendOptions.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,21 @@ public struct SendOptions: OptionSet, Sendable {
3838
/// - Working with systems that don't support binary transport
3939
public static let forceDictionary = SendOptions(rawValue: 1 << 0)
4040

41+
/// Use application-context (latest-state) delivery when the counterpart is unreachable.
42+
///
43+
/// By default, a message that cannot be delivered immediately is *queued* for
44+
/// guaranteed FIFO background delivery (`transferUserInfo` / `transferFile`).
45+
/// Set this option to instead use `updateApplicationContext`, which keeps only
46+
/// the **most recent** payload (latest-state wins, coalescing) — appropriate for
47+
/// syncing current state rather than delivering every event.
48+
///
49+
/// Whether to queue or coalesce is an intent the framework cannot infer from
50+
/// reachability alone, so it is expressed explicitly here.
51+
///
52+
/// - Note: This flag is consumed by the stream-layer routing (Phase 2); the
53+
/// queued transport primitives it selects between live in this module.
54+
public static let useApplicationContext = SendOptions(rawValue: 1 << 1)
55+
4156
/// The raw integer value of the option set.
4257
public let rawValue: Int
4358

Sources/SundialKitConnectivity/WatchConnectivitySession+ConnectivitySession.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929

3030
#if canImport(WatchConnectivity)
3131
public import Foundation
32+
#if canImport(os)
33+
import os.log
34+
#endif
3235
public import SundialKitCore
3336
import WatchConnectivity
3437

@@ -76,6 +79,16 @@
7679
return ConnectivityMessage(forceCasting: context)
7780
}
7881

82+
/// The number of queued user-info transfers not yet delivered.
83+
public var outstandingUserInfoTransferCount: Int {
84+
session.outstandingUserInfoTransfers.count
85+
}
86+
87+
/// The number of queued file transfers not yet delivered.
88+
public var outstandingFileTransferCount: Int {
89+
session.outstandingFileTransfers.count
90+
}
91+
7992
/// Updates the application context to be sent to the counterpart device.
8093
///
8194
/// The context is delivered opportunistically when the counterpart wakes up.
@@ -133,6 +146,43 @@
133146
}
134147
}
135148

149+
/// Queues a dictionary for background delivery to the counterpart device.
150+
///
151+
/// Backed by `WCSession.transferUserInfo(_:)`. The returned transfer handle
152+
/// is ignored for now.
153+
///
154+
/// - Parameter userInfo: The dictionary to queue for delivery
155+
public func transferUserInfo(_ userInfo: ConnectivityMessage) {
156+
_ = session.transferUserInfo(userInfo as [String: Any])
157+
}
158+
159+
/// Queues binary data for background delivery to the counterpart device.
160+
///
161+
/// The encoded `Data` is written to a uniquely named temporary file, which
162+
/// is then handed to `WCSession.transferFile(_:metadata:)`. The temporary
163+
/// file is removed once the transfer finishes (see
164+
/// `session(_:didFinish:error:)`). The returned transfer handle is ignored
165+
/// for now.
166+
///
167+
/// - Parameters:
168+
/// - fileData: The encoded binary data to transfer
169+
/// - metadata: Optional dictionary describing the payload
170+
public func transferFile(_ fileData: Data, metadata: ConnectivityMessage?) {
171+
let tempURL = FileManager.default.temporaryDirectory
172+
.appendingPathComponent(UUID().uuidString)
173+
do {
174+
try fileData.write(to: tempURL)
175+
} catch {
176+
if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
177+
SundialLogger.connectivity.error(
178+
"Failed to write temp file for transferFile: \(error.localizedDescription)"
179+
)
180+
}
181+
return
182+
}
183+
_ = session.transferFile(tempURL, metadata: metadata as [String: Any]?)
184+
}
185+
136186
/// Activates the session to begin communication with the counterpart device.
137187
///
138188
/// Must be called before any message exchange can occur.

Sources/SundialKitConnectivity/WatchConnectivitySession+WCSessionDelegate.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,38 @@
140140
delegate?.session(self, didReceiveMessageData: messageData, replyHandler: handler)
141141
replyHandler(Data())
142142
}
143+
144+
internal func session(
145+
_: WCSession,
146+
didReceiveUserInfo userInfo: [String: Any]
147+
) {
148+
// WatchConnectivity only supports property list types which are inherently Sendable
149+
let sendableUserInfo = ConnectivityMessage(forceCasting: userInfo)
150+
delegate?.session(self, didReceiveUserInfo: sendableUserInfo)
151+
}
152+
153+
internal func session(
154+
_: WCSession,
155+
didReceive file: WCSessionFile
156+
) {
157+
// WatchConnectivity deletes the file as soon as this method returns, so the
158+
// contents must be read synchronously here before forwarding.
159+
let metadata = file.metadata.map(ConnectivityMessage.init(forceCasting:))
160+
guard let fileData = try? Data(contentsOf: file.fileURL) else {
161+
return
162+
}
163+
delegate?.session(self, didReceiveFile: fileData, metadata: metadata)
164+
}
165+
166+
internal func session(
167+
_: WCSession,
168+
didFinish fileTransfer: WCSessionFileTransfer,
169+
error _: Error?
170+
) {
171+
// `transferFile(_:metadata:)` writes the payload to a temporary file; remove
172+
// it once the transfer completes (successfully or not).
173+
try? FileManager.default.removeItem(at: fileTransfer.file.fileURL)
174+
}
143175
}
144176

145177
#endif
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//
2+
// CapturingConnectivityDelegate.swift
3+
// SundialKit
4+
//
5+
// Created by Leo Dion.
6+
// Copyright © 2026 BrightDigit.
7+
//
8+
9+
import Foundation
10+
11+
@testable import SundialKitConnectivity
12+
@testable import SundialKitCore
13+
14+
/// Captures the queued-transport delegate callbacks for assertions.
15+
internal final class CapturingConnectivityDelegate: ConnectivitySessionDelegate,
16+
@unchecked Sendable
17+
{
18+
internal var receivedUserInfo: ConnectivityMessage?
19+
internal var receivedFileData: Data?
20+
internal var receivedFileMetadata: ConnectivityMessage?
21+
22+
internal func session(
23+
_: any ConnectivitySession,
24+
activationDidCompleteWith _: ActivationState,
25+
error _: (any Error)?
26+
) {}
27+
28+
internal func sessionDidBecomeInactive(_: any ConnectivitySession) {}
29+
30+
internal func sessionDidDeactivate(_: any ConnectivitySession) {}
31+
32+
internal func sessionCompanionStateDidChange(_: any ConnectivitySession) {}
33+
34+
internal func sessionReachabilityDidChange(_: any ConnectivitySession) {}
35+
36+
internal func session(
37+
_: any ConnectivitySession,
38+
didReceiveMessage _: ConnectivityMessage,
39+
replyHandler _: @escaping ConnectivityHandler
40+
) {}
41+
42+
internal func session(
43+
_: any ConnectivitySession,
44+
didReceiveApplicationContext _: ConnectivityMessage,
45+
error _: (any Error)?
46+
) {}
47+
48+
internal func session(
49+
_: any ConnectivitySession,
50+
didReceiveMessageData _: Data,
51+
replyHandler _: @escaping @Sendable (Data) -> Void
52+
) {}
53+
54+
internal func session(
55+
_: any ConnectivitySession,
56+
didReceiveUserInfo userInfo: ConnectivityMessage
57+
) {
58+
receivedUserInfo = userInfo
59+
}
60+
61+
internal func session(
62+
_: any ConnectivitySession,
63+
didReceiveFile fileData: Data,
64+
metadata: ConnectivityMessage?
65+
) {
66+
receivedFileData = fileData
67+
receivedFileMetadata = metadata
68+
}
69+
}

0 commit comments

Comments
 (0)