From 32059fd7caf350b0a60d319484e0d0472178a314 Mon Sep 17 00:00:00 2001 From: leogdion Date: Wed, 28 Aug 2024 10:25:42 -0400 Subject: [PATCH] v1.0.0 (#1) --- .github/workflows/SublimationBonjour.yml | 140 ++++++ .github/workflows/codeql.yml | 78 ++++ .gitignore | 144 ++++++ .hound.yml | 2 + .periphery.yml | 5 + .spi.yml | 4 + .swift-format | 70 +++ LICENSE | 21 + Mintfile | 2 + Package.resolved | 33 ++ Package.swift | 65 +++ README.md | 155 +++++++ Scripts/gh-md-toc | 421 ++++++++++++++++++ Scripts/header.sh | 98 ++++ Scripts/lint.sh | 50 +++ .../BindingConfiguration+TXTRecord.swift | 76 ++++ .../Client/BindingConfiguration+URL.swift | 49 ++ .../Client/BonjourClient.swift | 127 ++++++ .../Client/StreamManager.swift | 74 +++ .../Client/URLDefaultConfiguration.swift | 48 ++ .../Documentation.docc/Documentation.md | 78 ++++ .../Resources/SublimationBonjour-Diagram.svg | 1 + .../SublimationBonjour-Diagram~dark.svg | 1 + .../Resources/SublimationBonjour.png | Bin 0 -> 28370 bytes .../Resources/SublimationBonjour.svg | 33 ++ .../Resources/SublimationBonjour@0.5x.png | Bin 0 -> 10520 bytes .../Extensions/Dictionary.swift | 47 ++ .../Extensions/NWListener.swift | 45 ++ .../Extensions/String.swift | 57 +++ .../SublimationBonjour/Extensions/URL.swift | 41 ++ .../BindingConfiguration+Protobuf.swift | 103 +++++ .../Server/BindingConfiguration.swift | 57 +++ .../Server/BonjourSublimatory.swift | 211 +++++++++ .../Server/Sublimation+Bonjour.swift | 99 ++++ .../SublimationBonjourTests.swift | 35 ++ codecov.yml | 2 + project.yml | 13 + 37 files changed, 2485 insertions(+) create mode 100644 .github/workflows/SublimationBonjour.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .gitignore create mode 100644 .hound.yml create mode 100644 .periphery.yml create mode 100644 .spi.yml create mode 100644 .swift-format create mode 100644 LICENSE create mode 100644 Mintfile create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100755 Scripts/gh-md-toc create mode 100755 Scripts/header.sh create mode 100755 Scripts/lint.sh create mode 100644 Sources/SublimationBonjour/Client/BindingConfiguration+TXTRecord.swift create mode 100644 Sources/SublimationBonjour/Client/BindingConfiguration+URL.swift create mode 100644 Sources/SublimationBonjour/Client/BonjourClient.swift create mode 100644 Sources/SublimationBonjour/Client/StreamManager.swift create mode 100644 Sources/SublimationBonjour/Client/URLDefaultConfiguration.swift create mode 100644 Sources/SublimationBonjour/Documentation.docc/Documentation.md create mode 100644 Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour-Diagram.svg create mode 100644 Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour-Diagram~dark.svg create mode 100644 Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour.png create mode 100644 Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour.svg create mode 100644 Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour@0.5x.png create mode 100644 Sources/SublimationBonjour/Extensions/Dictionary.swift create mode 100644 Sources/SublimationBonjour/Extensions/NWListener.swift create mode 100644 Sources/SublimationBonjour/Extensions/String.swift create mode 100644 Sources/SublimationBonjour/Extensions/URL.swift create mode 100644 Sources/SublimationBonjour/Server/BindingConfiguration+Protobuf.swift create mode 100644 Sources/SublimationBonjour/Server/BindingConfiguration.swift create mode 100644 Sources/SublimationBonjour/Server/BonjourSublimatory.swift create mode 100644 Sources/SublimationBonjour/Server/Sublimation+Bonjour.swift create mode 100644 Tests/SublimationBonjourTests/SublimationBonjourTests.swift create mode 100644 codecov.yml create mode 100644 project.yml diff --git a/.github/workflows/SublimationBonjour.yml b/.github/workflows/SublimationBonjour.yml new file mode 100644 index 0000000..94cbc30 --- /dev/null +++ b/.github/workflows/SublimationBonjour.yml @@ -0,0 +1,140 @@ +name: SublimationBonjour +on: + push: + branches-ignore: + - '*WIP' + + + +env: + PACKAGE_NAME: SublimationBonjour +jobs: + build-ubuntu: + name: Build on Ubuntu + env: + SWIFT_VER: 6.0 + if: "!contains(github.event.head_commit.message, 'ci skip')" + runs-on: ubuntu-latest + container: + image: swiftlang/swift:nightly-6.0-jammy + steps: + - uses: actions/checkout@v4 + - name: Cache swift package modules + id: cache-spm-linux + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.swift-version }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.swift-version }}- + ${{ runner.os }}-${{ env.cache-name }}- + - name: Test + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + flags: swift-${{ matrix.swift-version }},ubuntu + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-macos: + name: Build on macOS + runs-on: ${{ matrix.os }} + if: "!contains(github.event.head_commit.message, 'ci skip')" + strategy: + matrix: + include: + - xcode: "/Applications/Xcode_16.1.app" + os: macos-14 + iOSVersion: "18.1" + watchOSVersion: "11.0" + watchName: "Apple Watch Ultra 2 (49mm)" + iPhoneName: "iPhone 15 Pro Max" + steps: + - uses: actions/checkout@v4 + - name: Cache swift package modules + id: cache-spm-macos + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ matrix.os }}-build-${{ env.cache-name }}-${{ matrix.xcode }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ matrix.os }}-build-${{ env.cache-name }}-${{ matrix.xcode }}- + - name: Cache mint + if: startsWith(matrix.xcode,'/Applications/Xcode_16.1') + id: cache-mint + uses: actions/cache@v4 + env: + cache-name: cache-mint + with: + path: .mint + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Mintfile') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Set Xcode Name + run: echo "XCODE_NAME=$(basename -- ${{ matrix.xcode }} | sed 's/\.[^.]*$//' | cut -d'_' -f2)" >> $GITHUB_ENV + - name: Setup Xcode + run: sudo xcode-select -s ${{ matrix.xcode }}/Contents/Developer || (sudo ls -1 /Applications | grep "Xcode") + - name: Install mint + if: startsWith(matrix.xcode,'/Applications/Xcode_16.1') + run: | + brew update + brew install mint + - name: Build + run: swift build + - name: Run Swift Package tests + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-spm + with: + fail-on-empty-output: true + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + files: ${{ join(fromJSON(steps.coverage-files-spm.outputs.files), ',') }} + token: ${{ secrets.CODECOV_TOKEN }} + flags: macOS,${{ env.XCODE_NAME }},${{ matrix.runs-on }} + - name: Clean up spm build directory + run: rm -rf .build + - name: Lint + run: ./scripts/lint.sh + if: startsWith(matrix.xcode,'/Applications/Xcode_16.1') + - name: Run iOS target tests + run: xcodebuild test -scheme ${{ env.PACKAGE_NAME }} -sdk "iphonesimulator" -destination 'platform=iOS Simulator,name=${{ matrix.iPhoneName }},OS=${{ matrix.iOSVersion }}' -enableCodeCoverage YES build test + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-iOS + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files-iOS.outputs.files), ',') }} + flags: iOS,iOS${{ matrix.iOSVersion }},macOS,${{ env.XCODE_NAME }} + - name: Run watchOS target tests + run: xcodebuild test -scheme ${{ env.PACKAGE_NAME }} -sdk "watchsimulator" -destination 'platform=watchOS Simulator,name=${{ matrix.watchName }},OS=${{ matrix.watchOSVersion }}' -enableCodeCoverage YES build test + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-watchOS + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files-watchOS.outputs.files), ',') }} + flags: watchOS,watchOS${{ matrix.watchOSVersion }},macOS,${{ env.XCODE_NAME }} \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..92b69d6 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,78 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches-ignore: + - '*WIP' + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: '20 11 * * 3' + +jobs: + analyze: + if: false + name: Analyze + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-13') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'swift' ] + # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] + # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Xcode + run: sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - run: | + echo "Run, Build Application using script" + swift build + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d84c52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,144 @@ +# Created by https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=swift,swiftpm,swiftpackagemanager,xcode,macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +*.xcodeproj +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm + +.build/ + +# CocoaPods +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# Pods/ +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +### SwiftPackageManager ### +Packages +xcuserdata +*.xcodeproj + + +### SwiftPM ### + + +### Xcode ### + +## Xcode 8 and earlier + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings +!Demo/SublimationDemoApp.xcodeproj +.mint +# End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos diff --git a/.hound.yml b/.hound.yml new file mode 100644 index 0000000..6941f63 --- /dev/null +++ b/.hound.yml @@ -0,0 +1,2 @@ +swiftlint: + config_file: .swiftlint.yml diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..444752c --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,5 @@ +retain_public: true +targets: +- SublimationBonjour +retain_files: +- '**/*+Protobuf.swift' diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..da382b9 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [SublimationBonjour] diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..4f562bf --- /dev/null +++ b/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "fileprivate" + }, + "indentation" : { + "spaces" : 2 + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : true, + "lineBreakAroundMultilineExpressionChainComponents" : true, + "lineBreakBeforeControlFlowKeywords" : true, + "lineBreakBeforeEachArgument" : true, + "lineBreakBeforeEachGenericRequirement" : true, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : false, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : true, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : true, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : true, + "ValidateDocumentationComments" : true + }, + "spacesAroundRangeFormationOperators" : false, + "tabWidth" : 2, + "version" : 1 +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..575c376 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 BrightDigit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Mintfile b/Mintfile new file mode 100644 index 0000000..7060932 --- /dev/null +++ b/Mintfile @@ -0,0 +1,2 @@ +apple/swift-format@4b62459 +peripheryapp/periphery@2.20.0 \ No newline at end of file diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..357a00b --- /dev/null +++ b/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "324068fd6ee6b7e9557628a9b3f2dc1278a1e9c76565625e94b09f9cfa474979", + "pins" : [ + { + "identity" : "sublimation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/Sublimation", + "state" : { + "branch" : "32-swift-service-lifecycle-ci", + "revision" : "c7629b93877c2f3c04e44f07b898b82fb11a23cc" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "e17d61f26df0f0e06f58f6977ba05a097a720106", + "version" : "1.27.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..68d25d6 --- /dev/null +++ b/Package.swift @@ -0,0 +1,65 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let swiftSettings: [SwiftSetting] = [ + SwiftSetting.enableExperimentalFeature("AccessLevelOnImport"), + SwiftSetting.enableExperimentalFeature("BitwiseCopyable"), + SwiftSetting.enableExperimentalFeature("GlobalActorIsolatedTypesUsability"), + SwiftSetting.enableExperimentalFeature("IsolatedAny"), + SwiftSetting.enableExperimentalFeature("MoveOnlyPartialConsumption"), + SwiftSetting.enableExperimentalFeature("NestedProtocols"), + SwiftSetting.enableExperimentalFeature("NoncopyableGenerics"), + SwiftSetting.enableExperimentalFeature("RegionBasedIsolation"), + SwiftSetting.enableExperimentalFeature("TransferringArgsAndResults"), + SwiftSetting.enableExperimentalFeature("VariadicGenerics"), + + SwiftSetting.enableUpcomingFeature("FullTypedThrows"), + SwiftSetting.enableUpcomingFeature("InternalImportsByDefault"), + +// SwiftSetting.unsafeFlags([ +// "-Xfrontend", +// "-warn-long-function-bodies=100" +// ]), +// SwiftSetting.unsafeFlags([ +// "-Xfrontend", +// "-warn-long-expression-type-checking=100" +// ]) +] + +let package = Package( + name: "SublimationBonjour", + platforms: [ + .macOS(.v14), + .iOS(.v17), + .watchOS(.v10), + .tvOS(.v17), + .visionOS(.v1), + .macCatalyst(.v17) + ], + products: [ + .library(name: "SublimationBonjour", targets: ["SublimationBonjour"]) + ], + dependencies: [ + .package(url: "https://github.com/brightdigit/Sublimation", from: "2.0.0-alpha.5"), + .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.26.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0") + ], + targets: [ + .target( + name: "SublimationBonjour", + dependencies: [ + .product(name: "Sublimation", package: "Sublimation"), + .product(name: "SublimationCore", package: "Sublimation"), + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "Logging", package: "swift-log") + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "SublimationBonjourTests", + dependencies: ["SublimationBonjour"] + ) + ] +) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9b8b89 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +

+ Sublimation +

+

SublimationBonjour

+ +Use [Bonjour](https://developer.apple.com/bonjour/) for [Sublimation](https://github.com/brightdigit/Sublimation) for automatic discovery of your [Swift Server](https://www.swift.org/documentation/server/). + +[![](https://img.shields.io/badge/docc-read_documentation-blue)](https://swiftpackageindex.com/brightdigit/SublimationBonjour/documentation) +[![SwiftPM](https://img.shields.io/badge/SPM-Linux%20%7C%20iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS-success?logo=swift)](https://swift.org) +[![Twitter](https://img.shields.io/badge/twitter-@brightdigit-blue.svg?style=flat)](http://twitter.com/brightdigit) +![GitHub](https://img.shields.io/github/license/brightdigit/SublimationBonjour) +![GitHub issues](https://img.shields.io/github/issues/brightdigit/SublimationBonjour) +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/brightdigit/SublimationBonjour/SublimationBonjour.yml?label=actions&logo=github&?branch=main) + +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FSublimationBonjour%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/brightdigit/SublimationBonjour) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FSublimationBonjour%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/brightdigit/SublimationBonjour) + + +[![Codecov](https://img.shields.io/codecov/c/github/brightdigit/SublimationBonjour)](https://codecov.io/gh/brightdigit/SublimationBonjour) +[![CodeFactor Grade](https://img.shields.io/codefactor/grade/github/brightdigit/SublimationBonjour)](https://www.codefactor.io/repository/github/brightdigit/SublimationBonjour) +[![codebeat badge](https://codebeat.co/badges/91d512f0-ab30-42f9-9791-02add3278171)](https://codebeat.co/projects/github-com-brightdigit-SublimationBonjour-main) +[![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/brightdigit/SublimationBonjour)](https://codeclimate.com/github/brightdigit/SublimationBonjour) +[![Code Climate technical debt](https://img.shields.io/codeclimate/tech-debt/brightdigit/SublimationBonjour?label=debt)](https://codeclimate.com/github/brightdigit/SublimationBonjour) +[![Code Climate issues](https://img.shields.io/codeclimate/issues/brightdigit/SublimationBonjour)](https://codeclimate.com/github/brightdigit/SublimationBonjour) + +# Table of Contents + +* [Introduction](#introduction) + * [Requirements](#requirements) + * [Installation](#installation) +* [Usage](#usage) + * [Setting up your Server](#setting-up-your-server) + * [Setting up your Client](#setting-up-your-client) +* [Documentation](#documentation) +* [License](#license) + + + + +# Introduction + +```mermaid +sequenceDiagram + participant Server as Hummingbird/Vapor Server + participant BonjourSub as BonjourSublimatory + participant NWListener as NWListener + participant Network as Local Network + participant BonjourClient as BonjourClient + participant App as iOS/watchOS App + + Server->>BonjourSub: Start server, provide IP addresses,
hostnames, port, and protocol (http/https) + BonjourSub->>NWListener: Configure with server information + NWListener->>Network: Advertise service:
1. Send encoded server data
2. Use Text Record for additional info + App->>BonjourClient: Request server URL + BonjourClient->>Network: Search for advertised services + Network-->>BonjourClient: Return advertised service information + BonjourClient->>BonjourClient: 1. Receive and decode server data
2. Parse Text Record + BonjourClient-->>App: Return AsyncStream
or first available URL + App->>Server: Connect to server using discovered URL +``` + +When the Swift Server begins it will tell Sublimation the ip addresses or host names which are available to access the server from (including the port number and whether to use https or http). This is called a `BonjourSublimatory`. The `BonjourSublimatory` then uses `NWListener` to advertise this information both by send the data encoded using Protocol Buffers as well as inside the Text Record advertised. + +The iPhone or Apple Watch then uses a `BonjourClient` to fetch either an `AsyncStream` of `URL` via `BonjourClient.urls` or simply get the `BonjourClient.first()` one available. + +## Requirements + +**Apple Platforms** + +- Xcode 16.0 or later +- Swift 6.0 or later +- iOS 17 / watchOS 10.0 / tvOS 17 / macOS 14 or later deployment targets + +**Linux** + +- Ubuntu 20.04 or later +- Swift 6.0 or later + +## Installation + +To integrate **SublimationBonjour** into your app using SPM, specify it in your Package.swift file: + +```swift +let package = Package( + ... + dependencies: [ + .package(url: "https://github.com/brightdigit/SublimationBonjour.git", from: "1.0.0") + ], + targets: [ + .target( + name: "YourServerApp", + dependencies: [ + .product(name: "SublimationBonjour", package: "SublimationBonjour"), ... + ]), + ... + ] +) +``` + +# Usage + +## Setting up your Server + +Create a `BindingConfiguration` with: + + +* a list of host names and ip address +* port number of the server +* whether the server uses https or http + +```swift +let bindingConfiguration = BindingConfiguration( + host: ["Leo's-Mac.local", "192.168.1.10"], + port: 8080 + isSecure: false +) +``` + + +Create a `BonjourSublimatory` using that `BindingConfiguration` and include your server's logger. Then attach it to the `Sublimation` object: + +```swift +let bonjour = BonjourSublimatory( + bindingConfiguration: bindingConfiguration, + logger: app.logger +) +let sublimation = Sublimation(sublimatory : bonjour) +``` + +You can also just create a `Sublimation` object: + + +```swift +let sublimation = Sublimation( + bindingConfiguration: bindingConfiguration, + logger: app.logger +) +``` + +## Setting up your Client + +On the device, create a `BonjourClient` and either get an `AsyncStream` of `URL` objects via `BonjourClient.urls` or just ask for the first one using `BonjourClient.first()`: + +```swift +let client = BonjourClient(logger: app.logger) +let hostURL = await client.first() +``` + +## Documentation + +To learn more, check out the full [documentation](https://swiftpackageindex.com/brightdigit/SublimationBonjour/documentation). + +# License + +This code is distributed under the MIT license. See the [LICENSE](https://github.com/brightdigit/SublimationBonjour/LICENSE) file for more info. diff --git a/Scripts/gh-md-toc b/Scripts/gh-md-toc new file mode 100755 index 0000000..03b5ddd --- /dev/null +++ b/Scripts/gh-md-toc @@ -0,0 +1,421 @@ +#!/usr/bin/env bash + +# +# Steps: +# +# 1. Download corresponding html file for some README.md: +# curl -s $1 +# +# 2. Discard rows where no substring 'user-content-' (github's markup): +# awk '/user-content-/ { ... +# +# 3.1 Get last number in each row like ' ... sitemap.js.*<\/h/)+2, RLENGTH-5) +# +# 5. Find anchor and insert it inside "(...)": +# substr($0, match($0, "href=\"[^\"]+?\" ")+6, RLENGTH-8) +# + +gh_toc_version="0.10.0" + +gh_user_agent="gh-md-toc v$gh_toc_version" + +# +# Download rendered into html README.md by its url. +# +# +gh_toc_load() { + local gh_url=$1 + + if type curl &>/dev/null; then + curl --user-agent "$gh_user_agent" -s "$gh_url" + elif type wget &>/dev/null; then + wget --user-agent="$gh_user_agent" -qO- "$gh_url" + else + echo "Please, install 'curl' or 'wget' and try again." + exit 1 + fi +} + +# +# Converts local md file into html by GitHub +# +# -> curl -X POST --data '{"text": "Hello world github/linguist#1 **cool**, and #1!"}' https://api.github.com/markdown +#

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

'" +gh_toc_md2html() { + local gh_file_md=$1 + local skip_header=$2 + + URL=https://api.github.com/markdown/raw + + if [ -n "$GH_TOC_TOKEN" ]; then + TOKEN=$GH_TOC_TOKEN + else + TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" + if [ -f "$TOKEN_FILE" ]; then + TOKEN="$(cat "$TOKEN_FILE")" + fi + fi + if [ -n "${TOKEN}" ]; then + AUTHORIZATION="Authorization: token ${TOKEN}" + fi + + local gh_tmp_file_md=$gh_file_md + if [ "$skip_header" = "yes" ]; then + if grep -Fxq "" "$gh_src"; then + # cut everything before the toc + gh_tmp_file_md=$gh_file_md~~ + sed '1,//d' "$gh_file_md" > "$gh_tmp_file_md" + fi + fi + + # echo $URL 1>&2 + OUTPUT=$(curl -s \ + --user-agent "$gh_user_agent" \ + --data-binary @"$gh_tmp_file_md" \ + -H "Content-Type:text/plain" \ + -H "$AUTHORIZATION" \ + "$URL") + + rm -f "${gh_file_md}~~" + + if [ "$?" != "0" ]; then + echo "XXNetworkErrorXX" + fi + if [ "$(echo "${OUTPUT}" | awk '/API rate limit exceeded/')" != "" ]; then + echo "XXRateLimitXX" + else + echo "${OUTPUT}" + fi +} + + +# +# Is passed string url +# +gh_is_url() { + case $1 in + https* | http*) + echo "yes";; + *) + echo "no";; + esac +} + +# +# TOC generator +# +gh_toc(){ + local gh_src=$1 + local gh_src_copy=$1 + local gh_ttl_docs=$2 + local need_replace=$3 + local no_backup=$4 + local no_footer=$5 + local indent=$6 + local skip_header=$7 + + if [ "$gh_src" = "" ]; then + echo "Please, enter URL or local path for a README.md" + exit 1 + fi + + + # Show "TOC" string only if working with one document + if [ "$gh_ttl_docs" = "1" ]; then + + echo "Table of Contents" + echo "=================" + echo "" + gh_src_copy="" + + fi + + if [ "$(gh_is_url "$gh_src")" == "yes" ]; then + gh_toc_load "$gh_src" | gh_toc_grab "$gh_src_copy" "$indent" + if [ "${PIPESTATUS[0]}" != "0" ]; then + echo "Could not load remote document." + echo "Please check your url or network connectivity" + exit 1 + fi + if [ "$need_replace" = "yes" ]; then + echo + echo "!! '$gh_src' is not a local file" + echo "!! Can't insert the TOC into it." + echo + fi + else + local rawhtml + rawhtml=$(gh_toc_md2html "$gh_src" "$skip_header") + if [ "$rawhtml" == "XXNetworkErrorXX" ]; then + echo "Parsing local markdown file requires access to github API" + echo "Please make sure curl is installed and check your network connectivity" + exit 1 + fi + if [ "$rawhtml" == "XXRateLimitXX" ]; then + echo "Parsing local markdown file requires access to github API" + echo "Error: You exceeded the hourly limit. See: https://developer.github.com/v3/#rate-limiting" + TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" + echo "or place GitHub auth token here: ${TOKEN_FILE}" + exit 1 + fi + local toc + toc=`echo "$rawhtml" | gh_toc_grab "$gh_src_copy" "$indent"` + echo "$toc" + if [ "$need_replace" = "yes" ]; then + if grep -Fxq "" "$gh_src" && grep -Fxq "" "$gh_src"; then + echo "Found markers" + else + echo "You don't have or in your file...exiting" + exit 1 + fi + local ts="<\!--ts-->" + local te="<\!--te-->" + local dt + dt=$(date +'%F_%H%M%S') + local ext=".orig.${dt}" + local toc_path="${gh_src}.toc.${dt}" + local toc_createdby="" + local toc_footer + toc_footer="" + # http://fahdshariff.blogspot.ru/2012/12/sed-mutli-line-replacement-between-two.html + # clear old TOC + sed -i"${ext}" "/${ts}/,/${te}/{//!d;}" "$gh_src" + # create toc file + echo "${toc}" > "${toc_path}" + if [ "${no_footer}" != "yes" ]; then + echo -e "\n${toc_createdby}\n${toc_footer}\n" >> "$toc_path" + fi + + # insert toc file + if ! sed --version > /dev/null 2>&1; then + sed -i "" "/${ts}/r ${toc_path}" "$gh_src" + else + sed -i "/${ts}/r ${toc_path}" "$gh_src" + fi + echo + if [ "${no_backup}" = "yes" ]; then + rm "$toc_path" "$gh_src$ext" + fi + echo "!! TOC was added into: '$gh_src'" + if [ -z "${no_backup}" ]; then + echo "!! Origin version of the file: '${gh_src}${ext}'" + echo "!! TOC added into a separate file: '${toc_path}'" + fi + echo + fi + fi +} + +# +# Grabber of the TOC from rendered html +# +# $1 - a source url of document. +# It's need if TOC is generated for multiple documents. +# $2 - number of spaces used to indent. +# +gh_toc_grab() { + + href_regex="/href=\"[^\"]+?\"/" + common_awk_script=' + modified_href = "" + split(href, chars, "") + for (i=1;i <= length(href); i++) { + c = chars[i] + res = "" + if (c == "+") { + res = " " + } else { + if (c == "%") { + res = "\\x" + } else { + res = c "" + } + } + modified_href = modified_href res + } + print sprintf("%*s", (level-1)*'"$2"', "") "* [" text "](" gh_url modified_href ")" + ' + if [ "`uname -s`" == "OS/390" ]; then + grepcmd="pcregrep -o" + echoargs="" + awkscript='{ + level = substr($0, 3, 1) + text = substr($0, match($0, /<\/span><\/a>[^<]*<\/h/)+11, RLENGTH-14) + href = substr($0, match($0, '$href_regex')+6, RLENGTH-7) + '"$common_awk_script"' + }' + else + grepcmd="grep -Eo" + echoargs="-e" + awkscript='{ + level = substr($0, 3, 1) + text = substr($0, match($0, /">.*<\/h/)+2, RLENGTH-5) + href = substr($0, match($0, '$href_regex')+6, RLENGTH-7) + '"$common_awk_script"' + }' + fi + + # if closed is on the new line, then move it on the prev line + # for example: + # was: The command foo1 + # + # became: The command foo1 + sed -e ':a' -e 'N' -e '$!ba' -e 's/\n<\/h/<\/h/g' | + + # Sometimes a line can start with . Fix that. + sed -e ':a' -e 'N' -e '$!ba' -e 's/\n//g' | sed 's/<\/code>//g' | + + # remove g-emoji + sed 's/]*[^<]*<\/g-emoji> //g' | + + # now all rows are like: + #

title

.. + # format result line + # * $0 - whole string + # * last element of each row: "/dev/null; then + $tool --version | head -n 1 + else + echo "not installed" + fi + done +} + +show_help() { + local app_name + app_name=$(basename "$0") + echo "GitHub TOC generator ($app_name): $gh_toc_version" + echo "" + echo "Usage:" + echo " $app_name [options] src [src] Create TOC for a README file (url or local path)" + echo " $app_name - Create TOC for markdown from STDIN" + echo " $app_name --help Show help" + echo " $app_name --version Show version" + echo "" + echo "Options:" + echo " --indent Set indent size. Default: 3." + echo " --insert Insert new TOC into original file. For local files only. Default: false." + echo " See https://github.com/ekalinin/github-markdown-toc/issues/41 for details." + echo " --no-backup Remove backup file. Set --insert as well. Default: false." + echo " --hide-footer Do not write date & author of the last TOC update. Set --insert as well. Default: false." + echo " --skip-header Hide entry of the topmost headlines. Default: false." + echo " See https://github.com/ekalinin/github-markdown-toc/issues/125 for details." + echo "" +} + +# +# Options handlers +# +gh_toc_app() { + local need_replace="no" + local indent=3 + + if [ "$1" = '--help' ] || [ $# -eq 0 ] ; then + show_help + return + fi + + if [ "$1" = '--version' ]; then + show_version + return + fi + + if [ "$1" = '--indent' ]; then + indent="$2" + shift 2 + fi + + if [ "$1" = "-" ]; then + if [ -z "$TMPDIR" ]; then + TMPDIR="/tmp" + elif [ -n "$TMPDIR" ] && [ ! -d "$TMPDIR" ]; then + mkdir -p "$TMPDIR" + fi + local gh_tmp_md + if [ "`uname -s`" == "OS/390" ]; then + local timestamp + timestamp=$(date +%m%d%Y%H%M%S) + gh_tmp_md="$TMPDIR/tmp.$timestamp" + else + gh_tmp_md=$(mktemp "$TMPDIR/tmp.XXXXXX") + fi + while read -r input; do + echo "$input" >> "$gh_tmp_md" + done + gh_toc_md2html "$gh_tmp_md" | gh_toc_grab "" "$indent" + return + fi + + if [ "$1" = '--insert' ]; then + need_replace="yes" + shift + fi + + if [ "$1" = '--no-backup' ]; then + need_replace="yes" + no_backup="yes" + shift + fi + + if [ "$1" = '--hide-footer' ]; then + need_replace="yes" + no_footer="yes" + shift + fi + + if [ "$1" = '--skip-header' ]; then + skip_header="yes" + shift + fi + + + for md in "$@" + do + echo "" + gh_toc "$md" "$#" "$need_replace" "$no_backup" "$no_footer" "$indent" "$skip_header" + done + + echo "" + echo "" +} + +# +# Entry point +# +gh_toc_app "$@" \ No newline at end of file diff --git a/Scripts/header.sh b/Scripts/header.sh new file mode 100755 index 0000000..4ed7446 --- /dev/null +++ b/Scripts/header.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +# Function to print usage +usage() { + echo "Usage: $0 -d directory -c creator -o company -p package [-y year]" + echo " -d directory Directory to read from (including subdirectories)" + echo " -c creator Name of the creator" + echo " -o company Name of the company with the copyright" + echo " -p package Package or library name" + echo " -y year Copyright year (optional, defaults to current year)" + exit 1 +} + +# Get the current year if not provided +current_year=$(date +"%Y") + +# Default values +year="$current_year" + +# Parse arguments +while getopts ":d:c:o:p:y:" opt; do + case $opt in + d) directory="$OPTARG" ;; + c) creator="$OPTARG" ;; + o) company="$OPTARG" ;; + p) package="$OPTARG" ;; + y) year="$OPTARG" ;; + *) usage ;; + esac +done + +# Check for mandatory arguments +if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then + usage +fi + +# Define the header template +header_template="// +// %s +// %s +// +// Created by %s. +// Copyright © %s %s. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +//" + +# Loop through each Swift file in the specified directory and subdirectories +find "$directory" -type f -name "*.swift" | while read -r file; do + # Check if the first line is the swift-format-ignore indicator + first_line=$(head -n 1 "$file") + if [[ "$first_line" == "// swift-format-ignore-file" ]]; then + echo "Skipping $file due to swift-format-ignore directive." + continue + fi + + # Create the header with the current filename + filename=$(basename "$file") + header=$(printf "$header_template" "$filename" "$package" "$creator" "$year" "$company") + + # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" + awk ' + BEGIN { skip = 1 } + { + if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) { + next + } + skip = 0 + print + }' "$file" > temp_file + + # Add the header to the cleaned file + (echo "$header"; echo; cat temp_file) > "$file" + + # Remove the temporary file + rm temp_file +done + +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." \ No newline at end of file diff --git a/Scripts/lint.sh b/Scripts/lint.sh new file mode 100755 index 0000000..5e5b1d3 --- /dev/null +++ b/Scripts/lint.sh @@ -0,0 +1,50 @@ +#!/bin/sh + +if [ "$ACTION" == "install" ]; then + if [ -n "$SRCROOT" ]; then + exit + fi +fi + +export MINT_PATH="$PWD/.mint" +MINT_ARGS="-n -m Mintfile --silent" +MINT_RUN="/opt/homebrew/bin/mint run $MINT_ARGS" + +if [ -z "$SRCROOT" ] || [ -n "$CHILD_PACKAGE" ]; then + SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + PACKAGE_DIR="${SCRIPT_DIR}/.." + PERIPHERY_OPTIONS="" +else + PACKAGE_DIR="${SRCROOT}" + PERIPHERY_OPTIONS="--skip-build" +fi + + +if [ "$LINT_MODE" == "NONE" ]; then + exit +elif [ "$LINT_MODE" == "STRICT" ]; then + SWIFTFORMAT_OPTIONS="--strict" +else + SWIFTFORMAT_OPTIONS="" +fi + +/opt/homebrew/bin/mint bootstrap + +echo "LINT Mode is $LINT_MODE" + +if [ "$LINT_MODE" == "INSTALL" ]; then + exit +fi + +if [ -z "$CI" ]; then + $MINT_RUN swift-format format --recursive --parallel --in-place $PACKAGE_DIR/Sources +else + set -e +fi + +$PACKAGE_DIR/scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "SublimationBonjour" +$MINT_RUN swift-format lint --recursive --parallel $SWIFTFORMAT_OPTIONS $PACKAGE_DIR/Sources + +pushd $PACKAGE_DIR +$MINT_RUN periphery scan $PERIPHERY_OPTIONS --disable-update-check +popd diff --git a/Sources/SublimationBonjour/Client/BindingConfiguration+TXTRecord.swift b/Sources/SublimationBonjour/Client/BindingConfiguration+TXTRecord.swift new file mode 100644 index 0000000..e02cd08 --- /dev/null +++ b/Sources/SublimationBonjour/Client/BindingConfiguration+TXTRecord.swift @@ -0,0 +1,76 @@ +// +// BindingConfiguration+TXTRecord.swift +// SublimationBonjour +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +extension Array where Element == Int { + fileprivate enum ConsecutiveFailure { + case emptyArray + case nonConsecutive(expectedSum: Int, actualSum: Int) + } + fileprivate var isNotConsecutive: ConsecutiveFailure? { + guard !isEmpty else { return ConsecutiveFailure.emptyArray } + let expectedSum = (count * (count - 1)) / 2 + let actualSum = reduce(0, +) + guard actualSum == expectedSum else { + return .nonConsecutive(expectedSum: expectedSum, actualSum: actualSum) + } + return nil + } +} + +extension BindingConfiguration { + private enum TXTRecordError: Error { + case key(String) + case index(String) + case indexMismatch(Array.ConsecutiveFailure) + case base64Decoding + } + internal init(txtRecordDictionary: [String: String]) throws { + let pairs = + try txtRecordDictionary.map { (key: String, value: String) in + try Self.txtRecordIndexValueFrom(key: key, value: value) + } + .sorted { $0.0 < $1.0 } + if let failure = pairs.map(\.0).isNotConsecutive { throw TXTRecordError.indexMismatch(failure) } + let values = pairs.map(\.1) + guard let data: Data = .init(base64Encoded: values.joined()) else { + throw TXTRecordError.base64Decoding + } + try self.init(serializedData: data) + } + static func txtRecordIndexValueFrom(key: String, value: String) throws -> (Int, String) { + let components = key.components(separatedBy: "_") + guard components.count == 2, components.first == "Sublimation", + let indexString = components.last + else { throw TXTRecordError.key(key) } + guard let index = Int(indexString) else { throw TXTRecordError.index(indexString) } + return (index, value) + } +} diff --git a/Sources/SublimationBonjour/Client/BindingConfiguration+URL.swift b/Sources/SublimationBonjour/Client/BindingConfiguration+URL.swift new file mode 100644 index 0000000..faacace --- /dev/null +++ b/Sources/SublimationBonjour/Client/BindingConfiguration+URL.swift @@ -0,0 +1,49 @@ +// +// BindingConfiguration+URL.swift +// SublimationBonjour +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Network) + + internal import Foundation + + internal import Network + + extension BindingConfiguration { + internal func urls(defaults: URLDefaultConfiguration) -> [URL] { + let isSecure = self.hasIsSecure ? self.isSecure : defaults.isSecure + let port = self.hasPort ? Int(self.port) : defaults.port + return self.hosts.compactMap { host in + if host.isLocalhost() { return nil } + if host.isValidIPv6Address() { return nil } + let url = URL(scheme: isSecure ? "https" : "http", host: host, port: port) + assert(url != nil) + return url + } + } + } +#endif diff --git a/Sources/SublimationBonjour/Client/BonjourClient.swift b/Sources/SublimationBonjour/Client/BonjourClient.swift new file mode 100644 index 0000000..26297bc --- /dev/null +++ b/Sources/SublimationBonjour/Client/BonjourClient.swift @@ -0,0 +1,127 @@ +// +// BonjourClient.swift +// SublimationBonjour +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Network) + public import Foundation + + import Network + + #if canImport(os) + public import os + #elseif canImport(Logging) + public import Logging + #endif + + /// Client for fetching the url of the host server. + /// + /// On the device, create a ``BonjourClient`` and either get an `AsyncStream` of `URL` objects or just ask for the first one: + /// ``` + /// let depositor = BonjourClient(logger: app.logger) + /// let hostURL = await depositor.first() + /// ``` + public actor BonjourClient { + private let browser: NWBrowser + private let streams = StreamManager() + private let logger: Logger? + private let defaultURLConfiguration: URLDefaultConfiguration + + /// AsyncStream of `URL` from the network. + public var urls: AsyncStream { + get async { + let browser = browser + let streams = streams + let logger = self.logger + if await self.streams.isEmpty { + logger?.debug("Starting Browser.") + browser.start(queue: .global()) + } + return AsyncStream { continuation in + Task { + await streams.append(continuation) { + logger?.debug("Shutting down browser.") + browser.cancel() + } + } + } + } + } + + /// Creates a BonjourClient for fetching the host urls availab.e + /// - Parameters: + /// - logger: Logger + /// - defaultURLConfiguration: default ``URL`` configuration for missing properties. + public init(logger: Logger? = nil, defaultURLConfiguration: URLDefaultConfiguration = .init()) { + assert(logger != nil) + let descriptor: NWBrowser.Descriptor + descriptor = .bonjourWithTXTRecord(type: "_sublimation._tcp", domain: nil) + + let browser = NWBrowser(for: descriptor, using: .tcp) + self.defaultURLConfiguration = defaultURLConfiguration + self.browser = browser + self.logger = logger + browser.browseResultsChangedHandler = { results, _ in self.parseResults(results) } + } + + private func append(urls: [URL]) async { await self.streams.yield(urls, logger: self.logger) } + + private nonisolated func append(urls: [URL]) { Task { await self.append(urls: urls) } } + + private nonisolated func parseResults(_ results: Set) { + Task { await self.addResults(results) } + } + + private func addResults(_ results: Set) { + for result in results { + guard case .bonjour(let txtRecord) = result.metadata else { + self.logger?.error("No TXT Record for \(result.endpoint.debugDescription)") + continue + } + let dictionary = txtRecord.dictionary + let configuration: BindingConfiguration + do { configuration = try BindingConfiguration(txtRecordDictionary: dictionary) } + catch { + self.logger? + .error("Failed to parse TXT Record for \(result.endpoint.debugDescription): \(error)") + continue + } + let urls = configuration.urls(defaults: self.defaultURLConfiguration) + self.append(urls: urls) + } + } + } + + extension BonjourClient { + /// First URL for the network. + /// - Returns: the first url + public func first() async -> URL? { + for await baseURL in await self.urls { return baseURL } + return nil + } + } +#endif diff --git a/Sources/SublimationBonjour/Client/StreamManager.swift b/Sources/SublimationBonjour/Client/StreamManager.swift new file mode 100644 index 0000000..ad92ad5 --- /dev/null +++ b/Sources/SublimationBonjour/Client/StreamManager.swift @@ -0,0 +1,74 @@ +// +// StreamManager.swift +// SublimationBonjour +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +#if canImport(os) + internal import os +#elseif canImport(Logging) + internal import Logging +#endif + +internal actor StreamManager { + private var streamContinuations: [Key: AsyncStream.Continuation] = [:] + + private var newID: @Sendable () -> Key + + internal var isEmpty: Bool { streamContinuations.isEmpty } + + internal init(newID: @escaping @Sendable () -> Key) { self.newID = newID } + + internal func yield(_ urls: [Value], logger: Logger?) { + if streamContinuations.isEmpty { logger?.debug("Missing Continuations.") } + for streamContinuation in streamContinuations { + for url in urls { streamContinuation.value.yield(url) } + } + } + + private func onTerminationOf(_ id: Key) -> Bool { + streamContinuations.removeValue(forKey: id) + return streamContinuations.isEmpty + } + + internal func append( + _ continuation: AsyncStream.Continuation, + onCancel: @Sendable @escaping () async -> Void + ) { + let id = newID() + streamContinuations[id] = continuation + continuation.onTermination = { _ in + Task { + let shouldCancel = await self.onTerminationOf(id) + if shouldCancel { await onCancel() } + } + } + } +} + +extension StreamManager { internal init() where Key == UUID { self.init { UUID() } } } diff --git a/Sources/SublimationBonjour/Client/URLDefaultConfiguration.swift b/Sources/SublimationBonjour/Client/URLDefaultConfiguration.swift new file mode 100644 index 0000000..7bdc0b1 --- /dev/null +++ b/Sources/SublimationBonjour/Client/URLDefaultConfiguration.swift @@ -0,0 +1,48 @@ +// +// URLDefaultConfiguration.swift +// SublimationBonjour +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Default Configuration for URLs. +/// +/// If the ``BindingConfiguration`` is missing properties such as +/// ``BindingConfiguration/port`` or ``BindingConfiguration/isSecure`` +/// ``BonjourClient`` using these settings as fallback. +public struct URLDefaultConfiguration { + /// Create the default configuration. + /// - Parameters: + /// - isSecure: Whether https or http + /// - port: HTTP Server port + public init(isSecure: Bool = false, port: Int = 8080) { + self.isSecure = isSecure + self.port = port + } + /// Whether https or http + public let isSecure: Bool + /// Server port number. + public let port: Int +} diff --git a/Sources/SublimationBonjour/Documentation.docc/Documentation.md b/Sources/SublimationBonjour/Documentation.docc/Documentation.md new file mode 100644 index 0000000..d974acb --- /dev/null +++ b/Sources/SublimationBonjour/Documentation.docc/Documentation.md @@ -0,0 +1,78 @@ +# ``SublimationBonjour`` + +Use [Bonjour](https://developer.apple.com/bonjour/) for [Sublimation](https://github.com/brightdigit/Sublimation) for automatic discovery of your [Swift Server](https://www.swift.org/documentation/server/). + +## Overview + +![SublimationBonjour Logo](SublimationBonjour.svg) + +When the Swift Server begins it will tell Sublimation the ip addresses or host names which are available to access the server from (including the port number and whether to use https or http). This is called a ``BonjourSublimatory``. + +![SublimationBonjour Diagram](SublimationBonjour-Diagram.svg) + +The ``BonjourSublimatory`` then uses `NWListener` to advertise this information both by send the data encoded using Protocol Buffers as well as inside the Text Record advertised. + +The iPhone or Apple Watch then uses a ``BonjourClient`` to fetch either an `AsyncStream` of `URL` via ``BonjourClient/urls`` or simply get the ``BonjourClient/first()`` one available. + +### Setting up your Server + +Create a ``BindingConfiguration`` with: + + +* a list of host names and ip address +* port number of the server +* whether the server uses https or http + +```swift +let bindingConfiguration = BindingConfiguration( + host: ["Leo's-Mac.local", "192.168.1.10"], + port: 8080 + isSecure: false +) +``` + + +Create a ``BonjourSublimatory`` using that ``BindingConfiguration`` and include your server's logger. Then attach it to the `Sublimation` object: + +```swift +let bonjour = BonjourSublimatory( + bindingConfiguration: bindingConfiguration, + logger: app.logger +) +let sublimation = Sublimation(sublimatory : bonjour) +``` + +You can also just create a `Sublimation` object: + + +```swift +let sublimation = Sublimation( + bindingConfiguration: bindingConfiguration, + logger: app.logger +) +``` + +#### Setting up your Client + +On the device, create a ``BonjourClient`` and either get an `AsyncStream` of `URL` objects via ``BonjourClient/urls`` or just ask for the first one using ``BonjourClient/first()``: + +```swift +let client = BonjourClient(logger: app.logger) +let hostURL = await client.first() +``` + +## Topics + +### Server Configuration + +- ``BonjourSublimatory`` + +- ``BindingConfiguration`` + +- ``SublimationBonjour/Sublimation/Sublimation`` + +### Client Configuration + +- ``BonjourClient`` + +- ``URLDefaultConfiguration`` diff --git a/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour-Diagram.svg b/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour-Diagram.svg new file mode 100644 index 0000000..eb30cc1 --- /dev/null +++ b/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour-Diagram.svg @@ -0,0 +1 @@ +iOS/watchOS AppBonjourClientLocal NetworkNWListenerBonjourSublimatoryHummingbird/Vapor ServeriOS/watchOS AppBonjourClientLocal NetworkNWListenerBonjourSublimatoryHummingbird/Vapor ServerStart server, provide IP addresses,hostnames, port, and protocol (http/https)Configure with server informationAdvertise service:1. Send encoded server data2. Use Text Record for additional infoRequest server URLSearch for advertised servicesReturn advertised service information1. Receive and decode server data2. Parse Text RecordReturn AsyncStream<URL>or first available URLConnect to server using discovered URL \ No newline at end of file diff --git a/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour-Diagram~dark.svg b/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour-Diagram~dark.svg new file mode 100644 index 0000000..fed427b --- /dev/null +++ b/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour-Diagram~dark.svg @@ -0,0 +1 @@ +iOS/watchOS AppBonjourClientLocal NetworkNWListenerBonjourSublimatoryHummingbird/Vapor ServeriOS/watchOS AppBonjourClientLocal NetworkNWListenerBonjourSublimatoryHummingbird/Vapor ServerStart server, provide IP addresses,hostnames, port, and protocol (http/https)Configure with server informationAdvertise service:1. Send encoded server data2. Use Text Record for additional infoRequest server URLSearch for advertised servicesReturn advertised service information1. Receive and decode server data2. Parse Text RecordReturn AsyncStream<URL>or first available URLConnect to server using discovered URL diff --git a/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour.png b/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour.png new file mode 100644 index 0000000000000000000000000000000000000000..231d0a77a88be874fefb4d8508570ab2e176ff45 GIT binary patch literal 28370 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�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 literal 0 HcmV?d00001 diff --git a/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour.svg b/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour.svg new file mode 100644 index 0000000..7958389 --- /dev/null +++ b/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour@0.5x.png b/Sources/SublimationBonjour/Documentation.docc/Resources/SublimationBonjour@0.5x.png new file mode 100644 index 0000000000000000000000000000000000000000..16ff7ce0be104d870a5b8e0b96a2862deb5d1cc1 GIT binary patch literal 10520 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 a=j0DPlImBqEo~J50000 Bool { + let localhostNames = ["localhost", "127.0.0.1", "::1"] + return localhostNames.contains(self) + } + + internal func isValidIPv6Address() -> Bool { + var sin6 = sockaddr_in6() + return self.withCString { cstring in inet_pton(AF_INET6, cstring, &sin6.sin6_addr) } == 1 + } + + func splitByMaxLength(_ maxLength: Int) -> [String] { + var result: [String] = [] + var currentIndex = self.startIndex + + while currentIndex < self.endIndex { + let endIndex = + self.index(currentIndex, offsetBy: maxLength, limitedBy: self.endIndex) ?? self.endIndex + let substring = String(self[currentIndex..=5.5) && canImport(_Concurrency) + extension BindingConfiguration: @unchecked Sendable {} +#endif // swift(>=5.5) && canImport(_Concurrency) + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +extension BindingConfiguration: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, + SwiftProtobuf._ProtoNameProviding +{ + public static let protoMessageName: String = "BindingConfiguration" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "is_secure"), 2: .same(proto: "port"), 9: .same(proto: "hosts"), + ] + + public mutating func decodeMessage(decoder: inout some SwiftProtobuf.Decoder) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { case 1: try decoder.decodeSingularBoolField(value: &self._isSecure) + case 2: try decoder.decodeSingularUInt32Field(value: &self._port) + case 9: try decoder.decodeRepeatedStringField(value: &self.hosts) + default: break + } + } + } + + public func traverse(visitor: inout some SwiftProtobuf.Visitor) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if let v = self._isSecure { try visitor.visitSingularBoolField(value: v, fieldNumber: 1) } + try { + if let v = self._port { try visitor.visitSingularUInt32Field(value: v, fieldNumber: 2) } + }() + if !self.hosts.isEmpty { + try visitor.visitRepeatedStringField(value: self.hosts, fieldNumber: 9) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func == (lhs: BindingConfiguration, rhs: BindingConfiguration) -> Bool { + if lhs._isSecure != rhs._isSecure { return false } + if lhs._port != rhs._port { return false } + if lhs.hosts != rhs.hosts { return false } + if lhs.unknownFields != rhs.unknownFields { return false } + return true + } +} diff --git a/Sources/SublimationBonjour/Server/BindingConfiguration.swift b/Sources/SublimationBonjour/Server/BindingConfiguration.swift new file mode 100644 index 0000000..665bba7 --- /dev/null +++ b/Sources/SublimationBonjour/Server/BindingConfiguration.swift @@ -0,0 +1,57 @@ +// +// BindingConfiguration.swift +// SublimationBonjour +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension BindingConfiguration { + /// Information to advertise how to connect to the server. + /// + /// ``` + /// let bindingConfiguration = BindingConfiguration( + /// host: ["Leo's-Mac.local", "192.168.1.10"], + /// port: 8080 + /// isSecure: false + /// ) + /// let bonjour = BonjourSublimatory( + /// bindingConfiguration: bindingConfiguration, + /// logger: app.logger + /// ) + /// let sublimation = Sublimation(sublimatory : bonjour) + /// ``` + /// + /// - Parameters: + /// - hosts: List of host names and ip addresses. + /// - port: The port number of the server. + /// - isSecure: Whether to use https or http. + /// + public init(hosts: [String], port: Int = 8080, isSecure: Bool = false) { + self.init() + self.hosts = hosts + self.isSecure = isSecure + self.port = .init(port) + } +} diff --git a/Sources/SublimationBonjour/Server/BonjourSublimatory.swift b/Sources/SublimationBonjour/Server/BonjourSublimatory.swift new file mode 100644 index 0000000..fdf8c8c --- /dev/null +++ b/Sources/SublimationBonjour/Server/BonjourSublimatory.swift @@ -0,0 +1,211 @@ +// +// BonjourSublimatory.swift +// SublimationBonjour +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Network) + + internal import Foundation + + public import Network + + public import SublimationCore + + public import Logging + + /// Sublimatory for using Bonjour auto-discovery. + public struct BonjourSublimatory: Sublimatory { + /// Uses a `NWListener` to broadcast the server information. + /// - Parameters: + /// - bindingConfiguration: A configuration with addresses, port and tls configuration. + /// - logger: A logger. + /// - listener: The `NWListener` to use. + /// - name: Service name. + /// - type: Service type. + /// - listenerQueue: DispatchQueue for the listener. + /// - connectionQueue: DispatchQueue for each new connection made. + public init( + bindingConfiguration: BindingConfiguration, + logger: Logger, + listener: NWListener, + name: String = Self.defaultName, + type: String = Self.defaultHttpTCPServiceType, + listenerQueue: DispatchQueue = .global(), + connectionQueue: DispatchQueue = .global() + ) { + self.bindingConfiguration = bindingConfiguration + self.logger = logger + self.listener = listener + self.name = name + self.type = type + self.listenerQueue = listenerQueue + self.connectionQueue = connectionQueue + } + + /// Creates a `NWListener` to broadcast the server information. + /// - Parameters: + /// - bindingConfiguration: A configuration with addresses, port and tls configuration. + /// - logger: A logger. + /// - listenerParameters: The network parameters to use for the listener. Default is `.tcp`. + /// - name: Service name. + /// - type: Service type. + /// - listenerQueue: DispatchQueue for the listener. + /// - connectionQueue: DispatchQueue for each new connection made. + /// - Throws: an error if the parameters are not compatible with the provided port. + public init( + bindingConfiguration: BindingConfiguration, + logger: Logger, + listenerParameters: NWParameters = Self.defaultParameters, + name: String = Self.defaultName, + type: String = Self.defaultHttpTCPServiceType, + listenerQueue: DispatchQueue = .global(), + connectionQueue: DispatchQueue = .global() + ) throws { + let listener = try NWListener(using: listenerParameters) + self.init( + bindingConfiguration: bindingConfiguration, + logger: logger, + listener: listener, + name: name, + type: type, + listenerQueue: listenerQueue, + connectionQueue: connectionQueue + ) + } + let bindingConfiguration: BindingConfiguration + let logger: Logger + let listener: NWListener + let name: String + let type: String + let listenerQueue: DispatchQueue + let connectionQueue: DispatchQueue + /// Default name for the listener service which is "Sublimation" + public static let defaultName = "Sublimation" + /// Default service type which is "_sublimation._tcp". + public static let defaultHttpTCPServiceType = "_sublimation._tcp" + /// Default parameters for the listener which is `NWParameters.tcp` + public static let defaultParameters: NWParameters = .tcp + + // @available(*, unavailable, message: "Temporary Code for pulling ipaddresses.") + // static func getAllIPAddresses() -> [String: [String]] { + // var addresses: [String: [String]] = [:] + // + // let monitor = NWPathMonitor() + // let queue = DispatchQueue.global(qos: .background) + // + // monitor.pathUpdateHandler = { path in + // for interface in path.availableInterfaces { + // var interfaceAddresses: [String] = [] + // let endpoint = NWEndpoint.Host(interface.debugDescription) + // let parameters = NWParameters.tcp + // parameters.requiredInterface = interface + // + // let connection = NWConnection(host: endpoint, port: 80, using: parameters) + // connection.stateUpdateHandler = { state in + // if case .ready = state { + // if let localEndpoint = connection.currentPath?.localEndpoint { + // switch localEndpoint { + // case let .hostPort(host, _): + // interfaceAddresses.append(host.debugDescription) + // default: + // break + // } + // } + // addresses[interface.debugDescription] = interfaceAddresses + // } + // } + // connection.start(queue: queue) + // } + // monitor.cancel() + // } + // + // monitor.start(queue: queue) + // + // // Wait for a short period to gather the results + // sleep(2) + // + // return addresses + // } + + /// Shutdown any active services by cancelling the listener. + public func shutdown() { listener.cancel() } + /// Runs the Sublimatory service. + /// - Note: This method contains long running work, returning from it is seen as a failure. + public func run() async throws { + let data = try self.bindingConfiguration.serializedData() + let txtRecordValues = data.base64EncodedString().splitByMaxLength(199) + let dictionary = txtRecordValues.enumerated() + .reduce(into: [String: String]()) { result, value in + result["Sublimation_\(value.offset)"] = String(value.element) + } + let txtRecord = NWTXTRecord(dictionary) + assert(listener.service == nil) + listener.service = .init(name: name, type: type, txtRecord: txtRecord) + + listener.newConnectionHandler = { connection in + connection.stateUpdateHandler = { state in + switch state { case .waiting(let error): + + self.logger.warning("Connection Waiting error: \(error)") + + case .ready: + self.logger.debug("Connection Ready") + self.logger.debug("Sending data \(data.count) bytes") + connection.send( + content: data, + completion: .contentProcessed { error in + if let error { self.logger.warning("Connection Send error: \(error)") } + connection.cancel() + } + ) + case .failed(let error): self.logger.error("Connection Failure: \(error)") + + default: self.logger.debug("Connection state updated: \(state)") + } + } + connection.start(queue: connectionQueue) + } + + listener.start(queue: listenerQueue) + + return try await withCheckedThrowingContinuation { continuation in + listener.stateUpdateHandler = { state in + switch state { case .waiting(let error): + self.logger.warning("Listener Waiting error: \(error)") + continuation.resume(throwing: error) + + case .failed(let error): + self.logger.error("Listener Failure: \(error)") + continuation.resume(throwing: error) + case .cancelled: continuation.resume() + default: self.logger.debug("Listener state updated: \(state)") + } + } + } + } + } +#endif diff --git a/Sources/SublimationBonjour/Server/Sublimation+Bonjour.swift b/Sources/SublimationBonjour/Server/Sublimation+Bonjour.swift new file mode 100644 index 0000000..6f54660 --- /dev/null +++ b/Sources/SublimationBonjour/Server/Sublimation+Bonjour.swift @@ -0,0 +1,99 @@ +// +// Sublimation+Bonjour.swift +// SublimationBonjour +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Network) + public import Network + public import Sublimation + public import Logging + + /// Friendly extensions for setting up a `Sublimation` object for `Bonjour` + extension Sublimation { + /// Initializes a `Sublimation` instance with the provided parameters. + /// + /// - Parameters: + /// - bindingConfiguration: A configuration with addresses, port and tls configuration. + /// - logger: A logger. + /// - listenerParameters: The network parameters to use for the listener. Default is `.tcp`. + /// - name: Service name. + /// - type: Service type. + /// - listenerQueue: DispatchQueue for the listener. + /// - connectionQueue: DispatchQueue for each new connection made. + /// - Throws: an error if the parameters are not compatible with the provided port. + public convenience init( + bindingConfiguration: BindingConfiguration, + logger: Logger, + listenerParameters: NWParameters = .tcp, + name: String = BonjourSublimatory.defaultName, + type: String = BonjourSublimatory.defaultHttpTCPServiceType, + listenerQueue: DispatchQueue = .global(), + connectionQueue: DispatchQueue = .global() + ) throws { + let sublimatory = try BonjourSublimatory( + bindingConfiguration: bindingConfiguration, + logger: logger, + listenerParameters: listenerParameters, + name: name, + type: type, + listenerQueue: listenerQueue, + connectionQueue: connectionQueue + ) + self.init(sublimatory: sublimatory) + } + /// Initializes a `Sublimation` instance with the provided parameters. + /// Uses a `NWListener` to broadcast the server information. + /// - Parameters: + /// - bindingConfiguration: A configuration with addresses, port and tls configuration. + /// - logger: A logger. + /// - listener: The `NWListener` to use. + /// - name: Service name. + /// - type: Service type. + /// - listenerQueue: DispatchQueue for the listener. + /// - connectionQueue: DispatchQueue for each new connection made. + public convenience init( + bindingConfiguration: BindingConfiguration, + logger: Logger, + listener: NWListener, + name: String = BonjourSublimatory.defaultName, + type: String = BonjourSublimatory.defaultHttpTCPServiceType, + listenerQueue: DispatchQueue = .global(), + connectionQueue: DispatchQueue = .global() + ) { + let sublimatory = BonjourSublimatory( + bindingConfiguration: bindingConfiguration, + logger: logger, + listener: listener, + name: name, + type: type, + listenerQueue: listenerQueue, + connectionQueue: connectionQueue + ) + self.init(sublimatory: sublimatory) + } + } +#endif diff --git a/Tests/SublimationBonjourTests/SublimationBonjourTests.swift b/Tests/SublimationBonjourTests/SublimationBonjourTests.swift new file mode 100644 index 0000000..78e1899 --- /dev/null +++ b/Tests/SublimationBonjourTests/SublimationBonjourTests.swift @@ -0,0 +1,35 @@ +// +// SublimationBonjourTests.swift +// SublimationBonjour +// +// Created by Leo Dion on 7/30/24. +// + +import XCTest + +final class SublimationBonjourTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..951b97b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "Tests" diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..67ed8e5 --- /dev/null +++ b/project.yml @@ -0,0 +1,13 @@ +name: SublimationBonjour +settings: + LINT_MODE: ${LINT_MODE} +packages: + SublimationBonjour: + path: . +aggregateTargets: + Lint: + buildScripts: + - path: Scripts/lint.sh + name: Lint + basedOnDependencyAnalysis: false + schemes: {}