diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..6fed9ba
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,32 @@
+{
+ "name": "Swift 6.2",
+ "image": "swift:6.2",
+ "features": {
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "installZsh": "false",
+ "username": "vscode",
+ "upgradePackages": "false"
+ },
+ "ghcr.io/devcontainers/features/git:1": {
+ "version": "os-provided",
+ "ppa": "false"
+ }
+ },
+ "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
+ "runArgs": [
+ "--cap-add=SYS_PTRACE",
+ "--security-opt",
+ "seccomp=unconfined"
+ ],
+ "customizations": {
+ "vscode": {
+ "settings": {
+ "lldb.library": "/usr/lib/liblldb.so"
+ },
+ "extensions": [
+ "sswg.swift-lang"
+ ]
+ }
+ },
+ "remoteUser": "root"
+}
\ No newline at end of file
diff --git a/.devcontainer/swift-6.1-nightly/devcontainer.json b/.devcontainer/swift-6.1-nightly/devcontainer.json
new file mode 100644
index 0000000..7949dc9
--- /dev/null
+++ b/.devcontainer/swift-6.1-nightly/devcontainer.json
@@ -0,0 +1,32 @@
+{
+ "name": "Swift 6.1 Nightly",
+ "image": "swiftlang/swift:nightly-6.1-noble",
+ "features": {
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "installZsh": "false",
+ "username": "vscode",
+ "upgradePackages": "false"
+ },
+ "ghcr.io/devcontainers/features/git:1": {
+ "version": "os-provided",
+ "ppa": "false"
+ }
+ },
+ "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
+ "runArgs": [
+ "--cap-add=SYS_PTRACE",
+ "--security-opt",
+ "seccomp=unconfined"
+ ],
+ "customizations": {
+ "vscode": {
+ "settings": {
+ "lldb.library": "/usr/lib/liblldb.so"
+ },
+ "extensions": [
+ "sswg.swift-lang"
+ ]
+ }
+ },
+ "remoteUser": "root"
+}
\ No newline at end of file
diff --git a/.devcontainer/swift-6.1/devcontainer.json b/.devcontainer/swift-6.1/devcontainer.json
new file mode 100644
index 0000000..bdb65e1
--- /dev/null
+++ b/.devcontainer/swift-6.1/devcontainer.json
@@ -0,0 +1,32 @@
+{
+ "name": "Swift 6.1",
+ "image": "swift:6.1",
+ "features": {
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "installZsh": "false",
+ "username": "vscode",
+ "upgradePackages": "false"
+ },
+ "ghcr.io/devcontainers/features/git:1": {
+ "version": "os-provided",
+ "ppa": "false"
+ }
+ },
+ "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
+ "runArgs": [
+ "--cap-add=SYS_PTRACE",
+ "--security-opt",
+ "seccomp=unconfined"
+ ],
+ "customizations": {
+ "vscode": {
+ "settings": {
+ "lldb.library": "/usr/lib/liblldb.so"
+ },
+ "extensions": [
+ "sswg.swift-lang"
+ ]
+ }
+ },
+ "remoteUser": "root"
+}
\ No newline at end of file
diff --git a/.devcontainer/swift-6.2-nightly/devcontainer.json b/.devcontainer/swift-6.2-nightly/devcontainer.json
new file mode 100644
index 0000000..b5bd73c
--- /dev/null
+++ b/.devcontainer/swift-6.2-nightly/devcontainer.json
@@ -0,0 +1,40 @@
+{
+ "name": "Swift 6.2 Nightly",
+ "image": "swiftlang/swift:nightly-6.2-noble",
+ "features": {
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "installZsh": "false",
+ "username": "vscode",
+ "upgradePackages": "false"
+ },
+ "ghcr.io/devcontainers/features/git:1": {
+ "version": "os-provided",
+ "ppa": "false"
+ }
+ },
+ "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
+ "runArgs": [
+ "--cap-add=SYS_PTRACE",
+ "--security-opt",
+ "seccomp=unconfined"
+ ],
+ // Configure tool-specific properties.
+ "customizations": {
+ // Configure properties specific to VS Code.
+ "vscode": {
+ // Set *default* container specific settings.json values on container create.
+ "settings": {
+ "lldb.library": "/usr/lib/liblldb.so"
+ },
+ // Add the IDs of extensions you want installed when the container is created.
+ "extensions": [
+ "sswg.swift-lang"
+ ]
+ }
+ },
+ // Use 'forwardPorts' to make a list of ports inside the container available locally.
+ // "forwardPorts": [],
+
+ // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
+ "remoteUser": "root"
+}
\ No newline at end of file
diff --git a/.devcontainer/swift-6.2/devcontainer.json b/.devcontainer/swift-6.2/devcontainer.json
new file mode 100644
index 0000000..6fed9ba
--- /dev/null
+++ b/.devcontainer/swift-6.2/devcontainer.json
@@ -0,0 +1,32 @@
+{
+ "name": "Swift 6.2",
+ "image": "swift:6.2",
+ "features": {
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "installZsh": "false",
+ "username": "vscode",
+ "upgradePackages": "false"
+ },
+ "ghcr.io/devcontainers/features/git:1": {
+ "version": "os-provided",
+ "ppa": "false"
+ }
+ },
+ "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
+ "runArgs": [
+ "--cap-add=SYS_PTRACE",
+ "--security-opt",
+ "seccomp=unconfined"
+ ],
+ "customizations": {
+ "vscode": {
+ "settings": {
+ "lldb.library": "/usr/lib/liblldb.so"
+ },
+ "extensions": [
+ "sswg.swift-lang"
+ ]
+ }
+ },
+ "remoteUser": "root"
+}
\ No newline at end of file
diff --git a/.github/workflows/SundialKitStream.yml b/.github/workflows/SundialKitStream.yml
new file mode 100644
index 0000000..6de6493
--- /dev/null
+++ b/.github/workflows/SundialKitStream.yml
@@ -0,0 +1,155 @@
+name: SundialKitStream
+on:
+ push:
+ branches-ignore:
+ - '*WIP'
+env:
+ PACKAGE_NAME: SundialKitStream
+jobs:
+ build-ubuntu:
+ name: Build on Ubuntu
+ runs-on: ubuntu-latest
+ container: ${{ matrix.swift.nightly && format('swiftlang/swift:nightly-{0}-{1}', matrix.swift.version, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }}
+ if: ${{ !contains(github.event.head_commit.message, 'ci skip') }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [noble, jammy]
+ swift:
+ - version: "6.1"
+ - version: "6.2"
+ - version: "6.1"
+ nightly: true
+ - version: "6.2"
+ nightly: true
+ steps:
+ - uses: actions/checkout@v4
+ - uses: brightdigit/swift-build@v1.4.0
+ with:
+ scheme: ${{ env.PACKAGE_NAME }}
+ - uses: sersoft-gmbh/swift-coverage-action@v4
+ id: coverage-files
+ with:
+ fail-on-empty-output: true
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v4
+ with:
+ fail_ci_if_error: true
+ flags: swift-${{ matrix.swift.version }}-${{ matrix.os }}${{ matrix.swift.nightly && '-nightly' || '' }}
+ verbose: true
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }}
+ build-macos:
+ name: Build on macOS
+ runs-on: ${{ matrix.runs-on }}
+ if: ${{ !contains(github.event.head_commit.message, 'ci skip') }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # SPM Build Matrix - Xcode 16.x+ (Swift 6.1+)
+ - runs-on: macos-26
+ xcode: "/Applications/Xcode_26.1.app"
+ - runs-on: macos-26
+ xcode: "/Applications/Xcode_26.0.app"
+ - runs-on: macos-15
+ xcode: "/Applications/Xcode_16.4.app"
+ - runs-on: macos-15
+ xcode: "/Applications/Xcode_16.3.app"
+
+ # iOS Build Matrix - Xcode 16.x+ (Swift 6.1+)
+ - type: ios
+ runs-on: macos-26
+ xcode: "/Applications/Xcode_26.1.app"
+ deviceName: "iPhone 17 Pro"
+ osVersion: "26.1"
+ download-platform: true
+
+ - type: ios
+ runs-on: macos-26
+ xcode: "/Applications/Xcode_26.0.app"
+ deviceName: "iPhone 17 Pro"
+ osVersion: "26.0.1"
+ download-platform: true
+
+ - type: ios
+ runs-on: macos-15
+ xcode: "/Applications/Xcode_16.4.app"
+ deviceName: "iPhone 16e"
+ osVersion: "18.5"
+
+ - type: ios
+ runs-on: macos-15
+ xcode: "/Applications/Xcode_16.3.app"
+ deviceName: "iPhone 16"
+ osVersion: "18.4"
+
+ # watchOS Build Matrix - Xcode 16.x+ (Swift 6.1+)
+ - type: watchos
+ runs-on: macos-26
+ xcode: "/Applications/Xcode_26.1.app"
+ deviceName: "Apple Watch Ultra 3 (49mm)"
+ osVersion: "26.0"
+ download-platform: true
+
+ - type: watchos
+ runs-on: macos-26
+ xcode: "/Applications/Xcode_26.0.app"
+ deviceName: "Apple Watch Ultra 3 (49mm)"
+ osVersion: "26.0"
+ download-platform: true
+
+ - type: watchos
+ runs-on: macos-15
+ xcode: "/Applications/Xcode_16.4.app"
+ deviceName: "Apple Watch Series 10 (46mm)"
+ osVersion: "11.5"
+
+ - type: watchos
+ runs-on: macos-15
+ xcode: "/Applications/Xcode_16.3.app"
+ deviceName: "Apple Watch Series 10 (42mm)"
+ osVersion: "11.4"
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build and Test
+ uses: brightdigit/swift-build@v1.4.0
+ with:
+ scheme: ${{ env.PACKAGE_NAME }}
+ type: ${{ matrix.type }}
+ xcode: ${{ matrix.xcode }}
+ deviceName: ${{ matrix.deviceName }}
+ osVersion: ${{ matrix.osVersion }}
+ download-platform: ${{ matrix.download-platform }}
+
+ # Coverage Steps
+ - name: Process Coverage
+ uses: sersoft-gmbh/swift-coverage-action@v4
+
+ - name: Upload Coverage
+ uses: codecov/codecov-action@v4
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }}
+
+ lint:
+ name: Linting
+ if: ${{ !contains(github.event.head_commit.message, 'ci skip') }}
+ runs-on: ubuntu-latest
+ needs: [build-ubuntu, build-macos]
+ env:
+ LINT_MODE: STRICT
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install mise
+ uses: jdx/mise-action@v2
+ with:
+ version: 2024.11.0
+ install: true
+ cache: true
+ - name: Lint
+ run: |
+ set -e
+ ./Scripts/lint.sh
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
new file mode 100644
index 0000000..205b0fe
--- /dev/null
+++ b/.github/workflows/claude-code-review.yml
@@ -0,0 +1,57 @@
+name: Claude Code Review
+
+on:
+ pull_request:
+ types: [opened, synchronize]
+ # Optional: Only run on specific file changes
+ # paths:
+ # - "src/**/*.ts"
+ # - "src/**/*.tsx"
+ # - "src/**/*.js"
+ # - "src/**/*.jsx"
+
+jobs:
+ claude-review:
+ # Optional: Filter by PR author
+ # if: |
+ # github.event.pull_request.user.login == 'external-contributor' ||
+ # github.event.pull_request.user.login == 'new-developer' ||
+ # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
+
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code Review
+ id: claude-review
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ prompt: |
+ REPO: ${{ github.repository }}
+ PR NUMBER: ${{ github.event.pull_request.number }}
+
+ Please review this pull request and provide feedback on:
+ - Code quality and best practices
+ - Potential bugs or issues
+ - Performance considerations
+ - Security concerns
+ - Test coverage
+
+ Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
+
+ Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
+
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
+ claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
+
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
new file mode 100644
index 0000000..412cef9
--- /dev/null
+++ b/.github/workflows/claude.yml
@@ -0,0 +1,50 @@
+name: Claude Code
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened, assigned]
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ claude:
+ if: |
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+ actions: read # Required for Claude to read CI results on PRs
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code
+ id: claude
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+ # This is an optional setting that allows Claude to read CI results on PRs
+ additional_permissions: |
+ actions: read
+
+ # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
+ # prompt: 'Update the pull request description to include a summary of changes.'
+
+ # Optional: Add claude_args to customize behavior and configuration
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
+ # claude_args: '--allowed-tools Bash(gh pr:*)'
+
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..ca63144
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,82 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+ push:
+ branches-ignore:
+ - '*WIP'
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [ "main" ]
+ schedule:
+ - cron: '20 11 * * 3'
+
+jobs:
+ analyze:
+ name: Analyze
+ # Runner size impacts CodeQL analysis time. To learn more, please see:
+ # - https://gh.io/recommended-hardware-resources-for-running-codeql
+ # - https://gh.io/supported-runners-and-hardware-resources
+ # - https://gh.io/using-larger-runners
+ # Consider using larger runners for possible analysis time improvements.
+ runs-on: ${{ (matrix.language == 'swift' && 'macos-15') || 'ubuntu-latest' }}
+ timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'swift' ]
+ # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
+ # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
+ # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
+ # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Xcode
+ run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer
+
+ - name: Verify Swift Version
+ run: |
+ swift --version
+ swift package --version
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+
+ # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+ # queries: security-extended,security-and-quality
+
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - run: |
+ echo "Run, Build Application using script"
+ swift build
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7995edc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,194 @@
+# macOS
+.DS_Store
+
+# Swift Package Manager
+.build/
+.swiftpm/
+DerivedData/
+.index-build/
+
+# Xcode
+*.xcodeproj
+*.xcworkspace
+xcuserdata/
+
+# IDE
+.vscode/
+.idea/
+
+### Node ###
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+.temp
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+### Node Patch ###
+# Serverless Webpack directories
+.webpack/
+
+# Optional stylelint cache
+
+# SvelteKit build / generate output
+.svelte-kit
+
+# End of https://www.toptal.com/developers/gitignore/api/node
+
+dev-debug.log
+# Environment variables
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+# OS specific
+
+.mint/
+/Keys/
+.claude/settings.local.json
+
+# Prevent accidental commits of private keys/certificates (server-to-server auth)
+*.p8
+*.pem
+*.key
+*.cer
+*.crt
+*.der
+*.p12
+*.pfx
+
+# Allow placeholder docs/samples in Keys
+!Keys/README.md
+!Keys/*.example.*
+
+# Task files
+# tasks.json
+# tasks/
+.mcp.json
\ No newline at end of file
diff --git a/.mise.toml b/.mise.toml
new file mode 100644
index 0000000..c98e333
--- /dev/null
+++ b/.mise.toml
@@ -0,0 +1,14 @@
+[tools]
+# Swift development tools (migrated from Mint)
+# Using Swift Package Manager plugins
+"spm:swiftlang/swift-format" = "602.0.0"
+swiftlint = "0.61.0"
+"spm:peripheryapp/periphery" = "3.2.0"
+
+[tasks]
+swift-format = "swift-format"
+swiftlint = "swiftlint"
+periphery = "periphery"
+
+[settings]
+experimental = true
diff --git a/.periphery.yml b/.periphery.yml
new file mode 100644
index 0000000..85b884a
--- /dev/null
+++ b/.periphery.yml
@@ -0,0 +1 @@
+retain_public: true
diff --git a/.spi.yml b/.spi.yml
new file mode 100644
index 0000000..2e9faf5
--- /dev/null
+++ b/.spi.yml
@@ -0,0 +1,5 @@
+version: 1
+builder:
+ configs:
+ - platform: ios
+ documentation_targets: [SundialKitStream]
diff --git a/.swift-format b/.swift-format
new file mode 100644
index 0000000..5e103e9
--- /dev/null
+++ b/.swift-format
@@ -0,0 +1,70 @@
+{
+ "fileScopedDeclarationPrivacy" : {
+ "accessLevel" : "fileprivate"
+ },
+ "indentation" : {
+ "spaces" : 2
+ },
+ "indentConditionalCompilationBlocks" : true,
+ "indentSwitchCaseLabels" : false,
+ "lineBreakAroundMultilineExpressionChainComponents" : false,
+ "lineBreakBeforeControlFlowKeywords" : false,
+ "lineBreakBeforeEachArgument" : false,
+ "lineBreakBeforeEachGenericRequirement" : false,
+ "lineLength" : 100,
+ "maximumBlankLines" : 1,
+ "multiElementCollectionTrailingCommas" : true,
+ "noAssignmentInExpressions" : {
+ "allowedFunctions" : [
+ "XCTAssertNoThrow"
+ ]
+ },
+ "prioritizeKeepingFunctionOutputTogether" : false,
+ "respectsExistingLineBreaks" : true,
+ "rules" : {
+ "AllPublicDeclarationsHaveDocumentation" : true,
+ "AlwaysUseLiteralForEmptyCollectionInit" : false,
+ "AlwaysUseLowerCamelCase" : true,
+ "AmbiguousTrailingClosureOverload" : true,
+ "BeginDocumentationCommentWithOneLineSummary" : false,
+ "DoNotUseSemicolons" : true,
+ "DontRepeatTypeInStaticProperties" : true,
+ "FileScopedDeclarationPrivacy" : true,
+ "FullyIndirectEnum" : true,
+ "GroupNumericLiterals" : true,
+ "IdentifiersMustBeASCII" : true,
+ "NeverForceUnwrap" : true,
+ "NeverUseForceTry" : true,
+ "NeverUseImplicitlyUnwrappedOptionals" : true,
+ "NoAccessLevelOnExtensionDeclaration" : true,
+ "NoAssignmentInExpressions" : true,
+ "NoBlockComments" : true,
+ "NoCasesWithOnlyFallthrough" : true,
+ "NoEmptyTrailingClosureParentheses" : true,
+ "NoLabelsInCasePatterns" : true,
+ "NoLeadingUnderscores" : true,
+ "NoParensAroundConditions" : true,
+ "NoPlaygroundLiterals" : true,
+ "NoVoidReturnOnFunctionSignature" : true,
+ "OmitExplicitReturns" : false,
+ "OneCasePerLine" : true,
+ "OneVariableDeclarationPerLine" : true,
+ "OnlyOneTrailingClosureArgument" : true,
+ "OrderedImports" : true,
+ "ReplaceForEachWithForLoop" : true,
+ "ReturnVoidInsteadOfEmptyTuple" : true,
+ "TypeNamesShouldBeCapitalized" : true,
+ "UseEarlyExits" : false,
+ "UseExplicitNilCheckInConditions" : true,
+ "UseLetInEveryBoundCaseVariable" : true,
+ "UseShorthandTypeNames" : true,
+ "UseSingleLinePropertyGetter" : true,
+ "UseSynthesizedInitializer" : true,
+ "UseTripleSlashForDocumentationComments" : true,
+ "UseWhereClausesInForLoops" : true,
+ "ValidateDocumentationComments" : true
+ },
+ "spacesAroundRangeFormationOperators" : false,
+ "tabWidth" : 2,
+ "version" : 1
+}
diff --git a/.swiftlint.yml b/.swiftlint.yml
new file mode 100644
index 0000000..326f480
--- /dev/null
+++ b/.swiftlint.yml
@@ -0,0 +1,133 @@
+opt_in_rules:
+ - array_init
+ - closure_body_length
+ - closure_end_indentation
+ - closure_spacing
+ - collection_alignment
+ - conditional_returns_on_newline
+ - contains_over_filter_count
+ - contains_over_filter_is_empty
+ - contains_over_first_not_nil
+ - contains_over_range_nil_comparison
+ - convenience_type
+ - discouraged_object_literal
+ - empty_collection_literal
+ - empty_count
+ - empty_string
+ - empty_xctest_method
+ - enum_case_associated_values_count
+ - expiring_todo
+ - explicit_acl
+ - explicit_init
+ - explicit_top_level_acl
+ # - fallthrough
+ - fatal_error_message
+ - file_name
+ - file_name_no_space
+ - file_types_order
+ - first_where
+ - flatmap_over_map_reduce
+ - force_unwrapping
+# - function_default_parameter_at_end
+ - ibinspectable_in_extension
+ - identical_operands
+ - implicit_return
+ - implicitly_unwrapped_optional
+ - indentation_width
+ - joined_default_parameter
+ - last_where
+ - legacy_multiple
+ - legacy_random
+ - literal_expression_end_indentation
+ - lower_acl_than_parent
+ - missing_docs
+ - modifier_order
+ - multiline_arguments
+ - multiline_arguments_brackets
+ - multiline_function_chains
+ - multiline_literal_brackets
+ - multiline_parameters
+ - nimble_operator
+ - nslocalizedstring_key
+ - nslocalizedstring_require_bundle
+ - number_separator
+ - object_literal
+ - operator_usage_whitespace
+ - optional_enum_case_matching
+ - overridden_super_call
+ - override_in_extension
+ - pattern_matching_keywords
+ - prefer_self_type_over_type_of_self
+ - prefer_zero_over_explicit_init
+ - private_action
+ - private_outlet
+ - prohibited_interface_builder
+ - prohibited_super_call
+ - quick_discouraged_call
+ - quick_discouraged_focused_test
+ - quick_discouraged_pending_test
+ - reduce_into
+ - redundant_nil_coalescing
+ - redundant_type_annotation
+ - required_enum_case
+ - single_test_class
+ - sorted_first_last
+ - sorted_imports
+ - static_operator
+ - strong_iboutlet
+ - toggle_bool
+# - trailing_closure
+ - type_contents_order
+ - unavailable_function
+ - unneeded_parentheses_in_closure_argument
+ - unowned_variable_capture
+ - untyped_error_in_catch
+ - vertical_parameter_alignment_on_call
+ - vertical_whitespace_closing_braces
+ - vertical_whitespace_opening_braces
+ - xct_specific_matcher
+ - yoda_condition
+analyzer_rules:
+ - unused_import
+ - unused_declaration
+cyclomatic_complexity:
+ - 6
+ - 12
+file_length:
+ warning: 225
+ error: 300
+function_body_length:
+ - 50
+ - 76
+function_parameter_count: 8
+line_length:
+ - 108
+ - 200
+closure_body_length:
+ - 50
+ - 60
+identifier_name:
+ excluded:
+ - id
+ - no
+excluded:
+ - DerivedData
+ - .build
+ - Mint
+ - Examples
+ - Sources/MistKit/Generated
+indentation_width:
+ indentation_width: 2
+file_name:
+ severity: error
+fatal_error_message:
+ severity: error
+disabled_rules:
+ - nesting
+ - implicit_getter
+ - switch_case_alignment
+ - closure_parameter_position
+ - trailing_comma
+ - opening_brace
+ - pattern_matching_keywords
+ - todo
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ec763d8
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,198 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Overview
+
+SundialKitStream is a modern Swift 6.1+ async/await observation plugin for SundialKit, providing actor-based observers with AsyncStream APIs for network monitoring and WatchConnectivity communication. The package prioritizes strict concurrency safety, avoiding `@unchecked Sendable` conformances in favor of proper actor isolation.
+
+## Development Commands
+
+### Building
+
+```bash
+# Build the package
+swift build
+
+# Build including tests
+swift build --build-tests
+
+# Run tests
+swift test
+```
+
+### Linting
+
+The project uses a comprehensive linting setup with SwiftLint, swift-format, and periphery (dead code detection):
+
+```bash
+# Run all linting and formatting
+./Scripts/lint.sh
+
+# Format only (skip linting checks)
+FORMAT_ONLY=1 ./Scripts/lint.sh
+
+# Strict mode (used in CI)
+LINT_MODE=STRICT ./Scripts/lint.sh
+```
+
+**Important**: Linting requires `mise` to be installed. The lint script uses mise to manage tool versions via `.mise.toml`.
+
+### Testing
+
+```bash
+# Run all tests
+swift test
+
+# Run a specific test target
+swift test --filter SundialKitStreamTests
+
+# Run a specific test class
+swift test --filter NetworkObserverTests
+
+# Run a specific test method
+swift test --filter NetworkObserverTests.testStreamUpdates
+```
+
+## Code Architecture
+
+### Layer Architecture
+
+SundialKitStream follows a three-layer architecture:
+
+1. **Core Protocols** (Dependencies from SundialKit package):
+ - `SundialKitCore` - Base protocols and types (`ActivationState`, `ConnectivityError`, etc.)
+ - `SundialKitNetwork` - Network monitoring protocols (`PathMonitor`, `NetworkPing`, `PathStatus`)
+ - `SundialKitConnectivity` - WatchConnectivity protocols (`ConnectivitySession`, `Messagable`)
+
+2. **Observer Layer** (This package - SundialKitStream):
+ - Actor-based observers: `NetworkObserver`, `ConnectivityObserver`
+ - AsyncStream-based state delivery
+ - Manages continuations and distributes updates to multiple subscribers
+
+### Core Observers
+
+#### NetworkObserver
+
+Actor-based network connectivity monitoring with AsyncStream APIs:
+
+- **Generic over**: `PathMonitor` (typically `NWPathMonitorAdapter`) and `NetworkPing`
+- **Streams**: `pathStatusStream`, `isExpensiveStream`, `isConstrainedStream`
+- **Thread Safety**: Actor isolation ensures safe concurrent access
+- **Location**: Sources/SundialKitStream/NetworkObserver.swift
+
+#### ConnectivityObserver
+
+Actor-based WatchConnectivity communication with AsyncStream APIs:
+
+- **Protocols**: Conforms to `ConnectivitySessionDelegate`, `StateHandling`, `MessageHandling`
+- **Key Components**:
+ - `ConnectivityStateManager` - Manages activation, reachability, and pairing state
+ - `StreamContinuationManager` - Centralized continuation management for all stream types
+ - `MessageDistributor` - Handles incoming message distribution to subscribers
+ - `MessageRouter` - Routes outgoing messages through appropriate transports
+- **Streams**: `activationStates()`, `activationCompletionStream()`, `reachabilityStream()`, `messageStream()`, `typedMessageStream()`
+- **Location**: Sources/SundialKitStream/ConnectivityObserver.swift
+
+### Key Architectural Patterns
+
+#### AsyncStream Continuation Management
+
+The codebase uses a centralized continuation management pattern via `StreamContinuationManager`:
+
+- Each stream type has its own continuation dictionary keyed by UUID
+- Registration asserts prevent duplicate continuations
+- Removal asserts catch programming errors
+- Yielding iterates all registered continuations for fan-out distribution
+- **Location**: Sources/SundialKitStream/StreamContinuationManager.swift
+
+#### State Management
+
+`ConnectivityStateManager` coordinates state updates with stream notifications:
+
+- Maintains `ConnectivityState` with activation, reachability, and pairing information
+- Synchronizes state updates with continuation notifications
+- Provides read-only snapshot accessors for current state
+- **Location**: Sources/SundialKitStream/ConnectivityStateManager.swift
+
+#### Message Handling
+
+Message flow uses a routing and distribution pattern:
+
+- `MessageRouter` - Selects appropriate transport (interactive message, application context, etc.)
+- `MessageDispatcher` - Transforms protocol-level callbacks into async operations
+- `MessageDistributor` - Distributes received messages to all active stream subscribers
+- Supports both untyped (`[String: any Sendable]`) and typed (`Messagable`) messages
+
+## Swift Version and Compiler Settings
+
+This package requires **Swift 6.1+** and enables extensive experimental features:
+
+- **Swift 6.2 Upcoming Features**: ExistentialAny, InternalImportsByDefault, MemberImportVisibility, FullTypedThrows
+- **Experimental Features**: BitwiseCopyable, BorrowingSwitch, NoncopyableGenerics, TransferringArgsAndResults, VariadicGenerics, and many more (see Package.swift:8-34)
+- **Strict Concurrency**: The project operates in Swift 6 strict concurrency mode
+
+When writing new code, ensure:
+- All public types properly declare their concurrency characteristics (`actor`, `@MainActor`, `Sendable`)
+- No `@unchecked Sendable` conformances are added
+- AsyncStream continuations are managed through `StreamContinuationManager`
+
+## File Organization
+
+Source files use functional organization with extensions:
+
+- Main type definition: `TypeName.swift`
+- Extensions by functionality: `TypeName+Functionality.swift`
+- Example: `ConnectivityObserver.swift`, `ConnectivityObserver+Lifecycle.swift`, `ConnectivityObserver+Messaging.swift`, `ConnectivityObserver+Streams.swift`
+
+Tests follow a similar pattern with hierarchical organization:
+
+- Test suites: `TypeName.swift`, `TypeName+Category.swift`
+- Individual test files: `TypeName.Category.SpecificTests.swift`
+- Example: `ConnectivityStateManager.swift`, `ConnectivityStateManager.State.swift`, `ConnectivityStateManager.State.UpdateTests.swift`
+
+## Code Style and Linting
+
+The project enforces strict code quality standards:
+
+- **File Length**: Warning at 225 lines, error at 300 lines
+- **Function Body Length**: Warning at 50 lines, error at 76 lines
+- **Line Length**: Warning at 108 characters, error at 200 characters
+- **Cyclomatic Complexity**: Warning at 6, error at 12
+- **Indentation**: 2 spaces (configured in .swiftlint.yml:120)
+- **Access Control**: Explicit access levels required (`explicit_acl`, `explicit_top_level_acl`)
+- **File Headers**: All source files require proper copyright headers (enforced by Scripts/header.sh)
+
+Disabled rules:
+- `nesting`, `implicit_getter`, `switch_case_alignment`, `closure_parameter_position`, `trailing_comma`, `opening_brace`, `pattern_matching_keywords`, `todo`
+
+## Dependencies
+
+This package depends on SundialKit v2.0.0+ which provides three products:
+
+- `SundialKitCore` - Core protocols and types
+- `SundialKitNetwork` - Network monitoring abstractions over Apple's Network framework
+- `SundialKitConnectivity` - WatchConnectivity abstractions
+
+## Platform Support
+
+- iOS 16+
+- watchOS 9+
+- tvOS 16+
+- macOS 13+
+
+## Testing Patterns
+
+Tests use mock implementations for protocol-based abstractions:
+
+- `MockPathMonitor` - Simulates network path changes
+- `MockNetworkPing` - Simulates ping operations
+- `MockConnectivitySession` - Simulates WatchConnectivity behavior
+- `TestValueCapture` - Captures async stream values for testing
+
+When writing tests:
+- Use actor-isolated test methods when testing actors
+- Capture stream values with async task groups or `TestValueCapture`
+- Test both success and error paths for `Result`-based streams (e.g., `activationCompletionStream()`)
+- In order to run the builds and tests for iOS or watchOS, use xcodebuild.
+- Don't use swift package generate-xcodeproj
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..639fbd5
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 BrightDigit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..6a0c3f6
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "bb26fa541c8043161a229c70d629895a66f222ad1353a6f5a22506d5b8fa4241",
+ "pins" : [
+ {
+ "identity" : "sundialkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/brightdigit/SundialKit.git",
+ "state" : {
+ "revision" : "ff0e3f28e61107d26405c05ec1fa9637dbce05ed",
+ "version" : "2.0.0-alpha.1"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..5b09d31
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,81 @@
+// swift-tools-version: 6.1
+// swiftlint:disable explicit_acl explicit_top_level_acl
+
+import PackageDescription
+
+// MARK: - Swift Settings Configuration
+
+let swiftSettings: [SwiftSetting] = [
+ // Swift 6.2 Upcoming Features
+ .enableUpcomingFeature("ExistentialAny"),
+ .enableUpcomingFeature("InternalImportsByDefault"),
+ .enableUpcomingFeature("MemberImportVisibility"),
+ .enableUpcomingFeature("FullTypedThrows"),
+
+ // Experimental Features
+ .enableExperimentalFeature("BitwiseCopyable"),
+ .enableExperimentalFeature("BorrowingSwitch"),
+ .enableExperimentalFeature("ExtensionMacros"),
+ .enableExperimentalFeature("FreestandingExpressionMacros"),
+ .enableExperimentalFeature("InitAccessors"),
+ .enableExperimentalFeature("IsolatedAny"),
+ .enableExperimentalFeature("MoveOnlyClasses"),
+ .enableExperimentalFeature("MoveOnlyEnumDeinits"),
+ .enableExperimentalFeature("MoveOnlyPartialConsumption"),
+ .enableExperimentalFeature("MoveOnlyResilientTypes"),
+ .enableExperimentalFeature("MoveOnlyTuples"),
+ .enableExperimentalFeature("NoncopyableGenerics"),
+ .enableExperimentalFeature("RawLayout"),
+ .enableExperimentalFeature("ReferenceBindings"),
+ .enableExperimentalFeature("SendingArgsAndResults"),
+ .enableExperimentalFeature("SymbolLinkageMarkers"),
+ .enableExperimentalFeature("TransferringArgsAndResults"),
+ .enableExperimentalFeature("VariadicGenerics"),
+ .enableExperimentalFeature("WarnUnsafeReflection")
+
+ // Enhanced compiler checking
+ // .unsafeFlags([
+ // "-warn-concurrency",
+ // "-enable-actor-data-race-checks",
+ // "-strict-concurrency=complete",
+ // "-enable-testing",
+ // "-Xfrontend", "-warn-long-function-bodies=100",
+ // "-Xfrontend", "-warn-long-expression-type-checking=100"
+ // ])
+]
+
+let package = Package(
+ name: "SundialKitStream",
+ platforms: [
+ .iOS(.v16),
+ .watchOS(.v9),
+ .tvOS(.v16),
+ .macOS(.v13)
+ ],
+ products: [
+ .library(
+ name: "SundialKitStream",
+ targets: ["SundialKitStream"]
+ )
+ ],
+ dependencies: [
+ .package(url: "https://github.com/brightdigit/SundialKit.git", from: "2.0.0-alpha.1")
+ ],
+ targets: [
+ .target(
+ name: "SundialKitStream",
+ dependencies: [
+ .product(name: "SundialKitCore", package: "SundialKit"),
+ .product(name: "SundialKitNetwork", package: "SundialKit"),
+ .product(name: "SundialKitConnectivity", package: "SundialKit")
+ ],
+ swiftSettings: swiftSettings
+ ),
+ .testTarget(
+ name: "SundialKitStreamTests",
+ dependencies: ["SundialKitStream"],
+ swiftSettings: swiftSettings
+ )
+ ]
+)
+// swiftlint:enable explicit_acl explicit_top_level_acl
diff --git a/README.md b/README.md
index f49da47..a9097d3 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,416 @@
# SundialKitStream
-Modern async/await plugin for SundialKit providing actor-based AsyncStream publishers
+
+
+
+
+
+Modern async/await observation plugin for SundialKit with actor-based concurrency safety.
+
+[](https://swift.org)
+[](http://twitter.com/brightdigit)
+
+
+## Table of Contents
+
+* [Overview](#overview)
+* [Why Choose SundialKitStream](#why-choose-sundialkitstream)
+* [Key Features](#key-features)
+* [Requirements](#requirements)
+* [Installation](#installation)
+* [Usage](#usage)
+ * [Network Monitoring](#network-monitoring)
+ * [WatchConnectivity Communication](#watchconnectivity-communication)
+ * [Type-Safe Messaging with Messagable](#type-safe-messaging-with-messagable)
+* [Architecture](#architecture)
+* [Comparison with SundialKitCombine](#comparison-with-sundialkitcombine)
+* [Documentation](#documentation)
+* [Related Packages](#related-packages)
+* [License](#license)
+
+## Overview
+
+**SundialKitStream** provides actor-based observers that deliver state updates via AsyncStream APIs. This plugin is designed for Swift 6.1+ projects using modern concurrency patterns, offering natural thread safety through Swift's actor isolation model and seamless integration with async/await code.
+
+**SundialKitCombine** is a part of **[SundialKit](https://github.com/brightdigit/SundialKit)** - a reactive communications library for Apple platforms.
+
+## Why Choose SundialKitStream
+
+If you're building a modern Swift application that embraces async/await and structured concurrency, SundialKitStream is the ideal choice. It leverages Swift's actor isolation to provide thread-safe state management without locks, mutexes, or manual synchronization. The AsyncStream-based APIs integrate naturally with async/await code, making it easy to consume network and connectivity updates in Task contexts.
+
+**Choose SundialKitStream when you:**
+- Want to use modern async/await patterns throughout your app
+- Need actor-based thread safety without @unchecked Sendable
+- Prefer consuming updates with `for await` loops
+- Target iOS 16+ / watchOS 9+ / tvOS 16+ / macOS 13+
+- Value compile-time concurrency safety with Swift 6.1 strict mode
+
+## Key Features
+
+- **Actor Isolation**: Natural thread safety without locks or manual synchronization
+- **AsyncStream APIs**: Consume state updates with `for await` loops in async contexts
+- **Swift 6.1 Strict Concurrency**: Zero `@unchecked Sendable` conformances - everything is properly isolated
+- **Composable**: Works seamlessly with SundialKitNetwork and SundialKitConnectivity
+- **Structured Concurrency**: AsyncStreams integrate naturally with Task hierarchies and cancellation
+
+## Requirements
+
+- **Swift**: 6.1+
+- **Xcode**: 16.0+
+- **Platforms**:
+ - iOS 16+
+ - watchOS 9+
+ - tvOS 16+
+ - macOS 13+
+
+## Installation
+
+Add SundialKitStream to your `Package.swift`:
+
+```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"), // For network monitoring
+ .product(name: "SundialKitConnectivity", package: "SundialKit") // For WatchConnectivity
+ ]
+ )
+ ]
+)
+```
+
+## Usage
+
+### Network Monitoring
+
+Monitor network connectivity changes using the actor-based `NetworkObserver`. The observer tracks network path status, connection quality (expensive, constrained), and optionally performs periodic connectivity verification with custom ping implementations.
+
+#### Basic Network Monitoring
+
+```swift
+import SundialKitStream
+import SundialKitNetwork
+import SwiftUI
+
+@MainActor
+@Observable
+class NetworkModel {
+ var pathStatus: PathStatus = .unknown
+ var isExpensive: Bool = false
+ var isConstrained: Bool = false
+
+ private let observer = NetworkObserver(
+ monitor: NWPathMonitorAdapter(),
+ ping: nil
+ )
+
+ func start() {
+ observer.start(queue: .global())
+
+ // Listen to path status updates
+ 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
+ }
+ }
+ }
+}
+
+// Use in SwiftUI
+struct NetworkView: View {
+ @State private var model = NetworkModel()
+
+ 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()
+ }
+ }
+}
+```
+
+The `NWPathMonitorAdapter` wraps Apple's `NWPathMonitor` from the Network framework, providing updates whenever the network path changes (WiFi connects/disconnects, cellular becomes available, etc.).
+
+#### Understanding PathStatus
+
+The `PathStatus` enum represents the current state of the network path:
+
+- **`.satisfied`** - Network is available and ready to use
+- **`.unsatisfied`** - No network connectivity
+- **`.requiresConnection`** - Network may be available but requires user action (e.g., connecting to WiFi)
+- **`.unknown`** - Initial state before first update
+
+#### Monitoring Connection Quality
+
+Beyond basic connectivity, you can track whether the current network connection is expensive (cellular data) or constrained (low data mode):
+
+```swift
+// Monitor all quality indicators
+Task {
+ for await isExpensive in observer.isExpensiveStream {
+ if isExpensive {
+ // User is on cellular data - consider reducing data usage
+ print("Warning: Using cellular data")
+ }
+ }
+}
+
+Task {
+ for await isConstrained in observer.isConstrainedStream {
+ if isConstrained {
+ // User has Low Data Mode enabled - minimize data usage
+ print("Low Data Mode active")
+ }
+ }
+}
+```
+
+This information helps you build adaptive applications that respect users' data plans and preferences.
+
+### WatchConnectivity Communication
+
+Communicate between iPhone and Apple Watch using the actor-based `ConnectivityObserver`. The observer manages the WatchConnectivity session lifecycle, handles automatic transport selection, and provides type-safe messaging through AsyncStream APIs.
+
+#### Session Activation
+
+Before sending or receiving messages, you must activate the WatchConnectivity session:
+
+```swift
+import SundialKitStream
+import SundialKitConnectivity
+
+actor WatchCommunicator {
+ private let observer = ConnectivityObserver()
+
+ func activate() async throws {
+ try await observer.activate()
+ }
+
+ func listenForMessages() async {
+ for await result in observer.messageStream() {
+ switch result.context {
+ case .replyWith(let handler):
+ print("Received: \(result.message)")
+ handler(["response": "acknowledged"])
+ case .applicationContext:
+ print("Context update: \(result.message)")
+ }
+ }
+ }
+
+ func sendMessage(_ message: ConnectivityMessage) async throws -> ConnectivitySendResult {
+ try await observer.sendMessage(message)
+ }
+}
+```
+
+The `activate()` method initializes the WatchConnectivity session and waits for it to become ready. Once activated, you can send and receive messages.
+
+#### Message Contexts
+
+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.
+
+This distinction helps you build responsive communication patterns - interactive messages for user-initiated actions, context updates for background state synchronization.
+
+#### SwiftUI Integration with WatchConnectivity
+
+Use the connectivity observer in SwiftUI with the `@Observable` macro:
+
+```swift
+import SwiftUI
+import SundialKitStream
+import SundialKitConnectivity
+
+@MainActor
+@Observable
+class WatchModel {
+ var activationState: ActivationState = .notActivated
+ var isReachable: Bool = false
+ var lastMessage: String = ""
+
+ private let observer = ConnectivityObserver()
+
+ func start() async throws {
+ 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
+ }
+ }
+
+ // Listen for messages
+ Task {
+ for await result in observer.messageStream() {
+ if let text = result.message["text"] as? String {
+ self.lastMessage = text
+ }
+ }
+ }
+ }
+
+ func sendMessage(_ text: String) async throws {
+ let result = try await observer.sendMessage(["text": text])
+ print("Sent via: \(result.context)")
+ }
+}
+
+struct WatchView: View {
+ @State private var model = WatchModel()
+ @State private var messageText = ""
+
+ var body: some View {
+ VStack {
+ Text("Session: \(model.activationState.description)")
+ Text("Reachable: \(model.isReachable ? "Yes" : "No")")
+
+ TextField("Message", text: $messageText)
+
+ Button("Send") {
+ Task {
+ try? await model.sendMessage(messageText)
+ }
+ }
+ .disabled(!model.isReachable)
+
+ Text("Last message: \(model.lastMessage)")
+ }
+ .task {
+ try? await model.start()
+ }
+ }
+}
+```
+
+### Type-Safe Messaging with Messagable
+
+For type-safe messaging, use the `Messagable` protocol with `MessageDecoder`:
+
+```swift
+import SundialKitConnectivity
+
+// Define a typed message
+struct ColorMessage: Messagable {
+ let red: Double
+ let green: Double
+ let blue: Double
+
+ static let key = "color"
+
+ init(from parameters: [String: any Sendable]) throws {
+ guard let red = parameters["red"] as? Double,
+ let green = parameters["green"] as? Double,
+ let blue = parameters["blue"] as? Double else {
+ throw SerializationError.missingField("color components")
+ }
+ self.red = red
+ self.green = green
+ self.blue = blue
+ }
+
+ func parameters() -> [String: any Sendable] {
+ ["red": red, "green": green, "blue": blue]
+ }
+
+ init(red: Double, green: Double, blue: Double) {
+ self.red = red
+ self.green = green
+ self.blue = blue
+ }
+}
+
+// Configure observer with MessageDecoder
+actor WatchCommunicator {
+ private let observer = ConnectivityObserver(
+ messageDecoder: MessageDecoder(messagableTypes: [ColorMessage.self])
+ )
+
+ func listenForColorMessages() async throws {
+ for await message in observer.typedMessageStream() {
+ if let colorMsg = message as? ColorMessage {
+ print("Received color: RGB(\(colorMsg.red), \(colorMsg.green), \(colorMsg.blue))")
+ }
+ }
+ }
+
+ func sendColor(_ color: ColorMessage) async throws {
+ let result = try await observer.send(color)
+ print("Color sent via: \(result.context)")
+ }
+}
+```
+
+## Architecture
+
+SundialKitStream is part of SundialKit's three-layer architecture:
+
+**Layer 1: Core Protocols** (SundialKitCore, SundialKitNetwork, SundialKitConnectivity)
+- Protocol-based abstractions over Apple's Network and WatchConnectivity frameworks
+- No observer patterns - pure wrappers
+
+**Layer 2: Observation Plugin** (SundialKitStream - this package)
+- Actor-based observers with AsyncStream APIs
+- Modern async/await patterns
+- Natural Sendable conformance without @unchecked
+
+## Comparison with SundialKitCombine
+
+| Feature | SundialKitStream | SundialKitCombine |
+|---------|------------------|-------------------|
+| **Concurrency Model** | Actor-based | @MainActor-based |
+| **State Updates** | AsyncStream | @Published properties |
+| **Thread Safety** | Actor isolation | @MainActor isolation |
+| **Platform Support** | iOS 16+, watchOS 9+, tvOS 16+, macOS 13+ | iOS 13+, watchOS 6+, tvOS 13+, macOS 10.15+ |
+| **Use Case** | Modern async/await apps | Combine-based apps, SwiftUI with ObservableObject |
+
+## Documentation
+
+For comprehensive documentation, see:
+- [SundialKitStream Documentation](https://swiftpackageindex.com/brightdigit/SundialKitStream/documentation)
+- [SundialKit Main Documentation](https://swiftpackageindex.com/brightdigit/SundialKit/documentation)
+
+## Related Packages
+
+- **[SundialKit](https://github.com/brightdigit/SundialKit)** - Main package with core protocols and implementations
+- **[SundialKitCombine](https://github.com/brightdigit/SundialKitCombine)** - Combine-based plugin
+
+## License
+
+This code is distributed under the MIT license. See the [LICENSE](https://github.com/brightdigit/SundialKitStream/LICENSE) file for more info.
\ No newline at end of file
diff --git a/Scripts/ensure-remote-deps.sh b/Scripts/ensure-remote-deps.sh
new file mode 100755
index 0000000..062430a
--- /dev/null
+++ b/Scripts/ensure-remote-deps.sh
@@ -0,0 +1,43 @@
+#!/bin/bash
+# Ensure Package.swift uses remote SundialKit dependency
+# Used in CI to guarantee remote URLs are configured
+
+set -euo pipefail
+
+REMOTE_URL="https://github.com/brightdigit/SundialKit.git"
+REMOTE_BRANCH="branch: \"48-demo-applications-part-3\""
+LOCAL_PATH="../../"
+
+PACKAGE_FILE="Package.swift"
+
+if [[ ! -f "$PACKAGE_FILE" ]]; then
+ echo "❌ Package.swift not found"
+ exit 1
+fi
+
+# Check if already using remote URL
+if grep -q "\.package(name: \"SundialKit\", url: \"$REMOTE_URL\"" "$PACKAGE_FILE"; then
+ echo "✅ Already using remote dependency"
+ exit 0
+fi
+
+# Switch from local to remote
+if grep -q "\.package(name: \"SundialKit\", path:" "$PACKAGE_FILE"; then
+ echo "🔄 Switching to remote dependency..."
+ # Cross-platform sed: use -i with empty string on macOS, without on Linux
+ # Use multiline format to avoid SwiftLint line length warnings
+ REPLACEMENT=".package(\n name: \"SundialKit\",\n url: \"$REMOTE_URL\",\n $REMOTE_BRANCH\n )"
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ sed -i '' \
+ -e 's|\.package(name: "SundialKit", path: "'"$LOCAL_PATH"'")|'"$REPLACEMENT"'|g' \
+ "$PACKAGE_FILE"
+ else
+ sed -i \
+ -e 's|\.package(name: "SundialKit", path: "'"$LOCAL_PATH"'")|'"$REPLACEMENT"'|g' \
+ "$PACKAGE_FILE"
+ fi
+ echo "✅ Switched to remote dependency"
+else
+ echo "⚠️ Unknown dependency format in Package.swift"
+ exit 1
+fi
diff --git a/Scripts/header.sh b/Scripts/header.sh
new file mode 100755
index 0000000..c571c18
--- /dev/null
+++ b/Scripts/header.sh
@@ -0,0 +1,104 @@
+#!/bin/bash
+
+# Function to print usage
+usage() {
+ echo "Usage: $0 -d directory -c creator -o company -p package [-y year]"
+ echo " -d directory Directory to read from (including subdirectories)"
+ echo " -c creator Name of the creator"
+ echo " -o company Name of the company with the copyright"
+ echo " -p package Package or library name"
+ echo " -y year Copyright year (optional, defaults to current year)"
+ exit 1
+}
+
+# Get the current year if not provided
+current_year=$(date +"%Y")
+
+# Default values
+year="$current_year"
+
+# Parse arguments
+while getopts ":d:c:o:p:y:" opt; do
+ case $opt in
+ d) directory="$OPTARG" ;;
+ c) creator="$OPTARG" ;;
+ o) company="$OPTARG" ;;
+ p) package="$OPTARG" ;;
+ y) year="$OPTARG" ;;
+ *) usage ;;
+ esac
+done
+
+# Check for mandatory arguments
+if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then
+ usage
+fi
+
+# Define the header template
+header_template="//
+// %s
+// %s
+//
+// Created by %s.
+// Copyright © %s %s.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the \"Software\"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//"
+
+# Loop through each Swift file in the specified directory and subdirectories
+find "$directory" -type f -name "*.swift" | while read -r file; do
+ # Skip files in the Generated directory
+ if [[ "$file" == *"/Generated/"* ]]; then
+ echo "Skipping $file (generated file)"
+ continue
+ fi
+
+ # Check if the first line is the swift-format-ignore indicator
+ first_line=$(head -n 1 "$file")
+ if [[ "$first_line" == "// swift-format-ignore-file" ]]; then
+ echo "Skipping $file due to swift-format-ignore directive."
+ continue
+ fi
+
+ # Create the header with the current filename
+ filename=$(basename "$file")
+ header=$(printf "$header_template" "$filename" "$package" "$creator" "$year" "$company")
+
+ # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//"
+ awk '
+ BEGIN { skip = 1 }
+ {
+ if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) {
+ next
+ }
+ skip = 0
+ print
+ }' "$file" > temp_file
+
+ # Add the header to the cleaned file
+ (echo "$header"; echo; cat temp_file) > "$file"
+
+ # Remove the temporary file
+ rm temp_file
+done
+
+echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories."
diff --git a/Scripts/lint.sh b/Scripts/lint.sh
new file mode 100755
index 0000000..70feef6
--- /dev/null
+++ b/Scripts/lint.sh
@@ -0,0 +1,102 @@
+#!/bin/bash
+
+# Remove set -e to allow script to continue running
+# set -e # Exit on any error
+
+ERRORS=0
+
+run_command() {
+ if [ "$LINT_MODE" = "STRICT" ]; then
+ "$@" || ERRORS=$((ERRORS + 1))
+ else
+ "$@"
+ fi
+}
+
+if [ "$LINT_MODE" = "INSTALL" ]; then
+ exit
+fi
+
+echo "LintMode: $LINT_MODE"
+
+# More portable way to get script directory
+if [ -z "$SRCROOT" ]; then
+ SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
+ PACKAGE_DIR="${SCRIPT_DIR}/.."
+else
+ PACKAGE_DIR="${SRCROOT}"
+fi
+
+# Detect if mise is available
+# Check common installation paths for mise
+MISE_PATHS=(
+ "/opt/homebrew/bin/mise"
+ "/usr/local/bin/mise"
+ "$HOME/.local/bin/mise"
+)
+
+MISE_BIN=""
+for mise_path in "${MISE_PATHS[@]}"; do
+ if [ -x "$mise_path" ]; then
+ MISE_BIN="$mise_path"
+ break
+ fi
+done
+
+# Fallback to PATH lookup
+if [ -z "$MISE_BIN" ] && command -v mise &> /dev/null; then
+ MISE_BIN="mise"
+fi
+
+if [ -n "$MISE_BIN" ]; then
+ TOOL_CMD="$MISE_BIN exec --"
+else
+ echo "Error: mise is not installed"
+ echo "Install mise: https://mise.jdx.dev/getting-started.html"
+ echo "Checked paths: ${MISE_PATHS[*]}"
+ exit 1
+fi
+
+if [ "$LINT_MODE" = "NONE" ]; then
+ exit
+elif [ "$LINT_MODE" = "STRICT" ]; then
+ SWIFTFORMAT_OPTIONS="--configuration .swift-format"
+ SWIFTLINT_OPTIONS="--strict"
+else
+ SWIFTFORMAT_OPTIONS="--configuration .swift-format"
+ SWIFTLINT_OPTIONS=""
+fi
+
+pushd $PACKAGE_DIR
+
+# Bootstrap tools (mise will install based on .mise.toml)
+run_command "$MISE_BIN" install
+
+if [ -z "$CI" ]; then
+ run_command $TOOL_CMD swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests
+ run_command $TOOL_CMD swiftlint --fix
+fi
+
+if [ -z "$FORMAT_ONLY" ]; then
+ run_command $TOOL_CMD swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests
+ run_command $TOOL_CMD swiftlint lint $SWIFTLINT_OPTIONS
+ # Check for compilation errors
+ run_command swift build --build-tests
+fi
+
+$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "SundialKitStream"
+
+if [ -z "$CI" ]; then
+ run_command $TOOL_CMD periphery scan $PERIPHERY_OPTIONS --disable-update-check
+fi
+
+popd
+
+# Exit with error code if any errors occurred
+if [ $ERRORS -gt 0 ]; then
+ echo "Linting completed with $ERRORS error(s)"
+ exit 1
+else
+ echo "Linting completed successfully"
+ exit 0
+fi
diff --git a/Scripts/preview-docs.sh b/Scripts/preview-docs.sh
new file mode 100755
index 0000000..2db2303
--- /dev/null
+++ b/Scripts/preview-docs.sh
@@ -0,0 +1,331 @@
+#!/bin/bash
+# preview-docs.sh
+# DocC documentation preview with auto-rebuild
+#
+# Copyright (c) 2025 BrightDigit, LLC
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Default configuration
+DEFAULT_PORT=8080
+CATALOG_PATH=""
+PORT=$DEFAULT_PORT
+NO_WATCH=false
+NO_SERVER=false
+CLEAN=false
+WATCH_PID=""
+SERVER_PID=""
+
+# Build output directories
+SYMBOL_GRAPH_DIR=".build/symbol-graphs"
+
+# Usage information
+usage() {
+ cat < [OPTIONS]
+
+DocC documentation preview with auto-rebuild on file changes.
+
+Arguments:
+ Path to .docc catalog directory
+ Example: Sources/SundialKit/SundialKit.docc
+
+Options:
+ --port Preview server port (default: 8080)
+ --no-watch Build once, don't watch for changes
+ --no-server Build only, don't start preview server
+ --clean Clean build artifacts before building
+ --help Show this help message
+
+Examples:
+ $(basename "$0") Sources/SundialKit/SundialKit.docc
+ $(basename "$0") Sources/SundialKit/SundialKit.docc --port 8081
+ $(basename "$0") Sources/SundialKit/SundialKit.docc --no-watch
+
+Note: This script requires fswatch for auto-rebuild functionality.
+Install with: brew install fswatch
+EOF
+}
+
+# Parse command-line arguments
+# First positional argument is catalog path
+if [[ $# -eq 0 ]] || [[ "$1" == "--help" ]]; then
+ usage
+ exit 0
+fi
+
+# Check if first argument is a flag
+if [[ "$1" == --* ]]; then
+ echo -e "${RED}Error: First argument must be the catalog path${NC}"
+ usage
+ exit 1
+fi
+
+CATALOG_PATH="$1"
+shift
+
+# Parse remaining options
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --port)
+ PORT="$2"
+ shift 2
+ ;;
+ --no-watch)
+ NO_WATCH=true
+ shift
+ ;;
+ --no-server)
+ NO_SERVER=true
+ shift
+ ;;
+ --clean)
+ CLEAN=true
+ shift
+ ;;
+ --help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo -e "${RED}Error: Unknown option: $1${NC}"
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+# Validate catalog path exists
+if [ -z "$CATALOG_PATH" ]; then
+ echo -e "${RED}Error: Catalog path is required${NC}"
+ usage
+ exit 1
+fi
+
+if [ ! -d "$CATALOG_PATH" ]; then
+ echo -e "${RED}Error: Catalog directory not found: $CATALOG_PATH${NC}"
+ exit 1
+fi
+
+# Extract catalog name for output
+CATALOG_NAME=$(basename "$CATALOG_PATH" .docc)
+
+# Cleanup function
+cleanup() {
+ echo ""
+ echo -e "${YELLOW}Shutting down...${NC}"
+
+ if [ -n "$WATCH_PID" ]; then
+ kill "$WATCH_PID" 2>/dev/null || true
+ fi
+
+ if [ -n "$SERVER_PID" ]; then
+ kill "$SERVER_PID" 2>/dev/null || true
+ fi
+
+ # Kill any background jobs
+ jobs -p | xargs -r kill 2>/dev/null || true
+
+ echo -e "${GREEN}Cleanup complete${NC}"
+ exit 0
+}
+
+# Register cleanup on exit
+trap cleanup EXIT INT TERM
+
+# Clean build artifacts if requested
+if [ "$CLEAN" = true ]; then
+ echo -e "${BLUE}Cleaning build artifacts...${NC}"
+ rm -rf "$SYMBOL_GRAPH_DIR" .build/docc .build/docs
+fi
+
+# Build and extract symbol graphs
+build_symbols() {
+ echo ""
+ echo -e "${BLUE}========================================${NC}"
+ echo -e "${BLUE}Preparing documentation for $CATALOG_NAME${NC}"
+ echo -e "${BLUE}========================================${NC}"
+
+ # Step 1: Build all targets
+ echo -e "${YELLOW}Building Swift targets...${NC}"
+ if ! swift build 2>&1 | grep -E "(Building|Build complete|error:|warning:)"; then
+ echo -e "${RED}Error: Swift build failed${NC}"
+ return 1
+ fi
+ echo -e "${GREEN}✓ Build complete${NC}"
+
+ # Step 2: Extract symbol graphs
+ echo -e "${YELLOW}Extracting symbol graphs...${NC}"
+
+ # Use swift package dump-symbol-graph (writes to .build//symbolgraph/)
+ if swift package dump-symbol-graph 2>&1 | grep -q "Emitting symbol graph"; then
+ # Find the symbolgraph directory (architecture-specific)
+ BUILT_SYMBOL_DIR=$(find .build -type d -name "symbolgraph" 2>/dev/null | head -1)
+
+ if [ -n "$BUILT_SYMBOL_DIR" ] && [ -d "$BUILT_SYMBOL_DIR" ]; then
+ # Use the built directory directly instead of copying
+ SYMBOL_GRAPH_DIR="$BUILT_SYMBOL_DIR"
+ echo -e "${GREEN}✓ Symbol graphs extracted to $SYMBOL_GRAPH_DIR${NC}"
+ else
+ echo -e "${YELLOW} Warning: No symbol graphs found. Documentation will only include catalog content.${NC}"
+ SYMBOL_GRAPH_DIR=""
+ fi
+ else
+ echo -e "${YELLOW} Warning: Symbol graph extraction failed. Documentation will only include catalog content.${NC}"
+ SYMBOL_GRAPH_DIR=""
+ fi
+
+ echo -e "${BLUE}========================================${NC}"
+ return 0
+}
+
+# Build symbols initially (only if not using docc preview's built-in watch)
+if [ "$NO_SERVER" = true ]; then
+ # For no-server mode, we need to build symbols and run docc convert
+ if ! build_symbols; then
+ echo -e "${RED}Symbol graph generation failed${NC}"
+ exit 1
+ fi
+
+ echo -e "${YELLOW}Converting to DocC archive...${NC}"
+
+ # Build docc convert command
+ DOCC_CMD=(xcrun docc convert "$CATALOG_PATH"
+ --fallback-display-name "$CATALOG_NAME"
+ --fallback-bundle-identifier "com.brightdigit.$(echo "$CATALOG_NAME" | tr '[:upper:]' '[:lower:]')"
+ --fallback-bundle-version "2.0.0"
+ --output-path ".build/docs/$CATALOG_NAME.doccarchive")
+
+ # Add symbol graphs if available
+ if [ -n "$(ls -A "$SYMBOL_GRAPH_DIR" 2>/dev/null)" ]; then
+ DOCC_CMD+=(--additional-symbol-graph-dir "$SYMBOL_GRAPH_DIR")
+ fi
+
+ if ! "${DOCC_CMD[@]}"; then
+ echo -e "${RED}Error: DocC conversion failed${NC}"
+ exit 1
+ fi
+
+ echo -e "${GREEN}✓ DocC archive created at .build/docs/$CATALOG_NAME.doccarchive${NC}"
+ echo -e "${BLUE}========================================${NC}"
+ exit 0
+fi
+
+# For server mode, build symbols first
+if ! build_symbols; then
+ echo -e "${RED}Symbol graph generation failed${NC}"
+ exit 1
+fi
+
+# Start preview server using docc preview
+echo ""
+echo -e "${BLUE}Starting documentation preview server...${NC}"
+
+# Build docc preview command
+DOCC_PREVIEW_CMD=(xcrun docc preview "$CATALOG_PATH"
+ --port "$PORT"
+ --fallback-display-name "$CATALOG_NAME"
+ --fallback-bundle-identifier "com.brightdigit.$(echo "$CATALOG_NAME" | tr '[:upper:]' '[:lower:]')"
+ --fallback-bundle-version "2.0.0")
+
+# Add symbol graphs if available
+if [ -n "$(ls -A "$SYMBOL_GRAPH_DIR" 2>/dev/null)" ]; then
+ DOCC_PREVIEW_CMD+=(--additional-symbol-graph-dir "$SYMBOL_GRAPH_DIR")
+fi
+
+# Start docc preview in background (it has its own watch mode if --no-watch is not set)
+"${DOCC_PREVIEW_CMD[@]}" &
+SERVER_PID=$!
+
+# Wait for server to start
+sleep 3
+
+# Check if server is still running
+if ! kill -0 "$SERVER_PID" 2>/dev/null; then
+ echo -e "${RED}Error: Preview server failed to start${NC}"
+ echo -e "${YELLOW}Check the output above for errors${NC}"
+ exit 1
+fi
+
+echo -e "${GREEN}✓ Preview server running${NC}"
+echo ""
+echo -e "${GREEN}========================================${NC}"
+echo -e "${GREEN}📚 Documentation available at:${NC}"
+echo -e "${BLUE} http://localhost:$PORT/documentation/$(echo "$CATALOG_NAME" | tr '[:upper:]' '[:lower:]')${NC}"
+echo -e "${GREEN}========================================${NC}"
+
+# If --no-watch, docc preview will still watch the catalog
+# For file watching of Swift source files, we add our own watcher
+if [ "$NO_WATCH" = false ]; then
+ # Check if fswatch is installed
+ if ! command -v fswatch &> /dev/null; then
+ echo ""
+ echo -e "${YELLOW}Note: fswatch not found. Source file watching disabled.${NC}"
+ echo -e "${YELLOW}Install with: brew install fswatch for auto-rebuild on source changes${NC}"
+ echo -e "${BLUE}DocC will still watch the catalog for changes.${NC}"
+ echo ""
+ echo -e "${BLUE}Press Ctrl+C to stop the preview server${NC}"
+ wait $SERVER_PID
+ else
+ echo ""
+ echo -e "${BLUE}Watching source files for changes...${NC}"
+ echo -e "${YELLOW}(Press Ctrl+C to stop)${NC}"
+ echo ""
+
+ # Watch Sources and Packages directories for Swift changes
+ WATCH_PATHS=()
+
+ if [ -d "Sources" ]; then
+ WATCH_PATHS+=("Sources")
+ fi
+
+ if [ -d "Packages" ]; then
+ WATCH_PATHS+=("Packages")
+ fi
+
+ if [ ${#WATCH_PATHS[@]} -eq 0 ]; then
+ echo -e "${YELLOW}Warning: No Sources or Packages directories found to watch${NC}"
+ echo -e "${BLUE}Press Ctrl+C to stop the preview server${NC}"
+ wait $SERVER_PID
+ else
+ # Use fswatch to monitor Swift file changes only
+ fswatch -r \
+ -e ".*" \
+ -i "\\.swift$" \
+ "${WATCH_PATHS[@]}" | while read -r changed_file; do
+
+ echo ""
+ echo -e "${YELLOW}Swift file changed: $(basename "$changed_file")${NC}"
+ echo -e "${YELLOW}Rebuilding symbol graphs...${NC}"
+
+ # Rebuild Swift and extract new symbols
+ if swift build 2>&1 | grep -E "(Building|Build complete|error:)" && \
+ swift package dump-symbol-graph 2>&1 | grep -q "Emitting symbol graph"; then
+ echo -e "${GREEN}✓ Symbol graphs updated${NC}"
+ echo -e "${BLUE} DocC will auto-reload the documentation${NC}"
+ else
+ echo -e "${RED}✗ Symbol graph update failed${NC}"
+ echo -e "${YELLOW} Fix the errors and save to rebuild${NC}"
+ fi
+
+ echo ""
+ echo -e "${BLUE}Watching for changes...${NC}"
+ done &
+ WATCH_PID=$!
+
+ # Wait for server (cleanup trap will handle both processes)
+ wait $SERVER_PID
+ fi
+ fi
+else
+ echo ""
+ echo -e "${BLUE}Press Ctrl+C to stop the preview server${NC}"
+ wait $SERVER_PID
+fi
diff --git a/Sources/SundialKitStream/AsyncStream+Continuation.swift b/Sources/SundialKitStream/AsyncStream+Continuation.swift
new file mode 100644
index 0000000..45a8ed0
--- /dev/null
+++ b/Sources/SundialKitStream/AsyncStream+Continuation.swift
@@ -0,0 +1,87 @@
+//
+// AsyncStream+Continuation.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+public import Foundation
+
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+extension AsyncStream {
+ /// Creates an AsyncStream with automatic continuation management
+ ///
+ /// This convenience initializer encapsulates the common pattern of:
+ /// 1. Generating a unique ID for each stream subscriber
+ /// 2. Registering a continuation with a continuation manager
+ /// 3. Optionally yielding an initial value immediately
+ /// 4. Setting up automatic cleanup on termination
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// public func activationStates() -> AsyncStream {
+ /// AsyncStream(
+ /// register: { id, cont in
+ /// await self.manager.register(id: id, continuation: cont)
+ /// },
+ /// unregister: { id in
+ /// await self.manager.unregister(id: id)
+ /// },
+ /// initialValue: { await self.currentState }
+ /// )
+ /// }
+ /// ```
+ ///
+ /// - Parameters:
+ /// - register: Closure to register the continuation with a manager
+ /// - unregister: Closure to remove the continuation from the manager
+ /// - initialValue: Optional closure to get an initial value to yield immediately
+ public init(
+ register: @escaping @Sendable (UUID, Continuation) async -> Void,
+ unregister: @escaping @Sendable (UUID) async -> Void,
+ initialValue: (@Sendable () async -> Element?)? = nil
+ ) {
+ self.init { continuation in
+ let id = UUID()
+ Task {
+ await register(id, continuation)
+
+ // Yield initial value if provided
+ if let initialValue = initialValue {
+ if let value = await initialValue() {
+ continuation.yield(value)
+ }
+ }
+ }
+
+ continuation.onTermination = { _ in
+ Task {
+ await unregister(id)
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/SundialKitStream/ConnectivityObserver+Lifecycle.swift b/Sources/SundialKitStream/ConnectivityObserver+Lifecycle.swift
new file mode 100644
index 0000000..2941d01
--- /dev/null
+++ b/Sources/SundialKitStream/ConnectivityObserver+Lifecycle.swift
@@ -0,0 +1,111 @@
+//
+// ConnectivityObserver+Lifecycle.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import SundialKitConnectivity
+public import SundialKitCore
+
+#if canImport(UIKit)
+ import UIKit
+#endif
+#if canImport(AppKit)
+ import AppKit
+#endif
+
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+extension ConnectivityObserver {
+ /// Updates the application context with new data.
+ ///
+ /// Application context is for background delivery of state updates.
+ /// The system will deliver this data to the counterpart device when it's convenient.
+ ///
+ /// - Parameter context: The context dictionary to send
+ /// - Throws: Error if the context cannot be updated
+ ///
+ /// ## Example
+ /// ```swift
+ /// let context: [String: any Sendable] = [
+ /// "appVersion": "1.0",
+ /// "lastSync": Date().timeIntervalSince1970
+ /// ]
+ /// try await observer.updateApplicationContext(context)
+ /// ```
+ public func updateApplicationContext(_ context: ConnectivityMessage) throws {
+ try session.updateApplicationContext(context)
+ }
+
+ /// Sets up automatic observation of app lifecycle to check for pending application context.
+ ///
+ /// When the app becomes active, this automatically checks if there's a pending
+ /// application context that arrived while the app was backgrounded.
+ ///
+ /// This handles the edge case where:
+ /// 1. Session is already activated and reachable
+ /// 2. updateApplicationContext arrives while app is backgrounded
+ /// 3. App returns to foreground
+ /// In this scenario, no activation or reachability events fire, so this is the only
+ /// mechanism that will detect and process the pending context.
+ internal func setupAppLifecycleObserver() {
+ // Guard against multiple calls
+ guard appLifecycleTask == nil else {
+ return
+ }
+
+ appLifecycleTask = Task { [weak self] in
+ guard let self else {
+ return
+ }
+
+ #if canImport(UIKit) && !os(watchOS)
+ // iOS/tvOS
+ let notificationName = UIApplication.didBecomeActiveNotification
+ #elseif os(watchOS)
+ // watchOS - use extension-specific notification
+ let notificationName = Notification.Name("NSExtensionHostDidBecomeActiveNotification")
+ #elseif canImport(AppKit)
+ // macOS
+ let notificationName = NSApplication.didBecomeActiveNotification
+ #else
+ // Unsupported platform - return early
+ return
+ #endif
+
+ #if canImport(Darwin)
+ let notifications = NotificationCenter.default.notifications(named: notificationName)
+
+ for await _ in notifications {
+ // Check for pending application context when app becomes active
+ if let pendingContext = self.session.receivedApplicationContext {
+ await self.handleApplicationContext(pendingContext, error: nil)
+ }
+ }
+ #endif
+ }
+ }
+}
diff --git a/Sources/SundialKitStream/ConnectivityObserver+Messaging.swift b/Sources/SundialKitStream/ConnectivityObserver+Messaging.swift
new file mode 100644
index 0000000..0eabb10
--- /dev/null
+++ b/Sources/SundialKitStream/ConnectivityObserver+Messaging.swift
@@ -0,0 +1,120 @@
+//
+// ConnectivityObserver+Messaging.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+public import Foundation
+public import SundialKitConnectivity
+public import SundialKitCore
+
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+extension ConnectivityObserver {
+ // MARK: - Message Sending
+
+ /// Sends a message to the counterpart device
+ /// - Parameter message: The message to send (must be property list types)
+ /// - Returns: The send result
+ /// - Throws: Error if the message cannot be sent
+ public func sendMessage(_ message: ConnectivityMessage) async throws -> ConnectivitySendResult {
+ do {
+ let sendResult = try await messageRouter.send(message)
+
+ // Notify send result stream subscribers
+ await messageDistributor.notifySendResult(sendResult)
+
+ return sendResult
+ } catch {
+ let sendResult = ConnectivitySendResult(message: message, context: .failure(error))
+
+ // Notify send result stream subscribers
+ await messageDistributor.notifySendResult(sendResult)
+
+ throw error
+ }
+ }
+
+ /// Sends a typed message with automatic transport selection
+ ///
+ /// Automatically chooses the best transport based on message type:
+ /// - `BinaryMessagable` types use binary transport (unless `forceDictionary` option is set)
+ /// - `Messagable` types use dictionary transport
+ ///
+ /// ## Example
+ ///
+ /// ```swift
+ /// // Binary transport (efficient)
+ /// let sensor = SensorData(temperature: 72.5)
+ /// let result = try await observer.send(sensor)
+ ///
+ /// // Dictionary transport
+ /// let status = StatusMessage(text: "ready")
+ /// let result = try await observer.send(status)
+ ///
+ /// // Force dictionary for testing
+ /// let result = try await observer.send(sensor, options: .forceDictionary)
+ /// ```
+ ///
+ /// - Parameters:
+ /// - message: The typed message to send
+ /// - options: Send options (e.g., `.forceDictionary`)
+ /// - Returns: The send result with transport indication
+ /// - Throws: Error if the message cannot be sent
+ public func send(_ message: some Messagable, options: SendOptions = []) async throws
+ -> ConnectivitySendResult
+ {
+ // Determine transport based on type and options
+ if let binaryMessage = message as? any BinaryMessagable,
+ !options.contains(.forceDictionary)
+ {
+ // Binary transport
+ let data = try BinaryMessageEncoder.encode(binaryMessage)
+ let originalMessage = message.message()
+
+ do {
+ let sendResult = try await messageRouter.sendBinary(data, originalMessage: originalMessage)
+
+ // Notify send result stream subscribers
+ await messageDistributor.notifySendResult(sendResult)
+
+ return sendResult
+ } catch {
+ let sendResult = ConnectivitySendResult(
+ message: originalMessage,
+ context: .failure(error)
+ )
+
+ // Notify send result stream subscribers
+ await messageDistributor.notifySendResult(sendResult)
+
+ throw error
+ }
+ } else {
+ // Dictionary transport
+ return try await sendMessage(message.message())
+ }
+ }
+}
diff --git a/Sources/SundialKitStream/ConnectivityObserver+Streams.swift b/Sources/SundialKitStream/ConnectivityObserver+Streams.swift
new file mode 100644
index 0000000..e1844f7
--- /dev/null
+++ b/Sources/SundialKitStream/ConnectivityObserver+Streams.swift
@@ -0,0 +1,137 @@
+//
+// ConnectivityObserver+Streams.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+public import Foundation
+public import SundialKitConnectivity
+public import SundialKitCore
+
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+extension ConnectivityObserver {
+ // MARK: - AsyncStream APIs
+
+ /// AsyncStream of activation state changes
+ /// - Returns: Stream that yields activation states as they change
+ public func activationStates() -> AsyncStream {
+ AsyncStream(
+ register: { id, cont in
+ await self.continuationManager.registerActivation(id: id, continuation: cont)
+ },
+ unregister: { id in await self.continuationManager.removeActivation(id: id) },
+ initialValue: { await self.stateManager.activationState }
+ )
+ }
+
+ /// AsyncStream of activation completion events (with success state or error)
+ /// - Returns: Stream that yields Result containing activation state or error
+ public func activationCompletionStream() -> AsyncStream> {
+ AsyncStream(
+ register: { id, cont in
+ await self.continuationManager.registerActivationCompletion(id: id, continuation: cont)
+ },
+ unregister: { id in await self.continuationManager.removeActivationCompletion(id: id) }
+ )
+ }
+
+ /// AsyncStream of reachability changes
+ /// - Returns: Stream that yields reachability status as it changes
+ public func reachabilityUpdates() -> AsyncStream {
+ AsyncStream(
+ register: { id, cont in
+ await self.continuationManager.registerReachability(id: id, continuation: cont)
+ },
+ unregister: { id in await self.continuationManager.removeReachability(id: id) },
+ initialValue: { await self.stateManager.isReachable }
+ )
+ }
+
+ /// AsyncStream of paired app installed status changes
+ /// - Returns: Stream that yields paired app installed status as it changes
+ public func pairedAppInstalledUpdates() -> AsyncStream {
+ AsyncStream(
+ register: { id, cont in
+ await self.continuationManager.registerPairedAppInstalled(id: id, continuation: cont)
+ },
+ unregister: { id in await self.continuationManager.removePairedAppInstalled(id: id) },
+ initialValue: { await self.stateManager.isPairedAppInstalled }
+ )
+ }
+
+ #if os(iOS)
+ /// AsyncStream of paired status changes (iOS only)
+ /// - Returns: Stream that yields paired status as it changes
+ @available(watchOS, unavailable)
+ public func pairedUpdates() -> AsyncStream {
+ AsyncStream(
+ register: { id, cont in
+ await self.continuationManager.registerPaired(id: id, continuation: cont)
+ },
+ unregister: { id in await self.continuationManager.removePaired(id: id) },
+ initialValue: { await self.stateManager.isPaired }
+ )
+ }
+ #endif
+
+ /// AsyncStream of received messages
+ /// - Returns: Stream that yields messages as they are received
+ public func messageStream() -> AsyncStream {
+ AsyncStream(
+ register: { id, cont in
+ await self.continuationManager.registerMessageReceived(id: id, continuation: cont)
+ },
+ unregister: { id in await self.continuationManager.removeMessageReceived(id: id) }
+ )
+ }
+
+ /// AsyncStream of typed decoded messages
+ ///
+ /// Requires a `MessageDecoder` to be configured during initialization.
+ /// Both dictionary and binary messages are automatically decoded into
+ /// their typed `Messagable` forms.
+ ///
+ /// - Returns: Stream that yields decoded messages as they are received
+ public func typedMessageStream() -> AsyncStream {
+ AsyncStream(
+ register: { id, cont in
+ await self.continuationManager.registerTypedMessage(id: id, continuation: cont)
+ },
+ unregister: { id in await self.continuationManager.removeTypedMessage(id: id) }
+ )
+ }
+
+ /// AsyncStream of send results
+ /// - Returns: Stream that yields send results as messages are sent
+ public func sendResultStream() -> AsyncStream {
+ AsyncStream(
+ register: { id, cont in
+ await self.continuationManager.registerSendResult(id: id, continuation: cont)
+ },
+ unregister: { id in await self.continuationManager.removeSendResult(id: id) }
+ )
+ }
+}
diff --git a/Sources/SundialKitStream/ConnectivityObserver.swift b/Sources/SundialKitStream/ConnectivityObserver.swift
new file mode 100644
index 0000000..8d54393
--- /dev/null
+++ b/Sources/SundialKitStream/ConnectivityObserver.swift
@@ -0,0 +1,192 @@
+//
+// ConnectivityObserver.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+public import Foundation
+public import SundialKitConnectivity
+public import SundialKitCore
+
+#if canImport(UIKit)
+ import UIKit
+#endif
+#if canImport(AppKit)
+ import AppKit
+#endif
+
+/// Actor-based WatchConnectivity observer providing AsyncStream APIs
+///
+/// `ConnectivityObserver` manages communication between iPhone and Apple Watch
+/// using Swift concurrency patterns.
+///
+/// ## Example Usage
+///
+/// ```swift
+/// import SundialKitStream
+///
+/// let observer = ConnectivityObserver()
+/// try await observer.activate()
+///
+/// // Monitor activation state
+/// for await state in observer.activationStates() {
+/// print("Activation state: \(state)")
+/// }
+///
+/// // Monitor activation completion (with errors)
+/// for await result in observer.activationCompletionStream() {
+/// switch result {
+/// case .success(let state):
+/// print("Activated: \(state)")
+/// case .failure(let error):
+/// print("Activation failed: \(error)")
+/// }
+/// }
+///
+/// // Check for activation errors
+/// if let error = await observer.getCurrentActivationError() {
+/// print("Last activation error: \(error)")
+/// }
+///
+/// // Send messages
+/// let result = try await observer.sendMessage(["key": "value"])
+/// ```
+///
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+public actor ConnectivityObserver: ConnectivitySessionDelegate, StateHandling, MessageHandling {
+ // MARK: - Private Properties
+
+ internal let session: any ConnectivitySession
+ internal let messageRouter: MessageRouter
+ internal let continuationManager: StreamContinuationManager
+
+ /// Manages connectivity activation state and stream continuations
+ public let stateManager: ConnectivityStateManager
+
+ /// Handles distribution of incoming messages to stream subscribers
+ public let messageDistributor: MessageDistributor
+
+ internal var appLifecycleTask: Task?
+
+ // MARK: - Initialization
+
+ internal init(session: any ConnectivitySession, messageDecoder: MessageDecoder? = nil) {
+ self.session = session
+ self.messageRouter = MessageRouter(session: session)
+ self.continuationManager = StreamContinuationManager()
+ self.stateManager = ConnectivityStateManager(continuationManager: continuationManager)
+ self.messageDistributor = MessageDistributor(
+ continuationManager: continuationManager,
+ messageDecoder: messageDecoder
+ )
+
+ // Ensure session doesn't already have a delegate
+ assert(
+ session.delegate == nil,
+ "Session already has a delegate - multiple delegates will cause undefined behavior"
+ )
+ session.delegate = self
+ }
+
+ #if canImport(WatchConnectivity)
+ /// Creates a `ConnectivityObserver` which uses WatchConnectivity
+ /// - Parameter messageDecoder: Optional decoder for automatic message decoding
+ @available(macOS, unavailable)
+ @available(tvOS, unavailable)
+ public init(messageDecoder: MessageDecoder? = nil) {
+ self.init(session: WatchConnectivitySession(), messageDecoder: messageDecoder)
+ }
+ #else
+ /// Creates a `ConnectivityObserver` with a never-available session
+ /// - Parameter messageDecoder: Optional decoder for automatic message decoding
+ @available(macOS, unavailable)
+ @available(tvOS, unavailable)
+ public init(messageDecoder: MessageDecoder? = nil) {
+ self.init(session: NeverConnectivitySession(), messageDecoder: messageDecoder)
+ }
+ #endif
+
+ // MARK: - Public API
+
+ /// Activates the connectivity session
+ /// - Throws: `ConnectivityError.sessionNotSupported` if not supported
+ public func activate() throws {
+ try session.activate()
+ setupAppLifecycleObserver()
+ }
+
+ /// Checks for pending application context that may have arrived while the app was inactive.
+ ///
+ /// This method is provided for manual checking, but is **automatically called** in the
+ /// following scenarios:
+ /// - After successful session activation
+ /// - When the session becomes reachable
+ /// - When the app returns to the foreground (via app lifecycle notifications)
+ ///
+ /// Most apps will not need to call this method directly.
+ public func checkPendingApplicationContext() async {
+ if let pendingContext = session.receivedApplicationContext {
+ await handleApplicationContext(pendingContext, error: nil)
+ }
+ }
+
+ /// Gets the current activation state snapshot
+ /// - Returns: The current activation state, or nil if not yet activated
+ public func getCurrentActivationState() async -> ActivationState? {
+ await stateManager.activationState
+ }
+
+ /// Gets the last activation error
+ /// - Returns: The last activation error, or nil if no error occurred
+ public func getCurrentActivationError() async -> (any Error)? {
+ await stateManager.activationError
+ }
+
+ /// Gets the current reachability status
+ /// - Returns: Whether the counterpart is reachable
+ public func isReachable() async -> Bool {
+ await stateManager.isReachable
+ }
+
+ /// Gets the current paired app installed status
+ /// - Returns: Whether the companion app is installed
+ public func isPairedAppInstalled() async -> Bool {
+ await stateManager.isPairedAppInstalled
+ }
+
+ #if os(iOS)
+ /// Gets the current paired status (iOS only)
+ /// - Returns: Whether an Apple Watch is paired
+ @available(watchOS, unavailable)
+ public func isPaired() async -> Bool {
+ await stateManager.isPaired
+ }
+ #endif
+
+ deinit {
+ appLifecycleTask?.cancel()
+ }
+}
diff --git a/Sources/SundialKitStream/ConnectivityState.swift b/Sources/SundialKitStream/ConnectivityState.swift
new file mode 100644
index 0000000..14fcbc1
--- /dev/null
+++ b/Sources/SundialKitStream/ConnectivityState.swift
@@ -0,0 +1,62 @@
+//
+// ConnectivityState.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import SundialKitCore
+
+/// Immutable value type representing WatchConnectivity session state.
+///
+/// `ConnectivityState` encapsulates the current state of a connectivity session,
+/// including activation status, reachability, and pairing information. This value
+/// type is used internally by `ConnectivityObserver` for state management.
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+internal struct ConnectivityState: Sendable {
+ /// The default initial state before activation.
+ internal static let initial = ConnectivityState(
+ activationState: nil,
+ activationError: nil,
+ isReachable: false,
+ isPairedAppInstalled: false,
+ isPaired: false
+ )
+
+ /// The current activation state of the session.
+ internal let activationState: ActivationState?
+
+ /// The last error that occurred during activation, if any.
+ internal let activationError: (any Error & Sendable)?
+
+ /// Whether the counterpart device is currently reachable.
+ internal let isReachable: Bool
+
+ /// Whether the counterpart app is installed on the paired device.
+ internal let isPairedAppInstalled: Bool
+
+ /// Whether an Apple Watch is paired with this iPhone (iOS only).
+ internal let isPaired: Bool
+}
diff --git a/Sources/SundialKitStream/ConnectivityStateManager+Updates.swift b/Sources/SundialKitStream/ConnectivityStateManager+Updates.swift
new file mode 100644
index 0000000..db3a2ff
--- /dev/null
+++ b/Sources/SundialKitStream/ConnectivityStateManager+Updates.swift
@@ -0,0 +1,184 @@
+//
+// ConnectivityStateManager+Updates.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+public import Foundation
+public import SundialKitConnectivity
+public import SundialKitCore
+
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+extension ConnectivityStateManager {
+ // MARK: - State Updates
+
+ /// Handles activation with full session state snapshot
+ internal func handleActivation(
+ from session: any ConnectivitySession,
+ activationState: ActivationState,
+ error: (any Error)?
+ ) async {
+ // Validate state consistency: activated state should not have an error
+ assert(
+ !(error != nil && activationState == .activated),
+ "Invalid state: activation cannot be .activated with an error present"
+ )
+
+ // Capture all session properties at activation time for consistent snapshot
+ #if os(iOS)
+ state = ConnectivityState(
+ activationState: activationState,
+ activationError: error,
+ isReachable: session.isReachable,
+ isPairedAppInstalled: session.isPairedAppInstalled,
+ isPaired: session.isPaired
+ )
+ #else
+ state = ConnectivityState(
+ activationState: activationState,
+ activationError: error,
+ isReachable: session.isReachable,
+ isPairedAppInstalled: session.isPairedAppInstalled,
+ isPaired: false // Always true on watchOS (implicit pairing)
+ )
+ #endif
+
+ // Notify subscribers
+ await continuationManager.yieldActivationState(activationState)
+
+ let result: Result =
+ if let error = error {
+ .failure(error)
+ } else {
+ .success(activationState)
+ }
+ await continuationManager.yieldActivationCompletion(result)
+ await continuationManager.yieldReachability(state.isReachable)
+ await continuationManager.yieldPairedAppInstalled(state.isPairedAppInstalled)
+
+ #if os(iOS)
+ await continuationManager.yieldPaired(state.isPaired)
+ #endif
+ }
+
+ /// Legacy method - preserved for backward compatibility
+ internal func handleActivation(_ activationState: ActivationState, error: (any Error)?) async {
+ // Validate state consistency: activated state should not have an error
+ assert(
+ !(error != nil && activationState == .activated),
+ "Invalid state: activation cannot be .activated with an error present"
+ )
+
+ #if os(iOS)
+ state = ConnectivityState(
+ activationState: activationState,
+ activationError: error,
+ isReachable: state.isReachable,
+ isPairedAppInstalled: state.isPairedAppInstalled,
+ isPaired: state.isPaired
+ )
+ #else
+ state = ConnectivityState(
+ activationState: activationState,
+ activationError: error,
+ isReachable: state.isReachable,
+ isPairedAppInstalled: state.isPairedAppInstalled,
+ isPaired: false
+ )
+ #endif
+
+ // Notify subscribers
+ await continuationManager.yieldActivationState(activationState)
+
+ let result: Result =
+ if let error = error {
+ .failure(error)
+ } else {
+ .success(activationState)
+ }
+ await continuationManager.yieldActivationCompletion(result)
+ await continuationManager.yieldReachability(state.isReachable)
+ await continuationManager.yieldPairedAppInstalled(state.isPairedAppInstalled)
+
+ #if os(iOS)
+ await continuationManager.yieldPaired(state.isPaired)
+ #endif
+ }
+
+ internal func updateReachability(_ isReachable: Bool) async {
+ // Verify session has been activated before updating reachability
+ assert(
+ state.activationState != nil,
+ "Cannot update reachability before session activation"
+ )
+
+ #if os(iOS)
+ state = ConnectivityState(
+ activationState: state.activationState,
+ activationError: state.activationError,
+ isReachable: isReachable,
+ isPairedAppInstalled: state.isPairedAppInstalled,
+ isPaired: state.isPaired
+ )
+ #else
+ state = ConnectivityState(
+ activationState: state.activationState,
+ activationError: state.activationError,
+ isReachable: isReachable,
+ isPairedAppInstalled: state.isPairedAppInstalled,
+ isPaired: false
+ )
+ #endif
+
+ await continuationManager.yieldReachability(isReachable)
+ }
+
+ internal func updateCompanionState(isPairedAppInstalled: Bool, isPaired: Bool) async {
+ #if os(iOS)
+ state = ConnectivityState(
+ activationState: state.activationState,
+ activationError: state.activationError,
+ isReachable: state.isReachable,
+ isPairedAppInstalled: isPairedAppInstalled,
+ isPaired: isPaired
+ )
+ #else
+ state = ConnectivityState(
+ activationState: state.activationState,
+ activationError: state.activationError,
+ isReachable: state.isReachable,
+ isPairedAppInstalled: isPairedAppInstalled,
+ isPaired: true
+ )
+ #endif
+
+ await continuationManager.yieldPairedAppInstalled(state.isPairedAppInstalled)
+
+ #if os(iOS)
+ await continuationManager.yieldPaired(state.isPaired)
+ #endif
+ }
+}
diff --git a/Sources/SundialKitStream/ConnectivityStateManager.swift b/Sources/SundialKitStream/ConnectivityStateManager.swift
new file mode 100644
index 0000000..3dcae04
--- /dev/null
+++ b/Sources/SundialKitStream/ConnectivityStateManager.swift
@@ -0,0 +1,78 @@
+//
+// ConnectivityStateManager.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+public import Foundation
+public import SundialKitConnectivity
+public import SundialKitCore
+
+/// Manages ConnectivityState and notifies stream subscribers of changes
+///
+/// This type coordinates state updates with the StreamContinuationManager,
+/// ensuring all subscribers receive state change notifications.
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+public actor ConnectivityStateManager {
+ // MARK: - Properties
+
+ internal var state: ConnectivityState = .initial
+ internal let continuationManager: StreamContinuationManager
+
+ // MARK: - State Access
+
+ internal var currentState: ConnectivityState {
+ state
+ }
+
+ internal var activationState: ActivationState? {
+ state.activationState
+ }
+
+ internal var activationError: (any Error)? {
+ state.activationError
+ }
+
+ internal var isReachable: Bool {
+ state.isReachable
+ }
+
+ internal var isPairedAppInstalled: Bool {
+ state.isPairedAppInstalled
+ }
+
+ #if os(iOS)
+ internal var isPaired: Bool {
+ state.isPaired
+ }
+ #endif
+
+ // MARK: - Initialization
+
+ internal init(continuationManager: StreamContinuationManager) {
+ self.continuationManager = continuationManager
+ }
+}
diff --git a/Sources/SundialKitStream/MessageDispatcher.swift b/Sources/SundialKitStream/MessageDispatcher.swift
new file mode 100644
index 0000000..87d17b7
--- /dev/null
+++ b/Sources/SundialKitStream/MessageDispatcher.swift
@@ -0,0 +1,168 @@
+//
+// MessageDispatcher.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import SundialKitConnectivity
+import SundialKitCore
+
+#if canImport(os.log)
+ import os.log
+#endif
+
+/// Internal helper for dispatching received messages to stream subscribers.
+///
+/// `MessageDispatcher` handles the distribution of incoming messages to various
+/// stream continuations, including:
+/// - Raw message streams (with reply handlers)
+/// - Typed message streams (decoded via `MessageDecoder`)
+/// - Application context messages
+/// - Binary message streams
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+internal struct MessageDispatcher {
+ // MARK: - Private Properties
+
+ private let messageDecoder: MessageDecoder?
+
+ // MARK: - Initialization
+
+ /// Creates a new message dispatcher.
+ ///
+ /// - Parameter messageDecoder: Optional decoder for automatic message decoding
+ internal init(messageDecoder: MessageDecoder?) {
+ self.messageDecoder = messageDecoder
+ }
+
+ // MARK: - Message Dispatching
+
+ /// Dispatches a received message to stream subscribers.
+ ///
+ /// Sends the message to both raw message streams and typed message streams
+ /// (if a decoder is available).
+ ///
+ /// - Parameters:
+ /// - message: The received message
+ /// - replyHandler: Handler for sending a reply
+ /// - messageRegistry: Registry of raw message stream continuations
+ /// - typedRegistry: Registry of typed message stream continuations
+ internal func dispatchMessage(
+ _ message: ConnectivityMessage,
+ replyHandler: @escaping @Sendable ([String: any Sendable]) -> Void,
+ to messageRegistry: StreamContinuationRegistry,
+ and typedRegistry: StreamContinuationRegistry
+ ) {
+ // Verify decoder exists if typed subscribers are registered
+ assert(
+ messageDecoder != nil || typedRegistry.isEmpty,
+ "Typed message subscribers exist but no decoder is configured"
+ )
+
+ // Send to raw stream subscribers
+ let result = ConnectivityReceiveResult(message: message, context: .replyWith(replyHandler))
+ messageRegistry.yield(result)
+
+ // Decode and send to typed stream subscribers
+ if let decoder = messageDecoder {
+ do {
+ let decoded = try decoder.decode(message)
+ typedRegistry.yield(decoded)
+ } catch {
+ // Decoding failed - crash in debug, log in production
+ assertionFailure("Failed to decode message: \(error)")
+ if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
+ SundialLogger.stream.error("Failed to decode message: \(String(describing: error))")
+ }
+ }
+ }
+ }
+
+ /// Dispatches an application context message to stream subscribers.
+ ///
+ /// - Parameters:
+ /// - context: The application context dictionary
+ /// - error: Optional error that occurred during context update
+ /// - messageRegistry: Registry of raw message stream continuations
+ /// - typedRegistry: Registry of typed message stream continuations
+ internal func dispatchApplicationContext(
+ _ context: ConnectivityMessage,
+ error: (any Error)?,
+ to messageRegistry: StreamContinuationRegistry,
+ and typedRegistry: StreamContinuationRegistry
+ ) {
+ // Send to raw stream subscribers
+ let result = ConnectivityReceiveResult(message: context, context: .applicationContext)
+ messageRegistry.yield(result)
+
+ // Decode and send to typed stream subscribers if no error
+ if error == nil, let decoder = messageDecoder {
+ do {
+ let decoded = try decoder.decode(context)
+ typedRegistry.yield(decoded)
+ } catch {
+ // Decoding failed - crash in debug, log in production
+ assertionFailure("Failed to decode application context: \(error)")
+ if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
+ SundialLogger.stream.error(
+ "Failed to decode application context: \(String(describing: error))"
+ )
+ }
+ }
+ }
+ }
+
+ /// Dispatches a binary message to stream subscribers.
+ ///
+ /// Attempts to decode the binary message using the MessageDecoder's decodeBinary method
+ /// and dispatches to typed streams. Binary messages are not sent to raw message streams.
+ ///
+ /// - Parameters:
+ /// - data: The binary message data
+ /// - replyHandler: Handler for sending a binary reply
+ /// - typedRegistry: Registry of typed message stream continuations
+ internal func dispatchBinaryMessage(
+ _ data: Data,
+ replyHandler: @escaping @Sendable (Data) -> Void,
+ to typedRegistry: StreamContinuationRegistry
+ ) {
+ // Decode and send to typed stream subscribers
+ if let decoder = messageDecoder {
+ do {
+ let decoded = try decoder.decodeBinary(data)
+ typedRegistry.yield(decoded)
+ } catch {
+ // Decoding failed - crash in debug, log in production
+ assertionFailure("Failed to decode binary message: \(error)")
+ if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
+ SundialLogger.stream.error(
+ "Failed to decode binary message: \(String(describing: error))"
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/SundialKitStream/MessageDistributor.swift b/Sources/SundialKitStream/MessageDistributor.swift
new file mode 100644
index 0000000..aefa427
--- /dev/null
+++ b/Sources/SundialKitStream/MessageDistributor.swift
@@ -0,0 +1,139 @@
+//
+// MessageDistributor.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+public import Foundation
+public import SundialKitConnectivity
+public import SundialKitCore
+
+#if canImport(os.log)
+ import os.log
+#endif
+
+/// Distributes incoming messages to appropriate stream subscribers
+///
+/// This type handles message decoding and distribution to both
+/// raw message streams and typed message streams.
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+public actor MessageDistributor {
+ // MARK: - Properties
+
+ private let continuationManager: StreamContinuationManager
+ private let messageDecoder: MessageDecoder?
+
+ // MARK: - Initialization
+
+ internal init(
+ continuationManager: StreamContinuationManager,
+ messageDecoder: MessageDecoder?
+ ) {
+ self.continuationManager = continuationManager
+ self.messageDecoder = messageDecoder
+ }
+
+ // MARK: - Message Handling
+
+ internal func handleMessage(
+ _ message: ConnectivityMessage,
+ replyHandler: @escaping @Sendable ([String: any Sendable]) -> Void
+ ) async {
+ // Send to raw stream subscribers
+ let result = ConnectivityReceiveResult(message: message, context: .replyWith(replyHandler))
+ await continuationManager.yieldMessageReceived(result)
+
+ // Decode and send to typed stream subscribers
+ if let decoder = messageDecoder {
+ do {
+ let decoded = try decoder.decode(message)
+ await continuationManager.yieldTypedMessage(decoded)
+ } catch {
+ // Decoding failed - crash in debug, log in production
+ assertionFailure("Failed to decode message: \(error)")
+ if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
+ SundialLogger.stream.error("Failed to decode message: \(String(describing: error))")
+ }
+ }
+ }
+ }
+
+ internal func handleApplicationContext(
+ _ applicationContext: ConnectivityMessage,
+ error: (any Error)?
+ ) async {
+ // Send to raw stream subscribers
+ let result = ConnectivityReceiveResult(
+ message: applicationContext,
+ context: .applicationContext
+ )
+ await continuationManager.yieldMessageReceived(result)
+
+ // Decode and send to typed stream subscribers if no error
+ if error == nil, let decoder = messageDecoder {
+ do {
+ let decoded = try decoder.decode(applicationContext)
+ await continuationManager.yieldTypedMessage(decoded)
+ } catch {
+ // Decoding failed - crash in debug, log in production
+ assertionFailure("Failed to decode application context: \(error)")
+ if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
+ SundialLogger.stream.error(
+ "Failed to decode application context: \(String(describing: error))"
+ )
+ }
+ }
+ }
+ }
+
+ internal func handleBinaryMessage(
+ _ data: Data,
+ replyHandler: @escaping @Sendable (Data) -> Void
+ ) async {
+ // TODO: Emit to raw message stream with reply handler like handleMessage does
+ // This will require extending ConnectivityReceiveResult to support binary data
+
+ // Decode and send to typed stream subscribers
+ if let decoder = messageDecoder {
+ do {
+ let decoded = try decoder.decodeBinary(data)
+ await continuationManager.yieldTypedMessage(decoded)
+ } catch {
+ // Decoding failed - crash in debug, log in production
+ assertionFailure("Failed to decode binary message: \(error)")
+ if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
+ SundialLogger.stream.error(
+ "Failed to decode binary message: \(String(describing: error))"
+ )
+ }
+ }
+ }
+ }
+
+ internal func notifySendResult(_ result: ConnectivitySendResult) async {
+ await continuationManager.yieldSendResult(result)
+ }
+}
diff --git a/Sources/SundialKitStream/MessageHandling.swift b/Sources/SundialKitStream/MessageHandling.swift
new file mode 100644
index 0000000..7ce6065
--- /dev/null
+++ b/Sources/SundialKitStream/MessageHandling.swift
@@ -0,0 +1,78 @@
+//
+// MessageHandling.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+internal import Foundation
+internal import SundialKitConnectivity
+internal import SundialKitCore
+
+/// Protocol for types that handle connectivity message distribution
+///
+/// Provides default implementations for common message handling patterns
+/// by delegating to a `MessageDistributor`.
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+public protocol MessageHandling {
+ /// The message distributor responsible for routing messages to subscribers
+ var messageDistributor: MessageDistributor { get }
+}
+
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+extension MessageHandling {
+ /// Handles received dictionary messages
+ /// - Parameters:
+ /// - message: The received message dictionary
+ /// - replyHandler: Handler to send a reply back to the sender
+ internal func handleMessage(
+ _ message: ConnectivityMessage,
+ replyHandler: @escaping @Sendable ([String: any Sendable]) -> Void
+ ) async {
+ await messageDistributor.handleMessage(message, replyHandler: replyHandler)
+ }
+
+ /// Handles application context updates
+ /// - Parameters:
+ /// - applicationContext: The updated application context
+ /// - error: Optional error that occurred during context update
+ internal func handleApplicationContext(
+ _ applicationContext: ConnectivityMessage, error: (any Error)?
+ )
+ async
+ {
+ await messageDistributor.handleApplicationContext(applicationContext, error: error)
+ }
+
+ /// Handles received binary messages
+ /// - Parameters:
+ /// - data: The received binary data
+ /// - replyHandler: Handler to send binary data back to the sender
+ internal func handleBinaryMessage(_ data: Data, replyHandler: @escaping @Sendable (Data) -> Void)
+ async
+ {
+ await messageDistributor.handleBinaryMessage(data, replyHandler: replyHandler)
+ }
+}
diff --git a/Sources/SundialKitStream/MessageRouter.swift b/Sources/SundialKitStream/MessageRouter.swift
new file mode 100644
index 0000000..a58bc79
--- /dev/null
+++ b/Sources/SundialKitStream/MessageRouter.swift
@@ -0,0 +1,153 @@
+//
+// MessageRouter.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import SundialKitConnectivity
+import SundialKitCore
+
+#if canImport(os.log)
+ import os.log
+#endif
+
+/// Internal helper for routing messages through appropriate transports.
+///
+/// `MessageRouter` encapsulates the logic for selecting the best transport
+/// method based on session state and message type. It handles:
+/// - Immediate delivery via `sendMessage` when reachable
+/// - Background delivery via `updateApplicationContext` when not reachable
+/// - Binary transport for `BinaryMessagable` types
+/// - Dictionary transport for regular `Messagable` types
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+internal struct MessageRouter {
+ // MARK: - Private Properties
+
+ private let session: any ConnectivitySession
+
+ // MARK: - Initialization
+
+ /// Creates a new message router.
+ ///
+ /// - Parameter session: The connectivity session to use for sending
+ internal init(session: any ConnectivitySession) {
+ self.session = session
+ }
+
+ // MARK: - Dictionary Message Routing
+
+ /// Routes a dictionary message using the best available transport.
+ ///
+ /// - Parameter message: The message to send
+ /// - Returns: The send result
+ /// - Throws: Error if the message cannot be sent
+ internal func send(_ message: ConnectivityMessage) async throws -> ConnectivitySendResult {
+ if session.isReachable {
+ // Use sendMessage for immediate delivery when reachable
+ return try await withCheckedThrowingContinuation { continuation in
+ session.sendMessage(message) { result in
+ let sendResult = ConnectivitySendResult(message: message, context: .init(result))
+ continuation.resume(returning: sendResult)
+ }
+ }
+ } else if session.isPairedAppInstalled {
+ // Use application context for background delivery
+ do {
+ try session.updateApplicationContext(message)
+ return ConnectivitySendResult(
+ message: message,
+ context: .applicationContext(transport: .dictionary)
+ )
+ } catch {
+ throw error
+ }
+ } else {
+ // No way to deliver the message - determine specific reason
+ // Check if devices are paired at all
+ if !session.isPaired {
+ if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
+ SundialLogger.stream.error(
+ "MessageRouter: Cannot send - devices not paired (isPaired=\(session.isPaired))"
+ )
+ }
+ throw ConnectivityError.deviceNotPaired
+ } else {
+ // Devices are paired but app not installed
+ if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
+ SundialLogger.stream.error(
+ // swiftlint:disable:next line_length
+ "MessageRouter: Cannot send - companion app not installed (isPaired=\(session.isPaired), isPairedAppInstalled=\(session.isPairedAppInstalled))"
+ )
+ }
+ throw ConnectivityError.companionAppNotInstalled
+ }
+ }
+ }
+
+ // MARK: - Binary Message Routing
+
+ /// Routes a binary message using sendMessageData.
+ ///
+ /// Binary messages require reachability and cannot use application context.
+ ///
+ /// - Parameters:
+ /// - data: The encoded binary message data
+ /// - originalMessage: The original message dictionary for result tracking
+ /// - Returns: The send result
+ /// - Throws: Error if the message cannot be sent or counterpart is not reachable
+ internal func sendBinary(
+ _ data: Data,
+ originalMessage: ConnectivityMessage
+ ) async throws -> ConnectivitySendResult {
+ guard session.isReachable else {
+ // Binary messages require reachability - can't use application context
+ if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
+ SundialLogger.stream.error(
+ // swiftlint:disable:next line_length
+ "MessageRouter: Cannot send binary - not reachable (isReachable=\(session.isReachable), isPaired=\(session.isPaired), isPairedAppInstalled=\(session.isPairedAppInstalled))"
+ )
+ }
+ throw ConnectivityError.notReachable
+ }
+
+ return try await withCheckedThrowingContinuation { continuation in
+ session.sendMessageData(data) { result in
+ switch result {
+ case .success:
+ // Note: Binary messages don't have reply data in current WatchConnectivity API
+ let sendResult = ConnectivitySendResult(
+ message: originalMessage,
+ context: .reply([:], transport: .binary)
+ )
+ continuation.resume(returning: sendResult)
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/SundialKitStream/NetworkObserver+Init.swift b/Sources/SundialKitStream/NetworkObserver+Init.swift
new file mode 100644
index 0000000..2680f93
--- /dev/null
+++ b/Sources/SundialKitStream/NetworkObserver+Init.swift
@@ -0,0 +1,76 @@
+//
+// NetworkObserver+Init.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+public import SundialKitNetwork
+
+// MARK: - Convenience Initializers
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+extension NetworkObserver where PingType == NeverPing {
+ /// Creates `NetworkObserver` without ping
+ public init(monitor: MonitorType) {
+ self.init(monitor: monitor, pingOrNil: nil)
+ }
+}
+
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+extension NetworkObserver {
+ /// Creates `NetworkObserver` with ping
+ public init(monitor: MonitorType, ping: PingType) {
+ self.init(monitor: monitor, pingOrNil: ping)
+ }
+}
+
+#if canImport(Network)
+ public import Network
+
+ @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *)
+ extension NetworkObserver where MonitorType == NWPathMonitor, PingType == NeverPing {
+ /// Creates `NetworkObserver` with default `NWPathMonitor` and no ping
+ ///
+ /// This is the simplest way to create a network observer for most use cases.
+ /// The observer uses Apple's `NWPathMonitor` to track network connectivity
+ /// changes without ping-based verification.
+ ///
+ /// ## Example Usage
+ ///
+ /// ```swift
+ /// import SundialKitStream
+ ///
+ /// let observer = NetworkObserver()
+ /// await observer.start(queue: .global())
+ ///
+ /// for await status in observer.pathStatusStream {
+ /// print("Network status: \(status)")
+ /// }
+ /// ```
+ public init() {
+ self.init(monitor: NWPathMonitor(), pingOrNil: nil)
+ }
+ }
+#endif
diff --git a/Sources/SundialKitStream/NetworkObserver.swift b/Sources/SundialKitStream/NetworkObserver.swift
new file mode 100644
index 0000000..41146c3
--- /dev/null
+++ b/Sources/SundialKitStream/NetworkObserver.swift
@@ -0,0 +1,213 @@
+//
+// NetworkObserver.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+public import Foundation
+public import SundialKitCore
+public import SundialKitNetwork
+
+/// Actor-based network connectivity observer providing AsyncStream APIs
+///
+/// `NetworkObserver` monitors network connectivity status using Swift concurrency.
+/// This is the modern async/await replacement for the Combine-based observer.
+///
+/// ## Example Usage
+///
+/// ```swift
+/// import SundialKitStream
+/// import SundialKitNetwork
+///
+/// let observer = NetworkObserver(
+/// monitor: NWPathMonitorAdapter(),
+/// ping: nil
+/// )
+///
+/// await observer.start(queue: .global())
+///
+/// // AsyncSequence API
+/// for await status in observer.pathStatusStream {
+/// print("Network status: \(status)")
+/// }
+/// ```
+///
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+public actor NetworkObserver {
+ // MARK: - Private Properties
+
+ private let ping: PingType?
+ private let monitor: MonitorType
+ private var currentPath: MonitorType.PathType?
+ private var currentPingStatus: PingType.StatusType?
+ private var pathContinuations: [UUID: AsyncStream.Continuation] = [:]
+ private var pingStatusContinuations: [UUID: AsyncStream.Continuation] = [:]
+
+ /// Stream of path status changes
+ public var pathStatusStream: AsyncStream {
+ AsyncStream { continuation in
+ Task {
+ for await path in pathUpdates() {
+ continuation.yield(path.pathStatus)
+ }
+ continuation.finish()
+ }
+ }
+ }
+
+ /// Stream of expensive state changes
+ public var isExpensiveStream: AsyncStream {
+ AsyncStream { continuation in
+ Task {
+ for await path in pathUpdates() {
+ continuation.yield(path.isExpensive)
+ }
+ continuation.finish()
+ }
+ }
+ }
+
+ /// Stream of constrained state changes
+ public var isConstrainedStream: AsyncStream {
+ AsyncStream { continuation in
+ Task {
+ for await path in pathUpdates() {
+ continuation.yield(path.isConstrained)
+ }
+ continuation.finish()
+ }
+ }
+ }
+
+ // MARK: - Initialization
+
+ internal init(monitor: MonitorType, pingOrNil: PingType?) {
+ self.monitor = monitor
+ self.ping = pingOrNil
+
+ // Setup callback from monitor
+ monitor.onPathUpdate { [weak self] path in
+ Task { await self?.handlePathUpdate(path) }
+ }
+ }
+
+ // MARK: - Public API
+
+ /// Starts monitoring network connectivity
+ /// - Parameter queue: The dispatch queue for network monitoring
+ public func start(queue: DispatchQueue) {
+ monitor.start(queue: queue)
+ }
+
+ /// Cancels network monitoring
+ public func cancel() {
+ monitor.cancel()
+
+ // Finish all active streams
+ for continuation in pathContinuations.values {
+ continuation.finish()
+ }
+ pathContinuations.removeAll()
+
+ for continuation in pingStatusContinuations.values {
+ continuation.finish()
+ }
+ pingStatusContinuations.removeAll()
+ }
+
+ /// Current network path snapshot
+ public func getCurrentPath() -> MonitorType.PathType? {
+ currentPath
+ }
+
+ /// Current ping status snapshot
+ public func getCurrentPingStatus() -> PingType.StatusType? {
+ currentPingStatus
+ }
+
+ // MARK: - AsyncStream APIs
+
+ /// Stream of path updates
+ public func pathUpdates() -> AsyncStream {
+ AsyncStream { continuation in
+ let id = UUID()
+ pathContinuations[id] = continuation
+
+ // Send current value immediately if available
+ if let currentPath = currentPath {
+ continuation.yield(currentPath)
+ }
+
+ continuation.onTermination = { [weak self] _ in
+ Task { await self?.removePathContinuation(id: id) }
+ }
+ }
+ }
+
+ /// Stream of ping status updates
+ public func pingStatusUpdates() -> AsyncStream {
+ AsyncStream { continuation in
+ let id = UUID()
+ pingStatusContinuations[id] = continuation
+
+ // Send current value immediately if available
+ if let currentPingStatus = currentPingStatus {
+ continuation.yield(currentPingStatus)
+ }
+
+ continuation.onTermination = { [weak self] _ in
+ Task { await self?.removePingStatusContinuation(id: id) }
+ }
+ }
+ }
+
+ // MARK: - Internal Handlers
+ private func handlePathUpdate(_ path: MonitorType.PathType) {
+ currentPath = path
+
+ // Notify all active path stream subscribers
+ for continuation in pathContinuations.values {
+ continuation.yield(path)
+ }
+ }
+
+ private func handlePingStatusUpdate(_ status: PingType.StatusType) {
+ currentPingStatus = status
+
+ // Notify all active ping status stream subscribers
+ for continuation in pingStatusContinuations.values {
+ continuation.yield(status)
+ }
+ }
+
+ private func removePathContinuation(id: UUID) {
+ pathContinuations.removeValue(forKey: id)
+ }
+
+ private func removePingStatusContinuation(id: UUID) {
+ pingStatusContinuations.removeValue(forKey: id)
+ }
+}
diff --git a/Sources/SundialKitStream/StateHandling+MessageHandling.swift b/Sources/SundialKitStream/StateHandling+MessageHandling.swift
new file mode 100644
index 0000000..8368f0e
--- /dev/null
+++ b/Sources/SundialKitStream/StateHandling+MessageHandling.swift
@@ -0,0 +1,120 @@
+//
+// StateHandling+MessageHandling.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+public import Foundation
+public import SundialKitConnectivity
+public import SundialKitCore
+
+extension StateHandling where Self: MessageHandling & Sendable {
+ // MARK: - ConnectivitySessionDelegate (nonisolated to receive callbacks)
+
+ /// Handles session activation completion.
+ nonisolated public func session(
+ _ session: any ConnectivitySession,
+ activationDidCompleteWith state: ActivationState,
+ error: (any Error)?
+ ) {
+ // Capture full session state snapshot at activation
+ Task {
+ await handleActivation(from: session, activationState: state, error: error)
+
+ // Check for pending application context that arrived while inactive
+ // This handles the case where updateApplicationContext was sent while the watch was unreachable
+ if error == nil, state == .activated, let pendingContext = session.receivedApplicationContext
+ {
+ await handleApplicationContext(pendingContext, error: nil)
+ }
+ }
+ }
+
+ /// Handles session becoming inactive.
+ nonisolated public func sessionDidBecomeInactive(_ session: any ConnectivitySession) {
+ Task {
+ await handleActivation(from: session, activationState: session.activationState, error: nil)
+ }
+ }
+
+ /// Handles session deactivation.
+ nonisolated public func sessionDidDeactivate(_ session: any ConnectivitySession) {
+ Task {
+ await handleActivation(from: session, activationState: session.activationState, error: nil)
+ }
+ }
+
+ /// Handles reachability changes.
+ nonisolated public func sessionReachabilityDidChange(_ session: any ConnectivitySession) {
+ Task {
+ await handleReachabilityChange(session.isReachable)
+
+ // Check for pending application context when becoming reachable
+ // This handles the case where updateApplicationContext was sent while unreachable
+ if session.isReachable, let pendingContext = session.receivedApplicationContext {
+ await handleApplicationContext(pendingContext, error: nil)
+ }
+ }
+ }
+
+ /// Handles companion device state changes.
+ nonisolated public func sessionCompanionStateDidChange(_ session: any ConnectivitySession) {
+ Task { await handleCompanionStateChange(session) }
+ }
+
+ /// Handles received messages with reply handler.
+ nonisolated public func session(
+ _: any ConnectivitySession,
+ didReceiveMessage message: ConnectivityMessage,
+ replyHandler: @escaping @Sendable ([String: any Sendable]) -> Void
+ ) {
+ Task {
+ await handleMessage(message, replyHandler: replyHandler)
+ }
+ }
+
+ /// Handles application context updates.
+ nonisolated public func session(
+ _: any ConnectivitySession,
+ didReceiveApplicationContext applicationContext: ConnectivityMessage,
+ error: (any Error)?
+ ) {
+ Task {
+ await handleApplicationContext(applicationContext, error: error)
+ }
+ }
+
+ /// Handles received binary message data.
+ nonisolated public func session(
+ _: any ConnectivitySession,
+ didReceiveMessageData messageData: Data,
+ replyHandler: @escaping @Sendable (Data) -> Void
+ ) {
+ Task {
+ await handleBinaryMessage(messageData, replyHandler: replyHandler)
+ }
+ }
+}
diff --git a/Sources/SundialKitStream/StateHandling.swift b/Sources/SundialKitStream/StateHandling.swift
new file mode 100644
index 0000000..0813ba7
--- /dev/null
+++ b/Sources/SundialKitStream/StateHandling.swift
@@ -0,0 +1,85 @@
+//
+// StateHandling.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+internal import Foundation
+internal import SundialKitConnectivity
+internal import SundialKitCore
+
+/// Protocol for types that handle connectivity state changes
+///
+/// Provides default implementations for common state handling patterns
+/// by delegating to a `ConnectivityStateManager`.
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+public protocol StateHandling {
+ /// The state manager responsible for tracking connectivity state
+ var stateManager: ConnectivityStateManager { get }
+}
+
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+extension StateHandling {
+ /// Handles activation with full session state snapshot
+ /// - Parameters:
+ /// - session: The connectivity session with current state
+ /// - activationState: The new activation state
+ /// - error: Optional error that occurred during activation
+ internal func handleActivation(
+ from session: any ConnectivitySession,
+ activationState: ActivationState,
+ error: (any Error)?
+ ) async {
+ await stateManager.handleActivation(
+ from: session,
+ activationState: activationState,
+ error: error
+ )
+ }
+
+ /// Handles activation state changes and errors (legacy)
+ /// - Parameters:
+ /// - activationState: The new activation state
+ /// - error: Optional error that occurred during activation
+ internal func handleActivation(_ activationState: ActivationState, error: (any Error)?) async {
+ await stateManager.handleActivation(activationState, error: error)
+ }
+
+ /// Handles reachability status changes
+ /// - Parameter isReachable: Whether the counterpart device is reachable
+ internal func handleReachabilityChange(_ isReachable: Bool) async {
+ await stateManager.updateReachability(isReachable)
+ }
+
+ /// Handles companion state changes (paired status, app installed status)
+ /// - Parameter session: The connectivity session with updated state
+ internal func handleCompanionStateChange(_ session: any ConnectivitySession) async {
+ await stateManager.updateCompanionState(
+ isPairedAppInstalled: session.isPairedAppInstalled,
+ isPaired: session.isPaired
+ )
+ }
+}
diff --git a/Sources/SundialKitStream/StreamContinuationManager+Messages.swift b/Sources/SundialKitStream/StreamContinuationManager+Messages.swift
new file mode 100644
index 0000000..a293e9d
--- /dev/null
+++ b/Sources/SundialKitStream/StreamContinuationManager+Messages.swift
@@ -0,0 +1,118 @@
+//
+// StreamContinuationManager+Messages.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import SundialKitConnectivity
+import SundialKitCore
+
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+extension StreamContinuationManager {
+ // MARK: - Message Received Registration & Removal
+
+ internal func registerMessageReceived(
+ id: UUID,
+ continuation: AsyncStream.Continuation
+ ) {
+ assert(
+ messageReceivedContinuations[id] == nil,
+ "Duplicate continuation registration for message received stream with ID: \(id)"
+ )
+ messageReceivedContinuations[id] = continuation
+ }
+
+ internal func removeMessageReceived(id: UUID) {
+ assert(
+ messageReceivedContinuations[id] != nil,
+ "Attempting to remove non-existent message received continuation with ID: \(id)"
+ )
+ messageReceivedContinuations.removeValue(forKey: id)
+ }
+
+ // MARK: - Typed Message Registration & Removal
+
+ internal func registerTypedMessage(
+ id: UUID,
+ continuation: AsyncStream.Continuation
+ ) {
+ assert(
+ typedMessageContinuations[id] == nil,
+ "Duplicate continuation registration for typed message stream with ID: \(id)"
+ )
+ typedMessageContinuations[id] = continuation
+ }
+
+ internal func removeTypedMessage(id: UUID) {
+ assert(
+ typedMessageContinuations[id] != nil,
+ "Attempting to remove non-existent typed message continuation with ID: \(id)"
+ )
+ typedMessageContinuations.removeValue(forKey: id)
+ }
+
+ // MARK: - Send Result Registration & Removal
+
+ internal func registerSendResult(
+ id: UUID,
+ continuation: AsyncStream.Continuation
+ ) {
+ assert(
+ sendResultContinuations[id] == nil,
+ "Duplicate continuation registration for send result stream with ID: \(id)"
+ )
+ sendResultContinuations[id] = continuation
+ }
+
+ internal func removeSendResult(id: UUID) {
+ assert(
+ sendResultContinuations[id] != nil,
+ "Attempting to remove non-existent send result continuation with ID: \(id)"
+ )
+ sendResultContinuations.removeValue(forKey: id)
+ }
+
+ // MARK: - Message Yielding
+
+ internal func yieldMessageReceived(_ result: ConnectivityReceiveResult) {
+ for continuation in messageReceivedContinuations.values {
+ continuation.yield(result)
+ }
+ }
+
+ internal func yieldTypedMessage(_ message: any Messagable) {
+ for continuation in typedMessageContinuations.values {
+ continuation.yield(message)
+ }
+ }
+
+ internal func yieldSendResult(_ result: ConnectivitySendResult) {
+ for continuation in sendResultContinuations.values {
+ continuation.yield(result)
+ }
+ }
+}
diff --git a/Sources/SundialKitStream/StreamContinuationManager+State.swift b/Sources/SundialKitStream/StreamContinuationManager+State.swift
new file mode 100644
index 0000000..badc3ca
--- /dev/null
+++ b/Sources/SundialKitStream/StreamContinuationManager+State.swift
@@ -0,0 +1,116 @@
+//
+// StreamContinuationManager+State.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+extension StreamContinuationManager {
+ // MARK: - Reachability Registration & Removal
+
+ internal func registerReachability(
+ id: UUID,
+ continuation: AsyncStream.Continuation
+ ) {
+ assert(
+ reachabilityContinuations[id] == nil,
+ "Duplicate continuation registration for reachability stream with ID: \(id)"
+ )
+ reachabilityContinuations[id] = continuation
+ }
+
+ internal func removeReachability(id: UUID) {
+ assert(
+ reachabilityContinuations[id] != nil,
+ "Attempting to remove non-existent reachability continuation with ID: \(id)"
+ )
+ reachabilityContinuations.removeValue(forKey: id)
+ }
+
+ // MARK: - Paired App Installed Registration & Removal
+
+ internal func registerPairedAppInstalled(
+ id: UUID,
+ continuation: AsyncStream.Continuation
+ ) {
+ assert(
+ pairedAppInstalledContinuations[id] == nil,
+ "Duplicate continuation registration for paired app installed stream with ID: \(id)"
+ )
+ pairedAppInstalledContinuations[id] = continuation
+ }
+
+ internal func removePairedAppInstalled(id: UUID) {
+ assert(
+ pairedAppInstalledContinuations[id] != nil,
+ "Attempting to remove non-existent paired app installed continuation with ID: \(id)"
+ )
+ pairedAppInstalledContinuations.removeValue(forKey: id)
+ }
+
+ // MARK: - Paired Registration & Removal
+
+ internal func registerPaired(
+ id: UUID,
+ continuation: AsyncStream.Continuation
+ ) {
+ assert(
+ pairedContinuations[id] == nil,
+ "Duplicate continuation registration for paired stream with ID: \(id)"
+ )
+ pairedContinuations[id] = continuation
+ }
+
+ internal func removePaired(id: UUID) {
+ assert(
+ pairedContinuations[id] != nil,
+ "Attempting to remove non-existent paired continuation with ID: \(id)"
+ )
+ pairedContinuations.removeValue(forKey: id)
+ }
+
+ // MARK: - State Yielding
+
+ internal func yieldReachability(_ isReachable: Bool) {
+ for continuation in reachabilityContinuations.values {
+ continuation.yield(isReachable)
+ }
+ }
+
+ internal func yieldPairedAppInstalled(_ isPairedAppInstalled: Bool) {
+ for continuation in pairedAppInstalledContinuations.values {
+ continuation.yield(isPairedAppInstalled)
+ }
+ }
+
+ internal func yieldPaired(_ isPaired: Bool) {
+ for continuation in pairedContinuations.values {
+ continuation.yield(isPaired)
+ }
+ }
+}
diff --git a/Sources/SundialKitStream/StreamContinuationManager.swift b/Sources/SundialKitStream/StreamContinuationManager.swift
new file mode 100644
index 0000000..a690b0c
--- /dev/null
+++ b/Sources/SundialKitStream/StreamContinuationManager.swift
@@ -0,0 +1,109 @@
+//
+// StreamContinuationManager.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+public import Foundation
+public import SundialKitConnectivity
+public import SundialKitCore
+
+/// Manages AsyncStream continuations for ConnectivityObserver
+///
+/// This type centralizes all continuation management, providing
+/// registration, removal, and yielding capabilities for all stream types.
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+internal actor StreamContinuationManager {
+ // MARK: - Continuation Storage
+
+ internal var activationContinuations: [UUID: AsyncStream.Continuation] = [:]
+ internal var activationCompletionContinuations:
+ [UUID: AsyncStream>.Continuation] = [:]
+ internal var reachabilityContinuations: [UUID: AsyncStream.Continuation] = [:]
+ internal var pairedAppInstalledContinuations: [UUID: AsyncStream.Continuation] = [:]
+ internal var pairedContinuations: [UUID: AsyncStream.Continuation] = [:]
+ internal var messageReceivedContinuations:
+ [UUID: AsyncStream.Continuation] = [:]
+ internal var typedMessageContinuations: [UUID: AsyncStream.Continuation] = [:]
+ internal var sendResultContinuations: [UUID: AsyncStream.Continuation] =
+ [:]
+
+ // MARK: - Registration
+
+ internal func registerActivation(
+ id: UUID,
+ continuation: AsyncStream.Continuation
+ ) {
+ assert(
+ activationContinuations[id] == nil,
+ "Duplicate continuation registration for activation stream with ID: \(id)"
+ )
+ activationContinuations[id] = continuation
+ }
+
+ internal func registerActivationCompletion(
+ id: UUID,
+ continuation: AsyncStream>.Continuation
+ ) {
+ assert(
+ activationCompletionContinuations[id] == nil,
+ "Duplicate continuation registration for activation completion stream with ID: \(id)"
+ )
+ activationCompletionContinuations[id] = continuation
+ }
+
+ // MARK: - Removal
+
+ internal func removeActivation(id: UUID) {
+ assert(
+ activationContinuations[id] != nil,
+ "Attempting to remove non-existent activation continuation with ID: \(id)"
+ )
+ activationContinuations.removeValue(forKey: id)
+ }
+
+ internal func removeActivationCompletion(id: UUID) {
+ assert(
+ activationCompletionContinuations[id] != nil,
+ "Attempting to remove non-existent activation completion continuation with ID: \(id)"
+ )
+ activationCompletionContinuations.removeValue(forKey: id)
+ }
+
+ // MARK: - Yielding Values
+
+ internal func yieldActivationState(_ state: ActivationState) {
+ for continuation in activationContinuations.values {
+ continuation.yield(state)
+ }
+ }
+
+ internal func yieldActivationCompletion(_ result: Result) {
+ for continuation in activationCompletionContinuations.values {
+ continuation.yield(result)
+ }
+ }
+}
diff --git a/Sources/SundialKitStream/StreamContinuationRegistry.swift b/Sources/SundialKitStream/StreamContinuationRegistry.swift
new file mode 100644
index 0000000..1f98ae8
--- /dev/null
+++ b/Sources/SundialKitStream/StreamContinuationRegistry.swift
@@ -0,0 +1,128 @@
+//
+// StreamContinuationRegistry.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+
+/// Generic registry for managing AsyncStream continuations.
+///
+/// `StreamContinuationRegistry` provides a centralized way to manage multiple
+/// active stream continuations, handling registration, removal, and value
+/// propagation. It is designed to be used within actors where actor isolation
+/// provides thread safety.
+///
+/// ## Usage Example
+///
+/// ```swift
+/// actor MyObserver {
+/// private let stateRegistry = StreamContinuationRegistry()
+///
+/// func stateStream() -> AsyncStream {
+/// stateRegistry.createStream(initialValue: currentState)
+/// }
+///
+/// private func updateState(_ newState: String) {
+/// stateRegistry.yield(newState)
+/// }
+/// }
+/// ```
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+internal struct StreamContinuationRegistry where Element: Sendable {
+ // MARK: - Properties
+
+ private var continuations: [UUID: AsyncStream.Continuation] = [:]
+
+ /// Returns the number of active continuations.
+ internal var count: Int {
+ continuations.count
+ }
+
+ internal var isEmpty: Bool {
+ continuations.isEmpty
+ }
+ // MARK: - Initialization
+
+ /// Creates a new stream continuation registry.
+ internal init() {}
+
+ // MARK: - Methods
+
+ /// Creates a new AsyncStream and registers its continuation.
+ ///
+ /// - Parameter initialValue: Optional value to yield immediately upon subscription
+ /// - Returns: A tuple containing the stream and a removal callback
+ internal mutating func createStream(
+ initialValue: Element? = nil
+ ) -> (stream: AsyncStream, onTermination: @Sendable () -> Void) {
+ let id = UUID()
+
+ let stream = AsyncStream { continuation in
+ continuations[id] = continuation
+
+ // Yield initial value if provided
+ if let initialValue = initialValue {
+ continuation.yield(initialValue)
+ }
+
+ continuation.onTermination = { [id] _ in
+ // Remove continuation on termination
+ // Note: This closure captures id, but removal happens in the actor context
+ }
+ }
+
+ let removal: @Sendable () -> Void = { [id] in
+ // This will be called from the actor context
+ }
+
+ return (stream: stream, onTermination: removal)
+ }
+
+ /// Removes a continuation by its ID.
+ ///
+ /// - Parameter id: The unique identifier of the continuation to remove
+ internal mutating func remove(id: UUID) {
+ continuations.removeValue(forKey: id)
+ }
+
+ /// Yields a value to all registered continuations.
+ ///
+ /// - Parameter value: The value to yield to all active streams
+ internal func yield(_ value: Element) {
+ for continuation in continuations.values {
+ continuation.yield(value)
+ }
+ }
+
+ /// Finishes all registered continuations.
+ internal mutating func finishAll() {
+ for continuation in continuations.values {
+ continuation.finish()
+ }
+ continuations.removeAll()
+ }
+}
diff --git a/Sources/SundialKitStream/SundialKitStream.docc/Documentation.md b/Sources/SundialKitStream/SundialKitStream.docc/Documentation.md
new file mode 100644
index 0000000..727584e
--- /dev/null
+++ b/Sources/SundialKitStream/SundialKitStream.docc/Documentation.md
@@ -0,0 +1,520 @@
+# ``SundialKitStream``
+
+Modern async/await observation plugin for SundialKit with actor-based concurrency safety.
+
+## Overview
+
+
+
+SundialKitStream provides actor-based observers that deliver state updates via AsyncStream APIs. This plugin is designed for Swift 6.1+ projects using modern concurrency patterns, offering natural thread safety through Swift's actor isolation model and seamless integration with async/await code.
+
+### Why Choose SundialKitStream
+
+If you're building a modern Swift application that embraces async/await and structured concurrency, SundialKitStream is the ideal choice. It leverages Swift's actor isolation to provide thread-safe state management without locks, mutexes, or manual synchronization. The AsyncStream-based APIs integrate naturally with async/await code, making it easy to consume network and connectivity updates in Task contexts.
+
+**Choose SundialKitStream when you:**
+- Want to use modern async/await patterns throughout your app
+- Need actor-based thread safety without @unchecked Sendable
+- Prefer consuming updates with `for await` loops
+- Target iOS 16+ / watchOS 9+ / tvOS 16+ / macOS 13+
+- Value compile-time concurrency safety with Swift 6.1 strict mode
+
+### Key Features
+
+- **Actor Isolation**: Natural thread safety without locks or manual synchronization
+- **AsyncStream APIs**: Consume state updates with `for await` loops in async contexts
+- **Swift 6.1 Strict Concurrency**: Zero `@unchecked Sendable` conformances - everything is properly isolated
+- **Composable**: Works seamlessly with SundialKitNetwork and SundialKitConnectivity
+- **Structured Concurrency**: AsyncStreams integrate naturally with Task hierarchies and cancellation
+
+### Requirements
+
+- Swift 6.1+
+- iOS 16+ / watchOS 9+ / tvOS 16+ / macOS 13+
+
+### Getting Started
+
+Add SundialKit to your `Package.swift`:
+
+```swift
+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"), // For network monitoring
+ .product(name: "SundialKitConnectivity", package: "SundialKit") // For WatchConnectivity
+ ]
+ )
+]
+```
+
+## Network Monitoring
+
+Monitor network connectivity changes using the actor-based ``NetworkObserver``. The observer tracks network path status, connection quality (expensive, constrained), and optionally performs periodic connectivity verification with custom ping implementations.
+
+### Basic Network Monitoring
+
+The simplest way to monitor network connectivity is to create a NetworkObserver and consume its AsyncStream APIs:
+
+```swift
+import SundialKitStream
+import SundialKitNetwork
+
+@MainActor
+@Observable
+class NetworkModel {
+ var pathStatus: PathStatus = .unknown
+ var isExpensive: Bool = false
+
+ private let observer = NetworkObserver(
+ monitor: NWPathMonitorAdapter(),
+ ping: nil
+ )
+
+ func start() {
+ observer.start(queue: .global())
+
+ // Listen to path status updates
+ 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
+ }
+ }
+ }
+}
+```
+
+The `NWPathMonitorAdapter` wraps Apple's `NWPathMonitor` from the Network framework, providing updates whenever the network path changes (WiFi connects/disconnects, cellular becomes available, etc.).
+
+### Understanding PathStatus
+
+The ``PathStatus`` enum represents the current state of the network path:
+
+- **`.satisfied`** - Network is available and ready to use
+- **`.unsatisfied`** - No network connectivity
+- **`.requiresConnection`** - Network may be available but requires user action (e.g., connecting to WiFi)
+- **`.unknown`** - Initial state before first update
+
+### Monitoring Connection Quality
+
+Beyond basic connectivity, you can track whether the current network connection is expensive (cellular data) or constrained (low data mode):
+
+```swift
+// Monitor all quality indicators
+Task {
+ for await isExpensive in observer.isExpensiveStream {
+ if isExpensive {
+ // User is on cellular data - consider reducing data usage
+ print("Warning: Using cellular data")
+ }
+ }
+}
+
+Task {
+ for await isConstrained in observer.isConstrainedStream {
+ if isConstrained {
+ // User has Low Data Mode enabled - minimize data usage
+ print("Low Data Mode active")
+ }
+ }
+}
+```
+
+This information helps you build adaptive applications that respect users' data plans and preferences.
+
+## WatchConnectivity Communication
+
+Communicate between iPhone and Apple Watch using the actor-based ``ConnectivityObserver``. The observer manages the WatchConnectivity session lifecycle, handles automatic transport selection, and provides type-safe messaging through AsyncStream APIs.
+
+### Session Activation
+
+Before sending or receiving messages, you must activate the WatchConnectivity session:
+
+```swift
+import SundialKitStream
+import SundialKitConnectivity
+
+actor WatchCommunicator {
+ private let observer = ConnectivityObserver()
+
+ func activate() async throws {
+ try await observer.activate()
+ }
+
+ func listenForMessages() async {
+ for await result in observer.messageStream() {
+ switch result.context {
+ case .replyWith(let handler):
+ print("Received: \(result.message)")
+ handler(["response": "acknowledged"])
+ case .applicationContext:
+ print("Context update: \(result.message)")
+ }
+ }
+ }
+
+ func sendMessage(_ message: ConnectivityMessage) async throws -> ConnectivitySendResult {
+ try await observer.sendMessage(message)
+ }
+}
+```
+
+The `activate()` method initializes the WatchConnectivity session and waits for it to become ready. Once activated, you can send and receive messages.
+
+### Message Contexts
+
+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.
+
+This distinction helps you build responsive communication patterns - interactive messages for user-initiated actions, context updates for background state synchronization.
+
+### Activation State Monitoring
+
+Track the WatchConnectivity session's activation state to understand when communication is possible:
+
+```swift
+Task {
+ for await state in observer.activationStates() {
+ switch state {
+ case .activated:
+ print("Session active - can communicate")
+ case .inactive:
+ print("Session inactive - waiting for activation")
+ case .notActivated:
+ print("Session not yet activated")
+ }
+ }
+}
+```
+
+### Reachability Monitoring
+
+Know when the counterpart device (iPhone or Apple Watch) is currently reachable for immediate communication:
+
+```swift
+Task {
+ for await isReachable in observer.reachabilityStream() {
+ if isReachable {
+ print("Counterpart is reachable - messages will be delivered immediately")
+ } else {
+ print("Counterpart unreachable - messages queued for later delivery")
+ }
+ }
+}
+```
+
+Reachability affects message transport - when devices are reachable, messages are sent immediately with `sendMessage`. When unreachable, messages are queued with `updateApplicationContext` for delivery when communication resumes.
+
+## SwiftUI Integration
+
+SundialKitStream works beautifully with SwiftUI through the `@Observable` macro. This pattern gives you actor-safe state management with minimal boilerplate:
+
+```swift
+import SwiftUI
+import SundialKitStream
+import SundialKitNetwork
+
+@MainActor
+@Observable
+class ConnectivityModel {
+ var pathStatus: PathStatus = .unknown
+ var isExpensive: Bool = false
+ var isConstrained: Bool = false
+
+ private let observer = NetworkObserver(
+ monitor: NWPathMonitorAdapter(),
+ ping: nil
+ )
+
+ func start() {
+ observer.start(queue: .global())
+
+ Task {
+ for await status in observer.pathStatusStream {
+ self.pathStatus = status
+ }
+ }
+
+ Task {
+ for await expensive in observer.isExpensiveStream {
+ self.isExpensive = expensive
+ }
+ }
+
+ Task {
+ for await constrained in observer.isConstrainedStream {
+ self.isConstrained = constrained
+ }
+ }
+ }
+}
+
+struct NetworkStatusView: View {
+ @State private var model = ConnectivityModel()
+
+ 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()
+ }
+ }
+}
+```
+
+## Type-Safe Messaging
+
+SundialKitConnectivity provides two protocols for defining custom message types: ``Messagable`` for dictionary-based messages and ``BinaryMessagable`` for efficient binary serialization. Both work seamlessly with ConnectivityObserver to provide compile-time type safety for your iPhone-Apple Watch communication.
+
+### Dictionary-Based Messages with Messagable
+
+The ``Messagable`` protocol enables type-safe message encoding and decoding. Instead of working with raw dictionaries, you define custom message types that are automatically serialized and deserialized:
+
+```swift
+import SundialKitConnectivity
+
+struct ColorMessage: Messagable {
+ static let key = "color" // Identifier for this message type
+
+ let red: Double
+ let green: Double
+ let blue: Double
+
+ init(red: Double, green: Double, blue: Double) {
+ self.red = red
+ self.green = green
+ self.blue = blue
+ }
+
+ init(from parameters: [String: any Sendable]) throws {
+ guard let red = parameters["red"] as? Double,
+ let green = parameters["green"] as? Double,
+ let blue = parameters["blue"] as? Double else {
+ throw SerializationError.missingField("color components")
+ }
+ self.red = red
+ self.green = green
+ self.blue = blue
+ }
+
+ func parameters() -> [String: any Sendable] {
+ ["red": red, "green": green, "blue": blue]
+ }
+}
+```
+
+The `key` property identifies the message type, allowing the receiver to route it to the correct handler. The `parameters()` method converts your type to a dictionary, and the `init(from:)` initializer reconstructs it from received data.
+
+### Binary Serialization with BinaryMessagable
+
+For larger datasets or complex data structures, ``BinaryMessagable`` provides efficient binary serialization. This approach works seamlessly with Protocol Buffers, MessagePack, or any custom binary format:
+
+```swift
+import SundialKitConnectivity
+import SwiftProtobuf
+
+// Extend your Protobuf-generated type
+extension UserProfile: BinaryMessagable {
+ // key defaults to "UserProfile" (type name)
+
+ public init(from data: Data) throws {
+ try self.init(serializedData: data) // SwiftProtobuf decoder
+ }
+
+ public func encode() throws -> Data {
+ try serializedData() // SwiftProtobuf encoder
+ }
+
+ // init(from parameters:) and parameters() auto-implemented!
+}
+```
+
+**Custom binary format example:**
+
+```swift
+struct TemperatureReading: BinaryMessagable {
+ let celsius: Float
+ let timestamp: UInt64
+
+ init(celsius: Float, timestamp: UInt64) {
+ self.celsius = celsius
+ self.timestamp = timestamp
+ }
+
+ public init(from data: Data) throws {
+ guard data.count == 12 else { // 4 + 8 bytes
+ throw SerializationError.invalidDataSize
+ }
+ celsius = data.withUnsafeBytes { $0.load(as: Float.self) }
+ timestamp = data.dropFirst(4).withUnsafeBytes { $0.load(as: UInt64.self) }
+ }
+
+ public func encode() throws -> Data {
+ var data = Data()
+ withUnsafeBytes(of: celsius) { data.append(contentsOf: $0) }
+ withUnsafeBytes(of: timestamp) { data.append(contentsOf: $0) }
+ return data
+ }
+}
+```
+
+### SwiftUI Integration with AsyncStreams
+
+Here's a complete example showing how to use custom message types with SwiftUI and async/await:
+
+```swift
+import SwiftUI
+import SundialKitStream
+import SundialKitConnectivity
+
+@MainActor
+@Observable
+class WatchMessenger {
+ let observer: ConnectivityObserver
+
+ var receivedColor: Color?
+ var isReachable: Bool = false
+ var activationState: ActivationState = .notActivated
+
+ init() {
+ // Create actor-based observer with message decoder
+ observer = ConnectivityObserver(
+ messageDecoder: MessageDecoder(messagableTypes: [
+ ColorMessage.self,
+ TemperatureReading.self
+ ])
+ )
+ }
+
+ func start() {
+ // Listen for typed messages using AsyncStream
+ Task {
+ for await message in await observer.typedMessageStream() {
+ if let colorMessage = message as? ColorMessage {
+ receivedColor = Color(
+ red: colorMessage.red,
+ green: colorMessage.green,
+ blue: colorMessage.blue
+ )
+ } else if let temp = message as? TemperatureReading {
+ print("Temperature: \(temp.celsius)°C at \(temp.timestamp)")
+ }
+ }
+ }
+
+ // Monitor reachability
+ Task {
+ for await reachable in await observer.reachabilityStream() {
+ isReachable = reachable
+ }
+ }
+
+ // Monitor activation state
+ Task {
+ for await state in await observer.activationStates() {
+ activationState = state
+ }
+ }
+ }
+
+ func activate() async throws {
+ try await observer.activate()
+ }
+
+ func sendColor(red: Double, green: Double, blue: Double) async throws {
+ let message = ColorMessage(red: red, green: green, blue: blue)
+ let result = try await observer.send(message)
+ print("Sent via: \(result.context)")
+ }
+}
+
+struct WatchColorView: View {
+ @State private var messenger = WatchMessenger()
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Text("WatchConnectivity")
+ .font(.headline)
+
+ Text("Session: \(messenger.activationState.description)")
+ Text("Reachable: \(messenger.isReachable ? "Yes" : "No")")
+
+ if let color = messenger.receivedColor {
+ Rectangle()
+ .fill(color)
+ .frame(width: 100, height: 100)
+ .cornerRadius(10)
+
+ Text("Received Color")
+ .font(.caption)
+ }
+
+ Button("Send Red") {
+ Task {
+ try? await messenger.sendColor(red: 1.0, green: 0.0, blue: 0.0)
+ }
+ }
+ .disabled(!messenger.isReachable)
+
+ Button("Send Blue") {
+ Task {
+ try? await messenger.sendColor(red: 0.0, green: 0.0, blue: 1.0)
+ }
+ }
+ .disabled(!messenger.isReachable)
+ }
+ .padding()
+ .task {
+ try? await messenger.activate()
+ messenger.start()
+ }
+ }
+}
+```
+
+This example demonstrates:
+- Creating a `MessageDecoder` with multiple custom message types
+- Using AsyncStreams to consume typed messages, reachability, and activation state
+- Converting received messages to SwiftUI views with the `@Observable` macro
+- Sending type-safe messages from button actions
+- Automatic UI updates when messages arrive or session state changes
+- Structured concurrency with the `.task` modifier for lifecycle management
+
+> Important: Dictionary-based messages have a size limit of approximately 65KB. For larger data, use ``BinaryMessagable`` for efficient serialization or consider file transfer methods.
+
+## Topics
+
+### Network Monitoring
+
+- ``NetworkObserver``
+
+### WatchConnectivity
+
+- ``ConnectivityObserver``
+- ``ConnectivityStateManager``
+
+### Message Distribution
+
+- ``MessageDistributor``
+
+### Protocols
+
+- ``StateHandling``
+- ``MessageHandling``
diff --git a/Sources/SundialKitStream/SundialKitStream.docc/Resources/logo.png b/Sources/SundialKitStream/SundialKitStream.docc/Resources/logo.png
new file mode 100644
index 0000000..07b058b
Binary files /dev/null and b/Sources/SundialKitStream/SundialKitStream.docc/Resources/logo.png differ
diff --git a/Sources/SundialKitStream/SundialLogger.swift b/Sources/SundialKitStream/SundialLogger.swift
new file mode 100644
index 0000000..c8a7f7d
--- /dev/null
+++ b/Sources/SundialKitStream/SundialLogger.swift
@@ -0,0 +1,185 @@
+//
+// SundialLogger.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+
+#if canImport(os.log)
+ import os.log
+#endif
+
+// swiftlint:disable file_types_order
+
+#if canImport(os.log)
+ /// Unified logging infrastructure for SundialKit
+ ///
+ /// Provides subsystem-based structured logging using OSLog/Logger framework.
+ /// Each SundialKit module has its own subsystem for organized log filtering.
+ @available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *)
+ internal enum SundialLogger {
+ /// Core protocols and types
+ internal static let core = Logger(
+ subsystem: "com.brightdigit.SundialKit.Core",
+ category: "core"
+ )
+
+ /// Network monitoring (PathMonitor, NetworkPing)
+ internal static let network = Logger(
+ subsystem: "com.brightdigit.SundialKit.Network",
+ category: "network"
+ )
+
+ /// WatchConnectivity abstractions
+ internal static let connectivity = Logger(
+ subsystem: "com.brightdigit.SundialKit.Connectivity",
+ category: "connectivity"
+ )
+
+ /// Stream-based observers (actor-based AsyncStream APIs)
+ internal static let stream = Logger(
+ subsystem: "com.brightdigit.SundialKit.Stream",
+ category: "stream"
+ )
+
+ /// Combine-based observers (@MainActor with publishers)
+ internal static let combine = Logger(
+ subsystem: "com.brightdigit.SundialKit.Combine",
+ category: "combine"
+ )
+
+ /// Binary message encoding/decoding
+ internal static let binary = Logger(
+ subsystem: "com.brightdigit.SundialKit.Binary",
+ category: "binary"
+ )
+
+ /// Messagable protocol and message decoding
+ internal static let messagable = Logger(
+ subsystem: "com.brightdigit.SundialKit.Messagable",
+ category: "messagable"
+ )
+
+ /// Test infrastructure
+ internal static let test = Logger(
+ subsystem: "com.brightdigit.SundialKit.Tests",
+ category: "tests"
+ )
+
+ /// Create a custom logger for specific categories
+ /// - Parameters:
+ /// - subsystem: Reverse DNS notation subsystem identifier
+ /// - category: Category within the subsystem
+ /// - Returns: Configured Logger instance
+ internal static func custom(subsystem: String, category: String) -> Logger {
+ Logger(subsystem: subsystem, category: category)
+ }
+ }
+#else
+ // MARK: - Fallback for non-Apple platforms (Linux, Windows)
+
+ /// Print-based logging fallback for platforms without OSLog
+ ///
+ /// Provides the same API as SundialLogger but uses print() for output
+ internal enum SundialLogger {
+ /// Fallback logger that prints to stdout
+ internal struct FallbackLogger {
+ internal let subsystem: String
+ internal let category: String
+
+ internal func error(_ message: String) {
+ print("[\(subsystem):\(category)] ERROR: \(message)")
+ }
+
+ internal func info(_ message: String) {
+ print("[\(subsystem):\(category)] INFO: \(message)")
+ }
+
+ internal func debug(_ message: String) {
+ print("[\(subsystem):\(category)] DEBUG: \(message)")
+ }
+ }
+
+ /// Core protocols and types
+ internal static let core = FallbackLogger(
+ subsystem: "com.brightdigit.SundialKit.Core",
+ category: "core"
+ )
+
+ /// Network monitoring (PathMonitor, NetworkPing)
+ internal static let network = FallbackLogger(
+ subsystem: "com.brightdigit.SundialKit.Network",
+ category: "network"
+ )
+
+ /// WatchConnectivity abstractions
+ internal static let connectivity = FallbackLogger(
+ subsystem: "com.brightdigit.SundialKit.Connectivity",
+ category: "connectivity"
+ )
+
+ /// Stream-based observers (actor-based AsyncStream APIs)
+ internal static let stream = FallbackLogger(
+ subsystem: "com.brightdigit.SundialKit.Stream",
+ category: "stream"
+ )
+
+ /// Combine-based observers (@MainActor with publishers)
+ internal static let combine = FallbackLogger(
+ subsystem: "com.brightdigit.SundialKit.Combine",
+ category: "combine"
+ )
+
+ /// Binary message encoding/decoding
+ internal static let binary = FallbackLogger(
+ subsystem: "com.brightdigit.SundialKit.Binary",
+ category: "binary"
+ )
+
+ /// Messagable protocol and message decoding
+ internal static let messagable = FallbackLogger(
+ subsystem: "com.brightdigit.SundialKit.Messagable",
+ category: "messagable"
+ )
+
+ /// Test infrastructure
+ internal static let test = FallbackLogger(
+ subsystem: "com.brightdigit.SundialKit.Tests",
+ category: "tests"
+ )
+
+ /// Create a custom logger for specific categories
+ /// - Parameters:
+ /// - subsystem: Reverse DNS notation subsystem identifier
+ /// - category: Category within the subsystem
+ /// - Returns: Configured FallbackLogger instance
+ internal static func custom(subsystem: String, category: String) -> FallbackLogger {
+ FallbackLogger(subsystem: subsystem, category: category)
+ }
+ }
+#endif
+// swiftlint:enable file_types_order
diff --git a/Tests/SundialKitStreamTests/ConnectivityStateManager.InitializationTests.swift b/Tests/SundialKitStreamTests/ConnectivityStateManager.InitializationTests.swift
new file mode 100644
index 0000000..5cac69a
--- /dev/null
+++ b/Tests/SundialKitStreamTests/ConnectivityStateManager.InitializationTests.swift
@@ -0,0 +1,198 @@
+//
+// ConnectivityStateManagerTests+Initialization.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension ConnectivityStateManager {
+ @Suite("Initialization and Activation Tests")
+ internal struct InitializationTests {
+ // MARK: - Initialization Tests
+
+ @Test("Initial state is correct")
+ internal func initialState() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+
+ let state = await stateManager.currentState
+
+ #expect(state.activationState == nil)
+ #expect(state.activationError == nil)
+ #expect(state.isReachable == false)
+ #expect(state.isPairedAppInstalled == false)
+ #expect(state.isPaired == false)
+ }
+
+ @Test("ConnectivityState.initial constant has expected values")
+ internal func connectivityStateInitial() {
+ let initial = ConnectivityState.initial
+
+ #expect(initial.activationState == nil)
+ #expect(initial.activationError == nil)
+ #expect(initial.isReachable == false)
+ #expect(initial.isPairedAppInstalled == false)
+ #expect(initial.isPaired == false)
+ }
+
+ // MARK: - Activation Handling Tests
+
+ @Test("Handle activation with .activated state captures session snapshot")
+ internal func handleActivationActivated() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ // Set up session state
+ session.activationState = .activated
+ session.isReachable = true
+ session.isPairedAppInstalled = true
+ session.isPaired = true
+
+ await stateManager.handleActivation(
+ from: session,
+ activationState: .activated,
+ error: nil
+ )
+
+ let state = await stateManager.currentState
+
+ #expect(state.activationState == .activated)
+ #expect(state.activationError == nil)
+ #expect(state.isReachable == true)
+ #expect(state.isPairedAppInstalled == true)
+ #if os(iOS)
+ #expect(state.isPaired == true)
+ #else
+ // watchOS always reports isPaired as false internally
+ #expect(state.isPaired == false)
+ #endif
+ }
+
+ @Test("Handle activation with .inactive state")
+ internal func handleActivationInactive() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ session.activationState = .inactive
+
+ await stateManager.handleActivation(
+ from: session,
+ activationState: .inactive,
+ error: nil
+ )
+
+ let activationState = await stateManager.activationState
+ #expect(activationState == .inactive)
+ }
+
+ @Test("Handle activation with .notActivated state")
+ internal func handleActivationNotActivated() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ session.activationState = .notActivated
+
+ await stateManager.handleActivation(
+ from: session,
+ activationState: .notActivated,
+ error: nil
+ )
+
+ let activationState = await stateManager.activationState
+ #expect(activationState == .notActivated)
+ }
+
+ @Test("Handle activation with error stores error")
+ internal func handleActivationWithError() async {
+ struct TestError: Error, Sendable {}
+
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ session.activationState = .inactive
+
+ await stateManager.handleActivation(
+ from: session,
+ activationState: .inactive,
+ error: TestError()
+ )
+
+ let activationError = await stateManager.activationError
+ #expect(activationError != nil)
+ #expect(activationError is TestError)
+ }
+
+ // MARK: - Legacy Activation Method Tests
+
+ @Test("Legacy handleActivation preserves existing state")
+ internal func legacyHandleActivation() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ // First set up state with reachability
+ session.isReachable = true
+ session.isPairedAppInstalled = true
+ session.isPaired = true
+ await stateManager.handleActivation(from: session, activationState: .activated, error: nil)
+
+ // Now use legacy method to change only activation state
+ await stateManager.handleActivation(.inactive, error: nil)
+
+ let state = await stateManager.currentState
+
+ #expect(state.activationState == .inactive)
+ // These should be preserved from first call
+ #expect(state.isReachable == true)
+ #expect(state.isPairedAppInstalled == true)
+ #if os(iOS)
+ #expect(state.isPaired == true)
+ #endif
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/ConnectivityStateManager.State.ConsistencyTests.swift b/Tests/SundialKitStreamTests/ConnectivityStateManager.State.ConsistencyTests.swift
new file mode 100644
index 0000000..dfb67ca
--- /dev/null
+++ b/Tests/SundialKitStreamTests/ConnectivityStateManager.State.ConsistencyTests.swift
@@ -0,0 +1,70 @@
+//
+// ConnectivityStateManager.State.ConsistencyTests.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension ConnectivityStateManager.State {
+ @Suite("Consistency Tests")
+ internal struct ConsistencyTests {
+ @Test("State snapshot is consistent across all properties")
+ internal func stateSnapshotConsistency() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ // Set up complete session state
+ session.activationState = .activated
+ session.isReachable = true
+ session.isPairedAppInstalled = true
+ session.isPaired = true
+
+ await stateManager.handleActivation(from: session, activationState: .activated, error: nil)
+
+ let state = await stateManager.currentState
+
+ // All properties should match session state
+ #expect(state.activationState == .activated)
+ #expect(state.activationError == nil)
+ #expect(state.isReachable == true)
+ #expect(state.isPairedAppInstalled == true)
+ #if os(iOS)
+ #expect(state.isPaired == true)
+ #else
+ #expect(state.isPaired == false)
+ #endif
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/ConnectivityStateManager.State.PropertyTests.swift b/Tests/SundialKitStreamTests/ConnectivityStateManager.State.PropertyTests.swift
new file mode 100644
index 0000000..3448cfe
--- /dev/null
+++ b/Tests/SundialKitStreamTests/ConnectivityStateManager.State.PropertyTests.swift
@@ -0,0 +1,121 @@
+//
+// ConnectivityStateManager.State.PropertyTests.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension ConnectivityStateManager.State {
+ @Suite("Property Tests")
+ internal struct PropertyTests {
+ @Test("activationState getter returns correct value")
+ internal func activationStateGetter() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ await stateManager.handleActivation(from: session, activationState: .activated, error: nil)
+
+ let activationState = await stateManager.activationState
+ #expect(activationState == .activated)
+ }
+
+ @Test("activationError getter returns correct value")
+ internal func activationErrorGetter() async {
+ struct TestError: Error, Sendable {}
+
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ await stateManager.handleActivation(
+ from: session,
+ activationState: .inactive,
+ error: TestError()
+ )
+
+ let activationError = await stateManager.activationError
+ #expect(activationError is TestError)
+ }
+
+ @Test("isReachable getter returns correct value")
+ internal func isReachableGetter() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ session.isReachable = true
+ await stateManager.handleActivation(from: session, activationState: .activated, error: nil)
+
+ let isReachable = await stateManager.isReachable
+ #expect(isReachable == true)
+ }
+
+ @Test("isPairedAppInstalled getter returns correct value")
+ internal func isPairedAppInstalledGetter() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ session.isPairedAppInstalled = true
+ await stateManager.handleActivation(from: session, activationState: .activated, error: nil)
+
+ let isPairedAppInstalled = await stateManager.isPairedAppInstalled
+ #expect(isPairedAppInstalled == true)
+ }
+
+ #if os(iOS)
+ @Test("isPaired getter returns correct value on iOS")
+ internal func isPairedGetter() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ session.isPaired = true
+ await stateManager.handleActivation(from: session, activationState: .activated, error: nil)
+
+ let isPaired = await stateManager.isPaired
+ #expect(isPaired == true)
+ }
+ #endif
+ }
+}
diff --git a/Tests/SundialKitStreamTests/ConnectivityStateManager.State.UpdateTests.swift b/Tests/SundialKitStreamTests/ConnectivityStateManager.State.UpdateTests.swift
new file mode 100644
index 0000000..eea67fc
--- /dev/null
+++ b/Tests/SundialKitStreamTests/ConnectivityStateManager.State.UpdateTests.swift
@@ -0,0 +1,130 @@
+//
+// ConnectivityStateManager.State.UpdateTests.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension ConnectivityStateManager.State {
+ @Suite("Update Tests")
+ internal struct UpdateTests {
+ // MARK: - Reachability Update Tests
+
+ @Test("Update reachability changes state")
+ internal func updateReachability() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ // Activate first
+ await stateManager.handleActivation(from: session, activationState: .activated, error: nil)
+
+ // Update reachability
+ await stateManager.updateReachability(true)
+
+ let isReachable = await stateManager.isReachable
+ #expect(isReachable == true)
+ }
+
+ @Test("Update reachability preserves other state")
+ internal func updateReachabilityPreservesState() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ // Set up initial state
+ session.activationState = .activated
+ session.isPairedAppInstalled = true
+ session.isPaired = true
+ await stateManager.handleActivation(from: session, activationState: .activated, error: nil)
+
+ // Update reachability
+ await stateManager.updateReachability(true)
+
+ let state = await stateManager.currentState
+
+ #expect(state.activationState == .activated)
+ #expect(state.isPairedAppInstalled == true)
+ #if os(iOS)
+ #expect(state.isPaired == true)
+ #endif
+ }
+
+ // MARK: - Companion State Update Tests
+
+ @Test("Update companion state changes both values on iOS")
+ internal func updateCompanionState() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+
+ await stateManager.updateCompanionState(isPairedAppInstalled: true, isPaired: true)
+
+ let state = await stateManager.currentState
+
+ #expect(state.isPairedAppInstalled == true)
+ #if os(iOS)
+ #expect(state.isPaired == true)
+ #else
+ // watchOS always true (implicit pairing)
+ #expect(state.isPaired == true)
+ #endif
+ }
+
+ @Test("Update companion state preserves activation state")
+ internal func updateCompanionStatePreservesActivation() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ // Set up initial state
+ session.activationState = .activated
+ session.isReachable = true
+ await stateManager.handleActivation(from: session, activationState: .activated, error: nil)
+
+ // Update companion state
+ await stateManager.updateCompanionState(isPairedAppInstalled: true, isPaired: true)
+
+ let state = await stateManager.currentState
+
+ #expect(state.activationState == .activated)
+ #expect(state.isReachable == true)
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/ConnectivityStateManager.State.swift b/Tests/SundialKitStreamTests/ConnectivityStateManager.State.swift
new file mode 100644
index 0000000..d1b2c5b
--- /dev/null
+++ b/Tests/SundialKitStreamTests/ConnectivityStateManager.State.swift
@@ -0,0 +1,42 @@
+//
+// ConnectivityStateManager.State.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension ConnectivityStateManager {
+ @Suite("State Tests")
+ internal enum State {
+ // Empty container - all tests in nested suites
+ }
+}
diff --git a/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.ActivationTests.swift b/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.ActivationTests.swift
new file mode 100644
index 0000000..859f1b0
--- /dev/null
+++ b/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.ActivationTests.swift
@@ -0,0 +1,108 @@
+//
+// ConnectivityStateManagerTests+StreamActivation.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension ConnectivityStateManager.Stream {
+ @Suite("Activation Tests")
+ internal struct ActivationTests {
+ @Test("Handle activation triggers all stream notifications")
+ internal func handleActivationTriggersNotifications() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ await confirmation("All notifications received", expectedCount: 3) { confirm in
+ let capture = TestValueCapture()
+
+ // Create streams
+ let activationStream = ConnectivityStateManager.Stream.createActivationStream(
+ manager: continuationManager,
+ id: UUID()
+ )
+ let reachabilityStream = ConnectivityStateManager.Stream.createReachabilityStream(
+ manager: continuationManager,
+ id: UUID()
+ )
+ let pairedAppInstalledStream = ConnectivityStateManager.Stream
+ .createPairedAppInstalledStream(
+ manager: continuationManager,
+ id: UUID()
+ )
+
+ // Consume streams
+ ConnectivityStateManager.Stream.consumeSingleValue(
+ from: activationStream,
+ into: capture,
+ setter: { await $0.set(activationState: $1) },
+ confirm: confirm
+ )
+ ConnectivityStateManager.Stream.consumeSingleValue(
+ from: reachabilityStream,
+ into: capture,
+ setter: { await $0.set(reachability: $1) },
+ confirm: confirm
+ )
+ ConnectivityStateManager.Stream.consumeSingleValue(
+ from: pairedAppInstalledStream,
+ into: capture,
+ setter: { await $0.set(pairedAppInstalled: $1) },
+ confirm: confirm
+ )
+
+ // Give streams time to register
+ try? await Task.sleep(for: .milliseconds(50))
+
+ // Trigger activation
+ session.isReachable = true
+ session.isPairedAppInstalled = true
+ await stateManager.handleActivation(from: session, activationState: .activated, error: nil)
+
+ // Wait for all streams to receive values
+ try? await Task.sleep(for: .milliseconds(100))
+
+ // Verify all notifications were triggered
+ let activationState = await capture.activationState
+ let reachability = await capture.reachability
+ let pairedAppInstalled = await capture.pairedAppInstalled
+
+ #expect(activationState == .activated)
+ #expect(reachability == true)
+ #expect(pairedAppInstalled == true)
+ }
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.CompanionStateTests.swift b/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.CompanionStateTests.swift
new file mode 100644
index 0000000..fa7e227
--- /dev/null
+++ b/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.CompanionStateTests.swift
@@ -0,0 +1,77 @@
+//
+// ConnectivityStateManagerTests+StreamCompanion.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension ConnectivityStateManager.Stream {
+ @Suite("Companion State Tests")
+ internal struct CompanionStateTests {
+ @Test("Update companion state triggers paired app installed stream")
+ internal func updateCompanionStateTriggersStream() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+
+ await confirmation("Paired app installed received", expectedCount: 1) { confirm in
+ let capture = TestValueCapture()
+
+ let pairedAppInstalledStream = ConnectivityStateManager.Stream
+ .createPairedAppInstalledStream(
+ manager: continuationManager,
+ id: UUID()
+ )
+
+ ConnectivityStateManager.Stream.consumeSingleValue(
+ from: pairedAppInstalledStream,
+ into: capture,
+ setter: { await $0.set(pairedAppInstalled: $1) },
+ confirm: confirm
+ )
+
+ // Give stream time to register
+ try? await Task.sleep(for: .milliseconds(50))
+
+ // Update companion state
+ await stateManager.updateCompanionState(isPairedAppInstalled: true, isPaired: false)
+
+ // Wait for stream to receive value
+ try? await Task.sleep(for: .milliseconds(100))
+
+ let capturedValue = await capture.pairedAppInstalled
+ #expect(capturedValue == true)
+ }
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.ReachabilityTests.swift b/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.ReachabilityTests.swift
new file mode 100644
index 0000000..0ee220f
--- /dev/null
+++ b/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.ReachabilityTests.swift
@@ -0,0 +1,85 @@
+//
+// ConnectivityStateManagerTests+StreamReachability.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension ConnectivityStateManager.Stream {
+ @Suite("Reachability Tests")
+ internal struct ReachabilityTests {
+ @Test("Update reachability triggers reachability stream")
+ internal func updateReachabilityTriggersStream() async {
+ let continuationManager = SundialKitStream.StreamContinuationManager()
+ let stateManager = SundialKitStream.ConnectivityStateManager(
+ continuationManager: continuationManager
+ )
+ let session = MockConnectivitySession()
+
+ await confirmation("Reachability values received", expectedCount: 2) { confirm in
+ let capture = TestValueCapture()
+
+ let reachabilityStream = ConnectivityStateManager.Stream.createReachabilityStream(
+ manager: continuationManager,
+ id: UUID()
+ )
+
+ ConnectivityStateManager.Stream.consumeMultipleValues(
+ from: reachabilityStream,
+ into: capture,
+ expectedCount: 2,
+ confirm: confirm
+ )
+
+ // Give stream time to register
+ try? await Task.sleep(for: .milliseconds(50))
+
+ // Trigger initial activation (should emit false)
+ await stateManager.handleActivation(from: session, activationState: .activated, error: nil)
+
+ // Give time for first notification
+ try? await Task.sleep(for: .milliseconds(10))
+
+ // Update reachability (should emit true)
+ await stateManager.updateReachability(true)
+
+ // Wait for both values to be received
+ try? await Task.sleep(for: .milliseconds(100))
+
+ let capturedValues = await capture.boolValues
+ #expect(capturedValues.count == 2)
+ #expect(capturedValues[0] == false)
+ #expect(capturedValues[1] == true)
+ }
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.swift b/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.swift
new file mode 100644
index 0000000..8294e36
--- /dev/null
+++ b/Tests/SundialKitStreamTests/ConnectivityStateManager.Stream.swift
@@ -0,0 +1,109 @@
+//
+// ConnectivityStateManagerTests+Stream.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension ConnectivityStateManager {
+ @Suite("Stream Tests")
+ internal enum Stream {
+ // MARK: - Helper Functions
+ // Note: Static helpers accessible to all nested suites
+
+ internal static func createActivationStream(
+ manager: SundialKitStream.StreamContinuationManager,
+ id: UUID
+ ) -> AsyncStream {
+ AsyncStream { continuation in
+ Task {
+ await manager.registerActivation(id: id, continuation: continuation)
+ }
+ }
+ }
+
+ internal static func createReachabilityStream(
+ manager: SundialKitStream.StreamContinuationManager,
+ id: UUID
+ ) -> AsyncStream {
+ AsyncStream { continuation in
+ Task {
+ await manager.registerReachability(id: id, continuation: continuation)
+ }
+ }
+ }
+
+ internal static func createPairedAppInstalledStream(
+ manager: SundialKitStream.StreamContinuationManager,
+ id: UUID
+ ) -> AsyncStream {
+ AsyncStream { continuation in
+ Task {
+ await manager.registerPairedAppInstalled(id: id, continuation: continuation)
+ }
+ }
+ }
+
+ internal static func consumeSingleValue(
+ from stream: AsyncStream,
+ into capture: TestValueCapture,
+ setter: @escaping @Sendable (TestValueCapture, T) async -> Void,
+ confirm: Confirmation
+ ) where T: Sendable {
+ Task { @Sendable in
+ for await value in stream {
+ await setter(capture, value)
+ confirm()
+ break
+ }
+ }
+ }
+
+ internal static func consumeMultipleValues(
+ from stream: AsyncStream,
+ into capture: TestValueCapture,
+ expectedCount: Int,
+ confirm: Confirmation
+ ) {
+ Task { @Sendable in
+ for await value in stream {
+ await capture.append(boolValue: value)
+ confirm()
+ let count = await capture.boolValues.count
+ if count >= expectedCount {
+ break
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/ConnectivityStateManager.swift b/Tests/SundialKitStreamTests/ConnectivityStateManager.swift
new file mode 100644
index 0000000..f760fbb
--- /dev/null
+++ b/Tests/SundialKitStreamTests/ConnectivityStateManager.swift
@@ -0,0 +1,33 @@
+//
+// ConnectivityStateManagerTests.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Testing
+
+@Suite("ConnectivityStateManager Tests")
+internal enum ConnectivityStateManager {}
diff --git a/Tests/SundialKitStreamTests/MockConnectivitySession.swift b/Tests/SundialKitStreamTests/MockConnectivitySession.swift
new file mode 100644
index 0000000..1f08715
--- /dev/null
+++ b/Tests/SundialKitStreamTests/MockConnectivitySession.swift
@@ -0,0 +1,59 @@
+//
+// MockConnectivitySession.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+// MARK: - Mock Session
+
+internal final class MockConnectivitySession: ConnectivitySession, @unchecked Sendable {
+ internal var delegate: (any ConnectivitySessionDelegate)?
+ internal var isReachable: Bool = false
+ internal var isPairedAppInstalled: Bool = false
+ internal var isPaired: Bool = false
+ internal var activationState: ActivationState = .notActivated
+ internal var receivedApplicationContext: ConnectivityMessage?
+
+ internal func activate() throws {}
+
+ internal func updateApplicationContext(_ context: ConnectivityMessage) throws {}
+
+ internal func sendMessage(
+ _ message: ConnectivityMessage,
+ _ replyHandler: @escaping (Result) -> Void
+ ) {}
+
+ internal func sendMessageData(
+ _ data: Data,
+ _ completion: @escaping (Result) -> Void
+ ) {}
+}
diff --git a/Tests/SundialKitStreamTests/MockNetworkPing.swift b/Tests/SundialKitStreamTests/MockNetworkPing.swift
new file mode 100644
index 0000000..a863eb6
--- /dev/null
+++ b/Tests/SundialKitStreamTests/MockNetworkPing.swift
@@ -0,0 +1,47 @@
+//
+// MockNetworkPing.swift
+// SundialKitStream
+//
+// Created by Leo Dion on 11/14/25.
+//
+
+import Foundation
+
+@testable import SundialKitCore
+@testable import SundialKitNetwork
+@testable import SundialKitStream
+
+internal final class MockNetworkPing: NetworkPing, @unchecked Sendable {
+ internal struct StatusType: Sendable, Equatable {
+ internal let value: String
+ }
+
+ internal private(set) var lastShouldPingStatus: PathStatus?
+ internal let id: UUID
+ internal let timeInterval: TimeInterval
+ internal var shouldPingResponse: Bool
+ internal var onPingHandler: ((StatusType) -> Void)?
+
+ internal init(
+ id: UUID = UUID(),
+ timeInterval: TimeInterval = 1.0,
+ shouldPingResponse: Bool = true
+ ) {
+ self.id = id
+ self.timeInterval = timeInterval
+ self.shouldPingResponse = shouldPingResponse
+ }
+
+ internal func shouldPing(onStatus status: PathStatus) -> Bool {
+ lastShouldPingStatus = status
+ return shouldPingResponse
+ }
+
+ internal func onPing(_ closure: @escaping (StatusType) -> Void) {
+ onPingHandler = closure
+ }
+
+ internal func sendPingStatus(_ status: StatusType) {
+ onPingHandler?(status)
+ }
+}
diff --git a/Tests/SundialKitStreamTests/MockPath.swift b/Tests/SundialKitStreamTests/MockPath.swift
new file mode 100644
index 0000000..d7d2c01
--- /dev/null
+++ b/Tests/SundialKitStreamTests/MockPath.swift
@@ -0,0 +1,26 @@
+//
+// MockPath.swift
+// SundialKitStream
+//
+// Created by Leo Dion on 11/14/25.
+//
+
+@testable import SundialKitCore
+@testable import SundialKitNetwork
+@testable import SundialKitStream
+
+internal struct MockPath: NetworkPath {
+ internal let isConstrained: Bool
+ internal let isExpensive: Bool
+ internal let pathStatus: PathStatus
+
+ internal init(
+ isConstrained: Bool = false,
+ isExpensive: Bool = false,
+ pathStatus: PathStatus = .unknown
+ ) {
+ self.isConstrained = isConstrained
+ self.isExpensive = isExpensive
+ self.pathStatus = pathStatus
+ }
+}
diff --git a/Tests/SundialKitStreamTests/MockPathMonitor.swift b/Tests/SundialKitStreamTests/MockPathMonitor.swift
new file mode 100644
index 0000000..b0439c3
--- /dev/null
+++ b/Tests/SundialKitStreamTests/MockPathMonitor.swift
@@ -0,0 +1,73 @@
+//
+// MockPathMonitor.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+
+@testable import SundialKitCore
+@testable import SundialKitNetwork
+@testable import SundialKitStream
+
+// MARK: - Mock Implementations
+
+internal final class MockPathMonitor: PathMonitor, @unchecked Sendable {
+ internal typealias PathType = MockPath
+
+ internal let id: UUID
+ internal private(set) var pathUpdate: ((MockPath) -> Void)?
+ internal private(set) var dispatchQueueLabel: String?
+ internal private(set) var isCancelled = false
+
+ internal init(id: UUID = UUID()) {
+ self.id = id
+ }
+
+ internal func onPathUpdate(_ handler: @escaping (MockPath) -> Void) {
+ pathUpdate = handler
+ }
+
+ internal func start(queue: DispatchQueue) {
+ dispatchQueueLabel = queue.label
+ // Immediately send an initial path
+ pathUpdate?(
+ .init(
+ isConstrained: false,
+ isExpensive: false,
+ pathStatus: .satisfied(.wiredEthernet)
+ )
+ )
+ }
+
+ internal func cancel() {
+ isCancelled = true
+ }
+
+ internal func sendPath(_ path: MockPath) {
+ pathUpdate?(path)
+ }
+}
diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift
new file mode 100644
index 0000000..6e4d475
--- /dev/null
+++ b/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift
@@ -0,0 +1,143 @@
+//
+// NetworkObserverTests+EdgeCases.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitCore
+@testable import SundialKitNetwork
+@testable import SundialKitStream
+
+extension NetworkObserverTests {
+ @Suite("Edge Cases and State Tests")
+ internal struct EdgeCasesTests {
+ // MARK: - Current State Tests
+
+ @Test("getCurrentPath returns latest path")
+ internal func getCurrentPathSnapshot() async {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ // Before start
+ var currentPath = await observer.getCurrentPath()
+ #expect(currentPath == nil)
+
+ // After start
+ await observer.start(queue: .global())
+
+ // Give time for async path update from start()
+ try? await Task.sleep(for: .milliseconds(10))
+
+ currentPath = await observer.getCurrentPath()
+ #expect(currentPath?.pathStatus == .satisfied(.wiredEthernet))
+
+ // After update
+ let newPath = MockPath(pathStatus: .satisfied(.wifi))
+ monitor.sendPath(newPath)
+
+ // Give time for async update
+ try? await Task.sleep(for: .milliseconds(10))
+
+ currentPath = await observer.getCurrentPath()
+ #expect(currentPath?.pathStatus == .satisfied(.wifi))
+ }
+
+ @Test("getCurrentPingStatus returns nil when no ping configured")
+ internal func getCurrentPingStatusWithoutPing() async {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+
+ let pingStatus = await observer.getCurrentPingStatus()
+ #expect(pingStatus == nil)
+ }
+
+ // MARK: - Stream Cleanup Tests
+
+ @Test("Cancel finishes all active path streams")
+ internal func cancelFinishesPathStreams() async throws {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+
+ let stream = await observer.pathUpdates()
+ var iterator = stream.makeAsyncIterator()
+
+ // Get first value
+ _ = await iterator.next()
+
+ // Cancel observer
+ await observer.cancel()
+
+ // Try to get next value - should complete
+ let nextValue = await iterator.next()
+ #expect(nextValue == nil)
+ }
+
+ @Test("Stream iteration completes after cancel")
+ internal func streamCompletesAfterCancel() async throws {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+
+ let capture = TestValueCapture()
+
+ try await confirmation("Received initial status", expectedCount: 1) { confirm in
+ Task { @Sendable in
+ let stream = await observer.pathStatusStream
+ var count = 0
+ for await _ in stream {
+ count += 1
+ if count == 1 {
+ confirm()
+ } else {
+ // Should not receive values after cancel
+ await capture.set(boolValue: true)
+ }
+ }
+ }
+
+ // Wait for initial value confirmation
+ try await Task.sleep(for: .milliseconds(50))
+
+ // Cancel observer
+ await observer.cancel()
+
+ // Give time to verify no additional values are received
+ try await Task.sleep(for: .milliseconds(100))
+ }
+
+ let receivedAfterCancel = await capture.boolValue
+ #expect(receivedAfterCancel != true)
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift
new file mode 100644
index 0000000..d6ff4bb
--- /dev/null
+++ b/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift
@@ -0,0 +1,141 @@
+//
+// NetworkObserverTests+Initialization.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitCore
+@testable import SundialKitNetwork
+@testable import SundialKitStream
+
+extension NetworkObserverTests {
+ @Suite("Initialization and Lifecycle Tests")
+ internal struct InitializationTests {
+ // MARK: - Initialization Tests
+
+ @Test("NetworkObserver initializes with monitor only")
+ internal func initializationWithMonitorOnly() async {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ let currentPath = await observer.getCurrentPath()
+ let currentPingStatus = await observer.getCurrentPingStatus()
+
+ #expect(currentPath == nil)
+ #expect(currentPingStatus == nil)
+ }
+
+ @Test("NetworkObserver initializes with monitor and ping")
+ internal func initializationWithMonitorAndPing() async {
+ let monitor = MockPathMonitor()
+ let ping = MockNetworkPing()
+ let observer = NetworkObserver(monitor: monitor, ping: ping)
+
+ let currentPath = await observer.getCurrentPath()
+ let currentPingStatus = await observer.getCurrentPingStatus()
+
+ #expect(currentPath == nil)
+ #expect(currentPingStatus == nil)
+ }
+
+ // MARK: - Start/Cancel Tests
+
+ @Test("Start monitoring begins path updates")
+ internal func startMonitoring() async {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+
+ #expect(monitor.dispatchQueueLabel != nil)
+ #expect(monitor.isCancelled == false)
+
+ // Give time for async path update from start()
+ try? await Task.sleep(for: .milliseconds(10))
+
+ // Should receive initial path from start()
+ let currentPath = await observer.getCurrentPath()
+ #expect(currentPath != nil)
+ #expect(currentPath?.pathStatus == .satisfied(.wiredEthernet))
+ }
+
+ @Test("Cancel stops monitoring and finishes streams")
+ internal func cancelMonitoring() async {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+ await observer.cancel()
+
+ #expect(monitor.isCancelled == true)
+ }
+
+ @Test("Path updates before start are not tracked")
+ internal func pathUpdatesBeforeStart() async {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ // Don't call start()
+ let currentPath = await observer.getCurrentPath()
+ #expect(currentPath == nil)
+ }
+
+ @Test("Multiple start calls use latest queue")
+ internal func multipleStartCalls() async {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+ let firstLabel = monitor.dispatchQueueLabel
+
+ await observer.start(queue: .main)
+ let secondLabel = monitor.dispatchQueueLabel
+
+ #expect(firstLabel != nil)
+ #expect(secondLabel != nil)
+ // Labels should be different since we used different queues
+ }
+
+ // MARK: - Ping Integration Tests
+
+ @Test("Ping status updates are not tracked without ping initialization")
+ internal func pingStatusWithoutPing() async {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+
+ let currentPingStatus = await observer.getCurrentPingStatus()
+ #expect(currentPingStatus == nil)
+ }
+
+ // Note: Full ping integration testing would require NetworkMonitor-level tests
+ // since NetworkObserver doesn't directly manage ping lifecycle
+ }
+}
diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift
new file mode 100644
index 0000000..3298962
--- /dev/null
+++ b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift
@@ -0,0 +1,220 @@
+//
+// NetworkObserverTests+Stream.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitCore
+@testable import SundialKitNetwork
+@testable import SundialKitStream
+
+extension NetworkObserverTests {
+ @Suite("Stream Tests")
+ internal struct StreamTests {
+ // MARK: - Path Updates Stream Tests
+
+ @Test("pathUpdates stream receives initial and subsequent paths")
+ internal func pathUpdatesStream() async throws {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+
+ let stream = await observer.pathUpdates()
+ var iterator = stream.makeAsyncIterator()
+
+ // Should receive initial path
+ let firstPath = await iterator.next()
+ #expect(firstPath?.pathStatus == .satisfied(.wiredEthernet))
+
+ // Send new path update
+ let newPath = MockPath(
+ isConstrained: true,
+ isExpensive: true,
+ pathStatus: .satisfied(.cellular)
+ )
+ monitor.sendPath(newPath)
+
+ // Give time for async delivery
+ try await Task.sleep(for: .milliseconds(10))
+
+ let secondPath = await iterator.next()
+ #expect(secondPath?.pathStatus == .satisfied(.cellular))
+ #expect(secondPath?.isConstrained == true)
+ #expect(secondPath?.isExpensive == true)
+ }
+
+ @Test("pathStatusStream extracts status from paths")
+ internal func pathStatusStream() async throws {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+
+ try await confirmation("Received path status", expectedCount: 2) { receivedStatus in
+ let capture = TestValueCapture()
+
+ Task { @Sendable in
+ let stream = await observer.pathStatusStream
+ for await status in stream {
+ await capture.append(pathStatus: status)
+ receivedStatus()
+ let count = await capture.pathStatuses.count
+ if count >= 2 { break }
+ }
+ }
+
+ // Wait briefly for initial status
+ try await Task.sleep(for: .milliseconds(10))
+
+ // Send new path update
+ let newPath = MockPath(pathStatus: .unsatisfied(.localNetworkDenied))
+ monitor.sendPath(newPath)
+
+ // Give time for async delivery
+ try await Task.sleep(for: .milliseconds(50))
+
+ let statuses = await capture.pathStatuses
+ #expect(statuses.count == 2)
+ #expect(statuses[0] == .satisfied(.wiredEthernet))
+ #expect(statuses[1] == .unsatisfied(.localNetworkDenied))
+ }
+ }
+
+ @Test("isExpensiveStream tracks expensive status")
+ internal func isExpensiveStream() async throws {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+
+ try await confirmation("Received expensive status", expectedCount: 2) { receivedValue in
+ let capture = TestValueCapture()
+
+ Task { @Sendable in
+ let stream = await observer.isExpensiveStream
+ for await value in stream {
+ await capture.append(boolValue: value)
+ receivedValue()
+ let count = await capture.boolValues.count
+ if count >= 2 { break }
+ }
+ }
+
+ // Wait briefly for initial value
+ try await Task.sleep(for: .milliseconds(10))
+
+ // Send expensive path
+ let expensivePath = MockPath(isExpensive: true, pathStatus: .satisfied(.cellular))
+ monitor.sendPath(expensivePath)
+
+ // Give time for async delivery
+ try await Task.sleep(for: .milliseconds(50))
+
+ let values = await capture.boolValues
+ #expect(values.count == 2)
+ #expect(values[0] == false)
+ #expect(values[1] == true)
+ }
+ }
+
+ @Test("isConstrainedStream tracks constrained status")
+ internal func isConstrainedStream() async throws {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+
+ try await confirmation("Received constrained status", expectedCount: 2) { receivedValue in
+ let capture = TestValueCapture()
+
+ Task { @Sendable in
+ let stream = await observer.isConstrainedStream
+ for await value in stream {
+ await capture.append(boolValue: value)
+ receivedValue()
+ let count = await capture.boolValues.count
+ if count >= 2 { break }
+ }
+ }
+
+ // Wait briefly for initial value
+ try await Task.sleep(for: .milliseconds(10))
+
+ // Send constrained path
+ let constrainedPath = MockPath(isConstrained: true, pathStatus: .satisfied(.wifi))
+ monitor.sendPath(constrainedPath)
+
+ // Give time for async delivery
+ try await Task.sleep(for: .milliseconds(50))
+
+ let values = await capture.boolValues
+ #expect(values.count == 2)
+ #expect(values[0] == false)
+ #expect(values[1] == true)
+ }
+ }
+
+ // MARK: - Multiple Subscribers Tests
+
+ @Test("Multiple path update subscribers receive same updates")
+ internal func multiplePathSubscribers() async throws {
+ let monitor = MockPathMonitor()
+ let observer = NetworkObserver(monitor: monitor)
+
+ await observer.start(queue: .global())
+
+ // Create two subscribers
+ let stream1 = await observer.pathUpdates()
+ let stream2 = await observer.pathUpdates()
+
+ var iterator1 = stream1.makeAsyncIterator()
+ var iterator2 = stream2.makeAsyncIterator()
+
+ // Both should receive initial path
+ let path1First = await iterator1.next()
+ let path2First = await iterator2.next()
+
+ #expect(path1First?.pathStatus == .satisfied(.wiredEthernet))
+ #expect(path2First?.pathStatus == .satisfied(.wiredEthernet))
+
+ // Send new path
+ let newPath = MockPath(pathStatus: .satisfied(.cellular))
+ monitor.sendPath(newPath)
+
+ try await Task.sleep(for: .milliseconds(10))
+
+ let path1Second = await iterator1.next()
+ let path2Second = await iterator2.next()
+
+ #expect(path1Second?.pathStatus == .satisfied(.cellular))
+ #expect(path2Second?.pathStatus == .satisfied(.cellular))
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests.swift b/Tests/SundialKitStreamTests/NetworkObserverTests.swift
new file mode 100644
index 0000000..b72a232
--- /dev/null
+++ b/Tests/SundialKitStreamTests/NetworkObserverTests.swift
@@ -0,0 +1,33 @@
+//
+// NetworkObserverTests.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Testing
+
+@Suite("NetworkObserver Tests")
+internal enum NetworkObserverTests {}
diff --git a/Tests/SundialKitStreamTests/StreamContinuationManager+Activation.swift b/Tests/SundialKitStreamTests/StreamContinuationManager+Activation.swift
new file mode 100644
index 0000000..679d1a7
--- /dev/null
+++ b/Tests/SundialKitStreamTests/StreamContinuationManager+Activation.swift
@@ -0,0 +1,152 @@
+//
+// StreamContinuationManagerTests+Activation.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension StreamContinuationManager {
+ @Suite("Activation Tests")
+ internal struct ActivationTests {
+ // MARK: - Activation State Tests
+
+ @Test("Register activation continuation succeeds")
+ internal func registerActivation() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+ let capture = TestValueCapture()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerActivation(id: id, continuation: continuation)
+ }
+ }
+
+ let task = Task { @Sendable in
+ for await value in stream {
+ await capture.set(activationState: value)
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldActivationState(.activated)
+ await task.value
+
+ let receivedValue = await capture.activationState
+ #expect(receivedValue == .activated)
+ }
+
+ @Test("Yield activation state to multiple subscribers")
+ internal func yieldActivationStateMultipleSubscribers() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let capture = TestValueCapture()
+
+ try await confirmation("All subscribers receive value", expectedCount: 3) { confirm in
+ // Create 3 subscribers
+ var consumerTasks: [Task] = []
+ for _ in 0..<3 {
+ let id = UUID()
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerActivation(id: id, continuation: continuation)
+ }
+ }
+
+ let task = Task { @Sendable in
+ for await value in stream {
+ await capture.set(activationState: value)
+ confirm()
+ break
+ }
+ }
+ consumerTasks.append(task)
+ }
+
+ // Give subscribers time to set up
+ try await Task.sleep(for: .milliseconds(100))
+
+ // Yield to all subscribers
+ await manager.yieldActivationState(.activated)
+
+ // Wait for all consumers to process the value
+ for task in consumerTasks {
+ await task.value
+ }
+ }
+
+ let receivedValue = await capture.activationState
+ #expect(receivedValue == .activated)
+ }
+
+ @Test("Yield activation state with no subscribers succeeds")
+ internal func yieldActivationStateNoSubscribers() async {
+ let manager = SundialKitStream.StreamContinuationManager()
+
+ // Should not crash
+ await manager.yieldActivationState(.activated)
+ }
+
+ @Test("Remove activation continuation succeeds")
+ internal func removeActivation() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerActivation(id: id, continuation: continuation)
+ }
+
+ continuation.onTermination = { @Sendable _ in
+ Task {
+ await manager.removeActivation(id: id)
+ }
+ }
+ }
+
+ let task = Task {
+ for await _ in stream {
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldActivationState(.activated)
+ task.cancel()
+ await task.value
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/StreamContinuationManager+ActivationCompletion.swift b/Tests/SundialKitStreamTests/StreamContinuationManager+ActivationCompletion.swift
new file mode 100644
index 0000000..3853d97
--- /dev/null
+++ b/Tests/SundialKitStreamTests/StreamContinuationManager+ActivationCompletion.swift
@@ -0,0 +1,140 @@
+//
+// StreamContinuationManagerTests+ActivationCompletion.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension StreamContinuationManager {
+ @Suite("Activation Completion Tests")
+ internal struct ActivationCompletionTests {
+ @Test("Yield activation completion with success")
+ internal func yieldActivationCompletionSuccess() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+ let capture = TestValueCapture()
+
+ let stream = AsyncStream> { continuation in
+ Task {
+ await manager.registerActivationCompletion(id: id, continuation: continuation)
+ }
+ }
+
+ let task = Task { @Sendable in
+ for await result in stream {
+ await capture.set(activationResult: result)
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldActivationCompletion(.success(.activated))
+ await task.value
+
+ let receivedResult = await capture.activationResult
+ #expect(receivedResult != nil)
+ if case .success(let state) = receivedResult {
+ #expect(state == .activated)
+ } else {
+ Issue.record("Expected success result")
+ }
+ }
+
+ @Test("Yield activation completion with failure")
+ internal func yieldActivationCompletionFailure() async throws {
+ struct TestError: Error {}
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+ let capture = TestValueCapture()
+
+ let stream = AsyncStream> { continuation in
+ Task {
+ await manager.registerActivationCompletion(id: id, continuation: continuation)
+ }
+ }
+
+ let task = Task { @Sendable in
+ for await result in stream {
+ await capture.set(activationResult: result)
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldActivationCompletion(.failure(TestError()))
+ await task.value
+
+ let receivedResult = await capture.activationResult
+ #expect(receivedResult != nil)
+ if case .failure = receivedResult {
+ #expect(Bool(true))
+ } else {
+ Issue.record("Expected failure result")
+ }
+ }
+
+ @Test("Remove activation completion continuation succeeds")
+ internal func removeActivationCompletion() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+
+ let stream = AsyncStream> { continuation in
+ Task {
+ await manager.registerActivationCompletion(id: id, continuation: continuation)
+ }
+
+ continuation.onTermination = { @Sendable _ in
+ Task {
+ await manager.removeActivationCompletion(id: id)
+ }
+ }
+ }
+
+ let task = Task {
+ for await _ in stream {
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldActivationCompletion(.success(.activated))
+ task.cancel()
+ await task.value
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/StreamContinuationManager+Concurrency.swift b/Tests/SundialKitStreamTests/StreamContinuationManager+Concurrency.swift
new file mode 100644
index 0000000..31781df
--- /dev/null
+++ b/Tests/SundialKitStreamTests/StreamContinuationManager+Concurrency.swift
@@ -0,0 +1,178 @@
+//
+// StreamContinuationManagerConcurrencyTests.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension StreamContinuationManager {
+ // MARK: - Concurrent Operations Tests
+
+ @Suite("Concurrency Tests")
+ internal struct Concurrency {
+ // MARK: - Helper Functions
+
+ private static func setupAndConsumeMultipleStreamTypes(
+ manager: SundialKitStream.StreamContinuationManager,
+ activationCapture: TestValueCapture,
+ reachabilityCapture: TestValueCapture,
+ pairedAppInstalledCapture: TestValueCapture,
+ confirm: Confirmation
+ ) {
+ // Activation stream
+ Task {
+ let id = UUID()
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerActivation(id: id, continuation: continuation)
+ }
+ }
+
+ for await _ in stream {
+ await activationCapture.set(boolValue: true)
+ confirm()
+ break
+ }
+ }
+
+ // Reachability stream
+ Task {
+ let id = UUID()
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerReachability(id: id, continuation: continuation)
+ }
+ }
+
+ for await _ in stream {
+ await reachabilityCapture.set(boolValue: true)
+ confirm()
+ break
+ }
+ }
+
+ // Paired app installed stream
+ Task {
+ let id = UUID()
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerPairedAppInstalled(id: id, continuation: continuation)
+ }
+ }
+
+ for await _ in stream {
+ await pairedAppInstalledCapture.set(boolValue: true)
+ confirm()
+ break
+ }
+ }
+ }
+
+ @Test("Concurrent yielding to same stream type")
+ internal func concurrentYielding() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerActivation(id: id, continuation: continuation)
+ }
+ }
+
+ try await confirmation("All values received", expectedCount: 10) { confirm in
+ let consumerTask = Task { @Sendable in
+ var count = 0
+ for await _ in stream {
+ confirm()
+ count += 1
+ if count >= 10 {
+ break
+ }
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ // Yield multiple values concurrently
+ await withTaskGroup(of: Void.self) { group in
+ for _ in 0..<10 {
+ group.addTask {
+ await manager.yieldActivationState(.activated)
+ }
+ }
+ }
+
+ // Wait for consumer to process all values
+ await consumerTask.value
+ }
+ }
+
+ @Test("Multiple stream types active simultaneously")
+ internal func multipleStreamTypes() async {
+ let manager = SundialKitStream.StreamContinuationManager()
+
+ await confirmation("All stream types received", expectedCount: 3) { confirm in
+ let activationCapture = TestValueCapture()
+ let reachabilityCapture = TestValueCapture()
+ let pairedAppInstalledCapture = TestValueCapture()
+
+ Self.setupAndConsumeMultipleStreamTypes(
+ manager: manager,
+ activationCapture: activationCapture,
+ reachabilityCapture: reachabilityCapture,
+ pairedAppInstalledCapture: pairedAppInstalledCapture,
+ confirm: confirm
+ )
+
+ // Give subscribers time to set up
+ try? await Task.sleep(for: .milliseconds(50))
+
+ // Yield to all streams
+ await manager.yieldActivationState(.activated)
+ await manager.yieldReachability(true)
+ await manager.yieldPairedAppInstalled(true)
+
+ // Wait for all streams to receive values
+ try? await Task.sleep(for: .milliseconds(100))
+
+ let activationValue = await activationCapture.boolValue
+ let reachabilityValue = await reachabilityCapture.boolValue
+ let pairedAppInstalledValue = await pairedAppInstalledCapture.boolValue
+
+ #expect(activationValue == true)
+ #expect(reachabilityValue == true)
+ #expect(pairedAppInstalledValue == true)
+ }
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/StreamContinuationManager+Messaging.swift b/Tests/SundialKitStreamTests/StreamContinuationManager+Messaging.swift
new file mode 100644
index 0000000..2fef4d1
--- /dev/null
+++ b/Tests/SundialKitStreamTests/StreamContinuationManager+Messaging.swift
@@ -0,0 +1,36 @@
+//
+// StreamContinuationManagerTests+Messaging.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Testing
+
+extension StreamContinuationManager {
+ /// Messaging-related test suites
+ @Suite("Messaging Tests")
+ internal enum Messaging {}
+}
diff --git a/Tests/SundialKitStreamTests/StreamContinuationManager+PairedState.swift b/Tests/SundialKitStreamTests/StreamContinuationManager+PairedState.swift
new file mode 100644
index 0000000..3e2ac22
--- /dev/null
+++ b/Tests/SundialKitStreamTests/StreamContinuationManager+PairedState.swift
@@ -0,0 +1,164 @@
+//
+// StreamContinuationManagerTests+PairedState.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension StreamContinuationManager {
+ @Suite("Paired State Tests")
+ internal struct PairedStateTests {
+ // MARK: - Paired App Installed Tests
+
+ @Test("Yield paired app installed status")
+ internal func yieldPairedAppInstalled() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+ let capture = TestValueCapture()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerPairedAppInstalled(id: id, continuation: continuation)
+ }
+ }
+
+ let task = Task { @Sendable in
+ for await value in stream {
+ await capture.set(boolValue: value)
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldPairedAppInstalled(true)
+ await task.value
+
+ let receivedValue = await capture.boolValue
+ #expect(receivedValue == true)
+ }
+
+ @Test("Remove paired app installed continuation succeeds")
+ internal func removePairedAppInstalled() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerPairedAppInstalled(id: id, continuation: continuation)
+ }
+
+ continuation.onTermination = { @Sendable _ in
+ Task {
+ await manager.removePairedAppInstalled(id: id)
+ }
+ }
+ }
+
+ let task = Task {
+ for await _ in stream {
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldPairedAppInstalled(true)
+ task.cancel()
+ await task.value
+ }
+
+ // MARK: - Paired Tests (iOS-specific)
+
+ @Test("Yield paired status")
+ internal func yieldPaired() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+ let capture = TestValueCapture()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerPaired(id: id, continuation: continuation)
+ }
+ }
+
+ let task = Task { @Sendable in
+ for await value in stream {
+ await capture.set(boolValue: value)
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldPaired(true)
+ await task.value
+
+ let receivedValue = await capture.boolValue
+ #expect(receivedValue == true)
+ }
+
+ @Test("Remove paired continuation succeeds")
+ internal func removePaired() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerPaired(id: id, continuation: continuation)
+ }
+
+ continuation.onTermination = { @Sendable _ in
+ Task {
+ await manager.removePaired(id: id)
+ }
+ }
+ }
+
+ let task = Task {
+ for await _ in stream {
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldPaired(true)
+ task.cancel()
+ await task.value
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/StreamContinuationManager+Reachability.swift b/Tests/SundialKitStreamTests/StreamContinuationManager+Reachability.swift
new file mode 100644
index 0000000..4e254ff
--- /dev/null
+++ b/Tests/SundialKitStreamTests/StreamContinuationManager+Reachability.swift
@@ -0,0 +1,135 @@
+//
+// StreamContinuationManagerTests+Reachability.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension StreamContinuationManager {
+ @Suite("Reachability Tests")
+ internal struct ReachabilityTests {
+ @Test("Yield reachability to subscribers")
+ internal func yieldReachability() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+ let capture = TestValueCapture()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerReachability(id: id, continuation: continuation)
+ }
+ }
+
+ let task = Task { @Sendable in
+ for await value in stream {
+ await capture.set(boolValue: value)
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldReachability(true)
+ await task.value
+
+ let receivedValue = await capture.boolValue
+ #expect(receivedValue == true)
+ }
+
+ @Test("Yield reachability transitions")
+ internal func yieldReachabilityTransitions() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+ let capture = TestValueCapture()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerReachability(id: id, continuation: continuation)
+ }
+ }
+
+ let task = Task { @Sendable in
+ for await value in stream {
+ await capture.append(boolValue: value)
+ let count = await capture.boolValues.count
+ if count >= 3 {
+ break
+ }
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldReachability(true)
+ await manager.yieldReachability(false)
+ await manager.yieldReachability(true)
+
+ await task.value
+
+ let receivedValues = await capture.boolValues
+ #expect(receivedValues == [true, false, true])
+ }
+
+ @Test("Remove reachability continuation succeeds")
+ internal func removeReachability() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerReachability(id: id, continuation: continuation)
+ }
+
+ continuation.onTermination = { @Sendable _ in
+ Task {
+ await manager.removeReachability(id: id)
+ }
+ }
+ }
+
+ let task = Task {
+ for await _ in stream {
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldReachability(true)
+ task.cancel()
+ await task.value
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/StreamContinuationManager.Messaging+MessageReceivedTests.swift b/Tests/SundialKitStreamTests/StreamContinuationManager.Messaging+MessageReceivedTests.swift
new file mode 100644
index 0000000..dbe21f9
--- /dev/null
+++ b/Tests/SundialKitStreamTests/StreamContinuationManager.Messaging+MessageReceivedTests.swift
@@ -0,0 +1,113 @@
+//
+// StreamContinuationManagerTests+Messaging+MessageReceived.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension StreamContinuationManager.Messaging {
+ @Suite("Message Received Tests")
+ internal struct MessageReceivedTests {
+ @Test("Yield message received")
+ internal func yieldMessageReceived() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+ let capture = TestValueCapture()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerMessageReceived(id: id, continuation: continuation)
+ }
+ }
+
+ let task = Task { @Sendable in
+ for await message in stream {
+ await capture.set(message: message)
+ break
+ }
+ }
+
+ let testMessage: ConnectivityMessage = ["key": "value"]
+ let result = ConnectivityReceiveResult(
+ message: testMessage,
+ context: .applicationContext
+ )
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldMessageReceived(result)
+ await task.value
+
+ let receivedMessage = await capture.message
+ #expect(receivedMessage != nil)
+ #expect(receivedMessage?.message["key"] as? String == "value")
+ }
+
+ @Test("Remove message received continuation succeeds")
+ internal func removeMessageReceived() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerMessageReceived(id: id, continuation: continuation)
+ }
+
+ continuation.onTermination = { @Sendable _ in
+ Task {
+ await manager.removeMessageReceived(id: id)
+ }
+ }
+ }
+
+ let task = Task {
+ for await _ in stream {
+ break
+ }
+ }
+
+ let testMessage: ConnectivityMessage = ["key": "value"]
+ let result = ConnectivityReceiveResult(
+ message: testMessage,
+ context: .applicationContext
+ )
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldMessageReceived(result)
+ task.cancel()
+ await task.value
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/StreamContinuationManager.Messaging+SendResultTests.swift b/Tests/SundialKitStreamTests/StreamContinuationManager.Messaging+SendResultTests.swift
new file mode 100644
index 0000000..b9e35f5
--- /dev/null
+++ b/Tests/SundialKitStreamTests/StreamContinuationManager.Messaging+SendResultTests.swift
@@ -0,0 +1,119 @@
+//
+// StreamContinuationManagerTests+Messaging+SendResult.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension StreamContinuationManager.Messaging {
+ @Suite("Send Result Tests")
+ internal struct SendResultTests {
+ @Test("Yield send result")
+ internal func yieldSendResult() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+ let capture = TestValueCapture()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerSendResult(id: id, continuation: continuation)
+ }
+ }
+
+ let task = Task { @Sendable in
+ for await result in stream {
+ // Store the result using message field
+ await capture.set(
+ message: ConnectivityReceiveResult(
+ message: result.message,
+ context: .applicationContext
+ )
+ )
+ break
+ }
+ }
+
+ let testMessage: ConnectivityMessage = ["key": "value"]
+ let sendResult = ConnectivitySendResult(
+ message: testMessage,
+ context: .applicationContext(transport: .dictionary)
+ )
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldSendResult(sendResult)
+ _ = await task.value
+
+ let receivedResult = await capture.message
+ #expect(receivedResult != nil)
+ #expect(receivedResult?.message["key"] as? String == "value")
+ }
+
+ @Test("Remove send result continuation succeeds")
+ internal func removeSendResult() async throws {
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerSendResult(id: id, continuation: continuation)
+ }
+
+ continuation.onTermination = { @Sendable _ in
+ Task {
+ await manager.removeSendResult(id: id)
+ }
+ }
+ }
+
+ let task = Task {
+ for await _ in stream {
+ break
+ }
+ }
+
+ let testMessage: ConnectivityMessage = ["key": "value"]
+ let sendResult = ConnectivitySendResult(
+ message: testMessage,
+ context: .applicationContext(transport: .dictionary)
+ )
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ await manager.yieldSendResult(sendResult)
+ task.cancel()
+ _ = await task.value
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/StreamContinuationManager.Messaging+TypedMessage.swift b/Tests/SundialKitStreamTests/StreamContinuationManager.Messaging+TypedMessage.swift
new file mode 100644
index 0000000..57a9ade
--- /dev/null
+++ b/Tests/SundialKitStreamTests/StreamContinuationManager.Messaging+TypedMessage.swift
@@ -0,0 +1,123 @@
+//
+// StreamContinuationManagerTests+Messaging+TypedMessage.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+import Testing
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+extension StreamContinuationManager.Messaging {
+ @Suite("Typed Message Tests")
+ internal struct TypedMessageTests {
+ @Test("Yield typed message")
+ internal func yieldTypedMessage() async throws {
+ struct TestMessage: Messagable {
+ static let key: String = "test"
+ let value: String
+
+ init(from message: ConnectivityMessage) {
+ self.value = message["value"] as? String ?? ""
+ }
+
+ func parameters() -> ConnectivityMessage {
+ ["value": value]
+ }
+ }
+
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+ let capture = TestValueCapture()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerTypedMessage(id: id, continuation: continuation)
+ }
+ }
+
+ let task = Task { @Sendable in
+ for await message in stream {
+ await capture.set(typedMessage: message)
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ let testMessage = TestMessage(from: ["value": "test"])
+ await manager.yieldTypedMessage(testMessage)
+ _ = await task.value
+
+ let receivedMessage = await capture.typedMessage
+ #expect(receivedMessage != nil)
+ #expect((receivedMessage as? TestMessage)?.value == "test")
+ }
+
+ @Test("Remove typed message continuation succeeds")
+ internal func removeTypedMessage() async throws {
+ struct TestMessage: Messagable {
+ static let key: String = "test"
+
+ init(from message: ConnectivityMessage) {}
+ func parameters() -> ConnectivityMessage { [:] }
+ }
+
+ let manager = SundialKitStream.StreamContinuationManager()
+ let id = UUID()
+
+ let stream = AsyncStream { continuation in
+ Task {
+ await manager.registerTypedMessage(id: id, continuation: continuation)
+ }
+
+ continuation.onTermination = { @Sendable _ in
+ Task {
+ await manager.removeTypedMessage(id: id)
+ }
+ }
+ }
+
+ let task = Task {
+ for await _ in stream {
+ break
+ }
+ }
+
+ // Give subscriber time to set up
+ try await Task.sleep(for: .milliseconds(50))
+
+ let testMessage = TestMessage(from: [:])
+ await manager.yieldTypedMessage(testMessage)
+ task.cancel()
+ _ = await task.value
+ }
+ }
+}
diff --git a/Tests/SundialKitStreamTests/StreamContinuationManager.swift b/Tests/SundialKitStreamTests/StreamContinuationManager.swift
new file mode 100644
index 0000000..ea96c14
--- /dev/null
+++ b/Tests/SundialKitStreamTests/StreamContinuationManager.swift
@@ -0,0 +1,34 @@
+//
+// StreamContinuationManagerTests.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Testing
+
+/// Top-level test suite for StreamContinuationManager
+@Suite("StreamContinuationManager Tests")
+internal enum StreamContinuationManager {}
diff --git a/Tests/SundialKitStreamTests/TestValueCapture.swift b/Tests/SundialKitStreamTests/TestValueCapture.swift
new file mode 100644
index 0000000..2e1669f
--- /dev/null
+++ b/Tests/SundialKitStreamTests/TestValueCapture.swift
@@ -0,0 +1,196 @@
+//
+// TestValueCapture.swift
+// SundialKitStream
+//
+// Created by Leo Dion.
+// Copyright © 2025 BrightDigit.
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+
+import Foundation
+
+@testable import SundialKitConnectivity
+@testable import SundialKitCore
+@testable import SundialKitStream
+
+/// Actor for safely capturing values in concurrent test scenarios
+///
+/// Provides actor-isolated storage to prevent data races when capturing
+/// values from AsyncStreams and Tasks in Swift 6.1+ strict concurrency mode.
+@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
+internal actor TestValueCapture {
+ // MARK: - Connectivity State Values
+
+ internal var activationState: ActivationState?
+ internal var activationResult: Result?
+ internal var reachability: Bool?
+ internal var pairedAppInstalled: Bool?
+ internal var paired: Bool?
+
+ // MARK: - Message Values
+
+ internal var message: ConnectivityReceiveResult?
+ internal var messages: [ConnectivityReceiveResult] = []
+ internal var typedMessage: (any Messagable)?
+
+ // MARK: - Network Values
+
+ internal var pathStatus: PathStatus?
+ internal var pathStatuses: [PathStatus] = []
+
+ // MARK: - Generic Values
+
+ internal var boolValue: Bool?
+ internal var boolValues: [Bool] = []
+ internal var stringValue: String?
+
+ // MARK: - Initialization
+
+ internal init() {}
+
+ // MARK: - Setters
+
+ internal func set(activationState: ActivationState) {
+ self.activationState = activationState
+ }
+
+ internal func set(activationResult: Result) {
+ self.activationResult = activationResult
+ }
+
+ internal func set(reachability: Bool) {
+ self.reachability = reachability
+ }
+
+ internal func set(pairedAppInstalled: Bool) {
+ self.pairedAppInstalled = pairedAppInstalled
+ }
+
+ internal func set(paired: Bool) {
+ self.paired = paired
+ }
+
+ internal func set(message: ConnectivityReceiveResult) {
+ self.message = message
+ }
+
+ internal func append(message: ConnectivityReceiveResult) {
+ self.messages.append(message)
+ }
+
+ internal func set(typedMessage: any Messagable) {
+ self.typedMessage = typedMessage
+ }
+
+ internal func set(pathStatus: PathStatus) {
+ self.pathStatus = pathStatus
+ }
+
+ internal func append(pathStatus: PathStatus) {
+ self.pathStatuses.append(pathStatus)
+ }
+
+ internal func set(boolValue: Bool) {
+ self.boolValue = boolValue
+ }
+
+ internal func append(boolValue: Bool) {
+ self.boolValues.append(boolValue)
+ }
+
+ internal func set(stringValue: String) {
+ self.stringValue = stringValue
+ }
+
+ // MARK: - Getters (for clarity, though direct property access works)
+
+ internal func getActivationState() -> ActivationState? {
+ activationState
+ }
+
+ internal func getActivationResult() -> Result? {
+ activationResult
+ }
+
+ internal func getReachability() -> Bool? {
+ reachability
+ }
+
+ internal func getPairedAppInstalled() -> Bool? {
+ pairedAppInstalled
+ }
+
+ internal func getPaired() -> Bool? {
+ paired
+ }
+
+ internal func getMessage() -> ConnectivityReceiveResult? {
+ message
+ }
+
+ internal func getMessages() -> [ConnectivityReceiveResult] {
+ messages
+ }
+
+ internal func getTypedMessage() -> (any Messagable)? {
+ typedMessage
+ }
+
+ internal func getPathStatus() -> PathStatus? {
+ pathStatus
+ }
+
+ internal func getPathStatuses() -> [PathStatus] {
+ pathStatuses
+ }
+
+ internal func getBoolValue() -> Bool? {
+ boolValue
+ }
+
+ internal func getBoolValues() -> [Bool] {
+ boolValues
+ }
+
+ internal func getStringValue() -> String? {
+ stringValue
+ }
+
+ // MARK: - Reset
+
+ internal func reset() {
+ activationState = nil
+ activationResult = nil
+ reachability = nil
+ pairedAppInstalled = nil
+ paired = nil
+ message = nil
+ messages = []
+ typedMessage = nil
+ pathStatus = nil
+ pathStatuses = []
+ boolValue = nil
+ boolValues = []
+ stringValue = nil
+ }
+}
diff --git a/project.yml b/project.yml
new file mode 100644
index 0000000..5ba3eb9
--- /dev/null
+++ b/project.yml
@@ -0,0 +1,13 @@
+name: SundialKitStream
+settings:
+ LINT_MODE: ${LINT_MODE}
+packages:
+ SundialKitStream:
+ path: .
+aggregateTargets:
+ Lint:
+ buildScripts:
+ - path: Scripts/lint.sh
+ name: Lint
+ basedOnDependencyAnalysis: false
+ schemes: {}