├── .dockerignore ├── .github └── workflows │ └── SundialKit.yml ├── .gitignore ├── .htaccess ├── .spi.yml ├── .swift-version ├── .swiftformat ├── .swiftlint.yml ├── Assets ├── Reachable-Sundial.gif ├── Reachable-Sundial.mov ├── Readme-Sundial.gif ├── Readme-Sundial.mov └── logo.svg ├── Dockerfile ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Scripts ├── docc.sh ├── gh-md-toc └── httpd.conf ├── Sources └── SundialKit │ ├── Network │ ├── Extensions │ │ ├── NWInterface.swift │ │ ├── NWPath.swift │ │ ├── NWPathMonitor.swift │ │ ├── PathStatus.Network.swift │ │ └── PathStatus.UnsatisfiedReason.swift │ ├── Interfaceable.swift │ ├── NetworkObserver.swift │ ├── NetworkPath.swift │ ├── NetworkPing.swift │ ├── NeverPing.swift │ ├── PathMonitor.swift │ └── PathStatus.swift │ ├── PassthroughSubject.swift │ ├── SundialError.swift │ ├── SundialKit.docc │ ├── ConnectivityObserver.md │ ├── Documentation.md │ ├── NetworkObserver.md │ └── Resources │ │ └── logo.jpg │ └── WatchConnectivity │ ├── ActivationState.swift │ ├── ConnectivityHandler.swift │ ├── ConnectivityMessage.swift │ ├── ConnectivityObserver.swift │ ├── ConnectivityReceiveContext.swift │ ├── ConnectivityReceiveResult.swift │ ├── ConnectivitySendContext.swift │ ├── ConnectivitySendResult.swift │ ├── ConnectivitySession.swift │ ├── ConnectivitySessionDelegate.swift │ ├── Extensions │ └── WCSession.swift │ ├── Messagable.swift │ ├── MessagableKeys.swift │ ├── MessageDecoder.swift │ ├── NeverConnectivitySession.swift │ └── WatchConnectivitySession.swift ├── Tests ├── .swiftlint.yml └── SundialKitTests │ ├── MockError.swift │ ├── MockMessage.swift │ ├── MockNetworkPing.swift │ ├── MockPath.swift │ ├── MockPathMonitor.swift │ ├── MockSession.swift │ ├── Network │ ├── Extensions │ │ ├── NWInterfaceTests.swift │ │ ├── NWPathMonitorTests.swift │ │ └── PathStatusNetworkTests.swift │ └── NetworkObserverTests.swift │ ├── PassthroughSubjectTests.swift │ └── WatchConnectivity │ ├── ConnectivityObserverInternalTests.swift │ ├── ConnectivityObserverMessageTests.swift │ ├── ConnectivityObserverPropertyTests.swift │ ├── ConnectivityReceiveContextTests.swift │ ├── ConnectivitySendContextTests.swift │ ├── MessagableTests.swift │ ├── MessageDecoderTests.swift │ └── NeverConnectivitySessionTests.swift └── codecov.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .swiftpm/ 3 | DerivedData 4 | -------------------------------------------------------------------------------- /.github/workflows/SundialKit.yml: -------------------------------------------------------------------------------- 1 | name: SundialKit 2 | on: 3 | push: 4 | branches-ignore: 5 | - '*WIP' 6 | jobs: 7 | build-ubuntu: 8 | name: Build on Ubuntu 9 | env: 10 | PACKAGE_NAME: Spinetail 11 | SWIFT_VER: ${{ matrix.swift-version }} 12 | runs-on: ${{ matrix.runs-on }} 13 | if: "!contains(github.event.head_commit.message, 'ci skip')" 14 | strategy: 15 | matrix: 16 | runs-on: [ubuntu-18.04, ubuntu-20.04] 17 | swift-version: [5.5.2, 5.6.2, 5.7] 18 | include: 19 | - runs-on: ubuntu-22.04 20 | swift-version: 5.7 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set Ubuntu Release DOT 24 | run: echo "RELEASE_DOT=$(lsb_release -sr)" >> $GITHUB_ENV 25 | - name: Set Ubuntu Release NUM 26 | run: echo "RELEASE_NUM=${RELEASE_DOT//[-._]/}" >> $GITHUB_ENV 27 | - name: Set Ubuntu Codename 28 | run: echo "RELEASE_NAME=$(lsb_release -sc)" >> $GITHUB_ENV 29 | - name: Download Swift 30 | run: curl -O https://download.swift.org/swift-${SWIFT_VER}-release/ubuntu${RELEASE_NUM}/swift-${SWIFT_VER}-RELEASE/swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}.tar.gz 31 | - name: Extract Swift 32 | run: tar xzf swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}.tar.gz 33 | - name: Add Path 34 | run: echo "$GITHUB_WORKSPACE/swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}/usr/bin" >> $GITHUB_PATH 35 | - name: Build 36 | run: swift build 37 | - name: Run tests 38 | run: swift test --enable-test-discovery --enable-code-coverage 39 | - uses: sersoft-gmbh/swift-coverage-action@v2 40 | - name: Upload SPM coverage to Codecov 41 | uses: codecov/codecov-action@v2 42 | with: 43 | fail_ci_if_error: true 44 | flags: spm,${{ env.RELEASE_NAME }},${{ env.SWIFT_VER }} 45 | verbose: true 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | build-macos: 48 | name: Build on macOS 49 | env: 50 | PACKAGE_NAME: SundialKit 51 | runs-on: ${{ matrix.runs-on }} 52 | if: "!contains(github.event.head_commit.message, 'ci skip')" 53 | strategy: 54 | matrix: 55 | include: 56 | - runs-on: macos-11 57 | xcode: "/Applications/Xcode_13.2.1.app" 58 | iOSVersion: 15.2 59 | watchOSVersion: 8.3 60 | watchName: "Apple Watch Series 7 - 41mm" 61 | - runs-on: macos-12 62 | xcode: "/Applications/Xcode_13.3.app" 63 | iOSVersion: 15.4 64 | watchOSVersion: 8.5 65 | watchName: "Apple Watch Series 7 - 41mm" 66 | - runs-on: macos-12 67 | xcode: "/Applications/Xcode_13.4.1.app" 68 | iOSVersion: 15.5 69 | watchOSVersion: 8.5 70 | watchName: "Apple Watch Series 7 - 41mm" 71 | - runs-on: macos-12 72 | xcode: "/Applications/Xcode_14.0.1.app" 73 | iOSVersion: "16.0" 74 | watchOSVersion: "9.0" 75 | watchName: "Apple Watch Series 8 (41mm)" 76 | - runs-on: macos-12 77 | xcode: "/Applications/Xcode_14.1.app" 78 | iOSVersion: "16.1" 79 | watchOSVersion: "9.1" 80 | watchName: "Apple Watch Ultra (49mm)" 81 | steps: 82 | - uses: actions/checkout@v2 83 | - name: Set Xcode Name 84 | run: echo "XCODE_NAME=$(basename -- ${{ matrix.xcode }} | sed 's/\.[^.]*$//' | cut -d'_' -f2)" >> $GITHUB_ENV 85 | - name: Setup Xcode 86 | run: sudo xcode-select -s ${{ matrix.xcode }}/Contents/Developer 87 | - name: Build 88 | run: swift build 89 | - name: Run Swift Package tests 90 | run: swift test -v --enable-code-coverage 91 | - uses: sersoft-gmbh/swift-coverage-action@v2 92 | - name: Upload SPM coverage to Codecov 93 | uses: codecov/codecov-action@v2 94 | with: 95 | fail_ci_if_error: true 96 | flags: spm 97 | verbose: true 98 | token: ${{ secrets.CODECOV_TOKEN }} 99 | - name: Clean up spm build directory 100 | run: rm -rf .build 101 | - name: Lint 102 | if: startsWith(github.ref, 'refs/tags/') != true 103 | run: swift run swiftformat --lint . && swift run swiftlint 104 | - name: Dump PIF 105 | run: | 106 | swift package dump-pif > /dev/null 107 | xcodebuild clean -scheme SundialKit -destination 'generic/platform=iOS' > /dev/null 108 | if: matrix.xcode == '/Applications/Xcode_14.1.app' 109 | - name: Run iOS target tests 110 | run: xcodebuild test -scheme SundialKit -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 13,OS=${{ matrix.iOSVersion }}' -enableCodeCoverage YES build test 111 | - uses: sersoft-gmbh/swift-coverage-action@v2 112 | - name: Upload iOS coverage to Codecov 113 | uses: codecov/codecov-action@v2 114 | with: 115 | fail_ci_if_error: true 116 | flags: iOS,iOS-${{ matrix.iOSVersion }} 117 | verbose: true 118 | token: ${{ secrets.CODECOV_TOKEN }} 119 | - name: Run watchOS target tests 120 | run: xcodebuild test -scheme SundialKit -sdk watchsimulator -destination 'platform=watchOS Simulator,name=${{ matrix.watchName }},OS=${{ matrix.watchOSVersion }}' -enableCodeCoverage YES build test 121 | - uses: sersoft-gmbh/swift-coverage-action@v2 122 | - name: Upload watchOS coverage to Codecov 123 | uses: codecov/codecov-action@v2 124 | with: 125 | fail_ci_if_error: true 126 | flags: watchOS,watchOS${{ matrix.watchOSVersion }} 127 | verbose: true 128 | token: ${{ secrets.CODECOV_TOKEN }} 129 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,swiftpm,swiftpackagemanager,xcode,macos 4 | 5 | ### macOS ### 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | 15 | # Thumbnails 16 | ._* 17 | 18 | # Files that might appear in the root of a volume 19 | .DocumentRevisions-V100 20 | .fseventsd 21 | .Spotlight-V100 22 | .TemporaryItems 23 | .Trashes 24 | .VolumeIcon.icns 25 | .com.apple.timemachine.donotpresent 26 | 27 | # Directories potentially created on remote AFP share 28 | .AppleDB 29 | .AppleDesktop 30 | Network Trash Folder 31 | Temporary Items 32 | .apdisk 33 | 34 | ### Swift ### 35 | # Xcode 36 | # 37 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 38 | 39 | ## User settings 40 | xcuserdata/ 41 | 42 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 43 | *.xcscmblueprint 44 | *.xccheckout 45 | 46 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 47 | build/ 48 | DerivedData/ 49 | *.moved-aside 50 | *.pbxuser 51 | !default.pbxuser 52 | *.mode1v3 53 | !default.mode1v3 54 | *.mode2v3 55 | !default.mode2v3 56 | *.perspectivev3 57 | !default.perspectivev3 58 | 59 | ## Obj-C/Swift specific 60 | *.hmap 61 | 62 | ## App packaging 63 | *.ipa 64 | *.dSYM.zip 65 | *.dSYM 66 | 67 | ## Playgrounds 68 | timeline.xctimeline 69 | playground.xcworkspace 70 | 71 | # Swift Package Manager 72 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 73 | # Packages/ 74 | # Package.pins 75 | # Package.resolved 76 | # *.xcodeproj 77 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 78 | # hence it is not needed unless you have added a package configuration file to your project 79 | .swiftpm 80 | 81 | .build/ 82 | 83 | # CocoaPods 84 | # We recommend against adding the Pods directory to your .gitignore. However 85 | # you should judge for yourself, the pros and cons are mentioned at: 86 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 87 | # Pods/ 88 | # Add this line if you want to avoid checking in source code from the Xcode workspace 89 | # *.xcworkspace 90 | 91 | # Carthage 92 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 93 | # Carthage/Checkouts 94 | 95 | Carthage/Build/ 96 | 97 | # Accio dependency management 98 | Dependencies/ 99 | .accio/ 100 | 101 | # fastlane 102 | # It is recommended to not store the screenshots in the git repo. 103 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 104 | # For more information about the recommended setup visit: 105 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 106 | 107 | fastlane/report.xml 108 | fastlane/Preview.html 109 | fastlane/screenshots/**/*.png 110 | fastlane/test_output 111 | 112 | # Code Injection 113 | # After new code Injection tools there's a generated folder /iOSInjectionProject 114 | # https://github.com/johnno1962/injectionforxcode 115 | 116 | iOSInjectionProject/ 117 | 118 | ### SwiftPackageManager ### 119 | Packages 120 | xcuserdata 121 | *.xcodeproj 122 | 123 | 124 | ### SwiftPM ### 125 | 126 | 127 | ### Xcode ### 128 | 129 | ## Xcode 8 and earlier 130 | 131 | ### Xcode Patch ### 132 | *.xcodeproj/* 133 | !*.xcodeproj/project.pbxproj 134 | !*.xcodeproj/xcshareddata/ 135 | !*.xcworkspace/contents.xcworkspacedata 136 | /*.gcno 137 | **/xcshareddata/WorkspaceSettings.xcsettings 138 | 139 | # End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos 140 | 141 | **/*.pxd/QuickLook/*.* 142 | **/*.appiconset/*.png -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | # Enable custom routing. 2 | RewriteEngine On 3 | 4 | RedirectMatch ^/$ /documentation/ 5 | 6 | # Route documentation and tutorial pages. 7 | RewriteRule ^(documentation|tutorials)\/.*$ SundialKit.doccarchive/index.html [L] 8 | 9 | RewriteRule /data/documentation.json SundialKit.doccarchive/data/documentation/sundialkit.json [L] 10 | 11 | # Route files and data for the documentation archive. 12 | # 13 | # If the file path doesn't exist in the website's root ... 14 | RewriteCond %{REQUEST_FILENAME} !-f 15 | RewriteCond %{REQUEST_FILENAME} !-d 16 | 17 | # ... route the request to that file path with the documentation archive. 18 | RewriteRule .* SundialKit.doccarchive/$0 [L] -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - platform: ios 5 | documentation_targets: [SundialKit] 6 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --indent 2 2 | --header strip 3 | --commas inline 4 | --disable wrapMultilineStatementBraces 5 | --extensionacl on-extension 6 | --decimalgrouping 3,4 7 | --exclude .build, DerivedData 8 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | opt_in_rules: 2 | - anyobject_protocol 3 | - array_init 4 | - attributes 5 | - closure_body_length 6 | - closure_end_indentation 7 | - closure_spacing 8 | - collection_alignment 9 | - conditional_returns_on_newline 10 | - contains_over_filter_count 11 | - contains_over_filter_is_empty 12 | - contains_over_first_not_nil 13 | - contains_over_range_nil_comparison 14 | - convenience_type 15 | - discouraged_object_literal 16 | - discouraged_optional_boolean 17 | - empty_collection_literal 18 | - empty_count 19 | - empty_string 20 | - empty_xctest_method 21 | - enum_case_associated_values_count 22 | - expiring_todo 23 | - explicit_acl 24 | - explicit_init 25 | - explicit_self 26 | - explicit_top_level_acl 27 | - fallthrough 28 | - fatal_error_message 29 | - file_header 30 | - file_name 31 | - file_name_no_space 32 | - file_types_order 33 | - first_where 34 | - flatmap_over_map_reduce 35 | - force_unwrapping 36 | - function_default_parameter_at_end 37 | - ibinspectable_in_extension 38 | - identical_operands 39 | - implicit_return 40 | - implicitly_unwrapped_optional 41 | - indentation_width 42 | - joined_default_parameter 43 | - last_where 44 | - legacy_multiple 45 | - legacy_random 46 | - literal_expression_end_indentation 47 | - lower_acl_than_parent 48 | - missing_docs 49 | - modifier_order 50 | - multiline_arguments 51 | - multiline_arguments_brackets 52 | - multiline_function_chains 53 | - multiline_literal_brackets 54 | - multiline_parameters 55 | - nimble_operator 56 | - nslocalizedstring_key 57 | - nslocalizedstring_require_bundle 58 | - number_separator 59 | - object_literal 60 | - operator_usage_whitespace 61 | - optional_enum_case_matching 62 | - overridden_super_call 63 | - override_in_extension 64 | - pattern_matching_keywords 65 | - prefer_self_type_over_type_of_self 66 | - prefer_zero_over_explicit_init 67 | - private_action 68 | - private_outlet 69 | - prohibited_interface_builder 70 | - prohibited_super_call 71 | - quick_discouraged_call 72 | - quick_discouraged_focused_test 73 | - quick_discouraged_pending_test 74 | - reduce_into 75 | - redundant_nil_coalescing 76 | - redundant_type_annotation 77 | - required_enum_case 78 | - single_test_class 79 | - sorted_first_last 80 | - sorted_imports 81 | - static_operator 82 | - strict_fileprivate 83 | - strong_iboutlet 84 | - switch_case_on_newline 85 | - toggle_bool 86 | - trailing_closure 87 | - type_contents_order 88 | - unavailable_function 89 | - unneeded_parentheses_in_closure_argument 90 | - unowned_variable_capture 91 | - untyped_error_in_catch 92 | - unused_declaration 93 | - unused_import 94 | - vertical_parameter_alignment_on_call 95 | - vertical_whitespace_between_cases 96 | - vertical_whitespace_closing_braces 97 | - vertical_whitespace_opening_braces 98 | - xct_specific_matcher 99 | - yoda_condition 100 | cyclomatic_complexity: 101 | - 6 102 | - 9 103 | file_length: 104 | warning: 200 105 | error: 300 106 | function_body_length: 107 | - 15 108 | - 25 109 | function_parameter_count: 8 110 | line_length: 111 | - 100 112 | - 120 113 | identifier_name: 114 | excluded: 115 | - id 116 | excluded: 117 | - Tests/*/XCTestManifests.swift 118 | - DerivedData 119 | - .build 120 | - Tests/LinuxMain.swift 121 | indentation_width: 122 | indentation_width: 2 123 | file_name: 124 | suffix_pattern: "\\..+" 125 | excluded: ["DeprecatedRenamed.swift", "PathStatus.UnsatisfiedReason.swift"] 126 | -------------------------------------------------------------------------------- /Assets/Reachable-Sundial.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brightdigit/SundialKit/2e1082d8ebdd74bc053191aff0884e2a439e483d/Assets/Reachable-Sundial.gif -------------------------------------------------------------------------------- /Assets/Reachable-Sundial.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brightdigit/SundialKit/2e1082d8ebdd74bc053191aff0884e2a439e483d/Assets/Reachable-Sundial.mov -------------------------------------------------------------------------------- /Assets/Readme-Sundial.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brightdigit/SundialKit/2e1082d8ebdd74bc053191aff0884e2a439e483d/Assets/Readme-Sundial.gif -------------------------------------------------------------------------------- /Assets/Readme-Sundial.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brightdigit/SundialKit/2e1082d8ebdd74bc053191aff0884e2a439e483d/Assets/Readme-Sundial.mov -------------------------------------------------------------------------------- /Assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM httpd:alpine 2 | 3 | RUN rm /usr/local/apache2/htdocs/index.html 4 | COPY Scripts/httpd.conf /usr/local/apache2/conf/httpd.conf -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 BrightDigit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Komondor", 6 | "repositoryURL": "https://github.com/shibapm/Komondor", 7 | "state": { 8 | "branch": null, 9 | "revision": "dedffeaf42a1b52bf072a9dc47afb35000a8b265", 10 | "version": "1.1.4" 11 | } 12 | }, 13 | { 14 | "package": "Logger", 15 | "repositoryURL": "https://github.com/shibapm/Logger", 16 | "state": { 17 | "branch": null, 18 | "revision": "53c3ecca5abe8cf46697e33901ee774236d94cce", 19 | "version": "0.2.3" 20 | } 21 | }, 22 | { 23 | "package": "PackageConfig", 24 | "repositoryURL": "https://github.com/shibapm/PackageConfig.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "58523193c26fb821ed1720dcd8a21009055c7cdb", 28 | "version": "1.1.3" 29 | } 30 | }, 31 | { 32 | "package": "Rocket", 33 | "repositoryURL": "https://github.com/shibapm/Rocket", 34 | "state": { 35 | "branch": null, 36 | "revision": "9880a5beb7fcb9e61ddd5764edc1700b8c418deb", 37 | "version": "1.2.1" 38 | } 39 | }, 40 | { 41 | "package": "ShellOut", 42 | "repositoryURL": "https://github.com/JohnSundell/ShellOut.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "e1577acf2b6e90086d01a6d5e2b8efdaae033568", 46 | "version": "2.3.0" 47 | } 48 | }, 49 | { 50 | "package": "SourceKitten", 51 | "repositoryURL": "https://github.com/jpsim/SourceKitten.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "817dfa6f2e09b0476f3a6c9dbc035991f02f0241", 55 | "version": "0.32.0" 56 | } 57 | }, 58 | { 59 | "package": "swift-argument-parser", 60 | "repositoryURL": "https://github.com/apple/swift-argument-parser.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "e394bf350e38cb100b6bc4172834770ede1b7232", 64 | "version": "1.0.3" 65 | } 66 | }, 67 | { 68 | "package": "SwiftSyntax", 69 | "repositoryURL": "https://github.com/apple/swift-syntax.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "0b6c22b97f8e9320bca62e82cdbee601cf37ad3f", 73 | "version": "0.50600.1" 74 | } 75 | }, 76 | { 77 | "package": "SwiftFormat", 78 | "repositoryURL": "https://github.com/nicklockwood/SwiftFormat", 79 | "state": { 80 | "branch": null, 81 | "revision": "4347527a2fb8ecec9e2c3eb23bbce7a5d1a5e459", 82 | "version": "0.50.0" 83 | } 84 | }, 85 | { 86 | "package": "SwiftLint", 87 | "repositoryURL": "https://github.com/realm/SwiftLint", 88 | "state": { 89 | "branch": null, 90 | "revision": "22fb9eb9e55b8f5ad9b48fe6f15ea7daabaafae3", 91 | "version": "0.48.0" 92 | } 93 | }, 94 | { 95 | "package": "SwiftShell", 96 | "repositoryURL": "https://github.com/kareman/SwiftShell", 97 | "state": { 98 | "branch": null, 99 | "revision": "a6014fe94c3dbff0ad500e8da4f251a5d336530b", 100 | "version": "5.1.0-beta.1" 101 | } 102 | }, 103 | { 104 | "package": "SwiftyTextTable", 105 | "repositoryURL": "https://github.com/scottrhoyt/SwiftyTextTable.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", 109 | "version": "0.9.0" 110 | } 111 | }, 112 | { 113 | "package": "SWXMLHash", 114 | "repositoryURL": "https://github.com/drmohundro/SWXMLHash.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "6469881a3f30417c5bb02404ea4b69207f297592", 118 | "version": "6.0.0" 119 | } 120 | }, 121 | { 122 | "package": "Yams", 123 | "repositoryURL": "https://github.com/jpsim/Yams", 124 | "state": { 125 | "branch": null, 126 | "revision": "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa", 127 | "version": "4.0.6" 128 | } 129 | } 130 | ] 131 | }, 132 | "version": 1 133 | } 134 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.5 2 | // swiftlint:disable explicit_top_level_acl explicit_acl 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SundialKit", 7 | platforms: [ 8 | .iOS(.v13), 9 | .watchOS(.v6), 10 | .tvOS(.v13), 11 | .macOS(.v10_13) 12 | ], 13 | products: [ 14 | .library( 15 | name: "SundialKit", 16 | targets: ["SundialKit"] 17 | ) 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/shibapm/Komondor", from: "1.1.2"), // dev 21 | .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.47.0"), // dev 22 | .package(url: "https://github.com/realm/SwiftLint", from: "0.41.0"), // dev 23 | .package(url: "https://github.com/shibapm/Rocket", from: "1.2.0") // dev 24 | ], 25 | targets: [ 26 | .target( 27 | name: "SundialKit", 28 | dependencies: [] 29 | ), 30 | .testTarget( 31 | name: "SundialKitTests", 32 | dependencies: ["SundialKit"] 33 | ) 34 | ] 35 | ) 36 | 37 | #if canImport(PackageConfig) 38 | import PackageConfig 39 | 40 | let requiredCoverage: Int = 0 41 | 42 | let config = PackageConfiguration([ 43 | "rocket": [ 44 | "steps": 45 | [ 46 | "hide_dev_dependencies", 47 | "git_add", 48 | ["commit": ["no_verify": true]], 49 | "tag", 50 | "unhide_dev_dependencies", 51 | "git_add", 52 | ["commit": ["message": "Unhide dev dependencies"]] 53 | ] 54 | ], 55 | "komondor": [ 56 | "pre-push": [ 57 | "swift test --enable-code-coverage" 58 | ], 59 | "pre-commit": [ 60 | "swift test --enable-code-coverage", 61 | "swift run swiftformat .", 62 | "swift run swiftlint autocorrect", 63 | "git add .", 64 | "swift run swiftformat --lint .", 65 | "swift run swiftlint lint --strict" 66 | ] 67 | ] 68 | ]).write() 69 | #endif 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | SundialKit 3 |

4 |

SundialKit

5 | 6 | Reactive communications library across Apple platforms. 7 | 8 | [![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) 9 | [![Twitter](https://img.shields.io/badge/twitter-@brightdigit-blue.svg?style=flat)](http://twitter.com/brightdigit) 10 | ![GitHub](https://img.shields.io/github/license/brightdigit/SundialKit) 11 | ![GitHub issues](https://img.shields.io/github/issues/brightdigit/SundialKit) 12 | ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/brightdigit/SundialKit/SundialKit?label=actions&logo=github) 13 | 14 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FSundialKit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/brightdigit/SundialKit) 15 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FSundialKit%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/brightdigit/SundialKit) 16 | 17 | 18 | [![Codecov](https://img.shields.io/codecov/c/github/brightdigit/SundialKit)](https://codecov.io/gh/brightdigit/SundialKit) 19 | [![CodeFactor Grade](https://img.shields.io/codefactor/grade/github/brightdigit/SundialKit)](https://www.codefactor.io/repository/github/brightdigit/SundialKit) 20 | [![codebeat badge](https://codebeat.co/badges/54695d4b-98c8-4f0f-855e-215500163094)](https://codebeat.co/projects/github-com-brightdigit-SundialKit-main) 21 | [![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/brightdigit/SundialKit)](https://codeclimate.com/github/brightdigit/SundialKit) 22 | [![Code Climate technical debt](https://img.shields.io/codeclimate/tech-debt/brightdigit/SundialKit?label=debt)](https://codeclimate.com/github/brightdigit/SundialKit) 23 | [![Code Climate issues](https://img.shields.io/codeclimate/issues/brightdigit/SundialKit)](https://codeclimate.com/github/brightdigit/SundialKit) 24 | [![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com) 25 | 26 | 27 | ![Communication between iPhone and Apple Watch using Demo App](Assets/Readme-Sundial.gif "Communication between iPhone and Apple Watch using Demo App") 28 | 29 | # Table of Contents 30 | 31 | * [**Introduction**](#introduction) 32 | * [**Features**](#features) 33 | * [**Installation**](#installation) 34 | * [**Usage**](#usage) 35 | * [**Listening to Networking Changes**](#listening-to-networking-changes) 36 | * [**Communication between iPhone and Apple Watch**](#communication-between-iphone-and-apple-watch) 37 | * [**Connection Status**](#connection-status) 38 | * [**Sending and Receiving Messages**](#sending-and-receiving-messages) 39 | * [**Using Messagable to Communicate**](#using-messagable-to-communicate) 40 | * [**License**](#license) 41 | 42 | # Introduction 43 | 44 | For easier use in reactive user interfaces, especially with `SwiftUI` and `Combine`, I've created a library which abstracts and maps common connectivity APIs. Particularly in my app Heartwitch, I mapped the functionality of _WatchConnectivity_ and _Network_ over to track the user's ability to connect to the Internet as well as the ability for their iPhone to connect to their Apple Watch via _WatchConnectivity_ 45 | 46 | # Features 47 | 48 | Here's what's currently implemented with this library: 49 | 50 | - [x] Monitor network connectivity and quality 51 | - [x] Communicate between iPhone and Apple Watch 52 | - [x] Monitor connectivity between devices 53 | - [x] Send messages back and forth between iPhone and Apple Watch 54 | - [x] Abstract messages for easier _encoding_ and _decoding_ 55 | 56 | # Installation 57 | 58 | Swift Package Manager is Apple's decentralized dependency manager to integrate libraries to your Swift projects. It is now fully integrated with Xcode 13. 59 | 60 | To integrate **SundialKit** into your project using SPM, specify it in your Package.swift file: 61 | 62 | ```swift 63 | let package = Package( 64 | ... 65 | dependencies: [ 66 | .package(url: "https://github.com/brightdigit/SundialKit.git", from: "0.2.0") 67 | ], 68 | targets: [ 69 | .target( 70 | name: "YourTarget", 71 | dependencies: ["SundialKit", ...]), 72 | ... 73 | ] 74 | ) 75 | ``` 76 | 77 | # Usage 78 | 79 | ## Listening to Networking Changes 80 | 81 | In the past `Reachability` or `AFNetworking` has been used to judge the network connectivity of a device. **SundialKit** uses the `Network` framework to listen to changes in connectivity providing all the information available. 82 | 83 | **SundialKit** provides a `NetworkObserver` which allows you to listen to a variety of publishers related to the network. This is especially useful if you are using `SwiftUI` in particular. With `SwiftUI`, you can create an `ObservableObject` which contains a `NetworkObserver`: 84 | 85 | ```swift 86 | import SwiftUI 87 | import SundialKit 88 | 89 | class NetworkConnectivityObject : ObservableObject { 90 | // our NetworkObserver 91 | let connectivityObserver = NetworkObserver() 92 | 93 | // our published property for pathStatus initially set to `.unknown` 94 | @Published var pathStatus : PathStatus = .unknown 95 | 96 | init () { 97 | // set the pathStatus changes to our published property 98 | connectivityObserver 99 | .pathStatusPublisher 100 | .receive(on: DispatchQueue.main) 101 | .assign(to: &self.$pathStatus) 102 | } 103 | 104 | // need to start listening 105 | func start () { 106 | self.connectivityObserver.start(queue: .global()) 107 | } 108 | } 109 | ``` 110 | 111 | There are 3 important pieces: 112 | 113 | 1. The `NetworkObserver` called `connectivityObserver` 114 | 2. On `init`, we use `Combine` to listen to the publisher and store each new `pathStatus` to our `@Published` property. 115 | 3. A `start` method which needs to be called to start listening to the `NetworkObserver`. 116 | 117 | Therefore for our `SwiftUI` `View`, we need to `start` listening `onAppear` and can use the `pathStatus` property in the `View`: 118 | 119 | ```swift 120 | 121 | struct NetworkObserverView: View { 122 | @StateObject var connectivityObject = NetworkConnectivityObject() 123 | var body: some View { 124 | // Use the `message` property to display text of the `pathStatus` 125 | Text(self.connectivityObject.pathStatus.message).onAppear{ 126 | // start the NetworkObserver 127 | self.connectivityObject.start() 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | Besides `pathStatus`, you also have access to: 134 | 135 | * `isExpensive` 136 | * `isConstrained` 137 | 138 | ### Verify Connectivity with ``NetworkPing`` 139 | 140 | In addition to utilizing `NWPathMonitor`, you can setup a periodic ping by implementing ``NetworkPing``. Here's an example which calls the _ipify_ API to verify there's an ip address: 141 | 142 | ```swift 143 | struct IpifyPing : NetworkPing { 144 | typealias StatusType = String? 145 | 146 | let session: URLSession 147 | let timeInterval: TimeInterval 148 | 149 | public func shouldPing(onStatus status: PathStatus) -> Bool { 150 | switch status { 151 | case .unknown, .unsatisfied: 152 | return false 153 | case .requiresConnection, .satisfied: 154 | return true 155 | } 156 | } 157 | 158 | static let url : URL = .init(string: "https://api.ipify.org")! 159 | 160 | func onPing(_ closure: @escaping (String?) -> Void) { 161 | session.dataTask(with: IpifyPing.url) { data, _, _ in 162 | closure(data.flatMap{String(data: $0, encoding: .utf8)}) 163 | }.resume() 164 | } 165 | } 166 | ``` 167 | 168 | Next, in our `ObservableObject`, we can create a ``NetworkObserver`` to use this with: 169 | 170 | ```swift 171 | @Published var nwObject = NetworkObserver(ping: 172 | // use the shared `URLSession` and check every 10.0 seconds 173 | IpifyPing(session: .shared, timeInterval: 10.0) 174 | ) 175 | ``` 176 | 177 | ## Communication between iPhone and Apple Watch 178 | 179 | Besides networking, **SundialKit** also provides an easier reactive interface into `WatchConnectivity`. This includes: 180 | 181 | 1. Various connection statuses like `isReachable`, `isInstalled`, etc.. 182 | 2. Send messages between the iPhone and paired Apple Watch 183 | 3. Easy encoding and decoding of messages between devices into `WatchConnectivity` friendly dictionaries. 184 | 185 | ![Showing changes to `isReachable` using SundialKit](Assets/Reachable-Sundial.gif "Showing changes to `isReachable` using SundialKit") 186 | 187 | Let's first talk about how `WatchConnectivity` status works. 188 | 189 | ### Connection Status 190 | 191 | With `WatchConnectivity` there's a variety of properties which tell you the status of connection between devices. Here's a similar example to `pathStatus` using `isReachable`: 192 | 193 | 194 | ```swift 195 | import SwiftUI 196 | import SundialKit 197 | 198 | class WatchConnectivityObject : ObservableObject { 199 | // our ConnectivityObserver 200 | let connectivityObserver = ConnectivityObserver() 201 | // our published property for isReachable initially set to false 202 | @Published var isReachable : Bool = false 203 | init () { 204 | // set the isReachable changes to our published property 205 | connectivityObserver 206 | .isReachablePublisher 207 | .receive(on: DispatchQueue.main) 208 | .assign(to: &self.$isReachable) 209 | } 210 | 211 | func activate () { 212 | // activate the WatchConnectivity session 213 | try! self.connectivityObserver.activate() 214 | } 215 | } 216 | ``` 217 | 218 | Again, there are 3 important pieces: 219 | 220 | 1. The `ConnectivityObserver` called `connectivityObserver` 221 | 2. On `init`, we use `Combine` to listen to the publisher and store each new `isReachable` to our `@Published` property. 222 | 3. An `activate` method which needs to be called to activate the session for `WatchConnectivity`. 223 | 224 | Therefore for our `SwiftUI` `View`, we need to `activate` the session at `onAppear` and can use the `isReachable` property in the `View`: 225 | 226 | ```swift 227 | 228 | struct WatchConnectivityView: View { 229 | @StateObject var connectivityObject = WatchConnectivityObject() 230 | var body: some View { 231 | Text( 232 | connectivityObject.isReachable ? 233 | "Reachable" : "Not Reachable" 234 | ) 235 | .onAppear{ 236 | self.connectivityObject.activate() 237 | } 238 | } 239 | } 240 | ``` 241 | 242 | Besides `isReachable`, you also have access to: 243 | 244 | * `activationState` 245 | * `isReachable` 246 | * `isPairedAppInstalled` 247 | * `isPaired` 248 | 249 | Additionally there's also a set of publishers for sending, receiving, and replying to messages between the iPhone and paired Apple Watch. 250 | 251 | ### Sending and Receiving Messages 252 | 253 | To send and receive messages through our ``ConnectivityObserver`` we can access two properties: 254 | 255 | - ``ConnectivityObserver/messageReceivedPublisher`` - for listening to messages 256 | - ``ConnectivityObserver/sendingMessageSubject`` - for sending messages 257 | 258 | **SundialKit** uses `[String:Any]` dictionaries for sending and receiving messages, which use the typealias ``ConnectivityMessage``. Let's expand upon the previous `WatchConnectivityObject` and use those properties: 259 | 260 | ```swift 261 | class WatchConnectivityObject : ObservableObject { 262 | 263 | // our ConnectivityObserver 264 | let connectivityObserver = ConnectivityObserver() 265 | 266 | // our published property for isReachable initially set to false 267 | @Published var isReachable : Bool = false 268 | 269 | // our published property for the last message received 270 | @Published var lastReceivedMessage : String = "" 271 | 272 | init () { 273 | // set the isReachable changes to our published property 274 | connectivityObserver 275 | .isReachablePublisher 276 | .receive(on: DispatchQueue.main) 277 | .assign(to: &self.$isReachable) 278 | 279 | // set the lastReceivedMessage based on the dictionary's _message_ key 280 | connectivityObserver 281 | .messageReceivedPublisher 282 | .compactMap({ received in 283 | received.message["message"] as? String 284 | }) 285 | .receive(on: DispatchQueue.main) 286 | .assign(to: &self.$lastReceivedMessage) 287 | } 288 | 289 | func activate () { 290 | // activate the WatchConnectivity session 291 | try! self.connectivityObserver.activate() 292 | } 293 | 294 | func sendMessage(_ message: String) { 295 | // create a dictionary with the message in the message key 296 | self.connectivityObserver.sendingMessageSubject.send(["message" : message]) 297 | } 298 | } 299 | ``` 300 | 301 | We can now create a simple SwiftUI View using our updated `WatchConnectivityObject`: 302 | 303 | ```swift 304 | struct WatchMessageDemoView: View { 305 | @StateObject var connectivityObject = WatchMessageObject() 306 | @State var message : String = "" 307 | var body: some View { 308 | VStack{ 309 | Text(connectivityObject.isReachable ? "Reachable" : "Not Reachable").onAppear{ 310 | self.connectivityObject.activate() 311 | } 312 | TextField("Message", text: self.$message) 313 | Button("Send") { 314 | self.connectivityObject.sendMessage(self.message) 315 | } 316 | 317 | Text("Last received message:") 318 | Text(self.connectivityObject.lastReceivedMessage) 319 | } 320 | } 321 | } 322 | ``` 323 | 324 | ### Using `Messagable` to Communicate 325 | 326 | We can even abstract the ``ConnectivityMessage`` using a ``MessageDecoder``. To do this we need to create a special type which implements ``Messagable``: 327 | 328 | ```swift 329 | struct Message : Messagable { 330 | internal init(text: String) { 331 | self.text = text 332 | } 333 | 334 | static let key: String = "_message" 335 | 336 | enum Parameters : String { 337 | case text 338 | } 339 | 340 | init?(from parameters: [String : Any]?) { 341 | guard let text = parameters?[Parameters.text.rawValue] as? String else { 342 | return nil 343 | } 344 | 345 | self.text = text 346 | } 347 | 348 | func parameters() -> [String : Any] { 349 | return [ 350 | Parameters.text.rawValue : self.text 351 | ] 352 | } 353 | 354 | let text : String 355 | } 356 | ``` 357 | 358 | There are three requirements for implementing ``Messagable``: 359 | 360 | * ``Messagable/init(from:)`` - try to create the object based on the dictionary, return nil if it's invalid 361 | * ``Messagable/parameters()`` - return a dictionary with all the parameters need to recreate the object 362 | * ``Messagable/key`` - return a string which identifies the type and is unique to the ``MessageDecoder`` 363 | 364 | Now that we have our implementation of ``Messagable``, we can use it in our `WatchConnectivityObject`: 365 | 366 | ```swift 367 | class WatchConnectivityObject : ObservableObject { 368 | 369 | // our ConnectivityObserver 370 | let connectivityObserver = ConnectivityObserver() 371 | 372 | // create a `MessageDecoder` which can decode our new `Message` type 373 | let messageDecoder = MessageDecoder(messagableTypes: [Message.self]) 374 | 375 | // our published property for isReachable initially set to false 376 | @Published var isReachable : Bool = false 377 | 378 | // our published property for the last message received 379 | @Published var lastReceivedMessage : String = "" 380 | 381 | init () { 382 | // set the isReachable changes to our published property 383 | connectivityObserver 384 | .isReachablePublisher 385 | .receive(on: DispatchQueue.main) 386 | .assign(to: &self.$isReachable) 387 | 388 | 389 | connectivityObserver 390 | .messageReceivedPublisher 391 | // get the ``ConnectivityReceiveResult/message`` part of the ``ConnectivityReceiveResult`` 392 | .map(\.message) 393 | // use our `messageDecoder` to call ``MessageDecoder/decode(_:)`` 394 | .compactMap(self.messageDecoder.decode) 395 | // check it's our `Message` 396 | .compactMap{$0 as? Message} 397 | // get the `text` property 398 | .map(\.text) 399 | .receive(on: DispatchQueue.main) 400 | // set it to our published property 401 | .assign(to: &self.$lastReceivedMessage) 402 | } 403 | 404 | func activate () { 405 | // activate the WatchConnectivity session 406 | try! self.connectivityObserver.activate() 407 | } 408 | 409 | func sendMessage(_ message: String) { 410 | // create a dictionary using ``Messagable/message()`` 411 | self.connectivityObserver.sendingMessageSubject.send(Message(text: message).message()) 412 | } 413 | } 414 | ``` 415 | 416 | # License 417 | 418 | This code is distributed under the MIT license. See the [LICENSE](https://github.com/brightdigit/SundialKit/LICENSE) file for more info. 419 | -------------------------------------------------------------------------------- /Scripts/docc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker rm -f sundialkit-docc || true 3 | xcodebuild docbuild -scheme SundialKit -destination 'generic/platform=iOS Simulator' -derivedDataPath DerivedData 4 | cp .htaccess DerivedData/Build/Products/Debug-iphonesimulator 5 | docker run --name sundialkit-docc -d -p 8080:80 -v "$(pwd)/DerivedData/Build/Products/Debug-iphonesimulator:/usr/local/apache2/htdocs/" --rm -it $(docker build -q .) 6 | -------------------------------------------------------------------------------- /Scripts/gh-md-toc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Steps: 5 | # 6 | # 1. Download corresponding html file for some README.md: 7 | # curl -s $1 8 | # 9 | # 2. Discard rows where no substring 'user-content-' (github's markup): 10 | # awk '/user-content-/ { ... 11 | # 12 | # 3.1 Get last number in each row like ' ... sitemap.js.*<\/h/)+2, RLENGTH-5) 21 | # 22 | # 5. Find anchor and insert it inside "(...)": 23 | # substr($0, match($0, "href=\"[^\"]+?\" ")+6, RLENGTH-8) 24 | # 25 | 26 | gh_toc_version="0.7.0" 27 | 28 | gh_user_agent="gh-md-toc v$gh_toc_version" 29 | 30 | # 31 | # Download rendered into html README.md by its url. 32 | # 33 | # 34 | gh_toc_load() { 35 | local gh_url=$1 36 | 37 | if type curl &>/dev/null; then 38 | curl --user-agent "$gh_user_agent" -s "$gh_url" 39 | elif type wget &>/dev/null; then 40 | wget --user-agent="$gh_user_agent" -qO- "$gh_url" 41 | else 42 | echo "Please, install 'curl' or 'wget' and try again." 43 | exit 1 44 | fi 45 | } 46 | 47 | # 48 | # Converts local md file into html by GitHub 49 | # 50 | # -> curl -X POST --data '{"text": "Hello world github/linguist#1 **cool**, and #1!"}' https://api.github.com/markdown 51 | #

Hello world github/linguist#1 cool, and #1!

'" 52 | gh_toc_md2html() { 53 | local gh_file_md=$1 54 | URL=https://api.github.com/markdown/raw 55 | 56 | if [ ! -z "$GH_TOC_TOKEN" ]; then 57 | TOKEN=$GH_TOC_TOKEN 58 | else 59 | TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" 60 | if [ -f "$TOKEN_FILE" ]; then 61 | TOKEN="$(cat $TOKEN_FILE)" 62 | fi 63 | fi 64 | if [ ! -z "${TOKEN}" ]; then 65 | AUTHORIZATION="Authorization: token ${TOKEN}" 66 | fi 67 | 68 | # echo $URL 1>&2 69 | OUTPUT=$(curl -s \ 70 | --user-agent "$gh_user_agent" \ 71 | --data-binary @"$gh_file_md" \ 72 | -H "Content-Type:text/plain" \ 73 | -H "$AUTHORIZATION" \ 74 | "$URL") 75 | 76 | if [ "$?" != "0" ]; then 77 | echo "XXNetworkErrorXX" 78 | fi 79 | if [ "$(echo "${OUTPUT}" | awk '/API rate limit exceeded/')" != "" ]; then 80 | echo "XXRateLimitXX" 81 | else 82 | echo "${OUTPUT}" 83 | fi 84 | } 85 | 86 | 87 | # 88 | # Is passed string url 89 | # 90 | gh_is_url() { 91 | case $1 in 92 | https* | http*) 93 | echo "yes";; 94 | *) 95 | echo "no";; 96 | esac 97 | } 98 | 99 | # 100 | # TOC generator 101 | # 102 | gh_toc(){ 103 | local gh_src=$1 104 | local gh_src_copy=$1 105 | local gh_ttl_docs=$2 106 | local need_replace=$3 107 | local no_backup=$4 108 | local no_footer=$5 109 | 110 | if [ "$gh_src" = "" ]; then 111 | echo "Please, enter URL or local path for a README.md" 112 | exit 1 113 | fi 114 | 115 | 116 | # Show "TOC" string only if working with one document 117 | if [ "$gh_ttl_docs" = "1" ]; then 118 | 119 | echo "Table of Contents" 120 | echo "=================" 121 | echo "" 122 | gh_src_copy="" 123 | 124 | fi 125 | 126 | if [ "$(gh_is_url "$gh_src")" == "yes" ]; then 127 | gh_toc_load "$gh_src" | gh_toc_grab "$gh_src_copy" 128 | if [ "${PIPESTATUS[0]}" != "0" ]; then 129 | echo "Could not load remote document." 130 | echo "Please check your url or network connectivity" 131 | exit 1 132 | fi 133 | if [ "$need_replace" = "yes" ]; then 134 | echo 135 | echo "!! '$gh_src' is not a local file" 136 | echo "!! Can't insert the TOC into it." 137 | echo 138 | fi 139 | else 140 | local rawhtml=$(gh_toc_md2html "$gh_src") 141 | if [ "$rawhtml" == "XXNetworkErrorXX" ]; then 142 | echo "Parsing local markdown file requires access to github API" 143 | echo "Please make sure curl is installed and check your network connectivity" 144 | exit 1 145 | fi 146 | if [ "$rawhtml" == "XXRateLimitXX" ]; then 147 | echo "Parsing local markdown file requires access to github API" 148 | echo "Error: You exceeded the hourly limit. See: https://developer.github.com/v3/#rate-limiting" 149 | TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" 150 | echo "or place GitHub auth token here: ${TOKEN_FILE}" 151 | exit 1 152 | fi 153 | local toc=`echo "$rawhtml" | gh_toc_grab "$gh_src_copy"` 154 | echo "$toc" 155 | if [ "$need_replace" = "yes" ]; then 156 | if grep -Fxq "" $gh_src && grep -Fxq "" $gh_src; then 157 | echo "Found markers" 158 | else 159 | echo "You don't have or in your file...exiting" 160 | exit 1 161 | fi 162 | local ts="<\!--ts-->" 163 | local te="<\!--te-->" 164 | local dt=`date +'%F_%H%M%S'` 165 | local ext=".orig.${dt}" 166 | local toc_path="${gh_src}.toc.${dt}" 167 | local toc_footer="" 168 | # http://fahdshariff.blogspot.ru/2012/12/sed-mutli-line-replacement-between-two.html 169 | # clear old TOC 170 | sed -i${ext} "/${ts}/,/${te}/{//!d;}" "$gh_src" 171 | # create toc file 172 | echo "${toc}" > "${toc_path}" 173 | if [ "${no_footer}" != "yes" ]; then 174 | echo -e "\n${toc_footer}\n" >> "$toc_path" 175 | fi 176 | 177 | # insert toc file 178 | if [[ "`uname`" == "Darwin" ]]; then 179 | sed -i "" "/${ts}/r ${toc_path}" "$gh_src" 180 | else 181 | sed -i "/${ts}/r ${toc_path}" "$gh_src" 182 | fi 183 | echo 184 | if [ "${no_backup}" = "yes" ]; then 185 | rm ${toc_path} ${gh_src}${ext} 186 | fi 187 | echo "!! TOC was added into: '$gh_src'" 188 | if [ -z "${no_backup}" ]; then 189 | echo "!! Origin version of the file: '${gh_src}${ext}'" 190 | echo "!! TOC added into a separate file: '${toc_path}'" 191 | fi 192 | echo 193 | fi 194 | fi 195 | } 196 | 197 | # 198 | # Grabber of the TOC from rendered html 199 | # 200 | # $1 - a source url of document. 201 | # It's need if TOC is generated for multiple documents. 202 | # 203 | gh_toc_grab() { 204 | common_awk_script=' 205 | modified_href = "" 206 | split(href, chars, "") 207 | for (i=1;i <= length(href); i++) { 208 | c = chars[i] 209 | res = "" 210 | if (c == "+") { 211 | res = " " 212 | } else { 213 | if (c == "%") { 214 | res = "\\x" 215 | } else { 216 | res = c "" 217 | } 218 | } 219 | modified_href = modified_href res 220 | } 221 | print sprintf("%*s", (level-1)*3, "") "* [" text "](" gh_url modified_href ")" 222 | ' 223 | if [ `uname -s` == "OS/390" ]; then 224 | grepcmd="pcregrep -o" 225 | echoargs="" 226 | awkscript='{ 227 | level = substr($0, length($0), 1) 228 | text = substr($0, match($0, /a>.*<\/h/)+2, RLENGTH-5) 229 | href = substr($0, match($0, "href=\"([^\"]+)?\"")+6, RLENGTH-7) 230 | '"$common_awk_script"' 231 | }' 232 | else 233 | grepcmd="grep -Eo" 234 | echoargs="-e" 235 | awkscript='{ 236 | level = substr($0, length($0), 1) 237 | text = substr($0, match($0, /a>.*<\/h/)+2, RLENGTH-5) 238 | href = substr($0, match($0, "href=\"[^\"]+?\"")+6, RLENGTH-7) 239 | '"$common_awk_script"' 240 | }' 241 | fi 242 | href_regex='href=\"[^\"]+?\"' 243 | 244 | # if closed is on the new line, then move it on the prev line 245 | # for example: 246 | # was: The command foo1 247 | # 248 | # became: The command foo1 249 | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n<\/h/<\/h/g' | 250 | 251 | # find strings that corresponds to template 252 | $grepcmd '//g' | sed 's/<\/code>//g' | 256 | 257 | # remove g-emoji 258 | sed 's/]*[^<]*<\/g-emoji> //g' | 259 | 260 | # now all rows are like: 261 | # ... / placeholders" 290 | echo " $app_name - Create TOC for markdown from STDIN" 291 | echo " $app_name --help Show help" 292 | echo " $app_name --version Show version" 293 | return 294 | fi 295 | 296 | if [ "$1" = '--version' ]; then 297 | echo "$gh_toc_version" 298 | echo 299 | echo "os: `lsb_release -d | cut -f 2`" 300 | echo "kernel: `cat /proc/version`" 301 | echo "shell: `$SHELL --version`" 302 | echo 303 | for tool in curl wget grep awk sed; do 304 | printf "%-5s: " $tool 305 | echo `$tool --version | head -n 1` 306 | done 307 | return 308 | fi 309 | 310 | if [ "$1" = "-" ]; then 311 | if [ -z "$TMPDIR" ]; then 312 | TMPDIR="/tmp" 313 | elif [ -n "$TMPDIR" -a ! -d "$TMPDIR" ]; then 314 | mkdir -p "$TMPDIR" 315 | fi 316 | local gh_tmp_md 317 | if [ `uname -s` == "OS/390" ]; then 318 | local timestamp=$(date +%m%d%Y%H%M%S) 319 | gh_tmp_md="$TMPDIR/tmp.$timestamp" 320 | else 321 | gh_tmp_md=$(mktemp $TMPDIR/tmp.XXXXXX) 322 | fi 323 | while read input; do 324 | echo "$input" >> "$gh_tmp_md" 325 | done 326 | gh_toc_md2html "$gh_tmp_md" | gh_toc_grab "" 327 | return 328 | fi 329 | 330 | if [ "$1" = '--insert' ]; then 331 | need_replace="yes" 332 | shift 333 | fi 334 | 335 | if [ "$1" = '--no-backup' ]; then 336 | need_replace="yes" 337 | no_backup="yes" 338 | shift 339 | fi 340 | 341 | if [ "$1" = '--hide-footer' ]; then 342 | need_replace="yes" 343 | no_footer="yes" 344 | shift 345 | fi 346 | 347 | for md in "$@" 348 | do 349 | echo "" 350 | gh_toc "$md" "$#" "$need_replace" "$no_backup" "$no_footer" 351 | done 352 | 353 | echo "" 354 | echo "Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc)" 355 | } 356 | 357 | # 358 | # Entry point 359 | # 360 | gh_toc_app "$@" 361 | 362 | -------------------------------------------------------------------------------- /Scripts/httpd.conf: -------------------------------------------------------------------------------- 1 | # 2 | # This is the main Apache HTTP server configuration file. It contains the 3 | # configuration directives that give the server its instructions. 4 | # See for detailed information. 5 | # In particular, see 6 | # 7 | # for a discussion of each configuration directive. 8 | # 9 | # Do NOT simply read the instructions in here without understanding 10 | # what they do. They're here only as hints or reminders. If you are unsure 11 | # consult the online docs. You have been warned. 12 | # 13 | # Configuration and logfile names: If the filenames you specify for many 14 | # of the server's control files begin with "/" (or "drive:/" for Win32), the 15 | # server will use that explicit path. If the filenames do *not* begin 16 | # with "/", the value of ServerRoot is prepended -- so "logs/access_log" 17 | # with ServerRoot set to "/usr/local/apache2" will be interpreted by the 18 | # server as "/usr/local/apache2/logs/access_log", whereas "/logs/access_log" 19 | # will be interpreted as '/logs/access_log'. 20 | 21 | # 22 | # ServerRoot: The top of the directory tree under which the server's 23 | # configuration, error, and log files are kept. 24 | # 25 | # Do not add a slash at the end of the directory path. If you point 26 | # ServerRoot at a non-local disk, be sure to specify a local disk on the 27 | # Mutex directive, if file-based mutexes are used. If you wish to share the 28 | # same ServerRoot for multiple httpd daemons, you will need to change at 29 | # least PidFile. 30 | # 31 | ServerRoot "/usr/local/apache2" 32 | 33 | # 34 | # Mutex: Allows you to set the mutex mechanism and mutex file directory 35 | # for individual mutexes, or change the global defaults 36 | # 37 | # Uncomment and change the directory if mutexes are file-based and the default 38 | # mutex file directory is not on a local disk or is not appropriate for some 39 | # other reason. 40 | # 41 | # Mutex default:logs 42 | 43 | # 44 | # Listen: Allows you to bind Apache to specific IP addresses and/or 45 | # ports, instead of the default. See also the 46 | # directive. 47 | # 48 | # Change this to Listen on specific IP addresses as shown below to 49 | # prevent Apache from glomming onto all bound IP addresses. 50 | # 51 | #Listen 12.34.56.78:80 52 | Listen 80 53 | 54 | # 55 | # Dynamic Shared Object (DSO) Support 56 | # 57 | # To be able to use the functionality of a module which was built as a DSO you 58 | # have to place corresponding `LoadModule' lines at this location so the 59 | # directives contained in it are actually available _before_ they are used. 60 | # Statically compiled modules (those listed by `httpd -l') do not need 61 | # to be loaded here. 62 | # 63 | # Example: 64 | # LoadModule foo_module modules/mod_foo.so 65 | # 66 | LoadModule mpm_event_module modules/mod_mpm_event.so 67 | #LoadModule mpm_prefork_module modules/mod_mpm_prefork.so 68 | #LoadModule mpm_worker_module modules/mod_mpm_worker.so 69 | LoadModule authn_file_module modules/mod_authn_file.so 70 | #LoadModule authn_dbm_module modules/mod_authn_dbm.so 71 | #LoadModule authn_anon_module modules/mod_authn_anon.so 72 | #LoadModule authn_dbd_module modules/mod_authn_dbd.so 73 | #LoadModule authn_socache_module modules/mod_authn_socache.so 74 | LoadModule authn_core_module modules/mod_authn_core.so 75 | LoadModule authz_host_module modules/mod_authz_host.so 76 | LoadModule authz_groupfile_module modules/mod_authz_groupfile.so 77 | LoadModule authz_user_module modules/mod_authz_user.so 78 | #LoadModule authz_dbm_module modules/mod_authz_dbm.so 79 | #LoadModule authz_owner_module modules/mod_authz_owner.so 80 | #LoadModule authz_dbd_module modules/mod_authz_dbd.so 81 | LoadModule authz_core_module modules/mod_authz_core.so 82 | #LoadModule authnz_ldap_module modules/mod_authnz_ldap.so 83 | #LoadModule authnz_fcgi_module modules/mod_authnz_fcgi.so 84 | LoadModule access_compat_module modules/mod_access_compat.so 85 | LoadModule auth_basic_module modules/mod_auth_basic.so 86 | #LoadModule auth_form_module modules/mod_auth_form.so 87 | #LoadModule auth_digest_module modules/mod_auth_digest.so 88 | #LoadModule allowmethods_module modules/mod_allowmethods.so 89 | #LoadModule isapi_module modules/mod_isapi.so 90 | #LoadModule file_cache_module modules/mod_file_cache.so 91 | #LoadModule cache_module modules/mod_cache.so 92 | #LoadModule cache_disk_module modules/mod_cache_disk.so 93 | #LoadModule cache_socache_module modules/mod_cache_socache.so 94 | #LoadModule socache_shmcb_module modules/mod_socache_shmcb.so 95 | #LoadModule socache_dbm_module modules/mod_socache_dbm.so 96 | #LoadModule socache_memcache_module modules/mod_socache_memcache.so 97 | #LoadModule socache_redis_module modules/mod_socache_redis.so 98 | #LoadModule watchdog_module modules/mod_watchdog.so 99 | #LoadModule macro_module modules/mod_macro.so 100 | #LoadModule dbd_module modules/mod_dbd.so 101 | #LoadModule bucketeer_module modules/mod_bucketeer.so 102 | #LoadModule dumpio_module modules/mod_dumpio.so 103 | #LoadModule echo_module modules/mod_echo.so 104 | #LoadModule example_hooks_module modules/mod_example_hooks.so 105 | #LoadModule case_filter_module modules/mod_case_filter.so 106 | #LoadModule case_filter_in_module modules/mod_case_filter_in.so 107 | #LoadModule example_ipc_module modules/mod_example_ipc.so 108 | #LoadModule buffer_module modules/mod_buffer.so 109 | #LoadModule data_module modules/mod_data.so 110 | #LoadModule ratelimit_module modules/mod_ratelimit.so 111 | LoadModule reqtimeout_module modules/mod_reqtimeout.so 112 | #LoadModule ext_filter_module modules/mod_ext_filter.so 113 | #LoadModule request_module modules/mod_request.so 114 | #LoadModule include_module modules/mod_include.so 115 | LoadModule filter_module modules/mod_filter.so 116 | #LoadModule reflector_module modules/mod_reflector.so 117 | #LoadModule substitute_module modules/mod_substitute.so 118 | #LoadModule sed_module modules/mod_sed.so 119 | #LoadModule charset_lite_module modules/mod_charset_lite.so 120 | #LoadModule deflate_module modules/mod_deflate.so 121 | #LoadModule xml2enc_module modules/mod_xml2enc.so 122 | #LoadModule proxy_html_module modules/mod_proxy_html.so 123 | #LoadModule brotli_module modules/mod_brotli.so 124 | LoadModule mime_module modules/mod_mime.so 125 | #LoadModule ldap_module modules/mod_ldap.so 126 | LoadModule log_config_module modules/mod_log_config.so 127 | #LoadModule log_debug_module modules/mod_log_debug.so 128 | #LoadModule log_forensic_module modules/mod_log_forensic.so 129 | #LoadModule logio_module modules/mod_logio.so 130 | #LoadModule lua_module modules/mod_lua.so 131 | LoadModule env_module modules/mod_env.so 132 | #LoadModule mime_magic_module modules/mod_mime_magic.so 133 | #LoadModule cern_meta_module modules/mod_cern_meta.so 134 | #LoadModule expires_module modules/mod_expires.so 135 | LoadModule headers_module modules/mod_headers.so 136 | #LoadModule ident_module modules/mod_ident.so 137 | #LoadModule usertrack_module modules/mod_usertrack.so 138 | #LoadModule unique_id_module modules/mod_unique_id.so 139 | LoadModule setenvif_module modules/mod_setenvif.so 140 | LoadModule version_module modules/mod_version.so 141 | #LoadModule remoteip_module modules/mod_remoteip.so 142 | #LoadModule proxy_module modules/mod_proxy.so 143 | #LoadModule proxy_connect_module modules/mod_proxy_connect.so 144 | #LoadModule proxy_ftp_module modules/mod_proxy_ftp.so 145 | #LoadModule proxy_http_module modules/mod_proxy_http.so 146 | #LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so 147 | #LoadModule proxy_scgi_module modules/mod_proxy_scgi.so 148 | #LoadModule proxy_uwsgi_module modules/mod_proxy_uwsgi.so 149 | #LoadModule proxy_fdpass_module modules/mod_proxy_fdpass.so 150 | #LoadModule proxy_wstunnel_module modules/mod_proxy_wstunnel.so 151 | #LoadModule proxy_ajp_module modules/mod_proxy_ajp.so 152 | #LoadModule proxy_balancer_module modules/mod_proxy_balancer.so 153 | #LoadModule proxy_express_module modules/mod_proxy_express.so 154 | #LoadModule proxy_hcheck_module modules/mod_proxy_hcheck.so 155 | #LoadModule session_module modules/mod_session.so 156 | #LoadModule session_cookie_module modules/mod_session_cookie.so 157 | #LoadModule session_crypto_module modules/mod_session_crypto.so 158 | #LoadModule session_dbd_module modules/mod_session_dbd.so 159 | #LoadModule slotmem_shm_module modules/mod_slotmem_shm.so 160 | #LoadModule slotmem_plain_module modules/mod_slotmem_plain.so 161 | #LoadModule ssl_module modules/mod_ssl.so 162 | #LoadModule optional_hook_export_module modules/mod_optional_hook_export.so 163 | #LoadModule optional_hook_import_module modules/mod_optional_hook_import.so 164 | #LoadModule optional_fn_import_module modules/mod_optional_fn_import.so 165 | #LoadModule optional_fn_export_module modules/mod_optional_fn_export.so 166 | #LoadModule dialup_module modules/mod_dialup.so 167 | #LoadModule http2_module modules/mod_http2.so 168 | #LoadModule proxy_http2_module modules/mod_proxy_http2.so 169 | #LoadModule md_module modules/mod_md.so 170 | #LoadModule lbmethod_byrequests_module modules/mod_lbmethod_byrequests.so 171 | #LoadModule lbmethod_bytraffic_module modules/mod_lbmethod_bytraffic.so 172 | #LoadModule lbmethod_bybusyness_module modules/mod_lbmethod_bybusyness.so 173 | #LoadModule lbmethod_heartbeat_module modules/mod_lbmethod_heartbeat.so 174 | LoadModule unixd_module modules/mod_unixd.so 175 | #LoadModule heartbeat_module modules/mod_heartbeat.so 176 | #LoadModule heartmonitor_module modules/mod_heartmonitor.so 177 | #LoadModule dav_module modules/mod_dav.so 178 | LoadModule status_module modules/mod_status.so 179 | LoadModule autoindex_module modules/mod_autoindex.so 180 | #LoadModule asis_module modules/mod_asis.so 181 | #LoadModule info_module modules/mod_info.so 182 | #LoadModule suexec_module modules/mod_suexec.so 183 | 184 | #LoadModule cgid_module modules/mod_cgid.so 185 | 186 | 187 | #LoadModule cgi_module modules/mod_cgi.so 188 | 189 | #LoadModule dav_fs_module modules/mod_dav_fs.so 190 | #LoadModule dav_lock_module modules/mod_dav_lock.so 191 | #LoadModule vhost_alias_module modules/mod_vhost_alias.so 192 | #LoadModule negotiation_module modules/mod_negotiation.so 193 | LoadModule dir_module modules/mod_dir.so 194 | #LoadModule imagemap_module modules/mod_imagemap.so 195 | #LoadModule actions_module modules/mod_actions.so 196 | #LoadModule speling_module modules/mod_speling.so 197 | #LoadModule userdir_module modules/mod_userdir.so 198 | LoadModule alias_module modules/mod_alias.so 199 | LoadModule rewrite_module modules/mod_rewrite.so 200 | 201 | 202 | # 203 | # If you wish httpd to run as a different user or group, you must run 204 | # httpd as root initially and it will switch. 205 | # 206 | # User/Group: The name (or #number) of the user/group to run httpd as. 207 | # It is usually good practice to create a dedicated user and group for 208 | # running httpd, as with most system services. 209 | # 210 | User daemon 211 | Group daemon 212 | 213 | 214 | 215 | # 'Main' server configuration 216 | # 217 | # The directives in this section set up the values used by the 'main' 218 | # server, which responds to any requests that aren't handled by a 219 | # definition. These values also provide defaults for 220 | # any containers you may define later in the file. 221 | # 222 | # All of these directives may appear inside containers, 223 | # in which case these default settings will be overridden for the 224 | # virtual host being defined. 225 | # 226 | 227 | # 228 | # ServerAdmin: Your address, where problems with the server should be 229 | # e-mailed. This address appears on some server-generated pages, such 230 | # as error documents. e.g. admin@your-domain.com 231 | # 232 | ServerAdmin you@example.com 233 | 234 | # 235 | # ServerName gives the name and port that the server uses to identify itself. 236 | # This can often be determined automatically, but we recommend you specify 237 | # it explicitly to prevent problems during startup. 238 | # 239 | # If your host doesn't have a registered DNS name, enter its IP address here. 240 | # 241 | #ServerName www.example.com:80 242 | 243 | # 244 | # Deny access to the entirety of your server's filesystem. You must 245 | # explicitly permit access to web content directories in other 246 | # blocks below. 247 | # 248 | 249 | AllowOverride none 250 | Require all denied 251 | 252 | 253 | # 254 | # Note that from this point forward you must specifically allow 255 | # particular features to be enabled - so if something's not working as 256 | # you might expect, make sure that you have specifically enabled it 257 | # below. 258 | # 259 | 260 | # 261 | # DocumentRoot: The directory out of which you will serve your 262 | # documents. By default, all requests are taken from this directory, but 263 | # symbolic links and aliases may be used to point to other locations. 264 | # 265 | DocumentRoot "/usr/local/apache2/htdocs" 266 | 267 | # 268 | # Possible values for the Options directive are "None", "All", 269 | # or any combination of: 270 | # Indexes Includes FollowSymLinks SymLinksifOwnerMatch ExecCGI MultiViews 271 | # 272 | # Note that "MultiViews" must be named *explicitly* --- "Options All" 273 | # doesn't give it to you. 274 | # 275 | # The Options directive is both complicated and important. Please see 276 | # http://httpd.apache.org/docs/2.4/mod/core.html#options 277 | # for more information. 278 | # 279 | Options Indexes FollowSymLinks 280 | 281 | # 282 | # AllowOverride controls what directives may be placed in .htaccess files. 283 | # It can be "All", "None", or any combination of the keywords: 284 | # AllowOverride FileInfo AuthConfig Limit 285 | # 286 | AllowOverride All 287 | 288 | # 289 | # Controls who can get stuff from this server. 290 | # 291 | Require all granted 292 | 293 | 294 | # 295 | # DirectoryIndex: sets the file that Apache will serve if a directory 296 | # is requested. 297 | # 298 | 299 | DirectoryIndex index.html 300 | 301 | 302 | # 303 | # The following lines prevent .htaccess and .htpasswd files from being 304 | # viewed by Web clients. 305 | # 306 | 307 | Require all denied 308 | 309 | 310 | # 311 | # ErrorLog: The location of the error log file. 312 | # If you do not specify an ErrorLog directive within a 313 | # container, error messages relating to that virtual host will be 314 | # logged here. If you *do* define an error logfile for a 315 | # container, that host's errors will be logged there and not here. 316 | # 317 | ErrorLog /proc/self/fd/2 318 | 319 | # 320 | # LogLevel: Control the number of messages logged to the error_log. 321 | # Possible values include: debug, info, notice, warn, error, crit, 322 | # alert, emerg. 323 | # 324 | LogLevel warn 325 | 326 | 327 | # 328 | # The following directives define some format nicknames for use with 329 | # a CustomLog directive (see below). 330 | # 331 | LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined 332 | LogFormat "%h %l %u %t \"%r\" %>s %b" common 333 | 334 | 335 | # You need to enable mod_logio.c to use %I and %O 336 | LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %I %O" combinedio 337 | 338 | 339 | # 340 | # The location and format of the access logfile (Common Logfile Format). 341 | # If you do not define any access logfiles within a 342 | # container, they will be logged here. Contrariwise, if you *do* 343 | # define per- access logfiles, transactions will be 344 | # logged therein and *not* in this file. 345 | # 346 | CustomLog /proc/self/fd/1 common 347 | 348 | # 349 | # If you prefer a logfile with access, agent, and referer information 350 | # (Combined Logfile Format) you can use the following directive. 351 | # 352 | #CustomLog "logs/access_log" combined 353 | 354 | 355 | 356 | # 357 | # Redirect: Allows you to tell clients about documents that used to 358 | # exist in your server's namespace, but do not anymore. The client 359 | # will make a new request for the document at its new location. 360 | # Example: 361 | # Redirect permanent /foo http://www.example.com/bar 362 | 363 | # 364 | # Alias: Maps web paths into filesystem paths and is used to 365 | # access content that does not live under the DocumentRoot. 366 | # Example: 367 | # Alias /webpath /full/filesystem/path 368 | # 369 | # If you include a trailing / on /webpath then the server will 370 | # require it to be present in the URL. You will also likely 371 | # need to provide a section to allow access to 372 | # the filesystem path. 373 | 374 | # 375 | # ScriptAlias: This controls which directories contain server scripts. 376 | # ScriptAliases are essentially the same as Aliases, except that 377 | # documents in the target directory are treated as applications and 378 | # run by the server when requested rather than as documents sent to the 379 | # client. The same rules about trailing "/" apply to ScriptAlias 380 | # directives as to Alias. 381 | # 382 | ScriptAlias /cgi-bin/ "/usr/local/apache2/cgi-bin/" 383 | 384 | 385 | 386 | 387 | # 388 | # ScriptSock: On threaded servers, designate the path to the UNIX 389 | # socket used to communicate with the CGI daemon of mod_cgid. 390 | # 391 | #Scriptsock cgisock 392 | 393 | 394 | # 395 | # "/usr/local/apache2/cgi-bin" should be changed to whatever your ScriptAliased 396 | # CGI directory exists, if you have that configured. 397 | # 398 | 399 | AllowOverride None 400 | Options None 401 | Require all granted 402 | 403 | 404 | 405 | # 406 | # Avoid passing HTTP_PROXY environment to CGI's on this or any proxied 407 | # backend servers which have lingering "httpoxy" defects. 408 | # 'Proxy' request header is undefined by the IETF, not listed by IANA 409 | # 410 | RequestHeader unset Proxy early 411 | 412 | 413 | 414 | # 415 | # TypesConfig points to the file containing the list of mappings from 416 | # filename extension to MIME-type. 417 | # 418 | TypesConfig conf/mime.types 419 | 420 | # 421 | # AddType allows you to add to or override the MIME configuration 422 | # file specified in TypesConfig for specific file types. 423 | # 424 | #AddType application/x-gzip .tgz 425 | # 426 | # AddEncoding allows you to have certain browsers uncompress 427 | # information on the fly. Note: Not all browsers support this. 428 | # 429 | #AddEncoding x-compress .Z 430 | #AddEncoding x-gzip .gz .tgz 431 | # 432 | # If the AddEncoding directives above are commented-out, then you 433 | # probably should define those extensions to indicate media types: 434 | # 435 | AddType application/x-compress .Z 436 | AddType application/x-gzip .gz .tgz 437 | 438 | # 439 | # AddHandler allows you to map certain file extensions to "handlers": 440 | # actions unrelated to filetype. These can be either built into the server 441 | # or added with the Action directive (see below) 442 | # 443 | # To use CGI scripts outside of ScriptAliased directories: 444 | # (You will also need to add "ExecCGI" to the "Options" directive.) 445 | # 446 | #AddHandler cgi-script .cgi 447 | 448 | # For type maps (negotiated resources): 449 | #AddHandler type-map var 450 | 451 | # 452 | # Filters allow you to process content before it is sent to the client. 453 | # 454 | # To parse .shtml files for server-side includes (SSI): 455 | # (You will also need to add "Includes" to the "Options" directive.) 456 | # 457 | #AddType text/html .shtml 458 | #AddOutputFilter INCLUDES .shtml 459 | 460 | 461 | # 462 | # The mod_mime_magic module allows the server to use various hints from the 463 | # contents of the file itself to determine its type. The MIMEMagicFile 464 | # directive tells the module where the hint definitions are located. 465 | # 466 | #MIMEMagicFile conf/magic 467 | 468 | # 469 | # Customizable error responses come in three flavors: 470 | # 1) plain text 2) local redirects 3) external redirects 471 | # 472 | # Some examples: 473 | #ErrorDocument 500 "The server made a boo boo." 474 | #ErrorDocument 404 /missing.html 475 | #ErrorDocument 404 "/cgi-bin/missing_handler.pl" 476 | #ErrorDocument 402 http://www.example.com/subscription_info.html 477 | # 478 | 479 | # 480 | # MaxRanges: Maximum number of Ranges in a request before 481 | # returning the entire resource, or one of the special 482 | # values 'default', 'none' or 'unlimited'. 483 | # Default setting is to accept 200 Ranges. 484 | #MaxRanges unlimited 485 | 486 | # 487 | # EnableMMAP and EnableSendfile: On systems that support it, 488 | # memory-mapping or the sendfile syscall may be used to deliver 489 | # files. This usually improves server performance, but must 490 | # be turned off when serving from networked-mounted 491 | # filesystems or if support for these functions is otherwise 492 | # broken on your system. 493 | # Defaults: EnableMMAP On, EnableSendfile Off 494 | # 495 | #EnableMMAP off 496 | #EnableSendfile on 497 | 498 | # Supplemental configuration 499 | # 500 | # The configuration files in the conf/extra/ directory can be 501 | # included to add extra features or to modify the default configuration of 502 | # the server, or you may simply copy their contents here and change as 503 | # necessary. 504 | 505 | # Server-pool management (MPM specific) 506 | #Include conf/extra/httpd-mpm.conf 507 | 508 | # Multi-language error messages 509 | #Include conf/extra/httpd-multilang-errordoc.conf 510 | 511 | # Fancy directory listings 512 | #Include conf/extra/httpd-autoindex.conf 513 | 514 | # Language settings 515 | #Include conf/extra/httpd-languages.conf 516 | 517 | # User home directories 518 | #Include conf/extra/httpd-userdir.conf 519 | 520 | # Real-time info on requests and configuration 521 | #Include conf/extra/httpd-info.conf 522 | 523 | # Virtual hosts 524 | #Include conf/extra/httpd-vhosts.conf 525 | 526 | # Local access to the Apache HTTP Server Manual 527 | #Include conf/extra/httpd-manual.conf 528 | 529 | # Distributed authoring and versioning (WebDAV) 530 | #Include conf/extra/httpd-dav.conf 531 | 532 | # Various default settings 533 | #Include conf/extra/httpd-default.conf 534 | 535 | # Configure mod_proxy_html to understand HTML4/XHTML1 536 | 537 | Include conf/extra/proxy-html.conf 538 | 539 | 540 | # Secure (SSL/TLS) connections 541 | #Include conf/extra/httpd-ssl.conf 542 | # 543 | # Note: The following must must be present to support 544 | # starting without SSL on platforms with no /dev/random equivalent 545 | # but a statically compiled-in mod_ssl. 546 | # 547 | 548 | SSLRandomSeed startup builtin 549 | SSLRandomSeed connect builtin 550 | 551 | 552 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/Extensions/NWInterface.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Network) 2 | 3 | import Network 4 | @available(macOS 10.14, *) 5 | // swiftlint:disable:next file_types_order 6 | internal extension NWInterface.InterfaceType { 7 | // swiftlint:disable:next explicit_acl 8 | var value: Int { 9 | switch self { 10 | case .other: 11 | return PathStatus.Interface.other.rawValue 12 | 13 | case .wifi: 14 | return PathStatus.Interface.wifi.rawValue 15 | 16 | case .cellular: 17 | return PathStatus.Interface.cellular.rawValue 18 | 19 | case .wiredEthernet: 20 | return PathStatus.Interface.wiredEthernet.rawValue 21 | 22 | case .loopback: 23 | return PathStatus.Interface.loopback.rawValue 24 | @unknown default: 25 | return 0 26 | } 27 | } 28 | } 29 | 30 | @available(macOS 10.14, *) 31 | extension NWInterface: Interfaceable { 32 | public var typeValue: Int { 33 | type.value 34 | } 35 | } 36 | #endif 37 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/Extensions/NWPath.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Network) 2 | import Network 3 | 4 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 5 | extension NWPath: NetworkPath { 6 | public var pathStatus: PathStatus { 7 | if #available(iOS 14.2, watchOS 7.1, macOS 11.0, tvOS 14.2, *) { 8 | return PathStatus( 9 | status, 10 | reason: unsatisfiedReason, 11 | interfaces: availableInterfaces.map { 12 | $0 as Interfaceable 13 | } 14 | ) 15 | } else { 16 | return PathStatus( 17 | status, 18 | interfaces: availableInterfaces.map { 19 | $0 as Interfaceable 20 | } 21 | ) 22 | } 23 | } 24 | } 25 | 26 | #endif 27 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/Extensions/NWPathMonitor.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Network) 2 | import Network 3 | 4 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 5 | extension NWPathMonitor: PathMonitor { 6 | public func onPathUpdate(_ handler: @escaping (NWPath) -> Void) { 7 | pathUpdateHandler = handler 8 | } 9 | } 10 | 11 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 12 | public extension NetworkObserver { 13 | /// Default implementation of `NetworkObserver` 14 | /// which does not use execute a perodic ``NetworkPing``. 15 | convenience init() where MonitorType == NWPathMonitor, PingType == NeverPing { 16 | let monitor = NWPathMonitor() 17 | self.init(monitor: monitor) 18 | } 19 | 20 | /// Default implementation of `NetworkObserver` 21 | /// which does use execute a perodic ``NetworkPing``. 22 | convenience init(ping: PingType) where MonitorType == NWPathMonitor { 23 | let monitor = NWPathMonitor() 24 | self.init(monitor: monitor, ping: ping) 25 | } 26 | } 27 | 28 | #endif 29 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/Extensions/PathStatus.Network.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable function_body_length cyclomatic_complexity 2 | #if canImport(Network) 3 | import Network 4 | 5 | @available(macOS 10.14, *) 6 | extension PathStatus { 7 | /// Creates a `PathStatus`. 8 | /// - Parameters: 9 | /// - status: The `NWPath.Status` 10 | /// - interfaces: The `Interfacable` objects. 11 | @available(macOS, obsoleted: 11.0) 12 | @available(iOS, obsoleted: 14.2) 13 | @available(watchOS, obsoleted: 7.0) 14 | @available(tvOS, obsoleted: 14.2) 15 | // swiftlint:disable:next explicit_acl 16 | init( 17 | _ status: NWPath.Status, 18 | interfaces: [Interfaceable] 19 | ) { 20 | self.init(status, reason: .unsupported, interfaces: interfaces) 21 | } 22 | 23 | /// Creates a `PathStatus`. 24 | /// - Parameters: 25 | /// - status: The `NWPath.Status` 26 | /// - reason: The `NWPath.UnsatisfiedReason` 27 | /// - interfaces: The `Interfacable` objects. 28 | @available(iOS 14.2, watchOS 7.1, macOS 11.0, tvOS 14.2, *) 29 | // swiftlint:disable:next explicit_acl 30 | init( 31 | _ status: NWPath.Status, 32 | reason: NWPath.UnsatisfiedReason, 33 | interfaces: [Interfaceable] 34 | ) { 35 | self.init(status, reason: UnsatisfiedReason(reason), interfaces: interfaces) 36 | } 37 | 38 | private init( 39 | _ status: NWPath.Status, 40 | reason: UnsatisfiedReason, 41 | interfaces: [Interfaceable] 42 | ) { 43 | switch (status, reason) { 44 | case (.satisfied, _): 45 | self = .satisfied(PathStatus.Interface(interfaces: interfaces)) 46 | 47 | case (.unsatisfied, .cellularDenied): 48 | self = .unsatisfied(.cellularDenied) 49 | 50 | case (.requiresConnection, _): 51 | self = .requiresConnection 52 | 53 | case (.unsatisfied, .notAvailable): 54 | self = .unsatisfied(.notAvailable) 55 | 56 | case (.unsatisfied, .wifiDenied): 57 | self = .unsatisfied(.wifiDenied) 58 | 59 | case (.unsatisfied, .localNetworkDenied): 60 | self = .unsatisfied(.localNetworkDenied) 61 | 62 | case (.unsatisfied, _): 63 | self = .unsatisfied(.unknown) 64 | 65 | case (_, _): 66 | self = .unknown 67 | } 68 | } 69 | } 70 | 71 | #endif 72 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/Extensions/PathStatus.UnsatisfiedReason.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(Network) 3 | import Network 4 | 5 | @available(macOS 11.0, iOS 14.2, watchOS 7.1, tvOS 14.2, *) 6 | public extension PathStatus.UnsatisfiedReason { 7 | /// Creates `UnsatisfiedReason` from a `Network` one. 8 | /// - Parameter reason: The `UnsatisfiedReason` from the `Network` API. 9 | init(_ reason: NWPath.UnsatisfiedReason) { 10 | switch reason { 11 | case .notAvailable: 12 | self = .notAvailable 13 | 14 | case .cellularDenied: 15 | self = .cellularDenied 16 | 17 | case .wifiDenied: 18 | self = .wifiDenied 19 | 20 | case .localNetworkDenied: 21 | self = .localNetworkDenied 22 | @unknown default: 23 | self = .unknown 24 | } 25 | } 26 | } 27 | #endif 28 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/Interfaceable.swift: -------------------------------------------------------------------------------- 1 | /// Defines an object which can be used as a path interface. 2 | internal protocol Interfaceable { 3 | /// Integer value which matches the `PathStatus.Interface` values. 4 | var typeValue: Int { get } 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/NetworkObserver.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Combine) 2 | import Combine 3 | import Foundation 4 | 5 | /// Observes the status of network connectivity 6 | /// 7 | /// `NetworkObserver` allows you the listen to variety of publishers related to the network. 8 | /// This is especially useful if you are using `SwiftUI` in particular. 9 | /// With `SwiftUI`, you can create an `ObservableObject` which contains an `NetworkObserver`: 10 | /// 11 | /// ```swift 12 | /// import SwiftUI 13 | /// import SundialKit 14 | /// 15 | /// class NetworkConnectivityObject : ObservableObject { 16 | /// // our NetworkObserver 17 | /// let connectivityObserver = NetworkObserver() 18 | /// 19 | /// // our published property for pathStatus initially set to `.unknown` 20 | /// @Published var pathStatus : PathStatus = .unknown 21 | /// 22 | /// init () { 23 | /// // set the pathStatus changes to our published property 24 | /// connectivityObserver 25 | /// .pathStatusPublisher 26 | /// .receive(on: DispatchQueue.main) 27 | /// .assign(to: &self.$pathStatus) 28 | /// } 29 | /// 30 | /// // need to start listening 31 | /// func start () { 32 | /// self.connectivityObserver.start(queue: .global()) 33 | /// } 34 | /// } 35 | /// ``` 36 | /// 37 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 38 | public class NetworkObserver { 39 | private let ping: PingType? 40 | private let monitor: MonitorType 41 | 42 | private let pathSubject = PassthroughSubject() 43 | private var pingCancellable: AnyCancellable? 44 | private var timerCancellable: Cancellable? 45 | private var otherCancellables = [AnyCancellable]() 46 | 47 | private let pathStatusSubject = PassthroughSubject() 48 | private let isExpensiveSubject = PassthroughSubject() 49 | private let isConstrainedSubject = PassthroughSubject() 50 | private let pingStatusSubject = PassthroughSubject() 51 | 52 | internal var isPingActive: Bool { 53 | timerCancellable != nil 54 | } 55 | 56 | internal var hasNetworkPing: Bool { 57 | ping != nil 58 | } 59 | 60 | /// Publishes updates to the `PathStatus` 61 | public var pathStatusPublisher: AnyPublisher { 62 | pathStatusSubject.eraseToAnyPublisher() 63 | } 64 | 65 | /// Publishes updates to whether the network connection is expensive. 66 | public var isExpensivePublisher: AnyPublisher { 67 | isExpensiveSubject.eraseToAnyPublisher() 68 | } 69 | 70 | /// Publishes updates to whether the network connection is constrained. 71 | public var isConstrainedPublisher: AnyPublisher { 72 | isConstrainedSubject.eraseToAnyPublisher() 73 | } 74 | 75 | /// Publishes updates to the `PingType.StatusType` 76 | public var pingStatusPublisher: AnyPublisher { 77 | pingStatusSubject.eraseToAnyPublisher() 78 | } 79 | 80 | internal init(monitor: MonitorType, pingOrNil: PingType?) { 81 | self.monitor = monitor 82 | ping = pingOrNil 83 | 84 | pathSubject.map(\.pathStatus).subscribe(pathStatusSubject).store(in: &otherCancellables) 85 | pathSubject.map(\.isExpensive).subscribe(isExpensiveSubject).store(in: &otherCancellables) 86 | pathSubject.map(\.isConstrained).subscribe(isConstrainedSubject).store(in: &otherCancellables) 87 | 88 | monitor.onPathUpdate(onUpdate(path:)) 89 | } 90 | 91 | /// Starts the monitor. 92 | /// - Parameter queue: The `DispatchQueue` to start the `PathMonitor` on. 93 | public func start(queue: DispatchQueue) { 94 | timerCancellable = ping.map { 95 | $0.publish(with: self.pathStatusSubject) 96 | .subscribe(self.pingStatusSubject) 97 | } 98 | monitor.start(queue: queue) 99 | } 100 | 101 | /// Cancels the montor. 102 | public func cancel() { 103 | if let timerCancellable = timerCancellable { 104 | timerCancellable.cancel() 105 | self.timerCancellable = nil 106 | } 107 | 108 | monitor.cancel() 109 | } 110 | 111 | internal func onUpdate(path: MonitorType.PathType) { 112 | pathSubject.send(path) 113 | } 114 | } 115 | 116 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 117 | public extension NetworkObserver { 118 | /// Creates `NetworkObserver` without a `NetworkPing` object. 119 | /// - Parameter monitor: The `PathMonitor` to monitor the network . 120 | convenience init(monitor: MonitorType) where PingType == NeverPing { 121 | self.init(monitor: monitor, pingOrNil: nil) 122 | } 123 | 124 | /// Creates `NetworkObserver` with a `NetworkPing` object. 125 | /// - Parameters: 126 | /// - monitor: The `PathMonitor` to monitor the network . 127 | /// - ping: The `NetworkPing` to ping periodically. 128 | convenience init(monitor: MonitorType, ping: PingType) { 129 | self.init(monitor: monitor, pingOrNil: ping) 130 | } 131 | } 132 | #endif 133 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/NetworkPath.swift: -------------------------------------------------------------------------------- 1 | /// A path which contains information about the network connections. 2 | public protocol NetworkPath { 3 | /// Whether the network path is constrained. 4 | var isConstrained: Bool { get } 5 | /// Whether the network path is expensive. 6 | var isExpensive: Bool { get } 7 | /// The status of the network connection. 8 | var pathStatus: PathStatus { get } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/NetworkPing.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(Combine) 4 | import Combine 5 | #endif 6 | 7 | /// Called periodically to verify network connectivity. 8 | /// 9 | /// Here's an example which calls the _ipify_ API to verify there's a ip address: 10 | /// ```swift 11 | /// struct IpifyPing : NetworkPing { 12 | /// typealias StatusType = String? 13 | /// 14 | /// let session: URLSession 15 | /// let timeInterval: TimeInterval 16 | /// 17 | /// public func shouldPing(onStatus status: PathStatus) -> Bool { 18 | /// switch status { 19 | /// case .unknown, .unsatisfied: 20 | /// return false 21 | /// case .requiresConnection, .satisfied: 22 | /// return true 23 | /// } 24 | /// } 25 | /// 26 | /// static let url : URL = .init(string: "https://api.ipify.org")! 27 | /// 28 | /// func onPing(_ closure: @escaping (String?) -> Void) { 29 | /// session.dataTask(with: IpifyPing.url) { data, _, _ in 30 | /// closure(data.flatMap{String(data: $0, encoding: .utf8)}) 31 | /// }.resume() 32 | /// } 33 | /// } 34 | /// ``` 35 | /// 36 | /// In our `ObservableObject`, we can create a ``NetworkObserver`` to use this with: 37 | /// 38 | /// ```swift 39 | /// @Published var nwObject = NetworkObserver(ping: 40 | /// // use the shared `URLSession` and check every 10.0 seconds 41 | /// IpifyPing(session: .shared, timeInterval: 10.0) 42 | /// ) 43 | /// ``` 44 | public protocol NetworkPing { 45 | /// The resulting status of the ping. 46 | associatedtype StatusType 47 | /// The amount of time between each verification 48 | var timeInterval: TimeInterval { get } 49 | /// Based on the `PathStatus` should it verify network connectivity. 50 | func shouldPing(onStatus status: PathStatus) -> Bool 51 | /// Invokes the network verification. 52 | func onPing(_ closure: @escaping (StatusType) -> Void) 53 | } 54 | 55 | internal extension NetworkPing { 56 | // swiftlint:disable:next explicit_acl 57 | func onPingForFuture(_ closure: @escaping (Result) -> Void) { 58 | onPing { 59 | closure(.success($0)) 60 | } 61 | } 62 | } 63 | 64 | #if canImport(Combine) 65 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 66 | internal extension NetworkPing { 67 | // swiftlint:disable:next explicit_acl 68 | func publish( 69 | with pathStatusPublisher: PathStatusPublisher 70 | ) -> AnyPublisher 71 | where 72 | PathStatusPublisher.Output == PathStatus, 73 | PathStatusPublisher.Failure == Never { 74 | let timerPublisher = Timer 75 | .publish( 76 | every: timeInterval, 77 | on: .current, 78 | in: .common 79 | ) 80 | .autoconnect() 81 | .prepend(.init()) 82 | 83 | return Publishers.CombineLatest(timerPublisher, pathStatusPublisher) 84 | .compactMap { _, status in 85 | self.shouldPing(onStatus: status) ? () : nil 86 | } 87 | .flatMap { 88 | Future(self.onPingForFuture(_:)) 89 | } 90 | .eraseToAnyPublisher() 91 | } 92 | } 93 | #endif 94 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/NeverPing.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// `NetworkPing` which is never called. 4 | /// This used for a `NetworkObserver` that doesn't need a continuous ping. 5 | public struct NeverPing: NetworkPing { 6 | public typealias StatusType = Never 7 | 8 | public var timeInterval: TimeInterval { 9 | .nan 10 | } 11 | 12 | private init() {} 13 | 14 | public func shouldPing(onStatus _: PathStatus) -> Bool { 15 | false 16 | } 17 | 18 | public func onPing(_: @escaping (Never) -> Void) {} 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/PathMonitor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Monitors the network for connectivity 4 | /// 5 | /// Typically you don't need to implement this and 6 | /// can use [`NWPathMonitor`](../network/nwpathmonitor) 7 | public protocol PathMonitor { 8 | /// The type of path accepted by the `PathMonitor`. 9 | associatedtype PathType: NetworkPath 10 | /// Sets the handler for when the `PathType` updates. 11 | func onPathUpdate(_ handler: @escaping (PathType) -> Void) 12 | /// Starts the monitor. 13 | func start(queue: DispatchQueue) 14 | /// Stops the montor. 15 | func cancel() 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SundialKit/Network/PathStatus.swift: -------------------------------------------------------------------------------- 1 | /// Status of a particular network path 2 | public enum PathStatus: Equatable { 3 | /// Unable to connnect 4 | case unsatisfied(UnsatisfiedReason?) 5 | /// Able to connect with interface 6 | case satisfied(Interface) 7 | /// The path is not currently available, but establishing a new connection may activate the path. 8 | case requiresConnection 9 | /// Unknown status 10 | case unknown 11 | 12 | public enum UnsatisfiedReason: Equatable { 13 | case cellularDenied 14 | case localNetworkDenied 15 | case notAvailable 16 | case wifiDenied 17 | case unknown 18 | case unsupported 19 | } 20 | 21 | /// Types of network interfaces, based on their link layer media types. 22 | public struct Interface: OptionSet, Interfaceable { 23 | public var typeValue: Int { 24 | rawValue 25 | } 26 | 27 | public var rawValue: Int 28 | 29 | public init(rawValue: Int) { 30 | self.rawValue = rawValue 31 | } 32 | 33 | /// Converts a group of `Interfaceable` objects into a `PathStatus.Interface` 34 | /// - Parameter interfaces: A list of `PathStatus.Interface` object. 35 | internal init(interfaces: [Interfaceable]) { 36 | let rawValue = Set(interfaces.map(\.typeValue)).reduce(0, +) 37 | self.init(rawValue: rawValue) 38 | } 39 | 40 | /// The network interface type used for communication over cellular networks. 41 | public static let cellular: Self = .init(rawValue: 1) 42 | /// The network interface type used for communication over Wi-Fi networks. 43 | public static let wifi: Self = .init(rawValue: 2) 44 | /// The network interface type used for communication over wired Ethernet networks. 45 | public static let wiredEthernet: Self = .init(rawValue: 4) 46 | /// The network interface type used for communication 47 | /// over virtual networks or networks of unknown types. 48 | public static let other: Self = .init(rawValue: 8) 49 | /// The network interface type used for communication over local loopback networks. 50 | public static let loopback: Self = .init(rawValue: 16) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/SundialKit/PassthroughSubject.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Combine) 2 | import Combine 3 | 4 | @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) 5 | extension PassthroughSubject { 6 | // swiftlint:disable:next explicit_acl 7 | func anyPublisher( 8 | for keyPath: KeyPath 9 | ) -> AnyPublisher { 10 | map(keyPath).eraseToAnyPublisher() 11 | } 12 | } 13 | #endif 14 | -------------------------------------------------------------------------------- /Sources/SundialKit/SundialError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Specific errors thrown by framework. 4 | public enum SundialError: Error { 5 | /// If WCSession.isSupport is true, 6 | /// you will throw this error at ``ConnectivityObserver/activate()``. 7 | case sessionNotSupported 8 | /// When there is no companion device (i.e. iPhone or Apple Watch) 9 | case missingCompanion 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SundialKit/SundialKit.docc/ConnectivityObserver.md: -------------------------------------------------------------------------------- 1 | # ``SundialKit/ConnectivityObserver`` 2 | 3 | Class for communication between the Apple Watch and iPhone. 4 | 5 | ## Discussion 6 | 7 | `ConnectivityObserver` allows you to listen to changes in `WatchConnectivity` as well as communicate with the other device (iPhone or Apple Watch) 8 | 9 | There's a variety of properties which tell you the status of connection between devices. Here's a similar example to ``NetworkObserver/pathStatusPublisher`` using ``ConnectivityObserver/isReachablePublisher``: 10 | 11 | ```swift 12 | import SwiftUI 13 | import SundialKit 14 | 15 | class WatchMessageObject : ObservableObject { 16 | // our ConnectivityObserver 17 | private let connectivityObserver = ConnectivityObserver() 18 | 19 | // a published property for when the other device is reachable 20 | @Published var isReachable : Bool = false 21 | 22 | // the last message received through WatchConnectivity 23 | @Published var lastReceivedMessage : String = "" 24 | 25 | init () { 26 | // on the main DispatchQueue set each change to reachability 27 | connectivityObserver 28 | .isReachablePublisher 29 | .receive(on: DispatchQueue.main) 30 | .assign(to: &self.$isReachable) 31 | 32 | // get the dictionary of the last message and pull the message component 33 | connectivityObserver 34 | .messageReceivedPublisher 35 | .compactMap({ received in 36 | received.message["message"] as? String 37 | }) 38 | .receive(on: DispatchQueue.main) 39 | .assign(to: &self.$lastReceivedMessage) 40 | } 41 | 42 | // activate the WatchConnectivity session 43 | func activate () { 44 | try! self.connectivityObserver.activate() 45 | } 46 | 47 | // send a message through WatchConnectivity 48 | func sendMessage(_ message: String) { 49 | self.connectivityObserver.sendingMessageSubject.send(["message" : message]) 50 | } 51 | } 52 | ``` 53 | 54 | ## Topics 55 | 56 | ### Activating the Session 57 | 58 | - ``init()`` 59 | - ``activate()`` 60 | 61 | ### Getting the Connection Status 62 | 63 | - ``activationStatePublisher`` 64 | - ``ActivationState`` 65 | - ``isPairedPublisher`` 66 | - ``isPairedAppInstalledPublisher`` 67 | - ``isReachablePublisher`` 68 | 69 | ### Communicating with another device 70 | 71 | - ``sendingMessageSubject`` 72 | - ``messageReceivedPublisher`` 73 | - ``replyMessagePublisher`` 74 | - ``ConnectivityMessage`` 75 | - ``ConnectivitySendContext`` 76 | - ``ConnectivityReceiveContext`` 77 | 78 | ### Decoding Messages 79 | 80 | - ``Messagable`` 81 | - ``MessageDecoder`` 82 | -------------------------------------------------------------------------------- /Sources/SundialKit/SundialKit.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``SundialKit`` 2 | 3 | Communications library across Apple platforms. 4 | 5 | ## Overview 6 | 7 | ![SundialKit Logo](logo.jpg) 8 | 9 | For easier use in reactive user interfaces, especially with `SwiftUI` and `Combine`, I've created a library which abstracts and maps common connectivity APIs. Particularly in my app Heartwitch, I mapped the functionality of _WatchConnectivity_ and _Network_ over to track the user's ability to connect to the Internet as well as the ability for their iPhone to connect to their Apple Watch via _WatchConnectivity_ 10 | 11 | ### Features 12 | 13 | * Monitor network connectivity and quality 14 | * Communicate between iPhone and Apple Watch 15 | - Monitor connectivity between devices 16 | - Send messages back and forth between iPhone and Apple Watch 17 | - Abstract messages for easier _encoding_ and _decoding_ 18 | 19 | ### Requirements 20 | 21 | **Apple Platforms** 22 | 23 | - Xcode 13.2.1 or later 24 | - Swift 5.5.2 or later 25 | - iOS 13.0 / watchOS 6.0 / tvOS 13.0 / macOS 11 or later deployment targets 26 | 27 | ### Installation 28 | 29 | Swift Package Manager is Apple's decentralized dependency manager to integrate libraries to your Swift projects. It is now fully integrated with Xcode 13. 30 | 31 | To integrate **SundialKit** into your project using SPM, specify it in your Package.swift file: 32 | 33 | ```swift 34 | let package = Package( 35 | ... 36 | dependencies: [ 37 | .package(url: "https://github.com/brightdigit/SundialKit", from: "0.2.0") 38 | ], 39 | targets: [ 40 | .target( 41 | name: "YourTarget", 42 | dependencies: ["SundialKit", ...]), 43 | ... 44 | ] 45 | ) 46 | ``` 47 | 48 | If this is for an Xcode project simply import the [Github repository](https://github.com/brightdigit/SundialKit) at: 49 | 50 | ``` 51 | https://github.com/brightdigit/SundialKit 52 | ``` 53 | 54 | ### Listening to Networking Changes 55 | 56 | In the past `Reachability` or `AFNetworking` has been used to judge the network connectivity of a device. **SundialKit** uses the `Network` framework to listen to changes in connectivity providing all the information available. 57 | 58 | **SundialKit** provides a ``NetworkObserver`` which allows you to listen to a variety of publishers related to the network. This is especially useful if you are using `SwiftUI` in particular. With `SwiftUI`, you can create an `ObservableObject` which contains a ``NetworkObserver``: 59 | 60 | ```swift 61 | import SwiftUI 62 | import SundialKit 63 | 64 | class NetworkConnectivityObject : ObservableObject { 65 | // our NetworkObserver 66 | let connectivityObserver = NetworkObserver() 67 | 68 | // our published property for pathStatus initially set to `.unknown` 69 | @Published var pathStatus : PathStatus = .unknown 70 | 71 | init () { 72 | // set the pathStatus changes to our published property 73 | connectivityObserver 74 | .pathStatusPublisher 75 | .receive(on: DispatchQueue.main) 76 | .assign(to: &self.$pathStatus) 77 | } 78 | 79 | // need to start listening 80 | func start () { 81 | self.connectivityObserver.start(queue: .global()) 82 | } 83 | } 84 | ``` 85 | 86 | There are 3 important pieces: 87 | 88 | 1. The ``NetworkObserver`` called `connectivityObserver` 89 | 2. On `init`, we use `Combine` to listen to the publisher and store each new ``PathStatus`` to our `@Published` property. 90 | 3. A `start` method which needs to be called to ``NetworkObserver/start(queue:)`` start listening to the `NetworkObserver`. 91 | 92 | Therefore for our `SwiftUI` `View`, we need to `start` listening `onAppear` and can use the ``PathStatus`` property in the `View`: 93 | 94 | ```swift 95 | 96 | struct NetworkObserverView: View { 97 | @StateObject var connectivityObject = NetworkConnectivityObject() 98 | var body: some View { 99 | // Use the `message` property to display text of the `pathStatus` 100 | Text(self.connectivityObject.pathStatus.message).onAppear{ 101 | // start the NetworkObserver 102 | self.connectivityObject.start() 103 | } 104 | } 105 | } 106 | ``` 107 | 108 | Besides ``NetworkObserver/pathStatusPublisher``, you also have access to: 109 | 110 | * `isExpensive` via ``NetworkObserver/isExpensivePublisher`` 111 | * `isConstrained` via ``NetworkObserver/isConstrainedPublisher`` 112 | 113 | ### Verify Connectivity with ``NetworkPing`` 114 | 115 | In addition to utilizing `NWPathMonitor`, you can setup a periodic ping by implementing ``NetworkPing``. Here's an example which calls the _ipify_ API to verify there's an ip address: 116 | 117 | ```swift 118 | struct IpifyPing : NetworkPing { 119 | typealias StatusType = String? 120 | 121 | let session: URLSession 122 | let timeInterval: TimeInterval 123 | 124 | public func shouldPing(onStatus status: PathStatus) -> Bool { 125 | switch status { 126 | case .unknown, .unsatisfied: 127 | return false 128 | case .requiresConnection, .satisfied: 129 | return true 130 | } 131 | } 132 | 133 | static let url : URL = .init(string: "https://api.ipify.org")! 134 | 135 | func onPing(_ closure: @escaping (String?) -> Void) { 136 | session.dataTask(with: IpifyPing.url) { data, _, _ in 137 | closure(data.flatMap{String(data: $0, encoding: .utf8)}) 138 | }.resume() 139 | } 140 | } 141 | ``` 142 | 143 | Next, in our `ObservableObject`, we can create a ``NetworkObserver`` to use this with: 144 | 145 | ```swift 146 | @Published var nwObject = NetworkObserver(ping: 147 | // use the shared `URLSession` and check every 10.0 seconds 148 | IpifyPing(session: .shared, timeInterval: 10.0) 149 | ) 150 | ``` 151 | 152 | ### Communication between iPhone and Apple Watch 153 | 154 | Besides networking, **SundialKit** also provides an easier reactive interface into `WatchConnectivity`. This includes: 155 | 156 | 1. Various connection statuses like `isReachable`, `isInstalled`, etc.. 157 | 2. Send messages between the iPhone and paired Apple Watch 158 | 3. Easy encoding and decoding of messages between devices into `WatchConnectivity` friendly dictionaries. 159 | 160 | Let's first talk about how `WatchConnectivity` status works. 161 | 162 | #### Connection Status 163 | 164 | With `WatchConnectivity` there's a variety of properties which tell you the status of connection between devices. Here's a similar example to ``NetworkObserver/pathStatusPublisher`` using ``ConnectivityObserver/isReachablePublisher``: 165 | 166 | 167 | ```swift 168 | import SwiftUI 169 | import SundialKit 170 | 171 | class WatchConnectivityObject : ObservableObject { 172 | 173 | // our ConnectivityObserver 174 | let connectivityObserver = ConnectivityObserver() 175 | 176 | // our published property for isReachable initially set to false 177 | @Published var isReachable : Bool = false 178 | 179 | init () { 180 | // set the isReachable changes to our published property 181 | connectivityObserver 182 | .isReachablePublisher 183 | .receive(on: DispatchQueue.main) 184 | .assign(to: &self.$isReachable) 185 | } 186 | 187 | func activate () { 188 | // activate the WatchConnectivity session 189 | try! self.connectivityObserver.activate() 190 | } 191 | } 192 | ``` 193 | 194 | Again, there are 3 important pieces: 195 | 196 | 1. The ``ConnectivityObserver`` called `connectivityObserver` 197 | 2. On `init`, we use `Combine` to listen to the publisher and store each new `isReachable` via ``ConnectivityObserver/isReachablePublisher`` to our `@Published` property. 198 | 3. An ``ConnectivityObserver/activate()`` method which needs to be called to activate the session for `WatchConnectivity`. 199 | 200 | Therefore for our `SwiftUI` `View`, we need to ``ConnectivityObserver/activate()`` the session at `onAppear` and can use the `isReachable` property in the `View`: 201 | 202 | ```swift 203 | 204 | struct WatchConnectivityView: View { 205 | @StateObject var connectivityObject = WatchConnectivityObject() 206 | var body: some View { 207 | Text( 208 | connectivityObject.isReachable ? 209 | "Reachable" : "Not Reachable" 210 | ) 211 | .onAppear{ 212 | self.connectivityObject.activate() 213 | } 214 | } 215 | } 216 | ``` 217 | 218 | Besides `isReachable`, you also have access to: 219 | 220 | * `activationState` via ``ConnectivityObserver/activationStatePublisher`` 221 | * `isReachable` via ``ConnectivityObserver/isReachablePublisher`` 222 | * `isPairedAppInstalled` via ``ConnectivityObserver/isPairedAppInstalledPublisher`` 223 | * `isPaired` via ``ConnectivityObserver/isPairedPublisher`` 224 | 225 | Additionally there's also a set of publishers for sending, receiving, and replying to messages between the iPhone and paired Apple Watch. 226 | 227 | ### Sending and Receiving Messages 228 | 229 | To send and receive messages through our ``ConnectivityObserver`` we can access two properties: 230 | 231 | - ``ConnectivityObserver/messageReceivedPublisher`` - for listening to messages 232 | - ``ConnectivityObserver/sendingMessageSubject`` - for sending messages 233 | 234 | **SundialKit** uses `[String:Any]` dictionaries for sending and receiving messages, which use the typealias ``ConnectivityMessage``. Let's expand upon the previous `WatchConnectivityObject` and use those properties: 235 | 236 | ```swift 237 | class WatchConnectivityObject : ObservableObject { 238 | 239 | // our ConnectivityObserver 240 | let connectivityObserver = ConnectivityObserver() 241 | 242 | // our published property for isReachable initially set to false 243 | @Published var isReachable : Bool = false 244 | 245 | // our published property for the last message received 246 | @Published var lastReceivedMessage : String = "" 247 | 248 | init () { 249 | // set the isReachable changes to our published property 250 | connectivityObserver 251 | .isReachablePublisher 252 | .receive(on: DispatchQueue.main) 253 | .assign(to: &self.$isReachable) 254 | 255 | // set the lastReceivedMessage based on the dictionary's _message_ key 256 | connectivityObserver 257 | .messageReceivedPublisher 258 | .compactMap({ received in 259 | received.message["message"] as? String 260 | }) 261 | .receive(on: DispatchQueue.main) 262 | .assign(to: &self.$lastReceivedMessage) 263 | } 264 | 265 | func activate () { 266 | // activate the WatchConnectivity session 267 | try! self.connectivityObserver.activate() 268 | } 269 | 270 | func sendMessage(_ message: String) { 271 | // create a dictionary with the message in the message key 272 | self.connectivityObserver.sendingMessageSubject.send(["message" : message]) 273 | } 274 | } 275 | ``` 276 | 277 | We can now create a simple SwiftUI View using our updated `WatchConnectivityObject`: 278 | 279 | ```swift 280 | struct WatchMessageDemoView: View { 281 | @StateObject var connectivityObject = WatchMessageObject() 282 | @State var message : String = "" 283 | var body: some View { 284 | VStack{ 285 | Text(connectivityObject.isReachable ? "Reachable" : "Not Reachable").onAppear{ 286 | self.connectivityObject.activate() 287 | } 288 | TextField("Message", text: self.$message) 289 | Button("Send") { 290 | self.connectivityObject.sendMessage(self.message) 291 | } 292 | 293 | Text("Last received message:") 294 | Text(self.connectivityObject.lastReceivedMessage) 295 | } 296 | } 297 | } 298 | ``` 299 | 300 | ### Using _Messagable_ to Communicate 301 | 302 | We can even abstract the ``ConnectivityMessage`` using a ``MessageDecoder``. To do this we need to create a special type which implements ``Messagable``: 303 | 304 | ```swift 305 | struct Message : Messagable { 306 | internal init(text: String) { 307 | self.text = text 308 | } 309 | 310 | static let key: String = "_message" 311 | 312 | enum Parameters : String { 313 | case text 314 | } 315 | 316 | init?(from parameters: [String : Any]?) { 317 | guard let text = parameters?[Parameters.text.rawValue] as? String else { 318 | return nil 319 | } 320 | 321 | self.text = text 322 | } 323 | 324 | func parameters() -> [String : Any] { 325 | return [ 326 | Parameters.text.rawValue : self.text 327 | ] 328 | } 329 | 330 | let text : String 331 | } 332 | ``` 333 | 334 | There are three requirements for implementing ``Messagable``: 335 | 336 | * ``Messagable/init(from:)`` - try to create the object based on the dictionary, return nil if it's invalid 337 | * ``Messagable/parameters()`` - return a dictionary with all the parameters need to recreate the object 338 | * ``Messagable/key`` - return a string which identifies the type and is unique to the ``MessageDecoder`` 339 | 340 | Now that we have our implmentation of ``Messagable``, we can use it in our `WatchConnectivityObject`: 341 | 342 | ```swift 343 | class WatchConnectivityObject : ObservableObject { 344 | 345 | // our ConnectivityObserver 346 | let connectivityObserver = ConnectivityObserver() 347 | 348 | // create a `MessageDecoder` which can decode our new `Message` type 349 | let messageDecoder = MessageDecoder(messagableTypes: [Message.self]) 350 | 351 | // our published property for isReachable initially set to false 352 | @Published var isReachable : Bool = false 353 | 354 | // our published property for the last message received 355 | @Published var lastReceivedMessage : String = "" 356 | 357 | init () { 358 | // set the isReachable changes to our published property 359 | connectivityObserver 360 | .isReachablePublisher 361 | .receive(on: DispatchQueue.main) 362 | .assign(to: &self.$isReachable) 363 | 364 | 365 | connectivityObserver 366 | // get the ``ConnectivityReceiveResult/message`` part of the ``ConnectivityReceiveResult`` 367 | .map(\.message) 368 | // use our `messageDecoder` to call ``MessageDecoder/decode(_:)`` 369 | .compactMap(self.messageDecoder.decode) 370 | // check it's our `Message` 371 | .compactMap{$0 as? Message} 372 | // get the `text` property 373 | .map(\.text) 374 | .receive(on: DispatchQueue.main) 375 | // set it to our published property 376 | .assign(to: &self.$lastReceivedMessage) 377 | } 378 | 379 | func activate () { 380 | // activate the WatchConnectivity session 381 | try! self.connectivityObserver.activate() 382 | } 383 | 384 | func sendMessage(_ message: String) { 385 | // create a dictionary using ``Messagable/message()`` 386 | self.connectivityObserver.sendingMessageSubject.send(Message(text: message).message()) 387 | } 388 | } 389 | ``` 390 | 391 | ## License 392 | 393 | This code is distributed under the MIT license. See the [LICENSE](https://github.com/brightdigit/SundialKit/LICENSE) file for more info. 394 | 395 | ## Topics 396 | 397 | ### Listening to Networking Changes 398 | 399 | - ``NetworkObserver`` 400 | - ``PathMonitor`` 401 | - ``PathStatus`` 402 | - ``NetworkPath`` 403 | - ``NetworkPing`` 404 | - ``NeverPing`` 405 | 406 | ### Communication between iPhone and Apple Watch 407 | 408 | - ``ConnectivityObserver`` 409 | 410 | #### Connection Status 411 | 412 | - ``ActivationState`` 413 | 414 | #### Communicating Messages between iPhone and Apple Watch 415 | 416 | - ``ConnectivityHandler`` 417 | - ``ConnectivityMessage`` 418 | - ``ConnectivityReceiveContext`` 419 | - ``ConnectivityReceiveResult`` 420 | - ``ConnectivitySendContext`` 421 | - ``ConnectivitySendResult`` 422 | 423 | #### Abstracting WatchConnectivity Messages 424 | 425 | - ``Messagable`` 426 | - ``MessageDecoder`` 427 | 428 | ### Error Handling 429 | 430 | - ``SundialError`` 431 | -------------------------------------------------------------------------------- /Sources/SundialKit/SundialKit.docc/NetworkObserver.md: -------------------------------------------------------------------------------- 1 | # ``SundialKit/NetworkObserver`` 2 | 3 | ## Topics 4 | 5 | ### Start Listening 6 | 7 | - ``init()`` 8 | - ``start(queue:)`` 9 | - ``cancel()`` 10 | ` 11 | ### Getting Network Status 12 | 13 | - ``pathStatusPublisher`` 14 | - ``PathStatus`` 15 | - ``PathStatus/Interface`` 16 | - ``PathStatus/UnsatisfiedReason`` 17 | - ``isExpensivePublisher`` 18 | - ``isConstrainedPublisher`` 19 | 20 | ### Using a Network Ping to Test Connectivity 21 | 22 | Rather than relying on only your `PathMonitor`, you can setup a periodic ping to the network. 23 | 24 | - ``init(ping:)`` 25 | - ``pingStatusPublisher`` 26 | - ``NetworkPing`` 27 | - ``NeverPing`` 28 | 29 | ### Custom Path Monitors 30 | 31 | Typically you'll want to use `NWPathMonitor` most of the time. However if you want to build your own, that's available: 32 | 33 | - ``init(monitor:)`` 34 | - ``init(monitor:ping:)`` 35 | - ``PathMonitor`` 36 | - ``NetworkPath`` 37 | -------------------------------------------------------------------------------- /Sources/SundialKit/SundialKit.docc/Resources/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brightdigit/SundialKit/2e1082d8ebdd74bc053191aff0884e2a439e483d/Sources/SundialKit/SundialKit.docc/Resources/logo.jpg -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/ActivationState.swift: -------------------------------------------------------------------------------- 1 | /// Identical to [`WCSessionActivationState`](../watchconnectivity/wcsessionactivationstate), 2 | /// these are constants indicating the activation state of a session. 3 | public enum ActivationState: Int { 4 | /// The session is not activated. When in this state, 5 | /// no communication occurs between the Watch app and iOS app. 6 | /// It is a programmer error to try to send data to the counterpart app while in this state. 7 | case notActivated = 0 8 | /// The session was active but is transitioning to the deactivated state. 9 | /// The session’s delegate object may still receive data while in this state, 10 | /// but it is a programmer error to try to send data to the counterpart app. 11 | case inactive = 1 12 | /// The session is active and the Watch app and iOS app 13 | /// may communicate with each other freely. 14 | case activated = 2 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/ConnectivityHandler.swift: -------------------------------------------------------------------------------- 1 | /// Handles a message received. 2 | public typealias ConnectivityHandler = (ConnectivityMessage) -> Void 3 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/ConnectivityMessage.swift: -------------------------------------------------------------------------------- 1 | /// Basic WatchConnectivity Message 2 | public typealias ConnectivityMessage = [String: Any] 3 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/ConnectivityObserver.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Combine) 2 | import Combine 3 | import Foundation 4 | 5 | /// Class for communication between the Apple Watch and iPhone. 6 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 7 | public class ConnectivityObserver: NSObject, ConnectivitySessionDelegate { 8 | /// `typealias` for `PassthroughSubject` without a `Failure`. 9 | private typealias SuccessfulSubject = PassthroughSubject 10 | 11 | internal let session: ConnectivitySession 12 | 13 | /// `Subject` for sending message through 14 | public let sendingMessageSubject = PassthroughSubject() 15 | 16 | // swiftlint:disable:next implicitly_unwrapped_optional 17 | private var cancellable: AnyCancellable! 18 | 19 | private let activationStateSubject = SuccessfulSubject() 20 | private let isReachableSubject = SuccessfulSubject() 21 | private let isPairedAppInstalledSubject = SuccessfulSubject() 22 | private let isPairedSubject = SuccessfulSubject() 23 | private let messageReceivedSubject = SuccessfulSubject() 24 | private let replyMessageSubject = SuccessfulSubject() 25 | 26 | /// Creates a publisher for changes to the 27 | /// [`activationState`](../watchconnectivity/wcsession/1615663-activationstate) 28 | public var activationStatePublisher: AnyPublisher { 29 | activationStateSubject.anyPublisher(for: \.activationState) 30 | } 31 | 32 | /// Creates a publisher for changes to the 33 | /// [`isReachable`](../watchconnectivity/wcsession/1615683-isreachable) . 34 | public var isReachablePublisher: AnyPublisher { 35 | isReachableSubject.anyPublisher(for: \.isReachable) 36 | } 37 | 38 | /// Creates a publisher for changes to the 39 | /// [`isCompanionAppInstalled`](../watchconnectivity/wcsession/3235766-iscompanionappinstalled) 40 | /// or [`isWatchAppInstalled`](../watchconnectivity/wcsession/1615623-iswatchappinstalled). 41 | public var isPairedAppInstalledPublisher: AnyPublisher { 42 | isPairedAppInstalledSubject.anyPublisher( 43 | for: \.isPairedAppInstalled 44 | ) 45 | } 46 | 47 | /// Creates a publisher for messages received through WatchConnectivity. 48 | public var messageReceivedPublisher: AnyPublisher { 49 | messageReceivedSubject.eraseToAnyPublisher() 50 | } 51 | 52 | /// Creates a publisher for replies 53 | /// from send messages received through WatchConnectivity. 54 | public var replyMessagePublisher: AnyPublisher { 55 | replyMessageSubject.eraseToAnyPublisher() 56 | } 57 | 58 | @available(watchOS, unavailable) 59 | /// Creates a publisher for changes 60 | /// to the [`isPaired`](../watchconnectivity/wcsession/1615665-ispaired) . 61 | public var isPairedPublisher: AnyPublisher { 62 | #if os(iOS) 63 | return isPairedSubject.anyPublisher(for: \.isPaired) 64 | #else 65 | return Empty(outputType: Bool.self, failureType: Never.self).eraseToAnyPublisher() 66 | #endif 67 | } 68 | 69 | internal init(session: ConnectivitySession) { 70 | self.session = session 71 | super.init() 72 | session.delegate = self 73 | cancellable = sendingMessageSubject.sink(receiveValue: sendMessage(_:)) 74 | } 75 | 76 | @available(macOS, unavailable) 77 | @available(tvOS, unavailable) 78 | /// Creates a `ConnectivityObserver` which uses [WatchConnectivity](../watchconnectivity). 79 | override public convenience init() { 80 | #if canImport(WatchConnectivity) 81 | self.init(session: WatchConnectivitySession()) 82 | #else 83 | self.init(session: NeverConnectivitySession()) 84 | #endif 85 | } 86 | 87 | /// Sessions are always available on Apple Watch. 88 | /// They are also available on iPhones that support pairing with an Apple Watch. 89 | /// For all other devices, this will throw ``SundialError/sessionNotSupported``. 90 | /// 91 | /// - Throws: `SundialError.sessionNotSupported` if session is not supported. 92 | public func activate() throws { 93 | session.delegate = self 94 | try session.activate() 95 | } 96 | 97 | internal func sessionDidBecomeInactive(_ session: ConnectivitySession) { 98 | activationStateSubject.send(session) 99 | } 100 | 101 | internal func sessionDidDeactivate(_ session: ConnectivitySession) { 102 | activationStateSubject.send(session) 103 | } 104 | 105 | internal func sessionCompanionStateDidChange(_ session: ConnectivitySession) { 106 | DispatchQueue.main.async { 107 | self.isPairedSubject.send(session) 108 | self.isPairedAppInstalledSubject.send(session) 109 | } 110 | } 111 | 112 | internal func session( 113 | _ session: ConnectivitySession, 114 | activationDidCompleteWith _: ActivationState, 115 | error _: Error? 116 | ) { 117 | DispatchQueue.main.async { 118 | self.activationStateSubject.send(session) 119 | 120 | self.isReachableSubject.send(session) 121 | self.isPairedAppInstalledSubject.send(session) 122 | #if os(iOS) 123 | self.isPairedSubject.send(session) 124 | #endif 125 | } 126 | } 127 | 128 | internal func sessionReachabilityDidChange(_ session: ConnectivitySession) { 129 | DispatchQueue.main.async { 130 | self.isReachableSubject.send(session) 131 | } 132 | } 133 | 134 | private func sendMessage(_ message: ConnectivityMessage) { 135 | if session.isReachable { 136 | session.sendMessage(message) { result in 137 | self.replyMessageSubject.send(.init(message: message, context: .init(result))) 138 | } 139 | } else if session.isPairedAppInstalled { 140 | do { 141 | try session.updateApplicationContext(message) 142 | } catch { 143 | replyMessageSubject.send(.init(message: message, context: .failure(error))) 144 | 145 | return 146 | } 147 | replyMessageSubject.send(.init(message: message, context: .applicationContext)) 148 | } else { 149 | replyMessageSubject.send( 150 | .init(message: message, context: .failure(SundialError.missingCompanion)) 151 | ) 152 | } 153 | } 154 | 155 | internal func session( 156 | _: ConnectivitySession, 157 | didReceiveMessage message: [String: Any], 158 | replyHandler: @escaping ([String: Any]) -> Void 159 | ) { 160 | messageReceivedSubject.send(.init(message: message, context: .replyWith(replyHandler))) 161 | } 162 | 163 | internal func session( 164 | _: ConnectivitySession, 165 | didReceiveApplicationContext applicationContext: ConnectivityMessage, 166 | error _: Error? 167 | ) { 168 | messageReceivedSubject.send(.init(message: applicationContext, context: .applicationContext)) 169 | } 170 | } 171 | #endif 172 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/ConnectivityReceiveContext.swift: -------------------------------------------------------------------------------- 1 | /// Context of the message received 2 | public enum ConnectivityReceiveContext { 3 | /// Received as a sent message with a reply handler 4 | case replyWith(ConnectivityHandler) 5 | /// Received as application context. 6 | case applicationContext 7 | 8 | /// The reply handler if it contains one. 9 | public var replyHandler: ConnectivityHandler? { 10 | guard case let .replyWith(handler) = self else { 11 | return nil 12 | } 13 | return handler 14 | } 15 | 16 | /// If this was from application context. 17 | public var isApplicationContext: Bool { 18 | guard case .applicationContext = self else { 19 | return false 20 | } 21 | return true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/ConnectivityReceiveResult.swift: -------------------------------------------------------------------------------- 1 | /// Message received. 2 | public struct ConnectivityReceiveResult { 3 | /// Message received. 4 | public let message: ConnectivityMessage 5 | 6 | /// How the message was received. 7 | public let context: ConnectivityReceiveContext 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/ConnectivitySendContext.swift: -------------------------------------------------------------------------------- 1 | /// Result from sending a message 2 | public enum ConnectivitySendContext { 3 | /// Sent via application context 4 | case applicationContext 5 | /// Sent via message with reply received 6 | case reply(ConnectivityMessage) 7 | /// Failure 8 | case failure(Error) 9 | } 10 | 11 | extension ConnectivitySendContext { 12 | // swiftlint:disable:next explicit_acl 13 | init(_ result: Result) { 14 | switch result { 15 | case let .success(message): 16 | self = .reply(message) 17 | 18 | case let .failure(error): 19 | self = .failure(error) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/ConnectivitySendResult.swift: -------------------------------------------------------------------------------- 1 | /// The result from sending a message. 2 | public struct ConnectivitySendResult { 3 | /// The reply message sent. 4 | public let message: ConnectivityMessage 5 | 6 | /// How the message was sent. 7 | public let context: ConnectivitySendContext 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/ConnectivitySession.swift: -------------------------------------------------------------------------------- 1 | internal protocol ConnectivitySession: AnyObject { 2 | var delegate: ConnectivitySessionDelegate? { get set } 3 | var isReachable: Bool { get } 4 | 5 | #if os(iOS) 6 | var isPaired: Bool { get } 7 | #endif 8 | var isPairedAppInstalled: Bool { get } 9 | var activationState: ActivationState { get } 10 | 11 | func activate() throws 12 | func updateApplicationContext(_ context: ConnectivityMessage) throws 13 | func sendMessage( 14 | _ message: ConnectivityMessage, 15 | _ completion: @escaping (Result) -> Void 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/ConnectivitySessionDelegate.swift: -------------------------------------------------------------------------------- 1 | internal protocol ConnectivitySessionDelegate: AnyObject { 2 | func session( 3 | _ session: ConnectivitySession, 4 | activationDidCompleteWith activationState: ActivationState, 5 | error: Error? 6 | ) 7 | 8 | func sessionDidBecomeInactive(_ session: ConnectivitySession) 9 | 10 | func sessionDidDeactivate(_ session: ConnectivitySession) 11 | 12 | func sessionCompanionStateDidChange(_ session: ConnectivitySession) 13 | 14 | func sessionReachabilityDidChange(_ session: ConnectivitySession) 15 | 16 | func session( 17 | _ session: ConnectivitySession, 18 | didReceiveMessage message: ConnectivityMessage, 19 | replyHandler: @escaping ConnectivityHandler 20 | ) 21 | 22 | func session( 23 | _ session: ConnectivitySession, 24 | didReceiveApplicationContext applicationContext: ConnectivityMessage, 25 | error: Error? 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/Extensions/WCSession.swift: -------------------------------------------------------------------------------- 1 | #if canImport(WatchConnectivity) 2 | import WatchConnectivity 3 | 4 | public extension WCSession { 5 | /// polyfill for whether the app is installed on the other device 6 | var isPairedAppInstalled: Bool { 7 | #if os(iOS) 8 | return isWatchAppInstalled 9 | #else 10 | return isCompanionAppInstalled 11 | #endif 12 | } 13 | } 14 | #endif 15 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/Messagable.swift: -------------------------------------------------------------------------------- 1 | /// An object which can be decoded by a ``MessageDecoder`` from a ``ConnectivityMessage``. 2 | /// 3 | /// ```swift 4 | /// struct Message : Messagable { 5 | /// internal init(text: String) { 6 | /// self.text = text 7 | /// } 8 | /// 9 | /// static let key: String = "_message" 10 | /// 11 | /// enum Parameters : String { 12 | /// case text 13 | /// } 14 | /// 15 | /// init?(from parameters: [String : Any]?) { 16 | /// guard let text = parameters?[Parameters.text.rawValue] as? String else { 17 | /// return nil 18 | /// } 19 | /// 20 | /// self.text = text 21 | /// } 22 | /// 23 | /// func parameters() -> [String : Any] { 24 | /// return [ 25 | /// Parameters.text.rawValue : self.text 26 | /// ] 27 | /// } 28 | /// 29 | /// let text : String 30 | /// } 31 | /// 32 | /// let messageDecoder = MessageDecoder(messagableTypes: [Message.self]) 33 | /// ``` 34 | public protocol Messagable { 35 | /// The unique key or type name to use for decoding. 36 | static var key: String { get } 37 | 38 | /// Create the object based on the `Dictionary`. 39 | /// - Parameter parameters: The parameters value. 40 | init?(from parameters: [String: Any]?) 41 | 42 | /// The parameters of the `Messagable` object. 43 | /// - Returns: The parameters of the `Messagable` object. 44 | func parameters() -> [String: Any] 45 | } 46 | 47 | public extension Messagable { 48 | /// Converts the object into a usable `ConnectivityMessage` for 49 | /// `WatchConnectivity` 50 | /// - Returns: `ConnectivityMessage` i.e. `[String:Any]` for sending. 51 | func message() -> ConnectivityMessage { 52 | [ 53 | MessagableKeys.typeKey: Self.key, 54 | MessagableKeys.parametersKey: parameters() 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/MessagableKeys.swift: -------------------------------------------------------------------------------- 1 | internal enum MessagableKeys { 2 | internal static let typeKey = "__type" 3 | internal static let parametersKey = "__parameters" 4 | } 5 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/MessageDecoder.swift: -------------------------------------------------------------------------------- 1 | /// Decodes a ``ConnectivityMessage`` based on provided ``Messagable`` types. 2 | /// 3 | /// ```swift 4 | /// struct Message : Messagable { 5 | /// internal init(text: String) { 6 | /// self.text = text 7 | /// } 8 | /// 9 | /// static let key: String = "_message" 10 | /// 11 | /// enum Parameters : String { 12 | /// case text 13 | /// } 14 | /// 15 | /// init?(from parameters: [String : Any]?) { 16 | /// guard let text = parameters?[Parameters.text.rawValue] as? String else { 17 | /// return nil 18 | /// } 19 | /// 20 | /// self.text = text 21 | /// } 22 | /// 23 | /// func parameters() -> [String : Any] { 24 | /// return [ 25 | /// Parameters.text.rawValue : self.text 26 | /// ] 27 | /// } 28 | /// 29 | /// let text : String 30 | /// } 31 | /// 32 | /// let messageDecoder = MessageDecoder(messagableTypes: [Message.self]) 33 | /// ``` 34 | public struct MessageDecoder { 35 | private let messagableTypes: [String: Messagable.Type] 36 | 37 | /// Creates a `MessageDecoder` based on a list of `Messagable` types. 38 | /// - Parameter messagableTypes: A list of `Messagable` types. 39 | public init(messagableTypes: [Messagable.Type]) { 40 | self.messagableTypes = Dictionary(uniqueKeysWithValues: messagableTypes.map { 41 | ($0.key, $0) 42 | }) 43 | } 44 | 45 | /// Decodes the `ConnectivityMessage` into a `Messagable` object. 46 | /// - Parameter message: The `ConnectivityMessage` or `[String : Any]` object. 47 | /// - Returns: The `Messagable` object or nil if it couldn't be decoded. 48 | public func decode(_ message: ConnectivityMessage) -> Messagable? { 49 | guard let typeKey = message[MessagableKeys.typeKey] as? String else { 50 | return nil 51 | } 52 | 53 | guard let type = messagableTypes[typeKey] else { 54 | return nil 55 | } 56 | 57 | return type.init(from: message[MessagableKeys.parametersKey] as? [String: Any]) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/NeverConnectivitySession.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal class NeverConnectivitySession: NSObject, ConnectivitySession { 4 | internal var delegate: ConnectivitySessionDelegate? { 5 | get { 6 | nil 7 | } 8 | // swiftlint:disable:next unused_setter_value 9 | set {} 10 | } 11 | 12 | internal var isReachable: Bool { 13 | false 14 | } 15 | 16 | internal var isPaired: Bool { 17 | false 18 | } 19 | 20 | internal var isPairedAppInstalled: Bool { 21 | false 22 | } 23 | 24 | internal var activationState: ActivationState { 25 | .notActivated 26 | } 27 | 28 | internal func activate() throws { 29 | throw SundialError.sessionNotSupported 30 | } 31 | 32 | internal func updateApplicationContext(_: ConnectivityMessage) throws { 33 | throw SundialError.sessionNotSupported 34 | } 35 | 36 | internal func sendMessage( 37 | _: ConnectivityMessage, 38 | _ completion: @escaping (Result) -> Void 39 | ) { 40 | completion(.failure(SundialError.sessionNotSupported)) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SundialKit/WatchConnectivity/WatchConnectivitySession.swift: -------------------------------------------------------------------------------- 1 | #if canImport(WatchConnectivity) 2 | import WatchConnectivity 3 | 4 | internal typealias WatchConnectivitySessionProtocol = 5 | ConnectivitySession & WCSessionDelegate 6 | 7 | internal class WatchConnectivitySession: NSObject, WatchConnectivitySessionProtocol { 8 | private let session: WCSession 9 | 10 | internal var delegate: ConnectivitySessionDelegate? 11 | 12 | internal var isReachable: Bool { 13 | session.isReachable 14 | } 15 | 16 | @available(watchOS, unavailable) 17 | internal var isPaired: Bool { 18 | session.isPaired 19 | } 20 | 21 | internal var isPairedAppInstalled: Bool { 22 | session.isPairedAppInstalled 23 | } 24 | 25 | internal var activationState: ActivationState { 26 | guard let state = ActivationState(rawValue: session.activationState.rawValue) else { 27 | preconditionFailure() 28 | } 29 | 30 | return state 31 | } 32 | 33 | internal init(session: WCSession) { 34 | self.session = session 35 | super.init() 36 | session.delegate = self 37 | } 38 | 39 | override internal convenience init() { 40 | self.init(session: .default) 41 | } 42 | 43 | internal func updateApplicationContext(_ context: ConnectivityMessage) throws { 44 | try session.updateApplicationContext(context) 45 | } 46 | 47 | internal func sendMessage( 48 | _ message: ConnectivityMessage, 49 | _ completion: @escaping (Result) -> Void 50 | ) { 51 | session.sendMessage( 52 | message 53 | ) { message in 54 | completion(.success(message)) 55 | } 56 | errorHandler: { error in 57 | completion(.failure(error)) 58 | } 59 | } 60 | 61 | internal func activate() throws { 62 | guard WCSession.isSupported() else { 63 | throw SundialError.sessionNotSupported 64 | } 65 | session.activate() 66 | } 67 | 68 | internal func session( 69 | _: WCSession, 70 | activationDidCompleteWith activationState: WCSessionActivationState, 71 | error: Error? 72 | ) { 73 | guard let activationState: ActivationState = 74 | .init(rawValue: activationState.rawValue) else { 75 | preconditionFailure() 76 | } 77 | delegate?.session( 78 | self, 79 | activationDidCompleteWith: activationState, 80 | error: error 81 | ) 82 | } 83 | 84 | #if os(iOS) 85 | 86 | internal func sessionDidBecomeInactive(_: WCSession) { 87 | delegate?.sessionDidBecomeInactive(self) 88 | } 89 | 90 | internal func sessionDidDeactivate(_: WCSession) { 91 | delegate?.sessionDidDeactivate(self) 92 | } 93 | 94 | internal func sessionWatchStateDidChange(_: WCSession) { 95 | delegate?.sessionCompanionStateDidChange(self) 96 | } 97 | 98 | #elseif os(watchOS) 99 | 100 | internal func sessionCompanionAppInstalledDidChange(_: WCSession) { 101 | delegate?.sessionCompanionStateDidChange(self) 102 | } 103 | #endif 104 | 105 | internal func sessionReachabilityDidChange(_: WCSession) { 106 | delegate?.sessionReachabilityDidChange(self) 107 | } 108 | 109 | internal func session( 110 | _: WCSession, 111 | didReceiveMessage message: [String: Any], 112 | replyHandler: @escaping ([String: Any]) -> Void 113 | ) { 114 | delegate?.session(self, didReceiveMessage: message, replyHandler: replyHandler) 115 | } 116 | 117 | internal func session( 118 | _: WCSession, 119 | didReceiveApplicationContext applicationContext: [String: Any] 120 | ) { 121 | delegate?.session( 122 | self, 123 | didReceiveApplicationContext: applicationContext, 124 | error: nil 125 | ) 126 | } 127 | 128 | internal func session( 129 | _: WCSession, 130 | didReceiveApplicationContext applicationContext: [String: Any], 131 | error: Error? 132 | ) { 133 | delegate?.session( 134 | self, 135 | didReceiveApplicationContext: applicationContext, 136 | error: error 137 | ) 138 | } 139 | } 140 | 141 | #endif 142 | -------------------------------------------------------------------------------- /Tests/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | function_body_length: 2 | - 25 3 | - 35 -------------------------------------------------------------------------------- /Tests/SundialKitTests/MockError.swift: -------------------------------------------------------------------------------- 1 | internal enum MockError: Error, Equatable { 2 | case value(T) 3 | } 4 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/MockMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Leo Dion on 5/20/22. 6 | // 7 | import Foundation 8 | import SundialKit 9 | 10 | public struct MockMessage: Messagable, Equatable { 11 | public static let key: String = UUID().uuidString 12 | internal let key: String 13 | internal let value: UUID 14 | public init() { 15 | key = UUID().uuidString 16 | value = UUID() 17 | } 18 | 19 | public init?(from parameters: [String: Any]?) { 20 | guard let pair = parameters?.first else { 21 | return nil 22 | } 23 | 24 | guard let value = pair.value as? UUID else { 25 | return nil 26 | } 27 | 28 | key = pair.key 29 | self.value = value 30 | } 31 | 32 | public func parameters() -> [String: Any] { 33 | [key: value] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/MockNetworkPing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Leo Dion on 5/19/22. 6 | // 7 | import Foundation 8 | import SundialKit 9 | 10 | internal class MockNetworkPing: NetworkPing { 11 | internal struct StatusType {} 12 | internal private(set) var lastShoundPingStatus: StatusType? 13 | internal let id: UUID 14 | internal let timeInterval: TimeInterval 15 | 16 | internal init(id: UUID, timeInterval: TimeInterval) { 17 | self.id = id 18 | self.timeInterval = timeInterval 19 | } 20 | 21 | internal func shouldPing(onStatus _: PathStatus) -> Bool { 22 | true 23 | } 24 | 25 | internal func onPing(_ closure: @escaping (StatusType) -> Void) { 26 | closure(.init()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/MockPath.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SundialKit 3 | 4 | internal struct MockPath: NetworkPath { 5 | internal let isConstrained: Bool 6 | 7 | internal let isExpensive: Bool 8 | 9 | internal let pathStatus: PathStatus 10 | internal init( 11 | isConstrained: Bool = false, 12 | isExpensive: Bool = false, 13 | pathStatus: PathStatus = .unknown 14 | ) { 15 | self.isConstrained = isConstrained 16 | self.isExpensive = isExpensive 17 | self.pathStatus = pathStatus 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/MockPathMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Leo Dion on 5/19/22. 6 | // 7 | import Foundation 8 | import SundialKit 9 | 10 | internal class MockPathMonitor: PathMonitor { 11 | internal typealias PathType = MockPath 12 | 13 | internal let id: UUID 14 | internal private(set) var pathUpdate: ((MockPath) -> Void)? 15 | internal private(set) var dispatchQueueLabel: String? 16 | internal private(set) var isCancelled = false 17 | internal init(id: UUID) { 18 | self.id = id 19 | } 20 | 21 | internal func onPathUpdate(_ handler: @escaping (MockPath) -> Void) { 22 | pathUpdate = handler 23 | } 24 | 25 | internal func start(queue: DispatchQueue) { 26 | dispatchQueueLabel = queue.label 27 | pathUpdate?( 28 | .init( 29 | isConstrained: false, 30 | isExpensive: false, 31 | pathStatus: .satisfied(.wiredEthernet) 32 | ) 33 | ) 34 | } 35 | 36 | internal func cancel() { 37 | isCancelled = true 38 | } 39 | 40 | internal func sendPath(_ path: MockPath) { 41 | pathUpdate?(path) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/MockSession.swift: -------------------------------------------------------------------------------- 1 | @testable import SundialKit 2 | 3 | internal class MockSession: ConnectivitySession { 4 | internal var lastMessageSent: ConnectivityMessage? 5 | internal var lastAppContext: ConnectivityMessage? 6 | // swiftlint:disable:next implicitly_unwrapped_optional 7 | internal var nextReplyResult: Result! 8 | internal var nextApplicationContextError: Error? 9 | internal var isPaired = false { 10 | didSet { 11 | delegate?.sessionCompanionStateDidChange(self) 12 | } 13 | } 14 | 15 | internal var delegate: ConnectivitySessionDelegate? 16 | 17 | internal var isReachable = false { 18 | didSet { 19 | delegate?.sessionReachabilityDidChange(self) 20 | } 21 | } 22 | 23 | internal var isPairedAppInstalled = false { 24 | didSet { 25 | delegate?.sessionCompanionStateDidChange(self) 26 | } 27 | } 28 | 29 | internal var activationState: ActivationState = .notActivated { 30 | didSet { 31 | switch activationState { 32 | case .activated: 33 | delegate?.session(self, activationDidCompleteWith: activationState, error: nil) 34 | 35 | case .inactive: 36 | delegate?.sessionDidBecomeInactive(self) 37 | 38 | case .notActivated: 39 | delegate?.sessionDidDeactivate(self) 40 | } 41 | } 42 | } 43 | 44 | internal func activate() throws { 45 | activationState = .activated 46 | } 47 | 48 | internal func updateApplicationContext(_ context: ConnectivityMessage) throws { 49 | if let nextApplicationContextError = nextApplicationContextError { 50 | throw nextApplicationContextError 51 | } 52 | lastAppContext = context 53 | delegate?.session(self, didReceiveApplicationContext: context, error: nil) 54 | } 55 | 56 | internal func sendMessage( 57 | _ message: ConnectivityMessage, 58 | _ replyHandler: @escaping (Result) -> Void 59 | ) { 60 | lastMessageSent = message 61 | replyHandler(nextReplyResult) 62 | } 63 | 64 | internal func receiveMessage( 65 | _ message: ConnectivityMessage, 66 | withReplyHandler replyHandler: @escaping ConnectivityHandler 67 | ) { 68 | delegate?.session(self, didReceiveMessage: message, replyHandler: replyHandler) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/Network/Extensions/NWInterfaceTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SundialKit 3 | 4 | #if canImport(Network) 5 | import Network 6 | #endif 7 | 8 | import XCTest 9 | 10 | public final class NWInterfaceTests: XCTestCase { 11 | #if canImport(Network) 12 | public func rawValue( 13 | of interface: PathStatus.Interface, 14 | interfaceType: NWInterface.InterfaceType 15 | ) { 16 | XCTAssertEqual(interface.rawValue, interfaceType.value) 17 | } 18 | #endif 19 | 20 | public func testValue() throws { 21 | #if canImport(Network) 22 | 23 | rawValue(of: .wifi, interfaceType: .wifi) 24 | rawValue(of: .wiredEthernet, interfaceType: .wiredEthernet) 25 | rawValue(of: .loopback, interfaceType: .loopback) 26 | rawValue(of: .cellular, interfaceType: .cellular) 27 | rawValue(of: .other, interfaceType: .other) 28 | #else 29 | throw XCTSkip("OS doesn't support Combine.") 30 | #endif 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/Network/Extensions/NWPathMonitorTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SundialKit 3 | 4 | #if canImport(Network) 5 | import Network 6 | #endif 7 | import XCTest 8 | 9 | public final class NWPathMonitorTests: XCTestCase { 10 | public func testPathUpdate() throws { 11 | #if canImport(Network) 12 | if #available(macOS 11.0, iOS 14.2, watchOS 7.1, tvOS 14.2, *) { 13 | let monitor = NWPathMonitor() 14 | XCTAssertNil(monitor.pathUpdateHandler) 15 | monitor.onPathUpdate { _ in 16 | } 17 | XCTAssertNotNil(monitor.pathUpdateHandler) 18 | return 19 | } 20 | #endif 21 | 22 | throw XCTSkip("OS doesn't support Network.") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/Network/Extensions/PathStatusNetworkTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SundialKit 3 | 4 | #if canImport(Network) 5 | import Network 6 | #endif 7 | 8 | import XCTest 9 | 10 | public final class PathStatusNetworkTests: XCTestCase { 11 | #if canImport(Network) 12 | 13 | @available(macOS 11.0, iOS 14.2, watchOS 7.1, tvOS 14.2, *) 14 | public func assertStatus( 15 | _ status: NWPath.Status, 16 | reason: NWPath.UnsatisfiedReason, 17 | interfaces: [PathStatus.Interface], 18 | equalsPathStatus expected: PathStatus 19 | ) { 20 | let actual: PathStatus = .init(status, reason: reason, interfaces: interfaces) 21 | XCTAssertEqual(actual, expected) 22 | } 23 | #endif 24 | // swiftlint:disable:next function_body_length 25 | public func testInit() throws { 26 | if #available(macOS 11.0, iOS 14.2, watchOS 7.1, tvOS 14.2, *) { 27 | #if canImport(Network) 28 | assertStatus( 29 | .satisfied, 30 | reason: .cellularDenied, 31 | interfaces: [.loopback, .cellular], 32 | equalsPathStatus: .satisfied([.loopback, .cellular]) 33 | ) 34 | assertStatus( 35 | .requiresConnection, 36 | reason: .cellularDenied, 37 | interfaces: [.loopback, .cellular], 38 | equalsPathStatus: .requiresConnection 39 | ) 40 | assertStatus( 41 | .unsatisfied, 42 | reason: .cellularDenied, 43 | interfaces: [.loopback, .cellular], 44 | equalsPathStatus: .unsatisfied(.cellularDenied) 45 | ) 46 | assertStatus( 47 | .unsatisfied, 48 | reason: .notAvailable, 49 | interfaces: [.loopback, .cellular], 50 | equalsPathStatus: .unsatisfied(.notAvailable) 51 | ) 52 | assertStatus( 53 | .unsatisfied, 54 | reason: .localNetworkDenied, 55 | interfaces: [.loopback, .cellular], 56 | equalsPathStatus: .unsatisfied(.localNetworkDenied) 57 | ) 58 | assertStatus( 59 | .unsatisfied, 60 | reason: .wifiDenied, 61 | interfaces: [.loopback, .cellular], 62 | equalsPathStatus: .unsatisfied(.wifiDenied) 63 | ) 64 | return 65 | #endif 66 | } 67 | throw XCTSkip("Network framework unavailable.") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/Network/NetworkObserverTests.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable discouraged_optional_boolean 2 | import Foundation 3 | @testable import SundialKit 4 | 5 | import XCTest 6 | 7 | public final class NetworkObserverTests: XCTestCase { 8 | public func testStart() throws { 9 | #if canImport(Combine) 10 | let monitor = MockPathMonitor(id: UUID()) 11 | let ping = MockNetworkPing(id: UUID(), timeInterval: 1.0) 12 | let observer = NetworkObserver( 13 | monitor: monitor, 14 | ping: ping 15 | ) 16 | var statuses = [MockNetworkPing.StatusType?]() 17 | let pingStatusExpectaion = expectation(description: "ping status received") 18 | 19 | let cancellable = observer.pingStatusPublisher.sink { status in 20 | statuses.append(status) 21 | if statuses.count > 1 { 22 | pingStatusExpectaion.fulfill() 23 | } 24 | } 25 | 26 | let dispatchQueueLabel = UUID().uuidString 27 | observer.start(queue: .init(label: dispatchQueueLabel)) 28 | 29 | XCTAssertEqual(monitor.dispatchQueueLabel, dispatchQueueLabel) 30 | 31 | waitForExpectations(timeout: 10.0) { error in 32 | XCTAssertNil(error) 33 | XCTAssertNotNil(statuses[0]) 34 | XCTAssertNotNil(statuses[1]) 35 | cancellable.cancel() 36 | } 37 | #else 38 | 39 | #endif 40 | } 41 | 42 | public func testCancel() throws { 43 | #if canImport(Combine) 44 | let monitor = MockPathMonitor(id: UUID()) 45 | let ping = MockNetworkPing(id: UUID(), timeInterval: 1.0) 46 | let observer = NetworkObserver( 47 | monitor: monitor, 48 | ping: ping 49 | ) 50 | observer.start(queue: .init(label: UUID().uuidString)) 51 | XCTAssertTrue(observer.isPingActive) 52 | 53 | observer.cancel() 54 | XCTAssertFalse(observer.isPingActive) 55 | XCTAssertTrue(monitor.isCancelled) 56 | #else 57 | throw XCTSkip("Combine is not supported by this OS.") 58 | #endif 59 | } 60 | 61 | public func testPathStatusPublisher() throws { 62 | #if canImport(Combine) 63 | let monitor = MockPathMonitor(id: UUID()) 64 | let ping = MockNetworkPing(id: UUID(), timeInterval: 1.0) 65 | let observer = NetworkObserver( 66 | monitor: monitor, 67 | ping: ping 68 | ) 69 | 70 | var pathStatus: PathStatus? 71 | let cancellable = observer.pathStatusPublisher.sink { 72 | pathStatus = $0 73 | } 74 | monitor.sendPath(.init(pathStatus: .requiresConnection)) 75 | XCTAssertEqual(pathStatus, .requiresConnection) 76 | cancellable.cancel() 77 | #else 78 | throw XCTSkip("Combine is not supported by this OS.") 79 | #endif 80 | } 81 | 82 | public func testIsExpensivePublisher() throws { 83 | #if canImport(Combine) 84 | let monitor = MockPathMonitor(id: UUID()) 85 | let ping = MockNetworkPing(id: UUID(), timeInterval: 1.0) 86 | let observer = NetworkObserver( 87 | monitor: monitor, 88 | ping: ping 89 | ) 90 | let expectedIsExpensive: Bool = .random() 91 | var actualIsExpensive: Bool? 92 | let cancellable = observer.isExpensivePublisher.sink { 93 | actualIsExpensive = $0 94 | } 95 | monitor.sendPath(.init(isExpensive: expectedIsExpensive)) 96 | XCTAssertEqual(expectedIsExpensive, actualIsExpensive) 97 | cancellable.cancel() 98 | #else 99 | throw XCTSkip("Combine is not supported by this OS.") 100 | #endif 101 | } 102 | 103 | public func testIsConstrainedPublisher() throws { 104 | #if canImport(Combine) 105 | let monitor = MockPathMonitor(id: UUID()) 106 | let ping = MockNetworkPing(id: UUID(), timeInterval: 1.0) 107 | let observer = NetworkObserver( 108 | monitor: monitor, 109 | ping: ping 110 | ) 111 | let expectedIsConstrained: Bool = .random() 112 | var actualIsConstrained: Bool? 113 | let cancellable = observer.isConstrainedPublisher.sink { 114 | actualIsConstrained = $0 115 | } 116 | monitor.sendPath(.init(isConstrained: expectedIsConstrained)) 117 | XCTAssertEqual(expectedIsConstrained, actualIsConstrained) 118 | cancellable.cancel() 119 | #else 120 | throw XCTSkip("Combine is not supported by this OS.") 121 | #endif 122 | } 123 | 124 | public func testInit() throws { 125 | #if canImport(Combine) 126 | let monitorID = UUID() 127 | let pingID = UUID() 128 | let observer = NetworkObserver( 129 | monitor: MockPathMonitor(id: monitorID), 130 | ping: MockNetworkPing(id: pingID, timeInterval: 2.0) 131 | ) 132 | XCTAssertTrue(observer.hasNetworkPing) 133 | #else 134 | throw XCTSkip("Combine is not supported by this OS.") 135 | #endif 136 | } 137 | 138 | public func testInitNever() throws { 139 | #if canImport(Combine) 140 | let monitorID = UUID() 141 | let observer = NetworkObserver( 142 | monitor: MockPathMonitor(id: monitorID) 143 | ) 144 | XCTAssertFalse(observer.hasNetworkPing) 145 | #else 146 | throw XCTSkip("Combine is not supported by this OS.") 147 | #endif 148 | } 149 | 150 | public func testInitNetwork() throws { 151 | #if canImport(Combine) && canImport(Network) 152 | let pingID = UUID() 153 | let observer = NetworkObserver( 154 | ping: MockNetworkPing(id: pingID, timeInterval: 2.0) 155 | ) 156 | XCTAssertTrue(observer.hasNetworkPing) 157 | #else 158 | throw XCTSkip("Combine is not supported by this OS.") 159 | #endif 160 | } 161 | 162 | public func testInitNetworkNever() throws { 163 | #if canImport(Combine) && canImport(Network) 164 | let observer = NetworkObserver() 165 | XCTAssertFalse(observer.hasNetworkPing) 166 | #else 167 | throw XCTSkip("Combine is not supported by this OS.") 168 | #endif 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/PassthroughSubjectTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Combine) 2 | import Combine 3 | #endif 4 | 5 | @testable import SundialKit 6 | import XCTest 7 | 8 | internal class PassthroughSubjectTests: XCTestCase { 9 | private struct MockStruct { 10 | // swiftlint:disable:next strict_fileprivate 11 | fileprivate let id: UUID 12 | } 13 | 14 | internal func testAnyPublisher() throws { 15 | #if canImport(Combine) 16 | let expected = UUID() 17 | var actual: UUID? 18 | let expectation = expectation(description: "Publisher Works") 19 | let subject = PassthroughSubject() 20 | let cancellable = subject.anyPublisher(for: \.id).sink { value in 21 | actual = value 22 | expectation.fulfill() 23 | } 24 | subject.send(.init(id: expected)) 25 | waitForExpectations(timeout: 1.0) { error in 26 | XCTAssertNil(error) 27 | XCTAssertEqual(expected, actual) 28 | cancellable.cancel() 29 | } 30 | #else 31 | throw XCTSkip("OS doesn't support Combine.") 32 | #endif 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/WatchConnectivity/ConnectivityObserverInternalTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SundialKit 3 | 4 | import XCTest 5 | 6 | public final class ConnectivityObserverInternalTests: XCTestCase { 7 | internal func testInit() throws { 8 | #if canImport(WatchConnectivity) 9 | let object = ConnectivityObserver() 10 | XCTAssert(object.session is WatchConnectivitySession) 11 | #else 12 | throw XCTSkip("`WatchConnectivity` is not supported by this OS.") 13 | #endif 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/WatchConnectivity/ConnectivityObserverMessageTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SundialKit 3 | 4 | import XCTest 5 | 6 | public final class ConnectivityObserverMessageTests: XCTestCase { 7 | // swiftlint:disable:next function_body_length 8 | internal func testCombineSendMessageReachable() throws { 9 | #if canImport(Combine) 10 | let expectation = expectation(description: "Message Sent Received") 11 | let session = MockSession() 12 | 13 | let key = UUID().uuidString 14 | let value = UUID() 15 | let newState: ConnectivityMessage = [key: value] 16 | let wcObject = ConnectivityObserver(session: session) 17 | let replyKey = UUID().uuidString 18 | let replyValue = UUID() 19 | let replyMessage = [replyKey: replyValue] 20 | session.isReachable = true 21 | session.nextReplyResult = .success(replyMessage) 22 | let replyCancellable = wcObject.replyMessagePublisher.sink { response in 23 | XCTAssertEqual(response.message[key] as? UUID, value) 24 | 25 | guard case let .reply(actual) = response.context else { 26 | XCTFail("Missing result") 27 | return 28 | } 29 | XCTAssertEqual(actual[replyKey] as? UUID, replyValue) 30 | 31 | expectation.fulfill() 32 | } 33 | wcObject.sendingMessageSubject.send(newState) 34 | waitForExpectations(timeout: 5.0) { error in 35 | XCTAssertNil(error) 36 | XCTAssertEqual(session.lastMessageSent?[key] as? UUID, value) 37 | 38 | replyCancellable.cancel() 39 | } 40 | #else 41 | throw XCTSkip("OS doesn't support Combine.") 42 | #endif 43 | } 44 | 45 | // swiftlint:disable:next function_body_length 46 | internal func testCombineSendMessageAppInstalled() throws { 47 | #if canImport(Combine) 48 | let expectation = expectation(description: "Message Sent Received") 49 | let session = MockSession() 50 | 51 | let key = UUID().uuidString 52 | let value = UUID() 53 | let newState: ConnectivityMessage = [key: value] 54 | let wcObject = ConnectivityObserver(session: session) 55 | let replyKey = UUID().uuidString 56 | let replyValue = UUID() 57 | let replyMessage = [replyKey: replyValue] 58 | session.isPairedAppInstalled = true 59 | session.nextReplyResult = .success(replyMessage) 60 | let replyCancellable = wcObject.replyMessagePublisher.sink { response in 61 | XCTAssertEqual(response.message[key] as? UUID, value) 62 | 63 | guard case .applicationContext = response.context else { 64 | XCTFail("Missing application context") 65 | return 66 | } 67 | 68 | expectation.fulfill() 69 | } 70 | wcObject.sendingMessageSubject.send(newState) 71 | waitForExpectations(timeout: 5.0) { error in 72 | XCTAssertNil(error) 73 | XCTAssertEqual(session.lastAppContext?[key] as? UUID, value) 74 | 75 | replyCancellable.cancel() 76 | } 77 | 78 | #else 79 | throw XCTSkip("OS doesn't support Combine.") 80 | #endif 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/WatchConnectivity/ConnectivityObserverPropertyTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SundialKit 3 | 4 | import XCTest 5 | 6 | public final class ConnectivityObserverPropertyTests: XCTestCase { 7 | public func testIsReachablePublisher() throws { 8 | #if canImport(Combine) 9 | let expectation = expectation(description: "Reachability Changed") 10 | let session = MockSession() 11 | 12 | let newState = true 13 | let wcObject = ConnectivityObserver(session: session) 14 | 15 | let cancellable = wcObject.isReachablePublisher.sink { state in 16 | XCTAssertEqual(state, newState) 17 | expectation.fulfill() 18 | } 19 | session.isReachable = newState 20 | waitForExpectations(timeout: 1.0) { error in 21 | XCTAssertNil(error) 22 | cancellable.cancel() 23 | } 24 | #else 25 | throw XCTSkip("OS doesn't support Combine.") 26 | #endif 27 | } 28 | 29 | public func testIsPairedAppInstalledPublisher() throws { 30 | #if canImport(Combine) 31 | let expectation = expectation(description: "Installed Changed") 32 | let session = MockSession() 33 | 34 | let newState = true 35 | let wcObject = ConnectivityObserver(session: session) 36 | 37 | let cancellable = wcObject.isPairedAppInstalledPublisher.sink { state in 38 | XCTAssertEqual(state, newState) 39 | expectation.fulfill() 40 | } 41 | session.isPairedAppInstalled = newState 42 | waitForExpectations(timeout: 1.0) { error in 43 | XCTAssertNil(error) 44 | cancellable.cancel() 45 | } 46 | #else 47 | throw XCTSkip("OS doesn't support Combine.") 48 | #endif 49 | } 50 | 51 | public func testIsPairedPublisher() throws { 52 | #if canImport(Combine) 53 | #if os(iOS) 54 | let expectation = expectation(description: "Installed Changed") 55 | let session = MockSession() 56 | 57 | let newState = true 58 | let wcObject = ConnectivityObserver(session: session) 59 | 60 | let cancellable = wcObject.isPairedPublisher.sink { state in 61 | XCTAssertEqual(state, newState) 62 | expectation.fulfill() 63 | } 64 | session.isPaired = newState 65 | waitForExpectations(timeout: 1.0) { error in 66 | XCTAssertNil(error) 67 | cancellable.cancel() 68 | } 69 | #else 70 | throw XCTSkip("`isPairedPublisher` is not supported by this OS.") 71 | #endif 72 | #else 73 | throw XCTSkip("OS doesn't support Combine.") 74 | #endif 75 | } 76 | 77 | public func testActivationStatePublisher() throws { 78 | #if canImport(Combine) 79 | let expectation = expectation(description: "State Change Called") 80 | let session = MockSession() 81 | 82 | let newState: ActivationState = .activated 83 | let wcObject = ConnectivityObserver(session: session) 84 | let cancellable = wcObject.activationStatePublisher.sink { state in 85 | XCTAssertEqual(state, newState) 86 | expectation.fulfill() 87 | } 88 | try wcObject.activate() 89 | waitForExpectations(timeout: 1.0) { error in 90 | XCTAssertNil(error) 91 | cancellable.cancel() 92 | } 93 | #else 94 | throw XCTSkip("OS doesn't support Combine.") 95 | #endif 96 | } 97 | 98 | public func testSessionDidBecomeInactive() throws { 99 | #if canImport(Combine) 100 | let expectation = expectation(description: "State Change Called") 101 | let session = MockSession() 102 | 103 | let newState: ActivationState = .inactive 104 | let wcObject = ConnectivityObserver(session: session) 105 | let cancellable = wcObject.activationStatePublisher.sink { state in 106 | XCTAssertEqual(state, newState) 107 | expectation.fulfill() 108 | } 109 | session.activationState = .inactive 110 | waitForExpectations(timeout: 1.0) { error in 111 | XCTAssertNil(error) 112 | cancellable.cancel() 113 | } 114 | #else 115 | throw XCTSkip("OS doesn't support Combine.") 116 | #endif 117 | } 118 | 119 | public func testSessionDidDeactivate() throws { 120 | #if canImport(Combine) 121 | let actExpectation = expectation(description: "State Change Called to Activate") 122 | let notExpectation = expectation( 123 | description: "State Change Called to Not Activated" 124 | ) 125 | let session = MockSession() 126 | 127 | let wcObject = ConnectivityObserver(session: session) 128 | let cancellable = wcObject.activationStatePublisher.sink { state in 129 | switch state { 130 | case .activated: 131 | session.activationState = .notActivated 132 | actExpectation.fulfill() 133 | 134 | case .notActivated: 135 | notExpectation.fulfill() 136 | 137 | default: 138 | XCTFail("Incorrect activation state.") 139 | } 140 | } 141 | try session.activate() 142 | waitForExpectations(timeout: 1.0) { error in 143 | XCTAssertNil(error) 144 | cancellable.cancel() 145 | } 146 | #else 147 | throw XCTSkip("OS doesn't support Combine.") 148 | #endif 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/WatchConnectivity/ConnectivityReceiveContextTests.swift: -------------------------------------------------------------------------------- 1 | import SundialKit 2 | import XCTest 3 | 4 | public final class ConnectivityReceiveContextTests: XCTestCase { 5 | public func testReplyHandler() { 6 | XCTAssertNil(ConnectivityReceiveContext.applicationContext.replyHandler) 7 | XCTAssertNotNil(ConnectivityReceiveContext.replyWith { _ in 8 | }.replyHandler) 9 | } 10 | 11 | public func testIsApplicationContext() { 12 | XCTAssertTrue(ConnectivityReceiveContext.applicationContext.isApplicationContext) 13 | XCTAssertFalse(ConnectivityReceiveContext.replyWith { _ in 14 | }.isApplicationContext) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/WatchConnectivity/ConnectivitySendContextTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SundialKit 2 | import XCTest 3 | 4 | public final class ConnectivitySendContextTests: XCTestCase { 5 | internal func testResult() { 6 | guard case .reply = ConnectivitySendContext(.success(.init())) else { 7 | XCTFail("Missing Reply") 8 | return 9 | } 10 | 11 | guard case .failure = ConnectivitySendContext( 12 | .failure(SundialError.sessionNotSupported) 13 | ) else { 14 | XCTFail("Should be a failure") 15 | return 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/WatchConnectivity/MessagableTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SundialKit 2 | import XCTest 3 | 4 | public final class MessagableTests: XCTestCase { 5 | public func testMessage() { 6 | let mockMessage = MockMessage() 7 | let dict = mockMessage.message() 8 | XCTAssertEqual(dict[MessagableKeys.typeKey] as? String, MockMessage.key) 9 | 10 | let actualParams = dict[MessagableKeys.parametersKey] 11 | guard let params = actualParams as? [String: Any] else { 12 | XCTAssertNotNil(actualParams) 13 | return 14 | } 15 | 16 | XCTAssertEqual(params[mockMessage.key] as? UUID, mockMessage.value) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/WatchConnectivity/MessageDecoderTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SundialKit 2 | import XCTest 3 | 4 | public final class MessageDecoderTests: XCTestCase { 5 | public func testExample() throws { 6 | let decoder = MessageDecoder(messagableTypes: [MockMessage.self]) 7 | let expMessage = MockMessage() 8 | let dict = expMessage.message() 9 | let actualMessage = decoder.decode(dict) as? MockMessage 10 | XCTAssertEqual(expMessage, actualMessage) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/SundialKitTests/WatchConnectivity/NeverConnectivitySessionTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SundialKit 2 | import XCTest 3 | 4 | public final class NeverConnectivitySessionTests: XCTestCase { 5 | private let session = NeverConnectivitySession() 6 | 7 | public func testDelegateGet() { 8 | XCTAssertNil(session.delegate) 9 | } 10 | 11 | public func testIsReachable() { 12 | XCTAssertFalse(session.isReachable) 13 | } 14 | 15 | public func testIsPaired() { 16 | XCTAssertFalse(session.isPaired) 17 | } 18 | 19 | public func testIsPairedAppInstalled() { 20 | XCTAssertFalse(session.isPairedAppInstalled) 21 | } 22 | 23 | public func testActivationState() { 24 | XCTAssertEqual(session.activationState, .notActivated) 25 | } 26 | 27 | public func testActivate() { 28 | XCTAssertThrowsError(try session.activate()) { error in 29 | XCTAssertEqual(error as? SundialError, SundialError.sessionNotSupported) 30 | } 31 | } 32 | 33 | public func testUpdateApplicationContext() { 34 | XCTAssertThrowsError(try session.updateApplicationContext(.init())) { error in 35 | XCTAssertEqual(error as? SundialError, SundialError.sessionNotSupported) 36 | } 37 | } 38 | 39 | public func testSendMessage() { 40 | let messageSentDone = expectation(description: "Message Sent Done") 41 | session.sendMessage(.init()) { result in 42 | guard case let .failure(error as SundialError) = result else { 43 | return 44 | } 45 | XCTAssertEqual(error, .sessionNotSupported) 46 | messageSentDone.fulfill() 47 | } 48 | waitForExpectations(timeout: 1.0) { 49 | XCTAssertNil($0) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "Tests" 3 | --------------------------------------------------------------------------------