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

+ SundialKit +

+ +Modern async/await observation plugin for SundialKit with actor-based concurrency safety. + +[![SwiftPM](https://img.shields.io/badge/SPM-Linux%20%7C%20iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS-success?logo=swift)](https://swift.org) +[![Twitter](https://img.shields.io/badge/twitter-@brightdigit-blue.svg?style=flat)](http://twitter.com/brightdigit) +![GitHub](https://img.shields.io/github/license/brightdigit/SundialKitStream) + +## 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 + +![SundialKit Logo](logo.png) + +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: {}