Swift 6.1+ reactive communications library with modern concurrency support for Apple platforms.
SundialKit v2.0.0 is a modern Swift 6.1+ library that provides reactive interfaces for network connectivity and device communication across Apple platforms. Originally created for my app Heartwitch, SundialKit abstracts and simplifies Apple's Network and WatchConnectivity frameworks with a clean, layered architecture.
- Swift 6.1 Strict Concurrency: Full compliance with Swift 6 concurrency model
- Three-Layer Architecture: Protocols, wrappers, and observation layers cleanly separated
- Multiple Concurrency Models: Choose between modern async/await (SundialKitStream) or Combine (SundialKitCombine)
- Zero @unchecked Sendable in Plugins: Actor-based patterns ensure thread safety
- Modular Design: Import only what you need - core protocols, network monitoring, or connectivity
- Swift Testing: Modern test framework support (v2.0.0+)
Core Features:
- Monitor network connectivity and quality using Apple's Network framework
- Communicate between iPhone and Apple Watch via WatchConnectivity
- Monitor device connectivity and pairing status
- Send and receive messages between devices
- Type-safe message encoding/decoding with Messagable protocol
- Built-in message serialization with Messagable (dictionary-based) and BinaryMessagable protocols
v2.0.0 Features:
- SundialKitStream: Actor-based observers with AsyncStream APIs
- SundialKitCombine: @MainActor observers with Combine publishers
- Protocol-oriented architecture for maximum flexibility
- Sendable-safe types throughout
- Comprehensive error handling with typed errors
Swift Package Manager is Apple's decentralized dependency manager to integrate libraries to your Swift projects. It is now fully integrated with Xcode 16+.
Add SundialKit to your Package.swift:
let package = Package(
name: "YourPackage",
platforms: [.iOS(.v16), .watchOS(.v9), .tvOS(.v16), .macOS(.v13)],
dependencies: [
.package(url: "https://github.com/brightdigit/SundialKit.git", from: "2.0.0-alpha.1"),
.package(url: "https://github.com/brightdigit/SundialKitStream.git", from: "1.0.0-alpha.1")
],
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "SundialKitStream", package: "SundialKitStream"),
.product(name: "SundialKitNetwork", package: "SundialKit"),
.product(name: "SundialKitConnectivity", package: "SundialKit")
]
)
]
)Need Combine support? If you need to support iOS 13+ or prefer Combine publishers, see SundialKitCombine.
SundialKit v2.0.0 has two types of features:
Core Packages (from brightdigit/SundialKit):
SundialKitCore: Protocol definitions and core typesSundialKitNetwork: Network connectivity monitoring with NWPathMonitor wrappersSundialKitConnectivity: WatchConnectivity abstractions with built-in message serialization- Messagable protocol: Type-safe dictionary-based messaging
- BinaryMessagable protocol: Efficient binary message encoding
- MessageDecoder: Type registry for decoding messages
Plugin Packages (separate repositories - choose your concurrency model):
SundialKitStream(frombrightdigit/SundialKitStream): Actor-based observers with AsyncStream APIsSundialKitCombine(frombrightdigit/SundialKitCombine): Combine-based observers with @Published properties
When you import SundialKitConnectivity, you automatically get Messagable and BinaryMessagable features. The observation plugins (Stream and Combine) are distributed as separate packages to keep dependencies minimal.
For building your own observers:
.product(name: "SundialKitCore", package: "SundialKit")- Swift: 6.1+ (strict concurrency enabled)
- Xcode: 16.0+
- Platforms:
- SundialKitStream: iOS 16+, watchOS 9+, tvOS 16+, macOS 13+
- SundialKitCombine: iOS 13+, watchOS 6+, tvOS 13+, macOS 10.15+
- Core modules: iOS 13+, watchOS 6+, tvOS 13+, macOS 10.13+
- Swift: 5.9+
- Xcode: 15.0+
- Platforms: iOS 13+, watchOS 6+, tvOS 13+, macOS 10.13+
Note: These examples use SundialKitStream with modern async/await patterns. For Combine-based examples and iOS 13+ support, see SundialKitCombine.
SundialKit uses Apple's Network framework to monitor network connectivity, providing detailed information about network status, quality, and interface types.
import SwiftUI
import SundialKitStream
import SundialKitNetwork
@Observable
class NetworkConnectivityModel {
var pathStatus: PathStatus = .unknown
var isExpensive: Bool = false
var isConstrained: Bool = false
private let observer = NetworkObserver(
monitor: NWPathMonitorAdapter(),
ping: nil
)
func start() {
// Start monitoring on a background queue
observer.start(queue: .global())
// Listen to path status updates using AsyncStream
Task {
for await status in observer.pathStatusStream {
self.pathStatus = status
}
}
// Listen to expensive network status
Task {
for await expensive in observer.isExpensiveStream {
self.isExpensive = expensive
}
}
// Listen to constrained network status
Task {
for await constrained in observer.isConstrainedStream {
self.isConstrained = constrained
}
}
}
}
struct NetworkView: View {
@State private var model = NetworkConnectivityModel()
var body: some View {
VStack {
Text("Status: \(model.pathStatus.description)")
Text("Expensive: \(model.isExpensive ? "Yes" : "No")")
Text("Constrained: \(model.isConstrained ? "Yes" : "No")")
}
.task {
model.start()
}
}
}Available Network Properties:
pathStatus: Overall network status (satisfied, unsatisfied, requiresConnection, unknown)isExpensive: Whether the connection is expensive (e.g., cellular data)isConstrained: Whether the connection has constraints (e.g., low data mode)
In addition to utilizing NWPathMonitor, you can setup a periodic ping by implementing NetworkPing. Here's an example which calls the ipify API to verify there's an ip address:
struct IpifyPing : NetworkPing {
typealias StatusType = String?
let session: URLSession
let timeInterval: TimeInterval
public func shouldPing(onStatus status: PathStatus) -> Bool {
switch status {
case .unknown, .unsatisfied:
return false
case .requiresConnection, .satisfied:
return true
}
}
static let url : URL = .init(string: "https://api.ipify.org")!
func onPing(_ closure: @escaping (String?) -> Void) {
session.dataTask(with: IpifyPing.url) { data, _, _ in
closure(data.flatMap{String(data: $0, encoding: .utf8)})
}.resume()
}
}Next, in our model, we can create a NetworkObserver to use this with:
@Observable
class NetworkModel {
private let observer = NetworkObserver(
monitor: NWPathMonitorAdapter(),
ping: IpifyPing(session: .shared, timeInterval: 10.0)
)
func start() {
observer.start(queue: .global())
}
}Besides networking, SundialKit also provides an easier reactive interface into WatchConnectivity. This includes:
- Various connection statuses like
isReachable,isInstalled, etc.. - Send messages between the iPhone and paired Apple Watch
- Easy encoding and decoding of messages between devices into
WatchConnectivityfriendly dictionaries.
Let's first talk about how WatchConnectivity status works.
With WatchConnectivity there's a variety of properties which tell you the status of connection between devices. Here's an example using SundialKitStream to monitor isReachable and activationState:
import SwiftUI
import SundialKitStream
import SundialKitConnectivity
@Observable
class WatchConnectivityModel {
var isReachable: Bool = false
var activationState: ActivationState = .notActivated
private let observer = ConnectivityObserver()
func start() async throws {
// Activate the WatchConnectivity session
try await observer.activate()
// Monitor activation state
Task {
for await state in observer.activationStates() {
self.activationState = state
}
}
// Monitor reachability
Task {
for await reachable in observer.reachabilityStream() {
self.isReachable = reachable
}
}
}
}There are 3 important pieces:
- The
ConnectivityObservercalledobserver - A
start()method that activates the session and sets up AsyncStream listeners - Tasks that monitor state changes using
for awaitloops
For our SwiftUI View, we use the .task modifier to start monitoring:
struct WatchConnectivityView: View {
@State private var model = WatchConnectivityModel()
var body: some View {
VStack {
Text("Session: \(model.activationState.description)")
Text(model.isReachable ? "Reachable" : "Not Reachable")
}
.task {
try? await model.start()
}
}
}Besides isReachable and activationState, you also have access to:
isPairedAppInstalledisPairedisCompanionAppInstalled(watchOS only)
All of these properties can be monitored via AsyncStream methods on the ConnectivityObserver.
To send and receive messages through our ConnectivityObserver, we use async methods and AsyncStream:
messageStream()- AsyncStream for listening to messagessendMessage(_:)async method - for sending messages
SundialKit uses [String: any Sendable] dictionaries for sending and receiving messages, which use the typealias ConnectivityMessage. Let's expand upon the previous WatchConnectivityModel to handle messaging:
import SwiftUI
import SundialKitStream
import SundialKitConnectivity
@Observable
class WatchConnectivityModel {
var isReachable: Bool = false
var lastReceivedMessage: String = ""
private let observer = ConnectivityObserver()
func start() async throws {
try await observer.activate()
// Monitor reachability
Task {
for await reachable in observer.reachabilityStream() {
self.isReachable = reachable
}
}
// Listen for received messages
Task {
for await result in observer.messageStream() {
if let message = result.message["message"] as? String {
self.lastReceivedMessage = message
}
}
}
}
func sendMessage(_ message: String) async throws {
// Send a message asynchronously
let result = try await observer.sendMessage(["message": message])
print("Message sent via: \(result.context)")
}
}We can now create a simple SwiftUI View using our updated WatchConnectivityModel:
struct WatchMessageDemoView: View {
@State private var model = WatchConnectivityModel()
@State private var message: String = ""
var body: some View {
VStack {
Text(model.isReachable ? "Reachable" : "Not Reachable")
TextField("Message", text: $message)
Button("Send") {
Task {
try? await model.sendMessage(message)
}
}
.disabled(!model.isReachable)
Text("Last received message:")
Text(model.lastReceivedMessage)
}
.task {
try? await model.start()
}
}
}Messages arrive with different contexts that indicate how they should be handled:
.replyWith(handler)- Interactive message expecting an immediate reply. Use the handler to send a response..applicationContext- Background state update delivered when devices can communicate. No reply expected.
We can use type-safe messaging by implementing the Messagable protocol. In v2.0.0, the ConnectivityObserver can be configured with a MessageDecoder to automatically decode incoming messages.
First, create a type that implements Messagable:
import SundialKitConnectivity
struct Message: Messagable {
let text: String
// Unique key for this message type
static let key: String = "textMessage"
// Throwing initializer from dictionary parameters
init(from parameters: [String: any Sendable]) throws {
guard let text = parameters["text"] as? String else {
throw SerializationError.missingField("text")
}
self.text = text
}
// Convert to dictionary parameters
func parameters() -> [String: any Sendable] {
["text": text]
}
// Regular initializer for creating messages
init(text: String) {
self.text = text
}
}There are two requirements for implementing Messagable:
init(from:)- Create the object from a dictionary, throwing an error if invalidparameters()- Return a dictionary with all the parameters needed to recreate the object
Optionally, you can provide:
key- A static string that identifies the type and must be unique within theMessageDecoder(if not provided, the type name is used)
Now configure our ConnectivityObserver with a MessageDecoder and use typed messages:
import SwiftUI
import SundialKitStream
import SundialKitConnectivity
@Observable
class WatchConnectivityModel {
var isReachable: Bool = false
var lastReceivedMessage: String = ""
// Create observer with MessageDecoder for typed message handling
private let observer = ConnectivityObserver(
messageDecoder: MessageDecoder(messagableTypes: [Message.self])
)
func start() async throws {
try await observer.activate()
// Monitor reachability
Task {
for await reachable in observer.reachabilityStream() {
self.isReachable = reachable
}
}
// Listen for typed messages
Task {
for await message in observer.typedMessageStream() {
if let textMessage = message as? Message {
self.lastReceivedMessage = textMessage.text
}
}
}
}
func sendMessage(_ text: String) async throws {
// Send using the typed message
let message = Message(text: text)
let result = try await observer.send(message)
print("Message sent via: \(result.context)")
}
}The MessageDecoder automatically routes incoming messages to the correct type based on the message's key field, and the typedMessageStream() AsyncStream provides already-decoded Messagable instances.
SundialKit includes two demo applications showcasing different concurrency approaches:
- Pulse (
Examples/Sundial/Apps/SundialCombine) - Combine-based reactive demo - Flow (
Examples/Sundial/Apps/SundialStream) - AsyncStream/actor-based demo with modern Swift concurrency
Both apps demonstrate:
- Network connectivity monitoring
- WatchConnectivity communication between iPhone and Apple Watch
- Real-world usage patterns for SundialKit
Both apps are available for internal testing via TestFlight.
See Examples/Sundial/DEPLOYMENT.md for deployment and development instructions.
SundialKit uses a Make-based workflow for building, testing, and linting the project.
make build # Build the package
make test # Run tests with code coverage
make lint # Run linting and formatting (strict mode)
make format # Format code only
make clean # Clean build artifacts
make help # Show all available commandsThe project uses mise to manage development tools:
- swift-format - Official Apple Swift formatter
- SwiftLint - Swift style and conventions linter
- Periphery - Unused code detection
Install mise on macOS:
curl https://mise.run | sh
# or
brew install miseInstall development tools:
mise install # Installs tools from .mise.tomlRun linting manually:
./Scripts/lint.sh # Normal mode
LINT_MODE=STRICT ./Scripts/lint.sh # Strict mode (CI)
FORMAT_ONLY=1 ./Scripts/lint.sh # Format onlyThis code is distributed under the MIT license. See the LICENSE file for more info.
