brightdigit/SundialKit
Communications library across Apple platforms.
What's New in v2.0.0
- 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+)
Features
Core Features:
- [x] Monitor network connectivity and quality using Apple's Network framework
- [x] Communicate between iPhone and Apple Watch via WatchConnectivity
- [x] Monitor device connectivity and pairing status
- [x] Send and receive messages between devices
- [x] Type-safe message encoding/decoding with Messagable protocol
- [x] Built-in message serialization with Messagable (dictionary-based) and BinaryMessagable protocols
v2.0.0 Features:
- [x] SundialKitStream: Actor-based observers with AsyncStream APIs
- [x] SundialKitCombine: @MainActor observers with Combine publishers
- [x] Protocol-oriented architecture for maximum flexibility
- [x] Sendable-safe types throughout
- [x] Comprehensive error handling with typed errors
Installation
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.
Understanding SundialKit Architecture
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.
Core Protocols Only
For building your own observers:
.product(name: "SundialKitCore", package: "SundialKit")Requirements
v2.0.0+
- 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+
v1.x (Legacy)
- Swift: 5.9+
- Xcode: 15.0+
- Platforms: iOS 13+, watchOS 6+, tvOS 13+, macOS 10.13+
Usage
Note: These examples use SundialKitStream with modern async/await patterns. For Combine-based examples and iOS 13+ support, see SundialKitCombine.
Listening to Networking Changes
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)
Verify Connectivity with `NetworkPing`
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())
}
}Communication between iPhone and Apple Watch
Besides networking, **SundialKit** also provides an easier reactive interface into `WatchConnectivity`. This includes:
1. Various connection statuses like `isReachable`, `isInstalled`, etc..
2. Send messages between the iPhone and paired Apple Watch
3. Easy encoding and decoding of messages between devices into `WatchConnectivity` friendly dictionaries.
Let's first talk about how `WatchConnectivity` status works.
### Connection Status
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`:
```swift
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:
1. The `ConnectivityObserver` called `observer`
2. A `start()` method that activates the session and sets up AsyncStream listeners
3. Tasks that monitor state changes using `for await` loops
For our `SwiftUI` `View`, we use the `.task` modifier to start monitoring:
```swift
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:
* `isPairedAppInstalled`
* `isPaired`
* `isCompanionAppInstalled` (watchOS only)
All of these properties can be monitored via AsyncStream methods on the `ConnectivityObserver`.
### Sending and Receiving Messages
To send and receive messages through our `ConnectivityObserver`, we use async methods and AsyncStream:
- `messageStream()` - AsyncStream for listening to messages
- `sendMessage(_:)` 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:
```swift
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`:
```swift
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.
### Using `Messagable` to Communicate
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`:
```swift
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 invalid
* `parameters()` - 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 the `MessageDecoder` (if not provided, the type name is used)
Now configure our `ConnectivityObserver` with a `MessageDecoder` and use typed messages:
```swift
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.
# Demo Applications
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](Examples/Sundial/DEPLOYMENT.md) for deployment and development instructions.
# Development
SundialKit uses a Make-based workflow for building, testing, and linting the project.Building and Testing
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 commandsDevelopment Tools
The 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 onlyLicense
This code is distributed under the MIT license. See the LICENSE file for more info.
Package Metadata
Repository: brightdigit/SundialKit
Homepage: https://swiftpackageindex.com/brightdigit/SundialKit/0.2.0/documentation/sundialkit
Stars: 31
Forks: 5
Open issues: 8
Default branch: main
Primary language: swift
License: MIT
Topics: apple-watch, apple-watch-application, network-analysis, swift, swift-package-manager, watchkit-sdk
README: README.md