Skip to content

brightdigit/SundialKit

Repository files navigation

SundialKit

SundialKit

Swift 6.1+ reactive communications library with modern concurrency support for Apple platforms.

SwiftPM Twitter GitHub GitHub issues SundialKit

Codecov CodeFactor Grade

Table of Contents

Introduction

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.

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:

  • 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

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 types
  • SundialKitNetwork: Network connectivity monitoring with NWPathMonitor wrappers
  • SundialKitConnectivity: 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 (from brightdigit/SundialKitStream): Actor-based observers with AsyncStream APIs
  • SundialKitCombine (from brightdigit/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:

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:

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:

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.

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:

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:

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 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 commands

Development 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 mise

Install development tools:

mise install  # Installs tools from .mise.toml

Run linting manually:

./Scripts/lint.sh                    # Normal mode
LINT_MODE=STRICT ./Scripts/lint.sh   # Strict mode (CI)
FORMAT_ONLY=1 ./Scripts/lint.sh      # Format only

License

This code is distributed under the MIT license. See the LICENSE file for more info.